#!/usr/bin/env python3 """ migration_reporter — единый инструмент анализа Kotlin-миграций PostgreSQL. Пайплайн: [git pull] → анализ .kt файлов → кэш JSON → HTML-отчет (+ Excel c --excel). HTML строится напрямую из данных анализа (без обратного парсинга Excel) и содержит вкладку "Батчи" — группировку по дате загрузки файлов в репозиторий. Команды: run [git pull] → анализ → кэш → HTML (+ Excel с --excel) analyze только анализ + кэш JSON (шаг DAG) excel только Excel + кэш (ручной запуск) html HTML из кэша (ручной запуск) Примеры: python migration_reporter.py run --dir pgsql --skip-pull python migration_reporter.py run --dir pgsql # c git pull python migration_reporter.py run --dir pgsql --excel # с Excel-отчетом python migration_reporter.py analyze --dir pgsql --output c.json # шаг DAG python migration_reporter.py excel --dir pgsql --output r.xlsx # вручную Excel python migration_reporter.py html --cache c.json --output r.html # вручную HTML Выходные артефакты (в папке --output-dir): migration_report_YYYYMMDD_HHMMSS.changes.json / .html / [.xlsx] latest.html / [latest.xlsx] — фиксированные имена последнего отчета """ import os import re import sys import json import shutil import argparse import subprocess from pathlib import Path from datetime import datetime from typing import List, Dict, Any, Optional, Tuple from dataclasses import dataclass, field from collections import defaultdict, OrderedDict from enum import Enum try: from openpyxl import Workbook from openpyxl.styles import ( Font, PatternFill, Alignment, Border, Side, NamedStyle, numbers ) from openpyxl.utils import get_column_letter except ImportError: print("❌ Требуется openpyxl. Установите: pip install openpyxl") sys.exit(1) # UTF-8 вывод в консоль (Windows cp1251 без эмодзи, контейнеры без локали) for _stream in (sys.stdout, sys.stderr): if _stream and hasattr(_stream, "reconfigure"): _stream.reconfigure(encoding="utf-8", errors="replace") class ChangeCategory(Enum): """Категории изменений для группировки""" NEW_TABLE = "🆕 Новая таблица" DROPPED_TABLE = "🗑️ Удаленная таблица" NEW_COLUMN = "➕ Новая колонка" DROPPED_COLUMN = "➖ Удаленная колонка" MODIFIED_COLUMN = "✏️ Измененная колонка" NEW_INDEX = "📑 Новый индекс" DROPPED_INDEX = "📑 Удаленный индекс" NEW_FOREIGN_KEY = "🔗 Новый внешний ключ" NEW_CONSTRAINT = "🔒 Новый constraint" RAW_SQL = "💻 Сырой SQL" COMMENT = "💬 Комментарий" OTHER = "📌 Прочее" @dataclass class MigrationChange: """Одно изменение в миграции""" sequence: int category: ChangeCategory file_name: str file_path: str line_number: int table_name: str description: str details: str code_snippet: str impact_level: str migration_date: str = "" file_created_date: str = "" file_modified_date: str = "" author: str = "" git_last_commit_date: str = "" git_last_author: str = "" git_commit_hash: str = "" git_commit_message: str = "" programmer_comment: str = "" db_comment: str = "" related_tables: List[str] = field(default_factory=list) columns_info: Dict[str, str] = field(default_factory=dict) class MigrationAnalyzer: """Анализатор миграций с полной поддержкой COMMENT ON и комментариев""" def __init__(self, directory: Path, debug: bool = False, use_git: bool = True): self.directory = directory self.debug = debug self.use_git = use_git self.changes: List[MigrationChange] = [] self.all_tables: Dict[str, Dict] = {} self.sequence_counter = 0 self.files_with_content = 0 self.files_without_content = 0 self.found_patterns = defaultdict(int) self.git_info_cache = {} self.db_comments = defaultdict(list) # Проверяем git self.git_available = self._check_git_available() if debug and use_git and not self.git_available: print("[DEBUG] Git не найден, используется информация о файлах") # Паттерны self.patterns = { # Комментарии 'jsdoc_comment': re.compile(r'/\*\*\s*(.*?)\s*\*/', re.MULTILINE | re.DOTALL), 'line_comment': re.compile(r'//\s*(.*?)$', re.MULTILINE), 'block_comment': re.compile(r'/\*\s*(.*?)\s*\*/', re.MULTILINE | re.DOTALL), 'commit_hash': re.compile(r'\b([0-9a-f]{7,40})\b', re.IGNORECASE), # COMMENT ON 'comment_on_table': re.compile( r'COMMENT\s+ON\s+TABLE\s+(\w+(?:\.\w+)?)\s+IS\s+(?:\'([^\']*)\'|"([^"]*)")', re.IGNORECASE | re.MULTILINE ), 'comment_on_column': re.compile( r'COMMENT\s+ON\s+COLUMN\s+(\w+(?:\.\w+)?)\.(\w+)\s+IS\s+(?:\'([^\']*)\'|"([^"]*)")', re.IGNORECASE | re.MULTILINE ), 'comment_on_view': re.compile( r'COMMENT\s+ON\s+VIEW\s+(\w+(?:\.\w+)?)\s+IS\s+(?:\'([^\']*)\'|"([^"]*)")', re.IGNORECASE | re.MULTILINE ), 'comment_on_index': re.compile( r'COMMENT\s+ON\s+INDEX\s+(\w+(?:\.\w+)?)\s+IS\s+(?:\'([^\']*)\'|"([^"]*)")', re.IGNORECASE | re.MULTILINE ), # SQL 'raw_sql_exec': re.compile(r'exec\s*\(\s*"""\s*(.*?)\s*"""', re.MULTILINE | re.DOTALL), 'raw_sql_transaction': re.compile(r'transaction\s*\(\s*"""\s*(.*?)\s*"""', re.MULTILINE | re.DOTALL), 'raw_sql_exec_single': re.compile(r'exec\s*\(\s*"((?:[^"\\]|\\.)*)"', re.MULTILINE), 'sql_variable': re.compile( r'(?:@Language\s*\(\s*"SQL"\s*\)\s*)?(?:val|var)\s+\w+\s*=\s*"""\s*(.*?)\s*"""\s*\.trimMargin\s*\(\s*\)', re.MULTILINE | re.DOTALL), # Exposed DSL 'exposed_table': re.compile( r'(?:object|class)\s+(\w+)\s*:\s*Table\s*\(\s*"([^"]+)"\s*\)', re.MULTILINE ), 'exposed_column': re.compile( r'val\s+(\w+)\s*=\s*(\w+)\s*\(\s*"([^"]+)"', re.MULTILINE ), 'exposed_reference': re.compile( r'val\s+(\w+)\s*=\s*reference\s*\(\s*"([^"]+)"\s*,\s*(\w+)', re.MULTILINE ), 'exposed_index': re.compile( r'(?:val\s+(\w+)\s*=\s*)?index\s*\(\s*"([^"]+)"', re.MULTILINE ), 'exposed_pk': re.compile( r'(?:override\s+)?val\s+primaryKey\s*=\s*PrimaryKey\s*\(([^)]+)\)', re.MULTILINE ), # SchemaUtils 'schema_utils': re.compile( r'SchemaUtils\.(create|drop|createTable|dropTable|createMissingTablesAndColumns)\s*\(([^)]+)\)', re.MULTILINE ), # SQL паттерны 'sql_create_table': re.compile( r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+(?:\.\w+)?)', re.IGNORECASE | re.MULTILINE ), 'sql_alter_table_add': re.compile( r'ALTER\s+TABLE\s+(?:ONLY\s+)?(\w+(?:\.\w+)?)\s+ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s+(.+?)(?:,|\n|;|$)', re.IGNORECASE | re.MULTILINE ), 'sql_alter_table_drop': re.compile( r'ALTER\s+TABLE\s+(\w+(?:\.\w+)?)\s+DROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?(\w+)', re.IGNORECASE | re.MULTILINE ), 'sql_alter_column': re.compile( r'ALTER\s+TABLE\s+(\w+(?:\.\w+)?)\s+ALTER\s+COLUMN\s+(\w+)\s+(.+)', re.IGNORECASE | re.MULTILINE ), 'sql_create_index': re.compile( r'CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:CONCURRENTLY\s+)?(\w+)\s+ON\s+(\w+(?:\.\w+)?)', re.IGNORECASE | re.MULTILINE ), 'sql_drop_table': re.compile( r'DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+(?:\.\w+)?)', re.IGNORECASE | re.MULTILINE ), 'sql_foreign_key': re.compile( r'FOREIGN\s+KEY\s*\(([^)]+)\)\s*REFERENCES\s+(\w+)\s*\(([^)]+)\)', re.IGNORECASE | re.MULTILINE ), 'sql_insert': re.compile( r'INSERT\s+INTO\s+(\w+(?:\.\w+)?)', re.IGNORECASE | re.MULTILINE ), 'sql_update': re.compile( r'UPDATE\s+(\w+(?:\.\w+)?)\s+SET', re.IGNORECASE | re.MULTILINE ), 'any_sql_like': re.compile( r'(?:CREATE|ALTER|DROP|INSERT|UPDATE|DELETE|TRUNCATE|GRANT|REVOKE|COMMENT)\s+', re.IGNORECASE | re.MULTILINE ), # Метаинформация 'migration_version': re.compile(r'(?:V|v)(\d+[._]\d+[._]\d+)', re.MULTILINE), 'author_info': re.compile(r'@author\s+(\w+)', re.MULTILINE), } def _check_git_available(self) -> bool: """Проверяет доступность git""" try: result = subprocess.run( ['git', 'rev-parse', '--git-dir'], cwd=str(self.directory), capture_output=True, text=True, timeout=5 ) return result.returncode == 0 except: return False def _get_git_info(self, file_path: Path) -> Dict[str, str]: """Получает информацию из git о файле""" if not self.git_available or not self.use_git: return {} cache_key = str(file_path) if cache_key in self.git_info_cache: return self.git_info_cache[cache_key] info = { 'last_commit_date': '', 'last_author': '', 'created_date': '', 'commit_hash': '', 'commit_message': '' } try: # Полная информация о последнем коммите result = subprocess.run( ['git', 'log', '-1', '--format=%H|%ai|%an|%s', '--', str(file_path.name)], cwd=str(self.directory), capture_output=True, text=True, timeout=5 ) if result.returncode == 0 and result.stdout.strip(): parts = result.stdout.strip().split('|') if len(parts) >= 4: info['commit_hash'] = parts[0][:8] info['last_commit_date'] = parts[1][:10] info['last_author'] = parts[2] info['commit_message'] = parts[3][:200] # Дата первого коммита result = subprocess.run( ['git', 'log', '--diff-filter=A', '--format=%ai', '--', str(file_path.name)], cwd=str(self.directory), capture_output=True, text=True, timeout=5 ) if result.returncode == 0 and result.stdout.strip(): info['created_date'] = result.stdout.strip().split('\n')[-1][:10] except Exception as e: if self.debug: print(f" [DEBUG] Git ошибка для {file_path.name}: {e}") self.git_info_cache[cache_key] = info return info def _get_file_dates(self, file_path: Path) -> Dict[str, str]: """Получает даты файла""" try: stat = file_path.stat() return { 'created_date': datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d'), 'modified_date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d'), } except: return { 'created_date': 'неизвестно', 'modified_date': 'неизвестно', } def _extract_programmer_comments(self, content: str) -> Dict[str, Any]: """Извлекает комментарии программистов""" comments = { 'jsdoc': [], 'line_comments': [], 'block_comments': [], 'commit_hashes': [] } # JSDoc комментарии for match in self.patterns['jsdoc_comment'].finditer(content): comment_text = match.group(1).strip() comment_line = content[:match.start()].count('\n') + 1 comments['jsdoc'].append({'text': comment_text, 'line': comment_line}) # Однострочные комментарии for match in self.patterns['line_comment'].finditer(content): comment_text = match.group(1).strip() comment_line = content[:match.start()].count('\n') + 1 if comment_text and not comment_text.startswith('import'): comments['line_comments'].append({'text': comment_text, 'line': comment_line}) # Блочные комментарии for match in self.patterns['block_comment'].finditer(content): comment_text = match.group(1).strip() comment_line = content[:match.start()].count('\n') + 1 comments['block_comments'].append({'text': comment_text, 'line': comment_line}) # Commit-хэши из комментариев all_text = ' '.join( [c['text'] for c in comments['jsdoc'] + comments['line_comments'] + comments['block_comments']]) for match in self.patterns['commit_hash'].finditer(all_text): if match.group(1) not in comments['commit_hashes']: comments['commit_hashes'].append(match.group(1)) return comments def _extract_db_comments(self, content: str) -> Dict[str, List[Dict[str, str]]]: """Извлекает COMMENT ON комментарии""" comments = {'tables': [], 'columns': [], 'views': [], 'indexes': []} # COMMENT ON TABLE for match in self.patterns['comment_on_table'].finditer(content): table_name = match.group(1) comment_text = match.group(2) or match.group(3) or '' line_num = content[:match.start()].count('\n') + 1 cleaned = self._clean_comment_text(comment_text) comments['tables'].append({'table': table_name, 'comment': cleaned, 'line': line_num}) self.db_comments[table_name].append({'type': 'table', 'comment': cleaned, 'line': line_num}) # COMMENT ON COLUMN for match in self.patterns['comment_on_column'].finditer(content): table_name = match.group(1) column_name = match.group(2) comment_text = match.group(3) or match.group(4) or '' line_num = content[:match.start()].count('\n') + 1 cleaned = self._clean_comment_text(comment_text) comments['columns'].append({ 'table': table_name, 'column': column_name, 'comment': cleaned, 'line': line_num }) self.db_comments[f"{table_name}.{column_name}"].append({ 'type': 'column', 'table': table_name, 'column': column_name, 'comment': cleaned, 'line': line_num }) # COMMENT ON VIEW for match in self.patterns['comment_on_view'].finditer(content): view_name = match.group(1) comment_text = match.group(2) or match.group(3) or '' line_num = content[:match.start()].count('\n') + 1 comments['views'].append({ 'view': view_name, 'comment': self._clean_comment_text(comment_text), 'line': line_num }) # COMMENT ON INDEX for match in self.patterns['comment_on_index'].finditer(content): index_name = match.group(1) comment_text = match.group(2) or match.group(3) or '' line_num = content[:match.start()].count('\n') + 1 comments['indexes'].append({ 'index': index_name, 'comment': self._clean_comment_text(comment_text), 'line': line_num }) return comments def _clean_comment_text(self, text: str) -> str: """Очищает текст комментария""" if not text: return "" return text.replace("''", "'").strip() def _format_comments(self, comments: Dict[str, Any]) -> str: """Форматирует комментарии в читаемый вид""" parts = [] for comment in comments['jsdoc']: text = comment['text'].strip() text = re.sub(r'^\s*\*\s?', '', text, flags=re.MULTILINE).strip() if text: parts.append(f"/** {text} */") for comment in comments['line_comments'][:5]: text = comment['text'].strip() if text and len(text) > 3: parts.append(f"// {text}") for commit_hash in comments['commit_hashes'][:3]: parts.append(f"Commit: {commit_hash}") return "\n".join(parts) if parts else "" def _combine_comments(self, programmer_comment: str, db_comments: Dict[str, List[Dict[str, str]]]) -> str: """Объединяет комментарии из кода и COMMENT ON""" parts = [] if programmer_comment: parts.append(programmer_comment) if db_comments['tables']: for tc in db_comments['tables'][:3]: parts.append(f"COMMENT ON TABLE {tc['table']}: {tc['comment']}") if db_comments['columns']: for cc in db_comments['columns'][:5]: parts.append(f"COMMENT ON COLUMN {cc['table']}.{cc['column']}: {cc['comment']}") if db_comments['views']: for vc in db_comments['views'][:3]: parts.append(f"COMMENT ON VIEW {vc['view']}: {vc['comment']}") if db_comments['indexes']: for ic in db_comments['indexes'][:3]: parts.append(f"COMMENT ON INDEX {ic['index']}: {ic['comment']}") return "\n\n".join(parts) if parts else "" def analyze(self): """Основной метод анализа""" kt_files = sorted(self.directory.rglob("*.kt")) print(f"[*] Анализирую {len(kt_files)} Kotlin-файлов...") if self.git_available: print("[*] Git информация доступна") for idx, kt_file in enumerate(kt_files, 1): if self.debug and idx % 100 == 0: print(f" Прогресс: {idx}/{len(kt_files)}") try: self._analyze_file(kt_file) except Exception as e: print(f" ⚠️ Ошибка в файле {kt_file.name}: {e}") if self.debug: import traceback traceback.print_exc() print(f"\n[✓] Файлов с содержимым: {self.files_with_content}") print(f"[✓] Файлов без содержимого: {self.files_without_content}") print(f"[✓] Найдено изменений: {len(self.changes)}") return self.changes def _analyze_direct_sql(self, content: str, file_path: Path, file_name: str, migration_date: str, file_dates: Dict, git_info: Dict, author: str, programmer_comment: str, commit_hash: str, commit_message: str): """Находит CREATE TABLE напрямую в контенте файла""" # Очищаем контент от trimMargin символов content_clean = self._clean_trim_margin(content) # Ищем все CREATE TABLE в контенте create_pattern = re.compile( r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[\w.]+?\s*\((.*?)\)\s*;', re.IGNORECASE | re.DOTALL ) for match in create_pattern.finditer(content_clean): sql = match.group(0) line_num = content_clean[:match.start()].count('\n') + 1 table_match = re.search(r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w.]+)', sql, re.IGNORECASE) if table_match: table_name = table_match.group(1).split('.')[-1] if self.debug: print(f" [DEBUG] Найдена CREATE TABLE напрямую: {table_name}") columns_info = self._extract_columns_from_create_table(sql) related_tables = self._extract_related_tables(sql, table_name) db_comment = self._get_table_comment(table_name) details = self._format_table_details(table_name, columns_info, related_tables) if db_comment: details += f"\nКомментарий: {db_comment}" self._create_change( ChangeCategory.NEW_TABLE, file_path, file_name, line_num, table_name, f"Создание таблицы {table_name}", details, sql[:200], "HIGH", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message, related_tables, columns_info, db_comment ) # Также ищем CREATE INDEX index_pattern = re.compile( r'CREATE\s+(?:UNIQUE\s+)?INDEX\s+(\w+)\s+ON\s+(\w+)', re.IGNORECASE ) for match in index_pattern.finditer(content_clean): idx_name = match.group(1) table_name = match.group(2) line_num = content_clean[:match.start()].count('\n') + 1 self._create_change( ChangeCategory.NEW_INDEX, file_path, file_name, line_num, table_name, f"Создание индекса {idx_name}", f"CREATE INDEX {idx_name} ON {table_name}", match.group(0), "MEDIUM", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message ) def _analyze_file(self, file_path: Path): """Анализирует один .kt файл""" try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() except Exception as e: if self.debug: print(f" ⚠️ Ошибка чтения {file_path.name}: {e}") return file_name = file_path.name file_dates = self._get_file_dates(file_path) git_info = self._get_git_info(file_path) migration_date = git_info.get('created_date') or file_dates['created_date'] author = git_info.get('last_author') or self._extract_author(content) or 'неизвестен' # Извлекаем комментарии all_comments = self._extract_programmer_comments(content) programmer_comment = self._format_comments(all_comments) db_comments = self._extract_db_comments(content) combined_comment = self._combine_comments(programmer_comment, db_comments) commit_hash = git_info.get('commit_hash', '') commit_message = git_info.get('commit_message', '') content_found = False # Анализируем COMMENT ON if db_comments and any(db_comments.values()): content_found = True self.found_patterns['comment_on'] += 1 self._analyze_db_comments(db_comments, file_path, file_name, migration_date, file_dates, git_info, author, combined_comment, commit_hash, commit_message) # Анализируем SQL в exec/transaction if self._has_raw_sql(content): content_found = True self.found_patterns['raw_sql'] += 1 self._analyze_raw_sql_all(content, file_path, file_name, migration_date, file_dates, git_info, author, combined_comment, commit_hash, commit_message) # ВАЖНО: Анализируем SQL в переменных (query, sql и т.д.) if self.patterns['sql_variable'].search(content): content_found = True self.found_patterns['sql_variable'] += 1 self._analyze_sql_variables(content, file_path, file_name, migration_date, file_dates, git_info, author, combined_comment, commit_hash, commit_message) # ДОПОЛНИТЕЛЬНО: Ищем SQL в up { } блоках if 'CREATE TABLE' in content.upper() or 'ALTER TABLE' in content.upper(): content_found = True self.found_patterns['direct_sql'] += 1 self._analyze_direct_sql(content, file_path, file_name, migration_date, file_dates, git_info, author, combined_comment, commit_hash, commit_message) # Exposed if self.patterns['exposed_table'].search(content): content_found = True self.found_patterns['exposed_table'] += 1 self._analyze_exposed_tables(content, file_path, file_name, migration_date, file_dates, git_info, author, combined_comment, commit_hash, commit_message) # SchemaUtils if self.patterns['schema_utils'].search(content): content_found = True self.found_patterns['schema_utils'] += 1 self._analyze_schema_utils(content, file_path, file_name, migration_date, file_dates, git_info, author, combined_comment, commit_hash, commit_message) if content_found: self.files_with_content += 1 else: self.files_without_content += 1 def _create_change(self, category: ChangeCategory, file_path: Path, file_name: str, line_num: int, table_name: str, description: str, details: str, code_snippet: str, impact_level: str, migration_date: str, file_dates: Dict[str, str], git_info: Dict[str, str], author: str, programmer_comment: str = "", commit_hash: str = "", commit_message: str = "", related_tables: List[str] = None, columns_info: Dict[str, str] = None, db_comment: str = ""): """Создает объект изменения""" self.sequence_counter += 1 if related_tables is None: related_tables = [] if columns_info is None: columns_info = {} self.changes.append(MigrationChange( sequence=self.sequence_counter, category=category, file_name=file_name, file_path=str(file_path), line_number=line_num, table_name=table_name, description=description, details=details, code_snippet=code_snippet, impact_level=impact_level, migration_date=migration_date, file_created_date=file_dates.get('created_date', ''), file_modified_date=file_dates.get('modified_date', ''), author=author, git_last_commit_date=git_info.get('last_commit_date', ''), git_last_author=git_info.get('last_author', ''), git_commit_hash=commit_hash or git_info.get('commit_hash', ''), git_commit_message=commit_message or git_info.get('commit_message', ''), programmer_comment=programmer_comment, db_comment=db_comment, related_tables=related_tables, columns_info=columns_info )) def _has_raw_sql(self, content: str) -> bool: return bool( self.patterns['raw_sql_exec'].search(content) or self.patterns['raw_sql_transaction'].search(content) or self.patterns['raw_sql_exec_single'].search(content) ) def _analyze_db_comments(self, db_comments: Dict, file_path: Path, file_name: str, migration_date: str, file_dates: Dict, git_info: Dict, author: str, combined_comment: str, commit_hash: str, commit_message: str): """Создает записи для COMMENT ON""" for tc in db_comments['tables']: self._create_change( ChangeCategory.COMMENT, file_path, file_name, tc['line'], tc['table'], f"Комментарий к таблице {tc['table']}", f"COMMENT ON TABLE {tc['table']} IS '{tc['comment']}'", f"COMMENT ON TABLE {tc['table']}", "LOW", migration_date, file_dates, git_info, author, combined_comment, commit_hash, commit_message, db_comment=tc['comment'] ) for cc in db_comments['columns']: self._create_change( ChangeCategory.COMMENT, file_path, file_name, cc['line'], f"{cc['table']}.{cc['column']}", f"Комментарий к колонке {cc['table']}.{cc['column']}", f"COMMENT ON COLUMN {cc['table']}.{cc['column']} IS '{cc['comment']}'", f"COMMENT ON COLUMN {cc['table']}.{cc['column']}", "LOW", migration_date, file_dates, git_info, author, combined_comment, commit_hash, commit_message, db_comment=cc['comment'] ) for vc in db_comments['views']: self._create_change( ChangeCategory.COMMENT, file_path, file_name, vc['line'], vc['view'], f"Комментарий к представлению {vc['view']}", f"COMMENT ON VIEW {vc['view']} IS '{vc['comment']}'", f"COMMENT ON VIEW {vc['view']}", "LOW", migration_date, file_dates, git_info, author, combined_comment, commit_hash, commit_message, db_comment=vc['comment'] ) for ic in db_comments['indexes']: self._create_change( ChangeCategory.COMMENT, file_path, file_name, ic['line'], ic['index'], f"Комментарий к индексу {ic['index']}", f"COMMENT ON INDEX {ic['index']} IS '{ic['comment']}'", f"COMMENT ON INDEX {ic['index']}", "LOW", migration_date, file_dates, git_info, author, combined_comment, commit_hash, commit_message, db_comment=ic['comment'] ) def _analyze_raw_sql_all(self, content: str, file_path: Path, file_name: str, migration_date: str, file_dates: Dict, git_info: Dict, author: str, programmer_comment: str, commit_hash: str, commit_message: str): """Извлекает SQL из разных источников с диагностикой""" # Проверяем exec с многострочным SQL exec_matches = list(self.patterns['raw_sql_exec'].finditer(content)) if self.debug and exec_matches: print(f" [DEBUG] {file_name}: найдено {len(exec_matches)} exec с многострочным SQL") for match in exec_matches: sql = match.group(1).strip() line_num = content[:match.start()].count('\n') + 1 if self.debug and 'CREATE TABLE' in sql.upper(): print(f" [DEBUG] Найден CREATE TABLE в exec, строка {line_num}") self._parse_sql_block(sql, file_path, file_name, line_num, migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message) # Проверяем transaction transaction_matches = list(self.patterns['raw_sql_transaction'].finditer(content)) if self.debug and transaction_matches: print(f" [DEBUG] {file_name}: найдено {len(transaction_matches)} transaction") for match in transaction_matches: sql = match.group(1).strip() line_num = content[:match.start()].count('\n') + 1 if self.debug and 'CREATE TABLE' in sql.upper(): print(f" [DEBUG] Найден CREATE TABLE в transaction, строка {line_num}") self._parse_sql_block(sql, file_path, file_name, line_num, migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message) # Проверяем однострочный SQL single_matches = list(self.patterns['raw_sql_exec_single'].finditer(content)) if self.debug and single_matches: print(f" [DEBUG] {file_name}: найдено {len(single_matches)} однострочных exec") for match in single_matches: sql = match.group(1).strip() line_num = content[:match.start()].count('\n') + 1 if self.debug and 'CREATE TABLE' in sql.upper(): print(f" [DEBUG] Найден CREATE TABLE в однострочном SQL, строка {line_num}") self._parse_sql_statement(sql, file_path, file_name, line_num, migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message) # Дополнительно: ищем CREATE TABLE напрямую в контенте if self.debug: create_matches = list(re.finditer(r'CREATE\s+TABLE', content, re.IGNORECASE)) if create_matches: print(f" [DEBUG] {file_name}: найдено {len(create_matches)} CREATE TABLE в файле") for cm in create_matches[:3]: line = content[:cm.start()].count('\n') + 1 print(f" Строка {line}: {content[cm.start():cm.start() + 100].strip()}") def _analyze_sql_variables(self, content: str, file_path: Path, file_name: str, migration_date: str, file_dates: Dict, git_info: Dict, author: str, programmer_comment: str, commit_hash: str, commit_message: str): """Анализирует SQL в переменных с поддержкой trimMargin""" for match in self.patterns['sql_variable'].finditer(content): sql = match.group(1).strip() line_num = content[:match.start()].count('\n') + 1 # Убираем символы | из trimMargin sql_clean = self._clean_trim_margin(sql) if self.debug: print(f" [DEBUG] Найден SQL в переменной, строка {line_num}") print(f" [DEBUG] SQL после очистки: {sql_clean[:200]}") self._parse_sql_block(sql_clean, file_path, file_name, line_num, migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message) def _clean_trim_margin(self, sql: str) -> str: """Очищает SQL от символов trimMargin""" lines = sql.split('\n') cleaned_lines = [] for line in lines: # Убираем ведущие пробелы и символ | cleaned = re.sub(r'^\s*\|', '', line) cleaned_lines.append(cleaned) return '\n'.join(cleaned_lines) def _parse_sql_block(self, sql: str, file_path: Path, file_name: str, line_num: int, migration_date: str, file_dates: Dict, git_info: Dict, author: str, programmer_comment: str, commit_hash: str, commit_message: str): """Парсит блок SQL""" statements = [s.strip() for s in sql.split(';') if s.strip()] for statement in statements: self._parse_sql_statement(statement, file_path, file_name, line_num, migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message) def _parse_sql_statement(self, sql: str, file_path: Path, file_name: str, line_num: int, migration_date: str, file_dates: Dict, git_info: Dict, author: str, programmer_comment: str, commit_hash: str, commit_message: str): """Парсит один SQL запрос с расширенной диагностикой""" if not sql.strip(): return sql_clean = re.sub(r'--.*$', '', sql, flags=re.MULTILINE).strip() sql_upper = sql_clean.upper() # РАСШИРЕННАЯ ПРОВЕРКА CREATE TABLE # Проверяем разные варианты написания if ('CREATE' in sql_upper and 'TABLE' in sql_upper and not sql_upper.startswith('CREATE INDEX') and not sql_upper.startswith('CREATE UNIQUE INDEX') and not sql_upper.startswith('CREATE OR REPLACE')): # Ищем название таблицы разными способами table_name = None # Стандартный паттерн match = self.patterns['sql_create_table'].search(sql_clean) if match: table_name = match.group(1) # Если не нашли, пробуем другие варианты if not table_name: # CREATE TABLE IF NOT EXISTS schema.table_name match = re.search(r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:[\w]+\.)?(\w+)', sql_clean, re.IGNORECASE) if match: table_name = match.group(1) # Если нашли имя таблицы if table_name: if self.debug: print(f" [DEBUG] Найдена CREATE TABLE: {table_name}") print(f" [DEBUG] SQL: {sql_clean[:200]}") related_tables = self._extract_related_tables(sql_clean, table_name) columns_info = self._extract_columns_from_create_table(sql_clean) details = self._format_table_details(table_name, columns_info, related_tables) db_comment = self._get_table_comment(table_name) if db_comment: details += f"\nКомментарий: {db_comment}" self._create_change( ChangeCategory.NEW_TABLE, file_path, file_name, line_num, table_name, f"Создание таблицы {table_name}", details, sql_clean[:200], "HIGH", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message, related_tables, columns_info, db_comment ) return # ALTER TABLE ADD elif 'ALTER TABLE' in sql_upper and 'ADD' in sql_upper: match = self.patterns['sql_alter_table_add'].search(sql_clean) if match: table_name = match.group(1) col_name = match.group(2) col_def = match.group(3).rstrip(';').strip()[:100] self._create_change( ChangeCategory.NEW_COLUMN, file_path, file_name, line_num, table_name, f"Добавление колонки {col_name} в {table_name}", f"Тип: {col_def}\nSQL: {sql_clean[:300]}", sql_clean[:200], "MEDIUM", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message ) # ALTER TABLE DROP elif 'ALTER TABLE' in sql_upper and 'DROP' in sql_upper: match = self.patterns['sql_alter_table_drop'].search(sql_clean) if match: table_name = match.group(1) col_name = match.group(2) self._create_change( ChangeCategory.DROPPED_COLUMN, file_path, file_name, line_num, table_name, f"Удаление колонки {col_name} из {table_name}", f"SQL: {sql_clean[:300]}", sql_clean[:200], "HIGH", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message ) # ALTER COLUMN elif 'ALTER TABLE' in sql_upper and 'ALTER COLUMN' in sql_upper: match = self.patterns['sql_alter_column'].search(sql_clean) if match: table_name = match.group(1) col_name = match.group(2) alteration = match.group(3).strip()[:100] self._create_change( ChangeCategory.MODIFIED_COLUMN, file_path, file_name, line_num, table_name, f"Изменение колонки {table_name}.{col_name}", f"Изменение: {alteration}", sql_clean[:200], "MEDIUM", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message ) # CREATE INDEX elif sql_upper.startswith('CREATE INDEX') or sql_upper.startswith('CREATE UNIQUE INDEX'): match = self.patterns['sql_create_index'].search(sql_clean) if match: idx_name = match.group(1) table_name = match.group(2) is_concurrent = 'CONCURRENTLY' in sql_upper concurrent_note = " (CONCURRENTLY)" if is_concurrent else " (⚠️ без CONCURRENTLY)" self._create_change( ChangeCategory.NEW_INDEX, file_path, file_name, line_num, table_name, f"Создание индекса {idx_name}{concurrent_note}", f"SQL: {sql_clean[:300]}", sql_clean[:200], "HIGH" if not is_concurrent else "MEDIUM", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message ) # DROP TABLE elif sql_upper.startswith('DROP TABLE'): match = self.patterns['sql_drop_table'].search(sql_clean) if match: table_name = match.group(1) self._create_change( ChangeCategory.DROPPED_TABLE, file_path, file_name, line_num, table_name, f"Удаление таблицы {table_name}", f"SQL: {sql_clean[:300]}", sql_clean[:200], "HIGH", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message ) # INSERT elif sql_upper.startswith('INSERT INTO'): match = self.patterns['sql_insert'].search(sql_clean) if match: table_name = match.group(1) self._create_change( ChangeCategory.RAW_SQL, file_path, file_name, line_num, table_name, f"Вставка данных в {table_name}", f"SQL: {sql_clean[:300]}", sql_clean[:200], "LOW", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message ) # UPDATE elif sql_upper.startswith('UPDATE'): match = self.patterns['sql_update'].search(sql_clean) if match: table_name = match.group(1) self._create_change( ChangeCategory.RAW_SQL, file_path, file_name, line_num, table_name, f"Обновление данных в {table_name}", f"SQL: {sql_clean[:300]}", sql_clean[:200], "LOW", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message ) # FOREIGN KEY elif 'FOREIGN KEY' in sql_upper: match = self.patterns['sql_foreign_key'].search(sql_clean) if match: fk_cols = match.group(1).strip() ref_table = match.group(2).strip() ref_cols = match.group(3).strip() table_match = re.search(r'ALTER\s+TABLE\s+(\w+)', sql_clean, re.IGNORECASE) table_name = table_match.group(1) if table_match else "неизвестно" self._create_change( ChangeCategory.NEW_FOREIGN_KEY, file_path, file_name, line_num, table_name, f"Внешний ключ {fk_cols} → {ref_table}({ref_cols})", f"SQL: {sql_clean[:300]}", sql_clean[:200], "MEDIUM", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message, [ref_table] ) # Другие SQL elif re.match(r'(?:CREATE|ALTER|DROP|INSERT|UPDATE|DELETE|TRUNCATE|GRANT|REVOKE)\s+', sql_upper, re.IGNORECASE): self._create_change( ChangeCategory.RAW_SQL, file_path, file_name, line_num, "разные", "Выполнение SQL", f"SQL: {sql_clean[:300]}", sql_clean[:200], "LOW", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message ) def _extract_related_tables(self, content: str, current_table: str) -> List[str]: """Извлекает связанные таблицы""" related = [] for match in re.finditer(r'REFERENCES\s+(\w+)', content, re.IGNORECASE): ref_table = match.group(1) if ref_table != current_table and ref_table not in related: related.append(ref_table) return related def _extract_columns_from_create_table(self, sql: str) -> Dict[str, str]: """Извлекает колонки из CREATE TABLE с расширенной поддержкой""" columns = {} # Ищем тело CREATE TABLE body_match = re.search(r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[\w.]+?\s*\((.*?)\)\s*;', sql, re.IGNORECASE | re.DOTALL) if not body_match: body_match = re.search(r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[\w.]+?\s*\((.*?)\)', sql, re.IGNORECASE | re.DOTALL) if body_match: body = body_match.group(1) definitions = self._split_definitions(body) for definition in definitions: definition = definition.strip() if not definition: continue # Пропускаем constraints if any(definition.upper().startswith(kw) for kw in ['PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE', 'CHECK', 'CONSTRAINT', 'INDEX']): continue # Расширенный паттерн для колонок # Поддерживает: name TYPE, "name" TYPE, name TYPE GENERATED ALWAYS AS IDENTITY col_match = re.match( r'"?(\w+)"?\s+([A-Za-z0-9_]+(?:\s+GENERATED\s+ALWAYS\s+AS\s+IDENTITY)?)', definition, re.IGNORECASE) if col_match: col_name = col_match.group(1) col_type = col_match.group(2).strip() # Добавляем модификаторы modifiers = [] if 'NOT NULL' in definition.upper(): modifiers.append("NOT NULL") if 'PRIMARY KEY' in definition.upper(): modifiers.append("PK") if 'DEFAULT' in definition.upper(): modifiers.append("DEFAULT") if 'REFERENCES' in definition.upper(): # Извлекаем ссылку ref_match = re.search(r'REFERENCES\s+(\w+)', definition, re.IGNORECASE) if ref_match: modifiers.append(f"FK→{ref_match.group(1)}") if 'GENERATED' in definition.upper(): modifiers.append("IDENTITY") if 'NULL' in definition.upper() and 'NOT NULL' not in definition.upper(): modifiers.append("NULL") if modifiers: col_type += " " + " ".join(modifiers) columns[col_name] = col_type return columns def _split_definitions(self, body: str) -> List[str]: """Разбивает тело CREATE TABLE на отдельные определения""" definitions = [] current = [] paren_depth = 0 in_quotes = False quote_char = None for char in body: # Обработка кавычек if char in ["'", '"']: if not in_quotes: in_quotes = True quote_char = char elif char == quote_char: in_quotes = False quote_char = None # Обработка скобок (только вне кавычек) if not in_quotes: if char == '(': paren_depth += 1 elif char == ')': paren_depth -= 1 elif char == ',' and paren_depth == 0: definitions.append(''.join(current)) current = [] continue current.append(char) if current: definitions.append(''.join(current)) return definitions def _format_table_details(self, table_name: str, columns: Dict[str, str], related_tables: List[str]) -> str: """Форматирует детали таблицы""" details = f"Таблица: {table_name}\n" if columns: details += "\nКолонки:\n" for col_name, col_type in columns.items(): details += f" • {col_name}: {col_type}\n" if related_tables: details += f"\nСвязанные таблицы: {', '.join(related_tables)}" return details def _analyze_exposed_tables(self, content: str, file_path: Path, file_name: str, migration_date: str, file_dates: Dict, git_info: Dict, author: str, programmer_comment: str, commit_hash: str, commit_message: str): """Анализирует Exposed таблицы""" for match in self.patterns['exposed_table'].finditer(content): class_name = match.group(1) table_name = match.group(2) line_num = content[:match.start()].count('\n') + 1 body_start = content.find('{', match.end()) if body_start == -1: continue body_end = self._find_matching_brace(content, body_start) if body_end == -1: continue body = content[body_start:body_end] columns = OrderedDict() related_tables = [] for col_match in self.patterns['exposed_column'].finditer(body): col_type = col_match.group(2) col_db_name = col_match.group(3) remaining = body[col_match.end():col_match.end() + 200] modifiers = [] if 'nullable()' in remaining.split('\n')[0]: modifiers.append("nullable") if 'autoIncrement()' in remaining.split('\n')[0]: modifiers.append("auto-increment") modifier_str = f" ({', '.join(modifiers)})" if modifiers else "" columns[col_db_name] = f"{col_type}{modifier_str}" for ref_match in self.patterns['exposed_reference'].finditer(body): ref_table = ref_match.group(3) if ref_table not in related_tables: related_tables.append(ref_table) details = f"Класс: {class_name}\nТаблица: {table_name}" if columns: details += f"\nКолонок: {len(columns)}" for col_name, col_type in list(columns.items())[:10]: details += f"\n • {col_name}: {col_type}" if related_tables: details += f"\nСвязанные таблицы: {', '.join(related_tables)}" # Получаем комментарий из БД db_comment = self._get_table_comment(table_name) if db_comment: details += f"\nКомментарий: {db_comment}" self._create_change( ChangeCategory.NEW_TABLE, file_path, file_name, line_num, table_name, f"Создание таблицы {table_name} (Exposed)", details, f"object {class_name} : Table(\"{table_name}\")", "HIGH", migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message, related_tables, dict(columns), db_comment ) def _analyze_schema_utils(self, content: str, file_path: Path, file_name: str, migration_date: str, file_dates: Dict, git_info: Dict, author: str, programmer_comment: str, commit_hash: str, commit_message: str): """Анализирует SchemaUtils вызовы""" for match in self.patterns['schema_utils'].finditer(content): operation = match.group(1).lower() tables = match.group(2) line_num = content[:match.start()].count('\n') + 1 if 'create' in operation: category = ChangeCategory.NEW_TABLE desc = f"Создание через SchemaUtils.{operation}" impact = "HIGH" elif 'drop' in operation: category = ChangeCategory.DROPPED_TABLE desc = f"Удаление через SchemaUtils.{operation}" impact = "HIGH" else: category = ChangeCategory.OTHER desc = f"SchemaUtils.{operation}" impact = "MEDIUM" self._create_change( category, file_path, file_name, line_num, tables, desc, f"SchemaUtils.{operation}({tables})", match.group(0), impact, migration_date, file_dates, git_info, author, programmer_comment, commit_hash, commit_message ) def _find_matching_brace(self, text: str, start: int) -> int: depth = 0 for i in range(start, len(text)): if text[i] == '{': depth += 1 elif text[i] == '}': depth -= 1 if depth == 0: return i return -1 def _extract_author(self, content: str) -> str: match = self.patterns['author_info'].search(content) return match.group(1) if match else "" def _get_table_comment(self, table_name: str) -> str: """Получает комментарий к таблице из db_comments""" if table_name in self.db_comments: for comment_info in self.db_comments[table_name]: if comment_info['type'] == 'table': return comment_info['comment'] return "" class ExcelReportGenerator: """Генератор Excel-отчета""" COLORS = { 'header_bg': PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid"), 'header_font': Font(color="FFFFFF", bold=True, size=11), 'high_impact': PatternFill(start_color="FFE0E0", end_color="FFE0E0", fill_type="solid"), 'medium_impact': PatternFill(start_color="FFF3E0", end_color="FFF3E0", fill_type="solid"), 'low_impact': PatternFill(start_color="E8F5E9", end_color="E8F5E9", fill_type="solid"), 'recent_change': PatternFill(start_color="FFF9C4", end_color="FFF9C4", fill_type="solid"), 'comment_bg': PatternFill(start_color="F3E5F5", end_color="F3E5F5", fill_type="solid"), 'db_comment_bg': PatternFill(start_color="E0F7FA", end_color="E0F7FA", fill_type="solid"), 'category_colors': { ChangeCategory.NEW_TABLE: "1B5E20", ChangeCategory.DROPPED_TABLE: "B71C1C", ChangeCategory.NEW_COLUMN: "0D47A1", ChangeCategory.DROPPED_COLUMN: "D32F2F", ChangeCategory.NEW_INDEX: "4A148C", ChangeCategory.NEW_FOREIGN_KEY: "E65100", ChangeCategory.RAW_SQL: "616161", ChangeCategory.COMMENT: "00838F", } } def __init__(self, changes: List[MigrationChange], output_path: str): self.changes = changes self.output_path = output_path self.wb = Workbook() self.recent_threshold = datetime.now().strftime('%Y-%m-%d') def generate(self): """Генерирует полный Excel-отчет""" print("\n[*] Генерирую Excel-отчет...") self._create_table_structure_sheet() # НОВЫЙ ЛИСТ self._create_summary_sheet() self._create_changes_sheet() self._create_comments_sheet() self._create_db_comments_sheet() self._create_by_table_sheet() self._create_by_file_sheet() self._create_impact_analysis_sheet() self._create_timeline_sheet() self._create_recent_changes_sheet() self.wb.save(self.output_path) print(f"[✓] Отчет сохранен: {self.output_path}") print(f" Листов: {len(self.wb.sheetnames)}") print(f" Изменений: {len(self.changes)}") def _create_table_structure_sheet(self): """Создает лист со структурой созданных таблиц""" ws = self.wb.create_sheet("📖 Структура таблиц", 0) # Первый лист # Заголовок ws.merge_cells('A1:H1') ws['A1'] = "СТРУКТУРА СОЗДАННЫХ ТАБЛИЦ" ws['A1'].font = Font(bold=True, size=16, color="1B5E20") ws['A1'].alignment = Alignment(horizontal='center', vertical='center') ws.merge_cells('A2:H2') ws['A2'] = f"Сгенерировано: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ws['A2'].font = Font(color="757575", size=10) ws['A2'].alignment = Alignment(horizontal='center') # Собираем все созданные таблицы created_tables = {} for change in self.changes: if change.category == ChangeCategory.NEW_TABLE: table_name = change.table_name if table_name not in created_tables: created_tables[table_name] = { 'columns': change.columns_info, 'comment': change.db_comment or change.programmer_comment, 'related_tables': change.related_tables, 'created_date': change.file_created_date, 'modified_date': change.file_modified_date, 'file': change.file_name, 'author': change.author or change.git_last_author, 'commit': change.git_commit_hash } else: # Обновляем существующую запись if change.columns_info: created_tables[table_name]['columns'].update(change.columns_info) if change.db_comment and not created_tables[table_name]['comment']: created_tables[table_name]['comment'] = change.db_comment if change.related_tables: created_tables[table_name]['related_tables'].extend( [t for t in change.related_tables if t not in created_tables[table_name]['related_tables']] ) row = 4 for table_name in sorted(created_tables.keys()): info = created_tables[table_name] # Заголовок таблицы ws.merge_cells(f'A{row}:H{row}') cell = ws.cell(row=row, column=1, value=f"📋 ТАБЛИЦА: {table_name}") cell.font = Font(bold=True, size=13, color="FFFFFF") cell.fill = PatternFill(start_color="1B5E20", end_color="1B5E20", fill_type="solid") cell.alignment = Alignment(horizontal='left', vertical='center') # Применяем стиль ко всей строке for col in range(1, 9): ws.cell(row=row, column=col).fill = PatternFill( start_color="1B5E20", end_color="1B5E20", fill_type="solid" ) ws.cell(row=row, column=col).border = Border( left=Side(style='thin', color='1B5E20'), right=Side(style='thin', color='1B5E20'), top=Side(style='thin', color='1B5E20'), bottom=Side(style='thin', color='1B5E20') ) row += 1 # Комментарий if info['comment']: ws.merge_cells(f'A{row}:H{row}') comment_cell = ws.cell(row=row, column=1, value=f"💬 Комментарий: {info['comment']}") comment_cell.font = Font(italic=True, color="4A148C") comment_cell.fill = PatternFill(start_color="F3E5F5", end_color="F3E5F5", fill_type="solid") comment_cell.alignment = Alignment(wrap_text=True, vertical='top') for col in range(1, 9): ws.cell(row=row, column=col).fill = PatternFill( start_color="F3E5F5", end_color="F3E5F5", fill_type="solid" ) row += 1 # Информация о создании info_parts = [] if info['created_date']: info_parts.append(f"📅 Создана: {info['created_date']}") if info['modified_date']: info_parts.append(f"✏️ Изменена: {info['modified_date']}") if info['file']: info_parts.append(f"📁 Файл: {info['file']}") if info['author']: info_parts.append(f"👤 Автор: {info['author']}") if info['commit']: info_parts.append(f"🔗 Commit: {info['commit']}") if info_parts: ws.merge_cells(f'A{row}:H{row}') info_cell = ws.cell(row=row, column=1, value=" | ".join(info_parts)) info_cell.font = Font(size=9, color="616161") info_cell.alignment = Alignment(horizontal='left') row += 1 # Заголовки колонок col_headers = ["Колонка", "Тип данных", "COMMENT ON", "Связи", "PK", "FK", "NOT NULL", "Default"] for col, header in enumerate(col_headers, 1): cell = ws.cell(row=row, column=col, value=header) cell.font = Font(bold=True, color="1B5E20") cell.fill = PatternFill(start_color="E8F5E9", end_color="E8F5E9", fill_type="solid") cell.alignment = Alignment(horizontal='center') cell.border = Border( left=Side(style='thin', color='dee2e6'), right=Side(style='thin', color='dee2e6'), top=Side(style='thin', color='dee2e6'), bottom=Side(style='thin', color='dee2e6') ) row += 1 # Данные колонок if info['columns']: for col_name, col_type in sorted(info['columns'].items()): # Определяем атрибуты колонки is_pk = 'PK' in col_type.upper() or 'PRIMARY' in col_type.upper() is_fk = 'REFERENCES' in col_type.upper() or 'FK' in col_type.upper() is_not_null = 'NOT NULL' in col_type.upper() has_default = 'DEFAULT' in col_type.upper() # Извлекаем чистый тип clean_type = col_type for attr in ['NOT NULL', 'NULL', 'PRIMARY KEY', 'PK', 'REFERENCES', 'DEFAULT', 'UNIQUE', 'CHECK']: clean_type = re.sub(rf'\s*{attr}.*$', '', clean_type, flags=re.IGNORECASE) # Ищем комментарий к колонке col_comment = "" for change in self.changes: if (change.category == ChangeCategory.COMMENT and change.table_name == f"{table_name}.{col_name}"): col_comment = change.db_comment break # Ищем связи fk_ref = "" fk_match = re.search(r'REFERENCES\s+(\w+)', col_type, re.IGNORECASE) if fk_match: fk_ref = f"→ {fk_match.group(1)}" ws.cell(row=row, column=1, value=col_name).font = Font(bold=True) ws.cell(row=row, column=2, value=clean_type.strip()) comment_cell = ws.cell(row=row, column=3, value=col_comment) if col_comment: comment_cell.fill = PatternFill(start_color="E0F7FA", end_color="E0F7FA", fill_type="solid") ws.cell(row=row, column=4, value=fk_ref) # Отметки ws.cell(row=row, column=5, value="✓" if is_pk else "") ws.cell(row=row, column=6, value="✓" if is_fk else "") ws.cell(row=row, column=7, value="✓" if is_not_null else "") ws.cell(row=row, column=8, value="✓" if has_default else "") # Выравнивание отметок for col in range(5, 9): ws.cell(row=row, column=col).alignment = Alignment(horizontal='center') # Границы for col in range(1, 9): ws.cell(row=row, column=col).border = Border( left=Side(style='thin', color='dee2e6'), right=Side(style='thin', color='dee2e6'), top=Side(style='thin', color='dee2e6'), bottom=Side(style='thin', color='dee2e6') ) row += 1 else: ws.merge_cells(f'A{row}:H{row}') ws.cell(row=row, column=1, value="Колонки не определены") ws.cell(row=row, column=1).font = Font(color="999999", italic=True) row += 1 # Связанные таблицы if info['related_tables']: ws.merge_cells(f'A{row}:H{row}') fk_header = ws.cell(row=row, column=1, value="🔗 СВЯЗАННЫЕ ТАБЛИЦЫ:") fk_header.font = Font(bold=True, color="E65100") fk_header.fill = PatternFill(start_color="FFF3E0", end_color="FFF3E0", fill_type="solid") for col in range(1, 9): ws.cell(row=row, column=col).fill = PatternFill( start_color="FFF3E0", end_color="FFF3E0", fill_type="solid" ) row += 1 ws.merge_cells(f'A{row}:H{row}') ws.cell(row=row, column=1, value=", ".join(info['related_tables'])) ws.cell(row=row, column=1).font = Font(color="E65100") row += 1 # Разделитель row += 1 ws.merge_cells(f'A{row}:H{row}') ws.cell(row=row, column=1, value="") row += 1 # Ширина колонок widths = [25, 30, 40, 20, 8, 8, 10, 10] for col, width in enumerate(widths, 1): ws.column_dimensions[get_column_letter(col)].width = width # Закрепление ws.freeze_panes = 'A5' print(f"[✓] Лист 'Структура таблиц' создан: {len(created_tables)} таблиц") def _create_summary_sheet(self): """Создает лист с общей сводкой""" ws = self.wb.create_sheet("📊 Общая сводка") # ИСПРАВЛЕНО: создаем новый лист ws.merge_cells('A1:H1') ws['A1'] = "ОТЧЕТ ПО МИГРАЦИЯМ БАЗЫ ДАННЫХ" ws['A1'].font = Font(bold=True, size=16, color="2F5496") ws['A1'].alignment = Alignment(horizontal='center', vertical='center') ws.merge_cells('A2:H2') ws['A2'] = f"Сгенерирован: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ws['A2'].alignment = Alignment(horizontal='center') ws['A2'].font = Font(color="757575", size=10) if self.changes: dates = set(c.file_modified_date for c in self.changes if c.file_modified_date) comments_count = sum(1 for c in self.changes if c.programmer_comment) db_comments_count = sum(1 for c in self.changes if c.db_comment) commits_count = sum(1 for c in self.changes if c.git_commit_hash) row = 3 ws.merge_cells(f'A{row}:H{row}') ws[f'A{row}'] = (f"Период: {min(dates)} — {max(dates)} | " f"Комментариев: {comments_count} | " f"COMMENT ON: {db_comments_count} | " f"Commit'ов: {commits_count}") ws[f'A{row}'].font = Font(color="424242", size=10) ws[f'A{row}'].alignment = Alignment(horizontal='center') row = 5 ws.merge_cells(f'A{row}:H{row}') ws[f'A{row}'] = "СТАТИСТИКА ПО КАТЕГОРИЯМ" ws[f'A{row}'].font = Font(bold=True, size=12, color="2F5496") row = 6 headers = ["Категория", "Количество", "%", "Влияние", "Комментарии", "COMMENT ON", "Commit'ы", "Описание"] for col, header in enumerate(headers, 1): cell = ws.cell(row=row, column=col, value=header) cell.font = Font(bold=True, color="FFFFFF") cell.fill = self.COLORS['header_bg'] cell.alignment = Alignment(horizontal='center') category_counts = defaultdict(int) category_comments = defaultdict(int) category_db_comments = defaultdict(int) category_commits = defaultdict(int) for change in self.changes: category_counts[change.category] += 1 if change.programmer_comment: category_comments[change.category] += 1 if change.db_comment: category_db_comments[change.category] += 1 if change.git_commit_hash: category_commits[change.category] += 1 total = len(self.changes) row = 7 for category, count in sorted(category_counts.items(), key=lambda x: x[1], reverse=True): impact = self._get_category_impact(category) ws.cell(row=row, column=1, value=category.value).font = Font( color=self.COLORS['category_colors'].get(category, "000000"), bold=True) ws.cell(row=row, column=2, value=count).alignment = Alignment(horizontal='center') ws.cell(row=row, column=3, value=f"{(count / total) * 100:.1f}%" if total > 0 else "0%").alignment = Alignment( horizontal='center') impact_cell = ws.cell(row=row, column=4, value=impact) impact_cell.alignment = Alignment(horizontal='center') if impact == "HIGH": impact_cell.fill = self.COLORS['high_impact'] elif impact == "MEDIUM": impact_cell.fill = self.COLORS['medium_impact'] else: impact_cell.fill = self.COLORS['low_impact'] ws.cell(row=row, column=5, value=category_comments[category]).alignment = Alignment(horizontal='center') ws.cell(row=row, column=6, value=category_db_comments[category]).alignment = Alignment(horizontal='center') ws.cell(row=row, column=7, value=category_commits[category]).alignment = Alignment(horizontal='center') ws.cell(row=row, column=8, value=self._get_category_description(category)) row += 1 row += 1 ws.cell(row=row, column=1, value="ВСЕГО").font = Font(bold=True) ws.cell(row=row, column=2, value=total).font = Font(bold=True) widths = [30, 15, 10, 18, 15, 15, 15, 50] for col, width in enumerate(widths, 1): ws.column_dimensions[get_column_letter(col)].width = width def _create_changes_sheet(self): """Создает лист со всеми изменениями""" ws = self.wb.create_sheet("📋 Все изменения") headers = [ "№", "Категория", "Файл", "Строка", "Таблица", "Описание", "Детали", "Код", "Влияние", "Дата миграции", "Файл создан", "Файл изменен", "Git commit", "Commit message", "Git автор", "Комментарий программиста", "COMMENT ON", "Связанные таблицы", "Автор" ] for col, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col, value=header) cell.font = Font(bold=True, color="FFFFFF", size=11) cell.fill = self.COLORS['header_bg'] cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) for row, change in enumerate(self.changes, 2): ws.cell(row=row, column=1, value=change.sequence).alignment = Alignment(horizontal='center') cat_cell = ws.cell(row=row, column=2, value=change.category.value) cat_color = self.COLORS['category_colors'].get(change.category, "000000") cat_cell.font = Font(color=cat_color, bold=True) ws.cell(row=row, column=3, value=change.file_name) ws.cell(row=row, column=4, value=change.line_number).alignment = Alignment(horizontal='center') ws.cell(row=row, column=5, value=change.table_name).font = Font(bold=True) ws.cell(row=row, column=6, value=change.description) details_cell = ws.cell(row=row, column=7, value=change.details) details_cell.alignment = Alignment(wrap_text=True, vertical='top') ws.cell(row=row, column=8, value=change.code_snippet) ws.cell(row=row, column=8).font = Font(name="Courier New", size=9) impact_cell = ws.cell(row=row, column=9, value=change.impact_level) impact_cell.alignment = Alignment(horizontal='center') if change.impact_level == "HIGH": impact_cell.fill = self.COLORS['high_impact'] impact_cell.font = Font(bold=True, color="C62828") elif change.impact_level == "MEDIUM": impact_cell.fill = self.COLORS['medium_impact'] else: impact_cell.fill = self.COLORS['low_impact'] ws.cell(row=row, column=10, value=change.migration_date).alignment = Alignment(horizontal='center') ws.cell(row=row, column=11, value=change.file_created_date).alignment = Alignment(horizontal='center') mod_cell = ws.cell(row=row, column=12, value=change.file_modified_date) mod_cell.alignment = Alignment(horizontal='center') if change.file_modified_date >= self.recent_threshold: mod_cell.fill = self.COLORS['recent_change'] ws.cell(row=row, column=13, value=change.git_commit_hash).alignment = Alignment(horizontal='center') ws.cell(row=row, column=14, value=change.git_commit_message) ws.cell(row=row, column=15, value=change.git_last_author) comment_cell = ws.cell(row=row, column=16, value=change.programmer_comment) comment_cell.alignment = Alignment(wrap_text=True) if change.programmer_comment: comment_cell.fill = self.COLORS['comment_bg'] db_comment_cell = ws.cell(row=row, column=17, value=change.db_comment) db_comment_cell.alignment = Alignment(wrap_text=True) if change.db_comment: db_comment_cell.fill = self.COLORS['db_comment_bg'] related_str = ", ".join(change.related_tables) if change.related_tables else "" ws.cell(row=row, column=18, value=related_str) ws.cell(row=row, column=19, value=change.author) widths = [5, 20, 25, 8, 18, 35, 50, 40, 10, 12, 12, 12, 12, 35, 15, 40, 35, 25, 12] for col, width in enumerate(widths, 1): ws.column_dimensions[get_column_letter(col)].width = width if len(self.changes) > 0: ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}{len(self.changes) + 1}" ws.freeze_panes = 'A2' def _create_comments_sheet(self): """Создает лист с комментариями программистов""" ws = self.wb.create_sheet("💬 Комментарии") changes_with_comments = [c for c in self.changes if c.programmer_comment] ws.merge_cells('A1:H1') ws['A1'] = "КОММЕНТАРИИ ПРОГРАММИСТОВ К МИГРАЦИЯМ" ws['A1'].font = Font(bold=True, size=14, color="6A1B9A") ws['A1'].alignment = Alignment(horizontal='center') ws.merge_cells('A2:H2') ws['A2'] = f"Найдено комментариев: {len(changes_with_comments)}" ws['A2'].font = Font(color="757575") ws['A2'].alignment = Alignment(horizontal='center') if not changes_with_comments: ws.merge_cells('A4:H4') ws['A4'] = "Комментарии не найдены" ws['A4'].font = Font(color="757575", italic=True) ws['A4'].alignment = Alignment(horizontal='center') return headers = ["№", "Таблица", "Описание", "Комментарий", "Commit", "Commit message", "Файл", "Дата"] for col, header in enumerate(headers, 1): cell = ws.cell(row=4, column=col, value=header) cell.font = Font(bold=True, color="FFFFFF") cell.fill = PatternFill(start_color="6A1B9A", end_color="6A1B9A", fill_type="solid") row = 5 for change in changes_with_comments: ws.cell(row=row, column=1, value=change.sequence).alignment = Alignment(horizontal='center') ws.cell(row=row, column=2, value=change.table_name).font = Font(bold=True) ws.cell(row=row, column=3, value=change.description) comment_cell = ws.cell(row=row, column=4, value=change.programmer_comment) comment_cell.alignment = Alignment(wrap_text=True, vertical='top') comment_cell.fill = self.COLORS['comment_bg'] ws.cell(row=row, column=5, value=change.git_commit_hash) ws.cell(row=row, column=6, value=change.git_commit_message) ws.cell(row=row, column=7, value=change.file_name) ws.cell(row=row, column=8, value=change.git_last_commit_date) row += 1 widths = [5, 20, 35, 50, 12, 40, 25, 12] for col, width in enumerate(widths, 1): ws.column_dimensions[get_column_letter(col)].width = width ws.freeze_panes = 'A5' def _create_db_comments_sheet(self): """Создает лист с COMMENT ON комментариями""" ws = self.wb.create_sheet("💬 COMMENT ON") comment_changes = [c for c in self.changes if c.db_comment] ws.merge_cells('A1:F1') ws['A1'] = "КОММЕНТАРИИ ИЗ БАЗЫ ДАННЫХ (COMMENT ON)" ws['A1'].font = Font(bold=True, size=14, color="00838F") ws['A1'].alignment = Alignment(horizontal='center') ws.merge_cells('A2:F2') ws['A2'] = f"Найдено COMMENT ON комментариев: {len(comment_changes)}" ws['A2'].font = Font(color="757575") ws['A2'].alignment = Alignment(horizontal='center') if not comment_changes: ws.merge_cells('A4:F4') ws['A4'] = "COMMENT ON комментарии не найдены" ws['A4'].font = Font(color="757575", italic=True) ws['A4'].alignment = Alignment(horizontal='center') return headers = ["№", "Объект", "Тип", "Комментарий", "Файл", "Дата"] for col, header in enumerate(headers, 1): cell = ws.cell(row=4, column=col, value=header) cell.font = Font(bold=True, color="FFFFFF") cell.fill = PatternFill(start_color="00838F", end_color="00838F", fill_type="solid") row = 5 for change in comment_changes: ws.cell(row=row, column=1, value=change.sequence).alignment = Alignment(horizontal='center') ws.cell(row=row, column=2, value=change.table_name).font = Font(bold=True) if 'COLUMN' in change.description: obj_type = "Колонка" elif 'VIEW' in change.description: obj_type = "Представление" elif 'INDEX' in change.description: obj_type = "Индекс" else: obj_type = "Таблица" ws.cell(row=row, column=3, value=obj_type) comment_cell = ws.cell(row=row, column=4, value=change.db_comment) comment_cell.alignment = Alignment(wrap_text=True) comment_cell.fill = self.COLORS['db_comment_bg'] ws.cell(row=row, column=5, value=change.file_name) ws.cell(row=row, column=6, value=change.file_modified_date) row += 1 widths = [5, 30, 15, 60, 25, 12] for col, width in enumerate(widths, 1): ws.column_dimensions[get_column_letter(col)].width = width ws.freeze_panes = 'A5' def _create_by_table_sheet(self): """Создает лист с группировкой по таблицам""" ws = self.wb.create_sheet("📊 По таблицам") changes_by_table = defaultdict(list) for change in self.changes: changes_by_table[change.table_name].append(change) headers = ["Таблица", "Изменений", "Первое появление", "Последнее изменение", "Дата создания", "Дата изменения", "Связанные таблицы", "Комментарии", "COMMENT ON", "Commit", "Категории", "Файлы"] for col, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col, value=header) cell.font = Font(bold=True, color="FFFFFF") cell.fill = self.COLORS['header_bg'] row = 2 for table_name, changes in sorted(changes_by_table.items()): categories = set(c.category.value for c in changes) files = set(c.file_name for c in changes) all_related = set() all_comments = [] all_db_comments = [] all_commits = set() for change in changes: all_related.update(change.related_tables) if change.programmer_comment: all_comments.append(change.programmer_comment) if change.db_comment: all_db_comments.append(change.db_comment) if change.git_commit_hash: all_commits.add(change.git_commit_hash) first_change = min(changes, key=lambda c: c.sequence) last_change = max(changes, key=lambda c: c.sequence) ws.cell(row=row, column=1, value=table_name).font = Font(bold=True) ws.cell(row=row, column=2, value=len(changes)).alignment = Alignment(horizontal='center') ws.cell(row=row, column=3, value=f"#{first_change.sequence}") ws.cell(row=row, column=4, value=f"#{last_change.sequence}") ws.cell(row=row, column=5, value=first_change.file_created_date).alignment = Alignment(horizontal='center') ws.cell(row=row, column=6, value=last_change.file_modified_date).alignment = Alignment(horizontal='center') ws.cell(row=row, column=7, value=", ".join(sorted(all_related)) if all_related else "") comments_str = "\n".join(all_comments[:2]) if all_comments else "" ws.cell(row=row, column=8, value=comments_str) ws.cell(row=row, column=8).alignment = Alignment(wrap_text=True) db_comments_str = "\n".join(all_db_comments[:2]) if all_db_comments else "" ws.cell(row=row, column=9, value=db_comments_str) ws.cell(row=row, column=9).alignment = Alignment(wrap_text=True) ws.cell(row=row, column=10, value=", ".join(sorted(all_commits)[:3]) if all_commits else "") ws.cell(row=row, column=11, value=", ".join(categories)) ws.cell(row=row, column=12, value=", ".join(files)) row += 1 widths = [25, 12, 12, 12, 15, 15, 25, 40, 40, 20, 40, 40] for col, width in enumerate(widths, 1): ws.column_dimensions[get_column_letter(col)].width = width if row > 2: ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}{row - 1}" ws.freeze_panes = 'A2' def _create_by_file_sheet(self): """Создает лист с группировкой по файлам""" ws = self.wb.create_sheet("📁 По файлам") changes_by_file = defaultdict(list) for change in self.changes: changes_by_file[change.file_name].append(change) headers = ["Файл", "Изменений", "Таблиц", "Категории", "HIGH", "Commit", "Комментарии", "COMMENT ON", "Создан", "Изменен", "Git автор"] for col, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col, value=header) cell.font = Font(bold=True, color="FFFFFF") cell.fill = self.COLORS['header_bg'] row = 2 for file_name, changes in sorted(changes_by_file.items()): tables = set(c.table_name for c in changes) categories = set(c.category.value for c in changes) high_count = sum(1 for c in changes if c.impact_level == "HIGH") comments_count = sum(1 for c in changes if c.programmer_comment) db_comments_count = sum(1 for c in changes if c.db_comment) commits = set(c.git_commit_hash for c in changes if c.git_commit_hash) first_change = changes[0] ws.cell(row=row, column=1, value=file_name) ws.cell(row=row, column=2, value=len(changes)).alignment = Alignment(horizontal='center') ws.cell(row=row, column=3, value=len(tables)).alignment = Alignment(horizontal='center') ws.cell(row=row, column=4, value=", ".join(categories)) high_cell = ws.cell(row=row, column=5, value=high_count) high_cell.alignment = Alignment(horizontal='center') if high_count > 0: high_cell.fill = self.COLORS['high_impact'] high_cell.font = Font(bold=True, color="C62828") ws.cell(row=row, column=6, value=", ".join(sorted(commits)[:3]) if commits else "") ws.cell(row=row, column=7, value=comments_count).alignment = Alignment(horizontal='center') ws.cell(row=row, column=8, value=db_comments_count).alignment = Alignment(horizontal='center') ws.cell(row=row, column=9, value=first_change.file_created_date).alignment = Alignment(horizontal='center') mod_cell = ws.cell(row=row, column=10, value=first_change.file_modified_date) mod_cell.alignment = Alignment(horizontal='center') if first_change.file_modified_date >= self.recent_threshold: mod_cell.fill = self.COLORS['recent_change'] ws.cell(row=row, column=11, value=first_change.git_last_author) row += 1 widths = [30, 12, 15, 40, 12, 25, 12, 12, 15, 15, 15] for col, width in enumerate(widths, 1): ws.column_dimensions[get_column_letter(col)].width = width if row > 2: ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}{row - 1}" ws.freeze_panes = 'A2' def _create_impact_analysis_sheet(self): """Создает лист с анализом влияния""" ws = self.wb.create_sheet("⚠️ Анализ влияния") high_impact_changes = [c for c in self.changes if c.impact_level in ("HIGH", "MEDIUM")] high_impact_changes.sort(key=lambda c: (c.impact_level, c.file_modified_date), reverse=True) ws.merge_cells('A1:K1') ws['A1'] = "АНАЛИЗ ПОТЕНЦИАЛЬНО ОПАСНЫХ ИЗМЕНЕНИЙ" ws['A1'].font = Font(bold=True, size=14, color="C62828") ws['A1'].alignment = Alignment(horizontal='center') ws.merge_cells('A2:K2') ws['A2'] = f"Изменений с высоким/средним влиянием: {len(high_impact_changes)}" ws['A2'].font = Font(color="757575") ws['A2'].alignment = Alignment(horizontal='center') headers = ["№", "Влияние", "Категория", "Таблица", "Описание", "Файл", "Дата", "Commit", "Комментарий", "Git автор", "Рекомендации"] for col, header in enumerate(headers, 1): cell = ws.cell(row=4, column=col, value=header) cell.font = Font(bold=True, color="FFFFFF") cell.fill = PatternFill(start_color="C62828", end_color="C62828", fill_type="solid") row = 5 for change in high_impact_changes: ws.cell(row=row, column=1, value=change.sequence).alignment = Alignment(horizontal='center') impact_cell = ws.cell(row=row, column=2, value=change.impact_level) impact_cell.alignment = Alignment(horizontal='center') if change.impact_level == "HIGH": impact_cell.fill = self.COLORS['high_impact'] impact_cell.font = Font(bold=True, color="C62828") else: impact_cell.fill = self.COLORS['medium_impact'] ws.cell(row=row, column=3, value=change.category.value) ws.cell(row=row, column=4, value=change.table_name).font = Font(bold=True) ws.cell(row=row, column=5, value=change.description) ws.cell(row=row, column=6, value=f"{change.file_name}:{change.line_number}") date_cell = ws.cell(row=row, column=7, value=change.file_modified_date) date_cell.alignment = Alignment(horizontal='center') if change.file_modified_date >= self.recent_threshold: date_cell.fill = self.COLORS['recent_change'] ws.cell(row=row, column=8, value=change.git_commit_hash) comment_cell = ws.cell(row=row, column=9, value=change.programmer_comment[:150] if change.programmer_comment else "") comment_cell.alignment = Alignment(wrap_text=True) if change.programmer_comment: comment_cell.fill = self.COLORS['comment_bg'] ws.cell(row=row, column=10, value=change.git_last_author or change.author) recommendations = self._get_recommendations(change) rec_cell = ws.cell(row=row, column=11, value=recommendations) rec_cell.alignment = Alignment(wrap_text=True) row += 1 widths = [5, 10, 20, 18, 35, 25, 15, 12, 35, 15, 50] for col, width in enumerate(widths, 1): ws.column_dimensions[get_column_letter(col)].width = width ws.freeze_panes = 'A5' def _create_timeline_sheet(self): """Создает лист с хронологией""" ws = self.wb.create_sheet("📅 Хронология") timeline_changes = sorted(self.changes, key=lambda c: (c.file_modified_date, c.sequence), reverse=True) headers = ["Дата изменения", "Дата создания", "№", "Категория", "Таблица", "Описание", "Файл", "Commit", "Комментарий", "Автор"] for col, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col, value=header) cell.font = Font(bold=True, color="FFFFFF") cell.fill = PatternFill(start_color="1B5E20", end_color="1B5E20", fill_type="solid") current_date = "" row = 2 for change in timeline_changes: if change.file_modified_date != current_date: current_date = change.file_modified_date ws.merge_cells(f'A{row}:J{row}') date_cell = ws.cell(row=row, column=1, value=f"📅 {current_date}") date_cell.font = Font(bold=True, size=11, color="1B5E20") date_cell.fill = PatternFill(start_color="E8F5E9", end_color="E8F5E9", fill_type="solid") if current_date >= self.recent_threshold: date_cell.fill = self.COLORS['recent_change'] date_cell.font = Font(bold=True, size=11, color="F57F17") row += 1 ws.cell(row=row, column=1, value="") ws.cell(row=row, column=2, value=change.file_created_date).alignment = Alignment(horizontal='center') ws.cell(row=row, column=3, value=f"#{change.sequence}").alignment = Alignment(horizontal='center') ws.cell(row=row, column=4, value=change.category.value) ws.cell(row=row, column=5, value=change.table_name).font = Font(bold=True) ws.cell(row=row, column=6, value=change.description) ws.cell(row=row, column=7, value=change.file_name) ws.cell(row=row, column=8, value=change.git_commit_hash).alignment = Alignment(horizontal='center') comment_text = change.programmer_comment or change.db_comment comment_cell = ws.cell(row=row, column=9, value=comment_text[:100] if comment_text else "") comment_cell.alignment = Alignment(wrap_text=True) if comment_text: comment_cell.fill = self.COLORS['comment_bg'] ws.cell(row=row, column=10, value=change.author) row += 1 widths = [15, 15, 8, 20, 18, 35, 25, 12, 30, 12] for col, width in enumerate(widths, 1): ws.column_dimensions[get_column_letter(col)].width = width ws.freeze_panes = 'A2' def _create_recent_changes_sheet(self): """Создает лист с недавними изменениями""" ws = self.wb.create_sheet("🕐 Недавние изменения") recent_changes = [c for c in self.changes if c.file_modified_date >= self.recent_threshold] ws.merge_cells('A1:J1') ws['A1'] = f"НЕДАВНИЕ ИЗМЕНЕНИЯ (после {self.recent_threshold})" ws['A1'].font = Font(bold=True, size=14, color="F57F17") ws['A1'].alignment = Alignment(horizontal='center') ws.merge_cells('A2:J2') ws['A2'] = f"Найдено изменений: {len(recent_changes)}" ws['A2'].font = Font(color="757575") ws['A2'].alignment = Alignment(horizontal='center') if not recent_changes: ws.merge_cells('A4:J4') ws['A4'] = "Недавних изменений не найдено" ws['A4'].font = Font(color="757575", italic=True) ws['A4'].alignment = Alignment(horizontal='center') return headers = ["№", "Дата", "Категория", "Таблица", "Описание", "Файл", "Commit", "Комментарий", "Git автор", "Влияние"] for col, header in enumerate(headers, 1): cell = ws.cell(row=4, column=col, value=header) cell.font = Font(bold=True, color="FFFFFF") cell.fill = PatternFill(start_color="F57F17", end_color="F57F17", fill_type="solid") recent_changes.sort(key=lambda c: c.file_modified_date, reverse=True) row = 5 for change in recent_changes: ws.cell(row=row, column=1, value=change.sequence).alignment = Alignment(horizontal='center') date_cell = ws.cell(row=row, column=2, value=change.file_modified_date) date_cell.alignment = Alignment(horizontal='center') date_cell.fill = self.COLORS['recent_change'] ws.cell(row=row, column=3, value=change.category.value) ws.cell(row=row, column=4, value=change.table_name).font = Font(bold=True) ws.cell(row=row, column=5, value=change.description) ws.cell(row=row, column=6, value=f"{change.file_name}:{change.line_number}") ws.cell(row=row, column=7, value=change.git_commit_hash) comment_text = change.programmer_comment or change.db_comment comment_cell = ws.cell(row=row, column=8, value=comment_text[:150] if comment_text else "") comment_cell.alignment = Alignment(wrap_text=True) if comment_text: comment_cell.fill = self.COLORS['comment_bg'] ws.cell(row=row, column=9, value=change.git_last_author or change.author) impact_cell = ws.cell(row=row, column=10, value=change.impact_level) impact_cell.alignment = Alignment(horizontal='center') if change.impact_level == "HIGH": impact_cell.fill = self.COLORS['high_impact'] impact_cell.font = Font(bold=True, color="C62828") row += 1 widths = [5, 15, 20, 18, 35, 25, 12, 35, 15, 10] for col, width in enumerate(widths, 1): ws.column_dimensions[get_column_letter(col)].width = width ws.freeze_panes = 'A5' def _get_category_impact(self, category: ChangeCategory) -> str: high_impact = {ChangeCategory.DROPPED_TABLE, ChangeCategory.DROPPED_COLUMN} medium_impact = {ChangeCategory.NEW_TABLE, ChangeCategory.NEW_INDEX, ChangeCategory.NEW_FOREIGN_KEY, ChangeCategory.MODIFIED_COLUMN} if category in high_impact: return "HIGH" elif category in medium_impact: return "MEDIUM" return "LOW" def _get_category_description(self, category: ChangeCategory) -> str: descriptions = { ChangeCategory.NEW_TABLE: "Создание новой таблицы", ChangeCategory.DROPPED_TABLE: "Удаление таблицы", ChangeCategory.NEW_COLUMN: "Добавление колонки", ChangeCategory.DROPPED_COLUMN: "Удаление колонки", ChangeCategory.MODIFIED_COLUMN: "Изменение колонки", ChangeCategory.NEW_INDEX: "Создание индекса", ChangeCategory.NEW_FOREIGN_KEY: "Добавление внешнего ключа", ChangeCategory.RAW_SQL: "Выполнение SQL", ChangeCategory.COMMENT: "Добавление комментария", } return descriptions.get(category, "Прочее изменение") def _get_recommendations(self, change: MigrationChange) -> str: recommendations = [] if change.category == ChangeCategory.DROPPED_TABLE: recommendations.append("⚠️ Проверьте зависимости (FK, VIEW)") recommendations.append("⚠️ Создайте резервную копию") elif change.category == ChangeCategory.DROPPED_COLUMN: recommendations.append("⚠️ Проверьте использование в индексах и FK") recommendations.append("⚠️ Убедитесь, что ORM не маппит колонку") elif change.category == ChangeCategory.NEW_INDEX: if "без CONCURRENTLY" in change.description: recommendations.append("❌ КРИТИЧНО: Используйте CREATE INDEX CONCURRENTLY") return "\n".join(recommendations) if recommendations else "Особых рекомендаций нет" # =================================================================== # HTML-ОТЧЕТ (из данных в памяти, без обратного парсинга Excel) # =================================================================== class HTMLReportGenerator: """Генератор HTML-отчета напрямую из списка изменений (change_to_dict). Вкладки: 📖 Структура таблиц — карточки таблиц (колонки, FK, история изменений) 📋 Все изменения — единый список с поиском 🗂 Батчи — группировка по дате загрузки файлов в репозиторий Фильтры: батч (дата загрузки), период, поиск. Модальное окно с деталями. """ NO_DATE = "без даты" def __init__(self, output_path: str = "report.html"): self.output_path = output_path self.changes: List[Dict[str, Any]] = [] self.tables: Dict[str, Dict[str, Any]] = {} self.batches: Dict[str, List[Dict[str, Any]]] = defaultdict(list) self.all_dates: set = set() self.changes_by_id: Dict[int, Dict[str, Any]] = {} def load_from_dicts(self, change_dicts: List[Dict[str, Any]]) -> "HTMLReportGenerator": """Строит таблицы и батчи из списка change_to_dict""" self.changes = list(change_dicts) tables_tmp: Dict[str, Dict[str, Any]] = {} for ch in self.changes: self.changes_by_id[ch["sequence"]] = ch self.batches[ch.get("batch") or self.NO_DATE].append(ch) for key in ("date", "migration_date", "created_date", "modified_date", "batch"): if ch.get(key): self.all_dates.add(ch[key]) table_name = ch.get("table") or "разные" table = tables_tmp.setdefault(table_name, { "name": table_name, "comment": "", "columns": OrderedDict(), "related": set(), "files": set(), "batches": set(), "dates": set(), "changes": [], }) table["changes"].append(ch) if ch.get("file"): table["files"].add(ch["file"]) if ch.get("batch"): table["batches"].add(ch["batch"]) for d in (ch.get("date"), ch.get("created_date"), ch.get("modified_date")): if d: table["dates"].add(d) if ch.get("related_tables"): for rt in re.split(r',\s*', ch["related_tables"]): rt = rt.strip() if rt and rt != table_name: table["related"].add(rt) # Структура колонок — только из изменений NEW_TABLE if ch.get("category") == ChangeCategory.NEW_TABLE.value: for col_name, col_type in (ch.get("columns_info") or {}).items(): table["columns"].setdefault(col_name, col_type) if not table["comment"]: table["comment"] = ch.get("db_comment") or ch.get("programmer_comment") or "" self.tables = tables_tmp print(f"[✓] Данные загружены: таблиц {len(self.tables)}, " f"изменений {len(self.changes)}, батчей {len(self.batches)}") return self def generate(self): """Записывает HTML-файл""" print("\n[*] Генерирую HTML-отчет...") output = Path(self.output_path) output.parent.mkdir(parents=True, exist_ok=True) html = self._build_html() with open(output, "w", encoding="utf-8") as f: f.write(html) print(f"[✓] HTML сохранен: {output}") print(f" Размер: {os.path.getsize(output) / 1024:.1f} KB") # ---------- вспомогательные визуальные хелперы ---------- @staticmethod def _impact_badge(impact: str) -> str: if impact == "HIGH": return 'HIGH' if impact == "MEDIUM": return 'MEDIUM' return "" @staticmethod def _category_icon(category: str) -> str: cat = category.lower() if "таблиц" in cat: return "🆕" if "новая" in cat else "🗑" if "колонк" in cat: return "➕" if "новая" in cat else ("➖" if "удаленная" in cat else "✏️") if "индекс" in cat: return "📑" if "внешн" in cat: return "🔗" if "коммент" in cat: return "💬" if "sql" in cat: return "💻" return "📌" def _sorted_batches(self) -> List[str]: """Батчи по дате загрузки: свежие сверху, 'без даты' в конце""" batches = sorted((b for b in self.batches if b != self.NO_DATE), reverse=True) if self.NO_DATE in self.batches: batches.append(self.NO_DATE) return batches def _change_entry_html(self, ch: Dict[str, Any]) -> str: dates = " ".join(sorted({d for d in ( ch.get("date"), ch.get("created_date"), ch.get("modified_date") ) if d})) icon = self._category_icon(ch.get("category", "")) return ( f'
' f'
{icon} {ch.get("description", "")}
' f'
Таблица: {ch.get("table", "")} | {ch.get("date", "")} | ' f'{ch.get("file", "")} {self._impact_badge(ch.get("impact", ""))}
' f'
👆 Кликните для деталей
' f'
' ) # ---------- вкладка «Структура таблиц» ---------- def _generate_table_cards_html(self) -> str: if not self.tables: return "

