diff --git a/src/animaltrack/events/store.py b/src/animaltrack/events/store.py index 79fbc54..d9e57f6 100644 --- a/src/animaltrack/events/store.py +++ b/src/animaltrack/events/store.py @@ -144,6 +144,7 @@ class EventStore: until_utc: int | None = None, actor: str | None = None, limit: int = 100, + include_tombstoned: bool = False, ) -> list[Event]: """List events with optional filters. @@ -153,34 +154,44 @@ class EventStore: until_utc: Include events with ts_utc <= until_utc. actor: Filter by actor. limit: Maximum number of events to return. + include_tombstoned: If True, include tombstoned (deleted) events. + Defaults to False, excluding tombstoned events. Returns: List of events ordered by ts_utc ASC. """ - query = "SELECT id, type, ts_utc, actor, entity_refs, payload, version FROM events" + query = """ + SELECT e.id, e.type, e.ts_utc, e.actor, e.entity_refs, e.payload, e.version + FROM events e + LEFT JOIN event_tombstones t ON e.id = t.target_event_id + """ conditions = [] params: list = [] + # Exclude tombstoned events unless explicitly included + if not include_tombstoned: + conditions.append("t.target_event_id IS NULL") + if event_type is not None: - conditions.append("type = ?") + conditions.append("e.type = ?") params.append(event_type) if since_utc is not None: - conditions.append("ts_utc >= ?") + conditions.append("e.ts_utc >= ?") params.append(since_utc) if until_utc is not None: - conditions.append("ts_utc <= ?") + conditions.append("e.ts_utc <= ?") params.append(until_utc) if actor is not None: - conditions.append("actor = ?") + conditions.append("e.actor = ?") params.append(actor) if conditions: query += " WHERE " + " AND ".join(conditions) - query += " ORDER BY ts_utc ASC" + query += " ORDER BY e.ts_utc ASC" query += f" LIMIT {limit}" rows = self.db.execute(query, tuple(params)).fetchall() diff --git a/src/animaltrack/services/stats.py b/src/animaltrack/services/stats.py index 83fe598..16b3c21 100644 --- a/src/animaltrack/services/stats.py +++ b/src/animaltrack/services/stats.py @@ -149,16 +149,19 @@ def _count_eggs_in_window( Returns (eggs_count, species) where species is extracted from product_code. Window is inclusive on both ends: [window_start, window_end]. + Excludes tombstoned (deleted) events. """ rows = db.execute( """ - SELECT json_extract(entity_refs, '$.product_code') as product_code, - json_extract(entity_refs, '$.quantity') as quantity - FROM events - WHERE type = 'ProductCollected' - AND json_extract(entity_refs, '$.location_id') = :location_id - AND ts_utc >= :window_start - AND ts_utc <= :window_end + SELECT json_extract(e.entity_refs, '$.product_code') as product_code, + json_extract(e.entity_refs, '$.quantity') as quantity + FROM events e + LEFT JOIN event_tombstones t ON e.id = t.target_event_id + WHERE e.type = 'ProductCollected' + AND json_extract(e.entity_refs, '$.location_id') = :location_id + AND e.ts_utc >= :window_start + AND e.ts_utc <= :window_end + AND t.target_event_id IS NULL """, {"location_id": location_id, "window_start": window_start, "window_end": window_end}, ).fetchall() @@ -182,16 +185,19 @@ def _get_feed_events_in_window( """Get all FeedGiven events at location in window. Window is inclusive on both ends: [window_start, window_end]. + Excludes tombstoned (deleted) events. """ rows = db.execute( """ - SELECT ts_utc, entity_refs - FROM events - WHERE type = 'FeedGiven' - AND json_extract(entity_refs, '$.location_id') = :location_id - AND ts_utc >= :window_start - AND ts_utc <= :window_end - ORDER BY ts_utc + SELECT e.ts_utc, e.entity_refs + FROM events e + LEFT JOIN event_tombstones t ON e.id = t.target_event_id + WHERE e.type = 'FeedGiven' + AND json_extract(e.entity_refs, '$.location_id') = :location_id + AND e.ts_utc >= :window_start + AND e.ts_utc <= :window_end + AND t.target_event_id IS NULL + ORDER BY e.ts_utc """, {"location_id": location_id, "window_start": window_start, "window_end": window_end}, ).fetchall() @@ -213,15 +219,18 @@ def _get_feed_price_at_time(db: Any, feed_type_code: str, ts_utc: int) -> int: """Get the feed price per kg in cents at a given time. Returns the price from the most recent FeedPurchased event <= ts_utc. + Excludes tombstoned (deleted) events. """ row = db.execute( """ - SELECT json_extract(entity_refs, '$.price_per_kg_cents') as price - FROM events - WHERE type = 'FeedPurchased' - AND json_extract(entity_refs, '$.feed_type_code') = :feed_type_code - AND ts_utc <= :ts_utc - ORDER BY ts_utc DESC + SELECT json_extract(e.entity_refs, '$.price_per_kg_cents') as price + FROM events e + LEFT JOIN event_tombstones t ON e.id = t.target_event_id + WHERE e.type = 'FeedPurchased' + AND json_extract(e.entity_refs, '$.feed_type_code') = :feed_type_code + AND e.ts_utc <= :ts_utc + AND t.target_event_id IS NULL + ORDER BY e.ts_utc DESC LIMIT 1 """, {"feed_type_code": feed_type_code, "ts_utc": ts_utc}, diff --git a/src/animaltrack/web/routes/eggs.py b/src/animaltrack/web/routes/eggs.py index 58a5fe7..5b40816 100644 --- a/src/animaltrack/web/routes/eggs.py +++ b/src/animaltrack/web/routes/eggs.py @@ -169,6 +169,75 @@ def _get_eggs_per_day(db: Any, now_ms: int) -> float | None: return total_eggs / 30.0 +def _get_global_cost_per_egg(db: Any, now_ms: int) -> float | None: + """Calculate global cost per egg over 30-day window. + + Aggregates feed costs and egg counts across all locations. + + Args: + db: Database connection. + now_ms: Current timestamp in milliseconds. + + Returns: + Cost per egg in EUR, or None if no eggs collected. + """ + from animaltrack.events import FEED_GIVEN + + window_start = now_ms - THIRTY_DAYS_MS + event_store = EventStore(db) + + # Count eggs across all locations + egg_events = event_store.list_events( + event_type=PRODUCT_COLLECTED, + since_utc=window_start, + until_utc=now_ms, + limit=10000, + ) + + total_eggs = 0 + for event in egg_events: + product_code = event.entity_refs.get("product_code", "") + if product_code.startswith("egg."): + total_eggs += event.entity_refs.get("quantity", 0) + + if total_eggs == 0: + return None + + # Sum feed costs across all locations + feed_events = event_store.list_events( + event_type=FEED_GIVEN, + since_utc=window_start, + until_utc=now_ms, + limit=10000, + ) + + total_cost_cents = 0.0 + for event in feed_events: + amount_kg = event.entity_refs.get("amount_kg", 0) + feed_type_code = event.entity_refs.get("feed_type_code", "") + + # Look up price at the time of feeding + price_row = db.execute( + """ + SELECT json_extract(e.entity_refs, '$.price_per_kg_cents') as price + FROM events e + LEFT JOIN event_tombstones t ON e.id = t.target_event_id + WHERE e.type = 'FeedPurchased' + AND json_extract(e.entity_refs, '$.feed_type_code') = ? + AND e.ts_utc <= ? + AND t.target_event_id IS NULL + ORDER BY e.ts_utc DESC + LIMIT 1 + """, + (feed_type_code, event.ts_utc), + ).fetchone() + + price_per_kg_cents = price_row[0] if price_row else 0 + total_cost_cents += amount_kg * price_per_kg_cents + + return (total_cost_cents / 100) / total_eggs + + def _get_sales_stats(db: Any, now_ms: int) -> dict | None: """Calculate sales statistics over 30-day window. @@ -177,7 +246,8 @@ def _get_sales_stats(db: Any, now_ms: int) -> dict | None: now_ms: Current timestamp in milliseconds. Returns: - Dict with 'total_qty' and 'total_cents', or None if no data. + Dict with 'total_qty', 'total_cents', and 'avg_price_per_egg_cents', + or None if no data. """ window_start = now_ms - THIRTY_DAYS_MS event_store = EventStore(db) @@ -197,7 +267,13 @@ def _get_sales_stats(db: Any, now_ms: int) -> dict | None: total_qty += event.entity_refs.get("quantity", 0) total_cents += event.entity_refs.get("total_price_cents", 0) - return {"total_qty": total_qty, "total_cents": total_cents} + avg_price_per_egg_cents = total_cents / total_qty if total_qty > 0 else 0 + + return { + "total_qty": total_qty, + "total_cents": total_cents, + "avg_price_per_egg_cents": avg_price_per_egg_cents, + } def _get_eggs_display_data(db: Any, locations: list) -> dict: @@ -208,13 +284,15 @@ def _get_eggs_display_data(db: Any, locations: list) -> dict: locations: List of Location objects for name lookup. Returns: - Dict with harvest_events, sell_events, eggs_per_day, sales_stats, location_names. + Dict with harvest_events, sell_events, eggs_per_day, cost_per_egg, + sales_stats, location_names. """ now_ms = int(time.time() * 1000) return { "harvest_events": _get_recent_events(db, PRODUCT_COLLECTED, limit=10), "sell_events": _get_recent_events(db, PRODUCT_SOLD, limit=10), "eggs_per_day": _get_eggs_per_day(db, now_ms), + "cost_per_egg": _get_global_cost_per_egg(db, now_ms), "sales_stats": _get_sales_stats(db, now_ms), "location_names": {loc.id: loc.name for loc in locations}, } diff --git a/src/animaltrack/web/routes/feed.py b/src/animaltrack/web/routes/feed.py index d6ee6ce..b140982 100644 --- a/src/animaltrack/web/routes/feed.py +++ b/src/animaltrack/web/routes/feed.py @@ -166,6 +166,83 @@ def _get_feed_per_bird_per_day(db: Any, now_ms: int) -> float | None: return total_g / bird_days +def _get_cost_per_bird_per_day(db: Any, now_ms: int) -> float | None: + """Calculate feed cost per bird per day over 30-day window. + + Uses global bird-days and feed costs across all locations. + + Args: + db: Database connection. + now_ms: Current timestamp in milliseconds. + + Returns: + Feed cost in EUR per bird per day, or None if no data. + """ + window_start = now_ms - THIRTY_DAYS_MS + + # Get total bird-days across all locations + row = db.execute( + """ + SELECT COALESCE(SUM( + MIN(COALESCE(ali.end_utc, :window_end), :window_end) - + MAX(ali.start_utc, :window_start) + ), 0) as total_ms + FROM animal_location_intervals ali + JOIN animal_registry ar ON ali.animal_id = ar.animal_id + WHERE ali.start_utc < :window_end + AND (ali.end_utc IS NULL OR ali.end_utc > :window_start) + AND ar.status = 'alive' + """, + {"window_start": window_start, "window_end": now_ms}, + ).fetchone() + + total_ms = row[0] if row else 0 + ms_per_day = 24 * 60 * 60 * 1000 + bird_days = total_ms // ms_per_day if total_ms else 0 + + if bird_days == 0: + return None + + # Get total feed cost in window (all locations) + event_store = EventStore(db) + events = event_store.list_events( + event_type=FEED_GIVEN, + since_utc=window_start, + until_utc=now_ms, + limit=10000, + ) + + if not events: + return None + + total_cost_cents = 0.0 + for event in events: + amount_kg = event.entity_refs.get("amount_kg", 0) + feed_type_code = event.entity_refs.get("feed_type_code", "") + + # Look up price at the time of feeding + price_row = db.execute( + """ + SELECT json_extract(e.entity_refs, '$.price_per_kg_cents') as price + FROM events e + LEFT JOIN event_tombstones t ON e.id = t.target_event_id + WHERE e.type = 'FeedPurchased' + AND json_extract(e.entity_refs, '$.feed_type_code') = ? + AND e.ts_utc <= ? + AND t.target_event_id IS NULL + ORDER BY e.ts_utc DESC + LIMIT 1 + """, + (feed_type_code, event.ts_utc), + ).fetchone() + + price_per_kg_cents = price_row[0] if price_row else 0 + total_cost_cents += amount_kg * price_per_kg_cents + + # Convert to EUR and divide by bird-days + return (total_cost_cents / 100) / bird_days + + def _get_purchase_stats(db: Any, now_ms: int) -> dict | None: """Calculate purchase statistics over 30-day window. @@ -221,6 +298,7 @@ def _get_feed_display_data(db: Any, locations: list, feed_types: list) -> dict: "give_events": _get_recent_events(db, FEED_GIVEN, limit=10), "purchase_events": _get_recent_events(db, FEED_PURCHASED, limit=10), "feed_per_bird_per_day_g": _get_feed_per_bird_per_day(db, now_ms), + "cost_per_bird_per_day": _get_cost_per_bird_per_day(db, now_ms), "purchase_stats": _get_purchase_stats(db, now_ms), "location_names": {loc.id: loc.name for loc in locations}, "feed_type_names": {ft.code: ft.name for ft in feed_types}, diff --git a/src/animaltrack/web/templates/eggs.py b/src/animaltrack/web/templates/eggs.py index cb4fb4b..6a37bbd 100644 --- a/src/animaltrack/web/templates/eggs.py +++ b/src/animaltrack/web/templates/eggs.py @@ -34,6 +34,7 @@ def eggs_page( harvest_events: list[tuple[Event, bool]] | None = None, sell_events: list[tuple[Event, bool]] | None = None, eggs_per_day: float | None = None, + cost_per_egg: float | None = None, sales_stats: dict | None = None, location_names: dict[str, str] | None = None, ): @@ -52,7 +53,8 @@ def eggs_page( harvest_events: Recent ProductCollected events (most recent first). sell_events: Recent ProductSold events (most recent first). eggs_per_day: 30-day average eggs per day. - sales_stats: Dict with 'total_qty' and 'total_cents' for 30-day sales. + cost_per_egg: 30-day average cost per egg in EUR. + sales_stats: Dict with 'total_qty', 'total_cents', and 'avg_price_per_egg_cents'. location_names: Dict mapping location_id to location name for display. Returns: @@ -81,6 +83,7 @@ def eggs_page( action=harvest_action, recent_events=harvest_events, eggs_per_day=eggs_per_day, + cost_per_egg=cost_per_egg, location_names=location_names, ), cls="uk-active" if harvest_active else None, @@ -108,6 +111,7 @@ def harvest_form( action: Callable[..., Any] | str = "/actions/product-collected", recent_events: list[tuple[Event, bool]] | None = None, eggs_per_day: float | None = None, + cost_per_egg: float | None = None, location_names: dict[str, str] | None = None, ) -> Div: """Create the Harvest form for egg collection. @@ -119,6 +123,7 @@ def harvest_form( action: Route function or URL string for form submission. recent_events: Recent (Event, is_deleted) tuples, most recent first. eggs_per_day: 30-day average eggs per day. + cost_per_egg: 30-day average cost per egg in EUR. location_names: Dict mapping location_id to location name for display. Returns: @@ -160,10 +165,13 @@ def harvest_form( loc_name = location_names.get(loc_id, "Unknown") return f"{quantity} eggs from {loc_name}", event.id - # Build stats text - stat_text = None + # Build stats text - combine eggs/day and cost/egg + stat_parts = [] if eggs_per_day is not None: - stat_text = f"{eggs_per_day:.1f} eggs/day (30-day avg)" + stat_parts.append(f"{eggs_per_day:.1f} eggs/day") + if cost_per_egg is not None: + stat_parts.append(f"€{cost_per_egg:.3f}/egg cost") + stat_text = " | ".join(stat_parts) + " (30-day avg)" if stat_parts else None form = Form( H2("Harvest Eggs", cls="text-xl font-bold mb-4"), @@ -275,13 +283,18 @@ def sell_form( total_eur = total_cents / 100 return f"{quantity} {product_code} for €{total_eur:.2f}", event.id - # Build stats text - stat_text = None + # Build stats text - combine total sold, revenue, and avg price + stat_parts = [] total_qty = sales_stats.get("total_qty") total_cents = sales_stats.get("total_cents") + avg_price_cents = sales_stats.get("avg_price_per_egg_cents") if total_qty is not None and total_cents is not None: total_eur = total_cents / 100 - stat_text = f"{total_qty} sold for €{total_eur:.2f} (30-day total)" + stat_parts.append(f"{total_qty} sold for €{total_eur:.2f}") + if avg_price_cents is not None and avg_price_cents > 0: + avg_price_eur = avg_price_cents / 100 + stat_parts.append(f"€{avg_price_eur:.2f}/egg avg") + stat_text = " | ".join(stat_parts) + " (30-day)" if stat_parts else None form = Form( H2("Sell Products", cls="text-xl font-bold mb-4"), diff --git a/src/animaltrack/web/templates/feed.py b/src/animaltrack/web/templates/feed.py index 36ca033..800e4d3 100644 --- a/src/animaltrack/web/templates/feed.py +++ b/src/animaltrack/web/templates/feed.py @@ -36,6 +36,7 @@ def feed_page( give_events: list[tuple[Event, bool]] | None = None, purchase_events: list[tuple[Event, bool]] | None = None, feed_per_bird_per_day_g: float | None = None, + cost_per_bird_per_day: float | None = None, purchase_stats: dict | None = None, location_names: dict[str, str] | None = None, feed_type_names: dict[str, str] | None = None, @@ -57,6 +58,7 @@ def feed_page( give_events: Recent FeedGiven events (most recent first). purchase_events: Recent FeedPurchased events (most recent first). feed_per_bird_per_day_g: Average feed consumption in g/bird/day. + cost_per_bird_per_day: Average feed cost per bird per day in EUR. purchase_stats: Dict with 'total_kg' and 'avg_price_per_kg_cents'. location_names: Dict mapping location_id to location name. feed_type_names: Dict mapping feed_type_code to feed type name. @@ -93,6 +95,7 @@ def feed_page( action=give_action, recent_events=give_events, feed_per_bird_per_day_g=feed_per_bird_per_day_g, + cost_per_bird_per_day=cost_per_bird_per_day, location_names=location_names, feed_type_names=feed_type_names, ), @@ -125,6 +128,7 @@ def give_feed_form( action: Callable[..., Any] | str = "/actions/feed-given", recent_events: list[tuple[Event, bool]] | None = None, feed_per_bird_per_day_g: float | None = None, + cost_per_bird_per_day: float | None = None, location_names: dict[str, str] | None = None, feed_type_names: dict[str, str] | None = None, ) -> Div: @@ -141,6 +145,7 @@ def give_feed_form( action: Route function or URL for form submission. recent_events: Recent (Event, is_deleted) tuples, most recent first. feed_per_bird_per_day_g: Average feed consumption in g/bird/day. + cost_per_bird_per_day: Average feed cost per bird per day in EUR. location_names: Dict mapping location_id to location name. feed_type_names: Dict mapping feed_type_code to feed type name. @@ -207,10 +212,13 @@ def give_feed_form( feed_name = feed_type_names.get(feed_code, feed_code) return f"{amount_kg}kg {feed_name} to {loc_name}", event.id - # Build stats text - stat_text = None + # Build stats text - combine g/bird/day and cost/bird/day + stat_parts = [] if feed_per_bird_per_day_g is not None: - stat_text = f"{feed_per_bird_per_day_g:.1f} g/bird/day (30-day avg)" + stat_parts.append(f"{feed_per_bird_per_day_g:.1f}g/bird/day") + if cost_per_bird_per_day is not None: + stat_parts.append(f"€{cost_per_bird_per_day:.3f}/bird/day cost") + stat_text = " | ".join(stat_parts) + " (30-day avg)" if stat_parts else None form = Form( H2("Give Feed", cls="text-xl font-bold mb-4"), diff --git a/tests/test_event_store.py b/tests/test_event_store.py index 3b5effe..3009eb1 100644 --- a/tests/test_event_store.py +++ b/tests/test_event_store.py @@ -359,3 +359,66 @@ class TestTombstoneChecking: ) assert event_store.is_tombstoned(event.id) is True + + def test_list_events_excludes_tombstoned_by_default(self, migrated_db, event_store, now_utc): + """list_events excludes tombstoned events by default.""" + event1 = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={}, + payload={"order": 1}, + ) + event2 = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc + 1000, + actor="ppetru", + entity_refs={}, + payload={"order": 2}, + ) + + # Tombstone event1 + tombstone_id = generate_id() + migrated_db.execute( + """INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id, reason) + VALUES (?, ?, ?, ?, ?)""", + (tombstone_id, now_utc + 2000, "admin", event1.id, "Test deletion"), + ) + + events = event_store.list_events() + + assert len(events) == 1 + assert events[0].id == event2.id + + def test_list_events_includes_tombstoned_when_requested( + self, migrated_db, event_store, now_utc + ): + """list_events includes tombstoned events when include_tombstoned=True.""" + event1 = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={}, + payload={"order": 1}, + ) + event2 = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc + 1000, + actor="ppetru", + entity_refs={}, + payload={"order": 2}, + ) + + # Tombstone event1 + tombstone_id = generate_id() + migrated_db.execute( + """INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id, reason) + VALUES (?, ?, ?, ?, ?)""", + (tombstone_id, now_utc + 2000, "admin", event1.id, "Test deletion"), + ) + + events = event_store.list_events(include_tombstoned=True) + + assert len(events) == 2 + assert events[0].id == event1.id + assert events[1].id == event2.id