Restore api/migrations/models/services source files accidentally removed in gitignore commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 12:16:28 +00:00
parent c8fad1cf36
commit 907243c2cd
31 changed files with 29855 additions and 0 deletions

745
backend/api/logbook.py Normal file
View file

@ -0,0 +1,745 @@
"""
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"}