Improve the inventory page 3

This commit is contained in:
Urtzi Alfaro
2025-09-18 08:06:32 +02:00
parent dcb3ce441b
commit ae77a0e1c5
31 changed files with 2376 additions and 1774 deletions

View File

@@ -395,7 +395,8 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
async def update_last_purchase_price(self, ingredient_id: UUID, price: float) -> Optional[Ingredient]:
"""Update the last purchase price for an ingredient"""
try:
update_data = {'last_purchase_price': price}
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:
@@ -442,4 +443,28 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
except Exception as e:
await self.session.rollback()
logger.error("Failed to hard delete ingredient", error=str(e), ingredient_id=ingredient_id, tenant_id=tenant_id)
raise
raise
async def get_active_tenants(self) -> List[UUID]:
"""Get list of active tenant IDs from ingredients table"""
try:
result = await self.session.execute(
select(func.distinct(Ingredient.tenant_id))
.where(Ingredient.is_active == True)
)
tenant_ids = []
for row in result.fetchall():
tenant_id = row[0]
# Convert to UUID if it's not already
if isinstance(tenant_id, UUID):
tenant_ids.append(tenant_id)
else:
tenant_ids.append(UUID(str(tenant_id)))
logger.info("Retrieved active tenants from ingredients", count=len(tenant_ids))
return tenant_ids
except Exception as e:
logger.error("Failed to get active tenants from ingredients", error=str(e))
return []

View File

@@ -6,6 +6,7 @@ Stock Movement Repository using Repository Pattern
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime, timedelta
from decimal import Decimal
from sqlalchemy import select, func, and_, or_, desc, asc
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
@@ -35,6 +36,16 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
create_data = movement_data.model_dump()
create_data['tenant_id'] = tenant_id
create_data['created_by'] = created_by
# Ensure movement_type is properly converted to enum value
if 'movement_type' in create_data:
movement_type = create_data['movement_type']
if hasattr(movement_type, 'value'):
# It's an enum object, use its value
create_data['movement_type'] = movement_type.value
elif isinstance(movement_type, str):
# It's already a string, ensure it's uppercase for database
create_data['movement_type'] = movement_type.upper()
# Set movement date if not provided
if not create_data.get('movement_date'):
@@ -42,7 +53,9 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
# Calculate total cost if unit cost provided
if create_data.get('unit_cost') and create_data.get('quantity'):
create_data['total_cost'] = create_data['unit_cost'] * create_data['quantity']
unit_cost = create_data['unit_cost']
quantity = Decimal(str(create_data['quantity']))
create_data['total_cost'] = unit_cost * quantity
# Create record
record = await self.create(create_data)
@@ -50,7 +63,7 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
"Created stock movement",
movement_id=record.id,
ingredient_id=record.ingredient_id,
movement_type=record.movement_type.value if record.movement_type else None,
movement_type=record.movement_type if record.movement_type else None,
quantity=record.quantity,
tenant_id=tenant_id
)
@@ -234,7 +247,7 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
summary = {}
for row in result:
movement_type = row.movement_type.value if row.movement_type else "unknown"
movement_type = row.movement_type if row.movement_type else "unknown"
summary[movement_type] = {
'count': row.count,
'total_quantity': float(row.total_quantity),
@@ -417,4 +430,65 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
ingredient_id=str(ingredient_id),
tenant_id=str(tenant_id)
)
raise
async def create_automatic_waste_movement(
self,
tenant_id: UUID,
ingredient_id: UUID,
stock_id: UUID,
quantity: float,
unit_cost: Optional[float],
batch_number: Optional[str],
expiration_date: datetime,
created_by: Optional[UUID] = None
) -> StockMovement:
"""Create an automatic waste movement for expired batches"""
try:
# Calculate total cost
total_cost = None
if unit_cost and quantity:
total_cost = Decimal(str(unit_cost)) * Decimal(str(quantity))
# Generate reference number
reference_number = f"AUTO-EXPIRE-{batch_number or stock_id}"
# Create movement data
movement_data = {
'tenant_id': tenant_id,
'ingredient_id': ingredient_id,
'stock_id': stock_id,
'movement_type': StockMovementType.WASTE.value,
'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')}",
'movement_date': datetime.now(),
'created_by': created_by
}
# Create the movement record
movement = await self.create(movement_data)
logger.info("Created automatic waste movement for expired batch",
movement_id=str(movement.id),
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id),
stock_id=str(stock_id),
quantity=quantity,
batch_number=batch_number,
reference_number=reference_number)
return movement
except Exception as e:
logger.error("Failed to create automatic waste movement",
error=str(e),
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id),
stock_id=str(stock_id))
raise

View File

