""" Logbook API for wastage, transfers, staff food, and manual adjustments. """ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_, or_, func from sqlalchemy.orm import selectinload from datetime import date, datetime from typing import Optional, List from pydantic import BaseModel from decimal import Decimal import os import logging from auth import get_current_user, require_cap from database import get_db from models.user import User from models.logbook import ( LogbookEntry, LogbookLineItem, LogbookAttachment, EntryType, WastageReason, TransferStatus ) # from models.products import Product # TODO: Add Product model router = APIRouter(prefix="/logbook", tags=["Logbook"]) logger = logging.getLogger(__name__) # ============ Pydantic Schemas ============ class LineItemInput(BaseModel): product_id: Optional[int] = None product_name: str product_code: Optional[str] = None supplier_name: Optional[str] = None quantity: float unit: Optional[str] = None unit_price: Optional[float] = None total_cost: float notes: Optional[str] = None class WastageEntryInput(BaseModel): entry_date: date reason: WastageReason line_items: List[LineItemInput] notes: Optional[str] = None reference_number: Optional[str] = None class TransferEntryInput(BaseModel): entry_date: date destination_kitchen_id: int status: TransferStatus = TransferStatus.PENDING line_items: List[LineItemInput] notes: Optional[str] = None reference_number: Optional[str] = None class StaffFoodEntryInput(BaseModel): entry_date: date meal_type: str # breakfast, lunch, dinner, snack staff_count: Optional[int] = None line_items: List[LineItemInput] notes: Optional[str] = None class ManualAdjustmentInput(BaseModel): entry_date: date adjustment_reason: str original_invoice_id: Optional[int] = None line_items: List[LineItemInput] notes: Optional[str] = None reference_number: Optional[str] = None class LineItemResponse(BaseModel): id: int product_id: Optional[int] product_name: str product_code: Optional[str] supplier_name: Optional[str] quantity: float unit: Optional[str] unit_price: Optional[float] total_cost: float notes: Optional[str] class AttachmentResponse(BaseModel): id: int file_name: str file_path: str file_type: str file_size_bytes: int description: Optional[str] uploaded_at: str class LogbookEntryResponse(BaseModel): id: int entry_type: str entry_date: str reference_number: Optional[str] total_cost: float notes: Optional[str] type_data: dict created_by: int created_by_name: Optional[str] created_at: str line_items: List[LineItemResponse] attachments: List[AttachmentResponse] class LogbookSummary(BaseModel): total_entries: int total_cost: float by_type: dict # ============ Helper Functions ============ def build_entry_response(entry: LogbookEntry) -> LogbookEntryResponse: """Convert LogbookEntry model to response""" return LogbookEntryResponse( id=entry.id, entry_type=entry.entry_type.value, entry_date=entry.entry_date.isoformat(), reference_number=entry.reference_number, total_cost=float(entry.total_cost), notes=entry.notes, type_data=entry.type_data or {}, created_by=entry.created_by, created_by_name=entry.created_by_user.name if entry.created_by_user else None, created_at=entry.created_at.isoformat(), line_items=[ LineItemResponse( id=item.id, product_id=item.product_id, product_name=item.product_name, product_code=item.product_code, supplier_name=item.supplier_name, quantity=float(item.quantity), unit=item.unit, unit_price=float(item.unit_price) if item.unit_price else None, total_cost=float(item.total_cost), notes=item.notes ) for item in entry.line_items ], attachments=[ AttachmentResponse( id=att.id, file_name=att.file_name, file_path=att.file_path, file_type=att.file_type, file_size_bytes=att.file_size_bytes, description=att.description, uploaded_at=att.uploaded_at.isoformat() ) for att in entry.attachments ] ) async def create_line_items( db: AsyncSession, entry_id: int, kitchen_id: int, items: List[LineItemInput] ) -> Decimal: """Create line items for an entry and return total cost""" total_cost = Decimal(0) for item_input in items: line_item = LogbookLineItem( entry_id=entry_id, kitchen_id=kitchen_id, product_id=item_input.product_id, product_name=item_input.product_name, product_code=item_input.product_code, supplier_name=item_input.supplier_name, quantity=Decimal(str(item_input.quantity)), unit=item_input.unit, unit_price=Decimal(str(item_input.unit_price)) if item_input.unit_price else None, total_cost=Decimal(str(item_input.total_cost)), notes=item_input.notes ) db.add(line_item) total_cost += line_item.total_cost return total_cost # ============ Endpoints ============ @router.get("") async def get_logbook_entries( entry_type: Optional[EntryType] = None, date_from: Optional[date] = None, date_to: Optional[date] = None, search: Optional[str] = None, limit: int = 100, offset: int = 0, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ) -> List[LogbookEntryResponse]: """Get logbook entries with filters""" query = select(LogbookEntry).options( selectinload(LogbookEntry.line_items), selectinload(LogbookEntry.attachments), selectinload(LogbookEntry.created_by_user) ).where( and_( LogbookEntry.kitchen_id == current_user.kitchen_id, LogbookEntry.is_deleted == False ) ) if entry_type: query = query.where(LogbookEntry.entry_type == entry_type) if date_from: query = query.where(LogbookEntry.entry_date >= date_from) if date_to: query = query.where(LogbookEntry.entry_date <= date_to) if search: # Search in notes, reference number, and line item product names query = query.outerjoin(LogbookLineItem).where( or_( LogbookEntry.notes.ilike(f"%{search}%"), LogbookEntry.reference_number.ilike(f"%{search}%"), LogbookLineItem.product_name.ilike(f"%{search}%") ) ).distinct() query = query.order_by(LogbookEntry.entry_date.desc(), LogbookEntry.created_at.desc()) query = query.limit(limit).offset(offset) try: result = await db.execute(query) entries = result.scalars().unique().all() return [build_entry_response(entry) for entry in entries] except Exception as e: logger.exception(f"Error fetching logbook entries: {e}") raise HTTPException(status_code=500, detail=f"Error fetching entries: {str(e)}") @router.get("/summary") async def get_logbook_summary( date_from: Optional[date] = None, date_to: Optional[date] = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ) -> LogbookSummary: """Get summary statistics for logbook entries""" query = select( LogbookEntry.entry_type, func.count(LogbookEntry.id).label('count'), func.sum(LogbookEntry.total_cost).label('total') ).where( and_( LogbookEntry.kitchen_id == current_user.kitchen_id, LogbookEntry.is_deleted == False ) ).group_by(LogbookEntry.entry_type) if date_from: query = query.where(LogbookEntry.entry_date >= date_from) if date_to: query = query.where(LogbookEntry.entry_date <= date_to) result = await db.execute(query) rows = result.all() by_type = {} total_entries = 0 total_cost = Decimal(0) for row in rows: entry_type, count, cost = row by_type[entry_type.value] = { 'count': count, 'total_cost': float(cost or 0) } total_entries += count total_cost += cost or 0 return LogbookSummary( total_entries=total_entries, total_cost=float(total_cost), by_type=by_type ) @router.get("/daily-stats") async def get_daily_logbook_stats( date_from: Optional[date] = None, date_to: Optional[date] = None, entry_type: Optional[EntryType] = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get daily logbook statistics for a date range (for purchases chart integration)""" query = select( LogbookEntry.entry_date, LogbookEntry.entry_type, func.count(LogbookEntry.id).label("count"), func.sum(LogbookEntry.total_cost).label("total_cost") ).where( and_( LogbookEntry.kitchen_id == current_user.kitchen_id, LogbookEntry.is_deleted == False ) ) if date_from: query = query.where(LogbookEntry.entry_date >= date_from) if date_to: query = query.where(LogbookEntry.entry_date <= date_to) if entry_type: query = query.where(LogbookEntry.entry_type == entry_type) query = query.group_by(LogbookEntry.entry_date, LogbookEntry.entry_type) query = query.order_by(LogbookEntry.entry_date) result = await db.execute(query) rows = result.all() # Group by date, then by type daily_stats = {} for row in rows: date_str = row.entry_date.isoformat() if date_str not in daily_stats: daily_stats[date_str] = {} daily_stats[date_str][row.entry_type.value] = { "count": row.count, "total_cost": float(row.total_cost or 0) } return {"daily_stats": daily_stats} @router.get("/products/search") async def search_products( query: str, limit: int = 20, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Search products from invoice line items for logbook entry""" from models.line_item import LineItem from models.invoice import Invoice from models.supplier import Supplier # Search line items from invoices for this kitchen # Get distinct products with most recent price result = await db.execute( select( LineItem.description, LineItem.product_code, LineItem.unit, LineItem.unit_price, Supplier.name.label('supplier_name') ) .join(Invoice, LineItem.invoice_id == Invoice.id) .outerjoin(Supplier, Invoice.supplier_id == Supplier.id) .where( and_( Invoice.kitchen_id == current_user.kitchen_id, or_( LineItem.description.ilike(f"%{query}%"), LineItem.product_code.ilike(f"%{query}%") ) ) ) .order_by(Invoice.invoice_date.desc()) .limit(limit * 3) # Get more to allow for deduplication ) rows = result.all() # Deduplicate by description, keeping first (most recent) price seen = set() products = [] for row in rows: key = (row.description or '').lower() if key and key not in seen: seen.add(key) products.append({ "id": 0, # No persistent product ID "name": row.description, "product_code": row.product_code, "supplier_name": row.supplier_name, "unit": row.unit, "last_price": float(row.unit_price) if row.unit_price else None }) if len(products) >= limit: break return products @router.get("/{entry_id}") async def get_logbook_entry( entry_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ) -> LogbookEntryResponse: """Get single logbook entry with details""" result = await db.execute( select(LogbookEntry).where( and_( LogbookEntry.id == entry_id, LogbookEntry.kitchen_id == current_user.kitchen_id, LogbookEntry.is_deleted == False ) ) ) entry = result.scalar_one_or_none() if not entry: raise HTTPException(status_code=404, detail="Entry not found") return build_entry_response(entry) @router.post("/wastage") async def create_wastage_entry( entry_input: WastageEntryInput, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ) -> LogbookEntryResponse: """Create wastage entry""" if not entry_input.line_items: raise HTTPException(status_code=400, detail="At least one line item is required") entry = LogbookEntry( kitchen_id=current_user.kitchen_id, entry_type=EntryType.WASTAGE, entry_date=entry_input.entry_date, reference_number=entry_input.reference_number, notes=entry_input.notes, type_data={"reason": entry_input.reason.value}, created_by=current_user.id ) db.add(entry) await db.flush() # Get entry.id total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items) entry.total_cost = total_cost await db.commit() await db.refresh(entry) logger.info(f"Created wastage entry {entry.id} for kitchen {current_user.kitchen_id}, cost: {total_cost}") return await get_logbook_entry(entry.id, current_user, db) @router.post("/transfer") async def create_transfer_entry( entry_input: TransferEntryInput, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ) -> LogbookEntryResponse: """Create transfer entry""" if not entry_input.line_items: raise HTTPException(status_code=400, detail="At least one line item is required") entry = LogbookEntry( kitchen_id=current_user.kitchen_id, entry_type=EntryType.TRANSFER, entry_date=entry_input.entry_date, reference_number=entry_input.reference_number, notes=entry_input.notes, type_data={ "destination_kitchen_id": entry_input.destination_kitchen_id, "status": entry_input.status.value }, created_by=current_user.id ) db.add(entry) await db.flush() total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items) entry.total_cost = total_cost await db.commit() await db.refresh(entry) logger.info(f"Created transfer entry {entry.id} for kitchen {current_user.kitchen_id} -> {entry_input.destination_kitchen_id}") # TODO: Send notification to destination kitchen return await get_logbook_entry(entry.id, current_user, db) @router.post("/staff-food") async def create_staff_food_entry( entry_input: StaffFoodEntryInput, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ) -> LogbookEntryResponse: """Create staff food entry""" if not entry_input.line_items: raise HTTPException(status_code=400, detail="At least one line item is required") entry = LogbookEntry( kitchen_id=current_user.kitchen_id, entry_type=EntryType.STAFF_FOOD, entry_date=entry_input.entry_date, notes=entry_input.notes, type_data={ "meal_type": entry_input.meal_type, "staff_count": entry_input.staff_count }, created_by=current_user.id ) db.add(entry) await db.flush() total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items) entry.total_cost = total_cost await db.commit() await db.refresh(entry) logger.info(f"Created staff food entry {entry.id} for kitchen {current_user.kitchen_id}, meal: {entry_input.meal_type}") return await get_logbook_entry(entry.id, current_user, db) @router.post("/manual-adjustment") async def create_manual_adjustment_entry( entry_input: ManualAdjustmentInput, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ) -> LogbookEntryResponse: """Create manual adjustment entry""" if not entry_input.line_items: raise HTTPException(status_code=400, detail="At least one line item is required") entry = LogbookEntry( kitchen_id=current_user.kitchen_id, entry_type=EntryType.MANUAL_ADJUSTMENT, entry_date=entry_input.entry_date, reference_number=entry_input.reference_number, notes=entry_input.notes, type_data={ "adjustment_reason": entry_input.adjustment_reason, "original_invoice_id": entry_input.original_invoice_id }, created_by=current_user.id ) db.add(entry) await db.flush() total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items) entry.total_cost = total_cost await db.commit() await db.refresh(entry) logger.info(f"Created manual adjustment entry {entry.id} for kitchen {current_user.kitchen_id}") return await get_logbook_entry(entry.id, current_user, db) @router.patch("/{entry_id}") async def update_logbook_entry( entry_id: int, notes: Optional[str] = None, reference_number: Optional[str] = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Update logbook entry (limited fields)""" result = await db.execute( select(LogbookEntry).where( and_( LogbookEntry.id == entry_id, LogbookEntry.kitchen_id == current_user.kitchen_id, LogbookEntry.is_deleted == False ) ) ) entry = result.scalar_one_or_none() if not entry: raise HTTPException(status_code=404, detail="Entry not found") if notes is not None: entry.notes = notes if reference_number is not None: entry.reference_number = reference_number entry.updated_at = datetime.utcnow() await db.commit() return {"status": "updated"} @router.delete("/{entry_id}") async def delete_logbook_entry( entry_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Soft delete logbook entry""" result = await db.execute( select(LogbookEntry).where( and_( LogbookEntry.id == entry_id, LogbookEntry.kitchen_id == current_user.kitchen_id ) ) ) entry = result.scalar_one_or_none() if not entry: raise HTTPException(status_code=404, detail="Entry not found") entry.is_deleted = True entry.updated_at = datetime.utcnow() await db.commit() logger.info(f"Deleted logbook entry {entry_id} for kitchen {current_user.kitchen_id}") return {"status": "deleted"} @router.post("/{entry_id}/attachments") async def upload_attachment( entry_id: int, file: UploadFile = File(...), description: Optional[str] = Form(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Upload photo or document to logbook entry""" # Verify entry exists and belongs to user's kitchen result = await db.execute( select(LogbookEntry).where( and_( LogbookEntry.id == entry_id, LogbookEntry.kitchen_id == current_user.kitchen_id, LogbookEntry.is_deleted == False ) ) ) entry = result.scalar_one_or_none() if not entry: raise HTTPException(status_code=404, detail="Entry not found") # Validate file type allowed_types = ["image/jpeg", "image/png", "image/heic", "image/webp", "application/pdf"] if file.content_type not in allowed_types: raise HTTPException(status_code=400, detail=f"File type {file.content_type} not allowed. Allowed: {allowed_types}") # Save file upload_dir = f"/app/attachments/logbook/kitchen_{current_user.kitchen_id}" os.makedirs(upload_dir, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") file_extension = os.path.splitext(file.filename)[1] if file.filename else ".jpg" file_name = f"entry_{entry_id}_{timestamp}{file_extension}" file_path = f"{upload_dir}/{file_name}" content = await file.read() with open(file_path, "wb") as f: f.write(content) # Create attachment record attachment = LogbookAttachment( entry_id=entry_id, kitchen_id=current_user.kitchen_id, file_name=file.filename or file_name, file_path=file_path, file_type=file.content_type, file_size_bytes=len(content), description=description, uploaded_by=current_user.id ) db.add(attachment) await db.commit() logger.info(f"Uploaded attachment {attachment.id} to entry {entry_id}") return { "id": attachment.id, "file_name": attachment.file_name, "file_path": attachment.file_path } @router.delete("/{entry_id}/attachments/{attachment_id}") async def delete_attachment( entry_id: int, attachment_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Delete attachment from logbook entry""" result = await db.execute( select(LogbookAttachment).where( and_( LogbookAttachment.id == attachment_id, LogbookAttachment.entry_id == entry_id, LogbookAttachment.kitchen_id == current_user.kitchen_id ) ) ) attachment = result.scalar_one_or_none() if not attachment: raise HTTPException(status_code=404, detail="Attachment not found") # Delete file from disk if os.path.exists(attachment.file_path): os.remove(attachment.file_path) await db.delete(attachment) await db.commit() return {"status": "deleted"}