Improve the frontend modals
This commit is contained in:
@@ -398,11 +398,80 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
|
||||
from app.schemas.inventory import IngredientUpdate
|
||||
update_data = IngredientUpdate(last_purchase_price=price)
|
||||
return await self.update(ingredient_id, update_data)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update last purchase price", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
async def update_weighted_average_cost(
|
||||
self,
|
||||
ingredient_id: UUID,
|
||||
current_stock_quantity: float,
|
||||
new_purchase_quantity: float,
|
||||
new_unit_cost: float
|
||||
) -> Optional[Ingredient]:
|
||||
"""
|
||||
Update the average cost using weighted average calculation.
|
||||
|
||||
Formula:
|
||||
new_average_cost = (current_stock_qty × current_avg_cost + new_qty × new_cost) / (current_stock_qty + new_qty)
|
||||
|
||||
Args:
|
||||
ingredient_id: ID of the ingredient
|
||||
current_stock_quantity: Current stock quantity before this purchase
|
||||
new_purchase_quantity: Quantity being purchased
|
||||
new_unit_cost: Unit cost of the new purchase
|
||||
|
||||
Returns:
|
||||
Updated ingredient or None if not found
|
||||
"""
|
||||
try:
|
||||
# Get current ingredient data
|
||||
ingredient = await self.get_by_id(ingredient_id)
|
||||
if not ingredient:
|
||||
logger.warning("Ingredient not found for average cost update", ingredient_id=ingredient_id)
|
||||
return None
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
# Get current average cost (default to new cost if not set)
|
||||
current_avg_cost = float(ingredient.average_cost) if ingredient.average_cost else float(new_unit_cost)
|
||||
|
||||
# Calculate weighted average
|
||||
# If no current stock, just use the new purchase price
|
||||
if current_stock_quantity <= 0:
|
||||
new_average_cost = Decimal(str(new_unit_cost))
|
||||
else:
|
||||
# Weighted average formula
|
||||
total_cost = (current_stock_quantity * current_avg_cost) + (new_purchase_quantity * new_unit_cost)
|
||||
total_quantity = current_stock_quantity + new_purchase_quantity
|
||||
new_average_cost = Decimal(str(total_cost / total_quantity))
|
||||
|
||||
# Update the ingredient
|
||||
from app.schemas.inventory import IngredientUpdate
|
||||
update_data = IngredientUpdate(average_cost=new_average_cost)
|
||||
updated_ingredient = await self.update(ingredient_id, update_data)
|
||||
|
||||
logger.info(
|
||||
"Updated weighted average cost",
|
||||
ingredient_id=ingredient_id,
|
||||
old_average_cost=current_avg_cost,
|
||||
new_average_cost=float(new_average_cost),
|
||||
current_stock_qty=current_stock_quantity,
|
||||
new_purchase_qty=new_purchase_quantity,
|
||||
new_unit_cost=new_unit_cost
|
||||
)
|
||||
|
||||
return updated_ingredient
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to update weighted average cost",
|
||||
error=str(e),
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_ingredients_by_category(self, tenant_id: UUID, category: str) -> List[Ingredient]:
|
||||
"""Get all ingredients in a specific category"""
|
||||
try:
|
||||
|
||||
@@ -28,7 +28,9 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
|
||||
self,
|
||||
movement_data: StockMovementCreate,
|
||||
tenant_id: UUID,
|
||||
created_by: Optional[UUID] = None
|
||||
created_by: Optional[UUID] = None,
|
||||
quantity_before: Optional[float] = None,
|
||||
quantity_after: Optional[float] = None
|
||||
) -> StockMovement:
|
||||
"""Create a new stock movement record"""
|
||||
try:
|
||||
@@ -37,6 +39,12 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
|
||||
create_data['tenant_id'] = tenant_id
|
||||
create_data['created_by'] = created_by
|
||||
|
||||
# Add quantity_before and quantity_after if provided
|
||||
if quantity_before is not None:
|
||||
create_data['quantity_before'] = quantity_before
|
||||
if quantity_after is not None:
|
||||
create_data['quantity_after'] = quantity_after
|
||||
|
||||
# Ensure movement_type is properly converted to enum value
|
||||
if 'movement_type' in create_data:
|
||||
movement_type = create_data['movement_type']
|
||||
@@ -65,6 +73,8 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
|
||||
ingredient_id=record.ingredient_id,
|
||||
movement_type=record.movement_type if record.movement_type else None,
|
||||
quantity=record.quantity,
|
||||
quantity_before=record.quantity_before,
|
||||
quantity_after=record.quantity_after,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return record
|
||||
@@ -453,7 +463,7 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
|
||||
# Generate reference number
|
||||
reference_number = f"AUTO-EXPIRE-{batch_number or stock_id}"
|
||||
|
||||
# Create movement data
|
||||
# Create movement data (without quantity_before/quantity_after - these will be calculated by the caller)
|
||||
movement_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'ingredient_id': ingredient_id,
|
||||
@@ -462,8 +472,6 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
|
||||
'quantity': quantity,
|
||||
'unit_cost': Decimal(str(unit_cost)) if unit_cost else None,
|
||||
'total_cost': total_cost,
|
||||
'quantity_before': quantity,
|
||||
'quantity_after': 0,
|
||||
'reference_number': reference_number,
|
||||
'reason_code': 'expired',
|
||||
'notes': f"Lote automáticamente marcado como caducado. Vencimiento: {expiration_date.strftime('%Y-%m-%d')}",
|
||||
@@ -536,4 +544,4 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get inventory waste total", error=str(e), tenant_id=str(tenant_id))
|
||||
raise
|
||||
raise
|
||||
|
||||
@@ -43,10 +43,10 @@ class IngredientCreate(InventoryBaseSchema):
|
||||
brand: Optional[str] = Field(None, max_length=100, description="Brand name")
|
||||
unit_of_measure: UnitOfMeasure = Field(..., description="Unit of measure")
|
||||
package_size: Optional[float] = Field(None, gt=0, description="Package size")
|
||||
|
||||
|
||||
# Pricing
|
||||
average_cost: Optional[Decimal] = Field(None, ge=0, description="Average cost per unit")
|
||||
standard_cost: Optional[Decimal] = Field(None, ge=0, description="Standard cost per unit")
|
||||
# Note: average_cost is calculated automatically from purchases (not set on create)
|
||||
standard_cost: Optional[Decimal] = Field(None, ge=0, description="Standard/target cost per unit for budgeting")
|
||||
|
||||
# Stock management
|
||||
low_stock_threshold: float = Field(10.0, ge=0, description="Low stock alert threshold")
|
||||
@@ -187,6 +187,13 @@ class StockCreate(InventoryBaseSchema):
|
||||
shelf_life_days: Optional[int] = Field(None, gt=0, description="Batch-specific shelf life in days")
|
||||
storage_instructions: Optional[str] = Field(None, description="Batch-specific storage instructions")
|
||||
|
||||
@validator('supplier_id', pre=True)
|
||||
def validate_supplier_id(cls, v):
|
||||
"""Convert empty string to None for optional UUID field"""
|
||||
if v == '' or (isinstance(v, str) and v.strip() == ''):
|
||||
return None
|
||||
return v
|
||||
|
||||
@validator('storage_temperature_max')
|
||||
def validate_temperature_range(cls, v, values):
|
||||
min_temp = values.get('storage_temperature_min')
|
||||
@@ -233,6 +240,13 @@ class StockUpdate(InventoryBaseSchema):
|
||||
shelf_life_days: Optional[int] = Field(None, gt=0, description="Batch-specific shelf life in days")
|
||||
storage_instructions: Optional[str] = Field(None, description="Batch-specific storage instructions")
|
||||
|
||||
@validator('supplier_id', pre=True)
|
||||
def validate_supplier_id(cls, v):
|
||||
"""Convert empty string to None for optional UUID field"""
|
||||
if v == '' or (isinstance(v, str) and v.strip() == ''):
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
class StockResponse(InventoryBaseSchema):
|
||||
"""Schema for stock API responses"""
|
||||
|
||||
@@ -10,6 +10,7 @@ import uuid
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
import structlog
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import text
|
||||
@@ -19,6 +20,8 @@ from shared.alerts.templates import format_item_message
|
||||
from app.repositories.stock_repository import StockRepository
|
||||
from app.repositories.stock_movement_repository import StockMovementRepository
|
||||
from app.repositories.inventory_alert_repository import InventoryAlertRepository
|
||||
from app.schemas.inventory import StockMovementCreate
|
||||
from app.models.inventory import StockMovementType
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -780,18 +783,35 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
||||
# 1. Mark the stock batch as expired
|
||||
await stock_repo.mark_batch_as_expired(stock.id, tenant_id)
|
||||
|
||||
# 2. Create waste stock movement
|
||||
await movement_repo.create_automatic_waste_movement(
|
||||
# 2. Get current stock level before this movement
|
||||
current_stock = await stock_repo.get_total_stock_by_ingredient(tenant_id, stock.ingredient_id)
|
||||
quantity_before = current_stock['total_available']
|
||||
quantity_after = quantity_before - stock.current_quantity
|
||||
|
||||
# 3. Create waste stock movement with proper quantity tracking
|
||||
await movement_repo.create_movement(
|
||||
movement_data=StockMovementCreate(
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=stock.ingredient_id,
|
||||
stock_id=stock.id,
|
||||
movement_type=StockMovementType.WASTE,
|
||||
quantity=stock.current_quantity,
|
||||
unit_cost=Decimal(str(stock.unit_cost)) if stock.unit_cost else None,
|
||||
quantity_before=quantity_before,
|
||||
quantity_after=quantity_after,
|
||||
reference_number=f"AUTO-EXPIRE-{stock.batch_number or stock.id}",
|
||||
reason_code='expired',
|
||||
notes=f"Lote automáticamente marcado como caducado. Vencimiento: {effective_expiration_date.strftime('%Y-%m-%d')}",
|
||||
movement_date=datetime.now(),
|
||||
created_by=None
|
||||
),
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=stock.ingredient_id,
|
||||
stock_id=stock.id,
|
||||
quantity=stock.current_quantity,
|
||||
unit_cost=float(stock.unit_cost) if stock.unit_cost else None,
|
||||
batch_number=stock.batch_number,
|
||||
expiration_date=effective_expiration_date,
|
||||
created_by=None # Automatic system operation
|
||||
created_by=None
|
||||
)
|
||||
|
||||
# 4. Update the stock quantity to 0 (moved to waste)
|
||||
await stock_repo.update_stock_to_zero(stock.id, tenant_id)
|
||||
|
||||
# 3. Update the stock quantity to 0 (moved to waste)
|
||||
await stock_repo.update_stock_to_zero(stock.id, tenant_id)
|
||||
|
||||
|
||||
@@ -280,6 +280,11 @@ class InventoryService:
|
||||
# Create stock entry
|
||||
stock = await stock_repo.create_stock_entry(stock_data, tenant_id)
|
||||
|
||||
# Get current stock level before this movement
|
||||
current_stock = await stock_repo.get_total_stock_by_ingredient(tenant_id, UUID(stock_data.ingredient_id))
|
||||
quantity_before = current_stock['total_available']
|
||||
quantity_after = quantity_before + stock_data.current_quantity
|
||||
|
||||
# Create stock movement record
|
||||
movement_data = StockMovementCreate(
|
||||
ingredient_id=stock_data.ingredient_id,
|
||||
@@ -289,14 +294,22 @@ class InventoryService:
|
||||
unit_cost=stock_data.unit_cost,
|
||||
notes=f"Initial stock entry - Batch: {stock_data.batch_number or 'N/A'}"
|
||||
)
|
||||
await movement_repo.create_movement(movement_data, tenant_id, user_id)
|
||||
|
||||
# Update ingredient's last purchase price
|
||||
await movement_repo.create_movement(movement_data, tenant_id, user_id, quantity_before, quantity_after)
|
||||
|
||||
# Update ingredient's last purchase price and weighted average cost
|
||||
if stock_data.unit_cost:
|
||||
await ingredient_repo.update_last_purchase_price(
|
||||
UUID(stock_data.ingredient_id),
|
||||
UUID(stock_data.ingredient_id),
|
||||
float(stock_data.unit_cost)
|
||||
)
|
||||
|
||||
# Calculate and update weighted average cost
|
||||
await ingredient_repo.update_weighted_average_cost(
|
||||
ingredient_id=UUID(stock_data.ingredient_id),
|
||||
current_stock_quantity=quantity_before,
|
||||
new_purchase_quantity=stock_data.current_quantity,
|
||||
new_unit_cost=float(stock_data.unit_cost)
|
||||
)
|
||||
|
||||
# Convert to response schema
|
||||
response = StockResponse(**stock.to_dict())
|
||||
@@ -333,19 +346,28 @@ class InventoryService:
|
||||
|
||||
# Reserve stock first
|
||||
reservations = await stock_repo.reserve_stock(tenant_id, ingredient_id, quantity, fifo)
|
||||
|
||||
|
||||
if not reservations:
|
||||
raise ValueError("Insufficient stock available")
|
||||
|
||||
|
||||
# Get current stock level before this consumption
|
||||
current_stock = await stock_repo.get_total_stock_by_ingredient(tenant_id, ingredient_id)
|
||||
running_stock_level = current_stock['total_available']
|
||||
|
||||
consumed_items = []
|
||||
for reservation in reservations:
|
||||
stock_id = UUID(reservation['stock_id'])
|
||||
reserved_qty = reservation['reserved_quantity']
|
||||
|
||||
|
||||
# Calculate before/after for this specific batch
|
||||
batch_quantity_before = running_stock_level
|
||||
batch_quantity_after = running_stock_level - reserved_qty
|
||||
running_stock_level = batch_quantity_after # Update for next iteration
|
||||
|
||||
# Consume from reserved stock
|
||||
consumed_stock = await stock_repo.consume_stock(stock_id, reserved_qty, from_reserved=True)
|
||||
|
||||
# Create movement record
|
||||
|
||||
# Create movement record with progressive tracking
|
||||
movement_data = StockMovementCreate(
|
||||
ingredient_id=str(ingredient_id),
|
||||
stock_id=str(stock_id),
|
||||
@@ -354,7 +376,7 @@ class InventoryService:
|
||||
reference_number=reference_number,
|
||||
notes=notes or f"Stock consumption - Batch: {reservation.get('batch_number', 'N/A')}"
|
||||
)
|
||||
await movement_repo.create_movement(movement_data, tenant_id, user_id)
|
||||
await movement_repo.create_movement(movement_data, tenant_id, user_id, batch_quantity_before, batch_quantity_after)
|
||||
|
||||
consumed_items.append({
|
||||
'stock_id': str(stock_id),
|
||||
@@ -650,6 +672,187 @@ class InventoryService:
|
||||
logger.error("Failed to get stock entries", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def create_stock_movement(
|
||||
self,
|
||||
movement_data: StockMovementCreate,
|
||||
tenant_id: UUID,
|
||||
user_id: Optional[UUID] = None
|
||||
) -> StockMovementResponse:
|
||||
"""Create a stock movement record with proper quantity tracking"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
movement_repo = StockMovementRepository(db)
|
||||
ingredient_repo = IngredientRepository(db)
|
||||
stock_repo = StockRepository(db)
|
||||
|
||||
# Validate ingredient exists
|
||||
ingredient = await ingredient_repo.get_by_id(UUID(movement_data.ingredient_id))
|
||||
if not ingredient or ingredient.tenant_id != tenant_id:
|
||||
raise ValueError("Ingredient not found")
|
||||
|
||||
# Get current stock level before this movement
|
||||
current_stock = await stock_repo.get_total_stock_by_ingredient(tenant_id, UUID(movement_data.ingredient_id))
|
||||
quantity_before = current_stock['total_available']
|
||||
|
||||
# Calculate quantity_after based on movement type
|
||||
movement_quantity = movement_data.quantity or 0
|
||||
if movement_data.movement_type in [StockMovementType.PURCHASE, StockMovementType.TRANSFORMATION, StockMovementType.INITIAL_STOCK]:
|
||||
# These are additions to stock
|
||||
quantity_after = quantity_before + movement_quantity
|
||||
else:
|
||||
# These are subtractions from stock (PRODUCTION_USE, WASTE, ADJUSTMENT)
|
||||
quantity_after = quantity_before - movement_quantity
|
||||
|
||||
# Create stock movement record
|
||||
movement = await movement_repo.create_movement(
|
||||
movement_data,
|
||||
tenant_id,
|
||||
user_id,
|
||||
quantity_before,
|
||||
quantity_after
|
||||
)
|
||||
|
||||
# Convert to response schema
|
||||
response = StockMovementResponse(**movement.to_dict())
|
||||
response.ingredient = IngredientResponse(**ingredient.to_dict())
|
||||
|
||||
logger.info(
|
||||
"Stock movement created successfully",
|
||||
movement_id=movement.id,
|
||||
ingredient_id=movement.ingredient_id,
|
||||
quantity=movement.quantity,
|
||||
quantity_before=quantity_before,
|
||||
quantity_after=quantity_after
|
||||
)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create stock movement", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_stock_entry(
|
||||
self,
|
||||
stock_id: UUID,
|
||||
tenant_id: UUID
|
||||
) -> Optional[StockResponse]:
|
||||
"""Get a single stock entry by ID"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
stock_repo = StockRepository(db)
|
||||
ingredient_repo = IngredientRepository(db)
|
||||
|
||||
# Get stock entry
|
||||
stock = await stock_repo.get_by_id(stock_id)
|
||||
|
||||
# Check if stock exists and belongs to tenant
|
||||
if not stock or stock.tenant_id != tenant_id:
|
||||
return None
|
||||
|
||||
# Get ingredient information
|
||||
ingredient = await ingredient_repo.get_by_id(stock.ingredient_id)
|
||||
|
||||
response = StockResponse(**stock.to_dict())
|
||||
if ingredient:
|
||||
ingredient_dict = ingredient.to_dict()
|
||||
# Map category field based on product type
|
||||
if ingredient.product_type and ingredient.product_type.value == 'finished_product':
|
||||
ingredient_dict['category'] = ingredient.product_category.value if ingredient.product_category else None
|
||||
else:
|
||||
ingredient_dict['category'] = ingredient.ingredient_category.value if ingredient.ingredient_category else None
|
||||
response.ingredient = IngredientResponse(**ingredient_dict)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stock entry", error=str(e), stock_id=stock_id, tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def update_stock(
|
||||
self,
|
||||
stock_id: UUID,
|
||||
stock_data: StockUpdate,
|
||||
tenant_id: UUID
|
||||
) -> Optional[StockResponse]:
|
||||
"""Update a stock entry"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
stock_repo = StockRepository(db)
|
||||
ingredient_repo = IngredientRepository(db)
|
||||
|
||||
# Check if stock exists and belongs to tenant
|
||||
existing_stock = await stock_repo.get_by_id(stock_id)
|
||||
if not existing_stock or existing_stock.tenant_id != tenant_id:
|
||||
return None
|
||||
|
||||
# Prepare update data
|
||||
update_data = stock_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Recalculate available_quantity if current_quantity or reserved_quantity changed
|
||||
if 'current_quantity' in update_data or 'reserved_quantity' in update_data:
|
||||
current_qty = update_data.get('current_quantity', existing_stock.current_quantity)
|
||||
reserved_qty = update_data.get('reserved_quantity', existing_stock.reserved_quantity)
|
||||
update_data['available_quantity'] = max(0, current_qty - reserved_qty)
|
||||
|
||||
# Recalculate total cost if unit_cost or current_quantity changed
|
||||
if 'unit_cost' in update_data or 'current_quantity' in update_data:
|
||||
unit_cost = update_data.get('unit_cost', existing_stock.unit_cost)
|
||||
current_qty = update_data.get('current_quantity', existing_stock.current_quantity)
|
||||
if unit_cost is not None and current_qty is not None:
|
||||
from decimal import Decimal
|
||||
update_data['total_cost'] = Decimal(str(unit_cost)) * Decimal(str(current_qty))
|
||||
|
||||
# Update the stock entry
|
||||
updated_stock = await stock_repo.update(stock_id, update_data)
|
||||
if not updated_stock:
|
||||
return None
|
||||
|
||||
# Get ingredient information
|
||||
ingredient = await ingredient_repo.get_by_id(updated_stock.ingredient_id)
|
||||
|
||||
response = StockResponse(**updated_stock.to_dict())
|
||||
if ingredient:
|
||||
ingredient_dict = ingredient.to_dict()
|
||||
# Map category field based on product type
|
||||
if ingredient.product_type and ingredient.product_type.value == 'finished_product':
|
||||
ingredient_dict['category'] = ingredient.product_category.value if ingredient.product_category else None
|
||||
else:
|
||||
ingredient_dict['category'] = ingredient.ingredient_category.value if ingredient.ingredient_category else None
|
||||
response.ingredient = IngredientResponse(**ingredient_dict)
|
||||
|
||||
logger.info("Stock entry updated successfully", stock_id=stock_id, tenant_id=tenant_id)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update stock entry", error=str(e), stock_id=stock_id, tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def delete_stock(
|
||||
self,
|
||||
stock_id: UUID,
|
||||
tenant_id: UUID
|
||||
) -> bool:
|
||||
"""Delete a stock entry"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
stock_repo = StockRepository(db)
|
||||
|
||||
# Check if stock exists and belongs to tenant
|
||||
existing_stock = await stock_repo.get_by_id(stock_id)
|
||||
if not existing_stock or existing_stock.tenant_id != tenant_id:
|
||||
return False
|
||||
|
||||
# Delete the stock entry
|
||||
success = await stock_repo.delete_by_id(stock_id)
|
||||
|
||||
if success:
|
||||
logger.info("Stock entry deleted successfully", stock_id=stock_id, tenant_id=tenant_id)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete stock entry", error=str(e), stock_id=stock_id, tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
# ===== DELETION METHODS =====
|
||||
|
||||
async def hard_delete_ingredient(
|
||||
|
||||
@@ -312,19 +312,41 @@ class SustainabilityService:
|
||||
baseline = await self._get_baseline_waste(db, tenant_id)
|
||||
|
||||
current_waste_percentage = waste_data['waste_percentage']
|
||||
# Ensure baseline is at least the industry average if not available
|
||||
baseline_percentage = baseline.get('waste_percentage', EnvironmentalConstants.EU_BAKERY_BASELINE_WASTE * 100)
|
||||
|
||||
# If baseline is too low (less than 1%), use industry average to prevent calculation errors
|
||||
if baseline_percentage < 1.0:
|
||||
baseline_percentage = EnvironmentalConstants.EU_BAKERY_BASELINE_WASTE * 100
|
||||
|
||||
# Calculate reduction from baseline
|
||||
# If current waste is higher than baseline, show negative reduction (worse than baseline)
|
||||
# If current waste is lower than baseline, show positive reduction (better than baseline)
|
||||
if baseline_percentage > 0:
|
||||
reduction_percentage = ((baseline_percentage - current_waste_percentage) / baseline_percentage) * 100
|
||||
else:
|
||||
reduction_percentage = 0
|
||||
|
||||
# SDG 12.3 target is 50% reduction
|
||||
sdg_target = baseline_percentage * (1 - EnvironmentalConstants.SDG_TARGET_REDUCTION)
|
||||
progress_to_target = (reduction_percentage / (EnvironmentalConstants.SDG_TARGET_REDUCTION * 100)) * 100
|
||||
# Calculate progress toward 50% reduction target
|
||||
# The target is to achieve 50% reduction from baseline
|
||||
# So if baseline is 25%, target is to reach 12.5% (25% * 0.5)
|
||||
target_reduction_percentage = 50.0
|
||||
target_waste_percentage = baseline_percentage * (1 - (target_reduction_percentage / 100))
|
||||
|
||||
# Calculate progress: how much of the 50% target has been achieved
|
||||
# If we've reduced from 25% to 19.28%, we've achieved (25-19.28)/(25-12.5) = 5.72/12.5 = 45.8% of target
|
||||
if baseline_percentage > target_waste_percentage:
|
||||
max_possible_reduction = baseline_percentage - target_waste_percentage
|
||||
actual_reduction = baseline_percentage - current_waste_percentage
|
||||
progress_to_target = (actual_reduction / max_possible_reduction) * 100 if max_possible_reduction > 0 else 0
|
||||
else:
|
||||
# If current is already better than target
|
||||
progress_to_target = 100.0 if current_waste_percentage <= target_waste_percentage else 0.0
|
||||
|
||||
# Status assessment
|
||||
# Ensure progress doesn't exceed 100%
|
||||
progress_to_target = min(progress_to_target, 100.0)
|
||||
|
||||
# Status assessment based on actual reduction achieved
|
||||
if reduction_percentage >= 50:
|
||||
status = 'sdg_compliant'
|
||||
status_label = 'SDG 12.3 Compliant'
|
||||
@@ -334,6 +356,12 @@ class SustainabilityService:
|
||||
elif reduction_percentage >= 10:
|
||||
status = 'progressing'
|
||||
status_label = 'Making Progress'
|
||||
elif reduction_percentage > 0:
|
||||
status = 'improving'
|
||||
status_label = 'Improving'
|
||||
elif reduction_percentage < 0:
|
||||
status = 'baseline'
|
||||
status_label = 'Above Baseline'
|
||||
else:
|
||||
status = 'baseline'
|
||||
status_label = 'Establishing Baseline'
|
||||
@@ -343,11 +371,11 @@ class SustainabilityService:
|
||||
'baseline_waste_percentage': round(baseline_percentage, 2),
|
||||
'current_waste_percentage': round(current_waste_percentage, 2),
|
||||
'reduction_achieved': round(reduction_percentage, 2),
|
||||
'target_reduction': 50.0,
|
||||
'progress_to_target': round(min(progress_to_target, 100), 1),
|
||||
'target_reduction': target_reduction_percentage,
|
||||
'progress_to_target': round(max(progress_to_target, 0), 1), # Ensure non-negative
|
||||
'status': status,
|
||||
'status_label': status_label,
|
||||
'target_waste_percentage': round(sdg_target, 2)
|
||||
'target_waste_percentage': round(target_waste_percentage, 2)
|
||||
},
|
||||
'baseline_period': baseline.get('period', 'industry_average'),
|
||||
'certification_ready': reduction_percentage >= 50,
|
||||
|
||||
@@ -81,16 +81,25 @@ class TransformationService:
|
||||
source_reservations
|
||||
)
|
||||
|
||||
# Consume source stock and create movements
|
||||
# Get current stock level before source consumption
|
||||
current_source_stock = await stock_repo.get_total_stock_by_ingredient(tenant_id, UUID(transformation_data.source_ingredient_id))
|
||||
running_stock_level = current_source_stock['total_available']
|
||||
|
||||
# Consume source stock and create movements with progressive tracking
|
||||
consumed_items = []
|
||||
for reservation in source_reservations:
|
||||
stock_id = UUID(reservation['stock_id'])
|
||||
reserved_qty = reservation['reserved_quantity']
|
||||
|
||||
# Calculate before/after for this specific batch
|
||||
batch_quantity_before = running_stock_level
|
||||
batch_quantity_after = running_stock_level - reserved_qty
|
||||
running_stock_level = batch_quantity_after # Update for next iteration
|
||||
|
||||
# Consume from reserved stock
|
||||
await stock_repo.consume_stock(stock_id, reserved_qty, from_reserved=True)
|
||||
|
||||
# Create movement record
|
||||
# Create movement record for source consumption with progressive tracking
|
||||
movement_data = StockMovementCreate(
|
||||
ingredient_id=transformation_data.source_ingredient_id,
|
||||
stock_id=str(stock_id),
|
||||
@@ -99,7 +108,7 @@ class TransformationService:
|
||||
reference_number=transformation.transformation_reference,
|
||||
notes=f"Transformation: {transformation_data.source_stage.value} → {transformation_data.target_stage.value}"
|
||||
)
|
||||
await movement_repo.create_movement(movement_data, tenant_id, user_id)
|
||||
await movement_repo.create_movement(movement_data, tenant_id, user_id, batch_quantity_before, batch_quantity_after)
|
||||
|
||||
consumed_items.append({
|
||||
'stock_id': str(stock_id),
|
||||
@@ -124,6 +133,11 @@ class TransformationService:
|
||||
|
||||
target_stock = await stock_repo.create_stock_entry(target_stock_data, tenant_id)
|
||||
|
||||
# Get current stock level before target addition
|
||||
current_target_stock = await stock_repo.get_total_stock_by_ingredient(tenant_id, UUID(transformation_data.target_ingredient_id))
|
||||
target_quantity_before = current_target_stock['total_available']
|
||||
target_quantity_after = target_quantity_before + transformation_data.target_quantity
|
||||
|
||||
# Create target stock movement
|
||||
target_movement_data = StockMovementCreate(
|
||||
ingredient_id=transformation_data.target_ingredient_id,
|
||||
@@ -133,7 +147,7 @@ class TransformationService:
|
||||
reference_number=transformation.transformation_reference,
|
||||
notes=f"Transformation result: {transformation_data.source_stage.value} → {transformation_data.target_stage.value}"
|
||||
)
|
||||
await movement_repo.create_movement(target_movement_data, tenant_id, user_id)
|
||||
await movement_repo.create_movement(target_movement_data, tenant_id, user_id, target_quantity_before, target_quantity_after)
|
||||
|
||||
# Convert to response schema
|
||||
response = ProductTransformationResponse(**transformation.to_dict())
|
||||
@@ -329,4 +343,4 @@ class TransformationService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get transformation summary", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user