"""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