Improve the frontend and fix TODOs

This commit is contained in:
Urtzi Alfaro
2025-10-24 13:05:04 +02:00
parent 07c33fa578
commit 61376b7a9f
100 changed files with 8284 additions and 3419 deletions

View File

@@ -5,7 +5,7 @@ Service-to-service endpoint for cloning inventory data with date adjustment
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, func
import structlog
import uuid
from datetime import datetime, timezone
@@ -18,7 +18,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from app.core.database import get_db
from app.models.inventory import Ingredient, Stock
from app.models.inventory import Ingredient, Stock, StockMovement
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
logger = structlog.get_logger()
@@ -83,15 +83,49 @@ async def clone_demo_data(
base_uuid = uuid.UUID(base_tenant_id)
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Check if data already exists for this virtual tenant (idempotency)
existing_check = await db.execute(
select(Ingredient).where(Ingredient.tenant_id == virtual_uuid).limit(1)
)
existing_ingredient = existing_check.scalars().first()
if existing_ingredient:
logger.warning(
"Data already exists for virtual tenant - cleaning before re-clone",
virtual_tenant_id=virtual_tenant_id,
base_tenant_id=base_tenant_id
)
# Clean up existing data first to ensure fresh clone
from sqlalchemy import delete
await db.execute(
delete(StockMovement).where(StockMovement.tenant_id == virtual_uuid)
)
await db.execute(
delete(Stock).where(Stock.tenant_id == virtual_uuid)
)
await db.execute(
delete(Ingredient).where(Ingredient.tenant_id == virtual_uuid)
)
await db.commit()
logger.info(
"Existing data cleaned, proceeding with fresh clone",
virtual_tenant_id=virtual_tenant_id
)
# Track cloning statistics
stats = {
"ingredients": 0,
"stock_batches": 0,
"stock_movements": 0,
"alerts_generated": 0
}
# Mapping from base ingredient ID to virtual ingredient ID
ingredient_id_mapping = {}
# Mapping from base stock ID to virtual stock ID
stock_id_mapping = {}
# Clone Ingredients
result = await db.execute(
@@ -213,9 +247,11 @@ async def clone_demo_data(
BASE_REFERENCE_DATE
) or session_created_at
# Create new stock batch
# Create new stock batch with new ID
new_stock_id = uuid.uuid4()
new_stock = Stock(
id=uuid.uuid4(),
id=new_stock_id,
tenant_id=virtual_uuid,
ingredient_id=new_ingredient_id,
supplier_id=stock.supplier_id,
@@ -250,6 +286,72 @@ async def clone_demo_data(
db.add(new_stock)
stats["stock_batches"] += 1
# Store mapping for movement cloning
stock_id_mapping[stock.id] = new_stock_id
await db.flush() # Ensure stock is persisted before movements
# Clone Stock Movements with date adjustment
result = await db.execute(
select(StockMovement).where(StockMovement.tenant_id == base_uuid)
)
base_movements = result.scalars().all()
logger.info(
"Found stock movements to clone",
count=len(base_movements),
base_tenant=str(base_uuid)
)
for movement in base_movements:
# Map ingredient ID and stock ID
new_ingredient_id = ingredient_id_mapping.get(movement.ingredient_id)
new_stock_id = stock_id_mapping.get(movement.stock_id) if movement.stock_id else None
if not new_ingredient_id:
logger.warning(
"Movement references non-existent ingredient, skipping",
movement_id=str(movement.id),
ingredient_id=str(movement.ingredient_id)
)
continue
# Adjust movement date relative to session creation
adjusted_movement_date = adjust_date_for_demo(
movement.movement_date,
session_created_at,
BASE_REFERENCE_DATE
) or session_created_at
adjusted_created_at = adjust_date_for_demo(
movement.created_at,
session_created_at,
BASE_REFERENCE_DATE
) or session_created_at
# Create new stock movement
new_movement = StockMovement(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
ingredient_id=new_ingredient_id,
stock_id=new_stock_id,
movement_type=movement.movement_type,
quantity=movement.quantity,
unit_cost=movement.unit_cost,
total_cost=movement.total_cost,
quantity_before=movement.quantity_before,
quantity_after=movement.quantity_after,
reference_number=movement.reference_number,
supplier_id=movement.supplier_id,
notes=movement.notes,
reason_code=movement.reason_code,
movement_date=adjusted_movement_date,
created_at=adjusted_created_at,
created_by=movement.created_by
)
db.add(new_movement)
stats["stock_movements"] += 1
# Commit all changes
await db.commit()
@@ -312,3 +414,104 @@ async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
"clone_endpoint": "available",
"version": "2.0.0"
}
@router.delete("/tenant/{virtual_tenant_id}")
async def delete_demo_data(
virtual_tenant_id: str,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
"""
Delete all inventory data for a virtual demo tenant
Called by demo session cleanup service to remove ephemeral data
when demo sessions expire or are destroyed.
Args:
virtual_tenant_id: Virtual tenant UUID to delete
Returns:
Deletion status and count of records deleted
"""
from sqlalchemy import delete
logger.info(
"Deleting inventory data for virtual tenant",
virtual_tenant_id=virtual_tenant_id
)
start_time = datetime.now(timezone.utc)
try:
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Count records before deletion for reporting
stock_count = await db.scalar(
select(func.count(Stock.id)).where(Stock.tenant_id == virtual_uuid)
)
ingredient_count = await db.scalar(
select(func.count(Ingredient.id)).where(Ingredient.tenant_id == virtual_uuid)
)
movement_count = await db.scalar(
select(func.count(StockMovement.id)).where(StockMovement.tenant_id == virtual_uuid)
)
# Delete in correct order to respect foreign key constraints
# 1. Delete StockMovements (references Stock)
await db.execute(
delete(StockMovement).where(StockMovement.tenant_id == virtual_uuid)
)
# 2. Delete Stock batches (references Ingredient)
await db.execute(
delete(Stock).where(Stock.tenant_id == virtual_uuid)
)
# 3. Delete Ingredients
await db.execute(
delete(Ingredient).where(Ingredient.tenant_id == virtual_uuid)
)
await db.commit()
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Inventory data deleted successfully",
virtual_tenant_id=virtual_tenant_id,
stocks_deleted=stock_count,
ingredients_deleted=ingredient_count,
movements_deleted=movement_count,
duration_ms=duration_ms
)
return {
"service": "inventory",
"status": "deleted",
"virtual_tenant_id": virtual_tenant_id,
"records_deleted": {
"stock_batches": stock_count,
"ingredients": ingredient_count,
"stock_movements": movement_count,
"total": stock_count + ingredient_count + movement_count
},
"duration_ms": duration_ms
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e))
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
logger.error(
"Failed to delete inventory data",
virtual_tenant_id=virtual_tenant_id,
error=str(e),
exc_info=True
)
await db.rollback()
raise HTTPException(
status_code=500,
detail=f"Failed to delete inventory data: {str(e)}"
)

View File

@@ -100,6 +100,106 @@ async def get_stock(
)
# ===== STOCK MOVEMENTS ROUTES (must come before stock/{stock_id} route) =====
@router.get(
route_builder.build_base_route("stock/movements"),
response_model=List[StockMovementResponse]
)
async def get_stock_movements(
tenant_id: UUID = Path(..., description="Tenant ID"),
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
ingredient_id: Optional[str] = Query(None, description="Filter by ingredient"),
movement_type: Optional[str] = Query(None, description="Filter by movement type"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get stock movements with filtering"""
logger.info("Stock movements endpoint called",
tenant_id=str(tenant_id),
ingredient_id=ingredient_id,
skip=skip,
limit=limit,
movement_type=movement_type)
# Validate and convert ingredient_id if provided
ingredient_uuid = None
if ingredient_id:
try:
ingredient_uuid = UUID(ingredient_id)
logger.info("Ingredient ID validated", ingredient_id=str(ingredient_uuid))
except (ValueError, AttributeError) as e:
logger.error("Invalid ingredient_id format",
ingredient_id=ingredient_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid ingredient_id format: {ingredient_id}. Must be a valid UUID."
)
try:
service = InventoryService()
movements = await service.get_stock_movements(
tenant_id, skip, limit, ingredient_uuid, movement_type
)
logger.info("Successfully retrieved stock movements",
count=len(movements),
tenant_id=str(tenant_id))
return movements
except ValueError as e:
logger.error("Validation error in stock movements",
error=str(e),
tenant_id=str(tenant_id),
ingredient_id=ingredient_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
logger.error("Failed to get stock movements",
error=str(e),
error_type=type(e).__name__,
tenant_id=str(tenant_id),
ingredient_id=ingredient_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get stock movements: {str(e)}"
)
@router.post(
route_builder.build_base_route("stock/movements"),
response_model=StockMovementResponse,
status_code=status.HTTP_201_CREATED
)
@require_user_role(['admin', 'owner', 'member'])
async def create_stock_movement(
movement_data: StockMovementCreate,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Create stock movement record"""
try:
user_id = get_current_user_id(current_user)
service = InventoryService()
movement = await service.create_stock_movement(movement_data, tenant_id, user_id)
return movement
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create stock movement"
)
# ===== STOCK DETAIL ROUTES (must come after stock/movements routes) =====
@router.get(
route_builder.build_resource_detail_route("stock", "stock_id"),
response_model=StockResponse
@@ -199,68 +299,3 @@ async def delete_stock(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete stock entry"
)
@router.get(
route_builder.build_base_route("stock/movements"),
response_model=List[StockMovementResponse]
)
async def get_stock_movements(
tenant_id: UUID = Path(..., description="Tenant ID"),
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient"),
movement_type: Optional[str] = Query(None, description="Filter by movement type"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get stock movements with filtering"""
logger.info("API endpoint reached!",
tenant_id=tenant_id,
ingredient_id=ingredient_id,
skip=skip,
limit=limit)
try:
service = InventoryService()
movements = await service.get_stock_movements(
tenant_id, skip, limit, ingredient_id, movement_type
)
logger.info("Returning movements", count=len(movements))
return movements
except Exception as e:
logger.error("Failed to get stock movements", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get stock movements"
)
@router.post(
route_builder.build_base_route("stock/movements"),
response_model=StockMovementResponse,
status_code=status.HTTP_201_CREATED
)
@require_user_role(['admin', 'owner', 'member'])
async def create_stock_movement(
movement_data: StockMovementCreate,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Create stock movement record"""
try:
user_id = get_current_user_id(current_user)
service = InventoryService()
movement = await service.create_stock_movement(movement_data, tenant_id, user_id)
return movement
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create stock movement"
)