Create the frontend receipes page to use real API
This commit is contained in:
142
add_real_recipes.sh
Executable file
142
add_real_recipes.sh
Executable file
@@ -0,0 +1,142 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Add real recipes using actual inventory data
|
||||
TENANT_ID="c464fb3e-7af2-46e6-9e43-85318f34199a"
|
||||
USER_ID="550e8400-e29b-41d4-a716-446655440000" # User UUID format
|
||||
API_BASE="http://localhost:8009"
|
||||
|
||||
echo "🧁 Adding real recipes with actual inventory data..."
|
||||
echo "📍 Tenant ID: $TENANT_ID"
|
||||
echo "🌐 API Base: $API_BASE"
|
||||
echo "=" | tr -d '\n' && printf '%.0s=' {1..50} && echo
|
||||
|
||||
# Real finished product IDs from inventory
|
||||
CROISSANT_ID="c9a049b7-d1ae-4bf5-99cc-ed2c46f0a509"
|
||||
NAPOLITANA_ID="72c1020f-64be-4b42-8857-e774908204d9"
|
||||
PALMERA_ID="9e888a05-9dda-488b-a06c-0c60a4479e67"
|
||||
PAN_TOSTADO_ID="368fba0e-8ec5-4048-a2f1-79b63f9e11cf"
|
||||
|
||||
# Real ingredient IDs from inventory
|
||||
HARINA_TRIGO_ID="1e1d496f-c041-4f42-82a9-2ae7837c9231" # Harina de Trigo
|
||||
LEVADURA_ID="de2f0852-75f5-4b18-8f0e-7c707f79a9f9" # Levadura Fresca
|
||||
MANTEQUILLA_ID="89e6224a-a055-4148-a2f1-86a5091becec" # Mantequilla
|
||||
|
||||
# Recipe 1: Croissant de Mantequilla
|
||||
echo "Creating Recipe 1: Croissant de Mantequilla..."
|
||||
curl -X POST "$API_BASE/api/v1/recipes/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-tenant-id: $TENANT_ID" \
|
||||
-H "x-user-id: $USER_ID" \
|
||||
-d '{
|
||||
"name": "Croissant de Mantequilla",
|
||||
"recipe_code": "CRO001",
|
||||
"finished_product_id": "'$CROISSANT_ID'",
|
||||
"description": "Croissant clásico francés con mantequilla, hojaldrado perfecto y textura crujiente",
|
||||
"category": "pastry",
|
||||
"difficulty_level": 3,
|
||||
"yield_quantity": 12,
|
||||
"yield_unit": "units",
|
||||
"prep_time_minutes": 240,
|
||||
"cook_time_minutes": 18,
|
||||
"total_time_minutes": 258,
|
||||
"is_signature_item": true,
|
||||
"target_margin_percentage": 65.0,
|
||||
"ingredients": [
|
||||
{"ingredient_id": "'$HARINA_TRIGO_ID'", "quantity": 500, "unit": "g", "is_optional": false, "ingredient_order": 1},
|
||||
{"ingredient_id": "'$MANTEQUILLA_ID'", "quantity": 300, "unit": "g", "is_optional": false, "ingredient_order": 2},
|
||||
{"ingredient_id": "'$LEVADURA_ID'", "quantity": 12, "unit": "g", "is_optional": false, "ingredient_order": 3}
|
||||
]
|
||||
}'
|
||||
|
||||
echo -e "\n\n"
|
||||
|
||||
# Recipe 2: Napolitana de Chocolate
|
||||
echo "Creating Recipe 2: Napolitana de Chocolate..."
|
||||
curl -X POST "$API_BASE/api/v1/recipes/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-tenant-id: $TENANT_ID" \
|
||||
-H "x-user-id: $USER_ID" \
|
||||
-d '{
|
||||
"name": "Napolitana de Chocolate",
|
||||
"recipe_code": "NAP001",
|
||||
"finished_product_id": "'$NAPOLITANA_ID'",
|
||||
"description": "Hojaldre relleno de chocolate, dulce y crujiente, perfecto para merienda",
|
||||
"category": "pastry",
|
||||
"difficulty_level": 2,
|
||||
"yield_quantity": 8,
|
||||
"yield_unit": "units",
|
||||
"prep_time_minutes": 45,
|
||||
"cook_time_minutes": 20,
|
||||
"total_time_minutes": 65,
|
||||
"is_signature_item": false,
|
||||
"target_margin_percentage": 70.0,
|
||||
"ingredients": [
|
||||
{"ingredient_id": "'$HARINA_TRIGO_ID'", "quantity": 300, "unit": "g", "is_optional": false, "ingredient_order": 1},
|
||||
{"ingredient_id": "'$MANTEQUILLA_ID'", "quantity": 200, "unit": "g", "is_optional": false, "ingredient_order": 2}
|
||||
]
|
||||
}'
|
||||
|
||||
echo -e "\n\n"
|
||||
|
||||
# Recipe 3: Palmera de Azúcar
|
||||
echo "Creating Recipe 3: Palmera de Azúcar..."
|
||||
curl -X POST "$API_BASE/api/v1/recipes/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-tenant-id: $TENANT_ID" \
|
||||
-H "x-user-id: $USER_ID" \
|
||||
-d '{
|
||||
"name": "Palmera de Azúcar",
|
||||
"recipe_code": "PAL001",
|
||||
"finished_product_id": "'$PALMERA_ID'",
|
||||
"description": "Palmera de hojaldre con azúcar caramelizado, dulce y crujiente",
|
||||
"category": "pastry",
|
||||
"difficulty_level": 2,
|
||||
"yield_quantity": 10,
|
||||
"yield_unit": "units",
|
||||
"prep_time_minutes": 30,
|
||||
"cook_time_minutes": 15,
|
||||
"total_time_minutes": 45,
|
||||
"is_signature_item": false,
|
||||
"target_margin_percentage": 75.0,
|
||||
"ingredients": [
|
||||
{"ingredient_id": "'$HARINA_TRIGO_ID'", "quantity": 400, "unit": "g", "is_optional": false, "ingredient_order": 1},
|
||||
{"ingredient_id": "'$MANTEQUILLA_ID'", "quantity": 150, "unit": "g", "is_optional": false, "ingredient_order": 2}
|
||||
]
|
||||
}'
|
||||
|
||||
echo -e "\n\n"
|
||||
|
||||
# Recipe 4: Pan Tostado Artesanal
|
||||
echo "Creating Recipe 4: Pan Tostado Artesanal..."
|
||||
curl -X POST "$API_BASE/api/v1/recipes/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-tenant-id: $TENANT_ID" \
|
||||
-H "x-user-id: $USER_ID" \
|
||||
-d '{
|
||||
"name": "Pan Tostado Artesanal",
|
||||
"recipe_code": "PAN001",
|
||||
"finished_product_id": "'$PAN_TOSTADO_ID'",
|
||||
"description": "Pan artesanal con corteza crujiente y miga tierna, perfecto para tostadas",
|
||||
"category": "bread",
|
||||
"difficulty_level": 2,
|
||||
"yield_quantity": 2,
|
||||
"yield_unit": "units",
|
||||
"prep_time_minutes": 180,
|
||||
"cook_time_minutes": 35,
|
||||
"total_time_minutes": 215,
|
||||
"is_signature_item": true,
|
||||
"target_margin_percentage": 60.0,
|
||||
"ingredients": [
|
||||
{"ingredient_id": "'$HARINA_TRIGO_ID'", "quantity": 600, "unit": "g", "is_optional": false, "ingredient_order": 1},
|
||||
{"ingredient_id": "'$LEVADURA_ID'", "quantity": 15, "unit": "g", "is_optional": false, "ingredient_order": 2}
|
||||
]
|
||||
}'
|
||||
|
||||
echo -e "\n\n🎉 Real recipes creation completed!"
|
||||
echo "✅ Created recipes for existing products:"
|
||||
echo " • Croissant de Mantequilla"
|
||||
echo " • Napolitana de Chocolate"
|
||||
echo " • Palmera de Azúcar"
|
||||
echo " • Pan Tostado Artesanal"
|
||||
echo ""
|
||||
echo "🔗 All recipes are linked to real products and ingredients from inventory!"
|
||||
145
add_sample_recipes.sh
Executable file
145
add_sample_recipes.sh
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Add sample recipes using the API
|
||||
TENANT_ID="c464fb3e-7af2-46e6-9e43-85318f34199a"
|
||||
API_BASE="http://localhost:8009"
|
||||
|
||||
echo "🧁 Adding sample recipes via API..."
|
||||
echo "📍 Tenant ID: $TENANT_ID"
|
||||
echo "🌐 API Base: $API_BASE"
|
||||
echo "=" | tr -d '\n' && printf '%.0s=' {1..50} && echo
|
||||
|
||||
# Sample finished product IDs (these should exist in your system)
|
||||
PRODUCT_ID_1="550e8400-e29b-41d4-a716-446655440001"
|
||||
PRODUCT_ID_2="550e8400-e29b-41d4-a716-446655440002"
|
||||
PRODUCT_ID_3="550e8400-e29b-41d4-a716-446655440003"
|
||||
PRODUCT_ID_4="550e8400-e29b-41d4-a716-446655440004"
|
||||
|
||||
# Sample ingredient IDs (these should exist in your system)
|
||||
ING_ID_1="660e8400-e29b-41d4-a716-446655440001" # Harina integral
|
||||
ING_ID_2="660e8400-e29b-41d4-a716-446655440002" # Agua
|
||||
ING_ID_3="660e8400-e29b-41d4-a716-446655440003" # Levadura
|
||||
ING_ID_4="660e8400-e29b-41d4-a716-446655440004" # Sal
|
||||
ING_ID_5="660e8400-e29b-41d4-a716-446655440005" # Harina de fuerza
|
||||
ING_ID_6="660e8400-e29b-41d4-a716-446655440006" # Mantequilla
|
||||
ING_ID_7="660e8400-e29b-41d4-a716-446655440007" # Leche
|
||||
ING_ID_8="660e8400-e29b-41d4-a716-446655440008" # Azúcar
|
||||
ING_ID_9="660e8400-e29b-41d4-a716-446655440009" # Manzanas
|
||||
ING_ID_10="660e8400-e29b-41d4-a716-446655440010" # Huevos
|
||||
ING_ID_11="660e8400-e29b-41d4-a716-446655440011" # Limón
|
||||
|
||||
# Recipe 1: Pan de Molde Integral
|
||||
echo "Creating Recipe 1: Pan de Molde Integral..."
|
||||
curl -X POST "$API_BASE/api/v1/recipes/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-tenant-id: $TENANT_ID" \
|
||||
-d '{
|
||||
"name": "Pan de Molde Integral",
|
||||
"recipe_code": "PAN001",
|
||||
"finished_product_id": "'$PRODUCT_ID_1'",
|
||||
"description": "Pan integral artesanal con semillas, perfecto para desayunos saludables.",
|
||||
"category": "bread",
|
||||
"difficulty_level": 2,
|
||||
"yield_quantity": 1,
|
||||
"yield_unit": "units",
|
||||
"prep_time_minutes": 120,
|
||||
"cook_time_minutes": 35,
|
||||
"total_time_minutes": 155,
|
||||
"is_signature_item": false,
|
||||
"target_margin_percentage": 40.0,
|
||||
"ingredients": [
|
||||
{"ingredient_id": "'$ING_ID_1'", "quantity": 500, "unit": "g", "is_optional": false, "ingredient_order": 1},
|
||||
{"ingredient_id": "'$ING_ID_2'", "quantity": 300, "unit": "ml", "is_optional": false, "ingredient_order": 2},
|
||||
{"ingredient_id": "'$ING_ID_3'", "quantity": 10, "unit": "g", "is_optional": false, "ingredient_order": 3},
|
||||
{"ingredient_id": "'$ING_ID_4'", "quantity": 8, "unit": "g", "is_optional": false, "ingredient_order": 4}
|
||||
]
|
||||
}'
|
||||
|
||||
echo -e "\n\n"
|
||||
|
||||
# Recipe 2: Croissants de Mantequilla
|
||||
echo "Creating Recipe 2: Croissants de Mantequilla..."
|
||||
curl -X POST "$API_BASE/api/v1/recipes/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-tenant-id: $TENANT_ID" \
|
||||
-d '{
|
||||
"name": "Croissants de Mantequilla",
|
||||
"recipe_code": "CRO001",
|
||||
"finished_product_id": "'$PRODUCT_ID_2'",
|
||||
"description": "Croissants franceses tradicionales con laminado de mantequilla.",
|
||||
"category": "pastry",
|
||||
"difficulty_level": 3,
|
||||
"yield_quantity": 12,
|
||||
"yield_unit": "units",
|
||||
"prep_time_minutes": 480,
|
||||
"cook_time_minutes": 20,
|
||||
"total_time_minutes": 500,
|
||||
"is_signature_item": true,
|
||||
"target_margin_percentage": 52.8,
|
||||
"ingredients": [
|
||||
{"ingredient_id": "'$ING_ID_5'", "quantity": 500, "unit": "g", "is_optional": false, "ingredient_order": 1},
|
||||
{"ingredient_id": "'$ING_ID_6'", "quantity": 250, "unit": "g", "is_optional": false, "ingredient_order": 2},
|
||||
{"ingredient_id": "'$ING_ID_7'", "quantity": 150, "unit": "ml", "is_optional": false, "ingredient_order": 3},
|
||||
{"ingredient_id": "'$ING_ID_8'", "quantity": 50, "unit": "g", "is_optional": false, "ingredient_order": 4}
|
||||
]
|
||||
}'
|
||||
|
||||
echo -e "\n\n"
|
||||
|
||||
# Recipe 3: Tarta de Manzana
|
||||
echo "Creating Recipe 3: Tarta de Manzana..."
|
||||
curl -X POST "$API_BASE/api/v1/recipes/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-tenant-id: $TENANT_ID" \
|
||||
-d '{
|
||||
"name": "Tarta de Manzana",
|
||||
"recipe_code": "TAR001",
|
||||
"finished_product_id": "'$PRODUCT_ID_3'",
|
||||
"description": "Tarta casera de manzana con canela y masa quebrada.",
|
||||
"category": "cake",
|
||||
"difficulty_level": 1,
|
||||
"yield_quantity": 8,
|
||||
"yield_unit": "portions",
|
||||
"prep_time_minutes": 45,
|
||||
"cook_time_minutes": 40,
|
||||
"total_time_minutes": 85,
|
||||
"is_signature_item": false,
|
||||
"target_margin_percentage": 65.0,
|
||||
"ingredients": [
|
||||
{"ingredient_id": "'$ING_ID_9'", "quantity": 1000, "unit": "g", "is_optional": false, "ingredient_order": 1},
|
||||
{"ingredient_id": "'$ING_ID_1'", "quantity": 250, "unit": "g", "is_optional": false, "ingredient_order": 2},
|
||||
{"ingredient_id": "'$ING_ID_6'", "quantity": 125, "unit": "g", "is_optional": false, "ingredient_order": 3},
|
||||
{"ingredient_id": "'$ING_ID_8'", "quantity": 100, "unit": "g", "is_optional": false, "ingredient_order": 4}
|
||||
]
|
||||
}'
|
||||
|
||||
echo -e "\n\n"
|
||||
|
||||
# Recipe 4: Magdalenas de Limón
|
||||
echo "Creating Recipe 4: Magdalenas de Limón..."
|
||||
curl -X POST "$API_BASE/api/v1/recipes/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-tenant-id: $TENANT_ID" \
|
||||
-d '{
|
||||
"name": "Magdalenas de Limón",
|
||||
"recipe_code": "MAG001",
|
||||
"finished_product_id": "'$PRODUCT_ID_4'",
|
||||
"description": "Magdalenas suaves y esponjosas con ralladura de limón.",
|
||||
"category": "pastry",
|
||||
"difficulty_level": 1,
|
||||
"yield_quantity": 12,
|
||||
"yield_unit": "units",
|
||||
"prep_time_minutes": 20,
|
||||
"cook_time_minutes": 25,
|
||||
"total_time_minutes": 45,
|
||||
"is_signature_item": false,
|
||||
"target_margin_percentage": 57.8,
|
||||
"ingredients": [
|
||||
{"ingredient_id": "'$ING_ID_1'", "quantity": 200, "unit": "g", "is_optional": false, "ingredient_order": 1},
|
||||
{"ingredient_id": "'$ING_ID_10'", "quantity": 3, "unit": "units", "is_optional": false, "ingredient_order": 2},
|
||||
{"ingredient_id": "'$ING_ID_8'", "quantity": 150, "unit": "g", "is_optional": false, "ingredient_order": 3},
|
||||
{"ingredient_id": "'$ING_ID_11'", "quantity": 2, "unit": "units", "is_optional": false, "ingredient_order": 4}
|
||||
]
|
||||
}'
|
||||
|
||||
echo -e "\n\n🎉 Sample recipes creation completed!"
|
||||
244
frontend/src/api/README_RECIPES_RESTRUCTURE.md
Normal file
244
frontend/src/api/README_RECIPES_RESTRUCTURE.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Recipes API Restructure Summary
|
||||
|
||||
## Overview
|
||||
|
||||
The recipes service API implementation has been completely restructured to handle tenant-dependent routing and properly mirror the backend API endpoints. This ensures consistency with the backend architecture and enables proper multi-tenant functionality.
|
||||
|
||||
## Architecture Changes
|
||||
|
||||
### 1. **Client Layer** (`src/api/client/`)
|
||||
- ✅ **Already properly implemented**: The existing `apiClient.ts` handles authentication, tenant headers, and error management
|
||||
- ✅ **Supports tenant-dependent routing**: Client properly forwards tenant ID in headers
|
||||
- ✅ **React Query integration**: Returns data directly for React Query consumption
|
||||
|
||||
### 2. **Services Layer** (`src/api/services/recipes.ts`)
|
||||
|
||||
#### **Before (Issues)**:
|
||||
- Missing tenant parameter in all methods
|
||||
- API calls didn't match backend tenant-dependent routing
|
||||
- Inconsistent URL patterns
|
||||
|
||||
#### **After (Fixed)**:
|
||||
```typescript
|
||||
// All methods now require tenantId parameter
|
||||
async createRecipe(tenantId: string, recipeData: RecipeCreate): Promise<RecipeResponse>
|
||||
async getRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse>
|
||||
async searchRecipes(tenantId: string, params: RecipeSearchParams = {}): Promise<RecipeResponse[]>
|
||||
|
||||
// URLs properly formatted for tenant-dependent routing
|
||||
private getBaseUrl(tenantId: string): string {
|
||||
return `/tenants/${tenantId}/recipes`;
|
||||
}
|
||||
```
|
||||
|
||||
#### **API Endpoints Mirrored**:
|
||||
- ✅ `POST /tenants/{tenant_id}/recipes` - Create recipe
|
||||
- ✅ `GET /tenants/{tenant_id}/recipes/{recipe_id}` - Get recipe with ingredients
|
||||
- ✅ `PUT /tenants/{tenant_id}/recipes/{recipe_id}` - Update recipe
|
||||
- ✅ `DELETE /tenants/{tenant_id}/recipes/{recipe_id}` - Delete recipe
|
||||
- ✅ `GET /tenants/{tenant_id}/recipes` - Search recipes with filters
|
||||
- ✅ `POST /tenants/{tenant_id}/recipes/{recipe_id}/duplicate` - Duplicate recipe
|
||||
- ✅ `POST /tenants/{tenant_id}/recipes/{recipe_id}/activate` - Activate recipe
|
||||
- ✅ `GET /tenants/{tenant_id}/recipes/{recipe_id}/feasibility` - Check feasibility
|
||||
- ✅ `GET /tenants/{tenant_id}/recipes/statistics/dashboard` - Get statistics
|
||||
- ✅ `GET /tenants/{tenant_id}/recipes/categories/list` - Get categories
|
||||
|
||||
### 3. **Types Layer** (`src/api/types/recipes.ts`)
|
||||
|
||||
#### **Backend Schema Mirroring**:
|
||||
- ✅ **Enums**: `RecipeStatus`, `MeasurementUnit`, `ProductionStatus`, `ProductionPriority`
|
||||
- ✅ **Interfaces**: Exactly match backend Pydantic schemas
|
||||
- ✅ **Request/Response types**: `RecipeCreate`, `RecipeUpdate`, `RecipeResponse`, etc.
|
||||
- ✅ **Search parameters**: `RecipeSearchParams` with all backend filters
|
||||
- ✅ **Additional types**: `RecipeFeasibilityResponse`, `RecipeStatisticsResponse`, etc.
|
||||
|
||||
### 4. **Hooks Layer** (`src/api/hooks/recipes.ts`)
|
||||
|
||||
#### **Before (Issues)**:
|
||||
- Missing tenant parameters in query keys
|
||||
- Hooks didn't accept tenant ID
|
||||
- Cache invalidation not tenant-scoped
|
||||
- Production batch hooks removed (moved to production service)
|
||||
|
||||
#### **After (Fixed)**:
|
||||
```typescript
|
||||
// Tenant-scoped query keys
|
||||
export const recipesKeys = {
|
||||
all: ['recipes'] as const,
|
||||
tenant: (tenantId: string) => [...recipesKeys.all, 'tenant', tenantId] as const,
|
||||
lists: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'list'] as const,
|
||||
detail: (tenantId: string, id: string) => [...recipesKeys.details(tenantId), id] as const,
|
||||
// ... other tenant-scoped keys
|
||||
};
|
||||
|
||||
// All hooks require tenantId parameter
|
||||
export const useRecipes = (
|
||||
tenantId: string,
|
||||
filters: RecipeSearchParams = {},
|
||||
options?: UseQueryOptions<RecipeResponse[], ApiError>
|
||||
) => {
|
||||
return useQuery<RecipeResponse[], ApiError>({
|
||||
queryKey: recipesKeys.list(tenantId, filters),
|
||||
queryFn: () => recipesService.searchRecipes(tenantId, filters),
|
||||
enabled: !!tenantId,
|
||||
// ...
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
#### **Available Hooks**:
|
||||
- ✅ **Queries**: `useRecipes`, `useRecipe`, `useRecipeStatistics`, `useRecipeCategories`, `useRecipeFeasibility`
|
||||
- ✅ **Mutations**: `useCreateRecipe`, `useUpdateRecipe`, `useDeleteRecipe`, `useDuplicateRecipe`, `useActivateRecipe`
|
||||
- ✅ **Infinite Queries**: `useInfiniteRecipes` for pagination
|
||||
|
||||
### 5. **Internationalization** (`src/locales/`)
|
||||
|
||||
#### **Added Complete i18n Support**:
|
||||
- ✅ **Spanish (`es/recipes.json`)**: Already existed, comprehensive translations
|
||||
- ✅ **English (`en/recipes.json`)**: Created new complete translation file
|
||||
- ✅ **Categories covered**:
|
||||
- Navigation, actions, fields, ingredients
|
||||
- Status values, difficulty levels, units
|
||||
- Categories, dietary tags, allergens
|
||||
- Production, feasibility, statistics
|
||||
- Filters, costs, messages, placeholders, tooltips
|
||||
|
||||
## Integration with Existing Stores
|
||||
|
||||
### **Auth Store Integration**:
|
||||
- ✅ API client automatically includes authentication headers
|
||||
- ✅ Token refresh handled transparently
|
||||
- ✅ User context forwarded to backend
|
||||
|
||||
### **Tenant Store Integration**:
|
||||
- ✅ All hooks require `tenantId` parameter from tenant store
|
||||
- ✅ Tenant-scoped query cache isolation
|
||||
- ✅ Automatic tenant context in API calls
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### **Basic Recipe List**:
|
||||
```typescript
|
||||
import { useRecipes } from '@/api';
|
||||
import { useCurrentTenant } from '@/stores/tenant.store';
|
||||
|
||||
const RecipesList = () => {
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { data: recipes, isLoading } = useRecipes(currentTenant?.id || '', {
|
||||
status: 'active',
|
||||
limit: 20
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{recipes?.map(recipe => (
|
||||
<div key={recipe.id}>{recipe.name}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### **Recipe Creation**:
|
||||
```typescript
|
||||
import { useCreateRecipe, MeasurementUnit } from '@/api';
|
||||
|
||||
const CreateRecipe = () => {
|
||||
const currentTenant = useCurrentTenant();
|
||||
const createRecipe = useCreateRecipe(currentTenant?.id || '');
|
||||
|
||||
const handleSubmit = () => {
|
||||
createRecipe.mutate({
|
||||
name: "Sourdough Bread",
|
||||
finished_product_id: "uuid-here",
|
||||
yield_quantity: 2,
|
||||
yield_unit: MeasurementUnit.UNITS,
|
||||
difficulty_level: 3,
|
||||
ingredients: [
|
||||
{
|
||||
ingredient_id: "flour-uuid",
|
||||
quantity: 500,
|
||||
unit: MeasurementUnit.GRAMS,
|
||||
is_optional: false,
|
||||
ingredient_order: 1
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### **1. Consistency with Backend**:
|
||||
- ✅ All API calls exactly match backend endpoints
|
||||
- ✅ Request/response types mirror Pydantic schemas
|
||||
- ✅ Proper tenant isolation at API level
|
||||
|
||||
### **2. Type Safety**:
|
||||
- ✅ Full TypeScript coverage
|
||||
- ✅ Compile-time validation of API calls
|
||||
- ✅ IDE autocomplete and error detection
|
||||
|
||||
### **3. Caching & Performance**:
|
||||
- ✅ Tenant-scoped React Query cache
|
||||
- ✅ Efficient cache invalidation
|
||||
- ✅ Background refetching and stale-while-revalidate
|
||||
|
||||
### **4. Developer Experience**:
|
||||
- ✅ Clean, consistent API surface
|
||||
- ✅ Comprehensive i18n support
|
||||
- ✅ Example components demonstrating usage
|
||||
- ✅ Self-documenting code with JSDoc
|
||||
|
||||
### **5. Multi-Tenant Architecture**:
|
||||
- ✅ Complete tenant isolation
|
||||
- ✅ Proper tenant context propagation
|
||||
- ✅ Cache separation between tenants
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### **For Existing Components**:
|
||||
|
||||
1. **Add tenant parameter**:
|
||||
```typescript
|
||||
// Before
|
||||
const { data } = useRecipes();
|
||||
|
||||
// After
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { data } = useRecipes(currentTenant?.id || '');
|
||||
```
|
||||
|
||||
2. **Update mutation calls**:
|
||||
```typescript
|
||||
// Before
|
||||
const createRecipe = useCreateRecipe();
|
||||
|
||||
// After
|
||||
const createRecipe = useCreateRecipe(currentTenant?.id || '');
|
||||
```
|
||||
|
||||
3. **Use proper types**:
|
||||
```typescript
|
||||
import { RecipeResponse, RecipeCreate, MeasurementUnit } from '@/api';
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
### **Backend Compatibility**:
|
||||
- ✅ All endpoints tested with actual backend
|
||||
- ✅ Request/response format validation
|
||||
- ✅ Tenant-dependent routing confirmed
|
||||
|
||||
### **Gateway Routing**:
|
||||
- ✅ Gateway properly proxies `/tenants/{tenant_id}/recipes/*` to recipes service
|
||||
- ✅ Tenant ID forwarded correctly in headers
|
||||
- ✅ Authentication and authorization working
|
||||
|
||||
### **Data Flow**:
|
||||
- ✅ Frontend → Gateway → Recipes Service → Database
|
||||
- ✅ Proper tenant isolation at all levels
|
||||
- ✅ Error handling and edge cases covered
|
||||
|
||||
This restructure provides a solid foundation for the recipes feature that properly integrates with the multi-tenant architecture and ensures consistency with the backend API design.
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Recipes React Query hooks
|
||||
* Data fetching and caching layer for recipe management
|
||||
* All hooks properly handle tenant-dependent operations
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -23,31 +24,19 @@ import type {
|
||||
RecipeFeasibilityResponse,
|
||||
RecipeStatisticsResponse,
|
||||
RecipeCategoriesResponse,
|
||||
ProductionBatchResponse,
|
||||
ProductionBatchCreate,
|
||||
ProductionBatchUpdate,
|
||||
} from '../types/recipes';
|
||||
|
||||
// Query Keys Factory
|
||||
export const recipesKeys = {
|
||||
all: ['recipes'] as const,
|
||||
lists: () => [...recipesKeys.all, 'list'] as const,
|
||||
list: (filters: RecipeSearchParams) => [...recipesKeys.lists(), { filters }] as const,
|
||||
details: () => [...recipesKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...recipesKeys.details(), id] as const,
|
||||
statistics: () => [...recipesKeys.all, 'statistics'] as const,
|
||||
categories: () => [...recipesKeys.all, 'categories'] as const,
|
||||
feasibility: (id: string, batchMultiplier: number) => [...recipesKeys.all, 'feasibility', id, batchMultiplier] as const,
|
||||
|
||||
// Production batch keys
|
||||
productionBatches: {
|
||||
all: ['production-batches'] as const,
|
||||
lists: () => [...recipesKeys.productionBatches.all, 'list'] as const,
|
||||
list: (filters: any) => [...recipesKeys.productionBatches.lists(), { filters }] as const,
|
||||
details: () => [...recipesKeys.productionBatches.all, 'detail'] as const,
|
||||
detail: (id: string) => [...recipesKeys.productionBatches.details(), id] as const,
|
||||
byRecipe: (recipeId: string) => [...recipesKeys.productionBatches.all, 'recipe', recipeId] as const,
|
||||
}
|
||||
tenant: (tenantId: string) => [...recipesKeys.all, 'tenant', tenantId] as const,
|
||||
lists: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'list'] as const,
|
||||
list: (tenantId: string, filters: RecipeSearchParams) => [...recipesKeys.lists(tenantId), { filters }] as const,
|
||||
details: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'detail'] as const,
|
||||
detail: (tenantId: string, id: string) => [...recipesKeys.details(tenantId), id] as const,
|
||||
statistics: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'statistics'] as const,
|
||||
categories: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'categories'] as const,
|
||||
feasibility: (tenantId: string, id: string, batchMultiplier: number) => [...recipesKeys.tenant(tenantId), 'feasibility', id, batchMultiplier] as const,
|
||||
} as const;
|
||||
|
||||
// Recipe Queries
|
||||
@@ -56,13 +45,14 @@ export const recipesKeys = {
|
||||
* Fetch a single recipe by ID
|
||||
*/
|
||||
export const useRecipe = (
|
||||
tenantId: string,
|
||||
recipeId: string,
|
||||
options?: Omit<UseQueryOptions<RecipeResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<RecipeResponse, ApiError>({
|
||||
queryKey: recipesKeys.detail(recipeId),
|
||||
queryFn: () => recipesService.getRecipe(recipeId),
|
||||
enabled: !!recipeId,
|
||||
queryKey: recipesKeys.detail(tenantId, recipeId),
|
||||
queryFn: () => recipesService.getRecipe(tenantId, recipeId),
|
||||
enabled: !!(tenantId && recipeId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
@@ -72,13 +62,15 @@ export const useRecipe = (
|
||||
* Search/list recipes with filters
|
||||
*/
|
||||
export const useRecipes = (
|
||||
tenantId: string,
|
||||
filters: RecipeSearchParams = {},
|
||||
options?: Omit<UseQueryOptions<RecipeResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<RecipeResponse[], ApiError>({
|
||||
queryKey: recipesKeys.list(filters),
|
||||
queryFn: () => recipesService.searchRecipes(filters),
|
||||
queryKey: recipesKeys.list(tenantId, filters),
|
||||
queryFn: () => recipesService.searchRecipes(tenantId, filters),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
enabled: !!tenantId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -87,18 +79,20 @@ export const useRecipes = (
|
||||
* Infinite query for recipes (pagination)
|
||||
*/
|
||||
export const useInfiniteRecipes = (
|
||||
tenantId: string,
|
||||
filters: Omit<RecipeSearchParams, 'offset'> = {},
|
||||
options?: Omit<UseInfiniteQueryOptions<RecipeResponse[], ApiError>, 'queryKey' | 'queryFn' | 'getNextPageParam'>
|
||||
) => {
|
||||
return useInfiniteQuery<RecipeResponse[], ApiError>({
|
||||
queryKey: recipesKeys.list(filters),
|
||||
queryKey: recipesKeys.list(tenantId, filters),
|
||||
queryFn: ({ pageParam = 0 }) =>
|
||||
recipesService.searchRecipes({ ...filters, offset: pageParam }),
|
||||
recipesService.searchRecipes(tenantId, { ...filters, offset: pageParam }),
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const limit = filters.limit || 100;
|
||||
if (lastPage.length < limit) return undefined;
|
||||
return allPages.length * limit;
|
||||
},
|
||||
enabled: !!tenantId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
@@ -108,12 +102,14 @@ export const useInfiniteRecipes = (
|
||||
* Get recipe statistics
|
||||
*/
|
||||
export const useRecipeStatistics = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<RecipeStatisticsResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<RecipeStatisticsResponse, ApiError>({
|
||||
queryKey: recipesKeys.statistics(),
|
||||
queryFn: () => recipesService.getRecipeStatistics(),
|
||||
queryKey: recipesKeys.statistics(tenantId),
|
||||
queryFn: () => recipesService.getRecipeStatistics(tenantId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: !!tenantId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -122,12 +118,14 @@ export const useRecipeStatistics = (
|
||||
* Get recipe categories
|
||||
*/
|
||||
export const useRecipeCategories = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<RecipeCategoriesResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<RecipeCategoriesResponse, ApiError>({
|
||||
queryKey: recipesKeys.categories(),
|
||||
queryFn: () => recipesService.getRecipeCategories(),
|
||||
queryKey: recipesKeys.categories(tenantId),
|
||||
queryFn: () => recipesService.getRecipeCategories(tenantId),
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
enabled: !!tenantId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -136,14 +134,15 @@ export const useRecipeCategories = (
|
||||
* Check recipe feasibility
|
||||
*/
|
||||
export const useRecipeFeasibility = (
|
||||
tenantId: string,
|
||||
recipeId: string,
|
||||
batchMultiplier: number = 1.0,
|
||||
options?: Omit<UseQueryOptions<RecipeFeasibilityResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<RecipeFeasibilityResponse, ApiError>({
|
||||
queryKey: recipesKeys.feasibility(recipeId, batchMultiplier),
|
||||
queryFn: () => recipesService.checkRecipeFeasibility(recipeId, batchMultiplier),
|
||||
enabled: !!recipeId,
|
||||
queryKey: recipesKeys.feasibility(tenantId, recipeId, batchMultiplier),
|
||||
queryFn: () => recipesService.checkRecipeFeasibility(tenantId, recipeId, batchMultiplier),
|
||||
enabled: !!(tenantId && recipeId),
|
||||
staleTime: 1 * 60 * 1000, // 1 minute (fresher data for inventory checks)
|
||||
...options,
|
||||
});
|
||||
@@ -155,21 +154,22 @@ export const useRecipeFeasibility = (
|
||||
* Create a new recipe
|
||||
*/
|
||||
export const useCreateRecipe = (
|
||||
tenantId: string,
|
||||
options?: UseMutationOptions<RecipeResponse, ApiError, RecipeCreate>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<RecipeResponse, ApiError, RecipeCreate>({
|
||||
mutationFn: (recipeData: RecipeCreate) => recipesService.createRecipe(recipeData),
|
||||
mutationFn: (recipeData: RecipeCreate) => recipesService.createRecipe(tenantId, recipeData),
|
||||
onSuccess: (data) => {
|
||||
// Add to lists cache
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
|
||||
// Set individual recipe cache
|
||||
queryClient.setQueryData(recipesKeys.detail(data.id), data);
|
||||
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
|
||||
// Invalidate statistics
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
|
||||
// Invalidate categories (new category might be added)
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.categories() });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.categories(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@@ -179,21 +179,22 @@ export const useCreateRecipe = (
|
||||
* Update an existing recipe
|
||||
*/
|
||||
export const useUpdateRecipe = (
|
||||
tenantId: string,
|
||||
options?: UseMutationOptions<RecipeResponse, ApiError, { id: string; data: RecipeUpdate }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<RecipeResponse, ApiError, { id: string; data: RecipeUpdate }>({
|
||||
mutationFn: ({ id, data }) => recipesService.updateRecipe(id, data),
|
||||
mutationFn: ({ id, data }) => recipesService.updateRecipe(tenantId, id, data),
|
||||
onSuccess: (data) => {
|
||||
// Update individual recipe cache
|
||||
queryClient.setQueryData(recipesKeys.detail(data.id), data);
|
||||
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
|
||||
// Invalidate lists (recipe might move in search results)
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
|
||||
// Invalidate statistics
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
|
||||
// Invalidate categories
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.categories() });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.categories(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@@ -203,23 +204,22 @@ export const useUpdateRecipe = (
|
||||
* Delete a recipe
|
||||
*/
|
||||
export const useDeleteRecipe = (
|
||||
tenantId: string,
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, string>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ message: string }, ApiError, string>({
|
||||
mutationFn: (recipeId: string) => recipesService.deleteRecipe(recipeId),
|
||||
mutationFn: (recipeId: string) => recipesService.deleteRecipe(tenantId, recipeId),
|
||||
onSuccess: (_, recipeId) => {
|
||||
// Remove from individual cache
|
||||
queryClient.removeQueries({ queryKey: recipesKeys.detail(recipeId) });
|
||||
queryClient.removeQueries({ queryKey: recipesKeys.detail(tenantId, recipeId) });
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
|
||||
// Invalidate statistics
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
|
||||
// Invalidate categories
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.categories() });
|
||||
// Invalidate production batches for this recipe
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(recipeId) });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.categories(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@@ -229,19 +229,20 @@ export const useDeleteRecipe = (
|
||||
* Duplicate a recipe
|
||||
*/
|
||||
export const useDuplicateRecipe = (
|
||||
tenantId: string,
|
||||
options?: UseMutationOptions<RecipeResponse, ApiError, { id: string; data: RecipeDuplicateRequest }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<RecipeResponse, ApiError, { id: string; data: RecipeDuplicateRequest }>({
|
||||
mutationFn: ({ id, data }) => recipesService.duplicateRecipe(id, data),
|
||||
mutationFn: ({ id, data }) => recipesService.duplicateRecipe(tenantId, id, data),
|
||||
onSuccess: (data) => {
|
||||
// Add to lists cache
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
|
||||
// Set individual recipe cache
|
||||
queryClient.setQueryData(recipesKeys.detail(data.id), data);
|
||||
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
|
||||
// Invalidate statistics
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@@ -251,227 +252,20 @@ export const useDuplicateRecipe = (
|
||||
* Activate a recipe
|
||||
*/
|
||||
export const useActivateRecipe = (
|
||||
tenantId: string,
|
||||
options?: UseMutationOptions<RecipeResponse, ApiError, string>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<RecipeResponse, ApiError, string>({
|
||||
mutationFn: (recipeId: string) => recipesService.activateRecipe(recipeId),
|
||||
mutationFn: (recipeId: string) => recipesService.activateRecipe(tenantId, recipeId),
|
||||
onSuccess: (data) => {
|
||||
// Update individual recipe cache
|
||||
queryClient.setQueryData(recipesKeys.detail(data.id), data);
|
||||
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
|
||||
// Invalidate statistics
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Production Batch Queries
|
||||
|
||||
/**
|
||||
* Get production batch by ID (recipe-specific)
|
||||
*/
|
||||
export const useRecipeProductionBatch = (
|
||||
batchId: string,
|
||||
options?: Omit<UseQueryOptions<ProductionBatchResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProductionBatchResponse, ApiError>({
|
||||
queryKey: recipesKeys.productionBatches.detail(batchId),
|
||||
queryFn: () => recipesService.getProductionBatch(batchId),
|
||||
enabled: !!batchId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get production batches with filters (recipe-specific)
|
||||
*/
|
||||
export const useRecipeProductionBatches = (
|
||||
filters: {
|
||||
recipe_id?: string;
|
||||
status?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
options?: Omit<UseQueryOptions<ProductionBatchResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProductionBatchResponse[], ApiError>({
|
||||
queryKey: recipesKeys.productionBatches.list(filters),
|
||||
queryFn: () => recipesService.getProductionBatches(filters),
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get production batches for a specific recipe
|
||||
*/
|
||||
export const useRecipeProductionBatchesByRecipe = (
|
||||
recipeId: string,
|
||||
options?: Omit<UseQueryOptions<ProductionBatchResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProductionBatchResponse[], ApiError>({
|
||||
queryKey: recipesKeys.productionBatches.byRecipe(recipeId),
|
||||
queryFn: () => recipesService.getRecipeProductionBatches(recipeId),
|
||||
enabled: !!recipeId,
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Production Batch Mutations
|
||||
|
||||
/**
|
||||
* Create a production batch for recipe
|
||||
*/
|
||||
export const useCreateRecipeProductionBatch = (
|
||||
options?: UseMutationOptions<ProductionBatchResponse, ApiError, ProductionBatchCreate>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ProductionBatchResponse, ApiError, ProductionBatchCreate>({
|
||||
mutationFn: (batchData: ProductionBatchCreate) => recipesService.createProductionBatch(batchData),
|
||||
onSuccess: (data) => {
|
||||
// Set individual batch cache
|
||||
queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data);
|
||||
// Invalidate batch lists
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() });
|
||||
// Invalidate recipe-specific batches
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a production batch for recipe
|
||||
*/
|
||||
export const useUpdateRecipeProductionBatch = (
|
||||
options?: UseMutationOptions<ProductionBatchResponse, ApiError, { id: string; data: ProductionBatchUpdate }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ProductionBatchResponse, ApiError, { id: string; data: ProductionBatchUpdate }>({
|
||||
mutationFn: ({ id, data }) => recipesService.updateProductionBatch(id, data),
|
||||
onSuccess: (data) => {
|
||||
// Update individual batch cache
|
||||
queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data);
|
||||
// Invalidate batch lists
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() });
|
||||
// Invalidate recipe-specific batches
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a production batch for recipe
|
||||
*/
|
||||
export const useDeleteRecipeProductionBatch = (
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, string>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ message: string }, ApiError, string>({
|
||||
mutationFn: (batchId: string) => recipesService.deleteProductionBatch(batchId),
|
||||
onSuccess: (_, batchId) => {
|
||||
// Remove from individual cache
|
||||
queryClient.removeQueries({ queryKey: recipesKeys.productionBatches.detail(batchId) });
|
||||
// Invalidate batch lists
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a production batch for recipe
|
||||
*/
|
||||
export const useStartRecipeProductionBatch = (
|
||||
options?: UseMutationOptions<ProductionBatchResponse, ApiError, string>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ProductionBatchResponse, ApiError, string>({
|
||||
mutationFn: (batchId: string) => recipesService.startProductionBatch(batchId),
|
||||
onSuccess: (data) => {
|
||||
// Update individual batch cache
|
||||
queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data);
|
||||
// Invalidate batch lists
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() });
|
||||
// Invalidate recipe-specific batches
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete a production batch for recipe
|
||||
*/
|
||||
export const useCompleteRecipeProductionBatch = (
|
||||
options?: UseMutationOptions<ProductionBatchResponse, ApiError, {
|
||||
id: string;
|
||||
data: {
|
||||
actual_quantity?: number;
|
||||
quality_score?: number;
|
||||
quality_notes?: string;
|
||||
waste_quantity?: number;
|
||||
waste_reason?: string;
|
||||
}
|
||||
}>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ProductionBatchResponse, ApiError, {
|
||||
id: string;
|
||||
data: {
|
||||
actual_quantity?: number;
|
||||
quality_score?: number;
|
||||
quality_notes?: string;
|
||||
waste_quantity?: number;
|
||||
waste_reason?: string;
|
||||
}
|
||||
}>({
|
||||
mutationFn: ({ id, data }) => recipesService.completeProductionBatch(id, data),
|
||||
onSuccess: (data) => {
|
||||
// Update individual batch cache
|
||||
queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data);
|
||||
// Invalidate batch lists
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() });
|
||||
// Invalidate recipe-specific batches
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) });
|
||||
// Invalidate inventory queries (production affects inventory)
|
||||
queryClient.invalidateQueries({ queryKey: ['inventory'] });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel a production batch for recipe
|
||||
*/
|
||||
export const useCancelRecipeProductionBatch = (
|
||||
options?: UseMutationOptions<ProductionBatchResponse, ApiError, { id: string; reason?: string }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ProductionBatchResponse, ApiError, { id: string; reason?: string }>({
|
||||
mutationFn: ({ id, reason }) => recipesService.cancelProductionBatch(id, reason),
|
||||
onSuccess: (data) => {
|
||||
// Update individual batch cache
|
||||
queryClient.setQueryData(recipesKeys.productionBatches.detail(data.id), data);
|
||||
// Invalidate batch lists
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.lists() });
|
||||
// Invalidate recipe-specific batches
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.productionBatches.byRecipe(data.recipe_id) });
|
||||
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -728,35 +728,7 @@ export {
|
||||
useDeleteRecipe,
|
||||
useDuplicateRecipe,
|
||||
useActivateRecipe,
|
||||
useRecipeProductionBatch,
|
||||
useRecipeProductionBatches,
|
||||
useRecipeProductionBatchesByRecipe,
|
||||
useCreateRecipeProductionBatch,
|
||||
useUpdateRecipeProductionBatch,
|
||||
useDeleteRecipeProductionBatch,
|
||||
useStartRecipeProductionBatch,
|
||||
useCompleteRecipeProductionBatch,
|
||||
useCancelRecipeProductionBatch,
|
||||
recipesKeys,
|
||||
} from './hooks/recipes';
|
||||
|
||||
// Query Key Factories (for advanced usage)
|
||||
export {
|
||||
authKeys,
|
||||
userKeys,
|
||||
onboardingKeys,
|
||||
tenantKeys,
|
||||
salesKeys,
|
||||
inventoryKeys,
|
||||
classificationKeys,
|
||||
inventoryDashboardKeys,
|
||||
foodSafetyKeys,
|
||||
trainingKeys,
|
||||
alertProcessorKeys,
|
||||
suppliersKeys,
|
||||
ordersKeys,
|
||||
dataImportKeys,
|
||||
forecastingKeys,
|
||||
productionKeys,
|
||||
posKeys,
|
||||
};
|
||||
// Note: All query key factories are already exported in their respective hook sections above
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Recipes service - API communication layer
|
||||
* Handles all recipe-related HTTP requests using the API client
|
||||
* Mirrors backend endpoints exactly for tenant-dependent operations
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client/apiClient';
|
||||
@@ -13,52 +14,66 @@ import type {
|
||||
RecipeFeasibilityResponse,
|
||||
RecipeStatisticsResponse,
|
||||
RecipeCategoriesResponse,
|
||||
ProductionBatchResponse,
|
||||
ProductionBatchCreate,
|
||||
ProductionBatchUpdate,
|
||||
} from '../types/recipes';
|
||||
|
||||
/**
|
||||
* Recipes API service
|
||||
* All methods return promises that resolve to the response data
|
||||
* Follows tenant-dependent routing pattern: /tenants/{tenant_id}/recipes
|
||||
*/
|
||||
export class RecipesService {
|
||||
private readonly baseUrl = '/recipes';
|
||||
/**
|
||||
* Get tenant-scoped base URL for recipes
|
||||
*/
|
||||
private getBaseUrl(tenantId: string): string {
|
||||
return `/tenants/${tenantId}/recipes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new recipe
|
||||
* POST /tenants/{tenant_id}/recipes
|
||||
*/
|
||||
async createRecipe(recipeData: RecipeCreate): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(this.baseUrl, recipeData);
|
||||
async createRecipe(tenantId: string, recipeData: RecipeCreate): Promise<RecipeResponse> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.post<RecipeResponse>(baseUrl, recipeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipe by ID with ingredients
|
||||
* GET /tenants/{tenant_id}/recipes/{recipe_id}
|
||||
*/
|
||||
async getRecipe(recipeId: string): Promise<RecipeResponse> {
|
||||
return apiClient.get<RecipeResponse>(`${this.baseUrl}/${recipeId}`);
|
||||
async getRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.get<RecipeResponse>(`${baseUrl}/${recipeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing recipe
|
||||
* PUT /tenants/{tenant_id}/recipes/{recipe_id}
|
||||
*/
|
||||
async updateRecipe(recipeId: string, recipeData: RecipeUpdate): Promise<RecipeResponse> {
|
||||
return apiClient.put<RecipeResponse>(`${this.baseUrl}/${recipeId}`, recipeData);
|
||||
async updateRecipe(tenantId: string, recipeId: string, recipeData: RecipeUpdate): Promise<RecipeResponse> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.put<RecipeResponse>(`${baseUrl}/${recipeId}`, recipeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recipe
|
||||
* DELETE /tenants/{tenant_id}/recipes/{recipe_id}
|
||||
*/
|
||||
async deleteRecipe(recipeId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${recipeId}`);
|
||||
async deleteRecipe(tenantId: string, recipeId: string): Promise<{ message: string }> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.delete<{ message: string }>(`${baseUrl}/${recipeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search recipes with filters
|
||||
* GET /tenants/{tenant_id}/recipes
|
||||
*/
|
||||
async searchRecipes(params: RecipeSearchParams = {}): Promise<RecipeResponse[]> {
|
||||
async searchRecipes(tenantId: string, params: RecipeSearchParams = {}): Promise<RecipeResponse[]> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
// Add all non-empty parameters to the query string
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
searchParams.append(key, String(value));
|
||||
@@ -66,144 +81,63 @@ export class RecipesService {
|
||||
});
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = queryString ? `${this.baseUrl}?${queryString}` : this.baseUrl;
|
||||
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
|
||||
return apiClient.get<RecipeResponse[]>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recipes (shorthand for search without filters)
|
||||
* GET /tenants/{tenant_id}/recipes
|
||||
*/
|
||||
async getRecipes(): Promise<RecipeResponse[]> {
|
||||
return this.searchRecipes();
|
||||
async getRecipes(tenantId: string): Promise<RecipeResponse[]> {
|
||||
return this.searchRecipes(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate an existing recipe
|
||||
* POST /tenants/{tenant_id}/recipes/{recipe_id}/duplicate
|
||||
*/
|
||||
async duplicateRecipe(recipeId: string, duplicateData: RecipeDuplicateRequest): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${recipeId}/duplicate`, duplicateData);
|
||||
async duplicateRecipe(tenantId: string, recipeId: string, duplicateData: RecipeDuplicateRequest): Promise<RecipeResponse> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.post<RecipeResponse>(`${baseUrl}/${recipeId}/duplicate`, duplicateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a recipe for production
|
||||
* POST /tenants/{tenant_id}/recipes/{recipe_id}/activate
|
||||
*/
|
||||
async activateRecipe(recipeId: string): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${recipeId}/activate`);
|
||||
async activateRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.post<RecipeResponse>(`${baseUrl}/${recipeId}/activate`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recipe can be produced with current inventory
|
||||
* GET /tenants/{tenant_id}/recipes/{recipe_id}/feasibility
|
||||
*/
|
||||
async checkRecipeFeasibility(recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibilityResponse> {
|
||||
async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibilityResponse> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
const params = new URLSearchParams({ batch_multiplier: String(batchMultiplier) });
|
||||
return apiClient.get<RecipeFeasibilityResponse>(`${this.baseUrl}/${recipeId}/feasibility?${params}`);
|
||||
return apiClient.get<RecipeFeasibilityResponse>(`${baseUrl}/${recipeId}/feasibility?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipe statistics for dashboard
|
||||
* GET /tenants/{tenant_id}/recipes/statistics/dashboard
|
||||
*/
|
||||
async getRecipeStatistics(): Promise<RecipeStatisticsResponse> {
|
||||
return apiClient.get<RecipeStatisticsResponse>(`${this.baseUrl}/statistics/dashboard`);
|
||||
async getRecipeStatistics(tenantId: string): Promise<RecipeStatisticsResponse> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.get<RecipeStatisticsResponse>(`${baseUrl}/statistics/dashboard`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of recipe categories used by tenant
|
||||
* GET /tenants/{tenant_id}/recipes/categories/list
|
||||
*/
|
||||
async getRecipeCategories(): Promise<RecipeCategoriesResponse> {
|
||||
return apiClient.get<RecipeCategoriesResponse>(`${this.baseUrl}/categories/list`);
|
||||
}
|
||||
|
||||
// Production Batch Methods
|
||||
|
||||
/**
|
||||
* Create a production batch for a recipe
|
||||
*/
|
||||
async createProductionBatch(batchData: ProductionBatchCreate): Promise<ProductionBatchResponse> {
|
||||
return apiClient.post<ProductionBatchResponse>('/production/batches', batchData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get production batch by ID
|
||||
*/
|
||||
async getProductionBatch(batchId: string): Promise<ProductionBatchResponse> {
|
||||
return apiClient.get<ProductionBatchResponse>(`/production/batches/${batchId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update production batch
|
||||
*/
|
||||
async updateProductionBatch(batchId: string, batchData: ProductionBatchUpdate): Promise<ProductionBatchResponse> {
|
||||
return apiClient.put<ProductionBatchResponse>(`/production/batches/${batchId}`, batchData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete production batch
|
||||
*/
|
||||
async deleteProductionBatch(batchId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`/production/batches/${batchId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get production batches for a recipe
|
||||
*/
|
||||
async getRecipeProductionBatches(recipeId: string): Promise<ProductionBatchResponse[]> {
|
||||
return apiClient.get<ProductionBatchResponse[]>(`/production/batches?recipe_id=${recipeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all production batches with optional filtering
|
||||
*/
|
||||
async getProductionBatches(params: {
|
||||
recipe_id?: string;
|
||||
status?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}): Promise<ProductionBatchResponse[]> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = queryString ? `/production/batches?${queryString}` : '/production/batches';
|
||||
|
||||
return apiClient.get<ProductionBatchResponse[]>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start production batch
|
||||
*/
|
||||
async startProductionBatch(batchId: string): Promise<ProductionBatchResponse> {
|
||||
return apiClient.post<ProductionBatchResponse>(`/production/batches/${batchId}/start`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete production batch
|
||||
*/
|
||||
async completeProductionBatch(
|
||||
batchId: string,
|
||||
completionData: {
|
||||
actual_quantity?: number;
|
||||
quality_score?: number;
|
||||
quality_notes?: string;
|
||||
waste_quantity?: number;
|
||||
waste_reason?: string;
|
||||
}
|
||||
): Promise<ProductionBatchResponse> {
|
||||
return apiClient.post<ProductionBatchResponse>(`/production/batches/${batchId}/complete`, completionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel production batch
|
||||
*/
|
||||
async cancelProductionBatch(batchId: string, reason?: string): Promise<ProductionBatchResponse> {
|
||||
return apiClient.post<ProductionBatchResponse>(`/production/batches/${batchId}/cancel`, { reason });
|
||||
async getRecipeCategories(tenantId: string): Promise<RecipeCategoriesResponse> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.get<RecipeCategoriesResponse>(`${baseUrl}/categories/list`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui';
|
||||
import { productionService, type ProductionBatchResponse, ProductionBatchStatus, ProductionPriority } from '../../../api/services/production.service';
|
||||
import { productionService, type ProductionBatchResponse, ProductionBatchStatus, ProductionPriority } from '../../../api';
|
||||
import type { ProductionBatch, QualityCheck } from '../../../types/production.types';
|
||||
|
||||
interface BatchTrackerProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Card, Button, Badge, Select, DatePicker, Input, Modal, Table } from '../../ui';
|
||||
import { productionService, type ProductionScheduleEntry, ProductionBatchStatus } from '../../../api/services/production.service';
|
||||
import { productionService, type ProductionScheduleEntry, ProductionBatchStatus } from '../../../api';
|
||||
import type { ProductionSchedule as ProductionScheduleType, ProductionBatch, EquipmentReservation, StaffAssignment } from '../../../types/production.types';
|
||||
|
||||
interface ProductionScheduleProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui';
|
||||
import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../api/services/production.service';
|
||||
import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../api';
|
||||
import type { QualityCheck, QualityCheckCriteria, ProductionBatch, QualityCheckType } from '../../../types/production.types';
|
||||
|
||||
interface QualityControlProps {
|
||||
|
||||
622
frontend/src/components/domain/recipes/CreateRecipeModal.tsx
Normal file
622
frontend/src/components/domain/recipes/CreateRecipeModal.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { ChefHat, Package, Clock, DollarSign, Star } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { RecipeCreate, RecipeIngredientCreate, MeasurementUnit } from '../../../api/types/recipes';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
|
||||
interface CreateRecipeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreateRecipe?: (recipeData: RecipeCreate) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateRecipeModal - Modal for creating a new recipe
|
||||
* Comprehensive form for adding new recipes
|
||||
*/
|
||||
export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreateRecipe
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<RecipeCreate>({
|
||||
name: '',
|
||||
recipe_code: '',
|
||||
finished_product_id: '', // This should come from a product selector
|
||||
description: '',
|
||||
category: '',
|
||||
cuisine_type: '',
|
||||
difficulty_level: 1,
|
||||
yield_quantity: 1,
|
||||
yield_unit: MeasurementUnit.UNITS,
|
||||
prep_time_minutes: 0,
|
||||
cook_time_minutes: 0,
|
||||
total_time_minutes: 0,
|
||||
rest_time_minutes: 0,
|
||||
estimated_cost_per_unit: 0,
|
||||
target_margin_percentage: 30,
|
||||
suggested_selling_price: 0,
|
||||
preparation_notes: '',
|
||||
storage_instructions: '',
|
||||
quality_standards: '',
|
||||
serves_count: 1,
|
||||
is_seasonal: false,
|
||||
season_start_month: undefined,
|
||||
season_end_month: undefined,
|
||||
is_signature_item: false,
|
||||
batch_size_multiplier: 1.0,
|
||||
minimum_batch_size: undefined,
|
||||
maximum_batch_size: undefined,
|
||||
optimal_production_temperature: undefined,
|
||||
optimal_humidity: undefined,
|
||||
allergen_info: '',
|
||||
dietary_tags: '',
|
||||
nutritional_info: '',
|
||||
ingredients: []
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'overview' | 'edit'>('edit');
|
||||
|
||||
// Get tenant and fetch inventory data
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
// Fetch inventory items to populate product and ingredient selectors
|
||||
const {
|
||||
data: inventoryItems = [],
|
||||
isLoading: inventoryLoading
|
||||
} = useIngredients(tenantId, {});
|
||||
|
||||
// Separate finished products and ingredients
|
||||
const finishedProducts = useMemo(() =>
|
||||
inventoryItems.filter(item => item.product_type === 'finished_product')
|
||||
.map(product => ({
|
||||
value: product.id,
|
||||
label: `${product.name} (${product.category || 'Sin categoría'})`
|
||||
})),
|
||||
[inventoryItems]
|
||||
);
|
||||
|
||||
const availableIngredients = useMemo(() =>
|
||||
inventoryItems.filter(item => item.product_type === 'ingredient')
|
||||
.map(ingredient => ({
|
||||
value: ingredient.id,
|
||||
label: `${ingredient.name} (${ingredient.unit_of_measure})`,
|
||||
unit: ingredient.unit_of_measure
|
||||
})),
|
||||
[inventoryItems]
|
||||
);
|
||||
|
||||
// Category options
|
||||
const categoryOptions = [
|
||||
{ value: 'bread', label: 'Pan' },
|
||||
{ value: 'pastry', label: 'Bollería' },
|
||||
{ value: 'cake', label: 'Tarta' },
|
||||
{ value: 'cookie', label: 'Galleta' },
|
||||
{ value: 'muffin', label: 'Muffin' },
|
||||
{ value: 'savory', label: 'Salado' },
|
||||
{ value: 'desserts', label: 'Postres' },
|
||||
{ value: 'specialty', label: 'Especialidad' },
|
||||
{ value: 'other', label: 'Otro' }
|
||||
];
|
||||
|
||||
// Cuisine type options
|
||||
const cuisineTypeOptions = [
|
||||
{ value: 'french', label: 'Francés' },
|
||||
{ value: 'spanish', label: 'Español' },
|
||||
{ value: 'italian', label: 'Italiano' },
|
||||
{ value: 'german', label: 'Alemán' },
|
||||
{ value: 'american', label: 'Americano' },
|
||||
{ value: 'artisanal', label: 'Artesanal' },
|
||||
{ value: 'traditional', label: 'Tradicional' },
|
||||
{ value: 'modern', label: 'Moderno' }
|
||||
];
|
||||
|
||||
// Unit options
|
||||
const unitOptions = [
|
||||
{ value: MeasurementUnit.UNITS, label: 'Unidades' },
|
||||
{ value: MeasurementUnit.PIECES, label: 'Piezas' },
|
||||
{ value: MeasurementUnit.GRAMS, label: 'Gramos' },
|
||||
{ value: MeasurementUnit.KILOGRAMS, label: 'Kilogramos' },
|
||||
{ value: MeasurementUnit.MILLILITERS, label: 'Mililitros' },
|
||||
{ value: MeasurementUnit.LITERS, label: 'Litros' }
|
||||
];
|
||||
|
||||
// Month options for seasonal recipes
|
||||
const monthOptions = [
|
||||
{ value: 1, label: 'Enero' },
|
||||
{ value: 2, label: 'Febrero' },
|
||||
{ value: 3, label: 'Marzo' },
|
||||
{ value: 4, label: 'Abril' },
|
||||
{ value: 5, label: 'Mayo' },
|
||||
{ value: 6, label: 'Junio' },
|
||||
{ value: 7, label: 'Julio' },
|
||||
{ value: 8, label: 'Agosto' },
|
||||
{ value: 9, label: 'Septiembre' },
|
||||
{ value: 10, label: 'Octubre' },
|
||||
{ value: 11, label: 'Noviembre' },
|
||||
{ value: 12, label: 'Diciembre' }
|
||||
];
|
||||
|
||||
// Allergen options
|
||||
const allergenOptions = [
|
||||
'Gluten', 'Lácteos', 'Huevos', 'Frutos secos', 'Soja', 'Sésamo', 'Pescado', 'Mariscos'
|
||||
];
|
||||
|
||||
// Dietary tags
|
||||
const dietaryTagOptions = [
|
||||
'Vegano', 'Vegetariano', 'Sin gluten', 'Sin lácteos', 'Sin frutos secos', 'Sin azúcar', 'Bajo en carbohidratos', 'Keto', 'Orgánico'
|
||||
];
|
||||
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
|
||||
const sections = getModalSections();
|
||||
const field = sections[sectionIndex]?.fields[fieldIndex];
|
||||
|
||||
if (!field) return;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field.key]: value
|
||||
}));
|
||||
|
||||
// Auto-calculate total time when prep or cook time changes
|
||||
if (field.key === 'prep_time_minutes' || field.key === 'cook_time_minutes') {
|
||||
const prepTime = field.key === 'prep_time_minutes' ? Number(value) : formData.prep_time_minutes || 0;
|
||||
const cookTime = field.key === 'cook_time_minutes' ? Number(value) : formData.cook_time_minutes || 0;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
total_time_minutes: prepTime + cookTime
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
alert('El nombre de la receta es obligatorio');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.category.trim()) {
|
||||
alert('Debe seleccionar una categoría');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.finished_product_id.trim()) {
|
||||
alert('Debe seleccionar un producto terminado');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate seasonal dates if seasonal is enabled
|
||||
if (formData.is_seasonal) {
|
||||
if (!formData.season_start_month || !formData.season_end_month) {
|
||||
alert('Para recetas estacionales, debe especificar los meses de inicio y fin');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate batch sizes
|
||||
if (formData.minimum_batch_size && formData.maximum_batch_size) {
|
||||
if (formData.minimum_batch_size > formData.maximum_batch_size) {
|
||||
alert('El tamaño mínimo de lote no puede ser mayor que el máximo');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Generate recipe code if not provided
|
||||
const recipeCode = formData.recipe_code ||
|
||||
formData.name.substring(0, 3).toUpperCase() +
|
||||
String(Date.now()).slice(-3);
|
||||
|
||||
// Calculate total time including rest time
|
||||
const totalTime = (formData.prep_time_minutes || 0) +
|
||||
(formData.cook_time_minutes || 0) +
|
||||
(formData.rest_time_minutes || 0);
|
||||
|
||||
const recipeData: RecipeCreate = {
|
||||
...formData,
|
||||
recipe_code: recipeCode,
|
||||
total_time_minutes: totalTime,
|
||||
// Clean up undefined values for optional fields
|
||||
season_start_month: formData.is_seasonal ? formData.season_start_month : undefined,
|
||||
season_end_month: formData.is_seasonal ? formData.season_end_month : undefined,
|
||||
minimum_batch_size: formData.minimum_batch_size || undefined,
|
||||
maximum_batch_size: formData.maximum_batch_size || undefined,
|
||||
optimal_production_temperature: formData.optimal_production_temperature || undefined,
|
||||
optimal_humidity: formData.optimal_humidity || undefined
|
||||
};
|
||||
|
||||
if (onCreateRecipe) {
|
||||
await onCreateRecipe(recipeData);
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
recipe_code: '',
|
||||
finished_product_id: '',
|
||||
description: '',
|
||||
category: '',
|
||||
cuisine_type: '',
|
||||
difficulty_level: 1,
|
||||
yield_quantity: 1,
|
||||
yield_unit: MeasurementUnit.UNITS,
|
||||
prep_time_minutes: 0,
|
||||
cook_time_minutes: 0,
|
||||
total_time_minutes: 0,
|
||||
rest_time_minutes: 0,
|
||||
estimated_cost_per_unit: 0,
|
||||
target_margin_percentage: 30,
|
||||
suggested_selling_price: 0,
|
||||
preparation_notes: '',
|
||||
storage_instructions: '',
|
||||
quality_standards: '',
|
||||
serves_count: 1,
|
||||
is_seasonal: false,
|
||||
season_start_month: undefined,
|
||||
season_end_month: undefined,
|
||||
is_signature_item: false,
|
||||
batch_size_multiplier: 1.0,
|
||||
minimum_batch_size: undefined,
|
||||
maximum_batch_size: undefined,
|
||||
optimal_production_temperature: undefined,
|
||||
optimal_humidity: undefined,
|
||||
allergen_info: '',
|
||||
dietary_tags: '',
|
||||
nutritional_info: '',
|
||||
ingredients: []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating recipe:', error);
|
||||
alert('Error al crear la receta. Por favor, inténtelo de nuevo.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getModalSections = () => [
|
||||
{
|
||||
title: 'Información Básica',
|
||||
icon: ChefHat,
|
||||
fields: [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Nombre de la receta',
|
||||
value: formData.name,
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Ej: Pan de molde integral'
|
||||
},
|
||||
{
|
||||
key: 'recipe_code',
|
||||
label: 'Código de receta',
|
||||
value: formData.recipe_code,
|
||||
type: 'text',
|
||||
placeholder: 'Ej: PAN001 (opcional, se genera automáticamente)'
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Descripción',
|
||||
value: formData.description,
|
||||
type: 'textarea',
|
||||
placeholder: 'Descripción de la receta...'
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Categoría',
|
||||
value: formData.category,
|
||||
type: 'select',
|
||||
options: categoryOptions,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'cuisine_type',
|
||||
label: 'Tipo de cocina',
|
||||
value: formData.cuisine_type,
|
||||
type: 'select',
|
||||
options: cuisineTypeOptions,
|
||||
placeholder: 'Selecciona el tipo de cocina'
|
||||
},
|
||||
{
|
||||
key: 'difficulty_level',
|
||||
label: 'Nivel de dificultad',
|
||||
value: formData.difficulty_level,
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 1, label: '1 - Fácil' },
|
||||
{ value: 2, label: '2 - Medio' },
|
||||
{ value: 3, label: '3 - Difícil' },
|
||||
{ value: 4, label: '4 - Muy Difícil' },
|
||||
{ value: 5, label: '5 - Extremo' }
|
||||
],
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'serves_count',
|
||||
label: 'Número de porciones',
|
||||
value: formData.serves_count,
|
||||
type: 'number',
|
||||
min: 1,
|
||||
placeholder: 'Cuántas personas sirve'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Rendimiento y Tiempos',
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
key: 'yield_quantity',
|
||||
label: 'Cantidad que produce',
|
||||
value: formData.yield_quantity,
|
||||
type: 'number',
|
||||
min: 1,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'yield_unit',
|
||||
label: 'Unidad de medida',
|
||||
value: formData.yield_unit,
|
||||
type: 'select',
|
||||
options: unitOptions,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'prep_time_minutes',
|
||||
label: 'Tiempo de preparación (minutos)',
|
||||
value: formData.prep_time_minutes,
|
||||
type: 'number',
|
||||
min: 0
|
||||
},
|
||||
{
|
||||
key: 'cook_time_minutes',
|
||||
label: 'Tiempo de cocción (minutos)',
|
||||
value: formData.cook_time_minutes,
|
||||
type: 'number',
|
||||
min: 0
|
||||
},
|
||||
{
|
||||
key: 'rest_time_minutes',
|
||||
label: 'Tiempo de reposo (minutos)',
|
||||
value: formData.rest_time_minutes,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
placeholder: 'Tiempo de fermentación o reposo'
|
||||
},
|
||||
{
|
||||
key: 'total_time_minutes',
|
||||
label: 'Tiempo total (calculado automáticamente)',
|
||||
value: formData.total_time_minutes,
|
||||
type: 'number',
|
||||
disabled: true,
|
||||
readonly: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Configuración Financiera',
|
||||
icon: DollarSign,
|
||||
fields: [
|
||||
{
|
||||
key: 'estimated_cost_per_unit',
|
||||
label: 'Costo estimado por unidad (€)',
|
||||
value: formData.estimated_cost_per_unit,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
placeholder: '0.00'
|
||||
},
|
||||
{
|
||||
key: 'suggested_selling_price',
|
||||
label: 'Precio de venta sugerido (€)',
|
||||
value: formData.suggested_selling_price,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
placeholder: '0.00'
|
||||
},
|
||||
{
|
||||
key: 'target_margin_percentage',
|
||||
label: 'Margen objetivo (%)',
|
||||
value: formData.target_margin_percentage,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 100,
|
||||
placeholder: '30'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Configuración de Producción',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
key: 'batch_size_multiplier',
|
||||
label: 'Multiplicador de lote',
|
||||
value: formData.batch_size_multiplier,
|
||||
type: 'number',
|
||||
min: 0.1,
|
||||
step: 0.1,
|
||||
placeholder: '1.0'
|
||||
},
|
||||
{
|
||||
key: 'minimum_batch_size',
|
||||
label: 'Tamaño mínimo de lote',
|
||||
value: formData.minimum_batch_size,
|
||||
type: 'number',
|
||||
min: 1,
|
||||
placeholder: 'Cantidad mínima a producir'
|
||||
},
|
||||
{
|
||||
key: 'maximum_batch_size',
|
||||
label: 'Tamaño máximo de lote',
|
||||
value: formData.maximum_batch_size,
|
||||
type: 'number',
|
||||
min: 1,
|
||||
placeholder: 'Cantidad máxima a producir'
|
||||
},
|
||||
{
|
||||
key: 'optimal_production_temperature',
|
||||
label: 'Temperatura óptima (°C)',
|
||||
value: formData.optimal_production_temperature,
|
||||
type: 'number',
|
||||
placeholder: 'Temperatura ideal de producción'
|
||||
},
|
||||
{
|
||||
key: 'optimal_humidity',
|
||||
label: 'Humedad óptima (%)',
|
||||
value: formData.optimal_humidity,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 100,
|
||||
placeholder: 'Humedad ideal'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Temporalidad y Especiales',
|
||||
icon: Star,
|
||||
fields: [
|
||||
{
|
||||
key: 'is_signature_item',
|
||||
label: 'Receta especial/estrella',
|
||||
value: formData.is_signature_item,
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'is_seasonal',
|
||||
label: 'Receta estacional',
|
||||
value: formData.is_seasonal,
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'season_start_month',
|
||||
label: 'Mes de inicio de temporada',
|
||||
value: formData.season_start_month,
|
||||
type: 'select',
|
||||
options: monthOptions,
|
||||
placeholder: 'Selecciona mes de inicio',
|
||||
disabled: !formData.is_seasonal
|
||||
},
|
||||
{
|
||||
key: 'season_end_month',
|
||||
label: 'Mes de fin de temporada',
|
||||
value: formData.season_end_month,
|
||||
type: 'select',
|
||||
options: monthOptions,
|
||||
placeholder: 'Selecciona mes de fin',
|
||||
disabled: !formData.is_seasonal
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Nutricional y Alérgenos',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
key: 'allergen_info',
|
||||
label: 'Información de alérgenos',
|
||||
value: formData.allergen_info,
|
||||
type: 'text',
|
||||
placeholder: 'Ej: Gluten, Lácteos, Huevos'
|
||||
},
|
||||
{
|
||||
key: 'dietary_tags',
|
||||
label: 'Etiquetas dietéticas',
|
||||
value: formData.dietary_tags,
|
||||
type: 'text',
|
||||
placeholder: 'Ej: Vegano, Sin gluten, Orgánico'
|
||||
},
|
||||
{
|
||||
key: 'nutritional_info',
|
||||
label: 'Información nutricional',
|
||||
value: formData.nutritional_info,
|
||||
type: 'textarea',
|
||||
placeholder: 'Calorías, proteínas, carbohidratos, etc.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Notas de Preparación',
|
||||
icon: ChefHat,
|
||||
fields: [
|
||||
{
|
||||
key: 'preparation_notes',
|
||||
label: 'Notas de preparación',
|
||||
value: formData.preparation_notes,
|
||||
type: 'textarea',
|
||||
placeholder: 'Instrucciones especiales, consejos, técnicas...'
|
||||
},
|
||||
{
|
||||
key: 'storage_instructions',
|
||||
label: 'Instrucciones de almacenamiento',
|
||||
value: formData.storage_instructions,
|
||||
type: 'textarea',
|
||||
placeholder: 'Cómo almacenar el producto terminado...'
|
||||
},
|
||||
{
|
||||
key: 'quality_standards',
|
||||
label: 'Estándares de calidad',
|
||||
value: formData.quality_standards,
|
||||
type: 'textarea',
|
||||
placeholder: 'Criterios de calidad, características del producto...'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Producto Terminado',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
key: 'finished_product_id',
|
||||
label: 'Producto terminado',
|
||||
value: formData.finished_product_id,
|
||||
type: 'select',
|
||||
options: finishedProducts,
|
||||
required: true,
|
||||
placeholder: inventoryLoading ? 'Cargando productos...' : 'Selecciona un producto terminado',
|
||||
help: 'Selecciona el producto del inventario que produce esta receta',
|
||||
disabled: inventoryLoading
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
title="Nueva Receta"
|
||||
subtitle="Crear una nueva receta para la panadería"
|
||||
statusIndicator={{
|
||||
color: '#3b82f6',
|
||||
text: 'Nueva',
|
||||
icon: ChefHat,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
}}
|
||||
size="xl"
|
||||
sections={getModalSections()}
|
||||
onFieldChange={handleFieldChange}
|
||||
actions={[
|
||||
{
|
||||
label: loading ? 'Creando...' : 'Crear Receta',
|
||||
icon: ChefHat,
|
||||
variant: 'primary',
|
||||
onClick: handleSubmit,
|
||||
disabled: loading || !formData.name.trim() || !formData.finished_product_id.trim()
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateRecipeModal;
|
||||
1
frontend/src/components/domain/recipes/index.ts
Normal file
1
frontend/src/components/domain/recipes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CreateRecipeModal } from './CreateRecipeModal';
|
||||
542
frontend/src/examples/RecipesExample.tsx
Normal file
542
frontend/src/examples/RecipesExample.tsx
Normal file
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* Example usage of the restructured recipes API
|
||||
* Demonstrates tenant-dependent routing and React Query hooks
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useRecipes,
|
||||
useRecipe,
|
||||
useCreateRecipe,
|
||||
useUpdateRecipe,
|
||||
useDeleteRecipe,
|
||||
useDuplicateRecipe,
|
||||
useActivateRecipe,
|
||||
useRecipeStatistics,
|
||||
useRecipeCategories,
|
||||
useRecipeFeasibility,
|
||||
type RecipeResponse,
|
||||
type RecipeCreate,
|
||||
type RecipeSearchParams,
|
||||
MeasurementUnit,
|
||||
} from '../api';
|
||||
import { useCurrentTenant } from '../stores/tenant.store';
|
||||
|
||||
/**
|
||||
* Example: Recipe List Component
|
||||
* Shows how to use the tenant-dependent useRecipes hook
|
||||
*/
|
||||
export const RecipesList: React.FC = () => {
|
||||
const { t } = useTranslation('recipes');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const [filters, setFilters] = useState<RecipeSearchParams>({
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Use tenant-dependent recipes hook
|
||||
const {
|
||||
data: recipes,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useRecipes(currentTenant?.id || '', filters, {
|
||||
enabled: !!currentTenant?.id,
|
||||
});
|
||||
|
||||
if (!currentTenant) {
|
||||
return <div>{t('messages.no_tenant_selected')}</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div>{t('messages.loading_recipes')}</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recipes-list">
|
||||
<h2>{t('title')}</h2>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="filters">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('filters.search_placeholder')}
|
||||
value={filters.search_term || ''}
|
||||
onChange={(e) => setFilters({ ...filters, search_term: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<option value="">{t('filters.all')}</option>
|
||||
<option value="active">{t('status.active')}</option>
|
||||
<option value="draft">{t('status.draft')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Recipe cards */}
|
||||
<div className="recipe-grid">
|
||||
{recipes?.map((recipe) => (
|
||||
<RecipeCard key={recipe.id} recipe={recipe} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(!recipes || recipes.length === 0) && (
|
||||
<div className="no-results">{t('messages.no_recipes_found')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example: Individual Recipe Card
|
||||
*/
|
||||
interface RecipeCardProps {
|
||||
recipe: RecipeResponse;
|
||||
}
|
||||
|
||||
const RecipeCard: React.FC<RecipeCardProps> = ({ recipe }) => {
|
||||
const { t } = useTranslation('recipes');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Mutation hooks for recipe actions
|
||||
const duplicateRecipe = useDuplicateRecipe(currentTenant?.id || '');
|
||||
const activateRecipe = useActivateRecipe(currentTenant?.id || '');
|
||||
const deleteRecipe = useDeleteRecipe(currentTenant?.id || '');
|
||||
|
||||
const handleDuplicate = () => {
|
||||
if (!currentTenant) return;
|
||||
|
||||
duplicateRecipe.mutate({
|
||||
id: recipe.id,
|
||||
data: { new_name: `${recipe.name} (Copy)` }
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
alert(t('messages.recipe_duplicated'));
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(error.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleActivate = () => {
|
||||
if (!currentTenant) return;
|
||||
|
||||
activateRecipe.mutate(recipe.id, {
|
||||
onSuccess: () => {
|
||||
alert(t('messages.recipe_activated'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!currentTenant) return;
|
||||
|
||||
if (confirm(t('messages.confirm_delete'))) {
|
||||
deleteRecipe.mutate(recipe.id, {
|
||||
onSuccess: () => {
|
||||
alert(t('messages.recipe_deleted'));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="recipe-card">
|
||||
<h3>{recipe.name}</h3>
|
||||
<p>{recipe.description}</p>
|
||||
|
||||
<div className="recipe-meta">
|
||||
<span className={`status status-${recipe.status}`}>
|
||||
{t(`status.${recipe.status}`)}
|
||||
</span>
|
||||
<span className="category">{recipe.category}</span>
|
||||
<span className="difficulty">
|
||||
{t(`difficulty.${recipe.difficulty_level}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="recipe-actions">
|
||||
<button onClick={handleDuplicate} disabled={duplicateRecipe.isPending}>
|
||||
{t('actions.duplicate_recipe')}
|
||||
</button>
|
||||
|
||||
{recipe.status === 'draft' && (
|
||||
<button onClick={handleActivate} disabled={activateRecipe.isPending}>
|
||||
{t('actions.activate_recipe')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button onClick={handleDelete} disabled={deleteRecipe.isPending}>
|
||||
{t('actions.delete_recipe')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example: Recipe Detail View
|
||||
*/
|
||||
interface RecipeDetailProps {
|
||||
recipeId: string;
|
||||
}
|
||||
|
||||
export const RecipeDetail: React.FC<RecipeDetailProps> = ({ recipeId }) => {
|
||||
const { t } = useTranslation('recipes');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Get individual recipe with tenant context
|
||||
const {
|
||||
data: recipe,
|
||||
isLoading,
|
||||
error,
|
||||
} = useRecipe(currentTenant?.id || '', recipeId, {
|
||||
enabled: !!(currentTenant?.id && recipeId),
|
||||
});
|
||||
|
||||
// Check feasibility
|
||||
const {
|
||||
data: feasibility,
|
||||
} = useRecipeFeasibility(
|
||||
currentTenant?.id || '',
|
||||
recipeId,
|
||||
1.0, // batch multiplier
|
||||
{
|
||||
enabled: !!(currentTenant?.id && recipeId),
|
||||
}
|
||||
);
|
||||
|
||||
if (!currentTenant) {
|
||||
return <div>{t('messages.no_tenant_selected')}</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div>{t('messages.loading_recipe')}</div>;
|
||||
}
|
||||
|
||||
if (error || !recipe) {
|
||||
return <div>Recipe not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recipe-detail">
|
||||
<header className="recipe-header">
|
||||
<h1>{recipe.name}</h1>
|
||||
<p>{recipe.description}</p>
|
||||
|
||||
<div className="recipe-info">
|
||||
<span>Yield: {recipe.yield_quantity} {recipe.yield_unit}</span>
|
||||
<span>Prep: {recipe.prep_time_minutes}min</span>
|
||||
<span>Cook: {recipe.cook_time_minutes}min</span>
|
||||
<span>Total: {recipe.total_time_minutes}min</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Feasibility check */}
|
||||
{feasibility && (
|
||||
<div className={`feasibility ${feasibility.feasible ? 'feasible' : 'not-feasible'}`}>
|
||||
<h3>{t('feasibility.title')}</h3>
|
||||
<p>
|
||||
{feasibility.feasible
|
||||
? t('feasibility.feasible')
|
||||
: t('feasibility.not_feasible')
|
||||
}
|
||||
</p>
|
||||
|
||||
{feasibility.missing_ingredients.length > 0 && (
|
||||
<div>
|
||||
<h4>{t('feasibility.missing_ingredients')}</h4>
|
||||
<ul>
|
||||
{feasibility.missing_ingredients.map((ingredient, index) => (
|
||||
<li key={index}>{JSON.stringify(ingredient)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ingredients */}
|
||||
<section className="ingredients">
|
||||
<h2>{t('ingredients.title')}</h2>
|
||||
<ul>
|
||||
{recipe.ingredients?.map((ingredient) => (
|
||||
<li key={ingredient.id}>
|
||||
{ingredient.quantity} {ingredient.unit} - {ingredient.ingredient_id}
|
||||
{ingredient.preparation_method && (
|
||||
<span className="prep-method"> ({ingredient.preparation_method})</span>
|
||||
)}
|
||||
{ingredient.is_optional && (
|
||||
<span className="optional"> ({t('ingredients.is_optional')})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Instructions */}
|
||||
{recipe.instructions && (
|
||||
<section className="instructions">
|
||||
<h2>{t('fields.instructions')}</h2>
|
||||
<div>{JSON.stringify(recipe.instructions, null, 2)}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example: Recipe Creation Form
|
||||
*/
|
||||
export const CreateRecipeForm: React.FC = () => {
|
||||
const { t } = useTranslation('recipes');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
const [formData, setFormData] = useState<RecipeCreate>({
|
||||
name: '',
|
||||
finished_product_id: '',
|
||||
yield_quantity: 1,
|
||||
yield_unit: MeasurementUnit.UNITS,
|
||||
difficulty_level: 1,
|
||||
batch_size_multiplier: 1.0,
|
||||
is_seasonal: false,
|
||||
is_signature_item: false,
|
||||
ingredients: [],
|
||||
});
|
||||
|
||||
const createRecipe = useCreateRecipe(currentTenant?.id || '', {
|
||||
onSuccess: (data) => {
|
||||
alert(t('messages.recipe_created'));
|
||||
// Reset form or redirect
|
||||
setFormData({
|
||||
name: '',
|
||||
finished_product_id: '',
|
||||
yield_quantity: 1,
|
||||
yield_unit: MeasurementUnit.UNITS,
|
||||
difficulty_level: 1,
|
||||
batch_size_multiplier: 1.0,
|
||||
is_seasonal: false,
|
||||
is_signature_item: false,
|
||||
ingredients: [],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!currentTenant) {
|
||||
alert('No tenant selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
alert(t('messages.recipe_name_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.ingredients.length === 0) {
|
||||
alert(t('messages.at_least_one_ingredient'));
|
||||
return;
|
||||
}
|
||||
|
||||
createRecipe.mutate(formData);
|
||||
};
|
||||
|
||||
if (!currentTenant) {
|
||||
return <div>{t('messages.no_tenant_selected')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="create-recipe-form">
|
||||
<h2>{t('actions.create_recipe')}</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">{t('fields.name')}</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t('placeholders.recipe_name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="description">{t('fields.description')}</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder={t('placeholders.description')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="yield_quantity">{t('fields.yield_quantity')}</label>
|
||||
<input
|
||||
id="yield_quantity"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
value={formData.yield_quantity}
|
||||
onChange={(e) => setFormData({ ...formData, yield_quantity: parseFloat(e.target.value) })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="yield_unit">{t('fields.yield_unit')}</label>
|
||||
<select
|
||||
id="yield_unit"
|
||||
value={formData.yield_unit}
|
||||
onChange={(e) => setFormData({ ...formData, yield_unit: e.target.value as MeasurementUnit })}
|
||||
required
|
||||
>
|
||||
{Object.values(MeasurementUnit).map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{t(`units.${unit}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="difficulty_level">{t('fields.difficulty_level')}</label>
|
||||
<select
|
||||
id="difficulty_level"
|
||||
value={formData.difficulty_level}
|
||||
onChange={(e) => setFormData({ ...formData, difficulty_level: parseInt(e.target.value) })}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{t(`difficulty.${level}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-checkboxes">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_signature_item}
|
||||
onChange={(e) => setFormData({ ...formData, is_signature_item: e.target.checked })}
|
||||
/>
|
||||
{t('fields.is_signature')}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_seasonal}
|
||||
onChange={(e) => setFormData({ ...formData, is_seasonal: e.target.checked })}
|
||||
/>
|
||||
{t('fields.is_seasonal')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Ingredients section would go here */}
|
||||
<div className="ingredients-section">
|
||||
<h3>{t('ingredients.title')}</h3>
|
||||
<p>{t('messages.no_ingredients')}</p>
|
||||
{/* Add ingredient form components here */}
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createRecipe.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{createRecipe.isPending ? 'Creating...' : t('actions.create_recipe')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example: Recipe Statistics Dashboard
|
||||
*/
|
||||
export const RecipeStatistics: React.FC = () => {
|
||||
const { t } = useTranslation('recipes');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
const { data: stats, isLoading } = useRecipeStatistics(currentTenant?.id || '', {
|
||||
enabled: !!currentTenant?.id,
|
||||
});
|
||||
|
||||
const { data: categories } = useRecipeCategories(currentTenant?.id || '', {
|
||||
enabled: !!currentTenant?.id,
|
||||
});
|
||||
|
||||
if (!currentTenant) {
|
||||
return <div>{t('messages.no_tenant_selected')}</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading statistics...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recipe-statistics">
|
||||
<h2>{t('statistics.title')}</h2>
|
||||
|
||||
{stats && (
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<h3>{t('statistics.total_recipes')}</h3>
|
||||
<span className="stat-number">{stats.total_recipes}</span>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>{t('statistics.active_recipes')}</h3>
|
||||
<span className="stat-number">{stats.active_recipes}</span>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>{t('statistics.signature_recipes')}</h3>
|
||||
<span className="stat-number">{stats.signature_recipes}</span>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>{t('statistics.seasonal_recipes')}</h3>
|
||||
<span className="stat-number">{stats.seasonal_recipes}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories && (
|
||||
<div className="categories-list">
|
||||
<h3>Categories</h3>
|
||||
<ul>
|
||||
{categories.categories.map((category) => (
|
||||
<li key={category}>{category}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
RecipesList,
|
||||
RecipeDetail,
|
||||
CreateRecipeForm,
|
||||
RecipeStatistics,
|
||||
};
|
||||
267
frontend/src/locales/en/recipes.json
Normal file
267
frontend/src/locales/en/recipes.json
Normal file
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"title": "Recipe Management",
|
||||
"subtitle": "Manage your bakery's recipes",
|
||||
"navigation": {
|
||||
"all_recipes": "All Recipes",
|
||||
"active_recipes": "Active Recipes",
|
||||
"draft_recipes": "Drafts",
|
||||
"signature_recipes": "Signature Recipes",
|
||||
"seasonal_recipes": "Seasonal Recipes",
|
||||
"production_batches": "Production Batches"
|
||||
},
|
||||
"actions": {
|
||||
"create_recipe": "Create Recipe",
|
||||
"edit_recipe": "Edit Recipe",
|
||||
"duplicate_recipe": "Duplicate Recipe",
|
||||
"activate_recipe": "Activate Recipe",
|
||||
"archive_recipe": "Archive Recipe",
|
||||
"delete_recipe": "Delete Recipe",
|
||||
"view_recipe": "View Recipe",
|
||||
"check_feasibility": "Check Feasibility",
|
||||
"create_batch": "Create Batch",
|
||||
"start_production": "Start Production",
|
||||
"complete_batch": "Complete Batch",
|
||||
"cancel_batch": "Cancel Batch",
|
||||
"export_recipe": "Export Recipe",
|
||||
"print_recipe": "Print Recipe"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Recipe Name",
|
||||
"recipe_code": "Recipe Code",
|
||||
"version": "Version",
|
||||
"description": "Description",
|
||||
"category": "Category",
|
||||
"cuisine_type": "Cuisine Type",
|
||||
"difficulty_level": "Difficulty Level",
|
||||
"yield_quantity": "Yield Quantity",
|
||||
"yield_unit": "Yield Unit",
|
||||
"prep_time": "Preparation Time",
|
||||
"cook_time": "Cooking Time",
|
||||
"total_time": "Total Time",
|
||||
"rest_time": "Rest Time",
|
||||
"instructions": "Instructions",
|
||||
"preparation_notes": "Preparation Notes",
|
||||
"storage_instructions": "Storage Instructions",
|
||||
"quality_standards": "Quality Standards",
|
||||
"serves_count": "Serving Count",
|
||||
"is_seasonal": "Is Seasonal",
|
||||
"season_start": "Season Start",
|
||||
"season_end": "Season End",
|
||||
"is_signature": "Is Signature Recipe",
|
||||
"target_margin": "Target Margin",
|
||||
"batch_multiplier": "Batch Multiplier",
|
||||
"min_batch_size": "Minimum Batch Size",
|
||||
"max_batch_size": "Maximum Batch Size",
|
||||
"optimal_temperature": "Optimal Temperature",
|
||||
"optimal_humidity": "Optimal Humidity",
|
||||
"allergens": "Allergens",
|
||||
"dietary_tags": "Dietary Tags",
|
||||
"nutritional_info": "Nutritional Information"
|
||||
},
|
||||
"ingredients": {
|
||||
"title": "Ingredients",
|
||||
"add_ingredient": "Add Ingredient",
|
||||
"remove_ingredient": "Remove Ingredient",
|
||||
"ingredient_name": "Ingredient Name",
|
||||
"quantity": "Quantity",
|
||||
"unit": "Unit",
|
||||
"alternative_quantity": "Alternative Quantity",
|
||||
"alternative_unit": "Alternative Unit",
|
||||
"preparation_method": "Preparation Method",
|
||||
"notes": "Ingredient Notes",
|
||||
"is_optional": "Is Optional",
|
||||
"ingredient_order": "Order",
|
||||
"ingredient_group": "Group",
|
||||
"substitutions": "Substitutions",
|
||||
"substitution_ratio": "Substitution Ratio",
|
||||
"cost_per_unit": "Cost per Unit",
|
||||
"total_cost": "Total Cost",
|
||||
"groups": {
|
||||
"wet_ingredients": "Wet Ingredients",
|
||||
"dry_ingredients": "Dry Ingredients",
|
||||
"spices": "Spices & Seasonings",
|
||||
"toppings": "Toppings",
|
||||
"fillings": "Fillings",
|
||||
"decorations": "Decorations"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"draft": "Draft",
|
||||
"active": "Active",
|
||||
"testing": "Testing",
|
||||
"archived": "Archived",
|
||||
"discontinued": "Discontinued"
|
||||
},
|
||||
"difficulty": {
|
||||
"1": "Very Easy",
|
||||
"2": "Easy",
|
||||
"3": "Intermediate",
|
||||
"4": "Hard",
|
||||
"5": "Very Hard"
|
||||
},
|
||||
"units": {
|
||||
"g": "grams",
|
||||
"kg": "kilograms",
|
||||
"ml": "milliliters",
|
||||
"l": "liters",
|
||||
"cups": "cups",
|
||||
"tbsp": "tablespoons",
|
||||
"tsp": "teaspoons",
|
||||
"units": "units",
|
||||
"pieces": "pieces",
|
||||
"%": "percentage"
|
||||
},
|
||||
"categories": {
|
||||
"bread": "Breads",
|
||||
"pastry": "Pastries",
|
||||
"cake": "Cakes & Tarts",
|
||||
"cookies": "Cookies",
|
||||
"savory": "Savory",
|
||||
"desserts": "Desserts",
|
||||
"seasonal": "Seasonal",
|
||||
"specialty": "Specialties"
|
||||
},
|
||||
"dietary_tags": {
|
||||
"vegan": "Vegan",
|
||||
"vegetarian": "Vegetarian",
|
||||
"gluten_free": "Gluten Free",
|
||||
"dairy_free": "Dairy Free",
|
||||
"nut_free": "Nut Free",
|
||||
"sugar_free": "Sugar Free",
|
||||
"low_carb": "Low Carb",
|
||||
"keto": "Keto",
|
||||
"organic": "Organic"
|
||||
},
|
||||
"allergens": {
|
||||
"gluten": "Gluten",
|
||||
"dairy": "Dairy",
|
||||
"eggs": "Eggs",
|
||||
"nuts": "Tree Nuts",
|
||||
"soy": "Soy",
|
||||
"sesame": "Sesame",
|
||||
"fish": "Fish",
|
||||
"shellfish": "Shellfish"
|
||||
},
|
||||
"production": {
|
||||
"title": "Production",
|
||||
"batch_number": "Batch Number",
|
||||
"production_date": "Production Date",
|
||||
"planned_quantity": "Planned Quantity",
|
||||
"actual_quantity": "Actual Quantity",
|
||||
"yield_percentage": "Yield Percentage",
|
||||
"priority": "Priority",
|
||||
"assigned_staff": "Assigned Staff",
|
||||
"production_notes": "Production Notes",
|
||||
"quality_score": "Quality Score",
|
||||
"quality_notes": "Quality Notes",
|
||||
"defect_rate": "Defect Rate",
|
||||
"rework_required": "Rework Required",
|
||||
"waste_quantity": "Waste Quantity",
|
||||
"waste_reason": "Waste Reason",
|
||||
"efficiency": "Efficiency",
|
||||
"material_cost": "Material Cost",
|
||||
"labor_cost": "Labor Cost",
|
||||
"overhead_cost": "Overhead Cost",
|
||||
"total_cost": "Total Cost",
|
||||
"cost_per_unit": "Cost per Unit",
|
||||
"status": {
|
||||
"planned": "Planned",
|
||||
"in_progress": "In Progress",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"priority": {
|
||||
"low": "Low",
|
||||
"normal": "Normal",
|
||||
"high": "High",
|
||||
"urgent": "Urgent"
|
||||
}
|
||||
},
|
||||
"feasibility": {
|
||||
"title": "Feasibility Check",
|
||||
"feasible": "Feasible",
|
||||
"not_feasible": "Not Feasible",
|
||||
"missing_ingredients": "Missing Ingredients",
|
||||
"insufficient_ingredients": "Insufficient Ingredients",
|
||||
"batch_multiplier": "Batch Multiplier",
|
||||
"required_quantity": "Required Quantity",
|
||||
"available_quantity": "Available Quantity",
|
||||
"shortage": "Shortage"
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Recipe Statistics",
|
||||
"total_recipes": "Total Recipes",
|
||||
"active_recipes": "Active Recipes",
|
||||
"signature_recipes": "Signature Recipes",
|
||||
"seasonal_recipes": "Seasonal Recipes",
|
||||
"category_breakdown": "Category Breakdown",
|
||||
"most_popular": "Most Popular",
|
||||
"most_profitable": "Most Profitable",
|
||||
"production_volume": "Production Volume"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"search_placeholder": "Search recipes...",
|
||||
"status_filter": "Filter by Status",
|
||||
"category_filter": "Filter by Category",
|
||||
"difficulty_filter": "Filter by Difficulty",
|
||||
"seasonal_filter": "Seasonal Recipes Only",
|
||||
"signature_filter": "Signature Recipes Only",
|
||||
"clear_filters": "Clear Filters"
|
||||
},
|
||||
"costs": {
|
||||
"estimated_cost": "Estimated Cost",
|
||||
"last_calculated": "Last Calculated",
|
||||
"suggested_price": "Suggested Price",
|
||||
"margin_percentage": "Margin Percentage",
|
||||
"cost_breakdown": "Cost Breakdown",
|
||||
"ingredient_costs": "Ingredient Costs",
|
||||
"labor_costs": "Labor Costs",
|
||||
"overhead_costs": "Overhead Costs"
|
||||
},
|
||||
"messages": {
|
||||
"recipe_created": "Recipe created successfully",
|
||||
"recipe_updated": "Recipe updated successfully",
|
||||
"recipe_deleted": "Recipe deleted successfully",
|
||||
"recipe_duplicated": "Recipe duplicated successfully",
|
||||
"recipe_activated": "Recipe activated successfully",
|
||||
"batch_created": "Production batch created successfully",
|
||||
"batch_started": "Production started successfully",
|
||||
"batch_completed": "Batch completed successfully",
|
||||
"batch_cancelled": "Batch cancelled successfully",
|
||||
"feasibility_checked": "Feasibility checked",
|
||||
"loading_recipes": "Loading recipes...",
|
||||
"loading_recipe": "Loading recipe...",
|
||||
"no_recipes_found": "No recipes found",
|
||||
"no_ingredients": "No ingredients added",
|
||||
"confirm_delete": "Are you sure you want to delete this recipe?",
|
||||
"confirm_cancel_batch": "Are you sure you want to cancel this batch?",
|
||||
"recipe_name_required": "Recipe name is required",
|
||||
"at_least_one_ingredient": "Must add at least one ingredient",
|
||||
"invalid_quantity": "Quantity must be greater than 0",
|
||||
"ingredient_required": "Must select an ingredient"
|
||||
},
|
||||
"placeholders": {
|
||||
"recipe_name": "e.g. Classic Sourdough Bread",
|
||||
"recipe_code": "e.g. BRD-001",
|
||||
"description": "Describe the unique aspects of this recipe...",
|
||||
"preparation_notes": "Special notes for preparation...",
|
||||
"storage_instructions": "How to store the finished product...",
|
||||
"quality_standards": "Quality criteria for the final product...",
|
||||
"batch_number": "e.g. BATCH-20231201-001",
|
||||
"production_notes": "Specific notes for this batch...",
|
||||
"quality_notes": "Quality observations...",
|
||||
"waste_reason": "Reason for waste..."
|
||||
},
|
||||
"tooltips": {
|
||||
"difficulty_level": "Level from 1 (very easy) to 5 (very hard)",
|
||||
"yield_quantity": "Amount this recipe produces",
|
||||
"batch_multiplier": "Factor to scale the recipe",
|
||||
"target_margin": "Target profit margin percentage",
|
||||
"optimal_temperature": "Ideal temperature for production",
|
||||
"optimal_humidity": "Ideal humidity for production",
|
||||
"is_seasonal": "Check if this is a seasonal recipe",
|
||||
"is_signature": "Check if this is a bakery signature recipe"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign, Loader } from 'lucide-react';
|
||||
import { Plus, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Loader, Euro } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
@@ -180,13 +180,13 @@ const OrdersPage: React.FC = () => {
|
||||
title: 'Ingresos Hoy',
|
||||
value: formatters.currency(orderStats.revenue_today),
|
||||
variant: 'success' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
{
|
||||
title: 'Valor Promedio',
|
||||
value: formatters.currency(orderStats.average_order_value),
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
@@ -219,13 +219,13 @@ const OrdersPage: React.FC = () => {
|
||||
title: 'Valor Total',
|
||||
value: formatters.currency(customers.reduce((sum, c) => sum + Number(c.total_spent || 0), 0)),
|
||||
variant: 'success' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
{
|
||||
title: 'Promedio por Cliente',
|
||||
value: formatters.currency(customers.reduce((sum, c) => sum + Number(c.average_order_value || 0), 0) / Math.max(customers.length, 1)),
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -565,7 +565,7 @@ const OrdersPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: 'Información Financiera',
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
fields: [
|
||||
{
|
||||
label: 'Subtotal',
|
||||
@@ -717,7 +717,7 @@ const OrdersPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: 'Configuración Comercial',
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
fields: [
|
||||
{
|
||||
label: 'Código de Cliente',
|
||||
|
||||
@@ -1,107 +1,47 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Download, Star, Clock, Users, DollarSign, Package, Eye, Edit, ChefHat, Timer } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Star, Clock, DollarSign, Package, Eye, Edit, ChefHat, Timer, Euro } from 'lucide-react';
|
||||
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/shared';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useRecipes, useRecipeStatistics, useCreateRecipe, useUpdateRecipe, useDeleteRecipe } from '../../../../api/hooks/recipes';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import type { RecipeResponse, RecipeCreate } from '../../../../api/types/recipes';
|
||||
import { CreateRecipeModal } from '../../../../components/domain/recipes';
|
||||
|
||||
const RecipesPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<typeof mockRecipes[0] | null>(null);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<RecipeResponse | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editedRecipe, setEditedRecipe] = useState<Partial<RecipeResponse>>({});
|
||||
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Pan de Molde Integral',
|
||||
category: 'bread',
|
||||
difficulty: 'medium',
|
||||
prepTime: 120,
|
||||
bakingTime: 35,
|
||||
yield: 1,
|
||||
rating: 4.8,
|
||||
cost: 2.50,
|
||||
price: 4.50,
|
||||
profit: 2.00,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['integral', 'saludable', 'artesanal'],
|
||||
description: 'Pan integral artesanal con semillas, perfecto para desayunos saludables.',
|
||||
ingredients: [
|
||||
{ name: 'Harina integral', quantity: 500, unit: 'g' },
|
||||
{ name: 'Agua', quantity: 300, unit: 'ml' },
|
||||
{ name: 'Levadura', quantity: 10, unit: 'g' },
|
||||
{ name: 'Sal', quantity: 8, unit: 'g' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Croissants de Mantequilla',
|
||||
category: 'pastry',
|
||||
difficulty: 'hard',
|
||||
prepTime: 480,
|
||||
bakingTime: 20,
|
||||
yield: 12,
|
||||
rating: 4.9,
|
||||
cost: 8.50,
|
||||
price: 18.00,
|
||||
profit: 9.50,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['francés', 'mantequilla', 'hojaldrado'],
|
||||
description: 'Croissants franceses tradicionales con laminado de mantequilla.',
|
||||
ingredients: [
|
||||
{ name: 'Harina de fuerza', quantity: 500, unit: 'g' },
|
||||
{ name: 'Mantequilla', quantity: 250, unit: 'g' },
|
||||
{ name: 'Leche', quantity: 150, unit: 'ml' },
|
||||
{ name: 'Azúcar', quantity: 50, unit: 'g' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Tarta de Manzana',
|
||||
category: 'cake',
|
||||
difficulty: 'easy',
|
||||
prepTime: 45,
|
||||
bakingTime: 40,
|
||||
yield: 8,
|
||||
rating: 4.6,
|
||||
cost: 4.20,
|
||||
price: 12.00,
|
||||
profit: 7.80,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['frutal', 'casera', 'temporada'],
|
||||
description: 'Tarta casera de manzana con canela y masa quebrada.',
|
||||
ingredients: [
|
||||
{ name: 'Manzanas', quantity: 1000, unit: 'g' },
|
||||
{ name: 'Harina', quantity: 250, unit: 'g' },
|
||||
{ name: 'Mantequilla', quantity: 125, unit: 'g' },
|
||||
{ name: 'Azúcar', quantity: 100, unit: 'g' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Magdalenas de Limón',
|
||||
category: 'pastry',
|
||||
difficulty: 'easy',
|
||||
prepTime: 20,
|
||||
bakingTime: 25,
|
||||
yield: 12,
|
||||
rating: 4.4,
|
||||
cost: 3.80,
|
||||
price: 9.00,
|
||||
profit: 5.20,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['cítrico', 'esponjoso', 'individual'],
|
||||
description: 'Magdalenas suaves y esponjosas con ralladura de limón.',
|
||||
ingredients: [
|
||||
{ name: 'Harina', quantity: 200, unit: 'g' },
|
||||
{ name: 'Huevos', quantity: 3, unit: 'uds' },
|
||||
{ name: 'Azúcar', quantity: 150, unit: 'g' },
|
||||
{ name: 'Limón', quantity: 2, unit: 'uds' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const getRecipeStatusConfig = (category: string, difficulty: string, rating: number) => {
|
||||
// Mutations
|
||||
const createRecipeMutation = useCreateRecipe(tenantId);
|
||||
const updateRecipeMutation = useUpdateRecipe(tenantId);
|
||||
const deleteRecipeMutation = useDeleteRecipe(tenantId);
|
||||
|
||||
// API Data
|
||||
const {
|
||||
data: recipes = [],
|
||||
isLoading: recipesLoading,
|
||||
error: recipesError
|
||||
} = useRecipes(tenantId, { search_term: searchTerm || undefined });
|
||||
|
||||
const {
|
||||
data: statisticsData,
|
||||
isLoading: statisticsLoading
|
||||
} = useRecipeStatistics(tenantId);
|
||||
|
||||
|
||||
const getRecipeStatusConfig = (recipe: RecipeResponse) => {
|
||||
const category = recipe.category || 'other';
|
||||
const difficulty = recipe.difficulty_level || 1;
|
||||
const isSignature = recipe.is_signature_item;
|
||||
const categoryConfig = {
|
||||
bread: { text: 'Pan', icon: ChefHat },
|
||||
pastry: { text: 'Bollería', icon: ChefHat },
|
||||
@@ -111,23 +51,22 @@ const RecipesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const difficultyConfig = {
|
||||
easy: { icon: '●', label: 'Fácil' },
|
||||
medium: { icon: '●●', label: 'Medio' },
|
||||
hard: { icon: '●●●', label: 'Difícil' },
|
||||
1: { icon: '●', label: 'Fácil' },
|
||||
2: { icon: '●●', label: 'Medio' },
|
||||
3: { icon: '●●●', label: 'Difícil' },
|
||||
};
|
||||
|
||||
const categoryInfo = categoryConfig[category as keyof typeof categoryConfig] || categoryConfig.other;
|
||||
const difficultyInfo = difficultyConfig[difficulty as keyof typeof difficultyConfig];
|
||||
const isPopular = rating >= 4.7;
|
||||
const difficultyInfo = difficultyConfig[Math.min(difficulty, 3) as keyof typeof difficultyConfig] || difficultyConfig[1];
|
||||
|
||||
return {
|
||||
color: getStatusColor(category),
|
||||
text: categoryInfo.text,
|
||||
icon: categoryInfo.icon,
|
||||
difficultyIcon: difficultyInfo?.icon || '●',
|
||||
difficultyLabel: difficultyInfo?.label || difficulty,
|
||||
difficultyIcon: difficultyInfo.icon,
|
||||
difficultyLabel: difficultyInfo.label,
|
||||
isCritical: false,
|
||||
isHighlight: isPopular
|
||||
isHighlight: isSignature
|
||||
};
|
||||
};
|
||||
|
||||
@@ -137,88 +76,303 @@ const RecipesPage: React.FC = () => {
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
};
|
||||
|
||||
const filteredRecipes = mockRecipes.filter(recipe => {
|
||||
const matchesSearch = recipe.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
recipe.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
recipe.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const filteredRecipes = useMemo(() => {
|
||||
if (!searchTerm) return recipes;
|
||||
|
||||
return matchesSearch;
|
||||
});
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return recipes.filter(recipe =>
|
||||
recipe.name.toLowerCase().includes(searchLower) ||
|
||||
(recipe.description && recipe.description.toLowerCase().includes(searchLower)) ||
|
||||
(recipe.category && recipe.category.toLowerCase().includes(searchLower))
|
||||
);
|
||||
}, [recipes, searchTerm]);
|
||||
|
||||
const mockRecipeStats = {
|
||||
totalRecipes: mockRecipes.length,
|
||||
popularRecipes: mockRecipes.filter(r => r.rating > 4.7).length,
|
||||
easyRecipes: mockRecipes.filter(r => r.difficulty === 'easy').length,
|
||||
averageCost: mockRecipes.reduce((sum, r) => sum + r.cost, 0) / mockRecipes.length,
|
||||
averageProfit: mockRecipes.reduce((sum, r) => sum + r.profit, 0) / mockRecipes.length,
|
||||
categories: [...new Set(mockRecipes.map(r => r.category))].length,
|
||||
};
|
||||
const recipeStats = useMemo(() => {
|
||||
const stats = {
|
||||
totalRecipes: recipes.length,
|
||||
signatureRecipes: recipes.filter(r => r.is_signature_item).length,
|
||||
easyRecipes: recipes.filter(r => r.difficulty_level === 1).length,
|
||||
averageCost: recipes.length > 0 ? recipes.reduce((sum, r) => sum + (r.estimated_cost_per_unit || 0), 0) / recipes.length : 0,
|
||||
averageProfit: 0, // Will be calculated based on suggested selling price - cost
|
||||
categories: [...new Set(recipes.map(r => r.category).filter(Boolean))].length,
|
||||
};
|
||||
|
||||
const recipeStats = [
|
||||
// Calculate average profit
|
||||
stats.averageProfit = recipes.length > 0 ?
|
||||
recipes.reduce((sum, r) => {
|
||||
const cost = r.estimated_cost_per_unit || 0;
|
||||
const price = r.suggested_selling_price || 0;
|
||||
return sum + (price - cost);
|
||||
}, 0) / recipes.length : 0;
|
||||
|
||||
return stats;
|
||||
}, [recipes]);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: 'Total Recetas',
|
||||
value: mockRecipeStats.totalRecipes,
|
||||
value: recipeStats.totalRecipes,
|
||||
variant: 'default' as const,
|
||||
icon: ChefHat,
|
||||
},
|
||||
{
|
||||
title: 'Populares',
|
||||
value: mockRecipeStats.popularRecipes,
|
||||
title: 'Especiales',
|
||||
value: recipeStats.signatureRecipes,
|
||||
variant: 'warning' as const,
|
||||
icon: Star,
|
||||
},
|
||||
{
|
||||
title: 'Fáciles',
|
||||
value: mockRecipeStats.easyRecipes,
|
||||
value: recipeStats.easyRecipes,
|
||||
variant: 'success' as const,
|
||||
icon: Timer,
|
||||
},
|
||||
{
|
||||
title: 'Costo Promedio',
|
||||
value: formatters.currency(mockRecipeStats.averageCost),
|
||||
value: formatters.currency(recipeStats.averageCost),
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
{
|
||||
title: 'Margen Promedio',
|
||||
value: formatters.currency(mockRecipeStats.averageProfit),
|
||||
value: formatters.currency(recipeStats.averageProfit),
|
||||
variant: 'success' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
{
|
||||
title: 'Categorías',
|
||||
value: mockRecipeStats.categories,
|
||||
value: recipeStats.categories,
|
||||
variant: 'info' as const,
|
||||
icon: Package,
|
||||
},
|
||||
];
|
||||
|
||||
// Handle creating a new recipe
|
||||
const handleCreateRecipe = async (recipeData: RecipeCreate) => {
|
||||
try {
|
||||
await createRecipeMutation.mutateAsync(recipeData);
|
||||
setShowCreateModal(false);
|
||||
console.log('Recipe created successfully');
|
||||
} catch (error) {
|
||||
console.error('Error creating recipe:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle field changes in edit mode
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
|
||||
if (!selectedRecipe) return;
|
||||
|
||||
const fieldMap: Record<string, string> = {
|
||||
'Categoría': 'category',
|
||||
'Dificultad': 'difficulty_level',
|
||||
'Estado': 'status',
|
||||
'Rendimiento': 'yield_quantity',
|
||||
'Tiempo de preparación': 'prep_time_minutes',
|
||||
'Tiempo de cocción': 'cook_time_minutes',
|
||||
'Costo estimado por unidad': 'estimated_cost_per_unit',
|
||||
'Precio de venta sugerido': 'suggested_selling_price',
|
||||
'Margen objetivo': 'target_margin_percentage'
|
||||
};
|
||||
|
||||
const sections = getModalSections();
|
||||
const field = sections[sectionIndex]?.fields[fieldIndex];
|
||||
const fieldKey = fieldMap[field?.label || ''];
|
||||
|
||||
if (fieldKey) {
|
||||
setEditedRecipe(prev => ({
|
||||
...prev,
|
||||
[fieldKey]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle saving edited recipe
|
||||
const handleSaveRecipe = async () => {
|
||||
if (!selectedRecipe || !Object.keys(editedRecipe).length) return;
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
...editedRecipe,
|
||||
// Convert time fields from formatted strings back to numbers if needed
|
||||
prep_time_minutes: typeof editedRecipe.prep_time_minutes === 'string'
|
||||
? parseInt(editedRecipe.prep_time_minutes.toString())
|
||||
: editedRecipe.prep_time_minutes,
|
||||
cook_time_minutes: typeof editedRecipe.cook_time_minutes === 'string'
|
||||
? parseInt(editedRecipe.cook_time_minutes.toString())
|
||||
: editedRecipe.cook_time_minutes,
|
||||
};
|
||||
|
||||
await updateRecipeMutation.mutateAsync({
|
||||
id: selectedRecipe.id,
|
||||
data: updateData
|
||||
});
|
||||
|
||||
setModalMode('view');
|
||||
setEditedRecipe({});
|
||||
console.log('Recipe updated successfully');
|
||||
} catch (error) {
|
||||
console.error('Error updating recipe:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get current value for field (edited value or original)
|
||||
const getFieldValue = (originalValue: any, fieldKey: string) => {
|
||||
return editedRecipe[fieldKey as keyof RecipeResponse] !== undefined
|
||||
? editedRecipe[fieldKey as keyof RecipeResponse]
|
||||
: originalValue;
|
||||
};
|
||||
|
||||
// Get modal sections with editable fields
|
||||
const getModalSections = () => {
|
||||
if (!selectedRecipe) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Información Básica',
|
||||
icon: ChefHat,
|
||||
fields: [
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: getFieldValue(selectedRecipe.category || 'Sin categoría', 'category'),
|
||||
type: modalMode === 'edit' ? 'select' : 'status',
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: 'bread', label: 'Pan' },
|
||||
{ value: 'pastry', label: 'Bollería' },
|
||||
{ value: 'cake', label: 'Tarta' },
|
||||
{ value: 'cookie', label: 'Galleta' },
|
||||
{ value: 'other', label: 'Otro' }
|
||||
] : undefined,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
label: 'Dificultad',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.difficulty_level, 'difficulty_level')
|
||||
: `Nivel ${getFieldValue(selectedRecipe.difficulty_level, 'difficulty_level')}`,
|
||||
type: modalMode === 'edit' ? 'select' : 'text',
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: 1, label: '1 - Fácil' },
|
||||
{ value: 2, label: '2 - Medio' },
|
||||
{ value: 3, label: '3 - Difícil' },
|
||||
{ value: 4, label: '4 - Muy Difícil' },
|
||||
{ value: 5, label: '5 - Extremo' }
|
||||
] : undefined,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
label: 'Estado',
|
||||
value: getFieldValue(selectedRecipe.status, 'status'),
|
||||
type: modalMode === 'edit' ? 'select' : 'text',
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: 'draft', label: 'Borrador' },
|
||||
{ value: 'active', label: 'Activo' },
|
||||
{ value: 'archived', label: 'Archivado' }
|
||||
] : undefined,
|
||||
highlight: selectedRecipe.status === 'active',
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
label: 'Rendimiento',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity')
|
||||
: `${getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity')} ${selectedRecipe.yield_unit}`,
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
editable: modalMode === 'edit'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Tiempos',
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
label: 'Tiempo de preparación',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.prep_time_minutes || 0, 'prep_time_minutes')
|
||||
: formatTime(getFieldValue(selectedRecipe.prep_time_minutes || 0, 'prep_time_minutes')),
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
placeholder: modalMode === 'edit' ? 'minutos' : undefined,
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de cocción',
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.cook_time_minutes || 0, 'cook_time_minutes')
|
||||
: formatTime(getFieldValue(selectedRecipe.cook_time_minutes || 0, 'cook_time_minutes')),
|
||||
type: modalMode === 'edit' ? 'number' : 'text',
|
||||
placeholder: modalMode === 'edit' ? 'minutos' : undefined,
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Tiempo total',
|
||||
value: selectedRecipe.total_time_minutes ? formatTime(selectedRecipe.total_time_minutes) : 'No especificado',
|
||||
type: 'text',
|
||||
highlight: true,
|
||||
readonly: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Análisis Financiero',
|
||||
icon: DollarSign,
|
||||
fields: [
|
||||
{
|
||||
label: 'Costo estimado por unidad',
|
||||
value: getFieldValue(selectedRecipe.estimated_cost_per_unit || 0, 'estimated_cost_per_unit'),
|
||||
type: modalMode === 'edit' ? 'number' : 'currency',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Precio de venta sugerido',
|
||||
value: getFieldValue(selectedRecipe.suggested_selling_price || 0, 'suggested_selling_price'),
|
||||
type: modalMode === 'edit' ? 'number' : 'currency',
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
{
|
||||
label: 'Margen objetivo',
|
||||
value: getFieldValue(selectedRecipe.target_margin_percentage || 0, 'target_margin_percentage'),
|
||||
type: modalMode === 'edit' ? 'number' : 'percentage',
|
||||
highlight: true,
|
||||
editable: modalMode === 'edit'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Ingredientes',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Lista de ingredientes',
|
||||
value: selectedRecipe.ingredients?.map(ing => `${ing.quantity} ${ing.unit} - ${ing.ingredient_id}`) || ['No especificados'],
|
||||
type: 'list',
|
||||
span: 2,
|
||||
readonly: true // For now, ingredients editing can be complex, so we'll keep it read-only
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Recetas"
|
||||
description="Administra y organiza todas las recetas de tu panadería"
|
||||
actions={[
|
||||
{
|
||||
id: "export",
|
||||
label: "Exportar",
|
||||
variant: "outline" as const,
|
||||
icon: Download,
|
||||
onClick: () => console.log('Export recipes')
|
||||
},
|
||||
{
|
||||
id: "new",
|
||||
label: "Nueva Receta",
|
||||
variant: "primary" as const,
|
||||
icon: Plus,
|
||||
onClick: () => setShowForm(true)
|
||||
onClick: () => setShowCreateModal(true)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid
|
||||
stats={recipeStats}
|
||||
stats={stats}
|
||||
columns={3}
|
||||
/>
|
||||
|
||||
@@ -227,25 +381,23 @@ const RecipesPage: React.FC = () => {
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Buscar recetas por nombre, ingredientes o etiquetas..."
|
||||
placeholder="Buscar recetas por nombre, descripción o categoría..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recipes Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredRecipes.map((recipe) => {
|
||||
const statusConfig = getRecipeStatusConfig(recipe.category, recipe.difficulty, recipe.rating);
|
||||
const profitMargin = Math.round((recipe.profit / recipe.price) * 100);
|
||||
const totalTime = formatTime(recipe.prepTime + recipe.bakingTime);
|
||||
const statusConfig = getRecipeStatusConfig(recipe);
|
||||
const cost = recipe.estimated_cost_per_unit || 0;
|
||||
const price = recipe.suggested_selling_price || 0;
|
||||
const profitMargin = price > 0 ? Math.round(((price - cost) / price) * 100) : 0;
|
||||
const totalTime = formatTime((recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0));
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
@@ -253,12 +405,12 @@ const RecipesPage: React.FC = () => {
|
||||
id={recipe.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={recipe.name}
|
||||
subtitle={`${statusConfig.text} • ${statusConfig.difficultyLabel}${statusConfig.isHighlight ? ' ★' + recipe.rating : ''}`}
|
||||
primaryValue={recipe.ingredients.length}
|
||||
subtitle={`${statusConfig.text} • ${statusConfig.difficultyLabel}${statusConfig.isHighlight ? ' ★' : ''}`}
|
||||
primaryValue={recipe.ingredients?.length || 0}
|
||||
primaryValueLabel="ingredientes"
|
||||
secondaryInfo={{
|
||||
label: 'Margen',
|
||||
value: `€${formatters.compact(recipe.profit)}`
|
||||
value: `€${formatters.compact(price - cost)}`
|
||||
}}
|
||||
progress={{
|
||||
label: 'Margen de beneficio',
|
||||
@@ -267,8 +419,8 @@ const RecipesPage: React.FC = () => {
|
||||
}}
|
||||
metadata={[
|
||||
`Tiempo: ${totalTime}`,
|
||||
`Porciones: ${recipe.yield}`,
|
||||
`${recipe.ingredients.length} ingredientes principales`
|
||||
`Rendimiento: ${recipe.yield_quantity} ${recipe.yield_unit}`,
|
||||
`${recipe.ingredients?.length || 0} ingredientes principales`
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
@@ -328,110 +480,33 @@ const RecipesPage: React.FC = () => {
|
||||
setShowForm(false);
|
||||
setSelectedRecipe(null);
|
||||
setModalMode('view');
|
||||
setEditedRecipe({});
|
||||
}}
|
||||
mode={modalMode}
|
||||
onModeChange={setModalMode}
|
||||
title={selectedRecipe.name}
|
||||
subtitle={selectedRecipe.description}
|
||||
statusIndicator={getRecipeStatusConfig(selectedRecipe.category, selectedRecipe.difficulty, selectedRecipe.rating)}
|
||||
image={selectedRecipe.image}
|
||||
subtitle={selectedRecipe.description || ''}
|
||||
statusIndicator={getRecipeStatusConfig(selectedRecipe)}
|
||||
size="xl"
|
||||
sections={[
|
||||
sections={getModalSections()}
|
||||
onFieldChange={handleFieldChange}
|
||||
actions={modalMode === 'edit' ? [
|
||||
{
|
||||
title: 'Información Básica',
|
||||
label: 'Guardar',
|
||||
icon: ChefHat,
|
||||
fields: [
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: selectedRecipe.category,
|
||||
type: 'status'
|
||||
},
|
||||
{
|
||||
label: 'Dificultad',
|
||||
value: selectedRecipe.difficulty
|
||||
},
|
||||
{
|
||||
label: 'Valoración',
|
||||
value: `${selectedRecipe.rating} ★`,
|
||||
highlight: selectedRecipe.rating >= 4.7
|
||||
},
|
||||
{
|
||||
label: 'Rendimiento',
|
||||
value: `${selectedRecipe.yield} porciones`
|
||||
}
|
||||
]
|
||||
variant: 'primary',
|
||||
onClick: handleSaveRecipe,
|
||||
disabled: updateRecipeMutation.isPending
|
||||
},
|
||||
{
|
||||
title: 'Tiempos',
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
label: 'Tiempo de preparación',
|
||||
value: formatTime(selectedRecipe.prepTime)
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de horneado',
|
||||
value: formatTime(selectedRecipe.bakingTime)
|
||||
},
|
||||
{
|
||||
label: 'Tiempo total',
|
||||
value: formatTime(selectedRecipe.prepTime + selectedRecipe.bakingTime),
|
||||
highlight: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Análisis Financiero',
|
||||
icon: DollarSign,
|
||||
fields: [
|
||||
{
|
||||
label: 'Costo de producción',
|
||||
value: selectedRecipe.cost,
|
||||
type: 'currency'
|
||||
},
|
||||
{
|
||||
label: 'Precio de venta',
|
||||
value: selectedRecipe.price,
|
||||
type: 'currency'
|
||||
},
|
||||
{
|
||||
label: 'Margen de beneficio',
|
||||
value: selectedRecipe.profit,
|
||||
type: 'currency',
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Porcentaje de margen',
|
||||
value: Math.round((selectedRecipe.profit / selectedRecipe.price) * 100),
|
||||
type: 'percentage',
|
||||
highlight: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Ingredientes',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Lista de ingredientes',
|
||||
value: selectedRecipe.ingredients.map(ing => `${ing.name}: ${ing.quantity} ${ing.unit}`),
|
||||
type: 'list',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Etiquetas',
|
||||
fields: [
|
||||
{
|
||||
label: 'Tags',
|
||||
value: selectedRecipe.tags.join(', '),
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
label: 'Cancelar',
|
||||
variant: 'outline',
|
||||
onClick: () => {
|
||||
setModalMode('view');
|
||||
setEditedRecipe({});
|
||||
}
|
||||
}
|
||||
]}
|
||||
actions={[
|
||||
] : [
|
||||
{
|
||||
label: 'Producir',
|
||||
icon: ChefHat,
|
||||
@@ -444,10 +519,18 @@ const RecipesPage: React.FC = () => {
|
||||
}
|
||||
]}
|
||||
onEdit={() => {
|
||||
console.log('Editing recipe:', selectedRecipe.id);
|
||||
setModalMode('edit');
|
||||
setEditedRecipe({});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Recipe Modal */}
|
||||
<CreateRecipeModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreateRecipe={handleCreateRecipe}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign, Loader } from 'lucide-react';
|
||||
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
@@ -107,7 +107,7 @@ const SuppliersPage: React.FC = () => {
|
||||
title: 'Gasto Total',
|
||||
value: formatters.currency(supplierStats.total_spend),
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
},
|
||||
{
|
||||
title: 'Calidad Media',
|
||||
@@ -382,7 +382,7 @@ const SuppliersPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: 'Rendimiento y Estadísticas',
|
||||
icon: DollarSign,
|
||||
icon: Euro,
|
||||
fields: [
|
||||
{
|
||||
label: 'Moneda',
|
||||
|
||||
@@ -257,6 +257,22 @@ async def proxy_tenant_deliveries(request: Request, tenant_id: str = Path(...),
|
||||
target_path = f"/api/v1/tenants/{tenant_id}/deliveries{path}".rstrip("/")
|
||||
return await _proxy_to_suppliers_service(request, target_path, tenant_id=tenant_id)
|
||||
|
||||
# ================================================================
|
||||
# TENANT-SCOPED RECIPES SERVICE ENDPOINTS
|
||||
# ================================================================
|
||||
|
||||
@router.api_route("/{tenant_id}/recipes", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||
async def proxy_tenant_recipes_base(request: Request, tenant_id: str = Path(...)):
|
||||
"""Proxy tenant recipes requests to recipes service (base path)"""
|
||||
target_path = f"/api/v1/tenants/{tenant_id}/recipes"
|
||||
return await _proxy_to_recipes_service(request, target_path, tenant_id=tenant_id)
|
||||
|
||||
@router.api_route("/{tenant_id}/recipes/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||
async def proxy_tenant_recipes_with_path(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||
"""Proxy tenant recipes requests to recipes service (with additional path)"""
|
||||
target_path = f"/api/v1/tenants/{tenant_id}/recipes/{path}".rstrip("/")
|
||||
return await _proxy_to_recipes_service(request, target_path, tenant_id=tenant_id)
|
||||
|
||||
# ================================================================
|
||||
# PROXY HELPER FUNCTIONS
|
||||
# ================================================================
|
||||
@@ -301,6 +317,10 @@ async def _proxy_to_suppliers_service(request: Request, target_path: str, tenant
|
||||
"""Proxy request to suppliers service"""
|
||||
return await _proxy_request(request, target_path, settings.SUPPLIERS_SERVICE_URL, tenant_id=tenant_id)
|
||||
|
||||
async def _proxy_to_recipes_service(request: Request, target_path: str, tenant_id: str = None):
|
||||
"""Proxy request to recipes service"""
|
||||
return await _proxy_request(request, target_path, settings.RECIPES_SERVICE_URL, tenant_id=tenant_id)
|
||||
|
||||
async def _proxy_request(request: Request, target_path: str, service_url: str, tenant_id: str = None):
|
||||
"""Generic proxy function with enhanced error handling"""
|
||||
|
||||
|
||||
302
insert_real_recipes.sql
Normal file
302
insert_real_recipes.sql
Normal file
@@ -0,0 +1,302 @@
|
||||
-- Insert real recipes using actual inventory data
|
||||
-- Tenant ID: c464fb3e-7af2-46e6-9e43-85318f34199a
|
||||
-- User email for created_by: fsdfsdfs@sdfsdf.com
|
||||
|
||||
-- Real finished product IDs from inventory:
|
||||
-- Croissant: c9a049b7-d1ae-4bf5-99cc-ed2c46f0a509
|
||||
-- Napolitana: 72c1020f-64be-4b42-8857-e774908204d9
|
||||
-- Palmera: 9e888a05-9dda-488b-a06c-0c60a4479e67
|
||||
-- Pan Tostado: 368fba0e-8ec5-4048-a2f1-79b63f9e11cf
|
||||
|
||||
-- Real ingredient IDs from inventory:
|
||||
-- Harina de Trigo: 1e1d496f-c041-4f42-82a9-2ae7837c9231
|
||||
-- Levadura Fresca: de2f0852-75f5-4b18-8f0e-7c707f79a9f9
|
||||
-- Mantequilla: 89e6224a-a055-4148-a2f1-86a5091becec
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Recipe 1: Croissant de Mantequilla
|
||||
INSERT INTO recipes (
|
||||
id, tenant_id, name, recipe_code, version, finished_product_id,
|
||||
description, category, difficulty_level, yield_quantity, yield_unit,
|
||||
prep_time_minutes, cook_time_minutes, total_time_minutes,
|
||||
estimated_cost_per_unit, target_margin_percentage, suggested_selling_price,
|
||||
batch_size_multiplier, status, is_signature_item, is_seasonal,
|
||||
created_at, updated_at, created_by
|
||||
) VALUES (
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
'Croissant de Mantequilla',
|
||||
'CRO001',
|
||||
'1.0',
|
||||
'c9a049b7-d1ae-4bf5-99cc-ed2c46f0a509',
|
||||
'Croissant clásico francés con mantequilla, hojaldrado perfecto y textura crujiente',
|
||||
'pastry',
|
||||
3,
|
||||
12,
|
||||
'UNITS',
|
||||
240,
|
||||
18,
|
||||
258,
|
||||
1.25,
|
||||
65.0,
|
||||
3.38,
|
||||
1.0,
|
||||
'ACTIVE',
|
||||
true,
|
||||
false,
|
||||
NOW(),
|
||||
NOW(),
|
||||
NULL
|
||||
);
|
||||
|
||||
-- Get the recipe ID for ingredients
|
||||
INSERT INTO recipe_ingredients (
|
||||
id, tenant_id, recipe_id, ingredient_id, quantity, unit,
|
||||
is_optional, ingredient_order
|
||||
) VALUES
|
||||
-- Harina de Trigo
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
(SELECT id FROM recipes WHERE recipe_code = 'CRO001' AND tenant_id = 'c464fb3e-7af2-46e6-9e43-85318f34199a'),
|
||||
'1e1d496f-c041-4f42-82a9-2ae7837c9231',
|
||||
0.5,
|
||||
'KILOGRAMS',
|
||||
false,
|
||||
1
|
||||
),
|
||||
-- Mantequilla
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
(SELECT id FROM recipes WHERE recipe_code = 'CRO001' AND tenant_id = 'c464fb3e-7af2-46e6-9e43-85318f34199a'),
|
||||
'89e6224a-a055-4148-a2f1-86a5091becec',
|
||||
0.3,
|
||||
'KILOGRAMS',
|
||||
false,
|
||||
2
|
||||
),
|
||||
-- Levadura Fresca
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
(SELECT id FROM recipes WHERE recipe_code = 'CRO001' AND tenant_id = 'c464fb3e-7af2-46e6-9e43-85318f34199a'),
|
||||
'de2f0852-75f5-4b18-8f0e-7c707f79a9f9',
|
||||
0.012,
|
||||
'KILOGRAMS',
|
||||
false,
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 2: Napolitana de Chocolate
|
||||
INSERT INTO recipes (
|
||||
id, tenant_id, name, recipe_code, version, finished_product_id,
|
||||
description, category, difficulty_level, yield_quantity, yield_unit,
|
||||
prep_time_minutes, cook_time_minutes, total_time_minutes,
|
||||
estimated_cost_per_unit, target_margin_percentage, suggested_selling_price,
|
||||
batch_size_multiplier, status, is_signature_item, is_seasonal,
|
||||
created_at, updated_at, created_by
|
||||
) VALUES (
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
'Napolitana de Chocolate',
|
||||
'NAP001',
|
||||
'1.0',
|
||||
'72c1020f-64be-4b42-8857-e774908204d9',
|
||||
'Hojaldre relleno de chocolate, dulce y crujiente, perfecto para merienda',
|
||||
'pastry',
|
||||
2,
|
||||
8,
|
||||
'UNITS',
|
||||
45,
|
||||
20,
|
||||
65,
|
||||
2.00,
|
||||
70.0,
|
||||
6.67,
|
||||
1.0,
|
||||
'ACTIVE',
|
||||
false,
|
||||
false,
|
||||
NOW(),
|
||||
NOW(),
|
||||
NULL
|
||||
);
|
||||
|
||||
-- Ingredients for Napolitana
|
||||
INSERT INTO recipe_ingredients (
|
||||
id, tenant_id, recipe_id, ingredient_id, quantity, unit,
|
||||
is_optional, ingredient_order
|
||||
) VALUES
|
||||
-- Harina de Trigo
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
(SELECT id FROM recipes WHERE recipe_code = 'NAP001' AND tenant_id = 'c464fb3e-7af2-46e6-9e43-85318f34199a'),
|
||||
'1e1d496f-c041-4f42-82a9-2ae7837c9231',
|
||||
0.3,
|
||||
'KILOGRAMS',
|
||||
false,
|
||||
1
|
||||
),
|
||||
-- Mantequilla
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
(SELECT id FROM recipes WHERE recipe_code = 'NAP001' AND tenant_id = 'c464fb3e-7af2-46e6-9e43-85318f34199a'),
|
||||
'89e6224a-a055-4148-a2f1-86a5091becec',
|
||||
0.2,
|
||||
'KILOGRAMS',
|
||||
false,
|
||||
2
|
||||
);
|
||||
|
||||
-- Recipe 3: Palmera de Azúcar
|
||||
INSERT INTO recipes (
|
||||
id, tenant_id, name, recipe_code, version, finished_product_id,
|
||||
description, category, difficulty_level, yield_quantity, yield_unit,
|
||||
prep_time_minutes, cook_time_minutes, total_time_minutes,
|
||||
estimated_cost_per_unit, target_margin_percentage, suggested_selling_price,
|
||||
batch_size_multiplier, status, is_signature_item, is_seasonal,
|
||||
created_at, updated_at, created_by
|
||||
) VALUES (
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
'Palmera de Azúcar',
|
||||
'PAL001',
|
||||
'1.0',
|
||||
'9e888a05-9dda-488b-a06c-0c60a4479e67',
|
||||
'Palmera de hojaldre con azúcar caramelizado, dulce y crujiente',
|
||||
'pastry',
|
||||
2,
|
||||
10,
|
||||
'UNITS',
|
||||
30,
|
||||
15,
|
||||
45,
|
||||
1.80,
|
||||
75.0,
|
||||
7.20,
|
||||
1.0,
|
||||
'ACTIVE',
|
||||
false,
|
||||
false,
|
||||
NOW(),
|
||||
NOW(),
|
||||
NULL
|
||||
);
|
||||
|
||||
-- Ingredients for Palmera
|
||||
INSERT INTO recipe_ingredients (
|
||||
id, tenant_id, recipe_id, ingredient_id, quantity, unit,
|
||||
is_optional, ingredient_order
|
||||
) VALUES
|
||||
-- Harina de Trigo
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
(SELECT id FROM recipes WHERE recipe_code = 'PAL001' AND tenant_id = 'c464fb3e-7af2-46e6-9e43-85318f34199a'),
|
||||
'1e1d496f-c041-4f42-82a9-2ae7837c9231',
|
||||
0.4,
|
||||
'KILOGRAMS',
|
||||
false,
|
||||
1
|
||||
),
|
||||
-- Mantequilla
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
(SELECT id FROM recipes WHERE recipe_code = 'PAL001' AND tenant_id = 'c464fb3e-7af2-46e6-9e43-85318f34199a'),
|
||||
'89e6224a-a055-4148-a2f1-86a5091becec',
|
||||
0.15,
|
||||
'KILOGRAMS',
|
||||
false,
|
||||
2
|
||||
);
|
||||
|
||||
-- Recipe 4: Pan Tostado Artesanal
|
||||
INSERT INTO recipes (
|
||||
id, tenant_id, name, recipe_code, version, finished_product_id,
|
||||
description, category, difficulty_level, yield_quantity, yield_unit,
|
||||
prep_time_minutes, cook_time_minutes, total_time_minutes,
|
||||
estimated_cost_per_unit, target_margin_percentage, suggested_selling_price,
|
||||
batch_size_multiplier, status, is_signature_item, is_seasonal,
|
||||
created_at, updated_at, created_by
|
||||
) VALUES (
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
'Pan Tostado Artesanal',
|
||||
'PAN001',
|
||||
'1.0',
|
||||
'368fba0e-8ec5-4048-a2f1-79b63f9e11cf',
|
||||
'Pan artesanal con corteza crujiente y miga tierna, perfecto para tostadas',
|
||||
'bread',
|
||||
2,
|
||||
2,
|
||||
'UNITS',
|
||||
180,
|
||||
35,
|
||||
215,
|
||||
3.50,
|
||||
60.0,
|
||||
9.33,
|
||||
1.0,
|
||||
'ACTIVE',
|
||||
true,
|
||||
false,
|
||||
NOW(),
|
||||
NOW(),
|
||||
NULL
|
||||
);
|
||||
|
||||
-- Ingredients for Pan Tostado
|
||||
INSERT INTO recipe_ingredients (
|
||||
id, tenant_id, recipe_id, ingredient_id, quantity, unit,
|
||||
is_optional, ingredient_order
|
||||
) VALUES
|
||||
-- Harina de Trigo
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
(SELECT id FROM recipes WHERE recipe_code = 'PAN001' AND tenant_id = 'c464fb3e-7af2-46e6-9e43-85318f34199a'),
|
||||
'1e1d496f-c041-4f42-82a9-2ae7837c9231',
|
||||
0.6,
|
||||
'KILOGRAMS',
|
||||
false,
|
||||
1
|
||||
),
|
||||
-- Levadura Fresca
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'c464fb3e-7af2-46e6-9e43-85318f34199a',
|
||||
(SELECT id FROM recipes WHERE recipe_code = 'PAN001' AND tenant_id = 'c464fb3e-7af2-46e6-9e43-85318f34199a'),
|
||||
'de2f0852-75f5-4b18-8f0e-7c707f79a9f9',
|
||||
0.015,
|
||||
'KILOGRAMS',
|
||||
false,
|
||||
2
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Verify the data was inserted
|
||||
SELECT
|
||||
r.name,
|
||||
r.recipe_code,
|
||||
r.category,
|
||||
r.difficulty_level,
|
||||
r.yield_quantity,
|
||||
r.yield_unit,
|
||||
r.total_time_minutes,
|
||||
r.estimated_cost_per_unit,
|
||||
r.suggested_selling_price,
|
||||
r.is_signature_item,
|
||||
COUNT(ri.id) as ingredient_count
|
||||
FROM recipes r
|
||||
LEFT JOIN recipe_ingredients ri ON r.id = ri.recipe_id
|
||||
WHERE r.tenant_id = 'c464fb3e-7af2-46e6-9e43-85318f34199a'
|
||||
GROUP BY r.id, r.name, r.recipe_code, r.category, r.difficulty_level,
|
||||
r.yield_quantity, r.yield_unit, r.total_time_minutes,
|
||||
r.estimated_cost_per_unit, r.suggested_selling_price, r.is_signature_item
|
||||
ORDER BY r.created_at DESC;
|
||||
183
services/recipes/add_sample_recipes.py
Normal file
183
services/recipes/add_sample_recipes.py
Normal file
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to add sample recipes for testing
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
# Add the app directory to Python path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), 'app'))
|
||||
|
||||
from core.database import get_db_session
|
||||
from repositories.recipe_repository import RecipeRepository
|
||||
from schemas.recipes import RecipeCreate, RecipeIngredientCreate
|
||||
|
||||
# Sample tenant ID - you should replace this with a real tenant ID from your system
|
||||
SAMPLE_TENANT_ID = "946206b3-7446-436b-b29d-f265b28d9ff5"
|
||||
|
||||
# Sample finished product IDs - you should replace these with real product IDs from your system
|
||||
SAMPLE_PRODUCT_IDS = [
|
||||
"550e8400-e29b-41d4-a716-446655440001", # Pan Integral
|
||||
"550e8400-e29b-41d4-a716-446655440002", # Croissant
|
||||
"550e8400-e29b-41d4-a716-446655440003", # Tarta de Manzana
|
||||
"550e8400-e29b-41d4-a716-446655440004", # Magdalenas
|
||||
]
|
||||
|
||||
# Sample ingredient IDs - you should replace these with real ingredient IDs from your system
|
||||
SAMPLE_INGREDIENT_IDS = [
|
||||
"660e8400-e29b-41d4-a716-446655440001", # Harina integral
|
||||
"660e8400-e29b-41d4-a716-446655440002", # Agua
|
||||
"660e8400-e29b-41d4-a716-446655440003", # Levadura
|
||||
"660e8400-e29b-41d4-a716-446655440004", # Sal
|
||||
"660e8400-e29b-41d4-a716-446655440005", # Harina de fuerza
|
||||
"660e8400-e29b-41d4-a716-446655440006", # Mantequilla
|
||||
"660e8400-e29b-41d4-a716-446655440007", # Leche
|
||||
"660e8400-e29b-41d4-a716-446655440008", # Azúcar
|
||||
"660e8400-e29b-41d4-a716-446655440009", # Manzanas
|
||||
"660e8400-e29b-41d4-a716-446655440010", # Huevos
|
||||
"660e8400-e29b-41d4-a716-446655440011", # Limón
|
||||
"660e8400-e29b-41d4-a716-446655440012", # Canela
|
||||
]
|
||||
|
||||
async def add_sample_recipes():
|
||||
"""Add sample recipes to the database"""
|
||||
async with get_db_session() as session:
|
||||
recipe_repo = RecipeRepository(session)
|
||||
|
||||
sample_recipes = [
|
||||
{
|
||||
"name": "Pan de Molde Integral",
|
||||
"recipe_code": "PAN001",
|
||||
"finished_product_id": SAMPLE_PRODUCT_IDS[0],
|
||||
"description": "Pan integral artesanal con semillas, perfecto para desayunos saludables.",
|
||||
"category": "bread",
|
||||
"difficulty_level": 2,
|
||||
"yield_quantity": 1,
|
||||
"yield_unit": "units",
|
||||
"prep_time_minutes": 120,
|
||||
"cook_time_minutes": 35,
|
||||
"total_time_minutes": 155,
|
||||
"is_signature_item": False,
|
||||
"target_margin_percentage": Decimal("40.0"),
|
||||
"ingredients": [
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[0], "quantity": 500, "unit": "g", "is_optional": False, "ingredient_order": 1},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[1], "quantity": 300, "unit": "ml", "is_optional": False, "ingredient_order": 2},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[2], "quantity": 10, "unit": "g", "is_optional": False, "ingredient_order": 3},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[3], "quantity": 8, "unit": "g", "is_optional": False, "ingredient_order": 4},
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Croissants de Mantequilla",
|
||||
"recipe_code": "CRO001",
|
||||
"finished_product_id": SAMPLE_PRODUCT_IDS[1],
|
||||
"description": "Croissants franceses tradicionales con laminado de mantequilla.",
|
||||
"category": "pastry",
|
||||
"difficulty_level": 3,
|
||||
"yield_quantity": 12,
|
||||
"yield_unit": "units",
|
||||
"prep_time_minutes": 480,
|
||||
"cook_time_minutes": 20,
|
||||
"total_time_minutes": 500,
|
||||
"is_signature_item": True,
|
||||
"target_margin_percentage": Decimal("52.8"),
|
||||
"ingredients": [
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[4], "quantity": 500, "unit": "g", "is_optional": False, "ingredient_order": 1},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[5], "quantity": 250, "unit": "g", "is_optional": False, "ingredient_order": 2},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[6], "quantity": 150, "unit": "ml", "is_optional": False, "ingredient_order": 3},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[7], "quantity": 50, "unit": "g", "is_optional": False, "ingredient_order": 4},
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Tarta de Manzana",
|
||||
"recipe_code": "TAR001",
|
||||
"finished_product_id": SAMPLE_PRODUCT_IDS[2],
|
||||
"description": "Tarta casera de manzana con canela y masa quebrada.",
|
||||
"category": "cake",
|
||||
"difficulty_level": 1,
|
||||
"yield_quantity": 8,
|
||||
"yield_unit": "portions",
|
||||
"prep_time_minutes": 45,
|
||||
"cook_time_minutes": 40,
|
||||
"total_time_minutes": 85,
|
||||
"is_signature_item": False,
|
||||
"target_margin_percentage": Decimal("65.0"),
|
||||
"ingredients": [
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[8], "quantity": 1000, "unit": "g", "is_optional": False, "ingredient_order": 1},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[0], "quantity": 250, "unit": "g", "is_optional": False, "ingredient_order": 2},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[5], "quantity": 125, "unit": "g", "is_optional": False, "ingredient_order": 3},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[7], "quantity": 100, "unit": "g", "is_optional": False, "ingredient_order": 4},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[11], "quantity": 5, "unit": "g", "is_optional": True, "ingredient_order": 5},
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Magdalenas de Limón",
|
||||
"recipe_code": "MAG001",
|
||||
"finished_product_id": SAMPLE_PRODUCT_IDS[3],
|
||||
"description": "Magdalenas suaves y esponjosas con ralladura de limón.",
|
||||
"category": "pastry",
|
||||
"difficulty_level": 1,
|
||||
"yield_quantity": 12,
|
||||
"yield_unit": "units",
|
||||
"prep_time_minutes": 20,
|
||||
"cook_time_minutes": 25,
|
||||
"total_time_minutes": 45,
|
||||
"is_signature_item": False,
|
||||
"target_margin_percentage": Decimal("57.8"),
|
||||
"ingredients": [
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[0], "quantity": 200, "unit": "g", "is_optional": False, "ingredient_order": 1},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[9], "quantity": 3, "unit": "units", "is_optional": False, "ingredient_order": 2},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[7], "quantity": 150, "unit": "g", "is_optional": False, "ingredient_order": 3},
|
||||
{"ingredient_id": SAMPLE_INGREDIENT_IDS[10], "quantity": 2, "unit": "units", "is_optional": False, "ingredient_order": 4},
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
for recipe_data in sample_recipes:
|
||||
try:
|
||||
# Prepare ingredients
|
||||
ingredients = [
|
||||
RecipeIngredientCreate(**ing_data)
|
||||
for ing_data in recipe_data.pop("ingredients")
|
||||
]
|
||||
|
||||
# Create recipe
|
||||
recipe_create = RecipeCreate(
|
||||
**recipe_data,
|
||||
ingredients=ingredients
|
||||
)
|
||||
|
||||
# Check if recipe already exists
|
||||
existing_recipes = await recipe_repo.search_recipes(
|
||||
tenant_id=SAMPLE_TENANT_ID,
|
||||
search_term=recipe_data["name"]
|
||||
)
|
||||
|
||||
recipe_exists = any(
|
||||
recipe.name == recipe_data["name"]
|
||||
for recipe in existing_recipes
|
||||
)
|
||||
|
||||
if not recipe_exists:
|
||||
recipe = await recipe_repo.create_recipe(
|
||||
tenant_id=SAMPLE_TENANT_ID,
|
||||
recipe_data=recipe_create
|
||||
)
|
||||
print(f"✅ Created recipe: {recipe.name}")
|
||||
else:
|
||||
print(f"⏭️ Recipe already exists: {recipe_data['name']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating recipe {recipe_data['name']}: {e}")
|
||||
|
||||
await session.commit()
|
||||
print(f"\n🎉 Sample recipes setup completed!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🧁 Adding sample recipes to database...")
|
||||
print(f"📍 Tenant ID: {SAMPLE_TENANT_ID}")
|
||||
print("=" * 50)
|
||||
|
||||
asyncio.run(add_sample_recipes())
|
||||
@@ -1,117 +0,0 @@
|
||||
# services/recipes/app/api/ingredients.py
|
||||
"""
|
||||
API endpoints for ingredient-related operations (bridge to inventory service)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Query
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import logging
|
||||
|
||||
from ..services.inventory_client import InventoryClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(...)) -> UUID:
|
||||
"""Extract tenant ID from header"""
|
||||
try:
|
||||
return UUID(x_tenant_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid tenant ID format")
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_ingredients(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
search_term: Optional[str] = Query(None),
|
||||
product_type: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0)
|
||||
):
|
||||
"""Search ingredients from inventory service"""
|
||||
try:
|
||||
inventory_client = InventoryClient()
|
||||
|
||||
# This would call the inventory service search endpoint
|
||||
# For now, return a placeholder response
|
||||
return {
|
||||
"ingredients": [],
|
||||
"total": 0,
|
||||
"message": "Integration with inventory service needed"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching ingredients: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/{ingredient_id}")
|
||||
async def get_ingredient(
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id)
|
||||
):
|
||||
"""Get ingredient details from inventory service"""
|
||||
try:
|
||||
inventory_client = InventoryClient()
|
||||
ingredient = await inventory_client.get_ingredient_by_id(tenant_id, ingredient_id)
|
||||
|
||||
if not ingredient:
|
||||
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||
|
||||
return ingredient
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ingredient {ingredient_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/{ingredient_id}/stock")
|
||||
async def get_ingredient_stock(
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id)
|
||||
):
|
||||
"""Get ingredient stock level from inventory service"""
|
||||
try:
|
||||
inventory_client = InventoryClient()
|
||||
stock = await inventory_client.get_ingredient_stock_level(tenant_id, ingredient_id)
|
||||
|
||||
if not stock:
|
||||
raise HTTPException(status_code=404, detail="Stock information not found")
|
||||
|
||||
return stock
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stock for ingredient {ingredient_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/check-availability")
|
||||
async def check_ingredients_availability(
|
||||
required_ingredients: List[dict],
|
||||
tenant_id: UUID = Depends(get_tenant_id)
|
||||
):
|
||||
"""Check if required ingredients are available for production"""
|
||||
try:
|
||||
inventory_client = InventoryClient()
|
||||
result = await inventory_client.check_ingredient_availability(
|
||||
tenant_id,
|
||||
required_ingredients
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result["data"]
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking ingredient availability: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
@@ -1,427 +0,0 @@
|
||||
# services/recipes/app/api/production.py
|
||||
"""
|
||||
API endpoints for production management
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from datetime import date, datetime
|
||||
import logging
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..services.production_service import ProductionService
|
||||
from ..schemas.production import (
|
||||
ProductionBatchCreate,
|
||||
ProductionBatchUpdate,
|
||||
ProductionBatchResponse,
|
||||
ProductionBatchSearchRequest,
|
||||
ProductionScheduleCreate,
|
||||
ProductionScheduleUpdate,
|
||||
ProductionScheduleResponse,
|
||||
ProductionStatisticsResponse,
|
||||
StartProductionRequest,
|
||||
CompleteProductionRequest
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(...)) -> UUID:
|
||||
"""Extract tenant ID from header"""
|
||||
try:
|
||||
return UUID(x_tenant_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid tenant ID format")
|
||||
|
||||
|
||||
def get_user_id(x_user_id: str = Header(...)) -> UUID:
|
||||
"""Extract user ID from header"""
|
||||
try:
|
||||
return UUID(x_user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
||||
|
||||
|
||||
# Production Batch Endpoints
|
||||
|
||||
@router.post("/batches", response_model=ProductionBatchResponse)
|
||||
async def create_production_batch(
|
||||
batch_data: ProductionBatchCreate,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new production batch"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
batch_dict = batch_data.dict()
|
||||
batch_dict["tenant_id"] = tenant_id
|
||||
|
||||
result = await production_service.create_production_batch(batch_dict, user_id)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return ProductionBatchResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating production batch: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}", response_model=ProductionBatchResponse)
|
||||
async def get_production_batch(
|
||||
batch_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get production batch by ID with consumptions"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
batch = production_service.get_production_batch_with_consumptions(batch_id)
|
||||
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||
|
||||
# Verify tenant ownership
|
||||
if batch["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return ProductionBatchResponse(**batch)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting production batch {batch_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.put("/batches/{batch_id}", response_model=ProductionBatchResponse)
|
||||
async def update_production_batch(
|
||||
batch_id: UUID,
|
||||
batch_data: ProductionBatchUpdate,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update an existing production batch"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
# Check if batch exists and belongs to tenant
|
||||
existing_batch = production_service.get_production_batch_with_consumptions(batch_id)
|
||||
if not existing_batch:
|
||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||
|
||||
if existing_batch["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
batch_dict = batch_data.dict(exclude_unset=True)
|
||||
|
||||
result = await production_service.update_production_batch(batch_id, batch_dict, user_id)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return ProductionBatchResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating production batch {batch_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/batches/{batch_id}")
|
||||
async def delete_production_batch(
|
||||
batch_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a production batch"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
# Check if batch exists and belongs to tenant
|
||||
existing_batch = production_service.get_production_batch_with_consumptions(batch_id)
|
||||
if not existing_batch:
|
||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||
|
||||
if existing_batch["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
success = production_service.production_repo.delete(batch_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||
|
||||
return {"message": "Production batch deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting production batch {batch_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/batches", response_model=List[ProductionBatchResponse])
|
||||
async def search_production_batches(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
search_term: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
priority: Optional[str] = Query(None),
|
||||
start_date: Optional[date] = Query(None),
|
||||
end_date: Optional[date] = Query(None),
|
||||
recipe_id: Optional[UUID] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Search production batches with filters"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
batches = production_service.search_production_batches(
|
||||
tenant_id=tenant_id,
|
||||
search_term=search_term,
|
||||
status=status,
|
||||
priority=priority,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
recipe_id=recipe_id,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return [ProductionBatchResponse(**batch) for batch in batches]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching production batches: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/batches/{batch_id}/start", response_model=ProductionBatchResponse)
|
||||
async def start_production_batch(
|
||||
batch_id: UUID,
|
||||
start_data: StartProductionRequest,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Start production batch and record ingredient consumptions"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
# Check if batch exists and belongs to tenant
|
||||
existing_batch = production_service.get_production_batch_with_consumptions(batch_id)
|
||||
if not existing_batch:
|
||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||
|
||||
if existing_batch["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
consumptions_list = [cons.dict() for cons in start_data.ingredient_consumptions]
|
||||
|
||||
result = await production_service.start_production_batch(
|
||||
batch_id,
|
||||
consumptions_list,
|
||||
start_data.staff_member or user_id,
|
||||
start_data.production_notes
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return ProductionBatchResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting production batch {batch_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/batches/{batch_id}/complete", response_model=ProductionBatchResponse)
|
||||
async def complete_production_batch(
|
||||
batch_id: UUID,
|
||||
complete_data: CompleteProductionRequest,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Complete production batch and add finished products to inventory"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
# Check if batch exists and belongs to tenant
|
||||
existing_batch = production_service.get_production_batch_with_consumptions(batch_id)
|
||||
if not existing_batch:
|
||||
raise HTTPException(status_code=404, detail="Production batch not found")
|
||||
|
||||
if existing_batch["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
completion_data = complete_data.dict()
|
||||
|
||||
result = await production_service.complete_production_batch(
|
||||
batch_id,
|
||||
completion_data,
|
||||
complete_data.staff_member or user_id
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return ProductionBatchResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error completing production batch {batch_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/batches/active/list", response_model=List[ProductionBatchResponse])
|
||||
async def get_active_production_batches(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all active production batches"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
batches = production_service.get_active_production_batches(tenant_id)
|
||||
|
||||
return [ProductionBatchResponse(**batch) for batch in batches]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting active production batches: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/statistics/dashboard", response_model=ProductionStatisticsResponse)
|
||||
async def get_production_statistics(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
start_date: Optional[date] = Query(None),
|
||||
end_date: Optional[date] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get production statistics for dashboard"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
stats = production_service.get_production_statistics(tenant_id, start_date, end_date)
|
||||
|
||||
return ProductionStatisticsResponse(**stats)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting production statistics: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# Production Schedule Endpoints
|
||||
|
||||
@router.post("/schedules", response_model=ProductionScheduleResponse)
|
||||
async def create_production_schedule(
|
||||
schedule_data: ProductionScheduleCreate,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new production schedule"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
schedule_dict = schedule_data.dict()
|
||||
schedule_dict["tenant_id"] = tenant_id
|
||||
schedule_dict["created_by"] = user_id
|
||||
|
||||
result = production_service.create_production_schedule(schedule_dict)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return ProductionScheduleResponse(**result["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating production schedule: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/schedules/{schedule_id}", response_model=ProductionScheduleResponse)
|
||||
async def get_production_schedule(
|
||||
schedule_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get production schedule by ID"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
schedule = production_service.get_production_schedule(schedule_id)
|
||||
|
||||
if not schedule:
|
||||
raise HTTPException(status_code=404, detail="Production schedule not found")
|
||||
|
||||
# Verify tenant ownership
|
||||
if schedule["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return ProductionScheduleResponse(**schedule)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting production schedule {schedule_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/schedules/date/{schedule_date}", response_model=ProductionScheduleResponse)
|
||||
async def get_production_schedule_by_date(
|
||||
schedule_date: date,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get production schedule for specific date"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
schedule = production_service.get_production_schedule_by_date(tenant_id, schedule_date)
|
||||
|
||||
if not schedule:
|
||||
raise HTTPException(status_code=404, detail="Production schedule not found for this date")
|
||||
|
||||
return ProductionScheduleResponse(**schedule)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting production schedule for {schedule_date}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/schedules", response_model=List[ProductionScheduleResponse])
|
||||
async def get_production_schedules(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
start_date: Optional[date] = Query(None),
|
||||
end_date: Optional[date] = Query(None),
|
||||
published_only: bool = Query(False),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get production schedules within date range"""
|
||||
try:
|
||||
production_service = ProductionService(db)
|
||||
|
||||
if published_only:
|
||||
schedules = production_service.get_published_schedules(tenant_id, start_date, end_date)
|
||||
else:
|
||||
schedules = production_service.get_production_schedules_range(tenant_id, start_date, end_date)
|
||||
|
||||
return [ProductionScheduleResponse(**schedule) for schedule in schedules]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting production schedules: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
@@ -4,7 +4,7 @@ API endpoints for recipe management
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import logging
|
||||
@@ -25,12 +25,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(...)) -> UUID:
|
||||
"""Extract tenant ID from header"""
|
||||
try:
|
||||
return UUID(x_tenant_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid tenant ID format")
|
||||
|
||||
|
||||
def get_user_id(x_user_id: str = Header(...)) -> UUID:
|
||||
@@ -41,12 +35,12 @@ def get_user_id(x_user_id: str = Header(...)) -> UUID:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
||||
|
||||
|
||||
@router.post("/", response_model=RecipeResponse)
|
||||
@router.post("/{tenant_id}/recipes", response_model=RecipeResponse)
|
||||
async def create_recipe(
|
||||
tenant_id: UUID,
|
||||
recipe_data: RecipeCreate,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new recipe"""
|
||||
try:
|
||||
@@ -76,16 +70,16 @@ async def create_recipe(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/{recipe_id}", response_model=RecipeResponse)
|
||||
@router.get("/{tenant_id}/recipes/{recipe_id}", response_model=RecipeResponse)
|
||||
async def get_recipe(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get recipe by ID with ingredients"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
|
||||
if not recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
@@ -103,20 +97,20 @@ async def get_recipe(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.put("/{recipe_id}", response_model=RecipeResponse)
|
||||
@router.put("/{tenant_id}/recipes/{recipe_id}", response_model=RecipeResponse)
|
||||
async def update_recipe(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
recipe_data: RecipeUpdate,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update an existing recipe"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Check if recipe exists and belongs to tenant
|
||||
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
if not existing_recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
@@ -149,26 +143,26 @@ async def update_recipe(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/{recipe_id}")
|
||||
@router.delete("/{tenant_id}/recipes/{recipe_id}")
|
||||
async def delete_recipe(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete a recipe"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Check if recipe exists and belongs to tenant
|
||||
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
if not existing_recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
if existing_recipe["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Use repository to delete
|
||||
success = recipe_service.recipe_repo.delete(recipe_id)
|
||||
# Use service to delete
|
||||
success = await recipe_service.delete_recipe(recipe_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
@@ -181,9 +175,9 @@ async def delete_recipe(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/", response_model=List[RecipeResponse])
|
||||
@router.get("/{tenant_id}/recipes", response_model=List[RecipeResponse])
|
||||
async def search_recipes(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None),
|
||||
@@ -192,13 +186,13 @@ async def search_recipes(
|
||||
difficulty_level: Optional[int] = Query(None, ge=1, le=5),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Search recipes with filters"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
recipes = recipe_service.search_recipes(
|
||||
recipes = await recipe_service.search_recipes(
|
||||
tenant_id=tenant_id,
|
||||
search_term=search_term,
|
||||
status=status,
|
||||
@@ -217,13 +211,13 @@ async def search_recipes(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/{recipe_id}/duplicate", response_model=RecipeResponse)
|
||||
@router.post("/{tenant_id}/recipes/{recipe_id}/duplicate", response_model=RecipeResponse)
|
||||
async def duplicate_recipe(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
duplicate_data: RecipeDuplicateRequest,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a duplicate of an existing recipe"""
|
||||
try:
|
||||
@@ -255,19 +249,19 @@ async def duplicate_recipe(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/{recipe_id}/activate", response_model=RecipeResponse)
|
||||
@router.post("/{tenant_id}/recipes/{recipe_id}/activate", response_model=RecipeResponse)
|
||||
async def activate_recipe(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: Session = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Activate a recipe for production"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Check if recipe exists and belongs to tenant
|
||||
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
if not existing_recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
@@ -288,19 +282,19 @@ async def activate_recipe(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/{recipe_id}/feasibility", response_model=RecipeFeasibilityResponse)
|
||||
@router.get("/{tenant_id}/recipes/{recipe_id}/feasibility", response_model=RecipeFeasibilityResponse)
|
||||
async def check_recipe_feasibility(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
batch_multiplier: float = Query(1.0, gt=0),
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Check if recipe can be produced with current inventory"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Check if recipe exists and belongs to tenant
|
||||
existing_recipe = recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
if not existing_recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
@@ -321,15 +315,15 @@ async def check_recipe_feasibility(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/statistics/dashboard", response_model=RecipeStatisticsResponse)
|
||||
@router.get("/{tenant_id}/recipes/statistics/dashboard", response_model=RecipeStatisticsResponse)
|
||||
async def get_recipe_statistics(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get recipe statistics for dashboard"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
stats = recipe_service.get_recipe_statistics(tenant_id)
|
||||
stats = await recipe_service.get_recipe_statistics(tenant_id)
|
||||
|
||||
return RecipeStatisticsResponse(**stats)
|
||||
|
||||
@@ -338,17 +332,17 @@ async def get_recipe_statistics(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/categories/list")
|
||||
@router.get("/{tenant_id}/recipes/categories/list")
|
||||
async def get_recipe_categories(
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get list of recipe categories used by tenant"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Get categories from existing recipes
|
||||
recipes = recipe_service.search_recipes(tenant_id, limit=1000)
|
||||
recipes = await recipe_service.search_recipes(tenant_id, limit=1000)
|
||||
categories = list(set(recipe["category"] for recipe in recipes if recipe["category"]))
|
||||
categories.sort()
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Settings:
|
||||
# API settings
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
# Database
|
||||
# Override DATABASE_URL for recipes service
|
||||
DATABASE_URL: str = os.getenv(
|
||||
"RECIPES_DATABASE_URL",
|
||||
"postgresql://recipes_user:recipes_pass@localhost:5432/recipes_db"
|
||||
@@ -27,6 +27,7 @@ class Settings:
|
||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
|
||||
# External service URLs
|
||||
GATEWAY_URL: str = os.getenv("GATEWAY_URL", "http://gateway:8000")
|
||||
INVENTORY_SERVICE_URL: str = os.getenv(
|
||||
"INVENTORY_SERVICE_URL",
|
||||
"http://inventory:8000"
|
||||
@@ -38,6 +39,7 @@ class Settings:
|
||||
|
||||
# Authentication
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-here")
|
||||
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "your-super-secret-jwt-key-change-in-production-min-32-characters-long")
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
||||
|
||||
# Logging
|
||||
|
||||
@@ -18,3 +18,8 @@ async def get_db():
|
||||
"""FastAPI dependency to get database session"""
|
||||
async for session in db_manager.get_db():
|
||||
yield session
|
||||
|
||||
# Initialize database
|
||||
async def init_database():
|
||||
"""Initialize database tables"""
|
||||
await db_manager.create_tables()
|
||||
@@ -14,7 +14,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from .core.config import settings
|
||||
from .core.database import db_manager
|
||||
from .api import recipes, production, ingredients
|
||||
from .api import recipes
|
||||
# Import models to register them with SQLAlchemy metadata
|
||||
from .models import recipes as recipe_models
|
||||
|
||||
@@ -121,24 +121,14 @@ async def health_check():
|
||||
)
|
||||
|
||||
|
||||
# Include API routers
|
||||
# Include API routers with tenant-scoped paths
|
||||
app.include_router(
|
||||
recipes.router,
|
||||
prefix=f"{settings.API_V1_PREFIX}/recipes",
|
||||
prefix=f"{settings.API_V1_PREFIX}/tenants",
|
||||
tags=["recipes"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
production.router,
|
||||
prefix=f"{settings.API_V1_PREFIX}/production",
|
||||
tags=["production"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
ingredients.router,
|
||||
prefix=f"{settings.API_V1_PREFIX}/ingredients",
|
||||
tags=["ingredients"]
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
# services/recipes/app/repositories/__init__.py
|
||||
|
||||
from .base import BaseRepository
|
||||
from .recipe_repository import RecipeRepository
|
||||
from .production_repository import ProductionRepository
|
||||
|
||||
__all__ = [
|
||||
"BaseRepository",
|
||||
"RecipeRepository",
|
||||
"ProductionRepository"
|
||||
"RecipeRepository"
|
||||
]
|
||||
@@ -1,96 +0,0 @@
|
||||
# services/recipes/app/repositories/base.py
|
||||
"""
|
||||
Base repository class for common database operations
|
||||
"""
|
||||
|
||||
from typing import TypeVar, Generic, List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, asc
|
||||
from uuid import UUID
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class BaseRepository(Generic[T]):
|
||||
"""Base repository with common CRUD operations"""
|
||||
|
||||
def __init__(self, model: type, db: Session):
|
||||
self.model = model
|
||||
self.db = db
|
||||
|
||||
def create(self, obj_data: Dict[str, Any]) -> T:
|
||||
"""Create a new record"""
|
||||
db_obj = self.model(**obj_data)
|
||||
self.db.add(db_obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def get_by_id(self, record_id: UUID) -> Optional[T]:
|
||||
"""Get record by ID"""
|
||||
return self.db.query(self.model).filter(self.model.id == record_id).first()
|
||||
|
||||
def get_by_tenant_id(self, tenant_id: UUID, limit: int = 100, offset: int = 0) -> List[T]:
|
||||
"""Get records by tenant ID with pagination"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(self.model.tenant_id == tenant_id)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
|
||||
def update(self, record_id: UUID, update_data: Dict[str, Any]) -> Optional[T]:
|
||||
"""Update record by ID"""
|
||||
db_obj = self.get_by_id(record_id)
|
||||
if db_obj:
|
||||
for key, value in update_data.items():
|
||||
if hasattr(db_obj, key):
|
||||
setattr(db_obj, key, value)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def delete(self, record_id: UUID) -> bool:
|
||||
"""Delete record by ID"""
|
||||
db_obj = self.get_by_id(record_id)
|
||||
if db_obj:
|
||||
self.db.delete(db_obj)
|
||||
self.db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def count_by_tenant(self, tenant_id: UUID) -> int:
|
||||
"""Count records by tenant"""
|
||||
return self.db.query(self.model).filter(self.model.tenant_id == tenant_id).count()
|
||||
|
||||
def list_with_filters(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
sort_by: str = "created_at",
|
||||
sort_order: str = "desc",
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[T]:
|
||||
"""List records with filtering and sorting"""
|
||||
query = self.db.query(self.model).filter(self.model.tenant_id == tenant_id)
|
||||
|
||||
# Apply filters
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
if hasattr(self.model, key) and value is not None:
|
||||
query = query.filter(getattr(self.model, key) == value)
|
||||
|
||||
# Apply sorting
|
||||
if hasattr(self.model, sort_by):
|
||||
if sort_order.lower() == "desc":
|
||||
query = query.order_by(desc(getattr(self.model, sort_by)))
|
||||
else:
|
||||
query = query.order_by(asc(getattr(self.model, sort_by)))
|
||||
|
||||
return query.limit(limit).offset(offset).all()
|
||||
|
||||
def exists(self, record_id: UUID) -> bool:
|
||||
"""Check if record exists"""
|
||||
return self.db.query(self.model).filter(self.model.id == record_id).first() is not None
|
||||
@@ -1,382 +0,0 @@
|
||||
# services/recipes/app/repositories/production_repository.py
|
||||
"""
|
||||
Repository for production-related database operations
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_, func, desc, asc
|
||||
from uuid import UUID
|
||||
from datetime import datetime, date, timedelta
|
||||
|
||||
from .base import BaseRepository
|
||||
from ..models.recipes import (
|
||||
ProductionBatch,
|
||||
ProductionIngredientConsumption,
|
||||
ProductionSchedule,
|
||||
ProductionStatus,
|
||||
ProductionPriority
|
||||
)
|
||||
|
||||
|
||||
class ProductionRepository(BaseRepository[ProductionBatch]):
|
||||
"""Repository for production batch operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(ProductionBatch, db)
|
||||
|
||||
def get_by_id_with_consumptions(self, batch_id: UUID) -> Optional[ProductionBatch]:
|
||||
"""Get production batch with ingredient consumptions loaded"""
|
||||
return (
|
||||
self.db.query(ProductionBatch)
|
||||
.options(joinedload(ProductionBatch.ingredient_consumptions))
|
||||
.filter(ProductionBatch.id == batch_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_batches_by_date_range(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
status: Optional[ProductionStatus] = None
|
||||
) -> List[ProductionBatch]:
|
||||
"""Get production batches within date range"""
|
||||
query = (
|
||||
self.db.query(ProductionBatch)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionBatch.tenant_id == tenant_id,
|
||||
ProductionBatch.production_date >= start_date,
|
||||
ProductionBatch.production_date <= end_date
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.filter(ProductionBatch.status == status)
|
||||
|
||||
return query.order_by(ProductionBatch.production_date, ProductionBatch.planned_start_time).all()
|
||||
|
||||
def get_active_batches(self, tenant_id: UUID) -> List[ProductionBatch]:
|
||||
"""Get all active production batches"""
|
||||
return (
|
||||
self.db.query(ProductionBatch)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionBatch.tenant_id == tenant_id,
|
||||
ProductionBatch.status.in_([
|
||||
ProductionStatus.PLANNED,
|
||||
ProductionStatus.IN_PROGRESS
|
||||
])
|
||||
)
|
||||
)
|
||||
.order_by(ProductionBatch.planned_start_time)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_batches_by_recipe(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
limit: int = 50
|
||||
) -> List[ProductionBatch]:
|
||||
"""Get recent production batches for a specific recipe"""
|
||||
return (
|
||||
self.db.query(ProductionBatch)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionBatch.tenant_id == tenant_id,
|
||||
ProductionBatch.recipe_id == recipe_id
|
||||
)
|
||||
)
|
||||
.order_by(desc(ProductionBatch.production_date))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def search_batches(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = None,
|
||||
status: Optional[ProductionStatus] = None,
|
||||
priority: Optional[ProductionPriority] = None,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
recipe_id: Optional[UUID] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ProductionBatch]:
|
||||
"""Search production batches with filters"""
|
||||
query = self.db.query(ProductionBatch).filter(ProductionBatch.tenant_id == tenant_id)
|
||||
|
||||
# Text search
|
||||
if search_term:
|
||||
query = query.filter(ProductionBatch.batch_number.ilike(f"%{search_term}%"))
|
||||
|
||||
# Status filter
|
||||
if status:
|
||||
query = query.filter(ProductionBatch.status == status)
|
||||
|
||||
# Priority filter
|
||||
if priority:
|
||||
query = query.filter(ProductionBatch.priority == priority)
|
||||
|
||||
# Date range filter
|
||||
if start_date:
|
||||
query = query.filter(ProductionBatch.production_date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ProductionBatch.production_date <= end_date)
|
||||
|
||||
# Recipe filter
|
||||
if recipe_id:
|
||||
query = query.filter(ProductionBatch.recipe_id == recipe_id)
|
||||
|
||||
return (
|
||||
query.order_by(desc(ProductionBatch.production_date))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_production_statistics(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Get production statistics for dashboard"""
|
||||
query = self.db.query(ProductionBatch).filter(ProductionBatch.tenant_id == tenant_id)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ProductionBatch.production_date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ProductionBatch.production_date <= end_date)
|
||||
|
||||
# Total batches
|
||||
total_batches = query.count()
|
||||
|
||||
# Completed batches
|
||||
completed_batches = query.filter(ProductionBatch.status == ProductionStatus.COMPLETED).count()
|
||||
|
||||
# Failed batches
|
||||
failed_batches = query.filter(ProductionBatch.status == ProductionStatus.FAILED).count()
|
||||
|
||||
# Average yield
|
||||
avg_yield = (
|
||||
self.db.query(func.avg(ProductionBatch.yield_percentage))
|
||||
.filter(
|
||||
and_(
|
||||
ProductionBatch.tenant_id == tenant_id,
|
||||
ProductionBatch.status == ProductionStatus.COMPLETED,
|
||||
ProductionBatch.yield_percentage.isnot(None)
|
||||
)
|
||||
)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
# Average quality score
|
||||
avg_quality = (
|
||||
self.db.query(func.avg(ProductionBatch.quality_score))
|
||||
.filter(
|
||||
and_(
|
||||
ProductionBatch.tenant_id == tenant_id,
|
||||
ProductionBatch.status == ProductionStatus.COMPLETED,
|
||||
ProductionBatch.quality_score.isnot(None)
|
||||
)
|
||||
)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
# Total production cost
|
||||
total_cost = (
|
||||
self.db.query(func.sum(ProductionBatch.total_production_cost))
|
||||
.filter(
|
||||
and_(
|
||||
ProductionBatch.tenant_id == tenant_id,
|
||||
ProductionBatch.status == ProductionStatus.COMPLETED,
|
||||
ProductionBatch.total_production_cost.isnot(None)
|
||||
)
|
||||
)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
# Status breakdown
|
||||
status_stats = (
|
||||
self.db.query(ProductionBatch.status, func.count(ProductionBatch.id))
|
||||
.filter(ProductionBatch.tenant_id == tenant_id)
|
||||
.group_by(ProductionBatch.status)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"total_batches": total_batches,
|
||||
"completed_batches": completed_batches,
|
||||
"failed_batches": failed_batches,
|
||||
"success_rate": (completed_batches / total_batches * 100) if total_batches > 0 else 0,
|
||||
"average_yield_percentage": float(avg_yield) if avg_yield else 0,
|
||||
"average_quality_score": float(avg_quality) if avg_quality else 0,
|
||||
"total_production_cost": float(total_cost) if total_cost else 0,
|
||||
"status_breakdown": [
|
||||
{"status": status.value, "count": count}
|
||||
for status, count in status_stats
|
||||
]
|
||||
}
|
||||
|
||||
def update_batch_status(
|
||||
self,
|
||||
batch_id: UUID,
|
||||
status: ProductionStatus,
|
||||
completed_by: Optional[UUID] = None,
|
||||
notes: Optional[str] = None
|
||||
) -> Optional[ProductionBatch]:
|
||||
"""Update production batch status"""
|
||||
batch = self.get_by_id(batch_id)
|
||||
if batch:
|
||||
batch.status = status
|
||||
if status == ProductionStatus.COMPLETED and completed_by:
|
||||
batch.completed_by = completed_by
|
||||
batch.actual_end_time = datetime.utcnow()
|
||||
if status == ProductionStatus.IN_PROGRESS and not batch.actual_start_time:
|
||||
batch.actual_start_time = datetime.utcnow()
|
||||
if notes:
|
||||
batch.production_notes = notes
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(batch)
|
||||
return batch
|
||||
|
||||
|
||||
class ProductionIngredientConsumptionRepository(BaseRepository[ProductionIngredientConsumption]):
|
||||
"""Repository for production ingredient consumption operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(ProductionIngredientConsumption, db)
|
||||
|
||||
def get_by_batch_id(self, batch_id: UUID) -> List[ProductionIngredientConsumption]:
|
||||
"""Get all ingredient consumptions for a production batch"""
|
||||
return (
|
||||
self.db.query(ProductionIngredientConsumption)
|
||||
.filter(ProductionIngredientConsumption.production_batch_id == batch_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_by_ingredient_id(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None
|
||||
) -> List[ProductionIngredientConsumption]:
|
||||
"""Get ingredient consumptions by ingredient ID"""
|
||||
query = (
|
||||
self.db.query(ProductionIngredientConsumption)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionIngredientConsumption.tenant_id == tenant_id,
|
||||
ProductionIngredientConsumption.ingredient_id == ingredient_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ProductionIngredientConsumption.consumption_time >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ProductionIngredientConsumption.consumption_time <= end_date)
|
||||
|
||||
return query.order_by(desc(ProductionIngredientConsumption.consumption_time)).all()
|
||||
|
||||
def calculate_ingredient_usage(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> Dict[str, Any]:
|
||||
"""Calculate ingredient usage statistics"""
|
||||
consumptions = self.get_by_ingredient_id(tenant_id, ingredient_id, start_date, end_date)
|
||||
|
||||
if not consumptions:
|
||||
return {
|
||||
"total_consumed": 0,
|
||||
"average_per_batch": 0,
|
||||
"total_cost": 0,
|
||||
"variance_percentage": 0,
|
||||
"consumption_count": 0
|
||||
}
|
||||
|
||||
total_consumed = sum(c.actual_quantity for c in consumptions)
|
||||
total_planned = sum(c.planned_quantity for c in consumptions)
|
||||
total_cost = sum((c.total_cost or 0) for c in consumptions)
|
||||
|
||||
variance_percentage = 0
|
||||
if total_planned > 0:
|
||||
variance_percentage = ((total_consumed - total_planned) / total_planned) * 100
|
||||
|
||||
return {
|
||||
"total_consumed": total_consumed,
|
||||
"total_planned": total_planned,
|
||||
"average_per_batch": total_consumed / len(consumptions),
|
||||
"total_cost": float(total_cost),
|
||||
"variance_percentage": variance_percentage,
|
||||
"consumption_count": len(consumptions)
|
||||
}
|
||||
|
||||
|
||||
class ProductionScheduleRepository(BaseRepository[ProductionSchedule]):
|
||||
"""Repository for production schedule operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(ProductionSchedule, db)
|
||||
|
||||
def get_by_date(self, tenant_id: UUID, schedule_date: date) -> Optional[ProductionSchedule]:
|
||||
"""Get production schedule for specific date"""
|
||||
return (
|
||||
self.db.query(ProductionSchedule)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionSchedule.tenant_id == tenant_id,
|
||||
ProductionSchedule.schedule_date == schedule_date
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_published_schedules(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> List[ProductionSchedule]:
|
||||
"""Get published schedules within date range"""
|
||||
return (
|
||||
self.db.query(ProductionSchedule)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionSchedule.tenant_id == tenant_id,
|
||||
ProductionSchedule.is_published == True,
|
||||
ProductionSchedule.schedule_date >= start_date,
|
||||
ProductionSchedule.schedule_date <= end_date
|
||||
)
|
||||
)
|
||||
.order_by(ProductionSchedule.schedule_date)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_upcoming_schedules(self, tenant_id: UUID, days_ahead: int = 7) -> List[ProductionSchedule]:
|
||||
"""Get upcoming production schedules"""
|
||||
start_date = date.today()
|
||||
end_date = date.today() + timedelta(days=days_ahead)
|
||||
|
||||
return (
|
||||
self.db.query(ProductionSchedule)
|
||||
.filter(
|
||||
and_(
|
||||
ProductionSchedule.tenant_id == tenant_id,
|
||||
ProductionSchedule.schedule_date >= start_date,
|
||||
ProductionSchedule.schedule_date <= end_date
|
||||
)
|
||||
)
|
||||
.order_by(ProductionSchedule.schedule_date)
|
||||
.all()
|
||||
)
|
||||
@@ -1,343 +1,245 @@
|
||||
# services/recipes/app/repositories/recipe_repository.py
|
||||
"""
|
||||
Repository for recipe-related database operations
|
||||
Async recipe repository for database operations
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_, or_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
import structlog
|
||||
|
||||
from .base import BaseRepository
|
||||
from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus
|
||||
from shared.database.repository import BaseRepository
|
||||
from ..models.recipes import Recipe, RecipeIngredient
|
||||
from ..schemas.recipes import RecipeCreate, RecipeUpdate
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class RecipeRepository(BaseRepository[Recipe]):
|
||||
"""Repository for recipe operations"""
|
||||
class RecipeRepository(BaseRepository[Recipe, RecipeCreate, RecipeUpdate]):
|
||||
"""Async repository for recipe operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(Recipe, db)
|
||||
def __init__(self, session: AsyncSession):
|
||||
super().__init__(Recipe, session)
|
||||
|
||||
def get_by_id_with_ingredients(self, recipe_id: UUID) -> Optional[Recipe]:
|
||||
async def get_recipe_with_ingredients(self, recipe_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get recipe with ingredients loaded"""
|
||||
return (
|
||||
self.db.query(Recipe)
|
||||
.options(joinedload(Recipe.ingredients))
|
||||
.filter(Recipe.id == recipe_id)
|
||||
.first()
|
||||
result = await self.session.execute(
|
||||
select(Recipe)
|
||||
.options(selectinload(Recipe.ingredients))
|
||||
.where(Recipe.id == recipe_id)
|
||||
)
|
||||
recipe = result.scalar_one_or_none()
|
||||
|
||||
def get_by_finished_product_id(self, tenant_id: UUID, finished_product_id: UUID) -> Optional[Recipe]:
|
||||
"""Get recipe by finished product ID"""
|
||||
return (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.finished_product_id == finished_product_id,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not recipe:
|
||||
return None
|
||||
|
||||
def search_recipes(
|
||||
return {
|
||||
"id": str(recipe.id),
|
||||
"tenant_id": str(recipe.tenant_id),
|
||||
"name": recipe.name,
|
||||
"recipe_code": recipe.recipe_code,
|
||||
"version": recipe.version,
|
||||
"finished_product_id": str(recipe.finished_product_id),
|
||||
"description": recipe.description,
|
||||
"category": recipe.category,
|
||||
"cuisine_type": recipe.cuisine_type,
|
||||
"difficulty_level": recipe.difficulty_level,
|
||||
"yield_quantity": recipe.yield_quantity,
|
||||
"yield_unit": recipe.yield_unit.value if recipe.yield_unit else None,
|
||||
"prep_time_minutes": recipe.prep_time_minutes,
|
||||
"cook_time_minutes": recipe.cook_time_minutes,
|
||||
"total_time_minutes": recipe.total_time_minutes,
|
||||
"rest_time_minutes": recipe.rest_time_minutes,
|
||||
"estimated_cost_per_unit": float(recipe.estimated_cost_per_unit) if recipe.estimated_cost_per_unit else None,
|
||||
"last_calculated_cost": float(recipe.last_calculated_cost) if recipe.last_calculated_cost else None,
|
||||
"cost_calculation_date": recipe.cost_calculation_date.isoformat() if recipe.cost_calculation_date else None,
|
||||
"target_margin_percentage": recipe.target_margin_percentage,
|
||||
"suggested_selling_price": float(recipe.suggested_selling_price) if recipe.suggested_selling_price else None,
|
||||
"instructions": recipe.instructions,
|
||||
"preparation_notes": recipe.preparation_notes,
|
||||
"storage_instructions": recipe.storage_instructions,
|
||||
"quality_standards": recipe.quality_standards,
|
||||
"serves_count": recipe.serves_count,
|
||||
"nutritional_info": recipe.nutritional_info,
|
||||
"allergen_info": recipe.allergen_info,
|
||||
"dietary_tags": recipe.dietary_tags,
|
||||
"batch_size_multiplier": recipe.batch_size_multiplier,
|
||||
"minimum_batch_size": recipe.minimum_batch_size,
|
||||
"maximum_batch_size": recipe.maximum_batch_size,
|
||||
"status": recipe.status,
|
||||
"is_seasonal": recipe.is_seasonal,
|
||||
"season_start_month": recipe.season_start_month,
|
||||
"season_end_month": recipe.season_end_month,
|
||||
"is_signature_item": recipe.is_signature_item,
|
||||
"created_at": recipe.created_at.isoformat() if recipe.created_at else None,
|
||||
"updated_at": recipe.updated_at.isoformat() if recipe.updated_at else None,
|
||||
"ingredients": [
|
||||
{
|
||||
"id": str(ingredient.id),
|
||||
"ingredient_id": str(ingredient.ingredient_id),
|
||||
"quantity": float(ingredient.quantity),
|
||||
"unit": ingredient.unit,
|
||||
"preparation_method": ingredient.preparation_method,
|
||||
"notes": ingredient.notes
|
||||
}
|
||||
for ingredient in recipe.ingredients
|
||||
] if hasattr(recipe, 'ingredients') else []
|
||||
}
|
||||
|
||||
async def search_recipes(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = None,
|
||||
status: Optional[RecipeStatus] = None,
|
||||
status: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
is_seasonal: Optional[bool] = None,
|
||||
is_signature: Optional[bool] = None,
|
||||
difficulty_level: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[Recipe]:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search recipes with multiple filters"""
|
||||
query = self.db.query(Recipe).filter(Recipe.tenant_id == tenant_id)
|
||||
query = select(Recipe).where(Recipe.tenant_id == tenant_id)
|
||||
|
||||
# Text search
|
||||
if search_term:
|
||||
search_filter = or_(
|
||||
Recipe.name.ilike(f"%{search_term}%"),
|
||||
Recipe.description.ilike(f"%{search_term}%"),
|
||||
Recipe.category.ilike(f"%{search_term}%")
|
||||
query = query.where(
|
||||
or_(
|
||||
Recipe.name.ilike(f"%{search_term}%"),
|
||||
Recipe.description.ilike(f"%{search_term}%")
|
||||
)
|
||||
)
|
||||
query = query.filter(search_filter)
|
||||
|
||||
# Status filter
|
||||
if status:
|
||||
query = query.filter(Recipe.status == status)
|
||||
query = query.where(Recipe.status == status)
|
||||
|
||||
# Category filter
|
||||
if category:
|
||||
query = query.filter(Recipe.category == category)
|
||||
query = query.where(Recipe.category == category)
|
||||
|
||||
# Seasonal filter
|
||||
if is_seasonal is not None:
|
||||
query = query.filter(Recipe.is_seasonal == is_seasonal)
|
||||
query = query.where(Recipe.is_seasonal == is_seasonal)
|
||||
|
||||
# Signature item filter
|
||||
# Signature filter
|
||||
if is_signature is not None:
|
||||
query = query.filter(Recipe.is_signature_item == is_signature)
|
||||
query = query.where(Recipe.is_signature_item == is_signature)
|
||||
|
||||
# Difficulty level filter
|
||||
# Difficulty filter
|
||||
if difficulty_level is not None:
|
||||
query = query.filter(Recipe.difficulty_level == difficulty_level)
|
||||
query = query.where(Recipe.difficulty_level == difficulty_level)
|
||||
|
||||
return query.order_by(Recipe.name).limit(limit).offset(offset).all()
|
||||
# Apply ordering and pagination
|
||||
query = query.order_by(Recipe.name).limit(limit).offset(offset)
|
||||
|
||||
def get_active_recipes(self, tenant_id: UUID) -> List[Recipe]:
|
||||
"""Get all active recipes for tenant"""
|
||||
return (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.order_by(Recipe.name)
|
||||
.all()
|
||||
)
|
||||
result = await self.session.execute(query)
|
||||
recipes = result.scalars().all()
|
||||
|
||||
def get_seasonal_recipes(self, tenant_id: UUID, current_month: int) -> List[Recipe]:
|
||||
"""Get seasonal recipes for current month"""
|
||||
return (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.status == RecipeStatus.ACTIVE,
|
||||
Recipe.is_seasonal == True,
|
||||
or_(
|
||||
and_(
|
||||
Recipe.season_start_month <= current_month,
|
||||
Recipe.season_end_month >= current_month
|
||||
),
|
||||
and_(
|
||||
Recipe.season_start_month > Recipe.season_end_month, # Crosses year boundary
|
||||
or_(
|
||||
Recipe.season_start_month <= current_month,
|
||||
Recipe.season_end_month >= current_month
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.order_by(Recipe.name)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": str(recipe.id),
|
||||
"tenant_id": str(recipe.tenant_id),
|
||||
"name": recipe.name,
|
||||
"recipe_code": recipe.recipe_code,
|
||||
"version": recipe.version,
|
||||
"finished_product_id": str(recipe.finished_product_id),
|
||||
"description": recipe.description,
|
||||
"category": recipe.category,
|
||||
"cuisine_type": recipe.cuisine_type,
|
||||
"difficulty_level": recipe.difficulty_level,
|
||||
"yield_quantity": recipe.yield_quantity,
|
||||
"yield_unit": recipe.yield_unit.value if recipe.yield_unit else None,
|
||||
"prep_time_minutes": recipe.prep_time_minutes,
|
||||
"cook_time_minutes": recipe.cook_time_minutes,
|
||||
"total_time_minutes": recipe.total_time_minutes,
|
||||
"rest_time_minutes": recipe.rest_time_minutes,
|
||||
"estimated_cost_per_unit": float(recipe.estimated_cost_per_unit) if recipe.estimated_cost_per_unit else None,
|
||||
"last_calculated_cost": float(recipe.last_calculated_cost) if recipe.last_calculated_cost else None,
|
||||
"cost_calculation_date": recipe.cost_calculation_date.isoformat() if recipe.cost_calculation_date else None,
|
||||
"target_margin_percentage": recipe.target_margin_percentage,
|
||||
"suggested_selling_price": float(recipe.suggested_selling_price) if recipe.suggested_selling_price else None,
|
||||
"instructions": recipe.instructions,
|
||||
"preparation_notes": recipe.preparation_notes,
|
||||
"storage_instructions": recipe.storage_instructions,
|
||||
"quality_standards": recipe.quality_standards,
|
||||
"serves_count": recipe.serves_count,
|
||||
"nutritional_info": recipe.nutritional_info,
|
||||
"allergen_info": recipe.allergen_info,
|
||||
"dietary_tags": recipe.dietary_tags,
|
||||
"batch_size_multiplier": recipe.batch_size_multiplier,
|
||||
"minimum_batch_size": recipe.minimum_batch_size,
|
||||
"maximum_batch_size": recipe.maximum_batch_size,
|
||||
"status": recipe.status,
|
||||
"is_seasonal": recipe.is_seasonal,
|
||||
"season_start_month": recipe.season_start_month,
|
||||
"season_end_month": recipe.season_end_month,
|
||||
"is_signature_item": recipe.is_signature_item,
|
||||
"created_at": recipe.created_at.isoformat() if recipe.created_at else None,
|
||||
"updated_at": recipe.updated_at.isoformat() if recipe.updated_at else None
|
||||
}
|
||||
for recipe in recipes
|
||||
]
|
||||
|
||||
def get_recipes_by_category(self, tenant_id: UUID, category: str) -> List[Recipe]:
|
||||
"""Get recipes by category"""
|
||||
return (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.category == category,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.order_by(Recipe.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_recipe_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
async def get_recipe_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get recipe statistics for dashboard"""
|
||||
total_recipes = self.db.query(Recipe).filter(Recipe.tenant_id == tenant_id).count()
|
||||
# Total recipes
|
||||
total_result = await self.session.execute(
|
||||
select(func.count(Recipe.id)).where(Recipe.tenant_id == tenant_id)
|
||||
)
|
||||
total_recipes = total_result.scalar() or 0
|
||||
|
||||
active_recipes = (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
# Active recipes
|
||||
active_result = await self.session.execute(
|
||||
select(func.count(Recipe.id)).where(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
Recipe.status == "active"
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
active_recipes = active_result.scalar() or 0
|
||||
|
||||
signature_recipes = (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
# Signature recipes
|
||||
signature_result = await self.session.execute(
|
||||
select(func.count(Recipe.id)).where(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.is_signature_item == True,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
Recipe.is_signature_item == True
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
signature_recipes = signature_result.scalar() or 0
|
||||
|
||||
seasonal_recipes = (
|
||||
self.db.query(Recipe)
|
||||
.filter(
|
||||
# Seasonal recipes
|
||||
seasonal_result = await self.session.execute(
|
||||
select(func.count(Recipe.id)).where(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.is_seasonal == True,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
Recipe.is_seasonal == True
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
seasonal_recipes = seasonal_result.scalar() or 0
|
||||
|
||||
# Category breakdown
|
||||
category_stats = (
|
||||
self.db.query(Recipe.category, func.count(Recipe.id))
|
||||
.filter(
|
||||
and_(
|
||||
Recipe.tenant_id == tenant_id,
|
||||
Recipe.status == RecipeStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
category_result = await self.session.execute(
|
||||
select(Recipe.category, func.count(Recipe.id))
|
||||
.where(Recipe.tenant_id == tenant_id)
|
||||
.group_by(Recipe.category)
|
||||
.all()
|
||||
)
|
||||
categories = dict(category_result.all())
|
||||
|
||||
return {
|
||||
"total_recipes": total_recipes,
|
||||
"active_recipes": active_recipes,
|
||||
"signature_recipes": signature_recipes,
|
||||
"seasonal_recipes": seasonal_recipes,
|
||||
"category_breakdown": [
|
||||
{"category": cat, "count": count}
|
||||
for cat, count in category_stats
|
||||
]
|
||||
"draft_recipes": total_recipes - active_recipes,
|
||||
"categories": categories,
|
||||
"average_difficulty": 3.0, # Could calculate from actual data
|
||||
"total_categories": len(categories)
|
||||
}
|
||||
|
||||
def duplicate_recipe(self, recipe_id: UUID, new_name: str, created_by: UUID) -> Optional[Recipe]:
|
||||
"""Create a duplicate of an existing recipe"""
|
||||
original = self.get_by_id_with_ingredients(recipe_id)
|
||||
if not original:
|
||||
return None
|
||||
|
||||
# Create new recipe
|
||||
recipe_data = {
|
||||
"tenant_id": original.tenant_id,
|
||||
"name": new_name,
|
||||
"recipe_code": f"{original.recipe_code}_copy" if original.recipe_code else None,
|
||||
"version": "1.0",
|
||||
"finished_product_id": original.finished_product_id,
|
||||
"description": original.description,
|
||||
"category": original.category,
|
||||
"cuisine_type": original.cuisine_type,
|
||||
"difficulty_level": original.difficulty_level,
|
||||
"yield_quantity": original.yield_quantity,
|
||||
"yield_unit": original.yield_unit,
|
||||
"prep_time_minutes": original.prep_time_minutes,
|
||||
"cook_time_minutes": original.cook_time_minutes,
|
||||
"total_time_minutes": original.total_time_minutes,
|
||||
"rest_time_minutes": original.rest_time_minutes,
|
||||
"instructions": original.instructions,
|
||||
"preparation_notes": original.preparation_notes,
|
||||
"storage_instructions": original.storage_instructions,
|
||||
"quality_standards": original.quality_standards,
|
||||
"serves_count": original.serves_count,
|
||||
"nutritional_info": original.nutritional_info,
|
||||
"allergen_info": original.allergen_info,
|
||||
"dietary_tags": original.dietary_tags,
|
||||
"batch_size_multiplier": original.batch_size_multiplier,
|
||||
"minimum_batch_size": original.minimum_batch_size,
|
||||
"maximum_batch_size": original.maximum_batch_size,
|
||||
"optimal_production_temperature": original.optimal_production_temperature,
|
||||
"optimal_humidity": original.optimal_humidity,
|
||||
"quality_check_points": original.quality_check_points,
|
||||
"common_issues": original.common_issues,
|
||||
"status": RecipeStatus.DRAFT,
|
||||
"is_seasonal": original.is_seasonal,
|
||||
"season_start_month": original.season_start_month,
|
||||
"season_end_month": original.season_end_month,
|
||||
"is_signature_item": False,
|
||||
"created_by": created_by
|
||||
}
|
||||
|
||||
new_recipe = self.create(recipe_data)
|
||||
|
||||
# Copy ingredients
|
||||
for ingredient in original.ingredients:
|
||||
ingredient_data = {
|
||||
"tenant_id": original.tenant_id,
|
||||
"recipe_id": new_recipe.id,
|
||||
"ingredient_id": ingredient.ingredient_id,
|
||||
"quantity": ingredient.quantity,
|
||||
"unit": ingredient.unit,
|
||||
"quantity_in_base_unit": ingredient.quantity_in_base_unit,
|
||||
"alternative_quantity": ingredient.alternative_quantity,
|
||||
"alternative_unit": ingredient.alternative_unit,
|
||||
"preparation_method": ingredient.preparation_method,
|
||||
"ingredient_notes": ingredient.ingredient_notes,
|
||||
"is_optional": ingredient.is_optional,
|
||||
"ingredient_order": ingredient.ingredient_order,
|
||||
"ingredient_group": ingredient.ingredient_group,
|
||||
"substitution_options": ingredient.substitution_options,
|
||||
"substitution_ratio": ingredient.substitution_ratio
|
||||
}
|
||||
|
||||
recipe_ingredient = RecipeIngredient(**ingredient_data)
|
||||
self.db.add(recipe_ingredient)
|
||||
|
||||
self.db.commit()
|
||||
return new_recipe
|
||||
|
||||
|
||||
class RecipeIngredientRepository(BaseRepository[RecipeIngredient]):
|
||||
"""Repository for recipe ingredient operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(RecipeIngredient, db)
|
||||
|
||||
def get_by_recipe_id(self, recipe_id: UUID) -> List[RecipeIngredient]:
|
||||
"""Get all ingredients for a recipe"""
|
||||
return (
|
||||
self.db.query(RecipeIngredient)
|
||||
.filter(RecipeIngredient.recipe_id == recipe_id)
|
||||
.order_by(RecipeIngredient.ingredient_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_by_ingredient_group(self, recipe_id: UUID, ingredient_group: str) -> List[RecipeIngredient]:
|
||||
"""Get ingredients by group within a recipe"""
|
||||
return (
|
||||
self.db.query(RecipeIngredient)
|
||||
.filter(
|
||||
and_(
|
||||
RecipeIngredient.recipe_id == recipe_id,
|
||||
RecipeIngredient.ingredient_group == ingredient_group
|
||||
)
|
||||
)
|
||||
.order_by(RecipeIngredient.ingredient_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
def update_ingredients_for_recipe(
|
||||
self,
|
||||
recipe_id: UUID,
|
||||
ingredients_data: List[Dict[str, Any]]
|
||||
) -> List[RecipeIngredient]:
|
||||
"""Update all ingredients for a recipe"""
|
||||
# Delete existing ingredients
|
||||
self.db.query(RecipeIngredient).filter(
|
||||
RecipeIngredient.recipe_id == recipe_id
|
||||
).delete()
|
||||
|
||||
# Create new ingredients
|
||||
new_ingredients = []
|
||||
for ingredient_data in ingredients_data:
|
||||
ingredient_data["recipe_id"] = recipe_id
|
||||
ingredient = RecipeIngredient(**ingredient_data)
|
||||
self.db.add(ingredient)
|
||||
new_ingredients.append(ingredient)
|
||||
|
||||
self.db.commit()
|
||||
return new_ingredients
|
||||
|
||||
def calculate_recipe_cost(self, recipe_id: UUID) -> float:
|
||||
"""Calculate total cost of recipe based on ingredient costs"""
|
||||
ingredients = self.get_by_recipe_id(recipe_id)
|
||||
total_cost = sum(
|
||||
(ingredient.total_cost or 0) for ingredient in ingredients
|
||||
)
|
||||
return total_cost
|
||||
@@ -7,16 +7,9 @@ from .recipes import (
|
||||
RecipeIngredientCreate,
|
||||
RecipeIngredientResponse,
|
||||
RecipeSearchRequest,
|
||||
RecipeFeasibilityResponse
|
||||
)
|
||||
from .production import (
|
||||
ProductionBatchCreate,
|
||||
ProductionBatchUpdate,
|
||||
ProductionBatchResponse,
|
||||
ProductionIngredientConsumptionCreate,
|
||||
ProductionIngredientConsumptionResponse,
|
||||
ProductionScheduleCreate,
|
||||
ProductionScheduleResponse
|
||||
RecipeFeasibilityResponse,
|
||||
RecipeDuplicateRequest,
|
||||
RecipeStatisticsResponse
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -27,11 +20,6 @@ __all__ = [
|
||||
"RecipeIngredientResponse",
|
||||
"RecipeSearchRequest",
|
||||
"RecipeFeasibilityResponse",
|
||||
"ProductionBatchCreate",
|
||||
"ProductionBatchUpdate",
|
||||
"ProductionBatchResponse",
|
||||
"ProductionIngredientConsumptionCreate",
|
||||
"ProductionIngredientConsumptionResponse",
|
||||
"ProductionScheduleCreate",
|
||||
"ProductionScheduleResponse"
|
||||
"RecipeDuplicateRequest",
|
||||
"RecipeStatisticsResponse"
|
||||
]
|
||||
@@ -1,257 +0,0 @@
|
||||
# services/recipes/app/schemas/production.py
|
||||
"""
|
||||
Pydantic schemas for production-related API requests and responses
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime, date
|
||||
from enum import Enum
|
||||
|
||||
from ..models.recipes import ProductionStatus, ProductionPriority, MeasurementUnit
|
||||
|
||||
|
||||
class ProductionIngredientConsumptionCreate(BaseModel):
|
||||
"""Schema for creating production ingredient consumption"""
|
||||
recipe_ingredient_id: UUID
|
||||
ingredient_id: UUID
|
||||
stock_id: Optional[UUID] = None
|
||||
planned_quantity: float = Field(..., gt=0)
|
||||
actual_quantity: float = Field(..., gt=0)
|
||||
unit: MeasurementUnit
|
||||
consumption_notes: Optional[str] = None
|
||||
staff_member: Optional[UUID] = None
|
||||
ingredient_condition: Optional[str] = None
|
||||
quality_impact: Optional[str] = None
|
||||
substitution_used: bool = False
|
||||
substitution_details: Optional[str] = None
|
||||
|
||||
|
||||
class ProductionIngredientConsumptionResponse(BaseModel):
|
||||
"""Schema for production ingredient consumption responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
production_batch_id: UUID
|
||||
recipe_ingredient_id: UUID
|
||||
ingredient_id: UUID
|
||||
stock_id: Optional[UUID] = None
|
||||
planned_quantity: float
|
||||
actual_quantity: float
|
||||
unit: str
|
||||
variance_quantity: Optional[float] = None
|
||||
variance_percentage: Optional[float] = None
|
||||
unit_cost: Optional[float] = None
|
||||
total_cost: Optional[float] = None
|
||||
consumption_time: datetime
|
||||
consumption_notes: Optional[str] = None
|
||||
staff_member: Optional[UUID] = None
|
||||
ingredient_condition: Optional[str] = None
|
||||
quality_impact: Optional[str] = None
|
||||
substitution_used: bool
|
||||
substitution_details: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductionBatchCreate(BaseModel):
|
||||
"""Schema for creating production batches"""
|
||||
recipe_id: UUID
|
||||
batch_number: str = Field(..., min_length=1, max_length=100)
|
||||
production_date: date
|
||||
planned_start_time: Optional[datetime] = None
|
||||
planned_end_time: Optional[datetime] = None
|
||||
planned_quantity: float = Field(..., gt=0)
|
||||
batch_size_multiplier: float = Field(default=1.0, gt=0)
|
||||
priority: ProductionPriority = ProductionPriority.NORMAL
|
||||
assigned_staff: Optional[List[UUID]] = None
|
||||
production_notes: Optional[str] = None
|
||||
customer_order_reference: Optional[str] = None
|
||||
pre_order_quantity: Optional[float] = Field(None, ge=0)
|
||||
shelf_quantity: Optional[float] = Field(None, ge=0)
|
||||
|
||||
|
||||
class ProductionBatchUpdate(BaseModel):
|
||||
"""Schema for updating production batches"""
|
||||
batch_number: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
production_date: Optional[date] = None
|
||||
planned_start_time: Optional[datetime] = None
|
||||
actual_start_time: Optional[datetime] = None
|
||||
planned_end_time: Optional[datetime] = None
|
||||
actual_end_time: Optional[datetime] = None
|
||||
planned_quantity: Optional[float] = Field(None, gt=0)
|
||||
actual_quantity: Optional[float] = Field(None, ge=0)
|
||||
batch_size_multiplier: Optional[float] = Field(None, gt=0)
|
||||
status: Optional[ProductionStatus] = None
|
||||
priority: Optional[ProductionPriority] = None
|
||||
assigned_staff: Optional[List[UUID]] = None
|
||||
production_notes: Optional[str] = None
|
||||
quality_score: Optional[float] = Field(None, ge=1, le=10)
|
||||
quality_notes: Optional[str] = None
|
||||
defect_rate: Optional[float] = Field(None, ge=0, le=100)
|
||||
rework_required: Optional[bool] = None
|
||||
labor_cost: Optional[float] = Field(None, ge=0)
|
||||
overhead_cost: Optional[float] = Field(None, ge=0)
|
||||
production_temperature: Optional[float] = None
|
||||
production_humidity: Optional[float] = Field(None, ge=0, le=100)
|
||||
oven_temperature: Optional[float] = None
|
||||
baking_time_minutes: Optional[int] = Field(None, ge=0)
|
||||
waste_quantity: Optional[float] = Field(None, ge=0)
|
||||
waste_reason: Optional[str] = None
|
||||
customer_order_reference: Optional[str] = None
|
||||
pre_order_quantity: Optional[float] = Field(None, ge=0)
|
||||
shelf_quantity: Optional[float] = Field(None, ge=0)
|
||||
|
||||
|
||||
class ProductionBatchResponse(BaseModel):
|
||||
"""Schema for production batch responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
recipe_id: UUID
|
||||
batch_number: str
|
||||
production_date: date
|
||||
planned_start_time: Optional[datetime] = None
|
||||
actual_start_time: Optional[datetime] = None
|
||||
planned_end_time: Optional[datetime] = None
|
||||
actual_end_time: Optional[datetime] = None
|
||||
planned_quantity: float
|
||||
actual_quantity: Optional[float] = None
|
||||
yield_percentage: Optional[float] = None
|
||||
batch_size_multiplier: float
|
||||
status: str
|
||||
priority: str
|
||||
assigned_staff: Optional[List[UUID]] = None
|
||||
production_notes: Optional[str] = None
|
||||
quality_score: Optional[float] = None
|
||||
quality_notes: Optional[str] = None
|
||||
defect_rate: Optional[float] = None
|
||||
rework_required: bool
|
||||
planned_material_cost: Optional[float] = None
|
||||
actual_material_cost: Optional[float] = None
|
||||
labor_cost: Optional[float] = None
|
||||
overhead_cost: Optional[float] = None
|
||||
total_production_cost: Optional[float] = None
|
||||
cost_per_unit: Optional[float] = None
|
||||
production_temperature: Optional[float] = None
|
||||
production_humidity: Optional[float] = None
|
||||
oven_temperature: Optional[float] = None
|
||||
baking_time_minutes: Optional[int] = None
|
||||
waste_quantity: float
|
||||
waste_reason: Optional[str] = None
|
||||
efficiency_percentage: Optional[float] = None
|
||||
customer_order_reference: Optional[str] = None
|
||||
pre_order_quantity: Optional[float] = None
|
||||
shelf_quantity: Optional[float] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: Optional[UUID] = None
|
||||
completed_by: Optional[UUID] = None
|
||||
ingredient_consumptions: Optional[List[ProductionIngredientConsumptionResponse]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductionBatchSearchRequest(BaseModel):
|
||||
"""Schema for production batch search requests"""
|
||||
search_term: Optional[str] = None
|
||||
status: Optional[ProductionStatus] = None
|
||||
priority: Optional[ProductionPriority] = None
|
||||
start_date: Optional[date] = None
|
||||
end_date: Optional[date] = None
|
||||
recipe_id: Optional[UUID] = None
|
||||
limit: int = Field(default=100, ge=1, le=1000)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class ProductionScheduleCreate(BaseModel):
|
||||
"""Schema for creating production schedules"""
|
||||
schedule_date: date
|
||||
schedule_name: Optional[str] = Field(None, max_length=255)
|
||||
estimated_production_hours: Optional[float] = Field(None, gt=0)
|
||||
estimated_material_cost: Optional[float] = Field(None, ge=0)
|
||||
available_staff_hours: Optional[float] = Field(None, gt=0)
|
||||
oven_capacity_hours: Optional[float] = Field(None, gt=0)
|
||||
production_capacity_limit: Optional[float] = Field(None, gt=0)
|
||||
schedule_notes: Optional[str] = None
|
||||
preparation_instructions: Optional[str] = None
|
||||
special_requirements: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ProductionScheduleUpdate(BaseModel):
|
||||
"""Schema for updating production schedules"""
|
||||
schedule_name: Optional[str] = Field(None, max_length=255)
|
||||
total_planned_batches: Optional[int] = Field(None, ge=0)
|
||||
total_planned_items: Optional[float] = Field(None, ge=0)
|
||||
estimated_production_hours: Optional[float] = Field(None, gt=0)
|
||||
estimated_material_cost: Optional[float] = Field(None, ge=0)
|
||||
is_published: Optional[bool] = None
|
||||
is_completed: Optional[bool] = None
|
||||
completion_percentage: Optional[float] = Field(None, ge=0, le=100)
|
||||
available_staff_hours: Optional[float] = Field(None, gt=0)
|
||||
oven_capacity_hours: Optional[float] = Field(None, gt=0)
|
||||
production_capacity_limit: Optional[float] = Field(None, gt=0)
|
||||
schedule_notes: Optional[str] = None
|
||||
preparation_instructions: Optional[str] = None
|
||||
special_requirements: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ProductionScheduleResponse(BaseModel):
|
||||
"""Schema for production schedule responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
schedule_date: date
|
||||
schedule_name: Optional[str] = None
|
||||
total_planned_batches: int
|
||||
total_planned_items: float
|
||||
estimated_production_hours: Optional[float] = None
|
||||
estimated_material_cost: Optional[float] = None
|
||||
is_published: bool
|
||||
is_completed: bool
|
||||
completion_percentage: Optional[float] = None
|
||||
available_staff_hours: Optional[float] = None
|
||||
oven_capacity_hours: Optional[float] = None
|
||||
production_capacity_limit: Optional[float] = None
|
||||
schedule_notes: Optional[str] = None
|
||||
preparation_instructions: Optional[str] = None
|
||||
special_requirements: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: Optional[UUID] = None
|
||||
published_by: Optional[UUID] = None
|
||||
published_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductionStatisticsResponse(BaseModel):
|
||||
"""Schema for production statistics responses"""
|
||||
total_batches: int
|
||||
completed_batches: int
|
||||
failed_batches: int
|
||||
success_rate: float
|
||||
average_yield_percentage: float
|
||||
average_quality_score: float
|
||||
total_production_cost: float
|
||||
status_breakdown: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class StartProductionRequest(BaseModel):
|
||||
"""Schema for starting production batch"""
|
||||
staff_member: Optional[UUID] = None
|
||||
production_notes: Optional[str] = None
|
||||
ingredient_consumptions: List[ProductionIngredientConsumptionCreate]
|
||||
|
||||
|
||||
class CompleteProductionRequest(BaseModel):
|
||||
"""Schema for completing production batch"""
|
||||
actual_quantity: float = Field(..., gt=0)
|
||||
quality_score: Optional[float] = Field(None, ge=1, le=10)
|
||||
quality_notes: Optional[str] = None
|
||||
defect_rate: Optional[float] = Field(None, ge=0, le=100)
|
||||
waste_quantity: Optional[float] = Field(None, ge=0)
|
||||
waste_reason: Optional[str] = None
|
||||
production_notes: Optional[str] = None
|
||||
staff_member: Optional[UUID] = None
|
||||
@@ -1,11 +1,7 @@
|
||||
# services/recipes/app/services/__init__.py
|
||||
|
||||
from .recipe_service import RecipeService
|
||||
from .production_service import ProductionService
|
||||
from .inventory_client import InventoryClient
|
||||
|
||||
__all__ = [
|
||||
"RecipeService",
|
||||
"ProductionService",
|
||||
"InventoryClient"
|
||||
"RecipeService"
|
||||
]
|
||||
@@ -1,151 +0,0 @@
|
||||
# services/recipes/app/services/inventory_client.py
|
||||
"""
|
||||
Client for communicating with Inventory Service
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from shared.clients.inventory_client import InventoryServiceClient as SharedInventoryClient
|
||||
from ..core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InventoryClient:
|
||||
"""Client for inventory service communication via shared client"""
|
||||
|
||||
def __init__(self):
|
||||
self._shared_client = SharedInventoryClient(settings)
|
||||
|
||||
async def get_ingredient_by_id(self, tenant_id: UUID, ingredient_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get ingredient details from inventory service"""
|
||||
try:
|
||||
result = await self._shared_client.get_ingredient_by_id(ingredient_id, str(tenant_id))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ingredient {ingredient_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_ingredients_by_ids(self, tenant_id: UUID, ingredient_ids: List[UUID]) -> List[Dict[str, Any]]:
|
||||
"""Get multiple ingredients by IDs"""
|
||||
try:
|
||||
# For now, get ingredients individually - could be optimized with batch endpoint
|
||||
results = []
|
||||
for ingredient_id in ingredient_ids:
|
||||
ingredient = await self._shared_client.get_ingredient_by_id(ingredient_id, str(tenant_id))
|
||||
if ingredient:
|
||||
results.append(ingredient)
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ingredients batch: {e}")
|
||||
return []
|
||||
|
||||
async def get_ingredient_stock_level(self, tenant_id: UUID, ingredient_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get current stock level for ingredient"""
|
||||
try:
|
||||
stock_entries = await self._shared_client.get_ingredient_stock(ingredient_id, str(tenant_id))
|
||||
if stock_entries:
|
||||
# Calculate total available stock from all entries
|
||||
total_stock = sum(entry.get('available_quantity', 0) for entry in stock_entries)
|
||||
return {
|
||||
'ingredient_id': str(ingredient_id),
|
||||
'total_available': total_stock,
|
||||
'stock_entries': stock_entries
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stock level for {ingredient_id}: {e}")
|
||||
return None
|
||||
|
||||
async def reserve_ingredients(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
reservations: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Reserve ingredients for production"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v1/stock/reserve",
|
||||
headers={"X-Tenant-ID": str(tenant_id)},
|
||||
json={"reservations": reservations}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {"success": True, "data": response.json()}
|
||||
else:
|
||||
logger.error(f"Failed to reserve ingredients: {response.status_code}")
|
||||
return {"success": False, "error": response.text}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reserving ingredients: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def consume_ingredients(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
consumptions: List[Dict[str, Any]],
|
||||
production_batch_id: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Record ingredient consumption for production"""
|
||||
try:
|
||||
consumption_data = {
|
||||
"consumptions": consumptions,
|
||||
"reference_number": str(production_batch_id),
|
||||
"movement_type": "production_use"
|
||||
}
|
||||
|
||||
result = await self._shared_client.consume_stock(consumption_data, str(tenant_id))
|
||||
|
||||
if result:
|
||||
return {"success": True, "data": result}
|
||||
else:
|
||||
return {"success": False, "error": "Failed to consume ingredients"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error consuming ingredients: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def add_finished_product_to_inventory(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
product_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Add finished product to inventory after production"""
|
||||
try:
|
||||
result = await self._shared_client.receive_stock(product_data, str(tenant_id))
|
||||
|
||||
if result:
|
||||
return {"success": True, "data": result}
|
||||
else:
|
||||
return {"success": False, "error": "Failed to add finished product"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding finished product: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def check_ingredient_availability(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
required_ingredients: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Check if required ingredients are available for production"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v1/stock/check-availability",
|
||||
headers={"X-Tenant-ID": str(tenant_id)},
|
||||
json={"required_ingredients": required_ingredients}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {"success": True, "data": response.json()}
|
||||
else:
|
||||
logger.error(f"Failed to check availability: {response.status_code}")
|
||||
return {"success": False, "error": response.text}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking availability: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -1,401 +0,0 @@
|
||||
# services/recipes/app/services/production_service.py
|
||||
"""
|
||||
Service layer for production management operations
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..repositories.production_repository import (
|
||||
ProductionRepository,
|
||||
ProductionIngredientConsumptionRepository,
|
||||
ProductionScheduleRepository
|
||||
)
|
||||
from ..repositories.recipe_repository import RecipeRepository
|
||||
from ..models.recipes import ProductionBatch, ProductionStatus, ProductionPriority
|
||||
from .inventory_client import InventoryClient
|
||||
from ..core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProductionService:
|
||||
"""Service for production management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.production_repo = ProductionRepository(db)
|
||||
self.consumption_repo = ProductionIngredientConsumptionRepository(db)
|
||||
self.schedule_repo = ProductionScheduleRepository(db)
|
||||
self.recipe_repo = RecipeRepository(db)
|
||||
self.inventory_client = InventoryClient()
|
||||
|
||||
async def create_production_batch(
|
||||
self,
|
||||
batch_data: Dict[str, Any],
|
||||
created_by: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new production batch"""
|
||||
try:
|
||||
# Validate recipe exists and is active
|
||||
recipe = self.recipe_repo.get_by_id(batch_data["recipe_id"])
|
||||
if not recipe:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe not found"
|
||||
}
|
||||
|
||||
if recipe.tenant_id != batch_data["tenant_id"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe does not belong to this tenant"
|
||||
}
|
||||
|
||||
# Check recipe feasibility if needed
|
||||
if batch_data.get("check_feasibility", True):
|
||||
from .recipe_service import RecipeService
|
||||
recipe_service = RecipeService(self.db)
|
||||
feasibility = await recipe_service.check_recipe_feasibility(
|
||||
recipe.id,
|
||||
batch_data.get("batch_size_multiplier", 1.0)
|
||||
)
|
||||
|
||||
if feasibility["success"] and not feasibility["data"]["feasible"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Insufficient ingredients available for production",
|
||||
"details": feasibility["data"]
|
||||
}
|
||||
|
||||
# Generate batch number if not provided
|
||||
if not batch_data.get("batch_number"):
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
count = self.production_repo.count_by_tenant(batch_data["tenant_id"])
|
||||
batch_data["batch_number"] = f"BATCH-{date_str}-{count + 1:04d}"
|
||||
|
||||
# Set defaults
|
||||
batch_data["created_by"] = created_by
|
||||
batch_data["status"] = ProductionStatus.PLANNED
|
||||
|
||||
batch = self.production_repo.create(batch_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": batch.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating production batch: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def start_production_batch(
|
||||
self,
|
||||
batch_id: UUID,
|
||||
ingredient_consumptions: List[Dict[str, Any]],
|
||||
staff_member: UUID,
|
||||
notes: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Start production batch and record ingredient consumptions"""
|
||||
try:
|
||||
batch = self.production_repo.get_by_id(batch_id)
|
||||
if not batch:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Production batch not found"
|
||||
}
|
||||
|
||||
if batch.status != ProductionStatus.PLANNED:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Cannot start batch in {batch.status.value} status"
|
||||
}
|
||||
|
||||
# Reserve ingredients in inventory
|
||||
reservations = []
|
||||
for consumption in ingredient_consumptions:
|
||||
reservations.append({
|
||||
"ingredient_id": str(consumption["ingredient_id"]),
|
||||
"quantity": consumption["actual_quantity"],
|
||||
"unit": consumption["unit"].value if hasattr(consumption["unit"], "value") else consumption["unit"],
|
||||
"reference": str(batch_id)
|
||||
})
|
||||
|
||||
reserve_result = await self.inventory_client.reserve_ingredients(
|
||||
batch.tenant_id,
|
||||
reservations
|
||||
)
|
||||
|
||||
if not reserve_result["success"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to reserve ingredients: {reserve_result['error']}"
|
||||
}
|
||||
|
||||
# Update batch status
|
||||
self.production_repo.update_batch_status(
|
||||
batch_id,
|
||||
ProductionStatus.IN_PROGRESS,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
# Record ingredient consumptions
|
||||
for consumption_data in ingredient_consumptions:
|
||||
consumption_data["tenant_id"] = batch.tenant_id
|
||||
consumption_data["production_batch_id"] = batch_id
|
||||
consumption_data["staff_member"] = staff_member
|
||||
consumption_data["consumption_time"] = datetime.utcnow()
|
||||
|
||||
# Calculate variance
|
||||
planned = consumption_data["planned_quantity"]
|
||||
actual = consumption_data["actual_quantity"]
|
||||
consumption_data["variance_quantity"] = actual - planned
|
||||
if planned > 0:
|
||||
consumption_data["variance_percentage"] = ((actual - planned) / planned) * 100
|
||||
|
||||
self.consumption_repo.create(consumption_data)
|
||||
|
||||
# Get updated batch
|
||||
updated_batch = self.production_repo.get_by_id_with_consumptions(batch_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_batch.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting production batch {batch_id}: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def complete_production_batch(
|
||||
self,
|
||||
batch_id: UUID,
|
||||
completion_data: Dict[str, Any],
|
||||
completed_by: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Complete production batch and add finished products to inventory"""
|
||||
try:
|
||||
batch = self.production_repo.get_by_id_with_consumptions(batch_id)
|
||||
if not batch:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Production batch not found"
|
||||
}
|
||||
|
||||
if batch.status != ProductionStatus.IN_PROGRESS:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Cannot complete batch in {batch.status.value} status"
|
||||
}
|
||||
|
||||
# Calculate yield percentage
|
||||
actual_quantity = completion_data["actual_quantity"]
|
||||
yield_percentage = (actual_quantity / batch.planned_quantity) * 100
|
||||
|
||||
# Calculate efficiency percentage
|
||||
efficiency_percentage = None
|
||||
if batch.actual_start_time and batch.planned_start_time and batch.planned_end_time:
|
||||
planned_duration = (batch.planned_end_time - batch.planned_start_time).total_seconds()
|
||||
actual_duration = (datetime.utcnow() - batch.actual_start_time).total_seconds()
|
||||
if actual_duration > 0:
|
||||
efficiency_percentage = (planned_duration / actual_duration) * 100
|
||||
|
||||
# Update batch with completion data
|
||||
update_data = {
|
||||
"actual_quantity": actual_quantity,
|
||||
"yield_percentage": yield_percentage,
|
||||
"efficiency_percentage": efficiency_percentage,
|
||||
"actual_end_time": datetime.utcnow(),
|
||||
"completed_by": completed_by,
|
||||
"status": ProductionStatus.COMPLETED,
|
||||
**{k: v for k, v in completion_data.items() if k != "actual_quantity"}
|
||||
}
|
||||
|
||||
updated_batch = self.production_repo.update(batch_id, update_data)
|
||||
|
||||
# Add finished products to inventory
|
||||
recipe = self.recipe_repo.get_by_id(batch.recipe_id)
|
||||
if recipe:
|
||||
product_data = {
|
||||
"ingredient_id": str(recipe.finished_product_id),
|
||||
"quantity": actual_quantity,
|
||||
"batch_number": batch.batch_number,
|
||||
"production_date": batch.production_date.isoformat(),
|
||||
"reference_number": str(batch_id),
|
||||
"movement_type": "production",
|
||||
"notes": f"Production batch {batch.batch_number}"
|
||||
}
|
||||
|
||||
inventory_result = await self.inventory_client.add_finished_product_to_inventory(
|
||||
batch.tenant_id,
|
||||
product_data
|
||||
)
|
||||
|
||||
if not inventory_result["success"]:
|
||||
logger.warning(f"Failed to add finished product to inventory: {inventory_result['error']}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_batch.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error completing production batch {batch_id}: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def get_production_batch_with_consumptions(self, batch_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get production batch with all consumption records"""
|
||||
batch = self.production_repo.get_by_id_with_consumptions(batch_id)
|
||||
if not batch:
|
||||
return None
|
||||
|
||||
batch_dict = batch.to_dict()
|
||||
batch_dict["ingredient_consumptions"] = [
|
||||
cons.to_dict() for cons in batch.ingredient_consumptions
|
||||
]
|
||||
|
||||
return batch_dict
|
||||
|
||||
def search_production_batches(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
recipe_id: Optional[UUID] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search production batches with filters"""
|
||||
production_status = ProductionStatus(status) if status else None
|
||||
production_priority = ProductionPriority(priority) if priority else None
|
||||
|
||||
batches = self.production_repo.search_batches(
|
||||
tenant_id=tenant_id,
|
||||
search_term=search_term,
|
||||
status=production_status,
|
||||
priority=production_priority,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
recipe_id=recipe_id,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return [batch.to_dict() for batch in batches]
|
||||
|
||||
def get_active_production_batches(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""Get all active production batches"""
|
||||
batches = self.production_repo.get_active_batches(tenant_id)
|
||||
return [batch.to_dict() for batch in batches]
|
||||
|
||||
def get_production_statistics(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Get production statistics for dashboard"""
|
||||
return self.production_repo.get_production_statistics(tenant_id, start_date, end_date)
|
||||
|
||||
async def update_production_batch(
|
||||
self,
|
||||
batch_id: UUID,
|
||||
update_data: Dict[str, Any],
|
||||
updated_by: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Update production batch"""
|
||||
try:
|
||||
batch = self.production_repo.get_by_id(batch_id)
|
||||
if not batch:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Production batch not found"
|
||||
}
|
||||
|
||||
# Add audit info
|
||||
update_data["updated_by"] = updated_by
|
||||
|
||||
updated_batch = self.production_repo.update(batch_id, update_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_batch.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating production batch {batch_id}: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
# Production Schedule methods
|
||||
def create_production_schedule(self, schedule_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new production schedule"""
|
||||
try:
|
||||
schedule = self.schedule_repo.create(schedule_data)
|
||||
return {
|
||||
"success": True,
|
||||
"data": schedule.to_dict()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating production schedule: {e}")
|
||||
self.db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def get_production_schedule(self, schedule_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get production schedule by ID"""
|
||||
schedule = self.schedule_repo.get_by_id(schedule_id)
|
||||
return schedule.to_dict() if schedule else None
|
||||
|
||||
def get_production_schedule_by_date(self, tenant_id: UUID, schedule_date: date) -> Optional[Dict[str, Any]]:
|
||||
"""Get production schedule for specific date"""
|
||||
schedule = self.schedule_repo.get_by_date(tenant_id, schedule_date)
|
||||
return schedule.to_dict() if schedule else None
|
||||
|
||||
def get_published_schedules(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get published schedules within date range"""
|
||||
schedules = self.schedule_repo.get_published_schedules(tenant_id, start_date, end_date)
|
||||
return [schedule.to_dict() for schedule in schedules]
|
||||
|
||||
def get_production_schedules_range(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get all schedules within date range"""
|
||||
if not start_date:
|
||||
start_date = date.today()
|
||||
if not end_date:
|
||||
from datetime import timedelta
|
||||
end_date = start_date + timedelta(days=7)
|
||||
|
||||
schedules = self.schedule_repo.get_published_schedules(tenant_id, start_date, end_date)
|
||||
return [schedule.to_dict() for schedule in schedules]
|
||||
@@ -7,24 +7,65 @@ import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..repositories.recipe_repository import RecipeRepository, RecipeIngredientRepository
|
||||
from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus
|
||||
from .inventory_client import InventoryClient
|
||||
from ..core.config import settings
|
||||
from ..repositories.recipe_repository import RecipeRepository
|
||||
from ..schemas.recipes import RecipeCreate, RecipeUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecipeService:
|
||||
"""Service for recipe management operations"""
|
||||
"""Async service for recipe management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.recipe_repo = RecipeRepository(db)
|
||||
self.ingredient_repo = RecipeIngredientRepository(db)
|
||||
self.inventory_client = InventoryClient()
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
self.recipe_repo = RecipeRepository(session)
|
||||
|
||||
async def get_recipe_with_ingredients(self, recipe_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get recipe by ID with ingredients"""
|
||||
try:
|
||||
return await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recipe {recipe_id}: {e}")
|
||||
return None
|
||||
|
||||
async def search_recipes(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
is_seasonal: Optional[bool] = None,
|
||||
is_signature: Optional[bool] = None,
|
||||
difficulty_level: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search recipes with filters"""
|
||||
try:
|
||||
return await self.recipe_repo.search_recipes(
|
||||
tenant_id=tenant_id,
|
||||
search_term=search_term,
|
||||
status=status,
|
||||
category=category,
|
||||
is_seasonal=is_seasonal,
|
||||
is_signature=is_signature,
|
||||
difficulty_level=difficulty_level,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching recipes: {e}")
|
||||
return []
|
||||
|
||||
async def get_recipe_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get recipe statistics for dashboard"""
|
||||
try:
|
||||
return await self.recipe_repo.get_recipe_statistics(tenant_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recipe statistics: {e}")
|
||||
return {"total_recipes": 0, "active_recipes": 0, "signature_recipes": 0, "seasonal_recipes": 0}
|
||||
|
||||
async def create_recipe(
|
||||
self,
|
||||
@@ -34,71 +75,26 @@ class RecipeService:
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new recipe with ingredients"""
|
||||
try:
|
||||
# Validate finished product exists in inventory
|
||||
finished_product = await self.inventory_client.get_ingredient_by_id(
|
||||
recipe_data["tenant_id"],
|
||||
recipe_data["finished_product_id"]
|
||||
)
|
||||
|
||||
if not finished_product:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Finished product not found in inventory"
|
||||
}
|
||||
|
||||
if finished_product.get("product_type") != "finished_product":
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Referenced item is not a finished product"
|
||||
}
|
||||
|
||||
# Validate ingredients exist in inventory
|
||||
ingredient_ids = [UUID(ing["ingredient_id"]) for ing in ingredients_data]
|
||||
ingredients = await self.inventory_client.get_ingredients_by_ids(
|
||||
recipe_data["tenant_id"],
|
||||
ingredient_ids
|
||||
)
|
||||
|
||||
if len(ingredients) != len(ingredient_ids):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Some ingredients not found in inventory"
|
||||
}
|
||||
|
||||
# Create recipe
|
||||
# Add metadata
|
||||
recipe_data["created_by"] = created_by
|
||||
recipe = self.recipe_repo.create(recipe_data)
|
||||
recipe_data["created_at"] = datetime.utcnow()
|
||||
recipe_data["updated_at"] = datetime.utcnow()
|
||||
|
||||
# Create recipe ingredients
|
||||
for ingredient_data in ingredients_data:
|
||||
ingredient_data["tenant_id"] = recipe_data["tenant_id"]
|
||||
ingredient_data["recipe_id"] = recipe.id
|
||||
# Use the shared repository's create method
|
||||
recipe_create = RecipeCreate(**recipe_data)
|
||||
recipe = await self.recipe_repo.create(recipe_create)
|
||||
|
||||
# Calculate cost if available
|
||||
inventory_ingredient = next(
|
||||
(ing for ing in ingredients if ing["id"] == ingredient_data["ingredient_id"]),
|
||||
None
|
||||
)
|
||||
if inventory_ingredient and inventory_ingredient.get("average_cost"):
|
||||
unit_cost = float(inventory_ingredient["average_cost"])
|
||||
total_cost = unit_cost * ingredient_data["quantity"]
|
||||
ingredient_data["unit_cost"] = unit_cost
|
||||
ingredient_data["total_cost"] = total_cost
|
||||
ingredient_data["cost_updated_at"] = datetime.utcnow()
|
||||
|
||||
self.ingredient_repo.create(ingredient_data)
|
||||
|
||||
# Calculate and update recipe cost
|
||||
await self._update_recipe_cost(recipe.id)
|
||||
# Get the created recipe with ingredients (if the repository supports it)
|
||||
result = await self.recipe_repo.get_recipe_with_ingredients(recipe.id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": recipe.to_dict()
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating recipe: {e}")
|
||||
self.db.rollback()
|
||||
await self.session.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
@@ -113,152 +109,67 @@ class RecipeService:
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an existing recipe"""
|
||||
try:
|
||||
recipe = self.recipe_repo.get_by_id(recipe_id)
|
||||
if not recipe:
|
||||
# Check if recipe exists
|
||||
existing_recipe = await self.recipe_repo.get_by_id(recipe_id)
|
||||
if not existing_recipe:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe not found"
|
||||
}
|
||||
|
||||
# Update recipe data
|
||||
# Add metadata
|
||||
if updated_by:
|
||||
recipe_data["updated_by"] = updated_by
|
||||
recipe_data["updated_at"] = datetime.utcnow()
|
||||
|
||||
updated_recipe = self.recipe_repo.update(recipe_id, recipe_data)
|
||||
# Use the shared repository's update method
|
||||
recipe_update = RecipeUpdate(**recipe_data)
|
||||
updated_recipe = await self.recipe_repo.update(recipe_id, recipe_update)
|
||||
|
||||
# Update ingredients if provided
|
||||
if ingredients_data is not None:
|
||||
# Validate ingredients exist in inventory
|
||||
ingredient_ids = [UUID(ing["ingredient_id"]) for ing in ingredients_data]
|
||||
ingredients = await self.inventory_client.get_ingredients_by_ids(
|
||||
recipe.tenant_id,
|
||||
ingredient_ids
|
||||
)
|
||||
|
||||
if len(ingredients) != len(ingredient_ids):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Some ingredients not found in inventory"
|
||||
}
|
||||
|
||||
# Update ingredients
|
||||
for ingredient_data in ingredients_data:
|
||||
ingredient_data["tenant_id"] = recipe.tenant_id
|
||||
|
||||
# Calculate cost if available
|
||||
inventory_ingredient = next(
|
||||
(ing for ing in ingredients if ing["id"] == ingredient_data["ingredient_id"]),
|
||||
None
|
||||
)
|
||||
if inventory_ingredient and inventory_ingredient.get("average_cost"):
|
||||
unit_cost = float(inventory_ingredient["average_cost"])
|
||||
total_cost = unit_cost * ingredient_data["quantity"]
|
||||
ingredient_data["unit_cost"] = unit_cost
|
||||
ingredient_data["total_cost"] = total_cost
|
||||
ingredient_data["cost_updated_at"] = datetime.utcnow()
|
||||
|
||||
self.ingredient_repo.update_ingredients_for_recipe(recipe_id, ingredients_data)
|
||||
|
||||
# Recalculate recipe cost
|
||||
await self._update_recipe_cost(recipe_id)
|
||||
# Get the updated recipe with ingredients
|
||||
result = await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_recipe.to_dict()
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating recipe {recipe_id}: {e}")
|
||||
self.db.rollback()
|
||||
await self.session.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def get_recipe_with_ingredients(self, recipe_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get recipe with all ingredients"""
|
||||
recipe = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
|
||||
if not recipe:
|
||||
return None
|
||||
|
||||
recipe_dict = recipe.to_dict()
|
||||
recipe_dict["ingredients"] = [ing.to_dict() for ing in recipe.ingredients]
|
||||
|
||||
return recipe_dict
|
||||
|
||||
def search_recipes(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
is_seasonal: Optional[bool] = None,
|
||||
is_signature: Optional[bool] = None,
|
||||
difficulty_level: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search recipes with filters"""
|
||||
recipe_status = RecipeStatus(status) if status else None
|
||||
|
||||
recipes = self.recipe_repo.search_recipes(
|
||||
tenant_id=tenant_id,
|
||||
search_term=search_term,
|
||||
status=recipe_status,
|
||||
category=category,
|
||||
is_seasonal=is_seasonal,
|
||||
is_signature=is_signature,
|
||||
difficulty_level=difficulty_level,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return [recipe.to_dict() for recipe in recipes]
|
||||
|
||||
def get_recipe_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get recipe statistics for dashboard"""
|
||||
return self.recipe_repo.get_recipe_statistics(tenant_id)
|
||||
async def delete_recipe(self, recipe_id: UUID) -> bool:
|
||||
"""Delete a recipe"""
|
||||
try:
|
||||
return await self.recipe_repo.delete(recipe_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting recipe {recipe_id}: {e}")
|
||||
return False
|
||||
|
||||
async def check_recipe_feasibility(self, recipe_id: UUID, batch_multiplier: float = 1.0) -> Dict[str, Any]:
|
||||
"""Check if recipe can be produced with current inventory"""
|
||||
try:
|
||||
recipe = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
|
||||
recipe = await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
|
||||
if not recipe:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe not found"
|
||||
}
|
||||
|
||||
# Calculate required ingredients
|
||||
required_ingredients = []
|
||||
for ingredient in recipe.ingredients:
|
||||
required_quantity = ingredient.quantity * batch_multiplier
|
||||
required_ingredients.append({
|
||||
"ingredient_id": str(ingredient.ingredient_id),
|
||||
"required_quantity": required_quantity,
|
||||
"unit": ingredient.unit.value
|
||||
})
|
||||
|
||||
# Check availability with inventory service
|
||||
availability_check = await self.inventory_client.check_ingredient_availability(
|
||||
recipe.tenant_id,
|
||||
required_ingredients
|
||||
)
|
||||
|
||||
if not availability_check["success"]:
|
||||
return availability_check
|
||||
|
||||
availability_data = availability_check["data"]
|
||||
|
||||
# Simplified feasibility check - can be enhanced later with inventory service integration
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"recipe_id": str(recipe_id),
|
||||
"recipe_name": recipe.name,
|
||||
"recipe_name": recipe["name"],
|
||||
"batch_multiplier": batch_multiplier,
|
||||
"feasible": availability_data.get("all_available", False),
|
||||
"missing_ingredients": availability_data.get("missing_ingredients", []),
|
||||
"insufficient_ingredients": availability_data.get("insufficient_ingredients", [])
|
||||
"feasible": True,
|
||||
"missing_ingredients": [],
|
||||
"insufficient_ingredients": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,21 +188,34 @@ class RecipeService:
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a duplicate of an existing recipe"""
|
||||
try:
|
||||
new_recipe = self.recipe_repo.duplicate_recipe(recipe_id, new_name, created_by)
|
||||
if not new_recipe:
|
||||
# Get original recipe
|
||||
original_recipe = await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
|
||||
if not original_recipe:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe not found"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": new_recipe.to_dict()
|
||||
}
|
||||
# Create new recipe data
|
||||
new_recipe_data = original_recipe.copy()
|
||||
new_recipe_data["name"] = new_name
|
||||
|
||||
# Remove fields that should be auto-generated
|
||||
new_recipe_data.pop("id", None)
|
||||
new_recipe_data.pop("created_at", None)
|
||||
new_recipe_data.pop("updated_at", None)
|
||||
|
||||
# Handle ingredients
|
||||
ingredients = new_recipe_data.pop("ingredients", [])
|
||||
|
||||
# Create the duplicate
|
||||
result = await self.create_recipe(new_recipe_data, ingredients, created_by)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error duplicating recipe {recipe_id}: {e}")
|
||||
self.db.rollback()
|
||||
await self.session.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
@@ -300,42 +224,36 @@ class RecipeService:
|
||||
async def activate_recipe(self, recipe_id: UUID, activated_by: UUID) -> Dict[str, Any]:
|
||||
"""Activate a recipe for production"""
|
||||
try:
|
||||
# Check if recipe is complete and valid
|
||||
recipe = self.recipe_repo.get_by_id_with_ingredients(recipe_id)
|
||||
# Check if recipe exists
|
||||
recipe = await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
|
||||
if not recipe:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe not found"
|
||||
}
|
||||
|
||||
if not recipe.ingredients:
|
||||
if not recipe.get("ingredients"):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Recipe must have at least one ingredient"
|
||||
}
|
||||
|
||||
# Validate all ingredients exist in inventory
|
||||
ingredient_ids = [ing.ingredient_id for ing in recipe.ingredients]
|
||||
ingredients = await self.inventory_client.get_ingredients_by_ids(
|
||||
recipe.tenant_id,
|
||||
ingredient_ids
|
||||
)
|
||||
|
||||
if len(ingredients) != len(ingredient_ids):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Some recipe ingredients not found in inventory"
|
||||
}
|
||||
|
||||
# Update recipe status
|
||||
updated_recipe = self.recipe_repo.update(recipe_id, {
|
||||
"status": RecipeStatus.ACTIVE,
|
||||
"updated_by": activated_by
|
||||
})
|
||||
update_data = {
|
||||
"status": "active",
|
||||
"updated_by": activated_by,
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
|
||||
recipe_update = RecipeUpdate(**update_data)
|
||||
await self.recipe_repo.update(recipe_id, recipe_update)
|
||||
|
||||
# Get the updated recipe
|
||||
result = await self.recipe_repo.get_recipe_with_ingredients(recipe_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": updated_recipe.to_dict()
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -344,31 +262,3 @@ class RecipeService:
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def _update_recipe_cost(self, recipe_id: UUID) -> None:
|
||||
"""Update recipe cost based on ingredient costs"""
|
||||
try:
|
||||
total_cost = self.ingredient_repo.calculate_recipe_cost(recipe_id)
|
||||
|
||||
recipe = self.recipe_repo.get_by_id(recipe_id)
|
||||
if recipe:
|
||||
cost_per_unit = total_cost / recipe.yield_quantity if recipe.yield_quantity > 0 else 0
|
||||
|
||||
# Add overhead
|
||||
overhead_cost = cost_per_unit * (settings.OVERHEAD_PERCENTAGE / 100)
|
||||
total_cost_with_overhead = cost_per_unit + overhead_cost
|
||||
|
||||
# Calculate suggested selling price with target margin
|
||||
if recipe.target_margin_percentage:
|
||||
suggested_price = total_cost_with_overhead * (1 + recipe.target_margin_percentage / 100)
|
||||
else:
|
||||
suggested_price = total_cost_with_overhead * 1.3 # Default 30% margin
|
||||
|
||||
self.recipe_repo.update(recipe_id, {
|
||||
"last_calculated_cost": total_cost_with_overhead,
|
||||
"cost_calculation_date": datetime.utcnow(),
|
||||
"suggested_selling_price": suggested_price
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating recipe cost for {recipe_id}: {e}")
|
||||
@@ -126,6 +126,7 @@ class BaseServiceSettings(BaseSettings):
|
||||
PRODUCTION_SERVICE_URL: str = os.getenv("PRODUCTION_SERVICE_URL", "http://bakery-production-service:8000")
|
||||
ORDERS_SERVICE_URL: str = os.getenv("ORDERS_SERVICE_URL", "http://bakery-orders-service:8000")
|
||||
SUPPLIERS_SERVICE_URL: str = os.getenv("SUPPLIERS_SERVICE_URL", "http://bakery-suppliers-service:8000")
|
||||
RECIPES_SERVICE_URL: str = os.getenv("RECIPES_SERVICE_URL", "http://recipes-service:8000")
|
||||
NOMINATIM_SERVICE_URL: str = os.getenv("NOMINATIM_SERVICE_URL", "http://nominatim:8080")
|
||||
|
||||
# HTTP Client Settings
|
||||
@@ -340,6 +341,7 @@ class BaseServiceSettings(BaseSettings):
|
||||
"production": self.PRODUCTION_SERVICE_URL,
|
||||
"orders": self.ORDERS_SERVICE_URL,
|
||||
"suppliers": self.SUPPLIERS_SERVICE_URL,
|
||||
"recipes": self.RECIPES_SERVICE_URL,
|
||||
}
|
||||
|
||||
@property
|
||||
|
||||
Reference in New Issue
Block a user