Automated Action 54bf9880b9 Implement Small Business Inventory Management System
This commit implements a comprehensive inventory management system for small businesses using FastAPI and SQLAlchemy. Features include:
- Product and category management
- Inventory tracking across multiple locations
- Supplier management
- Purchase management
- Transaction tracking for inventory movements
- Complete API documentation

generated with BackendIM... (backend.im)
2025-05-12 12:55:31 +00:00

583 lines
20 KiB
Python

from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from app.db.session import get_db
from app.models.inventory import (
Location,
LocationType,
InventoryItem,
InventoryTransaction,
InventoryTransactionItem,
TransactionType
)
from app.models.product import Product
from app.schemas.inventory import (
LocationCreate,
LocationUpdate,
LocationInDB,
InventoryItemCreate,
InventoryItemUpdate,
InventoryItemInDB,
InventoryItemWithDetails,
InventoryTransactionCreate,
InventoryTransactionWithItems,
InventoryTransactionInDB,
TransactionTypeEnum
)
from app.api.deps import get_product, get_location
router = APIRouter(prefix="/api/v1")
# Location endpoints
@router.post("/locations/", response_model=LocationInDB, status_code=status.HTTP_201_CREATED)
def create_location(
*,
db: Session = Depends(get_db),
location_in: LocationCreate
):
"""Create a new location"""
try:
# Convert from schema enum to model enum
type_value = LocationType(location_in.type.value)
db_location = Location(
name=location_in.name,
type=type_value,
address=location_in.address,
description=location_in.description
)
db.add(db_location)
db.commit()
db.refresh(db_location)
return db_location
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Could not create location: {str(e)}"
)
@router.get("/locations/", response_model=List[LocationInDB])
def read_locations(
*,
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
location_type: Optional[str] = None
):
"""Get all locations with optional filters"""
query = db.query(Location)
if search:
query = query.filter(Location.name.ilike(f"%{search}%"))
if location_type:
try:
type_value = LocationType(location_type)
query = query.filter(Location.type == type_value)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid location type: {location_type}"
)
return query.offset(skip).limit(limit).all()
@router.get("/locations/{location_id}", response_model=LocationInDB)
def read_location(
*,
db: Session = Depends(get_db),
location: Location = Depends(get_location)
):
"""Get a specific location by ID"""
return location
@router.put("/locations/{location_id}", response_model=LocationInDB)
def update_location(
*,
db: Session = Depends(get_db),
location: Location = Depends(get_location),
location_in: LocationUpdate
):
"""Update a location"""
update_data = location_in.model_dump(exclude_unset=True)
# Convert type enum if present
if "type" in update_data and update_data["type"]:
update_data["type"] = LocationType(update_data["type"].value)
for field, value in update_data.items():
setattr(location, field, value)
try:
db.add(location)
db.commit()
db.refresh(location)
return location
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Could not update location: {str(e)}"
)
@router.delete("/locations/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_location(
*,
db: Session = Depends(get_db),
location: Location = Depends(get_location)
):
"""Delete a location"""
# Check if location has inventory items
if location.inventory_items:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete location with existing inventory"
)
db.delete(location)
db.commit()
return None
# Inventory Item endpoints
@router.post("/inventory/", response_model=InventoryItemInDB, status_code=status.HTTP_201_CREATED)
def create_inventory_item(
*,
db: Session = Depends(get_db),
item_in: InventoryItemCreate
):
"""Create a new inventory item"""
# Check if product exists
product = db.query(Product).filter(Product.id == item_in.product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {item_in.product_id} not found"
)
# Check if location exists
location = db.query(Location).filter(Location.id == item_in.location_id).first()
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Location with ID {item_in.location_id} not found"
)
# Check if inventory item already exists for this product and location
existing_item = db.query(InventoryItem).filter(
InventoryItem.product_id == item_in.product_id,
InventoryItem.location_id == item_in.location_id
).first()
if existing_item:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Inventory item already exists for this product and location"
)
try:
db_item = InventoryItem(**item_in.model_dump())
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Could not create inventory item: {str(e)}"
)
@router.get("/inventory/", response_model=List[InventoryItemWithDetails])
def read_inventory_items(
*,
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
product_id: Optional[int] = None,
location_id: Optional[int] = None,
low_stock: bool = False
):
"""Get inventory items with optional filters"""
query = db.query(InventoryItem)
if product_id:
query = query.filter(InventoryItem.product_id == product_id)
if location_id:
query = query.filter(InventoryItem.location_id == location_id)
if low_stock:
query = query.filter(InventoryItem.quantity <= InventoryItem.minimum_stock)
items = query.offset(skip).limit(limit).all()
# Enhance with product and location details
result = []
for item in items:
item_dict = {
"id": item.id,
"product_id": item.product_id,
"location_id": item.location_id,
"quantity": item.quantity,
"minimum_stock": item.minimum_stock,
"maximum_stock": item.maximum_stock,
"created_at": item.created_at,
"updated_at": item.updated_at,
"product": {
"id": item.product.id,
"name": item.product.name,
"sku": item.product.sku,
},
"location": {
"id": item.location.id,
"name": item.location.name,
"type": item.location.type.value,
}
}
result.append(item_dict)
return result
@router.get("/inventory/{item_id}", response_model=InventoryItemWithDetails)
def read_inventory_item(
*,
db: Session = Depends(get_db),
item_id: int
):
"""Get a specific inventory item by ID"""
item = db.query(InventoryItem).filter(InventoryItem.id == item_id).first()
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Inventory item with ID {item_id} not found"
)
# Enhance with product and location details
item_dict = {
"id": item.id,
"product_id": item.product_id,
"location_id": item.location_id,
"quantity": item.quantity,
"minimum_stock": item.minimum_stock,
"maximum_stock": item.maximum_stock,
"created_at": item.created_at,
"updated_at": item.updated_at,
"product": {
"id": item.product.id,
"name": item.product.name,
"sku": item.product.sku,
},
"location": {
"id": item.location.id,
"name": item.location.name,
"type": item.location.type.value,
}
}
return item_dict
@router.put("/inventory/{item_id}", response_model=InventoryItemInDB)
def update_inventory_item(
*,
db: Session = Depends(get_db),
item_id: int,
item_in: InventoryItemUpdate
):
"""Update an inventory item"""
item = db.query(InventoryItem).filter(InventoryItem.id == item_id).first()
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Inventory item with ID {item_id} not found"
)
update_data = item_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(item, field, value)
try:
db.add(item)
db.commit()
db.refresh(item)
return item
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Could not update inventory item: {str(e)}"
)
@router.delete("/inventory/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_inventory_item(
*,
db: Session = Depends(get_db),
item_id: int
):
"""Delete an inventory item"""
item = db.query(InventoryItem).filter(InventoryItem.id == item_id).first()
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Inventory item with ID {item_id} not found"
)
db.delete(item)
db.commit()
return None
# Inventory Transaction endpoints
@router.post("/inventory-transactions/", response_model=InventoryTransactionInDB, status_code=status.HTTP_201_CREATED)
def create_inventory_transaction(
*,
db: Session = Depends(get_db),
transaction_in: InventoryTransactionWithItems
):
"""Create a new inventory transaction with items"""
# Convert from schema enum to model enum
try:
transaction_type = TransactionType(transaction_in.type.value)
# Create transaction
db_transaction = InventoryTransaction(
type=transaction_type,
reference_id=transaction_in.reference_id,
notes=transaction_in.notes
)
db.add(db_transaction)
db.flush() # Get ID without committing
# Process transaction items
transaction_items = []
for item in transaction_in.items:
# Validate product exists
product = db.query(Product).filter(Product.id == item.product_id).first()
if not product:
db.rollback()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {item.product_id} not found"
)
# Process based on transaction type
if transaction_type == TransactionType.TRANSFER:
# For transfers, need both locations
if not item.from_location_id or not item.to_location_id:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Transfer transactions require both from_location_id and to_location_id"
)
# Validate from_location exists
from_location = db.query(Location).filter(Location.id == item.from_location_id).first()
if not from_location:
db.rollback()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"From location with ID {item.from_location_id} not found"
)
# Validate to_location exists
to_location = db.query(Location).filter(Location.id == item.to_location_id).first()
if not to_location:
db.rollback()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"To location with ID {item.to_location_id} not found"
)
# Update inventory at from_location
from_inventory = db.query(InventoryItem).filter(
InventoryItem.product_id == item.product_id,
InventoryItem.location_id == item.from_location_id
).first()
if not from_inventory:
db.rollback()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product {item.product_id} not found at location {item.from_location_id}"
)
if from_inventory.quantity < item.quantity:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Insufficient stock for product {item.product_id} at location {item.from_location_id}"
)
from_inventory.quantity -= item.quantity
# Update inventory at to_location
to_inventory = db.query(InventoryItem).filter(
InventoryItem.product_id == item.product_id,
InventoryItem.location_id == item.to_location_id
).first()
if to_inventory:
to_inventory.quantity += item.quantity
else:
# Create new inventory item if it doesn't exist
to_inventory = InventoryItem(
product_id=item.product_id,
location_id=item.to_location_id,
quantity=item.quantity,
minimum_stock=0
)
db.add(to_inventory)
elif transaction_type in [TransactionType.PURCHASE, TransactionType.RETURN]:
# For purchases, need to_location (where items are added)
if not item.to_location_id:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{transaction_type.value} transactions require to_location_id"
)
# Validate to_location exists
to_location = db.query(Location).filter(Location.id == item.to_location_id).first()
if not to_location:
db.rollback()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Location with ID {item.to_location_id} not found"
)
# Update inventory at to_location
inventory = db.query(InventoryItem).filter(
InventoryItem.product_id == item.product_id,
InventoryItem.location_id == item.to_location_id
).first()
if inventory:
inventory.quantity += item.quantity
else:
# Create new inventory item if it doesn't exist
inventory = InventoryItem(
product_id=item.product_id,
location_id=item.to_location_id,
quantity=item.quantity,
minimum_stock=0
)
db.add(inventory)
elif transaction_type in [TransactionType.SALE, TransactionType.ADJUSTMENT]:
# For sales, need from_location (where items are removed)
if not item.from_location_id:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{transaction_type.value} transactions require from_location_id"
)
# Validate from_location exists
from_location = db.query(Location).filter(Location.id == item.from_location_id).first()
if not from_location:
db.rollback()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Location with ID {item.from_location_id} not found"
)
# Update inventory at from_location
inventory = db.query(InventoryItem).filter(
InventoryItem.product_id == item.product_id,
InventoryItem.location_id == item.from_location_id
).first()
if not inventory:
db.rollback()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product {item.product_id} not found at location {item.from_location_id}"
)
if inventory.quantity < item.quantity and transaction_type == TransactionType.SALE:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Insufficient stock for product {item.product_id} at location {item.from_location_id}"
)
inventory.quantity -= item.quantity
# Create transaction item
transaction_item = InventoryTransactionItem(
transaction_id=db_transaction.id,
product_id=item.product_id,
quantity=item.quantity,
from_location_id=item.from_location_id,
to_location_id=item.to_location_id
)
db.add(transaction_item)
transaction_items.append(transaction_item)
db.commit()
db.refresh(db_transaction)
# Refresh transaction items
for item in transaction_items:
db.refresh(item)
return db_transaction
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Database integrity error: {str(e)}"
)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Could not create transaction: {str(e)}"
)
@router.get("/inventory-transactions/", response_model=List[InventoryTransactionInDB])
def read_inventory_transactions(
*,
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
transaction_type: Optional[str] = None,
reference_id: Optional[str] = None
):
"""Get inventory transactions with optional filters"""
query = db.query(InventoryTransaction)
if transaction_type:
try:
type_value = TransactionType(transaction_type)
query = query.filter(InventoryTransaction.type == type_value)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid transaction type: {transaction_type}"
)
if reference_id:
query = query.filter(InventoryTransaction.reference_id == reference_id)
return query.order_by(InventoryTransaction.created_at.desc()).offset(skip).limit(limit).all()
@router.get("/inventory-transactions/{transaction_id}", response_model=InventoryTransactionInDB)
def read_inventory_transaction(
*,
db: Session = Depends(get_db),
transaction_id: int
):
"""Get a specific inventory transaction by ID"""
transaction = db.query(InventoryTransaction).filter(InventoryTransaction.id == transaction_id).first()
if not transaction:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Inventory transaction with ID {transaction_id} not found"
)
return transaction