/
dstemnik
/
DSServices
Обзор
Документация
Войти
/
dstemnik
/
DSServices
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
test_app/models.py
509 строк
20 KB
Tem
добавили в коробе в модель
06 дек 2025, 20:03
06 дек 2025, 20:03
07b0751
Код
Авторство
О чём код?
# test_app/models.py from django.db import models from django.conf import settings from decimal import Decimal, ROUND_HALF_UP, InvalidOperation from django.core.validators import MinValueValidator from datetime import date, timedelta from django.db.models import Sum class Tag(models.Model): """Ярлык (тэг) продавца в Wildberries.""" tag_id = models.PositiveIntegerField(primary_key=True, verbose_name="ID ярлыка WB") name = models.CharField(max_length=120, verbose_name="Название") class Meta: verbose_name = "Ярлык" verbose_name_plural = "Ярлыки" def __str__(self): return self.name class WBProductManager(models.Manager): def get_existing_vendor_codes(self, store=None): qs = self if store is not None: qs = qs.filter(store=store) return set(qs.values_list('vendor_code', flat=True)) class WBProduct(models.Model): objects = WBProductManager() nm_id = models.BigIntegerField(unique=True, verbose_name="Артикул WB") title = models.CharField(max_length=500, verbose_name="Название") vendor_code = models.CharField(max_length=100, verbose_name="Артикул") logistics_cost = models.DecimalField("Стоимость логистики", max_digits=10, decimal_places=2, default=0) length = models.PositiveIntegerField("Длина (cм)", default=0) width = models.PositiveIntegerField("Ширина (cм)", default=0) height = models.PositiveIntegerField("Высота (cм)", default=0) box_quantity = models.PositiveIntegerField("В коробе", default=1) weight_brutto = models.DecimalField("Вес (кг)", max_digits=8, decimal_places=3, default=0) subject_id = models.PositiveIntegerField("ID категории", default=0) subject_name = models.CharField(max_length=100, verbose_name="Категория", default='') skus = models.JSONField( verbose_name="SKU Wildberries", default=list, blank=True, help_text="Список артикулов (SKU) Wildberries" ) tags = models.ManyToManyField(Tag, related_name="products", blank=True, verbose_name="Ярлыки") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) store = models.ForeignKey('test_app.WBStore', verbose_name="Магазин", related_name="products", on_delete=models.CASCADE, null=True, blank=True, ) class Meta: # Переименовано, чтобы в админке модель отображалась как «Центр управления» verbose_name = "Центр управления" verbose_name_plural = "Центр управления" indexes = [ models.Index(fields=['store', 'vendor_code'], name='wbp_store_vendor_idx'), models.Index(fields=['store', 'nm_id'], name='wbp_store_nmid_idx'), ] constraints = [ models.UniqueConstraint(fields=['store', 'vendor_code'], name='wbp_unique_store_vendor'), ] def __str__(self): return f"{self.nm_id} - {self.title}" def get_commission(self): try: return Commission.objects.get(subject_id=self.subject_id) except Commission.DoesNotExist: return None def get_commission_rate(self): """Возвращает комиссию, учитывая возможную аннотацию""" if hasattr(self, 'annotated_commission_rate'): return self.annotated_commission_rate try: return Commission.objects.get(subject_id=self.subject_id).kgvp_marketplace except Commission.DoesNotExist: return Decimal('0') def calculate_logistics(self): try: logistics = WBLogistics.objects.get(warehouse_name="Маркетплейс") volume = (self.length * self.width * self.height) / 1000 # литры if volume > 1: return ((volume - 1) * logistics.box_delivery_liter) + logistics.box_delivery_base return logistics.box_delivery_base except WBLogistics.DoesNotExist: return Decimal('0') def calculate_target_price(self, target_profit=10, fixed_discount=40, commission_rate=None): """ Универсальный расчет цены с поддержкой отрицательной прибыли и обработкой всех краевых случаев. """ try: purchase_price = self.opt4_price.price logistics = self.logistics_cost or Decimal('0') if commission_rate is None: try: commission = Commission.objects.get(subject_id=self.subject_id).kgvp_marketplace except Commission.DoesNotExist: commission = Decimal('0') else: commission = commission_rate purchase_price = Decimal(str(purchase_price)) logistics = Decimal(str(logistics)) commission = Decimal(str(commission)) target_profit_dec = Decimal(str(target_profit)) fixed_discount_dec = Decimal(str(fixed_discount)) min_price = (purchase_price + logistics) * Decimal('1.05') # минимальная наценка 5% try: numerator = purchase_price + logistics discount_part = ( fixed_discount_dec/Decimal('100') + commission/Decimal('100') + target_profit_dec/Decimal('100') ) denominator = Decimal('1') - discount_part if denominator <= 0: return min_price target_price = numerator / denominator except (ZeroDivisionError, InvalidOperation): target_price = min_price try: required_revenue = (purchase_price + logistics) * (Decimal('1') + target_profit_dec/Decimal('100')) discount_factor = (Decimal('100') - fixed_discount_dec) / Decimal('100') commission_factor = (Decimal('100') - commission) / Decimal('100') alternative_price = required_revenue / (discount_factor * commission_factor) target_price = min(target_price, alternative_price) except Exception: pass if target_price <= purchase_price: target_price = min_price return target_price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) except Exception: try: return (purchase_price + logistics) * Decimal('1.5') except Exception: return Decimal('0.00') def get_effective_wb_price(self) -> Decimal: """ Текущая цена товара на WB: - реальная цена со скидкой (discounted_price), если задана; - иначе базовая price. Если цен нет — возвращает 0. """ price_obj = getattr(self, "current_price", None) if price_obj is None: return Decimal("0") value = price_obj.discounted_price or price_obj.price try: return Decimal(str(value)) except (InvalidOperation, TypeError): return Decimal("0") def get_profit_rub_from_wb_price(self) -> Decimal: """ Прибыль в рублях, считая от текущей цены на WB с учётом комиссии, логистики, налога и НДС магазина. Налог и НДС считаются от продажной цены. """ price = self.get_effective_wb_price() if price <= 0: return Decimal("0") # закупочная цена (Opt4) try: purchase_price = Decimal(str(self.opt4_price.price)) except Exception: return Decimal("0") # логистика logistics = Decimal(str(self.logistics_cost or 0)) # комиссия маркетплейса, % commission_rate = self.get_commission_rate() commission_rate = Decimal(str(commission_rate or 0)) commission_value = price * commission_rate / Decimal("100") # налог и НДС из магазина (в процентах от цены) store = getattr(self, "store", None) tax_percent = Decimal(str(getattr(store, "tax_percent", 0) or 0)) vat_percent = Decimal(str(getattr(store, "vat_percent", 0) or 0)) vat_value = price * vat_percent / Decimal("100") tax_value = price * tax_percent / Decimal("100") full_cost = purchase_price + logistics + commission_value + vat_value + tax_value profit = price - full_cost return profit.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) def get_profit_percent_from_wb_price(self) -> Decimal: """ Прибыль в процентах от цены товара на WB. """ price = self.get_effective_wb_price() if price <= 0: return Decimal("0") profit = self.get_profit_rub_from_wb_price() try: percent = (profit / price) * Decimal("100") except (InvalidOperation, ZeroDivisionError): return Decimal("0") return percent.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) def get_total_stock(self) -> int: """ Суммарный остаток товара по всем складам (FBS + FBO). """ if hasattr(self, "stock_info") and self.stock_info is not None: return int(self.stock_info.stock_main or 0) + int(self.stock_info.stock_wb or 0) return 0 def get_average_daily_sales(self, days: int = 30) -> Decimal: """ Средние дневные продажи за последние N дней по данным WBDailySale. Если продаж не было — возвращает 0. """ if days <= 0: return Decimal("0") end_date = date.today() start_date = end_date - timedelta(days=days - 1) qs = WBDailySale.objects.filter( product=self, date__range=(start_date, end_date), ) agg = qs.aggregate(total_qty=Sum("quantity")) total_qty = agg.get("total_qty") or 0 if total_qty == 0: return Decimal("0") avg = Decimal(str(total_qty)) / Decimal(str(days)) return avg.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) def get_estimated_days_to_zero_stock(self, days: int = 30): """ Оценка, на сколько дней хватит текущего остатка при среднем расходе за N дней. Если средних продаж нет или остаток нулевой — возвращает None. """ total_stock = self.get_total_stock() if total_stock <= 0: return None avg_daily = self.get_average_daily_sales(days=days) if avg_daily <= 0: return None estimated = Decimal(str(total_stock)) / avg_daily return estimated.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) class Opt4Price(models.Model): product = models.OneToOneField( WBProduct, on_delete=models.CASCADE, related_name='opt4_price', primary_key=True, ) name = models.CharField(max_length=255, verbose_name="Название из прайса", blank=True) price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Цена Opt4", validators=[MinValueValidator(0.01)]) updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = "Цена Opt4" verbose_name_plural = "Цены Opt4" indexes = [models.Index(fields=['product'], name='opt4_product_idx')] def __str__(self): return f"{self.product.vendor_code} - {self.price}" class WBLogistics(models.Model): warehouse_name = models.CharField(max_length=200, verbose_name="Название склада", unique=True) box_delivery_base = models.DecimalField("Базовая доставка", max_digits=10, decimal_places=2, default=0) box_delivery_liter = models.DecimalField("Доставка за литр", max_digits=10, decimal_places=2, default=0) last_updated = models.DateTimeField(auto_now=True, verbose_name="Последнее обновление") class Meta: verbose_name = "Логистические данные" verbose_name_plural = "Логистические данные" def __str__(self): return f"{self.warehouse_name} ({self.last_updated})" class WBPrice(models.Model): product = models.OneToOneField( WBProduct, on_delete=models.CASCADE, related_name='current_price', verbose_name="Товар", primary_key=True ) price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Базовая цена", validators=[MinValueValidator(0.01)]) discounted_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Реальная цена", null=True, validators=[MinValueValidator(0.01)]) discount = models.PositiveIntegerField(verbose_name="Скидка, %", default=0) size = models.CharField(max_length=50, verbose_name="Размер", blank=True, null=True) updated_at = models.DateTimeField(auto_now=True, verbose_name="Последнее обновление") def __str__(self): return f"{self.product.vendor_code} - {self.price} ₽" class Meta: verbose_name = "Цена товара" verbose_name_plural = "Цены товаров" ordering = ['-updated_at'] class Commission(models.Model): subject_id = models.PositiveIntegerField(unique=True, verbose_name="ID категории") subject_name = models.CharField(max_length=100, verbose_name="Категория") parent_id = models.PositiveIntegerField(verbose_name="ID родительской категории", default=0) parent_name = models.CharField(max_length=100, verbose_name="Родительская категория", default='') kgvp_marketplace = models.DecimalField("Комиссия Marketplace (%)", max_digits=5, decimal_places=2, default=0) class Meta: verbose_name = "Комиссия" verbose_name_plural = "Комиссии" def __str__(self): return f"{self.subject_id} - {self.subject_name}" class Stock(models.Model): product = models.OneToOneField( WBProduct, on_delete=models.CASCADE, related_name='stock_info', verbose_name="Товар", primary_key=True ) stock_main = models.PositiveIntegerField("Остаток FBS (основной склад)", default=0) stock_wb = models.PositiveIntegerField("Остаток FBO (склады WB)", default=0) updated_at = models.DateTimeField(auto_now=True, verbose_name="Последнее обновление") class Meta: verbose_name = "Остаток товара" verbose_name_plural = "Остатки товаров" ordering = ['-updated_at'] def __str__(self): return f"{self.product.vendor_code}: FBS={self.stock_main}, FBO={self.stock_wb}" class WBDailySale(models.Model): """ Ежедневные заказы по товару. Одна запись = агрегированные заказы по конкретному товару за конкретную дату. """ product = models.ForeignKey( WBProduct, on_delete=models.CASCADE, related_name="daily_sales", verbose_name="Товар", ) date = models.DateField( verbose_name="Дата", ) quantity = models.PositiveIntegerField( verbose_name="Заказано, шт", default=0, ) revenue = models.DecimalField( verbose_name="Сумма заказов, руб", max_digits=12, decimal_places=2, default=Decimal('0.00'), ) class Meta: verbose_name = "Ежедневные заказы" verbose_name_plural = "Ежедневные заказы" unique_together = ("product", "date") ordering = ["-date"] def __str__(self): return f"{self.product.vendor_code} @ {self.date}: {self.quantity} шт / {self.revenue} руб." class WBStore(models.Model): name = models.CharField("Название магазина", max_length=255, unique=True) api_token_content = models.CharField( "API ключ (WB_API_TOKEN_CONTENT)", max_length=512, blank=True, default="", ) main_warehouse_id = models.CharField( "ID склада для отгрузки (MAIN_WAREHOUSE_ID)", max_length=64, blank=True, default="", ) warehouse_name = models.CharField( "Название главного склада (WAREHOUSE_NAME)", max_length=255, blank=True, default="", ) # Налог и НДС магазина tax_percent = models.PositiveIntegerField( "Ставка налога, %", choices=[ (1, "1%"), (6, "6%"), ], default=6, help_text="Процент налога с выручки (например, 1 или 6).", ) vat_percent = models.PositiveIntegerField( "Ставка НДС, %", choices=[ (0, "Без НДС"), (5, "5%"), (7, "7%"), (10, "10%"), (20, "20%"), ], default=0, help_text="0 — без НДС, иначе ставка НДС в процентах.", ) # ───────── автообновление цен ───────── auto_price_sync_enabled = models.BooleanField( "Включить автообновление цен", default=False, help_text="Если включено — цены будут периодически синхронизироваться по расписанию.", ) auto_price_sync_interval_hours = models.PositiveIntegerField( "Интервал автообновления цен, часов", default=3, validators=[MinValueValidator(3)], help_text="Минимум 3 часа. Используется management-командой sync_wb_prices.", ) last_price_sync_at = models.DateTimeField( "Время последней синхронизации цен", null=True, blank=True, ) class Meta: verbose_name = "Магазин WB" verbose_name_plural = "Магазины WB" def __str__(self): return self.name class StoreAccess(models.Model): """ Доступ пользователя к конкретному магазину WB внутри аккаунта. Идея: - админ аккаунта видит все магазины WBStore в своей схеме; - для менеджеров доступ ограничивается записями StoreAccess; - одна запись = один пользователь ↔ один магазин. """ store = models.ForeignKey( WBStore, on_delete=models.CASCADE, related_name="accesses", verbose_name="Магазин", ) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="store_accesses", verbose_name="Пользователь", db_constraint=False, # пользователь живёт в shared-схеме ) is_active = models.BooleanField( "Активен", default=True, ) class Meta: verbose_name = "Доступ к магазину" verbose_name_plural = "Доступы к магазинам" unique_together = ("store", "user") def __str__(self) -> str: return f"{self.user} → {self.store}"