generated from westfarn/web_django_template
## Summary - Rebrand the Django client template as Print Forge (`client_site` → `print_forge`) with shop + shipping enabled. - Product listings support multiple photos, color variants (shared price/description/STL, per-color stock and photos), and a photo-first / 3D-second gallery. - Public pages use 3D printer / printed-toy photography instead of leftover t-shirt mockups; beta CI deploys on `master`. Closes #1 Infra: [server-infra#27](ai_ml_operations/server-infra#27) (easy deploy beta). ## Test plan - [ ] Product page shows photo first, 3D model second; color swatches swap photos and stock - [ ] Portal can upload multiple photos and per-color qty/images; STL stays shared - [ ] Public home/about/gallery have no t-shirt mockups - [ ] `manage.py test` passes - [ ] After server-infra#27: beta deploy to `print-forge-preview.aimloperations.com` Reviewed-on: #2
132 lines
4.2 KiB
Python
132 lines
4.2 KiB
Python
"""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
|