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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user