✨ 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:
AndreyandClaude Opus 4.8 committed 2026-07-12 09:51:35 +03:00
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}")
+153
View File
@@ -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
+100
View File
@@ -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)