
- Set up project structure and FastAPI application - Create database models with SQLAlchemy - Implement authentication with JWT - Add CRUD operations for products, inventory, categories - Implement purchase order and sales functionality - Create reporting endpoints - Set up Alembic for database migrations - Add comprehensive documentation in README.md
177 lines
5.9 KiB
Python
177 lines
5.9 KiB
Python
from typing import List, Optional, Dict, Any
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session, joinedload
|
|
from decimal import Decimal
|
|
|
|
from app.crud.base import CRUDBase
|
|
from app.models.sale import Sale, SaleItem
|
|
from app.models.inventory import Inventory, InventoryTransaction
|
|
from app.schemas.sale import SaleCreate, SaleUpdate
|
|
|
|
|
|
class CRUDSale(CRUDBase[Sale, SaleCreate, SaleUpdate]):
|
|
def create_with_items(
|
|
self, db: Session, *, obj_in: SaleCreate, user_id: int
|
|
) -> Optional[Sale]:
|
|
"""Create sale with items and update inventory"""
|
|
# First check if we have enough inventory
|
|
for item in obj_in.items:
|
|
inventory_qty = db.query(func.sum(Inventory.quantity)).filter(
|
|
Inventory.product_id == item.product_id
|
|
).scalar() or 0
|
|
|
|
if inventory_qty < item.quantity:
|
|
# Not enough inventory
|
|
return None
|
|
|
|
# Create sale
|
|
db_obj = Sale(
|
|
customer_name=obj_in.customer_name,
|
|
notes=obj_in.notes,
|
|
status=obj_in.status,
|
|
created_by=user_id
|
|
)
|
|
db.add(db_obj)
|
|
db.flush()
|
|
|
|
# Create items and update inventory
|
|
for item in obj_in.items:
|
|
db_item = SaleItem(
|
|
sale_id=db_obj.id,
|
|
product_id=item.product_id,
|
|
quantity=item.quantity,
|
|
unit_price=item.unit_price
|
|
)
|
|
db.add(db_item)
|
|
|
|
# Update inventory - reduce quantities
|
|
self._reduce_inventory(
|
|
db,
|
|
product_id=item.product_id,
|
|
quantity=item.quantity,
|
|
sale_id=db_obj.id
|
|
)
|
|
|
|
db.commit()
|
|
db.refresh(db_obj)
|
|
return db_obj
|
|
|
|
def _reduce_inventory(
|
|
self, db: Session, *, product_id: int, quantity: int, sale_id: int
|
|
) -> None:
|
|
"""Reduce inventory for a product, starting with oldest inventory first"""
|
|
remaining = quantity
|
|
|
|
# Get all inventory for this product, ordered by id (assuming oldest first)
|
|
inventories = db.query(Inventory).filter(
|
|
Inventory.product_id == product_id,
|
|
Inventory.quantity > 0
|
|
).order_by(Inventory.id).all()
|
|
|
|
for inv in inventories:
|
|
if remaining <= 0:
|
|
break
|
|
|
|
if inv.quantity >= remaining:
|
|
# This inventory is enough to cover remaining quantity
|
|
inv.quantity -= remaining
|
|
|
|
# Record transaction
|
|
transaction = InventoryTransaction(
|
|
product_id=product_id,
|
|
quantity=-remaining, # Negative for reduction
|
|
transaction_type="sale",
|
|
reference_id=sale_id,
|
|
location=inv.location
|
|
)
|
|
db.add(transaction)
|
|
|
|
remaining = 0
|
|
else:
|
|
# Use all of this inventory and continue to next
|
|
remaining -= inv.quantity
|
|
|
|
# Record transaction
|
|
transaction = InventoryTransaction(
|
|
product_id=product_id,
|
|
quantity=-inv.quantity, # Negative for reduction
|
|
transaction_type="sale",
|
|
reference_id=sale_id,
|
|
location=inv.location
|
|
)
|
|
db.add(transaction)
|
|
|
|
inv.quantity = 0
|
|
|
|
def get_with_items(self, db: Session, id: int) -> Optional[Sale]:
|
|
"""Get sale with its items"""
|
|
return db.query(Sale).options(
|
|
joinedload(Sale.items).joinedload(SaleItem.product)
|
|
).filter(Sale.id == id).first()
|
|
|
|
def get_multi_with_items(
|
|
self, db: Session, *, skip: int = 0, limit: int = 100
|
|
) -> List[Sale]:
|
|
"""Get multiple sales with their items"""
|
|
return db.query(Sale).options(
|
|
joinedload(Sale.items).joinedload(SaleItem.product)
|
|
).offset(skip).limit(limit).all()
|
|
|
|
def cancel_sale(self, db: Session, *, id: int) -> Optional[Sale]:
|
|
"""Cancel a sale and return items to inventory"""
|
|
sale = self.get_with_items(db, id)
|
|
|
|
if not sale:
|
|
return None
|
|
|
|
if sale.status != "completed":
|
|
return sale # Already cancelled or returned
|
|
|
|
sale.status = "cancelled"
|
|
|
|
# Return items to inventory
|
|
for item in sale.items:
|
|
# Find or create inventory at default location
|
|
inventory = db.query(Inventory).filter(
|
|
Inventory.product_id == item.product_id,
|
|
Inventory.location == None # Default location
|
|
).first()
|
|
|
|
if not inventory:
|
|
inventory = Inventory(
|
|
product_id=item.product_id,
|
|
quantity=0,
|
|
location=None
|
|
)
|
|
db.add(inventory)
|
|
db.flush()
|
|
|
|
# Update quantity
|
|
inventory.quantity += item.quantity
|
|
|
|
# Record transaction
|
|
transaction = InventoryTransaction(
|
|
product_id=item.product_id,
|
|
quantity=item.quantity,
|
|
transaction_type="return",
|
|
reference_id=sale.id,
|
|
reason="Sale cancelled"
|
|
)
|
|
db.add(transaction)
|
|
|
|
db.commit()
|
|
db.refresh(sale)
|
|
return sale
|
|
|
|
def get_total_amount(self, db: Session, *, id: int) -> Decimal:
|
|
"""Calculate total amount for a sale"""
|
|
result = db.query(
|
|
func.sum(SaleItem.quantity * SaleItem.unit_price).label("total")
|
|
).filter(
|
|
SaleItem.sale_id == id
|
|
).scalar()
|
|
|
|
return result or Decimal("0.00")
|
|
|
|
|
|
sale = CRUDSale(Sale) |