diff --git a/apps/backend/hub_platform/sales/management/commands/import_legacy_orders.py b/apps/backend/hub_platform/sales/management/commands/import_legacy_orders.py new file mode 100644 index 0000000..2459cd1 --- /dev/null +++ b/apps/backend/hub_platform/sales/management/commands/import_legacy_orders.py @@ -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}") diff --git a/apps/backend/hub_platform/sales/services.py b/apps/backend/hub_platform/sales/services.py index f480153..e31d43b 100644 --- a/apps/backend/hub_platform/sales/services.py +++ b/apps/backend/hub_platform/sales/services.py @@ -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 diff --git a/apps/backend/hub_platform/sales/tests.py b/apps/backend/hub_platform/sales/tests.py index 2a96ff9..04b9572 100644 --- a/apps/backend/hub_platform/sales/tests.py +++ b/apps/backend/hub_platform/sales/tests.py @@ -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)