Files
chatballs/apps/backend/hub_platform/orders/services.py
T
AndreyandClaude Opus 4.8 c99bfdd828 feat(orders): product-backend order ingest webhook (ADR-HUB-0018)
Orders originate in the product backends (FoxRay/FirePage); Hub records
them. Added an authenticated ingest webhook instead of manual order entry:

- POST /api/v1/orders/ingest/ (AllowAny + product-token auth via
  X-Product-Token; token stored only as SHA-256). Idempotent by
  (organization, source=product.code, externalId). Resolves the contact
  from conversationId when given, else creates one from contactName;
  resolves items by offerCode within the product; PAID stamps paid_at +
  fulfillment PENDING. Audited (orders.ingested / ingest_duplicate).
- Order gains source/external_id with a partial-unique constraint;
  Product gains ingest_token_hash. Migrations included.
- issue_product_ingest_token <code> management command generates + prints
  the token once (user-run; rotates on re-run).

Tests: 10 orders tests (incl. ingest auth + idempotency); full suite 95/95.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:58:22 +03:00

181 lines
6.4 KiB
Python

import hashlib
from dataclasses import dataclass
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from hub_platform.conversations.models import Contact, Conversation
from hub_platform.orders.models import FulfillmentStatus, Order, OrderItem, PaymentStatus
from hub_platform.products.models import Offer, Price, Product
def hash_ingest_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@dataclass(frozen=True)
class OrderItemInput:
offer_id: int
price_id: int | None = None
quantity: int = 1
def _active_price(offer: Offer) -> Price | None:
return offer.prices.filter(is_active=True).order_by("-version").first()
@transaction.atomic
def create_order(*, organization, contact: Contact, items: list[OrderItemInput], conversation: Conversation | None = None) -> Order:
if not items:
raise ValidationError({"items": "Order needs at least one item"})
if contact.organization_id != organization.id:
raise ValidationError({"contact": "Contact belongs to another organization"})
resolved: list[tuple[Offer, Price | None, int, int]] = []
product = None
currency = "RUB"
total = 0
for item in items:
try:
offer = Offer.objects.select_related("product").get(id=item.offer_id, product__organization=organization)
except Offer.DoesNotExist as error:
raise ValidationError({"items": f"Offer {item.offer_id} not found"}) from error
price = None
if item.price_id is not None:
price = offer.prices.filter(id=item.price_id).first()
if price is None:
raise ValidationError({"items": f"Price {item.price_id} not found for offer {offer.code}"})
else:
price = _active_price(offer)
quantity = max(1, int(item.quantity))
unit = price.amount_minor if price else 0
currency = price.currency if price else currency
product = product or offer.product
total += unit * quantity
resolved.append((offer, price, quantity, unit * quantity))
order = Order.objects.create(
organization=organization,
contact=contact,
conversation=conversation,
product=product,
channel=conversation.channel if conversation else None,
amount_minor=total,
currency=currency,
)
OrderItem.objects.bulk_create(
[
OrderItem(order=order, offer=offer, price=price, title=offer.name, quantity=quantity, amount_minor=amount, currency=currency)
for offer, price, quantity, amount in resolved
]
)
return order
def mark_paid(*, order: Order) -> Order:
if order.payment_status != PaymentStatus.PAID:
order.payment_status = PaymentStatus.PAID
order.paid_at = timezone.now()
if order.fulfillment_status == FulfillmentStatus.NONE:
order.fulfillment_status = FulfillmentStatus.PENDING
order.save(update_fields=["payment_status", "paid_at", "fulfillment_status", "updated_at"])
return order
def cancel_order(*, order: Order) -> Order:
order.payment_status = PaymentStatus.CANCELLED
order.save(update_fields=["payment_status", "updated_at"])
return order
def set_fulfillment(*, order: Order, status: str) -> Order:
if status not in FulfillmentStatus.values:
raise ValidationError({"fulfillmentStatus": "Unknown status"})
order.fulfillment_status = status
order.save(update_fields=["fulfillment_status", "updated_at"])
return order
# --- Ingest from a product backend (webhook, ADR-HUB-0018) ---
@dataclass(frozen=True)
class IngestItemInput:
offer_code: str
quantity: int = 1
def resolve_product_by_token(token: str) -> Product | None:
if not token:
return None
return Product.objects.filter(ingest_token_hash=hash_ingest_token(token)).first()
@transaction.atomic
def ingest_order(
*,
product: Product,
external_id: str,
items: list[IngestItemInput],
payment_status: str,
currency: str = "RUB",
amount_minor: int | None = None,
conversation: Conversation | None = None,
contact_name: str = "",
) -> tuple[Order, bool]:
if payment_status not in PaymentStatus.values:
raise ValidationError({"paymentStatus": "Unknown status"})
if not items:
raise ValidationError({"items": "Order needs at least one item"})
organization = product.organization
# Идемпотентность по (организация, продукт, внешний id).
if external_id:
existing = Order.objects.filter(organization=organization, source=product.code, external_id=external_id).first()
if existing is not None:
return existing, False
resolved: list[tuple[Offer, Price | None, int, int]] = []
total = 0
for item in items:
try:
offer = Offer.objects.get(product=product, code=item.offer_code)
except Offer.DoesNotExist as error:
raise ValidationError({"items": f"Offer {item.offer_code} not found in {product.code}"}) from error
price = _active_price(offer)
quantity = max(1, int(item.quantity))
unit = price.amount_minor if price else 0
if price:
currency = price.currency
total += unit * quantity
resolved.append((offer, price, quantity, unit * quantity))
if conversation is not None:
contact = conversation.contact
else:
contact = Contact.objects.create(organization=organization, name=contact_name or "Клиент")
is_paid = payment_status == PaymentStatus.PAID
order = Order.objects.create(
organization=organization,
contact=contact,
conversation=conversation,
product=product,
channel=conversation.channel if conversation else None,
payment_status=payment_status,
fulfillment_status=FulfillmentStatus.PENDING if is_paid else FulfillmentStatus.NONE,
amount_minor=amount_minor if amount_minor is not None else total,
currency=currency,
source=product.code,
external_id=external_id,
paid_at=timezone.now() if is_paid else None,
)
OrderItem.objects.bulk_create(
[
OrderItem(order=order, offer=offer, price=price, title=offer.name, quantity=quantity, amount_minor=amount, currency=currency)
for offer, price, quantity, amount in resolved
]
)
return order, True