feat: add feed given event handling

- Add FEED_GIVEN to FeedInventoryProjection with apply/revert
- Add give_feed method to FeedService
- Block feed given if no purchase exists <= ts_utc
- Validate feed type and location existence
- 13 new tests for give_feed functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-29 08:10:37 +00:00
parent ff8f3bb3f5
commit fa3c99b755
4 changed files with 349 additions and 13 deletions

12
PLAN.md
View File

@@ -153,12 +153,12 @@ Check off items as completed. Each phase builds on the previous.
- [x] **Commit checkpoint** (5c10a75) - [x] **Commit checkpoint** (5c10a75)
### Step 4.2: Feed Given Event ### Step 4.2: Feed Given Event
- [ ] Implement apply_feed_given (increment given_kg, decrement balance) - [x] Implement apply_feed_given (increment given_kg, decrement balance)
- [ ] Implement revert_feed_given - [x] Implement revert_feed_given
- [ ] Add give_feed to services/feed.py - [x] Add give_feed to services/feed.py
- [ ] Block if no purchase <= ts_utc - [x] Block if no purchase <= ts_utc
- [ ] Write tests: give updates inventory, blocked without purchase, revert - [x] Write tests: give updates inventory, blocked without purchase, revert
- [ ] **Commit checkpoint** - [x] **Commit checkpoint** (2eb0ca7)
### Step 4.3: Product Collection Event ### Step 4.3: Product Collection Event
- [ ] Create `projections/products.py` for ProductCollected - [ ] Create `projections/products.py` for ProductCollected

View File

@@ -3,7 +3,7 @@
from typing import Any from typing import Any
from animaltrack.events.types import FEED_PURCHASED from animaltrack.events.types import FEED_GIVEN, FEED_PURCHASED
from animaltrack.models.events import Event from animaltrack.models.events import Event
from animaltrack.projections.base import Projection from animaltrack.projections.base import Projection
@@ -25,17 +25,21 @@ class FeedInventoryProjection(Projection):
def get_event_types(self) -> list[str]: def get_event_types(self) -> list[str]:
"""Return the event types this projection handles.""" """Return the event types this projection handles."""
return [FEED_PURCHASED] return [FEED_PURCHASED, FEED_GIVEN]
def apply(self, event: Event) -> None: def apply(self, event: Event) -> None:
"""Apply feed event to update inventory.""" """Apply feed event to update inventory."""
if event.type == FEED_PURCHASED: if event.type == FEED_PURCHASED:
self._apply_feed_purchased(event) self._apply_feed_purchased(event)
elif event.type == FEED_GIVEN:
self._apply_feed_given(event)
def revert(self, event: Event) -> None: def revert(self, event: Event) -> None:
"""Revert feed event from inventory.""" """Revert feed event from inventory."""
if event.type == FEED_PURCHASED: if event.type == FEED_PURCHASED:
self._revert_feed_purchased(event) self._revert_feed_purchased(event)
elif event.type == FEED_GIVEN:
self._revert_feed_given(event)
def _apply_feed_purchased(self, event: Event) -> None: def _apply_feed_purchased(self, event: Event) -> None:
"""Apply feed purchase to inventory. """Apply feed purchase to inventory.
@@ -86,3 +90,47 @@ class FeedInventoryProjection(Projection):
""", """,
(total_kg, total_kg, ts_utc, feed_type_code), (total_kg, total_kg, ts_utc, feed_type_code),
) )
def _apply_feed_given(self, event: Event) -> None:
"""Apply feed given to inventory.
- Increment given_kg
- Decrement balance_kg
- Update last_given_at_utc
"""
feed_type_code = event.entity_refs.get("feed_type_code")
amount_kg = event.entity_refs.get("amount_kg")
ts_utc = event.ts_utc
self.db.execute(
"""
UPDATE feed_inventory
SET given_kg = given_kg + ?,
balance_kg = balance_kg - ?,
last_given_at_utc = ?,
updated_at_utc = ?
WHERE feed_type_code = ?
""",
(amount_kg, amount_kg, ts_utc, ts_utc, feed_type_code),
)
def _revert_feed_given(self, event: Event) -> None:
"""Revert feed given from inventory.
- Decrement given_kg
- Increment balance_kg
"""
feed_type_code = event.entity_refs.get("feed_type_code")
amount_kg = event.entity_refs.get("amount_kg")
ts_utc = event.ts_utc
self.db.execute(
"""
UPDATE feed_inventory
SET given_kg = given_kg - ?,
balance_kg = balance_kg + ?,
updated_at_utc = ?
WHERE feed_type_code = ?
""",
(amount_kg, amount_kg, ts_utc, feed_type_code),
)

