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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
43
src/animaltrack/projections/products.py
Normal file
43
src/animaltrack/projections/products.py
Normal 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
|
||||
9
src/animaltrack/selection/__init__.py
Normal file
9
src/animaltrack/selection/__init__.py
Normal 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",
|
||||
]
|
||||
50
src/animaltrack/selection/resolver.py
Normal file
50
src/animaltrack/selection/resolver.py
Normal 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
|
||||
130
src/animaltrack/services/products.py
Normal file
130
src/animaltrack/services/products.py
Normal 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
|
||||
Reference in New Issue
Block a user