
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)
583 lines
20 KiB
Python
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 |