Create the frontend receipes page to use real API

This commit is contained in:
Urtzi Alfaro
2025-09-19 21:39:04 +02:00
parent 8002d89d2b
commit d18c64ce6e
36 changed files with 3356 additions and 3171 deletions

142
add_real_recipes.sh Executable file
View 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
View 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!"

View 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.

View File

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

View File

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

View File

@@ -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`);
}
}

View File

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

View File

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

View File

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

View 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;

View File

@@ -0,0 +1 @@
export { CreateRecipeModal } from './CreateRecipeModal';

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

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

View File

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

View File

@@ -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'
variant: 'primary',
onClick: handleSaveRecipe,
disabled: updateRecipeMutation.isPending
},
{
label: 'Dificultad',
value: selectedRecipe.difficulty
},
{
label: 'Valoración',
value: `${selectedRecipe.rating}`,
highlight: selectedRecipe.rating >= 4.7
},
{
label: 'Rendimiento',
value: `${selectedRecipe.yield} porciones`
label: 'Cancelar',
variant: 'outline',
onClick: () => {
setModalMode('view');
setEditedRecipe({});
}
]
},
{
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
}
]
}
]}
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>
);
};

View File

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

View File

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

View 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())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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_(
query = query.where(
or_(
Recipe.name.ilike(f"%{search_term}%"),
Recipe.description.ilike(f"%{search_term}%"),
Recipe.category.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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
update_data = {
"status": "active",
"updated_by": activated_by,
"updated_at": datetime.utcnow()
}
# Update recipe status
updated_recipe = self.recipe_repo.update(recipe_id, {
"status": RecipeStatus.ACTIVE,
"updated_by": activated_by
})
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}")

View File

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