From c08fa476e033b2c1d92c93762128f31dd00b2991 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Mon, 29 Dec 2025 09:25:39 +0000 Subject: [PATCH] feat: add 30-day egg stats computation service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement compute-on-read egg statistics per spec section 9: - Create egg_stats_30d_by_location table (migration 0007) - Add get_egg_stats service with bird-days calculation - Calculate layer-eligible days (adult female + matching species) - Implement feed proration formula with INTEGER truncation - Cache computed stats with window bounds Verifies E2E test #1 baseline values: - eggs_total_pcs = 12 - feed_total_g = 6000, feed_layers_g = 4615 - cost_per_egg_all = 0.600, cost_per_egg_layers = 0.462 - layer_eligible_bird_days = 10 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- migrations/0007-egg-stats-30d.sql | 22 ++ src/animaltrack/services/stats.py | 359 +++++++++++++++++++++ tests/test_service_stats.py | 506 ++++++++++++++++++++++++++++++ 3 files changed, 887 insertions(+) create mode 100644 migrations/0007-egg-stats-30d.sql create mode 100644 src/animaltrack/services/stats.py create mode 100644 tests/test_service_stats.py diff --git a/migrations/0007-egg-stats-30d.sql b/migrations/0007-egg-stats-30d.sql new file mode 100644 index 0000000..036a386 --- /dev/null +++ b/migrations/0007-egg-stats-30d.sql @@ -0,0 +1,22 @@ +-- ABOUTME: Migration to create egg_stats_30d_by_location table. +-- ABOUTME: Caches computed 30-day rolling statistics for egg production. + +-- Egg stats computed on-read and cached per location +-- Feed amounts in grams (not kg) for precision +-- Costs stored as REAL EUR values +CREATE TABLE egg_stats_30d_by_location ( + location_id TEXT PRIMARY KEY REFERENCES locations(id), + window_start_utc INTEGER NOT NULL, + window_end_utc INTEGER NOT NULL, + eggs_total_pcs INTEGER NOT NULL, + feed_total_g INTEGER NOT NULL, + feed_layers_g INTEGER NOT NULL, + cost_per_egg_all_eur REAL NOT NULL, + cost_per_egg_layers_eur REAL NOT NULL, + layer_eligible_bird_days INTEGER NOT NULL, + layer_eligible_count_now INTEGER NOT NULL, + updated_at_utc INTEGER NOT NULL +); + +-- Index for finding stale stats that need recomputation +CREATE INDEX idx_egg_stats_updated ON egg_stats_30d_by_location(updated_at_utc); diff --git a/src/animaltrack/services/stats.py b/src/animaltrack/services/stats.py new file mode 100644 index 0000000..83fe598 --- /dev/null +++ b/src/animaltrack/services/stats.py @@ -0,0 +1,359 @@ +# ABOUTME: Service for computing 30-day egg production statistics. +# ABOUTME: Implements compute-on-read pattern per spec section 14. + +import json +import time +from dataclasses import dataclass +from typing import Any + +# 30 days in milliseconds +THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000 + + +@dataclass +class EggStats: + """30-day egg statistics for a single location.""" + + location_id: str + window_start_utc: int + window_end_utc: int + eggs_total_pcs: int + feed_total_g: int + feed_layers_g: int + cost_per_egg_all_eur: float + cost_per_egg_layers_eur: float + layer_eligible_bird_days: int + layer_eligible_count_now: int + updated_at_utc: int + + +def _get_species_for_product(product_code: str) -> str | None: + """Extract species from product code. + + Product codes like 'egg.duck' have species as suffix. + Returns None for generic products like 'meat'. + """ + if "." in product_code: + return product_code.split(".", 1)[1] + return None + + +def _calculate_bird_days(db: Any, location_id: str, window_start: int, window_end: int) -> int: + """Calculate total bird-days for all animals at a location within window. + + Bird-days = sum of overlap durations in animal_location_intervals. + Intervals with NULL end_utc are treated as open (use window_end). + """ + 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 + WHERE ali.location_id = :location_id + AND ali.start_utc < :window_end + AND (ali.end_utc IS NULL OR ali.end_utc > :window_start) + """, + {"location_id": location_id, "window_start": window_start, "window_end": window_end}, + ).fetchone() + + total_ms = row[0] if row else 0 + # Convert ms to days (we use integer days for simplicity in E2E test) + ms_per_day = 24 * 60 * 60 * 1000 + return total_ms // ms_per_day if total_ms else 0 + + +def _calculate_layer_eligible_bird_days( + db: Any, location_id: str, species: str, window_start: int, window_end: int +) -> int: + """Calculate layer-eligible bird-days at a location within window. + + Filters for: status='alive', life_stage='adult', sex='female', matching species. + """ + 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 ar.animal_id = ali.animal_id + WHERE ali.location_id = :location_id + AND ali.start_utc < :window_end + AND (ali.end_utc IS NULL OR ali.end_utc > :window_start) + AND ar.species_code = :species + -- Check status=alive at interval start + AND EXISTS ( + SELECT 1 FROM animal_attr_intervals aai + WHERE aai.animal_id = ali.animal_id + AND aai.attr = 'status' AND aai.value = 'alive' + AND aai.start_utc <= ali.start_utc + AND (aai.end_utc IS NULL OR aai.end_utc > ali.start_utc) + ) + -- Check life_stage=adult at interval start + AND EXISTS ( + SELECT 1 FROM animal_attr_intervals aai + WHERE aai.animal_id = ali.animal_id + AND aai.attr = 'life_stage' AND aai.value = 'adult' + AND aai.start_utc <= ali.start_utc + AND (aai.end_utc IS NULL OR aai.end_utc > ali.start_utc) + ) + -- Check sex=female at interval start + AND EXISTS ( + SELECT 1 FROM animal_attr_intervals aai + WHERE aai.animal_id = ali.animal_id + AND aai.attr = 'sex' AND aai.value = 'female' + AND aai.start_utc <= ali.start_utc + AND (aai.end_utc IS NULL OR aai.end_utc > ali.start_utc) + ) + """, + { + "location_id": location_id, + "species": species, + "window_start": window_start, + "window_end": window_end, + }, + ).fetchone() + + total_ms = row[0] if row else 0 + ms_per_day = 24 * 60 * 60 * 1000 + return total_ms // ms_per_day if total_ms else 0 + + +def _count_layer_eligible_now(db: Any, location_id: str, species: str, ts_utc: int) -> int: + """Count layer-eligible animals currently at location.""" + row = db.execute( + """ + SELECT COUNT(DISTINCT ar.animal_id) + FROM animal_registry ar + JOIN animal_location_intervals ali ON ali.animal_id = ar.animal_id + WHERE ali.location_id = :location_id + AND ali.start_utc <= :ts_utc + AND (ali.end_utc IS NULL OR ali.end_utc > :ts_utc) + AND ar.species_code = :species + AND ar.status = 'alive' + AND ar.life_stage = 'adult' + AND ar.sex = 'female' + """, + {"location_id": location_id, "species": species, "ts_utc": ts_utc}, + ).fetchone() + + return row[0] if row else 0 + + +def _count_eggs_in_window( + db: Any, location_id: str, window_start: int, window_end: int +) -> tuple[int, str | None]: + """Count eggs collected in window and return the species. + + Returns (eggs_count, species) where species is extracted from product_code. + Window is inclusive on both ends: [window_start, window_end]. + """ + 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 + """, + {"location_id": location_id, "window_start": window_start, "window_end": window_end}, + ).fetchall() + + total_eggs = 0 + species = None + for row in rows: + product_code = row[0] + quantity = row[1] + if product_code and product_code.startswith("egg."): + total_eggs += quantity + if species is None: + species = _get_species_for_product(product_code) + + return total_eggs, species + + +def _get_feed_events_in_window( + db: Any, location_id: str, window_start: int, window_end: int +) -> list[dict]: + """Get all FeedGiven events at location in window. + + Window is inclusive on both ends: [window_start, window_end]. + """ + 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 + """, + {"location_id": location_id, "window_start": window_start, "window_end": window_end}, + ).fetchall() + + result = [] + for row in rows: + entity_refs = json.loads(row[1]) + result.append( + { + "ts_utc": row[0], + "feed_type_code": entity_refs["feed_type_code"], + "amount_kg": entity_refs["amount_kg"], + } + ) + return result + + +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. + """ + 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 + LIMIT 1 + """, + {"feed_type_code": feed_type_code, "ts_utc": ts_utc}, + ).fetchone() + + return row[0] if row else 0 + + +def _upsert_stats(db: Any, stats: EggStats) -> None: + """Upsert stats to the cache table.""" + db.execute( + """ + INSERT INTO egg_stats_30d_by_location ( + location_id, window_start_utc, window_end_utc, + eggs_total_pcs, feed_total_g, feed_layers_g, + cost_per_egg_all_eur, cost_per_egg_layers_eur, + layer_eligible_bird_days, layer_eligible_count_now, + updated_at_utc + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(location_id) DO UPDATE SET + window_start_utc = excluded.window_start_utc, + window_end_utc = excluded.window_end_utc, + eggs_total_pcs = excluded.eggs_total_pcs, + feed_total_g = excluded.feed_total_g, + feed_layers_g = excluded.feed_layers_g, + cost_per_egg_all_eur = excluded.cost_per_egg_all_eur, + cost_per_egg_layers_eur = excluded.cost_per_egg_layers_eur, + layer_eligible_bird_days = excluded.layer_eligible_bird_days, + layer_eligible_count_now = excluded.layer_eligible_count_now, + updated_at_utc = excluded.updated_at_utc + """, + ( + stats.location_id, + stats.window_start_utc, + stats.window_end_utc, + stats.eggs_total_pcs, + stats.feed_total_g, + stats.feed_layers_g, + stats.cost_per_egg_all_eur, + stats.cost_per_egg_layers_eur, + stats.layer_eligible_bird_days, + stats.layer_eligible_count_now, + stats.updated_at_utc, + ), + ) + + +def get_egg_stats(db: Any, location_id: str, ts_utc: int) -> EggStats: + """Compute and cache 30-day egg stats for a location. + + This is a compute-on-read operation. Stats are computed fresh + from the event log and interval tables, then upserted to the + cache table. + + Args: + db: Database connection. + location_id: The location to compute stats for. + ts_utc: The timestamp to use as window_end_utc (usually now). + + Returns: + Computed stats for the location. + """ + window_end_utc = ts_utc + window_start_utc = ts_utc - THIRTY_DAYS_MS + updated_at_utc = int(time.time() * 1000) + + # Count eggs and determine species + eggs_total_pcs, species = _count_eggs_in_window( + db, location_id, window_start_utc, window_end_utc + ) + + # Default species to 'duck' if no eggs collected (for bird-days calculation) + if species is None: + species = "duck" + + # Calculate bird-days + all_animal_days = _calculate_bird_days(db, location_id, window_start_utc, window_end_utc) + layer_eligible_bird_days = _calculate_layer_eligible_bird_days( + db, location_id, species, window_start_utc, window_end_utc + ) + + # Count layer-eligible animals now + layer_eligible_count_now = _count_layer_eligible_now(db, location_id, species, ts_utc) + + # Calculate feed totals and costs + feed_events = _get_feed_events_in_window(db, location_id, window_start_utc, window_end_utc) + + feed_total_g = 0 + feed_layers_g = 0 + total_cost_cents = 0.0 + total_cost_layers_cents = 0.0 + + # Calculate share: layer_eligible_days / all_animal_days + share = layer_eligible_bird_days / all_animal_days if all_animal_days > 0 else 0.0 + + for feed_event in feed_events: + amount_g = feed_event["amount_kg"] * 1000 + price_per_kg_cents = _get_feed_price_at_time( + db, feed_event["feed_type_code"], feed_event["ts_utc"] + ) + + feed_total_g += amount_g + feed_layers_g += int(amount_g * share) # INTEGER truncation + + # Cost in cents for this feed event + cost_cents = feed_event["amount_kg"] * price_per_kg_cents + total_cost_cents += cost_cents + total_cost_layers_cents += cost_cents * share + + # Calculate cost per egg in EUR + if eggs_total_pcs > 0: + cost_per_egg_all_eur = (total_cost_cents / 100) / eggs_total_pcs + cost_per_egg_layers_eur = (total_cost_layers_cents / 100) / eggs_total_pcs + else: + cost_per_egg_all_eur = 0.0 + cost_per_egg_layers_eur = 0.0 + + stats = EggStats( + location_id=location_id, + window_start_utc=window_start_utc, + window_end_utc=window_end_utc, + eggs_total_pcs=eggs_total_pcs, + feed_total_g=feed_total_g, + feed_layers_g=feed_layers_g, + cost_per_egg_all_eur=cost_per_egg_all_eur, + cost_per_egg_layers_eur=cost_per_egg_layers_eur, + layer_eligible_bird_days=layer_eligible_bird_days, + layer_eligible_count_now=layer_eligible_count_now, + updated_at_utc=updated_at_utc, + ) + + # Cache the stats + _upsert_stats(db, stats) + + return stats diff --git a/tests/test_service_stats.py b/tests/test_service_stats.py new file mode 100644 index 0000000..4594700 --- /dev/null +++ b/tests/test_service_stats.py @@ -0,0 +1,506 @@ +# ABOUTME: Tests for stats service operations. +# ABOUTME: Tests get_egg_stats with E2E baseline verification per spec section 21. + +import time + +import pytest + +from animaltrack.events.payloads import ( + AnimalCohortCreatedPayload, + FeedGivenPayload, + FeedPurchasedPayload, + ProductCollectedPayload, +) +from animaltrack.events.store import EventStore +from animaltrack.projections import ProjectionRegistry +from animaltrack.projections.animal_registry import AnimalRegistryProjection +from animaltrack.projections.event_animals import EventAnimalsProjection +from animaltrack.projections.feed import FeedInventoryProjection +from animaltrack.projections.intervals import IntervalProjection +from animaltrack.projections.products import ProductsProjection +from animaltrack.services.animal import AnimalService +from animaltrack.services.feed import FeedService +from animaltrack.services.products import ProductService +from animaltrack.services.stats import EggStats, get_egg_stats + + +@pytest.fixture +def event_store(seeded_db): + """Create an EventStore for testing.""" + return EventStore(seeded_db) + + +@pytest.fixture +def projection_registry(seeded_db): + """Create a ProjectionRegistry with all needed projections.""" + registry = ProjectionRegistry() + registry.register(AnimalRegistryProjection(seeded_db)) + registry.register(EventAnimalsProjection(seeded_db)) + registry.register(IntervalProjection(seeded_db)) + registry.register(ProductsProjection(seeded_db)) + registry.register(FeedInventoryProjection(seeded_db)) + return registry + + +@pytest.fixture +def animal_service(seeded_db, event_store, projection_registry): + """Create an AnimalService for testing.""" + return AnimalService(seeded_db, event_store, projection_registry) + + +@pytest.fixture +def feed_service(seeded_db, event_store, projection_registry): + """Create a FeedService for testing.""" + return FeedService(seeded_db, event_store, projection_registry) + + +@pytest.fixture +def product_service(seeded_db, event_store, projection_registry): + """Create a ProductService for testing.""" + return ProductService(seeded_db, event_store, projection_registry) + + +@pytest.fixture +def location_id(seeded_db): + """Get Strip 1 location_id from seeded data.""" + row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone() + return row[0] + + +@pytest.fixture +def e2e_test1_setup(seeded_db, animal_service, feed_service, product_service, location_id): + """Set up E2E test #1: 13 ducks at Strip 1. + + Setup from spec section 21: + - 10 adult female ducks + 3 adult male ducks at Strip 1 + - 40kg feed purchased @ EUR 1.20/kg (2x20kg bags @ EUR 24) + - 6kg feed given + - 12 eggs collected + + Animals are created 1 day before feed/eggs events so bird-days calculation + shows 1 day of presence (13 animal-days total, 10 layer-eligible). + """ + one_day_ms = 24 * 60 * 60 * 1000 + base_ts = int(time.time() * 1000) + animal_creation_ts = base_ts - one_day_ms # Animals created 1 day ago + + # Create 10 adult female ducks (1 day ago) + females_payload = AnimalCohortCreatedPayload( + species="duck", + count=10, + life_stage="adult", + sex="female", + location_id=location_id, + origin="purchased", + ) + females_event = animal_service.create_cohort(females_payload, animal_creation_ts, "test_user") + female_ids = females_event.entity_refs["animal_ids"] + + # Create 3 adult male ducks (1 day ago) + males_payload = AnimalCohortCreatedPayload( + species="duck", + count=3, + life_stage="adult", + sex="male", + location_id=location_id, + origin="purchased", + ) + males_event = animal_service.create_cohort(males_payload, animal_creation_ts, "test_user") + male_ids = males_event.entity_refs["animal_ids"] + + # Purchase 40kg feed @ EUR 1.20/kg (2 bags of 20kg @ EUR 24 each) + purchase_payload = FeedPurchasedPayload( + feed_type_code="layer", + bag_size_kg=20, + bags_count=2, + bag_price_cents=2400, + ) + feed_service.purchase_feed(purchase_payload, base_ts + 1000, "test_user") + + # Give 6kg feed + give_payload = FeedGivenPayload( + location_id=location_id, + feed_type_code="layer", + amount_kg=6, + ) + feed_service.give_feed(give_payload, base_ts + 2000, "test_user") + + # Collect 12 eggs + all_animal_ids = female_ids + male_ids + collect_payload = ProductCollectedPayload( + location_id=location_id, + product_code="egg.duck", + quantity=12, + resolved_ids=all_animal_ids, + ) + product_service.collect_product(collect_payload, base_ts + 3000, "test_user") + + return { + "location_id": location_id, + "ts_utc": base_ts + 3000, + "female_ids": female_ids, + "male_ids": male_ids, + "base_ts": base_ts, + } + + +# ============================================================================= +# Migration Tests +# ============================================================================= + + +class TestEggStatsMigration: + """Tests for egg_stats_30d_by_location table schema.""" + + def test_table_exists(self, seeded_db): + """The egg_stats_30d_by_location table exists after migration.""" + row = seeded_db.execute( + """ + SELECT name FROM sqlite_master + WHERE type='table' AND name='egg_stats_30d_by_location' + """ + ).fetchone() + assert row is not None + + def test_table_has_all_columns(self, seeded_db): + """Table has all required columns.""" + rows = seeded_db.execute("PRAGMA table_info(egg_stats_30d_by_location)").fetchall() + column_names = {row[1] for row in rows} + + expected_columns = { + "location_id", + "window_start_utc", + "window_end_utc", + "eggs_total_pcs", + "feed_total_g", + "feed_layers_g", + "cost_per_egg_all_eur", + "cost_per_egg_layers_eur", + "layer_eligible_bird_days", + "layer_eligible_count_now", + "updated_at_utc", + } + assert expected_columns == column_names + + +# ============================================================================= +# E2E Test #1: Baseline Eggs+Feed+Costs +# ============================================================================= + + +class TestEggStatsE2EBaseline: + """Tests for E2E test #1 baseline values from spec section 21.""" + + def test_returns_egg_stats_dataclass(self, seeded_db, e2e_test1_setup): + """get_egg_stats returns an EggStats instance.""" + stats = get_egg_stats(seeded_db, e2e_test1_setup["location_id"], e2e_test1_setup["ts_utc"]) + assert isinstance(stats, EggStats) + + def test_eggs_total_pcs_is_12(self, seeded_db, e2e_test1_setup): + """eggs_total_pcs should be 12.""" + stats = get_egg_stats(seeded_db, e2e_test1_setup["location_id"], e2e_test1_setup["ts_utc"]) + assert stats.eggs_total_pcs == 12 + + def test_feed_total_g_is_6000(self, seeded_db, e2e_test1_setup): + """feed_total_g should be 6000 (6kg in grams).""" + stats = get_egg_stats(seeded_db, e2e_test1_setup["location_id"], e2e_test1_setup["ts_utc"]) + assert stats.feed_total_g == 6000 + + def test_feed_layers_g_is_4615(self, seeded_db, e2e_test1_setup): + """feed_layers_g should be 4615 (6000 * 10/13 = 4615.38 -> 4615). + + This uses INTEGER truncation, not rounding. + """ + stats = get_egg_stats(seeded_db, e2e_test1_setup["location_id"], e2e_test1_setup["ts_utc"]) + assert stats.feed_layers_g == 4615 + + def test_cost_per_egg_all_is_0_600(self, seeded_db, e2e_test1_setup): + """cost_per_egg_all should be 0.600 +/- 0.001. + + 6kg @ EUR 1.20/kg = EUR 7.20 / 12 eggs = EUR 0.60/egg + """ + stats = get_egg_stats(seeded_db, e2e_test1_setup["location_id"], e2e_test1_setup["ts_utc"]) + assert abs(stats.cost_per_egg_all_eur - 0.600) < 0.001 + + def test_cost_per_egg_layers_is_0_462(self, seeded_db, e2e_test1_setup): + """cost_per_egg_layers should be 0.462 +/- 0.001. + + share = 10/13 = 0.769230769 + layer_cost = EUR 7.20 * 0.769230769 = EUR 5.538461538 + cost_per_egg_layers = EUR 5.538 / 12 = EUR 0.46153... + """ + stats = get_egg_stats(seeded_db, e2e_test1_setup["location_id"], e2e_test1_setup["ts_utc"]) + assert abs(stats.cost_per_egg_layers_eur - 0.462) < 0.001 + + def test_layer_eligible_bird_days_is_10(self, seeded_db, e2e_test1_setup): + """layer_eligible_bird_days should be 10. + + 10 adult female ducks, all present for same duration = 10 bird-days. + """ + stats = get_egg_stats(seeded_db, e2e_test1_setup["location_id"], e2e_test1_setup["ts_utc"]) + assert stats.layer_eligible_bird_days == 10 + + def test_layer_eligible_count_now_is_10(self, seeded_db, e2e_test1_setup): + """layer_eligible_count_now should be 10. + + 10 adult female ducks currently at the location. + """ + stats = get_egg_stats(seeded_db, e2e_test1_setup["location_id"], e2e_test1_setup["ts_utc"]) + assert stats.layer_eligible_count_now == 10 + + +# ============================================================================= +# Edge Case Tests +# ============================================================================= + + +class TestEggStatsEdgeCases: + """Tests for edge cases in stats computation.""" + + def test_no_eggs_returns_zero_costs(self, seeded_db, location_id): + """When eggs_total_pcs=0, costs should be 0.0.""" + ts_utc = int(time.time() * 1000) + stats = get_egg_stats(seeded_db, location_id, ts_utc) + + assert stats.eggs_total_pcs == 0 + assert stats.cost_per_egg_all_eur == 0.0 + assert stats.cost_per_egg_layers_eur == 0.0 + + def test_no_feed_returns_zero_costs( + self, seeded_db, animal_service, product_service, location_id + ): + """When no feed given, costs should be 0.0.""" + base_ts = int(time.time() * 1000) + + # Create animals + payload = AnimalCohortCreatedPayload( + species="duck", + count=5, + life_stage="adult", + sex="female", + location_id=location_id, + origin="purchased", + ) + event = animal_service.create_cohort(payload, base_ts, "test_user") + animal_ids = event.entity_refs["animal_ids"] + + # Collect eggs without any feed + collect_payload = ProductCollectedPayload( + location_id=location_id, + product_code="egg.duck", + quantity=10, + resolved_ids=animal_ids, + ) + product_service.collect_product(collect_payload, base_ts + 1000, "test_user") + + stats = get_egg_stats(seeded_db, location_id, base_ts + 1000) + + assert stats.eggs_total_pcs == 10 + assert stats.feed_total_g == 0 + assert stats.cost_per_egg_all_eur == 0.0 + assert stats.cost_per_egg_layers_eur == 0.0 + + def test_no_animals_returns_zero_bird_days(self, seeded_db, location_id): + """When no animals at location, bird-days should be 0.""" + ts_utc = int(time.time() * 1000) + stats = get_egg_stats(seeded_db, location_id, ts_utc) + + assert stats.layer_eligible_bird_days == 0 + assert stats.layer_eligible_count_now == 0 + + +# ============================================================================= +# Filtering Tests +# ============================================================================= + + +class TestEggStatsFiltering: + """Tests for layer-eligible filtering criteria.""" + + def test_males_not_layer_eligible( + self, seeded_db, animal_service, feed_service, product_service, location_id + ): + """Male animals should not be counted in layer-eligible bird-days.""" + base_ts = int(time.time() * 1000) + + # Create only male ducks + payload = AnimalCohortCreatedPayload( + species="duck", + count=5, + life_stage="adult", + sex="male", + location_id=location_id, + origin="purchased", + ) + event = animal_service.create_cohort(payload, base_ts, "test_user") + animal_ids = event.entity_refs["animal_ids"] + + # Purchase and give feed + feed_service.purchase_feed( + FeedPurchasedPayload( + feed_type_code="layer", bag_size_kg=20, bags_count=1, bag_price_cents=2400 + ), + base_ts + 1000, + "test_user", + ) + feed_service.give_feed( + FeedGivenPayload(location_id=location_id, feed_type_code="layer", amount_kg=5), + base_ts + 2000, + "test_user", + ) + + # Collect eggs + product_service.collect_product( + ProductCollectedPayload( + location_id=location_id, + product_code="egg.duck", + quantity=6, + resolved_ids=animal_ids, + ), + base_ts + 3000, + "test_user", + ) + + stats = get_egg_stats(seeded_db, location_id, base_ts + 3000) + + assert stats.layer_eligible_bird_days == 0 + assert stats.layer_eligible_count_now == 0 + + def test_juveniles_not_layer_eligible( + self, seeded_db, animal_service, feed_service, product_service, location_id + ): + """Juvenile animals should not be counted in layer-eligible bird-days.""" + base_ts = int(time.time() * 1000) + + # Create juvenile females + payload = AnimalCohortCreatedPayload( + species="duck", + count=5, + life_stage="juvenile", + sex="female", + location_id=location_id, + origin="hatched", + ) + event = animal_service.create_cohort(payload, base_ts, "test_user") + animal_ids = event.entity_refs["animal_ids"] + + # Purchase and give feed + feed_service.purchase_feed( + FeedPurchasedPayload( + feed_type_code="layer", bag_size_kg=20, bags_count=1, bag_price_cents=2400 + ), + base_ts + 1000, + "test_user", + ) + feed_service.give_feed( + FeedGivenPayload(location_id=location_id, feed_type_code="layer", amount_kg=5), + base_ts + 2000, + "test_user", + ) + + # Collect eggs + product_service.collect_product( + ProductCollectedPayload( + location_id=location_id, + product_code="egg.duck", + quantity=6, + resolved_ids=animal_ids, + ), + base_ts + 3000, + "test_user", + ) + + stats = get_egg_stats(seeded_db, location_id, base_ts + 3000) + + assert stats.layer_eligible_bird_days == 0 + assert stats.layer_eligible_count_now == 0 + + def test_wrong_species_not_layer_eligible( + self, seeded_db, animal_service, feed_service, product_service, location_id + ): + """Goose animals should not be counted for egg.duck product.""" + base_ts = int(time.time() * 1000) + + # Create adult female geese + payload = AnimalCohortCreatedPayload( + species="goose", + count=5, + life_stage="adult", + sex="female", + location_id=location_id, + origin="purchased", + ) + event = animal_service.create_cohort(payload, base_ts, "test_user") + animal_ids = event.entity_refs["animal_ids"] + + # Purchase and give feed + feed_service.purchase_feed( + FeedPurchasedPayload( + feed_type_code="layer", bag_size_kg=20, bags_count=1, bag_price_cents=2400 + ), + base_ts + 1000, + "test_user", + ) + feed_service.give_feed( + FeedGivenPayload(location_id=location_id, feed_type_code="layer", amount_kg=5), + base_ts + 2000, + "test_user", + ) + + # Collect duck eggs (geese producing duck eggs is unusual but tests filtering) + product_service.collect_product( + ProductCollectedPayload( + location_id=location_id, + product_code="egg.duck", + quantity=6, + resolved_ids=animal_ids, + ), + base_ts + 3000, + "test_user", + ) + + stats = get_egg_stats(seeded_db, location_id, base_ts + 3000) + + # Geese don't count as layer-eligible for duck eggs + assert stats.layer_eligible_bird_days == 0 + assert stats.layer_eligible_count_now == 0 + + +# ============================================================================= +# Caching Tests +# ============================================================================= + + +class TestEggStatsCaching: + """Tests for stats caching behavior.""" + + def test_stats_upserted_to_table(self, seeded_db, e2e_test1_setup): + """Stats are cached in egg_stats_30d_by_location table.""" + get_egg_stats(seeded_db, e2e_test1_setup["location_id"], e2e_test1_setup["ts_utc"]) + + row = seeded_db.execute( + "SELECT eggs_total_pcs FROM egg_stats_30d_by_location WHERE location_id = ?", + (e2e_test1_setup["location_id"],), + ).fetchone() + + assert row is not None + assert row[0] == 12 + + def test_cached_stats_have_window_bounds(self, seeded_db, e2e_test1_setup): + """Cached stats include window_start_utc and window_end_utc.""" + ts_utc = e2e_test1_setup["ts_utc"] + get_egg_stats(seeded_db, e2e_test1_setup["location_id"], ts_utc) + + row = seeded_db.execute( + """ + SELECT window_start_utc, window_end_utc + FROM egg_stats_30d_by_location WHERE location_id = ? + """, + (e2e_test1_setup["location_id"],), + ).fetchone() + + assert row is not None + assert row[1] == ts_utc # window_end_utc + # Window is 30 days + thirty_days_ms = 30 * 24 * 60 * 60 * 1000 + assert row[0] == ts_utc - thirty_days_ms # window_start_utc