feat: add product sold event handling

Add sell_product() service method that creates ProductSold events
with calculated unit_price_cents (floor division of total/qty).
Update ProductsProjection to handle PRODUCT_SOLD events.

🤖 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:40:34 +00:00
parent c82104b029
commit b48fab5dde
4 changed files with 284 additions and 19 deletions

View File

@@ -1,19 +1,18 @@
# ABOUTME: Projection for product collection tracking.
# ABOUTME: Placeholder for ProductCollected events until stats tables exist.
# ABOUTME: Projection for product events (collected and sold).
# ABOUTME: Handles ProductCollected and ProductSold events.
from typing import Any
from animaltrack.events.types import PRODUCT_COLLECTED
from animaltrack.events.types import PRODUCT_COLLECTED, PRODUCT_SOLD
from animaltrack.models.events import Event
from animaltrack.projections.base import Projection
class ProductsProjection(Projection):
"""Handles ProductCollected events.
"""Handles ProductCollected and ProductSold 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.
ProductCollected: event_animals linkage handled by EventAnimalsProjection.
ProductSold: currently a no-op (fast-revert projection per spec §14).
"""
def __init__(self, db: Any) -> None:
@@ -26,18 +25,18 @@ class ProductsProjection(Projection):
def get_event_types(self) -> list[str]:
"""Return the event types this projection handles."""
return [PRODUCT_COLLECTED]
return [PRODUCT_COLLECTED, PRODUCT_SOLD]
def apply(self, event: Event) -> None:
"""Apply ProductCollected event.
"""Apply product event.
Currently a no-op. Stats projection added in Step 4.4.
Currently a no-op for both event types.
"""
pass
def revert(self, event: Event) -> None:
"""Revert ProductCollected event.
"""Revert product event.
Currently a no-op. Stats projection added in Step 4.4.
Currently a no-op for both event types.
"""
pass

View File

@@ -4,10 +4,10 @@
from typing import Any
from animaltrack.db import transaction
from animaltrack.events.payloads import ProductCollectedPayload
from animaltrack.events.payloads import ProductCollectedPayload, ProductSoldPayload
from animaltrack.events.processor import process_event
from animaltrack.events.store import EventStore
from animaltrack.events.types import PRODUCT_COLLECTED
from animaltrack.events.types import PRODUCT_COLLECTED, PRODUCT_SOLD
from animaltrack.models.events import Event
from animaltrack.projections import ProjectionRegistry
from animaltrack.repositories.locations import LocationRepository
@@ -128,3 +128,69 @@ class ProductService:
process_event(event, self.registry)
return event
def sell_product(
self,
payload: ProductSoldPayload,
ts_utc: int,
actor: str,
nonce: str | None = None,
route: str | None = None,
) -> Event:
"""Record a product sale.
Creates a ProductSold event with calculated unit_price_cents.
Args:
payload: Validated product sold payload.
ts_utc: Timestamp in milliseconds since epoch.
actor: The user performing the sale.
nonce: Optional idempotency nonce.
route: Required if nonce provided.
Returns:
The created event.
Raises:
ValidationError: If product doesn't exist, is inactive,
or is not sellable.
"""
# 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.sellable:
msg = f"Product '{payload.product_code}' is not sellable"
raise ValidationError(msg)
# Calculate unit price using floor division
unit_price_cents = payload.total_price_cents // payload.quantity
# Build entity_refs
entity_refs = {
"product_code": payload.product_code,
"quantity": payload.quantity,
"total_price_cents": payload.total_price_cents,
"unit_price_cents": unit_price_cents,
}
with transaction(self.db):
event = self.event_store.append_event(
event_type=PRODUCT_SOLD,
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