/
dstemnik
/
DSServices
Обзор
Документация
Войти
/
dstemnik
/
DSServices
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
test_app/views.py
1 080 строк
47 KB
Tem
first_commit
07 дек 2025, 18:06
07 дек 2025, 18:06
fa137ea
Код
Авторство
О чём код?
from django.views.generic import TemplateView, DetailView from django.contrib.auth.mixins import LoginRequiredMixin from datetime import datetime from .models import WBProduct, WBPrice, WBLogistics, Commission, Opt4Price, Stock, WBStore, WBDailySale from django.db import transaction from decimal import Decimal, InvalidOperation import csv, io, requests, time, math from django.db.models import OuterRef, Subquery, F, ExpressionWrapper, DecimalField from django.db.models import Max from django.contrib import messages from io import StringIO from django.utils.safestring import mark_safe from django.views.decorators.csrf import csrf_protect from django.utils.decorators import method_decorator from django.db.models.functions import Coalesce from django.utils.http import urlencode from django.http import HttpResponseRedirect, HttpResponse from collections import defaultdict import zipfile, json from .wb_utils import safe_request from .stock_services import StockCalculator from . import wb_services from .recommendations import calculate_store_recommendations, select_products_needing_attention # ---- active store helper ---- def _active_store(request): return getattr(request, "active_store", None) # ----------------------------- @method_decorator(csrf_protect, name='dispatch') class Opt4PriceUpdateView(TemplateView): template_name = "test_app/update_opt4.html" def post(self, request, *args, **kwargs): CSV_URL = 'https://opt-tut.ru/upload/opt4.csv' BATCH_SIZE = 500 context = self.get_context_data() stats = { 'total': 0, 'processed': 0, 'skipped_missing': 0, 'invalid_price': 0, 'deleted': 0, 'invalid_name': 0 # Добавляем счетчик для новых ошибок } try: with transaction.atomic(): store = _active_store(request) if store is None: messages.error(request, "Магазин не выбран — обновление opt4 недоступно.") return self.render_to_response(context) existing_vendor_codes = WBProduct.objects.get_existing_vendor_codes(store=store) response = requests.get(CSV_URL, timeout=30) response.encoding = 'utf-8-sig' response.raise_for_status() with StringIO(response.text) as csv_file: reader = csv.DictReader(csv_file, delimiter=';') # Проверяем наличие всех необходимых колонок required_columns = {'article', 'name', 'opt4'} if not required_columns.issubset(reader.fieldnames): missing = required_columns - set(reader.fieldnames) raise ValueError(f"Отсутствуют колонки: {', '.join(missing)}") current_vendor_codes = set() batch_to_create = [] for row in reader: stats['total'] += 1 vendor_code = row['article'].strip() if vendor_code not in existing_vendor_codes: stats['skipped_missing'] += 1 continue try: # Валидация имени name = row['name'].strip() if not name: raise ValueError("Пустое название") # Валидация цены raw_price = row['opt4'].replace(' ', '').replace(',', '.') price = round(Decimal(raw_price), 2) if price <= 0: raise ValueError("Некорректная цена") except (ValueError, KeyError, InvalidOperation) as e: stats['invalid_price' if 'opt4' in str(e) else 'invalid_name'] += 1 continue product = WBProduct.objects.filter(store=store, vendor_code=vendor_code).only("id").first() if not product: stats['skipped_missing'] += 1 continue batch_to_create.append( Opt4Price( product=product, name=name, price=price ) ) current_vendor_codes.add(vendor_code) if len(batch_to_create) >= BATCH_SIZE: self._process_batch(batch_to_create) stats['processed'] += len(batch_to_create) batch_to_create = [] if batch_to_create: self._process_batch(batch_to_create) stats['processed'] += len(batch_to_create) stats['deleted'] = ( Opt4Price.objects .filter(product__store=store) .exclude(product__vendor_code__in=current_vendor_codes) .delete()[0] ) messages.success(request, mark_safe( f"Обработано: {stats['processed']}<br>" f"Некорректных цен: {stats['invalid_price']}<br>" f"Пустых названий: {stats['invalid_name']}<br>" f"Пропущено артикулов: {stats['skipped_missing']}" )) except Exception as e: messages.error(request, f'Ошибка: {str(e)}') print("Ошибка при обновлении цен") return self.render_to_response(context) def _process_batch(self, batch): """Обработка пакета с новым полем""" Opt4Price.objects.bulk_create( batch, update_conflicts=True, update_fields=['name', 'price', 'updated_at'], unique_fields=['product'], # важ: имя поля модели, не product_id ) @method_decorator(csrf_protect, name='dispatch') class WBProductsView(LoginRequiredMixin, TemplateView): login_url = "login" template_name = "test_app/wb_products.html" def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) # Логистика для шапки ctx["logistics"] = WBLogistics.objects.filter( warehouse_name="Маркетплейс" ).order_by("-last_updated").first() # Аннотации для расчета прибыли latest_price = WBPrice.objects.filter( product=OuterRef("pk") ).order_by("-updated_at") store = _active_store(self.request) base_qs = WBProduct.objects.select_related("opt4_price", "stock_info") if store is None: messages.info(self.request, "Магазин не выбран — показаны карточки без привязки к магазину.") products = base_qs.none() else: products = base_qs.filter(store=store) products = products.annotate( product_name=Coalesce("opt4_price__name", "title"), latest_discount=Subquery(latest_price.values("discount")[:1]), latest_discounted_price=Subquery(latest_price.values("discounted_price")[:1]), commission=Subquery( Commission.objects .filter(subject_id=OuterRef("subject_id")) .values("kgvp_marketplace")[:1] ), purchase_price=F("opt4_price__price"), profit_amount=ExpressionWrapper( F("latest_discounted_price") - (F("latest_discounted_price") * F("commission") / Decimal("100")) - F("logistics_cost") - F("purchase_price"), output_field=DecimalField(max_digits=12, decimal_places=2), ), profit_percentage=ExpressionWrapper( (F("profit_amount") / F("purchase_price")) * Decimal("100"), output_field=DecimalField(max_digits=7, decimal_places=2), ), ).order_by("-updated_at") # Добавляем информацию об остатках for product in products: if hasattr(product, 'stock_info'): product.stock_main = product.stock_info.stock_main product.stock_wb = product.stock_info.stock_wb else: # Если по какой-то причине остатки не рассчитаны product.stock_main = 0 product.stock_wb = 0 ctx["products"] = products return ctx @method_decorator(csrf_protect, name='dispatch') class WBProductDetailView(LoginRequiredMixin, DetailView): """Детальная страница одного товара WB с теми же аннотациями, что и в списке.""" model = WBProduct template_name = "test_app/wb_product_detail.html" context_object_name = "product" login_url = "login" def get_queryset(self): # Берём ту же логику аннотаций, что и в WBProductsView latest_price = WBPrice.objects.filter( product=OuterRef("pk") ).order_by("-updated_at") store = _active_store(self.request) base_qs = WBProduct.objects.select_related("opt4_price", "stock_info") if store is None: messages.info(self.request, "Магазин не выбран — карточка товара недоступна.") return base_qs.none() else: products = base_qs.filter(store=store) products = products.annotate( product_name=Coalesce("opt4_price__name", "title"), latest_discount=Subquery(latest_price.values("discount")[:1]), latest_discounted_price=Subquery(latest_price.values("discounted_price")[:1]), commission=Subquery( Commission.objects .filter(subject_id=OuterRef("subject_id")) .values("kgvp_marketplace")[:1] ), purchase_price=F("opt4_price__price"), profit_amount=ExpressionWrapper( F("latest_discounted_price") - (F("latest_discounted_price") * F("commission") / Decimal("100")) - F("logistics_cost") - F("purchase_price"), output_field=DecimalField(max_digits=12, decimal_places=2), ), profit_percentage=ExpressionWrapper( (F("profit_amount") / F("purchase_price")) * Decimal("100"), output_field=DecimalField(max_digits=7, decimal_places=2), ), ) return products def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) product = ctx.get("product") # Логистика для шапки — как на общей странице ctx["logistics"] = WBLogistics.objects.filter( warehouse_name="Маркетплейс" ).order_by("-last_updated").first() # Остатки, как в списке if hasattr(product, 'stock_info'): product.stock_main = product.stock_info.stock_main product.stock_wb = product.stock_info.stock_wb else: product.stock_main = 0 product.stock_wb = 0 # Ежедневные продажи для графиков ctx["daily_sales"] = WBDailySale.objects.filter(product=product).order_by("date") return ctx @method_decorator(csrf_protect, name="dispatch") class WBRecommendationsView(LoginRequiredMixin, TemplateView): """ Отдельная страница с рекомендациями по поставкам и закупкам для всех товаров активного магазина. """ login_url = "login" template_name = "test_app/wb_recommendations.html" def get(self, request, *args, **kwargs): """ GET: - обычный HTML (по умолчанию), - либо CSV, если ?format=csv. """ ctx = self.get_context_data(**kwargs) # Выгрузка CSV if request.GET.get("format") == "csv": filename = f"recommendations_{ctx.get('period_days', 30)}d.csv" response = HttpResponse( content_type="text/csv; charset=utf-8" ) response["Content-Disposition"] = f'attachment; filename="{filename}"' # BOM, чтобы Excel понял, что файл в UTF-8 response.write("\ufeff") writer = csv.writer(response, delimiter=';') # Заголовки колонок – те же, что и в таблице writer.writerow([ "Товар", "Артикул WB", "Артикул продавца", "Средние заказы / день", "Остаток FBO", "Остаток FBS", "Дней до нуля (общий)", "Дней до нуля FBO", "Целевой запас WB", "Отправить на WB", "Целевой общий запас", "Докупить на свой склад", ]) def fmt_decimal(val): """ Преобразует Decimal/float в строку в русском формате: 14.11 -> '14,11', чтобы Excel не превращал это в дату. """ if val is None or val == "": return "" s = str(val).strip() # Не трогаем экспоненциальный формат if "." in s and "e" not in s and "E" not in s: s = s.replace(".", ",") return s for rec in ctx.get("recommendations", []): product = rec.get("product") opt4 = getattr(product, "opt4_price", None) if product else None name = (getattr(opt4, "name", None) or getattr(product, "title", "") if product else "") writer.writerow([ name, getattr(product, "nm_id", "") if product else "", getattr(product, "vendor_code", "") if product else "", fmt_decimal(rec.get("avg_daily_sales") or 0), fmt_decimal(rec.get("current_stock_wb") or 0), fmt_decimal(rec.get("current_stock_main") or 0), fmt_decimal(rec.get("days_to_zero_stock") or ""), fmt_decimal(rec.get("days_to_zero_stock_fbo") or ""), fmt_decimal(rec.get("target_stock_wb") or 0), fmt_decimal(rec.get("recommend_send_to_wb") or 0), fmt_decimal(rec.get("target_total_stock") or 0), fmt_decimal(rec.get("recommend_purchase_to_main") or 0), ]) return response # Обычный HTML return self.render_to_response(ctx) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) store = _active_store(self.request) ctx["store"] = store # Определяем период анализа / горизонта планирования по GET-параметру ?period= # Допустимые значения: 7, 14, 30 (по умолчанию 30). raw_period = self.request.GET.get("period") default_period = 30 allowed_periods = {7, 14, 30} try: period_days = int(raw_period) if raw_period is not None else default_period except (TypeError, ValueError): period_days = default_period if period_days not in allowed_periods: period_days = default_period ctx["period_days"] = period_days # Для совместимости с существующим шаблоном дублируем в старые ключи ctx["sales_window_days"] = period_days ctx["supply_horizon_days"] = period_days if store is None: messages.info(self.request, "Магазин не выбран — рекомендации не рассчитаны.") ctx["recommendations"] = [] return ctx # Берём те же товары, что и в списке: с ценами и остатками products = WBProduct.objects.select_related("opt4_price", "stock_info").filter(store=store) # Считаем рекомендации по всем товарам с единым периодом period_days rec_map = calculate_store_recommendations( products=products, period_days=ctx["period_days"], ) rec_list = list(rec_map.values()) # -------- Сортировка -------- # sort – по какому полю; dir – asc/desc sort_field = self.request.GET.get("sort") or "product_name" sort_dir = self.request.GET.get("dir") or "asc" ctx["sort_field"] = sort_field ctx["sort_dir"] = sort_dir # Карта поддерживаемых полей сортировки allowed_sort_fields = { "name": "product_name", "product_name": "product_name", "nm_id": "nm_id", "vendor_code": "vendor_code", "avg_daily_sales": "avg_daily_sales", "stock_wb": "current_stock_wb", "stock_fbs": "current_stock_main", "days_to_zero": "days_to_zero_stock", "days_to_zero_fbo": "days_to_zero_stock_fbo", "target_stock_wb": "target_stock_wb", "send_to_wb": "recommend_send_to_wb", "target_total_stock": "target_total_stock", "purchase_to_main": "recommend_purchase_to_main", } key = allowed_sort_fields.get(sort_field) if key: numeric_fields = { "avg_daily_sales", "current_stock_wb", "current_stock_main", "days_to_zero_stock", "days_to_zero_stock_fbo", "target_stock_wb", "recommend_send_to_wb", "target_total_stock", "recommend_purchase_to_main", } def extract_value(rec): # Имя товара – как в списке товаров if key == "product_name": product = rec.get("product") if not product: return (1, "") opt4 = getattr(product, "opt4_price", None) name = getattr(opt4, "name", None) or getattr(product, "title", "") or "" return (0, name.lower()) # Поля WBProduct (nm_id, vendor_code) if key in ("nm_id", "vendor_code"): product = rec.get("product") val = getattr(product, key, "") if product else "" return (0, str(val)) # Числовые поля из словаря rec if key in numeric_fields: val = rec.get(key) if val is None: return (1, 0.0) try: return (0, float(val)) except (TypeError, ValueError): return (0, 0.0) # Прочие строковые поля val = rec.get(key) if val is None: return (1, "") return (0, str(val)) reverse = (sort_dir == "desc") rec_list.sort(key=extract_value, reverse=reverse) # Передаём в шаблон уже отсортированный список рекомендаций ctx["recommendations"] = rec_list return ctx @method_decorator(csrf_protect, name='dispatch') class WBUpdateProductsView(TemplateView): template_name = "test_app/wb_products_update.html" def get(self, request, *args, **kwargs): context = self.get_context_data(**kwargs) return self.render_to_response(context) def post(self, request, *args, **kwargs): context = self.get_context_data(**kwargs) return self.render_to_response(context) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) if self.request.method == 'POST': context.update(self._fetch_products()) return context def _fetch_products(self): result = {'total_processed': 0, 'products': []} store = _active_store(self.request) if store is None: return { 'total_processed': 0, 'products': [], 'error': {'code': 400, 'message': 'Магазин не выбран — операция недоступна'} } try: all_products = [] cursor = None has_more = True limit = 100 # Максимальный размер страницы по API while has_more: # Формируем тело запроса с курсором json_data = { "settings": { "cursor": {"limit": limit}, "filter": {"withPhoto": -1} } } # Добавляем курсор для пагинации if cursor: json_data["settings"]["cursor"].update(cursor) response = requests.post( "https://content-api.wildberries.ru/content/v2/get/cards/list", headers={ "Authorization": store.api_token_content, "Content-Type": "application/json", }, json=json_data, timeout=30, ) if response.status_code == 200: response_data = response.json() products = response_data.get('cards', []) all_products.extend(products) # Получаем курсор для следующей страницы new_cursor = response_data.get('cursor') if new_cursor: cursor = { "updatedAt": new_cursor.get("updatedAt"), "nmID": new_cursor.get("nmID") } # Проверяем нужно ли продолжать пагинацию has_more = len(products) >= limit else: has_more = False else: result['error'] = { 'code': response.status_code, 'message': self._get_error_message(response), 'details': response.text[:300] + '...' } break if not result.get('error'): # Сохраняем все полученные товары saved_count = self.save_products(all_products, store) result['total_processed'] = saved_count result['total_received'] = len(all_products) except Exception as e: result['error'] = { 'code': 500, 'message': "Внутренняя ошибка сервера", 'details': str(e) } return result def save_products(self, products, store): saved_count = 0 for product in products: try: nm_id = product.get('nmID') title = product.get('title') vendor_code = product.get('vendorCode') subject_id = product.get('subjectID') subject_name = product.get('subjectName') dimensions = product.get('dimensions', {}) # Получаем список SKU из sizes → skus sku_list = [] for size in product.get('sizes', []): # product['sizes'] — это список словарей с полем 'skus' for s in size.get('skus', []): try: sku_list.append(int(s)) except (ValueError, TypeError): # если не число — игнорируем continue # Оставляем только уникальные значения sku_list = list(set(sku_list)) if nm_id and title and vendor_code: WBProduct.objects.update_or_create( nm_id=nm_id, defaults={ 'store': store, 'title': title.strip(), 'vendor_code': str(vendor_code).strip(), 'length': dimensions.get('length', 0), 'width': dimensions.get('width', 0), 'height': dimensions.get('height', 0), 'weight_brutto': dimensions.get('weightBrutto', 0.0), 'subject_id': int(subject_id or 0), 'subject_name': str(subject_name).strip(), 'skus': sku_list, } ) saved_count += 1 except Exception as e: print(f"Error saving product {nm_id}: {e}") return saved_count def _get_error_message(self, response): error_messages = { 400: "Некорректный запрос к API Wildberries", 401: "Ошибка авторизации: недействительный API-ключ", 403: "Доступ запрещен", 429: "Превышен лимит запросов", 500: "Ошибка сервера Wildberries" } return error_messages.get(response.status_code, "Неизвестная ошибка") @method_decorator(csrf_protect, name='dispatch') class WBPriceView(TemplateView): """ • GET → просто выводим товары и статистику из базы • POST → запускаем wb_services.sync_prices(), затем показываем обновлённые данные """ template_name = "wb_prices.html" # путь тот же, что был в проекте # ────────────────────────────────────────────────────────────────────────── # ВСПОМОГАТЕЛЬНОЕ: формируем блок статистики, который ждёт шаблон # ────────────────────────────────────────────────────────────────────────── def _build_sync_info(self, saved: int = 0, store=None) -> dict: qs = WBPrice.objects.filter(product__store=store) if store else WBPrice.objects.none() return { "total": qs.count(), "saved": saved, "last_synced": qs.aggregate(ts=Max("updated_at"))["ts"], } # ────────────────────────────────────────────────────────────────────────── # GET – ничего не дергаем из WB-API, берём данные ТОЛЬКО из базы # ────────────────────────────────────────────────────────────────────────── def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) store = _active_store(self.request) base = WBProduct.objects.select_related("current_price").order_by("nm_id") ctx["products"] = base.filter(store=store) if store else base.none() if store is None: messages.info(self.request, "Магазин не выбран — цены не показаны.") ctx["sync_info"] = self._build_sync_info(store=store) return ctx # ────────────────────────────────────────────────────────────────────────── # POST – по кнопке «Синхронизировать» запускаем сервис sync_prices() # ────────────────────────────────────────────────────────────────────────── def post(self, request, *args, **kwargs): saved = wb_services.sync_prices(store=_active_store(request)) # пагинация и retry внутри ctx = self.get_context_data() ctx["sync_info"] = self._build_sync_info(saved=saved, store=_active_store(request)) return self.render_to_response(ctx) def _parse_price(self, price_value): try: if isinstance(price_value, str): cleaned = price_value.replace(' ', '').replace(',', '.') return Decimal(cleaned) return Decimal(str(price_value)) except: return Decimal('0.00') def _format_error(self, response): return { 'code': response.status_code, 'message': response.text[:300] + '...' } def _format_exception(self, e): return { 'code': 500, 'message': 'Ошибка сервера при обновлении цен', 'details': str(e) } @method_decorator(csrf_protect, name='dispatch') class WBLogistView(TemplateView): template_name = "test_app/wb_logistics.html" TARGET_WAREHOUSE = "Маркетплейс" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) selected_date = self.request.GET.get('date') or datetime.now().strftime("%Y-%m-%d") context['date'] = selected_date store = _active_store(self.request) if store is None: context['calc_errors'] = ["Магазин не выбран — расчет логистики не выполнен."] return context # Получение и сохранение данных склада try: response = requests.get( "https://common-api.wildberries.ru/api/v1/tariffs/box", headers={ "Authorization": store.api_token_content, }, params={"date": selected_date}, timeout=10 ) if response.status_code != 200: context['error'] = self._format_error(response) return context data = response.json().get('response', {}).get('data', {}) saved_data = self._save_marketplace_data(data.get("warehouseList", [])) context['warehouse'] = self._prepare_warehouse_context(saved_data) context['last_updated'] = getattr(saved_data, 'last_updated', None) except Exception as e: context['error'] = {'message': 'Ошибка при загрузке данных склада', 'details': str(e)} return context # Расчет logistics_cost на Python (целые числа) calc_errors = [] debug_info = [] updated_count = 0 if saved_data: products = list(WBProduct.objects.filter(store=store)) for p in products: try: base = int(saved_data.box_delivery_base) liter = int(saved_data.box_delivery_liter) # объем в литрах = см³/1000, оставляем дробь только для расчёта volume = (p.length * p.width * p.height) / 1000.0 if volume > 1: # вычисляем количество целых литров сверх первого extra = int(volume - 1) cost = base + extra * liter else: cost = base # сохраняем как целое p.logistics_cost = cost if len(debug_info) < 10: debug_info.append({ 'vendor_code': p.vendor_code, 'volume': round(volume, 3), 'cost': cost }) except Exception as ex: calc_errors.append(f"{p.vendor_code}: {ex}") # Сохраняем изменения одним bulk_update try: with transaction.atomic(): WBProduct.objects.bulk_update(products, ['logistics_cost']) updated_count = len(products) except Exception as ex: context['bulk_update_error'] = str(ex) context['updated_count'] = updated_count context['calc_errors'] = calc_errors context['calc_debug'] = debug_info return context def _save_marketplace_data(self, warehouses): for wh in warehouses: if wh.get('warehouseName') == self.TARGET_WAREHOUSE: try: base = self._convert_to_float(wh.get('boxDeliveryBase')) liter = self._convert_to_float(wh.get('boxDeliveryLiter')) obj, _ = WBLogistics.objects.update_or_create( warehouse_name=self.TARGET_WAREHOUSE, defaults={'box_delivery_base': base, 'box_delivery_liter': liter} ) return obj except Exception: return None return None def _convert_to_float(self, value): try: return float(str(value).replace(',', '.').strip()) except: return 0.0 def _prepare_warehouse_context(self, saved_data): if not saved_data: return None return { "warehouseName": saved_data.warehouse_name, "boxDeliveryBase": saved_data.box_delivery_base, "boxDeliveryLiter": saved_data.box_delivery_liter } def _format_error(self, response): return { 'code': response.status_code, 'message': response.text[:200] } @method_decorator(csrf_protect, name='dispatch') class WBUpdateCommissionsView(TemplateView): template_name = "test_app/wb_commissions_update.html" def get(self, request, *args, **kwargs): context = self.get_context_data(**kwargs) return self.render_to_response(context) def post(self, request, *args, **kwargs): context = self.get_context_data(**kwargs) return self.render_to_response(context) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) if self.request.method == 'POST': context.update(self._fetch_commissions()) return context def _fetch_commissions(self): result = {'total_processed': 0, 'commissions': []} store = _active_store(self.request) if store is None: result['error'] = { 'code': 400, 'message': 'Магазин не выбран — обновление комиссий недоступно', } return result try: response = requests.get( "https://common-api.wildberries.ru/api/v1/tariffs/commission", headers={ "Authorization": store.api_token_content, }, timeout=30, ) if response.status_code == 200: data = response.json() commissions = data.get('report', []) saved_count = self.save_commissions(commissions) result['total_processed'] = saved_count result['commissions'] = commissions else: result['error'] = { 'code': response.status_code, 'message': self._get_error_message(response), 'details': response.text[:300] + '...', } except Exception as e: result['error'] = { 'code': 500, 'message': "Внутренняя ошибка сервера", 'details': str(e), } return result def save_commissions(self, commissions): saved_count = 0 for commission_data in commissions: try: # Логируем сырые данные для отладки print("Raw commission data:", commission_data) # <- Добавьте это subject_id = commission_data.get('subjectID') parent_id = commission_data.get('parentID', 0) parent_name = commission_data.get('parentName', '') # Проверяем обязательные поля if not subject_id: print("Skipping commission: missing subjectID") continue Commission.objects.update_or_create( subject_id=subject_id, defaults={ 'subject_name': commission_data.get('subjectName', ''), 'parent_id': parent_id, 'parent_name': parent_name.strip(), 'kgvp_marketplace': commission_data.get('kgvpMarketplace', 0.0) } ) saved_count += 1 except Exception as e: print(f"Error saving commission {subject_id}: {str(e)}") import traceback traceback.print_exc() # <- Вывод полной трассировки ошибки return saved_count def _get_error_message(self, response): """Возвращает понятное сообщение об ошибке""" error_messages = { 400: "Некорректный запрос. Проверьте параметр даты", 401: "Ошибка авторизации: недействительный API-ключ", 403: "Доступ к логистическим данным запрещен", 404: "Данные за указанную дату не найдены", 429: "Превышен лимит запросов", 500: "Ошибка сервера Wildberries" } return error_messages.get(response.status_code, "Неизвестная ошибка") @method_decorator(csrf_protect, name='dispatch') class WarehouseRemainsReportView(TemplateView): template_name = "test_app/warehouse_remains.html" BASE = "https://seller-analytics-api.wildberries.ru/api/v1/warehouse_remains" CREATE_URL = BASE + "?groupBySa=true" # разбивка по артикулам STATUS_URL = BASE + "/tasks/{task_id}/status" DOWNLOAD_URL = BASE + "/tasks/{task_id}/download" def _headers(self, request): store = _active_store(request) if store is None: raise ValueError("Магазин не выбран — отчёт по остаткам недоступен") return {"Authorization": store.api_token_content, "Accept": "application/json"} # ───────── POST: создать отчёт ───────── def post(self, request, *args, **kwargs): resp = safe_request("get", self.CREATE_URL, headers=self._headers(request), timeout=30) task_id = resp.json()["data"]["taskId"] self._flash(request, f"Отчёт создан. task_id = {task_id}") qs = urlencode({"task_id": task_id}) return HttpResponseRedirect(f"{request.path}?{qs}") # ───────── GET ───────── def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) tid = self.request.GET.get("task_id") dwn = self.request.GET.get("download") if tid: ctx["task_id"] = tid if dwn == "1": res = self._download_and_prepare(tid) ctx["msg" if isinstance(res, str) else "rows"] = res return ctx # ───────── helpers ───────── def _status(self, tid: str) -> str: r = safe_request("get", self.STATUS_URL.format(task_id=tid), headers=self._headers(self.request), timeout=15) return r.json()["data"]["status"].upper() def _download_and_prepare(self, tid: str): if self._status(tid) != "DONE": return "Отчёт ещё формируется… Обновите страницу позже." r = safe_request("get", self.DOWNLOAD_URL.format(task_id=tid), headers=self._headers(self.request), timeout=60) if r.status_code == 202: return "Файл ещё формируется (HTTP 202). Обновите позже." r.raise_for_status() ctype = r.headers.get("Content-Type", "") raw = r.content # ─── JSON (основной формат) ─── if "json" in ctype or raw.startswith(b"["): return self._aggregate_json(json.loads(raw.decode())) # ─── ZIP → CSV ─── if "zip" in ctype: with zipfile.ZipFile(io.BytesIO(raw)) as zf: raw = zf.read(zf.namelist()[0]) ctype = "text/csv" # ─── CSV ─── if "csv" in ctype: text = raw.decode("utf-8-sig", errors="ignore") rows = list(csv.DictReader(io.StringIO(text))) return self._aggregate_csv(rows) if rows else "CSV пуст." return f"Неизвестный Content-Type: {ctype}" # ───────── JSON-агрегация ───────── def _aggregate_json(self, data): map_wh = { "В пути до получателей": "in_way_to", "В пути возвраты на склад WB": "in_way_from", "Всего находится на складах": "quantity_full", } rows = [] for item in data: art = item.get("vendorCode") if not art: continue obj = {v: 0 for v in map_wh.values()} for wh in item.get("warehouses", []): key = map_wh.get(wh["warehouseName"]) if key: obj[key] = wh["quantity"] rows.append({ "vendor_code": art, **obj, "price": self._opt4_price(art), }) return rows or "JSON пуст: нет строк с vendorCode." # ───────── CSV-агрегация ───────── def _aggregate_csv(self, rows): agg = defaultdict(lambda: {"in_way_to": 0, "in_way_from": 0, "quantity_full": 0}) for r in rows: art = r.get("vendorCode") or r.get("supplierArticle") if not art: continue agg[art]["in_way_to"] += int(r.get("inWayToClient", 0)) agg[art]["in_way_from"] += int(r.get("inWayFromClient", 0)) agg[art]["quantity_full"] += int(r.get("quantityFull", r.get("quantity", 0))) if not agg: return "CSV без артикулов." return [ {"vendor_code": art, **vals, "price": self._opt4_price(art)} for art, vals in agg.items() ] # ───────── цена Opt4 ───────── def _opt4_price(self, code): """ Возвращает opt4-цену по vendor_code в рамках активного магазина. """ store = _active_store(self.request) qs = Opt4Price.objects.filter(product__vendor_code=code) if store is not None: qs = qs.filter(product__store=store) return qs.values_list("price", flat=True).first() or "—" # ───────── flash helper ───────── @staticmethod def _flash(request, msg, lvl=messages.INFO): messages.add_message(request, lvl, msg) @method_decorator(csrf_protect, name='dispatch') class UpdateStocksView(TemplateView): template_name = "test_app/update_stocks.html" def post(self, request, *args, **kwargs): calculator = StockCalculator() updated_count = calculator.calculate_and_save_stocks(store=_active_store(request)) messages.success( request, f"Остатки обновлены! Обработано товаров: {updated_count}" ) return self.render_to_response(self.get_context_data()) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) store = _active_store(self.request) qs = Stock.objects.select_related('product') context['stocks'] = qs.filter(product__store=store)[:50] if store else qs.none() if store is None: messages.info(self.request, "Магазин не выбран — остатки не показаны.") return context