@@ -6,6 +6,7 @@ Stock Repository using Repository Pattern
from typing import List, Optional, Dict, Any, Tuple
from uuid import UUID
from datetime import datetime, timedelta
from decimal import Decimal
from sqlalchemy import select, func, and_, or_, desc, asc, update
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
@@ -13,11 +14,12 @@ import structlog
from app.models.inventory import Stock, Ingredient
from app.schemas.inventory import StockCreate, StockUpdate
from shared.database.repository import BaseRepository
from shared.utils.batch_generator import BatchCountProvider
logger = structlog.get_logger()
class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]):
class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate], BatchCountProvider):
"""Repository for stock operations"""
def __init__(self, session: AsyncSession):
@@ -29,6 +31,20 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]):
# Prepare data
create_data = stock_data.model_dump()
create_data['tenant_id'] = tenant_id
# Ensure production_stage is properly converted to enum value
if 'production_stage' in create_data:
if hasattr(create_data['production_stage'], 'value'):
create_data['production_stage'] = create_data['production_stage'].value
elif isinstance(create_data['production_stage'], str):
# If it's a string, ensure it's the correct enum value
from app.models.inventory import ProductionStage
try:
enum_obj = ProductionStage[create_data['production_stage']]
create_data['production_stage'] = enum_obj.value
except KeyError:
# If it's already the value, keep it as is
pass
# Calculate available quantity
available_qty = create_data['current_quantity'] - create_data.get('reserved_quantity', 0)
@@ -36,7 +52,9 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]):
# Calculate total cost if unit cost provided
if create_data.get('unit_cost') and create_data.get('current_quantity'):
create_data['total_cost'] = create_data['unit_cost'] * create_data['current_quantity']
unit_cost = create_data['unit_cost']
current_quantity = Decimal(str(create_data['current_quantity']))
create_data['total_cost'] = unit_cost * current_quantity
# Create record
record = await self.create(create_data)
@@ -524,4 +542,164 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]):
ingredient_id=str(ingredient_id),
tenant_id=str(tenant_id)
)
raise
async def get_daily_batch_count(
self,
tenant_id: str,
date_start: datetime,
date_end: datetime,
prefix: Optional[str] = None
) -> int:
"""Get the count of batches created today for the given tenant"""
try:
conditions = [
Stock.tenant_id == tenant_id,
Stock.created_at >= date_start,
Stock.created_at <= date_end
]
if prefix:
conditions.append(Stock.batch_number.like(f"{prefix}-%"))
stmt = select(func.count(Stock.id)).where(and_(*conditions))
result = await self.session.execute(stmt)
count = result.scalar() or 0
logger.debug(
"Retrieved daily batch count",
tenant_id=tenant_id,
prefix=prefix,
count=count,
date_start=date_start,
date_end=date_end
)
return count
except Exception as e:
logger.error(
"Failed to get daily batch count",
error=str(e),
tenant_id=tenant_id,
prefix=prefix
)
raise
async def get_expired_batches_for_processing(self, tenant_id: UUID) -> List[Tuple[Stock, Ingredient]]:
"""Get expired batches that haven't been processed yet (for automatic processing)"""
try:
current_date = datetime.now()
# Find expired batches that are still available and not yet marked as expired
result = await self.session.execute(
select(Stock, Ingredient)
.join(Ingredient, Stock.ingredient_id == Ingredient.id)
.where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
Stock.is_expired == False,
Stock.current_quantity > 0,
or_(
and_(
Stock.final_expiration_date.isnot(None),
Stock.final_expiration_date <= current_date
),
and_(
Stock.final_expiration_date.is_(None),
Stock.expiration_date.isnot(None),
Stock.expiration_date <= current_date
)
)
)
)
.order_by(
asc(func.coalesce(Stock.final_expiration_date, Stock.expiration_date))
)
)
expired_batches = result.all()
logger.info("Found expired batches for processing",
tenant_id=str(tenant_id),
count=len(expired_batches))
return expired_batches
except Exception as e:
logger.error("Failed to get expired batches for processing",
error=str(e), tenant_id=tenant_id)
raise
async def mark_batch_as_expired(self, stock_id: UUID, tenant_id: UUID) -> bool:
"""Mark a specific batch as expired and unavailable"""
try:
result = await self.session.execute(
update(Stock)
.where(
and_(
Stock.id == stock_id,
Stock.tenant_id == tenant_id
)
)
.values(
is_expired=True,
is_available=False,
quality_status="expired",
updated_at=datetime.now()
)
)
if result.rowcount > 0:
logger.info("Marked batch as expired",
stock_id=str(stock_id),
tenant_id=str(tenant_id))
return True
else:
logger.warning("No batch found to mark as expired",
stock_id=str(stock_id),
tenant_id=str(tenant_id))
return False
except Exception as e:
logger.error("Failed to mark batch as expired",
error=str(e),
stock_id=str(stock_id),
tenant_id=str(tenant_id))
raise
async def update_stock_to_zero(self, stock_id: UUID, tenant_id: UUID) -> bool:
"""Update stock quantities to zero after moving to waste"""
try:
result = await self.session.execute(
update(Stock)
.where(
and_(
Stock.id == stock_id,
Stock.tenant_id == tenant_id
)
)
.values(
current_quantity=0,
available_quantity=0,
updated_at=datetime.now()
)
)
if result.rowcount > 0:
logger.info("Updated stock quantities to zero",
stock_id=str(stock_id),
tenant_id=str(tenant_id))
return True
else:
logger.warning("No stock found to update to zero",
stock_id=str(stock_id),
tenant_id=str(tenant_id))
return False
except Exception as e:
logger.error("Failed to update stock to zero",
error=str(e),
stock_id=str(stock_id),
tenant_id=str(tenant_id))
raise