279 lines
11 KiB
Python
279 lines
11 KiB
Python
import uuid
|
||
|
||
from django.conf import settings
|
||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||
from django.db import models
|
||
|
||
|
||
class SourceSystem(models.Model):
|
||
"""External system that supplies observations and business events."""
|
||
|
||
code = models.SlugField("Код", max_length=64, unique=True)
|
||
name = models.CharField("Название", max_length=128)
|
||
is_active = models.BooleanField("Активен", default=True)
|
||
metadata = models.JSONField("Настройки без секретов", default=dict, blank=True)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Источник данных"
|
||
verbose_name_plural = "Источники данных"
|
||
ordering = ["name"]
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class SourceRecord(models.Model):
|
||
"""Latest source payload retained with provenance for repeatable imports."""
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
source = models.ForeignKey(
|
||
SourceSystem,
|
||
on_delete=models.PROTECT,
|
||
related_name="records",
|
||
verbose_name="Источник",
|
||
)
|
||
entity_type = models.CharField("Тип объекта", max_length=64)
|
||
external_id = models.CharField("Внешний ID", max_length=255)
|
||
payload = models.JSONField("Исходные данные", default=dict)
|
||
payload_hash = models.CharField("Хеш данных", max_length=64, editable=False)
|
||
source_updated_at = models.DateTimeField(
|
||
"Изменено в источнике", null=True, blank=True
|
||
)
|
||
received_at = models.DateTimeField("Получено", auto_now=True)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Исходная запись"
|
||
verbose_name_plural = "Исходные записи"
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["source", "entity_type", "external_id"],
|
||
name="commerce_source_record_identity",
|
||
)
|
||
]
|
||
indexes = [
|
||
models.Index(
|
||
fields=["source", "entity_type", "source_updated_at"],
|
||
name="commerce_source_record_sync",
|
||
)
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.source.code}:{self.entity_type}:{self.external_id}"
|
||
|
||
|
||
class ProductBinding(models.Model):
|
||
"""Maps an external product/offer identifier to the canonical product."""
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
product = models.ForeignKey(
|
||
"commerce.Product",
|
||
on_delete=models.CASCADE,
|
||
related_name="bindings",
|
||
verbose_name="Товар",
|
||
)
|
||
source = models.ForeignKey(
|
||
SourceSystem,
|
||
on_delete=models.PROTECT,
|
||
related_name="product_bindings",
|
||
verbose_name="Источник",
|
||
)
|
||
external_id = models.CharField("Внешний ID", max_length=255)
|
||
external_sku = models.CharField(
|
||
"Артикул в источнике", max_length=128, blank=True, db_index=True
|
||
)
|
||
metadata = models.JSONField(
|
||
"Нормализованные данные источника",
|
||
default=dict,
|
||
blank=True,
|
||
)
|
||
is_primary = models.BooleanField("Основная привязка", default=False)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Привязка товара"
|
||
verbose_name_plural = "Привязки товаров"
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["source", "external_id"],
|
||
name="commerce_binding_source_external",
|
||
),
|
||
]
|
||
indexes = [
|
||
models.Index(
|
||
fields=["source", "external_sku"],
|
||
name="commerce_binding_source_sku",
|
||
)
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.product.sku} ← {self.source.code}:{self.external_id}"
|
||
|
||
|
||
class IntegrationProvider(models.TextChoices):
|
||
MOYSKLAD = "moysklad", "МойСклад"
|
||
WILDBERRIES = "wildberries", "Wildberries"
|
||
|
||
|
||
class IntegrationStatus(models.TextChoices):
|
||
DISCONNECTED = "disconnected", "Не подключено"
|
||
READY = "ready", "Подключено"
|
||
QUEUED = "queued", "В очереди"
|
||
SYNCING = "syncing", "Синхронизация"
|
||
ERROR = "error", "Ошибка"
|
||
|
||
|
||
class IntegrationSyncStage(models.TextChoices):
|
||
PROJECTS = "projects", "Проекты"
|
||
PROJECT_PAYMENTS = "project_payments", "Расходы проектов"
|
||
CATALOG = "catalog", "Каталог"
|
||
IMAGES = "images", "Изображения"
|
||
VARIANT_LINKS = "variant_links", "Связи вариантов"
|
||
STOCKS = "stocks", "Остатки"
|
||
SALES = "sales", "Продажи"
|
||
RETURNS = "returns", "Возвраты"
|
||
FINALIZING = "finalizing", "Завершение"
|
||
|
||
|
||
class IntegrationConnection(models.Model):
|
||
"""Encrypted credentials and synchronization state for an external source."""
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
source = models.OneToOneField(
|
||
SourceSystem,
|
||
on_delete=models.PROTECT,
|
||
related_name="connection",
|
||
verbose_name="Источник",
|
||
)
|
||
provider = models.CharField(
|
||
"Провайдер",
|
||
max_length=32,
|
||
choices=IntegrationProvider.choices,
|
||
unique=True,
|
||
)
|
||
auth_type = models.CharField(
|
||
"Способ авторизации",
|
||
max_length=16,
|
||
choices=[("token", "Токен"), ("basic", "Логин и пароль")],
|
||
default="token",
|
||
)
|
||
encrypted_credentials = models.TextField("Зашифрованные реквизиты")
|
||
status = models.CharField(
|
||
"Статус",
|
||
max_length=16,
|
||
choices=IntegrationStatus.choices,
|
||
default=IntegrationStatus.DISCONNECTED,
|
||
)
|
||
account_name = models.CharField("Учётная запись", max_length=255, blank=True)
|
||
last_error = models.TextField("Последняя ошибка", blank=True)
|
||
last_tested_at = models.DateTimeField("Доступ проверен", null=True, blank=True)
|
||
last_sync_started_at = models.DateTimeField(
|
||
"Синхронизация начата", null=True, blank=True
|
||
)
|
||
last_sync_finished_at = models.DateTimeField(
|
||
"Синхронизация завершена", null=True, blank=True
|
||
)
|
||
last_stats = models.JSONField("Результат синхронизации", default=dict, blank=True)
|
||
task_id = models.CharField("ID фоновой задачи", max_length=255, blank=True)
|
||
auto_sync = models.BooleanField("Автоматическая синхронизация", default=True)
|
||
sync_interval_minutes = models.PositiveIntegerField(
|
||
"Интервал синхронизации, минут",
|
||
default=15,
|
||
validators=[MinValueValidator(5), MaxValueValidator(1440)],
|
||
)
|
||
next_sync_at = models.DateTimeField(
|
||
"Следующая синхронизация", null=True, blank=True, db_index=True
|
||
)
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="commerce_connections_created",
|
||
verbose_name="Создал",
|
||
)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Подключение интеграции"
|
||
verbose_name_plural = "Подключения интеграций"
|
||
|
||
def __str__(self):
|
||
return f"{self.get_provider_display()} · {self.get_status_display()}"
|
||
|
||
|
||
class IntegrationSyncRun(models.Model):
|
||
"""Persistent progress and result of one full connector scan."""
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
connection = models.ForeignKey(
|
||
IntegrationConnection,
|
||
on_delete=models.CASCADE,
|
||
related_name="sync_runs",
|
||
verbose_name="Подключение",
|
||
)
|
||
status = models.CharField(
|
||
"Статус",
|
||
max_length=16,
|
||
choices=[
|
||
("queued", "В очереди"),
|
||
("running", "Выполняется"),
|
||
("success", "Завершено"),
|
||
("error", "Ошибка"),
|
||
],
|
||
default="queued",
|
||
)
|
||
task_id = models.CharField("ID фоновой задачи", max_length=255, blank=True)
|
||
stage = models.CharField(
|
||
"Этап",
|
||
max_length=32,
|
||
choices=IntegrationSyncStage.choices,
|
||
blank=True,
|
||
default="",
|
||
)
|
||
stage_processed = models.PositiveIntegerField("Обработано на этапе", default=0)
|
||
stage_total = models.PositiveIntegerField("Всего на этапе", default=0)
|
||
last_progress_at = models.DateTimeField(
|
||
"Последний прогресс",
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
processed = models.PositiveIntegerField("Обработано", default=0)
|
||
created_products = models.PositiveIntegerField("Создано товаров", default=0)
|
||
matched_products = models.PositiveIntegerField("Сопоставлено", default=0)
|
||
stock_snapshots = models.PositiveIntegerField("Снимков остатков", default=0)
|
||
images = models.PositiveIntegerField("Изображений", default=0)
|
||
image_errors = models.PositiveIntegerField("Ошибок изображений", default=0)
|
||
sales = models.PositiveIntegerField("Продаж и возвратов", default=0)
|
||
costs = models.PositiveIntegerField("Расходов", default=0)
|
||
error = models.TextField("Ошибка", blank=True)
|
||
started_at = models.DateTimeField("Начато", null=True, blank=True)
|
||
finished_at = models.DateTimeField("Завершено", null=True, blank=True)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Запуск синхронизации"
|
||
verbose_name_plural = "Запуски синхронизации"
|
||
ordering = ["-created_at"]
|
||
indexes = [
|
||
models.Index(
|
||
fields=["connection", "-created_at"],
|
||
name="commerce_sync_connection_date",
|
||
),
|
||
models.Index(
|
||
fields=["status", "created_at"],
|
||
name="commerce_sync_status_created",
|
||
),
|
||
models.Index(
|
||
fields=["status", "last_progress_at"],
|
||
name="commerce_sync_status_progress",
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.connection.provider} · {self.status} · {self.created_at}"
|