Fix new services implementation 2
This commit is contained in:
@@ -392,6 +392,57 @@ private buildURL(endpoint: string): string {
|
|||||||
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
|
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw request that returns the Response object for binary data
|
||||||
|
*/
|
||||||
|
async getRaw(endpoint: string, config?: RequestConfig): Promise<Response> {
|
||||||
|
const url = this.buildURL(endpoint);
|
||||||
|
const modifiedConfig = await this.applyRequestInterceptors(config || {});
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...modifiedConfig.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchConfig: RequestInit = {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(modifiedConfig.timeout || apiConfig.timeout),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add query parameters
|
||||||
|
const urlWithParams = new URL(url);
|
||||||
|
if (modifiedConfig.params) {
|
||||||
|
Object.entries(modifiedConfig.params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
urlWithParams.searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(urlWithParams.toString(), fetchConfig);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
let errorData: ApiError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
errorData = JSON.parse(errorText);
|
||||||
|
} catch {
|
||||||
|
errorData = {
|
||||||
|
message: `HTTP ${response.status}: ${response.statusText}`,
|
||||||
|
detail: errorText,
|
||||||
|
code: `HTTP_${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error(errorData.message || 'Request failed');
|
||||||
|
(error as any).response = { status: response.status, data: errorData };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File upload with progress tracking
|
* File upload with progress tracking
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ interface UseInventoryItemReturn {
|
|||||||
// ========== MAIN INVENTORY HOOK ==========
|
// ========== MAIN INVENTORY HOOK ==========
|
||||||
|
|
||||||
export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
||||||
const tenantId = useTenantId();
|
const { tenantId } = useTenantId();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||||
@@ -373,7 +373,7 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
|||||||
// ========== DASHBOARD HOOK ==========
|
// ========== DASHBOARD HOOK ==========
|
||||||
|
|
||||||
export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
|
export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
|
||||||
const tenantId = useTenantId();
|
const { tenantId } = useTenantId();
|
||||||
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
||||||
const [alerts, setAlerts] = useState<StockAlert[]>([]);
|
const [alerts, setAlerts] = useState<StockAlert[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -419,7 +419,7 @@ export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
|
|||||||
// ========== SINGLE ITEM HOOK ==========
|
// ========== SINGLE ITEM HOOK ==========
|
||||||
|
|
||||||
export const useInventoryItem = (itemId: string): UseInventoryItemReturn => {
|
export const useInventoryItem = (itemId: string): UseInventoryItemReturn => {
|
||||||
const tenantId = useTenantId();
|
const { tenantId } = useTenantId();
|
||||||
const [item, setItem] = useState<InventoryItem | null>(null);
|
const [item, setItem] = useState<InventoryItem | null>(null);
|
||||||
const [stockLevel, setStockLevel] = useState<StockLevel | null>(null);
|
const [stockLevel, setStockLevel] = useState<StockLevel | null>(null);
|
||||||
const [recentMovements, setRecentMovements] = useState<StockMovement[]>([]);
|
const [recentMovements, setRecentMovements] = useState<StockMovement[]>([]);
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ import {
|
|||||||
} from '../services/suppliers.service';
|
} from '../services/suppliers.service';
|
||||||
import { useAuth } from './useAuth';
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
// Re-export types for component use
|
||||||
|
export type {
|
||||||
|
Supplier,
|
||||||
|
SupplierSummary,
|
||||||
|
CreateSupplierRequest,
|
||||||
|
UpdateSupplierRequest,
|
||||||
|
SupplierSearchParams,
|
||||||
|
SupplierStatistics,
|
||||||
|
PurchaseOrder,
|
||||||
|
CreatePurchaseOrderRequest,
|
||||||
|
PurchaseOrderSearchParams,
|
||||||
|
PurchaseOrderStatistics,
|
||||||
|
Delivery,
|
||||||
|
DeliverySearchParams,
|
||||||
|
DeliveryPerformanceStats
|
||||||
|
} from '../services/suppliers.service';
|
||||||
|
|
||||||
const suppliersService = new SuppliersService();
|
const suppliersService = new SuppliersService();
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -196,13 +213,13 @@ export function useSuppliers(): UseSuppliers {
|
|||||||
|
|
||||||
// Create supplier
|
// Create supplier
|
||||||
const createSupplier = useCallback(async (data: CreateSupplierRequest): Promise<Supplier | null> => {
|
const createSupplier = useCallback(async (data: CreateSupplierRequest): Promise<Supplier | null> => {
|
||||||
if (!user?.tenant_id || !user?.user_id) return null;
|
if (!user?.tenant_id || !user?.id) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const supplier = await suppliersService.createSupplier(user.tenant_id, user.user_id, data);
|
const supplier = await suppliersService.createSupplier(user.tenant_id, user.id, data);
|
||||||
|
|
||||||
// Refresh suppliers list
|
// Refresh suppliers list
|
||||||
await loadSuppliers(currentParams);
|
await loadSuppliers(currentParams);
|
||||||
@@ -217,17 +234,17 @@ export function useSuppliers(): UseSuppliers {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
}, [user?.tenant_id, user?.user_id, loadSuppliers, loadStatistics, currentParams]);
|
}, [user?.tenant_id, user?.id, loadSuppliers, loadStatistics, currentParams]);
|
||||||
|
|
||||||
// Update supplier
|
// Update supplier
|
||||||
const updateSupplier = useCallback(async (supplierId: string, data: UpdateSupplierRequest): Promise<Supplier | null> => {
|
const updateSupplier = useCallback(async (supplierId: string, data: UpdateSupplierRequest): Promise<Supplier | null> => {
|
||||||
if (!user?.tenant_id || !user?.user_id) return null;
|
if (!user?.tenant_id || !user?.id) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const updatedSupplier = await suppliersService.updateSupplier(user.tenant_id, user.user_id, supplierId, data);
|
const updatedSupplier = await suppliersService.updateSupplier(user.tenant_id, user.id, supplierId, data);
|
||||||
|
|
||||||
// Update current supplier if it's the one being edited
|
// Update current supplier if it's the one being edited
|
||||||
if (supplier?.id === supplierId) {
|
if (supplier?.id === supplierId) {
|
||||||
@@ -246,7 +263,7 @@ export function useSuppliers(): UseSuppliers {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
}
|
}
|
||||||
}, [user?.tenant_id, user?.user_id, supplier?.id, loadSuppliers, currentParams]);
|
}, [user?.tenant_id, user?.id, supplier?.id, loadSuppliers, currentParams]);
|
||||||
|
|
||||||
// Delete supplier
|
// Delete supplier
|
||||||
const deleteSupplier = useCallback(async (supplierId: string): Promise<boolean> => {
|
const deleteSupplier = useCallback(async (supplierId: string): Promise<boolean> => {
|
||||||
@@ -277,12 +294,12 @@ export function useSuppliers(): UseSuppliers {
|
|||||||
|
|
||||||
// Approve/reject supplier
|
// Approve/reject supplier
|
||||||
const approveSupplier = useCallback(async (supplierId: string, action: 'approve' | 'reject', notes?: string): Promise<Supplier | null> => {
|
const approveSupplier = useCallback(async (supplierId: string, action: 'approve' | 'reject', notes?: string): Promise<Supplier | null> => {
|
||||||
if (!user?.tenant_id || !user?.user_id) return null;
|
if (!user?.tenant_id || !user?.id) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const updatedSupplier = await suppliersService.approveSupplier(user.tenant_id, user.user_id, supplierId, action, notes);
|
const updatedSupplier = await suppliersService.approveSupplier(user.tenant_id, user.id, supplierId, action, notes);
|
||||||
|
|
||||||
// Update current supplier if it's the one being approved/rejected
|
// Update current supplier if it's the one being approved/rejected
|
||||||
if (supplier?.id === supplierId) {
|
if (supplier?.id === supplierId) {
|
||||||
@@ -300,7 +317,7 @@ export function useSuppliers(): UseSuppliers {
|
|||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [user?.tenant_id, user?.user_id, supplier?.id, loadSuppliers, loadStatistics, currentParams]);
|
}, [user?.tenant_id, user?.id, supplier?.id, loadSuppliers, loadStatistics, currentParams]);
|
||||||
|
|
||||||
// Clear error
|
// Clear error
|
||||||
const clearError = useCallback(() => {
|
const clearError = useCallback(() => {
|
||||||
@@ -504,13 +521,13 @@ export function usePurchaseOrders(): UsePurchaseOrders {
|
|||||||
}, [user?.tenant_id]);
|
}, [user?.tenant_id]);
|
||||||
|
|
||||||
const createPurchaseOrder = useCallback(async (data: CreatePurchaseOrderRequest): Promise<PurchaseOrder | null> => {
|
const createPurchaseOrder = useCallback(async (data: CreatePurchaseOrderRequest): Promise<PurchaseOrder | null> => {
|
||||||
if (!user?.tenant_id || !user?.user_id) return null;
|
if (!user?.tenant_id || !user?.id) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const order = await suppliersService.createPurchaseOrder(user.tenant_id, user.user_id, data);
|
const order = await suppliersService.createPurchaseOrder(user.tenant_id, user.id, data);
|
||||||
|
|
||||||
// Refresh orders list
|
// Refresh orders list
|
||||||
await loadPurchaseOrders(currentParams);
|
await loadPurchaseOrders(currentParams);
|
||||||
@@ -525,15 +542,15 @@ export function usePurchaseOrders(): UsePurchaseOrders {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
}, [user?.tenant_id, user?.user_id, loadPurchaseOrders, loadStatistics, currentParams]);
|
}, [user?.tenant_id, user?.id, loadPurchaseOrders, loadStatistics, currentParams]);
|
||||||
|
|
||||||
const updateOrderStatus = useCallback(async (poId: string, status: string, notes?: string): Promise<PurchaseOrder | null> => {
|
const updateOrderStatus = useCallback(async (poId: string, status: string, notes?: string): Promise<PurchaseOrder | null> => {
|
||||||
if (!user?.tenant_id || !user?.user_id) return null;
|
if (!user?.tenant_id || !user?.id) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const updatedOrder = await suppliersService.updatePurchaseOrderStatus(user.tenant_id, user.user_id, poId, status, notes);
|
const updatedOrder = await suppliersService.updatePurchaseOrderStatus(user.tenant_id, user.id, poId, status, notes);
|
||||||
|
|
||||||
if (purchaseOrder?.id === poId) {
|
if (purchaseOrder?.id === poId) {
|
||||||
setPurchaseOrder(updatedOrder);
|
setPurchaseOrder(updatedOrder);
|
||||||
@@ -548,15 +565,15 @@ export function usePurchaseOrders(): UsePurchaseOrders {
|
|||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
|
}, [user?.tenant_id, user?.id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
|
||||||
|
|
||||||
const approveOrder = useCallback(async (poId: string, action: 'approve' | 'reject', notes?: string): Promise<PurchaseOrder | null> => {
|
const approveOrder = useCallback(async (poId: string, action: 'approve' | 'reject', notes?: string): Promise<PurchaseOrder | null> => {
|
||||||
if (!user?.tenant_id || !user?.user_id) return null;
|
if (!user?.tenant_id || !user?.id) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const updatedOrder = await suppliersService.approvePurchaseOrder(user.tenant_id, user.user_id, poId, action, notes);
|
const updatedOrder = await suppliersService.approvePurchaseOrder(user.tenant_id, user.id, poId, action, notes);
|
||||||
|
|
||||||
if (purchaseOrder?.id === poId) {
|
if (purchaseOrder?.id === poId) {
|
||||||
setPurchaseOrder(updatedOrder);
|
setPurchaseOrder(updatedOrder);
|
||||||
@@ -572,15 +589,15 @@ export function usePurchaseOrders(): UsePurchaseOrders {
|
|||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, loadOrdersRequiringApproval, currentParams]);
|
}, [user?.tenant_id, user?.id, purchaseOrder?.id, loadPurchaseOrders, loadOrdersRequiringApproval, currentParams]);
|
||||||
|
|
||||||
const sendToSupplier = useCallback(async (poId: string, sendEmail: boolean = true): Promise<PurchaseOrder | null> => {
|
const sendToSupplier = useCallback(async (poId: string, sendEmail: boolean = true): Promise<PurchaseOrder | null> => {
|
||||||
if (!user?.tenant_id || !user?.user_id) return null;
|
if (!user?.tenant_id || !user?.id) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const updatedOrder = await suppliersService.sendToSupplier(user.tenant_id, user.user_id, poId, sendEmail);
|
const updatedOrder = await suppliersService.sendToSupplier(user.tenant_id, user.id, poId, sendEmail);
|
||||||
|
|
||||||
if (purchaseOrder?.id === poId) {
|
if (purchaseOrder?.id === poId) {
|
||||||
setPurchaseOrder(updatedOrder);
|
setPurchaseOrder(updatedOrder);
|
||||||
@@ -595,15 +612,15 @@ export function usePurchaseOrders(): UsePurchaseOrders {
|
|||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
|
}, [user?.tenant_id, user?.id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
|
||||||
|
|
||||||
const cancelOrder = useCallback(async (poId: string, reason: string): Promise<PurchaseOrder | null> => {
|
const cancelOrder = useCallback(async (poId: string, reason: string): Promise<PurchaseOrder | null> => {
|
||||||
if (!user?.tenant_id || !user?.user_id) return null;
|
if (!user?.tenant_id || !user?.id) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const updatedOrder = await suppliersService.cancelPurchaseOrder(user.tenant_id, user.user_id, poId, reason);
|
const updatedOrder = await suppliersService.cancelPurchaseOrder(user.tenant_id, user.id, poId, reason);
|
||||||
|
|
||||||
if (purchaseOrder?.id === poId) {
|
if (purchaseOrder?.id === poId) {
|
||||||
setPurchaseOrder(updatedOrder);
|
setPurchaseOrder(updatedOrder);
|
||||||
@@ -618,7 +635,7 @@ export function usePurchaseOrders(): UsePurchaseOrders {
|
|||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
|
}, [user?.tenant_id, user?.id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
const clearError = useCallback(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -804,12 +821,12 @@ export function useDeliveries(): UseDeliveries {
|
|||||||
}, [user?.tenant_id]);
|
}, [user?.tenant_id]);
|
||||||
|
|
||||||
const updateDeliveryStatus = useCallback(async (deliveryId: string, status: string, notes?: string): Promise<Delivery | null> => {
|
const updateDeliveryStatus = useCallback(async (deliveryId: string, status: string, notes?: string): Promise<Delivery | null> => {
|
||||||
if (!user?.tenant_id || !user?.user_id) return null;
|
if (!user?.tenant_id || !user?.id) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const updatedDelivery = await suppliersService.updateDeliveryStatus(user.tenant_id, user.user_id, deliveryId, status, notes);
|
const updatedDelivery = await suppliersService.updateDeliveryStatus(user.tenant_id, user.id, deliveryId, status, notes);
|
||||||
|
|
||||||
if (delivery?.id === deliveryId) {
|
if (delivery?.id === deliveryId) {
|
||||||
setDelivery(updatedDelivery);
|
setDelivery(updatedDelivery);
|
||||||
@@ -824,15 +841,15 @@ export function useDeliveries(): UseDeliveries {
|
|||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [user?.tenant_id, user?.user_id, delivery?.id, loadDeliveries, currentParams]);
|
}, [user?.tenant_id, user?.id, delivery?.id, loadDeliveries, currentParams]);
|
||||||
|
|
||||||
const receiveDelivery = useCallback(async (deliveryId: string, receiptData: any): Promise<Delivery | null> => {
|
const receiveDelivery = useCallback(async (deliveryId: string, receiptData: any): Promise<Delivery | null> => {
|
||||||
if (!user?.tenant_id || !user?.user_id) return null;
|
if (!user?.tenant_id || !user?.id) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const updatedDelivery = await suppliersService.receiveDelivery(user.tenant_id, user.user_id, deliveryId, receiptData);
|
const updatedDelivery = await suppliersService.receiveDelivery(user.tenant_id, user.id, deliveryId, receiptData);
|
||||||
|
|
||||||
if (delivery?.id === deliveryId) {
|
if (delivery?.id === deliveryId) {
|
||||||
setDelivery(updatedDelivery);
|
setDelivery(updatedDelivery);
|
||||||
@@ -847,7 +864,7 @@ export function useDeliveries(): UseDeliveries {
|
|||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [user?.tenant_id, user?.user_id, delivery?.id, loadDeliveries, currentParams]);
|
}, [user?.tenant_id, user?.id, delivery?.id, loadDeliveries, currentParams]);
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
const clearError = useCallback(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface InventoryItem {
|
|||||||
supplier?: string;
|
supplier?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
barcode?: string;
|
barcode?: string;
|
||||||
|
sku?: string;
|
||||||
cost_per_unit?: number;
|
cost_per_unit?: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -211,7 +211,20 @@ export class OnboardingService {
|
|||||||
suggestions: suggestions.map(s => ({
|
suggestions: suggestions.map(s => ({
|
||||||
suggestion_id: s.suggestion_id,
|
suggestion_id: s.suggestion_id,
|
||||||
approved: s.user_approved ?? true,
|
approved: s.user_approved ?? true,
|
||||||
modifications: s.user_modifications || {}
|
modifications: s.user_modifications || {},
|
||||||
|
// Include full suggestion data for backend processing
|
||||||
|
original_name: s.original_name,
|
||||||
|
suggested_name: s.suggested_name,
|
||||||
|
product_type: s.product_type,
|
||||||
|
category: s.category,
|
||||||
|
unit_of_measure: s.unit_of_measure,
|
||||||
|
confidence_score: s.confidence_score,
|
||||||
|
estimated_shelf_life_days: s.estimated_shelf_life_days,
|
||||||
|
requires_refrigeration: s.requires_refrigeration,
|
||||||
|
requires_freezing: s.requires_freezing,
|
||||||
|
is_seasonal: s.is_seasonal,
|
||||||
|
suggested_supplier: s.suggested_supplier,
|
||||||
|
notes: s.notes
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,14 +367,14 @@ export class RecipesService {
|
|||||||
headers: { 'X-Tenant-ID': tenantId },
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
params
|
params
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecipe(tenantId: string, recipeId: string): Promise<Recipe> {
|
async getRecipe(tenantId: string, recipeId: string): Promise<Recipe> {
|
||||||
const response = await apiClient.get<Recipe>(`${this.baseUrl}/recipes/${recipeId}`, {
|
const response = await apiClient.get<Recipe>(`${this.baseUrl}/recipes/${recipeId}`, {
|
||||||
headers: { 'X-Tenant-ID': tenantId }
|
headers: { 'X-Tenant-ID': tenantId }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRecipe(tenantId: string, userId: string, data: CreateRecipeRequest): Promise<Recipe> {
|
async createRecipe(tenantId: string, userId: string, data: CreateRecipeRequest): Promise<Recipe> {
|
||||||
@@ -384,7 +384,7 @@ export class RecipesService {
|
|||||||
'X-User-ID': userId
|
'X-User-ID': userId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateRecipe(tenantId: string, userId: string, recipeId: string, data: UpdateRecipeRequest): Promise<Recipe> {
|
async updateRecipe(tenantId: string, userId: string, recipeId: string, data: UpdateRecipeRequest): Promise<Recipe> {
|
||||||
@@ -394,7 +394,7 @@ export class RecipesService {
|
|||||||
'X-User-ID': userId
|
'X-User-ID': userId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRecipe(tenantId: string, recipeId: string): Promise<void> {
|
async deleteRecipe(tenantId: string, recipeId: string): Promise<void> {
|
||||||
@@ -413,7 +413,7 @@ export class RecipesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async activateRecipe(tenantId: string, userId: string, recipeId: string): Promise<Recipe> {
|
async activateRecipe(tenantId: string, userId: string, recipeId: string): Promise<Recipe> {
|
||||||
@@ -423,7 +423,7 @@ export class RecipesService {
|
|||||||
'X-User-ID': userId
|
'X-User-ID': userId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibility> {
|
async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibility> {
|
||||||
@@ -431,21 +431,21 @@ export class RecipesService {
|
|||||||
headers: { 'X-Tenant-ID': tenantId },
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
params: { batch_multiplier: batchMultiplier }
|
params: { batch_multiplier: batchMultiplier }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecipeStatistics(tenantId: string): Promise<RecipeStatistics> {
|
async getRecipeStatistics(tenantId: string): Promise<RecipeStatistics> {
|
||||||
const response = await apiClient.get<RecipeStatistics>(`${this.baseUrl}/recipes/statistics/dashboard`, {
|
const response = await apiClient.get<RecipeStatistics>(`${this.baseUrl}/recipes/statistics/dashboard`, {
|
||||||
headers: { 'X-Tenant-ID': tenantId }
|
headers: { 'X-Tenant-ID': tenantId }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecipeCategories(tenantId: string): Promise<string[]> {
|
async getRecipeCategories(tenantId: string): Promise<string[]> {
|
||||||
const response = await apiClient.get<{ categories: string[] }>(`${this.baseUrl}/recipes/categories/list`, {
|
const response = await apiClient.get<{ categories: string[] }>(`${this.baseUrl}/recipes/categories/list`, {
|
||||||
headers: { 'X-Tenant-ID': tenantId }
|
headers: { 'X-Tenant-ID': tenantId }
|
||||||
});
|
});
|
||||||
return response.data.categories;
|
return response.categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production Management
|
// Production Management
|
||||||
@@ -454,14 +454,14 @@ export class RecipesService {
|
|||||||
headers: { 'X-Tenant-ID': tenantId },
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
params
|
params
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProductionBatch(tenantId: string, batchId: string): Promise<ProductionBatch> {
|
async getProductionBatch(tenantId: string, batchId: string): Promise<ProductionBatch> {
|
||||||
const response = await apiClient.get<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}`, {
|
const response = await apiClient.get<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}`, {
|
||||||
headers: { 'X-Tenant-ID': tenantId }
|
headers: { 'X-Tenant-ID': tenantId }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createProductionBatch(tenantId: string, userId: string, data: CreateProductionBatchRequest): Promise<ProductionBatch> {
|
async createProductionBatch(tenantId: string, userId: string, data: CreateProductionBatchRequest): Promise<ProductionBatch> {
|
||||||
@@ -471,7 +471,7 @@ export class RecipesService {
|
|||||||
'X-User-ID': userId
|
'X-User-ID': userId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProductionBatch(tenantId: string, userId: string, batchId: string, data: UpdateProductionBatchRequest): Promise<ProductionBatch> {
|
async updateProductionBatch(tenantId: string, userId: string, batchId: string, data: UpdateProductionBatchRequest): Promise<ProductionBatch> {
|
||||||
@@ -481,7 +481,7 @@ export class RecipesService {
|
|||||||
'X-User-ID': userId
|
'X-User-ID': userId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteProductionBatch(tenantId: string, batchId: string): Promise<void> {
|
async deleteProductionBatch(tenantId: string, batchId: string): Promise<void> {
|
||||||
@@ -494,7 +494,7 @@ export class RecipesService {
|
|||||||
const response = await apiClient.get<ProductionBatch[]>(`${this.baseUrl}/production/batches/active/list`, {
|
const response = await apiClient.get<ProductionBatch[]>(`${this.baseUrl}/production/batches/active/list`, {
|
||||||
headers: { 'X-Tenant-ID': tenantId }
|
headers: { 'X-Tenant-ID': tenantId }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async startProductionBatch(tenantId: string, userId: string, batchId: string, data: {
|
async startProductionBatch(tenantId: string, userId: string, batchId: string, data: {
|
||||||
@@ -519,7 +519,7 @@ export class RecipesService {
|
|||||||
'X-User-ID': userId
|
'X-User-ID': userId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeProductionBatch(tenantId: string, userId: string, batchId: string, data: {
|
async completeProductionBatch(tenantId: string, userId: string, batchId: string, data: {
|
||||||
@@ -538,7 +538,7 @@ export class RecipesService {
|
|||||||
'X-User-ID': userId
|
'X-User-ID': userId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProductionStatistics(tenantId: string, startDate?: string, endDate?: string): Promise<ProductionStatistics> {
|
async getProductionStatistics(tenantId: string, startDate?: string, endDate?: string): Promise<ProductionStatistics> {
|
||||||
@@ -546,6 +546,6 @@ export class RecipesService {
|
|||||||
headers: { 'X-Tenant-ID': tenantId },
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
params: { start_date: startDate, end_date: endDate }
|
params: { start_date: startDate, end_date: endDate }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,7 +361,7 @@ export class SuppliersService {
|
|||||||
`${this.baseUrl}?${searchParams.toString()}`,
|
`${this.baseUrl}?${searchParams.toString()}`,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSupplier(tenantId: string, supplierId: string): Promise<Supplier> {
|
async getSupplier(tenantId: string, supplierId: string): Promise<Supplier> {
|
||||||
@@ -369,7 +369,7 @@ export class SuppliersService {
|
|||||||
`${this.baseUrl}/${supplierId}`,
|
`${this.baseUrl}/${supplierId}`,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSupplier(tenantId: string, userId: string, data: CreateSupplierRequest): Promise<Supplier> {
|
async createSupplier(tenantId: string, userId: string, data: CreateSupplierRequest): Promise<Supplier> {
|
||||||
@@ -378,7 +378,7 @@ export class SuppliersService {
|
|||||||
data,
|
data,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSupplier(tenantId: string, userId: string, supplierId: string, data: UpdateSupplierRequest): Promise<Supplier> {
|
async updateSupplier(tenantId: string, userId: string, supplierId: string, data: UpdateSupplierRequest): Promise<Supplier> {
|
||||||
@@ -387,7 +387,7 @@ export class SuppliersService {
|
|||||||
data,
|
data,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSupplier(tenantId: string, supplierId: string): Promise<void> {
|
async deleteSupplier(tenantId: string, supplierId: string): Promise<void> {
|
||||||
@@ -403,7 +403,7 @@ export class SuppliersService {
|
|||||||
{ action, notes },
|
{ action, notes },
|
||||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supplier Analytics & Lists
|
// Supplier Analytics & Lists
|
||||||
@@ -412,7 +412,7 @@ export class SuppliersService {
|
|||||||
`${this.baseUrl}/statistics`,
|
`${this.baseUrl}/statistics`,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActiveSuppliers(tenantId: string): Promise<SupplierSummary[]> {
|
async getActiveSuppliers(tenantId: string): Promise<SupplierSummary[]> {
|
||||||
@@ -420,7 +420,7 @@ export class SuppliersService {
|
|||||||
`${this.baseUrl}/active`,
|
`${this.baseUrl}/active`,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTopSuppliers(tenantId: string, limit: number = 10): Promise<SupplierSummary[]> {
|
async getTopSuppliers(tenantId: string, limit: number = 10): Promise<SupplierSummary[]> {
|
||||||
@@ -428,7 +428,7 @@ export class SuppliersService {
|
|||||||
`${this.baseUrl}/top?limit=${limit}`,
|
`${this.baseUrl}/top?limit=${limit}`,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSuppliersByType(tenantId: string, supplierType: string): Promise<SupplierSummary[]> {
|
async getSuppliersByType(tenantId: string, supplierType: string): Promise<SupplierSummary[]> {
|
||||||
@@ -436,7 +436,7 @@ export class SuppliersService {
|
|||||||
`${this.baseUrl}/types/${supplierType}`,
|
`${this.baseUrl}/types/${supplierType}`,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSuppliersNeedingReview(tenantId: string, daysSinceLastOrder: number = 30): Promise<SupplierSummary[]> {
|
async getSuppliersNeedingReview(tenantId: string, daysSinceLastOrder: number = 30): Promise<SupplierSummary[]> {
|
||||||
@@ -444,7 +444,7 @@ export class SuppliersService {
|
|||||||
`${this.baseUrl}/pending-review?days_since_last_order=${daysSinceLastOrder}`,
|
`${this.baseUrl}/pending-review?days_since_last_order=${daysSinceLastOrder}`,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purchase Orders
|
// Purchase Orders
|
||||||
@@ -463,7 +463,7 @@ export class SuppliersService {
|
|||||||
`/api/v1/purchase-orders?${searchParams.toString()}`,
|
`/api/v1/purchase-orders?${searchParams.toString()}`,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPurchaseOrder(tenantId: string, poId: string): Promise<PurchaseOrder> {
|
async getPurchaseOrder(tenantId: string, poId: string): Promise<PurchaseOrder> {
|
||||||
@@ -471,7 +471,7 @@ export class SuppliersService {
|
|||||||
`/api/v1/purchase-orders/${poId}`,
|
`/api/v1/purchase-orders/${poId}`,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPurchaseOrder(tenantId: string, userId: string, data: CreatePurchaseOrderRequest): Promise<PurchaseOrder> {
|
async createPurchaseOrder(tenantId: string, userId: string, data: CreatePurchaseOrderRequest): Promise<PurchaseOrder> {
|
||||||
@@ -480,7 +480,7 @@ export class SuppliersService {
|
|||||||
data,
|
data,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePurchaseOrderStatus(tenantId: string, userId: string, poId: string, status: string, notes?: string): Promise<PurchaseOrder> {
|
async updatePurchaseOrderStatus(tenantId: string, userId: string, poId: string, status: string, notes?: string): Promise<PurchaseOrder> {
|
||||||
@@ -489,7 +489,7 @@ export class SuppliersService {
|
|||||||
{ status, notes },
|
{ status, notes },
|
||||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async approvePurchaseOrder(tenantId: string, userId: string, poId: string, action: 'approve' | 'reject', notes?: string): Promise<PurchaseOrder> {
|
async approvePurchaseOrder(tenantId: string, userId: string, poId: string, action: 'approve' | 'reject', notes?: string): Promise<PurchaseOrder> {
|
||||||
@@ -498,7 +498,7 @@ export class SuppliersService {
|
|||||||
{ action, notes },
|
{ action, notes },
|
||||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendToSupplier(tenantId: string, userId: string, poId: string, sendEmail: boolean = true): Promise<PurchaseOrder> {
|
async sendToSupplier(tenantId: string, userId: string, poId: string, sendEmail: boolean = true): Promise<PurchaseOrder> {
|
||||||
@@ -507,7 +507,7 @@ export class SuppliersService {
|
|||||||
{},
|
{},
|
||||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelPurchaseOrder(tenantId: string, userId: string, poId: string, reason: string): Promise<PurchaseOrder> {
|
async cancelPurchaseOrder(tenantId: string, userId: string, poId: string, reason: string): Promise<PurchaseOrder> {
|
||||||
@@ -516,7 +516,7 @@ export class SuppliersService {
|
|||||||
{},
|
{},
|
||||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPurchaseOrderStatistics(tenantId: string): Promise<PurchaseOrderStatistics> {
|
async getPurchaseOrderStatistics(tenantId: string): Promise<PurchaseOrderStatistics> {
|
||||||
@@ -524,7 +524,7 @@ export class SuppliersService {
|
|||||||
'/api/v1/purchase-orders/statistics',
|
'/api/v1/purchase-orders/statistics',
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOrdersRequiringApproval(tenantId: string): Promise<PurchaseOrder[]> {
|
async getOrdersRequiringApproval(tenantId: string): Promise<PurchaseOrder[]> {
|
||||||
@@ -532,7 +532,7 @@ export class SuppliersService {
|
|||||||
'/api/v1/purchase-orders/pending-approval',
|
'/api/v1/purchase-orders/pending-approval',
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOverdueOrders(tenantId: string): Promise<PurchaseOrder[]> {
|
async getOverdueOrders(tenantId: string): Promise<PurchaseOrder[]> {
|
||||||
@@ -540,7 +540,7 @@ export class SuppliersService {
|
|||||||
'/api/v1/purchase-orders/overdue',
|
'/api/v1/purchase-orders/overdue',
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliveries
|
// Deliveries
|
||||||
@@ -558,7 +558,7 @@ export class SuppliersService {
|
|||||||
`/api/v1/deliveries?${searchParams.toString()}`,
|
`/api/v1/deliveries?${searchParams.toString()}`,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDelivery(tenantId: string, deliveryId: string): Promise<Delivery> {
|
async getDelivery(tenantId: string, deliveryId: string): Promise<Delivery> {
|
||||||
@@ -566,7 +566,7 @@ export class SuppliersService {
|
|||||||
`/api/v1/deliveries/${deliveryId}`,
|
`/api/v1/deliveries/${deliveryId}`,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTodaysDeliveries(tenantId: string): Promise<Delivery[]> {
|
async getTodaysDeliveries(tenantId: string): Promise<Delivery[]> {
|
||||||
@@ -574,7 +574,7 @@ export class SuppliersService {
|
|||||||
'/api/v1/deliveries/today',
|
'/api/v1/deliveries/today',
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOverdueDeliveries(tenantId: string): Promise<Delivery[]> {
|
async getOverdueDeliveries(tenantId: string): Promise<Delivery[]> {
|
||||||
@@ -582,7 +582,7 @@ export class SuppliersService {
|
|||||||
'/api/v1/deliveries/overdue',
|
'/api/v1/deliveries/overdue',
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateDeliveryStatus(tenantId: string, userId: string, deliveryId: string, status: string, notes?: string): Promise<Delivery> {
|
async updateDeliveryStatus(tenantId: string, userId: string, deliveryId: string, status: string, notes?: string): Promise<Delivery> {
|
||||||
@@ -591,7 +591,7 @@ export class SuppliersService {
|
|||||||
{ status, notes, update_timestamps: true },
|
{ status, notes, update_timestamps: true },
|
||||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async receiveDelivery(tenantId: string, userId: string, deliveryId: string, receiptData: {
|
async receiveDelivery(tenantId: string, userId: string, deliveryId: string, receiptData: {
|
||||||
@@ -605,7 +605,7 @@ export class SuppliersService {
|
|||||||
receiptData,
|
receiptData,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeliveryPerformanceStats(tenantId: string, daysBack: number = 30, supplierId?: string): Promise<DeliveryPerformanceStats> {
|
async getDeliveryPerformanceStats(tenantId: string, daysBack: number = 30, supplierId?: string): Promise<DeliveryPerformanceStats> {
|
||||||
@@ -617,6 +617,6 @@ export class SuppliersService {
|
|||||||
`/api/v1/deliveries/performance-stats?${params.toString()}`,
|
`/api/v1/deliveries/performance-stats?${params.toString()}`,
|
||||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,4 +39,16 @@ export interface BaseQueryParams {
|
|||||||
search?: string;
|
search?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
order?: 'asc' | 'desc';
|
order?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateResponse<T = any> {
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateResponse<T = any> {
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
status: string;
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,15 @@ export interface SalesData {
|
|||||||
source: string;
|
source: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
external_factors?: ExternalFactors;
|
external_factors?: ExternalFactors;
|
||||||
|
// Additional properties used by components
|
||||||
|
sales_channel?: string;
|
||||||
|
is_validated?: boolean;
|
||||||
|
cost_of_goods?: number;
|
||||||
|
revenue?: number;
|
||||||
|
quantity_sold?: number;
|
||||||
|
inventory_product_id?: string;
|
||||||
|
discount_applied?: number;
|
||||||
|
weather_condition?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SalesValidationResult {
|
export interface SalesValidationResult {
|
||||||
@@ -53,6 +62,10 @@ export interface SalesDataQuery extends BaseQueryParams {
|
|||||||
max_quantity?: number;
|
max_quantity?: number;
|
||||||
min_revenue?: number;
|
min_revenue?: number;
|
||||||
max_revenue?: number;
|
max_revenue?: number;
|
||||||
|
search_term?: string;
|
||||||
|
sales_channel?: string;
|
||||||
|
inventory_product_id?: string;
|
||||||
|
is_validated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SalesDataImport {
|
export interface SalesDataImport {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface TenantInfo {
|
|||||||
settings?: TenantSettings;
|
settings?: TenantSettings;
|
||||||
subscription?: TenantSubscription;
|
subscription?: TenantSubscription;
|
||||||
location?: TenantLocation;
|
location?: TenantLocation;
|
||||||
|
business_type?: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
|
||||||
|
business_model?: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TenantSettings {
|
export interface TenantSettings {
|
||||||
@@ -62,7 +64,8 @@ export interface TenantSubscription {
|
|||||||
export interface TenantCreate {
|
export interface TenantCreate {
|
||||||
name: string;
|
name: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
business_type?: 'individual' | 'central_workshop';
|
business_type?: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
|
||||||
|
business_model?: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
|
||||||
postal_code: string;
|
postal_code: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
|
|||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: actualTenantId
|
url: actualTenantId
|
||||||
? `ws://localhost:8002/api/v1/ws/tenants/${actualTenantId}/training/jobs/${jobId}/live`
|
? `ws://localhost:8000/api/v1/ws/tenants/${actualTenantId}/training/jobs/${jobId}/live`
|
||||||
: `ws://localhost:8002/api/v1/ws/tenants/unknown/training/jobs/${jobId}/live`,
|
: `ws://localhost:8000/api/v1/ws/tenants/unknown/training/jobs/${jobId}/live`,
|
||||||
reconnect: true,
|
reconnect: true,
|
||||||
reconnectInterval: 3000,
|
reconnectInterval: 3000,
|
||||||
maxReconnectAttempts: 10
|
maxReconnectAttempts: 10
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Lightbulb
|
Lightbulb,
|
||||||
|
Building2,
|
||||||
|
Truck
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
@@ -236,6 +238,43 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
|||||||
|
|
||||||
const renderBusinessModelInsight = (analysis: BusinessModelAnalysis) => {
|
const renderBusinessModelInsight = (analysis: BusinessModelAnalysis) => {
|
||||||
const modelConfig = {
|
const modelConfig = {
|
||||||
|
individual_bakery: {
|
||||||
|
icon: Factory,
|
||||||
|
title: 'Panadería Individual',
|
||||||
|
description: 'Producción completa desde ingredientes básicos (harina, levadura, etc.)',
|
||||||
|
color: 'blue',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-200',
|
||||||
|
textColor: 'text-blue-900'
|
||||||
|
},
|
||||||
|
central_baker_satellite: {
|
||||||
|
icon: Truck,
|
||||||
|
title: 'Punto de Venta - Obrador Central',
|
||||||
|
description: 'Recibe productos semi-elaborados y los finaliza (horneado, decoración)',
|
||||||
|
color: 'amber',
|
||||||
|
bgColor: 'bg-amber-50',
|
||||||
|
borderColor: 'border-amber-200',
|
||||||
|
textColor: 'text-amber-900'
|
||||||
|
},
|
||||||
|
retail_bakery: {
|
||||||
|
icon: Store,
|
||||||
|
title: 'Panadería de Distribución',
|
||||||
|
description: 'Vende productos terminados de proveedores externos',
|
||||||
|
color: 'green',
|
||||||
|
bgColor: 'bg-green-50',
|
||||||
|
borderColor: 'border-green-200',
|
||||||
|
textColor: 'text-green-900'
|
||||||
|
},
|
||||||
|
hybrid_bakery: {
|
||||||
|
icon: Settings2,
|
||||||
|
title: 'Modelo Mixto',
|
||||||
|
description: 'Combina producción propia con productos de proveedores',
|
||||||
|
color: 'purple',
|
||||||
|
bgColor: 'bg-purple-50',
|
||||||
|
borderColor: 'border-purple-200',
|
||||||
|
textColor: 'text-purple-900'
|
||||||
|
},
|
||||||
|
// Legacy fallbacks
|
||||||
production: {
|
production: {
|
||||||
icon: Factory,
|
icon: Factory,
|
||||||
title: 'Panadería de Producción',
|
title: 'Panadería de Producción',
|
||||||
@@ -265,7 +304,8 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = modelConfig[analysis.model];
|
// Provide fallback if analysis.model is not in modelConfig
|
||||||
|
const config = modelConfig[analysis.model as keyof typeof modelConfig] || modelConfig.hybrid_bakery;
|
||||||
const IconComponent = config.icon;
|
const IconComponent = config.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ const SalesAnalyticsDashboard: React.FC = () => {
|
|||||||
} = useSales();
|
} = useSales();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ingredients: products,
|
items: products,
|
||||||
loadIngredients: loadProducts,
|
loadItems: loadProducts,
|
||||||
isLoading: inventoryLoading
|
isLoading: inventoryLoading
|
||||||
} = useInventory();
|
} = useInventory();
|
||||||
|
|
||||||
@@ -159,11 +159,14 @@ const SalesAnalyticsDashboard: React.FC = () => {
|
|||||||
|
|
||||||
// Top products
|
// Top products
|
||||||
const topProducts = Object.entries(productPerformance)
|
const topProducts = Object.entries(productPerformance)
|
||||||
.map(([productId, data]) => ({
|
.map(([productId, data]) => {
|
||||||
productId,
|
const perf = data as { revenue: number; units: number; orders: number };
|
||||||
...data as any,
|
return {
|
||||||
avgPrice: data.revenue / data.units
|
productId,
|
||||||
}))
|
...perf,
|
||||||
|
avgPrice: perf.revenue / perf.units
|
||||||
|
};
|
||||||
|
})
|
||||||
.sort((a, b) => b.revenue - a.revenue)
|
.sort((a, b) => b.revenue - a.revenue)
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
|
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ const SalesDashboardWidget: React.FC<SalesDashboardWidgetProps> = ({
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="font-semibold text-gray-900">Ventas de Hoy</h3>
|
<h3 className="font-semibold text-gray-900">Ventas de Hoy</h3>
|
||||||
{onViewAll && (
|
{onViewAll && (
|
||||||
<Button variant="ghost" size="sm" onClick={onViewAll}>
|
<Button variant="outline" size="sm" onClick={onViewAll}>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
MapPin,
|
MapPin,
|
||||||
Grid3X3,
|
Grid,
|
||||||
List,
|
List,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -51,8 +51,8 @@ const SalesManagementPage: React.FC = () => {
|
|||||||
} = useSales();
|
} = useSales();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ingredients: products,
|
items: products,
|
||||||
loadIngredients: loadProducts,
|
loadItems: loadProducts,
|
||||||
isLoading: inventoryLoading
|
isLoading: inventoryLoading
|
||||||
} = useInventory();
|
} = useInventory();
|
||||||
|
|
||||||
@@ -89,7 +89,9 @@ const SalesManagementPage: React.FC = () => {
|
|||||||
const loadSalesData = async () => {
|
const loadSalesData = async () => {
|
||||||
if (!user?.tenant_id) return;
|
if (!user?.tenant_id) return;
|
||||||
|
|
||||||
const query: SalesDataQuery = {};
|
const query: SalesDataQuery = {
|
||||||
|
tenant_id: user.tenant_id
|
||||||
|
};
|
||||||
|
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
query.search_term = filters.search;
|
query.search_term = filters.search;
|
||||||
@@ -155,7 +157,9 @@ const SalesManagementPage: React.FC = () => {
|
|||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
if (!user?.tenant_id) return;
|
if (!user?.tenant_id) return;
|
||||||
|
|
||||||
const query: SalesDataQuery = {};
|
const query: SalesDataQuery = {
|
||||||
|
tenant_id: user.tenant_id
|
||||||
|
};
|
||||||
if (filters.date_from) query.start_date = filters.date_from;
|
if (filters.date_from) query.start_date = filters.date_from;
|
||||||
if (filters.date_to) query.end_date = filters.date_to;
|
if (filters.date_to) query.end_date = filters.date_to;
|
||||||
if (filters.channel) query.sales_channel = filters.channel;
|
if (filters.channel) query.sales_channel = filters.channel;
|
||||||
@@ -386,7 +390,7 @@ const SalesManagementPage: React.FC = () => {
|
|||||||
onClick={() => setViewMode('grid')}
|
onClick={() => setViewMode('grid')}
|
||||||
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
||||||
>
|
>
|
||||||
<Grid3X3 className="w-4 h-4" />
|
<Grid className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Star,
|
Star,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
LightBulb,
|
Lightbulb,
|
||||||
Calendar,
|
Calendar,
|
||||||
Package
|
Package
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -53,10 +53,10 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
|||||||
} = useSales();
|
} = useSales();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
predictions,
|
forecasts,
|
||||||
loadPredictions,
|
getForecasts,
|
||||||
performance,
|
quickForecasts,
|
||||||
loadPerformance,
|
getQuickForecasts,
|
||||||
isLoading: forecastLoading
|
isLoading: forecastLoading
|
||||||
} = useForecast();
|
} = useForecast();
|
||||||
|
|
||||||
@@ -87,8 +87,8 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
|||||||
end_date: endDate,
|
end_date: endDate,
|
||||||
limit: 1000
|
limit: 1000
|
||||||
}),
|
}),
|
||||||
loadPredictions(),
|
getForecasts(user.tenant_id),
|
||||||
loadPerformance()
|
getQuickForecasts(user.tenant_id)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setSalesAnalytics(analytics);
|
setSalesAnalytics(analytics);
|
||||||
@@ -200,9 +200,9 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Forecasting insights
|
// Forecasting insights
|
||||||
if (predictions.length > 0) {
|
if (forecasts.length > 0) {
|
||||||
const todayPrediction = predictions.find(p => {
|
const todayPrediction = forecasts.find(p => {
|
||||||
const predDate = new Date(p.date).toDateString();
|
const predDate = new Date(p.forecast_date).toDateString();
|
||||||
const today = new Date().toDateString();
|
const today = new Date().toDateString();
|
||||||
return predDate === today;
|
return predDate === today;
|
||||||
});
|
});
|
||||||
@@ -213,8 +213,8 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
|||||||
type: 'forecast',
|
type: 'forecast',
|
||||||
title: 'Predicción para hoy',
|
title: 'Predicción para hoy',
|
||||||
description: `La IA predice ${todayPrediction.predicted_demand} unidades de demanda con ${
|
description: `La IA predice ${todayPrediction.predicted_demand} unidades de demanda con ${
|
||||||
todayPrediction.confidence === 'high' ? 'alta' :
|
(todayPrediction.confidence_level || 0) > 0.8 ? 'alta' :
|
||||||
todayPrediction.confidence === 'medium' ? 'media' : 'baja'
|
(todayPrediction.confidence_level || 0) > 0.6 ? 'media' : 'baja'
|
||||||
} confianza.`,
|
} confianza.`,
|
||||||
value: `${todayPrediction.predicted_demand} unidades`,
|
value: `${todayPrediction.predicted_demand} unidades`,
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
@@ -227,8 +227,9 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Performance vs forecast insight
|
// Performance vs forecast insight
|
||||||
if (performance) {
|
if (quickForecasts.length > 0) {
|
||||||
const accuracy = performance.accuracy || 0;
|
const latestForecast = quickForecasts[0];
|
||||||
|
const accuracy = latestForecast.confidence_score || 0;
|
||||||
if (accuracy > 85) {
|
if (accuracy > 85) {
|
||||||
insights.push({
|
insights.push({
|
||||||
id: 'forecast_accuracy',
|
id: 'forecast_accuracy',
|
||||||
@@ -288,7 +289,7 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
|||||||
// Sort by priority
|
// Sort by priority
|
||||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||||
return insights.sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]);
|
return insights.sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]);
|
||||||
}, [salesAnalytics, salesData, predictions, performance, onActionClick]);
|
}, [salesAnalytics, salesData, forecasts, quickForecasts, onActionClick]);
|
||||||
|
|
||||||
// Get insight icon
|
// Get insight icon
|
||||||
const getInsightIcon = (type: PerformanceInsight['type']) => {
|
const getInsightIcon = (type: PerformanceInsight['type']) => {
|
||||||
@@ -301,7 +302,7 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
|||||||
return Brain;
|
return Brain;
|
||||||
case 'info':
|
case 'info':
|
||||||
default:
|
default:
|
||||||
return LightBulb;
|
return Lightbulb;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Calendar,
|
Calendar,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Grid3X3,
|
Grid,
|
||||||
List,
|
List,
|
||||||
Download,
|
Download,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -486,7 +486,7 @@ const DeliveryTrackingPage: React.FC = () => {
|
|||||||
onClick={() => setViewMode('grid')}
|
onClick={() => setViewMode('grid')}
|
||||||
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
||||||
>
|
>
|
||||||
<Grid3X3 className="w-4 h-4" />
|
<Grid className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ const PurchaseOrderForm: React.FC<PurchaseOrderFormProps> = ({
|
|||||||
const [activeTab, setActiveTab] = useState<'basic' | 'delivery' | 'items' | 'financial'>('basic');
|
const [activeTab, setActiveTab] = useState<'basic' | 'delivery' | 'items' | 'financial'>('basic');
|
||||||
|
|
||||||
const { activeSuppliers, loadActiveSuppliers } = useSuppliers();
|
const { activeSuppliers, loadActiveSuppliers } = useSuppliers();
|
||||||
const { ingredients, loadInventoryItems } = useInventory();
|
const { items: ingredients, loadItems: loadInventoryItems } = useInventory();
|
||||||
|
|
||||||
// Initialize form data when order changes
|
// Initialize form data when order changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Package,
|
Package,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Grid3X3,
|
Grid,
|
||||||
List
|
List
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -446,7 +446,7 @@ const PurchaseOrderManagementPage: React.FC = () => {
|
|||||||
onClick={() => setViewMode('grid')}
|
onClick={() => setViewMode('grid')}
|
||||||
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
||||||
>
|
>
|
||||||
<Grid3X3 className="w-4 h-4" />
|
<Grid className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
@@ -599,7 +599,7 @@ const PurchaseOrderManagementPage: React.FC = () => {
|
|||||||
isOpen={showPurchaseOrderForm}
|
isOpen={showPurchaseOrderForm}
|
||||||
isCreating={isCreating}
|
isCreating={isCreating}
|
||||||
onSubmit={selectedOrder ?
|
onSubmit={selectedOrder ?
|
||||||
(data) => {
|
async (data) => {
|
||||||
// Handle update logic here if needed
|
// Handle update logic here if needed
|
||||||
setShowPurchaseOrderForm(false);
|
setShowPurchaseOrderForm(false);
|
||||||
setSelectedOrder(null);
|
setSelectedOrder(null);
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ interface SupplierCostData extends SupplierSummary {
|
|||||||
market_share_percentage: number;
|
market_share_percentage: number;
|
||||||
cost_trend: 'increasing' | 'decreasing' | 'stable';
|
cost_trend: 'increasing' | 'decreasing' | 'stable';
|
||||||
cost_efficiency_score: number;
|
cost_efficiency_score: number;
|
||||||
|
total_amount: number;
|
||||||
|
quality_rating: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SupplierCostAnalysis: React.FC = () => {
|
const SupplierCostAnalysis: React.FC = () => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Package,
|
Package,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Grid3X3,
|
Grid,
|
||||||
List
|
List
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -422,7 +422,7 @@ const SupplierManagementPage: React.FC = () => {
|
|||||||
onClick={() => setViewMode('grid')}
|
onClick={() => setViewMode('grid')}
|
||||||
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
||||||
>
|
>
|
||||||
<Grid3X3 className="w-4 h-4" />
|
<Grid className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ interface SupplierPerformance extends SupplierSummary {
|
|||||||
cost_efficiency: number;
|
cost_efficiency: number;
|
||||||
response_time: number;
|
response_time: number;
|
||||||
quality_consistency: number;
|
quality_consistency: number;
|
||||||
|
total_orders: number;
|
||||||
|
total_amount: number;
|
||||||
|
quality_rating: number;
|
||||||
|
delivery_rating: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SupplierPerformanceReport: React.FC = () => {
|
const SupplierPerformanceReport: React.FC = () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check, Brain, Clock, CheckCircle, AlertTriangle, Loader, TrendingUp } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Check, Brain, Clock, CheckCircle, AlertTriangle, Loader, TrendingUp } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
import SimplifiedTrainingProgress from '../../components/SimplifiedTrainingProgress';
|
import SimplifiedTrainingProgress from '../../components/SimplifiedTrainingProgress';
|
||||||
@@ -25,7 +25,7 @@ interface OnboardingPageProps {
|
|||||||
interface BakeryData {
|
interface BakeryData {
|
||||||
name: string;
|
name: string;
|
||||||
address: string;
|
address: string;
|
||||||
businessType: 'individual' | 'central_workshop';
|
businessType?: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant'; // Will be auto-detected from sales data
|
||||||
coordinates?: { lat: number; lng: number };
|
coordinates?: { lat: number; lng: number };
|
||||||
products: string[];
|
products: string[];
|
||||||
hasHistoricalData: boolean;
|
hasHistoricalData: boolean;
|
||||||
@@ -51,7 +51,6 @@ const MADRID_PRODUCTS = [
|
|||||||
const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => {
|
const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => {
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [useSmartImport, setUseSmartImport] = useState(true); // New state for smart import
|
|
||||||
const manualNavigation = useRef(false);
|
const manualNavigation = useRef(false);
|
||||||
|
|
||||||
// Enhanced onboarding with progress tracking
|
// Enhanced onboarding with progress tracking
|
||||||
@@ -64,7 +63,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
const [bakeryData, setBakeryData] = useState<BakeryData>({
|
const [bakeryData, setBakeryData] = useState<BakeryData>({
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
businessType: 'individual',
|
// businessType will be auto-detected during smart data import
|
||||||
products: MADRID_PRODUCTS, // Automatically assign all products
|
products: MADRID_PRODUCTS, // Automatically assign all products
|
||||||
hasHistoricalData: false
|
hasHistoricalData: false
|
||||||
});
|
});
|
||||||
@@ -95,14 +94,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
estimatedTimeRemaining: 0
|
estimatedTimeRemaining: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sales data validation state
|
|
||||||
const [validationStatus, setValidationStatus] = useState<{
|
|
||||||
status: 'idle' | 'validating' | 'valid' | 'invalid';
|
|
||||||
message?: string;
|
|
||||||
records?: number;
|
|
||||||
}>({
|
|
||||||
status: 'idle'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize current step from onboarding progress (only when not manually navigating)
|
// Initialize current step from onboarding progress (only when not manually navigating)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -292,8 +283,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
try {
|
try {
|
||||||
if (currentStep === 1) {
|
if (currentStep === 1) {
|
||||||
await createBakeryAndTenant();
|
await createBakeryAndTenant();
|
||||||
} else if (currentStep === 2) {
|
// Smart import step handles its own processing
|
||||||
await uploadAndValidateSalesData();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Error al procesar el paso. Por favor, inténtalo de nuevo.');
|
toast.error('Error al procesar el paso. Por favor, inténtalo de nuevo.');
|
||||||
@@ -317,7 +307,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
const tenantData: TenantCreate = {
|
const tenantData: TenantCreate = {
|
||||||
name: bakeryData.name,
|
name: bakeryData.name,
|
||||||
address: bakeryData.address,
|
address: bakeryData.address,
|
||||||
business_type: "individual",
|
business_type: 'bakery', // Default value - will be automatically updated after AI analyzes sales data
|
||||||
postal_code: "28010",
|
postal_code: "28010",
|
||||||
phone: "+34655334455",
|
phone: "+34655334455",
|
||||||
coordinates: bakeryData.coordinates,
|
coordinates: bakeryData.coordinates,
|
||||||
@@ -337,7 +327,8 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
await completeStep('bakery_registered', {
|
await completeStep('bakery_registered', {
|
||||||
bakery_name: bakeryData.name,
|
bakery_name: bakeryData.name,
|
||||||
bakery_address: bakeryData.address,
|
bakery_address: bakeryData.address,
|
||||||
business_type: bakeryData.businessType,
|
business_type: 'bakery', // Default - will be auto-detected from sales data
|
||||||
|
auto_detect_business_type: true, // Flag indicating business type will be auto-detected
|
||||||
tenant_id: currentTenantId, // Use the correct tenant ID
|
tenant_id: currentTenantId, // Use the correct tenant ID
|
||||||
user_id: user?.id
|
user_id: user?.id
|
||||||
});
|
});
|
||||||
@@ -354,81 +345,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate sales data without importing
|
|
||||||
const validateSalesFile = async () => {
|
|
||||||
if (!tenantId || !bakeryData.csvFile) {
|
|
||||||
toast.error('Falta información del tenant o archivo');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setValidationStatus({ status: 'validating' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
|
|
||||||
|
|
||||||
if (validationResult.is_valid) {
|
|
||||||
setValidationStatus({
|
|
||||||
status: 'valid',
|
|
||||||
message: validationResult.message,
|
|
||||||
records: validationResult.details?.total_records || 0
|
|
||||||
});
|
|
||||||
toast.success('¡Archivo validado correctamente!');
|
|
||||||
} else {
|
|
||||||
setValidationStatus({
|
|
||||||
status: 'invalid',
|
|
||||||
message: validationResult.message
|
|
||||||
});
|
|
||||||
toast.error(`Error en validación: ${validationResult.message}`);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
setValidationStatus({
|
|
||||||
status: 'invalid',
|
|
||||||
message: 'Error al validar el archivo'
|
|
||||||
});
|
|
||||||
toast.error('Error al validar el archivo');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Upload and validate sales data for step 2
|
|
||||||
const uploadAndValidateSalesData = async () => {
|
|
||||||
if (!tenantId || !bakeryData.csvFile) {
|
|
||||||
throw new Error('Falta información del tenant o archivo');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If not already validated, validate first
|
|
||||||
if (validationStatus.status !== 'valid') {
|
|
||||||
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
|
|
||||||
|
|
||||||
if (!validationResult.is_valid) {
|
|
||||||
toast.error(`Error en validación: ${validationResult.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If validation passes, upload the data
|
|
||||||
const uploadResult = await uploadSalesHistory(tenantId, bakeryData.csvFile);
|
|
||||||
|
|
||||||
// Mark step as completed
|
|
||||||
await completeStep('sales_data_uploaded', {
|
|
||||||
file_name: bakeryData.csvFile.name,
|
|
||||||
file_size: bakeryData.csvFile.size,
|
|
||||||
records_imported: uploadResult.records_imported || 0,
|
|
||||||
validation_status: 'success',
|
|
||||||
tenant_id: tenantId,
|
|
||||||
user_id: user?.id
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(`Datos importados correctamente (${uploadResult.records_imported} registros)`);
|
|
||||||
|
|
||||||
// Automatically start training after successful upload
|
|
||||||
await startTraining();
|
|
||||||
} catch (error: any) {
|
|
||||||
// Error uploading sales data
|
|
||||||
const message = error?.response?.data?.detail || error.message || 'Error al subir datos de ventas';
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to mark current step as completed
|
// Helper function to mark current step as completed
|
||||||
const markStepCompleted = async () => {
|
const markStepCompleted = async () => {
|
||||||
@@ -450,7 +367,8 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
if (currentStep === 1) {
|
if (currentStep === 1) {
|
||||||
stepData.bakery_name = bakeryData.name;
|
stepData.bakery_name = bakeryData.name;
|
||||||
stepData.bakery_address = bakeryData.address;
|
stepData.bakery_address = bakeryData.address;
|
||||||
stepData.business_type = bakeryData.businessType;
|
stepData.business_type = 'bakery'; // Default - will be auto-detected
|
||||||
|
stepData.auto_detect_business_type = true;
|
||||||
stepData.products_count = bakeryData.products.length;
|
stepData.products_count = bakeryData.products.length;
|
||||||
} else if (currentStep === 2) {
|
} else if (currentStep === 2) {
|
||||||
stepData.file_name = bakeryData.csvFile?.name;
|
stepData.file_name = bakeryData.csvFile?.name;
|
||||||
@@ -479,39 +397,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
case 2:
|
case 2:
|
||||||
// Skip validation if using smart import (it handles its own validation)
|
// Smart import handles its own validation
|
||||||
if (useSmartImport) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bakeryData.csvFile) {
|
|
||||||
toast.error('Por favor, selecciona un archivo con tus datos históricos');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file format
|
|
||||||
const fileName = bakeryData.csvFile.name.toLowerCase();
|
|
||||||
const supportedFormats = ['.csv', '.xlsx', '.xls', '.json'];
|
|
||||||
const isValidFormat = supportedFormats.some(format => fileName.endsWith(format));
|
|
||||||
|
|
||||||
if (!isValidFormat) {
|
|
||||||
toast.error('Formato de archivo no soportado. Usa CSV, Excel (.xlsx, .xls) o JSON');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (10MB limit as per backend)
|
|
||||||
const maxSize = 10 * 1024 * 1024;
|
|
||||||
if (bakeryData.csvFile.size > maxSize) {
|
|
||||||
toast.error('El archivo es demasiado grande. Máximo 10MB');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if validation was successful
|
|
||||||
if (validationStatus.status !== 'valid') {
|
|
||||||
toast.error('Por favor, valida el archivo antes de continuar');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
@@ -672,37 +558,20 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
<div className="flex items-start">
|
||||||
Tipo de negocio
|
<div className="flex-shrink-0">
|
||||||
</label>
|
<Brain className="w-5 h-5 text-blue-500 mt-0.5" />
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
</div>
|
||||||
<button
|
<div className="ml-3">
|
||||||
type="button"
|
<h4 className="text-sm font-medium text-blue-900">
|
||||||
onClick={() => setBakeryData(prev => ({ ...prev, businessType: 'individual' }))}
|
🤖 Detección automática del tipo de negocio
|
||||||
className={`p-4 border rounded-xl text-left transition-all ${
|
</h4>
|
||||||
bakeryData.businessType === 'individual'
|
<p className="text-sm text-blue-700 mt-1">
|
||||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
Nuestro sistema de IA analizará automáticamente tus datos de ventas para identificar
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
si eres una panadería, cafetería, pastelería o restaurante. No necesitas seleccionar nada manualmente.
|
||||||
}`}
|
</p>
|
||||||
>
|
</div>
|
||||||
<Store className="h-5 w-5 mb-2" />
|
|
||||||
<div className="font-medium">Panadería Individual</div>
|
|
||||||
<div className="text-sm text-gray-500">Un solo punto de venta</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setBakeryData(prev => ({ ...prev, businessType: 'central_workshop' }))}
|
|
||||||
className={`p-4 border rounded-xl text-left transition-all ${
|
|
||||||
bakeryData.businessType === 'central_workshop'
|
|
||||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Factory className="h-5 w-5 mb-2" />
|
|
||||||
<div className="font-medium">Obrador Central</div>
|
|
||||||
<div className="text-sm text-gray-500">Múltiples puntos de venta</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -724,360 +593,30 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Smart Import by default, with option to switch to traditional
|
// Smart Import only
|
||||||
if (useSmartImport) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with import mode toggle */}
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900">
|
|
||||||
Importación Inteligente de Datos 🧠
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mt-1">
|
|
||||||
Nuestra IA creará automáticamente tu inventario desde tus datos históricos
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setUseSmartImport(false)}
|
|
||||||
className="flex items-center space-x-2 px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4" />
|
|
||||||
<span>Importación tradicional</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Smart Import Component */}
|
|
||||||
<SmartHistoricalDataImport
|
|
||||||
tenantId={tenantId}
|
|
||||||
onComplete={(result) => {
|
|
||||||
// Mark sales data as uploaded and proceed to training
|
|
||||||
completeStep('sales_data_uploaded', {
|
|
||||||
smart_import: true,
|
|
||||||
records_imported: result.successful_imports,
|
|
||||||
import_job_id: result.import_job_id,
|
|
||||||
tenant_id: tenantId,
|
|
||||||
user_id: user?.id
|
|
||||||
}).then(() => {
|
|
||||||
setBakeryData(prev => ({ ...prev, hasHistoricalData: true }));
|
|
||||||
startTraining();
|
|
||||||
}).catch(() => {
|
|
||||||
// Continue even if step completion fails
|
|
||||||
setBakeryData(prev => ({ ...prev, hasHistoricalData: true }));
|
|
||||||
startTraining();
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onBack={() => setUseSmartImport(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traditional import fallback
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header with import mode toggle */}
|
{/* Smart Import Component */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<SmartHistoricalDataImport
|
||||||
<div>
|
tenantId={tenantId}
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
onComplete={(result) => {
|
||||||
Datos Históricos (Modo Tradicional)
|
// Mark sales data as uploaded and proceed to training
|
||||||
</h3>
|
completeStep('sales_data_uploaded', {
|
||||||
<p className="text-gray-600 mt-1">
|
smart_import: true,
|
||||||
Sube tus datos y configura tu inventario manualmente
|
records_imported: result.successful_imports,
|
||||||
</p>
|
import_job_id: result.import_job_id,
|
||||||
</div>
|
tenant_id: tenantId,
|
||||||
|
user_id: user?.id
|
||||||
<button
|
}).then(() => {
|
||||||
onClick={() => setUseSmartImport(true)}
|
setBakeryData(prev => ({ ...prev, hasHistoricalData: true }));
|
||||||
className="flex items-center space-x-2 px-4 py-2 text-sm bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-lg hover:from-blue-600 hover:to-purple-600 transition-colors"
|
startTraining();
|
||||||
>
|
}).catch(() => {
|
||||||
<Brain className="w-4 h-4" />
|
// Continue even if step completion fails
|
||||||
<span>Activar IA</span>
|
setBakeryData(prev => ({ ...prev, hasHistoricalData: true }));
|
||||||
</button>
|
startTraining();
|
||||||
</div>
|
});
|
||||||
|
}}
|
||||||
<p className="text-gray-600 mb-6">
|
/>
|
||||||
Para obtener predicciones precisas, necesitamos tus datos históricos de ventas.
|
|
||||||
Puedes subir archivos en varios formatos.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg mb-6">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
|
|
||||||
<span className="text-white text-sm font-bold">!</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h4 className="text-sm font-medium text-blue-900">
|
|
||||||
Formatos soportados y estructura de datos
|
|
||||||
</h4>
|
|
||||||
<div className="mt-2 text-sm text-blue-700">
|
|
||||||
<p className="mb-3"><strong>Formatos aceptados:</strong></p>
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-3">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">📊 Hojas de cálculo:</p>
|
|
||||||
<ul className="list-disc list-inside text-xs space-y-1">
|
|
||||||
<li>.xlsx (Excel moderno)</li>
|
|
||||||
<li>.xls (Excel clásico)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">📄 Datos estructurados:</p>
|
|
||||||
<ul className="list-disc list-inside text-xs space-y-1">
|
|
||||||
<li>.csv (Valores separados por comas)</li>
|
|
||||||
<li>.json (Formato JSON)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mb-2"><strong>Columnas requeridas (en cualquier idioma):</strong></p>
|
|
||||||
<ul className="list-disc list-inside text-xs space-y-1">
|
|
||||||
<li><strong>Fecha</strong>: fecha, date, datum (formato: YYYY-MM-DD, DD/MM/YYYY, etc.)</li>
|
|
||||||
<li><strong>Producto</strong>: producto, product, item, articulo, nombre</li>
|
|
||||||
<li><strong>Cantidad</strong>: cantidad, quantity, cantidad_vendida, qty</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 p-6 border-2 border-dashed border-gray-300 rounded-xl hover:border-primary-300 transition-colors">
|
|
||||||
<div className="text-center">
|
|
||||||
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
|
||||||
<div className="mt-4">
|
|
||||||
<label htmlFor="sales-file-upload" className="cursor-pointer">
|
|
||||||
<span className="mt-2 block text-sm font-medium text-gray-900">
|
|
||||||
Subir archivo de datos históricos
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 block text-sm text-gray-500">
|
|
||||||
Arrastra y suelta tu archivo aquí, o haz clic para seleccionar
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 block text-xs text-gray-400">
|
|
||||||
Máximo 10MB - CSV, Excel (.xlsx, .xls), JSON
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="sales-file-upload"
|
|
||||||
type="file"
|
|
||||||
accept=".csv,.xlsx,.xls,.json"
|
|
||||||
required
|
|
||||||
onChange={async (e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
// Validate file size (10MB limit)
|
|
||||||
const maxSize = 10 * 1024 * 1024;
|
|
||||||
if (file.size > maxSize) {
|
|
||||||
toast.error('El archivo es demasiado grande. Máximo 10MB.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update bakery data with the selected file
|
|
||||||
setBakeryData(prev => ({
|
|
||||||
...prev,
|
|
||||||
csvFile: file,
|
|
||||||
hasHistoricalData: true
|
|
||||||
}));
|
|
||||||
|
|
||||||
toast.success(`Archivo ${file.name} seleccionado correctamente`);
|
|
||||||
|
|
||||||
// Auto-validate the file after upload if tenantId exists
|
|
||||||
if (tenantId) {
|
|
||||||
setValidationStatus({ status: 'validating' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const validationResult = await validateSalesData(tenantId, file);
|
|
||||||
|
|
||||||
if (validationResult.is_valid) {
|
|
||||||
setValidationStatus({
|
|
||||||
status: 'valid',
|
|
||||||
message: validationResult.message,
|
|
||||||
records: validationResult.details?.total_records || 0
|
|
||||||
});
|
|
||||||
toast.success('¡Archivo validado correctamente!');
|
|
||||||
} else {
|
|
||||||
setValidationStatus({
|
|
||||||
status: 'invalid',
|
|
||||||
message: validationResult.message
|
|
||||||
});
|
|
||||||
toast.error(`Error en validación: ${validationResult.message}`);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
setValidationStatus({
|
|
||||||
status: 'invalid',
|
|
||||||
message: 'Error al validar el archivo'
|
|
||||||
});
|
|
||||||
toast.error('Error al validar el archivo');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If no tenantId yet, set to idle and wait for manual validation
|
|
||||||
setValidationStatus({ status: 'idle' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{bakeryData.csvFile ? (
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
<div className="p-4 bg-gray-50 rounded-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<CheckCircle className="h-5 w-5 text-gray-500 mr-2" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-700">
|
|
||||||
{bakeryData.csvFile.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-600">
|
|
||||||
{(bakeryData.csvFile.size / 1024).toFixed(1)} KB • {bakeryData.csvFile.type || 'Archivo de datos'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setBakeryData(prev => ({ ...prev, csvFile: undefined }));
|
|
||||||
setValidationStatus({ status: 'idle' });
|
|
||||||
}}
|
|
||||||
className="text-red-600 hover:text-red-800 text-sm"
|
|
||||||
>
|
|
||||||
Quitar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Validation Section */}
|
|
||||||
<div className="p-4 border rounded-lg">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900">
|
|
||||||
Validación de datos
|
|
||||||
</h4>
|
|
||||||
{validationStatus.status === 'validating' ? (
|
|
||||||
<Loader className="h-4 w-4 animate-spin text-blue-500" />
|
|
||||||
) : validationStatus.status === 'valid' ? (
|
|
||||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
|
||||||
) : validationStatus.status === 'invalid' ? (
|
|
||||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
|
||||||
) : (
|
|
||||||
<Clock className="h-4 w-4 text-gray-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{validationStatus.status === 'idle' && tenantId ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Valida tu archivo para verificar que tiene el formato correcto.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={validateSalesFile}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Validar archivo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{!tenantId ? (
|
|
||||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
||||||
<p className="text-sm text-yellow-800">
|
|
||||||
⚠️ No se ha encontrado la panadería registrada.
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-yellow-600 mt-1">
|
|
||||||
Ve al paso anterior para registrar tu panadería o espera mientras se carga desde el servidor.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : validationStatus.status !== 'idle' ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setValidationStatus({ status: 'idle' })}
|
|
||||||
className="px-4 py-2 bg-gray-500 text-white text-sm rounded-lg hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
Resetear validación
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{validationStatus.status === 'validating' && (
|
|
||||||
<p className="text-sm text-blue-600">
|
|
||||||
Validando archivo... Por favor espera.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{validationStatus.status === 'valid' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-green-700">
|
|
||||||
✅ Archivo validado correctamente
|
|
||||||
</p>
|
|
||||||
{validationStatus.records && (
|
|
||||||
<p className="text-xs text-green-600">
|
|
||||||
{validationStatus.records} registros encontrados
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{validationStatus.message && (
|
|
||||||
<p className="text-xs text-green-600">
|
|
||||||
{validationStatus.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{validationStatus.status === 'invalid' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-red-700">
|
|
||||||
❌ Error en validación
|
|
||||||
</p>
|
|
||||||
{validationStatus.message && (
|
|
||||||
<p className="text-xs text-red-600">
|
|
||||||
{validationStatus.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={validateSalesFile}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Validar de nuevo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-yellow-600 mr-2" />
|
|
||||||
<p className="text-sm text-yellow-800">
|
|
||||||
<strong>Archivo requerido:</strong> Selecciona un archivo con tus datos históricos de ventas
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Show switch to smart import suggestion if traditional validation fails */}
|
|
||||||
{validationStatus.status === 'invalid' && (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<Brain className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-blue-900 mb-2">
|
|
||||||
💡 ¿Problemas con la validación?
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-blue-700 mb-3">
|
|
||||||
Nuestra IA puede manejar archivos con formatos más flexibles y ayudarte a solucionar problemas automáticamente.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setUseSmartImport(true)}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Probar importación inteligente
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1369,7 +908,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Navigation with Enhanced Accessibility - Hidden during training (step 3), completion (step 4), and smart import */}
|
{/* Navigation with Enhanced Accessibility - Hidden during training (step 3), completion (step 4), and smart import */}
|
||||||
{currentStep < 3 && !(currentStep === 2 && useSmartImport) && (
|
{currentStep < 3 && currentStep !== 2 && (
|
||||||
<nav
|
<nav
|
||||||
className="flex justify-between items-center bg-white rounded-3xl shadow-sm p-6"
|
className="flex justify-between items-center bg-white rounded-3xl shadow-sm p-6"
|
||||||
role="navigation"
|
role="navigation"
|
||||||
@@ -1391,16 +930,15 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
<div className="text-sm text-gray-500">Paso {currentStep} de 4</div>
|
<div className="text-sm text-gray-500">Paso {currentStep} de 4</div>
|
||||||
<div className="text-xs text-gray-400 mt-1">
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
{currentStep === 1 && "Configura tu panadería"}
|
{currentStep === 1 && "Configura tu panadería"}
|
||||||
{currentStep === 2 && "Sube datos de ventas"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dynamic Next Button with contextual text */}
|
{/* Dynamic Next Button with contextual text */}
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={isLoading || (currentStep === 2 && validationStatus?.status !== 'valid')}
|
disabled={isLoading}
|
||||||
className="flex items-center px-8 py-3 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-xl hover:from-primary-600 hover:to-primary-700 transition-all hover:shadow-lg hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none group focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
className="flex items-center px-8 py-3 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-xl hover:from-primary-600 hover:to-primary-700 transition-all hover:shadow-lg hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none group focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||||
aria-label={`${currentStep === 1 ? 'Save bakery information and continue' : currentStep === 2 && validationStatus?.status === 'valid' ? 'Start AI training' : 'Continue to next step'}`}
|
aria-label={`${currentStep === 1 ? 'Save bakery information and continue' : 'Continue to next step'}`}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
@@ -1412,11 +950,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{currentStep === 1 && "Guardar y Continuar"}
|
{currentStep === 1 && "Guardar y Continuar"}
|
||||||
{currentStep === 2 && (
|
|
||||||
<>
|
|
||||||
{validationStatus?.status === 'valid' ? "Iniciar Entrenamiento" : "Continuar"}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<ChevronRight className="h-5 w-5 ml-2 group-hover:translate-x-0.5 transition-transform" aria-hidden="true" />
|
<ChevronRight className="h-5 w-5 ml-2 group-hover:translate-x-0.5 transition-transform" aria-hidden="true" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Handles routing, authentication, rate limiting, and cross-cutting concerns
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import FastAPI, Request, HTTPException, Depends
|
from fastapi import FastAPI, Request, HTTPException, Depends, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
import httpx
|
import httpx
|
||||||
@@ -17,7 +17,7 @@ from app.core.service_discovery import ServiceDiscovery
|
|||||||
from app.middleware.auth import AuthMiddleware
|
from app.middleware.auth import AuthMiddleware
|
||||||
from app.middleware.logging import LoggingMiddleware
|
from app.middleware.logging import LoggingMiddleware
|
||||||
from app.middleware.rate_limit import RateLimitMiddleware
|
from app.middleware.rate_limit import RateLimitMiddleware
|
||||||
from app.routes import auth, tenant, notification, nominatim, user, inventory
|
from app.routes import auth, tenant, notification, nominatim, user
|
||||||
from shared.monitoring.logging import setup_logging
|
from shared.monitoring.logging import setup_logging
|
||||||
from shared.monitoring.metrics import MetricsCollector
|
from shared.monitoring.metrics import MetricsCollector
|
||||||
|
|
||||||
@@ -60,7 +60,6 @@ app.include_router(user.router, prefix="/api/v1/users", tags=["users"])
|
|||||||
app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"])
|
app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"])
|
||||||
app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
|
app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
|
||||||
app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"])
|
app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"])
|
||||||
app.include_router(inventory.router, prefix="/api/v1/inventory", tags=["inventory"])
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
@@ -117,6 +116,92 @@ async def metrics():
|
|||||||
"""Metrics endpoint for monitoring"""
|
"""Metrics endpoint for monitoring"""
|
||||||
return {"metrics": "enabled"}
|
return {"metrics": "enabled"}
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# WEBSOCKET ROUTING FOR TRAINING SERVICE
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
@app.websocket("/api/v1/ws/tenants/{tenant_id}/training/jobs/{job_id}/live")
|
||||||
|
async def websocket_training_progress(websocket: WebSocket, tenant_id: str, job_id: str):
|
||||||
|
"""WebSocket proxy for training progress updates"""
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# Get token from query params
|
||||||
|
token = websocket.query_params.get("token")
|
||||||
|
if not token:
|
||||||
|
await websocket.close(code=1008, reason="Authentication token required")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build HTTP URL to training service (we'll use HTTP client to proxy)
|
||||||
|
training_service_base = settings.TRAINING_SERVICE_URL.rstrip('/')
|
||||||
|
training_ws_url = f"{training_service_base}/api/v1/ws/tenants/{tenant_id}/training/jobs/{job_id}/live?token={token}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use HTTP client to connect to training service WebSocket
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Since we can't easily proxy WebSocket with httpx, let's try a different approach
|
||||||
|
# We'll make periodic HTTP requests to get training status
|
||||||
|
logger.info(f"Starting WebSocket proxy for training job {job_id}")
|
||||||
|
|
||||||
|
# Send initial connection confirmation
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "connection_established",
|
||||||
|
"job_id": job_id,
|
||||||
|
"tenant_id": tenant_id
|
||||||
|
})
|
||||||
|
|
||||||
|
# Poll for training updates
|
||||||
|
last_status = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Make HTTP request to get current training status
|
||||||
|
status_url = f"{training_service_base}/api/v1/tenants/{tenant_id}/training/jobs/{job_id}/status"
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
status_url,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=5.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
current_status = response.json()
|
||||||
|
|
||||||
|
# Only send update if status changed
|
||||||
|
if current_status != last_status:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "training_progress",
|
||||||
|
"data": current_status
|
||||||
|
})
|
||||||
|
last_status = current_status
|
||||||
|
|
||||||
|
# If training is completed or failed, we can stop polling
|
||||||
|
if current_status.get('status') in ['completed', 'failed', 'cancelled']:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "training_" + current_status.get('status', 'completed'),
|
||||||
|
"data": current_status
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
# Wait before next poll
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("WebSocket client disconnected")
|
||||||
|
break
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
# Continue polling even if request times out
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error polling training status: {e}")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("WebSocket client disconnected during setup")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebSocket proxy error: {e}")
|
||||||
|
await websocket.close(code=1011, reason="Internal server error")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
# gateway/app/routes/inventory.py
|
|
||||||
"""
|
|
||||||
Inventory routes for API Gateway - Handles inventory management endpoints
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Response, HTTPException, Path
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
import httpx
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
# Inventory service URL - add to settings
|
|
||||||
INVENTORY_SERVICE_URL = "http://inventory-service:8000"
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# TENANT-SCOPED INVENTORY ENDPOINTS
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@router.api_route("/{tenant_id}/inventory/ingredients{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
|
||||||
async def proxy_tenant_ingredients(request: Request, tenant_id: str = Path(...), path: str = ""):
|
|
||||||
"""Proxy tenant ingredient requests to inventory service"""
|
|
||||||
base_path = f"/api/v1/ingredients"
|
|
||||||
|
|
||||||
# If path is empty or just "/", use base path
|
|
||||||
if not path or path == "/" or path == "":
|
|
||||||
target_path = base_path
|
|
||||||
else:
|
|
||||||
# Ensure path starts with "/"
|
|
||||||
if not path.startswith("/"):
|
|
||||||
path = "/" + path
|
|
||||||
target_path = base_path + path
|
|
||||||
|
|
||||||
return await _proxy_to_inventory_service(request, target_path)
|
|
||||||
|
|
||||||
@router.api_route("/{tenant_id}/inventory/stock{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
|
||||||
async def proxy_tenant_stock(request: Request, tenant_id: str = Path(...), path: str = ""):
|
|
||||||
"""Proxy tenant stock requests to inventory service"""
|
|
||||||
base_path = f"/api/v1/stock"
|
|
||||||
|
|
||||||
# If path is empty or just "/", use base path
|
|
||||||
if not path or path == "/" or path == "":
|
|
||||||
target_path = base_path
|
|
||||||
else:
|
|
||||||
# Ensure path starts with "/"
|
|
||||||
if not path.startswith("/"):
|
|
||||||
path = "/" + path
|
|
||||||
target_path = base_path + path
|
|
||||||
|
|
||||||
return await _proxy_to_inventory_service(request, target_path)
|
|
||||||
|
|
||||||
@router.api_route("/{tenant_id}/inventory/alerts{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
|
||||||
async def proxy_tenant_alerts(request: Request, tenant_id: str = Path(...), path: str = ""):
|
|
||||||
"""Proxy tenant inventory alert requests to inventory service"""
|
|
||||||
base_path = f"/api/v1/alerts"
|
|
||||||
|
|
||||||
# If path is empty or just "/", use base path
|
|
||||||
if not path or path == "/" or path == "":
|
|
||||||
target_path = base_path
|
|
||||||
else:
|
|
||||||
# Ensure path starts with "/"
|
|
||||||
if not path.startswith("/"):
|
|
||||||
path = "/" + path
|
|
||||||
target_path = base_path + path
|
|
||||||
|
|
||||||
return await _proxy_to_inventory_service(request, target_path)
|
|
||||||
|
|
||||||
@router.api_route("/{tenant_id}/inventory/dashboard{path:path}", methods=["GET", "OPTIONS"])
|
|
||||||
async def proxy_tenant_inventory_dashboard(request: Request, tenant_id: str = Path(...), path: str = ""):
|
|
||||||
"""Proxy tenant inventory dashboard requests to inventory service"""
|
|
||||||
base_path = f"/api/v1/dashboard"
|
|
||||||
|
|
||||||
# If path is empty or just "/", use base path
|
|
||||||
if not path or path == "/" or path == "":
|
|
||||||
target_path = base_path
|
|
||||||
else:
|
|
||||||
# Ensure path starts with "/"
|
|
||||||
if not path.startswith("/"):
|
|
||||||
path = "/" + path
|
|
||||||
target_path = base_path + path
|
|
||||||
|
|
||||||
return await _proxy_to_inventory_service(request, target_path)
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# DIRECT INVENTORY ENDPOINTS (for backward compatibility)
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@router.api_route("/ingredients{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
|
||||||
async def proxy_ingredients(request: Request, path: str = ""):
|
|
||||||
"""Proxy ingredient requests to inventory service"""
|
|
||||||
base_path = f"/api/v1/ingredients"
|
|
||||||
|
|
||||||
if not path or path == "/" or path == "":
|
|
||||||
target_path = base_path
|
|
||||||
else:
|
|
||||||
if not path.startswith("/"):
|
|
||||||
path = "/" + path
|
|
||||||
target_path = base_path + path
|
|
||||||
|
|
||||||
return await _proxy_to_inventory_service(request, target_path)
|
|
||||||
|
|
||||||
@router.api_route("/stock{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
|
||||||
async def proxy_stock(request: Request, path: str = ""):
|
|
||||||
"""Proxy stock requests to inventory service"""
|
|
||||||
base_path = f"/api/v1/stock"
|
|
||||||
|
|
||||||
if not path or path == "/" or path == "":
|
|
||||||
target_path = base_path
|
|
||||||
else:
|
|
||||||
if not path.startswith("/"):
|
|
||||||
path = "/" + path
|
|
||||||
target_path = base_path + path
|
|
||||||
|
|
||||||
return await _proxy_to_inventory_service(request, target_path)
|
|
||||||
|
|
||||||
@router.api_route("/alerts{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
|
||||||
async def proxy_alerts(request: Request, path: str = ""):
|
|
||||||
"""Proxy inventory alert requests to inventory service"""
|
|
||||||
base_path = f"/api/v1/alerts"
|
|
||||||
|
|
||||||
if not path or path == "/" or path == "":
|
|
||||||
target_path = base_path
|
|
||||||
else:
|
|
||||||
if not path.startswith("/"):
|
|
||||||
path = "/" + path
|
|
||||||
target_path = base_path + path
|
|
||||||
|
|
||||||
return await _proxy_to_inventory_service(request, target_path)
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# PROXY HELPER FUNCTION
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
async def _proxy_to_inventory_service(request: Request, target_path: str):
|
|
||||||
"""Proxy request to inventory service with enhanced error handling"""
|
|
||||||
|
|
||||||
# Handle OPTIONS requests directly for CORS
|
|
||||||
if request.method == "OPTIONS":
|
|
||||||
return Response(
|
|
||||||
status_code=200,
|
|
||||||
headers={
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
|
|
||||||
"Access-Control-Allow-Credentials": "true",
|
|
||||||
"Access-Control-Max-Age": "86400"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
url = f"{INVENTORY_SERVICE_URL}{target_path}"
|
|
||||||
|
|
||||||
# Forward headers and add user/tenant context
|
|
||||||
headers = dict(request.headers)
|
|
||||||
headers.pop("host", None)
|
|
||||||
|
|
||||||
# Get request body if present
|
|
||||||
body = None
|
|
||||||
if request.method in ["POST", "PUT", "PATCH"]:
|
|
||||||
body = await request.body()
|
|
||||||
|
|
||||||
# Add query parameters
|
|
||||||
params = dict(request.query_params)
|
|
||||||
|
|
||||||
timeout_config = httpx.Timeout(
|
|
||||||
connect=30.0, # Connection timeout
|
|
||||||
read=600.0, # Read timeout: 10 minutes
|
|
||||||
write=30.0, # Write timeout
|
|
||||||
pool=30.0 # Pool timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=timeout_config) as client:
|
|
||||||
response = await client.request(
|
|
||||||
method=request.method,
|
|
||||||
url=url,
|
|
||||||
headers=headers,
|
|
||||||
content=body,
|
|
||||||
params=params
|
|
||||||
)
|
|
||||||
|
|
||||||
# Handle different response types
|
|
||||||
if response.headers.get("content-type", "").startswith("application/json"):
|
|
||||||
try:
|
|
||||||
content = response.json()
|
|
||||||
except:
|
|
||||||
content = {"message": "Invalid JSON response from inventory service"}
|
|
||||||
else:
|
|
||||||
content = response.text
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=response.status_code,
|
|
||||||
content=content
|
|
||||||
)
|
|
||||||
|
|
||||||
except httpx.ConnectTimeout:
|
|
||||||
logger.error(f"Connection timeout to inventory service: {INVENTORY_SERVICE_URL}{target_path}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Inventory service temporarily unavailable"
|
|
||||||
)
|
|
||||||
except httpx.ReadTimeout:
|
|
||||||
logger.error(f"Read timeout from inventory service: {INVENTORY_SERVICE_URL}{target_path}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=504,
|
|
||||||
detail="Inventory service response timeout"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected error proxying to inventory service {INVENTORY_SERVICE_URL}{target_path}: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Internal gateway error"
|
|
||||||
)
|
|
||||||
@@ -134,6 +134,26 @@ async def proxy_tenant_notifications(request: Request, tenant_id: str = Path(...
|
|||||||
target_path = f"/api/v1/tenants/{tenant_id}/notifications/{path}".rstrip("/")
|
target_path = f"/api/v1/tenants/{tenant_id}/notifications/{path}".rstrip("/")
|
||||||
return await _proxy_to_notification_service(request, target_path)
|
return await _proxy_to_notification_service(request, target_path)
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# TENANT-SCOPED INVENTORY SERVICE ENDPOINTS
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
@router.api_route("/{tenant_id}/inventory/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||||
|
async def proxy_tenant_inventory(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
|
"""Proxy tenant inventory requests to inventory service"""
|
||||||
|
# The inventory service expects /api/v1/tenants/{tenant_id}/inventory/{path}
|
||||||
|
# Keep the full path structure for inventory service
|
||||||
|
target_path = f"/api/v1/tenants/{tenant_id}/inventory/{path}".rstrip("/")
|
||||||
|
return await _proxy_to_inventory_service(request, target_path)
|
||||||
|
|
||||||
|
@router.api_route("/{tenant_id}/ingredients{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||||
|
async def proxy_tenant_ingredients(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
|
"""Proxy tenant ingredient requests to inventory service"""
|
||||||
|
# The inventory service ingredient endpoints are now tenant-scoped: /api/v1/tenants/{tenant_id}/ingredients/{path}
|
||||||
|
# Keep the full tenant path structure
|
||||||
|
target_path = f"/api/v1/tenants/{tenant_id}/ingredients{path}".rstrip("/")
|
||||||
|
return await _proxy_to_inventory_service(request, target_path)
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# PROXY HELPER FUNCTIONS
|
# PROXY HELPER FUNCTIONS
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -162,6 +182,10 @@ async def _proxy_to_notification_service(request: Request, target_path: str):
|
|||||||
"""Proxy request to notification service"""
|
"""Proxy request to notification service"""
|
||||||
return await _proxy_request(request, target_path, settings.NOTIFICATION_SERVICE_URL)
|
return await _proxy_request(request, target_path, settings.NOTIFICATION_SERVICE_URL)
|
||||||
|
|
||||||
|
async def _proxy_to_inventory_service(request: Request, target_path: str):
|
||||||
|
"""Proxy request to inventory service"""
|
||||||
|
return await _proxy_request(request, target_path, settings.INVENTORY_SERVICE_URL)
|
||||||
|
|
||||||
async def _proxy_request(request: Request, target_path: str, service_url: str):
|
async def _proxy_request(request: Request, target_path: str, service_url: str):
|
||||||
"""Generic proxy function with enhanced error handling"""
|
"""Generic proxy function with enhanced error handling"""
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ AI-powered product classification for onboarding automation
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID, uuid4
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
@@ -38,12 +38,12 @@ class ProductSuggestionResponse(BaseModel):
|
|||||||
category: str
|
category: str
|
||||||
unit_of_measure: str
|
unit_of_measure: str
|
||||||
confidence_score: float
|
confidence_score: float
|
||||||
estimated_shelf_life_days: int = None
|
estimated_shelf_life_days: Optional[int] = None
|
||||||
requires_refrigeration: bool = False
|
requires_refrigeration: bool = False
|
||||||
requires_freezing: bool = False
|
requires_freezing: bool = False
|
||||||
is_seasonal: bool = False
|
is_seasonal: bool = False
|
||||||
suggested_supplier: str = None
|
suggested_supplier: Optional[str] = None
|
||||||
notes: str = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class BusinessModelAnalysisResponse(BaseModel):
|
class BusinessModelAnalysisResponse(BaseModel):
|
||||||
@@ -87,7 +87,7 @@ async def classify_single_product(
|
|||||||
|
|
||||||
# Convert to response format
|
# Convert to response format
|
||||||
response = ProductSuggestionResponse(
|
response = ProductSuggestionResponse(
|
||||||
suggestion_id=str(UUID.uuid4()), # Generate unique ID for tracking
|
suggestion_id=str(uuid4()), # Generate unique ID for tracking
|
||||||
original_name=suggestion.original_name,
|
original_name=suggestion.original_name,
|
||||||
suggested_name=suggestion.suggested_name,
|
suggested_name=suggestion.suggested_name,
|
||||||
product_type=suggestion.product_type.value,
|
product_type=suggestion.product_type.value,
|
||||||
@@ -144,7 +144,7 @@ async def classify_products_batch(
|
|||||||
suggestion_responses = []
|
suggestion_responses = []
|
||||||
for suggestion in suggestions:
|
for suggestion in suggestions:
|
||||||
suggestion_responses.append(ProductSuggestionResponse(
|
suggestion_responses.append(ProductSuggestionResponse(
|
||||||
suggestion_id=str(UUID.uuid4()),
|
suggestion_id=str(uuid4()),
|
||||||
original_name=suggestion.original_name,
|
original_name=suggestion.original_name,
|
||||||
suggested_name=suggestion.suggested_name,
|
suggested_name=suggestion.suggested_name,
|
||||||
product_type=suggestion.product_type.value,
|
product_type=suggestion.product_type.value,
|
||||||
@@ -159,39 +159,58 @@ async def classify_products_batch(
|
|||||||
notes=suggestion.notes
|
notes=suggestion.notes
|
||||||
))
|
))
|
||||||
|
|
||||||
# Analyze business model
|
# Analyze business model with enhanced detection
|
||||||
ingredient_count = sum(1 for s in suggestions if s.product_type.value == 'ingredient')
|
ingredient_count = sum(1 for s in suggestions if s.product_type.value == 'ingredient')
|
||||||
finished_count = sum(1 for s in suggestions if s.product_type.value == 'finished_product')
|
finished_count = sum(1 for s in suggestions if s.product_type.value == 'finished_product')
|
||||||
|
semi_finished_count = sum(1 for s in suggestions if 'semi' in s.suggested_name.lower() or 'frozen' in s.suggested_name.lower() or 'pre' in s.suggested_name.lower())
|
||||||
total = len(suggestions)
|
total = len(suggestions)
|
||||||
ingredient_ratio = ingredient_count / total if total > 0 else 0
|
ingredient_ratio = ingredient_count / total if total > 0 else 0
|
||||||
|
semi_finished_ratio = semi_finished_count / total if total > 0 else 0
|
||||||
|
|
||||||
# Determine business model
|
# Enhanced business model determination
|
||||||
if ingredient_ratio >= 0.7:
|
if ingredient_ratio >= 0.7:
|
||||||
model = 'production'
|
model = 'individual_bakery' # Full production from raw ingredients
|
||||||
|
elif ingredient_ratio <= 0.2 and semi_finished_ratio >= 0.3:
|
||||||
|
model = 'central_baker_satellite' # Receives semi-finished products from central baker
|
||||||
elif ingredient_ratio <= 0.3:
|
elif ingredient_ratio <= 0.3:
|
||||||
model = 'retail'
|
model = 'retail_bakery' # Sells finished products from suppliers
|
||||||
else:
|
else:
|
||||||
model = 'hybrid'
|
model = 'hybrid_bakery' # Mixed model
|
||||||
|
|
||||||
confidence = max(abs(ingredient_ratio - 0.5) * 2, 0.1)
|
# Calculate confidence based on clear distinction
|
||||||
|
if model == 'individual_bakery':
|
||||||
|
confidence = min(ingredient_ratio * 1.2, 0.95)
|
||||||
|
elif model == 'central_baker_satellite':
|
||||||
|
confidence = min((semi_finished_ratio + (1 - ingredient_ratio)) / 2 * 1.2, 0.95)
|
||||||
|
else:
|
||||||
|
confidence = max(abs(ingredient_ratio - 0.5) * 2, 0.1)
|
||||||
|
|
||||||
recommendations = {
|
recommendations = {
|
||||||
'production': [
|
'individual_bakery': [
|
||||||
'Focus on ingredient inventory management',
|
'Set up raw ingredient inventory management',
|
||||||
'Set up recipe cost calculation',
|
'Configure recipe cost calculation and production planning',
|
||||||
'Configure supplier relationships',
|
'Enable supplier relationships for flour, yeast, sugar, etc.',
|
||||||
'Enable production planning features'
|
'Set up full production workflow with proofing and baking schedules',
|
||||||
|
'Enable waste tracking for overproduction'
|
||||||
],
|
],
|
||||||
'retail': [
|
'central_baker_satellite': [
|
||||||
'Configure central baker relationships',
|
'Configure central baker delivery schedules',
|
||||||
'Set up delivery schedule tracking',
|
'Set up semi-finished product inventory (frozen dough, par-baked items)',
|
||||||
'Enable finished product freshness monitoring',
|
'Enable finish-baking workflow and timing optimization',
|
||||||
'Focus on sales forecasting'
|
'Track freshness and shelf-life for received products',
|
||||||
|
'Focus on customer demand forecasting for final products'
|
||||||
],
|
],
|
||||||
'hybrid': [
|
'retail_bakery': [
|
||||||
'Configure both ingredient and finished product management',
|
'Set up finished product supplier relationships',
|
||||||
'Set up flexible inventory categories',
|
'Configure delivery schedule tracking',
|
||||||
'Enable both production and retail features'
|
'Enable freshness monitoring and expiration management',
|
||||||
|
'Focus on sales forecasting and customer preferences'
|
||||||
|
],
|
||||||
|
'hybrid_bakery': [
|
||||||
|
'Configure both ingredient and semi-finished product management',
|
||||||
|
'Set up flexible production workflows',
|
||||||
|
'Enable both supplier and central baker relationships',
|
||||||
|
'Configure multi-tier inventory categories'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ API endpoints for ingredient management
|
|||||||
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -17,10 +17,9 @@ from app.schemas.inventory import (
|
|||||||
InventoryFilter,
|
InventoryFilter,
|
||||||
PaginatedResponse
|
PaginatedResponse
|
||||||
)
|
)
|
||||||
from shared.auth.decorators import get_current_user_dep
|
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
|
||||||
from shared.auth.tenant_access import verify_tenant_access_dep
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/ingredients", tags=["ingredients"])
|
router = APIRouter(tags=["ingredients"])
|
||||||
|
|
||||||
# Helper function to extract user ID from user object
|
# Helper function to extract user ID from user object
|
||||||
def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID:
|
def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID:
|
||||||
@@ -34,15 +33,31 @@ def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> U
|
|||||||
return UUID(user_id)
|
return UUID(user_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=IngredientResponse)
|
@router.post("/tenants/{tenant_id}/ingredients", response_model=IngredientResponse)
|
||||||
async def create_ingredient(
|
async def create_ingredient(
|
||||||
ingredient_data: IngredientCreate,
|
ingredient_data: IngredientCreate,
|
||||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
user_id: UUID = Depends(get_current_user_id),
|
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Create a new ingredient"""
|
"""Create a new ingredient"""
|
||||||
try:
|
try:
|
||||||
|
# Verify tenant access
|
||||||
|
if str(tenant_id) != current_tenant:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||||
|
|
||||||
|
# Extract user ID - handle service tokens that don't have UUID user_ids
|
||||||
|
raw_user_id = current_user.get('user_id')
|
||||||
|
if current_user.get('type') == 'service':
|
||||||
|
# For service tokens, user_id might not be a UUID, so set to None
|
||||||
|
user_id = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
user_id = UUID(raw_user_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
user_id = None
|
||||||
|
|
||||||
service = InventoryService()
|
service = InventoryService()
|
||||||
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
|
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
|
||||||
return ingredient
|
return ingredient
|
||||||
@@ -58,14 +73,20 @@ async def create_ingredient(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ingredient_id}", response_model=IngredientResponse)
|
@router.get("/tenants/{tenant_id}/ingredients/{ingredient_id}", response_model=IngredientResponse)
|
||||||
async def get_ingredient(
|
async def get_ingredient(
|
||||||
ingredient_id: UUID,
|
ingredient_id: UUID,
|
||||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get ingredient by ID"""
|
"""Get ingredient by ID"""
|
||||||
try:
|
try:
|
||||||
|
# Verify tenant access
|
||||||
|
if str(tenant_id) != current_tenant:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||||
|
|
||||||
service = InventoryService()
|
service = InventoryService()
|
||||||
ingredient = await service.get_ingredient(ingredient_id, tenant_id)
|
ingredient = await service.get_ingredient(ingredient_id, tenant_id)
|
||||||
|
|
||||||
@@ -85,15 +106,21 @@ async def get_ingredient(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{ingredient_id}", response_model=IngredientResponse)
|
@router.put("/tenants/{tenant_id}/ingredients/{ingredient_id}", response_model=IngredientResponse)
|
||||||
async def update_ingredient(
|
async def update_ingredient(
|
||||||
ingredient_id: UUID,
|
ingredient_id: UUID,
|
||||||
ingredient_data: IngredientUpdate,
|
ingredient_data: IngredientUpdate,
|
||||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Update ingredient"""
|
"""Update ingredient"""
|
||||||
try:
|
try:
|
||||||
|
# Verify tenant access
|
||||||
|
if str(tenant_id) != current_tenant:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||||
|
|
||||||
service = InventoryService()
|
service = InventoryService()
|
||||||
ingredient = await service.update_ingredient(ingredient_id, ingredient_data, tenant_id)
|
ingredient = await service.update_ingredient(ingredient_id, ingredient_data, tenant_id)
|
||||||
|
|
||||||
@@ -118,8 +145,9 @@ async def update_ingredient(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[IngredientResponse])
|
@router.get("/tenants/{tenant_id}/ingredients", response_model=List[IngredientResponse])
|
||||||
async def list_ingredients(
|
async def list_ingredients(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||||
category: Optional[str] = Query(None, description="Filter by category"),
|
category: Optional[str] = Query(None, description="Filter by category"),
|
||||||
@@ -127,11 +155,16 @@ async def list_ingredients(
|
|||||||
is_low_stock: Optional[bool] = Query(None, description="Filter by low stock status"),
|
is_low_stock: Optional[bool] = Query(None, description="Filter by low stock status"),
|
||||||
needs_reorder: Optional[bool] = Query(None, description="Filter by reorder needed"),
|
needs_reorder: Optional[bool] = Query(None, description="Filter by reorder needed"),
|
||||||
search: Optional[str] = Query(None, description="Search in name, SKU, or barcode"),
|
search: Optional[str] = Query(None, description="Search in name, SKU, or barcode"),
|
||||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""List ingredients with filtering"""
|
"""List ingredients with filtering"""
|
||||||
try:
|
try:
|
||||||
|
# Verify tenant access
|
||||||
|
if str(tenant_id) != current_tenant:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||||
|
|
||||||
service = InventoryService()
|
service = InventoryService()
|
||||||
|
|
||||||
# Build filters
|
# Build filters
|
||||||
@@ -156,14 +189,20 @@ async def list_ingredients(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{ingredient_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/tenants/{tenant_id}/ingredients/{ingredient_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_ingredient(
|
async def delete_ingredient(
|
||||||
ingredient_id: UUID,
|
ingredient_id: UUID,
|
||||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Soft delete ingredient (mark as inactive)"""
|
"""Soft delete ingredient (mark as inactive)"""
|
||||||
try:
|
try:
|
||||||
|
# Verify tenant access
|
||||||
|
if str(tenant_id) != current_tenant:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||||
|
|
||||||
service = InventoryService()
|
service = InventoryService()
|
||||||
ingredient = await service.update_ingredient(
|
ingredient = await service.update_ingredient(
|
||||||
ingredient_id,
|
ingredient_id,
|
||||||
@@ -187,15 +226,21 @@ async def delete_ingredient(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ingredient_id}/stock", response_model=List[dict])
|
@router.get("/tenants/{tenant_id}/ingredients/{ingredient_id}/stock", response_model=List[dict])
|
||||||
async def get_ingredient_stock(
|
async def get_ingredient_stock(
|
||||||
ingredient_id: UUID,
|
ingredient_id: UUID,
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
include_unavailable: bool = Query(False, description="Include unavailable stock"),
|
include_unavailable: bool = Query(False, description="Include unavailable stock"),
|
||||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get stock entries for an ingredient"""
|
"""Get stock entries for an ingredient"""
|
||||||
try:
|
try:
|
||||||
|
# Verify tenant access
|
||||||
|
if str(tenant_id) != current_tenant:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||||
|
|
||||||
service = InventoryService()
|
service = InventoryService()
|
||||||
stock_entries = await service.get_stock_by_ingredient(
|
stock_entries = await service.get_stock_by_ingredient(
|
||||||
ingredient_id, tenant_id, include_unavailable
|
ingredient_id, tenant_id, include_unavailable
|
||||||
|
|||||||
@@ -160,6 +160,14 @@ class Ingredient(Base):
|
|||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert model to dictionary for API responses"""
|
"""Convert model to dictionary for API responses"""
|
||||||
|
# Map to response schema format - use ingredient_category as primary category
|
||||||
|
category = None
|
||||||
|
if self.ingredient_category:
|
||||||
|
category = self.ingredient_category.value
|
||||||
|
elif self.product_category:
|
||||||
|
# For finished products, we could map to a generic category
|
||||||
|
category = "other"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': str(self.id),
|
'id': str(self.id),
|
||||||
'tenant_id': str(self.tenant_id),
|
'tenant_id': str(self.tenant_id),
|
||||||
@@ -167,6 +175,7 @@ class Ingredient(Base):
|
|||||||
'sku': self.sku,
|
'sku': self.sku,
|
||||||
'barcode': self.barcode,
|
'barcode': self.barcode,
|
||||||
'product_type': self.product_type.value if self.product_type else None,
|
'product_type': self.product_type.value if self.product_type else None,
|
||||||
|
'category': category, # Map to what IngredientResponse expects
|
||||||
'ingredient_category': self.ingredient_category.value if self.ingredient_category else None,
|
'ingredient_category': self.ingredient_category.value if self.ingredient_category else None,
|
||||||
'product_category': self.product_category.value if self.product_category else None,
|
'product_category': self.product_category.value if self.product_category else None,
|
||||||
'subcategory': self.subcategory,
|
'subcategory': self.subcategory,
|
||||||
|
|||||||
@@ -26,17 +26,53 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
|
|||||||
async def create_ingredient(self, ingredient_data: IngredientCreate, tenant_id: UUID) -> Ingredient:
|
async def create_ingredient(self, ingredient_data: IngredientCreate, tenant_id: UUID) -> Ingredient:
|
||||||
"""Create a new ingredient"""
|
"""Create a new ingredient"""
|
||||||
try:
|
try:
|
||||||
# Prepare data
|
# Prepare data and map schema fields to model fields
|
||||||
create_data = ingredient_data.model_dump()
|
create_data = ingredient_data.model_dump()
|
||||||
create_data['tenant_id'] = tenant_id
|
create_data['tenant_id'] = tenant_id
|
||||||
|
|
||||||
|
# Map 'category' from schema to appropriate model fields
|
||||||
|
if 'category' in create_data:
|
||||||
|
category_value = create_data.pop('category')
|
||||||
|
# For now, assume all items are ingredients and map to ingredient_category
|
||||||
|
# Convert string to enum object
|
||||||
|
from app.models.inventory import IngredientCategory
|
||||||
|
try:
|
||||||
|
# Find the enum member by value
|
||||||
|
for enum_member in IngredientCategory:
|
||||||
|
if enum_member.value == category_value:
|
||||||
|
create_data['ingredient_category'] = enum_member
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# If not found, default to OTHER
|
||||||
|
create_data['ingredient_category'] = IngredientCategory.OTHER
|
||||||
|
except Exception:
|
||||||
|
# Fallback to OTHER if any issues
|
||||||
|
create_data['ingredient_category'] = IngredientCategory.OTHER
|
||||||
|
|
||||||
|
# Convert unit_of_measure string to enum object
|
||||||
|
if 'unit_of_measure' in create_data:
|
||||||
|
unit_value = create_data['unit_of_measure']
|
||||||
|
from app.models.inventory import UnitOfMeasure
|
||||||
|
try:
|
||||||
|
# Find the enum member by value
|
||||||
|
for enum_member in UnitOfMeasure:
|
||||||
|
if enum_member.value == unit_value:
|
||||||
|
create_data['unit_of_measure'] = enum_member
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# If not found, default to UNITS
|
||||||
|
create_data['unit_of_measure'] = UnitOfMeasure.UNITS
|
||||||
|
except Exception:
|
||||||
|
# Fallback to UNITS if any issues
|
||||||
|
create_data['unit_of_measure'] = UnitOfMeasure.UNITS
|
||||||
|
|
||||||
# Create record
|
# Create record
|
||||||
record = await self.create(create_data)
|
record = await self.create(create_data)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Created ingredient",
|
"Created ingredient",
|
||||||
ingredient_id=record.id,
|
ingredient_id=record.id,
|
||||||
name=record.name,
|
name=record.name,
|
||||||
category=record.category.value if record.category else None,
|
ingredient_category=record.ingredient_category.value if record.ingredient_category else None,
|
||||||
tenant_id=tenant_id
|
tenant_id=tenant_id
|
||||||
)
|
)
|
||||||
return record
|
return record
|
||||||
|
|||||||
@@ -25,6 +25,27 @@ logger = structlog.get_logger()
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class FileValidationResponse(BaseModel):
|
||||||
|
"""Response for file validation step"""
|
||||||
|
is_valid: bool
|
||||||
|
total_records: int
|
||||||
|
unique_products: int
|
||||||
|
product_list: List[str]
|
||||||
|
validation_errors: List[Any]
|
||||||
|
validation_warnings: List[Any]
|
||||||
|
summary: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSuggestionsResponse(BaseModel):
|
||||||
|
"""Response for AI suggestions step"""
|
||||||
|
suggestions: List[Dict[str, Any]]
|
||||||
|
business_model_analysis: Dict[str, Any]
|
||||||
|
total_products: int
|
||||||
|
high_confidence_count: int
|
||||||
|
low_confidence_count: int
|
||||||
|
processing_time_seconds: float
|
||||||
|
|
||||||
|
|
||||||
class InventoryApprovalRequest(BaseModel):
|
class InventoryApprovalRequest(BaseModel):
|
||||||
"""Request to approve/modify inventory suggestions"""
|
"""Request to approve/modify inventory suggestions"""
|
||||||
suggestions: List[Dict[str, Any]] = Field(..., description="Approved suggestions with modifications")
|
suggestions: List[Dict[str, Any]] = Field(..., description="Approved suggestions with modifications")
|
||||||
|
|||||||
@@ -310,6 +310,19 @@ class AIOnboardingService:
|
|||||||
processing_time_seconds=processing_time
|
processing_time_seconds=processing_time
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Update tenant's business model based on AI analysis
|
||||||
|
if business_model.model != "unknown" and business_model.confidence >= 0.6:
|
||||||
|
try:
|
||||||
|
await self._update_tenant_business_model(tenant_id, business_model.model)
|
||||||
|
logger.info("Updated tenant business model",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
business_model=business_model.model,
|
||||||
|
confidence=business_model.confidence)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to update tenant business model",
|
||||||
|
error=str(e), tenant_id=tenant_id)
|
||||||
|
# Don't fail the entire process if tenant update fails
|
||||||
|
|
||||||
logger.info("AI inventory suggestions completed",
|
logger.info("AI inventory suggestions completed",
|
||||||
total_suggestions=len(suggestions),
|
total_suggestions=len(suggestions),
|
||||||
business_model=business_model.model,
|
business_model=business_model.model,
|
||||||
@@ -385,21 +398,68 @@ class AIOnboardingService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Build inventory item data from suggestion and modifications
|
# Build inventory item data from suggestion and modifications
|
||||||
|
# Map to inventory service expected format
|
||||||
|
raw_category = modifications.get("category") or approval.get("category", "other")
|
||||||
|
raw_unit = modifications.get("unit_of_measure") or approval.get("unit_of_measure", "units")
|
||||||
|
|
||||||
|
# Map categories to inventory service enum values
|
||||||
|
category_mapping = {
|
||||||
|
"flour": "flour",
|
||||||
|
"yeast": "yeast",
|
||||||
|
"dairy": "dairy",
|
||||||
|
"eggs": "eggs",
|
||||||
|
"sugar": "sugar",
|
||||||
|
"fats": "fats",
|
||||||
|
"salt": "salt",
|
||||||
|
"spices": "spices",
|
||||||
|
"additives": "additives",
|
||||||
|
"packaging": "packaging",
|
||||||
|
"cleaning": "cleaning",
|
||||||
|
"grains": "flour", # Map common variations
|
||||||
|
"bread": "other",
|
||||||
|
"pastries": "other",
|
||||||
|
"croissants": "other",
|
||||||
|
"cakes": "other",
|
||||||
|
"other_products": "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map units to inventory service enum values
|
||||||
|
unit_mapping = {
|
||||||
|
"kg": "kg",
|
||||||
|
"kilograms": "kg",
|
||||||
|
"g": "g",
|
||||||
|
"grams": "g",
|
||||||
|
"l": "l",
|
||||||
|
"liters": "l",
|
||||||
|
"ml": "ml",
|
||||||
|
"milliliters": "ml",
|
||||||
|
"units": "units",
|
||||||
|
"pieces": "pcs",
|
||||||
|
"pcs": "pcs",
|
||||||
|
"packages": "pkg",
|
||||||
|
"pkg": "pkg",
|
||||||
|
"bags": "bags",
|
||||||
|
"boxes": "boxes"
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped_category = category_mapping.get(raw_category.lower(), "other")
|
||||||
|
mapped_unit = unit_mapping.get(raw_unit.lower(), "units")
|
||||||
|
|
||||||
inventory_data = {
|
inventory_data = {
|
||||||
"name": modifications.get("name") or approval.get("suggested_name"),
|
"name": modifications.get("name") or approval.get("suggested_name"),
|
||||||
"product_type": modifications.get("product_type") or approval.get("product_type"),
|
"category": mapped_category,
|
||||||
"category": modifications.get("category") or approval.get("category"),
|
"unit_of_measure": mapped_unit,
|
||||||
"unit_of_measure": modifications.get("unit_of_measure") or approval.get("unit_of_measure"),
|
|
||||||
"description": modifications.get("description") or approval.get("notes", ""),
|
"description": modifications.get("description") or approval.get("notes", ""),
|
||||||
"estimated_shelf_life_days": modifications.get("estimated_shelf_life_days") or approval.get("estimated_shelf_life_days"),
|
# Optional fields
|
||||||
"requires_refrigeration": modifications.get("requires_refrigeration", approval.get("requires_refrigeration", False)),
|
"brand": modifications.get("brand") or approval.get("suggested_supplier"),
|
||||||
"requires_freezing": modifications.get("requires_freezing", approval.get("requires_freezing", False)),
|
"is_active": True
|
||||||
"is_seasonal": modifications.get("is_seasonal", approval.get("is_seasonal", False)),
|
|
||||||
"suggested_supplier": modifications.get("suggested_supplier") or approval.get("suggested_supplier"),
|
|
||||||
"is_active": True,
|
|
||||||
"source": "ai_onboarding"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add optional numeric fields only if they exist
|
||||||
|
shelf_life = modifications.get("estimated_shelf_life_days") or approval.get("estimated_shelf_life_days")
|
||||||
|
if shelf_life:
|
||||||
|
inventory_data["shelf_life_days"] = shelf_life
|
||||||
|
|
||||||
# Create inventory item via inventory service
|
# Create inventory item via inventory service
|
||||||
created_item = await self.inventory_client.create_ingredient(
|
created_item = await self.inventory_client.create_ingredient(
|
||||||
inventory_data, str(tenant_id)
|
inventory_data, str(tenant_id)
|
||||||
@@ -619,6 +679,47 @@ class AIOnboardingService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to analyze product sales data", error=str(e))
|
logger.warning("Failed to analyze product sales data", error=str(e))
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
async def _update_tenant_business_model(self, tenant_id: UUID, business_model: str) -> None:
|
||||||
|
"""Update tenant's business model based on AI analysis"""
|
||||||
|
try:
|
||||||
|
# Use the gateway URL for all inter-service communication
|
||||||
|
from app.core.config import settings
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
gateway_url = settings.GATEWAY_URL
|
||||||
|
url = f"{gateway_url}/api/v1/tenants/{tenant_id}"
|
||||||
|
|
||||||
|
# Prepare update data
|
||||||
|
update_data = {
|
||||||
|
"business_model": business_model
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make request through gateway
|
||||||
|
timeout_config = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout_config) as client:
|
||||||
|
response = await client.put(
|
||||||
|
url,
|
||||||
|
json=update_data,
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
logger.info("Successfully updated tenant business model via gateway",
|
||||||
|
tenant_id=tenant_id, business_model=business_model)
|
||||||
|
else:
|
||||||
|
logger.warning("Failed to update tenant business model via gateway",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
status_code=response.status_code,
|
||||||
|
response=response.text)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error updating tenant business model via gateway",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
business_model=business_model,
|
||||||
|
error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
# Factory function for dependency injection
|
# Factory function for dependency injection
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ import base64
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
import structlog
|
import structlog
|
||||||
import re
|
import re
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from app.repositories.sales_repository import SalesRepository
|
from app.repositories.sales_repository import SalesRepository
|
||||||
from app.models.sales import SalesData
|
from app.models.sales import SalesData
|
||||||
from app.schemas.sales import SalesDataCreate
|
from app.schemas.sales import SalesDataCreate
|
||||||
from app.core.database import get_db_transaction
|
from app.core.database import get_db_transaction
|
||||||
|
from app.services.inventory_client import InventoryServiceClient
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
@@ -79,7 +82,10 @@ class DataImportService:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize enhanced import service"""
|
"""Initialize enhanced import service"""
|
||||||
pass
|
self.inventory_client = InventoryServiceClient()
|
||||||
|
# Product resolution cache for the import session
|
||||||
|
self.product_cache = {} # product_name -> inventory_product_id
|
||||||
|
self.failed_products = set() # Track products that failed to resolve
|
||||||
|
|
||||||
async def validate_import_data(self, data: Dict[str, Any]) -> SalesValidationResult:
|
async def validate_import_data(self, data: Dict[str, Any]) -> SalesValidationResult:
|
||||||
"""Enhanced validation with better error handling and suggestions"""
|
"""Enhanced validation with better error handling and suggestions"""
|
||||||
@@ -349,6 +355,9 @@ class DataImportService:
|
|||||||
start_time = datetime.utcnow()
|
start_time = datetime.utcnow()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Clear cache for new import session
|
||||||
|
self._clear_import_cache()
|
||||||
|
|
||||||
logger.info("Starting enhanced data import",
|
logger.info("Starting enhanced data import",
|
||||||
filename=filename,
|
filename=filename,
|
||||||
format=file_format,
|
format=file_format,
|
||||||
@@ -451,12 +460,24 @@ class DataImportService:
|
|||||||
warnings.extend(parsed_data.get("warnings", []))
|
warnings.extend(parsed_data.get("warnings", []))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Resolve product name to inventory_product_id
|
||||||
|
inventory_product_id = await self._resolve_product_to_inventory_id(
|
||||||
|
parsed_data["product_name"],
|
||||||
|
parsed_data.get("product_category"),
|
||||||
|
tenant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not inventory_product_id:
|
||||||
|
error_msg = f"Row {index + 1}: Could not resolve product '{parsed_data['product_name']}' to inventory ID"
|
||||||
|
errors.append(error_msg)
|
||||||
|
logger.warning("Product resolution failed", error=error_msg)
|
||||||
|
continue
|
||||||
|
|
||||||
# Create sales record with enhanced data
|
# Create sales record with enhanced data
|
||||||
sales_data = SalesDataCreate(
|
sales_data = SalesDataCreate(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
date=parsed_data["date"],
|
date=parsed_data["date"],
|
||||||
product_name=parsed_data["product_name"],
|
inventory_product_id=inventory_product_id,
|
||||||
product_category=parsed_data.get("product_category"),
|
|
||||||
quantity_sold=parsed_data["quantity_sold"],
|
quantity_sold=parsed_data["quantity_sold"],
|
||||||
unit_price=parsed_data.get("unit_price"),
|
unit_price=parsed_data.get("unit_price"),
|
||||||
revenue=parsed_data.get("revenue"),
|
revenue=parsed_data.get("revenue"),
|
||||||
@@ -619,12 +640,24 @@ class DataImportService:
|
|||||||
warnings.extend(parsed_data.get("warnings", []))
|
warnings.extend(parsed_data.get("warnings", []))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Resolve product name to inventory_product_id
|
||||||
|
inventory_product_id = await self._resolve_product_to_inventory_id(
|
||||||
|
parsed_data["product_name"],
|
||||||
|
parsed_data.get("product_category"),
|
||||||
|
tenant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not inventory_product_id:
|
||||||
|
error_msg = f"Row {index + 1}: Could not resolve product '{parsed_data['product_name']}' to inventory ID"
|
||||||
|
errors.append(error_msg)
|
||||||
|
logger.warning("Product resolution failed", error=error_msg)
|
||||||
|
continue
|
||||||
|
|
||||||
# Create enhanced sales record
|
# Create enhanced sales record
|
||||||
sales_data = SalesDataCreate(
|
sales_data = SalesDataCreate(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
date=parsed_data["date"],
|
date=parsed_data["date"],
|
||||||
product_name=parsed_data["product_name"],
|
inventory_product_id=inventory_product_id,
|
||||||
product_category=parsed_data.get("product_category"),
|
|
||||||
quantity_sold=parsed_data["quantity_sold"],
|
quantity_sold=parsed_data["quantity_sold"],
|
||||||
unit_price=parsed_data.get("unit_price"),
|
unit_price=parsed_data.get("unit_price"),
|
||||||
revenue=parsed_data.get("revenue"),
|
revenue=parsed_data.get("revenue"),
|
||||||
@@ -874,6 +907,94 @@ class DataImportService:
|
|||||||
|
|
||||||
return cleaned if cleaned else "Producto sin nombre"
|
return cleaned if cleaned else "Producto sin nombre"
|
||||||
|
|
||||||
|
def _clear_import_cache(self):
|
||||||
|
"""Clear the product resolution cache for a new import session"""
|
||||||
|
self.product_cache.clear()
|
||||||
|
self.failed_products.clear()
|
||||||
|
logger.info("Import cache cleared for new session")
|
||||||
|
|
||||||
|
async def _resolve_product_to_inventory_id(self, product_name: str, product_category: Optional[str], tenant_id: UUID) -> Optional[UUID]:
|
||||||
|
"""Resolve a product name to an inventory_product_id via the inventory service with caching and rate limiting"""
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
if product_name in self.product_cache:
|
||||||
|
logger.debug("Product resolved from cache", product_name=product_name, tenant_id=tenant_id)
|
||||||
|
return self.product_cache[product_name]
|
||||||
|
|
||||||
|
# Skip if this product already failed to resolve
|
||||||
|
if product_name in self.failed_products:
|
||||||
|
logger.debug("Skipping previously failed product", product_name=product_name, tenant_id=tenant_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
max_retries = 3
|
||||||
|
base_delay = 1.0 # Start with 1 second delay
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
# Add delay before API calls to avoid rate limiting
|
||||||
|
if attempt > 0:
|
||||||
|
delay = base_delay * (2 ** (attempt - 1)) # Exponential backoff
|
||||||
|
logger.info(f"Retrying product resolution after {delay}s delay",
|
||||||
|
product_name=product_name, attempt=attempt, tenant_id=tenant_id)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
# First try to search for existing product by name
|
||||||
|
products = await self.inventory_client.search_products(product_name, tenant_id)
|
||||||
|
|
||||||
|
if products:
|
||||||
|
# Return the first matching product's ID
|
||||||
|
product_id = products[0].get('id')
|
||||||
|
if product_id:
|
||||||
|
uuid_id = UUID(str(product_id))
|
||||||
|
self.product_cache[product_name] = uuid_id # Cache for future use
|
||||||
|
logger.info("Resolved product to existing inventory ID",
|
||||||
|
product_name=product_name, product_id=product_id, tenant_id=tenant_id)
|
||||||
|
return uuid_id
|
||||||
|
|
||||||
|
# Add small delay before creation attempt to avoid hitting rate limits
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# If not found, create a new ingredient/product in inventory
|
||||||
|
ingredient_data = {
|
||||||
|
'name': product_name,
|
||||||
|
'type': 'finished_product', # Assuming sales are of finished products
|
||||||
|
'unit': 'unit', # Default unit
|
||||||
|
'current_stock': 0, # No stock initially
|
||||||
|
'reorder_point': 0,
|
||||||
|
'cost_per_unit': 0,
|
||||||
|
'category': product_category or 'general'
|
||||||
|
}
|
||||||
|
|
||||||
|
created_product = await self.inventory_client.create_ingredient(ingredient_data, str(tenant_id))
|
||||||
|
if created_product and created_product.get('id'):
|
||||||
|
product_id = created_product['id']
|
||||||
|
uuid_id = UUID(str(product_id))
|
||||||
|
self.product_cache[product_name] = uuid_id # Cache for future use
|
||||||
|
logger.info("Created new inventory product for sales data",
|
||||||
|
product_name=product_name, product_id=product_id, tenant_id=tenant_id)
|
||||||
|
return uuid_id
|
||||||
|
|
||||||
|
logger.warning("Failed to resolve or create product in inventory",
|
||||||
|
product_name=product_name, tenant_id=tenant_id, attempt=attempt)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_str = str(e)
|
||||||
|
if "429" in error_str or "rate limit" in error_str.lower():
|
||||||
|
logger.warning("Rate limit hit, retrying",
|
||||||
|
product_name=product_name, attempt=attempt, error=error_str, tenant_id=tenant_id)
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
continue # Retry with exponential backoff
|
||||||
|
else:
|
||||||
|
logger.error("Error resolving product to inventory ID",
|
||||||
|
error=error_str, product_name=product_name, tenant_id=tenant_id)
|
||||||
|
break # Don't retry for non-rate-limit errors
|
||||||
|
|
||||||
|
# If all retries failed, mark as failed and return None
|
||||||
|
self.failed_products.add(product_name)
|
||||||
|
logger.error("Failed to resolve product after all retries",
|
||||||
|
product_name=product_name, tenant_id=tenant_id)
|
||||||
|
return None
|
||||||
|
|
||||||
def _structure_messages(self, messages: List[Union[str, Dict]]) -> List[Dict[str, Any]]:
|
def _structure_messages(self, messages: List[Union[str, Dict]]) -> List[Dict[str, Any]]:
|
||||||
"""Convert string messages to structured format"""
|
"""Convert string messages to structured format"""
|
||||||
structured = []
|
structured = []
|
||||||
|
|||||||
@@ -117,6 +117,20 @@ class InventoryServiceClient:
|
|||||||
logger.error("Error fetching products by category",
|
logger.error("Error fetching products by category",
|
||||||
error=str(e), category=category, tenant_id=tenant_id)
|
error=str(e), category=category, tenant_id=tenant_id)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def create_ingredient(self, ingredient_data: Dict[str, Any], tenant_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Create a new ingredient/product in inventory service"""
|
||||||
|
try:
|
||||||
|
result = await self._shared_client.create_ingredient(ingredient_data, tenant_id)
|
||||||
|
if result:
|
||||||
|
logger.info("Created ingredient in inventory service",
|
||||||
|
ingredient_name=ingredient_data.get('name'), tenant_id=tenant_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error creating ingredient",
|
||||||
|
error=str(e), ingredient_data=ingredient_data, tenant_id=tenant_id)
|
||||||
|
return None
|
||||||
|
|
||||||
# Dependency injection
|
# Dependency injection
|
||||||
async def get_inventory_client() -> InventoryServiceClient:
|
async def get_inventory_client() -> InventoryServiceClient:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class Tenant(Base):
|
|||||||
name = Column(String(200), nullable=False)
|
name = Column(String(200), nullable=False)
|
||||||
subdomain = Column(String(100), unique=True)
|
subdomain = Column(String(100), unique=True)
|
||||||
business_type = Column(String(100), default="bakery")
|
business_type = Column(String(100), default="bakery")
|
||||||
|
business_model = Column(String(100), default="individual_bakery") # individual_bakery, central_baker_satellite, retail_bakery, hybrid_bakery
|
||||||
|
|
||||||
# Location info
|
# Location info
|
||||||
address = Column(Text, nullable=False)
|
address = Column(Text, nullable=False)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class BakeryRegistration(BaseModel):
|
|||||||
postal_code: str = Field(..., pattern=r"^\d{5}$")
|
postal_code: str = Field(..., pattern=r"^\d{5}$")
|
||||||
phone: str = Field(..., min_length=9, max_length=20)
|
phone: str = Field(..., min_length=9, max_length=20)
|
||||||
business_type: str = Field(default="bakery")
|
business_type: str = Field(default="bakery")
|
||||||
|
business_model: Optional[str] = Field(default="individual_bakery")
|
||||||
|
|
||||||
@validator('phone')
|
@validator('phone')
|
||||||
def validate_spanish_phone(cls, v):
|
def validate_spanish_phone(cls, v):
|
||||||
@@ -41,6 +42,15 @@ class BakeryRegistration(BaseModel):
|
|||||||
if v not in valid_types:
|
if v not in valid_types:
|
||||||
raise ValueError(f'Business type must be one of: {valid_types}')
|
raise ValueError(f'Business type must be one of: {valid_types}')
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@validator('business_model')
|
||||||
|
def validate_business_model(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
valid_models = ['individual_bakery', 'central_baker_satellite', 'retail_bakery', 'hybrid_bakery']
|
||||||
|
if v not in valid_models:
|
||||||
|
raise ValueError(f'Business model must be one of: {valid_models}')
|
||||||
|
return v
|
||||||
|
|
||||||
class TenantResponse(BaseModel):
|
class TenantResponse(BaseModel):
|
||||||
"""Tenant response schema - FIXED VERSION"""
|
"""Tenant response schema - FIXED VERSION"""
|
||||||
@@ -48,6 +58,7 @@ class TenantResponse(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
subdomain: Optional[str]
|
subdomain: Optional[str]
|
||||||
business_type: str
|
business_type: str
|
||||||
|
business_model: Optional[str]
|
||||||
address: str
|
address: str
|
||||||
city: str
|
city: str
|
||||||
postal_code: str
|
postal_code: str
|
||||||
@@ -101,6 +112,7 @@ class TenantUpdate(BaseModel):
|
|||||||
address: Optional[str] = Field(None, min_length=10, max_length=500)
|
address: Optional[str] = Field(None, min_length=10, max_length=500)
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
business_type: Optional[str] = None
|
business_type: Optional[str] = None
|
||||||
|
business_model: Optional[str] = None
|
||||||
|
|
||||||
class TenantListResponse(BaseModel):
|
class TenantListResponse(BaseModel):
|
||||||
"""Response schema for listing tenants"""
|
"""Response schema for listing tenants"""
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class BaseServiceSettings(BaseSettings):
|
|||||||
SALES_SERVICE_URL: str = os.getenv("SALES_SERVICE_URL", "http://sales-service:8000")
|
SALES_SERVICE_URL: str = os.getenv("SALES_SERVICE_URL", "http://sales-service:8000")
|
||||||
EXTERNAL_SERVICE_URL: str = os.getenv("EXTERNAL_SERVICE_URL", "http://external-service:8000")
|
EXTERNAL_SERVICE_URL: str = os.getenv("EXTERNAL_SERVICE_URL", "http://external-service:8000")
|
||||||
TENANT_SERVICE_URL: str = os.getenv("TENANT_SERVICE_URL", "http://tenant-service:8000")
|
TENANT_SERVICE_URL: str = os.getenv("TENANT_SERVICE_URL", "http://tenant-service:8000")
|
||||||
|
INVENTORY_SERVICE_URL: str = os.getenv("INVENTORY_SERVICE_URL", "http://inventory-service:8000")
|
||||||
NOTIFICATION_SERVICE_URL: str = os.getenv("NOTIFICATION_SERVICE_URL", "http://notification-service:8000")
|
NOTIFICATION_SERVICE_URL: str = os.getenv("NOTIFICATION_SERVICE_URL", "http://notification-service:8000")
|
||||||
NOMINATIM_SERVICE_URL: str = os.getenv("NOMINATIM_SERVICE_URL", "http://nominatim:8080")
|
NOMINATIM_SERVICE_URL: str = os.getenv("NOMINATIM_SERVICE_URL", "http://nominatim:8080")
|
||||||
|
|
||||||
@@ -331,6 +332,7 @@ class BaseServiceSettings(BaseSettings):
|
|||||||
"sales": self.SALES_SERVICE_URL,
|
"sales": self.SALES_SERVICE_URL,
|
||||||
"external": self.EXTERNAL_SERVICE_URL,
|
"external": self.EXTERNAL_SERVICE_URL,
|
||||||
"tenant": self.TENANT_SERVICE_URL,
|
"tenant": self.TENANT_SERVICE_URL,
|
||||||
|
"inventory": self.INVENTORY_SERVICE_URL,
|
||||||
"notification": self.NOTIFICATION_SERVICE_URL,
|
"notification": self.NOTIFICATION_SERVICE_URL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
162
test_onboarding_debug.py
Normal file
162
test_onboarding_debug.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to debug onboarding inventory creation step by step
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import sys
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
# Test configuration
|
||||||
|
GATEWAY_URL = "http://localhost:8000"
|
||||||
|
TENANT_ID = "946206b3-7446-436b-b29d-f265b28d9ff5"
|
||||||
|
|
||||||
|
# Test token (you'll need to replace this with a real token)
|
||||||
|
TEST_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzOTUyYTEwOC1lNWFmLTRlMjktOTJkOC0xMjc0MTBiOWJiYmEiLCJ1c2VyX2lkIjoiMzk1MmExMDgtZTVhZi00ZTI5LTkyZDgtMTI3NDEwYjliYmJhIiwiZW1haWwiOiJkZnNmc2RAdGVzdC5jb20iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzU1MTY3NTk4LCJpYXQiOjE3NTUxNjU3OTgsImlzcyI6ImJha2VyeS1hdXRoIiwiZnVsbF9uYW1lIjoiZGZzZGZzZGYiLCJpc192ZXJpZmllZCI6ZmFsc2UsImlzX2FjdGl2ZSI6dHJ1ZSwicm9sZSI6InVzZXIifQ.hYyRqqqZ-Ud-uzn42l_ic-QjP-NWYvT8RmwmU12uaQU"
|
||||||
|
|
||||||
|
async def test_onboarding_flow():
|
||||||
|
"""Test the complete onboarding inventory creation flow"""
|
||||||
|
|
||||||
|
print("🧪 Testing Onboarding Inventory Creation Flow")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {TEST_TOKEN}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
|
||||||
|
# Step 1: Test direct ingredient creation via inventory service
|
||||||
|
print("\n1️⃣ Testing Direct Ingredient Creation via Inventory Service")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
ingredient_data = {
|
||||||
|
"name": "Test Flour",
|
||||||
|
"description": "Test ingredient for debugging",
|
||||||
|
"category": "flour", # Use valid enum value
|
||||||
|
"unit_of_measure": "kg", # Use correct field name
|
||||||
|
"brand": "Test Supplier",
|
||||||
|
"is_active": True
|
||||||
|
}
|
||||||
|
|
||||||
|
ingredient_url = f"{GATEWAY_URL}/api/v1/tenants/{TENANT_ID}/ingredients"
|
||||||
|
print(f"URL: {ingredient_url}")
|
||||||
|
print(f"Data: {json.dumps(ingredient_data, indent=2)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.post(ingredient_url, json=ingredient_data, headers=headers)
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
print(f"Response: {response.text}")
|
||||||
|
|
||||||
|
if response.status_code == 201:
|
||||||
|
print("✅ Direct ingredient creation SUCCESS!")
|
||||||
|
created_ingredient = response.json()
|
||||||
|
ingredient_id = created_ingredient.get('id')
|
||||||
|
print(f"Created ingredient ID: {ingredient_id}")
|
||||||
|
else:
|
||||||
|
print("❌ Direct ingredient creation FAILED!")
|
||||||
|
if response.status_code == 401:
|
||||||
|
print("❌ Authentication failed - token might be expired")
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Direct ingredient creation ERROR: {e}")
|
||||||
|
|
||||||
|
# Step 2: Test onboarding inventory creation endpoint
|
||||||
|
print("\n2️⃣ Testing Onboarding Inventory Creation Endpoint")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
# Create test suggestions like the frontend sends
|
||||||
|
suggestions = [
|
||||||
|
{
|
||||||
|
"suggestion_id": str(uuid4()),
|
||||||
|
"approved": True,
|
||||||
|
"modifications": {},
|
||||||
|
"original_name": "Pan",
|
||||||
|
"suggested_name": "Pan",
|
||||||
|
"product_type": "finished_product",
|
||||||
|
"category": "other_products",
|
||||||
|
"unit_of_measure": "units",
|
||||||
|
"confidence_score": 0.9,
|
||||||
|
"estimated_shelf_life_days": None,
|
||||||
|
"requires_refrigeration": False,
|
||||||
|
"requires_freezing": False,
|
||||||
|
"is_seasonal": False,
|
||||||
|
"suggested_supplier": None,
|
||||||
|
"notes": "Test bread product"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"suggestion_id": str(uuid4()),
|
||||||
|
"approved": True,
|
||||||
|
"modifications": {},
|
||||||
|
"original_name": "Test Croissant",
|
||||||
|
"suggested_name": "Test Croissant",
|
||||||
|
"product_type": "finished_product",
|
||||||
|
"category": "pastries",
|
||||||
|
"unit_of_measure": "units",
|
||||||
|
"confidence_score": 0.8,
|
||||||
|
"estimated_shelf_life_days": 2,
|
||||||
|
"requires_refrigeration": False,
|
||||||
|
"requires_freezing": False,
|
||||||
|
"is_seasonal": False,
|
||||||
|
"suggested_supplier": "Test Bakery",
|
||||||
|
"notes": "Test pastry product"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
onboarding_data = {"suggestions": suggestions}
|
||||||
|
onboarding_url = f"{GATEWAY_URL}/api/v1/tenants/{TENANT_ID}/onboarding/create-inventory"
|
||||||
|
|
||||||
|
print(f"URL: {onboarding_url}")
|
||||||
|
print(f"Suggestions count: {len(suggestions)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.post(onboarding_url, json=onboarding_data, headers=headers)
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
print(f"Response: {response.text}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
print(f"✅ Onboarding inventory creation completed!")
|
||||||
|
print(f"Created items: {len(result.get('created_items', []))}")
|
||||||
|
print(f"Failed items: {len(result.get('failed_items', []))}")
|
||||||
|
print(f"Success rate: {result.get('success_rate', 0)}")
|
||||||
|
|
||||||
|
if result.get('failed_items'):
|
||||||
|
print("\n❌ Failed items details:")
|
||||||
|
for item in result['failed_items']:
|
||||||
|
print(f" - {item.get('suggestion_id')}: {item.get('error')}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("❌ Onboarding inventory creation FAILED!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Onboarding inventory creation ERROR: {e}")
|
||||||
|
|
||||||
|
# Step 3: Test service health
|
||||||
|
print("\n3️⃣ Testing Service Health")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
services = [
|
||||||
|
("Gateway", f"{GATEWAY_URL}/health"),
|
||||||
|
("Inventory Service", "http://localhost:8008/health"),
|
||||||
|
("Sales Service", "http://localhost:8004/health")
|
||||||
|
]
|
||||||
|
|
||||||
|
for service_name, health_url in services:
|
||||||
|
try:
|
||||||
|
response = await client.get(health_url)
|
||||||
|
status = "✅ Healthy" if response.status_code == 200 else f"❌ Unhealthy ({response.status_code})"
|
||||||
|
print(f"{service_name}: {status}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{service_name}: ❌ Error - {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Starting onboarding flow debug test...")
|
||||||
|
print("Make sure your services are running with docker-compose!")
|
||||||
|
print()
|
||||||
|
|
||||||
|
asyncio.run(test_onboarding_flow())
|
||||||
153
test_sales_import_fix.py
Normal file
153
test_sales_import_fix.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to verify the sales import fix for inventory_product_id issue
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
# Test configuration
|
||||||
|
GATEWAY_URL = "http://localhost:8000"
|
||||||
|
TENANT_ID = "946206b3-7446-436b-b29d-f265b28d9ff5"
|
||||||
|
|
||||||
|
# Test token
|
||||||
|
TEST_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzOTUyYTEwOC1lNWFmLTRlMjktOTJkOC0xMjc0MTBiOWJiYmEiLCJ1c2VyX2lkIjoiMzk1MmExMDgtZTVhZi00ZTI5LTkyZDgtMTI3NDEwYjliYmJhIiwiZW1haWwiOiJkZnNmc2RAdGVzdC5jb20iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzU1MTY3NTk4LCJpYXQiOjE3NTUxNjU3OTgsImlzcyI6ImJha2VyeS1hdXRoIiwiZnVsbF9uYW1lIjoiZGZzZGZzZGYiLCJpc192ZXJpZmllZCI6ZmFsc2UsImlzX2FjdGl2ZSI6dHJ1ZSwicm9sZSI6InVzZXIifQ.hYyRqqqZ-Ud-uzn42l_ic-QjP-NWYvT8RmwmU12uaQU"
|
||||||
|
|
||||||
|
async def test_sales_import():
|
||||||
|
"""Test the sales import functionality with the inventory_product_id fix"""
|
||||||
|
|
||||||
|
print("🧪 Testing Sales Import Fix for inventory_product_id")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {TEST_TOKEN}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
|
||||||
|
# Step 1: Create test CSV data
|
||||||
|
print("\n1️⃣ Creating Test CSV Data")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
csv_data = [
|
||||||
|
["date", "product_name", "quantity_sold", "revenue", "location_id"],
|
||||||
|
["2024-01-15", "Test Bread", "10", "25.50", "store-1"],
|
||||||
|
["2024-01-15", "Test Croissant", "5", "15.00", "store-1"],
|
||||||
|
["2024-01-15", "Test Muffin", "8", "20.00", "store-2"]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Convert to CSV string
|
||||||
|
csv_string = io.StringIO()
|
||||||
|
writer = csv.writer(csv_string)
|
||||||
|
for row in csv_data:
|
||||||
|
writer.writerow(row)
|
||||||
|
csv_content = csv_string.getvalue()
|
||||||
|
|
||||||
|
print(f"CSV Content:")
|
||||||
|
print(csv_content)
|
||||||
|
|
||||||
|
# Step 2: Test the import endpoint
|
||||||
|
print("\n2️⃣ Testing Sales Data Import")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
import_data = {
|
||||||
|
"csv_data": csv_content,
|
||||||
|
"import_type": "enhanced",
|
||||||
|
"overwrite_existing": False
|
||||||
|
}
|
||||||
|
|
||||||
|
import_url = f"{GATEWAY_URL}/api/v1/tenants/{TENANT_ID}/sales/import/csv"
|
||||||
|
print(f"URL: {import_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.post(import_url, json=import_data, headers=headers)
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
print(f"✅ Sales import completed!")
|
||||||
|
print(f"Records processed: {result.get('records_processed', 0)}")
|
||||||
|
print(f"Records created: {result.get('records_created', 0)}")
|
||||||
|
print(f"Records failed: {result.get('records_failed', 0)}")
|
||||||
|
print(f"Success: {result.get('success', False)}")
|
||||||
|
|
||||||
|
if result.get('errors'):
|
||||||
|
print(f"\n❌ Errors ({len(result['errors'])}):")
|
||||||
|
for error in result['errors'][:5]: # Show first 5 errors
|
||||||
|
print(f" - {error}")
|
||||||
|
|
||||||
|
if result.get('warnings'):
|
||||||
|
print(f"\n⚠️ Warnings ({len(result['warnings'])}):")
|
||||||
|
for warning in result['warnings'][:3]: # Show first 3 warnings
|
||||||
|
print(f" - {warning}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("❌ Sales import FAILED!")
|
||||||
|
print(f"Response: {response.text}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Sales import ERROR: {e}")
|
||||||
|
|
||||||
|
# Step 3: Check if inventory items were created
|
||||||
|
print("\n3️⃣ Checking Inventory Items Creation")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ingredients_url = f"{GATEWAY_URL}/api/v1/tenants/{TENANT_ID}/ingredients"
|
||||||
|
response = await client.get(ingredients_url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
ingredients = response.json()
|
||||||
|
print(f"✅ Found {len(ingredients)} inventory items")
|
||||||
|
|
||||||
|
test_products = ["Test Bread", "Test Croissant", "Test Muffin"]
|
||||||
|
for product in test_products:
|
||||||
|
found = any(ingredient.get('name') == product for ingredient in ingredients)
|
||||||
|
status = "✅" if found else "❌"
|
||||||
|
print(f" {status} {product}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"❌ Failed to fetch inventory items: {response.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error checking inventory items: {e}")
|
||||||
|
|
||||||
|
# Step 4: Check sales records
|
||||||
|
print("\n4️⃣ Checking Sales Records")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sales_url = f"{GATEWAY_URL}/api/v1/tenants/{TENANT_ID}/sales"
|
||||||
|
response = await client.get(sales_url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
sales_data = response.json()
|
||||||
|
sales_count = len(sales_data) if isinstance(sales_data, list) else sales_data.get('total', 0)
|
||||||
|
print(f"✅ Found {sales_count} sales records")
|
||||||
|
|
||||||
|
# Show recent records
|
||||||
|
records = sales_data if isinstance(sales_data, list) else sales_data.get('records', [])
|
||||||
|
for i, record in enumerate(records[:3]): # Show first 3 records
|
||||||
|
inventory_id = record.get('inventory_product_id')
|
||||||
|
date = record.get('date', 'Unknown')
|
||||||
|
quantity = record.get('quantity_sold', 0)
|
||||||
|
revenue = record.get('revenue', 0)
|
||||||
|
print(f" Record {i+1}: Date={date}, Qty={quantity}, Revenue=${revenue}, InventoryID={inventory_id}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"❌ Failed to fetch sales records: {response.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error checking sales records: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Testing sales import fix...")
|
||||||
|
print("Make sure your services are running with docker-compose!")
|
||||||
|
print()
|
||||||
|
|
||||||
|
asyncio.run(test_sales_import())
|
||||||
Reference in New Issue
Block a user