Improve the frontend and fix TODOs
This commit is contained in:
@@ -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)}"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user