diff --git a/PLAN.md b/PLAN.md index 6a7aea0..a2d7e71 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/src/animaltrack/projections/feed.py b/src/animaltrack/projections/feed.py index af1d7c7..3fde03c 100644 --- a/src/animaltrack/projections/feed.py +++ b/src/animaltrack/projections/feed.py @@ -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), + ) diff --git a/src/animaltrack/services/feed.py b/src/animaltrack/services/feed.py index 25c7e4e..3022c2e 100644 --- a/src/animaltrack/services/feed.py +++ b/src/animaltrack/services/feed.py @@ -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 diff --git a/tests/test_service_feed.py b/tests/test_service_feed.py index 5a6580e..cb55a99 100644 --- a/tests/test_service_feed.py +++ b/tests/test_service_feed.py @@ -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")