mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(sales): legacy orders migration command (ADR-HUB-0025 §11)
Phase 3 миграции legacy orders → домен продаж. Команда import_legacy_orders, по умолчанию DRY-RUN (только отчёт по классам, без записи). - classify_legacy_order: external / manual / pending_ambiguous; PENDING не считается продажей и требует ручного решения (§11.2) - import_legacy_order: подтверждённый Order (PAID/REFUNDED/CANCELLED) → Sale + SaleEvent(sale.legacy_imported), sales_source=null, идемпотентно по metadata.legacy_order_id; OrderItem → line_items_snapshot; fulfillment_status и legacy id — только в metadata; contact/conversation сохраняются - provision_product_sales_source: создать SalesSource(PRODUCT_API) и скопировать действующий Product.ingest_token_hash (§11 шаг 2-3) — без генерации/ротации - флаги --apply / --provision-sources / --product; команда НИКОГДА не удаляет legacy, не отключает legacy write API и не ротирует token (гейты §11 11-12) - 7 тестов (dry-run, import, идемпотентность, refund→0, PENDING-skip, provision) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b93b1ee80
commit
19ad83ff0a
3 files changed
+349
No files matched your search
@@ -0,0 +1,96 @@
|
||||
"""Миграция legacy `orders` в домен продаж (ADR-HUB-0025 §11, SPEC-HUB-0014 §11).
|
||||
|
||||
По умолчанию — DRY-RUN: только отчёт по классам записей, без единой записи в БД.
|
||||
|
||||
Флаги (каждый включается ОСОЗНАННО владельцем):
|
||||
--apply импортировать подтверждённые legacy-заказы в Sale/SaleEvent
|
||||
(sale.legacy_imported). PENDING-заказы НЕ импортируются.
|
||||
--provision-sources создать production SalesSource(PRODUCT_API) для каждого
|
||||
продукта и скопировать действующий Product.ingest_token_hash
|
||||
в credential_hash (§11 шаг 2-3). Это единственный шаг,
|
||||
затрагивающий credential; секрет не генерируется и не ротируется.
|
||||
--product CODE ограничить одним продуктом.
|
||||
|
||||
Команда НИКОГДА не удаляет legacy-данные, не отключает legacy write API и не
|
||||
ротирует/очищает Product.ingest_token_hash — это отдельные шаги §11 (11-12),
|
||||
требующие отдельного подтверждения владельца.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from hub_platform.orders.models import Order
|
||||
from hub_platform.products.models import Product
|
||||
from hub_platform.sales.services import (
|
||||
LEGACY_CLASS_PENDING,
|
||||
classify_legacy_order,
|
||||
import_legacy_order,
|
||||
provision_product_sales_source,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Dry-run/import legacy orders into the sales domain (ADR-HUB-0025 §11)."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("--apply", action="store_true", help="Импортировать подтверждённые заказы в Sale/SaleEvent")
|
||||
parser.add_argument("--provision-sources", action="store_true", help="Создать SalesSource и скопировать ingest_token_hash")
|
||||
parser.add_argument("--product", default="", help="Код продукта (по умолчанию — все)")
|
||||
|
||||
def _orders(self, product_code: str) -> QuerySet[Order]:
|
||||
orders = Order.objects.select_related("organization", "product", "contact", "conversation").prefetch_related("items")
|
||||
if product_code:
|
||||
orders = orders.filter(product__code=product_code)
|
||||
return orders.order_by("id")
|
||||
|
||||
def handle(self, *args: object, **options: object) -> None:
|
||||
apply = bool(options["apply"])
|
||||
provision = bool(options["provision_sources"])
|
||||
product_code = str(options["product"])
|
||||
|
||||
orders = self._orders(product_code)
|
||||
classes = Counter(classify_legacy_order(order) for order in orders)
|
||||
total = sum(classes.values())
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING("Отчёт по legacy orders:"))
|
||||
self.stdout.write(f" всего: {total}")
|
||||
for name in ("external", "manual", LEGACY_CLASS_PENDING):
|
||||
self.stdout.write(f" {name}: {classes.get(name, 0)}")
|
||||
self.stdout.write(f" импортируемых (external+manual): {classes.get('external', 0) + classes.get('manual', 0)}")
|
||||
self.stdout.write(f" требуют ручного решения (PENDING): {classes.get(LEGACY_CLASS_PENDING, 0)}")
|
||||
|
||||
if provision:
|
||||
self._provision(product_code)
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING("DRY-RUN: изменения не внесены. Повторите с --apply для импорта."))
|
||||
return
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
pending = 0
|
||||
for order in orders:
|
||||
if classify_legacy_order(order) == LEGACY_CLASS_PENDING:
|
||||
pending += 1
|
||||
continue
|
||||
_, was_created = import_legacy_order(order=order)
|
||||
created += int(was_created)
|
||||
skipped += int(not was_created)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Импортировано: {created}; уже были: {skipped}; пропущено PENDING: {pending}"))
|
||||
self.stdout.write("Legacy orders НЕ удалены и остаются read-only источником до отдельного подтверждения владельца (§11 шаг 12).")
|
||||
|
||||
def _provision(self, product_code: str) -> None:
|
||||
products = Product.objects.all()
|
||||
if product_code:
|
||||
products = products.filter(code=product_code)
|
||||
self.stdout.write(self.style.MIGRATE_HEADING("Provisioning SalesSource:"))
|
||||
for product in products:
|
||||
source, created, copied = provision_product_sales_source(product=product)
|
||||
verb = "создан" if created else "существует"
|
||||
note = "; действующий token скопирован" if copied else ("; token уже задан" if source.credential_hash else "; token отсутствует — выпустите issue_sales_source_credential")
|
||||
self.stdout.write(f" {product.code}: source {verb}{note}")
|
||||
@@ -37,6 +37,7 @@ from hub_platform.sales.models import (
|
||||
SaleEventType,
|
||||
SalesSource,
|
||||
SalesSourceStatus,
|
||||
SalesSourceType,
|
||||
SaleStatus,
|
||||
SourceType,
|
||||
)
|
||||
@@ -602,3 +603,155 @@ def issue_sales_source_credential(*, source: SalesSource) -> str:
|
||||
source.credential_hint = raw[-4:]
|
||||
source.save(update_fields=["credential_hash", "credential_hint", "updated_at"])
|
||||
return raw
|
||||
|
||||
|
||||
# --- Миграция legacy orders (ADR-HUB-0025 §11, SPEC-HUB-0014 §11) ---
|
||||
|
||||
# Классификация legacy-заказа для отчёта dry-run (SPEC §11.2).
|
||||
LEGACY_CLASS_EXTERNAL = "external" # внешний Order (source+external_id) — импорт
|
||||
LEGACY_CLASS_MANUAL = "manual" # ручной Order с финальным статусом — импорт
|
||||
LEGACY_CLASS_PENDING = "pending_ambiguous" # внутренний PENDING — НЕ продажа, ручное решение
|
||||
|
||||
# Legacy PaymentStatus → SaleStatus. PENDING сюда не входит: не считается продажей.
|
||||
_LEGACY_STATUS_MAP = {
|
||||
"PAID": SaleStatus.CONFIRMED,
|
||||
"REFUNDED": SaleStatus.REFUNDED,
|
||||
"CANCELLED": SaleStatus.CANCELLED,
|
||||
}
|
||||
|
||||
|
||||
def classify_legacy_order(order) -> str:
|
||||
"""Класс legacy-заказа: PENDING без подтверждения продажей не считается (§11.2)."""
|
||||
if order.payment_status == "PENDING":
|
||||
return LEGACY_CLASS_PENDING
|
||||
if order.source and order.external_id:
|
||||
return LEGACY_CLASS_EXTERNAL
|
||||
return LEGACY_CLASS_MANUAL
|
||||
|
||||
|
||||
def _legacy_line_items(order) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"external_item_id": f"legacy-item-{item.id}",
|
||||
"offer_code": item.offer.code if item.offer_id else "",
|
||||
"title": item.title,
|
||||
"quantity": item.quantity,
|
||||
"amount_minor": item.amount_minor,
|
||||
"currency": item.currency,
|
||||
}
|
||||
for item in order.items.all()
|
||||
]
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def import_legacy_order(*, order) -> tuple[Sale, bool]:
|
||||
"""Импортирует ОДИН подтверждённый legacy-заказ в Sale через sale.legacy_imported.
|
||||
|
||||
Идемпотентно по metadata.legacy_order_id. Возвращает (sale, created). PENDING-заказы
|
||||
отклоняются: они не являются продажей и требуют ручного подтверждения (§11.2).
|
||||
Не синтезирует историю событий, которой нет в исходных данных (§11.3).
|
||||
"""
|
||||
status = _LEGACY_STATUS_MAP.get(order.payment_status)
|
||||
if status is None:
|
||||
raise InvalidPayload(f"Order {order.id} ({order.payment_status}) is not a confirmed sale")
|
||||
|
||||
organization = order.organization
|
||||
existing = Sale.objects.filter(
|
||||
organization=organization,
|
||||
source_type=SourceType.LEGACY_IMPORT,
|
||||
metadata__legacy_order_id=order.id,
|
||||
).first()
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
|
||||
occurred_at = order.paid_at or order.created_at
|
||||
external_sale_id = order.external_id.strip() if order.external_id else f"legacy-{order.id}"
|
||||
amount_minor = int(order.amount_minor or 0)
|
||||
refunded_amount_minor = amount_minor if status == SaleStatus.REFUNDED else 0
|
||||
line_items = _legacy_line_items(order)
|
||||
|
||||
# fulfillment_status переносится ТОЛЬКО в metadata, не становится полем Sale (§11.2).
|
||||
metadata = {
|
||||
"legacy_order_id": order.id,
|
||||
"legacy_code": order.code,
|
||||
"legacy_source": order.source,
|
||||
"legacy_external_id": order.external_id,
|
||||
"legacy_payment_status": order.payment_status,
|
||||
"legacy_fulfillment_status": order.fulfillment_status,
|
||||
}
|
||||
|
||||
event = SaleEvent.objects.create(
|
||||
organization=organization,
|
||||
sales_source=None,
|
||||
environment=Environment.PRODUCTION,
|
||||
source_type=SourceType.LEGACY_IMPORT,
|
||||
event_type=SaleEventType.LEGACY_IMPORTED,
|
||||
schema_version=1,
|
||||
occurred_at=occurred_at,
|
||||
raw_payload={
|
||||
"event_type": SaleEventType.LEGACY_IMPORTED.value,
|
||||
"sale": {
|
||||
"external_sale_id": external_sale_id,
|
||||
"amount_minor": amount_minor,
|
||||
"refunded_amount_minor": refunded_amount_minor,
|
||||
"currency": order.currency,
|
||||
"items": line_items,
|
||||
},
|
||||
"metadata": metadata,
|
||||
},
|
||||
processing_status=ProcessingStatus.RECEIVED,
|
||||
)
|
||||
|
||||
# Контакт/диалог сохраняются при валидной принадлежности организации (§11.2).
|
||||
contact = order.contact if order.contact_id and order.contact.organization_id == organization.id else None
|
||||
conversation = order.conversation if order.conversation_id and order.conversation.organization_id == organization.id else None
|
||||
attribution = AttributionMethod.CONTACT_MATCH if (contact or conversation) else AttributionMethod.NONE
|
||||
|
||||
sale = Sale.objects.create(
|
||||
organization=organization,
|
||||
product=order.product,
|
||||
sales_source=None,
|
||||
environment=Environment.PRODUCTION,
|
||||
source_type=SourceType.LEGACY_IMPORT,
|
||||
external_sale_id=external_sale_id,
|
||||
contact=contact,
|
||||
conversation=conversation,
|
||||
status=status,
|
||||
amount_minor=amount_minor,
|
||||
refunded_amount_minor=refunded_amount_minor,
|
||||
currency=order.currency,
|
||||
occurred_at=occurred_at,
|
||||
last_event_at=occurred_at,
|
||||
attribution_method=attribution,
|
||||
line_items_snapshot=line_items,
|
||||
metadata=metadata,
|
||||
)
|
||||
event.sale = sale
|
||||
event.processing_status = ProcessingStatus.APPLIED
|
||||
event.applied_at = timezone.now()
|
||||
event.save(update_fields=["sale", "processing_status", "applied_at"])
|
||||
sale.last_event = event
|
||||
sale.save(update_fields=["last_event", "updated_at"])
|
||||
return sale, True
|
||||
|
||||
|
||||
def provision_product_sales_source(*, product) -> tuple[SalesSource, bool, bool]:
|
||||
"""Создаёт production SalesSource(PRODUCT_API) продукта и копирует существующий
|
||||
Product.ingest_token_hash в credential_hash для временной совместимости (§11 шаг 2-3).
|
||||
|
||||
НЕ генерирует и НЕ ротирует секрет — только копирует уже действующий hash, чтобы
|
||||
текущий token продукта продолжал работать против нового endpoint. Возвращает
|
||||
(source, created, credential_copied).
|
||||
"""
|
||||
source, created = SalesSource.objects.get_or_create(
|
||||
organization=product.organization,
|
||||
product=product,
|
||||
code="product-api",
|
||||
defaults={"type": SalesSourceType.PRODUCT_API, "environment": Environment.PRODUCTION},
|
||||
)
|
||||
credential_copied = False
|
||||
if product.ingest_token_hash and not source.credential_hash:
|
||||
source.credential_hash = product.ingest_token_hash
|
||||
source.save(update_fields=["credential_hash", "updated_at"])
|
||||
credential_copied = True
|
||||
return source, created, credential_copied
|
||||
@@ -296,3 +296,103 @@ class CredentialHashTests(TestCase):
|
||||
def test_hash_is_stable(self) -> None:
|
||||
self.assertEqual(hash_credential("abc"), hash_credential("abc"))
|
||||
self.assertNotEqual(hash_credential("abc"), hash_credential("abd"))
|
||||
|
||||
|
||||
class LegacyMigrationTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.product = Product.objects.get(code="firepage")
|
||||
self.contact = Contact.objects.create(organization=self.organization, name="Клиент")
|
||||
|
||||
def _order(self, *, payment_status: str, source: str = "", external_id: str = "", amount: int = 490000):
|
||||
from hub_platform.orders.models import Order
|
||||
|
||||
return Order.objects.create(
|
||||
organization=self.organization,
|
||||
contact=self.contact,
|
||||
product=self.product,
|
||||
payment_status=payment_status,
|
||||
fulfillment_status="DELIVERED" if payment_status == "PAID" else "NONE",
|
||||
amount_minor=amount,
|
||||
currency="RUB",
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
def test_classification(self) -> None:
|
||||
from hub_platform.sales.services import (
|
||||
LEGACY_CLASS_EXTERNAL,
|
||||
LEGACY_CLASS_MANUAL,
|
||||
LEGACY_CLASS_PENDING,
|
||||
classify_legacy_order,
|
||||
)
|
||||
|
||||
external = self._order(payment_status="PAID", source="firepage", external_id="fp-1")
|
||||
manual = self._order(payment_status="PAID")
|
||||
pending = self._order(payment_status="PENDING")
|
||||
self.assertEqual(classify_legacy_order(external), LEGACY_CLASS_EXTERNAL)
|
||||
self.assertEqual(classify_legacy_order(manual), LEGACY_CLASS_MANUAL)
|
||||
self.assertEqual(classify_legacy_order(pending), LEGACY_CLASS_PENDING)
|
||||
|
||||
def test_import_creates_legacy_sale_idempotently(self) -> None:
|
||||
from hub_platform.sales.services import import_legacy_order
|
||||
|
||||
order = self._order(payment_status="PAID", source="firepage", external_id="fp-100")
|
||||
sale, created = import_legacy_order(order=order)
|
||||
again, created_again = import_legacy_order(order=order)
|
||||
self.assertTrue(created)
|
||||
self.assertFalse(created_again)
|
||||
self.assertEqual(sale.id, again.id)
|
||||
self.assertEqual(sale.source_type, SourceType.LEGACY_IMPORT)
|
||||
self.assertEqual(sale.status, SaleStatus.CONFIRMED)
|
||||
self.assertIsNone(sale.sales_source_id)
|
||||
self.assertEqual(sale.external_sale_id, "fp-100")
|
||||
self.assertEqual(sale.metadata["legacy_order_id"], order.id)
|
||||
self.assertEqual(sale.metadata["legacy_fulfillment_status"], "DELIVERED")
|
||||
self.assertEqual(sale.events.count(), 1)
|
||||
self.assertEqual(sale.events.first().event_type, "sale.legacy_imported")
|
||||
|
||||
def test_refunded_order_maps_to_zero_net(self) -> None:
|
||||
from hub_platform.sales.services import import_legacy_order
|
||||
|
||||
order = self._order(payment_status="REFUNDED", amount=200000)
|
||||
sale, _ = import_legacy_order(order=order)
|
||||
self.assertEqual(sale.status, SaleStatus.REFUNDED)
|
||||
self.assertEqual(sale.refunded_amount_minor, 200000)
|
||||
self.assertEqual(sale.net_amount_minor, 0)
|
||||
self.assertTrue(sale.external_sale_id.startswith("legacy-"))
|
||||
|
||||
def test_pending_order_is_not_a_sale(self) -> None:
|
||||
from hub_platform.sales.services import SalesApiError, import_legacy_order
|
||||
|
||||
order = self._order(payment_status="PENDING")
|
||||
with self.assertRaises(SalesApiError):
|
||||
import_legacy_order(order=order)
|
||||
|
||||
def test_dry_run_writes_nothing(self) -> None:
|
||||
from django.core.management import call_command
|
||||
|
||||
self._order(payment_status="PAID", source="firepage", external_id="fp-dry")
|
||||
call_command("import_legacy_orders")
|
||||
self.assertEqual(Sale.objects.count(), 0)
|
||||
|
||||
def test_apply_imports_and_skips_pending(self) -> None:
|
||||
from django.core.management import call_command
|
||||
|
||||
self._order(payment_status="PAID", source="firepage", external_id="fp-a")
|
||||
self._order(payment_status="CANCELLED")
|
||||
self._order(payment_status="PENDING")
|
||||
call_command("import_legacy_orders", "--apply")
|
||||
self.assertEqual(Sale.objects.count(), 2)
|
||||
self.assertEqual(Sale.objects.filter(status=SaleStatus.CANCELLED).count(), 1)
|
||||
|
||||
def test_provision_copies_ingest_token_hash(self) -> None:
|
||||
from hub_platform.sales.services import provision_product_sales_source
|
||||
|
||||
self.product.ingest_token_hash = "a" * 64
|
||||
self.product.save(update_fields=["ingest_token_hash"])
|
||||
source, created, copied = provision_product_sales_source(product=self.product)
|
||||
self.assertTrue(created)
|
||||
self.assertTrue(copied)
|
||||
self.assertEqual(source.credential_hash, "a" * 64)
|
||||
Reference in new issue
Block a user