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)
### Step 4.2: Feed Given Event
- [ ] Implement apply_feed_given (increment given_kg, decrement balance)
- [ ] Implement revert_feed_given
- [ ] Add give_feed to services/feed.py
- [ ] Block if no purchase <= ts_utc
- [ ] Write tests: give updates inventory, blocked without purchase, revert
- [ ] **Commit checkpoint**
- [x] Implement apply_feed_given (increment given_kg, decrement balance)
- [x] Implement revert_feed_given
- [x] Add give_feed to services/feed.py
- [x] Block if no purchase <= ts_utc
- [x] Write tests: give updates inventory, blocked without purchase, revert
- [x] **Commit checkpoint** (2eb0ca7)
### Step 4.3: Product Collection Event
- [ ] Create `projections/products.py` for ProductCollected

View File

@@ -3,7 +3,7 @@
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.projections.base import Projection
@@ -25,17 +25,21 @@ class FeedInventoryProjection(Projection):
def get_event_types(self) -> list[str]:
"""Return the event types this projection handles."""
return [FEED_PURCHASED]
return [FEED_PURCHASED, FEED_GIVEN]
def apply(self, event: Event) -> None:
"""Apply feed event to update inventory."""
if event.type == FEED_PURCHASED:
self._apply_feed_purchased(event)
elif event.type == FEED_GIVEN:
self._apply_feed_given(event)
def revert(self, event: Event) -> None:
"""Revert feed event from inventory."""
if event.type == FEED_PURCHASED:
self._revert_feed_purchased(event)
elif event.type == FEED_GIVEN:
self._revert_feed_given(event)
def _apply_feed_purchased(self, event: Event) -> None:
"""Apply feed purchase to inventory.
@@ -86,3 +90,47 @@ class FeedInventoryProjection(Projection):
""",
(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 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.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.projections import ProjectionRegistry
from animaltrack.repositories.feed_types import FeedTypeRepository
from animaltrack.repositories.locations import LocationRepository
class FeedServiceError(Exception):
@@ -45,6 +46,7 @@ class FeedService:
self.event_store = event_store
self.registry = registry
self.feed_type_repo = FeedTypeRepository(db)
self.location_repo = LocationRepository(db)
def purchase_feed(
self,
@@ -108,3 +110,81 @@ class FeedService:
process_event(event, self.registry)
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 purchase_feed with inventory tracking and price storage.
# ABOUTME: Tests purchase_feed and give_feed with inventory tracking.
import time
import pytest
from animaltrack.events.payloads import FeedPurchasedPayload
from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload
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.services.feed import FeedService, ValidationError
@@ -236,3 +236,211 @@ class TestFeedServicePurchasePriceCalculation:
# (3000 * 3) / (25 * 3) = 9000 / 75 = 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")