feat: add 30-day egg stats computation service
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 <noreply@anthropic.com>
This commit is contained in:
22
migrations/0007-egg-stats-30d.sql
Normal file
22
migrations/0007-egg-stats-30d.sql
Normal file
@@ -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);
|
||||
359
src/animaltrack/services/stats.py
Normal file
359
src/animaltrack/services/stats.py
Normal file
@@ -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
|
||||
506
tests/test_service_stats.py
Normal file
506
tests/test_service_stats.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user