add: Перерасчет рейтинга перенесен в Django Management Command

This commit is contained in:
2026-05-13 00:50:12 +03:00
parent bfd785d78e
commit bcebc20943
3 changed files with 941 additions and 7 deletions

View File

@@ -12,6 +12,7 @@
1. `generate_sitemaps` — оффлайн генерация sitemap-файлов. 1. `generate_sitemaps` — оффлайн генерация sitemap-файлов.
2. `regenerate_seria_prerender` — оффлайн пересборка pre-render шаблонов для `catalog_seria_info`. 2. `regenerate_seria_prerender` — оффлайн пересборка pre-render шаблонов для `catalog_seria_info`.
3. `populate_seo_fields` — автозаполнение SEO-полей блога из существующих данных. 3. `populate_seo_fields` — автозаполнение SEO-полей блога из существующих данных.
4. `make_rating` — пересчёт рейтингов профилей и стеклопакетов методом Манна-Уитни.
## Общие правила запуска ## Общие правила запуска
@@ -225,6 +226,238 @@ print(f'Пусто sMetaKeywords: {posts.filter(sMetaKeywords=\"\").count()}')
-**Откат через SQL** — если нужно очистить, используй: `UPDATE oknardia_blogposts SET sSlug='', sMetaDescription='', sMetaKeywords='';` -**Откат через SQL** — если нужно очистить, используй: `UPDATE oknardia_blogposts SET sSlug='', sMetaDescription='', sMetaKeywords='';`
-**Всегда используй `--dry-run`** перед первым запуском для проверки. -**Всегда используй `--dry-run`** перед первым запуском для проверки.
## 4) Команда `make_rating`
Назначение:
- пересчитать рейтинги оконных профилей, стеклопакетов и наборов услуг используя метод Манна-Уитни.
- сохранить результаты в поля `fProfileRating`, `fGlazingRating`, `fSetRating` (0.0 … 5.0 звёзд).
- заполнить JSON-состав рейтинга в поля `sProfileDescription`, `sGlazingDescription`, `sSetDescription`.
### Базовый запуск
Пересчитать рейтинги всех профилей и стеклопакетов (стандартный режим):
```bash
cd /Users/e-serg/PRJ/2022-oknardia
poetry run python oknardia/manage.py make_rating
```
### Параметры запуска
**`--verbosity 0`** — минимум информации (только ошибки):
**`--verbosity 1`** — стандартная информация (по умолчанию):
**`--verbosity 3`** — очень подробный вывод (для отладки, для каждого профиля/стеклопакета таблица):
Пример использования с параметром `--verbosity`:
```bash
poetry run python oknardia/manage.py make_rating --verbosity 3 | head -500
```
### АЛГОРИТМ: Метод Манна-Уитни (Mann-Whitney U Step Rank)
Команда использует адаптированный вариант критерия Манна-Уитни для ранжирования параметров качества оконных
предложений и комопнентов (профилей, стеклопакетов, наборов услуг) на основе их технических характеристик
и популярности у поставщиков.
#### Как это работает:
1. **Сортировка объектов** по одному параметру (например, по теплопередаче):
- Профиль A: 0.60 Ro → ранг = 0.0
- Профиль B: 0.60 Ro → ранг = 0.0 (то же значение, ранг не меняется)
- Профиль C: 0.80 Ro → ранг = 1.0 (новое значение, добавляем вес параметра)
- Профиль D: 0.95 Ro → ранг = 2.0 (ещё новое значение)
2. **Направление ранжирования** определяется флагом `revers`:
- `revers=False`**БОЛЬШЕ = ЛУЧШЕ** (например, теплопередача, звукоизоляция)
- `revers=True`**МЕНЬШЕ = ЛУЧШЕ** (например, высота в проёме для прочности)
3. **Нормализация рангов** к диапазону 0.0 … 1.0:
- Профиль A: 0.0 / 2.0 = 0.0
- Профиль B: 0.0 / 2.0 = 0.0
- Профиль C: 1.0 / 2.0 = 0.5
- Профиль D: 2.0 / 2.0 = 1.0
4. **Суммирование рангов** по всем параметрам:
- TmpRating = Σ(ранг_параметра × вес_параметра)
5. **Преобразование в звёзды** (0.0 … 5.0):
- ТmpRating нормализуется к 0..1
- Умножается на 5.0 для получения финального рейтинга
#### Пример итогового рейтинга профиля:
```
Профиль "Brusbox Super Aero"
Теплопередача: 0.60 Ro (ранг 0.9, вес 1.0)
Звукоизоляция: 33 дБ (ранг 0.8, вес 1.0)
Высота в проёме: 112 мм (ранг 0.6, вес 0.3)
Количество камер: 6 шт (ранг 0.7, вес 0.1)
Итого: (0.9×1.0 + 0.8×1.0 + 0.6×0.3 + 0.7×0.1) / 2.3 ≈ 3.8 звёзд ⭐⭐⭐⭐
```
### ПРОФИЛИ: какие параметры учитываются
| № | Параметр | Поле БД | ЛУЧШЕ | Вес | Описание |
|---|----------------------|----------------------------|--------------------|-----|-----------------------------------------------------|
| 1 | Звукоизоляция | `fProfileSoundproofing` | БОЛЬШЕ дБ | 1.0 | Сопротивление шуму (дБ) |
| 2 | Теплопередача | `fProfileHeatTransf` | БОЛЬШЕ Ro | 1.0 | Сопротивление теплопередаче (м²×°C/Вт) |
| 3 | Высота в проёме | `iProfileHeight` | МЕНЬШЕ мм | 0.3 | Видимая высота в световом проёме (экономия) |
| 4 | Высота фальца | `iProfileRabbet` | БОЛЬШЕ мм | 0.2 | Глубина фальца для герметизации |
| 5 | Толщина стеклопакета | `iProfileGlazingThickness` | БОЛЬШЕ мм | 0.2 | Максимальная толщина стеклопакета |
| 6 | Толщина профиля | `iProfileThickness` | БОЛЬШЕ мм | 0.2 | Монтажная (боковая) ширина профиля |
| 7 | Контуры уплотнения | `fProfileSeals` | БОЛЬШЕ контуров | 1.2 | Количество контуров уплотнения |
| 8 | Количество камер | `iProfileCameras` | БОЛЬШЕ шт | 0.1 | Число камер в профиле (из рамки + створки) |
| 9 | Популярность | `NumOffer` | БОЛЬШЕ предложений | 0.1 | Используется ли профиль в коммерческих предложениях |
**Примеры интерпретации:**
- Профиль с рейтингом **5.0 ⭐⭐⭐⭐⭐**: отличная теплопередача + звукоизоляция + много камер + многоконтурные
уплотнения.
- Профиль с рейтингом **2.0 ⭐⭐**: среднее качество, слабые характеристики.
- Профиль с рейтингом **0.5 ⭐**: слабые характеристики или производить не предоставил данных и их нет в отрытых источниках.
### СТЕКЛОПАКЕТЫ: какие параметры учитываются
| № | Параметр | Поле БД | ЛУЧШЕ | Вес | Описание |
|---|-------------------|-----------------------------|--------------|------|----------------------------------------------------------------------------------------|
| 1 | Звукоизоляция | `fGlazingSoundproofing` | БОЛЬШЕ дБ | 1.0 | Звукоизоляционный коэффициент (дБ) |
| 2 | Теплопередача | `fGlazingHeatTransfer` | БОЛЬШЕ Ro | 1.0 | Сопротивление теплопередаче (м²×°C/Вт) |
| 3 | Светопропускание | `fGlazingLightTransmission` | БОЛЬШЕ % | 0.25 | Коэффициент пропускания видимого света (%), отражение света снаружи |
| 4 | Солнцепропускание | `fGlazingPassingSun` | **МЕНЬШЕ %** | 0.15 | Коэффициент солнечного излучения (SHGC) — В России меньше = лучше для охлаждения летом |
| 5 | Толщина | `iGlazingThickness` | БОЛЬШЕ мм | 0.1 | Общая толщина стеклопакета |
| 6 | Количество камер | `iGlazingCamerasN` | БОЛЬШЕ шт | 0.1 | Число воздушных/аргоновых камер |
**Особенности стеклопакетов:**
- **Светопропускание** = как много естественного света проходит в помещение (больше = лучше)
- **Солнцепропускание** = как много солнечного тепла/излучения проходит (в России: меньше = лучше, потому что внутри есть отражающее напыление)
- Двухкамерный (с аргоном) почти всегда лучше однокамерного
- Трёхкамерные = премиум для холодного климата
**Примеры интерпретации:**
- **5.0 ⭐⭐⭐⭐⭐**: трёхкамерный с хорошей теплопередачей, звукоизоляцией (обычно с аргоном и напылением).
- **3.0 ⭐⭐⭐**: двухкамерный, среднее качество
- **1.0 ⭐**: однокамерный старого образца или с плохими характеристиками
### Когда запускать
- **После первого развертывания** — заполнить рейтинги всех профилей и стеклопакетов.
- **После изменения каталога** (добавление нового профиля/стеклопакета).
- **После уточнения характеристик** (например, поставщик предоставил новые данные).
```bash
poetry run python oknardia/manage.py make_rating
```
- **По расписанию** (например, ежемесячно, чтобы пересчитать популярность):
```bash
30 2 * * 1 cd /Users/e-serg/PRJ/2022-oknardia && poetry run python oknardia/manage.py make_rating >> /var/log/oknardia-rating.log 2>&1
```
- **После обновления весов** в `settings.py` (константы `RANK_PVCP_*`, `RANK_GLAZ_*`).
### Откат и безопасность
- **Безопасна для повторного запуска** — пересчитывает все рейтинги заново.
- **Всегда обновляет только рейтинги** — другие данные в таблицах не меняются.
- **Откат через SQL** — если нужно вернуть старые значения (перед запуском рекомендуется бэкап):
```sql
-- Очистить рейтинги профилей
UPDATE oknardia_pvcprofiles SET fProfileRating = 0.0, sProfileDescription = '{}';
-- Очистить рейтинги стеклопакетов
UPDATE oknardia_glazing SET fGlazingRating = 0.0, sGlazingDescription = '{}';
```
### Примеры из реальных данных
Пример вывода `--verbosity 1`:
```
=== НАЧАЛИ ПЕРЕСЧЁТ РЕЙТИНГОВ ===
========================================
[ЭТАП 1]: Пересчёт рейтингов ПРОФИЛЕЙ...
========================================
✓ Обнулены рейтинги у 94 профилей
✓ Найдено 94 профилей для ранжирования
✓ Сохранено 94 профилей с финальными рейтингами
=============================================
[ЭТАП 2]: Пересчёт рейтингов СТЕКЛОПАКЕТОВ...
=============================================
✓ Обнулены рейтинги у 97 стеклопакетов
✓ Найдено 97 стеклопакетов для ранжирования
✓ Сохранено 97 стеклопакетов с финальными рейтингами
[OK!] ПЕРЕСЧЁТ РЕЙТИНГОВ ЗАВЕРШЁН УСПЕШНО!
• Обновлено профилей: 94
• Обновлено стеклопакетов: 97
```
Пример вывода `--verbosity 3` (наиболее подробный):
```
=== НАЧАЛИ ПЕРЕСЧЁТ РЕЙТИНГОВ ===
========================================
[ЭТАП 1]: Пересчёт рейтингов ПРОФИЛЕЙ...
========================================
✓ Обнулены рейтинги у 94 профилей
✓ Найдено 94 профилей для ранжирования
...
...
====================================================================================================
ПРОФИЛЬ: politech W80 (ID: 78)
====================================================================================================
Характеристика Значение Ранг (0..1) Вклад
----------------------------------------------------------------------------------------------------
Высота в проёме 120 мм 0.368 *
Популярность 0 предл. 0.000
Теплопередача 0.91 Ro 0.657 ***
Толщина профиля 80 мм 0.588 **
Толщина стеклопакета 42 мм 0.409 **
Уплотнители 3 контуров 1.000 *****
Фальц 14 мм 0.150
Число камер 12 шт 0.714 ***
Шумоизоляция 44.00 дБ 0.909 ****
----------------------------------------------------------------------------------------------------
ИТОГО: Рейтинг = 4.94/5.0 ****
...
...
✓ Сохранено 94 профилей с финальными рейтингами
=============================================
[ЭТАП 2]: Пересчёт рейтингов СТЕКЛОПАКЕТОВ...
=============================================
✓ Обнулены рейтинги у 97 стеклопакетов
✓ Найдено 97 стеклопакетов для ранжирования
...
...
====================================================================================================
СТЕКЛОПАКЕТ: Однокамерный 5-4, 25 мм (И+аргон) (ID: 60) | Марка:СПО 5М1-Ar16-И4
====================================================================================================
Характеристика Значение Ранг (0..1) Вклад
----------------------------------------------------------------------------------------------------
Камеры — 0.000
Светопропускание 74.00% 0.824 ****
Солнцепропускание 58.00% 0.450 **
Теплопередача 0.91 Ro 0.936 ****
Толщина 25 мм 0.400 **
Шумоизоляция — 0.429 **
----------------------------------------------------------------------------------------------------
ИТОГО: Рейтинг = 4.87/5.0 ****
...
...
✓ Сохранено 97 стеклопакетов с финальными рейтингами
[OK!] ПЕРЕСЧЁТ РЕЙТИНГОВ ЗАВЕРШЁН УСПЕШНО!
• Обновлено профилей: 94
• Обновлено стеклопакетов: 97
```
## Оркестрация и reload веб-сервера ## Оркестрация и reload веб-сервера
Важно: Важно:
@@ -276,9 +509,7 @@ poetry run python oknardia/manage.py check
- все тяжелые и административные операции переносить из HTTP в management-команды; - все тяжелые и административные операции переносить из HTTP в management-команды;
- `/service/*` оставлять только как thin UI/мониторинг или убрать полностью. - `/service/*` оставлять только как thin UI/мониторинг или убрать полностью.
Кандидаты на перенос:
- действия из `service.py` (`/service/make_rating`, sitemap/служебные задачи и т.п.);
- любые операции, которые могут идти дольше обычного web-request.
--- ---

