From fe73363a4b95d20b7430375e837140f55b71f85b Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sat, 10 Jan 2026 16:56:08 +0000 Subject: [PATCH] Filter egg harvest events to only include adult female ducks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Males, juveniles, and other non-laying animals were incorrectly being associated with egg collection events. Added life_stage='adult' and sex='female' filters to resolve_ducks_at_location() query. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/animaltrack/web/routes/eggs.py | 8 ++- tests/test_web_eggs.py | 87 ++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/animaltrack/web/routes/eggs.py b/src/animaltrack/web/routes/eggs.py index 8bc1086..30611b1 100644 --- a/src/animaltrack/web/routes/eggs.py +++ b/src/animaltrack/web/routes/eggs.py @@ -55,7 +55,9 @@ ar = APIRouter() def resolve_ducks_at_location(db: Any, location_id: str, ts_utc: int) -> list[str]: - """Resolve all duck animal IDs at a location at given timestamp. + """Resolve layer-eligible duck IDs at a location at given timestamp. + + Only includes adult female ducks that can lay eggs. Args: db: Database connection. @@ -63,7 +65,7 @@ def resolve_ducks_at_location(db: Any, location_id: str, ts_utc: int) -> list[st ts_utc: Timestamp in ms since Unix epoch. Returns: - List of animal IDs (ducks at the location, alive at ts_utc). + List of animal IDs (adult female ducks at the location, alive at ts_utc). """ query = """ SELECT DISTINCT ali.animal_id @@ -74,6 +76,8 @@ def resolve_ducks_at_location(db: Any, location_id: str, ts_utc: int) -> list[st AND (ali.end_utc IS NULL OR ali.end_utc > ?) AND ar.species_code = 'duck' AND ar.status = 'alive' + AND ar.life_stage = 'adult' + AND ar.sex = 'female' ORDER BY ali.animal_id """ rows = db.execute(query, (location_id, ts_utc, ts_utc)).fetchall() diff --git a/tests/test_web_eggs.py b/tests/test_web_eggs.py index f867012..69249a3 100644 --- a/tests/test_web_eggs.py +++ b/tests/test_web_eggs.py @@ -278,3 +278,90 @@ class TestEggsRecentEvents: # The response should contain a link to the event detail assert f"/events/{event_id}" in resp.text + + +class TestEggCollectionAnimalFiltering: + """Tests that egg collection only associates adult females.""" + + def test_egg_collection_excludes_males_and_juveniles( + self, client, seeded_db, location_strip1_id + ): + """Egg collection only associates adult female ducks, not males or juveniles.""" + # Setup: Create mixed animals at location + event_store = EventStore(seeded_db) + registry = ProjectionRegistry() + registry.register(AnimalRegistryProjection(seeded_db)) + registry.register(EventAnimalsProjection(seeded_db)) + registry.register(IntervalProjection(seeded_db)) + registry.register(ProductsProjection(seeded_db)) + + animal_service = AnimalService(seeded_db, event_store, registry) + ts_utc = int(time.time() * 1000) + + # Create adult female (should be included) + female_payload = AnimalCohortCreatedPayload( + species="duck", + count=1, + life_stage="adult", + sex="female", + location_id=location_strip1_id, + origin="purchased", + ) + female_event = animal_service.create_cohort(female_payload, ts_utc, "test_user") + female_id = female_event.entity_refs["animal_ids"][0] + + # Create adult male (should be excluded) + male_payload = AnimalCohortCreatedPayload( + species="duck", + count=1, + life_stage="adult", + sex="male", + location_id=location_strip1_id, + origin="purchased", + ) + male_event = animal_service.create_cohort(male_payload, ts_utc, "test_user") + male_id = male_event.entity_refs["animal_ids"][0] + + # Create juvenile female (should be excluded) + juvenile_payload = AnimalCohortCreatedPayload( + species="duck", + count=1, + life_stage="juvenile", + sex="female", + location_id=location_strip1_id, + origin="purchased", + ) + juvenile_event = animal_service.create_cohort(juvenile_payload, ts_utc, "test_user") + juvenile_id = juvenile_event.entity_refs["animal_ids"][0] + + # Collect eggs + resp = client.post( + "/actions/product-collected", + data={ + "location_id": location_strip1_id, + "quantity": "6", + "nonce": "test-nonce-filter", + }, + ) + assert resp.status_code == 200 + + # Get the egg collection event + event_row = seeded_db.execute( + "SELECT id FROM events WHERE type = 'ProductCollected' ORDER BY id DESC LIMIT 1" + ).fetchone() + event_id = event_row[0] + + # Check which animals are associated with the event + animal_rows = seeded_db.execute( + "SELECT animal_id FROM event_animals WHERE event_id = ?", + (event_id,), + ).fetchall() + associated_ids = {row[0] for row in animal_rows} + + # Only the adult female should be associated + assert female_id in associated_ids, "Adult female should be associated with egg collection" + assert male_id not in associated_ids, "Male should NOT be associated with egg collection" + assert juvenile_id not in associated_ids, ( + "Juvenile should NOT be associated with egg collection" + ) + assert len(associated_ids) == 1, "Only adult females should be associated"