feat: add product collection event handling

Implements Step 4.3 from the plan:
- Add selection/resolver.py with basic resolve_selection for validating
  animal IDs exist and are alive
- Add ProductsProjection placeholder (stats tables added in Step 4.4)
- Add ProductService with collect_product() function
- Add PRODUCT_COLLECTED to EventAnimalsProjection for linking events
  to affected animals
- Full test coverage for all new components

🤖 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 09:08:13 +00:00
parent fa3c99b755
commit d53decdb66
8 changed files with 691 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ from animaltrack.projections.base import Projection, ProjectionRegistry
from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.exceptions import ProjectionError
from animaltrack.projections.intervals import IntervalProjection
from animaltrack.projections.products import ProductsProjection
__all__ = [
"AnimalRegistryProjection",
@@ -14,4 +15,5 @@ __all__ = [
"Projection",
"ProjectionError",
"ProjectionRegistry",
"ProductsProjection",
]

View File

@@ -9,6 +9,7 @@ from animaltrack.events.types import (
ANIMAL_MOVED,
ANIMAL_TAG_ENDED,
ANIMAL_TAGGED,
PRODUCT_COLLECTED,
)
from animaltrack.models.events import Event
from animaltrack.projections.base import Projection
@@ -38,6 +39,7 @@ class EventAnimalsProjection(Projection):
ANIMAL_ATTRIBUTES_UPDATED,
ANIMAL_TAGGED,
ANIMAL_TAG_ENDED,
PRODUCT_COLLECTED,
]
def apply(self, event: Event) -> None:

View File

@@ -0,0 +1,43 @@
# ABOUTME: Projection for product collection tracking.
# ABOUTME: Placeholder for ProductCollected events until stats tables exist.
from typing import Any
from animaltrack.events.types import PRODUCT_COLLECTED
from animaltrack.models.events import Event
from animaltrack.projections.base import Projection
class ProductsProjection(Projection):
"""Handles ProductCollected events.
This is a placeholder projection for Step 4.3.
Product statistics tables are created in Step 4.4.
The event_animals linkage is handled by EventAnimalsProjection.
"""
def __init__(self, db: Any) -> None:
"""Initialize the projection with a database connection.
Args:
db: A fastlite database connection.
"""
super().__init__(db)
def get_event_types(self) -> list[str]:
"""Return the event types this projection handles."""
return [PRODUCT_COLLECTED]
def apply(self, event: Event) -> None:
"""Apply ProductCollected event.
Currently a no-op. Stats projection added in Step 4.4.
"""
pass
def revert(self, event: Event) -> None:
"""Revert ProductCollected event.
Currently a no-op. Stats projection added in Step 4.4.
"""
pass

View File

@@ -0,0 +1,9 @@
# ABOUTME: Selection system for resolving animal sets from filters.
# ABOUTME: Provides resolver functions for animal selection contexts.
from animaltrack.selection.resolver import SelectionResolverError, resolve_selection
__all__ = [
"SelectionResolverError",
"resolve_selection",
]

View File

@@ -0,0 +1,50 @@
# ABOUTME: Basic animal selection resolver for Step 4.3.
# ABOUTME: Validates resolved_ids exist and are alive.
from typing import Any
class SelectionResolverError(Exception):
"""Base exception for selection resolver errors."""
def resolve_selection(
db: Any,
resolved_ids: list[str],
) -> list[str]:
"""Validate that animal IDs exist and are alive.
This is the basic resolver for Step 4.3. Full filter DSL
parsing and historical resolution are added in Phase 5.
Args:
db: Database connection.
resolved_ids: List of animal IDs to validate.
Returns:
The validated list of animal IDs (same as input if all valid).
Raises:
SelectionResolverError: If list is empty, any animal not found,
or any animal is not alive.
"""
if not resolved_ids:
raise SelectionResolverError("resolved_ids cannot be empty")
for animal_id in resolved_ids:
row = db.execute(
"""
SELECT animal_id, status FROM animal_registry
WHERE animal_id = ?
""",
(animal_id,),
).fetchone()
if row is None:
raise SelectionResolverError(f"Animal '{animal_id}' not found")
status = row[1]
if status != "alive":
raise SelectionResolverError(f"Animal '{animal_id}' is not alive (status: {status})")
return resolved_ids

View File

@@ -0,0 +1,130 @@
# ABOUTME: Service layer for product operations.
# ABOUTME: Coordinates event creation with projection updates for product collection.
from typing import Any
from animaltrack.db import transaction
from animaltrack.events.payloads import ProductCollectedPayload
from animaltrack.events.processor import process_event
from animaltrack.events.store import EventStore
from animaltrack.events.types import PRODUCT_COLLECTED
from animaltrack.models.events import Event
from animaltrack.projections import ProjectionRegistry
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.products import ProductRepository
from animaltrack.selection import SelectionResolverError, resolve_selection
class ProductServiceError(Exception):
"""Base exception for product service errors."""
class ValidationError(ProductServiceError):
"""Raised when validation fails."""
class ProductService:
"""Service for product-related operations.
Provides methods to collect products from animals.
All operations are atomic and update projections synchronously.
"""
def __init__(
self,
db: Any,
event_store: EventStore,
registry: ProjectionRegistry,
) -> None:
"""Initialize the service.
Args:
db: A fastlite database connection.
event_store: The event store for appending events.
registry: Registry of projections to update.
"""
self.db = db
self.event_store = event_store
self.registry = registry
self.product_repo = ProductRepository(db)
self.location_repo = LocationRepository(db)
def collect_product(
self,
payload: ProductCollectedPayload,
ts_utc: int,
actor: str,
nonce: str | None = None,
route: str | None = None,
) -> Event:
"""Record product collection from animals.
Creates a ProductCollected event and updates projections.
Args:
payload: Validated product collection payload.
ts_utc: Timestamp in milliseconds since epoch.
actor: The user performing the collection.
nonce: Optional idempotency nonce.
route: Required if nonce provided.
Returns:
The created event.
Raises:
ValidationError: If product doesn't exist, is inactive,
is not collectable, location doesn't exist,
or animals are invalid.
"""
# Validate product exists and is active
product = self.product_repo.get(payload.product_code)
if product is None:
msg = f"Product '{payload.product_code}' not found"
raise ValidationError(msg)
if not product.active:
msg = f"Product '{payload.product_code}' is inactive"
raise ValidationError(msg)
if not product.collectable:
msg = f"Product '{payload.product_code}' is not collectable"
raise ValidationError(msg)
# Validate location exists and is active
location = self.location_repo.get(payload.location_id)
if location is None:
msg = f"Location '{payload.location_id}' not found"
raise ValidationError(msg)
if not location.active:
msg = f"Location '{payload.location_id}' is archived"
raise ValidationError(msg)
# Validate animals exist and are alive
try:
resolve_selection(self.db, payload.resolved_ids)
except SelectionResolverError as e:
raise ValidationError(str(e)) from e
# Build entity_refs
entity_refs = {
"product_code": payload.product_code,
"location_id": payload.location_id,
"quantity": payload.quantity,
"animal_ids": payload.resolved_ids,
}
with transaction(self.db):
event = self.event_store.append_event(
event_type=PRODUCT_COLLECTED,
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