Add Other Purchases to budget page

- New other_purchases table (date, total, notes)
- /api/other CRUD endpoints
- Budget stats now include other costs in cost/egg and cost/dozen math
- Budget page: new Log Other Purchases form, stat cards for other costs,
  combined Purchase History table showing feed and other entries together

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 22:47:57 -08:00
parent ceb0780663
commit 404fd0510f
8 changed files with 351 additions and 77 deletions

59
backend/routers/other.py Normal file
View File

@@ -0,0 +1,59 @@
from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from database import get_db
from models import OtherPurchase
from schemas import OtherPurchaseCreate, OtherPurchaseUpdate, OtherPurchaseOut
router = APIRouter(prefix="/api/other", tags=["other"])
@router.get("", response_model=list[OtherPurchaseOut])
def list_other_purchases(
start: Optional[date] = None,
end: Optional[date] = None,
db: Session = Depends(get_db),
):
q = select(OtherPurchase).order_by(OtherPurchase.date.desc())
if start:
q = q.where(OtherPurchase.date >= start)
if end:
q = q.where(OtherPurchase.date <= end)
return db.scalars(q).all()
@router.post("", response_model=OtherPurchaseOut, status_code=201)
def create_other_purchase(body: OtherPurchaseCreate, db: Session = Depends(get_db)):
record = OtherPurchase(**body.model_dump())
db.add(record)
db.commit()
db.refresh(record)
return record
@router.put("/{record_id}", response_model=OtherPurchaseOut)
def update_other_purchase(
record_id: int,
body: OtherPurchaseUpdate,
db: Session = Depends(get_db),
):
record = db.get(OtherPurchase, record_id)
if not record:
raise HTTPException(status_code=404, detail="Record not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(record, field, value)
db.commit()
db.refresh(record)
return record
@router.delete("/{record_id}", status_code=204)
def delete_other_purchase(record_id: int, db: Session = Depends(get_db)):
record = db.get(OtherPurchase, record_id)
if not record:
raise HTTPException(status_code=404, detail="Record not found")
db.delete(record)
db.commit()

View File

@@ -6,7 +6,7 @@ from sqlalchemy import select, func
from sqlalchemy.orm import Session
from database import get_db
from models import EggCollection, FlockHistory, FeedPurchase
from models import EggCollection, FlockHistory, FeedPurchase, OtherPurchase
from schemas import DashboardStats, BudgetStats, MonthlySummary
router = APIRouter(prefix="/api/stats", tags=["stats"])
@@ -65,6 +65,15 @@ def _total_feed_cost(db: Session, start: date | None = None, end: date | None =
return db.scalar(q)
def _total_other_cost(db: Session, start: date | None = None, end: date | None = None):
q = select(func.coalesce(func.sum(OtherPurchase.total), 0))
if start:
q = q.where(OtherPurchase.date >= start)
if end:
q = q.where(OtherPurchase.date <= end)
return db.scalar(q)
@router.get("/dashboard", response_model=DashboardStats)
def dashboard_stats(db: Session = Depends(get_db)):
today = date.today()
@@ -132,6 +141,18 @@ def monthly_stats(db: Session = Depends(get_db)):
feed_map = {(r.year, r.month): r.feed_cost for r in feed_rows}
# Monthly other costs
other_rows = db.execute(
select(
func.year(OtherPurchase.date).label('year'),
func.month(OtherPurchase.date).label('month'),
func.sum(OtherPurchase.total).label('other_cost'),
)
.group_by(func.year(OtherPurchase.date), func.month(OtherPurchase.date))
).all()
other_map = {(r.year, r.month): r.other_cost for r in other_rows}
results = []
for row in egg_rows:
y, m = int(row.year), int(row.month)
@@ -152,9 +173,13 @@ def monthly_stats(db: Session = Depends(get_db)):
avg_per_day = round(total_eggs / days_logged, 2) if days_logged else None
avg_per_hen = round(avg_per_day / flock, 3) if (avg_per_day and flock) else None
raw_feed_cost = feed_map.get((y, m))
feed_cost = round(Decimal(str(raw_feed_cost)), 2) if raw_feed_cost else None
cpe = round(Decimal(str(raw_feed_cost)) / Decimal(total_eggs), 4) if (raw_feed_cost and total_eggs) else None
raw_feed_cost = feed_map.get((y, m))
raw_other_cost = other_map.get((y, m))
feed_cost = round(Decimal(str(raw_feed_cost)), 2) if raw_feed_cost else None
other_cost = round(Decimal(str(raw_other_cost)), 2) if raw_other_cost else None
raw_total_cost = (raw_feed_cost or 0) + (raw_other_cost or 0)
cpe = round(Decimal(str(raw_total_cost)) / Decimal(total_eggs), 4) if (raw_total_cost and total_eggs) else None
cpd = round(cpe * 12, 4) if cpe else None
results.append(MonthlySummary(
@@ -167,6 +192,7 @@ def monthly_stats(db: Session = Depends(get_db)):
flock_at_month_end=flock,
avg_eggs_per_hen_per_day=avg_per_hen,
feed_cost=feed_cost,
other_cost=other_cost,
cost_per_egg=cpe,
cost_per_dozen=cpd,
))
@@ -179,10 +205,12 @@ def budget_stats(db: Session = Depends(get_db)):
today = date.today()
start_30d = today - timedelta(days=30)
total_cost = _total_feed_cost(db)
total_cost_30d = _total_feed_cost(db, start=start_30d)
total_eggs = _total_eggs(db)
total_eggs_30d = _total_eggs(db, start=start_30d)
total_feed_cost = _total_feed_cost(db)
total_feed_cost_30d = _total_feed_cost(db, start=start_30d)
total_other_cost = _total_other_cost(db)
total_other_cost_30d = _total_other_cost(db, start=start_30d)
total_eggs = _total_eggs(db)
total_eggs_30d = _total_eggs(db, start=start_30d)
def cost_per_egg(cost, eggs):
if not eggs or not cost:
@@ -192,12 +220,17 @@ def budget_stats(db: Session = Depends(get_db)):
def cost_per_dozen(cpe):
return round(cpe * 12, 4) if cpe else None
cpe = cost_per_egg(total_cost, total_eggs)
cpe_30d = cost_per_egg(total_cost_30d, total_eggs_30d)
combined_cost = total_feed_cost + total_other_cost
combined_cost_30d = total_feed_cost_30d + total_other_cost_30d
cpe = cost_per_egg(combined_cost, total_eggs)
cpe_30d = cost_per_egg(combined_cost_30d, total_eggs_30d)
return BudgetStats(
total_feed_cost=round(Decimal(str(total_cost)), 2) if total_cost else None,
total_feed_cost_30d=round(Decimal(str(total_cost_30d)), 2) if total_cost_30d else None,
total_feed_cost=round(Decimal(str(total_feed_cost)), 2) if total_feed_cost else None,
total_feed_cost_30d=round(Decimal(str(total_feed_cost_30d)), 2) if total_feed_cost_30d else None,
total_other_cost=round(Decimal(str(total_other_cost)), 2) if total_other_cost else None,
total_other_cost_30d=round(Decimal(str(total_other_cost_30d)), 2) if total_other_cost_30d else None,
total_eggs_alltime=total_eggs,
total_eggs_30d=total_eggs_30d,
cost_per_egg=cpe,