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)