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:
10
PLAN.md
10
PLAN.md
@@ -179,11 +179,11 @@ Check off items as completed. Each phase builds on the previous.
|
|||||||
- [x] **Commit checkpoint** (c08fa47)
|
- [x] **Commit checkpoint** (c08fa47)
|
||||||
|
|
||||||
### Step 4.5: Product Sold Event
|
### Step 4.5: Product Sold Event
|
||||||
- [ ] Implement apply_product_sold projection
|
- [x] Implement apply_product_sold projection
|
||||||
- [ ] Add sell_product to services/products.py
|
- [x] Add sell_product to services/products.py
|
||||||
- [ ] Calculate unit_price_cents = floor(total/qty)
|
- [x] Calculate unit_price_cents = floor(total/qty)
|
||||||
- [ ] Write tests: event stored, unit price calculated
|
- [x] Write tests: event stored, unit price calculated
|
||||||
- [ ] **Commit checkpoint**
|
- [x] **Commit checkpoint**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
# ABOUTME: Projection for product collection tracking.
|
# ABOUTME: Projection for product events (collected and sold).
|
||||||
# ABOUTME: Placeholder for ProductCollected events until stats tables exist.
|
# ABOUTME: Handles ProductCollected and ProductSold events.
|
||||||
|
|
||||||
from typing import Any
|
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.models.events import Event
|
||||||
from animaltrack.projections.base import Projection
|
from animaltrack.projections.base import Projection
|
||||||
|
|
||||||
|
|
||||||
class ProductsProjection(Projection):
|
class ProductsProjection(Projection):
|
||||||
"""Handles ProductCollected events.
|
"""Handles ProductCollected and ProductSold events.
|
||||||
|
|
||||||
This is a placeholder projection for Step 4.3.
|
ProductCollected: event_animals linkage handled by EventAnimalsProjection.
|
||||||
Product statistics tables are created in Step 4.4.
|
ProductSold: currently a no-op (fast-revert projection per spec §14).
|
||||||
The event_animals linkage is handled by EventAnimalsProjection.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db: Any) -> None:
|
def __init__(self, db: Any) -> None:
|
||||||
@@ -26,18 +25,18 @@ class ProductsProjection(Projection):
|
|||||||
|
|
||||||
def get_event_types(self) -> list[str]:
|
def get_event_types(self) -> list[str]:
|
||||||
"""Return the event types this projection handles."""
|
"""Return the event types this projection handles."""
|
||||||
return [PRODUCT_COLLECTED]
|
return [PRODUCT_COLLECTED, PRODUCT_SOLD]
|
||||||
|
|
||||||
def apply(self, event: Event) -> None:
|
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
|
pass
|
||||||
|
|
||||||
def revert(self, event: Event) -> None:
|
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
|
pass
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from animaltrack.db import transaction
|
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.processor import process_event
|
||||||
from animaltrack.events.store import EventStore
|
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.models.events import Event
|
||||||
from animaltrack.projections import ProjectionRegistry
|
from animaltrack.projections import ProjectionRegistry
|
||||||
from animaltrack.repositories.locations import LocationRepository
|
from animaltrack.repositories.locations import LocationRepository
|
||||||
@@ -128,3 +128,69 @@ class ProductService:
|
|||||||
process_event(event, self.registry)
|
process_event(event, self.registry)
|
||||||
|
|
||||||
return event
|
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
|
||||||
|
|||||||
200
tests/test_service_products_sold.py
Normal file
200
tests/test_service_products_sold.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# ABOUTME: Tests for ProductService sell_product operations.
|
||||||
|
# ABOUTME: Tests sell_product with event creation and unit_price_cents calculation.
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from animaltrack.events.payloads import ProductSoldPayload
|
||||||
|
from animaltrack.events.store import EventStore
|
||||||
|
from animaltrack.events.types import PRODUCT_SOLD
|
||||||
|
from animaltrack.projections import ProjectionRegistry
|
||||||
|
from animaltrack.projections.products import ProductsProjection
|
||||||
|
from animaltrack.services.products import ProductService, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def event_store(seeded_db):
|
||||||
|
"""Create an EventStore for testing."""
|
||||||
|
return EventStore(seeded_db)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def projection_registry(seeded_db):
|
||||||
|
"""Create a ProjectionRegistry with ProductsProjection."""
|
||||||
|
registry = ProjectionRegistry()
|
||||||
|
registry.register(ProductsProjection(seeded_db))
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def product_service(seeded_db, event_store, projection_registry):
|
||||||
|
"""Create a ProductService for testing."""
|
||||||
|
return ProductService(seeded_db, event_store, projection_registry)
|
||||||
|
|
||||||
|
|
||||||
|
def make_sell_payload(
|
||||||
|
product_code: str = "egg.duck",
|
||||||
|
quantity: int = 30,
|
||||||
|
total_price_cents: int = 1500,
|
||||||
|
buyer: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> ProductSoldPayload:
|
||||||
|
"""Create a product sold payload for testing."""
|
||||||
|
return ProductSoldPayload(
|
||||||
|
product_code=product_code,
|
||||||
|
quantity=quantity,
|
||||||
|
total_price_cents=total_price_cents,
|
||||||
|
buyer=buyer,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# sell_product Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductServiceSell:
|
||||||
|
"""Tests for sell_product()."""
|
||||||
|
|
||||||
|
def test_creates_product_sold_event(self, seeded_db, product_service):
|
||||||
|
"""sell_product creates a ProductSold event."""
|
||||||
|
payload = make_sell_payload()
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = product_service.sell_product(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
assert event.type == PRODUCT_SOLD
|
||||||
|
assert event.actor == "test_user"
|
||||||
|
assert event.ts_utc == ts_utc
|
||||||
|
|
||||||
|
def test_event_has_product_code_in_entity_refs(self, seeded_db, product_service):
|
||||||
|
"""Event entity_refs contains product_code."""
|
||||||
|
payload = make_sell_payload(product_code="egg.duck")
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = product_service.sell_product(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
assert event.entity_refs["product_code"] == "egg.duck"
|
||||||
|
|
||||||
|
def test_event_has_quantity_in_entity_refs(self, seeded_db, product_service):
|
||||||
|
"""Event entity_refs contains quantity."""
|
||||||
|
payload = make_sell_payload(quantity=24)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = product_service.sell_product(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
assert event.entity_refs["quantity"] == 24
|
||||||
|
|
||||||
|
def test_event_has_total_price_cents_in_entity_refs(self, seeded_db, product_service):
|
||||||
|
"""Event entity_refs contains total_price_cents."""
|
||||||
|
payload = make_sell_payload(total_price_cents=2400)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = product_service.sell_product(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
assert event.entity_refs["total_price_cents"] == 2400
|
||||||
|
|
||||||
|
def test_event_has_unit_price_cents_in_entity_refs(self, seeded_db, product_service):
|
||||||
|
"""Event entity_refs contains calculated unit_price_cents."""
|
||||||
|
payload = make_sell_payload(quantity=30, total_price_cents=1500)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = product_service.sell_product(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
# 1500 / 30 = 50 cents per unit
|
||||||
|
assert event.entity_refs["unit_price_cents"] == 50
|
||||||
|
|
||||||
|
def test_unit_price_cents_floor_division(self, seeded_db, product_service):
|
||||||
|
"""unit_price_cents uses floor division (exact case)."""
|
||||||
|
# 1500 cents / 30 = 50.0 cents exactly
|
||||||
|
payload = make_sell_payload(quantity=30, total_price_cents=1500)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = product_service.sell_product(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
assert event.entity_refs["unit_price_cents"] == 50
|
||||||
|
|
||||||
|
def test_unit_price_cents_rounds_down(self, seeded_db, product_service):
|
||||||
|
"""unit_price_cents rounds down for fractional cents."""
|
||||||
|
# 1000 cents / 3 = 333.33... -> 333
|
||||||
|
payload = make_sell_payload(quantity=3, total_price_cents=1000)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = product_service.sell_product(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
assert event.entity_refs["unit_price_cents"] == 333
|
||||||
|
|
||||||
|
def test_unit_price_cents_another_fractional_case(self, seeded_db, product_service):
|
||||||
|
"""unit_price_cents correctly floors another fractional case."""
|
||||||
|
# 1001 cents / 3 = 333.66... -> 333
|
||||||
|
payload = make_sell_payload(quantity=3, total_price_cents=1001)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = product_service.sell_product(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
assert event.entity_refs["unit_price_cents"] == 333
|
||||||
|
|
||||||
|
def test_event_stored_in_events_table(self, seeded_db, product_service):
|
||||||
|
"""Event is stored in the events table."""
|
||||||
|
payload = make_sell_payload()
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = product_service.sell_product(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT id, type FROM events WHERE id = ?",
|
||||||
|
(event.id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] == event.id
|
||||||
|
assert row[1] == PRODUCT_SOLD
|
||||||
|
|
||||||
|
def test_buyer_stored_in_payload(self, seeded_db, product_service):
|
||||||
|
"""Optional buyer is stored in event payload."""
|
||||||
|
payload = make_sell_payload(buyer="Local Market")
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = product_service.sell_product(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
assert event.payload["buyer"] == "Local Market"
|
||||||
|
|
||||||
|
def test_notes_stored_in_payload(self, seeded_db, product_service):
|
||||||
|
"""Optional notes are stored in event payload."""
|
||||||
|
payload = make_sell_payload(notes="Cash sale")
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = product_service.sell_product(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
assert event.payload["notes"] == "Cash sale"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductServiceSellValidation:
|
||||||
|
"""Tests for sell_product() validation."""
|
||||||
|
|
||||||
|
def test_rejects_nonexistent_product(self, seeded_db, product_service):
|
||||||
|
"""Raises ValidationError for non-existent product_code."""
|
||||||
|
payload = make_sell_payload(product_code="nonexistent.product")
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="not found"):
|
||||||
|
product_service.sell_product(payload, int(time.time() * 1000), "test_user")
|
||||||
|
|
||||||
|
def test_rejects_inactive_product(self, seeded_db, product_service):
|
||||||
|
"""Raises ValidationError for inactive product."""
|
||||||
|
seeded_db.execute("UPDATE products SET active = 0 WHERE code = 'egg.duck'")
|
||||||
|
|
||||||
|
payload = make_sell_payload(product_code="egg.duck")
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="inactive"):
|
||||||
|
product_service.sell_product(payload, int(time.time() * 1000), "test_user")
|
||||||
|
|
||||||
|
def test_rejects_non_sellable_product(self, seeded_db, product_service):
|
||||||
|
"""Raises ValidationError for non-sellable product."""
|
||||||
|
seeded_db.execute("UPDATE products SET sellable = 0 WHERE code = 'egg.duck'")
|
||||||
|
|
||||||
|
payload = make_sell_payload(product_code="egg.duck")
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="not sellable"):
|
||||||
|
product_service.sell_product(payload, int(time.time() * 1000), "test_user")
|
||||||
Reference in New Issue
Block a user