This commit is contained in:
Urtzi Alfaro
2026-01-01 19:01:33 +01:00
parent d6728b4bd8
commit 93c9475239
10 changed files with 286 additions and 84 deletions

View File

@@ -23,6 +23,7 @@ import {
ProductTransformationResponse, ProductTransformationResponse,
ProductionStage, ProductionStage,
DeletionSummary, DeletionSummary,
BulkStockResponse,
} from '../types/inventory'; } from '../types/inventory';
import { ApiError } from '../client'; import { ApiError } from '../client';
@@ -355,6 +356,30 @@ export const useAddStock = (
}); });
}; };
export const useBulkAddStock = (
options?: UseMutationOptions<BulkStockResponse, ApiError, { tenantId: string; stocks: StockCreate[] }>
) => {
const queryClient = useQueryClient();
return useMutation<BulkStockResponse, ApiError, { tenantId: string; stocks: StockCreate[] }>({
mutationFn: ({ tenantId, stocks }) => inventoryService.bulkAddStock(tenantId, stocks),
onSuccess: (data, { tenantId }) => {
// Invalidate all stock queries since multiple ingredients may have been affected
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
// Invalidate per-ingredient stock queries
data.results.forEach((result) => {
if (result.success && result.stock) {
queryClient.invalidateQueries({
queryKey: inventoryKeys.stock.byIngredient(tenantId, result.stock.ingredient_id)
});
}
});
},
...options,
});
};
export const useUpdateStock = ( export const useUpdateStock = (
options?: UseMutationOptions< options?: UseMutationOptions<
StockResponse, StockResponse,

View File

@@ -29,6 +29,7 @@ import {
StockFilter, StockFilter,
StockMovementCreate, StockMovementCreate,
StockMovementResponse, StockMovementResponse,
BulkStockResponse,
// Operations // Operations
StockConsumptionRequest, StockConsumptionRequest,
StockConsumptionResponse, StockConsumptionResponse,
@@ -162,6 +163,16 @@ export class InventoryService {
); );
} }
async bulkAddStock(
tenantId: string,
stocks: StockCreate[]
): Promise<BulkStockResponse> {
return apiClient.post<BulkStockResponse>(
`${this.baseUrl}/${tenantId}/inventory/stock/bulk`,
{ stocks }
);
}
async getStock(tenantId: string, stockId: string): Promise<StockResponse> { async getStock(tenantId: string, stockId: string): Promise<StockResponse> {
return apiClient.get<StockResponse>( return apiClient.get<StockResponse>(
`${this.baseUrl}/${tenantId}/inventory/stock/${stockId}` `${this.baseUrl}/${tenantId}/inventory/stock/${stockId}`

View File

@@ -330,6 +330,28 @@ export interface StockResponse {
ingredient?: IngredientResponse | null; ingredient?: IngredientResponse | null;
} }
// ===== BULK STOCK SCHEMAS =====
// Mirror: BulkStockCreate, BulkStockResult, BulkStockResponse from inventory.py
export interface BulkStockCreate {
stocks: StockCreate[];
}
export interface BulkStockResult {
index: number;
success: boolean;
stock: StockResponse | null;
error: string | null;
}
export interface BulkStockResponse {
total_requested: number;
total_created: number;
total_failed: number;
results: BulkStockResult[];
transaction_id: string;
}
// ===== STOCK MOVEMENT SCHEMAS ===== // ===== STOCK MOVEMENT SCHEMAS =====
// Mirror: StockMovementCreate from inventory.py:277 // Mirror: StockMovementCreate from inventory.py:277

View File

@@ -1,12 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SetupStepProps } from '../types'; import { SetupStepProps } from '../types';
import { useIngredients, useCreateIngredient, useUpdateIngredient, useSoftDeleteIngredient, useAddStock, useStockByIngredient } from '../../../../api/hooks/inventory'; import { useIngredients, useCreateIngredient, useUpdateIngredient, useSoftDeleteIngredient, useBulkAddStock } from '../../../../api/hooks/inventory';
import { useSuppliers } from '../../../../api/hooks/suppliers'; import { useSuppliers } from '../../../../api/hooks/suppliers';
import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAuthUser } from '../../../../stores/auth.store'; import { useAuthUser } from '../../../../stores/auth.store';
import { UnitOfMeasure, IngredientCategory, ProductionStage } from '../../../../api/types/inventory'; import { UnitOfMeasure, IngredientCategory, ProductionStage } from '../../../../api/types/inventory';
import type { IngredientCreate, IngredientUpdate, StockCreate, StockResponse } from '../../../../api/types/inventory'; import type { IngredientCreate, IngredientUpdate, StockCreate } from '../../../../api/types/inventory';
import { ESSENTIAL_INGREDIENTS, COMMON_INGREDIENTS, PACKAGING_ITEMS, type IngredientTemplate, templateToIngredientCreate } from '../data/ingredientTemplates'; import { ESSENTIAL_INGREDIENTS, COMMON_INGREDIENTS, PACKAGING_ITEMS, type IngredientTemplate, templateToIngredientCreate } from '../data/ingredientTemplates';
export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplete, onPrevious, canContinue }) => { export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplete, onPrevious, canContinue }) => {
@@ -29,7 +29,7 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
const createIngredientMutation = useCreateIngredient(); const createIngredientMutation = useCreateIngredient();
const updateIngredientMutation = useUpdateIngredient(); const updateIngredientMutation = useUpdateIngredient();
const deleteIngredientMutation = useSoftDeleteIngredient(); const deleteIngredientMutation = useSoftDeleteIngredient();
const addStockMutation = useAddStock(); const bulkAddStockMutation = useBulkAddStock();
// Form state // Form state
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
@@ -59,8 +59,10 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
}); });
const [stockErrors, setStockErrors] = useState<Record<string, string>>({}); const [stockErrors, setStockErrors] = useState<Record<string, string>>({});
// Track stocks added per ingredient (for displaying the list) // Track pending stocks to be submitted in batch (for display and batch submission)
const [ingredientStocks, setIngredientStocks] = useState<Record<string, StockResponse[]>>({}); const [pendingStocks, setPendingStocks] = useState<StockCreate[]>([]);
// Track stocks display per ingredient (using StockCreate for pending, before API submission)
const [ingredientStocks, setIngredientStocks] = useState<Record<string, Array<StockCreate & { _tempId: string }>>>({});
// Notify parent when count changes // Notify parent when count changes
useEffect(() => { useEffect(() => {
@@ -270,7 +272,6 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
const handleSaveStock = async (addAnother: boolean = false) => { const handleSaveStock = async (addAnother: boolean = false) => {
if (!addingStockForId || !validateStockForm()) return; if (!addingStockForId || !validateStockForm()) return;
try {
const stockData: StockCreate = { const stockData: StockCreate = {
ingredient_id: addingStockForId, ingredient_id: addingStockForId,
current_quantity: Number(stockFormData.current_quantity), current_quantity: Number(stockFormData.current_quantity),
@@ -282,15 +283,17 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
quality_status: 'good', quality_status: 'good',
}; };
const result = await addStockMutation.mutateAsync({ // Generate a temporary ID for display purposes
tenantId, const tempId = `temp-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
stockData, const stockWithTempId = { ...stockData, _tempId: tempId };
});
// Add to pending stocks for batch submission
setPendingStocks(prev => [...prev, stockData]);
// Add to local state for display // Add to local state for display
setIngredientStocks(prev => ({ setIngredientStocks(prev => ({
...prev, ...prev,
[addingStockForId]: [...(prev[addingStockForId] || []), result], [addingStockForId]: [...(prev[addingStockForId] || []), stockWithTempId],
})); }));
if (addAnother) { if (addAnother) {
@@ -306,19 +309,42 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
} else { } else {
handleCancelStock(); handleCancelStock();
} }
} catch (error) {
console.error('Error adding stock:', error);
setStockErrors({ submit: t('common:error_saving', 'Error saving. Please try again.') });
}
}; };
const handleDeleteStock = async (ingredientId: string, stockId: string) => { const handleDeleteStock = (ingredientId: string, tempId: string) => {
// Remove from local state // Remove from local display state
setIngredientStocks(prev => ({ setIngredientStocks(prev => ({
...prev, ...prev,
[ingredientId]: (prev[ingredientId] || []).filter(s => s.id !== stockId), [ingredientId]: (prev[ingredientId] || []).filter(s => s._tempId !== tempId),
})); }));
// Note: We don't delete from backend during setup - stocks are created and can be managed later // Remove from pending stocks
setPendingStocks(prev => prev.filter((s) => {
// Find and remove the matching stock entry
const stocksForIngredient = ingredientStocks[ingredientId] || [];
const stockToRemove = stocksForIngredient.find(st => st._tempId === tempId);
if (!stockToRemove) return true;
return s.ingredient_id !== stockToRemove.ingredient_id ||
s.current_quantity !== stockToRemove.current_quantity ||
s.batch_number !== stockToRemove.batch_number;
}));
};
// Submit all pending stocks when proceeding to next step
const handleSubmitPendingStocks = async (): Promise<boolean> => {
if (pendingStocks.length === 0) return true;
try {
await bulkAddStockMutation.mutateAsync({
tenantId,
stocks: pendingStocks,
});
// Clear pending stocks after successful submission
setPendingStocks([]);
return true;
} catch (error) {
console.error('Error submitting stocks:', error);
return false;
}
}; };
const categoryOptions = [ const categoryOptions = [
@@ -640,7 +666,7 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
<div className="ml-6 space-y-1"> <div className="ml-6 space-y-1">
{stocks.map((stock) => ( {stocks.map((stock) => (
<div <div
key={stock.id} key={stock._tempId}
className="flex items-center justify-between p-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded text-sm" className="flex items-center justify-between p-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded text-sm"
> >
<div className="flex items-center gap-3 text-xs"> <div className="flex items-center gap-3 text-xs">
@@ -659,7 +685,7 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
</div> </div>
<button <button
type="button" type="button"
onClick={() => handleDeleteStock(ingredient.id, stock.id)} onClick={() => handleDeleteStock(ingredient.id, stock._tempId)}
className="p-1 text-[var(--text-secondary)] hover:text-[var(--color-error)] rounded transition-colors" className="p-1 text-[var(--text-secondary)] hover:text-[var(--color-error)] rounded transition-colors"
aria-label="Delete lot" aria-label="Delete lot"
> >
@@ -782,33 +808,19 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
<button <button
type="button" type="button"
onClick={() => handleSaveStock(true)} onClick={() => handleSaveStock(true)}
disabled={addStockMutation.isPending} className="px-4 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-secondary)] text-[var(--text-primary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
className="px-4 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-secondary)] text-[var(--text-primary)] rounded hover:bg-[var(--bg-secondary)] disabled:opacity-50 transition-colors"
> >
{t('setup_wizard:inventory.add_another_lot', '+ Add Another Lot')} {t('setup_wizard:inventory.add_another_lot', '+ Add Another Lot')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => handleSaveStock(false)} onClick={() => handleSaveStock(false)}
disabled={addStockMutation.isPending} className="px-4 py-2 text-sm bg-[var(--color-primary)] text-white rounded hover:bg-[var(--color-primary-dark)] transition-colors flex items-center gap-1"
className="px-4 py-2 text-sm bg-[var(--color-primary)] text-white rounded hover:bg-[var(--color-primary-dark)] disabled:opacity-50 transition-colors flex items-center gap-1"
> >
{addStockMutation.isPending ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{t('common:saving', 'Saving...')}
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
{t('common:save', 'Save')} {t('common:save', 'Save')}
</>
)}
</button> </button>
</div> </div>
@@ -1021,13 +1033,33 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{pendingStocks.length > 0 && (
<span className="text-sm text-[var(--text-secondary)]">
{pendingStocks.length} {t('setup_wizard:inventory.pending_stocks', 'stock entries pending')}
</span>
)}
<button <button
type="button" type="button"
onClick={() => onComplete()} onClick={async () => {
disabled={canContinue === false} const success = await handleSubmitPendingStocks();
if (success) {
onComplete();
}
}}
disabled={canContinue === false || bulkAddStockMutation.isPending}
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center gap-2" className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center gap-2"
> >
{t('common:next', 'Continuar →')} {bulkAddStockMutation.isPending ? (
<>
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{t('common:saving', 'Saving...')}
</>
) : (
t('common:next', 'Continuar →')
)}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -79,10 +79,10 @@ const DemoPage = () => {
// Helper function to calculate estimated progress based on elapsed time // Helper function to calculate estimated progress based on elapsed time
const calculateEstimatedProgress = (tier: string, startTime: number): number => { const calculateEstimatedProgress = (tier: string, startTime: number): number => {
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
const duration = tier === 'enterprise' ? 90000 : 40000; // ms (90s for enterprise, 40s for professional) const duration = 5000; // ms (5s for both professional and enterprise)
const linearProgress = Math.min(95, (elapsed / duration) * 100); const linearProgress = Math.min(95, (elapsed / duration) * 100);
// Logarithmic curve for natural feel - starts fast, slows down // Logarithmic curve for natural feel - starts fast, slows down
return Math.min(95, Math.round(linearProgress * (1 - Math.exp(-elapsed / 10000)))); return Math.min(95, Math.round(linearProgress * (1 - Math.exp(-elapsed / 1000))));
}; };
const demoOptions = [ const demoOptions = [
@@ -645,6 +645,7 @@ const DemoPage = () => {
<div <div
key={option.id} key={option.id}
className={` className={`
flex flex-col
bg-[var(--bg-primary)] bg-[var(--bg-primary)]
border border-[var(--border-primary)] border border-[var(--border-primary)]
rounded-xl rounded-xl
@@ -691,7 +692,7 @@ const DemoPage = () => {
</div> </div>
{/* Card Body */} {/* Card Body */}
<div className="p-6"> <div className="p-6 flex-1">
{/* Features List with Icons */} {/* Features List with Icons */}
<div className="space-y-3 mb-6"> <div className="space-y-3 mb-6">
<h4 className="font-semibold text-[var(--text-primary)] text-sm uppercase tracking-wide mb-4"> <h4 className="font-semibold text-[var(--text-primary)] text-sm uppercase tracking-wide mb-4">
@@ -765,7 +766,7 @@ const DemoPage = () => {
</div> </div>
{/* Card Footer */} {/* Card Footer */}
<div className="p-6 bg-[var(--bg-secondary)] border-t border-[var(--border-primary)]"> <div className="px-6 py-4 bg-[var(--bg-secondary)] border-t border-[var(--border-primary)]">
<Button <Button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -774,7 +775,7 @@ const DemoPage = () => {
disabled={creatingTier !== null} disabled={creatingTier !== null}
size="lg" size="lg"
isFullWidth={true} isFullWidth={true}
variant={option.tier === 'enterprise' ? 'gradient' : 'primary'} variant="gradient"
className="font-semibold group" className="font-semibold group"
> >
{creatingTier === option.tier ? ( {creatingTier === option.tier ? (

View File

@@ -293,7 +293,7 @@ export const routesConfig: RouteConfig[] = [
name: 'Distribution', name: 'Distribution',
component: 'DistributionPage', component: 'DistributionPage',
title: 'Distribución', title: 'Distribución',
icon: 'truck', icon: 'distribution',
requiresAuth: true, requiresAuth: true,
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,

View File

@@ -426,8 +426,7 @@ class DemoSessionManager:
estimated_remaining_seconds = None estimated_remaining_seconds = None
if session.cloning_started_at and not session.cloning_completed_at: if session.cloning_started_at and not session.cloning_completed_at:
elapsed = (datetime.now(timezone.utc) - session.cloning_started_at).total_seconds() elapsed = (datetime.now(timezone.utc) - session.cloning_started_at).total_seconds()
# Professional: ~40s average, Enterprise: ~75s average avg_duration = 5
avg_duration = 75 if session.demo_account_type == 'enterprise' else 40
estimated_remaining_seconds = max(0, int(avg_duration - elapsed)) estimated_remaining_seconds = max(0, int(avg_duration - elapsed))
status_data = { status_data = {
@@ -480,8 +479,7 @@ class DemoSessionManager:
estimated_remaining_seconds = None estimated_remaining_seconds = None
if session.cloning_started_at and not session.cloning_completed_at: if session.cloning_started_at and not session.cloning_completed_at:
elapsed = (datetime.now(timezone.utc) - session.cloning_started_at).total_seconds() elapsed = (datetime.now(timezone.utc) - session.cloning_started_at).total_seconds()
# Professional: ~40s average, Enterprise: ~75s average avg_duration = 5
avg_duration = 75 if session.demo_account_type == 'enterprise' else 40
estimated_remaining_seconds = max(0, int(avg_duration - elapsed)) estimated_remaining_seconds = max(0, int(avg_duration - elapsed))
return { return {

View File

@@ -16,7 +16,9 @@ from app.schemas.inventory import (
StockUpdate, StockUpdate,
StockResponse, StockResponse,
StockMovementCreate, StockMovementCreate,
StockMovementResponse StockMovementResponse,
BulkStockCreate,
BulkStockResponse
) )
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 from shared.auth.access_control import require_user_role, admin_role_required
@@ -73,6 +75,37 @@ async def add_stock(
) )
@router.post(
route_builder.build_base_route("stock/bulk"),
response_model=BulkStockResponse,
status_code=status.HTTP_201_CREATED
)
@require_user_role(['admin', 'owner', 'member'])
async def bulk_add_stock(
bulk_data: BulkStockCreate,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Bulk add stock entries for efficient batch operations"""
try:
user_id = get_current_user_id(current_user)
service = InventoryService()
result = await service.bulk_add_stock(bulk_data, tenant_id, user_id)
return result
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
logger.error("Failed to bulk add stock", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to bulk add stock"
)
@router.get( @router.get(
route_builder.build_base_route("stock"), route_builder.build_base_route("stock"),
response_model=List[StockResponse] response_model=List[StockResponse]

View File

@@ -366,6 +366,30 @@ class StockResponse(InventoryBaseSchema):
ingredient: Optional[IngredientResponse] = None ingredient: Optional[IngredientResponse] = None
# ===== BULK STOCK SCHEMAS =====
class BulkStockCreate(InventoryBaseSchema):
"""Schema for bulk creating stock entries"""
stocks: List[StockCreate] = Field(..., description="List of stock entries to create")
class BulkStockResult(InventoryBaseSchema):
"""Schema for individual result in bulk stock operation"""
index: int = Field(..., description="Index of the stock in the original request")
success: bool = Field(..., description="Whether the creation succeeded")
stock: Optional[StockResponse] = Field(None, description="Created stock (if successful)")
error: Optional[str] = Field(None, description="Error message (if failed)")
class BulkStockResponse(InventoryBaseSchema):
"""Schema for bulk stock creation response"""
total_requested: int = Field(..., description="Total number of stock entries requested")
total_created: int = Field(..., description="Number of stock entries successfully created")
total_failed: int = Field(..., description="Number of stock entries that failed")
results: List[BulkStockResult] = Field(..., description="Detailed results for each stock entry")
transaction_id: str = Field(..., description="Transaction ID for audit trail")
# ===== STOCK MOVEMENT SCHEMAS ===== # ===== STOCK MOVEMENT SCHEMAS =====
class StockMovementCreate(InventoryBaseSchema): class StockMovementCreate(InventoryBaseSchema):

View File

@@ -347,6 +347,62 @@ class InventoryService:
logger.error("Failed to add stock", error=str(e), tenant_id=tenant_id) logger.error("Failed to add stock", error=str(e), tenant_id=tenant_id)
raise raise
async def bulk_add_stock(
self,
bulk_data: 'BulkStockCreate',
tenant_id: UUID,
user_id: Optional[UUID] = None
) -> 'BulkStockResponse':
"""Bulk add stock entries for efficient batch operations"""
import uuid as uuid_lib
from app.schemas.inventory import BulkStockCreate, BulkStockResult, BulkStockResponse
transaction_id = str(uuid_lib.uuid4())
results = []
total_created = 0
total_failed = 0
for index, stock_data in enumerate(bulk_data.stocks):
try:
stock_response = await self.add_stock(stock_data, tenant_id, user_id)
results.append(BulkStockResult(
index=index,
success=True,
stock=stock_response,
error=None
))
total_created += 1
except Exception as e:
logger.warning(
"Failed to create stock in bulk operation",
index=index,
ingredient_id=stock_data.ingredient_id,
error=str(e)
)
results.append(BulkStockResult(
index=index,
success=False,
stock=None,
error=str(e)
))
total_failed += 1
logger.info(
"Bulk stock operation completed",
transaction_id=transaction_id,
total_requested=len(bulk_data.stocks),
total_created=total_created,
total_failed=total_failed
)
return BulkStockResponse(
total_requested=len(bulk_data.stocks),
total_created=total_created,
total_failed=total_failed,
results=results,
transaction_id=transaction_id
)
async def consume_stock( async def consume_stock(
self, self,
ingredient_id: UUID, ingredient_id: UUID,