View File

@@ -4,13 +4,14 @@
from typing import Any from typing import Any
from animaltrack.db import transaction from animaltrack.db import transaction
from animaltrack.events.payloads import FeedPurchasedPayload from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload
from animaltrack.events.processor import process_event from animaltrack.events.processor import process_event
from animaltrack.events.store import EventStore from animaltrack.events.store import EventStore
from animaltrack.events.types import FEED_PURCHASED from animaltrack.events.types import FEED_GIVEN, FEED_PURCHASED
from animaltrack.models.events import Event from animaltrack.models.events import Event
from animaltrack.projections import ProjectionRegistry from animaltrack.projections import ProjectionRegistry
from animaltrack.repositories.feed_types import FeedTypeRepository from animaltrack.repositories.feed_types import FeedTypeRepository
from animaltrack.repositories.locations import LocationRepository
class FeedServiceError(Exception): class FeedServiceError(Exception):
@@ -45,6 +46,7 @@ class FeedService:
self.event_store = event_store self.event_store = event_store
self.registry = registry self.registry = registry
self.feed_type_repo = FeedTypeRepository(db) self.feed_type_repo = FeedTypeRepository(db)
self.location_repo = LocationRepository(db)
def purchase_feed( def purchase_feed(
self, self,
@@ -108,3 +110,81 @@ class FeedService:
process_event(event, self.registry) process_event(event, self.registry)
return event return event
def give_feed(
self,
payload: FeedGivenPayload,
ts_utc: int,
actor: str,
nonce: str | None = None,
route: str | None = None,
) -> Event:
"""Record feed given to animals.
Creates a FeedGiven event and updates inventory.
Decrements balance_kg by the given amount.
Args:
payload: Validated give feed payload.
ts_utc: Timestamp in milliseconds since epoch.
actor: The user performing the action.
nonce: Optional idempotency nonce.
route: Required if nonce provided.
Returns:
The created event.
Raises:
ValidationError: If feed type doesn't exist, is inactive,
location doesn't exist, or no prior purchase.
"""
# Validate feed type exists and is active
feed_type = self.feed_type_repo.get(payload.feed_type_code)
if feed_type is None:
msg = f"Feed type '{payload.feed_type_code}' not found"
raise ValidationError(msg)
if not feed_type.active:
msg = f"Feed type '{payload.feed_type_code}' is inactive"
raise ValidationError(msg)
# Validate location exists
location = self.location_repo.get(payload.location_id)
if location is None:
msg = f"Location '{payload.location_id}' not found"
raise ValidationError(msg)
# Check that a purchase exists for this feed type <= ts_utc
row = self.db.execute(
"""
SELECT 1 FROM feed_inventory
WHERE feed_type_code = ? AND last_purchase_at_utc <= ?
""",
(payload.feed_type_code, ts_utc),
).fetchone()
if row is None:
msg = f"No feed purchased for '{payload.feed_type_code}' before this date"
raise ValidationError(msg)
# Build entity_refs
entity_refs = {
"feed_type_code": payload.feed_type_code,
"location_id": payload.location_id,
"amount_kg": payload.amount_kg,
}
with transaction(self.db):
event = self.event_store.append_event(
event_type=FEED_GIVEN,
ts_utc=ts_utc,
actor=actor,
entity_refs=entity_refs,
payload=payload.model_dump(),
nonce=nonce,
route=route,
)
process_event(event, self.registry)
return event

View File

@@ -1,13 +1,13 @@
# ABOUTME: Tests for FeedService operations. # ABOUTME: Tests for FeedService operations.
# ABOUTME: Tests purchase_feed with inventory tracking and price storage. # ABOUTME: Tests purchase_feed and give_feed with inventory tracking.
import time import time
import pytest import pytest
from animaltrack.events.payloads import FeedPurchasedPayload from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload
from animaltrack.events.store import EventStore from animaltrack.events.store import EventStore
from animaltrack.events.types import FEED_PURCHASED from animaltrack.events.types import FEED_GIVEN, FEED_PURCHASED
from animaltrack.projections import ProjectionRegistry from animaltrack.projections import ProjectionRegistry
from animaltrack.services.feed import FeedService, ValidationError from animaltrack.services.feed import FeedService, ValidationError
@@ -236,3 +236,211 @@ class TestFeedServicePurchasePriceCalculation:
# (3000 * 3) / (25 * 3) = 9000 / 75 = 120 # (3000 * 3) / (25 * 3) = 9000 / 75 = 120
assert event.entity_refs["price_per_kg_cents"] == 120 assert event.entity_refs["price_per_kg_cents"] == 120
# =============================================================================
# give_feed Tests
# =============================================================================
@pytest.fixture
def location_id(seeded_db):
"""Get a valid location_id from seeded data."""
row = seeded_db.execute("SELECT id FROM locations LIMIT 1").fetchone()
return row[0]
def make_given_payload(
location_id: str,
feed_type_code: str = "layer",
amount_kg: int = 5,
notes: str | None = None,
) -> FeedGivenPayload:
"""Create a give feed payload for testing."""
return FeedGivenPayload(
location_id=location_id,
feed_type_code=feed_type_code,
amount_kg=amount_kg,
notes=notes,
)
class TestFeedServiceGive:
"""Tests for give_feed()."""
def test_creates_feed_given_event(self, seeded_db, feed_service, location_id):
"""give_feed creates a FeedGiven event."""
# Setup: first purchase some feed
purchase_payload = make_purchase_payload(bag_size_kg=20, bags_count=2)
ts_utc = int(time.time() * 1000)
feed_service.purchase_feed(purchase_payload, ts_utc, "test_user")
# Give feed
give_payload = make_given_payload(location_id, amount_kg=5)
event = feed_service.give_feed(give_payload, ts_utc + 1000, "test_user")
assert event.type == FEED_GIVEN
assert event.actor == "test_user"
assert event.ts_utc == ts_utc + 1000
def test_event_has_feed_type_in_entity_refs(self, seeded_db, feed_service, location_id):
"""Event entity_refs contains feed_type_code."""
ts_utc = int(time.time() * 1000)
feed_service.purchase_feed(
make_purchase_payload(feed_type_code="starter"), ts_utc, "test_user"
)
give_payload = make_given_payload(location_id, feed_type_code="starter")
event = feed_service.give_feed(give_payload, ts_utc + 1000, "test_user")
assert event.entity_refs["feed_type_code"] == "starter"
def test_event_has_location_id_in_entity_refs(self, seeded_db, feed_service, location_id):
"""Event entity_refs contains location_id."""
ts_utc = int(time.time() * 1000)
feed_service.purchase_feed(make_purchase_payload(), ts_utc, "test_user")
give_payload = make_given_payload(location_id, amount_kg=5)
event = feed_service.give_feed(give_payload, ts_utc + 1000, "test_user")
assert event.entity_refs["location_id"] == location_id
def test_event_has_amount_kg_in_entity_refs(self, seeded_db, feed_service, location_id):
"""Event entity_refs contains amount_kg."""
ts_utc = int(time.time() * 1000)
feed_service.purchase_feed(make_purchase_payload(), ts_utc, "test_user")
give_payload = make_given_payload(location_id, amount_kg=7)
event = feed_service.give_feed(give_payload, ts_utc + 1000, "test_user")
assert event.entity_refs["amount_kg"] == 7
def test_increments_given_kg(self, seeded_db, feed_service, location_id):
"""give_feed increments given_kg in feed_inventory."""
ts_utc = int(time.time() * 1000)
feed_service.purchase_feed(
make_purchase_payload(bag_size_kg=20, bags_count=2), ts_utc, "test_user"
)
give_payload = make_given_payload(location_id, amount_kg=10)
feed_service.give_feed(give_payload, ts_utc + 1000, "test_user")
row = seeded_db.execute(
"SELECT given_kg FROM feed_inventory WHERE feed_type_code = ?",
("layer",),
).fetchone()
assert row[0] == 10
def test_decrements_balance_kg(self, seeded_db, feed_service, location_id):
"""give_feed decrements balance_kg in feed_inventory."""
ts_utc = int(time.time() * 1000)
# Purchase 40kg
feed_service.purchase_feed(
make_purchase_payload(bag_size_kg=20, bags_count=2), ts_utc, "test_user"
)
# Give 15kg
give_payload = make_given_payload(location_id, amount_kg=15)
feed_service.give_feed(give_payload, ts_utc + 1000, "test_user")
row = seeded_db.execute(
"SELECT purchased_kg, given_kg, balance_kg FROM feed_inventory "
"WHERE feed_type_code = ?",
("layer",),
).fetchone()
assert row[0] == 40 # purchased_kg unchanged
assert row[1] == 15 # given_kg
assert row[2] == 25 # balance_kg = 40 - 15
def test_updates_last_given_at_utc(self, seeded_db, feed_service, location_id):
"""give_feed updates last_given_at_utc."""
ts_utc = int(time.time() * 1000)
feed_service.purchase_feed(make_purchase_payload(), ts_utc, "test_user")
give_ts = ts_utc + 5000
give_payload = make_given_payload(location_id, amount_kg=5)
feed_service.give_feed(give_payload, give_ts, "test_user")
row = seeded_db.execute(
"SELECT last_given_at_utc FROM feed_inventory WHERE feed_type_code = ?",
("layer",),
).fetchone()
assert row[0] == give_ts
def test_multiple_gives_accumulate(self, seeded_db, feed_service, location_id):
"""Multiple gives accumulate in given_kg."""
ts_utc = int(time.time() * 1000)
feed_service.purchase_feed(
make_purchase_payload(bag_size_kg=20, bags_count=5), ts_utc, "test_user"
)
# First give: 10kg
feed_service.give_feed(
make_given_payload(location_id, amount_kg=10), ts_utc + 1000, "test_user"
)
# Second give: 15kg
feed_service.give_feed(
make_given_payload(location_id, amount_kg=15), ts_utc + 2000, "test_user"
)
row = seeded_db.execute(
"SELECT purchased_kg, given_kg, balance_kg FROM feed_inventory "
"WHERE feed_type_code = ?",
("layer",),
).fetchone()
assert row[0] == 100 # purchased_kg (5 bags * 20kg)
assert row[1] == 25 # given_kg: 10 + 15
assert row[2] == 75 # balance_kg: 100 - 25
class TestFeedServiceGiveValidation:
"""Tests for give_feed() validation."""
def test_rejects_nonexistent_feed_type(self, seeded_db, feed_service, location_id):
"""Raises ValidationError for non-existent feed_type_code."""
give_payload = make_given_payload(location_id, feed_type_code="nonexistent")
with pytest.raises(ValidationError, match="not found"):
feed_service.give_feed(give_payload, int(time.time() * 1000), "test_user")
def test_rejects_inactive_feed_type(self, seeded_db, feed_service, location_id):
"""Raises ValidationError for inactive feed_type."""
seeded_db.execute("UPDATE feed_types SET active = 0 WHERE code = 'layer'")
give_payload = make_given_payload(location_id)
with pytest.raises(ValidationError, match="inactive"):
feed_service.give_feed(give_payload, int(time.time() * 1000), "test_user")
def test_rejects_nonexistent_location(self, seeded_db, feed_service):
"""Raises ValidationError for non-existent location_id."""
fake_location_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
give_payload = make_given_payload(fake_location_id)
with pytest.raises(ValidationError, match="not found"):
feed_service.give_feed(give_payload, int(time.time() * 1000), "test_user")
def test_blocks_without_prior_purchase(self, seeded_db, feed_service, location_id):
"""Raises ValidationError if no purchase exists for feed type."""
give_payload = make_given_payload(location_id)
with pytest.raises(ValidationError, match="No feed purchased"):
feed_service.give_feed(give_payload, int(time.time() * 1000), "test_user")
def test_blocks_purchase_after_give_timestamp(self, seeded_db, feed_service, location_id):
"""Raises ValidationError if purchase is after give timestamp."""
ts_utc = int(time.time() * 1000)
# Purchase at ts_utc + 1000
feed_service.purchase_feed(make_purchase_payload(), ts_utc + 1000, "test_user")
# Try to give at ts_utc (before purchase)
give_payload = make_given_payload(location_id)
with pytest.raises(ValidationError, match="No feed purchased"):
feed_service.give_feed(give_payload, ts_utc, "test_user")