Нет данных о таблицах

" cards = [self._generate_single_card(self.tables[name]) for name in sorted(self.tables)] return "\n".join(cards) def _generate_single_card(self, table: Dict[str, Any]) -> str: # Колонки if table["columns"]: cols = [] for col_name in sorted(table["columns"]): col_type = table["columns"][col_name] upper = col_type.upper() badges = [] if "PK" in upper or "PRIMARY" in upper: badges.append('PK') clean_type = col_type fk_match = re.search(r'REFERENCES\s+(\w+)', col_type, re.IGNORECASE) if fk_match: badges.append(f'FK→{fk_match.group(1)}') for attr in ["NOT NULL", "NULL", "PRIMARY KEY", "PK", "REFERENCES", "FK", "DEFAULT", "UNIQUE", "CHECK"]: clean_type = re.sub(rf'\s*{attr}.*$', '', clean_type, flags=re.IGNORECASE) badges_html = " ".join(badges) cols.append( f'{col_name}' f'{clean_type.strip()}{badges_html}') columns_html = ('' '' + "".join(cols) + '
КолонкаТипСвязи
') else: columns_html = "

Колонки не определены

" # История изменений (последние 10) changes_html = "" recent = sorted(table["changes"], key=lambda c: c.get("sequence", 0), reverse=True)[:10] if recent: entries = "\n".join(self._change_entry_html(ch) for ch in recent) changes_html = (f'
' f'📜 История (кликните для деталей):{entries}
') fk_html = "" if table["related"]: links = "".join(f'→ {rt}' for rt in sorted(table["related"])) fk_html = f'' comment_html = f'
💬 {table["comment"]}
' if table["comment"] else "" dates = sorted(table["dates"]) info_parts = [] if dates: info_parts.append(f"📅 {min(dates)}") info_parts.append(f"✏️ {max(dates)}") info_html = f'
{" | ".join(info_parts)}
' if info_parts else "" dates_attr = " ".join(sorted(table["dates"])) batches_attr = " ".join(sorted(table["batches"])) return f"""

