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

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