diff --git a/backend/main.py b/backend/main.py index af55333..40255cf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routers import eggs, flock, feed, stats +from routers import eggs, flock, feed, stats, other app = FastAPI(title="Eggtracker API") @@ -16,6 +16,7 @@ app.add_middleware( app.include_router(eggs.router) app.include_router(flock.router) app.include_router(feed.router) +app.include_router(other.router) app.include_router(stats.router) diff --git a/backend/models.py b/backend/models.py index 186356f..bf45591 100644 --- a/backend/models.py +++ b/backend/models.py @@ -33,3 +33,13 @@ class FeedPurchase(Base): price_per_bag: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) notes: Mapped[str] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + +class OtherPurchase(Base): + __tablename__ = "other_purchases" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + total: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) + notes: Mapped[str] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/routers/other.py b/backend/routers/other.py new file mode 100644 index 0000000..dedb7a1 --- /dev/null +++ b/backend/routers/other.py @@ -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() diff --git a/backend/routers/stats.py b/backend/routers/stats.py index eb3c401..f3e7933 100644 --- a/backend/routers/stats.py +++ b/backend/routers/stats.py @@ -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, diff --git a/backend/schemas.py b/backend/schemas.py index fd7b515..67896c1 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -73,6 +73,28 @@ class FeedPurchaseOut(BaseModel): model_config = {"from_attributes": True} +# ── Other Purchases ─────────────────────────────────────────────────────────── + +class OtherPurchaseCreate(BaseModel): + date: date + total: Decimal = Field(gt=0, decimal_places=2) + notes: Optional[str] = None + +class OtherPurchaseUpdate(BaseModel): + date: Optional[date] = None + total: Optional[Decimal] = Field(default=None, gt=0, decimal_places=2) + notes: Optional[str] = None + +class OtherPurchaseOut(BaseModel): + id: int + date: date + total: Decimal + notes: Optional[str] + created_at: datetime + + model_config = {"from_attributes": True} + + # ── Stats ───────────────────────────────────────────────────────────────────── class MonthlySummary(BaseModel): @@ -85,6 +107,7 @@ class MonthlySummary(BaseModel): flock_at_month_end: Optional[int] avg_eggs_per_hen_per_day: Optional[float] feed_cost: Optional[Decimal] + other_cost: Optional[Decimal] cost_per_egg: Optional[Decimal] cost_per_dozen: Optional[Decimal] @@ -99,11 +122,13 @@ class DashboardStats(BaseModel): days_tracked: int class BudgetStats(BaseModel): - total_feed_cost: Optional[Decimal] - total_feed_cost_30d: Optional[Decimal] - total_eggs_alltime: int - total_eggs_30d: int - cost_per_egg: Optional[Decimal] - cost_per_dozen: Optional[Decimal] - cost_per_egg_30d: Optional[Decimal] - cost_per_dozen_30d: Optional[Decimal] + total_feed_cost: Optional[Decimal] + total_feed_cost_30d: Optional[Decimal] + total_other_cost: Optional[Decimal] + total_other_cost_30d: Optional[Decimal] + total_eggs_alltime: int + total_eggs_30d: int + cost_per_egg: Optional[Decimal] + cost_per_dozen: Optional[Decimal] + cost_per_egg_30d: Optional[Decimal] + cost_per_dozen_30d: Optional[Decimal] diff --git a/mysql/init.sql b/mysql/init.sql index fc1ab9b..6554384 100644 --- a/mysql/init.sql +++ b/mysql/init.sql @@ -43,3 +43,15 @@ CREATE TABLE IF NOT EXISTS feed_purchases ( PRIMARY KEY (id), INDEX idx_date (date) ) ENGINE=InnoDB; + +-- ── Other purchases ─────────────────────────────────────────────────────────── +-- Catch-all for non-feed costs: bedding, snacks, shelter, etc. +CREATE TABLE IF NOT EXISTS other_purchases ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + date DATE NOT NULL, + total DECIMAL(10, 2) NOT NULL, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_date (date) +) ENGINE=InnoDB; diff --git a/nginx/html/budget.html b/nginx/html/budget.html index 079686e..7d802da 100644 --- a/nginx/html/budget.html +++ b/nginx/html/budget.html @@ -29,6 +29,7 @@