Add traslations
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
IngredientCreate,
|
IngredientCreate,
|
||||||
IngredientUpdate,
|
IngredientUpdate,
|
||||||
IngredientResponse,
|
IngredientResponse,
|
||||||
|
BulkIngredientResponse,
|
||||||
StockCreate,
|
StockCreate,
|
||||||
StockUpdate,
|
StockUpdate,
|
||||||
StockResponse,
|
StockResponse,
|
||||||
@@ -239,7 +240,7 @@ export const useCreateIngredient = (
|
|||||||
options?: UseMutationOptions<IngredientResponse, ApiError, { tenantId: string; ingredientData: IngredientCreate }>
|
options?: UseMutationOptions<IngredientResponse, ApiError, { tenantId: string; ingredientData: IngredientCreate }>
|
||||||
) => {
|
) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation<IngredientResponse, ApiError, { tenantId: string; ingredientData: IngredientCreate }>({
|
return useMutation<IngredientResponse, ApiError, { tenantId: string; ingredientData: IngredientCreate }>({
|
||||||
mutationFn: ({ tenantId, ingredientData }) => inventoryService.createIngredient(tenantId, ingredientData),
|
mutationFn: ({ tenantId, ingredientData }) => inventoryService.createIngredient(tenantId, ingredientData),
|
||||||
onSuccess: (data, { tenantId }) => {
|
onSuccess: (data, { tenantId }) => {
|
||||||
@@ -253,6 +254,22 @@ export const useCreateIngredient = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useBulkCreateIngredients = (
|
||||||
|
options?: UseMutationOptions<BulkIngredientResponse, ApiError, { tenantId: string; ingredients: IngredientCreate[] }>
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<BulkIngredientResponse, ApiError, { tenantId: string; ingredients: IngredientCreate[] }>({
|
||||||
|
mutationFn: ({ tenantId, ingredients }) => inventoryService.bulkCreateIngredients(tenantId, ingredients),
|
||||||
|
onSuccess: (data, { tenantId }) => {
|
||||||
|
// Invalidate all ingredient lists to refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.byCategory(tenantId) });
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useUpdateIngredient = (
|
export const useUpdateIngredient = (
|
||||||
options?: UseMutationOptions<
|
options?: UseMutationOptions<
|
||||||
IngredientResponse,
|
IngredientResponse,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
IngredientUpdate,
|
IngredientUpdate,
|
||||||
IngredientResponse,
|
IngredientResponse,
|
||||||
IngredientFilter,
|
IngredientFilter,
|
||||||
|
BulkIngredientResponse,
|
||||||
// Stock
|
// Stock
|
||||||
StockCreate,
|
StockCreate,
|
||||||
StockUpdate,
|
StockUpdate,
|
||||||
@@ -72,6 +73,16 @@ export class InventoryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkCreateIngredients(
|
||||||
|
tenantId: string,
|
||||||
|
ingredients: IngredientCreate[]
|
||||||
|
): Promise<BulkIngredientResponse> {
|
||||||
|
return apiClient.post<BulkIngredientResponse>(
|
||||||
|
`${this.baseUrl}/${tenantId}/inventory/ingredients/bulk`,
|
||||||
|
{ ingredients }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getIngredient(tenantId: string, ingredientId: string): Promise<IngredientResponse> {
|
async getIngredient(tenantId: string, ingredientId: string): Promise<IngredientResponse> {
|
||||||
return apiClient.get<IngredientResponse>(
|
return apiClient.get<IngredientResponse>(
|
||||||
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}`
|
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}`
|
||||||
|
|||||||
@@ -177,6 +177,28 @@ export interface IngredientResponse {
|
|||||||
needs_reorder?: boolean | null;
|
needs_reorder?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== BULK INGREDIENT SCHEMAS =====
|
||||||
|
// Mirror: BulkIngredientCreate, BulkIngredientResult, BulkIngredientResponse from inventory.py
|
||||||
|
|
||||||
|
export interface BulkIngredientCreate {
|
||||||
|
ingredients: IngredientCreate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkIngredientResult {
|
||||||
|
index: number;
|
||||||
|
success: boolean;
|
||||||
|
ingredient: IngredientResponse | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkIngredientResponse {
|
||||||
|
total_requested: number;
|
||||||
|
total_created: number;
|
||||||
|
total_failed: number;
|
||||||
|
results: BulkIngredientResult[];
|
||||||
|
transaction_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== STOCK SCHEMAS =====
|
// ===== STOCK SCHEMAS =====
|
||||||
// Mirror: StockCreate from inventory.py:140
|
// Mirror: StockCreate from inventory.py:140
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ interface StepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const OnboardingWizardContent: React.FC = () => {
|
const OnboardingWizardContent: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -77,7 +77,8 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
|
|
||||||
// All possible steps with conditional visibility
|
// All possible steps with conditional visibility
|
||||||
// All step IDs match backend ONBOARDING_STEPS exactly
|
// All step IDs match backend ONBOARDING_STEPS exactly
|
||||||
const ALL_STEPS: StepConfig[] = [
|
// Wrapped in useMemo to re-evaluate when language changes
|
||||||
|
const ALL_STEPS: StepConfig[] = useMemo(() => [
|
||||||
// Phase 1: Discovery
|
// Phase 1: Discovery
|
||||||
{
|
{
|
||||||
id: 'bakery-type-selection',
|
id: 'bakery-type-selection',
|
||||||
@@ -119,8 +120,8 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
// Enterprise-specific: Child Tenants Setup
|
// Enterprise-specific: Child Tenants Setup
|
||||||
{
|
{
|
||||||
id: 'child-tenants-setup',
|
id: 'child-tenants-setup',
|
||||||
title: 'Configurar Sucursales',
|
title: t('onboarding:wizard.steps.child_tenants.title', 'Configurar Sucursales'),
|
||||||
description: 'Registra las sucursales de tu red empresarial',
|
description: t('onboarding:wizard.steps.child_tenants.description', 'Registra las sucursales de tu red empresarial'),
|
||||||
component: ChildTenantsSetupStep,
|
component: ChildTenantsSetupStep,
|
||||||
isConditional: true,
|
isConditional: true,
|
||||||
condition: () => {
|
condition: () => {
|
||||||
@@ -203,7 +204,7 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
description: t('onboarding:steps.completion.description', '¡Todo listo!'),
|
description: t('onboarding:steps.completion.description', '¡Todo listo!'),
|
||||||
component: CompletionStep,
|
component: CompletionStep,
|
||||||
},
|
},
|
||||||
];
|
], [i18n.language]); // Re-create steps when language changes
|
||||||
|
|
||||||
// Filter visible steps based on wizard context
|
// Filter visible steps based on wizard context
|
||||||
// useMemo ensures VISIBLE_STEPS recalculates when wizard context state changes
|
// useMemo ensures VISIBLE_STEPS recalculates when wizard context state changes
|
||||||
@@ -225,7 +226,7 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return visibleSteps;
|
return visibleSteps;
|
||||||
}, [wizardContext.state, isEnterprise]); // Added isEnterprise to dependencies
|
}, [ALL_STEPS, wizardContext.state, isEnterprise]); // Added ALL_STEPS to re-filter when translations change
|
||||||
|
|
||||||
const isNewTenant = searchParams.get('new') === 'true';
|
const isNewTenant = searchParams.get('new') === 'true';
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const CompletionStep: React.FC<CompletionStepProps> = ({
|
|||||||
|
|
||||||
{/* Success Message */}
|
{/* Success Message */}
|
||||||
<div className="space-y-5 animate-slide-up">
|
<div className="space-y-5 animate-slide-up">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-[var(--color-primary)] via-[var(--color-success)] to-[var(--color-primary)] bg-clip-text text-transparent animate-shimmer" style={{ backgroundSize: '200% auto' }}>
|
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-[var(--color-primary)] via-[var(--color-success)] to-[var(--color-primary)] bg-clip-text text-transparent" style={{ backgroundSize: '200% auto' }}>
|
||||||
{t('onboarding:completion.congratulations', '¡Felicidades! Tu Sistema Está Listo')}
|
{t('onboarding:completion.congratulations', '¡Felicidades! Tu Sistema Está Listo')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg md:text-xl text-[var(--text-secondary)] max-w-3xl mx-auto leading-relaxed">
|
<p className="text-lg md:text-xl text-[var(--text-secondary)] max-w-3xl mx-auto leading-relaxed">
|
||||||
|
|||||||
@@ -57,27 +57,30 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
|
|||||||
|
|
||||||
// Merge existing stock from backend on mount
|
// Merge existing stock from backend on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stockData?.items && products.length > 0) {
|
if (!stockData?.items || products.length === 0) return;
|
||||||
console.log('🔄 Merging backend stock data into initial stock entry state...', { itemsCount: stockData.items.length });
|
|
||||||
|
|
||||||
let hasChanges = false;
|
console.log('🔄 Merging backend stock data into initial stock entry state...', { itemsCount: stockData.items.length });
|
||||||
const updatedProducts = products.map(p => {
|
|
||||||
const existingStock = stockData?.items?.find(s => s.ingredient_id === p.id);
|
|
||||||
if (existingStock && p.initialStock !== existingStock.current_quantity) {
|
|
||||||
hasChanges = true;
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
initialStock: existingStock.current_quantity
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasChanges) {
|
let hasChanges = false;
|
||||||
setProducts(updatedProducts);
|
const updatedProducts = products.map(p => {
|
||||||
|
const existingStock = stockData.items.find(s => s.ingredient_id === p.id);
|
||||||
|
|
||||||
|
// Only merge if user hasn't entered value yet
|
||||||
|
if (existingStock && p.initialStock === undefined) {
|
||||||
|
hasChanges = true;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
initialStock: existingStock.current_quantity
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
setProducts(updatedProducts);
|
||||||
|
onUpdate?.({ productsWithStock: updatedProducts });
|
||||||
}
|
}
|
||||||
}, [stockData, products]); // Run when stock data changes or products list is initialized
|
}, [stockData]); // Only depend on stockData to avoid infinite loop
|
||||||
|
|
||||||
const ingredients = products.filter(p => p.type === 'ingredient');
|
const ingredients = products.filter(p => p.type === 'ingredient');
|
||||||
const finishedProducts = products.filter(p => p.type === 'finished_product');
|
const finishedProducts = products.filter(p => p.type === 'finished_product');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '../../../ui/Button';
|
import { Button } from '../../../ui/Button';
|
||||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||||
import { useCreateIngredient, useIngredients } from '../../../../api/hooks/inventory';
|
import { useCreateIngredient, useBulkCreateIngredients, useIngredients } from '../../../../api/hooks/inventory';
|
||||||
import { useImportSalesData } from '../../../../api/hooks/sales';
|
import { useImportSalesData } from '../../../../api/hooks/sales';
|
||||||
import type { ProductSuggestionResponse, IngredientCreate } from '../../../../api/types/inventory';
|
import type { ProductSuggestionResponse, IngredientCreate } from '../../../../api/types/inventory';
|
||||||
import { ProductType, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../../api/types/inventory';
|
import { ProductType, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../../api/types/inventory';
|
||||||
@@ -140,6 +140,7 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
|
|||||||
|
|
||||||
// API hooks
|
// API hooks
|
||||||
const createIngredientMutation = useCreateIngredient();
|
const createIngredientMutation = useCreateIngredient();
|
||||||
|
const bulkCreateIngredientsMutation = useBulkCreateIngredients();
|
||||||
const importSalesMutation = useImportSalesData();
|
const importSalesMutation = useImportSalesData();
|
||||||
const { data: existingIngredients } = useIngredients(tenantId);
|
const { data: existingIngredients } = useIngredients(tenantId);
|
||||||
|
|
||||||
@@ -333,26 +334,61 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
|
|||||||
|
|
||||||
console.log(`📦 Inventory processing: ${itemsToCreate.length} to create, ${existingMatches.length} already exist.`);
|
console.log(`📦 Inventory processing: ${itemsToCreate.length} to create, ${existingMatches.length} already exist.`);
|
||||||
|
|
||||||
// STEP 1: Create new inventory items in parallel
|
// STEP 1: Create new inventory items using bulk API (with fallback to individual creates)
|
||||||
const createPromises = itemsToCreate.map((item, index) => {
|
let newlyCreatedIngredients: any[] = [];
|
||||||
const ingredientData: IngredientCreate = {
|
|
||||||
name: item.name,
|
|
||||||
product_type: item.product_type,
|
|
||||||
category: item.category,
|
|
||||||
unit_of_measure: item.unit_of_measure as UnitOfMeasure,
|
|
||||||
};
|
|
||||||
|
|
||||||
return createIngredientMutation.mutateAsync({
|
if (itemsToCreate.length > 0) {
|
||||||
tenantId,
|
try {
|
||||||
ingredientData,
|
// Try bulk creation first (more efficient)
|
||||||
}).catch(error => {
|
const ingredientsData: IngredientCreate[] = itemsToCreate.map(item => ({
|
||||||
console.error(`❌ Failed to create ingredient "${item.name}":`, error);
|
name: item.name,
|
||||||
throw error;
|
product_type: item.product_type,
|
||||||
});
|
category: item.category,
|
||||||
});
|
unit_of_measure: item.unit_of_measure as UnitOfMeasure,
|
||||||
|
}));
|
||||||
|
|
||||||
const newlyCreatedIngredients = await Promise.all(createPromises);
|
const bulkResult = await bulkCreateIngredientsMutation.mutateAsync({
|
||||||
console.log('✅ New inventory items created successfully');
|
tenantId,
|
||||||
|
ingredients: ingredientsData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract successfully created ingredients
|
||||||
|
newlyCreatedIngredients = bulkResult.results
|
||||||
|
.filter(r => r.success && r.ingredient)
|
||||||
|
.map(r => r.ingredient!);
|
||||||
|
|
||||||
|
console.log(`✅ Bulk creation: ${bulkResult.total_created}/${bulkResult.total_requested} items created successfully`);
|
||||||
|
|
||||||
|
// Log any failures
|
||||||
|
if (bulkResult.total_failed > 0) {
|
||||||
|
const failures = bulkResult.results.filter(r => !r.success);
|
||||||
|
console.warn(`⚠️ ${bulkResult.total_failed} items failed:`, failures.map(f => f.error));
|
||||||
|
}
|
||||||
|
} catch (bulkError) {
|
||||||
|
console.warn('⚠️ Bulk create failed, falling back to individual creates:', bulkError);
|
||||||
|
|
||||||
|
// Fallback: Create items individually in parallel
|
||||||
|
const createPromises = itemsToCreate.map((item, index) => {
|
||||||
|
const ingredientData: IngredientCreate = {
|
||||||
|
name: item.name,
|
||||||
|
product_type: item.product_type,
|
||||||
|
category: item.category,
|
||||||
|
unit_of_measure: item.unit_of_measure as UnitOfMeasure,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createIngredientMutation.mutateAsync({
|
||||||
|
tenantId,
|
||||||
|
ingredientData,
|
||||||
|
}).catch(error => {
|
||||||
|
console.error(`❌ Failed to create ingredient "${item.name}":`, error);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
newlyCreatedIngredients = await Promise.all(createPromises);
|
||||||
|
console.log('✅ Fallback: New inventory items created successfully via individual requests');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// STEP 2: Import sales data (only if file was uploaded)
|
// STEP 2: Import sales data (only if file was uploaded)
|
||||||
let salesImported = false;
|
let salesImported = false;
|
||||||
@@ -467,10 +503,10 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
|
|||||||
<span className="text-3xl group-hover:scale-110 transition-transform">{template.icon}</span>
|
<span className="text-3xl group-hover:scale-110 transition-transform">{template.icon}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="font-medium text-[var(--text-primary)] mb-1">
|
<h4 className="font-medium text-[var(--text-primary)] mb-1">
|
||||||
{template.name}
|
{t(`setup_wizard:inventory.templates.${template.id}`, template.name)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-2">
|
<p className="text-xs text-[var(--text-secondary)] mb-2">
|
||||||
{template.description}
|
{t(`setup_wizard:inventory.templates.${template.id}-desc`, template.description)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-purple-600 dark:text-purple-400 font-medium">
|
<p className="text-xs text-purple-600 dark:text-purple-400 font-medium">
|
||||||
{template.items.length} {t('inventory:templates.items', 'ingredientes')}
|
{template.items.length} {t('inventory:templates.items', 'ingredientes')}
|
||||||
|
|||||||
@@ -592,7 +592,7 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
|
|||||||
value={formData.yield_quantity}
|
value={formData.yield_quantity}
|
||||||
onChange={(e) => setFormData({ ...formData, yield_quantity: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, yield_quantity: e.target.value })}
|
||||||
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.yield_quantity ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
|
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.yield_quantity ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
|
||||||
placeholder="10"
|
placeholder={t('setup_wizard:recipes.placeholders.yield_quantity', '10')}
|
||||||
/>
|
/>
|
||||||
{errors.yield_quantity && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.yield_quantity}</p>}
|
{errors.yield_quantity && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.yield_quantity}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -666,7 +666,7 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
value={ing.quantity}
|
value={ing.quantity}
|
||||||
onChange={(e) => updateIngredient(index, 'quantity', e.target.value)}
|
onChange={(e) => updateIngredient(index, 'quantity', e.target.value)}
|
||||||
placeholder="Qty"
|
placeholder={t('setup_wizard:recipes.placeholders.ingredient_quantity', 'Qty')}
|
||||||
className={`w-full px-2 py-1.5 text-sm bg-[var(--bg-secondary)] border ${errors[`ingredient_${index}_quantity`] ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
|
className={`w-full px-2 py-1.5 text-sm bg-[var(--bg-secondary)] border ${errors[`ingredient_${index}_quantity`] ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
|
||||||
/>
|
/>
|
||||||
{errors[`ingredient_${index}_quantity`] && <p className="mt-1 text-xs text-[var(--color-error)]">{errors[`ingredient_${index}_quantity`]}</p>}
|
{errors[`ingredient_${index}_quantity`] && <p className="mt-1 text-xs text-[var(--color-error)]">{errors[`ingredient_${index}_quantity`]}</p>}
|
||||||
|
|||||||
@@ -3,12 +3,20 @@
|
|||||||
"title": "Initial Setup",
|
"title": "Initial Setup",
|
||||||
"subtitle": "We'll guide you step by step to configure your bakery",
|
"subtitle": "We'll guide you step by step to configure your bakery",
|
||||||
"steps": {
|
"steps": {
|
||||||
|
"bakery_type": {
|
||||||
|
"title": "Bakery Type",
|
||||||
|
"description": "Select your business type"
|
||||||
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
"title": "Register Bakery",
|
"title": "Register Bakery",
|
||||||
"title_enterprise": "Register Central Bakery",
|
"title_enterprise": "Register Central Bakery",
|
||||||
"description": "Configure your bakery's basic information",
|
"description": "Configure your bakery's basic information",
|
||||||
"description_enterprise": "Central bakery information"
|
"description_enterprise": "Central bakery information"
|
||||||
},
|
},
|
||||||
|
"child_tenants": {
|
||||||
|
"title": "Configure Branches",
|
||||||
|
"description": "Register the branches of your enterprise network"
|
||||||
|
},
|
||||||
"poi_detection": {
|
"poi_detection": {
|
||||||
"title": "Location Analysis",
|
"title": "Location Analysis",
|
||||||
"description": "Detect nearby points of interest"
|
"description": "Detect nearby points of interest"
|
||||||
@@ -17,6 +25,34 @@
|
|||||||
"title": "Configure Inventory",
|
"title": "Configure Inventory",
|
||||||
"description": "Upload sales data and set up your initial inventory"
|
"description": "Upload sales data and set up your initial inventory"
|
||||||
},
|
},
|
||||||
|
"upload_sales": {
|
||||||
|
"title": "Upload Sales Data",
|
||||||
|
"description": "Load file with sales history"
|
||||||
|
},
|
||||||
|
"inventory_review": {
|
||||||
|
"title": "Review Inventory",
|
||||||
|
"description": "Confirm detected products"
|
||||||
|
},
|
||||||
|
"stock": {
|
||||||
|
"title": "Stock Levels",
|
||||||
|
"description": "Initial quantities"
|
||||||
|
},
|
||||||
|
"suppliers": {
|
||||||
|
"title": "Suppliers",
|
||||||
|
"description": "Configure your suppliers"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"description": "Production recipes"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"title": "Quality",
|
||||||
|
"description": "Quality standards"
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"title": "Team",
|
||||||
|
"description": "Team members"
|
||||||
|
},
|
||||||
"ml_training": {
|
"ml_training": {
|
||||||
"title": "AI Training",
|
"title": "AI Training",
|
||||||
"description": "Train your personalized artificial intelligence model"
|
"description": "Train your personalized artificial intelligence model"
|
||||||
@@ -180,6 +216,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"bakery_type": {
|
||||||
|
"title": "What type of bakery do you have?",
|
||||||
|
"subtitle": "This will help us personalize the experience and show you only the features you need",
|
||||||
|
"features_label": "Features",
|
||||||
|
"examples_label": "Examples",
|
||||||
|
"continue_button": "Continue",
|
||||||
|
"help_text": "💡 Don't worry, you can always change this later in settings",
|
||||||
|
"selected_info_title": "Perfect for your bakery",
|
||||||
|
"production": {
|
||||||
|
"name": "Production Bakery",
|
||||||
|
"description": "We produce from scratch using basic ingredients",
|
||||||
|
"feature1": "Complete recipe management",
|
||||||
|
"feature2": "Ingredient and cost control",
|
||||||
|
"feature3": "Production planning",
|
||||||
|
"feature4": "Raw material quality control",
|
||||||
|
"example1": "Artisan bread",
|
||||||
|
"example2": "Pastries",
|
||||||
|
"example3": "Confectionery",
|
||||||
|
"example4": "Patisserie",
|
||||||
|
"selected_info": "We'll set up a complete recipe, ingredient, and production management system tailored to your workflow."
|
||||||
|
},
|
||||||
|
"retail": {
|
||||||
|
"name": "Retail Bakery",
|
||||||
|
"description": "We bake and sell pre-made products",
|
||||||
|
"feature1": "Finished product control",
|
||||||
|
"feature2": "Simple baking management",
|
||||||
|
"feature3": "Point of sale inventory control",
|
||||||
|
"feature4": "Sales and shrinkage tracking",
|
||||||
|
"example1": "Pre-baked bread",
|
||||||
|
"example2": "Frozen products to finish",
|
||||||
|
"example3": "Ready-to-sell pastries",
|
||||||
|
"example4": "Cakes and pastries from suppliers",
|
||||||
|
"selected_info": "We'll set up a simple system focused on inventory control, baking, and sales without the complexity of recipes."
|
||||||
|
},
|
||||||
|
"mixed": {
|
||||||
|
"name": "Mixed Bakery",
|
||||||
|
"description": "We combine own production with finished products",
|
||||||
|
"feature1": "Own recipes and external products",
|
||||||
|
"feature2": "Total management flexibility",
|
||||||
|
"feature3": "Complete cost control",
|
||||||
|
"feature4": "Maximum adaptability",
|
||||||
|
"example1": "Own bread + supplier pastries",
|
||||||
|
"example2": "Own cakes + pre-baked goods",
|
||||||
|
"example3": "Artisan + industrial products",
|
||||||
|
"example4": "Seasonal combination",
|
||||||
|
"selected_info": "We'll set up a flexible system that allows you to manage both own production and external products according to your needs."
|
||||||
|
}
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"step_failed": "Error in this step",
|
"step_failed": "Error in this step",
|
||||||
"data_invalid": "Invalid data",
|
"data_invalid": "Invalid data",
|
||||||
@@ -210,6 +294,7 @@
|
|||||||
"incomplete_warning": "{count} products remaining",
|
"incomplete_warning": "{count} products remaining",
|
||||||
"incomplete_help": "You can continue, but we recommend entering all quantities for better inventory control.",
|
"incomplete_help": "You can continue, but we recommend entering all quantities for better inventory control.",
|
||||||
"complete": "Complete Setup",
|
"complete": "Complete Setup",
|
||||||
|
"continue_to_next": "Continue",
|
||||||
"continue_anyway": "Continue anyway",
|
"continue_anyway": "Continue anyway",
|
||||||
"no_products_title": "Initial Stock",
|
"no_products_title": "Initial Stock",
|
||||||
"no_products_message": "You can configure stock levels later in the inventory section."
|
"no_products_message": "You can configure stock levels later in the inventory section."
|
||||||
|
|||||||
@@ -114,6 +114,16 @@
|
|||||||
"quantity_required": "Quantity must be greater than zero",
|
"quantity_required": "Quantity must be greater than zero",
|
||||||
"expiration_past": "Expiration date is in the past",
|
"expiration_past": "Expiration date is in the past",
|
||||||
"expiring_soon": "Warning: This ingredient expires very soon!"
|
"expiring_soon": "Warning: This ingredient expires very soon!"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"basic-bakery": "Basic Bakery Ingredients",
|
||||||
|
"basic-bakery-desc": "Essential ingredients for any bakery",
|
||||||
|
"pastry-essentials": "Pastry Essentials",
|
||||||
|
"pastry-essentials-desc": "Ingredients for cakes and desserts",
|
||||||
|
"bread-basics": "Bread Basics",
|
||||||
|
"bread-basics-desc": "Everything needed for artisan bread",
|
||||||
|
"chocolate-specialties": "Chocolate Specialties",
|
||||||
|
"chocolate-specialties-desc": "For chocolate products"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -153,7 +163,9 @@
|
|||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"name": "e.g., Baguette, Croissant",
|
"name": "e.g., Baguette, Croissant",
|
||||||
"finished_product": "Select finished product..."
|
"finished_product": "Select finished product...",
|
||||||
|
"yield_quantity": "10",
|
||||||
|
"ingredient_quantity": "Qty"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"name_required": "Recipe name is required",
|
"name_required": "Recipe name is required",
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
"description": "Información básica",
|
"description": "Información básica",
|
||||||
"description_enterprise": "Información del obrador central"
|
"description_enterprise": "Información del obrador central"
|
||||||
},
|
},
|
||||||
|
"child_tenants": {
|
||||||
|
"title": "Configurar Sucursales",
|
||||||
|
"description": "Registra las sucursales de tu red empresarial"
|
||||||
|
},
|
||||||
"poi_detection": {
|
"poi_detection": {
|
||||||
"title": "Análisis de Ubicación",
|
"title": "Análisis de Ubicación",
|
||||||
"description": "Detectar puntos de interés cercanos"
|
"description": "Detectar puntos de interés cercanos"
|
||||||
@@ -30,6 +34,18 @@
|
|||||||
"title": "Configurar Inventario",
|
"title": "Configurar Inventario",
|
||||||
"description": "Sube datos de ventas y configura tu inventario inicial"
|
"description": "Sube datos de ventas y configura tu inventario inicial"
|
||||||
},
|
},
|
||||||
|
"upload_sales": {
|
||||||
|
"title": "Subir Datos de Ventas",
|
||||||
|
"description": "Cargar archivo con historial de ventas"
|
||||||
|
},
|
||||||
|
"inventory_review": {
|
||||||
|
"title": "Revisar Inventario",
|
||||||
|
"description": "Confirmar productos detectados"
|
||||||
|
},
|
||||||
|
"stock": {
|
||||||
|
"title": "Niveles de Stock",
|
||||||
|
"description": "Cantidades iniciales"
|
||||||
|
},
|
||||||
"suppliers": {
|
"suppliers": {
|
||||||
"title": "Proveedores",
|
"title": "Proveedores",
|
||||||
"description": "Configura tus proveedores"
|
"description": "Configura tus proveedores"
|
||||||
@@ -400,6 +416,7 @@
|
|||||||
"incomplete_warning": "Faltan {count} productos por completar",
|
"incomplete_warning": "Faltan {count} productos por completar",
|
||||||
"incomplete_help": "Puedes continuar, pero recomendamos ingresar todas las cantidades para un mejor control de inventario.",
|
"incomplete_help": "Puedes continuar, pero recomendamos ingresar todas las cantidades para un mejor control de inventario.",
|
||||||
"complete": "Completar Configuración",
|
"complete": "Completar Configuración",
|
||||||
|
"continue_to_next": "Continuar",
|
||||||
"continue_anyway": "Continuar de todos modos",
|
"continue_anyway": "Continuar de todos modos",
|
||||||
"no_products_title": "Stock Inicial",
|
"no_products_title": "Stock Inicial",
|
||||||
"no_products_message": "Podrás configurar los niveles de stock más tarde en la sección de inventario."
|
"no_products_message": "Podrás configurar los niveles de stock más tarde en la sección de inventario."
|
||||||
|
|||||||
@@ -153,7 +153,9 @@
|
|||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"name": "ej., Baguette, Croissant",
|
"name": "ej., Baguette, Croissant",
|
||||||
"finished_product": "Seleccionar producto terminado..."
|
"finished_product": "Seleccionar producto terminado...",
|
||||||
|
"yield_quantity": "10",
|
||||||
|
"ingredient_quantity": "Cant."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"name_required": "El nombre de la receta es obligatorio",
|
"name_required": "El nombre de la receta es obligatorio",
|
||||||
|
|||||||
@@ -69,6 +69,40 @@
|
|||||||
"cleaning": "Garbiketa",
|
"cleaning": "Garbiketa",
|
||||||
"other": "Besteak"
|
"other": "Besteak"
|
||||||
},
|
},
|
||||||
|
"product_type": {
|
||||||
|
"ingredient": "Osagaia",
|
||||||
|
"finished_product": "Produktu Amaitua"
|
||||||
|
},
|
||||||
|
"production_stage": {
|
||||||
|
"raw_ingredient": "Osagai Gordina",
|
||||||
|
"par_baked": "Erdi-egosita",
|
||||||
|
"fully_baked": "Guztiz Egosita",
|
||||||
|
"prepared_dough": "Orea Prestatuta",
|
||||||
|
"frozen_product": "Produktu Izoztua"
|
||||||
|
},
|
||||||
|
"unit_of_measure": {
|
||||||
|
"kg": "Kilogramoak",
|
||||||
|
"g": "Gramoak",
|
||||||
|
"l": "Litroak",
|
||||||
|
"ml": "Mililitroak",
|
||||||
|
"units": "Unitateak",
|
||||||
|
"pcs": "Piezak",
|
||||||
|
"pkg": "Paketeak",
|
||||||
|
"bags": "Poltsak",
|
||||||
|
"boxes": "Kutxak"
|
||||||
|
},
|
||||||
|
"product_category": {
|
||||||
|
"bread": "Ogiak",
|
||||||
|
"croissants": "Croissantak",
|
||||||
|
"pastries": "Gozogintza",
|
||||||
|
"cakes": "Tartak",
|
||||||
|
"cookies": "Galletak",
|
||||||
|
"muffins": "Muffinak",
|
||||||
|
"sandwiches": "Ogitartekoak",
|
||||||
|
"seasonal": "Sasoikoak",
|
||||||
|
"beverages": "Edariak",
|
||||||
|
"other_products": "Beste Produktuak"
|
||||||
|
},
|
||||||
"stock_movement_type": {
|
"stock_movement_type": {
|
||||||
"PURCHASE": "Erosketa",
|
"PURCHASE": "Erosketa",
|
||||||
"PRODUCTION_USE": "Ekoizpenean Erabilera",
|
"PRODUCTION_USE": "Ekoizpenean Erabilera",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"description": "Oinarrizko informazioa",
|
"description": "Oinarrizko informazioa",
|
||||||
"description_enterprise": "Okindegi zentralaren informazioa"
|
"description_enterprise": "Okindegi zentralaren informazioa"
|
||||||
},
|
},
|
||||||
|
"child_tenants": {
|
||||||
|
"title": "Sukurtsalak Konfiguratu",
|
||||||
|
"description": "Erregistratu zure enpresa-sareko sukurtsalak"
|
||||||
|
},
|
||||||
"poi_detection": {
|
"poi_detection": {
|
||||||
"title": "Kokapen Analisia",
|
"title": "Kokapen Analisia",
|
||||||
"description": "Inguruko interesguneak detektatu"
|
"description": "Inguruko interesguneak detektatu"
|
||||||
@@ -29,6 +33,18 @@
|
|||||||
"title": "Inbentarioa Konfiguratu",
|
"title": "Inbentarioa Konfiguratu",
|
||||||
"description": "Salmenten datuak igo eta hasierako inbentarioa ezarri"
|
"description": "Salmenten datuak igo eta hasierako inbentarioa ezarri"
|
||||||
},
|
},
|
||||||
|
"upload_sales": {
|
||||||
|
"title": "Salmenta Datuak Igo",
|
||||||
|
"description": "Igo fitxategia salmenta historiarekin"
|
||||||
|
},
|
||||||
|
"inventory_review": {
|
||||||
|
"title": "Inbentarioa Berrikusi",
|
||||||
|
"description": "Berretsi detektatutako produktuak"
|
||||||
|
},
|
||||||
|
"stock": {
|
||||||
|
"title": "Stock Mailak",
|
||||||
|
"description": "Hasierako kantit aterak"
|
||||||
|
},
|
||||||
"suppliers": {
|
"suppliers": {
|
||||||
"title": "Hornitzaileak",
|
"title": "Hornitzaileak",
|
||||||
"description": "Konfiguratu zure hornitzaileak"
|
"description": "Konfiguratu zure hornitzaileak"
|
||||||
@@ -382,6 +398,7 @@
|
|||||||
"incomplete_warning": "{count} produktu osatu gabe geratzen dira",
|
"incomplete_warning": "{count} produktu osatu gabe geratzen dira",
|
||||||
"incomplete_help": "Jarraitu dezakezu, baina kopuru guztiak sartzea gomendatzen dizugu inbentario-kontrol hobeagorako.",
|
"incomplete_help": "Jarraitu dezakezu, baina kopuru guztiak sartzea gomendatzen dizugu inbentario-kontrol hobeagorako.",
|
||||||
"complete": "Konfigurazioa Osatu",
|
"complete": "Konfigurazioa Osatu",
|
||||||
|
"continue_to_next": "Jarraitu",
|
||||||
"continue_anyway": "Jarraitu hala ere",
|
"continue_anyway": "Jarraitu hala ere",
|
||||||
"no_products_title": "Hasierako Stocka",
|
"no_products_title": "Hasierako Stocka",
|
||||||
"no_products_message": "Stock-mailak geroago konfigura ditzakezu inbentario atalean."
|
"no_products_message": "Stock-mailak geroago konfigura ditzakezu inbentario atalean."
|
||||||
|
|||||||
@@ -114,6 +114,16 @@
|
|||||||
"quantity_required": "Kantitatea zero baino handiagoa izan behar da",
|
"quantity_required": "Kantitatea zero baino handiagoa izan behar da",
|
||||||
"expiration_past": "Iraungitze data iraganean dago",
|
"expiration_past": "Iraungitze data iraganean dago",
|
||||||
"expiring_soon": "Abisua: Osagai hau laster iraungitzen da!"
|
"expiring_soon": "Abisua: Osagai hau laster iraungitzen da!"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"basic-bakery": "Oinarrizko Okindegi Osagaiak",
|
||||||
|
"basic-bakery-desc": "Okindegi orokorrentzako funtsezko osagaiak",
|
||||||
|
"pastry-essentials": "Pastelgintza Oinarrizkoak",
|
||||||
|
"pastry-essentials-desc": "Pastel eta postreerak egiteko osagaiak",
|
||||||
|
"bread-basics": "Ogi Oinarrizkoak",
|
||||||
|
"bread-basics-desc": "Ogi artisanalean beharrezko guztia",
|
||||||
|
"chocolate-specialties": "Txokolate Espezialiteak",
|
||||||
|
"chocolate-specialties-desc": "Txokolatezko produktuentzat"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -153,7 +163,9 @@
|
|||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"name": "adib., Baguette, Croissant",
|
"name": "adib., Baguette, Croissant",
|
||||||
"finished_product": "Aukeratu produktu amaituak..."
|
"finished_product": "Aukeratu produktu amaituak...",
|
||||||
|
"yield_quantity": "10",
|
||||||
|
"ingredient_quantity": "Kant."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"name_required": "Errezeta izena beharrezkoa da",
|
"name_required": "Errezeta izena beharrezkoa da",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import aboutEs from './es/about.json';
|
|||||||
import demoEs from './es/demo.json';
|
import demoEs from './es/demo.json';
|
||||||
import blogEs from './es/blog.json';
|
import blogEs from './es/blog.json';
|
||||||
import alertsEs from './es/alerts.json';
|
import alertsEs from './es/alerts.json';
|
||||||
|
import onboardingEs from './es/onboarding.json';
|
||||||
|
import setupWizardEs from './es/setup_wizard.json';
|
||||||
|
|
||||||
// English translations
|
// English translations
|
||||||
import commonEn from './en/common.json';
|
import commonEn from './en/common.json';
|
||||||
@@ -49,6 +51,8 @@ import aboutEn from './en/about.json';
|
|||||||
import demoEn from './en/demo.json';
|
import demoEn from './en/demo.json';
|
||||||
import blogEn from './en/blog.json';
|
import blogEn from './en/blog.json';
|
||||||
import alertsEn from './en/alerts.json';
|
import alertsEn from './en/alerts.json';
|
||||||
|
import onboardingEn from './en/onboarding.json';
|
||||||
|
import setupWizardEn from './en/setup_wizard.json';
|
||||||
|
|
||||||
// Basque translations
|
// Basque translations
|
||||||
import commonEu from './eu/common.json';
|
import commonEu from './eu/common.json';
|
||||||
@@ -75,6 +79,8 @@ import aboutEu from './eu/about.json';
|
|||||||
import demoEu from './eu/demo.json';
|
import demoEu from './eu/demo.json';
|
||||||
import blogEu from './eu/blog.json';
|
import blogEu from './eu/blog.json';
|
||||||
import alertsEu from './eu/alerts.json';
|
import alertsEu from './eu/alerts.json';
|
||||||
|
import onboardingEu from './eu/onboarding.json';
|
||||||
|
import setupWizardEu from './eu/setup_wizard.json';
|
||||||
|
|
||||||
// Translation resources by language
|
// Translation resources by language
|
||||||
export const resources = {
|
export const resources = {
|
||||||
@@ -103,6 +109,8 @@ export const resources = {
|
|||||||
demo: demoEs,
|
demo: demoEs,
|
||||||
blog: blogEs,
|
blog: blogEs,
|
||||||
alerts: alertsEs,
|
alerts: alertsEs,
|
||||||
|
onboarding: onboardingEs,
|
||||||
|
setup_wizard: setupWizardEs,
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
common: commonEn,
|
common: commonEn,
|
||||||
@@ -129,6 +137,8 @@ export const resources = {
|
|||||||
demo: demoEn,
|
demo: demoEn,
|
||||||
blog: blogEn,
|
blog: blogEn,
|
||||||
alerts: alertsEn,
|
alerts: alertsEn,
|
||||||
|
onboarding: onboardingEn,
|
||||||
|
setup_wizard: setupWizardEn,
|
||||||
},
|
},
|
||||||
eu: {
|
eu: {
|
||||||
common: commonEu,
|
common: commonEu,
|
||||||
@@ -155,6 +165,8 @@ export const resources = {
|
|||||||
demo: demoEu,
|
demo: demoEu,
|
||||||
blog: blogEu,
|
blog: blogEu,
|
||||||
alerts: alertsEu,
|
alerts: alertsEu,
|
||||||
|
onboarding: onboardingEu,
|
||||||
|
setup_wizard: setupWizardEu,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,7 +203,7 @@ export const languageConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Namespaces available in translations
|
// Namespaces available in translations
|
||||||
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings', 'ajustes', 'reasoning', 'wizards', 'subscription', 'purchase_orders', 'help', 'features', 'about', 'demo', 'blog', 'alerts'] as const;
|
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings', 'ajustes', 'reasoning', 'wizards', 'subscription', 'purchase_orders', 'help', 'features', 'about', 'demo', 'blog', 'alerts', 'onboarding', 'setup_wizard'] as const;
|
||||||
export type Namespace = typeof namespaces[number];
|
export type Namespace = typeof namespaces[number];
|
||||||
|
|
||||||
// Helper function to get language display name
|
// Helper function to get language display name
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ from app.schemas.inventory import (
|
|||||||
StockResponse,
|
StockResponse,
|
||||||
StockCreate,
|
StockCreate,
|
||||||
StockUpdate,
|
StockUpdate,
|
||||||
|
BulkIngredientCreate,
|
||||||
|
BulkIngredientResponse,
|
||||||
|
BulkIngredientResult,
|
||||||
)
|
)
|
||||||
from shared.auth.decorators import get_current_user_dep
|
from shared.auth.decorators import get_current_user_dep
|
||||||
from shared.auth.access_control import require_user_role, admin_role_required, owner_role_required
|
from shared.auth.access_control import require_user_role, admin_role_required, owner_role_required
|
||||||
@@ -157,6 +160,162 @@ async def create_ingredient(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
route_builder.build_base_route("ingredients/bulk"),
|
||||||
|
response_model=BulkIngredientResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
@require_user_role(['admin', 'owner'])
|
||||||
|
async def bulk_create_ingredients(
|
||||||
|
bulk_data: BulkIngredientCreate,
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create multiple ingredients in a single transaction (Admin/Manager only)"""
|
||||||
|
import uuid
|
||||||
|
transaction_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
try:
|
||||||
|
# CRITICAL: Check subscription limit ONCE before creating any ingredients
|
||||||
|
from app.core.config import settings
|
||||||
|
total_requested = len(bulk_data.ingredients)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
try:
|
||||||
|
# Check if we can add this many products
|
||||||
|
limit_check_response = await client.get(
|
||||||
|
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/subscriptions/{tenant_id}/can-add-products/{total_requested}",
|
||||||
|
headers={
|
||||||
|
"x-user-id": str(current_user.get('user_id')),
|
||||||
|
"x-tenant-id": str(tenant_id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if limit_check_response.status_code == 200:
|
||||||
|
limit_check = limit_check_response.json()
|
||||||
|
|
||||||
|
if not limit_check.get('can_add', False):
|
||||||
|
logger.warning(
|
||||||
|
"Bulk product limit exceeded",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
requested=total_requested,
|
||||||
|
current=limit_check.get('current_count'),
|
||||||
|
max=limit_check.get('max_allowed'),
|
||||||
|
reason=limit_check.get('reason')
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
detail={
|
||||||
|
"error": "product_limit_exceeded",
|
||||||
|
"message": limit_check.get('reason', 'Product limit exceeded'),
|
||||||
|
"requested": total_requested,
|
||||||
|
"current_count": limit_check.get('current_count'),
|
||||||
|
"max_allowed": limit_check.get('max_allowed'),
|
||||||
|
"upgrade_required": True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to check product limit, allowing bulk creation",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
status_code=limit_check_response.status_code
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.warning(
|
||||||
|
"Timeout checking product limit, allowing bulk creation",
|
||||||
|
tenant_id=str(tenant_id)
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.warning(
|
||||||
|
"Error checking product limit, allowing bulk creation",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract user ID - handle service tokens
|
||||||
|
raw_user_id = current_user.get('user_id')
|
||||||
|
if current_user.get('type') == 'service':
|
||||||
|
user_id = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
user_id = UUID(raw_user_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
user_id = None
|
||||||
|
|
||||||
|
# Create all ingredients
|
||||||
|
service = InventoryService()
|
||||||
|
results: List[BulkIngredientResult] = []
|
||||||
|
total_created = 0
|
||||||
|
total_failed = 0
|
||||||
|
|
||||||
|
for index, ingredient_data in enumerate(bulk_data.ingredients):
|
||||||
|
try:
|
||||||
|
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
|
||||||
|
results.append(BulkIngredientResult(
|
||||||
|
index=index,
|
||||||
|
success=True,
|
||||||
|
ingredient=IngredientResponse.from_orm(ingredient),
|
||||||
|
error=None
|
||||||
|
))
|
||||||
|
total_created += 1
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Ingredient created in bulk operation",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
ingredient_id=str(ingredient.id),
|
||||||
|
ingredient_name=ingredient.name,
|
||||||
|
index=index,
|
||||||
|
transaction_id=transaction_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
results.append(BulkIngredientResult(
|
||||||
|
index=index,
|
||||||
|
success=False,
|
||||||
|
ingredient=None,
|
||||||
|
error=str(e)
|
||||||
|
))
|
||||||
|
total_failed += 1
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Failed to create ingredient in bulk operation",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
index=index,
|
||||||
|
error=str(e),
|
||||||
|
transaction_id=transaction_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Bulk ingredient creation completed",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
total_requested=total_requested,
|
||||||
|
total_created=total_created,
|
||||||
|
total_failed=total_failed,
|
||||||
|
transaction_id=transaction_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return BulkIngredientResponse(
|
||||||
|
total_requested=total_requested,
|
||||||
|
total_created=total_created,
|
||||||
|
total_failed=total_failed,
|
||||||
|
results=results,
|
||||||
|
transaction_id=transaction_id
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to process bulk ingredient creation",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
error=str(e),
|
||||||
|
transaction_id=transaction_id
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to process bulk ingredient creation"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
route_builder.build_base_route("ingredients/count"),
|
route_builder.build_base_route("ingredients/count"),
|
||||||
response_model=dict
|
response_model=dict
|
||||||
|
|||||||
@@ -171,6 +171,30 @@ class IngredientResponse(InventoryBaseSchema):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ===== BULK INGREDIENT SCHEMAS =====
|
||||||
|
|
||||||
|
class BulkIngredientCreate(InventoryBaseSchema):
|
||||||
|
"""Schema for bulk creating ingredients"""
|
||||||
|
ingredients: List[IngredientCreate] = Field(..., description="List of ingredients to create")
|
||||||
|
|
||||||
|
|
||||||
|
class BulkIngredientResult(InventoryBaseSchema):
|
||||||
|
"""Schema for individual result in bulk operation"""
|
||||||
|
index: int = Field(..., description="Index of the ingredient in the original request")
|
||||||
|
success: bool = Field(..., description="Whether the creation succeeded")
|
||||||
|
ingredient: Optional[IngredientResponse] = Field(None, description="Created ingredient (if successful)")
|
||||||
|
error: Optional[str] = Field(None, description="Error message (if failed)")
|
||||||
|
|
||||||
|
|
||||||
|
class BulkIngredientResponse(InventoryBaseSchema):
|
||||||
|
"""Schema for bulk ingredient creation response"""
|
||||||
|
total_requested: int = Field(..., description="Total number of ingredients requested")
|
||||||
|
total_created: int = Field(..., description="Number of ingredients successfully created")
|
||||||
|
total_failed: int = Field(..., description="Number of ingredients that failed")
|
||||||
|
results: List[BulkIngredientResult] = Field(..., description="Detailed results for each ingredient")
|
||||||
|
transaction_id: str = Field(..., description="Transaction ID for audit trail")
|
||||||
|
|
||||||
|
|
||||||
# ===== STOCK SCHEMAS =====
|
# ===== STOCK SCHEMAS =====
|
||||||
|
|
||||||
class StockCreate(InventoryBaseSchema):
|
class StockCreate(InventoryBaseSchema):
|
||||||
|
|||||||
Reference in New Issue
Block a user