Filter egg harvest events to only include adult female ducks

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 16:56:08 +00:00
parent 66d404efbc
commit fe73363a4b
2 changed files with 93 additions and 2 deletions

View File

@@ -55,7 +55,9 @@ ar = APIRouter()
def resolve_ducks_at_location(db: Any, location_id: str, ts_utc: int) -> list[str]: 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: Args:
db: Database connection. 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. ts_utc: Timestamp in ms since Unix epoch.
Returns: 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 = """ query = """
SELECT DISTINCT ali.animal_id 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 (ali.end_utc IS NULL OR ali.end_utc > ?)
AND ar.species_code = 'duck' AND ar.species_code = 'duck'
AND ar.status = 'alive' AND ar.status = 'alive'
AND ar.life_stage = 'adult'
AND ar.sex = 'female'
ORDER BY ali.animal_id ORDER BY ali.animal_id
""" """
rows = db.execute(query, (location_id, ts_utc, ts_utc)).fetchall() rows = db.execute(query, (location_id, ts_utc, ts_utc)).fetchall()

View File

@@ -278,3 +278,90 @@ class TestEggsRecentEvents:
# The response should contain a link to the event detail # The response should contain a link to the event detail
assert f"/events/{event_id}" in resp.text 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"