View File

@@ -170,13 +170,13 @@ def get_rating_set_for_stars(rating: float = 0.) -> list:
# return distance # return distance
def normalize(val: float, val_max: int = 5, val_min: int = 0) -> float: def normalize(val: float, val_max: float = 5.0, val_min: float = 0.0) -> float:
""" Нормализация значения """ Нормализация значения
:param val: float -- значение которое надо нормализовать :param val: float -- значение которое надо нормализовать
:param val_max: int -- максимальное значение в нормализуемом диапазоне :param val_max: float -- максимальное значение в нормализуемом диапазоне
:param val_min: int -- минимальное значение в нормализуемом диапазоне :param val_min: float -- минимальное значение в нормализуемом диапазоне
:return: float: float -- нормализованное значение :return: float -- нормализованное значение
""" """
return float(val - val_min) / float(val_max - val_min) return float(val - val_min) / float(val_max - val_min)

View File

@@ -0,0 +1,703 @@
# -*- coding: utf-8 -*-
"""
Django management command: make_rating
Вычисляет (каждый раз заново) рейтинги оконных профилей и стеклопакетов.
Вычисление базируется на ренкинге методом Манна-Уитни (Mann-Whitney U test шаг).
В базовом ренкинге участвуют только профили и стеклопакеты, присутствующие
в коммерческих предложениях размещённых в ОКНАРДИИ.
АЛГОРИТМ РАНЖИРОВАНИЯ (Mann-Whitney U Step Rank):
====================================================
1. Для каждой характеристики (параметра):
- Сортируем список объектов по значению параметра (от МЕНЬШЕГО к БОЛЬШЕМУ)
- Начинаем с начального ранга = 0
- Проходим по отсортированному списку и увеличиваем ранг на вес характеристики
ТОЛьКО когда значение МЕНЯЕТСЯ на новое уникальное значение
2. Логика определения "улучшения":
- revers=False (ПО ВОЗРАСТАНИЮ):
* Начинаем с МИНИМУМА, повышаем ранг при УВЕЛИЧЕНИИ значения
* Используется для параметров где БОЛЬШЕ = ЛУЧШЕ
* Примеры: HeatTransfer (Ro), Soundproofing (дБ), LightTransmission (%)
- revers=True (ПО УБЫВАНИЮ):
* Начинаем с МАКСИМУМА, повышаем ранг при УМЕНЬШЕНИИ значения
* Используется для параметров где МЕНЬШЕ = ЛУЧШЕ
* Примеры: PassingSun (%), Height (для фальца)
3. После ранжирования по всем параметрам:
- Суммируем ранги по каждому объекту
- Нормализуем итоговый ранг: (значение - минимум) / максимум
- Переводим в финальный рейтинг: нормализованный * 5.0 звёзд
ДИАПАЗОНЫ РЕЙТИНГА:
===================
- ТемпоральныйРейтинг (TmpRating): от 0 до максимальной суммы всех рангов
- Нормализованный (RatingConsist): 0.0 … 1.0 (для каждого параметра)
- Финальный рейтинг профиля/стеклопакета (fProfileRating/fGlazingRating): 0.0 … 5.0 звёзд
ПАРАМЕТРЫ РАНЖИРОВАНИЯ И ИХ НАПРАВЛЕНИЯ:
==========================================
ПРОФИЛИ (PVCprofiles):
- fProfileSoundproofing (дБ) → БОЛЬШЕ дБ = ЛУЧШЕ звукоизоляция → revers=False
- fProfileHeatTransf (м²×°C/Вт) → БОЛЬШЕ сопротивление = ЛУЧШЕ теплоизоляция → revers=False
- iProfileHeight (см) → МЕНЬШЕ высота в проёме = ЛУЧШЕ (экономия) → revers=True
- iProfileRabbet (мм) → БОЛЬШЕ высота фальца = ЛУЧШЕ (надежнее) → revers=False
- iProfileGlazingThickness (мм) → БОЛЬШЕ толщина = ЛУЧШЕ → revers=False
- iProfileThickness (мм) → БОЛЬШЕ толщина профиля = ЛУЧШЕ → revers=False
- fProfileSeals (контуры) → БОЛЬШЕ контуров = ЛУЧШЕ уплотнение → revers=False
- iProfileCamerasN (камер) → БОЛЬШЕ камер = ЛУЧШЕ теплоизоляция → revers=False
- NumOffer (кол-во предложений) → БОЛЬШЕ предложений = популярнее → revers=False
СТЕКЛОПАКЕТЫ (Glazing):
- fGlazingHeatTransfer (м²×°C/Вт) → БОЛЬШЕ сопротивление = ЛУЧШЕ теплоизоляция → revers=False
- fGlazingSoundproofing (дБ) → БОЛЬШЕ дБ = ЛУЧШЕ звукоизоляция → revers=False
- fGlazingLightTransmission (%) → БОЛЬШЕ светопропускание = ЛУЧШЕ видимость → revers=False ⚠️ ИСПРАВЛЕНО
- fGlazingPassingSun (%) → МЕНЬШЕ солнцепропускания = ЛУЧШЕ (летнее охлаждение) → revers=True
- iGlazingThickness (мм) → БОЛЬШЕ толщина = ЛУЧШЕ → revers=False
- iGlazingCamerasN (камер) → БОЛЬШЕ камер = ЛУЧШЕ теплоизоляция → revers=False
ВАЖНО:
- sGlazingLightReflectance (строка "30/20") - НЕ РАНЖИРУЕТСЯ, это только информация
- В России отопление независимое, поэтому низкий SHGC (PassingSun) лучше для литнего охлаждения
ТЕХНИЧЕСКАЯ ИНФОРМАЦИЯ:
=======================
- Функция GetRank() реализует алгоритм Mann-Whitney U test
- Prepare_PVC_Dictionary() исправляет данные перед ранжированием
* Конвертирует iProfileCamerasN из строки (например "3/3") в число суммированием цифр
* Для деревянных окон (камер = 0) ставит 1000 (максимум, т.к. дерево лучше)
* Для профилей с высотой < 10см ставит 1000 (не валидные данные)
- Prepare_GLAZ_Dictionary() исправляет данные стеклопакетов
* Для светопропускания/солнцепропускания < 1% ставит 10000 (нет данных = минимум)
"""
from django.core.management.base import BaseCommand
import json
import time
from oknardia.models import PVCprofiles, Glazing
from oknardia.settings import (
RANK_PVCP_SOUNDPROOFING, RANK_PVCP_HEAT_TRANSFER,
RANK_PVCP_HEIGHT, RANK_PVCP_RABBET, RANK_PVCP_G_THICKNESS,
RANK_PVCP_THICKNESS, RANK_PVCP_SEALS, RANK_PVCP_CAMERAS_NUM,
RANK_PVCP_SOUNDPROOFING_NAME, RANK_PVCP_HEAT_TRANSFER_NAME,
RANK_PVCP_HEIGHT_NAME, RANK_PVCP_RABBET_NAME, RANK_PVCP_G_THICKNESS_NAME,
RANK_PVCP_THICKNESS_NAME, RANK_PVCP_SEALS_NAME, RANK_PVCP_CAMERAS_NUM_NAME,
RANK_PVCP_CAMERAS_POPULARITY_NAME,
RANK_STEP_SET_NUM_OFFER,
RANK_GLAZ_SOUNDPROOFING, RANK_GLAZ_HEAT_TRANSFER,
RANK_GLAZ_LIGHT_TRANSMISSION, RANK_GLAZ_PASSING_SUN,
RANK_GLAZ_THICKNESS, RANK_GLAZ_CAMERAS_NUM,
RARING_PVC_PROFILE_MIN, RARING_PVC_PROFILE_MAX,
RARING_GLAZING_MIN, RARING_GLAZING_MAX,
KEY_RATING, KEY_RATING_VIRTUAL
)
from web.add_func import sum_through, normalize
def get_rank(set_dictionary, key, key_weight: float = 1, rank_name="", revers=False):
"""
Реализует алгоритм ранжирования Манна-Уитни (Mann-Whitney U Step Rank).
Алгоритм:
1. Сортируем список по значению ключа (от МЕНЬШЕГО к БОЛЬШЕМУ)
2. Если revers=True, разворачиваем список (от БОЛЬШЕГО к МЕНЬШЕМУ)
3. Начинаем с начального значения (минимум или максимум)
4. Проходим по списку, ПОВЫШАЕМ ранг на key_weight когда значение МЕНЯЕТСЯ
Параметры:
- set_dictionary: список словарей для ранжирования
- key: название поля/ключа для ранжирования
- key_weight: вес этого параметра в итоговом рейтинге
- rank_name: подпись названия параметра (для логирования/хранения)
- revers: True если МЕНЬШЕ = ЛУЧШЕ, False если БОЛЬШЕ = ЛУЧШЕ
Возвращает:
- (словарь с ранжированными объектами, максимальный ранг для параметра)
"""
new_set_dictionary = sorted(set_dictionary, key=lambda item: float(item[key]))
rank = 0
# Выбираем стартовое значение
if revers:
# ПО УБЫВАНИЮ: начинаем с МАКСИМУМА, повышаем ранг при УМЕНЬШЕНИИ
low_value = new_set_dictionary[-1][key]
new_set_dictionary = new_set_dictionary[::-1] # разворачиваем список
else:
# ПО ВОЗРАСТАНИЮ: начинаем с МИНИМУМА, повышаем ранг при УВЕЛИЧЕНИИ
low_value = new_set_dictionary[0][key]
# Проходим по отсортированному списку и присваиваем ранги
for i in new_set_dictionary:
# Инициализируем поля если их ещё нет
if "RatingConsist" not in i:
i.update({"RatingConsist": {}})
if "TmpRating" not in i:
i.update({"TmpRating": 0})
# Логика повышения ранга: при УЛУЧШЕНИИ параметра
if revers:
# МЕНЬШЕ = ЛУЧШЕ → повышаем ранг когда значение ПАДАЕТ
if i[key] < low_value:
low_value = i[key]
rank += key_weight
else:
# БОЛЬШЕ = ЛУЧШЕ → повышаем ранг когда значение РАСТЕТ
if i[key] > low_value:
low_value = i[key]
rank += key_weight
# Аккумулируем ранг в общий TmpRating
i["TmpRating"] += rank
i["RatingConsist"].update({rank_name: rank})
# Нормализуем ранги для этого параметра (0.0 … 1.0)
for i in new_set_dictionary:
i["RatingConsist"].update({rank_name: round(normalize(i["RatingConsist"][rank_name], val_max=rank), 3)})
return new_set_dictionary, rank
def prepare_pvc_dictionary(profile_use_list):
"""
Конвертирует RawQuerySet в список словарей и исправляет данные для ранжирования.
Коррекции:
1. iProfileCameras: конвертирует строку "3/3" в число суммированием цифр
- Деревянные окна (число камер = 0) → ставим 1000 (максимум, т.к. дерево лучше)
2. iProfileHeight: для значений < 10 см → ставим 1000 (невалидные данные)
3. NumOffer: добавляем поле если его нет (количество коммерческих предложений)
:param profile_use_list: RawQuerySet с объектами профилей
:return: список словарей с исправленными данными
"""
pvc_dictionary = []
for profile in profile_use_list:
profile_dict = profile.__dict__.copy()
# Коррекция: количество камер из строки в число
if 'iProfileCameras' in profile_dict:
profile_dict['iProfileCameras'] = sum_through(str(profile_dict['iProfileCameras']))
# Деревянные окна (ноль камер) → ставим 1000 как максимум (они лучше для ранжирования)
if profile_dict['iProfileCameras'] == 0:
profile_dict['iProfileCameras'] = 1000
# Коррекция: невалидная высота в световом проёме
if 'iProfileHeight' in profile_dict and profile_dict['iProfileHeight'] < 10:
profile_dict['iProfileHeight'] = 1000
# Инициализация: количество коммерческих предложений
if 'NumOffer' not in profile_dict:
profile_dict['NumOffer'] = 0
pvc_dictionary.append(profile_dict)
return pvc_dictionary
def prepare_glaz_dictionary(glazing_use_list):
"""
Конвертирует RawQuerySet в список словарей и исправляет данные для ранжирования.
Коррекции:
1. fGlazingLightTransmission: если < 1% → ставим 10000 (нет данных о светопропускании)
2. fGlazingPassingSun: если < 1% → ставим 10000 (нет данных о солнцепропускании)
:param glazing_use_list: RawQuerySet с объектами стеклопакетов
:return: список словарей с исправленными данными
"""
glaz_dictionary = []
for glazing in glazing_use_list:
glazing_dict = glazing.__dict__.copy()
# Коррекция: светопропускание < 1% означает нет данных
if glazing_dict.get("fGlazingLightTransmission", 0) < 1:
glazing_dict["fGlazingLightTransmission"] = 10000
# Коррекция: солнцепропускание < 1% означает нет данных
if glazing_dict.get("fGlazingPassingSun", 0) < 1:
glazing_dict["fGlazingPassingSun"] = 10000
glaz_dictionary.append(glazing_dict)
return glaz_dictionary
def ranking_pvc(pvc_dictionary):
"""
Ранжирует профили по всем параметрам используя алгоритм Манна-Уитни.
Параметры ранжирования (в порядке применения):
1. Звукоизоляция (fProfileSoundproofing) - БОЛЬШЕ = ЛУЧШЕ
2. Сопротивление теплопередаче (fProfileHeatTransf) - БОЛЬШЕ = ЛУЧШЕ
3. Высота в световом проёме (iProfileHeight) - МЕНЬШЕ = ЛУЧШЕ
4. Высота фальца (iProfileRabbet) - БОЛЬШЕ = ЛУЧШЕ
5. Максимальная толщина стеклопакета (iProfileGlazingThickness) - БОЛЬШЕ = ЛУЧШЕ
6. Толщина профиля (iProfileThickness) - БОЛЬШЕ = ЛУЧШЕ
7. Контуры уплотнения (fProfileSeals) - БОЛЬШЕ = ЛУЧШЕ
8. Количество камер (iProfileCamerasN) - БОЛЬШЕ = ЛУЧШЕ
9. Популярность/кол-во предложений (NumOffer) - БОЛЬШЕ = ЛУЧШЕ
:param pvc_dictionary: список словарей с профилями
:return: (словарь со статистикой, ранжированный список словарей)
"""
dimension_to_template = {}
dimension_to_template.update({'NUM_PROFILE': len(pvc_dictionary)})
# 1. Звукоизоляция (дБ) - БОЛЬШЕ = ЛУЧШЕ → revers=False
pvc_dictionary, max_rank = get_rank(
pvc_dictionary,
"fProfileSoundproofing",
key_weight=RANK_PVCP_SOUNDPROOFING,
rank_name=RANK_PVCP_SOUNDPROOFING_NAME,
revers=False
)
# 2. Теплопередача (м²×°C/Вт) - БОЛЬШЕ = ЛУЧШЕ → revers=False
pvc_dictionary, max_rank = get_rank(
pvc_dictionary,
"fProfileHeatTransf",
key_weight=RANK_PVCP_HEAT_TRANSFER,
rank_name=RANK_PVCP_HEAT_TRANSFER_NAME,
revers=False
)
# 3. Высота в световом проёме (см) - МЕНЬШЕ = ЛУЧШЕ → revers=True
pvc_dictionary, max_rank = get_rank(
pvc_dictionary,
"iProfileHeight",
key_weight=RANK_PVCP_HEIGHT,
rank_name=RANK_PVCP_HEIGHT_NAME,
revers=True
)
# 4. Высота фальца (мм) - БОЛЬШЕ = ЛУЧШЕ → revers=False
pvc_dictionary, max_rank = get_rank(
pvc_dictionary,
"iProfileRabbet",
key_weight=RANK_PVCP_RABBET,
rank_name=RANK_PVCP_RABBET_NAME,
revers=False
)
# 5. Максимальная толщина стеклопакета (мм) - БОЛЬШЕ = ЛУЧШЕ → revers=False
pvc_dictionary, max_rank = get_rank(
pvc_dictionary,
"iProfileGlazingThickness",
key_weight=RANK_PVCP_G_THICKNESS,
rank_name=RANK_PVCP_G_THICKNESS_NAME,
revers=False
)
# 6. Монтажная ширина профиля (мм) - БОЛЬШЕ = ЛУЧШЕ → revers=False
pvc_dictionary, max_rank = get_rank(
pvc_dictionary,
"iProfileThickness",
key_weight=RANK_PVCP_THICKNESS,
rank_name=RANK_PVCP_THICKNESS_NAME,
revers=False
)
# 7. Контуры уплотнения - БОЛЬШЕ = ЛУЧШЕ → revers=False
pvc_dictionary, max_rank = get_rank(
pvc_dictionary,
"fProfileSeals",
key_weight=RANK_PVCP_SEALS,
rank_name=RANK_PVCP_SEALS_NAME,
revers=False
)
# 8. Количество камер - БОЛЬШЕ = ЛУЧШЕ → revers=False
pvc_dictionary, max_rank = get_rank(
pvc_dictionary,
"iProfileCameras",
key_weight=RANK_PVCP_CAMERAS_NUM,
rank_name=RANK_PVCP_CAMERAS_NUM_NAME,
revers=False
)
# 9. Популярность (кол-во коммерческих предложений) - БОЛЬШЕ = ЛУЧШЕ → revers=False
pvc_dictionary, max_rank = get_rank(
pvc_dictionary,
"NumOffer",
key_weight=RANK_STEP_SET_NUM_OFFER,
rank_name=RANK_PVCP_CAMERAS_POPULARITY_NAME,
revers=False
)
return dimension_to_template, pvc_dictionary
def ranking_glazing(glaz_dictionary):
"""
Ранжирует стеклопакеты по всем параметрам используя алгоритм Манна-Уитни.
Параметры ранжирования (в порядке применения):
1. Звукоизоляция (fGlazingSoundproofing) - БОЛЬШЕ = ЛУЧШЕ
2. Сопротивление теплопередаче (fGlazingHeatTransfer) - БОЛЬШЕ = ЛУЧШЕ
3. Светопропускание (fGlazingLightTransmission) - БОЛЬШЕ = ЛУЧШЕ ⚠️ ИСПРАВЛЕНО
4. Солнцепропускание (fGlazingPassingSun) - МЕНЬШЕ = ЛУЧШЕ (летнее охлаждение в РФ)
5. Толщина стеклопакета (iGlazingThickness) - БОЛЬШЕ = ЛУЧШЕ
6. Количество камер (iGlazingCamerasN) - БОЛЬШЕ = ЛУЧШЕ
ВАЖНО: sGlazingLightReflectance (строка "30/20") НЕ ранжируется - только информация!
:param glaz_dictionary: список словарей со стеклопакетами
:return: (словарь со статистикой, ранжированный список словарей)
"""
# ... existing code ...
# 1. Звукоизоляция (дБ) - БОЛЬШЕ = ЛУЧШЕ → revers=False
glaz_dictionary, max_rank = get_rank(
glaz_dictionary,
"fGlazingSoundproofing",
key_weight=RANK_GLAZ_SOUNDPROOFING,
rank_name=RANK_PVCP_SOUNDPROOFING_NAME,
revers=False
)
# 2. Теплопередача (м²×°C/Вт) - БОЛЬШЕ = ЛУЧШЕ → revers=False
glaz_dictionary, max_rank = get_rank(
glaz_dictionary,
"fGlazingHeatTransfer",
key_weight=RANK_GLAZ_HEAT_TRANSFER,
rank_name=RANK_PVCP_HEAT_TRANSFER_NAME,
revers=False
)
# 3. Светопропускание (%) - БОЛЬШЕ = ЛУЧШЕ → revers=False
# БОЛЬШЕ света в помещение = ЛУЧШЕ освещенность!
glaz_dictionary, max_rank = get_rank(
glaz_dictionary,
"fGlazingLightTransmission",
key_weight=RANK_GLAZ_LIGHT_TRANSMISSION,
rank_name="Светопропускание",
revers=False
)
# 4. Солнцепропускание (%) - МЕНЬШЕ = ЛУЧШЕ → revers=True
# В России отопление независимое, поэтому меньше солнечного нагрева = лучше для лета
# На внутреннем стекле напыление отражает тепло, не пуская его в квартиру
glaz_dictionary, max_rank = get_rank(
glaz_dictionary,
"fGlazingPassingSun",
key_weight=RANK_GLAZ_PASSING_SUN,
rank_name="Солнцепропускание",
revers=True
)
# 5. Толщина стеклопакета (мм) - БОЛЬШЕ = ЛУЧШЕ → revers=False
glaz_dictionary, max_rank = get_rank(
glaz_dictionary,
"iGlazingThickness",
key_weight=RANK_GLAZ_THICKNESS,
rank_name="Толщина",
revers=False
)
# 6. Количество камер - БОЛЬШЕ = ЛУЧШЕ → revers=False
glaz_dictionary, max_rank = get_rank(
glaz_dictionary,
"iGlazingCamerasN",
key_weight=RANK_GLAZ_CAMERAS_NUM,
rank_name="Камеры",
revers=False
)
# ПРОПУСКАЕМ: sGlazingLightReflectance это СТРОКА ("30/20"), не число!
# Не ранжируем, только информация об отражении света
return {}, glaz_dictionary
class Command(BaseCommand):
help = 'Пересчитывает рейтинги профилей и стеклопакетов используя Mann-Whitney метод'
def add_arguments(self, parser):
# Django уже добавляет --verbosity автоматически
# 0 = минимум, 1 = нормально, 2 = подробно, 3 = очень подробно
pass
def format_profile_table(self, profile_item, profile_obj):
"""Форматирует таблицу характеристик профиля для подробного вывода"""
output = []
output.append(f"\n {'='*100}")
output.append(f" ПРОФИЛЬ: {profile_obj.sProfileName} (ID: {profile_item['id']})")
output.append(f" {'='*100}")
# Основные параметры
if 'RatingConsist' in profile_item:
output.append(f" {'Характеристика':<30} {'Значение':<15} {'Ранг (0..1)':<15} {'Вклад':<15}")
output.append(f" {'-'*100}")
for param_name, rank_value in sorted(profile_item['RatingConsist'].items()):
# Получаем значение параметра из словаря
if param_name == RANK_PVCP_SOUNDPROOFING_NAME and 'fProfileSoundproofing' in profile_item:
value = f"{profile_item['fProfileSoundproofing']:.2f} дБ"
elif param_name == RANK_PVCP_HEAT_TRANSFER_NAME and 'fProfileHeatTransf' in profile_item:
value = f"{profile_item['fProfileHeatTransf']:.2f} Ro"
elif param_name == RANK_PVCP_HEIGHT_NAME and 'iProfileHeight' in profile_item:
value = f"{profile_item['iProfileHeight']} мм"
elif param_name == RANK_PVCP_RABBET_NAME and 'iProfileRabbet' in profile_item:
value = f"{profile_item['iProfileRabbet']} мм"
elif param_name == RANK_PVCP_G_THICKNESS_NAME and 'iProfileGlazingThickness' in profile_item:
value = f"{profile_item['iProfileGlazingThickness']} мм"
elif param_name == RANK_PVCP_THICKNESS_NAME and 'iProfileThickness' in profile_item:
value = f"{profile_item['iProfileThickness']} мм"
elif param_name == RANK_PVCP_SEALS_NAME and 'fProfileSeals' in profile_item:
value = f"{profile_item['fProfileSeals']} контуров"
elif param_name == RANK_PVCP_CAMERAS_NUM_NAME and 'iProfileCameras' in profile_item:
value = f"{profile_item['iProfileCameras']} шт"
elif param_name == RANK_PVCP_CAMERAS_POPULARITY_NAME and 'NumOffer' in profile_item:
value = f"{profile_item['NumOffer']} предл."
else:
value = ""
rank_str = f"{rank_value:.3f}" if isinstance(rank_value, float) else str(rank_value)
output.append(f" {param_name:<30} {value:<15} {rank_value:.3f} {'*' * int(float(rank_str)*5):<15}")
output.append(f" {'-'*100}")
output.append(f" ИТОГО: Рейтинг = {profile_obj.fProfileRating:.2f}/5.0 "
f"{'*' * int(profile_obj.fProfileRating)}")
return "\n".join(output)
def format_glazing_table(self, glazing_item, glazing_obj):
"""Форматирует таблицу характеристик стеклопакета для подробного вывода"""
output = []
output.append(f"\n {'='*100}")
output.append(f" СТЕКЛОПАКЕТ: {glazing_obj.sGlazingName} (ID: {glazing_item['id']}) | Марка:"
f"{glazing_obj.sGlazingMark}")
output.append(f" {'='*100}")
# Основные параметры
if 'RatingConsist' in glazing_item:
output.append(f" {'Характеристика':<35} {'Значение':<20} {'Ранг (0..1)':<15} {'Вклад':<15}")
output.append(f" {'-'*100}")
for param_name, rank_value in sorted(glazing_item['RatingConsist'].items()):
# Получаем значение параметра из словаря
if param_name == "Звукоизоляция" and 'fGlazingSoundproofing' in glazing_item:
value = f"{glazing_item['fGlazingSoundproofing']:.2f} дБ"
elif param_name == "Теплопередача" and 'fGlazingHeatTransfer' in glazing_item:
value = f"{glazing_item['fGlazingHeatTransfer']:.2f} Ro"
elif param_name == "Светопропускание" and 'fGlazingLightTransmission' in glazing_item:
value = f"{glazing_item['fGlazingLightTransmission']:.2f}%"
elif param_name == "Солнцепропускание" and 'fGlazingPassingSun' in glazing_item:
value = f"{glazing_item['fGlazingPassingSun']:.2f}%"
elif param_name == "Толщина" and 'iGlazingThickness' in glazing_item:
value = f"{glazing_item['iGlazingThickness']} мм"
elif param_name == "Число камер" and 'iGlazingCamerasN' in glazing_item:
value = f"{glazing_item['iGlazingCamerasN']} камер"
else:
value = ""
rank_str = f"{rank_value:.3f}" if isinstance(rank_value, float) else str(rank_value)
output.append(f" {param_name:<35} {value:<20} {rank_value:.3f} {'*' * int(float(rank_str)*5):<15}")
output.append(f" {'-'*100}")
output.append(f" ИТОГО: Рейтинг = {glazing_obj.fGlazingRating:.2f}/5.0 "
f"'*' * int(glazing_obj.fGlazingRating)}")
return "\n".join(output)
def handle(self, *args, **options):
verbose = int(options.get('verbosity', 1))
time_start = time.perf_counter()
self.stdout.write(self.style.SUCCESS('=== НАЧАЛИ ПЕРЕСЧЁТ РЕЙТИНГОВ ==='))
# ========== РАНЖИРОВАНИЕ ПРОФИЛЕЙ ==========
self.stdout.write('\n========================================')
self.stdout.write('[ЭТАП 1]: Пересчёт рейтингов ПРОФИЛЕЙ...')
self.stdout.write('========================================\n')
# Обнуляем все рейтинги профилей
profile_all_num = PVCprofiles.objects.all().update(fProfileRating=0.0)
self.stdout.write(f' ✓ Обнулены рейтинги у {profile_all_num} профилей')
# Извлекаем профили которые используются в коммерческих предложениях
q = PVCprofiles.objects.raw(
"SELECT oknardia_pvcprofiles.*, COUNT(oknardia_priceoffer.id) AS NumOffer "
"FROM oknardia_setkit "
"INNER JOIN oknardia_priceoffer "
" ON oknardia_setkit.id = oknardia_priceoffer.kOffer2SetKit_id "
"RIGHT OUTER JOIN oknardia_pvcprofiles "
" ON oknardia_setkit.kSet2PVCprofiles_id = oknardia_pvcprofiles.id "
"GROUP BY oknardia_pvcprofiles.id"
)
profile_use_list = list(q)
self.stdout.write(f' ✓ Найдено {len(profile_use_list)} профилей для ранжирования')
# Подготавливаем словарь профилей с коррекциями
pvc_dictionary = prepare_pvc_dictionary(profile_use_list)
# Ранжируем профили
dim, pvc_dictionary = ranking_pvc(pvc_dictionary)
# Сортируем по рейтингу
pvc_dictionary = sorted(pvc_dictionary, key=lambda item: item["TmpRating"])
# Нахождение минимального и максимального рейтинга
num_max_rank = 0
num_min_rank = -1
for j, i in enumerate(pvc_dictionary):
if i["NumOffer"] != 0:
num_max_rank = j
if num_min_rank == -1:
num_min_rank = j
# Сохраняем финальные рейтинги в БД
for j, i in enumerate(pvc_dictionary):
obj = PVCprofiles.objects.get(id=i["id"])
# Загружаем JSON описание профиля
try:
getted_json = json.loads(obj.sProfileDescription)
except:
getted_json = {}
# Обновляем JSON рейтингом
if i["NumOffer"] != 0:
try:
del getted_json[KEY_RATING_VIRTUAL]
except:
pass
getted_json[KEY_RATING] = i["RatingConsist"]
else:
try:
del getted_json[KEY_RATING]
except:
pass
getted_json[KEY_RATING_VIRTUAL] = i["RatingConsist"]
obj.sProfileDescription = json.dumps(
getted_json,
separators=(",", ":"),
sort_keys=True,
ensure_ascii=False
)
# Вычисляем финальный рейтинг (0.0 … 5.0 звёзд)
if j <= num_max_rank:
obj.fProfileRating = (
normalize(i["TmpRating"], pvc_dictionary[num_max_rank]["TmpRating"]) *
(RARING_PVC_PROFILE_MAX - RARING_PVC_PROFILE_MIN) +
RARING_PVC_PROFILE_MIN
)
else:
obj.fProfileRating = 5.0
obj.save()
# Подробный вывод в режиме verbosity >= 3
if verbose >= 3:
self.stdout.write(self.format_profile_table(i, obj))
self.stdout.write(f' ✓ Сохранено {len(pvc_dictionary)} профилей с финальными рейтингами')
# ========== РАНЖИРОВАНИЕ СТЕКЛОПАКЕТОВ ==========
self.stdout.write('\n=============================================')
self.stdout.write('[ЭТАП 2]: Пересчёт рейтингов СТЕКЛОПАКЕТОВ...')
self.stdout.write('=============================================\n')
# Обнуляем все рейтинги стеклопакетов
glazing_all_num = Glazing.objects.all().update(fGlazingRating=0.0)
self.stdout.write(f' ✓ Обнулены рейтинги у {glazing_all_num} стеклопакетов')
# Извлекаем стеклопакеты которые используются в коммерческих предложениях
q = Glazing.objects.raw(
"SELECT oknardia_glazing.*, COUNT(oknardia_priceoffer.id) AS NumOffer "
"FROM oknardia_setkit "
"INNER JOIN oknardia_priceoffer "
" ON oknardia_setkit.id = oknardia_priceoffer.kOffer2SetKit_id "
"RIGHT OUTER JOIN oknardia_glazing "
" ON oknardia_setkit.kSet2Glazing_id = oknardia_glazing.id "
"GROUP BY oknardia_glazing.id"
)
glazing_use_list = list(q)
self.stdout.write(f' ✓ Найдено {len(glazing_use_list)} стеклопакетов для ранжирования')
# Подготавливаем словарь стеклопакетов с коррекциями
glaz_dictionary = prepare_glaz_dictionary(glazing_use_list)
# Ранжируем стеклопакеты
dim, glaz_dictionary = ranking_glazing(glaz_dictionary)
# Сортируем по рейтингу
glaz_dictionary = sorted(glaz_dictionary, key=lambda item: item["TmpRating"])
# Нахождение минимального и максимального рейтинга
num_max_rank = 0
num_min_rank = -1
for j, i in enumerate(glaz_dictionary):
if i["NumOffer"] != 0:
num_max_rank = j
if num_min_rank == -1:
num_min_rank = j
# Сохраняем финальные рейтинги в БД
for j, i in enumerate(glaz_dictionary):
obj = Glazing.objects.get(id=i["id"])
# Загружаем JSON описание стеклопакета
try:
getted_json = json.loads(obj.sGlazingDescription)
except:
getted_json = {}
# Обновляем JSON рейтингом
if i["NumOffer"] != 0:
try:
del getted_json[KEY_RATING_VIRTUAL]
except:
pass
getted_json[KEY_RATING] = i["RatingConsist"]
else:
try:
del getted_json[KEY_RATING]
except:
pass
getted_json[KEY_RATING_VIRTUAL] = i["RatingConsist"]
obj.sGlazingDescription = json.dumps(
getted_json,
separators=(",", ":"),
sort_keys=True,
ensure_ascii=False
)
# Вычисляем финальный рейтинг (0.0 … 5.0 звёзд)
if j <= num_max_rank:
obj.fGlazingRating = (
normalize(i["TmpRating"], glaz_dictionary[num_max_rank]["TmpRating"]) *
(RARING_GLAZING_MAX - RARING_GLAZING_MIN) +
RARING_GLAZING_MIN
)
else:
obj.fGlazingRating = 5.0
obj.save()
# Подробный вывод в режиме verbosity >= 3
if verbose >= 3:
self.stdout.write(self.format_glazing_table(i, obj))
self.stdout.write(f' ✓ Сохранено {len(glaz_dictionary)} стеклопакетов с финальными рейтингами')
self.stdout.write(self.style.SUCCESS('\n[OK!] ПЕРЕСЧЁТ РЕЙТИНГОВ ЗАВЕРШЁН УСПЕШНО!'))
self.stdout.write(f' • Обновлено профилей: {len(pvc_dictionary)}')
self.stdout.write(f' • Обновлено стеклопакетов: {len(glaz_dictionary)}')