📋 {table["name"]}▼

{comment_html} {info_html} {columns_html} {fk_html} {changes_html}
""" # ---------- вкладка «Батчи» ---------- def _generate_batches_html(self) -> str: parts = [] for batch in self._sorted_batches(): batch_changes = self.batches[batch] files = sorted(set(ch.get("file", "") for ch in batch_changes if ch.get("file"))) entries = "\n".join(self._change_entry_html(ch) for ch in sorted(batch_changes, key=lambda c: c["sequence"])) files_html = "".join(f'📄 {fname}' for fname in files) parts.append(f"""
🗂 {batch} {len(files)} файлов • {len(batch_changes)} изменений
{files_html}
{entries}
""") return "\n".join(parts) if parts else "

Нет данных о батчах

" # ---------- главный шаблон ---------- def _generate_date_options(self) -> str: return "\n".join(f'' for d in sorted(self.all_dates, reverse=True)) def _generate_batch_options(self) -> str: return "\n".join(f'' for b in self._sorted_batches()) def _get_total_columns(self) -> int: return sum(len(t["columns"]) for t in self.tables.values()) def _build_html(self) -> str: tables_cards_html = self._generate_table_cards_html() changes_html = "\n".join(self._change_entry_html(ch) for ch in sorted(self.changes, key=lambda c: c.get("sequence", 0), reverse=True)) or "

Нет изменений

" batches_html = self._generate_batches_html() date_options = self._generate_date_options() batch_options = self._generate_batch_options() return f""" Отчет Миграций

