mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-10 03:15:00 +03:00
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>
181 lines
6.4 KiB
Python
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
|