/
dstemnik
/
DSServices
Обзор
Документация
Войти
/
dstemnik
/
DSServices
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
test_app/admin.py
522 строки
22 KB
Tem
Краткое осмысленное описание изменений
08 дек 2025, 15:48
08 дек 2025, 15:48
ab07a39
Код
Авторство
О чём код?
# test_app/admin.py from datetime import datetime from decimal import Decimal, ROUND_HALF_UP, ROUND_UP, InvalidOperation import csv from io import StringIO from django.contrib import admin, messages from django.db import transaction from django.http import HttpResponse from django.shortcuts import redirect from django.urls import path from . import wb_services as wb from .models import ( WBProduct, Opt4Price, WBPrice, Commission, # Ниже модели, которые скрываем из меню: WBLogistics, Stock, Tag, ) # ──────────────────────────── расчётные константы ──────────────────────────── VAT_RATE = Decimal("6") FIXED_DISCOUNT = Decimal("50") TARGET_PROFIT_PCT = Decimal("13") DISCOUNT_FACTOR = (Decimal("100") - FIXED_DISCOUNT) / Decimal("100") VAT_FACTOR = VAT_RATE / Decimal("100") # ────────────── helper: метод для расчёта целевой цены со скидкой ───────────── def target_selling_price(self, commission_pct: Decimal) -> Decimal: purchase = self.opt4_price.price logistics = self.logistics_cost or Decimal("0") numerator = logistics + purchase * (Decimal("1") + TARGET_PROFIT_PCT / Decimal("100")) denominator = Decimal("1") - commission_pct / Decimal("100") - VAT_FACTOR if denominator <= 0: raise ValueError("Комиссия + НДС ≥ 100 % — расчёт невозможен") return (numerator / denominator).quantize(Decimal("0.01"), ROUND_HALF_UP) WBProduct.add_to_class("target_selling_price", target_selling_price) # ────────────────────────── inline-формы ───────────────────────── class Opt4Inline(admin.StackedInline): model = Opt4Price extra = 0 readonly_fields = ("updated_at",) fields = ("price", "updated_at") class PriceInline(admin.StackedInline): model = WBPrice extra = 0 readonly_fields = ("updated_at",) fields = ("price", "discount", "discounted_price", "updated_at") class StoreTagFilter(admin.SimpleListFilter): title = "Ярлыки" parameter_name = "tag" def lookups(self, request, model_admin): store = getattr(request, "active_store", None) if not store: return [] # Показываем только ярлыки, реально встречающиеся у товаров активного магазина qs = Tag.objects.filter(products__store=store).distinct().order_by("name") return [(t.pk, t.name) for t in qs] def queryset(self, request, queryset): value = self.value() if value: return queryset.filter(tags__pk=value) return queryset # ───────────────────── «Центр управления» (скрываем до настроек) ───────────── @admin.register(WBProduct) class WBProductAdmin(admin.ModelAdmin): inlines = (Opt4Inline, PriceInline) list_display = ( "vendor_code", "title", "subject_name", "opt4_price", "current_price", "logistics_cost", "box_quantity", "updated_at", ) search_fields = ("vendor_code", "title") list_filter = ("subject_name", StoreTagFilter) readonly_fields = ("updated_at",) change_list_template = "admin/wbproduct_changelist.html" # Массовые действия с ярлыками (ограничены активным магазином) actions = ("add_tag_from_filter", "remove_tag_from_filter") def has_add_permission(self, request): # Запрещаем добавление товаров через админку, # они должны приходить только через синхронизацию. return False def get_queryset(self, request): qs = super().get_queryset(request).select_related("opt4_price", "current_price") store = getattr(request, "active_store", None) if store is None: messages.info(request, "Магазин не выбран — показаны карточки без привязки к магазину.") return qs.none() return qs.filter(store=store) def formfield_for_manytomany(self, db_field, request, **kwargs): if db_field.name == "tags": store = getattr(request, "active_store", None) if store is None: kwargs["queryset"] = Tag.objects.none() else: kwargs["queryset"] = ( Tag.objects .filter(products__store=store) .distinct() .order_by("name") ) return super().formfield_for_manytomany(db_field, request, **kwargs) # Кастомные URL-ы def get_urls(self): urls = super().get_urls() app = self.model._meta.app_label model = self.model._meta.model_name def _n(suffix: str) -> str: return f"{app}_{model}_{suffix}" custom_urls = [ path("sync-products/", self.admin_site.admin_view(self.sync_products), name=_n("sync_products")), path("sync-prices/", self.admin_site.admin_view(self.sync_prices), name=_n("sync_prices")), path("sync-opt4/", self.admin_site.admin_view(self.sync_opt4), name=_n("sync_opt4")), path("sync-logistics/", self.admin_site.admin_view(self.sync_logistics), name=_n("sync_logistics")), path("sync-commissions/", self.admin_site.admin_view(self.sync_commissions), name=_n("sync_commissions")), path("sync-stocks/", self.admin_site.admin_view(self.sync_stocks), name=_n("sync_stocks")), path("sync-daily-sales/", self.admin_site.admin_view(self.sync_daily_sales), name=_n("sync_daily_sales")), path("sync-tags/", self.admin_site.admin_view(self.sync_tags), name=_n("sync_tags")), path( "box-quantity-upload/", self.admin_site.admin_view(self.box_quantity_upload), name=_n("box_quantity_upload"), ), path( "box-quantity-template/", self.admin_site.admin_view(self.box_quantity_template), name=_n("box_quantity_template"), ), path("adjust-prices/", self.admin_site.admin_view(self.adjust_prices), name=_n("adjust_prices")), ] return custom_urls + urls # Кнопки-синхронизации (если вдруг полезут по прямой ссылке — не пустим в has_*_permission) def sync_products(self, request): saved = wb.sync_products(store=getattr(request, "active_store", None)) self.message_user(request, f"Карточки обновлены: {saved}", messages.SUCCESS) return redirect("..") def sync_prices(self, request): saved = wb.sync_prices(store=getattr(request, "active_store", None)) self.message_user(request, f"Цены WB обновлены: {saved}", messages.SUCCESS) return redirect("..") def sync_opt4(self, request): saved = wb.sync_opt4(store=getattr(request, "active_store", None)) self.message_user(request, f"Закупочные цены обновлены: {saved}", messages.SUCCESS) return redirect("..") def sync_logistics(self, request): saved = wb.sync_logistics(store=getattr(request, "active_store", None)) self.message_user(request, f"Логистика обновлена: {saved}", messages.SUCCESS) return redirect("..") def sync_commissions(self, request): saved = wb.sync_commissions() self.message_user(request, f"Комиссии обновлены: {saved}", messages.SUCCESS) return redirect("..") def sync_stocks(self, request): saved = wb.sync_stocks(store=getattr(request, "active_store", None)) self.message_user(request, f"Остатки обновлены: {saved}", messages.SUCCESS) return redirect("..") def sync_daily_sales(self, request): saved = wb.sync_daily_sales(store=getattr(request, "active_store", None)) self.message_user(request, f"Ежедневные продажи обновлены: {saved}", messages.SUCCESS) return redirect("..") def sync_tags(self, request): processed = wb.sync_tags() self.message_user(request, f"Ярлыки обновлены: {processed}", messages.SUCCESS) return redirect("..") def box_quantity_upload(self, request): """ Обновление поля «В коробе» из CSV. CSV-формат: Артикул 1С;В коробе """ store = getattr(request, "active_store", None) if store is None: self.message_user( request, "Магазин не выбран — загрузка «В коробе» недоступна.", messages.WARNING, ) return redirect("..") if request.method != "POST": self.message_user(request, "Некорректный метод запроса.", messages.ERROR) return redirect("..") csv_file = request.FILES.get("box_csv") if not csv_file: self.message_user(request, "Файл CSV не выбран.", messages.ERROR) return redirect("..") # читаем файл и пробуем UTF-8, затем CP1251 raw = csv_file.read() try: content = raw.decode("utf-8-sig") except UnicodeDecodeError: try: content = raw.decode("cp1251") except UnicodeDecodeError: self.message_user( request, "Не удалось прочитать файл. Ожидается кодировка UTF-8 или CP1251.", messages.ERROR, ) return redirect("..") reader = csv.DictReader(StringIO(content), delimiter=";") stats = {"total": 0, "updated": 0, "missing": 0, "invalid": 0} for row in reader: stats["total"] += 1 art = (row.get("Артикул 1С") or row.get("Артикул") or "").strip() qty_raw = (row.get("В коробе") or "").strip() if not art or not qty_raw: stats["invalid"] += 1 continue normalized = qty_raw.replace(" ", "").replace(",", ".") try: qty = int(float(normalized)) if qty < 0: qty = 0 except (ValueError, TypeError): stats["invalid"] += 1 continue try: product = WBProduct.objects.get(vendor_code=art, store=store) except WBProduct.DoesNotExist: stats["missing"] += 1 continue if product.box_quantity != qty: product.box_quantity = qty product.save(update_fields=["box_quantity"]) stats["updated"] += 1 msg = ( f"Строк в файле: {stats['total']}; " f"обновлено товаров: {stats['updated']}; " f"не найдены по артикулу: {stats['missing']}; " f"некорректных строк: {stats['invalid']}." ) self.message_user(request, msg, messages.SUCCESS) return redirect("..") def box_quantity_template(self, request): """ Скачать шаблон CSV для поля «В коробе». """ # реальные переводы строки, без двойных слэшей content = "Артикул 1С;В коробе\n12345;12\n" response = HttpResponse(content_type="text/csv; charset=utf-8") # настоящий символ BOM, а не текст "\ufeff" response.write("\ufeff") response.write(content) response["Content-Disposition"] = ( 'attachment; filename="box_quantity_template.csv"' ) return response # Helpers @staticmethod def _fmt(value, digits: int = 2) -> str: if digits == 0: val = Decimal(value).quantize(Decimal("1"), ROUND_UP) return f"{val:.0f}".replace(".", ",") val = Decimal(value).quantize(Decimal("0.01"), ROUND_HALF_UP) return f"{val:.2f}".replace(".", ",") @admin.action(description="Добавить ярлык (используется выбранный в фильтре «Ярлыки»)") def add_tag_from_filter(self, request, queryset): store = getattr(request, "active_store", None) if store is None: self.message_user(request, "Магазин не выбран — действие недоступно.", messages.WARNING) return # Берём выбранный в панели фильтров ярлык (SimpleListFilter.parameter_name = "tag") tag_id = request.GET.get("tag") or request.POST.get("tag") if not tag_id: self.message_user(request, "Сначала выберите ярлык в фильтре справа.", messages.WARNING) return tag = ( Tag.objects .filter(pk=tag_id, products__store=store) .distinct() .first() ) if tag is None: self.message_user(request, "Ярлык не принадлежит активному магазину.", messages.ERROR) return affected = 0 with transaction.atomic(): for p in queryset.filter(store=store): p.tags.add(tag) affected += 1 self.message_user(request, f"Ярлык «{tag.name}» добавлен: {affected} шт.", messages.SUCCESS) @admin.action(description="Снять ярлык (используется выбранный в фильтре «Ярлыки»)") def remove_tag_from_filter(self, request, queryset): store = getattr(request, "active_store", None) if store is None: self.message_user(request, "Магазин не выбран — действие недоступно.", messages.WARNING) return tag_id = request.GET.get("tag") or request.POST.get("tag") if not tag_id: self.message_user(request, "Сначала выберите ярлык в фильтре справа.", messages.WARNING) return tag = ( Tag.objects .filter(pk=tag_id, products__store=store) .distinct() .first() ) if tag is None: self.message_user(request, "Ярлык не принадлежит активному магазину.", messages.ERROR) return affected = 0 with transaction.atomic(): for p in queryset.filter(store=store): p.tags.remove(tag) affected += 1 self.message_user(request, f"Ярлык «{tag.name}» снят: {affected} шт.", messages.SUCCESS) # Основное действие: пересчёт цен и выгрузка CSV def adjust_prices(self, request): ids = request.POST.getlist("_selected_action") store = getattr(request, "active_store", None) if store is None: self.message_user(request, "Магазин не выбран — операции недоступны.", messages.WARNING) return redirect("..") base_qs = ( WBProduct.objects.filter(store=store, pk__in=ids) if ids else WBProduct.objects.filter(store=store) ) qs = ( base_qs .filter(opt4_price__isnull=False, current_price__isnull=False) .select_related("opt4_price", "current_price") ) if not qs.exists(): self.message_user(request, "Нет подходящих товаров для расчёта.", messages.WARNING) return redirect("..") # Целевая прибыль задаётся в процентах от цены на WB. # Поле в шаблоне называется target_profit, поэтому читаем именно его. raw_pct = request.POST.get("target_profit", "").replace(",", ".") try: target_profit_pct = Decimal(raw_pct) if raw_pct else TARGET_PROFIT_PCT assert target_profit_pct >= 0 except (InvalidOperation, AssertionError): self.message_user(request, "Некорректный процент прибыли", messages.ERROR) return redirect("..") # Налог и НДС берём из настроек магазина tax_percent = Decimal(str(getattr(store, "tax_percent", 0) or 0)) vat_percent = Decimal(str(getattr(store, "vat_percent", 0) or 0)) tax_factor = tax_percent / Decimal("100") vat_factor = vat_percent / Decimal("100") buffer = StringIO() buffer.write("\ufeff") writer = csv.writer(buffer, delimiter=";") writer.writerow([ "Артикул 1С", "Артикул WB", "Цена без скидки", "Скидка %", "Цена со скидкой", "Прибыль ₽", "Прибыль %", "Ярлык", ]) updated = 0 for p in qs: try: try: commission_pct = Commission.objects.get(subject_id=p.subject_id).kgvp_marketplace except Commission.DoesNotExist: commission_pct = Decimal("0") purchase = Decimal(str(p.opt4_price.price)) logistics = Decimal(str(p.logistics_cost or 0)) commission_factor = Decimal(str(commission_pct)) / Decimal("100") target_factor = target_profit_pct / Decimal("100") # Формула: # Comm = P * commission_factor # VAT = P * vat_factor # Tax = P * tax_factor # Profit = P - C - L - Comm - VAT - Tax = P * target_factor # # => P * (1 - commission_factor - vat_factor - tax_factor - target_factor) = C + L # => P = (C + L) / (1 - commission_factor - vat_factor - tax_factor - target_factor) denominator = ( Decimal("1") - commission_factor - vat_factor - tax_factor - target_factor ) if denominator <= 0: raise ValueError("Сумма комиссии, НДС, налога и целевой прибыли ≥ 100 % — расчёт невозможен") numerator = purchase + logistics sell = (numerator / denominator).quantize(Decimal("0.01"), ROUND_HALF_UP) base = (sell / DISCOUNT_FACTOR).quantize(Decimal("1"), ROUND_UP) comm_value = sell * commission_factor vat_value = sell * vat_factor tax_value = sell * tax_factor profit_rub = sell - purchase - logistics - comm_value - vat_value - tax_value # Прибыль в % считаем от цены на WB, а не от закупки profit_pct = (profit_rub / sell * Decimal("100")).quantize(Decimal("0.01")) except Exception as exc: writer.writerow([ p.vendor_code, p.nm_id, "-", "-", "-", "ОШИБКА", f"ОШИБКА: {exc}", "", ]) continue with transaction.atomic(): WBPrice.objects.update_or_create( product=p, defaults={ "price": base, "discounted_price": sell, "discount": FIXED_DISCOUNT, }, ) updated += 1 # Собираем ярлыки товара в одну строку tag_names = list(p.tags.values_list("name", flat=True)) labels = ", ".join(tag_names) writer.writerow([ p.vendor_code, p.nm_id, self._fmt(base, 0), self._fmt(FIXED_DISCOUNT, 0), self._fmt(sell, 2), self._fmt(profit_rub, 2), self._fmt(profit_pct, 2), labels, ]) buffer.seek(0) filename = datetime.now().strftime("adjusted_%Y%m%d_%H%M%S.csv") response = HttpResponse( buffer.getvalue(), content_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) info = "всех" if not ids else "отмеченных" self.message_user(request, f"Пересчитано {info} товаров: {updated}", messages.SUCCESS) return response # ───────────────────── скрываем из меню всё лишнее ─────────────────────────── try: for _m in (WBLogistics, Stock): try: admin.site.unregister(_m) except admin.sites.NotRegistered: pass except Exception: pass # Мультимагазин: переопределения админки from . import admin_overrides