📊 Отчет о всех миграциях

Сгенерировано: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

{len(self.tables)}
Таблиц
{self._get_total_columns()}
Колонок
{len(self.changes)}
Изменений
{len(self.batches)}
Батчей загрузки
🔍 Фильтры
Батч:
Период:
Быстрый выбор:
{batches_html}
{tables_cards_html}
{changes_html}
""" # =================================================================== # ПАЙПЛАЙН: анализ → Excel + кэш → HTML # =================================================================== def change_to_dict(c: "MigrationChange") -> Dict[str, Any]: """Сериализует изменение для HTML-генератора и кэша JSON""" created = c.file_created_date or "" modified = c.file_modified_date or "" migration = c.migration_date or "" return { "sequence": c.sequence, "category": c.category.value, "table": c.table_name, "description": c.description, "details": c.details, "code_snippet": c.code_snippet, "impact": c.impact_level, "file": c.file_name, "line": c.line_number, "migration_date": migration, "created_date": created, "modified_date": modified, "date": modified or created or migration, "batch": created or migration or modified or HTMLReportGenerator.NO_DATE, "git_commit": c.git_commit_hash, "commit_message": c.git_commit_message, "git_author": c.git_last_author, "author": c.author, "programmer_comment": c.programmer_comment, "db_comment": c.db_comment, "related_tables": ", ".join(c.related_tables) if c.related_tables else "", "columns_info": dict(c.columns_info) if c.columns_info else {}, } def _git_pull(repo_dir: Path): """Обновляет локальную копию репозитория миграций""" print(f"[*] Обновляю репозиторий: {repo_dir}") result = subprocess.run( ["git", "-C", str(repo_dir), "pull", "--ff-only"], capture_output=True, text=True, timeout=300 ) if result.returncode != 0: print(f"❌ git pull не выполнен:\n{result.stderr}") sys.exit(3) print(result.stdout.strip() or "[✓] Репозиторий обновлен") def analyze_directory(migrations_dir: Path, debug: bool = False, use_git: bool = True) -> List["MigrationChange"]: """Анализирует директорию с .kt миграциями""" if not migrations_dir.exists(): print(f"❌ Директория не найдена: {migrations_dir}", file=sys.stderr) sys.exit(2) print("=" * 60) print("🔍 АНАЛИЗ МИГРАЦИЙ") print("=" * 60) analyzer = MigrationAnalyzer(migrations_dir, debug=debug, use_git=use_git) changes = analyzer.analyze() if not changes: print("\n❌ Изменений не найдено!") sys.exit(1) return changes def generate_excel_file(changes: List["MigrationChange"], output_path: Path) -> Path: """Генерирует Excel-отчет + копию latest.xlsx""" output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) generator = ExcelReportGenerator(changes, str(output_path)) generator.generate() shutil.copy2(output_path, output_path.parent / "latest.xlsx") print(f"[✓] Копия последнего отчета: {output_path.parent / 'latest.xlsx'}") return output_path def read_cache(cache_path: Path) -> List[Dict[str, Any]]: """Читает кэш изменений (JSON)""" with open(cache_path, "r", encoding="utf-8") as f: return json.load(f) def write_cache(change_dicts: List[Dict[str, Any]], cache_path: Path) -> Path: """Сохраняет сериализованные изменения в JSON-кэш (прослойка между шагами DAG и HTML)""" cache_path = Path(cache_path) cache_path.parent.mkdir(parents=True, exist_ok=True) with open(cache_path, "w", encoding="utf-8") as f: json.dump(change_dicts, f, ensure_ascii=False, indent=1) print(f"[✓] Кэш изменений сохранен: {cache_path}") return cache_path def generate_html_file(change_dicts: List[Dict[str, Any]], output_path: Path) -> Path: """Генерирует HTML-отчет + копию latest.html""" output_path = Path(output_path) HTMLReportGenerator(str(output_path)).load_from_dicts(change_dicts).generate() shutil.copy2(output_path, output_path.parent / "latest.html") print(f"[✓] Копия последнего отчета: {output_path.parent / 'latest.html'}") return output_path def cmd_run(args): """Полный цикл: [git pull] → анализ → кэш → HTML (+ Excel с --excel)""" migrations_dir = Path(args.dir) repo_dir = Path(args.repo_dir) if args.repo_dir else Path(args.dir).resolve().parent if not args.skip_pull: _git_pull(repo_dir) changes = analyze_directory(migrations_dir, debug=args.debug, use_git=not args.no_git) change_dicts = [change_to_dict(c) for c in changes] timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_dir = Path(args.output_dir) cache_file = output_dir / f"migration_report_{timestamp}.changes.json" html_file = output_dir / f"migration_report_{timestamp}.html" report_file = output_dir / f"migration_report_{timestamp}.xlsx" write_cache(change_dicts, cache_file) if args.excel: generate_excel_file(changes, report_file) generate_html_file(change_dicts, html_file) print(f"\nCACHE_FILE: {cache_file.resolve()}") if args.excel: print(f"REPORT_FILE: {report_file.resolve()}") print(f"HTML_FILE: {html_file.resolve()}") def cmd_analyze(args): """Только анализ + кэш изменений (без Excel и HTML)""" changes = analyze_directory(Path(args.dir), debug=args.debug, use_git=not args.no_git) change_dicts = [change_to_dict(c) for c in changes] cache_file = write_cache(change_dicts, Path(args.output)) print(f"\nCACHE_FILE: {cache_file.resolve()}") def cmd_excel(args): """Анализ + Excel-отчет + кэш изменений (для последующей генерации HTML)""" migrations_dir = Path(args.dir) changes = analyze_directory(migrations_dir, debug=args.debug, use_git=not args.no_git) change_dicts = [change_to_dict(c) for c in changes] report_file = generate_excel_file(changes, Path(args.output)) cache_file = write_cache(change_dicts, Path(args.output).with_suffix(".changes.json")) print(f"\nREPORT_FILE: {report_file.resolve()}") print(f"CACHE_FILE: {cache_file.resolve()}") def cmd_html(args): """HTML-отчет из кэша изменений""" cache_path = Path(args.cache) if not cache_path.exists(): print(f"❌ Файл кэша не найден: {cache_path}", file=sys.stderr) sys.exit(2) change_dicts = read_cache(cache_path) html_file = generate_html_file(change_dicts, Path(args.output)) print(f"\nHTML_FILE: {html_file.resolve()}") def main(): parser = argparse.ArgumentParser( prog="migration_reporter", description="Единый инструмент анализа Kotlin-миграций: Excel + HTML отчеты", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Примеры: python migration_reporter.py run --dir pgsql --skip-pull python migration_reporter.py run --dir pgsql python migration_reporter.py excel --dir pgsql --output reports/report.xlsx python migration_reporter.py html --cache reports/report.changes.json --output reports/report.html """ ) sub = parser.add_subparsers(dest="command", required=True) p_run = sub.add_parser("run", help="Полный цикл: [git pull] → анализ → кэш → HTML (+ Excel с --excel)") p_run.add_argument("--dir", default="pgsql", help="Директория с .kt миграциями (default: pgsql)") p_run.add_argument("--repo-dir", default=None, help="Корень git-репозитория для pull (default: родитель --dir)") p_run.add_argument("--output-dir", "-o", default="reports", help="Папка для отчетов (default: reports)") p_run.add_argument("--skip-pull", action="store_true", help="Не выполнять git pull") p_run.add_argument("--excel", action="store_true", help="Генерировать Excel-отчет (по умолчанию выключен)") p_run.add_argument("--no-git", action="store_true", help="Не использовать git в анализе") p_run.add_argument("--debug", action="store_true", help="Детальная диагностика") p_analyze = sub.add_parser("analyze", help="Только анализ + кэш изменений (шаг DAG)") p_analyze.add_argument("--dir", default="pgsql", help="Директория с .kt миграциями") p_analyze.add_argument("--output", required=True, help="Путь к файлу кэша (.changes.json)") p_analyze.add_argument("--no-git", action="store_true", help="Не использовать git в анализе") p_analyze.add_argument("--debug", action="store_true", help="Детальная диагностика") p_excel = sub.add_parser("excel", help="Анализ + Excel-отчет + кэш изменений") p_excel.add_argument("--dir", default="pgsql", help="Директория с .kt миграциями") p_excel.add_argument("--output", required=True, help="Путь к выходному Excel-файлу") p_excel.add_argument("--no-git", action="store_true", help="Не использовать git в анализе") p_excel.add_argument("--debug", action="store_true", help="Детальная диагностика") p_html = sub.add_parser("html", help="HTML-отчет из кэша изменений") p_html.add_argument("--cache", required=True, help="Путь к файлу кэша (.changes.json)") p_html.add_argument("--output", required=True, help="Путь к выходному HTML-файлу") args = parser.parse_args() if args.command == "run": cmd_run(args) elif args.command == "analyze": cmd_analyze(args) elif args.command == "excel": cmd_excel(args) elif args.command == "html": cmd_html(args) if __name__ == "__main__": main()