Initial commit - production deployment
This commit is contained in:
452
frontend/src/api/services/aiInsights.ts
Normal file
452
frontend/src/api/services/aiInsights.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* AI Insights Service
|
||||
*
|
||||
* Provides access to AI-generated insights from the AI Insights microservice.
|
||||
* Replaces mock data with real API integration.
|
||||
*
|
||||
* Backend endpoints:
|
||||
* - GET /tenants/{tenant_id}/insights
|
||||
* - GET /tenants/{tenant_id}/insights/{insight_id}
|
||||
* - POST /tenants/{tenant_id}/insights/feedback
|
||||
* - GET /tenants/{tenant_id}/insights/stats
|
||||
* - GET /tenants/{tenant_id}/insights/orchestration-ready
|
||||
*
|
||||
* Last Updated: 2025-11-03
|
||||
* Status: ✅ Complete - Real API Integration
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import { useTenantStore } from '../../stores/tenant.store';
|
||||
import { getTenantCurrencySymbol } from '../../hooks/useTenantCurrency';
|
||||
|
||||
export interface AIInsight {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
type: 'optimization' | 'alert' | 'prediction' | 'recommendation' | 'insight' | 'anomaly';
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
category: 'forecasting' | 'inventory' | 'production' | 'procurement' | 'customer' | 'cost' | 'quality' | 'efficiency' | 'demand' | 'maintenance' | 'energy' | 'scheduling';
|
||||
title: string;
|
||||
description: string;
|
||||
impact_type?: 'cost_savings' | 'revenue_increase' | 'waste_reduction' | 'efficiency_gain' | 'quality_improvement' | 'risk_mitigation';
|
||||
impact_value?: number;
|
||||
impact_unit?: string;
|
||||
confidence: number;
|
||||
metrics_json: Record<string, any>;
|
||||
actionable: boolean;
|
||||
recommendation_actions?: Array<{
|
||||
label: string;
|
||||
action: string;
|
||||
endpoint?: string;
|
||||
}>;
|
||||
source_service?: string;
|
||||
source_data_id?: string;
|
||||
status: 'new' | 'acknowledged' | 'in_progress' | 'applied' | 'dismissed' | 'expired';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
applied_at?: string;
|
||||
expired_at?: string;
|
||||
}
|
||||
|
||||
export interface AIInsightFilters {
|
||||
type?: 'optimization' | 'alert' | 'prediction' | 'recommendation' | 'insight' | 'anomaly';
|
||||
priority?: 'low' | 'medium' | 'high' | 'critical';
|
||||
status?: 'new' | 'acknowledged' | 'in_progress' | 'applied' | 'dismissed' | 'expired';
|
||||
category?: 'forecasting' | 'inventory' | 'production' | 'procurement' | 'customer' | 'cost' | 'quality' | 'efficiency' | 'demand' | 'maintenance' | 'energy' | 'scheduling';
|
||||
actionable_only?: boolean;
|
||||
min_confidence?: number;
|
||||
source_service?: string;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AIInsightListResponse {
|
||||
items: AIInsight[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface AIInsightStatsResponse {
|
||||
total_insights: number;
|
||||
actionable_insights: number;
|
||||
average_confidence: number;
|
||||
high_priority_count: number;
|
||||
medium_priority_count: number;
|
||||
low_priority_count: number;
|
||||
critical_priority_count: number;
|
||||
by_category: Record<string, number>;
|
||||
by_status: Record<string, number>;
|
||||
total_potential_impact?: number;
|
||||
}
|
||||
|
||||
export interface FeedbackRequest {
|
||||
applied: boolean;
|
||||
applied_at?: string;
|
||||
outcome_date?: string;
|
||||
outcome_metrics?: Record<string, any>;
|
||||
user_rating?: number;
|
||||
user_comment?: string;
|
||||
}
|
||||
|
||||
export interface FeedbackResponse {
|
||||
insight_id: string;
|
||||
feedback_recorded: boolean;
|
||||
feedback_id: string;
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
export interface OrchestrationReadyInsightsRequest {
|
||||
target_date: string;
|
||||
min_confidence?: number;
|
||||
}
|
||||
|
||||
export interface OrchestrationReadyInsightsResponse {
|
||||
target_date: string;
|
||||
insights: AIInsight[];
|
||||
categorized_insights: {
|
||||
demand_forecasts: AIInsight[];
|
||||
supplier_alerts: AIInsight[];
|
||||
inventory_optimizations: AIInsight[];
|
||||
price_opportunities: AIInsight[];
|
||||
yield_predictions: AIInsight[];
|
||||
business_rules: AIInsight[];
|
||||
other: AIInsight[];
|
||||
};
|
||||
total_insights: number;
|
||||
}
|
||||
|
||||
export class AIInsightsService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
/**
|
||||
* Get all AI insights for a tenant with optional filters
|
||||
*/
|
||||
async getInsights(
|
||||
tenantId: string,
|
||||
filters?: AIInsightFilters
|
||||
): Promise<AIInsightListResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filters?.type) queryParams.append('type', filters.type);
|
||||
if (filters?.priority) queryParams.append('priority', filters.priority);
|
||||
if (filters?.category) queryParams.append('category', filters.category);
|
||||
if (filters?.status) queryParams.append('status', filters.status);
|
||||
if (filters?.min_confidence) queryParams.append('min_confidence', filters.min_confidence.toString());
|
||||
if (filters?.actionable_only) queryParams.append('actionable_only', 'true');
|
||||
if (filters?.source_service) queryParams.append('source_service', filters.source_service);
|
||||
if (filters?.from_date) queryParams.append('from_date', filters.from_date);
|
||||
if (filters?.to_date) queryParams.append('to_date', filters.to_date);
|
||||
if (filters?.limit) queryParams.append('limit', filters.limit.toString());
|
||||
if (filters?.offset) queryParams.append('offset', filters.offset.toString());
|
||||
|
||||
const url = `${this.baseUrl}/${tenantId}/insights${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
|
||||
return apiClient.get<AIInsightListResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single insight by ID
|
||||
*/
|
||||
async getInsight(
|
||||
tenantId: string,
|
||||
insightId: string
|
||||
): Promise<AIInsight> {
|
||||
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}`;
|
||||
return apiClient.get<AIInsight>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get insight statistics
|
||||
*/
|
||||
async getInsightStats(
|
||||
tenantId: string,
|
||||
filters?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<AIInsightStatsResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filters?.start_date) queryParams.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) queryParams.append('end_date', filters.end_date);
|
||||
|
||||
const url = `${this.baseUrl}/${tenantId}/insights/metrics/summary${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
|
||||
return apiClient.get<AIInsightStatsResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orchestration-ready insights for a specific date
|
||||
*/
|
||||
async getOrchestrationReadyInsights(
|
||||
tenantId: string,
|
||||
request: OrchestrationReadyInsightsRequest
|
||||
): Promise<OrchestrationReadyInsightsResponse> {
|
||||
const url = `${this.baseUrl}/${tenantId}/insights/orchestration-ready`;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('target_date', request.target_date);
|
||||
if (request.min_confidence) {
|
||||
queryParams.append('min_confidence', request.min_confidence.toString());
|
||||
}
|
||||
|
||||
return apiClient.get<OrchestrationReadyInsightsResponse>(
|
||||
`${url}?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record feedback for an applied insight
|
||||
*/
|
||||
async recordFeedback(
|
||||
tenantId: string,
|
||||
insightId: string,
|
||||
feedback: FeedbackRequest
|
||||
): Promise<FeedbackResponse> {
|
||||
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}/feedback`;
|
||||
return apiClient.post<FeedbackResponse>(url, feedback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an insight (mark as applied)
|
||||
*/
|
||||
async applyInsight(
|
||||
tenantId: string,
|
||||
insightId: string
|
||||
): Promise<AIInsight> {
|
||||
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}/apply`;
|
||||
return apiClient.post<AIInsight>(url, {
|
||||
applied_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss an insight
|
||||
*/
|
||||
async dismissInsight(
|
||||
tenantId: string,
|
||||
insightId: string
|
||||
): Promise<void> {
|
||||
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}`;
|
||||
return apiClient.delete(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an insight status (acknowledge, apply, etc.)
|
||||
*/
|
||||
async updateInsightStatus(
|
||||
tenantId: string,
|
||||
insightId: string,
|
||||
status: 'acknowledged' | 'in_progress' | 'applied' | 'expired'
|
||||
): Promise<AIInsight> {
|
||||
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}`;
|
||||
return apiClient.patch<AIInsight>(url, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get insights by priority (for dashboard widgets)
|
||||
*/
|
||||
async getHighPriorityInsights(
|
||||
tenantId: string,
|
||||
limit: number = 10
|
||||
): Promise<AIInsight[]> {
|
||||
// Fetch critical priority insights first
|
||||
const response = await this.getInsights(tenantId, {
|
||||
priority: 'critical',
|
||||
status: 'new',
|
||||
limit,
|
||||
});
|
||||
|
||||
if (response.items.length < limit) {
|
||||
// Add high priority if not enough critical
|
||||
const highPriorityResponse = await this.getInsights(tenantId, {
|
||||
priority: 'high',
|
||||
status: 'new',
|
||||
limit: limit - response.items.length,
|
||||
});
|
||||
return [...response.items, ...highPriorityResponse.items];
|
||||
}
|
||||
|
||||
return response.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get actionable insights (for recommendations panel)
|
||||
*/
|
||||
async getActionableInsights(
|
||||
tenantId: string,
|
||||
limit: number = 20
|
||||
): Promise<AIInsight[]> {
|
||||
const response = await this.getInsights(tenantId, {
|
||||
actionable_only: true,
|
||||
status: 'new',
|
||||
limit,
|
||||
});
|
||||
|
||||
return response.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get insights by category
|
||||
*/
|
||||
async getInsightsByCategory(
|
||||
tenantId: string,
|
||||
category: string,
|
||||
limit: number = 20
|
||||
): Promise<AIInsight[]> {
|
||||
const response = await this.getInsights(tenantId, {
|
||||
category: category as any, // Category comes from user input
|
||||
status: 'new',
|
||||
limit,
|
||||
});
|
||||
|
||||
return response.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search insights
|
||||
*/
|
||||
async searchInsights(
|
||||
tenantId: string,
|
||||
query: string,
|
||||
filters?: Partial<AIInsightFilters>
|
||||
): Promise<AIInsight[]> {
|
||||
// Note: search parameter not supported by backend API
|
||||
// This is a client-side workaround - fetch all and filter
|
||||
const response = await this.getInsights(tenantId, {
|
||||
...filters,
|
||||
limit: filters?.limit || 50,
|
||||
});
|
||||
|
||||
// Filter by query on client side
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return response.items.filter(
|
||||
(insight) =>
|
||||
insight.title.toLowerCase().includes(lowerQuery) ||
|
||||
insight.description.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent insights (for activity feed)
|
||||
*/
|
||||
async getRecentInsights(
|
||||
tenantId: string,
|
||||
days: number = 7,
|
||||
limit: number = 50
|
||||
): Promise<AIInsight[]> {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
|
||||
const response = await this.getInsights(tenantId, {
|
||||
start_date: startDate.toISOString(),
|
||||
end_date: endDate.toISOString(),
|
||||
limit,
|
||||
});
|
||||
|
||||
return response.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get insights summary for dashboard
|
||||
*/
|
||||
async getDashboardSummary(
|
||||
tenantId: string
|
||||
): Promise<{
|
||||
stats: AIInsightStatsResponse;
|
||||
highPriority: AIInsight[];
|
||||
recent: AIInsight[];
|
||||
}> {
|
||||
const [stats, highPriority, recent] = await Promise.all([
|
||||
this.getInsightStats(tenantId),
|
||||
this.getHighPriorityInsights(tenantId, 5),
|
||||
this.getRecentInsights(tenantId, 7, 10),
|
||||
]);
|
||||
|
||||
return {
|
||||
stats,
|
||||
highPriority,
|
||||
recent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format impact value for display
|
||||
*/
|
||||
formatImpactValue(insight: AIInsight): string {
|
||||
if (!insight.impact_value) return 'N/A';
|
||||
|
||||
const value = insight.impact_value;
|
||||
const unit = insight.impact_unit || 'units';
|
||||
const currencySymbol = getTenantCurrencySymbol(useTenantStore.getState().currentTenant?.currency);
|
||||
|
||||
if (unit === 'euros_per_year' || unit === 'eur') {
|
||||
return `${currencySymbol}${value.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}/year`;
|
||||
} else if (unit === 'euros') {
|
||||
return `${currencySymbol}${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
} else if (unit === 'percentage' || unit === 'percentage_points') {
|
||||
return `${value.toFixed(1)}%`;
|
||||
} else if (unit === 'units') {
|
||||
return `${value.toFixed(0)} units`;
|
||||
} else {
|
||||
return `${value.toFixed(2)} ${unit}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority badge color
|
||||
*/
|
||||
getPriorityColor(priority: string): string {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return 'red';
|
||||
case 'high':
|
||||
return 'orange';
|
||||
case 'medium':
|
||||
return 'yellow';
|
||||
case 'low':
|
||||
return 'blue';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type icon
|
||||
*/
|
||||
getTypeIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'forecast':
|
||||
return '📈';
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
case 'opportunity':
|
||||
return '💡';
|
||||
case 'positive':
|
||||
return '✅';
|
||||
case 'optimization':
|
||||
return '🎯';
|
||||
case 'rule':
|
||||
return '📋';
|
||||
default:
|
||||
return '📊';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate confidence color
|
||||
*/
|
||||
getConfidenceColor(confidence: number): string {
|
||||
if (confidence >= 90) return 'green';
|
||||
if (confidence >= 75) return 'blue';
|
||||
if (confidence >= 60) return 'yellow';
|
||||
return 'red';
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const aiInsightsService = new AIInsightsService();
|
||||
253
frontend/src/api/services/alertService.ts
Normal file
253
frontend/src/api/services/alertService.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Clean Alert Service - Matches Backend API Exactly
|
||||
*
|
||||
* Backend API: /services/alert_processor/app/api/alerts_clean.py
|
||||
*
|
||||
* NO backward compatibility, uses new type system from /api/types/events.ts
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
EventResponse,
|
||||
Alert,
|
||||
Notification,
|
||||
Recommendation,
|
||||
PaginatedResponse,
|
||||
EventsSummary,
|
||||
EventQueryParams,
|
||||
} from '../types/events';
|
||||
|
||||
const BASE_PATH = '/tenants';
|
||||
|
||||
// ============================================================
|
||||
// QUERY METHODS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get events list with filtering and pagination
|
||||
*/
|
||||
export async function getEvents(
|
||||
tenantId: string,
|
||||
params?: EventQueryParams
|
||||
): Promise<PaginatedResponse<EventResponse>> {
|
||||
return await apiClient.get<PaginatedResponse<EventResponse>>(
|
||||
`${BASE_PATH}/${tenantId}/alerts`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single event by ID
|
||||
*/
|
||||
export async function getEvent(
|
||||
tenantId: string,
|
||||
eventId: string
|
||||
): Promise<EventResponse> {
|
||||
return await apiClient.get<EventResponse>(
|
||||
`${BASE_PATH}/${tenantId}/alerts/${eventId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events summary for dashboard
|
||||
*/
|
||||
export async function getEventsSummary(
|
||||
tenantId: string
|
||||
): Promise<EventsSummary> {
|
||||
return await apiClient.get<EventsSummary>(
|
||||
`${BASE_PATH}/${tenantId}/alerts/summary`
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MUTATION METHODS - Alerts
|
||||
// ============================================================
|
||||
|
||||
export interface AcknowledgeAlertResponse {
|
||||
success: boolean;
|
||||
event_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge an alert
|
||||
*/
|
||||
export async function acknowledgeAlert(
|
||||
tenantId: string,
|
||||
alertId: string
|
||||
): Promise<AcknowledgeAlertResponse> {
|
||||
return await apiClient.post<AcknowledgeAlertResponse>(
|
||||
`${BASE_PATH}/${tenantId}/alerts/${alertId}/acknowledge`
|
||||
);
|
||||
}
|
||||
|
||||
export interface ResolveAlertResponse {
|
||||
success: boolean;
|
||||
event_id: string;
|
||||
status: string;
|
||||
resolved_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an alert
|
||||
*/
|
||||
export async function resolveAlert(
|
||||
tenantId: string,
|
||||
alertId: string
|
||||
): Promise<ResolveAlertResponse> {
|
||||
return await apiClient.post<ResolveAlertResponse>(
|
||||
`${BASE_PATH}/${tenantId}/alerts/${alertId}/resolve`
|
||||
);
|
||||
}
|
||||
|
||||
export interface CancelAutoActionResponse {
|
||||
success: boolean;
|
||||
event_id: string;
|
||||
message: string;
|
||||
updated_type_class: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an alert's auto-action (escalation countdown)
|
||||
*/
|
||||
export async function cancelAutoAction(
|
||||
tenantId: string,
|
||||
alertId: string
|
||||
): Promise<CancelAutoActionResponse> {
|
||||
return await apiClient.post<CancelAutoActionResponse>(
|
||||
`${BASE_PATH}/${tenantId}/alerts/${alertId}/cancel-auto-action`
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MUTATION METHODS - Recommendations
|
||||
// ============================================================
|
||||
|
||||
export interface DismissRecommendationResponse {
|
||||
success: boolean;
|
||||
event_id: string;
|
||||
dismissed_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a recommendation
|
||||
*/
|
||||
export async function dismissRecommendation(
|
||||
tenantId: string,
|
||||
recommendationId: string
|
||||
): Promise<DismissRecommendationResponse> {
|
||||
return await apiClient.post<DismissRecommendationResponse>(
|
||||
`${BASE_PATH}/${tenantId}/recommendations/${recommendationId}/dismiss`
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INTERACTION TRACKING
|
||||
// ============================================================
|
||||
|
||||
export interface RecordInteractionResponse {
|
||||
success: boolean;
|
||||
interaction_id: string;
|
||||
event_id: string;
|
||||
interaction_type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record user interaction with an event (for analytics)
|
||||
*/
|
||||
export async function recordInteraction(
|
||||
tenantId: string,
|
||||
eventId: string,
|
||||
interactionType: string,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<RecordInteractionResponse> {
|
||||
return await apiClient.post<RecordInteractionResponse>(
|
||||
`${BASE_PATH}/${tenantId}/events/${eventId}/interactions`,
|
||||
{
|
||||
interaction_type: interactionType,
|
||||
interaction_metadata: metadata,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BULK OPERATIONS (by metadata)
|
||||
// ============================================================
|
||||
|
||||
export interface BulkAcknowledgeResponse {
|
||||
success: boolean;
|
||||
acknowledged_count: number;
|
||||
alert_ids: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge multiple alerts by metadata filter
|
||||
*/
|
||||
export async function acknowledgeAlertsByMetadata(
|
||||
tenantId: string,
|
||||
alertType: string,
|
||||
metadataFilter: Record<string, any>
|
||||
): Promise<BulkAcknowledgeResponse> {
|
||||
return await apiClient.post<BulkAcknowledgeResponse>(
|
||||
`${BASE_PATH}/${tenantId}/alerts/bulk-acknowledge`,
|
||||
{
|
||||
alert_type: alertType,
|
||||
metadata_filter: metadataFilter,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export interface BulkResolveResponse {
|
||||
success: boolean;
|
||||
resolved_count: number;
|
||||
alert_ids: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve multiple alerts by metadata filter
|
||||
*/
|
||||
export async function resolveAlertsByMetadata(
|
||||
tenantId: string,
|
||||
alertType: string,
|
||||
metadataFilter: Record<string, any>
|
||||
): Promise<BulkResolveResponse> {
|
||||
return await apiClient.post<BulkResolveResponse>(
|
||||
`${BASE_PATH}/${tenantId}/alerts/bulk-resolve`,
|
||||
{
|
||||
alert_type: alertType,
|
||||
metadata_filter: metadataFilter,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EXPORT AS NAMED OBJECT
|
||||
// ============================================================
|
||||
|
||||
export const alertService = {
|
||||
// Query
|
||||
getEvents,
|
||||
getEvent,
|
||||
getEventsSummary,
|
||||
|
||||
// Alert mutations
|
||||
acknowledgeAlert,
|
||||
resolveAlert,
|
||||
cancelAutoAction,
|
||||
|
||||
// Recommendation mutations
|
||||
dismissRecommendation,
|
||||
|
||||
// Interaction tracking
|
||||
recordInteraction,
|
||||
|
||||
// Bulk operations
|
||||
acknowledgeAlertsByMetadata,
|
||||
resolveAlertsByMetadata,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// DEFAULT EXPORT
|
||||
// ============================================================
|
||||
|
||||
export default alertService;
|
||||
125
frontend/src/api/services/alert_analytics.ts
Normal file
125
frontend/src/api/services/alert_analytics.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Alert Analytics API Client
|
||||
* Handles all API calls for alert analytics and interaction tracking
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
export interface AlertTrendData {
|
||||
date: string;
|
||||
count: number;
|
||||
urgentCount: number;
|
||||
highCount: number;
|
||||
mediumCount: number;
|
||||
lowCount: number;
|
||||
}
|
||||
|
||||
export interface AlertCategory {
|
||||
category: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface AlertAnalytics {
|
||||
trends: AlertTrendData[];
|
||||
averageResponseTime: number;
|
||||
topCategories: AlertCategory[];
|
||||
totalAlerts: number;
|
||||
resolvedAlerts: number;
|
||||
activeAlerts: number;
|
||||
resolutionRate: number;
|
||||
predictedDailyAverage: number;
|
||||
busiestDay: string;
|
||||
}
|
||||
|
||||
export interface AlertInteraction {
|
||||
alert_id: string;
|
||||
interaction_type: 'acknowledged' | 'resolved' | 'snoozed' | 'dismissed';
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface InteractionResponse {
|
||||
id: string;
|
||||
alert_id: string;
|
||||
interaction_type: string;
|
||||
interacted_at: string;
|
||||
response_time_seconds: number;
|
||||
}
|
||||
|
||||
export interface BatchInteractionResponse {
|
||||
created_count: number;
|
||||
interactions: Array<{
|
||||
id: string;
|
||||
alert_id: string;
|
||||
interaction_type: string;
|
||||
interacted_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a single alert interaction
|
||||
*/
|
||||
export async function trackAlertInteraction(
|
||||
tenantId: string,
|
||||
alertId: string,
|
||||
interactionType: 'acknowledged' | 'resolved' | 'snoozed' | 'dismissed',
|
||||
metadata?: Record<string, any>
|
||||
): Promise<InteractionResponse> {
|
||||
return apiClient.post<InteractionResponse>(
|
||||
`/tenants/${tenantId}/alerts/${alertId}/interactions`,
|
||||
{
|
||||
alert_id: alertId,
|
||||
interaction_type: interactionType,
|
||||
metadata
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track multiple alert interactions in batch
|
||||
*/
|
||||
export async function trackAlertInteractionsBatch(
|
||||
tenantId: string,
|
||||
interactions: AlertInteraction[]
|
||||
): Promise<BatchInteractionResponse> {
|
||||
return apiClient.post<BatchInteractionResponse>(
|
||||
`/tenants/${tenantId}/alerts/interactions/batch`,
|
||||
{
|
||||
interactions
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive alert analytics
|
||||
*/
|
||||
export async function getAlertAnalytics(
|
||||
tenantId: string,
|
||||
days: number = 7
|
||||
): Promise<AlertAnalytics> {
|
||||
console.log('[getAlertAnalytics] Calling API:', `/tenants/${tenantId}/alerts/analytics`, 'with days:', days);
|
||||
const data = await apiClient.get<AlertAnalytics>(
|
||||
`/tenants/${tenantId}/alerts/analytics`,
|
||||
{
|
||||
params: { days }
|
||||
}
|
||||
);
|
||||
console.log('[getAlertAnalytics] Received data:', data);
|
||||
console.log('[getAlertAnalytics] Data type:', typeof data);
|
||||
return data; // apiClient.get() already returns data, not response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get alert trends only
|
||||
*/
|
||||
export async function getAlertTrends(
|
||||
tenantId: string,
|
||||
days: number = 7
|
||||
): Promise<AlertTrendData[]> {
|
||||
return apiClient.get<AlertTrendData[]>(
|
||||
`/tenants/${tenantId}/alerts/analytics/trends`,
|
||||
{
|
||||
params: { days }
|
||||
}
|
||||
);
|
||||
}
|
||||
267
frontend/src/api/services/auditLogs.ts
Normal file
267
frontend/src/api/services/auditLogs.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/auditLogs.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Audit Logs Aggregation Service
|
||||
*
|
||||
* Aggregates audit logs from all microservices and provides
|
||||
* unified access to system event history.
|
||||
*
|
||||
* Backend endpoints:
|
||||
* - GET /tenants/{tenant_id}/{service}/audit-logs
|
||||
* - GET /tenants/{tenant_id}/{service}/audit-logs/stats
|
||||
*
|
||||
* Last Updated: 2025-11-02
|
||||
* Status: ✅ Complete - Multi-service aggregation
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
AuditLogResponse,
|
||||
AuditLogFilters,
|
||||
AuditLogListResponse,
|
||||
AuditLogStatsResponse,
|
||||
AggregatedAuditLog,
|
||||
AUDIT_LOG_SERVICES,
|
||||
AuditLogServiceName,
|
||||
} from '../types/auditLogs';
|
||||
|
||||
export class AuditLogsService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
/**
|
||||
* Get audit logs from a single service
|
||||
*/
|
||||
async getServiceAuditLogs(
|
||||
tenantId: string,
|
||||
serviceName: AuditLogServiceName,
|
||||
filters?: AuditLogFilters
|
||||
): Promise<AuditLogListResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filters?.start_date) queryParams.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) queryParams.append('end_date', filters.end_date);
|
||||
if (filters?.user_id) queryParams.append('user_id', filters.user_id);
|
||||
if (filters?.action) queryParams.append('action', filters.action);
|
||||
if (filters?.resource_type) queryParams.append('resource_type', filters.resource_type);
|
||||
if (filters?.severity) queryParams.append('severity', filters.severity);
|
||||
if (filters?.search) queryParams.append('search', filters.search);
|
||||
if (filters?.limit) queryParams.append('limit', filters.limit.toString());
|
||||
if (filters?.offset) queryParams.append('offset', filters.offset.toString());
|
||||
|
||||
const url = `${this.baseUrl}/${tenantId}/${serviceName}/audit-logs${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
|
||||
return apiClient.get<AuditLogListResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log statistics from a single service
|
||||
*/
|
||||
async getServiceAuditLogStats(
|
||||
tenantId: string,
|
||||
serviceName: AuditLogServiceName,
|
||||
filters?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<AuditLogStatsResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filters?.start_date) queryParams.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) queryParams.append('end_date', filters.end_date);
|
||||
|
||||
const url = `${this.baseUrl}/${tenantId}/${serviceName}/audit-logs/stats${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
|
||||
return apiClient.get<AuditLogStatsResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated audit logs from ALL services
|
||||
* Makes parallel requests to all services and combines results
|
||||
*/
|
||||
async getAllAuditLogs(
|
||||
tenantId: string,
|
||||
filters?: AuditLogFilters
|
||||
): Promise<AggregatedAuditLog[]> {
|
||||
// Make parallel requests to all services
|
||||
const promises = AUDIT_LOG_SERVICES.map(service =>
|
||||
this.getServiceAuditLogs(tenantId, service, {
|
||||
...filters,
|
||||
limit: filters?.limit || 100,
|
||||
}).catch(error => {
|
||||
// If a service fails, log the error but don't fail the entire request
|
||||
console.warn(`Failed to fetch audit logs from ${service}:`, error);
|
||||
return { items: [], total: 0, limit: 0, offset: 0, has_more: false };
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Combine all results
|
||||
const allLogs: AggregatedAuditLog[] = results.flatMap(result => result.items);
|
||||
|
||||
// Sort by created_at descending (most recent first)
|
||||
allLogs.sort((a, b) => {
|
||||
const dateA = new Date(a.created_at).getTime();
|
||||
const dateB = new Date(b.created_at).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Apply limit if specified
|
||||
const limit = filters?.limit || 100;
|
||||
const offset = filters?.offset || 0;
|
||||
|
||||
return allLogs.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated statistics from ALL services
|
||||
*/
|
||||
async getAllAuditLogStats(
|
||||
tenantId: string,
|
||||
filters?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<AuditLogStatsResponse> {
|
||||
// Make parallel requests to all services
|
||||
const promises = AUDIT_LOG_SERVICES.map(service =>
|
||||
this.getServiceAuditLogStats(tenantId, service, filters).catch(error => {
|
||||
console.warn(`Failed to fetch audit log stats from ${service}:`, error);
|
||||
return {
|
||||
total_events: 0,
|
||||
events_by_action: {},
|
||||
events_by_severity: {},
|
||||
events_by_resource_type: {},
|
||||
date_range: { min: null, max: null },
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Aggregate statistics
|
||||
const aggregated: AuditLogStatsResponse = {
|
||||
total_events: 0,
|
||||
events_by_action: {},
|
||||
events_by_severity: {},
|
||||
events_by_resource_type: {},
|
||||
date_range: { min: null, max: null },
|
||||
};
|
||||
|
||||
for (const result of results) {
|
||||
aggregated.total_events += result.total_events;
|
||||
|
||||
// Merge events_by_action
|
||||
for (const [action, count] of Object.entries(result.events_by_action)) {
|
||||
aggregated.events_by_action[action] = (aggregated.events_by_action[action] || 0) + count;
|
||||
}
|
||||
|
||||
// Merge events_by_severity
|
||||
for (const [severity, count] of Object.entries(result.events_by_severity)) {
|
||||
aggregated.events_by_severity[severity] = (aggregated.events_by_severity[severity] || 0) + count;
|
||||
}
|
||||
|
||||
// Merge events_by_resource_type
|
||||
for (const [resource, count] of Object.entries(result.events_by_resource_type)) {
|
||||
aggregated.events_by_resource_type[resource] = (aggregated.events_by_resource_type[resource] || 0) + count;
|
||||
}
|
||||
|
||||
// Update date range
|
||||
if (result.date_range.min) {
|
||||
if (!aggregated.date_range.min || result.date_range.min < aggregated.date_range.min) {
|
||||
aggregated.date_range.min = result.date_range.min;
|
||||
}
|
||||
}
|
||||
if (result.date_range.max) {
|
||||
if (!aggregated.date_range.max || result.date_range.max > aggregated.date_range.max) {
|
||||
aggregated.date_range.max = result.date_range.max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aggregated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export audit logs to CSV format
|
||||
*/
|
||||
exportToCSV(logs: AggregatedAuditLog[]): string {
|
||||
if (logs.length === 0) return '';
|
||||
|
||||
const headers = [
|
||||
'Timestamp',
|
||||
'Service',
|
||||
'User ID',
|
||||
'Action',
|
||||
'Resource Type',
|
||||
'Resource ID',
|
||||
'Severity',
|
||||
'Description',
|
||||
'IP Address',
|
||||
'Endpoint',
|
||||
'Method',
|
||||
];
|
||||
|
||||
const rows = logs.map(log => [
|
||||
log.created_at,
|
||||
log.service_name,
|
||||
log.user_id || '',
|
||||
log.action,
|
||||
log.resource_type,
|
||||
log.resource_id || '',
|
||||
log.severity,
|
||||
log.description,
|
||||
log.ip_address || '',
|
||||
log.endpoint || '',
|
||||
log.method || '',
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(',')),
|
||||
].join('\n');
|
||||
|
||||
return csvContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export audit logs to JSON format
|
||||
*/
|
||||
exportToJSON(logs: AggregatedAuditLog[]): string {
|
||||
return JSON.stringify(logs, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download audit logs as a file
|
||||
*/
|
||||
downloadAuditLogs(
|
||||
logs: AggregatedAuditLog[],
|
||||
format: 'csv' | 'json',
|
||||
filename?: string
|
||||
): void {
|
||||
const content = format === 'csv' ? this.exportToCSV(logs) : this.exportToJSON(logs);
|
||||
const blob = new Blob([content], {
|
||||
type: format === 'csv' ? 'text/csv;charset=utf-8;' : 'application/json',
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute(
|
||||
'download',
|
||||
filename || `audit-logs-${new Date().toISOString().split('T')[0]}.${format}`
|
||||
);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const auditLogsService = new AuditLogsService();
|
||||
258
frontend/src/api/services/auth.ts
Normal file
258
frontend/src/api/services/auth.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/auth.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Auth Service - Atomic Registration Architecture
|
||||
*
|
||||
* Backend API structure (3-tier architecture):
|
||||
* - OPERATIONS: auth_operations.py, onboarding_progress.py
|
||||
*
|
||||
* Last Updated: 2025-01-14
|
||||
* Status: Complete - SetupIntent-first registration flow with 3DS support
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
UserRegistration,
|
||||
UserLogin,
|
||||
TokenResponse,
|
||||
RefreshTokenRequest,
|
||||
PasswordChange,
|
||||
PasswordReset,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
TokenVerificationResponse,
|
||||
AuthHealthResponse,
|
||||
RegistrationStartResponse,
|
||||
RegistrationCompletionResponse,
|
||||
RegistrationVerification,
|
||||
} from '../types/auth';
|
||||
|
||||
export class AuthService {
|
||||
private readonly baseUrl = '/auth';
|
||||
|
||||
// User Profile (authenticated)
|
||||
// Backend: services/auth/app/api/users.py
|
||||
// ===================================================================
|
||||
|
||||
async getProfile(): Promise<UserResponse> {
|
||||
// Get current user ID from auth store
|
||||
const { useAuthStore } = await import('../../stores/auth.store');
|
||||
const user = useAuthStore.getState().user;
|
||||
|
||||
if (!user?.id) {
|
||||
throw new Error('User not authenticated or user ID not available');
|
||||
}
|
||||
|
||||
return apiClient.get<UserResponse>(`${this.baseUrl}/users/${user.id}`);
|
||||
}
|
||||
|
||||
async updateProfile(updateData: UserUpdate): Promise<UserResponse> {
|
||||
// Get current user ID from auth store
|
||||
const { useAuthStore } = await import('../../stores/auth.store');
|
||||
const user = useAuthStore.getState().user;
|
||||
|
||||
if (!user?.id) {
|
||||
throw new Error('User not authenticated or user ID not available');
|
||||
}
|
||||
|
||||
return apiClient.put<UserResponse>(`${this.baseUrl}/users/${user.id}`, updateData);
|
||||
}
|
||||
|
||||
// ATOMIC REGISTRATION: SetupIntent-First Approach
|
||||
// These methods implement the secure registration flow with 3DS support
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Start secure registration flow with SetupIntent-first approach
|
||||
* This is the FIRST step in the atomic registration flow
|
||||
* Backend: services/auth/app/api/auth_operations.py:start_registration()
|
||||
*/
|
||||
async startRegistration(userData: UserRegistration): Promise<RegistrationStartResponse> {
|
||||
return apiClient.post<RegistrationStartResponse>(`${this.baseUrl}/start-registration`, userData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete registration after 3DS verification
|
||||
* This is the SECOND step in the atomic registration flow
|
||||
* Backend: services/auth/app/api/auth_operations.py:complete_registration()
|
||||
*/
|
||||
async completeRegistration(verificationData: RegistrationVerification): Promise<RegistrationCompletionResponse> {
|
||||
return apiClient.post<RegistrationCompletionResponse>(`${this.baseUrl}/complete-registration`, verificationData);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Authentication
|
||||
// Backend: services/auth/app/api/auth_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async login(loginData: UserLogin): Promise<TokenResponse> {
|
||||
return apiClient.post<TokenResponse>(`${this.baseUrl}/login`, loginData);
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<TokenResponse> {
|
||||
const refreshData: RefreshTokenRequest = { refresh_token: refreshToken };
|
||||
return apiClient.post<TokenResponse>(`${this.baseUrl}/refresh`, refreshData);
|
||||
}
|
||||
|
||||
async verifyToken(token?: string): Promise<TokenVerificationResponse> {
|
||||
// If token is provided, temporarily set it; otherwise use current token
|
||||
const currentToken = apiClient.getAuthToken();
|
||||
if (token && token !== currentToken) {
|
||||
apiClient.setAuthToken(token);
|
||||
}
|
||||
|
||||
const response = await apiClient.post<TokenVerificationResponse>(`${this.baseUrl}/verify`);
|
||||
|
||||
// Restore original token if we temporarily changed it
|
||||
if (token && token !== currentToken) {
|
||||
apiClient.setAuthToken(currentToken);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async logout(refreshToken: string): Promise<{ message: string }> {
|
||||
const refreshData: RefreshTokenRequest = { refresh_token: refreshToken };
|
||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/logout`, refreshData);
|
||||
}
|
||||
|
||||
async changePassword(passwordData: PasswordChange): Promise<{ message: string }> {
|
||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/change-password`, passwordData);
|
||||
}
|
||||
|
||||
async requestPasswordReset(email: string): Promise<{ message: string }> {
|
||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/password/reset-request`, { email });
|
||||
}
|
||||
|
||||
async resetPasswordWithToken(token: string, newPassword: string): Promise<{ message: string }> {
|
||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/password/reset`, {
|
||||
token,
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Email Verification
|
||||
// Backend: services/auth/app/api/auth_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async verifyEmail(
|
||||
userId: string,
|
||||
verificationToken: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/verify-email`, {
|
||||
user_id: userId,
|
||||
verification_token: verificationToken,
|
||||
});
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Account Management (self-service)
|
||||
// Backend: services/auth/app/api/account_deletion.py
|
||||
// ===================================================================
|
||||
|
||||
async deleteAccount(confirmEmail: string, password: string, reason?: string): Promise<{ message: string; deletion_date: string }> {
|
||||
return apiClient.delete(`${this.baseUrl}/me/account`, {
|
||||
data: {
|
||||
confirm_email: confirmEmail,
|
||||
password: password,
|
||||
reason: reason || ''
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAccountDeletionInfo(): Promise<Record<string, unknown>> {
|
||||
return apiClient.get(`${this.baseUrl}/me/account/deletion-info`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// GDPR Consent Management
|
||||
// Backend: services/auth/app/api/consent.py
|
||||
// ===================================================================
|
||||
|
||||
async recordConsent(consentData: {
|
||||
terms_accepted: boolean;
|
||||
privacy_accepted: boolean;
|
||||
marketing_consent?: boolean;
|
||||
analytics_consent?: boolean;
|
||||
consent_method: string;
|
||||
consent_version?: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
return apiClient.post(`${this.baseUrl}/me/consent`, consentData);
|
||||
}
|
||||
|
||||
async getCurrentConsent(): Promise<Record<string, unknown>> {
|
||||
return apiClient.get(`${this.baseUrl}/me/consent/current`);
|
||||
}
|
||||
|
||||
async getConsentHistory(): Promise<Record<string, unknown>[]> {
|
||||
return apiClient.get(`${this.baseUrl}/me/consent/history`);
|
||||
}
|
||||
|
||||
async updateConsent(consentData: {
|
||||
terms_accepted: boolean;
|
||||
privacy_accepted: boolean;
|
||||
marketing_consent?: boolean;
|
||||
analytics_consent?: boolean;
|
||||
consent_method: string;
|
||||
consent_version?: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
return apiClient.put(`${this.baseUrl}/me/consent`, consentData);
|
||||
}
|
||||
|
||||
async withdrawConsent(): Promise<{ message: string; withdrawn_count: number }> {
|
||||
return apiClient.post(`${this.baseUrl}/me/consent/withdraw`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Data Export (GDPR)
|
||||
// Backend: services/auth/app/api/data_export.py
|
||||
// ===================================================================
|
||||
|
||||
async exportMyData(): Promise<Record<string, unknown>> {
|
||||
return apiClient.get(`${this.baseUrl}/me/export`);
|
||||
}
|
||||
|
||||
async getExportSummary(): Promise<Record<string, unknown>> {
|
||||
return apiClient.get(`${this.baseUrl}/me/export/summary`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Onboarding Progress
|
||||
// Backend: services/auth/app/api/onboarding_progress.py
|
||||
// ===================================================================
|
||||
|
||||
async getOnboardingProgress(): Promise<Record<string, unknown>> {
|
||||
return apiClient.get(`${this.baseUrl}/me/onboarding/progress`);
|
||||
}
|
||||
|
||||
async updateOnboardingStep(stepName: string, completed: boolean, data?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
return apiClient.put(`${this.baseUrl}/me/onboarding/step`, {
|
||||
step_name: stepName,
|
||||
completed: completed,
|
||||
data: data
|
||||
});
|
||||
}
|
||||
|
||||
async getNextOnboardingStep(): Promise<{ step: string }> {
|
||||
return apiClient.get(`${this.baseUrl}/me/onboarding/next-step`);
|
||||
}
|
||||
|
||||
async canAccessOnboardingStep(stepName: string): Promise<{ can_access: boolean }> {
|
||||
return apiClient.get(`${this.baseUrl}/me/onboarding/can-access/${stepName}`);
|
||||
}
|
||||
|
||||
async completeOnboarding(): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.post(`${this.baseUrl}/me/onboarding/complete`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Health Check
|
||||
// ===================================================================
|
||||
|
||||
async healthCheck(): Promise<AuthHealthResponse> {
|
||||
return apiClient.get<AuthHealthResponse>(`${this.baseUrl}/health`);
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
88
frontend/src/api/services/consent.ts
Normal file
88
frontend/src/api/services/consent.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/consent.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Consent Service - GDPR Compliance
|
||||
*
|
||||
* Backend API: services/auth/app/api/consent.py
|
||||
*
|
||||
* Last Updated: 2025-10-16
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
|
||||
export interface ConsentRequest {
|
||||
terms_accepted: boolean;
|
||||
privacy_accepted: boolean;
|
||||
marketing_consent?: boolean;
|
||||
analytics_consent?: boolean;
|
||||
consent_method: 'registration' | 'settings' | 'cookie_banner';
|
||||
consent_version?: string;
|
||||
}
|
||||
|
||||
export interface ConsentResponse {
|
||||
id: string;
|
||||
user_id: string;
|
||||
terms_accepted: boolean;
|
||||
privacy_accepted: boolean;
|
||||
marketing_consent: boolean;
|
||||
analytics_consent: boolean;
|
||||
consent_version: string;
|
||||
consent_method: string;
|
||||
consented_at: string;
|
||||
withdrawn_at: string | null;
|
||||
}
|
||||
|
||||
export interface ConsentHistoryResponse {
|
||||
id: string;
|
||||
user_id: string;
|
||||
action: string;
|
||||
consent_snapshot: Record<string, any>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export class ConsentService {
|
||||
private readonly baseUrl = '/auth';
|
||||
|
||||
/**
|
||||
* Record user consent for data processing
|
||||
* GDPR Article 7 - Conditions for consent
|
||||
*/
|
||||
async recordConsent(consentData: ConsentRequest): Promise<ConsentResponse> {
|
||||
return apiClient.post<ConsentResponse>(`${this.baseUrl}/consent`, consentData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active consent for user
|
||||
*/
|
||||
async getCurrentConsent(): Promise<ConsentResponse | null> {
|
||||
return apiClient.get<ConsentResponse | null>(`${this.baseUrl}/consent/current`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete consent history for user
|
||||
* GDPR Article 7(1) - Demonstrating consent
|
||||
*/
|
||||
async getConsentHistory(): Promise<ConsentHistoryResponse[]> {
|
||||
return apiClient.get<ConsentHistoryResponse[]>(`${this.baseUrl}/consent/history`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user consent preferences
|
||||
* GDPR Article 7(3) - Withdrawal of consent
|
||||
*/
|
||||
async updateConsent(consentData: ConsentRequest): Promise<ConsentResponse> {
|
||||
return apiClient.put<ConsentResponse>(`${this.baseUrl}/consent`, consentData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw all consent
|
||||
* GDPR Article 7(3) - Right to withdraw consent
|
||||
*/
|
||||
async withdrawConsent(): Promise<{ message: string; withdrawn_count: number }> {
|
||||
return apiClient.post<{ message: string; withdrawn_count: number }>(
|
||||
`${this.baseUrl}/consent/withdraw`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const consentService = new ConsentService();
|
||||
204
frontend/src/api/services/demo.ts
Normal file
204
frontend/src/api/services/demo.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/demo.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Demo Session Service - Complete backend alignment
|
||||
*
|
||||
* Backend API structure (3-tier architecture):
|
||||
* - ATOMIC: demo_accounts.py, demo_sessions.py
|
||||
* - OPERATIONS: demo_operations.py
|
||||
*
|
||||
* Note: Demo service does NOT use tenant prefix
|
||||
*
|
||||
* Last Updated: 2025-10-05
|
||||
* Status: ✅ Complete - Zero drift with backend
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type { DemoSessionResponse } from '../types/demo';
|
||||
|
||||
export interface DemoAccount {
|
||||
account_type: string;
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
description?: string;
|
||||
features?: string[];
|
||||
business_model?: string;
|
||||
}
|
||||
|
||||
// Use the complete type from types/demo.ts which matches backend response
|
||||
export type DemoSession = DemoSessionResponse;
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
demo_account_type: 'individual_bakery' | 'central_baker';
|
||||
}
|
||||
|
||||
export interface ExtendSessionRequest {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface DestroySessionRequest {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface ServiceProgress {
|
||||
status: 'not_started' | 'in_progress' | 'completed' | 'failed';
|
||||
records_cloned: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SessionStatusResponse {
|
||||
session_id: string;
|
||||
status: 'pending' | 'ready' | 'partial' | 'failed' | 'active' | 'expired' | 'destroyed';
|
||||
total_records_cloned: number;
|
||||
progress?: Record<string, ServiceProgress>;
|
||||
errors?: Array<{ service: string; error_message: string }>;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Demo Session Status and Cloning
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get session status
|
||||
* GET /demo/sessions/{session_id}/status
|
||||
*/
|
||||
export const getSessionStatus = async (sessionId: string): Promise<SessionStatusResponse> => {
|
||||
return await apiClient.get<SessionStatusResponse>(`/demo/sessions/${sessionId}/status`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retry data cloning for a session
|
||||
* POST /demo/sessions/{session_id}/retry
|
||||
*/
|
||||
export const retryCloning = async (sessionId: string): Promise<SessionStatusResponse> => {
|
||||
return await apiClient.post<SessionStatusResponse>(`/demo/sessions/${sessionId}/retry`, {});
|
||||
};
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Demo Accounts
|
||||
// Backend: services/demo_session/app/api/demo_accounts.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get available demo accounts
|
||||
* GET /demo/accounts
|
||||
*/
|
||||
export const getDemoAccounts = async (): Promise<DemoAccount[]> => {
|
||||
return await apiClient.get<DemoAccount[]>('/demo/accounts');
|
||||
};
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Demo Sessions
|
||||
// Backend: services/demo_session/app/api/demo_sessions.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Create a new demo session
|
||||
* POST /demo/sessions
|
||||
*/
|
||||
export const createDemoSession = async (
|
||||
request: CreateSessionRequest
|
||||
): Promise<DemoSession> => {
|
||||
return await apiClient.post<DemoSession>('/demo/sessions', request);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get demo session details
|
||||
* GET /demo/sessions/{session_id}
|
||||
*/
|
||||
export const getDemoSession = async (sessionId: string): Promise<any> => {
|
||||
return await apiClient.get(`/demo/sessions/${sessionId}`);
|
||||
};
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Demo Session Management
|
||||
// Backend: services/demo_session/app/api/demo_operations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Extend an existing demo session
|
||||
* POST /demo/sessions/{session_id}/extend
|
||||
*/
|
||||
export const extendDemoSession = async (
|
||||
request: ExtendSessionRequest
|
||||
): Promise<DemoSession> => {
|
||||
return await apiClient.post<DemoSession>(
|
||||
`/demo/sessions/${request.session_id}/extend`,
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Destroy a demo session
|
||||
* Note: This might be a DELETE endpoint - verify backend implementation
|
||||
*/
|
||||
export const destroyDemoSession = async (
|
||||
request: DestroySessionRequest
|
||||
): Promise<{ message: string }> => {
|
||||
return await apiClient.post<{ message: string }>(
|
||||
`/demo/sessions/${request.session_id}/destroy`,
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get demo session statistics
|
||||
* GET /demo/stats
|
||||
*/
|
||||
export const getDemoStats = async (): Promise<any> => {
|
||||
return await apiClient.get('/demo/stats');
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleanup expired demo sessions (Admin/Operations)
|
||||
* POST /demo/operations/cleanup
|
||||
*/
|
||||
export const cleanupExpiredSessions = async (): Promise<any> => {
|
||||
return await apiClient.post('/demo/operations/cleanup', {});
|
||||
};
|
||||
|
||||
// ===================================================================
|
||||
// API Service Class
|
||||
// ===================================================================
|
||||
|
||||
export class DemoSessionAPI {
|
||||
async getDemoAccounts(): Promise<DemoAccount[]> {
|
||||
return getDemoAccounts();
|
||||
}
|
||||
|
||||
async createDemoSession(request: CreateSessionRequest): Promise<DemoSession> {
|
||||
return createDemoSession(request);
|
||||
}
|
||||
|
||||
async getDemoSession(sessionId: string): Promise<any> {
|
||||
return getDemoSession(sessionId);
|
||||
}
|
||||
|
||||
async extendDemoSession(request: ExtendSessionRequest): Promise<DemoSession> {
|
||||
return extendDemoSession(request);
|
||||
}
|
||||
|
||||
async destroyDemoSession(request: DestroySessionRequest): Promise<{ message: string }> {
|
||||
return destroyDemoSession(request);
|
||||
}
|
||||
|
||||
async getDemoStats(): Promise<any> {
|
||||
return getDemoStats();
|
||||
}
|
||||
|
||||
async cleanupExpiredSessions(): Promise<any> {
|
||||
return cleanupExpiredSessions();
|
||||
}
|
||||
|
||||
async getSessionStatus(sessionId: string): Promise<SessionStatusResponse> {
|
||||
return getSessionStatus(sessionId);
|
||||
}
|
||||
|
||||
async retryCloning(sessionId: string): Promise<SessionStatusResponse> {
|
||||
return retryCloning(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const demoSessionAPI = new DemoSessionAPI();
|
||||
68
frontend/src/api/services/distribution.ts
Normal file
68
frontend/src/api/services/distribution.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/distribution.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Distribution Service - Complete backend alignment
|
||||
*
|
||||
* Backend API structure:
|
||||
* - services/distribution/app/api/routes.py
|
||||
* - services/distribution/app/api/shipments.py
|
||||
*
|
||||
* Last Updated: 2025-12-03
|
||||
* Status: ✅ Complete - Backend alignment
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
export class DistributionService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// ===================================================================
|
||||
// SHIPMENTS
|
||||
// Backend: services/distribution/app/api/shipments.py
|
||||
// ===================================================================
|
||||
|
||||
async getShipments(
|
||||
tenantId: string,
|
||||
date?: string
|
||||
): Promise<any[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (date) {
|
||||
params.append('date_from', date);
|
||||
params.append('date_to', date);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${this.baseUrl}/${tenantId}/distribution/shipments${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await apiClient.get<any>(url);
|
||||
return response.shipments || response;
|
||||
}
|
||||
|
||||
async getShipment(
|
||||
tenantId: string,
|
||||
shipmentId: string
|
||||
): Promise<any> {
|
||||
return apiClient.get(`${this.baseUrl}/${tenantId}/distribution/shipments/${shipmentId}`);
|
||||
}
|
||||
|
||||
async getRouteSequences(
|
||||
tenantId: string,
|
||||
date?: string
|
||||
): Promise<any[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (date) {
|
||||
params.append('date_from', date);
|
||||
params.append('date_to', date);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${this.baseUrl}/${tenantId}/distribution/routes${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await apiClient.get<any>(url);
|
||||
return response.routes || response;
|
||||
}
|
||||
}
|
||||
|
||||
export const distributionService = new DistributionService();
|
||||
export default distributionService;
|
||||
281
frontend/src/api/services/equipment.ts
Normal file
281
frontend/src/api/services/equipment.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
// frontend/src/api/services/equipment.ts
|
||||
/**
|
||||
* Equipment API service
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
Equipment,
|
||||
EquipmentCreate,
|
||||
EquipmentUpdate,
|
||||
EquipmentResponse,
|
||||
EquipmentListResponse,
|
||||
EquipmentDeletionSummary
|
||||
} from '../types/equipment';
|
||||
|
||||
class EquipmentService {
|
||||
private readonly baseURL = '/tenants';
|
||||
|
||||
/**
|
||||
* Helper to convert snake_case API response to camelCase Equipment
|
||||
*/
|
||||
private convertToEquipment(response: EquipmentResponse): Equipment {
|
||||
return {
|
||||
id: response.id,
|
||||
tenant_id: response.tenant_id,
|
||||
name: response.name,
|
||||
type: response.type,
|
||||
model: response.model || '',
|
||||
serialNumber: response.serial_number || '',
|
||||
location: response.location || '',
|
||||
status: response.status.toLowerCase() as Equipment['status'],
|
||||
installDate: response.install_date || new Date().toISOString().split('T')[0],
|
||||
lastMaintenance: response.last_maintenance_date || new Date().toISOString().split('T')[0],
|
||||
nextMaintenance: response.next_maintenance_date || new Date().toISOString().split('T')[0],
|
||||
maintenanceInterval: response.maintenance_interval_days || 30,
|
||||
temperature: response.current_temperature || undefined,
|
||||
targetTemperature: response.target_temperature || undefined,
|
||||
efficiency: response.efficiency_percentage || 0,
|
||||
uptime: response.uptime_percentage || 0,
|
||||
energyUsage: response.energy_usage_kwh || 0,
|
||||
utilizationToday: 0, // Not in backend yet
|
||||
alerts: [], // Not in backend yet
|
||||
maintenanceHistory: [], // Not in backend yet
|
||||
specifications: {
|
||||
power: response.power_kw || 0,
|
||||
capacity: response.capacity || 0,
|
||||
dimensions: {
|
||||
width: 0, // Not in backend separately
|
||||
height: 0,
|
||||
depth: 0
|
||||
},
|
||||
weight: response.weight_kg || 0
|
||||
},
|
||||
is_active: response.is_active,
|
||||
support_contact: response.support_contact || undefined,
|
||||
created_at: response.created_at,
|
||||
updated_at: response.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to convert Equipment to API request format (snake_case)
|
||||
*/
|
||||
private convertToApiFormat(equipment: Partial<Equipment>): EquipmentCreate | EquipmentUpdate {
|
||||
return {
|
||||
name: equipment.name,
|
||||
type: equipment.type,
|
||||
model: equipment.model,
|
||||
serial_number: equipment.serialNumber,
|
||||
location: equipment.location,
|
||||
status: equipment.status,
|
||||
install_date: equipment.installDate,
|
||||
last_maintenance_date: equipment.lastMaintenance,
|
||||
next_maintenance_date: equipment.nextMaintenance,
|
||||
maintenance_interval_days: equipment.maintenanceInterval,
|
||||
efficiency_percentage: equipment.efficiency,
|
||||
uptime_percentage: equipment.uptime,
|
||||
energy_usage_kwh: equipment.energyUsage,
|
||||
power_kw: equipment.specifications?.power,
|
||||
capacity: equipment.specifications?.capacity,
|
||||
weight_kg: equipment.specifications?.weight,
|
||||
current_temperature: equipment.temperature,
|
||||
target_temperature: equipment.targetTemperature,
|
||||
is_active: equipment.is_active,
|
||||
support_contact: equipment.support_contact
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all equipment for a tenant
|
||||
*/
|
||||
async getEquipment(
|
||||
tenantId: string,
|
||||
filters?: {
|
||||
status?: string;
|
||||
type?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
): Promise<Equipment[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.type) params.append('type', filters.type);
|
||||
if (filters?.is_active !== undefined) params.append('is_active', String(filters.is_active));
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${this.baseURL}/${tenantId}/production/equipment${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const data: EquipmentListResponse = await apiClient.get(url, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
|
||||
return data.equipment.map(eq => this.convertToEquipment(eq));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific equipment item
|
||||
*/
|
||||
async getEquipmentById(
|
||||
tenantId: string,
|
||||
equipmentId: string
|
||||
): Promise<Equipment> {
|
||||
const data: EquipmentResponse = await apiClient.get(
|
||||
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}`,
|
||||
{
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
}
|
||||
);
|
||||
return this.convertToEquipment(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new equipment item
|
||||
*/
|
||||
async createEquipment(
|
||||
tenantId: string,
|
||||
equipmentData: Equipment
|
||||
): Promise<Equipment> {
|
||||
const apiData = this.convertToApiFormat(equipmentData);
|
||||
const data: EquipmentResponse = await apiClient.post(
|
||||
`${this.baseURL}/${tenantId}/production/equipment`,
|
||||
apiData,
|
||||
{
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
}
|
||||
);
|
||||
return this.convertToEquipment(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an equipment item
|
||||
*/
|
||||
async updateEquipment(
|
||||
tenantId: string,
|
||||
equipmentId: string,
|
||||
equipmentData: Partial<Equipment>
|
||||
): Promise<Equipment> {
|
||||
const apiData = this.convertToApiFormat(equipmentData);
|
||||
const data: EquipmentResponse = await apiClient.put(
|
||||
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}`,
|
||||
apiData,
|
||||
{
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
}
|
||||
);
|
||||
return this.convertToEquipment(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an equipment item (soft delete)
|
||||
*/
|
||||
async deleteEquipment(tenantId: string, equipmentId: string): Promise<void> {
|
||||
await apiClient.delete(
|
||||
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}`,
|
||||
{
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete an equipment item (hard delete)
|
||||
*/
|
||||
async hardDeleteEquipment(tenantId: string, equipmentId: string): Promise<void> {
|
||||
await apiClient.delete(
|
||||
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}?permanent=true`,
|
||||
{
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deletion summary for an equipment item
|
||||
*/
|
||||
async getEquipmentDeletionSummary(
|
||||
tenantId: string,
|
||||
equipmentId: string
|
||||
): Promise<EquipmentDeletionSummary> {
|
||||
const data: EquipmentDeletionSummary = await apiClient.get(
|
||||
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}/deletion-summary`,
|
||||
{
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
}
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report equipment failure
|
||||
*/
|
||||
async reportEquipmentFailure(
|
||||
tenantId: string,
|
||||
equipmentId: string,
|
||||
failureData: {
|
||||
failureType: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
photos?: File[];
|
||||
estimatedImpact: boolean;
|
||||
}
|
||||
): Promise<Equipment> {
|
||||
const apiData = {
|
||||
failureType: failureData.failureType,
|
||||
severity: failureData.severity,
|
||||
description: failureData.description,
|
||||
estimatedImpact: failureData.estimatedImpact,
|
||||
// Note: Photos would be handled separately in a real implementation
|
||||
// For now, we'll just send the metadata
|
||||
photos: failureData.photos ? failureData.photos.map(p => p.name) : []
|
||||
};
|
||||
|
||||
const data: EquipmentResponse = await apiClient.post(
|
||||
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}/report-failure`,
|
||||
apiData,
|
||||
{
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
}
|
||||
);
|
||||
return this.convertToEquipment(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark equipment as repaired
|
||||
*/
|
||||
async markEquipmentAsRepaired(
|
||||
tenantId: string,
|
||||
equipmentId: string,
|
||||
repairData: {
|
||||
repairDate: string;
|
||||
technicianName: string;
|
||||
repairDescription: string;
|
||||
partsReplaced: string[];
|
||||
cost: number;
|
||||
photos?: File[];
|
||||
testResults: boolean;
|
||||
}
|
||||
): Promise<Equipment> {
|
||||
const apiData = {
|
||||
repairDate: repairData.repairDate,
|
||||
technicianName: repairData.technicianName,
|
||||
repairDescription: repairData.repairDescription,
|
||||
partsReplaced: repairData.partsReplaced,
|
||||
cost: repairData.cost,
|
||||
testResults: repairData.testResults,
|
||||
// Note: Photos would be handled separately in a real implementation
|
||||
// For now, we'll just send the metadata
|
||||
photos: repairData.photos ? repairData.photos.map(p => p.name) : []
|
||||
};
|
||||
|
||||
const data: EquipmentResponse = await apiClient.post(
|
||||
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}/mark-repaired`,
|
||||
apiData,
|
||||
{
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
}
|
||||
);
|
||||
return this.convertToEquipment(data);
|
||||
}
|
||||
}
|
||||
|
||||
export const equipmentService = new EquipmentService();
|
||||
123
frontend/src/api/services/external.ts
Normal file
123
frontend/src/api/services/external.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// frontend/src/api/services/external.ts
|
||||
/**
|
||||
* External Data API Service
|
||||
* Handles weather and traffic data operations
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
CityInfoResponse,
|
||||
DataAvailabilityResponse,
|
||||
WeatherDataResponse,
|
||||
TrafficDataResponse,
|
||||
HistoricalWeatherRequest,
|
||||
HistoricalTrafficRequest,
|
||||
} from '../types/external';
|
||||
|
||||
class ExternalDataService {
|
||||
/**
|
||||
* List all supported cities
|
||||
*/
|
||||
async listCities(): Promise<CityInfoResponse[]> {
|
||||
return await apiClient.get<CityInfoResponse[]>(
|
||||
'/api/v1/external/cities'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data availability for a specific city
|
||||
*/
|
||||
async getCityAvailability(cityId: string): Promise<DataAvailabilityResponse> {
|
||||
return await apiClient.get<DataAvailabilityResponse>(
|
||||
`/api/v1/external/operations/cities/${cityId}/availability`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical weather data (optimized city-based endpoint)
|
||||
*/
|
||||
async getHistoricalWeatherOptimized(
|
||||
tenantId: string,
|
||||
params: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
}
|
||||
): Promise<WeatherDataResponse[]> {
|
||||
return await apiClient.get<WeatherDataResponse[]>(
|
||||
`/api/v1/tenants/${tenantId}/external/operations/historical-weather-optimized`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical traffic data (optimized city-based endpoint)
|
||||
*/
|
||||
async getHistoricalTrafficOptimized(
|
||||
tenantId: string,
|
||||
params: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
}
|
||||
): Promise<TrafficDataResponse[]> {
|
||||
return await apiClient.get<TrafficDataResponse[]>(
|
||||
`/api/v1/tenants/${tenantId}/external/operations/historical-traffic-optimized`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current weather for a location (real-time)
|
||||
*/
|
||||
async getCurrentWeather(
|
||||
tenantId: string,
|
||||
params: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
): Promise<WeatherDataResponse> {
|
||||
return await apiClient.get<WeatherDataResponse>(
|
||||
`/api/v1/tenants/${tenantId}/external/operations/weather/current`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weather forecast
|
||||
*/
|
||||
async getWeatherForecast(
|
||||
tenantId: string,
|
||||
params: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
days?: number;
|
||||
}
|
||||
): Promise<WeatherDataResponse[]> {
|
||||
return await apiClient.get<WeatherDataResponse[]>(
|
||||
`/api/v1/tenants/${tenantId}/external/operations/weather/forecast`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current traffic conditions (real-time)
|
||||
*/
|
||||
async getCurrentTraffic(
|
||||
tenantId: string,
|
||||
params: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
): Promise<TrafficDataResponse> {
|
||||
return await apiClient.get<TrafficDataResponse>(
|
||||
`/api/v1/tenants/${tenantId}/external/operations/traffic/current`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const externalDataService = new ExternalDataService();
|
||||
export default externalDataService;
|
||||
317
frontend/src/api/services/forecasting.ts
Normal file
317
frontend/src/api/services/forecasting.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/forecasting.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Forecasting Service - Complete backend alignment
|
||||
*
|
||||
* Backend API structure (3-tier architecture):
|
||||
* - ATOMIC: forecasts.py
|
||||
* - OPERATIONS: forecasting_operations.py
|
||||
* - ANALYTICS: analytics.py
|
||||
*
|
||||
* Last Updated: 2025-10-05
|
||||
* Status: ✅ Complete - Zero drift with backend
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client/apiClient';
|
||||
import {
|
||||
ForecastRequest,
|
||||
ForecastResponse,
|
||||
BatchForecastRequest,
|
||||
BatchForecastResponse,
|
||||
ForecastListResponse,
|
||||
ForecastByIdResponse,
|
||||
ForecastStatistics,
|
||||
DeleteForecastResponse,
|
||||
GetForecastsParams,
|
||||
ForecastingHealthResponse,
|
||||
MultiDayForecastResponse,
|
||||
ScenarioSimulationRequest,
|
||||
ScenarioSimulationResponse,
|
||||
ScenarioComparisonRequest,
|
||||
ScenarioComparisonResponse,
|
||||
} from '../types/forecasting';
|
||||
|
||||
export class ForecastingService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Forecast CRUD
|
||||
// Backend: services/forecasting/app/api/forecasts.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* List forecasts with optional filters
|
||||
* GET /tenants/{tenant_id}/forecasting/forecasts
|
||||
*/
|
||||
async getTenantForecasts(
|
||||
tenantId: string,
|
||||
params?: GetForecastsParams
|
||||
): Promise<ForecastListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.inventory_product_id) {
|
||||
searchParams.append('inventory_product_id', params.inventory_product_id);
|
||||
}
|
||||
if (params?.start_date) {
|
||||
searchParams.append('start_date', params.start_date);
|
||||
}
|
||||
if (params?.end_date) {
|
||||
searchParams.append('end_date', params.end_date);
|
||||
}
|
||||
if (params?.skip !== undefined) {
|
||||
searchParams.append('skip', params.skip.toString());
|
||||
}
|
||||
if (params?.limit !== undefined) {
|
||||
searchParams.append('limit', params.limit.toString());
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${this.baseUrl}/${tenantId}/forecasting/forecasts${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiClient.get<ForecastListResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific forecast by ID
|
||||
* GET /tenants/{tenant_id}/forecasting/forecasts/{forecast_id}
|
||||
*/
|
||||
async getForecastById(
|
||||
tenantId: string,
|
||||
forecastId: string
|
||||
): Promise<ForecastByIdResponse> {
|
||||
return apiClient.get<ForecastByIdResponse>(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/forecasts/${forecastId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a forecast
|
||||
* DELETE /tenants/{tenant_id}/forecasting/forecasts/{forecast_id}
|
||||
*/
|
||||
async deleteForecast(
|
||||
tenantId: string,
|
||||
forecastId: string
|
||||
): Promise<DeleteForecastResponse> {
|
||||
return apiClient.delete<DeleteForecastResponse>(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/forecasts/${forecastId}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Forecasting Operations
|
||||
// Backend: services/forecasting/app/api/forecasting_operations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Generate a single product forecast
|
||||
* POST /tenants/{tenant_id}/forecasting/operations/single
|
||||
*/
|
||||
async createSingleForecast(
|
||||
tenantId: string,
|
||||
request: ForecastRequest
|
||||
): Promise<ForecastResponse> {
|
||||
return apiClient.post<ForecastResponse, ForecastRequest>(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/operations/single`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate multiple daily forecasts for the specified period
|
||||
* POST /tenants/{tenant_id}/forecasting/operations/multi-day
|
||||
*/
|
||||
async createMultiDayForecast(
|
||||
tenantId: string,
|
||||
request: ForecastRequest
|
||||
): Promise<MultiDayForecastResponse> {
|
||||
return apiClient.post<MultiDayForecastResponse, ForecastRequest>(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/operations/multi-day`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate batch forecasts for multiple products
|
||||
* POST /tenants/{tenant_id}/forecasting/operations/batch
|
||||
*/
|
||||
async createBatchForecast(
|
||||
tenantId: string,
|
||||
request: BatchForecastRequest
|
||||
): Promise<BatchForecastResponse> {
|
||||
return apiClient.post<BatchForecastResponse, BatchForecastRequest>(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/operations/batch`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive forecast statistics
|
||||
* GET /tenants/{tenant_id}/forecasting/operations/statistics
|
||||
*/
|
||||
async getForecastStatistics(
|
||||
tenantId: string
|
||||
): Promise<ForecastStatistics> {
|
||||
return apiClient.get<ForecastStatistics>(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/operations/statistics`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate real-time prediction
|
||||
* POST /tenants/{tenant_id}/forecasting/operations/realtime
|
||||
*/
|
||||
async generateRealtimePrediction(
|
||||
tenantId: string,
|
||||
predictionRequest: {
|
||||
inventory_product_id: string;
|
||||
model_id: string;
|
||||
features: Record<string, any>;
|
||||
model_path?: string;
|
||||
confidence_level?: number;
|
||||
}
|
||||
): Promise<{
|
||||
tenant_id: string;
|
||||
inventory_product_id: string;
|
||||
model_id: string;
|
||||
prediction: number;
|
||||
confidence: number;
|
||||
timestamp: string;
|
||||
}> {
|
||||
return apiClient.post(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/operations/realtime`,
|
||||
predictionRequest
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate batch predictions
|
||||
* POST /tenants/{tenant_id}/forecasting/operations/batch-predictions
|
||||
*/
|
||||
async generateBatchPredictions(
|
||||
tenantId: string,
|
||||
predictionsRequest: Array<{
|
||||
inventory_product_id?: string;
|
||||
model_id: string;
|
||||
features: Record<string, any>;
|
||||
model_path?: string;
|
||||
confidence_level?: number;
|
||||
}>
|
||||
): Promise<{
|
||||
predictions: Array<{
|
||||
inventory_product_id?: string;
|
||||
prediction?: number;
|
||||
confidence?: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
total: number;
|
||||
}> {
|
||||
return apiClient.post(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/operations/batch-predictions`,
|
||||
predictionsRequest
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate predictions against actual sales data
|
||||
* POST /tenants/{tenant_id}/forecasting/operations/validate-predictions
|
||||
*/
|
||||
async validatePredictions(
|
||||
tenantId: string,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<any> {
|
||||
return apiClient.post(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/operations/validate-predictions?start_date=${startDate}&end_date=${endDate}`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear prediction cache
|
||||
* DELETE /tenants/{tenant_id}/forecasting/operations/cache
|
||||
*/
|
||||
async clearPredictionCache(tenantId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/operations/cache`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ANALYTICS: Performance Metrics
|
||||
// Backend: services/forecasting/app/api/analytics.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get predictions performance analytics
|
||||
* GET /tenants/{tenant_id}/forecasting/analytics/predictions-performance
|
||||
*/
|
||||
async getPredictionsPerformance(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<any> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (startDate) searchParams.append('start_date', startDate);
|
||||
if (endDate) searchParams.append('end_date', endDate);
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
return apiClient.get(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/analytics/predictions-performance${queryString ? `?${queryString}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SCENARIO SIMULATION - PROFESSIONAL/ENTERPRISE ONLY
|
||||
// Backend: services/forecasting/app/api/scenario_operations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Run a "what-if" scenario simulation on forecasts
|
||||
* POST /tenants/{tenant_id}/forecasting/analytics/scenario-simulation
|
||||
*
|
||||
* **PROFESSIONAL/ENTERPRISE ONLY**
|
||||
*/
|
||||
async simulateScenario(
|
||||
tenantId: string,
|
||||
request: ScenarioSimulationRequest
|
||||
): Promise<ScenarioSimulationResponse> {
|
||||
return apiClient.post<ScenarioSimulationResponse, ScenarioSimulationRequest>(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/analytics/scenario-simulation`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare multiple scenario simulations
|
||||
* POST /tenants/{tenant_id}/forecasting/analytics/scenario-comparison
|
||||
*
|
||||
* **PROFESSIONAL/ENTERPRISE ONLY**
|
||||
*/
|
||||
async compareScenarios(
|
||||
tenantId: string,
|
||||
request: ScenarioComparisonRequest
|
||||
): Promise<ScenarioComparisonResponse> {
|
||||
return apiClient.post<ScenarioComparisonResponse, ScenarioComparisonRequest>(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/analytics/scenario-comparison`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Health Check
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Health check for forecasting service
|
||||
* GET /health
|
||||
*/
|
||||
async getHealthCheck(): Promise<ForecastingHealthResponse> {
|
||||
return apiClient.get<ForecastingHealthResponse>('/health');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const forecastingService = new ForecastingService();
|
||||
export default forecastingService;
|
||||
544
frontend/src/api/services/inventory.ts
Normal file
544
frontend/src/api/services/inventory.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/inventory.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Inventory Service - Complete backend alignment
|
||||
*
|
||||
* Backend API structure (3-tier architecture):
|
||||
* - ATOMIC: ingredients.py, stock_entries.py, transformations.py, temperature_logs.py
|
||||
* - OPERATIONS: inventory_operations.py, food_safety_operations.py
|
||||
* - ANALYTICS: analytics.py, dashboard.py
|
||||
* - COMPLIANCE: food_safety_alerts.py, food_safety_compliance.py
|
||||
*
|
||||
* Last Updated: 2025-10-05
|
||||
* Status: ✅ Complete - Zero drift with backend
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
// Ingredients
|
||||
IngredientCreate,
|
||||
IngredientUpdate,
|
||||
IngredientResponse,
|
||||
IngredientFilter,
|
||||
BulkIngredientResponse,
|
||||
// Stock
|
||||
StockCreate,
|
||||
StockUpdate,
|
||||
StockResponse,
|
||||
StockFilter,
|
||||
StockMovementCreate,
|
||||
StockMovementResponse,
|
||||
BulkStockResponse,
|
||||
// Operations
|
||||
StockConsumptionRequest,
|
||||
StockConsumptionResponse,
|
||||
// Transformations
|
||||
ProductTransformationCreate,
|
||||
ProductTransformationResponse,
|
||||
// Food Safety
|
||||
TemperatureLogCreate,
|
||||
TemperatureLogResponse,
|
||||
FoodSafetyAlertResponse,
|
||||
FoodSafetyComplianceResponse,
|
||||
// Classification
|
||||
ProductClassificationRequest,
|
||||
ProductSuggestionResponse,
|
||||
BatchClassificationRequest,
|
||||
BatchClassificationResponse,
|
||||
BusinessModelAnalysisResponse,
|
||||
// Dashboard & Analytics
|
||||
InventorySummary,
|
||||
InventoryDashboardSummary,
|
||||
InventoryAnalytics,
|
||||
// Common
|
||||
PaginatedResponse,
|
||||
DeletionSummary,
|
||||
} from '../types/inventory';
|
||||
|
||||
export class InventoryService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Ingredients CRUD
|
||||
// Backend: services/inventory/app/api/ingredients.py
|
||||
// ===================================================================
|
||||
|
||||
async createIngredient(
|
||||
tenantId: string,
|
||||
ingredientData: IngredientCreate
|
||||
): Promise<IngredientResponse> {
|
||||
return apiClient.post<IngredientResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/ingredients`,
|
||||
ingredientData
|
||||
);
|
||||
}
|
||||
|
||||
async bulkCreateIngredients(
|
||||
tenantId: string,
|
||||
ingredients: IngredientCreate[]
|
||||
): Promise<BulkIngredientResponse> {
|
||||
return apiClient.post<BulkIngredientResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/ingredients/bulk`,
|
||||
{ ingredients }
|
||||
);
|
||||
}
|
||||
|
||||
async getIngredient(tenantId: string, ingredientId: string): Promise<IngredientResponse> {
|
||||
return apiClient.get<IngredientResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}`
|
||||
);
|
||||
}
|
||||
|
||||
async getIngredients(
|
||||
tenantId: string,
|
||||
filter?: IngredientFilter
|
||||
): Promise<IngredientResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filter?.category) queryParams.append('category', filter.category);
|
||||
if (filter?.stock_status) queryParams.append('stock_status', filter.stock_status);
|
||||
if (filter?.requires_refrigeration !== undefined)
|
||||
queryParams.append('requires_refrigeration', filter.requires_refrigeration.toString());
|
||||
if (filter?.requires_freezing !== undefined)
|
||||
queryParams.append('requires_freezing', filter.requires_freezing.toString());
|
||||
if (filter?.is_seasonal !== undefined)
|
||||
queryParams.append('is_seasonal', filter.is_seasonal.toString());
|
||||
if (filter?.supplier_id) queryParams.append('supplier_id', filter.supplier_id);
|
||||
if (filter?.expiring_within_days !== undefined)
|
||||
queryParams.append('expiring_within_days', filter.expiring_within_days.toString());
|
||||
if (filter?.search) queryParams.append('search', filter.search);
|
||||
if (filter?.limit !== undefined) queryParams.append('limit', filter.limit.toString());
|
||||
if (filter?.offset !== undefined) queryParams.append('offset', filter.offset.toString());
|
||||
if (filter?.order_by) queryParams.append('order_by', filter.order_by);
|
||||
if (filter?.order_direction) queryParams.append('order_direction', filter.order_direction);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/inventory/ingredients?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/inventory/ingredients`;
|
||||
|
||||
return apiClient.get<IngredientResponse[]>(url);
|
||||
}
|
||||
|
||||
async updateIngredient(
|
||||
tenantId: string,
|
||||
ingredientId: string,
|
||||
updateData: IngredientUpdate
|
||||
): Promise<IngredientResponse> {
|
||||
return apiClient.put<IngredientResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}`,
|
||||
updateData
|
||||
);
|
||||
}
|
||||
|
||||
async softDeleteIngredient(tenantId: string, ingredientId: string): Promise<void> {
|
||||
return apiClient.delete<void>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}`
|
||||
);
|
||||
}
|
||||
|
||||
async hardDeleteIngredient(tenantId: string, ingredientId: string): Promise<DeletionSummary> {
|
||||
return apiClient.delete<DeletionSummary>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}/hard`
|
||||
);
|
||||
}
|
||||
|
||||
async getIngredientsByCategory(
|
||||
tenantId: string
|
||||
): Promise<Record<string, IngredientResponse[]>> {
|
||||
return apiClient.get<Record<string, IngredientResponse[]>>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/ingredients/by-category`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Stock CRUD
|
||||
// Backend: services/inventory/app/api/stock_entries.py
|
||||
// ===================================================================
|
||||
|
||||
async addStock(tenantId: string, stockData: StockCreate): Promise<StockResponse> {
|
||||
return apiClient.post<StockResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/stock`,
|
||||
stockData
|
||||
);
|
||||
}
|
||||
|
||||
async bulkAddStock(
|
||||
tenantId: string,
|
||||
stocks: StockCreate[]
|
||||
): Promise<BulkStockResponse> {
|
||||
return apiClient.post<BulkStockResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/stock/bulk`,
|
||||
{ stocks }
|
||||
);
|
||||
}
|
||||
|
||||
async getStock(tenantId: string, stockId: string): Promise<StockResponse> {
|
||||
return apiClient.get<StockResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/stock/${stockId}`
|
||||
);
|
||||
}
|
||||
|
||||
async getStockByIngredient(
|
||||
tenantId: string,
|
||||
ingredientId: string,
|
||||
includeUnavailable: boolean = false
|
||||
): Promise<StockResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('include_unavailable', includeUnavailable.toString());
|
||||
|
||||
return apiClient.get<StockResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}/stock?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getAllStock(
|
||||
tenantId: string,
|
||||
filter?: StockFilter
|
||||
): Promise<PaginatedResponse<StockResponse>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filter?.ingredient_id) queryParams.append('ingredient_id', filter.ingredient_id);
|
||||
if (filter?.is_available !== undefined)
|
||||
queryParams.append('is_available', filter.is_available.toString());
|
||||
if (filter?.is_expired !== undefined)
|
||||
queryParams.append('is_expired', filter.is_expired.toString());
|
||||
if (filter?.expiring_within_days !== undefined)
|
||||
queryParams.append('expiring_within_days', filter.expiring_within_days.toString());
|
||||
if (filter?.batch_number) queryParams.append('batch_number', filter.batch_number);
|
||||
if (filter?.supplier_id) queryParams.append('supplier_id', filter.supplier_id);
|
||||
if (filter?.limit !== undefined) queryParams.append('limit', filter.limit.toString());
|
||||
if (filter?.offset !== undefined) queryParams.append('offset', filter.offset.toString());
|
||||
if (filter?.order_by) queryParams.append('order_by', filter.order_by);
|
||||
if (filter?.order_direction) queryParams.append('order_direction', filter.order_direction);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/inventory/stock?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/inventory/stock`;
|
||||
|
||||
return apiClient.get<PaginatedResponse<StockResponse>>(url);
|
||||
}
|
||||
|
||||
async updateStock(
|
||||
tenantId: string,
|
||||
stockId: string,
|
||||
updateData: StockUpdate
|
||||
): Promise<StockResponse> {
|
||||
return apiClient.put<StockResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/stock/${stockId}`,
|
||||
updateData
|
||||
);
|
||||
}
|
||||
|
||||
async deleteStock(tenantId: string, stockId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/stock/${stockId}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Stock Movements
|
||||
// Backend: services/inventory/app/api/stock_entries.py
|
||||
// ===================================================================
|
||||
|
||||
async createStockMovement(
|
||||
tenantId: string,
|
||||
movementData: StockMovementCreate
|
||||
): Promise<StockMovementResponse> {
|
||||
return apiClient.post<StockMovementResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/stock/movements`,
|
||||
movementData
|
||||
);
|
||||
}
|
||||
|
||||
async getStockMovements(
|
||||
tenantId: string,
|
||||
ingredientId?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<StockMovementResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (ingredientId) queryParams.append('ingredient_id', ingredientId);
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('skip', offset.toString());
|
||||
|
||||
return apiClient.get<StockMovementResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/stock/movements?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Transformations
|
||||
// Backend: services/inventory/app/api/transformations.py
|
||||
// ===================================================================
|
||||
|
||||
async createTransformation(
|
||||
tenantId: string,
|
||||
transformationData: ProductTransformationCreate
|
||||
): Promise<ProductTransformationResponse> {
|
||||
return apiClient.post<ProductTransformationResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/transformations`,
|
||||
transformationData
|
||||
);
|
||||
}
|
||||
|
||||
async listTransformations(
|
||||
tenantId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<ProductTransformationResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('skip', offset.toString());
|
||||
|
||||
return apiClient.get<ProductTransformationResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/transformations?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Temperature Logs
|
||||
// Backend: services/inventory/app/api/temperature_logs.py
|
||||
// ===================================================================
|
||||
|
||||
async logTemperature(
|
||||
tenantId: string,
|
||||
temperatureData: TemperatureLogCreate
|
||||
): Promise<TemperatureLogResponse> {
|
||||
return apiClient.post<TemperatureLogResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/temperature-logs`,
|
||||
temperatureData
|
||||
);
|
||||
}
|
||||
|
||||
async listTemperatureLogs(
|
||||
tenantId: string,
|
||||
ingredientId?: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit: number = 100,
|
||||
offset: number = 0
|
||||
): Promise<TemperatureLogResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (ingredientId) queryParams.append('ingredient_id', ingredientId);
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('skip', offset.toString());
|
||||
|
||||
return apiClient.get<TemperatureLogResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/temperature-logs?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Stock Management
|
||||
// Backend: services/inventory/app/api/inventory_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async consumeStock(
|
||||
tenantId: string,
|
||||
consumptionData: StockConsumptionRequest
|
||||
): Promise<StockConsumptionResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('ingredient_id', consumptionData.ingredient_id);
|
||||
queryParams.append('quantity', consumptionData.quantity.toString());
|
||||
if (consumptionData.reference_number)
|
||||
queryParams.append('reference_number', consumptionData.reference_number);
|
||||
if (consumptionData.notes) queryParams.append('notes', consumptionData.notes);
|
||||
if (consumptionData.fifo !== undefined)
|
||||
queryParams.append('fifo', consumptionData.fifo.toString());
|
||||
|
||||
return apiClient.post<StockConsumptionResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/operations/consume-stock?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getExpiringStock(
|
||||
tenantId: string,
|
||||
withinDays: number = 7
|
||||
): Promise<StockResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('days_ahead', withinDays.toString());
|
||||
|
||||
return apiClient.get<StockResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/operations/stock/expiring?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getExpiredStock(tenantId: string): Promise<StockResponse[]> {
|
||||
return apiClient.get<StockResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/operations/stock/expired`
|
||||
);
|
||||
}
|
||||
|
||||
async getLowStockIngredients(tenantId: string): Promise<IngredientResponse[]> {
|
||||
return apiClient.get<IngredientResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/operations/stock/low-stock`
|
||||
);
|
||||
}
|
||||
|
||||
async getStockSummary(tenantId: string): Promise<InventorySummary> {
|
||||
return apiClient.get<InventorySummary>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/operations/stock/summary`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Classification
|
||||
// Backend: services/inventory/app/api/inventory_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async classifyProduct(
|
||||
tenantId: string,
|
||||
classificationData: ProductClassificationRequest
|
||||
): Promise<ProductSuggestionResponse> {
|
||||
return apiClient.post<ProductSuggestionResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/operations/classify`,
|
||||
classificationData
|
||||
);
|
||||
}
|
||||
|
||||
async classifyBatch(
|
||||
tenantId: string,
|
||||
batchData: BatchClassificationRequest
|
||||
): Promise<BatchClassificationResponse> {
|
||||
return apiClient.post<BatchClassificationResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/operations/classify-products-batch`,
|
||||
batchData
|
||||
);
|
||||
}
|
||||
|
||||
async analyzeBusinessModel(tenantId: string): Promise<BusinessModelAnalysisResponse> {
|
||||
return apiClient.post<BusinessModelAnalysisResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/operations/analyze-business-model`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Batch Inventory Summary (Enterprise Feature)
|
||||
// Backend: services/inventory/app/api/inventory_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async getBatchInventorySummary(tenantIds: string[]): Promise<Record<string, any>> {
|
||||
return apiClient.post<Record<string, any>>(
|
||||
'/tenants/batch/inventory-summary',
|
||||
{
|
||||
tenant_ids: tenantIds,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Food Safety
|
||||
// Backend: services/inventory/app/api/food_safety_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async acknowledgeAlert(
|
||||
tenantId: string,
|
||||
alertId: string,
|
||||
notes?: string
|
||||
): Promise<{ message: string }> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (notes) queryParams.append('notes', notes);
|
||||
|
||||
return apiClient.post<{ message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/food-safety/alerts/${alertId}/acknowledge?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async resolveAlert(
|
||||
tenantId: string,
|
||||
alertId: string,
|
||||
resolution: string
|
||||
): Promise<{ message: string }> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('resolution', resolution);
|
||||
|
||||
return apiClient.post<{ message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/food-safety/alerts/${alertId}/resolve?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getComplianceStatus(tenantId: string): Promise<FoodSafetyComplianceResponse> {
|
||||
return apiClient.get<FoodSafetyComplianceResponse>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/food-safety/compliance/status`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// COMPLIANCE: Food Safety Alerts
|
||||
// Backend: services/inventory/app/api/food_safety_alerts.py
|
||||
// ===================================================================
|
||||
|
||||
async listFoodSafetyAlerts(
|
||||
tenantId: string,
|
||||
status?: string,
|
||||
severity?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<FoodSafetyAlertResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (status) queryParams.append('status', status);
|
||||
if (severity) queryParams.append('severity', severity);
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('skip', offset.toString());
|
||||
|
||||
return apiClient.get<FoodSafetyAlertResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/food-safety/alerts?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ANALYTICS: Dashboard
|
||||
// Backend: services/inventory/app/api/dashboard.py
|
||||
// ===================================================================
|
||||
|
||||
async getDashboardSummary(tenantId: string): Promise<InventoryDashboardSummary> {
|
||||
return apiClient.get<InventoryDashboardSummary>(
|
||||
`${this.baseUrl}/${tenantId}/inventory/dashboard/summary`
|
||||
);
|
||||
}
|
||||
|
||||
async getInventoryAnalytics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<InventoryAnalytics> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/inventory/analytics?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/inventory/analytics`;
|
||||
|
||||
return apiClient.get<InventoryAnalytics>(url);
|
||||
}
|
||||
|
||||
// Legacy method - keeping for backward compatibility during transition
|
||||
async getStockAnalytics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<{
|
||||
total_ingredients: number;
|
||||
total_stock_value: number;
|
||||
low_stock_count: number;
|
||||
out_of_stock_count: number;
|
||||
expiring_soon_count: number;
|
||||
stock_turnover_rate: number;
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/inventory/dashboard/analytics?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/inventory/dashboard/analytics`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
}
|
||||
|
||||
export const inventoryService = new InventoryService();
|
||||
106
frontend/src/api/services/nominatim.ts
Normal file
106
frontend/src/api/services/nominatim.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Nominatim Geocoding API Service
|
||||
* Provides address search and autocomplete functionality
|
||||
*/
|
||||
|
||||
import apiClient from '../client';
|
||||
|
||||
export interface NominatimResult {
|
||||
place_id: number;
|
||||
lat: string;
|
||||
lon: string;
|
||||
display_name: string;
|
||||
address: {
|
||||
road?: string;
|
||||
house_number?: string;
|
||||
city?: string;
|
||||
town?: string;
|
||||
village?: string;
|
||||
municipality?: string;
|
||||
postcode?: string;
|
||||
country?: string;
|
||||
};
|
||||
boundingbox: [string, string, string, string];
|
||||
}
|
||||
|
||||
export interface NominatimSearchParams {
|
||||
q: string;
|
||||
format?: 'json';
|
||||
addressdetails?: 1 | 0;
|
||||
limit?: number;
|
||||
countrycodes?: string;
|
||||
}
|
||||
|
||||
class NominatimService {
|
||||
private baseUrl = '/api/v1/nominatim';
|
||||
|
||||
/**
|
||||
* Search for addresses matching a query
|
||||
*/
|
||||
async searchAddress(query: string, limit: number = 5): Promise<NominatimResult[]> {
|
||||
if (!query || query.length < 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await apiClient.get<NominatimResult[]>(`${this.baseUrl}/search`, {
|
||||
params: {
|
||||
q: query,
|
||||
format: 'json',
|
||||
addressdetails: 1,
|
||||
limit,
|
||||
countrycodes: 'es', // Spain only
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Address search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Nominatim result for display
|
||||
*/
|
||||
formatAddress(result: NominatimResult): string {
|
||||
return result.display_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract structured address components
|
||||
*/
|
||||
parseAddress(result: NominatimResult) {
|
||||
const { address } = result;
|
||||
|
||||
return {
|
||||
street: address.road
|
||||
? `${address.road}${address.house_number ? ' ' + address.house_number : ''}`
|
||||
: '',
|
||||
city: address.city || address.town || address.village || address.municipality || '',
|
||||
postalCode: address.postcode || '',
|
||||
latitude: parseFloat(result.lat),
|
||||
longitude: parseFloat(result.lon),
|
||||
displayName: result.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocode a structured address to coordinates
|
||||
*/
|
||||
async geocodeAddress(
|
||||
street: string,
|
||||
city: string,
|
||||
postalCode?: string
|
||||
): Promise<NominatimResult | null> {
|
||||
const parts = [street, city];
|
||||
if (postalCode) parts.push(postalCode);
|
||||
parts.push('Spain');
|
||||
|
||||
const query = parts.join(', ');
|
||||
const results = await this.searchAddress(query, 1);
|
||||
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
}
|
||||
|
||||
export const nominatimService = new NominatimService();
|
||||
export default nominatimService;
|
||||
244
frontend/src/api/services/onboarding.ts
Normal file
244
frontend/src/api/services/onboarding.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Onboarding Service - Mirror backend onboarding endpoints
|
||||
* Frontend and backend step names now match directly!
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import { UserProgress, UpdateStepRequest, SaveStepDraftRequest, StepDraftResponse } from '../types/onboarding';
|
||||
|
||||
// Backend onboarding steps (full list from backend - UPDATED to match refactored flow)
|
||||
// NOTE: poi-detection removed - now happens automatically in background during tenant registration
|
||||
export const BACKEND_ONBOARDING_STEPS = [
|
||||
'user_registered', // Phase 0: User account created (auto-completed)
|
||||
'bakery-type-selection', // Phase 1: Choose bakery type
|
||||
'setup', // Phase 2: Basic bakery setup and tenant creation
|
||||
'upload-sales-data', // Phase 2b: File upload, validation, AI classification
|
||||
'inventory-review', // Phase 2b: Review AI-detected products with type selection
|
||||
'initial-stock-entry', // Phase 2b: Capture initial stock levels
|
||||
'product-categorization', // Phase 2c: Advanced categorization (optional)
|
||||
'suppliers-setup', // Phase 2d: Suppliers configuration
|
||||
'recipes-setup', // Phase 3: Production recipes (optional)
|
||||
'quality-setup', // Phase 3: Quality standards (optional)
|
||||
'team-setup', // Phase 3: Team members (optional)
|
||||
'ml-training', // Phase 4: AI model training
|
||||
'setup-review', // Phase 4: Review all configuration
|
||||
'completion' // Phase 4: Onboarding completed
|
||||
];
|
||||
|
||||
// Frontend step order for navigation (excludes user_registered as it's auto-completed)
|
||||
// NOTE: poi-detection removed - now happens automatically in background during tenant registration
|
||||
export const FRONTEND_STEP_ORDER = [
|
||||
'bakery-type-selection', // Phase 1: Choose bakery type
|
||||
'setup', // Phase 2: Basic bakery setup and tenant creation
|
||||
'upload-sales-data', // Phase 2b: File upload and AI classification
|
||||
'inventory-review', // Phase 2b: Review AI-detected products
|
||||
'initial-stock-entry', // Phase 2b: Initial stock levels
|
||||
'product-categorization', // Phase 2c: Advanced categorization (optional)
|
||||
'suppliers-setup', // Phase 2d: Suppliers configuration
|
||||
'recipes-setup', // Phase 3: Production recipes (optional)
|
||||
'quality-setup', // Phase 3: Quality standards (optional)
|
||||
'team-setup', // Phase 3: Team members (optional)
|
||||
'ml-training', // Phase 4: AI model training
|
||||
'setup-review', // Phase 4: Review configuration
|
||||
'completion' // Phase 4: Onboarding completed
|
||||
];
|
||||
|
||||
export class OnboardingService {
|
||||
private readonly baseUrl = '/auth/me/onboarding';
|
||||
|
||||
async getUserProgress(userId: string): Promise<UserProgress> {
|
||||
// Backend uses current user from auth token, so userId parameter is ignored
|
||||
return apiClient.get<UserProgress>(`${this.baseUrl}/progress`);
|
||||
}
|
||||
|
||||
async updateStep(userId: string, stepData: UpdateStepRequest): Promise<UserProgress> {
|
||||
// Backend uses current user from auth token, so userId parameter is ignored
|
||||
return apiClient.put<UserProgress>(`${this.baseUrl}/step`, stepData);
|
||||
}
|
||||
|
||||
async markStepCompleted(
|
||||
userId: string,
|
||||
stepName: string,
|
||||
data?: Record<string, any>
|
||||
): Promise<UserProgress> {
|
||||
// Backend uses current user from auth token, so userId parameter is ignored
|
||||
// Backend expects UpdateStepRequest format for completion
|
||||
const requestBody = {
|
||||
step_name: stepName,
|
||||
completed: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
console.log(`🔄 API call to mark step "${stepName}" as completed:`, requestBody);
|
||||
|
||||
try {
|
||||
const response = await apiClient.put<UserProgress>(`${this.baseUrl}/step`, requestBody);
|
||||
console.log(`✅ Step "${stepName}" marked as completed successfully:`, response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`❌ API error marking step "${stepName}" as completed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resetProgress(userId: string): Promise<UserProgress> {
|
||||
// Note: Backend doesn't have a reset endpoint, this might need to be implemented
|
||||
// For now, we'll throw an error
|
||||
throw new Error('Reset progress functionality not implemented in backend');
|
||||
}
|
||||
|
||||
async getStepDetails(stepName: string): Promise<{
|
||||
name: string;
|
||||
description: string;
|
||||
dependencies: string[];
|
||||
estimated_time_minutes: number;
|
||||
}> {
|
||||
// This endpoint doesn't exist in backend, we'll need to implement it or mock it
|
||||
throw new Error('getStepDetails functionality not implemented in backend');
|
||||
}
|
||||
|
||||
async getAllSteps(): Promise<Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
dependencies: string[];
|
||||
estimated_time_minutes: number;
|
||||
}>> {
|
||||
// This endpoint doesn't exist in backend, we'll need to implement it or mock it
|
||||
throw new Error('getAllSteps functionality not implemented in backend');
|
||||
}
|
||||
|
||||
async getNextStep(): Promise<{ step: string; completed?: boolean }> {
|
||||
// This endpoint exists in backend
|
||||
return apiClient.get(`${this.baseUrl}/next-step`);
|
||||
}
|
||||
|
||||
async canAccessStep(stepName: string): Promise<{ can_access: boolean; reason?: string }> {
|
||||
// This endpoint exists in backend
|
||||
return apiClient.get(`${this.baseUrl}/can-access/${stepName}`);
|
||||
}
|
||||
|
||||
async completeOnboarding(): Promise<{ success: boolean; message: string }> {
|
||||
// This endpoint exists in backend
|
||||
return apiClient.post(`${this.baseUrl}/complete`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to mark a step as completed (now direct mapping)
|
||||
*/
|
||||
async markStepAsCompleted(
|
||||
stepId: string,
|
||||
data?: Record<string, any>
|
||||
): Promise<UserProgress> {
|
||||
try {
|
||||
return await this.markStepCompleted('', stepId, data);
|
||||
} catch (error) {
|
||||
console.error(`Error marking step ${stepId} as completed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get the next step based on backend progress
|
||||
*/
|
||||
async getNextStepId(): Promise<string> {
|
||||
try {
|
||||
const result = await this.getNextStep();
|
||||
return result.step || 'setup';
|
||||
} catch (error) {
|
||||
console.error('Error getting next step:', error);
|
||||
return 'setup';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to determine which step the user should resume from
|
||||
*/
|
||||
async getResumeStep(): Promise<{ stepId: string; stepIndex: number }> {
|
||||
try {
|
||||
const progress = await this.getUserProgress('');
|
||||
|
||||
// If fully completed, go to completion
|
||||
if (progress.fully_completed) {
|
||||
return { stepId: 'completion', stepIndex: FRONTEND_STEP_ORDER.indexOf('completion') };
|
||||
}
|
||||
|
||||
// Get the current step from backend
|
||||
const currentStep = progress.current_step;
|
||||
|
||||
// If current step is user_registered, start from setup
|
||||
const resumeStep = currentStep === 'user_registered' ? 'setup' : currentStep;
|
||||
|
||||
// Find the step index in our frontend order
|
||||
let stepIndex = FRONTEND_STEP_ORDER.indexOf(resumeStep);
|
||||
if (stepIndex === -1) {
|
||||
stepIndex = 0; // Default to first step
|
||||
}
|
||||
|
||||
return { stepId: FRONTEND_STEP_ORDER[stepIndex], stepIndex };
|
||||
} catch (error) {
|
||||
console.error('Error determining resume step:', error);
|
||||
return { stepId: 'setup', stepIndex: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save in-progress step data without marking the step as complete.
|
||||
* This allows users to save their work and resume later.
|
||||
*/
|
||||
async saveStepDraft(stepName: string, draftData: Record<string, any>): Promise<{ success: boolean }> {
|
||||
const requestBody: SaveStepDraftRequest = {
|
||||
step_name: stepName,
|
||||
draft_data: draftData,
|
||||
};
|
||||
|
||||
console.log(`💾 Saving draft for step "${stepName}":`, draftData);
|
||||
|
||||
try {
|
||||
const response = await apiClient.put<{ success: boolean }>(`${this.baseUrl}/step-draft`, requestBody);
|
||||
console.log(`✅ Draft saved for step "${stepName}"`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`❌ Error saving draft for step "${stepName}":`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saved draft data for a specific step.
|
||||
* Returns null if no draft exists or the step is already completed.
|
||||
*/
|
||||
async getStepDraft(stepName: string): Promise<StepDraftResponse> {
|
||||
console.log(`📖 Getting draft for step "${stepName}"`);
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<StepDraftResponse>(`${this.baseUrl}/step-draft/${stepName}`);
|
||||
if (response.draft_data) {
|
||||
console.log(`✅ Found draft for step "${stepName}":`, response.draft_data);
|
||||
} else {
|
||||
console.log(`ℹ️ No draft found for step "${stepName}"`);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`❌ Error getting draft for step "${stepName}":`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete saved draft data for a specific step.
|
||||
* Called after step is completed to clean up draft data.
|
||||
*/
|
||||
async deleteStepDraft(stepName: string): Promise<{ success: boolean }> {
|
||||
console.log(`🗑️ Deleting draft for step "${stepName}"`);
|
||||
|
||||
try {
|
||||
const response = await apiClient.delete<{ success: boolean }>(`${this.baseUrl}/step-draft/${stepName}`);
|
||||
console.log(`✅ Draft deleted for step "${stepName}"`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(`❌ Error deleting draft for step "${stepName}":`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const onboardingService = new OnboardingService();
|
||||
254
frontend/src/api/services/orchestrator.ts
Normal file
254
frontend/src/api/services/orchestrator.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Orchestrator Service API Client
|
||||
* Handles coordinated workflows across Forecasting, Production, and Procurement services
|
||||
*
|
||||
* NEW in Sprint 2: Orchestrator Service coordinates the daily workflow:
|
||||
* 1. Forecasting Service → Get demand forecasts
|
||||
* 2. Production Service → Generate production schedule from forecast
|
||||
* 3. Procurement Service → Generate procurement plan from forecast + schedule
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
OrchestratorWorkflowRequest,
|
||||
OrchestratorWorkflowResponse,
|
||||
WorkflowExecutionSummary,
|
||||
WorkflowExecutionDetail,
|
||||
OrchestratorStatus,
|
||||
OrchestratorConfig,
|
||||
WorkflowStepResult
|
||||
} from '../types/orchestrator';
|
||||
|
||||
// Re-export types for backward compatibility
|
||||
export type {
|
||||
OrchestratorWorkflowRequest,
|
||||
OrchestratorWorkflowResponse,
|
||||
WorkflowExecutionSummary,
|
||||
WorkflowExecutionDetail,
|
||||
OrchestratorStatus,
|
||||
OrchestratorConfig,
|
||||
WorkflowStepResult
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ORCHESTRATOR WORKFLOW API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Run the daily orchestrated workflow
|
||||
* This is the main entry point for coordinated planning
|
||||
*
|
||||
* Workflow:
|
||||
* 1. Forecasting Service: Get demand forecasts for target date
|
||||
* 2. Production Service: Generate production schedule from forecast
|
||||
* 3. Procurement Service: Generate procurement plan from forecast + schedule
|
||||
*
|
||||
* NEW in Sprint 2: Replaces autonomous schedulers with centralized orchestration
|
||||
*/
|
||||
export async function runDailyWorkflow(
|
||||
tenantId: string,
|
||||
request?: OrchestratorWorkflowRequest
|
||||
): Promise<OrchestratorWorkflowResponse> {
|
||||
return apiClient.post<OrchestratorWorkflowResponse>(
|
||||
`/tenants/${tenantId}/orchestrator/run-daily-workflow`,
|
||||
request || {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run workflow for a specific date
|
||||
*/
|
||||
export async function runWorkflowForDate(
|
||||
tenantId: string,
|
||||
targetDate: string,
|
||||
options?: Omit<OrchestratorWorkflowRequest, 'target_date'>
|
||||
): Promise<OrchestratorWorkflowResponse> {
|
||||
return runDailyWorkflow(tenantId, {
|
||||
...options,
|
||||
target_date: targetDate
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test workflow with sample data (for development/testing)
|
||||
*/
|
||||
export async function testWorkflow(
|
||||
tenantId: string
|
||||
): Promise<OrchestratorWorkflowResponse> {
|
||||
return apiClient.post<OrchestratorWorkflowResponse>(
|
||||
`/tenants/${tenantId}/orchestrator/test-workflow`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of workflow executions
|
||||
*/
|
||||
export async function listWorkflowExecutions(
|
||||
tenantId: string,
|
||||
params?: {
|
||||
status?: WorkflowExecutionSummary['status'];
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
): Promise<WorkflowExecutionSummary[]> {
|
||||
return apiClient.get<WorkflowExecutionSummary[]>(
|
||||
`/tenants/${tenantId}/orchestrator/executions`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single workflow execution by ID with full details
|
||||
*/
|
||||
export async function getWorkflowExecution(
|
||||
tenantId: string,
|
||||
executionId: string
|
||||
): Promise<WorkflowExecutionDetail> {
|
||||
return apiClient.get<WorkflowExecutionDetail>(
|
||||
`/tenants/${tenantId}/orchestrator/executions/${executionId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest workflow execution
|
||||
*/
|
||||
export async function getLatestWorkflowExecution(
|
||||
tenantId: string
|
||||
): Promise<WorkflowExecutionDetail | null> {
|
||||
const executions = await listWorkflowExecutions(tenantId, {
|
||||
limit: 1
|
||||
});
|
||||
|
||||
if (executions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getWorkflowExecution(tenantId, executions[0].id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running workflow execution
|
||||
*/
|
||||
export async function cancelWorkflowExecution(
|
||||
tenantId: string,
|
||||
executionId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.post<{ message: string }>(
|
||||
`/tenants/${tenantId}/orchestrator/executions/${executionId}/cancel`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a failed workflow execution
|
||||
*/
|
||||
export async function retryWorkflowExecution(
|
||||
tenantId: string,
|
||||
executionId: string
|
||||
): Promise<OrchestratorWorkflowResponse> {
|
||||
return apiClient.post<OrchestratorWorkflowResponse>(
|
||||
`/tenants/${tenantId}/orchestrator/executions/${executionId}/retry`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ORCHESTRATOR STATUS & HEALTH
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get orchestrator service status
|
||||
*/
|
||||
export async function getOrchestratorStatus(
|
||||
tenantId: string
|
||||
): Promise<OrchestratorStatus> {
|
||||
return apiClient.get<OrchestratorStatus>(
|
||||
`/tenants/${tenantId}/orchestrator/status`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timestamp of last orchestration run
|
||||
*/
|
||||
export async function getLastOrchestrationRun(
|
||||
tenantId: string
|
||||
): Promise<{ timestamp: string | null; runNumber: number | null }> {
|
||||
return apiClient.get<{ timestamp: string | null; runNumber: number | null }>(
|
||||
`/tenants/${tenantId}/orchestrator/last-run`
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ORCHESTRATOR CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get orchestrator configuration for tenant
|
||||
*/
|
||||
export async function getOrchestratorConfig(
|
||||
tenantId: string
|
||||
): Promise<OrchestratorConfig> {
|
||||
return apiClient.get<OrchestratorConfig>(
|
||||
`/tenants/${tenantId}/orchestrator/config`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update orchestrator configuration
|
||||
*/
|
||||
export async function updateOrchestratorConfig(
|
||||
tenantId: string,
|
||||
config: Partial<OrchestratorConfig>
|
||||
): Promise<OrchestratorConfig> {
|
||||
return apiClient.put<OrchestratorConfig>(
|
||||
`/tenants/${tenantId}/orchestrator/config`,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format workflow duration for display
|
||||
*/
|
||||
export function formatWorkflowDuration(durationMs: number): string {
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs}ms`;
|
||||
} else if (durationMs < 60000) {
|
||||
return `${(durationMs / 1000).toFixed(1)}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(durationMs / 60000);
|
||||
const seconds = Math.floor((durationMs % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow step status icon
|
||||
*/
|
||||
export function getWorkflowStepStatusIcon(status: WorkflowStepResult['status']): string {
|
||||
switch (status) {
|
||||
case 'success': return '✅';
|
||||
case 'failed': return '❌';
|
||||
case 'skipped': return '⏭️';
|
||||
default: return '❓';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow overall status color
|
||||
*/
|
||||
export function getWorkflowStatusColor(status: WorkflowExecutionSummary['status']): string {
|
||||
switch (status) {
|
||||
case 'completed': return 'green';
|
||||
case 'running': return 'blue';
|
||||
case 'failed': return 'red';
|
||||
case 'cancelled': return 'gray';
|
||||
default: return 'gray';
|
||||
}
|
||||
}
|
||||
204
frontend/src/api/services/orders.ts
Normal file
204
frontend/src/api/services/orders.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/orders.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Orders Service - Complete backend alignment
|
||||
*
|
||||
* Backend API structure (3-tier architecture):
|
||||
* - ATOMIC: orders.py, customers.py
|
||||
* - OPERATIONS: order_operations.py, procurement_operations.py
|
||||
*
|
||||
* Last Updated: 2025-10-05
|
||||
* Status: ✅ Complete - Zero drift with backend
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client/apiClient';
|
||||
import {
|
||||
OrderResponse,
|
||||
OrderCreate,
|
||||
OrderUpdate,
|
||||
CustomerResponse,
|
||||
CustomerCreate,
|
||||
CustomerUpdate,
|
||||
OrdersDashboardSummary,
|
||||
DemandRequirements,
|
||||
BusinessModelDetection,
|
||||
ServiceStatus,
|
||||
GetOrdersParams,
|
||||
GetCustomersParams,
|
||||
UpdateOrderStatusParams,
|
||||
GetDemandRequirementsParams,
|
||||
} from '../types/orders';
|
||||
|
||||
export class OrdersService {
|
||||
// ===================================================================
|
||||
// OPERATIONS: Dashboard & Analytics
|
||||
// Backend: services/orders/app/api/order_operations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get comprehensive dashboard summary for orders
|
||||
* GET /tenants/{tenant_id}/orders/operations/dashboard-summary
|
||||
*/
|
||||
static async getDashboardSummary(tenantId: string): Promise<OrdersDashboardSummary> {
|
||||
return apiClient.get<OrdersDashboardSummary>(`/tenants/${tenantId}/orders/operations/dashboard-summary`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get demand requirements for production planning
|
||||
* GET /tenants/{tenant_id}/orders/operations/demand-requirements
|
||||
*/
|
||||
static async getDemandRequirements(params: GetDemandRequirementsParams): Promise<DemandRequirements> {
|
||||
const { tenant_id, target_date } = params;
|
||||
return apiClient.get<DemandRequirements>(
|
||||
`/tenants/${tenant_id}/orders/operations/demand-requirements?target_date=${target_date}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Orders CRUD
|
||||
// Backend: services/orders/app/api/orders.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Create a new customer order
|
||||
* POST /tenants/{tenant_id}/orders
|
||||
*/
|
||||
static async createOrder(orderData: OrderCreate): Promise<OrderResponse> {
|
||||
const { tenant_id } = orderData;
|
||||
// Note: tenant_id is in both URL path and request body (backend schema requirement)
|
||||
return apiClient.post<OrderResponse>(`/tenants/${tenant_id}/orders`, orderData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order details with items
|
||||
* GET /tenants/{tenant_id}/orders/{order_id}
|
||||
*/
|
||||
static async getOrder(tenantId: string, orderId: string): Promise<OrderResponse> {
|
||||
return apiClient.get<OrderResponse>(`/tenants/${tenantId}/orders/${orderId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders with filtering and pagination
|
||||
* GET /tenants/{tenant_id}/orders
|
||||
*/
|
||||
static async getOrders(params: GetOrdersParams): Promise<OrderResponse[]> {
|
||||
const { tenant_id, status_filter, start_date, end_date, skip = 0, limit = 100 } = params;
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
skip: skip.toString(),
|
||||
limit: limit.toString(),
|
||||
});
|
||||
|
||||
if (status_filter) {
|
||||
queryParams.append('status_filter', status_filter);
|
||||
}
|
||||
if (start_date) {
|
||||
queryParams.append('start_date', start_date);
|
||||
}
|
||||
if (end_date) {
|
||||
queryParams.append('end_date', end_date);
|
||||
}
|
||||
|
||||
return apiClient.get<OrderResponse[]>(`/tenants/${tenant_id}/orders?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order details
|
||||
* PUT /tenants/{tenant_id}/orders/{order_id}
|
||||
*/
|
||||
static async updateOrder(tenantId: string, orderId: string, orderData: OrderUpdate): Promise<OrderResponse> {
|
||||
return apiClient.put<OrderResponse>(`/tenants/${tenantId}/orders/${orderId}`, orderData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order status
|
||||
* PUT /tenants/{tenant_id}/orders/{order_id}/status
|
||||
*/
|
||||
static async updateOrderStatus(params: UpdateOrderStatusParams): Promise<OrderResponse> {
|
||||
const { tenant_id, order_id, new_status, reason } = params;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (reason) {
|
||||
queryParams.append('reason', reason);
|
||||
}
|
||||
|
||||
const url = `/tenants/${tenant_id}/orders/${order_id}/status${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
|
||||
return apiClient.put<OrderResponse>(url, { status: new_status });
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Customers CRUD
|
||||
// Backend: services/orders/app/api/customers.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Create a new customer
|
||||
* POST /tenants/{tenant_id}/orders/customers
|
||||
*/
|
||||
static async createCustomer(customerData: CustomerCreate): Promise<CustomerResponse> {
|
||||
const { tenant_id, ...data } = customerData;
|
||||
return apiClient.post<CustomerResponse>(`/tenants/${tenant_id}/orders/customers`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customers with filtering and pagination
|
||||
* GET /tenants/{tenant_id}/customers
|
||||
*/
|
||||
static async getCustomers(params: GetCustomersParams): Promise<CustomerResponse[]> {
|
||||
const { tenant_id, active_only = true, skip = 0, limit = 100 } = params;
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
active_only: active_only.toString(),
|
||||
skip: skip.toString(),
|
||||
limit: limit.toString(),
|
||||
});
|
||||
|
||||
return apiClient.get<CustomerResponse[]>(`/tenants/${tenant_id}/orders/customers?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer details
|
||||
* GET /tenants/{tenant_id}/customers/{customer_id}
|
||||
*/
|
||||
static async getCustomer(tenantId: string, customerId: string): Promise<CustomerResponse> {
|
||||
return apiClient.get<CustomerResponse>(`/tenants/${tenantId}/orders/customers/${customerId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update customer details
|
||||
* PUT /tenants/{tenant_id}/customers/{customer_id}
|
||||
*/
|
||||
static async updateCustomer(tenantId: string, customerId: string, customerData: CustomerUpdate): Promise<CustomerResponse> {
|
||||
return apiClient.put<CustomerResponse>(`/tenants/${tenantId}/orders/customers/${customerId}`, customerData);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Business Intelligence
|
||||
// Backend: services/orders/app/api/order_operations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Detect business model based on order patterns
|
||||
* GET /tenants/{tenant_id}/orders/operations/business-model
|
||||
*/
|
||||
static async detectBusinessModel(tenantId: string): Promise<BusinessModelDetection> {
|
||||
return apiClient.get<BusinessModelDetection>(`/tenants/${tenantId}/orders/operations/business-model`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Health Check
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get orders service status
|
||||
* GET /tenants/{tenant_id}/orders/operations/status
|
||||
*/
|
||||
static async getServiceStatus(tenantId: string): Promise<ServiceStatus> {
|
||||
return apiClient.get<ServiceStatus>(`/tenants/${tenantId}/orders/operations/status`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default OrdersService;
|
||||
597
frontend/src/api/services/pos.ts
Normal file
597
frontend/src/api/services/pos.ts
Normal file
@@ -0,0 +1,597 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/pos.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* POS Service - Complete backend alignment
|
||||
*
|
||||
* Backend API structure (3-tier architecture):
|
||||
* - ATOMIC: configurations.py, transactions.py
|
||||
* - OPERATIONS: pos_operations.py
|
||||
* - ANALYTICS: analytics.py
|
||||
*
|
||||
* Last Updated: 2025-10-05
|
||||
* Status: ✅ Complete - Zero drift with backend
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
POSConfiguration,
|
||||
POSTransaction,
|
||||
POSWebhookLog,
|
||||
POSSyncLog,
|
||||
POSSystemInfo,
|
||||
GetPOSConfigurationsRequest,
|
||||
GetPOSConfigurationsResponse,
|
||||
CreatePOSConfigurationRequest,
|
||||
CreatePOSConfigurationResponse,
|
||||
GetPOSConfigurationRequest,
|
||||
GetPOSConfigurationResponse,
|
||||
UpdatePOSConfigurationRequest,
|
||||
UpdatePOSConfigurationResponse,
|
||||
DeletePOSConfigurationRequest,
|
||||
DeletePOSConfigurationResponse,
|
||||
TestPOSConnectionRequest,
|
||||
TestPOSConnectionResponse,
|
||||
GetSupportedPOSSystemsResponse,
|
||||
POSSystem,
|
||||
} from '../types/pos';
|
||||
|
||||
export class POSService {
|
||||
private readonly basePath = '/pos';
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: POS Configuration CRUD
|
||||
// Backend: services/pos/app/api/configurations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get POS configurations for a tenant
|
||||
*/
|
||||
async getPOSConfigurations(params: GetPOSConfigurationsRequest): Promise<GetPOSConfigurationsResponse> {
|
||||
const { tenant_id, pos_system, is_active } = params;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (pos_system) queryParams.append('pos_system', pos_system);
|
||||
if (is_active !== undefined) queryParams.append('is_active', is_active.toString());
|
||||
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/configurations${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
|
||||
return apiClient.get<GetPOSConfigurationsResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new POS configuration
|
||||
*/
|
||||
async createPOSConfiguration(params: CreatePOSConfigurationRequest): Promise<CreatePOSConfigurationResponse> {
|
||||
const { tenant_id, ...configData } = params;
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/configurations`;
|
||||
|
||||
return apiClient.post<CreatePOSConfigurationResponse>(url, configData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific POS configuration
|
||||
*/
|
||||
async getPOSConfiguration(params: GetPOSConfigurationRequest): Promise<GetPOSConfigurationResponse> {
|
||||
const { tenant_id, config_id } = params;
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}`;
|
||||
|
||||
return apiClient.get<GetPOSConfigurationResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a POS configuration
|
||||
*/
|
||||
async updatePOSConfiguration(params: UpdatePOSConfigurationRequest): Promise<UpdatePOSConfigurationResponse> {
|
||||
const { tenant_id, config_id, ...updateData } = params;
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}`;
|
||||
|
||||
return apiClient.put<UpdatePOSConfigurationResponse>(url, updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a POS configuration
|
||||
*/
|
||||
async deletePOSConfiguration(params: DeletePOSConfigurationRequest): Promise<DeletePOSConfigurationResponse> {
|
||||
const { tenant_id, config_id } = params;
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}`;
|
||||
|
||||
return apiClient.delete<DeletePOSConfigurationResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to POS system
|
||||
*/
|
||||
async testPOSConnection(params: TestPOSConnectionRequest): Promise<TestPOSConnectionResponse> {
|
||||
const { tenant_id, config_id } = params;
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/test-connection`;
|
||||
|
||||
return apiClient.post<TestPOSConnectionResponse>(url);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Supported Systems
|
||||
// Backend: services/pos/app/api/pos_operations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get list of supported POS systems
|
||||
*/
|
||||
async getSupportedPOSSystems(): Promise<GetSupportedPOSSystemsResponse> {
|
||||
const url = `${this.basePath}/supported-systems`;
|
||||
return apiClient.get<GetSupportedPOSSystemsResponse>(url);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Transactions
|
||||
// Backend: services/pos/app/api/transactions.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get POS transactions for a tenant (Updated with backend structure)
|
||||
*/
|
||||
async getPOSTransactions(params: {
|
||||
tenant_id: string;
|
||||
pos_system?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
status?: string;
|
||||
is_synced?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{
|
||||
transactions: POSTransaction[];
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
summary: {
|
||||
total_amount: number;
|
||||
transaction_count: number;
|
||||
sync_status: {
|
||||
synced: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
};
|
||||
};
|
||||
}> {
|
||||
const { tenant_id, ...queryParams } = params;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/transactions${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single transaction to sales service
|
||||
*/
|
||||
async syncSingleTransaction(params: {
|
||||
tenant_id: string;
|
||||
transaction_id: string;
|
||||
force?: boolean;
|
||||
}): Promise<{
|
||||
message: string;
|
||||
transaction_id: string;
|
||||
sync_status: string;
|
||||
sales_record_id: string;
|
||||
}> {
|
||||
const { tenant_id, transaction_id, force } = params;
|
||||
const queryParams = new URLSearchParams();
|
||||
if (force) queryParams.append('force', force.toString());
|
||||
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/transactions/${transaction_id}/sync${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
|
||||
return apiClient.post(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync performance analytics
|
||||
*/
|
||||
async getSyncAnalytics(params: {
|
||||
tenant_id: string;
|
||||
days?: number;
|
||||
}): Promise<{
|
||||
period_days: number;
|
||||
total_syncs: number;
|
||||
successful_syncs: number;
|
||||
failed_syncs: number;
|
||||
success_rate: number;
|
||||
average_duration_minutes: number;
|
||||
total_transactions_synced: number;
|
||||
total_revenue_synced: number;
|
||||
sync_frequency: {
|
||||
daily_average: number;
|
||||
peak_day?: string;
|
||||
peak_count: number;
|
||||
};
|
||||
error_analysis: {
|
||||
common_errors: any[];
|
||||
error_trends: any[];
|
||||
};
|
||||
}> {
|
||||
const { tenant_id, days } = params;
|
||||
const queryParams = new URLSearchParams();
|
||||
if (days) queryParams.append('days', days.toString());
|
||||
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/analytics/sync-performance${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resync failed transactions
|
||||
*/
|
||||
async resyncFailedTransactions(params: {
|
||||
tenant_id: string;
|
||||
days_back?: number;
|
||||
}): Promise<{
|
||||
message: string;
|
||||
job_id: string;
|
||||
scope: string;
|
||||
estimated_transactions: number;
|
||||
}> {
|
||||
const { tenant_id, days_back } = params;
|
||||
const queryParams = new URLSearchParams();
|
||||
if (days_back) queryParams.append('days_back', days_back.toString());
|
||||
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/data/resync${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
|
||||
return apiClient.post(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific POS transaction
|
||||
*/
|
||||
async getPOSTransaction(params: {
|
||||
tenant_id: string;
|
||||
transaction_id: string;
|
||||
}): Promise<POSTransaction> {
|
||||
const { tenant_id, transaction_id } = params;
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/transactions/${transaction_id}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get POS transactions dashboard summary
|
||||
*/
|
||||
async getPOSTransactionsDashboard(params: {
|
||||
tenant_id: string;
|
||||
}): Promise<{
|
||||
total_transactions_today: number;
|
||||
total_transactions_this_week: number;
|
||||
total_transactions_this_month: number;
|
||||
revenue_today: number;
|
||||
revenue_this_week: number;
|
||||
revenue_this_month: number;
|
||||
average_transaction_value: number;
|
||||
status_breakdown: Record<string, number>;
|
||||
payment_method_breakdown: Record<string, number>;
|
||||
sync_status: {
|
||||
synced: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
last_sync_at?: string;
|
||||
};
|
||||
}> {
|
||||
const { tenant_id } = params;
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/operations/transactions-dashboard`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Sync Operations
|
||||
// Backend: services/pos/app/api/pos_operations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Trigger manual sync for a POS configuration
|
||||
*/
|
||||
async triggerManualSync(params: {
|
||||
tenant_id: string;
|
||||
config_id: string;
|
||||
sync_type?: 'full' | 'incremental';
|
||||
data_types?: string[];
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
}): Promise<{
|
||||
sync_id: string;
|
||||
message: string;
|
||||
status: string;
|
||||
sync_type: string;
|
||||
data_types: string[];
|
||||
estimated_duration: string;
|
||||
}> {
|
||||
const { tenant_id, config_id, ...syncData } = params;
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/sync`;
|
||||
|
||||
return apiClient.post(url, syncData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status for a POS configuration
|
||||
*/
|
||||
async getSyncStatus(params: {
|
||||
tenant_id: string;
|
||||
config_id: string;
|
||||
limit?: number;
|
||||
}): Promise<{
|
||||
current_sync: any;
|
||||
last_successful_sync: any;
|
||||
recent_syncs: any[];
|
||||
sync_health: {
|
||||
status: string;
|
||||
success_rate: number;
|
||||
average_duration_minutes: number;
|
||||
last_error?: string;
|
||||
};
|
||||
}> {
|
||||
const { tenant_id, config_id, limit } = params;
|
||||
const queryParams = new URLSearchParams();
|
||||
if (limit) queryParams.append('limit', limit.toString());
|
||||
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/sync/status${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed sync logs for a configuration
|
||||
*/
|
||||
async getDetailedSyncLogs(params: {
|
||||
tenant_id: string;
|
||||
config_id: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: string;
|
||||
sync_type?: string;
|
||||
data_type?: string;
|
||||
}): Promise<{
|
||||
logs: any[];
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
}> {
|
||||
const { tenant_id, config_id, ...queryParams } = params;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/sync/logs${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync logs for a POS configuration
|
||||
*/
|
||||
async getSyncLogs(params: {
|
||||
tenant_id: string;
|
||||
config_id?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{
|
||||
sync_logs: POSSyncLog[];
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
}> {
|
||||
const { tenant_id, ...queryParams } = params;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/sync-logs${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Webhook Management
|
||||
// Backend: services/pos/app/api/pos_operations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get webhook logs
|
||||
*/
|
||||
async getWebhookLogs(params: {
|
||||
tenant_id: string;
|
||||
pos_system?: POSSystem;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{
|
||||
webhook_logs: POSWebhookLog[];
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
}> {
|
||||
const { tenant_id, ...queryParams } = params;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const url = `/tenants/${tenant_id}${this.basePath}/webhook-logs${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook endpoint status for a POS system
|
||||
*/
|
||||
async getWebhookStatus(pos_system: POSSystem): Promise<{
|
||||
pos_system: string;
|
||||
status: string;
|
||||
endpoint: string;
|
||||
supported_events: {
|
||||
events: string[];
|
||||
format: string;
|
||||
authentication: string;
|
||||
};
|
||||
last_received?: string;
|
||||
total_received: number;
|
||||
}> {
|
||||
const url = `/webhooks/${pos_system}/status`;
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process webhook (typically called by POS systems, but useful for testing)
|
||||
*/
|
||||
async processWebhook(params: {
|
||||
pos_system: POSSystem;
|
||||
payload: any;
|
||||
signature?: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Promise<{
|
||||
status: string;
|
||||
message?: string;
|
||||
success?: boolean;
|
||||
received?: boolean;
|
||||
}> {
|
||||
const { pos_system, payload, signature, headers = {} } = params;
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (signature) {
|
||||
requestHeaders['X-Webhook-Signature'] = signature;
|
||||
}
|
||||
|
||||
const url = `/webhooks/${pos_system}`;
|
||||
|
||||
// Note: This would typically be called by the POS system, not the frontend
|
||||
// This method is mainly for testing webhook processing
|
||||
return apiClient.post(url, payload);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Frontend Utility Methods
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Format price for display
|
||||
*/
|
||||
formatPrice(amount: number, currency: string = 'EUR'): string {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get POS system display name
|
||||
*/
|
||||
getPOSSystemDisplayName(posSystem: POSSystem): string {
|
||||
const systemNames: Record<POSSystem, string> = {
|
||||
square: 'Square POS',
|
||||
toast: 'Toast POS',
|
||||
lightspeed: 'Lightspeed POS',
|
||||
};
|
||||
|
||||
return systemNames[posSystem] || posSystem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status color for UI
|
||||
*/
|
||||
getConnectionStatusColor(isConnected: boolean, healthStatus?: string): 'green' | 'yellow' | 'red' {
|
||||
if (!isConnected) return 'red';
|
||||
if (healthStatus === 'healthy') return 'green';
|
||||
if (healthStatus === 'unhealthy') return 'red';
|
||||
return 'yellow'; // unknown status
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status color for UI
|
||||
*/
|
||||
getSyncStatusColor(status?: string): 'green' | 'yellow' | 'red' {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'green';
|
||||
case 'failed':
|
||||
return 'red';
|
||||
case 'partial':
|
||||
return 'yellow';
|
||||
default:
|
||||
return 'yellow';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format sync interval for display
|
||||
*/
|
||||
formatSyncInterval(minutes: string): string {
|
||||
const mins = parseInt(minutes);
|
||||
if (mins < 60) {
|
||||
return `${mins} minutos`;
|
||||
} else if (mins < 1440) {
|
||||
const hours = Math.floor(mins / 60);
|
||||
return hours === 1 ? '1 hora' : `${hours} horas`;
|
||||
} else {
|
||||
const days = Math.floor(mins / 1440);
|
||||
return days === 1 ? '1 día' : `${days} días`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate POS credentials based on system type
|
||||
*/
|
||||
validateCredentials(posSystem: POSSystem, credentials: Record<string, any>): {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
switch (posSystem) {
|
||||
case 'square':
|
||||
if (!credentials.application_id) errors.push('Application ID es requerido');
|
||||
if (!credentials.access_token) errors.push('Access Token es requerido');
|
||||
if (!credentials.location_id) errors.push('Location ID es requerido');
|
||||
break;
|
||||
|
||||
case 'toast':
|
||||
if (!credentials.api_key) errors.push('API Key es requerido');
|
||||
if (!credentials.restaurant_guid) errors.push('Restaurant GUID es requerido');
|
||||
if (!credentials.location_id) errors.push('Location ID es requerido');
|
||||
break;
|
||||
|
||||
case 'lightspeed':
|
||||
if (!credentials.api_key) errors.push('API Key es requerido');
|
||||
if (!credentials.api_secret) errors.push('API Secret es requerido');
|
||||
if (!credentials.account_id) errors.push('Account ID es requerido');
|
||||
if (!credentials.shop_id) errors.push('Shop ID es requerido');
|
||||
break;
|
||||
|
||||
default:
|
||||
errors.push('Sistema POS no soportado');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const posService = new POSService();
|
||||
export default posService;
|
||||
468
frontend/src/api/services/procurement-service.ts
Normal file
468
frontend/src/api/services/procurement-service.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/procurement-service.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Procurement Service - Fully aligned with backend Procurement Service API
|
||||
*
|
||||
* Backend API: services/procurement/app/api/
|
||||
* - procurement_plans.py: Plan CRUD and generation
|
||||
* - analytics.py: Analytics and dashboard
|
||||
* - purchase_orders.py: PO creation from plans
|
||||
*
|
||||
* Base URL: /api/v1/tenants/{tenant_id}/procurement/*
|
||||
*
|
||||
* Last Updated: 2025-10-31
|
||||
* Status: ✅ Complete - 100% backend alignment
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client/apiClient';
|
||||
import {
|
||||
// Procurement Plan types
|
||||
ProcurementPlanResponse,
|
||||
ProcurementPlanCreate,
|
||||
ProcurementPlanUpdate,
|
||||
PaginatedProcurementPlans,
|
||||
|
||||
// Procurement Requirement types
|
||||
ProcurementRequirementResponse,
|
||||
ProcurementRequirementUpdate,
|
||||
|
||||
// Dashboard & Analytics types
|
||||
ProcurementDashboardData,
|
||||
ProcurementTrendsData,
|
||||
|
||||
// Request/Response types
|
||||
GeneratePlanRequest,
|
||||
GeneratePlanResponse,
|
||||
AutoGenerateProcurementRequest,
|
||||
AutoGenerateProcurementResponse,
|
||||
CreatePOsResult,
|
||||
LinkRequirementToPORequest,
|
||||
UpdateDeliveryStatusRequest,
|
||||
ApprovalRequest,
|
||||
RejectionRequest,
|
||||
|
||||
// Query parameter types
|
||||
GetProcurementPlansParams,
|
||||
GetPlanRequirementsParams,
|
||||
UpdatePlanStatusParams,
|
||||
} from '../types/procurement';
|
||||
|
||||
/**
|
||||
* Procurement Service
|
||||
* All methods use the standalone Procurement Service backend API
|
||||
*/
|
||||
export class ProcurementService {
|
||||
|
||||
// ===================================================================
|
||||
// ANALYTICS & DASHBOARD
|
||||
// Backend: services/procurement/app/api/analytics.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get procurement analytics dashboard data
|
||||
* GET /api/v1/tenants/{tenant_id}/procurement/analytics/procurement
|
||||
*/
|
||||
static async getProcurementAnalytics(tenantId: string): Promise<ProcurementDashboardData> {
|
||||
return apiClient.get<ProcurementDashboardData>(`/tenants/${tenantId}/procurement/analytics/procurement`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get procurement time-series trends for charts
|
||||
* GET /api/v1/tenants/{tenant_id}/procurement/analytics/procurement/trends
|
||||
*/
|
||||
static async getProcurementTrends(tenantId: string, days: number = 7): Promise<ProcurementTrendsData> {
|
||||
return apiClient.get<ProcurementTrendsData>(`/tenants/${tenantId}/procurement/analytics/procurement/trends?days=${days}`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// PROCUREMENT PLAN GENERATION
|
||||
// Backend: services/procurement/app/api/procurement_plans.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Auto-generate procurement plan from forecast data (Orchestrator integration)
|
||||
* POST /api/v1/tenants/{tenant_id}/procurement/operations/auto-generate
|
||||
*
|
||||
* Called by Orchestrator Service to create procurement plans based on forecast data
|
||||
*/
|
||||
static async autoGenerateProcurement(
|
||||
tenantId: string,
|
||||
request: AutoGenerateProcurementRequest
|
||||
): Promise<AutoGenerateProcurementResponse> {
|
||||
return apiClient.post<AutoGenerateProcurementResponse>(
|
||||
`/tenants/${tenantId}/procurement/operations/auto-generate`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new procurement plan (manual/UI-driven)
|
||||
* POST /api/v1/tenants/{tenant_id}/procurement/plans
|
||||
*/
|
||||
static async generateProcurementPlan(
|
||||
tenantId: string,
|
||||
request: GeneratePlanRequest
|
||||
): Promise<GeneratePlanResponse> {
|
||||
return apiClient.post<GeneratePlanResponse>(
|
||||
`/tenants/${tenantId}/procurement/plans`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// PROCUREMENT PLAN CRUD
|
||||
// Backend: services/procurement/app/api/procurement_plans.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get the current day's procurement plan
|
||||
* GET /api/v1/tenants/{tenant_id}/procurement/plans/current
|
||||
*/
|
||||
static async getCurrentProcurementPlan(tenantId: string): Promise<ProcurementPlanResponse | null> {
|
||||
return apiClient.get<ProcurementPlanResponse | null>(
|
||||
`/tenants/${tenantId}/procurement/plans/current`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get procurement plan by ID
|
||||
* GET /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}
|
||||
*/
|
||||
static async getProcurementPlanById(
|
||||
tenantId: string,
|
||||
planId: string
|
||||
): Promise<ProcurementPlanResponse | null> {
|
||||
return apiClient.get<ProcurementPlanResponse | null>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get procurement plan for a specific date
|
||||
* GET /api/v1/tenants/{tenant_id}/procurement/plans/date/{plan_date}
|
||||
*/
|
||||
static async getProcurementPlanByDate(
|
||||
tenantId: string,
|
||||
planDate: string
|
||||
): Promise<ProcurementPlanResponse | null> {
|
||||
return apiClient.get<ProcurementPlanResponse | null>(
|
||||
`/tenants/${tenantId}/procurement/plans/date/${planDate}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all procurement plans for tenant with pagination and filtering
|
||||
* GET /api/v1/tenants/{tenant_id}/procurement/plans
|
||||
*/
|
||||
static async getProcurementPlans(params: GetProcurementPlansParams): Promise<PaginatedProcurementPlans> {
|
||||
const { tenant_id, status, start_date, end_date, limit = 50, offset = 0 } = params;
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
limit: limit.toString(),
|
||||
offset: offset.toString(),
|
||||
});
|
||||
|
||||
if (status) queryParams.append('status', status);
|
||||
if (start_date) queryParams.append('start_date', start_date);
|
||||
if (end_date) queryParams.append('end_date', end_date);
|
||||
|
||||
return apiClient.get<PaginatedProcurementPlans>(
|
||||
`/tenants/${tenant_id}/procurement/plans?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update procurement plan status
|
||||
* PATCH /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/status
|
||||
*/
|
||||
static async updateProcurementPlanStatus(params: UpdatePlanStatusParams): Promise<ProcurementPlanResponse> {
|
||||
const { tenant_id, plan_id, status, notes } = params;
|
||||
|
||||
const queryParams = new URLSearchParams({ status });
|
||||
if (notes) queryParams.append('notes', notes);
|
||||
|
||||
return apiClient.patch<ProcurementPlanResponse>(
|
||||
`/tenants/${tenant_id}/procurement/plans/${plan_id}/status?${queryParams.toString()}`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// PROCUREMENT REQUIREMENTS
|
||||
// Backend: services/procurement/app/api/procurement_plans.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get all requirements for a procurement plan
|
||||
* GET /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/requirements
|
||||
*/
|
||||
static async getPlanRequirements(params: GetPlanRequirementsParams): Promise<ProcurementRequirementResponse[]> {
|
||||
const { tenant_id, plan_id, status, priority } = params;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (status) queryParams.append('status', status);
|
||||
if (priority) queryParams.append('priority', priority);
|
||||
|
||||
const url = `/tenants/${tenant_id}/procurement/plans/${plan_id}/requirements${
|
||||
queryParams.toString() ? `?${queryParams.toString()}` : ''
|
||||
}`;
|
||||
|
||||
return apiClient.get<ProcurementRequirementResponse[]>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get critical requirements across all plans
|
||||
* GET /api/v1/tenants/{tenant_id}/procurement/requirements/critical
|
||||
*/
|
||||
static async getCriticalRequirements(tenantId: string): Promise<ProcurementRequirementResponse[]> {
|
||||
return apiClient.get<ProcurementRequirementResponse[]>(
|
||||
`/tenants/${tenantId}/procurement/requirements/critical`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a procurement requirement to a purchase order
|
||||
* POST /api/v1/tenants/{tenant_id}/procurement/requirements/{requirement_id}/link-purchase-order
|
||||
*/
|
||||
static async linkRequirementToPurchaseOrder(
|
||||
tenantId: string,
|
||||
requirementId: string,
|
||||
request: LinkRequirementToPORequest
|
||||
): Promise<{ success: boolean; message: string; requirement_id: string; purchase_order_id: string }> {
|
||||
return apiClient.post<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
requirement_id: string;
|
||||
purchase_order_id: string;
|
||||
}>(
|
||||
`/tenants/${tenantId}/procurement/requirements/${requirementId}/link-purchase-order`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update delivery status for a requirement
|
||||
* PUT /api/v1/tenants/{tenant_id}/procurement/requirements/{requirement_id}/delivery-status
|
||||
*/
|
||||
static async updateRequirementDeliveryStatus(
|
||||
tenantId: string,
|
||||
requirementId: string,
|
||||
request: UpdateDeliveryStatusRequest
|
||||
): Promise<{ success: boolean; message: string; requirement_id: string; delivery_status: string }> {
|
||||
return apiClient.put<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
requirement_id: string;
|
||||
delivery_status: string;
|
||||
}>(
|
||||
`/tenants/${tenantId}/procurement/requirements/${requirementId}/delivery-status`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ADVANCED PROCUREMENT OPERATIONS
|
||||
// Backend: services/procurement/app/api/procurement_plans.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Recalculate an existing procurement plan
|
||||
* POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/recalculate
|
||||
*/
|
||||
static async recalculateProcurementPlan(
|
||||
tenantId: string,
|
||||
planId: string
|
||||
): Promise<GeneratePlanResponse> {
|
||||
return apiClient.post<GeneratePlanResponse>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/recalculate`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a procurement plan with optional notes
|
||||
* POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/approve
|
||||
*/
|
||||
static async approveProcurementPlan(
|
||||
tenantId: string,
|
||||
planId: string,
|
||||
request?: ApprovalRequest
|
||||
): Promise<ProcurementPlanResponse> {
|
||||
return apiClient.post<ProcurementPlanResponse>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/approve`,
|
||||
request || {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a procurement plan with optional notes
|
||||
* POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/reject
|
||||
*/
|
||||
static async rejectProcurementPlan(
|
||||
tenantId: string,
|
||||
planId: string,
|
||||
request?: RejectionRequest
|
||||
): Promise<ProcurementPlanResponse> {
|
||||
return apiClient.post<ProcurementPlanResponse>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/reject`,
|
||||
request || {}
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// PURCHASE ORDERS
|
||||
// Backend: services/procurement/app/api/purchase_orders.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Create purchase orders from procurement plan requirements
|
||||
* Groups requirements by supplier and creates POs
|
||||
* POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/create-purchase-orders
|
||||
*/
|
||||
static async createPurchaseOrdersFromPlan(
|
||||
tenantId: string,
|
||||
planId: string,
|
||||
autoApprove: boolean = false
|
||||
): Promise<CreatePOsResult> {
|
||||
return apiClient.post<CreatePOsResult>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/create-purchase-orders`,
|
||||
{ auto_approve: autoApprove }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new purchase order
|
||||
* POST /api/v1/tenants/{tenant_id}/procurement/purchase-orders
|
||||
*/
|
||||
static async createPurchaseOrder(
|
||||
tenantId: string,
|
||||
poData: any
|
||||
): Promise<PurchaseOrderResponse> {
|
||||
return apiClient.post<PurchaseOrderResponse>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders`,
|
||||
poData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get purchase order by ID
|
||||
* GET /api/v1/tenants/{tenant_id}/procurement/purchase-orders/{po_id}
|
||||
*/
|
||||
static async getPurchaseOrderById(
|
||||
tenantId: string,
|
||||
poId: string
|
||||
): Promise<PurchaseOrderWithSupplierResponse> {
|
||||
return apiClient.get<PurchaseOrderWithSupplierResponse>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List purchase orders
|
||||
* GET /api/v1/tenants/{tenant_id}/procurement/purchase-orders
|
||||
*/
|
||||
static async getPurchaseOrders(
|
||||
tenantId: string,
|
||||
params?: { skip?: number; limit?: number; supplier_id?: string; status?: string }
|
||||
): Promise<PurchaseOrderResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.skip !== undefined) queryParams.append('skip', params.skip.toString());
|
||||
if (params?.limit !== undefined) queryParams.append('limit', params.limit.toString());
|
||||
if (params?.supplier_id) queryParams.append('supplier_id', params.supplier_id);
|
||||
if (params?.status) queryParams.append('status', params.status);
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const url = `/tenants/${tenantId}/procurement/purchase-orders${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiClient.get<PurchaseOrderResponse[]>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update purchase order
|
||||
* PATCH /api/v1/tenants/{tenant_id}/procurement/purchase-orders/{po_id}
|
||||
*/
|
||||
static async updatePurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
poData: any
|
||||
): Promise<PurchaseOrderResponse> {
|
||||
return apiClient.patch<PurchaseOrderResponse>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`,
|
||||
poData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update purchase order status
|
||||
* PATCH /api/v1/tenants/{tenant_id}/procurement/purchase-orders/{po_id}/status
|
||||
*/
|
||||
static async updatePurchaseOrderStatus(
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
status: string,
|
||||
notes?: string
|
||||
): Promise<PurchaseOrderResponse> {
|
||||
const queryParams = new URLSearchParams({ status });
|
||||
if (notes) queryParams.append('notes', notes);
|
||||
|
||||
return apiClient.patch<PurchaseOrderResponse>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/status?${queryParams.toString()}`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve or reject purchase order
|
||||
* POST /api/v1/tenants/{tenant_id}/procurement/purchase-orders/{po_id}/approve
|
||||
*/
|
||||
static async approvePurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
approveData: any
|
||||
): Promise<PurchaseOrderResponse> {
|
||||
return apiClient.post<PurchaseOrderResponse>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/approve`,
|
||||
approveData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel purchase order
|
||||
* POST /api/v1/tenants/{tenant_id}/procurement/purchase-orders/{po_id}/cancel
|
||||
*/
|
||||
static async cancelPurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
reason: string,
|
||||
cancelledBy?: string
|
||||
): Promise<PurchaseOrderResponse> {
|
||||
const queryParams = new URLSearchParams({ reason });
|
||||
if (cancelledBy) queryParams.append('cancelled_by', cancelledBy);
|
||||
|
||||
return apiClient.post<PurchaseOrderResponse>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/cancel?${queryParams.toString()}`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expected deliveries
|
||||
* GET /api/v1/tenants/{tenant_id}/procurement/expected-deliveries
|
||||
*/
|
||||
static async getExpectedDeliveries(
|
||||
tenantId: string,
|
||||
params?: { days_ahead?: number; include_overdue?: boolean }
|
||||
): Promise<{ deliveries: any[]; total_count: number }> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.days_ahead !== undefined) queryParams.append('days_ahead', params.days_ahead.toString());
|
||||
if (params?.include_overdue !== undefined) queryParams.append('include_overdue', params.include_overdue.toString());
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const url = `/tenants/${tenantId}/procurement/expected-deliveries${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiClient.get<{ deliveries: any[]; total_count: number }>(url);
|
||||
}
|
||||
}
|
||||
|
||||
export default ProcurementService;
|
||||
445
frontend/src/api/services/production.ts
Normal file
445
frontend/src/api/services/production.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/production.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Production Service - Complete backend alignment
|
||||
*
|
||||
* Backend API structure (3-tier architecture):
|
||||
* - ATOMIC: production_batches.py, production_schedules.py
|
||||
* - OPERATIONS: production_operations.py (batch lifecycle, capacity management)
|
||||
* - ANALYTICS: analytics.py, production_dashboard.py
|
||||
*
|
||||
* Last Updated: 2025-10-05
|
||||
* Status: ✅ Complete - Zero drift with backend
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client/apiClient';
|
||||
import {
|
||||
// Batches
|
||||
ProductionBatchResponse,
|
||||
ProductionBatchCreate,
|
||||
ProductionBatchUpdate,
|
||||
ProductionBatchStatusUpdate,
|
||||
ProductionBatchListResponse,
|
||||
ProductionBatchFilters,
|
||||
BatchStatistics,
|
||||
// Schedules
|
||||
ProductionScheduleResponse,
|
||||
ProductionScheduleCreate,
|
||||
ProductionScheduleUpdate,
|
||||
ProductionScheduleFilters,
|
||||
// Capacity
|
||||
ProductionCapacityResponse,
|
||||
ProductionCapacityFilters,
|
||||
// Quality
|
||||
QualityCheckResponse,
|
||||
QualityCheckCreate,
|
||||
QualityCheckFilters,
|
||||
// Analytics
|
||||
ProductionPerformanceAnalytics,
|
||||
YieldTrendsAnalytics,
|
||||
TopDefectsAnalytics,
|
||||
EquipmentEfficiencyAnalytics,
|
||||
CapacityBottlenecks,
|
||||
// Dashboard
|
||||
ProductionDashboardSummary,
|
||||
} from '../types/production';
|
||||
|
||||
export class ProductionService {
|
||||
private baseUrl = '/tenants';
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Production Batches CRUD
|
||||
// Backend: services/production/app/api/production_batches.py
|
||||
// ===================================================================
|
||||
|
||||
async getBatches(
|
||||
tenantId: string,
|
||||
filters?: ProductionBatchFilters
|
||||
): Promise<ProductionBatchListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.product_id) params.append('product_id', filters.product_id);
|
||||
if (filters?.order_id) params.append('order_id', filters.order_id);
|
||||
if (filters?.start_date) params.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) params.append('end_date', filters.end_date);
|
||||
if (filters?.page) params.append('page', filters.page.toString());
|
||||
if (filters?.page_size) params.append('page_size', filters.page_size.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${this.baseUrl}/${tenantId}/production/batches${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiClient.get<ProductionBatchListResponse>(url);
|
||||
}
|
||||
|
||||
async getBatch(tenantId: string, batchId: string): Promise<ProductionBatchResponse> {
|
||||
return apiClient.get<ProductionBatchResponse>(
|
||||
`${this.baseUrl}/${tenantId}/production/batches/${batchId}`
|
||||
);
|
||||
}
|
||||
|
||||
async createBatch(
|
||||
tenantId: string,
|
||||
batchData: ProductionBatchCreate
|
||||
): Promise<ProductionBatchResponse> {
|
||||
return apiClient.post<ProductionBatchResponse>(
|
||||
`${this.baseUrl}/${tenantId}/production/batches`,
|
||||
batchData
|
||||
);
|
||||
}
|
||||
|
||||
async updateBatch(
|
||||
tenantId: string,
|
||||
batchId: string,
|
||||
batchData: ProductionBatchUpdate
|
||||
): Promise<ProductionBatchResponse> {
|
||||
return apiClient.put<ProductionBatchResponse>(
|
||||
`${this.baseUrl}/${tenantId}/production/batches/${batchId}`,
|
||||
batchData
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBatch(tenantId: string, batchId: string): Promise<void> {
|
||||
return apiClient.delete<void>(`${this.baseUrl}/${tenantId}/production/batches/${batchId}`);
|
||||
}
|
||||
|
||||
async getBatchStatistics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<BatchStatistics> {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${this.baseUrl}/${tenantId}/production/batches/stats${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiClient.get<BatchStatistics>(url);
|
||||
}
|
||||
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Production Schedules CRUD
|
||||
// Backend: services/production/app/api/production_schedules.py
|
||||
// ===================================================================
|
||||
|
||||
async getSchedules(
|
||||
tenantId: string,
|
||||
filters?: ProductionScheduleFilters
|
||||
): Promise<{ schedules: ProductionScheduleResponse[]; total_count: number; page: number; page_size: number }> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.start_date) params.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) params.append('end_date', filters.end_date);
|
||||
if (filters?.is_finalized !== undefined)
|
||||
params.append('is_finalized', filters.is_finalized.toString());
|
||||
if (filters?.page) params.append('page', filters.page.toString());
|
||||
if (filters?.page_size) params.append('page_size', filters.page_size.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${this.baseUrl}/${tenantId}/production/schedules${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getSchedule(tenantId: string, scheduleId: string): Promise<ProductionScheduleResponse> {
|
||||
return apiClient.get<ProductionScheduleResponse>(
|
||||
`${this.baseUrl}/${tenantId}/production/schedules/${scheduleId}`
|
||||
);
|
||||
}
|
||||
|
||||
async createSchedule(
|
||||
tenantId: string,
|
||||
scheduleData: ProductionScheduleCreate
|
||||
): Promise<ProductionScheduleResponse> {
|
||||
return apiClient.post<ProductionScheduleResponse>(
|
||||
`${this.baseUrl}/${tenantId}/production/schedules`,
|
||||
scheduleData
|
||||
);
|
||||
}
|
||||
|
||||
async updateSchedule(
|
||||
tenantId: string,
|
||||
scheduleId: string,
|
||||
scheduleData: ProductionScheduleUpdate
|
||||
): Promise<ProductionScheduleResponse> {
|
||||
return apiClient.put<ProductionScheduleResponse>(
|
||||
`${this.baseUrl}/${tenantId}/production/schedules/${scheduleId}`,
|
||||
scheduleData
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSchedule(tenantId: string, scheduleId: string): Promise<void> {
|
||||
return apiClient.delete<void>(`${this.baseUrl}/${tenantId}/production/schedules/${scheduleId}`);
|
||||
}
|
||||
|
||||
async getTodaysSchedule(tenantId: string): Promise<ProductionScheduleResponse | null> {
|
||||
return apiClient.get<ProductionScheduleResponse | null>(
|
||||
`${this.baseUrl}/${tenantId}/production/schedules/today`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Batch Lifecycle Management
|
||||
// Backend: services/production/app/api/production_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async updateBatchStatus(
|
||||
tenantId: string,
|
||||
batchId: string,
|
||||
statusData: ProductionBatchStatusUpdate
|
||||
): Promise<ProductionBatchResponse> {
|
||||
return apiClient.patch<ProductionBatchResponse>(
|
||||
`${this.baseUrl}/${tenantId}/production/batches/${batchId}/status`,
|
||||
statusData
|
||||
);
|
||||
}
|
||||
|
||||
async startBatch(tenantId: string, batchId: string): Promise<ProductionBatchResponse> {
|
||||
return apiClient.post<ProductionBatchResponse>(
|
||||
`${this.baseUrl}/${tenantId}/production/batches/${batchId}/start`
|
||||
);
|
||||
}
|
||||
|
||||
async completeBatch(
|
||||
tenantId: string,
|
||||
batchId: string,
|
||||
completionData?: { actual_quantity?: number; notes?: string }
|
||||
): Promise<ProductionBatchResponse> {
|
||||
return apiClient.post<ProductionBatchResponse>(
|
||||
`${this.baseUrl}/${tenantId}/production/batches/${batchId}/complete`,
|
||||
completionData || {}
|
||||
);
|
||||
}
|
||||
|
||||
async finalizeSchedule(tenantId: string, scheduleId: string): Promise<ProductionScheduleResponse> {
|
||||
return apiClient.post<ProductionScheduleResponse>(
|
||||
`${this.baseUrl}/${tenantId}/production/schedules/${scheduleId}/finalize`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Capacity Management
|
||||
// Backend: services/production/app/api/production_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async getCapacity(
|
||||
tenantId: string,
|
||||
filters?: ProductionCapacityFilters
|
||||
): Promise<{ capacity: ProductionCapacityResponse[]; total_count: number; page: number; page_size: number }> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.resource_type) params.append('resource_type', filters.resource_type);
|
||||
if (filters?.date) params.append('date', filters.date);
|
||||
if (filters?.availability !== undefined)
|
||||
params.append('availability', filters.availability.toString());
|
||||
if (filters?.page) params.append('page', filters.page.toString());
|
||||
if (filters?.page_size) params.append('page_size', filters.page_size.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${this.baseUrl}/${tenantId}/production/capacity${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getCapacityByDate(tenantId: string, date: string): Promise<ProductionCapacityResponse[]> {
|
||||
return apiClient.get<ProductionCapacityResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/production/capacity/date/${date}`
|
||||
);
|
||||
}
|
||||
|
||||
async getCapacityByResource(
|
||||
tenantId: string,
|
||||
resourceId: string
|
||||
): Promise<ProductionCapacityResponse[]> {
|
||||
return apiClient.get<ProductionCapacityResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/production/capacity/resource/${resourceId}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Quality Checks
|
||||
// Backend: services/production/app/api/production_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async getQualityChecks(
|
||||
tenantId: string,
|
||||
filters?: QualityCheckFilters
|
||||
): Promise<{ quality_checks: QualityCheckResponse[]; total_count: number; page: number; page_size: number }> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.batch_id) params.append('batch_id', filters.batch_id);
|
||||
if (filters?.product_id) params.append('product_id', filters.product_id);
|
||||
if (filters?.start_date) params.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) params.append('end_date', filters.end_date);
|
||||
if (filters?.pass_fail !== undefined) params.append('pass_fail', filters.pass_fail.toString());
|
||||
if (filters?.page) params.append('page', filters.page.toString());
|
||||
if (filters?.page_size) params.append('page_size', filters.page_size.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${this.baseUrl}/${tenantId}/production/quality-checks${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getQualityCheck(tenantId: string, checkId: string): Promise<QualityCheckResponse> {
|
||||
return apiClient.get<QualityCheckResponse>(
|
||||
`${this.baseUrl}/${tenantId}/production/quality-checks/${checkId}`
|
||||
);
|
||||
}
|
||||
|
||||
async createQualityCheck(
|
||||
tenantId: string,
|
||||
checkData: QualityCheckCreate
|
||||
): Promise<QualityCheckResponse> {
|
||||
return apiClient.post<QualityCheckResponse>(
|
||||
`${this.baseUrl}/${tenantId}/production/quality-checks`,
|
||||
checkData
|
||||
);
|
||||
}
|
||||
|
||||
async getQualityChecksByBatch(
|
||||
tenantId: string,
|
||||
batchId: string
|
||||
): Promise<QualityCheckResponse[]> {
|
||||
return apiClient.get<QualityCheckResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/production/quality-checks/batch/${batchId}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ANALYTICS: Performance & Trends
|
||||
// Backend: services/production/app/api/analytics.py
|
||||
// ===================================================================
|
||||
|
||||
async getPerformanceAnalytics(
|
||||
tenantId: string,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<ProductionPerformanceAnalytics> {
|
||||
return apiClient.get<ProductionPerformanceAnalytics>(
|
||||
`${this.baseUrl}/${tenantId}/production/analytics/performance?start_date=${startDate}&end_date=${endDate}`
|
||||
);
|
||||
}
|
||||
|
||||
async getYieldTrends(
|
||||
tenantId: string,
|
||||
period: 'week' | 'month' = 'week'
|
||||
): Promise<YieldTrendsAnalytics> {
|
||||
return apiClient.get<YieldTrendsAnalytics>(
|
||||
`${this.baseUrl}/${tenantId}/production/analytics/yield-trends?period=${period}`
|
||||
);
|
||||
}
|
||||
|
||||
async getTopDefects(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<TopDefectsAnalytics> {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${this.baseUrl}/${tenantId}/production/analytics/defects${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiClient.get<TopDefectsAnalytics>(url);
|
||||
}
|
||||
|
||||
async getEquipmentEfficiency(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<EquipmentEfficiencyAnalytics> {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${this.baseUrl}/${tenantId}/production/analytics/equipment-efficiency${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiClient.get<EquipmentEfficiencyAnalytics>(url);
|
||||
}
|
||||
|
||||
async getCapacityBottlenecks(tenantId: string, days: number = 7): Promise<CapacityBottlenecks> {
|
||||
return apiClient.get<CapacityBottlenecks>(
|
||||
`${this.baseUrl}/${tenantId}/production/analytics/capacity-bottlenecks?days=${days}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ANALYTICS: Dashboard
|
||||
// Backend: services/production/app/api/production_dashboard.py
|
||||
// ===================================================================
|
||||
|
||||
async getDashboardSummary(tenantId: string): Promise<ProductionDashboardSummary> {
|
||||
return apiClient.get<ProductionDashboardSummary>(
|
||||
`${this.baseUrl}/${tenantId}/production/dashboard/summary`
|
||||
);
|
||||
}
|
||||
|
||||
async getDailyProductionPlan(tenantId: string, date?: string): Promise<any> {
|
||||
const queryString = date ? `?date=${date}` : '';
|
||||
return apiClient.get(`${this.baseUrl}/${tenantId}/production/dashboard/daily-plan${queryString}`);
|
||||
}
|
||||
|
||||
async getProductionRequirements(tenantId: string, date: string): Promise<any> {
|
||||
return apiClient.get(`${this.baseUrl}/${tenantId}/production/dashboard/requirements?date=${date}`);
|
||||
}
|
||||
|
||||
async getCapacityOverview(tenantId: string, date?: string): Promise<any> {
|
||||
const queryString = date ? `?date=${date}` : '';
|
||||
return apiClient.get(
|
||||
`${this.baseUrl}/${tenantId}/production/dashboard/capacity-overview${queryString}`
|
||||
);
|
||||
}
|
||||
|
||||
async getQualityOverview(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${this.baseUrl}/${tenantId}/production/dashboard/quality-overview${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ANALYTICS: Batch Production Summary (Enterprise Feature)
|
||||
// Backend: services/production/app/api/analytics.py
|
||||
// ===================================================================
|
||||
|
||||
async getBatchProductionSummary(tenantIds: string[]): Promise<Record<string, any>> {
|
||||
return apiClient.post<Record<string, any>>(
|
||||
'/tenants/batch/production-summary',
|
||||
{
|
||||
tenant_ids: tenantIds,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Scheduler
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Trigger production scheduler manually (for testing/development)
|
||||
* POST /tenants/{tenant_id}/production/operations/scheduler/trigger
|
||||
*/
|
||||
static async triggerProductionScheduler(tenantId: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
tenant_id: string
|
||||
}> {
|
||||
return apiClient.post(
|
||||
`/tenants/${tenantId}/production/operations/scheduler/trigger`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const productionService = new ProductionService();
|
||||
export default productionService;
|
||||
345
frontend/src/api/services/purchase_orders.ts
Normal file
345
frontend/src/api/services/purchase_orders.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Purchase Orders API Client
|
||||
* Handles all API calls for purchase orders
|
||||
*
|
||||
* UPDATED in Sprint 3: Purchase orders now managed by Procurement Service
|
||||
* Previously: Suppliers Service (/tenants/{id}/purchase-orders)
|
||||
* Now: Procurement Service (/tenants/{id}/procurement/purchase-orders)
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
export type PurchaseOrderStatus =
|
||||
| 'DRAFT'
|
||||
| 'PENDING_APPROVAL'
|
||||
| 'APPROVED'
|
||||
| 'SENT_TO_SUPPLIER'
|
||||
| 'CONFIRMED'
|
||||
| 'RECEIVED'
|
||||
| 'COMPLETED'
|
||||
| 'CANCELLED'
|
||||
| 'DISPUTED';
|
||||
|
||||
export type PurchaseOrderPriority = 'urgent' | 'high' | 'normal' | 'low';
|
||||
|
||||
export interface PurchaseOrderItem {
|
||||
id: string;
|
||||
inventory_product_id: string;
|
||||
product_code?: string;
|
||||
product_name?: string;
|
||||
ordered_quantity: number;
|
||||
unit_of_measure: string;
|
||||
unit_price: string; // Decimal as string
|
||||
line_total: string; // Decimal as string
|
||||
received_quantity: number;
|
||||
remaining_quantity: number;
|
||||
quality_requirements?: string;
|
||||
item_notes?: string;
|
||||
}
|
||||
|
||||
export interface SupplierSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
supplier_code: string;
|
||||
supplier_type: string;
|
||||
status: string;
|
||||
contact_person?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderSummary {
|
||||
id: string;
|
||||
po_number: string;
|
||||
supplier_id: string;
|
||||
supplier_name?: string;
|
||||
status: PurchaseOrderStatus;
|
||||
priority: PurchaseOrderPriority;
|
||||
order_date: string;
|
||||
required_delivery_date?: string;
|
||||
total_amount: string; // Decimal as string
|
||||
currency: string;
|
||||
created_at: string;
|
||||
reasoning_data?: any; // AI reasoning data for dashboard display
|
||||
ai_reasoning_summary?: string; // Human-readable summary
|
||||
}
|
||||
|
||||
export interface PurchaseOrderDetail extends PurchaseOrderSummary {
|
||||
reference_number?: string;
|
||||
estimated_delivery_date?: string;
|
||||
|
||||
// Financial information
|
||||
subtotal: string;
|
||||
tax_amount: string;
|
||||
shipping_cost: string;
|
||||
discount_amount: string;
|
||||
|
||||
// Delivery information
|
||||
delivery_address?: string;
|
||||
delivery_instructions?: string;
|
||||
delivery_contact?: string;
|
||||
delivery_phone?: string;
|
||||
|
||||
// Approval workflow
|
||||
requires_approval: boolean;
|
||||
approved_by?: string;
|
||||
approved_at?: string;
|
||||
rejection_reason?: string;
|
||||
|
||||
// Communication tracking
|
||||
sent_to_supplier_at?: string;
|
||||
supplier_confirmation_date?: string;
|
||||
supplier_reference?: string;
|
||||
|
||||
// Additional information
|
||||
notes?: string;
|
||||
internal_notes?: string;
|
||||
terms_and_conditions?: string;
|
||||
|
||||
// Audit fields
|
||||
updated_at: string;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
|
||||
// Related data
|
||||
supplier?: SupplierSummary;
|
||||
items?: PurchaseOrderItem[];
|
||||
}
|
||||
|
||||
export interface PurchaseOrderSearchParams {
|
||||
supplier_id?: string;
|
||||
status?: PurchaseOrderStatus;
|
||||
priority?: PurchaseOrderPriority;
|
||||
date_from?: string; // YYYY-MM-DD
|
||||
date_to?: string; // YYYY-MM-DD
|
||||
search_term?: string;
|
||||
limit?: number;
|
||||
skip?: number; // ✅ Changed from "offset" to "skip" to match backend
|
||||
}
|
||||
|
||||
export interface PurchaseOrderUpdateData {
|
||||
status?: PurchaseOrderStatus;
|
||||
priority?: PurchaseOrderPriority;
|
||||
notes?: string;
|
||||
rejection_reason?: string;
|
||||
internal_notes?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderItemCreate {
|
||||
inventory_product_id: string;
|
||||
ordered_quantity: number;
|
||||
unit_price: string; // Decimal as string
|
||||
unit_of_measure: string;
|
||||
quality_requirements?: string;
|
||||
item_notes?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderCreateData {
|
||||
supplier_id: string;
|
||||
required_delivery_date?: string;
|
||||
priority?: PurchaseOrderPriority;
|
||||
tax_amount?: number;
|
||||
shipping_cost?: number;
|
||||
discount_amount?: number;
|
||||
notes?: string;
|
||||
procurement_plan_id?: string;
|
||||
items: PurchaseOrderItemCreate[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new purchase order
|
||||
*/
|
||||
export async function createPurchaseOrder(
|
||||
tenantId: string,
|
||||
data: PurchaseOrderCreateData
|
||||
): Promise<PurchaseOrderDetail> {
|
||||
return apiClient.post<PurchaseOrderDetail>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of purchase orders with optional filters
|
||||
*/
|
||||
export async function listPurchaseOrders(
|
||||
tenantId: string,
|
||||
params?: PurchaseOrderSearchParams
|
||||
): Promise<PurchaseOrderSummary[]> {
|
||||
return apiClient.get<PurchaseOrderSummary[]>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get purchase orders by status
|
||||
*/
|
||||
export async function getPurchaseOrdersByStatus(
|
||||
tenantId: string,
|
||||
status: PurchaseOrderStatus,
|
||||
limit: number = 50
|
||||
): Promise<PurchaseOrderSummary[]> {
|
||||
return listPurchaseOrders(tenantId, { status, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending approval purchase orders
|
||||
*/
|
||||
export async function getPendingApprovalPurchaseOrders(
|
||||
tenantId: string,
|
||||
limit: number = 50
|
||||
): Promise<PurchaseOrderSummary[]> {
|
||||
return getPurchaseOrdersByStatus(tenantId, 'PENDING_APPROVAL', limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single purchase order by ID with full details
|
||||
*/
|
||||
export async function getPurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string
|
||||
): Promise<PurchaseOrderDetail> {
|
||||
return apiClient.get<PurchaseOrderDetail>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update purchase order
|
||||
*/
|
||||
export async function updatePurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
data: PurchaseOrderUpdateData
|
||||
): Promise<PurchaseOrderDetail> {
|
||||
return apiClient.put<PurchaseOrderDetail>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a purchase order
|
||||
*/
|
||||
export async function approvePurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
notes?: string
|
||||
): Promise<PurchaseOrderDetail> {
|
||||
return apiClient.post<PurchaseOrderDetail>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/approve`,
|
||||
{
|
||||
action: 'approve',
|
||||
notes: notes || 'Approved from dashboard'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a purchase order
|
||||
*/
|
||||
export async function rejectPurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
reason: string
|
||||
): Promise<PurchaseOrderDetail> {
|
||||
return apiClient.post<PurchaseOrderDetail>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/approve`,
|
||||
{
|
||||
action: 'reject',
|
||||
notes: reason
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk approve purchase orders
|
||||
*/
|
||||
export async function bulkApprovePurchaseOrders(
|
||||
tenantId: string,
|
||||
poIds: string[],
|
||||
notes?: string
|
||||
): Promise<PurchaseOrderDetail[]> {
|
||||
const approvalPromises = poIds.map(poId =>
|
||||
approvePurchaseOrder(tenantId, poId, notes)
|
||||
);
|
||||
return Promise.all(approvalPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete purchase order
|
||||
*/
|
||||
export async function deletePurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// DELIVERY TYPES AND METHODS
|
||||
// ================================================================
|
||||
|
||||
export interface DeliveryItemInput {
|
||||
purchase_order_item_id: string;
|
||||
inventory_product_id: string;
|
||||
ordered_quantity: number;
|
||||
delivered_quantity: number;
|
||||
accepted_quantity: number;
|
||||
rejected_quantity: number;
|
||||
batch_lot_number?: string;
|
||||
expiry_date?: string;
|
||||
quality_grade?: string;
|
||||
quality_issues?: string;
|
||||
rejection_reason?: string;
|
||||
item_notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateDeliveryInput {
|
||||
purchase_order_id: string;
|
||||
supplier_id: string;
|
||||
supplier_delivery_note?: string;
|
||||
scheduled_date?: string;
|
||||
estimated_arrival?: string;
|
||||
carrier_name?: string;
|
||||
tracking_number?: string;
|
||||
inspection_passed?: boolean;
|
||||
inspection_notes?: string;
|
||||
notes?: string;
|
||||
items: DeliveryItemInput[];
|
||||
}
|
||||
|
||||
export interface DeliveryResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
purchase_order_id: string;
|
||||
supplier_id: string;
|
||||
delivery_number: string;
|
||||
status: string;
|
||||
scheduled_date?: string;
|
||||
estimated_arrival?: string;
|
||||
actual_arrival?: string;
|
||||
completed_at?: string;
|
||||
inspection_passed?: boolean;
|
||||
inspection_notes?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create delivery for purchase order
|
||||
*/
|
||||
export async function createDelivery(
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
deliveryData: CreateDeliveryInput
|
||||
): Promise<DeliveryResponse> {
|
||||
return apiClient.post<DeliveryResponse>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/deliveries`,
|
||||
deliveryData
|
||||
);
|
||||
}
|
||||
205
frontend/src/api/services/qualityTemplates.ts
Normal file
205
frontend/src/api/services/qualityTemplates.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// frontend/src/api/services/qualityTemplates.ts
|
||||
/**
|
||||
* Quality Check Template API service
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
QualityCheckTemplate,
|
||||
QualityCheckTemplateCreate,
|
||||
QualityCheckTemplateUpdate,
|
||||
QualityCheckTemplateList,
|
||||
QualityTemplateQueryParams,
|
||||
ProcessStage,
|
||||
QualityCheckExecutionRequest,
|
||||
QualityCheckExecutionResponse
|
||||
} from '../types/qualityTemplates';
|
||||
|
||||
class QualityTemplateService {
|
||||
private readonly baseURL = '/tenants';
|
||||
|
||||
/**
|
||||
* Create a new quality check template
|
||||
*/
|
||||
async createTemplate(
|
||||
tenantId: string,
|
||||
templateData: QualityCheckTemplateCreate
|
||||
): Promise<QualityCheckTemplate> {
|
||||
const data = await apiClient.post(`${this.baseURL}/${tenantId}/production/quality-templates`, templateData, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quality check templates with filtering and pagination
|
||||
*/
|
||||
async getTemplates(
|
||||
tenantId: string,
|
||||
params?: QualityTemplateQueryParams
|
||||
): Promise<QualityCheckTemplateList> {
|
||||
const data = await apiClient.get(`${this.baseURL}/${tenantId}/production/quality-templates`, {
|
||||
params,
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific quality check template
|
||||
*/
|
||||
async getTemplate(
|
||||
tenantId: string,
|
||||
templateId: string
|
||||
): Promise<QualityCheckTemplate> {
|
||||
const data = await apiClient.get(`${this.baseURL}/${tenantId}/production/quality-templates/${templateId}`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a quality check template
|
||||
*/
|
||||
async updateTemplate(
|
||||
tenantId: string,
|
||||
templateId: string,
|
||||
templateData: QualityCheckTemplateUpdate
|
||||
): Promise<QualityCheckTemplate> {
|
||||
const data = await apiClient.put(`${this.baseURL}/${tenantId}/production/quality-templates/${templateId}`, templateData, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a quality check template
|
||||
*/
|
||||
async deleteTemplate(tenantId: string, templateId: string): Promise<void> {
|
||||
await apiClient.delete(`${this.baseURL}/${tenantId}/production/quality-templates/${templateId}`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get templates applicable to a specific process stage
|
||||
*/
|
||||
async getTemplatesForStage(
|
||||
tenantId: string,
|
||||
stage: ProcessStage,
|
||||
isActive: boolean = true
|
||||
): Promise<QualityCheckTemplateList> {
|
||||
const data = await apiClient.get(`${this.baseURL}/${tenantId}/production/quality-templates/stages/${stage}`, {
|
||||
params: { is_active: isActive },
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate an existing quality check template
|
||||
*/
|
||||
async duplicateTemplate(
|
||||
tenantId: string,
|
||||
templateId: string
|
||||
): Promise<QualityCheckTemplate> {
|
||||
const data = await apiClient.post(`${this.baseURL}/${tenantId}/production/quality-templates/${templateId}/duplicate`, {}, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a quality check using a template
|
||||
*/
|
||||
async executeQualityCheck(
|
||||
tenantId: string,
|
||||
executionData: QualityCheckExecutionRequest
|
||||
): Promise<QualityCheckExecutionResponse> {
|
||||
const data = await apiClient.post(`${this.baseURL}/${tenantId}/production/quality-checks/execute`, executionData, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quality check history for a batch
|
||||
*/
|
||||
async getQualityCheckHistory(
|
||||
tenantId: string,
|
||||
batchId: string,
|
||||
stage?: ProcessStage
|
||||
): Promise<any[]> {
|
||||
const data = await apiClient.get(`${this.baseURL}/${tenantId}/production/quality-checks`, {
|
||||
params: { batch_id: batchId, process_stage: stage },
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quality check templates for recipe configuration
|
||||
*/
|
||||
async getTemplatesForRecipe(
|
||||
tenantId: string,
|
||||
recipeId: string
|
||||
): Promise<Record<ProcessStage, QualityCheckTemplate[]>> {
|
||||
const allTemplates = await this.getTemplates(tenantId, { is_active: true });
|
||||
|
||||
// Group templates by applicable stages
|
||||
const templatesByStage: Record<ProcessStage, QualityCheckTemplate[]> = {} as any;
|
||||
|
||||
Object.values(ProcessStage).forEach(stage => {
|
||||
templatesByStage[stage] = allTemplates.templates.filter(template =>
|
||||
!template.applicable_stages ||
|
||||
template.applicable_stages.length === 0 ||
|
||||
template.applicable_stages.includes(stage)
|
||||
);
|
||||
});
|
||||
|
||||
return templatesByStage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate template configuration
|
||||
*/
|
||||
async validateTemplate(
|
||||
tenantId: string,
|
||||
templateData: Partial<QualityCheckTemplateCreate | QualityCheckTemplateUpdate>
|
||||
): Promise<{ valid: boolean; errors: string[] }> {
|
||||
try {
|
||||
const data = await apiClient.post(`${this.baseURL}/${tenantId}/production/quality-templates/validate`, templateData, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 400) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [error.response?.data?.detail || 'Validation failed']
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default template suggestions based on product type
|
||||
*/
|
||||
async getDefaultTemplates(
|
||||
tenantId: string,
|
||||
productCategory: string
|
||||
): Promise<QualityCheckTemplate[]> {
|
||||
const templates = await this.getTemplates(tenantId, {
|
||||
is_active: true,
|
||||
category: productCategory
|
||||
});
|
||||
|
||||
// Return commonly used templates for the product category
|
||||
return templates.templates.filter(template =>
|
||||
template.is_required || template.weight > 5.0
|
||||
).sort((a, b) => b.weight - a.weight);
|
||||
}
|
||||
}
|
||||
|
||||
export const qualityTemplateService = new QualityTemplateService();
|
||||
225
frontend/src/api/services/recipes.ts
Normal file
225
frontend/src/api/services/recipes.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/recipes.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Recipes Service - Complete backend alignment
|
||||
*
|
||||
* Backend API structure (3-tier architecture):
|
||||
* - ATOMIC: recipes.py, recipe_quality_configs.py
|
||||
* - OPERATIONS: recipe_operations.py (duplicate, activate, feasibility)
|
||||
*
|
||||
* Last Updated: 2025-10-05
|
||||
* Status: ✅ Complete - Zero drift with backend
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client/apiClient';
|
||||
import type {
|
||||
RecipeResponse,
|
||||
RecipeCreate,
|
||||
RecipeUpdate,
|
||||
RecipeSearchParams,
|
||||
RecipeDuplicateRequest,
|
||||
RecipeFeasibilityResponse,
|
||||
RecipeStatisticsResponse,
|
||||
RecipeCategoriesResponse,
|
||||
RecipeQualityConfiguration,
|
||||
RecipeQualityConfigurationUpdate,
|
||||
RecipeDeletionSummary,
|
||||
} from '../types/recipes';
|
||||
|
||||
export class RecipesService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Recipes CRUD
|
||||
// Backend: services/recipes/app/api/recipes.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Create a new recipe
|
||||
* POST /tenants/{tenant_id}/recipes
|
||||
*/
|
||||
async createRecipe(tenantId: string, recipeData: RecipeCreate): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes`, recipeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search recipes with filters
|
||||
* GET /tenants/{tenant_id}/recipes
|
||||
*/
|
||||
async searchRecipes(tenantId: string, params: RecipeSearchParams = {}): Promise<RecipeResponse[]> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
// Add all non-empty parameters to the query string
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = queryString ? `${this.baseUrl}/${tenantId}/recipes?${queryString}` : `${this.baseUrl}/${tenantId}/recipes`;
|
||||
|
||||
return apiClient.get<RecipeResponse[]>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recipes (shorthand for search without filters)
|
||||
* GET /tenants/{tenant_id}/recipes
|
||||
*/
|
||||
async getRecipes(tenantId: string): Promise<RecipeResponse[]> {
|
||||
return this.searchRecipes(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipe by ID with ingredients
|
||||
* GET /tenants/{tenant_id}/recipes/{recipe_id}
|
||||
*/
|
||||
async getRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
|
||||
return apiClient.get<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing recipe
|
||||
* PUT /tenants/{tenant_id}/recipes/{recipe_id}
|
||||
*/
|
||||
async updateRecipe(tenantId: string, recipeId: string, recipeData: RecipeUpdate): Promise<RecipeResponse> {
|
||||
return apiClient.put<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}`, recipeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recipe
|
||||
* DELETE /tenants/{tenant_id}/recipes/{recipe_id}
|
||||
*/
|
||||
async deleteRecipe(tenantId: string, recipeId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a recipe (soft delete by setting status to ARCHIVED)
|
||||
* PATCH /tenants/{tenant_id}/recipes/{recipe_id}/archive
|
||||
*/
|
||||
async archiveRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
|
||||
return apiClient.patch<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/archive`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deletion summary for a recipe
|
||||
* GET /tenants/{tenant_id}/recipes/{recipe_id}/deletion-summary
|
||||
*/
|
||||
async getRecipeDeletionSummary(tenantId: string, recipeId: string): Promise<RecipeDeletionSummary> {
|
||||
return apiClient.get<RecipeDeletionSummary>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/deletion-summary`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Quality Configuration CRUD
|
||||
// Backend: services/recipes/app/api/recipe_quality_configs.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get quality configuration for a recipe
|
||||
* GET /tenants/{tenant_id}/recipes/{recipe_id}/quality-configuration
|
||||
*/
|
||||
async getRecipeQualityConfiguration(
|
||||
tenantId: string,
|
||||
recipeId: string
|
||||
): Promise<RecipeQualityConfiguration> {
|
||||
return apiClient.get<RecipeQualityConfiguration>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/quality-configuration`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update quality configuration for a recipe
|
||||
* PUT /tenants/{tenant_id}/recipes/{recipe_id}/quality-configuration
|
||||
*/
|
||||
async updateRecipeQualityConfiguration(
|
||||
tenantId: string,
|
||||
recipeId: string,
|
||||
qualityConfig: RecipeQualityConfigurationUpdate
|
||||
): Promise<RecipeQualityConfiguration> {
|
||||
return apiClient.put<RecipeQualityConfiguration>(
|
||||
`${this.baseUrl}/${tenantId}/recipes/${recipeId}/quality-configuration`,
|
||||
qualityConfig
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add quality templates to a recipe stage
|
||||
* POST /tenants/{tenant_id}/recipes/{recipe_id}/quality-configuration/stages/{stage}/templates
|
||||
*/
|
||||
async addQualityTemplatesToStage(
|
||||
tenantId: string,
|
||||
recipeId: string,
|
||||
stage: string,
|
||||
templateIds: string[]
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.post<{ message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/recipes/${recipeId}/quality-configuration/stages/${stage}/templates`,
|
||||
templateIds
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a quality template from a recipe stage
|
||||
* DELETE /tenants/{tenant_id}/recipes/{recipe_id}/quality-configuration/stages/{stage}/templates/{template_id}
|
||||
*/
|
||||
async removeQualityTemplateFromStage(
|
||||
tenantId: string,
|
||||
recipeId: string,
|
||||
stage: string,
|
||||
templateId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/recipes/${recipeId}/quality-configuration/stages/${stage}/templates/${templateId}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Recipe Management
|
||||
// Backend: services/recipes/app/api/recipe_operations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Duplicate an existing recipe
|
||||
* POST /tenants/{tenant_id}/recipes/{recipe_id}/duplicate
|
||||
*/
|
||||
async duplicateRecipe(tenantId: string, recipeId: string, duplicateData: RecipeDuplicateRequest): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/duplicate`, duplicateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a recipe for production
|
||||
* POST /tenants/{tenant_id}/recipes/{recipe_id}/activate
|
||||
*/
|
||||
async activateRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/activate`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recipe can be produced with current inventory
|
||||
* GET /tenants/{tenant_id}/recipes/{recipe_id}/feasibility
|
||||
*/
|
||||
async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibilityResponse> {
|
||||
const params = new URLSearchParams({ batch_multiplier: String(batchMultiplier) });
|
||||
return apiClient.get<RecipeFeasibilityResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/feasibility?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipe statistics for dashboard
|
||||
* GET /tenants/{tenant_id}/recipes/dashboard/statistics
|
||||
*/
|
||||
async getRecipeStatistics(tenantId: string): Promise<RecipeStatisticsResponse> {
|
||||
return apiClient.get<RecipeStatisticsResponse>(`${this.baseUrl}/${tenantId}/recipes/dashboard/statistics`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of recipe categories used by tenant
|
||||
* GET /tenants/{tenant_id}/recipes/categories/list
|
||||
*/
|
||||
async getRecipeCategories(tenantId: string): Promise<RecipeCategoriesResponse> {
|
||||
return apiClient.get<RecipeCategoriesResponse>(`${this.baseUrl}/${tenantId}/recipes/categories/list`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
export const recipesService = new RecipesService();
|
||||
export default recipesService;
|
||||
294
frontend/src/api/services/sales.ts
Normal file
294
frontend/src/api/services/sales.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/sales.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Sales Service - Complete backend alignment
|
||||
*
|
||||
* Backend API structure (3-tier architecture):
|
||||
* - ATOMIC: sales_records.py
|
||||
* - OPERATIONS: sales_operations.py (validation, import, aggregation)
|
||||
* - ANALYTICS: analytics.py
|
||||
*
|
||||
* Last Updated: 2025-10-05
|
||||
* Status: ✅ Complete - Zero drift with backend
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
// Sales Data
|
||||
SalesDataCreate,
|
||||
SalesDataUpdate,
|
||||
SalesDataResponse,
|
||||
SalesDataQuery,
|
||||
// Import
|
||||
ImportValidationResult,
|
||||
BulkImportResponse,
|
||||
ImportSummary,
|
||||
// Analytics
|
||||
SalesAnalytics,
|
||||
ProductSalesAnalytics,
|
||||
CategorySalesAnalytics,
|
||||
ChannelPerformance,
|
||||
} from '../types/sales';
|
||||
|
||||
export class SalesService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Sales Records CRUD
|
||||
// Backend: services/sales/app/api/sales_records.py
|
||||
// ===================================================================
|
||||
|
||||
async createSalesRecord(
|
||||
tenantId: string,
|
||||
salesData: SalesDataCreate
|
||||
): Promise<SalesDataResponse> {
|
||||
return apiClient.post<SalesDataResponse>(
|
||||
`${this.baseUrl}/${tenantId}/sales/sales`,
|
||||
salesData
|
||||
);
|
||||
}
|
||||
|
||||
async getSalesRecords(
|
||||
tenantId: string,
|
||||
query?: SalesDataQuery
|
||||
): Promise<SalesDataResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (query?.start_date) queryParams.append('start_date', query.start_date);
|
||||
if (query?.end_date) queryParams.append('end_date', query.end_date);
|
||||
if (query?.product_name) queryParams.append('product_name', query.product_name);
|
||||
if (query?.product_category) queryParams.append('product_category', query.product_category);
|
||||
if (query?.location_id) queryParams.append('location_id', query.location_id);
|
||||
if (query?.sales_channel) queryParams.append('sales_channel', query.sales_channel);
|
||||
if (query?.source) queryParams.append('source', query.source);
|
||||
if (query?.is_validated !== undefined)
|
||||
queryParams.append('is_validated', query.is_validated.toString());
|
||||
if (query?.limit !== undefined) queryParams.append('limit', query.limit.toString());
|
||||
if (query?.offset !== undefined) queryParams.append('offset', query.offset.toString());
|
||||
if (query?.order_by) queryParams.append('order_by', query.order_by);
|
||||
if (query?.order_direction) queryParams.append('order_direction', query.order_direction);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/sales/sales?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/sales/sales`;
|
||||
|
||||
return apiClient.get<SalesDataResponse[]>(url);
|
||||
}
|
||||
|
||||
async getSalesRecord(tenantId: string, recordId: string): Promise<SalesDataResponse> {
|
||||
return apiClient.get<SalesDataResponse>(
|
||||
`${this.baseUrl}/${tenantId}/sales/sales/${recordId}`
|
||||
);
|
||||
}
|
||||
|
||||
async updateSalesRecord(
|
||||
tenantId: string,
|
||||
recordId: string,
|
||||
updateData: SalesDataUpdate
|
||||
): Promise<SalesDataResponse> {
|
||||
return apiClient.put<SalesDataResponse>(
|
||||
`${this.baseUrl}/${tenantId}/sales/sales/${recordId}`,
|
||||
updateData
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSalesRecord(tenantId: string, recordId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/sales/sales/${recordId}`
|
||||
);
|
||||
}
|
||||
|
||||
async getProductCategories(tenantId: string): Promise<string[]> {
|
||||
return apiClient.get<string[]>(`${this.baseUrl}/${tenantId}/sales/categories`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Validation
|
||||
// Backend: services/sales/app/api/sales_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async validateSalesRecord(
|
||||
tenantId: string,
|
||||
recordId: string,
|
||||
validationNotes?: string
|
||||
): Promise<SalesDataResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (validationNotes) queryParams.append('validation_notes', validationNotes);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/sales/operations/validate-record/${recordId}?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/sales/operations/validate-record/${recordId}`;
|
||||
|
||||
return apiClient.post<SalesDataResponse>(url);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Cross-Service Queries
|
||||
// Backend: services/sales/app/api/sales_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async getProductSales(
|
||||
tenantId: string,
|
||||
inventoryProductId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<SalesDataResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/inventory-products/${inventoryProductId}/sales?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/inventory-products/${inventoryProductId}/sales`;
|
||||
|
||||
return apiClient.get<SalesDataResponse[]>(url);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Data Import
|
||||
// Backend: services/sales/app/api/sales_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async validateImportFile(tenantId: string, file: File): Promise<ImportValidationResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return apiClient.uploadFile<ImportValidationResult>(
|
||||
`${this.baseUrl}/${tenantId}/sales/operations/import/validate`,
|
||||
formData
|
||||
);
|
||||
}
|
||||
|
||||
async importSalesData(
|
||||
tenantId: string,
|
||||
file: File,
|
||||
skipValidation: boolean = false
|
||||
): Promise<BulkImportResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('skip_validation', skipValidation.toString());
|
||||
|
||||
return apiClient.uploadFile<BulkImportResponse>(
|
||||
`${this.baseUrl}/${tenantId}/sales/operations/import`,
|
||||
formData
|
||||
);
|
||||
}
|
||||
|
||||
async getImportHistory(
|
||||
tenantId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<ImportSummary[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('offset', offset.toString());
|
||||
|
||||
return apiClient.get<ImportSummary[]>(
|
||||
`${this.baseUrl}/${tenantId}/sales/operations/import/history?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async downloadImportTemplate(tenantId: string): Promise<Blob> {
|
||||
return apiClient.get<Blob>(
|
||||
`${this.baseUrl}/${tenantId}/sales/operations/import/template`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Batch Sales Summary (Enterprise Feature)
|
||||
// Backend: services/sales/app/api/sales_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async getBatchSalesSummary(
|
||||
tenantIds: string[],
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<Record<string, any>> {
|
||||
return apiClient.post<Record<string, any>>(
|
||||
'/tenants/batch/sales-summary',
|
||||
{
|
||||
tenant_ids: tenantIds,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Aggregation
|
||||
// Backend: services/sales/app/api/sales_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async aggregateSalesByProduct(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<ProductSalesAnalytics[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/sales/operations/aggregate/by-product?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/sales/operations/aggregate/by-product`;
|
||||
|
||||
return apiClient.get<ProductSalesAnalytics[]>(url);
|
||||
}
|
||||
|
||||
async aggregateSalesByCategory(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<CategorySalesAnalytics[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/sales/operations/aggregate/by-category?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/sales/operations/aggregate/by-category`;
|
||||
|
||||
return apiClient.get<CategorySalesAnalytics[]>(url);
|
||||
}
|
||||
|
||||
async aggregateSalesByChannel(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<ChannelPerformance[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/sales/operations/aggregate/by-channel?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/sales/operations/aggregate/by-channel`;
|
||||
|
||||
return apiClient.get<ChannelPerformance[]>(url);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ANALYTICS: Sales Summary
|
||||
// Backend: services/sales/app/api/analytics.py
|
||||
// ===================================================================
|
||||
|
||||
async getSalesAnalytics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<SalesAnalytics> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/sales/analytics/summary?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/sales/analytics/summary`;
|
||||
|
||||
return apiClient.get<SalesAnalytics>(url);
|
||||
}
|
||||
}
|
||||
|
||||
export const salesService = new SalesService();
|
||||
152
frontend/src/api/services/settings.ts
Normal file
152
frontend/src/api/services/settings.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// frontend/src/api/services/settings.ts
|
||||
/**
|
||||
* API service for Tenant Settings
|
||||
* Handles all HTTP requests for tenant operational configuration
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client/apiClient';
|
||||
import type {
|
||||
TenantSettings,
|
||||
TenantSettingsUpdate,
|
||||
SettingsCategory,
|
||||
CategoryResetResponse,
|
||||
} from '../types/settings';
|
||||
|
||||
export const settingsApi = {
|
||||
/**
|
||||
* Get all settings for a tenant
|
||||
*/
|
||||
getSettings: async (tenantId: string): Promise<TenantSettings> => {
|
||||
try {
|
||||
console.log('🔍 Fetching settings for tenant:', tenantId);
|
||||
const response = await apiClient.get<TenantSettings>(`/tenants/${tenantId}/settings`);
|
||||
console.log('📊 Settings API response data:', response);
|
||||
|
||||
// Validate the response data structure
|
||||
if (!response) {
|
||||
throw new Error('Settings response data is null or undefined');
|
||||
}
|
||||
|
||||
if (!response.tenant_id) {
|
||||
throw new Error('Settings response missing tenant_id');
|
||||
}
|
||||
|
||||
if (!response.procurement_settings) {
|
||||
throw new Error('Settings response missing procurement_settings');
|
||||
}
|
||||
|
||||
console.log('✅ Settings data validation passed');
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching settings:', error);
|
||||
console.error('Error details:', {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
tenantId
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update tenant settings (partial update supported)
|
||||
*/
|
||||
updateSettings: async (
|
||||
tenantId: string,
|
||||
updates: TenantSettingsUpdate
|
||||
): Promise<TenantSettings> => {
|
||||
try {
|
||||
console.log('🔍 Updating settings for tenant:', tenantId, 'with updates:', updates);
|
||||
const response = await apiClient.put<TenantSettings>(`/tenants/${tenantId}/settings`, updates);
|
||||
console.log('📊 Settings update response:', response);
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Settings update response data is null or undefined');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating settings:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get settings for a specific category
|
||||
*/
|
||||
getCategorySettings: async (
|
||||
tenantId: string,
|
||||
category: SettingsCategory
|
||||
): Promise<Record<string, any>> => {
|
||||
try {
|
||||
console.log('🔍 Fetching category settings for tenant:', tenantId, 'category:', category);
|
||||
const response = await apiClient.get<{ tenant_id: string; category: string; settings: Record<string, any> }>(
|
||||
`/tenants/${tenantId}/settings/${category}`
|
||||
);
|
||||
console.log('📊 Category settings response:', response);
|
||||
|
||||
if (!response || !response.settings) {
|
||||
throw new Error('Category settings response data is null or undefined');
|
||||
}
|
||||
|
||||
return response.settings;
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching category settings:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update settings for a specific category
|
||||
*/
|
||||
updateCategorySettings: async (
|
||||
tenantId: string,
|
||||
category: SettingsCategory,
|
||||
settings: Record<string, any>
|
||||
): Promise<TenantSettings> => {
|
||||
try {
|
||||
console.log('🔍 Updating category settings for tenant:', tenantId, 'category:', category, 'settings:', settings);
|
||||
const response = await apiClient.put<TenantSettings>(
|
||||
`/tenants/${tenantId}/settings/${category}`,
|
||||
{ settings }
|
||||
);
|
||||
console.log('📊 Category settings update response:', response);
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Category settings update response data is null or undefined');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating category settings:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset a category to default values
|
||||
*/
|
||||
resetCategory: async (
|
||||
tenantId: string,
|
||||
category: SettingsCategory
|
||||
): Promise<CategoryResetResponse> => {
|
||||
try {
|
||||
console.log('🔍 Resetting category for tenant:', tenantId, 'category:', category);
|
||||
const response = await apiClient.post<CategoryResetResponse>(
|
||||
`/tenants/${tenantId}/settings/${category}/reset`
|
||||
);
|
||||
console.log('📊 Category reset response:', response);
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Category reset response data is null or undefined');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('❌ Error resetting category:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default settingsApi;
|
||||
569
frontend/src/api/services/subscription.ts
Normal file
569
frontend/src/api/services/subscription.ts
Normal file
@@ -0,0 +1,569 @@
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
// New types
|
||||
SubscriptionTier,
|
||||
SUBSCRIPTION_TIERS,
|
||||
BillingCycle,
|
||||
PlanMetadata,
|
||||
AvailablePlans,
|
||||
UsageSummary,
|
||||
FeatureCheckResponse,
|
||||
QuotaCheckResponse,
|
||||
PlanUpgradeValidation,
|
||||
PlanUpgradeResult,
|
||||
doesPlanMeetMinimum,
|
||||
getPlanColor,
|
||||
getYearlyDiscountPercentage,
|
||||
PLAN_HIERARCHY,
|
||||
|
||||
// Analytics levels
|
||||
ANALYTICS_LEVELS,
|
||||
AnalyticsLevel,
|
||||
ANALYTICS_HIERARCHY
|
||||
} from '../types/subscription';
|
||||
|
||||
// Map plan tiers to analytics levels based on backend data
|
||||
const TIER_TO_ANALYTICS_LEVEL: Record<SubscriptionTier | string, AnalyticsLevel> = {
|
||||
[SUBSCRIPTION_TIERS.STARTER]: ANALYTICS_LEVELS.BASIC,
|
||||
[SUBSCRIPTION_TIERS.PROFESSIONAL]: ANALYTICS_LEVELS.ADVANCED,
|
||||
[SUBSCRIPTION_TIERS.ENTERPRISE]: ANALYTICS_LEVELS.PREDICTIVE,
|
||||
'demo': ANALYTICS_LEVELS.ADVANCED, // Treat demo tier same as professional for analytics access
|
||||
};
|
||||
|
||||
// Cache for available plans
|
||||
let cachedPlans: AvailablePlans | null = null;
|
||||
let lastFetchTime: number | null = null;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export class SubscriptionService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
private readonly plansUrl = '/plans';
|
||||
|
||||
// ============================================================================
|
||||
// NEW METHODS - Centralized Plans API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Invalidate cached plan data
|
||||
* Call this when subscription changes to ensure fresh data on next fetch
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
cachedPlans = null;
|
||||
lastFetchTime = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available subscription plans with complete metadata
|
||||
* Uses cached data if available and fresh (5 min cache)
|
||||
*/
|
||||
async fetchAvailablePlans(): Promise<AvailablePlans> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached data if it's still valid
|
||||
if (cachedPlans && lastFetchTime && (now - lastFetchTime) < CACHE_DURATION) {
|
||||
return cachedPlans;
|
||||
}
|
||||
|
||||
try {
|
||||
const plans = await apiClient.get<AvailablePlans>(this.plansUrl);
|
||||
cachedPlans = plans;
|
||||
lastFetchTime = now;
|
||||
return plans;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subscription plans:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a specific plan tier
|
||||
*/
|
||||
async getPlanMetadata(tier: SubscriptionTier): Promise<PlanMetadata | null> {
|
||||
try {
|
||||
const plans = await this.fetchAvailablePlans();
|
||||
return plans.plans[tier] || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get plan metadata:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available features for a tier
|
||||
*/
|
||||
async getPlanFeatures(tier: SubscriptionTier): Promise<string[]> {
|
||||
try {
|
||||
const metadata = await this.getPlanMetadata(tier);
|
||||
return metadata?.features || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get plan features:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is available in a tier
|
||||
*/
|
||||
async hasFeatureInTier(tier: SubscriptionTier, featureName: string): Promise<boolean> {
|
||||
try {
|
||||
const features = await this.getPlanFeatures(tier);
|
||||
return features.includes(featureName);
|
||||
} catch (error) {
|
||||
console.error('Failed to check feature availability:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan comparison data for pricing page
|
||||
*/
|
||||
async getPlanComparison(): Promise<{
|
||||
tiers: SubscriptionTier[];
|
||||
metadata: Record<SubscriptionTier, PlanMetadata>;
|
||||
}> {
|
||||
try {
|
||||
const plans = await this.fetchAvailablePlans();
|
||||
return {
|
||||
tiers: [
|
||||
SUBSCRIPTION_TIERS.STARTER,
|
||||
SUBSCRIPTION_TIERS.PROFESSIONAL,
|
||||
SUBSCRIPTION_TIERS.ENTERPRISE
|
||||
],
|
||||
metadata: plans.plans
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get plan comparison:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate savings for yearly billing
|
||||
*/
|
||||
calculateYearlySavings(monthlyPrice: number, yearlyPrice: number): {
|
||||
savingsAmount: number;
|
||||
savingsPercentage: number;
|
||||
monthsFree: number;
|
||||
} {
|
||||
const yearlyAnnual = monthlyPrice * 12;
|
||||
const savingsAmount = yearlyAnnual - yearlyPrice;
|
||||
const savingsPercentage = getYearlyDiscountPercentage(monthlyPrice, yearlyPrice);
|
||||
const monthsFree = Math.round(savingsAmount / monthlyPrice);
|
||||
|
||||
return {
|
||||
savingsAmount,
|
||||
savingsPercentage,
|
||||
monthsFree
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user's plan meets minimum requirement
|
||||
*/
|
||||
checkPlanMeetsMinimum(userPlan: SubscriptionTier, requiredPlan: SubscriptionTier): boolean {
|
||||
return doesPlanMeetMinimum(userPlan, requiredPlan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan display color
|
||||
*/
|
||||
getPlanDisplayColor(tier: SubscriptionTier): string {
|
||||
return getPlanColor(tier);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TENANT SUBSCRIPTION STATUS & USAGE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current usage summary for a tenant
|
||||
*/
|
||||
async getUsageSummary(tenantId: string): Promise<UsageSummary> {
|
||||
return apiClient.get<UsageSummary>(`/tenants/${tenantId}/subscription/usage`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tenant has access to a specific feature
|
||||
*/
|
||||
async checkFeatureAccess(
|
||||
tenantId: string,
|
||||
featureName: string
|
||||
): Promise<FeatureCheckResponse> {
|
||||
return apiClient.get<FeatureCheckResponse>(
|
||||
`/tenants/${tenantId}/subscription/features/${featureName}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tenant can perform an action within quota limits
|
||||
*/
|
||||
async checkQuotaLimit(
|
||||
tenantId: string,
|
||||
quotaType: string,
|
||||
requestedAmount?: number
|
||||
): Promise<QuotaCheckResponse> {
|
||||
// Map quotaType to the new subscription limit endpoints
|
||||
let endpoint: string;
|
||||
switch (quotaType) {
|
||||
case 'inventory_items':
|
||||
case 'products':
|
||||
endpoint = 'products';
|
||||
break;
|
||||
case 'users':
|
||||
endpoint = 'users';
|
||||
break;
|
||||
case 'locations':
|
||||
endpoint = 'locations';
|
||||
break;
|
||||
case 'recipes':
|
||||
endpoint = 'recipes';
|
||||
break;
|
||||
case 'suppliers':
|
||||
endpoint = 'suppliers';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported quota type: ${quotaType}`);
|
||||
}
|
||||
|
||||
const url = `/tenants/${tenantId}/subscription/limits/${endpoint}`;
|
||||
|
||||
// Get the response from the endpoint (returns different format than expected)
|
||||
const response = await apiClient.get<{
|
||||
can_add: boolean;
|
||||
current_count?: number;
|
||||
max_allowed?: number;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}>(url);
|
||||
|
||||
// Map the response to QuotaCheckResponse format
|
||||
return {
|
||||
allowed: response.can_add,
|
||||
current: response.current_count || 0,
|
||||
limit: response.max_allowed || null,
|
||||
remaining: response.max_allowed !== undefined && response.current_count !== undefined
|
||||
? response.max_allowed - response.current_count
|
||||
: null,
|
||||
message: response.reason || response.message || ''
|
||||
};
|
||||
}
|
||||
|
||||
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
|
||||
return apiClient.get<PlanUpgradeValidation>(`/tenants/${tenantId}/subscription/validate-upgrade/${planKey}`);
|
||||
}
|
||||
|
||||
async upgradePlan(tenantId: string, planKey: string, billingCycle: BillingCycle = 'monthly'): Promise<PlanUpgradeResult> {
|
||||
// The backend expects new_plan and billing_cycle as query parameters
|
||||
return apiClient.post<PlanUpgradeResult>(
|
||||
`/tenants/${tenantId}/subscription/upgrade?new_plan=${planKey}&billing_cycle=${billingCycle}`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
|
||||
return apiClient.get(`/tenants/${tenantId}/subscription/limits/locations`);
|
||||
}
|
||||
|
||||
async canAddProduct(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
|
||||
return apiClient.get(`/tenants/${tenantId}/subscription/limits/products`);
|
||||
}
|
||||
|
||||
async canAddUser(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
|
||||
return apiClient.get(`/tenants/${tenantId}/subscription/limits/users`);
|
||||
}
|
||||
|
||||
async canAddRecipe(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
|
||||
return apiClient.get(`/tenants/${tenantId}/subscription/limits/recipes`);
|
||||
}
|
||||
|
||||
async canAddSupplier(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
|
||||
return apiClient.get(`/tenants/${tenantId}/subscription/limits/suppliers`);
|
||||
}
|
||||
|
||||
async hasFeature(tenantId: string, featureName: string): Promise<{ has_feature: boolean; feature_value?: any; plan?: string; reason?: string }> {
|
||||
return apiClient.get(`/tenants/${tenantId}/subscription/features/${featureName}`);
|
||||
}
|
||||
|
||||
formatPrice(amount: number): string {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get plan display information
|
||||
*/
|
||||
async getPlanDisplayInfo(planKey: string) {
|
||||
try {
|
||||
const plans = await this.fetchAvailablePlans();
|
||||
const plan = plans.plans[planKey as SubscriptionTier];
|
||||
|
||||
if (plan) {
|
||||
return {
|
||||
name: plan.name,
|
||||
color: this.getPlanColor(planKey as SubscriptionTier),
|
||||
description: plan.description,
|
||||
monthlyPrice: plan.monthly_price
|
||||
};
|
||||
}
|
||||
|
||||
return { name: 'Desconocido', color: 'gray', description: '', monthlyPrice: 0 };
|
||||
} catch (error) {
|
||||
console.error('Failed to get plan display info:', error);
|
||||
return { name: 'Desconocido', color: 'gray', description: '', monthlyPrice: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan color based on plan key
|
||||
*/
|
||||
getPlanColor(planKey: string): string {
|
||||
switch (planKey) {
|
||||
case SUBSCRIPTION_TIERS.STARTER:
|
||||
return 'blue';
|
||||
case SUBSCRIPTION_TIERS.PROFESSIONAL:
|
||||
return 'purple';
|
||||
case SUBSCRIPTION_TIERS.ENTERPRISE:
|
||||
return 'amber';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics level for a plan tier
|
||||
*/
|
||||
getAnalyticsLevelForTier(tier: SubscriptionTier): AnalyticsLevel {
|
||||
return TIER_TO_ANALYTICS_LEVEL[tier] || ANALYTICS_LEVELS.NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics level for a plan (alias for getAnalyticsLevelForTier)
|
||||
* @deprecated Use getAnalyticsLevelForTier instead
|
||||
*/
|
||||
getAnalyticsLevelForPlan(tier: SubscriptionTier): AnalyticsLevel {
|
||||
return this.getAnalyticsLevelForTier(tier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if analytics level meets minimum requirements
|
||||
*/
|
||||
doesAnalyticsLevelMeetMinimum(level: AnalyticsLevel, minimumRequired: AnalyticsLevel): boolean {
|
||||
return ANALYTICS_HIERARCHY[level] >= ANALYTICS_HIERARCHY[minimumRequired];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel subscription - Downgrade to read-only mode
|
||||
*/
|
||||
async cancelSubscription(tenantId: string, reason?: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
status: string;
|
||||
cancellation_effective_date: string;
|
||||
days_remaining: number;
|
||||
read_only_mode_starts: string;
|
||||
}> {
|
||||
return apiClient.post(`/tenants/${tenantId}/subscription/cancel`, {
|
||||
reason: reason || ''
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate a cancelled or inactive subscription
|
||||
*/
|
||||
async reactivateSubscription(tenantId: string, plan: string = 'starter'): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
status: string;
|
||||
plan: string;
|
||||
next_billing_date: string | null;
|
||||
}> {
|
||||
return apiClient.post(`/tenants/${tenantId}/subscription/reactivate`, {
|
||||
plan
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription status including read-only mode info
|
||||
*/
|
||||
async getSubscriptionStatus(tenantId: string): Promise<{
|
||||
tenant_id: string;
|
||||
status: string;
|
||||
plan: string;
|
||||
is_read_only: boolean;
|
||||
cancellation_effective_date: string | null;
|
||||
days_until_inactive: number | null;
|
||||
billing_cycle?: string;
|
||||
next_billing_date?: string;
|
||||
}> {
|
||||
return apiClient.get(`/tenants/${tenantId}/subscription/status`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice history for a tenant
|
||||
*/
|
||||
async getInvoices(tenantId: string): Promise<Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
description: string | null;
|
||||
invoice_pdf: string | null;
|
||||
hosted_invoice_url: string | null;
|
||||
}>> {
|
||||
return apiClient.get(`/tenants/${tenantId}/subscription/invoices`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current payment method for a subscription
|
||||
*/
|
||||
async getCurrentPaymentMethod(
|
||||
tenantId: string
|
||||
): Promise<{
|
||||
brand: string;
|
||||
last4: string;
|
||||
exp_month?: number;
|
||||
exp_year?: number;
|
||||
} | null> {
|
||||
try {
|
||||
const response = await apiClient.get(`/tenants/${tenantId}/subscription/payment-method`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to get current payment method:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the default payment method for a subscription
|
||||
*/
|
||||
async updatePaymentMethod(
|
||||
tenantId: string,
|
||||
paymentMethodId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
payment_method_id: string;
|
||||
brand: string;
|
||||
last4: string;
|
||||
exp_month?: number;
|
||||
exp_year?: number;
|
||||
requires_action?: boolean;
|
||||
client_secret?: string;
|
||||
payment_intent_status?: string;
|
||||
}> {
|
||||
return apiClient.post(`/tenants/${tenantId}/subscription/payment-method`, {
|
||||
payment_method_id: paymentMethodId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// NEW METHODS - Usage Forecasting & Predictive Analytics
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get usage forecast for all metrics
|
||||
* Returns predictions for when tenant will hit limits based on growth rate
|
||||
*/
|
||||
async getUsageForecast(tenantId: string): Promise<{
|
||||
tenant_id: string;
|
||||
forecasted_at: string;
|
||||
metrics: Array<{
|
||||
metric: string;
|
||||
label: string;
|
||||
current: number;
|
||||
limit: number | null;
|
||||
unit: string;
|
||||
daily_growth_rate: number | null;
|
||||
predicted_breach_date: string | null;
|
||||
days_until_breach: number | null;
|
||||
usage_percentage: number;
|
||||
status: string;
|
||||
trend_data: Array<{ date: string; value: number }>;
|
||||
}>;
|
||||
}> {
|
||||
return apiClient.get(`/usage-forecast?tenant_id=${tenantId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track daily usage (called by cron jobs or manually)
|
||||
* Stores usage snapshots in Redis for trend analysis
|
||||
*/
|
||||
async trackDailyUsage(
|
||||
tenantId: string,
|
||||
metric: string,
|
||||
value: number
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
tenant_id: string;
|
||||
metric: string;
|
||||
value: number;
|
||||
date: string;
|
||||
}> {
|
||||
return apiClient.post('/usage-forecast/track-usage', {
|
||||
tenant_id: tenantId,
|
||||
metric,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current subscription for a tenant
|
||||
* Combines subscription data with available plans metadata
|
||||
*/
|
||||
async getCurrentSubscription(tenantId: string): Promise<{
|
||||
tier: SubscriptionTier;
|
||||
billing_cycle: 'monthly' | 'yearly';
|
||||
monthly_price: number;
|
||||
yearly_price: number;
|
||||
renewal_date: string;
|
||||
trial_ends_at?: string;
|
||||
limits: {
|
||||
users: number | null;
|
||||
locations: number | null;
|
||||
products: number | null;
|
||||
recipes: number | null;
|
||||
suppliers: number | null;
|
||||
trainingJobsPerDay: number | null;
|
||||
forecastsPerDay: number | null;
|
||||
storageGB: number | null;
|
||||
};
|
||||
availablePlans: AvailablePlans;
|
||||
}> {
|
||||
// Fetch both subscription status and available plans
|
||||
const [status, plans] = await Promise.all([
|
||||
this.getSubscriptionStatus(tenantId),
|
||||
this.fetchAvailablePlans(),
|
||||
]);
|
||||
|
||||
const currentPlan = plans.plans[status.plan as SubscriptionTier];
|
||||
|
||||
return {
|
||||
tier: status.plan as SubscriptionTier,
|
||||
billing_cycle: (status.billing_cycle as 'monthly' | 'yearly') || 'monthly',
|
||||
monthly_price: currentPlan?.monthly_price || 0,
|
||||
yearly_price: currentPlan?.yearly_price || 0,
|
||||
renewal_date: status.next_billing_date || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
limits: {
|
||||
users: currentPlan?.limits?.users ?? null,
|
||||
locations: currentPlan?.limits?.locations ?? null,
|
||||
products: currentPlan?.limits?.products ?? null,
|
||||
recipes: currentPlan?.limits?.recipes ?? null,
|
||||
suppliers: currentPlan?.limits?.suppliers ?? null,
|
||||
trainingJobsPerDay: currentPlan?.limits?.training_jobs_per_day ?? null,
|
||||
forecastsPerDay: currentPlan?.limits?.forecasts_per_day ?? null,
|
||||
storageGB: currentPlan?.limits?.storage_gb ?? null,
|
||||
},
|
||||
availablePlans: plans,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionService = new SubscriptionService();
|
||||
478
frontend/src/api/services/suppliers.ts
Normal file
478
frontend/src/api/services/suppliers.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/suppliers.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Suppliers Service - Complete backend alignment
|
||||
*
|
||||
* Backend API structure (3-tier architecture):
|
||||
* - ATOMIC: suppliers.py, purchase_orders.py, deliveries.py
|
||||
* - OPERATIONS: supplier_operations.py (approval, statistics, performance)
|
||||
* - ANALYTICS: analytics.py (performance metrics, alerts)
|
||||
*
|
||||
* Last Updated: 2025-10-05
|
||||
* Status: ✅ Complete - Zero drift with backend
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client/apiClient';
|
||||
import type {
|
||||
SupplierCreate,
|
||||
SupplierUpdate,
|
||||
SupplierResponse,
|
||||
SupplierSummary,
|
||||
SupplierApproval,
|
||||
SupplierSearchParams,
|
||||
SupplierStatistics,
|
||||
SupplierDeletionSummary,
|
||||
SupplierResponse as SupplierResponse_,
|
||||
DeliveryCreate,
|
||||
DeliveryUpdate,
|
||||
DeliveryResponse,
|
||||
DeliveryReceiptConfirmation,
|
||||
DeliverySearchParams,
|
||||
PerformanceMetric,
|
||||
PerformanceAlert,
|
||||
SupplierPriceListCreate,
|
||||
SupplierPriceListUpdate,
|
||||
SupplierPriceListResponse
|
||||
} from '../types/suppliers';
|
||||
|
||||
class SuppliersService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Suppliers CRUD
|
||||
// Backend: services/suppliers/app/api/suppliers.py
|
||||
// ===================================================================
|
||||
|
||||
async createSupplier(
|
||||
tenantId: string,
|
||||
supplierData: SupplierCreate
|
||||
): Promise<SupplierResponse> {
|
||||
return apiClient.post<SupplierResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers`,
|
||||
supplierData
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Supplier Price Lists CRUD
|
||||
// Backend: services/suppliers/app/api/suppliers.py (price list endpoints)
|
||||
// ===================================================================
|
||||
|
||||
async getSupplierPriceLists(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
isActive: boolean = true
|
||||
): Promise<SupplierPriceListResponse[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('is_active', isActive.toString());
|
||||
|
||||
return apiClient.get<SupplierPriceListResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getSupplierPriceList(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
priceListId: string
|
||||
): Promise<SupplierPriceListResponse> {
|
||||
return apiClient.get<SupplierPriceListResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists/${priceListId}`
|
||||
);
|
||||
}
|
||||
|
||||
async createSupplierPriceList(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
priceListData: SupplierPriceListCreate
|
||||
): Promise<SupplierPriceListResponse> {
|
||||
return apiClient.post<SupplierPriceListResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists`,
|
||||
priceListData
|
||||
);
|
||||
}
|
||||
|
||||
async updateSupplierPriceList(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
priceListId: string,
|
||||
priceListData: SupplierPriceListUpdate
|
||||
): Promise<SupplierPriceListResponse> {
|
||||
return apiClient.put<SupplierPriceListResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists/${priceListId}`,
|
||||
priceListData
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSupplierPriceList(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
priceListId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists/${priceListId}`
|
||||
);
|
||||
}
|
||||
|
||||
async getSuppliers(
|
||||
tenantId: string,
|
||||
queryParams?: SupplierSearchParams
|
||||
): Promise<SupplierSummary[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (queryParams?.search_term) params.append('search_term', queryParams.search_term);
|
||||
if (queryParams?.supplier_type) params.append('supplier_type', queryParams.supplier_type);
|
||||
if (queryParams?.status) params.append('status', queryParams.status);
|
||||
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
|
||||
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
|
||||
|
||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||
return apiClient.get<SupplierSummary[]>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers${queryString}`
|
||||
);
|
||||
}
|
||||
|
||||
async getSupplier(tenantId: string, supplierId: string): Promise<SupplierResponse> {
|
||||
return apiClient.get<SupplierResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}`
|
||||
);
|
||||
}
|
||||
|
||||
async updateSupplier(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
updateData: SupplierUpdate
|
||||
): Promise<SupplierResponse> {
|
||||
return apiClient.put<SupplierResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}`,
|
||||
updateData
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSupplier(
|
||||
tenantId: string,
|
||||
supplierId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}`
|
||||
);
|
||||
}
|
||||
|
||||
async hardDeleteSupplier(
|
||||
tenantId: string,
|
||||
supplierId: string
|
||||
): Promise<SupplierDeletionSummary> {
|
||||
return apiClient.delete<SupplierDeletionSummary>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/hard`
|
||||
);
|
||||
}
|
||||
|
||||
async getSupplierProducts(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
isActive: boolean = true
|
||||
): Promise<Array<{ inventory_product_id: string }>> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('is_active', isActive.toString());
|
||||
|
||||
return apiClient.get<Array<{ inventory_product_id: string }>>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/products?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Purchase Orders CRUD
|
||||
// Backend: services/suppliers/app/api/purchase_orders.py
|
||||
// ===================================================================
|
||||
|
||||
async createPurchaseOrder(
|
||||
tenantId: string,
|
||||
orderData: PurchaseOrderCreate
|
||||
): Promise<PurchaseOrderResponse> {
|
||||
return apiClient.post<PurchaseOrderResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/purchase-orders`,
|
||||
orderData
|
||||
);
|
||||
}
|
||||
|
||||
async getPurchaseOrders(
|
||||
tenantId: string,
|
||||
queryParams?: PurchaseOrderSearchParams
|
||||
): Promise<PurchaseOrderResponse[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (queryParams?.supplier_id) params.append('supplier_id', queryParams.supplier_id);
|
||||
if (queryParams?.status) params.append('status', queryParams.status);
|
||||
if (queryParams?.priority) params.append('priority', queryParams.priority);
|
||||
if (queryParams?.date_from) params.append('date_from', queryParams.date_from);
|
||||
if (queryParams?.date_to) params.append('date_to', queryParams.date_to);
|
||||
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
|
||||
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
|
||||
|
||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||
return apiClient.get<PurchaseOrderResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/purchase-orders${queryString}`
|
||||
);
|
||||
}
|
||||
|
||||
async getPurchaseOrder(tenantId: string, orderId: string): Promise<PurchaseOrderResponse> {
|
||||
return apiClient.get<PurchaseOrderResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/purchase-orders/${orderId}`
|
||||
);
|
||||
}
|
||||
|
||||
async updatePurchaseOrder(
|
||||
tenantId: string,
|
||||
orderId: string,
|
||||
updateData: PurchaseOrderUpdate
|
||||
): Promise<PurchaseOrderResponse> {
|
||||
return apiClient.put<PurchaseOrderResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/purchase-orders/${orderId}`,
|
||||
updateData
|
||||
);
|
||||
}
|
||||
|
||||
async approvePurchaseOrder(
|
||||
tenantId: string,
|
||||
orderId: string,
|
||||
approval: PurchaseOrderApproval
|
||||
): Promise<PurchaseOrderResponse> {
|
||||
return apiClient.post<PurchaseOrderResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/purchase-orders/${orderId}/approve`,
|
||||
approval
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Deliveries CRUD
|
||||
// Backend: services/suppliers/app/api/deliveries.py
|
||||
// ===================================================================
|
||||
|
||||
async createDelivery(
|
||||
tenantId: string,
|
||||
deliveryData: DeliveryCreate
|
||||
): Promise<DeliveryResponse> {
|
||||
return apiClient.post<DeliveryResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/deliveries`,
|
||||
deliveryData
|
||||
);
|
||||
}
|
||||
|
||||
async getDeliveries(
|
||||
tenantId: string,
|
||||
queryParams?: DeliverySearchParams
|
||||
): Promise<DeliveryResponse[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (queryParams?.supplier_id) params.append('supplier_id', queryParams.supplier_id);
|
||||
if (queryParams?.purchase_order_id) {
|
||||
params.append('purchase_order_id', queryParams.purchase_order_id);
|
||||
}
|
||||
if (queryParams?.status) params.append('status', queryParams.status);
|
||||
if (queryParams?.scheduled_date_from) {
|
||||
params.append('scheduled_date_from', queryParams.scheduled_date_from);
|
||||
}
|
||||
if (queryParams?.scheduled_date_to) {
|
||||
params.append('scheduled_date_to', queryParams.scheduled_date_to);
|
||||
}
|
||||
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
|
||||
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
|
||||
|
||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||
return apiClient.get<DeliveryResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/deliveries${queryString}`
|
||||
);
|
||||
}
|
||||
|
||||
async getDelivery(tenantId: string, deliveryId: string): Promise<DeliveryResponse> {
|
||||
return apiClient.get<DeliveryResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/deliveries/${deliveryId}`
|
||||
);
|
||||
}
|
||||
|
||||
async updateDelivery(
|
||||
tenantId: string,
|
||||
deliveryId: string,
|
||||
updateData: DeliveryUpdate
|
||||
): Promise<DeliveryResponse> {
|
||||
return apiClient.put<DeliveryResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/deliveries/${deliveryId}`,
|
||||
updateData
|
||||
);
|
||||
}
|
||||
|
||||
async confirmDeliveryReceipt(
|
||||
tenantId: string,
|
||||
deliveryId: string,
|
||||
confirmation: DeliveryReceiptConfirmation
|
||||
): Promise<DeliveryResponse> {
|
||||
return apiClient.post<DeliveryResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/deliveries/${deliveryId}/confirm-receipt`,
|
||||
confirmation
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Supplier Management
|
||||
// Backend: services/suppliers/app/api/supplier_operations.py
|
||||
// ===================================================================
|
||||
|
||||
async getSupplierStatistics(tenantId: string): Promise<SupplierStatistics> {
|
||||
return apiClient.get<SupplierStatistics>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/operations/statistics`
|
||||
);
|
||||
}
|
||||
|
||||
async getActiveSuppliers(
|
||||
tenantId: string,
|
||||
queryParams?: SupplierSearchParams
|
||||
): Promise<SupplierSummary[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (queryParams?.search_term) params.append('search_term', queryParams.search_term);
|
||||
if (queryParams?.supplier_type) params.append('supplier_type', queryParams.supplier_type);
|
||||
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
|
||||
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
|
||||
|
||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||
return apiClient.get<SupplierSummary[]>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/operations/active${queryString}`
|
||||
);
|
||||
}
|
||||
|
||||
async getTopSuppliers(tenantId: string): Promise<TopSuppliersResponse> {
|
||||
return apiClient.get<TopSuppliersResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/operations/top`
|
||||
);
|
||||
}
|
||||
|
||||
async getPendingApprovalSuppliers(
|
||||
tenantId: string
|
||||
): Promise<PaginatedResponse<SupplierSummary>> {
|
||||
return apiClient.get<PaginatedResponse<SupplierSummary>>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/operations/pending-review`
|
||||
);
|
||||
}
|
||||
|
||||
async getSuppliersByType(
|
||||
tenantId: string,
|
||||
supplierType: string,
|
||||
queryParams?: Omit<SupplierQueryParams, 'supplier_type'>
|
||||
): Promise<PaginatedResponse<SupplierSummary>> {
|
||||
const params = new URLSearchParams();
|
||||
if (queryParams?.search_term) params.append('search_term', queryParams.search_term);
|
||||
if (queryParams?.status) params.append('status', queryParams.status);
|
||||
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
|
||||
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
|
||||
|
||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||
return apiClient.get<PaginatedResponse<SupplierSummary>>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/types/${supplierType}${queryString}`
|
||||
);
|
||||
}
|
||||
|
||||
async approveSupplier(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
approval: SupplierApproval
|
||||
): Promise<SupplierResponse> {
|
||||
return apiClient.post<SupplierResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/approve`,
|
||||
approval
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ANALYTICS: Performance Metrics
|
||||
// Backend: services/suppliers/app/api/analytics.py
|
||||
// ===================================================================
|
||||
|
||||
async calculateSupplierPerformance(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
request?: PerformanceCalculationRequest
|
||||
): Promise<{ message: string; calculation_id: string }> {
|
||||
const params = new URLSearchParams();
|
||||
if (request?.period) params.append('period', request.period);
|
||||
if (request?.period_start) params.append('period_start', request.period_start);
|
||||
if (request?.period_end) params.append('period_end', request.period_end);
|
||||
|
||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||
return apiClient.post<{ message: string; calculation_id: string }>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/analytics/performance/${supplierId}/calculate${queryString}`
|
||||
);
|
||||
}
|
||||
|
||||
async getSupplierPerformanceMetrics(
|
||||
tenantId: string,
|
||||
supplierId: string
|
||||
): Promise<PerformanceMetric[]> {
|
||||
return apiClient.get<PerformanceMetric[]>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/analytics/performance/${supplierId}/metrics`
|
||||
);
|
||||
}
|
||||
|
||||
async evaluatePerformanceAlerts(
|
||||
tenantId: string
|
||||
): Promise<{ alerts_generated: number; message: string }> {
|
||||
return apiClient.post<{ alerts_generated: number; message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/analytics/performance/alerts/evaluate`
|
||||
);
|
||||
}
|
||||
|
||||
async getPerformanceAlerts(
|
||||
tenantId: string,
|
||||
supplierId?: string
|
||||
): Promise<PerformanceAlert[]> {
|
||||
const url = supplierId
|
||||
? `${this.baseUrl}/${tenantId}/suppliers/analytics/performance/${supplierId}/alerts`
|
||||
: `${this.baseUrl}/${tenantId}/suppliers/analytics/performance/alerts`;
|
||||
|
||||
return apiClient.get<PerformanceAlert[]>(url);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// UTILITY METHODS (Client-side helpers)
|
||||
// ===================================================================
|
||||
|
||||
calculateOrderTotal(
|
||||
items: { ordered_quantity: number; unit_price: number }[],
|
||||
taxAmount: number = 0,
|
||||
shippingCost: number = 0,
|
||||
discountAmount: number = 0
|
||||
): number {
|
||||
const subtotal = items.reduce(
|
||||
(sum, item) => sum + (item.ordered_quantity * item.unit_price),
|
||||
0
|
||||
);
|
||||
return subtotal + taxAmount + shippingCost - discountAmount;
|
||||
}
|
||||
|
||||
formatSupplierCode(name: string, sequence?: number): string {
|
||||
const cleanName = name.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||
const prefix = cleanName.substring(0, 3).padEnd(3, 'X');
|
||||
const suffix = sequence ? sequence.toString().padStart(3, '0') : '001';
|
||||
return `${prefix}${suffix}`;
|
||||
}
|
||||
|
||||
validateTaxId(taxId: string, country: string = 'ES'): boolean {
|
||||
// Simplified validation - real implementation would have proper country-specific validation
|
||||
if (country === 'ES') {
|
||||
// Spanish VAT format: ES + letter + 8 digits or ES + 8 digits + letter
|
||||
const spanishVatRegex = /^ES[A-Z]\d{8}$|^ES\d{8}[A-Z]$/;
|
||||
return spanishVatRegex.test(taxId.toUpperCase());
|
||||
}
|
||||
return taxId.length > 0;
|
||||
}
|
||||
|
||||
formatCurrency(amount: number, currency: string = 'EUR'): string {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(amount);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
export const suppliersService = new SuppliersService();
|
||||
export default suppliersService;
|
||||
617
frontend/src/api/services/sustainability.ts
Normal file
617
frontend/src/api/services/sustainability.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
/**
|
||||
* Sustainability API Service - Microservices Architecture
|
||||
* Fetches data from production and inventory services in parallel
|
||||
* Performs client-side aggregation of sustainability metrics
|
||||
*/
|
||||
|
||||
import apiClient from '../client/apiClient';
|
||||
import type {
|
||||
SustainabilityMetrics,
|
||||
SustainabilityWidgetData,
|
||||
SDGCompliance,
|
||||
EnvironmentalImpact,
|
||||
GrantReport,
|
||||
WasteMetrics,
|
||||
FinancialImpact,
|
||||
AvoidedWaste,
|
||||
GrantReadiness
|
||||
} from '../types/sustainability';
|
||||
|
||||
// ===== SERVICE-SPECIFIC API CALLS =====
|
||||
|
||||
/**
|
||||
* Production Service: Get production waste metrics
|
||||
*/
|
||||
export async function getProductionWasteMetrics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `/tenants/${tenantId}/production/sustainability/waste-metrics${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return await apiClient.get<any>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Production Service: Get production baseline metrics
|
||||
*/
|
||||
export async function getProductionBaseline(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `/tenants/${tenantId}/production/sustainability/baseline${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return await apiClient.get<any>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Production Service: Get AI impact on waste reduction
|
||||
*/
|
||||
export async function getProductionAIImpact(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `/tenants/${tenantId}/production/sustainability/ai-impact${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return await apiClient.get<any>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Production Service: Get summary widget data
|
||||
*/
|
||||
export async function getProductionSummary(
|
||||
tenantId: string,
|
||||
days: number = 30
|
||||
): Promise<any> {
|
||||
return await apiClient.get<any>(
|
||||
`/tenants/${tenantId}/production/sustainability/summary?days=${days}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory Service: Get inventory waste metrics
|
||||
*/
|
||||
export async function getInventoryWasteMetrics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `/tenants/${tenantId}/inventory/sustainability/waste-metrics${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return await apiClient.get<any>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory Service: Get expiry alerts
|
||||
*/
|
||||
export async function getInventoryExpiryAlerts(
|
||||
tenantId: string,
|
||||
daysAhead: number = 7
|
||||
): Promise<any> {
|
||||
return await apiClient.get<any>(
|
||||
`/tenants/${tenantId}/inventory/sustainability/expiry-alerts?days_ahead=${daysAhead}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory Service: Get waste events
|
||||
*/
|
||||
export async function getInventoryWasteEvents(
|
||||
tenantId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
reasonCode?: string
|
||||
): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
if (reasonCode) params.append('reason_code', reasonCode);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `/tenants/${tenantId}/inventory/sustainability/waste-events?${queryString}`;
|
||||
|
||||
return await apiClient.get<any>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory Service: Get summary widget data
|
||||
*/
|
||||
export async function getInventorySummary(
|
||||
tenantId: string,
|
||||
days: number = 30
|
||||
): Promise<any> {
|
||||
return await apiClient.get<any>(
|
||||
`/tenants/${tenantId}/inventory/sustainability/summary?days=${days}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===== AGGREGATION FUNCTIONS =====
|
||||
|
||||
/**
|
||||
* Environmental Constants for calculations
|
||||
*/
|
||||
const EnvironmentalConstants = {
|
||||
CO2_PER_KG_WASTE: 1.9, // kg CO2e per kg waste
|
||||
WATER_PER_KG: 1500, // liters per kg
|
||||
LAND_USE_PER_KG: 3.4, // m² per kg
|
||||
TREES_PER_TON_CO2: 50,
|
||||
SDG_TARGET_REDUCTION: 0.50, // 50% reduction target
|
||||
EU_BAKERY_BASELINE_WASTE: 0.25, // 25% baseline
|
||||
MINIMUM_PRODUCTION_KG: 50 // Minimum production to show meaningful metrics
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate environmental impact from total waste
|
||||
*/
|
||||
function calculateEnvironmentalImpact(totalWasteKg: number): EnvironmentalImpact {
|
||||
const co2Kg = totalWasteKg * EnvironmentalConstants.CO2_PER_KG_WASTE;
|
||||
const co2Tons = co2Kg / 1000;
|
||||
const waterLiters = totalWasteKg * EnvironmentalConstants.WATER_PER_KG;
|
||||
const landSqMeters = totalWasteKg * EnvironmentalConstants.LAND_USE_PER_KG;
|
||||
|
||||
return {
|
||||
co2_emissions: {
|
||||
kg: Math.round(co2Kg * 100) / 100,
|
||||
tons: Math.round(co2Tons * 1000) / 1000,
|
||||
trees_to_offset: Math.ceil(co2Tons * EnvironmentalConstants.TREES_PER_TON_CO2)
|
||||
},
|
||||
water_footprint: {
|
||||
liters: Math.round(waterLiters),
|
||||
cubic_meters: Math.round(waterLiters / 1000 * 100) / 100
|
||||
},
|
||||
land_use: {
|
||||
square_meters: Math.round(landSqMeters * 100) / 100,
|
||||
hectares: Math.round(landSqMeters / 10000 * 1000) / 1000
|
||||
},
|
||||
human_equivalents: {
|
||||
car_km_equivalent: Math.round(co2Kg / 0.120), // 120g CO2 per km
|
||||
smartphone_charges: Math.round(co2Kg * 1000 / 8), // 8g CO2 per charge
|
||||
showers_equivalent: Math.round(waterLiters / 65), // 65L per shower
|
||||
trees_planted: Math.ceil(co2Tons * EnvironmentalConstants.TREES_PER_TON_CO2)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SDG compliance status
|
||||
*/
|
||||
function calculateSDGCompliance(
|
||||
currentWastePercentage: number,
|
||||
baselineWastePercentage: number
|
||||
): SDGCompliance {
|
||||
const reductionAchieved = baselineWastePercentage > 0
|
||||
? ((baselineWastePercentage - currentWastePercentage) / baselineWastePercentage) * 100
|
||||
: 0;
|
||||
|
||||
const targetReduction = EnvironmentalConstants.SDG_TARGET_REDUCTION * 100; // 50%
|
||||
const progressToTarget = Math.max(0, Math.min(100, (reductionAchieved / targetReduction) * 100));
|
||||
|
||||
let status: 'sdg_compliant' | 'on_track' | 'progressing' | 'baseline' | 'above_baseline' = 'baseline';
|
||||
let statusLabel = 'Establishing Baseline';
|
||||
|
||||
if (reductionAchieved >= targetReduction) {
|
||||
status = 'sdg_compliant';
|
||||
statusLabel = 'SDG Compliant';
|
||||
} else if (reductionAchieved >= 30) {
|
||||
status = 'on_track';
|
||||
statusLabel = 'On Track';
|
||||
} else if (reductionAchieved >= 10) {
|
||||
status = 'progressing';
|
||||
statusLabel = 'Progressing';
|
||||
} else if (reductionAchieved > 0) {
|
||||
status = 'baseline';
|
||||
statusLabel = 'Improving from Baseline';
|
||||
} else if (reductionAchieved < 0) {
|
||||
status = 'above_baseline';
|
||||
statusLabel = 'Above Baseline';
|
||||
}
|
||||
|
||||
return {
|
||||
sdg_12_3: {
|
||||
baseline_waste_percentage: Math.round(baselineWastePercentage * 100) / 100,
|
||||
current_waste_percentage: Math.round(currentWastePercentage * 100) / 100,
|
||||
reduction_achieved: Math.round(reductionAchieved * 100) / 100,
|
||||
target_reduction: targetReduction,
|
||||
progress_to_target: Math.round(progressToTarget * 100) / 100,
|
||||
status,
|
||||
status_label: statusLabel,
|
||||
target_waste_percentage: baselineWastePercentage * (1 - EnvironmentalConstants.SDG_TARGET_REDUCTION)
|
||||
},
|
||||
baseline_period: 'first_90_days',
|
||||
certification_ready: status === 'sdg_compliant',
|
||||
improvement_areas: status === 'sdg_compliant' ? [] : ['reduce_waste_further']
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assess grant readiness based on SDG compliance
|
||||
*/
|
||||
function assessGrantReadiness(sdgCompliance: SDGCompliance): GrantReadiness {
|
||||
const reductionAchieved = sdgCompliance.sdg_12_3.reduction_achieved;
|
||||
const isSdgCompliant = sdgCompliance.certification_ready;
|
||||
|
||||
const grantPrograms: Record<string, any> = {
|
||||
life_circular_economy: {
|
||||
eligible: reductionAchieved >= 30,
|
||||
confidence: reductionAchieved >= 40 ? 'high' : reductionAchieved >= 30 ? 'medium' : 'low',
|
||||
requirements_met: reductionAchieved >= 30,
|
||||
funding_eur: 73_000_000,
|
||||
deadline: '2025-09-23',
|
||||
program_type: 'grant'
|
||||
},
|
||||
horizon_europe_cluster_6: {
|
||||
eligible: isSdgCompliant,
|
||||
confidence: isSdgCompliant ? 'high' : 'low',
|
||||
requirements_met: isSdgCompliant,
|
||||
funding_eur: 880_000_000,
|
||||
deadline: 'rolling_2025',
|
||||
program_type: 'grant'
|
||||
},
|
||||
fedima_sustainability_grant: {
|
||||
eligible: reductionAchieved >= 20,
|
||||
confidence: reductionAchieved >= 25 ? 'high' : reductionAchieved >= 20 ? 'medium' : 'low',
|
||||
requirements_met: reductionAchieved >= 20,
|
||||
funding_eur: 20_000,
|
||||
deadline: '2025-06-30',
|
||||
program_type: 'grant',
|
||||
sector_specific: 'bakery'
|
||||
},
|
||||
eit_food_retail: {
|
||||
eligible: reductionAchieved >= 15,
|
||||
confidence: reductionAchieved >= 20 ? 'high' : reductionAchieved >= 15 ? 'medium' : 'low',
|
||||
requirements_met: reductionAchieved >= 15,
|
||||
funding_eur: 45_000,
|
||||
deadline: 'rolling',
|
||||
program_type: 'grant',
|
||||
sector_specific: 'retail'
|
||||
},
|
||||
un_sdg_certified: {
|
||||
eligible: isSdgCompliant,
|
||||
confidence: isSdgCompliant ? 'high' : 'low',
|
||||
requirements_met: isSdgCompliant,
|
||||
funding_eur: 0,
|
||||
deadline: 'ongoing',
|
||||
program_type: 'certification'
|
||||
}
|
||||
};
|
||||
|
||||
const recommendedApplications = Object.entries(grantPrograms)
|
||||
.filter(([_, program]) => program.eligible && program.confidence !== 'low')
|
||||
.map(([name, _]) => name);
|
||||
|
||||
const eligibleCount = Object.values(grantPrograms).filter(p => p.eligible).length;
|
||||
const overallReadiness = (eligibleCount / Object.keys(grantPrograms).length) * 100;
|
||||
|
||||
return {
|
||||
overall_readiness_percentage: Math.round(overallReadiness),
|
||||
grant_programs: grantPrograms,
|
||||
recommended_applications: recommendedApplications,
|
||||
spain_compliance: {
|
||||
law_1_2025: reductionAchieved >= 50,
|
||||
circular_economy_strategy: reductionAchieved >= 30
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ===== MAIN AGGREGATION FUNCTION =====
|
||||
|
||||
/**
|
||||
* Get default metrics for insufficient data state
|
||||
*/
|
||||
function getInsufficientDataMetrics(
|
||||
totalProductionKg: number,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): SustainabilityMetrics {
|
||||
return {
|
||||
period: {
|
||||
start_date: startDate || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
end_date: endDate || new Date().toISOString(),
|
||||
days: 30
|
||||
},
|
||||
waste_metrics: {
|
||||
total_waste_kg: 0,
|
||||
production_waste_kg: 0,
|
||||
expired_waste_kg: 0,
|
||||
waste_percentage: 0,
|
||||
waste_by_reason: {}
|
||||
},
|
||||
environmental_impact: {
|
||||
co2_emissions: { kg: 0, tons: 0, trees_to_offset: 0 },
|
||||
water_footprint: { liters: 0, cubic_meters: 0 },
|
||||
land_use: { square_meters: 0, hectares: 0 },
|
||||
human_equivalents: { car_km_equivalent: 0, smartphone_charges: 0, showers_equivalent: 0, trees_planted: 0 }
|
||||
},
|
||||
sdg_compliance: {
|
||||
sdg_12_3: {
|
||||
baseline_waste_percentage: 0,
|
||||
current_waste_percentage: 0,
|
||||
reduction_achieved: 0,
|
||||
target_reduction: 50,
|
||||
progress_to_target: 0,
|
||||
status: 'baseline',
|
||||
status_label: 'Collecting Baseline Data',
|
||||
target_waste_percentage: 0
|
||||
},
|
||||
baseline_period: 'not_available',
|
||||
certification_ready: false,
|
||||
improvement_areas: ['start_production_tracking']
|
||||
},
|
||||
avoided_waste: {
|
||||
waste_avoided_kg: 0,
|
||||
ai_assisted_batches: 0,
|
||||
environmental_impact_avoided: { co2_kg: 0, water_liters: 0 },
|
||||
methodology: 'insufficient_data'
|
||||
},
|
||||
financial_impact: {
|
||||
waste_cost_eur: 0,
|
||||
cost_per_kg: 3.50,
|
||||
potential_monthly_savings: 0,
|
||||
annual_projection: 0
|
||||
},
|
||||
grant_readiness: {
|
||||
overall_readiness_percentage: 0,
|
||||
grant_programs: {
|
||||
life_circular_economy: { eligible: false, confidence: 'low', requirements_met: false, funding_eur: 73_000_000 },
|
||||
horizon_europe_cluster_6: { eligible: false, confidence: 'low', requirements_met: false, funding_eur: 880_000_000 },
|
||||
fedima_sustainability_grant: { eligible: false, confidence: 'low', requirements_met: false, funding_eur: 20_000 },
|
||||
eit_food_retail: { eligible: false, confidence: 'low', requirements_met: false, funding_eur: 45_000 },
|
||||
un_sdg_certified: { eligible: false, confidence: 'low', requirements_met: false, funding_eur: 0 }
|
||||
},
|
||||
recommended_applications: [],
|
||||
spain_compliance: { law_1_2025: false, circular_economy_strategy: false }
|
||||
},
|
||||
data_sufficient: false,
|
||||
minimum_production_required_kg: EnvironmentalConstants.MINIMUM_PRODUCTION_KG,
|
||||
current_production_kg: totalProductionKg
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive sustainability metrics by aggregating production and inventory data
|
||||
*/
|
||||
export async function getSustainabilityMetrics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<SustainabilityMetrics> {
|
||||
try {
|
||||
// Fetch data from both services in parallel
|
||||
const [productionData, inventoryData, productionBaseline, aiImpact] = await Promise.all([
|
||||
getProductionWasteMetrics(tenantId, startDate, endDate),
|
||||
getInventoryWasteMetrics(tenantId, startDate, endDate),
|
||||
getProductionBaseline(tenantId, startDate, endDate),
|
||||
getProductionAIImpact(tenantId, startDate, endDate)
|
||||
]);
|
||||
|
||||
// Calculate total production
|
||||
const totalProductionKg = productionData.total_planned || 0;
|
||||
|
||||
// Check if we have sufficient data for meaningful metrics
|
||||
// Minimum: 50kg production to avoid false metrics on empty accounts
|
||||
const hasDataSufficient = totalProductionKg >= EnvironmentalConstants.MINIMUM_PRODUCTION_KG;
|
||||
|
||||
// If insufficient data, return a "collecting data" state
|
||||
if (!hasDataSufficient) {
|
||||
return getInsufficientDataMetrics(totalProductionKg, startDate, endDate);
|
||||
}
|
||||
|
||||
// Aggregate waste metrics
|
||||
const productionWaste = (productionData.total_production_waste || 0) + (productionData.total_defects || 0);
|
||||
const totalWasteKg = productionWaste + (inventoryData.inventory_waste_kg || 0);
|
||||
|
||||
const wastePercentage = totalProductionKg > 0
|
||||
? (totalWasteKg / totalProductionKg) * 100
|
||||
: 0;
|
||||
|
||||
const wasteMetrics: WasteMetrics = {
|
||||
total_waste_kg: Math.round(totalWasteKg * 100) / 100,
|
||||
production_waste_kg: Math.round(productionWaste * 100) / 100,
|
||||
expired_waste_kg: Math.round((inventoryData.inventory_waste_kg || 0) * 100) / 100,
|
||||
waste_percentage: Math.round(wastePercentage * 100) / 100,
|
||||
waste_by_reason: {
|
||||
...(productionData.waste_by_defect_type || {}),
|
||||
...(inventoryData.waste_by_reason || {})
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate environmental impact
|
||||
const environmentalImpact = calculateEnvironmentalImpact(totalWasteKg);
|
||||
|
||||
// Calculate SDG compliance
|
||||
const baselineWastePercentage = productionBaseline.waste_percentage ||
|
||||
EnvironmentalConstants.EU_BAKERY_BASELINE_WASTE * 100;
|
||||
const sdgCompliance = calculateSDGCompliance(wastePercentage, baselineWastePercentage);
|
||||
|
||||
// Calculate avoided waste from AI
|
||||
const wasteAvoidedKg = aiImpact.impact?.waste_avoided_kg || 0;
|
||||
const avoidedWaste: AvoidedWaste = {
|
||||
waste_avoided_kg: Math.round(wasteAvoidedKg * 100) / 100,
|
||||
ai_assisted_batches: aiImpact.ai_batches?.count || 0,
|
||||
environmental_impact_avoided: {
|
||||
co2_kg: Math.round(wasteAvoidedKg * EnvironmentalConstants.CO2_PER_KG_WASTE * 100) / 100,
|
||||
water_liters: Math.round(wasteAvoidedKg * EnvironmentalConstants.WATER_PER_KG)
|
||||
},
|
||||
methodology: 'ai_vs_manual_comparison'
|
||||
};
|
||||
|
||||
// Calculate financial impact
|
||||
const inventoryCost = inventoryData.waste_cost_eur || 0;
|
||||
const productionCost = productionWaste * 3.50; // €3.50/kg avg
|
||||
const totalCost = inventoryCost + productionCost;
|
||||
|
||||
const financialImpact: FinancialImpact = {
|
||||
waste_cost_eur: Math.round(totalCost * 100) / 100,
|
||||
cost_per_kg: 3.50,
|
||||
potential_monthly_savings: Math.round((aiImpact.impact?.cost_savings_eur || 0) * 100) / 100,
|
||||
annual_projection: Math.round((aiImpact.impact?.cost_savings_eur || 0) * 12 * 100) / 100
|
||||
};
|
||||
|
||||
// Assess grant readiness
|
||||
const grantReadiness = assessGrantReadiness(sdgCompliance);
|
||||
|
||||
return {
|
||||
period: productionData.period || {
|
||||
start_date: startDate || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
end_date: endDate || new Date().toISOString(),
|
||||
days: 30
|
||||
},
|
||||
waste_metrics: wasteMetrics,
|
||||
environmental_impact: environmentalImpact,
|
||||
sdg_compliance: sdgCompliance,
|
||||
avoided_waste: avoidedWaste,
|
||||
financial_impact: financialImpact,
|
||||
grant_readiness: grantReadiness,
|
||||
data_sufficient: true,
|
||||
minimum_production_required_kg: EnvironmentalConstants.MINIMUM_PRODUCTION_KG,
|
||||
current_production_kg: totalProductionKg
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error aggregating sustainability metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get simplified sustainability widget data
|
||||
*/
|
||||
export async function getSustainabilityWidgetData(
|
||||
tenantId: string,
|
||||
days: number = 30
|
||||
): Promise<SustainabilityWidgetData> {
|
||||
try {
|
||||
// Fetch summaries from both services in parallel
|
||||
const [productionSummary, inventorySummary] = await Promise.all([
|
||||
getProductionSummary(tenantId, days),
|
||||
getInventorySummary(tenantId, days)
|
||||
]);
|
||||
|
||||
const productionWasteWidget = (productionSummary.total_production_waste || 0) + (productionSummary.total_defects || 0);
|
||||
const totalWasteKg = productionWasteWidget + (inventorySummary.inventory_waste_kg || 0);
|
||||
|
||||
const totalProduction = productionSummary.total_planned || productionSummary.total_production_kg || 0;
|
||||
const wastePercentage = totalProduction > 0 ? ((totalWasteKg / totalProduction) * 100) : 0;
|
||||
|
||||
const baselinePercentage = productionSummary.waste_percentage || 25;
|
||||
const reductionPercentage = baselinePercentage > 0
|
||||
? ((baselinePercentage - wastePercentage) / baselinePercentage) * 100
|
||||
: 0;
|
||||
|
||||
const co2SavedKg = totalWasteKg * EnvironmentalConstants.CO2_PER_KG_WASTE;
|
||||
const waterSavedLiters = totalWasteKg * EnvironmentalConstants.WATER_PER_KG;
|
||||
|
||||
return {
|
||||
total_waste_kg: Math.round(totalWasteKg * 100) / 100,
|
||||
waste_reduction_percentage: Math.round(reductionPercentage * 100) / 100,
|
||||
co2_saved_kg: Math.round(co2SavedKg * 100) / 100,
|
||||
water_saved_liters: Math.round(waterSavedLiters),
|
||||
trees_equivalent: Math.ceil((co2SavedKg / 1000) * EnvironmentalConstants.TREES_PER_TON_CO2),
|
||||
sdg_status: reductionPercentage >= 50 ? 'sdg_compliant' :
|
||||
reductionPercentage >= 37.5 ? 'on_track' :
|
||||
reductionPercentage >= 12.5 ? 'progressing' : 'baseline',
|
||||
sdg_progress: Math.min(100, (reductionPercentage / 50) * 100),
|
||||
grant_programs_ready: reductionPercentage >= 50 ? 5 :
|
||||
reductionPercentage >= 30 ? 3 :
|
||||
reductionPercentage >= 15 ? 2 : 0,
|
||||
financial_savings_eur: Math.round(
|
||||
((inventorySummary.waste_cost_eur || 0) + (productionWasteWidget * 3.50)) * 100
|
||||
) / 100
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching sustainability widget data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SDG 12.3 compliance status
|
||||
*/
|
||||
export async function getSDGCompliance(tenantId: string): Promise<SDGCompliance> {
|
||||
const metrics = await getSustainabilityMetrics(tenantId);
|
||||
return metrics.sdg_compliance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environmental impact metrics
|
||||
*/
|
||||
export async function getEnvironmentalImpact(
|
||||
tenantId: string,
|
||||
days: number = 30
|
||||
): Promise<EnvironmentalImpact> {
|
||||
const endDate = new Date().toISOString();
|
||||
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const metrics = await getSustainabilityMetrics(tenantId, startDate, endDate);
|
||||
return metrics.environmental_impact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export grant application report
|
||||
* Note: This still uses the aggregated metrics approach
|
||||
*/
|
||||
export async function exportGrantReport(
|
||||
tenantId: string,
|
||||
grantType: string = 'general',
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<GrantReport> {
|
||||
const metrics = await getSustainabilityMetrics(tenantId, startDate, endDate);
|
||||
|
||||
return {
|
||||
report_metadata: {
|
||||
generated_at: new Date().toISOString(),
|
||||
report_type: grantType,
|
||||
period: metrics.period,
|
||||
tenant_id: tenantId
|
||||
},
|
||||
executive_summary: {
|
||||
total_waste_reduced_kg: metrics.avoided_waste.waste_avoided_kg,
|
||||
waste_reduction_percentage: metrics.sdg_compliance.sdg_12_3.reduction_achieved,
|
||||
co2_emissions_avoided_kg: metrics.avoided_waste.environmental_impact_avoided.co2_kg,
|
||||
financial_savings_eur: metrics.financial_impact.potential_monthly_savings,
|
||||
sdg_compliance_status: metrics.sdg_compliance.sdg_12_3.status_label
|
||||
},
|
||||
detailed_metrics: metrics,
|
||||
certifications: {
|
||||
sdg_12_3_compliant: metrics.sdg_compliance.certification_ready,
|
||||
grant_programs_eligible: metrics.grant_readiness.recommended_applications
|
||||
},
|
||||
supporting_data: {
|
||||
baseline_comparison: {
|
||||
baseline: metrics.sdg_compliance.sdg_12_3.baseline_waste_percentage,
|
||||
current: metrics.sdg_compliance.sdg_12_3.current_waste_percentage,
|
||||
improvement: metrics.sdg_compliance.sdg_12_3.reduction_achieved
|
||||
},
|
||||
environmental_benefits: metrics.environmental_impact,
|
||||
financial_benefits: metrics.financial_impact
|
||||
}
|
||||
};
|
||||
}
|
||||
264
frontend/src/api/services/tenant.ts
Normal file
264
frontend/src/api/services/tenant.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/tenant.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Tenant Service - Complete backend alignment
|
||||
*
|
||||
* Backend API structure (3-tier architecture):
|
||||
* - ATOMIC: tenants.py, tenant_members.py
|
||||
* - OPERATIONS: tenant_operations.py
|
||||
*
|
||||
* Last Updated: 2025-10-05
|
||||
* Status: ✅ Complete - Zero drift with backend
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
BakeryRegistration,
|
||||
BakeryRegistrationWithSubscription,
|
||||
TenantResponse,
|
||||
TenantAccessResponse,
|
||||
TenantUpdate,
|
||||
TenantMemberResponse,
|
||||
TenantStatistics,
|
||||
TenantSearchParams,
|
||||
TenantNearbyParams,
|
||||
AddMemberWithUserCreate,
|
||||
SubscriptionLinkingResponse,
|
||||
} from '../types/tenant';
|
||||
|
||||
export class TenantService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Tenant CRUD
|
||||
// Backend: services/tenant/app/api/tenants.py
|
||||
// ===================================================================
|
||||
async registerBakery(bakeryData: BakeryRegistration): Promise<TenantResponse> {
|
||||
return apiClient.post<TenantResponse>(`${this.baseUrl}/register`, bakeryData);
|
||||
}
|
||||
|
||||
async registerBakeryWithSubscription(bakeryData: BakeryRegistrationWithSubscription): Promise<TenantResponse> {
|
||||
return apiClient.post<TenantResponse>(`${this.baseUrl}/register`, bakeryData);
|
||||
}
|
||||
|
||||
async linkSubscriptionToTenant(
|
||||
tenantId: string,
|
||||
subscriptionId: string,
|
||||
userId: string
|
||||
): Promise<SubscriptionLinkingResponse> {
|
||||
return apiClient.post<SubscriptionLinkingResponse>(
|
||||
`${this.baseUrl}/link-subscription`,
|
||||
{ tenant_id: tenantId, subscription_id: subscriptionId, user_id: userId }
|
||||
);
|
||||
}
|
||||
|
||||
async getTenant(tenantId: string): Promise<TenantResponse> {
|
||||
return apiClient.get<TenantResponse>(`${this.baseUrl}/${tenantId}`);
|
||||
}
|
||||
|
||||
async getTenantBySubdomain(subdomain: string): Promise<TenantResponse> {
|
||||
return apiClient.get<TenantResponse>(`${this.baseUrl}/subdomain/${subdomain}`);
|
||||
}
|
||||
|
||||
async getUserTenants(userId: string): Promise<TenantResponse[]> {
|
||||
// Use the /tenants endpoint to get both owned and member tenants
|
||||
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/user/${userId}/tenants`);
|
||||
}
|
||||
|
||||
async getUserOwnedTenants(userId: string): Promise<TenantResponse[]> {
|
||||
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/user/${userId}/owned`);
|
||||
}
|
||||
|
||||
async updateTenant(tenantId: string, updateData: TenantUpdate): Promise<TenantResponse> {
|
||||
return apiClient.put<TenantResponse>(`${this.baseUrl}/${tenantId}`, updateData);
|
||||
}
|
||||
|
||||
async deactivateTenant(tenantId: string): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.post<{ success: boolean; message: string }>(`${this.baseUrl}/${tenantId}/deactivate`);
|
||||
}
|
||||
|
||||
async activateTenant(tenantId: string): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.post<{ success: boolean; message: string }>(`${this.baseUrl}/${tenantId}/activate`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Access Control
|
||||
// Backend: services/tenant/app/api/tenant_operations.py
|
||||
// ===================================================================
|
||||
async verifyTenantAccess(tenantId: string, userId: string): Promise<TenantAccessResponse> {
|
||||
return apiClient.get<TenantAccessResponse>(`${this.baseUrl}/${tenantId}/access/${userId}`);
|
||||
}
|
||||
|
||||
async getCurrentUserTenantAccess(tenantId: string): Promise<TenantAccessResponse> {
|
||||
// This will use the current user from the auth token
|
||||
// The backend endpoint handles extracting user_id from the token
|
||||
return apiClient.get<TenantAccessResponse>(`${this.baseUrl}/${tenantId}/my-access`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Enterprise Hierarchy
|
||||
// Backend: services/tenant/app/api/tenant_hierarchy.py
|
||||
// ===================================================================
|
||||
async getChildTenants(parentTenantId: string): Promise<TenantResponse[]> {
|
||||
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/${parentTenantId}/children`);
|
||||
}
|
||||
|
||||
async bulkCreateChildTenants(parentTenantId: string, request: {
|
||||
child_tenants: Array<{
|
||||
name: string;
|
||||
city: string;
|
||||
zone?: string;
|
||||
address: string;
|
||||
postal_code: string;
|
||||
location_code: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
business_type?: string;
|
||||
business_model?: string;
|
||||
timezone?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}>;
|
||||
auto_configure_distribution?: boolean;
|
||||
}): Promise<{
|
||||
parent_tenant_id: string;
|
||||
created_count: number;
|
||||
failed_count: number;
|
||||
created_tenants: TenantResponse[];
|
||||
failed_tenants: Array<{ name: string; location_code: string; error: string }>;
|
||||
distribution_configured: boolean;
|
||||
}> {
|
||||
return apiClient.post(`${this.baseUrl}/${parentTenantId}/bulk-children`, request);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Search & Discovery
|
||||
// Backend: services/tenant/app/api/tenant_operations.py
|
||||
// ===================================================================
|
||||
async searchTenants(params: TenantSearchParams): Promise<TenantResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params.search_term) queryParams.append('search_term', params.search_term);
|
||||
if (params.business_type) queryParams.append('business_type', params.business_type);
|
||||
if (params.city) queryParams.append('city', params.city);
|
||||
if (params.skip !== undefined) queryParams.append('skip', params.skip.toString());
|
||||
if (params.limit !== undefined) queryParams.append('limit', params.limit.toString());
|
||||
|
||||
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/search?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
async getNearbyTenants(params: TenantNearbyParams): Promise<TenantResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
queryParams.append('latitude', params.latitude.toString());
|
||||
queryParams.append('longitude', params.longitude.toString());
|
||||
if (params.radius_km !== undefined) queryParams.append('radius_km', params.radius_km.toString());
|
||||
if (params.limit !== undefined) queryParams.append('limit', params.limit.toString());
|
||||
|
||||
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/nearby?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Model Status Management
|
||||
// Backend: services/tenant/app/api/tenant_operations.py
|
||||
// ===================================================================
|
||||
async updateModelStatus(
|
||||
tenantId: string,
|
||||
modelTrained: boolean,
|
||||
lastTrainingDate?: string
|
||||
): Promise<TenantResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('model_trained', modelTrained.toString());
|
||||
if (lastTrainingDate) queryParams.append('last_training_date', lastTrainingDate);
|
||||
|
||||
return apiClient.put<TenantResponse>(`${this.baseUrl}/${tenantId}/model-status?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Team Member Management
|
||||
// Backend: services/tenant/app/api/tenant_members.py
|
||||
// ===================================================================
|
||||
async addTeamMember(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
role: string
|
||||
): Promise<TenantMemberResponse> {
|
||||
return apiClient.post<TenantMemberResponse>(`${this.baseUrl}/${tenantId}/members`, {
|
||||
user_id: userId,
|
||||
role: role,
|
||||
});
|
||||
}
|
||||
|
||||
async addTeamMemberWithUserCreation(
|
||||
tenantId: string,
|
||||
memberData: AddMemberWithUserCreate
|
||||
): Promise<TenantMemberResponse> {
|
||||
return apiClient.post<TenantMemberResponse>(
|
||||
`${this.baseUrl}/${tenantId}/members/with-user`,
|
||||
memberData
|
||||
);
|
||||
}
|
||||
|
||||
async getTeamMembers(tenantId: string, activeOnly: boolean = true): Promise<TenantMemberResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('active_only', activeOnly.toString());
|
||||
|
||||
return apiClient.get<TenantMemberResponse[]>(`${this.baseUrl}/${tenantId}/members?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
async updateMemberRole(
|
||||
tenantId: string,
|
||||
memberUserId: string,
|
||||
newRole: string
|
||||
): Promise<TenantMemberResponse> {
|
||||
return apiClient.put<TenantMemberResponse>(
|
||||
`${this.baseUrl}/${tenantId}/members/${memberUserId}/role`,
|
||||
{ new_role: newRole }
|
||||
);
|
||||
}
|
||||
|
||||
async removeTeamMember(tenantId: string, memberUserId: string): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.delete<{ success: boolean; message: string }>(`${this.baseUrl}/${tenantId}/members/${memberUserId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer tenant ownership to another admin
|
||||
* Backend: services/tenant/app/api/tenant_members.py - transfer_ownership endpoint
|
||||
*
|
||||
* @param tenantId - The tenant ID
|
||||
* @param newOwnerId - The user ID of the new owner (must be an existing admin)
|
||||
* @returns Updated tenant with new owner
|
||||
*/
|
||||
async transferOwnership(tenantId: string, newOwnerId: string): Promise<TenantResponse> {
|
||||
return apiClient.post<TenantResponse>(
|
||||
`${this.baseUrl}/${tenantId}/transfer-ownership`,
|
||||
{ new_owner_id: newOwnerId }
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Statistics & Admin
|
||||
// Backend: services/tenant/app/api/tenant_operations.py
|
||||
// ===================================================================
|
||||
async getTenantStatistics(): Promise<TenantStatistics> {
|
||||
return apiClient.get<TenantStatistics>(`${this.baseUrl}/statistics`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Frontend Context Management
|
||||
// ===================================================================
|
||||
setCurrentTenant(tenant: TenantResponse): void {
|
||||
// Set tenant context in API client
|
||||
if (tenant && tenant.id) {
|
||||
apiClient.setTenantId(tenant.id);
|
||||
}
|
||||
}
|
||||
|
||||
clearCurrentTenant(): void {
|
||||
// Clear tenant context from API client
|
||||
apiClient.setTenantId(null);
|
||||
}
|
||||
}
|
||||
|
||||
export const tenantService = new TenantService();
|
||||
198
frontend/src/api/services/training.ts
Normal file
198
frontend/src/api/services/training.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/training.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Training Service - Complete backend alignment
|
||||
*
|
||||
* Backend API structure (3-tier architecture):
|
||||
* - ATOMIC: training_jobs.py, models.py
|
||||
* - OPERATIONS: training_operations.py
|
||||
*
|
||||
* Last Updated: 2025-10-05
|
||||
* Status: ✅ Complete - Zero drift with backend
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client/apiClient';
|
||||
import type {
|
||||
TrainingJobRequest,
|
||||
TrainingJobResponse,
|
||||
TrainingJobStatus,
|
||||
SingleProductTrainingRequest,
|
||||
ActiveModelResponse,
|
||||
ModelMetricsResponse,
|
||||
TrainedModelResponse,
|
||||
TenantStatistics,
|
||||
ModelPerformanceResponse,
|
||||
ModelsQueryParams,
|
||||
PaginatedResponse,
|
||||
} from '../types/training';
|
||||
|
||||
class TrainingService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Training Job Creation
|
||||
// Backend: services/training/app/api/training_operations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Create a new training job
|
||||
* POST /tenants/{tenant_id}/training/jobs
|
||||
*/
|
||||
async createTrainingJob(
|
||||
tenantId: string,
|
||||
request: TrainingJobRequest
|
||||
): Promise<TrainingJobResponse> {
|
||||
return apiClient.post<TrainingJobResponse>(
|
||||
`${this.baseUrl}/${tenantId}/training/jobs`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Train a single product
|
||||
* POST /tenants/{tenant_id}/training/products/{inventory_product_id}
|
||||
*/
|
||||
async trainSingleProduct(
|
||||
tenantId: string,
|
||||
inventoryProductId: string,
|
||||
request: SingleProductTrainingRequest
|
||||
): Promise<TrainingJobResponse> {
|
||||
return apiClient.post<TrainingJobResponse>(
|
||||
`${this.baseUrl}/${tenantId}/training/products/${inventoryProductId}`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Training Job Status
|
||||
// Backend: services/training/app/api/training_jobs.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get training job status
|
||||
* GET /tenants/{tenant_id}/training/jobs/{job_id}/status
|
||||
*/
|
||||
async getTrainingJobStatus(
|
||||
tenantId: string,
|
||||
jobId: string
|
||||
): Promise<TrainingJobStatus> {
|
||||
return apiClient.get<TrainingJobStatus>(
|
||||
`${this.baseUrl}/${tenantId}/training/jobs/${jobId}/status`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get training statistics
|
||||
* GET /tenants/{tenant_id}/training/statistics
|
||||
*/
|
||||
async getTenantStatistics(tenantId: string): Promise<TenantStatistics> {
|
||||
return apiClient.get<TenantStatistics>(
|
||||
`${this.baseUrl}/${tenantId}/training/statistics`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Model Management
|
||||
// Backend: services/training/app/api/models.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get active model for a product
|
||||
* GET /tenants/{tenant_id}/training/models/{inventory_product_id}/active
|
||||
*/
|
||||
async getActiveModel(
|
||||
tenantId: string,
|
||||
inventoryProductId: string
|
||||
): Promise<ActiveModelResponse> {
|
||||
return apiClient.get<ActiveModelResponse>(
|
||||
`${this.baseUrl}/${tenantId}/training/models/${inventoryProductId}/active`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model metrics
|
||||
* GET /tenants/{tenant_id}/training/models/{model_id}/metrics
|
||||
*/
|
||||
async getModelMetrics(
|
||||
tenantId: string,
|
||||
modelId: string
|
||||
): Promise<ModelMetricsResponse> {
|
||||
return apiClient.get<ModelMetricsResponse>(
|
||||
`${this.baseUrl}/${tenantId}/training/models/${modelId}/metrics`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List models with optional filters
|
||||
* GET /tenants/{tenant_id}/training/models
|
||||
*/
|
||||
async getModels(
|
||||
tenantId: string,
|
||||
queryParams?: ModelsQueryParams
|
||||
): Promise<PaginatedResponse<TrainedModelResponse>> {
|
||||
const params = new URLSearchParams();
|
||||
if (queryParams?.status) params.append('status', queryParams.status);
|
||||
if (queryParams?.model_type) params.append('model_type', queryParams.model_type);
|
||||
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
|
||||
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
|
||||
|
||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||
return apiClient.get<PaginatedResponse<TrainedModelResponse>>(
|
||||
`${this.baseUrl}/${tenantId}/training/models${queryString}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model performance metrics
|
||||
* Note: This endpoint might be deprecated - check backend for actual implementation
|
||||
*/
|
||||
async getModelPerformance(
|
||||
tenantId: string,
|
||||
modelId: string
|
||||
): Promise<ModelPerformanceResponse> {
|
||||
return apiClient.get<ModelPerformanceResponse>(
|
||||
`${this.baseUrl}/${tenantId}/training/models/${modelId}/performance`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all tenant models (Admin only)
|
||||
* DELETE /models/tenant/{tenant_id}
|
||||
*/
|
||||
async deleteAllTenantModels(tenantId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`/models/tenant/${tenantId}`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// WebSocket Support
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get WebSocket URL for real-time training updates
|
||||
*/
|
||||
getTrainingWebSocketUrl(tenantId: string, jobId: string): string {
|
||||
const baseWsUrl = apiClient.getAxiosInstance().defaults.baseURL
|
||||
?.replace(/^http(s?):/, 'ws$1:'); // http: → ws:, https: → wss:
|
||||
return `${baseWsUrl}/tenants/${tenantId}/training/jobs/${jobId}/live`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper method to construct WebSocket connection
|
||||
*/
|
||||
createWebSocketConnection(
|
||||
tenantId: string,
|
||||
jobId: string,
|
||||
token?: string
|
||||
): WebSocket {
|
||||
const wsUrl = this.getTrainingWebSocketUrl(tenantId, jobId);
|
||||
const urlWithToken = token ? `${wsUrl}?token=${token}` : wsUrl;
|
||||
|
||||
return new WebSocket(urlWithToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
export const trainingService = new TrainingService();
|
||||
export default trainingService;
|
||||
49
frontend/src/api/services/user.ts
Normal file
49
frontend/src/api/services/user.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* User Service - Mirror backend user endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import { UserResponse, UserUpdate } from '../types/auth';
|
||||
import { AdminDeleteRequest, AdminDeleteResponse } from '../types/user';
|
||||
|
||||
export class UserService {
|
||||
private readonly baseUrl = '/users';
|
||||
|
||||
async getCurrentUser(): Promise<UserResponse> {
|
||||
// Get current user ID from auth store
|
||||
const authStore = useAuthStore.getState();
|
||||
const userId = authStore.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('No authenticated user found');
|
||||
}
|
||||
|
||||
return apiClient.get<UserResponse>(`${this.baseUrl}/${userId}`);
|
||||
}
|
||||
|
||||
async updateUser(userId: string, updateData: UserUpdate): Promise<UserResponse> {
|
||||
return apiClient.put<UserResponse>(`${this.baseUrl}/${userId}`, updateData);
|
||||
}
|
||||
|
||||
async deleteUser(userId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${userId}`);
|
||||
}
|
||||
|
||||
// Admin operations
|
||||
async adminDeleteUser(deleteRequest: AdminDeleteRequest): Promise<AdminDeleteResponse> {
|
||||
return apiClient.post<AdminDeleteResponse>(`${this.baseUrl}/admin/delete`, deleteRequest);
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<UserResponse[]> {
|
||||
return apiClient.get<UserResponse[]>(`${this.baseUrl}/admin/all`);
|
||||
}
|
||||
|
||||
async getUserById(userId: string): Promise<UserResponse> {
|
||||
return apiClient.get<UserResponse>(`${this.baseUrl}/admin/${userId}`);
|
||||
}
|
||||
|
||||
async getUserActivity(userId: string): Promise<any> {
|
||||
return apiClient.get<any>(`/auth/users/${userId}/activity`);
|
||||
}
|
||||
}
|
||||
|
||||
export const userService = new UserService();
|
||||
Reference in New Issue
Block a user