diff --git a/MANAGEMENT_RUNBOOK.md b/MANAGEMENT_RUNBOOK.md index ab1308b..1cb67da 100644 --- a/MANAGEMENT_RUNBOOK.md +++ b/MANAGEMENT_RUNBOOK.md @@ -9,10 +9,11 @@ ## Каталог команд -1. `generate_sitemaps` — оффлайн генерация sitemap-файлов. -2. `regenerate_seria_prerender` — оффлайн пересборка pre-render шаблонов для `catalog_seria_info`. -3. `populate_seo_fields` — автозаполнение SEO-полей блога из существующих данных. -4. `make_rating` — пересчёт рейтингов профилей и стеклопакетов методом Манна-Уитни. +1. `regenerate_seria_roots` — пересчет корневых серий (иерархия и консолидация). +2. `generate_sitemaps` — оффлайн генерация sitemap-файлов. +3. `regenerate_seria_prerender` — оффлайн пересборка pre-render шаблонов для `catalog_seria_info`. +4. `populate_seo_fields` — автозаполнение SEO-полей блога из существующих данных. +5. `make_rating` — пересчёт рейтингов профилей и стеклопакетов методом Манна-Уитни. ## Общие правила запуска @@ -28,7 +29,210 @@ cd /Users/e-serg/PRJ/2022-oknardia poetry run python oknardia/manage.py [args] ``` -## 1) Команда `generate_sitemaps` +## 1) Команда `regenerate_seria_roots` + +Назначение: +- пересчитать корневые серии (root) для всей иерархии серий домов. +- консолидировать различные написания одной и той же серии в одну "корневую" серию. + +### Контекст + +На разных источниках данные о типовых сериях домов были записаны с разными орфографическими вариантами. +Например серия **II-57** могла быть обозначена как: +- `2-57` (цифра вместо кириллицы) +- `И-57` (кириллица И вместо латинской II) +- `П-57` (опечатка) +- и т.п. + +При парсинге данных эти варианты могли оказаться в БД как отдельные серии, хотя на самом деле это одна и та же серия. +Функция `regenerate_seria_roots` связывает все эти варианты (алиасы) с одной **корневой** серией. + +### Когда это нужно + +При добавлении новых адресов и серий типового строительства, при ручном редактировании иерархии серий в админке, +при загрузке новых данных с разными орфографическими вариантами серий. Кроме того, если для уже существующих в базе +серий будут получены данные о типовых размерах оконных проёмов и типов квартирах (сейчас в базе около 4500 серий, из +них всего 31 корневых серий и 1957 серий с найденным корнем... ещё 2502 неописанных серии без корня, т.е. больше +половины, могут пополнить каталог Окнардии. + +### Как это работает + +1. **Этап 1**: Находит "корневые" серии (root series) — те, что реально используются в таблице `Apartment_Type` + (т.е. у которых есть квартиры). Для каждой такой серии устанавливает `kRoot_id = own_id`. + +2. **Этап 2**: Для всех остальных серий: + - Движется вверх по дереву иерархии (`kParent_id`) + - Ищет корневую серию (ту, которая либо не имеет родителя, либо она в списке корневых) + - Устанавливает найденную корневую серию в поле `kRoot_id` + - Если не находит корневую серию → `kRoot_id = None` + +### Пример структуры после обработки + +``` +Таблица: Seria_Info + +id | sSeriaName | kParent_id | kRoot_id | Комментарий +----|------------|------------|-----------|------------------- +123 | II-57 | NULL | 123 | Корневая серия +124 | 2-57 | 123 | 123 | Алиас (через родителя) +125 | И-57 | 123 | 123 | Алиас (через родителя) +126 | П-57 | 123 | 123 | Алиас (через родителя) +``` + +### Базовый запуск + +```bash +cd /Users/e-serg/PRJ/2022-oknardia +poetry run python oknardia/manage.py regenerate_seria_roots +``` + +### Параметры запуска + +**`--verbosity 0`** — только ошибки (минимум информации): + +```bash +poetry run python oknardia/manage.py regenerate_seria_roots --verbosity 0 +``` + +**`--verbosity 1`** — точки/плюсы (стандартный режим): + +```bash +poetry run python oknardia/manage.py regenerate_seria_roots +# или явно +poetry run python oknardia/manage.py regenerate_seria_roots --verbosity 1 +``` + +**`--verbosity 2`** — подробный вывод (названия серий): + +```bash +poetry run python oknardia/manage.py regenerate_seria_roots --verbosity 2 +``` + +**`--verbosity 3`** — очень подробный вывод в виде таблицы: + +```bash +poetry run python oknardia/manage.py regenerate_seria_roots --verbosity 3 +``` + +### Примеры вывода + +**Verbosity 0 (только ошибки - чистый вывод):** +``` +✅ Пересчет завершен! Время: 2.18с +``` + +**Verbosity 1 (магический режим - точки и символы):** +``` +=== ПЕРЕСЧЕТ КОРНЕВЫХ СЕРИЙ === +Этап 1: Ищем корневые серии в таблице квартир... +✓ Найдено корневых серий: 241 +............................... + +Этап 2: Главная магия - обрабатываем все серии в иерархии... +-----++..--.++--...--.+.++++.-+++++++-.-.++.-+--++--++++-+++++++-++.+--.+++-+-..++++++-++++++++++--.-++++++-++++ ++--+-+++++++++-++++++++++-++++--+++.+++--+++++++++++++++++++---++.+-+-+++++-++++++++++-+++-+----+.-+++-+--++++++ ++++----+--+-+++++-+--+++--+++-.+++++-++++++++-+---++-+++++---+++------++----++-+--++----+--++--++++--+++++++++++ ++++++++-------++++---+++-[... очень много символов ...] + +=== РЕЗУЛЬТАТЫ === +✓ Корневых серий (обработаны на этапе 1): 31 +✓ Серий с найденным корнем: 1957 +⚠ Серий без корня: 2502 + +✅ Пересчет завершен! Время: 2.18с +``` + +**Легенда магического режима:** +- `.` = корневая серия (обработана на этапе 1) +- `+` = серия с найденным корнем +- `-` = серия без найденного корня +- `E` = ошибка при обработке + +**Verbosity 2 (подробный):** +``` +=== ПЕРЕСЧЕТ КОРНЕВЫХ СЕРИЙ === +Этап 1: Ищем корневые серии в таблице квартир... +✓ Найдено корневых серий: 241 + ✓ 0008 П-44 + ✓ 0009 П-3 + ✓ 0012 II-49 + ✓ 0017 КОПЭ + ... + +Этап 2: Главная магия - обрабатываем все серии в иерархии... + 0001: Нет корня + 0002: Нет корня + 0006: корень → 12 + 0007: корень → 9 + 0008: корневая + ... +``` + +**Verbosity 3 (очень подробный - таблица):** +``` +=== ПЕРЕСЧЕТ КОРНЕВЫХ СЕРИЙ === +Этап 1: Ищем корневые серии в таблице квартир... +✓ Найдено корневых серий: 241 + ✓ 0008 | П-44 | корневая + ✓ 0009 | П-3 | корневая + ✓ 0012 | II-49 | корневая + ... + +Этап 2: Главная магия - обрабатываем все серии в иерархии... +-------------------------------------------------------------------------------------------------------------- + ID | Название | Родитель | Путь | Результат +-------------------------------------------------------------------------------------------------------------- + ... + ... + 2565 | Г-ЗИ | 9 | 9 | ✓ Корень #9 + 2566 | I-528КП-809/69 | - | (нет) | ✗ Нет корня + 2567 | 464Д-0154 | 3339 | 3339 → 963 → 375 | ✓ Корень #375 + 2568 | УЛГ-507-4/64 | 2105 | 2105 | ✓ Корень #2105 + 2569 | ЛГ-507-4 | 2105 | 2105 | ✓ Корень #2105 + 2570 | 1ЛГ-600-И-1 | - | (нет) | ✗ Нет корня + 2571 | 464Д-0154 Новополоцкого ДСК | 3339 | 3339 → 963 → 375 | ✓ Корень #375 + 2572 | 121-0142,13,87 | - | (нет) | ✗ Нет корня + ... + ... +-------------------------------------------------------------------------------------------------------------- + +=== РЕЗУЛЬТАТЫ === +✓ Корневых серий (обработаны на этапе 1): 31 +✓ Серий с найденным корнем: 1957 +⚠ Серий без корня: 2502 + +✅ Пересчет завершен! Время: 2.18с +``` + +### Когда запускать + +- **После первого развертывания** — консолидировать иерархию серий. +- **После ручного редактирования иерархии** (добавления родитель-потомков в админку). +- **После загрузки новых данных** с разными орфографическими вариантами серий. +- **По расписанию** (опционально, например раз в месяц): + ```bash + 0 2 * * 1 cd /home/user/app-path/2022-oknardia && poetry run python oknardia/manage.py regenerate_seria_roots >> /var/log/oknardia-seria-roots.log 2>&1 + ``` + +### Откат и безопасность + +- **Безопасна для повторного запуска** — просто пересчитывает все `kRoot_id`. +- **Откат через SQL** — если нужно очистить поле (перед запуском рекомендуется бэкап): + ```sql + UPDATE oknardia_seria_info SET kRoot_id = NULL; + ``` +- **Проверка результатов** — после запуска можно проверить: + ```bash + poetry run python oknardia/manage.py shell -c " + from oknardia.models import Seria_Info + count_null = Seria_Info.objects.filter(kRoot_id__isnull=True).count() + count_with_root = Seria_Info.objects.filter(kRoot_id__isnull=False).count() + print(f'Серий без корня: {count_null}') + print(f'Серий с корнем: {count_with_root}') + " + ``` + +## 2) Команда `generate_sitemaps` Назначение: - пересобрать `sitemap.xml` и chunk-файлы в `MEDIA_ROOT/_serv_sitemap`. @@ -76,7 +280,7 @@ location = /sitemap.xml { } ``` -## 2) Команда `regenerate_seria_prerender` +## 3) Команда `regenerate_seria_prerender` Назначение: - пересобрать pre-render шаблоны для страниц серий (`catalog_seria_info`) в каталоге `seria_info/prepared/`. @@ -114,7 +318,7 @@ poetry run python oknardia/manage.py regenerate_seria_prerender --seria-id 843 - - после массового обновления данных серий/окон/квартир; - после очистки `seria_info/prepared/`. -## 3) Команда `populate_seo_fields` +## 4) Команда `populate_seo_fields` Назначение: - автозаполнить SEO-поля (`sSlug`, `sMetaDescription`, `sMetaKeywords`) для всех существующих записей блога. @@ -226,7 +430,7 @@ print(f'Пусто sMetaKeywords: {posts.filter(sMetaKeywords=\"\").count()}') - ✅ **Откат через SQL** — если нужно очистить, используй: `UPDATE oknardia_blogposts SET sSlug='', sMetaDescription='', sMetaKeywords='';` - ✅ **Всегда используй `--dry-run`** перед первым запуском для проверки. -## 4) Команда `make_rating` +## 5) Команда `make_rating` Назначение: - пересчитать рейтинги оконных профилей, стеклопакетов и наборов услуг используя адаптированный метод Манна-Уитни (Mann-Whitney U Step Rank). diff --git a/oknardia/web/management/commands/regenerate_seria_roots.py b/oknardia/web/management/commands/regenerate_seria_roots.py new file mode 100644 index 0000000..cebd6e8 --- /dev/null +++ b/oknardia/web/management/commands/regenerate_seria_roots.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +""" +Django management command: regenerate_seria_roots + +Пересчет корневых серий (root series) для всех серий домов. + +Идея: +Серии домов могут иметь сложную иерархию (дерево потомственности) из-за того, +что на разных сайтах одна и та же серия обозначалась по-разному. +Например серия II-57 записывалась как 2-57, И-57, П-57 и т.п. + +Эта команда: +1. Находит "корневые" серии - те, что используются в Apartment_Type (у них есть квартиры) +2. Для всех остальных серий ищет их корневую серию, двигаясь вверх по дереву иерархии +3. Устанавливает найденную корневую серию в поле kRoot_id каждой серии + +Результат: +- Все серии-алиасы (синонимы) указывают на одну корневую серию +- Это позволяет консолидировать данные по сериям дома +""" + +from django.core.management.base import BaseCommand +from oknardia.models import Seria_Info, Apartment_Type + + +class Command(BaseCommand): + help = 'Пересчитывает корневые серии для всей иерархии серий домов' + + def add_arguments(self, parser): + # Django уже добавляет --verbosity автоматически + # 0 = минимум (только ошибки) + # 1 = нормально (точки) + # 2 = подробно (информация) + # 3 = очень подробно (таблица) + pass + + def handle(self, *args, **options): + verbose = int(options.get('verbosity', 1)) + + self.stdout.write(self.style.SUCCESS('=== ПЕРЕСЧЕТ КОРНЕВЫХ СЕРИЙ ===\n')) + + time_start = self.get_time() + + # ========== ЭТАП 1: Находим корневые серии ========== + if verbose >= 1: + self.stdout.write('Этап 1: Ищем корневые серии в таблице квартир...\n') + + # Получаем все УНИКАЛЬНЫЕ серии, которые используются в квартирах + root_series_ids = list( + Apartment_Type.objects.values_list('kSeria_id', flat=True).distinct() + ) + + # Получаем объекты корневых серий (для вывода их названий) + root_series = Seria_Info.objects.filter(id__in=root_series_ids) + + if verbose >= 1: + self.stdout.write(f'✓ Найдено корневых серий: {len(root_series_ids)}\n') + + # Устанавливаем для корневых серий kRoot_id = own_id + for seria in root_series: + seria.kRoot_id = seria.id # Серия сама себе корень + seria.save() + + if verbose >= 3: + # Очень подробный вывод - таблица + self.stdout.write( + f' ✓ {seria.id:04d} | {seria.sName:<30} | корневая' + ) + elif verbose >= 2: + # Подробный - с названием + self.stdout.write(f' ✓ {seria.id:04d} {seria.sName}') + elif verbose >= 1: + # Нормально - точки + self.stdout.write('.', ending='') + + if verbose < 2: + self.stdout.write('\n') + self.stdout.write('') + + # ========== ЭТАП 2: Обрабатываем все серии ========== + if verbose >= 1: + self.stdout.write('\nЭтап 2: Главная магия - обрабатываем все серии в иерархии...\n') + + if verbose >= 3: + # Заголовок таблицы для очень подробного режима + self.stdout.write('-' * 110) + self.stdout.write( + f'{"ID":>5} | {"Название":<35} | {"Родитель":>10} | ' + f'{"Путь":<40} | {"Результат":<20}' + ) + self.stdout.write('-' * 110) + + all_series = Seria_Info.objects.all() + count_with_root = 0 + count_without_root = 0 + count_errors = 0 + count_root = 0 + + for seria in all_series: + try: + # Если это уже корневая серия, пропускаем + if seria.id in root_series_ids: + count_root += 1 + if verbose >= 3: + self.stdout.write( + f'{seria.id:>5} | {seria.sName:<35} | ' + f'{str(seria.kParent_id or "-"):>10} | ' + f'(корневая) | ✓' + ) + elif verbose >= 2: + self.stdout.write(f' {seria.id:04d}: корневая') + elif verbose >= 1: + self.stdout.write('.', ending='') + continue + + # Движемся вверх по дереву потомок → предок + current_id = seria.kParent_id + path_trace = [] + + # Рекурсивно ищем корневую серию + while current_id is not None: + path_trace.append(current_id) + try: + parent_seria = Seria_Info.objects.get(id=current_id) + + # Проверяем: либо у родителя нет родителя, либо родитель - корневая + if parent_seria.kParent_id is None or current_id in root_series_ids: + break + + current_id = parent_seria.kParent_id + except Seria_Info.DoesNotExist: + current_id = None + break + + # Проверяем, что нашли корневую серию + if current_id and current_id in root_series_ids: + seria.kRoot_id = current_id + seria.save() + + if verbose >= 3: + path_str = ' → '.join(str(i) for i in path_trace) + self.stdout.write( + f'{seria.id:>5} | {seria.sName:<35} | ' + f'{str(seria.kParent_id or "-"):>10} | ' + f'{path_str:<40} | ✓ Корень #{current_id}' + ) + elif verbose >= 2: + self.stdout.write(f' {seria.id:04d}: корень → {current_id}') + elif verbose >= 1: + self.stdout.write('+', ending='') + + count_with_root += 1 + else: + seria.kRoot_id = None + seria.save() + + if verbose >= 3: + path_str = ' → '.join(str(i) for i in path_trace) if path_trace else '(нет)' + self.stdout.write( + f'{seria.id:>5} | {seria.sName:<35} | ' + f'{str(seria.kParent_id or "-"):>10} | ' + f'{path_str:<40} | ✗ Нет корня' + ) + elif verbose >= 2: + self.stdout.write(f' {seria.id:04d}: Нет корня') + elif verbose >= 1: + self.stdout.write('-', ending='') + + count_without_root += 1 + + except Exception as e: + if verbose >= 3: + self.stdout.write( + self.style.ERROR(f'{seria.id:>5} | ОШИБКА: {str(e):<80}') + ) + elif verbose >= 1: + self.stdout.write('E', ending='') + count_errors += 1 + + if verbose >= 3: + self.stdout.write('-' * 110) + elif verbose < 2: + self.stdout.write('\n') + + # ========== РЕЗУЛЬТАТЫ ========== + self.stdout.write(self.style.SUCCESS('\n\n=== РЕЗУЛЬТАТЫ ===')) + self.stdout.write(f'✓ Корневых серий (обработаны на этапе 1): {count_root}') + self.stdout.write(f'✓ Серий с найденным корнем: {count_with_root}') + self.stdout.write(f'⚠ Серий без корня: {count_without_root}') + if count_errors > 0: + self.stdout.write(self.style.ERROR(f'✗ Ошибок: {count_errors}')) + + time_elapsed = self.get_time() - time_start + self.stdout.write( + self.style.SUCCESS(f'\n✅ Пересчет завершен! Время: {time_elapsed:.2f}с') + ) + + @staticmethod + def get_time(): + import time + return time.perf_counter() +