Add frontend imporvements
This commit is contained in:
@@ -5,7 +5,7 @@ API endpoints for stock management
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -19,31 +19,39 @@ from app.schemas.inventory import (
|
||||
StockFilter
|
||||
)
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.tenant_access import verify_tenant_access_dep
|
||||
|
||||
router = APIRouter(prefix="/stock", tags=["stock"])
|
||||
router = APIRouter(tags=["stock"])
|
||||
|
||||
# Helper function to extract user ID from user object
|
||||
def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID:
|
||||
"""Extract user ID from current user context"""
|
||||
user_id = current_user.get('user_id')
|
||||
if not user_id:
|
||||
# Handle service tokens that don't have UUID user_ids
|
||||
if current_user.get('type') == 'service':
|
||||
return None
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User ID not found in context"
|
||||
)
|
||||
return UUID(user_id)
|
||||
try:
|
||||
return UUID(user_id)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/", response_model=StockResponse)
|
||||
@router.post("/tenants/{tenant_id}/stock", response_model=StockResponse)
|
||||
async def add_stock(
|
||||
stock_data: StockCreate,
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
user_id: UUID = Depends(get_current_user_id),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Add new stock entry"""
|
||||
try:
|
||||
# Extract user ID - handle service tokens
|
||||
user_id = get_current_user_id(current_user)
|
||||
|
||||
service = InventoryService()
|
||||
stock = await service.add_stock(stock_data, tenant_id, user_id)
|
||||
return stock
|
||||
@@ -59,19 +67,22 @@ async def add_stock(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/consume")
|
||||
@router.post("/tenants/{tenant_id}/stock/consume")
|
||||
async def consume_stock(
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
ingredient_id: UUID = Query(..., description="Ingredient ID to consume"),
|
||||
quantity: float = Query(..., gt=0, description="Quantity to consume"),
|
||||
reference_number: Optional[str] = Query(None, description="Reference number (e.g., production order)"),
|
||||
notes: Optional[str] = Query(None, description="Additional notes"),
|
||||
fifo: bool = Query(True, description="Use FIFO (First In, First Out) method"),
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
user_id: UUID = Depends(get_current_user_id),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Consume stock for production"""
|
||||
try:
|
||||
# Extract user ID - handle service tokens
|
||||
user_id = get_current_user_id(current_user)
|
||||
|
||||
service = InventoryService()
|
||||
consumed_items = await service.consume_stock(
|
||||
ingredient_id, quantity, tenant_id, user_id, reference_number, notes, fifo
|
||||
@@ -94,31 +105,10 @@ async def consume_stock(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ingredient/{ingredient_id}", response_model=List[StockResponse])
|
||||
async def get_ingredient_stock(
|
||||
ingredient_id: UUID,
|
||||
include_unavailable: bool = Query(False, description="Include unavailable stock"),
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock entries for an ingredient"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
stock_entries = await service.get_stock_by_ingredient(
|
||||
ingredient_id, tenant_id, include_unavailable
|
||||
)
|
||||
return stock_entries
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get ingredient stock"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/expiring", response_model=List[dict])
|
||||
@router.get("/tenants/{tenant_id}/stock/expiring", response_model=List[dict])
|
||||
async def get_expiring_stock(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
days_ahead: int = Query(7, ge=1, le=365, description="Days ahead to check for expiring items"),
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock items expiring within specified days"""
|
||||
@@ -133,9 +123,9 @@ async def get_expiring_stock(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/low-stock", response_model=List[dict])
|
||||
@router.get("/tenants/{tenant_id}/stock/low-stock", response_model=List[dict])
|
||||
async def get_low_stock(
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get ingredients with low stock levels"""
|
||||
@@ -150,9 +140,9 @@ async def get_low_stock(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=dict)
|
||||
@router.get("/tenants/{tenant_id}/stock/summary", response_model=dict)
|
||||
async def get_stock_summary(
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock summary for dashboard"""
|
||||
@@ -164,4 +154,164 @@ async def get_stock_summary(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get stock summary"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/stock", response_model=List[StockResponse])
|
||||
async def get_stock(
|
||||
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"),
|
||||
available_only: bool = Query(True, description="Show only available stock"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock entries with filtering"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
stock_entries = await service.get_stock(
|
||||
tenant_id, skip, limit, ingredient_id, available_only
|
||||
)
|
||||
return stock_entries
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get stock entries"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/stock/{stock_id}", response_model=StockResponse)
|
||||
async def get_stock_entry(
|
||||
stock_id: UUID = Path(..., description="Stock entry ID"),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get specific stock entry"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
stock = await service.get_stock_entry(stock_id, tenant_id)
|
||||
|
||||
if not stock:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Stock entry not found"
|
||||
)
|
||||
|
||||
return stock
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get stock entry"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/tenants/{tenant_id}/stock/{stock_id}", response_model=StockResponse)
|
||||
async def update_stock(
|
||||
stock_id: UUID = Path(..., description="Stock entry ID"),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
stock_data: StockUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update stock entry"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
stock = await service.update_stock(stock_id, stock_data, tenant_id)
|
||||
|
||||
if not stock:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Stock entry not found"
|
||||
)
|
||||
|
||||
return stock
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update stock entry"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tenants/{tenant_id}/stock/{stock_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_stock(
|
||||
stock_id: UUID = Path(..., description="Stock entry ID"),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete stock entry (mark as unavailable)"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
deleted = await service.delete_stock(stock_id, tenant_id)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Stock entry not found"
|
||||
)
|
||||
|
||||
return None
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete stock entry"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/stock/movements", response_model=StockMovementResponse)
|
||||
async def create_stock_movement(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
movement_data: StockMovementCreate,
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create stock movement record"""
|
||||
try:
|
||||
# Extract user ID - handle service tokens
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/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"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock movements with filtering"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
movements = await service.get_stock_movements(
|
||||
tenant_id, skip, limit, ingredient_id, movement_type
|
||||
)
|
||||
return movements
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get stock movements"
|
||||
)
|
||||
@@ -6,6 +6,7 @@ Procurement Service - Business logic for procurement planning and scheduling
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime, date, timedelta
|
||||
from decimal import Decimal
|
||||
@@ -371,7 +372,14 @@ class ProcurementService:
|
||||
safety_stock = predicted_demand * (request.safety_stock_percentage / 100)
|
||||
|
||||
total_needed = predicted_demand + safety_stock
|
||||
net_requirement = max(Decimal('0'), total_needed - current_stock)
|
||||
|
||||
# Round up to whole numbers for finished products (can't order fractional units)
|
||||
# Use ceiling to ensure we never under-order
|
||||
total_needed_rounded = Decimal(str(math.ceil(float(total_needed))))
|
||||
predicted_demand_rounded = Decimal(str(math.ceil(float(predicted_demand))))
|
||||
safety_stock_rounded = total_needed_rounded - predicted_demand_rounded
|
||||
|
||||
net_requirement = max(Decimal('0'), total_needed_rounded - current_stock)
|
||||
|
||||
if net_requirement > 0: # Only create requirement if needed
|
||||
requirement_number = await self.requirement_repo.generate_requirement_number(plan_id)
|
||||
@@ -388,15 +396,15 @@ class ProcurementService:
|
||||
'product_sku': item.get('sku', ''),
|
||||
'product_category': item.get('category', ''),
|
||||
'product_type': 'product',
|
||||
'required_quantity': predicted_demand,
|
||||
'required_quantity': predicted_demand_rounded,
|
||||
'unit_of_measure': item.get('unit', 'units'),
|
||||
'safety_stock_quantity': safety_stock,
|
||||
'total_quantity_needed': total_needed,
|
||||
'safety_stock_quantity': safety_stock_rounded,
|
||||
'total_quantity_needed': total_needed_rounded,
|
||||
'current_stock_level': current_stock,
|
||||
'available_stock': current_stock,
|
||||
'net_requirement': net_requirement,
|
||||
'forecast_demand': predicted_demand,
|
||||
'buffer_demand': safety_stock,
|
||||
'forecast_demand': predicted_demand_rounded,
|
||||
'buffer_demand': safety_stock_rounded,
|
||||
'required_by_date': required_by_date,
|
||||
'suggested_order_date': suggested_order_date,
|
||||
'latest_order_date': latest_order_date,
|
||||
|
||||
Reference in New Issue
Block a user