Template
Add shop, POS sync, event ticketing, and shipping catalog apps (#6)
## Summary - Closes #5 - Optional `shop`, `pos_sync`, `events`, and `shipping` apps gated by `FEATURE_*` flags - Deps match the catalog: shop/events need email + Stripe; POS/shipping need shop - Portal inventory, POS webhooks, capacity tickets, EasyPost/Pirate Ship shipping ## Test plan - [ ] `manage.py test` (152 passed locally) - [ ] Shop cart + paid order decrements stocked inventory - [ ] POS inbound webhook decrements SKU; paid order queues outbound reserve - [ ] Event capacity blocks overbook; paid order emails ticket codes - [ ] Shipping stub label + Pirate Ship CSV of unshipped paid orders - [ ] `validate-env.sh` rejects shop without email/payments, POS/shipping without shop Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"""POS inventory sync. Inbound webhooks decrement shop stock; outbound queues reserve POS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from pos_sync.models import POSConnection, SyncEvent
|
||||
from shop.models import Product
|
||||
from shop.services import ShopError, adjust_stock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class POSSyncError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def active_connection() -> POSConnection | None:
|
||||
return POSConnection.objects.filter(is_active=True).order_by("created_at").first()
|
||||
|
||||
|
||||
def apply_inbound_sale(
|
||||
*,
|
||||
sku: str,
|
||||
quantity: int,
|
||||
external_id: str = "",
|
||||
payload: dict | None = None,
|
||||
connection: POSConnection | None = None,
|
||||
) -> SyncEvent:
|
||||
sku = (sku or "").strip()
|
||||
if not sku:
|
||||
raise POSSyncError("SKU is required.")
|
||||
if quantity < 1:
|
||||
raise POSSyncError("Quantity must be at least 1.")
|
||||
product = Product.objects.filter(sku__iexact=sku).first()
|
||||
if product is None:
|
||||
raise POSSyncError(f"Unknown SKU {sku}.")
|
||||
event = SyncEvent.objects.create(
|
||||
connection=connection or active_connection(),
|
||||
direction=SyncEvent.Direction.INBOUND,
|
||||
status=SyncEvent.Status.PENDING,
|
||||
sku=product.sku,
|
||||
quantity=quantity,
|
||||
external_id=external_id,
|
||||
payload=payload or {},
|
||||
)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
locked = Product.objects.select_for_update().get(pk=product.pk)
|
||||
if locked.track_inventory and locked.fulfillment == Product.Fulfillment.STOCKED:
|
||||
adjust_stock(locked, -quantity)
|
||||
event.status = SyncEvent.Status.DONE
|
||||
event.save(update_fields=["status", "updated_at"])
|
||||
except ShopError as exc:
|
||||
event.status = SyncEvent.Status.FAILED
|
||||
event.error = str(exc)
|
||||
event.save(update_fields=["status", "error", "updated_at"])
|
||||
raise POSSyncError(str(exc)) from exc
|
||||
return event
|
||||
|
||||
|
||||
def enqueue_online_sale(order) -> list[SyncEvent]:
|
||||
"""Queue outbound POS reserves after an online shop sale."""
|
||||
events = []
|
||||
connection = active_connection()
|
||||
for item in order.items.all():
|
||||
sku = item.sku
|
||||
if not sku:
|
||||
continue
|
||||
events.append(
|
||||
SyncEvent.objects.create(
|
||||
connection=connection,
|
||||
direction=SyncEvent.Direction.OUTBOUND,
|
||||
status=SyncEvent.Status.PENDING,
|
||||
sku=sku,
|
||||
quantity=item.quantity,
|
||||
external_id=order.number,
|
||||
payload={"order_id": str(order.pk), "order_number": order.number},
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _post_reserve(connection: POSConnection, event: SyncEvent) -> None:
|
||||
base = (connection.api_base_url or settings.POS_API_BASE_URL or "").rstrip("/")
|
||||
token = connection.api_token or (settings.POS_API_TOKEN or "")
|
||||
if not base:
|
||||
raise POSSyncError("POS API base URL is not configured.")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
response = requests.post(
|
||||
f"{base}/inventory/reserve",
|
||||
json={
|
||||
"sku": event.sku,
|
||||
"quantity": event.quantity,
|
||||
"external_id": event.external_id,
|
||||
"source": "online",
|
||||
},
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def dispatch_pending_outbound() -> int:
|
||||
sent = 0
|
||||
for event in SyncEvent.objects.filter(
|
||||
direction=SyncEvent.Direction.OUTBOUND,
|
||||
status=SyncEvent.Status.PENDING,
|
||||
)[:50]:
|
||||
connection = event.connection or active_connection()
|
||||
try:
|
||||
if connection is None:
|
||||
raise POSSyncError("No active POS connection.")
|
||||
_post_reserve(connection, event)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("POS outbound failed for %s", event.sku)
|
||||
event.status = SyncEvent.Status.FAILED
|
||||
event.error = str(exc)
|
||||
event.save(update_fields=["status", "error", "updated_at"])
|
||||
else:
|
||||
event.status = SyncEvent.Status.DONE
|
||||
event.error = ""
|
||||
event.save(update_fields=["status", "error", "updated_at"])
|
||||
sent += 1
|
||||
return sent
|
||||
Reference in New Issue
Block a user