commit 71eda2ec3fc731f089069825e13e2a7e59dde8b8 Author: DokoGen Date: Mon Aug 3 21:46:55 2026 +0400 Исходный код DokoGen (генератор документов 152-ФЗ) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01aac8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.pyc +__pycache__/ +.autosave/ +Сгенерированные_документы/ +*.result.docx diff --git a/build_exe.bat b/build_exe.bat new file mode 100644 index 0000000..ea328ed --- /dev/null +++ b/build_exe.bat @@ -0,0 +1,43 @@ +@echo off +chcp 65001 >nul +title Build DokoGen.exe +echo ============================================ +echo Building DokoGen into a single EXE file +echo ============================================ +echo. + +cd /d "%~dp0" + +echo [1/3] Installing PyInstaller... +py -m pip install --upgrade pyinstaller +if errorlevel 1 ( + echo ERROR: failed to install PyInstaller + pause + exit /b 1 +) + +echo. +echo [2/3] Installing dependencies... +py -m pip install -r requirements.txt +if errorlevel 1 ( + echo ERROR: failed to install dependencies + pause + exit /b 1 +) + +echo. +echo [3/3] Building DokoGen.exe (1-3 minutes)... +py -m PyInstaller --clean --noconfirm dokogen.spec +if errorlevel 1 ( + echo ERROR: build failed + pause + exit /b 1 +) + +echo. +echo ============================================ +echo DONE! File: dist\DokoGen.exe +echo Copy it to any Windows PC and run it. +echo Python is NOT required on target PC. +echo ============================================ +pause diff --git a/dokogen/__init__.py b/dokogen/__init__.py new file mode 100644 index 0000000..a55ba29 --- /dev/null +++ b/dokogen/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Пакет генератора документов 152-ФЗ""" + +from .models import Company, CommissionMember, InformationSystem, PaperDocument +from .declension import inflect_name, decline, company_name_decline, get_short_fio, get_initials +from .variables import build_replacements, VARIABLE_DEFS +from .generator import process_template + +# Импорт из Excel +from . import importer + +__version__ = "1.0.0" +__app_name__ = "ДоКоГеНеРаТоР" diff --git a/dokogen/__main__.py b/dokogen/__main__.py new file mode 100644 index 0000000..0f12052 --- /dev/null +++ b/dokogen/__main__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Точка входа""" + +import sys +import os + +# Добавляем путь к пакету +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from ui import main + +if __name__ == "__main__": + main() diff --git a/dokogen/api_settings.py b/dokogen/api_settings.py new file mode 100644 index 0000000..cb5eac0 --- /dev/null +++ b/dokogen/api_settings.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +"""DokoGen — настройки API для внешних сервисов проверки. + +Хранятся в JSON рядом с программой (api_settings.json). +Поля: +- dadata_token — API-ключ DaData (проверка организации по ИНН) +- ai_api_key — API-ключ ИИ (OpenAI-совместимый: DeepSeek, ChatGPT и др.) +- ai_model — модель ИИ (deepseek-chat, gpt-4o-mini, gpt-4o и т.п.) +- ai_base_url — адрес API ИИ (по умолчанию DeepSeek) +""" + +import os +import sys +import json + +DEFAULT_SETTINGS = { + 'dadata_token': '', + 'ai_api_key': '', + 'ai_model': 'deepseek-chat', + 'ai_base_url': 'https://api.deepseek.com', +} + +# Старые ключи (для совместимости со старым файлом api_settings.json) +_LEGACY_KEYS = { + 'deepseek_api_key': 'ai_api_key', + 'deepseek_model': 'ai_model', +} + + +def default_api_settings_path(): + """Путь к файлу настроек: рядом с exe (frozen) или рядом с модулем.""" + if getattr(sys, 'frozen', False): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(base, 'api_settings.json') + + +def load_api_settings(path=None): + """Загрузка настроек API из JSON-файла. Если файла нет — дефолт.""" + if path is None: + path = default_api_settings_path() + if not path or not os.path.exists(path): + return dict(DEFAULT_SETTINGS) + try: + with open(path, encoding='utf-8-sig') as f: + loaded = json.load(f) + merged = dict(DEFAULT_SETTINGS) + for k, v in loaded.items(): + # переносим старые ключи в новые + if k in _LEGACY_KEYS and not loaded.get(_LEGACY_KEYS[k]): + merged[_LEGACY_KEYS[k]] = v + elif k in merged: + merged[k] = v + return merged + except Exception: + return dict(DEFAULT_SETTINGS) + + +def save_api_settings(settings, path=None): + """Сохранение настроек API. Возвращает True/False.""" + if path is None: + path = default_api_settings_path() + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w', encoding='utf-8') as f: + json.dump(settings, f, ensure_ascii=False, indent=2) + return True + except Exception: + return False diff --git a/dokogen/data/Опросный_лист_152_ФЗ.xlsx b/dokogen/data/Опросный_лист_152_ФЗ.xlsx new file mode 100644 index 0000000..c4ecf12 Binary files /dev/null and b/dokogen/data/Опросный_лист_152_ФЗ.xlsx differ diff --git a/dokogen/declension.py b/dokogen/declension.py new file mode 100644 index 0000000..2ba8fe6 --- /dev/null +++ b/dokogen/declension.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Склонение (pymorphy3 + правила для ФИО/организаций)""" + +from functools import lru_cache +from typing import Dict +import re + +try: + import pymorphy3 + morph = pymorphy3.MorphAnalyzer() + MORPH_AVAILABLE = True +except ImportError: + morph = None + MORPH_AVAILABLE = False + +CASES = ["nomn", "gent", "datv", "accs", "ablt", "loct"] +CASE_MAP = { + "nominative": "nomn", "им": "nomn", + "genitive": "gent", "род": "gent", + "dative": "datv", "дат": "datv", + "accusative": "accs", "вин": "accs", + "instrumental": "ablt", "твор": "ablt", + "prepositional": "loct", "пр": "loct", +} +CASE_NAMES = { + "nomn": "Именительный (Кто? Что?)", + "gent": "Родительный (Кого? Чего?)", + "datv": "Дательный (Кому? Чему?)", + "accs": "Винительный (Кого? Что?)", + "ablt": "Творительный (Кем? Чем?)", + "loct": "Предложный (О ком? О чём?)", +} +CASE_SHORT = { + "nomn": "им.п.", "gent": "род.п.", "datv": "дат.п.", + "accs": "вин.п.", "ablt": "твор.п.", "loct": "пр.п.", +} + + +def _normalize_case(case: str) -> str: + return CASE_MAP.get(case, "nomn" if case not in CASES else case) + + +def inflect_name(name: str) -> Dict[str, str]: + """Склонение названия организации по всем падежам.""" + return {gr: company_name_decline(name, gr) for gr in CASES} + + +@lru_cache(maxsize=512) +def company_name_decline(name: str, case: str) -> str: + """Склонение названия организации.""" + if not name: + return "" + case_gr = _normalize_case(case) + if case_gr == "nomn": + return name + + m = re.match(r'^(.+?)\s*([\u00ab\"].*[\u00bb\"])$', name.strip()) + if m: + prefix = m.group(1).strip() + quoted = m.group(2) + if prefix: + return decline(prefix, case_gr, decline_all=True) + " " + quoted + return name + return decline(name, case_gr, decline_all=True) + + +@lru_cache(maxsize=512) +def decline(text: str, case: str, decline_all: bool = False) -> str: + """Склонение строки (ФИО, фразы) по падежам.""" + if not text: + return "" + case_gr = _normalize_case(case) + if case_gr == "nomn": + return text + + clean_text, quoted = _extract_quoted(text) + tokens = re.split(r"(\s+)", clean_text) + content_tokens = [t for t in tokens if not t.isspace() and t.strip()] + + only_first = False + if not decline_all and len(content_tokens) >= 2: + for ct in content_tokens[1:]: + if ct[0].islower(): + only_first = True + break + + result_parts = [] + content_idx = 0 + for token in tokens: + if token.isspace() or not token.strip(): + result_parts.append(token) + continue + stripped = token.strip() + if only_first and content_idx > 0: + result_parts.append(token) + content_idx += 1 + continue + if _is_quoted(stripped): + result_parts.append(token) + content_idx += 1 + continue + if stripped.isupper() and len(stripped) > 1: + result_parts.append(token) + content_idx += 1 + continue + inflected = _inflect_word(stripped, case_gr) + if stripped[0].isupper(): + inflected = inflected[0].upper() + inflected[1:] + result_parts.append(inflected) + content_idx += 1 + + return _restore_quoted("".join(result_parts), quoted) + + +def _inflect_word(word: str, case_gr: str) -> str: + """Склонение одного слова через pymorphy3 с fallback на ручные правила.""" + if not word or len(word) < 2 or case_gr == "nomn": + return word + if not MORPH_AVAILABLE or morph is None: + return _rules_decline(word, case_gr) or word + try: + parsed = morph.parse(word) + if not parsed: + return _rules_decline(word, case_gr) or word + best = parsed[0] + inflected = best.inflect({case_gr}) + if inflected: + return inflected.word + return _rules_decline(word, case_gr) or word + except Exception: + return _rules_decline(word, case_gr) or word + + +def _rules_decline(word: str, case_gr: str) -> str: + """Ручное склонение фамилий.""" + w = word.lower() + # Мужские фамилии на -ов/-ев/-ёв + if w.endswith(('ов', 'ев', 'ёв')) and not w.endswith(('ова', 'ева', 'ёва')): + return { + 'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а', + 'ablt': word + 'ым', 'loct': word + 'е' + }.get(case_gr, word) + # Мужские фамилии на -ин + if w.endswith('ин') and not w.endswith('ина'): + return { + 'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а', + 'ablt': word + 'ым', 'loct': word + 'е' + }.get(case_gr, word) + # Женские фамилии на -ова/-ева/-ёва + if w.endswith(('ова', 'ева', 'ёва')): + stem = word[:-1] + return { + 'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у', + 'ablt': stem + 'ой', 'loct': stem + 'ой' + }.get(case_gr, word) + # Женские фамилии на -ина + if w.endswith('ина'): + stem = word[:-1] + return { + 'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у', + 'ablt': stem + 'ой', 'loct': stem + 'ой' + }.get(case_gr, word) + return word + + +def _extract_quoted(text: str): + """Извлекает подстроки в кавычках, заменяет плейсхолдерами.""" + if not text: + return text, {} + pattern = re.compile(r'(\u00ab[^\u00bb]*\u00bb|"[^"]*")') + placeholders = {} + counter = [0] + + def _replace(m): + ph = f'__Q_{counter[0]}__' + placeholders[ph] = m.group(0) + counter[0] += 1 + return ph + + return pattern.sub(_replace, text), placeholders + + +def _restore_quoted(text: str, placeholders: dict) -> str: + for ph, original in placeholders.items(): + text = text.replace(ph, original) + return text + + +def _is_quoted(word: str) -> bool: + w = word.strip() + if not w: + return False + return (w.startswith("\u00ab") and w.endswith("\u00bb")) or \ + (w.startswith('"') and w.endswith('"')) + + +# ==================== ФОРМАТИРОВАНИЕ ФИО ==================== + +def _initial(word: str) -> str: + """Инициал из слова: если уже с точкой — вернуть как есть, + иначе первая буква + точка. «В.Н.» → «В.Н.», «Владимир» → «В.»""" + w = word.strip() + if not w: + return '' + if w.endswith('.'): + return w + return w[0] + '.' + + +def get_short_fio(fio: str) -> str: + """Фамилия И.О.""" + if not fio: + return "" + parts = fio.split() + if len(parts) >= 2: + initials = _initial(parts[1]) + if len(parts) >= 3: + initials += _initial(parts[2]) + return parts[0] + " " + initials + return fio + + +def get_initials(fio: str) -> str: + """И.О. Фамилия""" + if not fio: + return "" + parts = fio.split() + if len(parts) >= 2: + initials = _initial(parts[1]) + if len(parts) >= 3: + initials += _initial(parts[2]) + return initials + " " + parts[0] + return fio diff --git a/dokogen/dictionaries/defense_tools.json b/dokogen/dictionaries/defense_tools.json new file mode 100644 index 0000000..5b4a952 --- /dev/null +++ b/dokogen/dictionaries/defense_tools.json @@ -0,0 +1,14 @@ +[ + "Антивирусное ПО", + "СКЗИ (криптография)", + "Межсетевой экран", + "СЗИ от НСД", + "СОВ (обнаружение вторжений)", + "Доверенная загрузка", + "SIEM-система", + "DLP-система", + "VPN", + "Система парольной защиты", + "Антиспам", + "Средства резервного копирования" +] diff --git a/dokogen/dictionaries/pd_actions.json b/dokogen/dictionaries/pd_actions.json new file mode 100644 index 0000000..b66179a --- /dev/null +++ b/dokogen/dictionaries/pd_actions.json @@ -0,0 +1,22 @@ +[ + "Сбор персональных данных", + "Запись персональных данных", + "Систематизация персональных данных", + "Накопление персональных данных", + "Хранение персональных данных", + "Уточнение (обновление, изменение) персональных данных", + "Извлечение персональных данных", + "Использование персональных данных", + "Передача (распространение, предоставление, доступ) персональных данных", + "Обезличивание персональных данных", + "Блокирование персональных данных", + "Удаление персональных данных", + "Уничтожение персональных данных", + "Трансграничная передача персональных данных", + "Автоматизированная обработка персональных данных", + "Неавтоматизированная обработка персональных данных", + "Смешанная обработка персональных данных", + "Формирование и ведение баз данных", + "Передача данных третьим лицам", + "Внутренняя передача данных между подразделениями" +] diff --git a/dokogen/dictionaries/pd_categories.json b/dokogen/dictionaries/pd_categories.json new file mode 100644 index 0000000..861bc5c --- /dev/null +++ b/dokogen/dictionaries/pd_categories.json @@ -0,0 +1,8 @@ +[ + "работники оператора", + "клиенты", + "контрагенты", + "родственники работников", + "соискатели", + "иные лица" +] diff --git a/dokogen/dictionaries/pd_items.json b/dokogen/dictionaries/pd_items.json new file mode 100644 index 0000000..be6fe4d --- /dev/null +++ b/dokogen/dictionaries/pd_items.json @@ -0,0 +1,28 @@ +[ + "Фамилия, имя, отчество", + "Дата рождения", + "Место рождения", + "Паспортные данные", + "Адрес регистрации", + "Адрес проживания", + "ИНН", + "СНИЛС", + "Номер телефона", + "Адрес электронной почты", + "Образование", + "Профессия", + "Сведения о доходах", + "Семейное положение", + "Состав семьи", + "Состояние здоровья", + "Национальность", + "Гражданство", + "Сведения о судимости", + "Фотография", + "Биометрические данные", + "Рабочий e-mail", + "Должность", + "Табельный номер", + "Сведения о воинском учете", + "Сведения о командировках" +] diff --git a/dokogen/docs/Инструкция_администратора.docx b/dokogen/docs/Инструкция_администратора.docx new file mode 100644 index 0000000..582ff19 Binary files /dev/null and b/dokogen/docs/Инструкция_администратора.docx differ diff --git a/dokogen/docs/Инструкция_пользователя.docx b/dokogen/docs/Инструкция_пользователя.docx new file mode 100644 index 0000000..44386aa Binary files /dev/null and b/dokogen/docs/Инструкция_пользователя.docx differ diff --git a/dokogen/docs/Опросный_лист_152_ФЗ.xlsx b/dokogen/docs/Опросный_лист_152_ФЗ.xlsx new file mode 100644 index 0000000..c4ecf12 Binary files /dev/null and b/dokogen/docs/Опросный_лист_152_ФЗ.xlsx differ diff --git a/dokogen/docs/Переменные_шаблонов.docx b/dokogen/docs/Переменные_шаблонов.docx new file mode 100644 index 0000000..23b9702 Binary files /dev/null and b/dokogen/docs/Переменные_шаблонов.docx differ diff --git a/dokogen/generator.py b/dokogen/generator.py new file mode 100644 index 0000000..4afffb6 --- /dev/null +++ b/dokogen/generator.py @@ -0,0 +1,862 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Генератор документов из шаблонов DOCX (чистая версия)""" + +import os +import re +import io +import shutil +import tempfile +import zipfile +from typing import Dict, List, Optional, Callable, Any +from datetime import datetime +from functools import lru_cache + +try: + from docx import Document + from docx.oxml.ns import qn + from docx.oxml import OxmlElement + from lxml import etree + DOCX_AVAILABLE = True +except ImportError: + Document = None + DOCX_AVAILABLE = False + +# ============================================================ +# 1. ПЕРЕМЕННЫЕ ШАБЛОНОВ И РЕГЕКСЫ +# ============================================================ + +# Паттерны для поиска переменных и маркеров циклов +VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}') +OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>') +UNMATCHED_PATTERN = re.compile(r'\{\{[^}]+\}\}|<[A-Za-z0-9_]+>') + +# «Переменные», которые на самом деле не переменные, а клавиши в тексте +# инструкций (Ctrl+Alt+Del и т.п.) — их не считаем незаменёнными +IGNORED_OLD_VARS = { + '', '', '', '', '', '', '', + '', '', '', '', '', '', '', + '', '', '', '', '', + '', '', '', '', '', '', '', + '', '', '', '', '', '', '', '', '', + '', '', '', +} + +LOOP_START = re.compile(r'(?:<!--|)') +LOOP_END = re.compile(r'(?:<!--|)') + +# Маппинг старых переменных <...> → {{...}} +OLD_VAR_MAP = { + '': '{{company_full_name}}', + '': '{{company_short_name}}', + '': '{{company_full_name_genitive}}', + '': '{{company_short_name_genitive}}', + '': '{{company_full_name_dative}}', + '': '{{company_short_name_dative}}', + '': '{{company_full_name_accs}}', + '': '{{company_short_name_accs}}', + '': '{{company_full_name_ablt}}', + '': '{{company_short_name_ablt}}', + '': '{{company_full_name_loct}}', + '': '{{company_short_name_loct}}', + '
': '{{company_address}}', + '': '{{company_inn}}', + '': '{{company_ogrn}}', + '': '{{company_kpp}}', + '': '{{chief_fio_initials}}', + '': '{{chief_fio_short}}', + '': '{{chief_position}}', + '': '{{responsible_fio_initials}}', + '': '{{responsible_fio_short}}', + '': '{{responsible_position}}', + '': '{{commission_list}}', + '': '{{document_number}}', + '': '{{document_date}}', + '': '{{date}}', + # Краткое наименование по падежам (старые шаблоны) + '': '{{company_short_name}}', + '': '{{company_short_name_genitive}}', + '': '{{company_short_name_dative}}', + '': '{{company_short_name_accs}}', + '': '{{company_short_name_ablt}}', + '': '{{company_short_name_loct}}', + '': '{{is_name_1}}', +} + + +# ============================================================ +# 2. ОБРАБОТКА ЦИКЛОВ НА УРОВНЕ ZIP/XML +# ============================================================ + +W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' + + +def _normalize_loop_markers(doc_xml: str) -> str: + """Word разбивает маркеры и переменные {{item.x}} + на несколько run'ов (например '' в третьем). Склеиваем текст каждого параграфа в один run, + чтобы маркеры и переменные циклов снова стали едиными.""" + try: + root = etree.fromstring(doc_xml.encode('utf-8')) + except Exception: + return doc_xml + + changed = False + for p in root.iter(f'{{{W_NS}}}p'): + # Собираем весь текст параграфа (по всем run'ам), + # табы превращаем в символ \t, чтобы не потерять + texts = [] + for child in p.iter(): + tag = child.tag.split('}')[-1] + if tag == 't': + texts.append(child.text or '') + elif tag == 'tab': + texts.append('\t') + full = ''.join(texts) + + # Интересуют параграфы с маркерами циклов ИЛИ любыми переменными {{...}} + if '{{' not in full and 'loop:' not in full and 'loop_end' not in full: + continue + + # Был ли в параграфе разрыв страницы (Word хранит его отдельным run) + has_page_break = any( + br.get(f'{{{W_NS}}}type') == 'page' + for br in p.iter(f'{{{W_NS}}}br') + ) + + # Сохраняем форматирование первого run + first_r = p.find(f'{{{W_NS}}}r') + rpr = None + if first_r is not None: + rpr_el = first_r.find(f'{{{W_NS}}}rPr') + if rpr_el is not None: + rpr = etree.fromstring(etree.tostring(rpr_el)) + + # Оставляем только pPr, удаляем все run'ы + for child in list(p): + if child.tag != f'{{{W_NS}}}pPr': + p.remove(child) + + # Разрыв страницы — отдельным run ПЕРЕД текстом (как было в оригинале) + if has_page_break: + br_r = etree.SubElement(p, f'{{{W_NS}}}r') + if rpr is not None: + br_r.append(etree.fromstring(etree.tostring(rpr))) + br_el = etree.SubElement(br_r, f'{{{W_NS}}}br') + br_el.set(f'{{{W_NS}}}type', 'page') + + # Создаём run с полным текстом параграфа, восстанавливая табы + new_r = etree.SubElement(p, f'{{{W_NS}}}r') + if rpr is not None: + new_r.append(rpr) + # Разбиваем по \t: обычный текст → , таб → + parts = full.split('\t') + for i, part in enumerate(parts): + if part: + new_t = etree.SubElement(new_r, f'{{{W_NS}}}t') + new_t.text = part + new_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve') + if i < len(parts) - 1: + tab_el = etree.SubElement(new_r, f'{{{W_NS}}}tab') + changed = True + + if not changed: + return doc_xml + + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True).decode('utf-8') + + +def _strip_edge_empty_paras(fragment: str) -> str: + """Удаляет пустые параграфы (без текста) с начала и конца XML-фрагмента. + Word оставляет их вокруг маркеров , из-за чего + при размножении цикла между строками появляются пустые строки.""" + para_re = re.compile(r']*?(?:/>|>.*?)', re.S) + + def _is_empty(p: str) -> bool: + # Не пустой, если есть разрыв страницы, рисунок, таблица и т.п. + if ']*>(.*?)', p, re.S) + if not texts: + return True + return all(t.strip() == '' for t in texts) + + # С начала + while True: + m = para_re.match(fragment) + if not m: + break + if _is_empty(m.group(0)): + fragment = fragment[m.end():] + else: + break + + # С конца + while True: + m = para_re.search(fragment) + if not m: + break + if m.end() != len(fragment): + break + if _is_empty(m.group(0)): + fragment = fragment[:m.start()] + else: + break + + return fragment + + +def _top_level_ranges(fragment: str): + """Возвращает список (start, end) ПОЛНЫХ элементов верхнего уровня + (w:p, w:tbl) в XML-фрагменте. Таблицы считаются одним элементом. + Обрывки (незакрытые ', i) or fragment.startswith('', j) or fragment.startswith('', j): + d -= 1 + j += 8 + if d == 0: + break + else: + j += 1 + ranges.append((i, j)) + i = j + elif fragment.startswith('', i) or fragment.startswith('', i) + if j == -1: + break # обрывок — не полный параграф, дальше suffix + j += 6 + ranges.append((i, j)) + i = j + else: + i += 1 + return ranges + + +def _split_template_edges(template: str): + """Разделяет шаблон цикла на три части: + prefix — хвост параграфа маркера начала (после -->), + core — полные элементы верхнего уровня (параграфы/таблицы) между маркерами, + suffix — начало параграфа маркера конца (до ). Намеренные пустые строки + ( без run'ов) не трогаем.""" + try: + root = etree.fromstring(doc_xml.encode('utf-8')) + except Exception: + return doc_xml + removed = False + for p in list(root.iter(f'{{{W_NS}}}p')): + # Не трогаем параграфы внутри таблиц (пустые ячейки валидны) + parent = p.getparent() + if parent is None: + continue + if parent.tag == f'{{{W_NS}}}tc': + continue + # Не трогаем параграфы с разрывом страницы/строки, рисунками и т.п. + if (p.find(f'{{{W_NS}}}r/{{{W_NS}}}br') is not None + or p.find(f'{{{W_NS}}}r/{{{W_NS}}}drawing') is not None + or p.find(f'{{{W_NS}}}r/{{{W_NS}}}object') is not None + or p.find(f'{{{W_NS}}}r/{{{W_NS}}}pict') is not None): + continue + # Есть непустой текст — не трогаем + texts = [t.text or '' for t in p.iter(f'{{{W_NS}}}t')] + if any(t.strip() for t in texts): + continue + # Есть run'ы (признак бывшего маркера), но текста нет — удаляем + runs = p.findall(f'{{{W_NS}}}r') + if not runs: + continue + parent.remove(p) + removed = True + if not removed: + return doc_xml + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True).decode('utf-8') + + +def _subst_var_xml(xml: str, var: str, val: str) -> str: + """Замена {{var}} в XML-фрагменте. + + Пустые значения («—»/«») подсвечиваются жёлтым маркером: значение + выносится в отдельный run с , чтобы + подсветка не затиралась при подстановке на уровне XML (циклы). + """ + token = '{{%s}}' % var + if val not in ('—', ''): + return xml.replace(token, val) + hl_run = ('' + '%s' % val) + return xml.replace(token, + '' + hl_run + + '') + + +def _expand_nested_loops(template: str, item: Dict) -> str: + """Разворачивает вложенные циклы вида ... + данными из item (список словарей). Используется для циклов внутри цикла ИС, + например пользователи ИС. Переменные внутри: {{user_fio}}, {{user_position}}.""" + result = template + for _ in range(20): + m_start = LOOP_START.search(result) + if not m_start: + break + key = m_start.group(1) + data = item.get(key) + if not isinstance(data, list): + # Нет данных для вложенного цикла — удаляем блок целиком + depth = 1 + pos = m_start.end() + m_end = None + for mm in LOOP_END.finditer(result, pos): + depth -= 1 + if depth == 0: + m_end = mm + break + if m_end: + result = result[:m_start.start()] + result[m_end.end():] + continue + # Ищем соответствующий loop_end с учётом вложенности + depth = 1 + pos = m_start.end() + m_end = None + markers = sorted( + [('s', x) for x in LOOP_START.finditer(result, pos)] + + [('e', x) for x in LOOP_END.finditer(result, pos)], + key=lambda t: t[1].start() + ) + for kind, mm in markers: + if kind == 's': + depth += 1 + else: + depth -= 1 + if depth == 0: + m_end = mm + break + if m_end is None: + break + inner = result[m_start.end():m_end.start()] + parts = [] + for sub in data: + clone = inner + for f, v in sub.items(): + val = str(v) if v else '' + clone = _subst_var_xml(clone, 'user_%s' % f, val) + clone = _subst_var_xml(clone, 'item.%s' % f, val) + parts.append(clone) + result = result[:m_start.start()] + ''.join(parts) + result[m_end.end():] + return result + + +def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str: + """Обрабатывает циклы напрямую в ZIP/DOCX на уровне XML. + Работает до загрузки python-docx — гарантирует корректную вложенность. + + Args: + docx_path: путь к шаблону .docx + loops: словарь {имя_цикла: [{field: value}, ...]} + + Returns: + путь к обработанному .docx (временный файл) + """ + if not loops: + return docx_path + + # Временный файл + tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.docx') + tmp.close() + + wrote = False # была ли хоть одна запись в tmp + max_passes = 30 + for _ in range(max_passes): + # Читаем ВСЕ файлы из исходного ZIP + all_files = {} + try: + with zipfile.ZipFile(docx_path if _ == 0 else tmp.name, 'r') as z: + for name in z.namelist(): + all_files[name] = z.read(name) + doc_xml = all_files.get('word/document.xml', b'').decode('utf-8') + except Exception: + break + + if not doc_xml: + break + + # Word мог разбить маркеры циклов на несколько run'ов — склеиваем + doc_xml = _normalize_loop_markers(doc_xml) + + # Находим ВСЕ маркеры циклов + start_markers = list(LOOP_START.finditer(doc_xml)) + end_markers = list(LOOP_END.finditer(doc_xml)) + + if not start_markers: + break # нет больше циклов + + # Внешний цикл = первый start (самый ранний в документе) + outer = start_markers[0] + outer_key = outer.group(1) + + # Соответствующий end = первый end после outer (или последний, если вложенные) + # Считаем глубину: каждый start +1, каждый end -1 + depth = 1 + outer_end = None + all_markers = sorted( + [('start', m) for m in start_markers] + + [('end', m) for m in end_markers], + key=lambda x: x[1].start() + ) + found_start = False + for kind, m in all_markers: + if m.start() <= outer.start(): + continue + if kind == 'start': + depth += 1 + elif kind == 'end': + depth -= 1 + if depth == 0: + outer_end = m + break + + if outer_end is None: + break + + items = loops.get(outer_key, []) + if not items: + # Удаляем блок цикла полностью + doc_xml = doc_xml[:outer.start()] + doc_xml[outer_end.end():] + all_files['word/document.xml'] = doc_xml.encode('utf-8') + with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z: + for name, data in all_files.items(): + z.writestr(name, data) + wrote = True + continue + + # Извлекаем шаблон (всё между start и end маркерами) + template = doc_xml[outer.end():outer_end.start()] + + # Word оставляет обрывки параграфов вокруг маркеров и пустые параграфы. + # Отделяем обрывки (prefix/suffix) от тела цикла и чистим пустые + # параграфы в теле — иначе между итерациями появляются пустые строки. + prefix, core, suffix = _split_template_edges(template) + core = _strip_edge_empty_paras(core) + + # НЕ удаляем loop_end из шаблона — они принадлежат вложенным циклам! + template_clean = core + + # Разрыв страницы рядом с маркером конца цикла (Word хранит его + # отдельным run в параграфе с ). Если он есть — + # каждый акт/блок цикла должен начинаться с новой страницы. + probe = doc_xml[max(0, outer_end.start() - 800):outer_end.end()] + has_page_break = '' in probe + # Если разрыв живёт в «хвосте» (параграф loop_end) — выносим его + # между итерациями, а из хвоста убираем (иначе акты слипаются). + br_in_suffix = has_page_break and '' in suffix + if br_in_suffix: + suffix = re.sub(r']*?w:type="page"[^>]*?/>', '', suffix) + + # Размножаем шаблон для каждого элемента данных + expanded_parts = [] + for idx, item in enumerate(items, 1): + clone = template_clean + # Вложенные циклы (users_loop и т.п.) — разворачиваем ДО подстановки + # обычных полей, чтобы {{user_fio}} внутри них не затёрлись алиасами + clone = _expand_nested_loops(clone, item) + for field, value in item.items(): + if isinstance(value, list): + continue # списки — данные вложенных циклов, не подставляем + val_str = str(value) if value else ('—' if value is None else '') + # С префиксом item. + clone = _subst_var_xml(clone, 'item.%s' % field, val_str) + # Без префикса (алиасы {{name}}, {{pd_list}}, {{document}} и т.д.) + clone = _subst_var_xml(clone, '%s' % field, val_str) + # Альтернативный синтаксис {{item_поле}} (без точки) + clone = _subst_var_xml(clone, 'item_%s' % field, val_str) + clone = _subst_var_xml(clone, 'item.number', str(idx)) + clone = _subst_var_xml(clone, 'number', str(idx)) + expanded_parts.append(clone) + if br_in_suffix: + expanded_parts.append('') + + # Собираем новый XML: обрывок параграфа маркера начала (prefix) один раз, + # затем размноженное тело цикла, затем обрывок параграфа маркера конца (suffix) + new_xml = (doc_xml[:outer.start()] + prefix + + ''.join(expanded_parts) + suffix + + doc_xml[outer_end.end():]) + + # Сохраняем ВСЕ файлы ZIP, обновляя только document.xml + all_files['word/document.xml'] = new_xml.encode('utf-8') + with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z: + for name, data in all_files.items(): + z.writestr(name, data) + wrote = True + + if not wrote: + # Циклов в шаблоне не было — возвращаем исходный файл, + # пустой временный удаляем (иначе python-docx упадёт с Package not found) + try: + os.unlink(tmp.name) + except Exception: + pass + return docx_path + + # Финальная зачистка: пустые параграфы-остатки маркеров () + try: + with zipfile.ZipFile(tmp.name, 'r') as z: + all_files = {name: z.read(name) for name in z.namelist()} + doc_xml = all_files.get('word/document.xml', b'').decode('utf-8') + cleaned = _cleanup_marker_paras(doc_xml) + if cleaned != doc_xml: + all_files['word/document.xml'] = cleaned.encode('utf-8') + with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z: + for name, data in all_files.items(): + z.writestr(name, data) + except Exception: + pass + + return tmp.name + + +# ============================================================ +# 3. ЗАМЕНА ПЕРЕМЕННЫХ В ЭЛЕМЕНТАХ DOCX +# ============================================================ + +def _apply_highlight_color(run_el, color: str): + """Добавляет маркер highlight указанного цвета к run.""" + ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' + rpr = run_el.find(f'{{{ns}}}rPr') + if rpr is None: + rpr = OxmlElement('w:rPr') + run_el.insert(0, rpr) + hl = OxmlElement('w:highlight') + hl.set(f'{{{ns}}}val', color) + rpr.append(hl) + + +def _add_run_with_tabs(para, text, style_source=None): + """Добавляет run с текстом, сохраняя табуляцию как . + + python-docx add_run('\t') вставляет символ таба в w:t, который Word + может «съесть». Поэтому разбиваем текст по '\t' и вставляем + настоящие элементы табуляции. + """ + parts = str(text).split('\t') + for i, part in enumerate(parts): + if part: + run = para.add_run(part) + if style_source is not None: + _copy_run_style(style_source, run) + if i < len(parts) - 1: + run = para.add_run() + if style_source is not None: + _copy_run_style(style_source, run) + run.add_tab() + + +def _replace_text_in_para(para, replacements: Dict[str, str]) -> bool: + """Заменяет {{var}} в параграфе. Возвращает True, если были замены. + + Значения «—» (незаполненная информация) подсвечиваются жёлтым маркером. + Табуляция в параграфе сохраняется (). + """ + full = para.text + matches = list(VAR_PATTERN.finditer(full)) + if not matches: + return False + + # Собираем параграф заново: обычный текст + значения переменных + first = para.runs[0] if para.runs else None + para.clear() + pos = 0 + changed = False + for m in matches: + var = m.group(0) + val = replacements.get(var) + # текст до переменной + if m.start() > pos: + _add_run_with_tabs(para, full[pos:m.start()], first) + if val is None: + # Переменная не заменена — оставляем как есть, + # красным её подсветит _highlight_unmatched на шаге 7 + run = para.add_run(var) + if first is not None: + _copy_run_style(first, run) + pos = m.end() + continue + # значение переменной + val_str = str(val) + run = para.add_run(val_str) + if first is not None: + _copy_run_style(first, run) + if val_str == '—' or val_str == '': + _apply_highlight_color(run._element, 'yellow') + changed = True + pos = m.end() + # хвост после последней переменной + if pos < len(full): + _add_run_with_tabs(para, full[pos:], first) + return changed + + +def _copy_run_style(source, target): + """Копирует форматирование run.""" + try: + target.bold = source.bold + target.italic = source.italic + target.underline = source.underline + if source.font: + target.font.size = source.font.size + target.font.name = source.font.name + except Exception: + pass + + +def _replace_in_paragraphs(paragraphs, replacements): + for para in paragraphs: + _replace_text_in_para(para, replacements) + + +def _replace_in_tables(tables, replacements): + for table in tables: + for row in table.rows: + for cell in row.cells: + _replace_in_paragraphs(cell.paragraphs, replacements) + + +def _replace_in_headers_footers(doc, replacements): + for section in doc.sections: + for hf in [section.header, section.footer, + section.first_page_header, section.first_page_footer]: + if hf: + _replace_in_paragraphs(hf.paragraphs, replacements) + + +def _strip_prooferr(doc): + """Удаляет proofErr из docx (красные волнистые линии).""" + ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' + for elem in doc.element.iter(): + for child in list(elem): + if child.tag == f'{{{ns}}}proofErr': + elem.remove(child) + + +def _highlight_unmatched(doc): + """Выделяет незаменённые переменные красным маркером (только саму переменную).""" + ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' + + def _apply_highlight(run_el): + rpr = run_el.find(f'{{{ns}}}rPr') + if rpr is None: + rpr = OxmlElement('w:rPr') + run_el.insert(0, rpr) + hl = OxmlElement('w:highlight') + hl.set(f'{{{ns}}}val', 'red') + rpr.append(hl) + + def _process_para(para): + full = para.text + matches = [m for m in UNMATCHED_PATTERN.finditer(full) + if m.group(0) not in IGNORED_OLD_VARS] + if not matches: + return + + # Пересобираем параграф: обычный текст и переменные отдельными run'ами, + # переменные — с красным маркером. Сохраняем стиль первого run и табы. + first = para.runs[0] if para.runs else None + para.clear() + pos = 0 + for m in matches: + if m.start() > pos: + _add_run_with_tabs(para, full[pos:m.start()], first) + run = para.add_run(m.group(0)) + if first is not None: + _copy_run_style(first, run) + _apply_highlight(run._element) + pos = m.end() + if pos < len(full): + _add_run_with_tabs(para, full[pos:], first) + run = para.add_run(full[pos:]) + if first is not None: + _copy_run_style(first, run) + + for para in doc.paragraphs: + _process_para(para) + + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for para in cell.paragraphs: + _process_para(para) + + for section in doc.sections: + for hf in [section.header, section.footer, + section.first_page_header, section.first_page_footer]: + if hf is not None: + for para in hf.paragraphs: + _process_para(para) + + +def _log_unmatched(doc, replacements=None, log_func=None): + """Логирует незаменённые переменные (без клавиш-исключений).""" + if not log_func: + return + unmatched = [] + seen = set() + for para in doc.paragraphs: + for m in UNMATCHED_PATTERN.finditer(para.text): + var = m.group(0) + if var in IGNORED_OLD_VARS: + continue + if var not in seen: + seen.add(var) + unmatched.append(var) + if unmatched: + log_func(f"⚠️ Незаменённые переменные ({len(unmatched)}): " + f"{', '.join(unmatched[:20])}" + f"{' ...' if len(unmatched) > 20 else ''}") + else: + log_func("✅ Все переменные успешно заменены") + + +# ============================================================ +# 4. МИГРАЦИЯ СТАРЫХ ПЕРЕМЕННЫХ +# ============================================================ + +def _migrate_old_vars(doc): + """Заменяет на {{new_var}}.""" + def _migrate_text(text): + return OLD_VAR_PATTERN.sub(lambda m: OLD_VAR_MAP.get(m.group(0), m.group(0)), text) + + for para in doc.paragraphs: + new_text = _migrate_text(para.text) + if new_text != para.text: + if para.runs: + first = para.runs[0] + para.clear() + run = para.add_run(new_text) + _copy_run_style(first, run) + else: + para.clear() + para.add_run(new_text) + + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for para in cell.paragraphs: + new_text = _migrate_text(para.text) + if new_text != para.text: + if para.runs: + first = para.runs[0] + para.clear() + run = para.add_run(new_text) + _copy_run_style(first, run) + else: + para.clear() + para.add_run(new_text) + + for section in doc.sections: + for hf in [section.header, section.footer, + section.first_page_header, section.first_page_footer]: + if hf: + for para in hf.paragraphs: + new_text = _migrate_text(para.text) + if new_text != para.text: + if para.runs: + first = para.runs[0] + para.clear() + run = para.add_run(new_text) + _copy_run_style(first, run) + else: + para.clear() + para.add_run(new_text) + + +# ============================================================ +# 5. ОСНОВНАЯ ФУНКЦИЯ ГЕНЕРАЦИИ +# ============================================================ + +def process_template( + template_path: str, + replacements: Dict[str, str], + output_path: Optional[str] = None, + log_func: Optional[Callable] = None, +) -> str: + """Обрабатывает шаблон DOCX: подстановка переменных, циклы, сохранение. + + Args: + template_path: путь к файлу шаблона .docx + replacements: словарь {переменная: значение} + output_path: путь для сохранения результата + log_func: функция логирования (str → None) + + Returns: + путь к сгенерированному файлу + """ + if not DOCX_AVAILABLE: + raise ImportError("python-docx не установлен") + + if not os.path.exists(template_path): + raise FileNotFoundError(f"Шаблон не найден: {template_path}") + + if output_path is None: + base, ext = os.path.splitext(template_path) + output_path = f"{base}_result{ext}" + + # ШАГ 1: Обрабатываем циклы на уровне ZIP/XML (до загрузки python-docx) + loops = replacements.pop('_loops', {}) + working_path = template_path + if loops: + working_path = expand_loops_in_zip(template_path, loops) + replacements['_loops'] = loops # возвращаем на всякий случай + + # ШАГ 2: Загружаем через python-docx + doc = Document(working_path) + + # ШАГ 3: Миграция старых переменных <...> → {{...}} + _migrate_old_vars(doc) + + # ШАГ 4: Убираем proofErr + _strip_prooferr(doc) + + # ШАГ 5: Замена переменных (3 прохода) + for _ in range(3): + _replace_in_paragraphs(doc.paragraphs, replacements) + _replace_in_tables(doc.tables, replacements) + _replace_in_headers_footers(doc, replacements) + + # ШАГ 6: Логирование незаменённых + _log_unmatched(doc, replacements, log_func) + + # ШАГ 7: Подсветка незаполненных + _highlight_unmatched(doc) + + # ШАГ 8: Сохранение + doc.save(output_path) + + # ШАГ 9: Если использовали временный файл — удаляем + if working_path != template_path: + try: + os.unlink(working_path) + except Exception: + pass + + if log_func: + log_func(f"✅ {os.path.basename(output_path)}") + return output_path diff --git a/dokogen/help_docs.py b/dokogen/help_docs.py new file mode 100644 index 0000000..a439111 --- /dev/null +++ b/dokogen/help_docs.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8 -*- +"""DokoGen — справка и инструкции (Word) + пустой опросник (Excel). + +Файлы генерируются при первом обращении и кладутся в папку docs/ +рядом с программой (при сборке .exe — рядом с exe): +- Инструкция_пользователя.docx +- Инструкция_администратора.docx +- Опросный_лист_152_ФЗ.xlsx (пустой шаблон) +""" + +import os +import sys +import shutil + +try: + import docx + from docx.shared import Pt, Cm + from docx.enum.text import WD_ALIGN_PARAGRAPH + DOCX_AVAILABLE = True +except ImportError: + DOCX_AVAILABLE = False + + +def docs_dir(): + """Папка docs рядом с exe (frozen) или рядом с модулем.""" + if getattr(sys, 'frozen', False): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + d = os.path.join(base, 'docs') + os.makedirs(d, exist_ok=True) + return d + + +def user_manual_path(): + return os.path.join(docs_dir(), 'Инструкция_пользователя.docx') + + +def admin_manual_path(): + return os.path.join(docs_dir(), 'Инструкция_администратора.docx') + + +def variables_reference_path(): + return os.path.join(docs_dir(), 'Переменные_шаблонов.docx') + + +def survey_path(): + return os.path.join(docs_dir(), 'Опросный_лист_152_ФЗ.xlsx') + + +def _h(doc, text, size=14): + p = doc.add_paragraph() + run = p.add_run(text) + run.bold = True + run.font.size = Pt(size) + return p + + +def _p(doc, text, bold=False): + p = doc.add_paragraph() + run = p.add_run(text) + run.bold = bold + run.font.size = Pt(11) + return p + + +def _bullet(doc, text): + p = doc.add_paragraph(style='List Bullet') + run = p.add_run(text) + run.font.size = Pt(11) + return p + + +def _generate_variables_reference(path): + """Справочник переменных шаблонов (Word).""" + try: + from .variables import VARIABLE_DEFS + except Exception: + VARIABLE_DEFS = [] + + doc = docx.Document() + _h(doc, 'Переменные шаблонов') + _p(doc, 'Список переменных для шаблонов Word. Переменная подставляется в текст ' + 'как есть: {{имя_переменной}}. Циклы: ... .') + + groups = {'A': 'Общие переменные', 'B': '152-ФЗ (персональные данные)'} + by_group = {'A': [], 'B': []} + for var, group, desc, _ in VARIABLE_DEFS: + by_group.setdefault(group, []).append((var, desc)) + + for gkey, title in groups.items(): + items = by_group.get(gkey, []) + if not items: + continue + _h(doc, title, 12) + for var, desc in items: + _bullet(doc, f'{var} — {desc}') + + _h(doc, 'Циклы', 12) + _bullet(doc, 'information_systems — ИС ({{item.name}}, {{item.description}}, {{item.structure}}, {{item.pd_actions}}, {{item.defense_tools_list}}, {{item.users}}, {{item.category}}, {{item.pd_count}} и др.).') + _bullet(doc, 'commission — комиссия ({{item.role}}, {{item.position}}, {{item.fio}}, {{item.fio_initials}}, {{item.fio_short}}, {{item.signature}}).') + _bullet(doc, 'employees_access — сотрудники с доступом ({{item.position}}, {{item.fio}}).') + _bullet(doc, 'paper_documents — бумажные документы ({{item.document}}, {{item.storage}}).') + + doc.save(path) + + +def _generate_user_manual(path): + """Инструкция пользователя: программа, циклы, переменные, проверки.""" + doc = docx.Document() + _h(doc, 'ДоКоГеНеРаТоР — 152-ФЗ') + _h(doc, 'Инструкция пользователя', 13) + + _h(doc, '1. Что это за программа', 12) + _p(doc, 'Программа автоматически заполняет шаблоны документов по 152-ФЗ ' + '(акты обследования, политики, инструкции, приказы и т.д.) данными ' + 'об организации, информационных системах, комиссии и сотрудниках. ' + 'Данные можно ввести вручную на вкладках или импортировать из ' + 'опросного листа Excel.') + + _h(doc, '2. Основные вкладки', 12) + _bullet(doc, 'Организация — реквизиты, адреса, руководитель, склонения названий, проверка данных (Морфер / DaData / ИИ).') + _bullet(doc, 'Информационные системы — ИС, категории ПДн, действия с ПДн, средства защиты, пользователи.') + _bullet(doc, 'Комиссия — состав комиссии по классификации ИСПДн.') + _bullet(doc, 'Пользователи — сотрудники с доступом к ПДн.') + _bullet(doc, 'Бумажные документы — бумажные носители ПДн и места хранения.') + _bullet(doc, 'Генерация — выбор шаблонов и запуск генерации.') + _bullet(doc, 'Лог — журнал действий, экспорт в файл.') + + _h(doc, '3. Переменные в шаблонах', 12) + _p(doc, 'В шаблонах Word используются переменные в двойных фигурных скобках, ' + 'например {{company_full_name}}, {{inn}}, {{chief_fio_initials}}. ' + 'Полный список — в пункте меню «Справка → Переменные шаблонов».') + _p(doc, 'Переменные, которые не удалось заменить (нет данных), подсвечиваются ' + 'КРАСНЫМ маркером. Незаполненная информация заменяется прочерком «—» ' + 'и подсвечивается ЖЁЛТЫМ.') + + _h(doc, '4. Циклы (повторяющиеся блоки)', 12) + _p(doc, 'Если в документе нужно повторить блок для каждой ИС, комиссии, ' + 'сотрудника или бумажного документа — используются маркеры цикла:') + _p(doc, ' ... {{item.name}} ... ', bold=True) + _p(doc, 'Доступные циклы:') + _bullet(doc, 'information_systems — ИС ({{item.name}}, {{item.description}}, {{item.structure}}, {{item.pd_actions}}, {{item.defense_tools_list}}, {{item.users}}, {{item.category}}, {{item.pd_count}} и др.).') + _bullet(doc, 'commission — комиссия ({{item.role}}, {{item.position}}, {{item.fio}}, {{item.fio_initials}}, {{item.fio_short}}, {{item.signature}}).') + _bullet(doc, 'employees_access — сотрудники с доступом ({{item.position}}, {{item.fio}}).') + _bullet(doc, 'paper_documents — бумажные документы ({{item.document}}, {{item.storage}}).') + _p(doc, 'Внутри цикла переменные пишутся через точку: {{item.поле}}. ' + 'Допускается и синтаксис без точки: {{item_поле}}.') + + _h(doc, '5. Импорт из опросника', 12) + _p(doc, 'Меню «Генерация → Импорт из опросника» (или кнопка «📥 Импорт»). ' + 'Выберите заполненный опросный лист Excel — данные подтянутся ' + 'автоматически. Что именно импортировать и откуда — настраивается ' + 'в файле import_settings.json (см. инструкцию администратора).') + + _h(doc, '6. Проверка данных', 12) + _bullet(doc, 'Склонение — Морфер 3.0 (бесплатно, без ключа) или ИИ.') + _bullet(doc, 'Реквизиты — DaData (по ИНН) или ИИ.') + _bullet(doc, 'ИИ-результаты помечаются дисклеймером «сгенерировано ИИ — перепроверьте».') + _bullet(doc, 'Ключи API задаются в меню «Файл → Настройки».') + + _h(doc, '7. Генерация', 12) + _p(doc, '1) Укажите папку с шаблонами (кнопка «📁 Шаблоны»).\n' + '2) Выберите нужные шаблоны в списке.\n' + '3) Укажите папку для результатов.\n' + '4) Нажмите «⚡ Генерация».') + _p(doc, 'Результаты сохраняются в выбранную папку. Если какой-то документ ' + 'не создался — смотрите вкладку «Лог»: там будет причина.') + + doc.save(path) + + +def _generate_admin_manual(path): + """Инструкция администратора: словари, API-ключи, лимиты.""" + doc = docx.Document() + _h(doc, 'ДоКоГеНеРаТоР — 152-ФЗ') + _h(doc, 'Инструкция администратора', 13) + + _h(doc, '1. Файлы настроек рядом с программой', 12) + _bullet(doc, 'api_settings.json — API-ключи (DaData, ИИ).') + _bullet(doc, 'import_settings.json — словарь импорта: что и откуда брать из опросника.') + _bullet(doc, 'dictionaries/ — справочники: defense_tools.json, pd_actions.json, pd_items.json, pd_categories.json.') + _bullet(doc, 'dokogen.log — журнал работы (пишется автоматически).') + _bullet(doc, 'docs/ — инструкции и пустой опросник (создаются автоматически).') + + _h(doc, '2. Словари (папка dictionaries)', 12) + _p(doc, 'JSON-файлы со списками значений для выпадающих списков. ' + 'Формат — простой массив строк:') + _p(doc, '[\n "Антивирусное ПО",\n "СКЗИ (криптография)",\n "Межсетевой экран"\n]', bold=True) + _p(doc, 'Чтобы добавить значение — откройте файл в Блокноте, добавьте строку ' + 'через запятую и сохраните. Программа перечитает файл при следующем ' + 'открытии окна выбора.') + + _h(doc, '3. Словарь импорта (import_settings.json)', 12) + _p(doc, 'Определяет, какие поля опросника импортируются и откуда. Каждое правило:') + _p(doc, '{"name": "Полное наименование организации", "var": "fullName", ' + '"cell": "\'Общие сведения\'!B2", "keywords": "полное наименование организации"}', bold=True) + _bullet(doc, 'cell — конкретная ячейка (если заполнена — берём оттуда);') + _bullet(doc, 'keywords — регулярное выражение для поиска строки-вопроса в колонке A;') + _bullet(doc, 'var — ключ в данных программы (fullName, inn, kpp, ogrn и т.д.).') + _p(doc, 'Также настраиваются: common_sheet_keyword (лист «Общие сведения»), ' + 'employees_sheet_keyword (лист «Сотрудники»), is_sheet_exclude_keywords ' + '(какие листы НЕ ИС), value_column (колонка значения, 1 = B).') + + _h(doc, '4. API-ключи (меню «Файл → Настройки»)', 12) + _p(doc, 'DaData — проверка организации по ИНН/ОГРН (наименование, адрес, КПП, руководитель).') + _bullet(doc, 'Где взять: https://dadata.ru → регистрация → раздел API. Бесплатный тариф ~10 000 запросов в день.') + _bullet(doc, 'Ключ: секция «Статический API-ключ» (формат: буквы и цифры).') + _p(doc, 'ИИ (OpenAI-совместимый: DeepSeek, ChatGPT и др.) — проверка склонений и реквизитов.') + _bullet(doc, 'DeepSeek: https://platform.deepseek.com → API Keys. Модель deepseek-chat. Дешёвая, ключ пополняется деньгами.') + _bullet(doc, 'ChatGPT/OpenAI: https://platform.openai.com → API Keys. Модели gpt-4o-mini, gpt-4o.') + _bullet(doc, 'Базовый URL: для DeepSeek можно оставить пустым; для OpenAI — https://api.openai.com.') + _p(doc, 'Лимиты запросов: зависят от тарифа. DaData — ~10 000/сутки на бесплатном тарифе. ' + 'DeepSeek/OpenAI — оплата за токены, лимиты настраиваются в личном кабинете. ' + 'Рекомендуется не превышать несколько сотен проверок в день, чтобы не упереться в лимиты.') + + _h(doc, '5. Морфер 3.0', 12) + _p(doc, 'Бесплатный веб-сервис склонения слов и ФИО. Ключ не требуется — ' + 'программа обращается к https://ws3.morpher.ru автоматически.') + + _h(doc, '6. Логи', 12) + _p(doc, 'Все действия (импорт, генерация, обращения к Морфер/DaData/ИИ с ответами) ' + 'записываются в файл dokogen.log рядом с программой. Экспорт лога — ' + 'кнопка «💾 Экспорт логов» на вкладке «Лог». При проблемах присылайте ' + 'этот файл разработчику.') + + _h(doc, '7. Сборка .exe', 12) + _p(doc, 'Программа собирается с PyInstaller: pyinstaller --onefile --windowed main.py. ' + 'Файлы настроек (api_settings.json, import_settings.json, dictionaries/, docs/) ' + 'создаются автоматически рядом с .exe при первом запуске и остаются ' + 'редактируемыми.') + + doc.save(path) + + +def _ensure_survey(): + """Копирует пустой опросник в docs/, если его ещё нет.""" + target = survey_path() + if os.path.exists(target): + return target + # Ищем шаблон опросника: рядом с программой или встроенный + candidates = [ + os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'Опросный_лист_152_ФЗ.xlsx'), + os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Опросный_лист_152_ФЗ.xlsx'), + ] + for c in candidates: + if os.path.exists(c): + shutil.copy(c, target) + return target + return None + + +def ensure_docs(): + """Создаёт инструкции и опросник при первом обращении.""" + created = [] + if DOCX_AVAILABLE: + up = user_manual_path() + if not os.path.exists(up): + try: + _generate_user_manual(up) + created.append(up) + except Exception: + pass + ap = admin_manual_path() + if not os.path.exists(ap): + try: + _generate_admin_manual(ap) + created.append(ap) + except Exception: + pass + vp = variables_reference_path() + if not os.path.exists(vp): + try: + _generate_variables_reference(vp) + created.append(vp) + except Exception: + pass + sp = _ensure_survey() + if sp and os.path.exists(sp): + created.append(sp) + return created diff --git a/dokogen/import_settings.json b/dokogen/import_settings.json new file mode 100644 index 0000000..a98c7cb --- /dev/null +++ b/dokogen/import_settings.json @@ -0,0 +1,209 @@ +{ + "common_sheet_keyword": "общие", + "employees_sheet_keyword": "сотруд", + "is_sheet_exclude_keywords": [ + "общие сведения", + "сотрудники" + ], + "value_column": 1, + "fields": [ + { + "name": "Полное наименование организации", + "var": "fullName", + "cell": "", + "keywords": "полное наименование организации" + }, + { + "name": "Сокращённое наименование организации", + "var": "shortName", + "cell": "", + "keywords": "сокращенное наименование организации" + }, + { + "name": "Юридический адрес", + "var": "addressLegal", + "cell": "", + "keywords": "адрес организации \\(юридический\\)" + }, + { + "name": "Фактический адрес", + "var": "addressActual", + "cell": "", + "keywords": "адрес организации \\(фактический\\)" + }, + { + "name": "ИНН", + "var": "inn", + "cell": "", + "keywords": "инн" + }, + { + "name": "Дата ОГРН", + "var": "ogrnDate", + "cell": "", + "keywords": "дата.*огрн" + }, + { + "name": "ОГРН", + "var": "ogrn", + "cell": "", + "keywords": "огрн" + }, + { + "name": "КПП", + "var": "kpp", + "cell": "", + "keywords": "кпп" + }, + { + "name": "Основной ОКВЭД", + "var": "okved", + "cell": "", + "keywords": "основной оквэд" + }, + { + "name": "Должность руководителя", + "var": "chiefPosition", + "cell": "", + "keywords": "должность руководителя" + }, + { + "name": "ФИО руководителя", + "var": "chiefFio", + "cell": "", + "keywords": "фио руководителя" + }, + { + "name": "Должность ответственного за ПДн", + "var": "ispdnPosition", + "cell": "", + "keywords": "должность.*защит.*информ|должность.*администрат" + }, + { + "name": "ФИО ответственного за ПДн", + "var": "ispdnFio", + "cell": "", + "keywords": "фио.*защит.*информ|фио.*администрат" + }, + { + "name": "Email ответственного за ПДн", + "var": "ispdnEmail", + "cell": "", + "keywords": "электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист" + }, + { + "name": "Телефон ответственного за ПДн", + "var": "ispdnPhone", + "cell": "", + "keywords": "телефон.*защит|телефон.*ответств|телефон.*специалист" + }, + { + "name": "Структурное подразделение по безопасности", + "var": "ispdnDepartment", + "cell": "", + "keywords": "структур.*подраздел" + }, + { + "name": "Номер договора", + "var": "contractNumber", + "cell": "", + "keywords": "номер.*договора|номер.*контракта" + }, + { + "name": "Дата договора", + "var": "contractDate", + "cell": "", + "keywords": "дата.*договора|дата.*контракта" + }, + { + "name": "Перечень бумажных документов", + "var": "paperDocuments", + "cell": "", + "keywords": "названи.*документ" + }, + { + "name": "Место хранения документов", + "var": "paperStorage", + "cell": "", + "keywords": "место.*хранен" + }, + { + "name": "Должность ответственного за обработку ПДн", + "var": "responsiblePosition", + "cell": "", + "keywords": "должность.*ответственн" + }, + { + "name": "ФИО ответственного за обработку ПДн", + "var": "responsibleFio", + "cell": "", + "keywords": "фио.*ответственн" + }, + { + "name": "Должность администратора безопасности", + "var": "administratorPosition", + "cell": "", + "keywords": "должность.*администрат.*(?:безопас|защит|пд)" + }, + { + "name": "ФИО администратора безопасности", + "var": "administratorFio", + "cell": "", + "keywords": "фио.*администрат.*(?:безопас|защит|пд)" + }, + { + "name": "Сайт организации", + "var": "site_name", + "cell": "", + "keywords": "сайт организации" + }, + { + "name": "Должность ответственного за сайт", + "var": "site_responsible_position", + "cell": "", + "keywords": "должность ответственного за сайт|должность.*отв.*сайт" + }, + { + "name": "ФИО ответственного за сайт", + "var": "site_responsible_fio", + "cell": "", + "keywords": "фио.*отв.*сайт|фио.*сайт" + }, + { + "name": "Разработчик сайта", + "var": "site_service_provider", + "cell": "", + "keywords": "наименование организации.*разрабат.*сайт|наименование.*размещала.*сайт|разрабат.*сайт.*организац" + }, + { + "name": "ИНН разработчика сайта", + "var": "site_service_provider_inn", + "cell": "", + "keywords": "инн.*разрабат.*сайт|инн.*размещала.*сайт" + }, + { + "name": "Размещение сайта (хостинг)", + "var": "site_hosting", + "cell": "", + "keywords": "размещение сайта|адрес расположения серверов|адрес.*сервер.*сайт" + }, + { + "name": "Адрес хостинга", + "var": "site_hosting_address", + "cell": "", + "keywords": "адрес.*хостинг|адрес.*располож.*сервер" + }, + { + "name": "Электронная почта", + "var": "email", + "cell": "", + "keywords": "электронная почта" + }, + { + "name": "Телефон", + "var": "phone", + "cell": "", + "keywords": "телефон" + } + ] +} \ No newline at end of file diff --git a/dokogen/import_settings.py b/dokogen/import_settings.py new file mode 100644 index 0000000..ca33475 --- /dev/null +++ b/dokogen/import_settings.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +"""DokoGen — настройки импорта из опросника (редактируемый словарь). + +Идея: «что импортировать и откуда» хранится НЕ в коде, а в текстовом +JSON-файле рядом с программой (import_settings.json). При сборке .exe +файл остаётся редактируемым — можно править пути к ячейкам и ключевые +слова без пересборки. + +Формат файла: +{ + "common_sheet_keyword": "общие", // ключевое слово листа «Общие сведения» + "employees_sheet_keyword": "сотруд", // ключевое слово листа «Сотрудники» + "is_sheet_exclude_keywords": ["общие сведения", "сотрудники"], // листы, которые НЕ ИС + "value_column": 1, // колонка значения (0=A, 1=B, ...) + "fields": [ + { + "name": "Полное наименование организации", // человекочитаемое название + "var": "fullName", // ключ в данных программы + "cell": "'Общие сведения'!B2", // (необязательно) конкретная ячейка + "keywords": "полное наименование организации" // (необязательно) регэксп поиска по колонке A + } + ] +} + +Порядок применения для каждого поля: +1) если задана cell и ячейка заполнена — берём значение оттуда; +2) иначе ищем строку, где колонка A совпадает с keywords (регэксп), + и берём значение из колонки value_column. +""" + +import os +import re +import sys +import json + +# ============================================================ +# ПУТЬ К ФАЙЛУ НАСТРОЕК ПО УМОЛЧАНИЮ +# ============================================================ +def default_import_settings_path(): + """Путь к файлу настроек: рядом с exe (frozen) или рядом с модулем.""" + if getattr(sys, 'frozen', False): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(base, 'import_settings.json') + + +# ============================================================ +# ДЕФОЛТНЫЙ СЛОВАРЬ (используется, если файл не найден) +# ============================================================ +def _f(name, var, cell='', keywords=''): + return {'name': name, 'var': var, 'cell': cell, 'keywords': keywords} + + +DEFAULT_SETTINGS = { + 'common_sheet_keyword': 'общие', + 'employees_sheet_keyword': 'сотруд', + 'is_sheet_exclude_keywords': ['общие сведения', 'сотрудники'], + 'value_column': 1, + 'fields': [ + _f('Полное наименование организации', 'fullName', keywords=r'полное наименование организации'), + _f('Сокращённое наименование организации', 'shortName', keywords=r'сокращенное наименование организации'), + _f('Юридический адрес', 'addressLegal', keywords=r'адрес организации \(юридический\)'), + _f('Фактический адрес', 'addressActual', keywords=r'адрес организации \(фактический\)'), + _f('ИНН', 'inn', keywords=r'инн'), + _f('Дата ОГРН', 'ogrnDate', keywords=r'дата.*огрн'), + _f('ОГРН', 'ogrn', keywords=r'огрн'), + _f('КПП', 'kpp', keywords=r'кпп'), + _f('Основной ОКВЭД', 'okved', keywords=r'основной оквэд'), + _f('Должность руководителя', 'chiefPosition', keywords=r'должность руководителя'), + _f('ФИО руководителя', 'chiefFio', keywords=r'фио руководителя'), + _f('Должность ответственного за ПДн', 'ispdnPosition', keywords=r'должность.*защит.*информ|должность.*администрат'), + _f('ФИО ответственного за ПДн', 'ispdnFio', keywords=r'фио.*защит.*информ|фио.*администрат'), + _f('Email ответственного за ПДн', 'ispdnEmail', keywords=r'электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист'), + _f('Телефон ответственного за ПДн', 'ispdnPhone', keywords=r'телефон.*защит|телефон.*ответств|телефон.*специалист'), + _f('Структурное подразделение по безопасности', 'ispdnDepartment', keywords=r'структур.*подраздел'), + _f('Номер договора', 'contractNumber', keywords=r'номер.*договора|номер.*контракта'), + _f('Дата договора', 'contractDate', keywords=r'дата.*договора|дата.*контракта'), + _f('Перечень бумажных документов', 'paperDocuments', keywords=r'названи.*документ'), + _f('Место хранения документов', 'paperStorage', keywords=r'место.*хранен'), + _f('Должность ответственного за обработку ПДн', 'responsiblePosition', keywords=r'должность.*ответственн'), + _f('ФИО ответственного за обработку ПДн', 'responsibleFio', keywords=r'фио.*ответственн'), + _f('Должность администратора безопасности', 'administratorPosition', keywords=r'должность.*администрат.*(?:безопас|защит|пд)'), + _f('ФИО администратора безопасности', 'administratorFio', keywords=r'фио.*администрат.*(?:безопас|защит|пд)'), + _f('Сайт организации', 'site_name', keywords=r'сайт организации'), + _f('Должность ответственного за сайт', 'site_responsible_position', keywords=r'должность ответственного за сайт|должность.*отв.*сайт'), + _f('ФИО ответственного за сайт', 'site_responsible_fio', keywords=r'фио.*отв.*сайт|фио.*сайт'), + _f('Разработчик сайта', 'site_service_provider', keywords=r'наименование организации.*разрабат.*сайт|наименование.*размещала.*сайт|разрабат.*сайт.*организац'), + _f('ИНН разработчика сайта', 'site_service_provider_inn', keywords=r'инн.*разрабат.*сайт|инн.*размещала.*сайт'), + _f('Размещение сайта (хостинг)', 'site_hosting', keywords=r'размещение сайта|адрес расположения серверов|адрес.*сервер.*сайт'), + _f('Адрес хостинга', 'site_hosting_address', keywords=r'адрес.*хостинг|адрес.*располож.*сервер'), + _f('Электронная почта', 'email', keywords=r'электронная почта'), + _f('Телефон', 'phone', keywords=r'телефон'), + ], +} + + +# ============================================================ +# ЗАГРУЗКА НАСТРОЕК +# ============================================================ +def load_settings(path=None): + """Загрузка словаря импорта из JSON-файла. + + path=None -> файл по умолчанию рядом с программой. + Если файла нет или он битый — возвращается встроенный дефолт. + """ + if path is None: + path = default_import_settings_path() + + if not path or not os.path.exists(path): + return DEFAULT_SETTINGS + + try: + with open(path, encoding='utf-8-sig') as f: + loaded = json.load(f) + # Дополняем недостающие ключи из дефолта + merged = dict(DEFAULT_SETTINGS) + for k, v in loaded.items(): + if k == 'fields' and isinstance(v, list): + merged['fields'] = v + else: + merged[k] = v + return merged + except Exception: + return DEFAULT_SETTINGS + + +def ensure_default_file(path=None): + """Создаёт файл настроек import_settings.json, если его ещё нет. + + Возвращает путь к файлу. Нужно, чтобы пользователь видел словарь + импорта и мог править его в блокноте без пересборки программы. + """ + if path is None: + path = default_import_settings_path() + if os.path.exists(path): + return path + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w', encoding='utf-8') as f: + json.dump(DEFAULT_SETTINGS, f, ensure_ascii=False, indent=2) + except Exception: + pass + return path + + +def parse_cell_ref(cell_ref): + """Разбор ссылки на ячейку: 'Общие сведения'!B2 / Общие сведения!B2 / B2. + + Возвращает (sheet_name или None, координаты 'B2'). + """ + if not cell_ref: + return None, '' + ref = cell_ref.strip() + if ref.startswith('='): + ref = ref[1:].strip() + if '!' in ref: + sheet_part, coord = ref.split('!', 1) + sheet_part = sheet_part.strip() + if sheet_part.startswith("'") and sheet_part.endswith("'"): + sheet_part = sheet_part[1:-1] + return sheet_part or None, coord.strip().upper() + return None, ref.upper() + + +def cell_value(ws, coord): + """Значение ячейки по координате 'B2' (с нормализацией).""" + if not coord: + return '' + try: + val = ws[coord] + return val.value if val is not None else '' + except Exception: + return '' diff --git a/dokogen/importer.py b/dokogen/importer.py new file mode 100644 index 0000000..3096a64 --- /dev/null +++ b/dokogen/importer.py @@ -0,0 +1,560 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Модуль импорта из Excel (опросный лист 152-ФЗ)""" + +import re +import traceback +from datetime import datetime, timedelta +from typing import Callable, Optional + +try: + import openpyxl +except ImportError: + openpyxl = None + + +from .import_settings import load_settings, parse_cell_ref, cell_value + + +# Известные аббревиатуры, которые сохраняются КАПСОМ при нормализации +_CAPS_ABBR = { + 'АРМ', 'СКУД', 'СЗИ', 'СКЗИ', 'СОВ', 'ЛВС', 'ИНН', 'КПП', 'ОГРН', 'ЦОД', + 'МБУ', 'МКУ', 'ООО', 'СНИЛС', 'СБИС', 'ЕМСЭД', 'ФИО', 'ИСПДН', 'ККТ', +} + + +def _normalize_caps(text: str) -> str: + """Приводит КАПС-текст к нормальному регистру по словам. + + Сохраняет: + - аббревиатуры из белого списка (АРМ, СКУД, ООО, ИНН и т.п.) + - инициалы и короткие служебные слова (И., Г., УЛ., №1) + - числа и номера (350000, №1) + - слова, уже содержащие строчные буквы + Длинные слова целиком заглавными (ДИРЕКТОР, КРАСНОДАР, ПЕТРОВ) → «Директор», «Краснодар». + """ + words = text.split() + result = [] + for w in words: + # отделяем пунктуацию по краям слова + pre = '' + post = '' + core = w + while core and not core[0].isalnum(): + pre += core[0] + core = core[1:] + while core and not core[-1].isalnum(): + post = core[-1] + post + core = core[:-1] + if not core: + result.append(w) + continue + # в слове уже есть строчные буквы — не трогаем + if any('а' <= c <= 'я' or c == 'ё' for c in core): + result.append(w) + continue + # нет заглавных кириллических букв (числа, латиница и т.п.) — не трогаем + if not any('А' <= c <= 'Я' or c == 'Ё' for c in core): + result.append(w) + continue + letters = [c for c in core if c.isalpha()] + # инициалы и служебные слова (1-2 буквы: И., Г., УЛ., ООО — но ООО в белом списке) + if len(letters) <= 2: + result.append(w) + continue + # известная аббревиатура — сохраняем как есть + if core.upper() in _CAPS_ABBR: + result.append(w) + continue + # обычное длинное слово: первая заглавная, остальные строчные + normalized = core[0].upper() + core[1:].lower() + result.append(pre + normalized + post) + return ' '.join(result) + + +_ADDRESS_ABBR = { + # нормальные сокращения для адресов: «Г» → «г.», «Р-Н» → «р-н.» и т.п. + 'Г': 'г.', 'УЛ': 'ул.', 'Д': 'д.', 'КВ': 'кв.', 'ПР': 'пр.', + 'ПЕР': 'пер.', 'ПЛ': 'пл.', 'ОБЛ': 'обл.', 'ПОС': 'пос.', + 'СТ': 'ст.', 'Ш': 'ш.', 'Р-Н': 'р-н.', 'Б-Р': 'б-р.', 'МКР': 'мкр.', + 'НАБ': 'наб.', 'ТУП': 'туп.', 'ПР-Д': 'пр-д.', 'КРП': 'крп.', + 'Г.': 'г.', 'УЛ.': 'ул.', 'Д.': 'д.', 'Р-Н.': 'р-н.', +} + + +def normalize_address(text: str) -> str: + """Приводит адрес к нормальному виду: «Г Крымск» → «г. Крымск», + «Р-Н Крымский» → «р-н. Крымский», «УЛ Карла» → «ул. Карла».""" + if not text: + return '' + parts = re.split(r'(\s+|[,\;])', text) + out = [] + for p in parts: + if p.strip() and p.strip().upper() in _ADDRESS_ABBR: + out.append(_ADDRESS_ABBR[p.strip().upper()]) + else: + out.append(p) + return ''.join(out) + + +def clean_value(val): + """Очистка и нормализация значения ячейки Excel.""" + if val is None: + return '' + if isinstance(val, (int, float)): + # Попытка распознать дату (серийный номер Excel) + if 10000 < val < 80000: + serial = int(val) + if serial > 60: + serial -= 1 + dt = datetime(1899, 12, 30) + timedelta(days=serial) + return dt.strftime('%d.%m.%Y') + if isinstance(val, float) and val == int(val): + return str(int(val)) + return str(val) + text = str(val).strip() + text = text.replace('\n', ' ').replace('\r', ' ') + text = ' '.join(text.split()) + # Если весь текст КАПСОМ (нет строчных букв) — нормализуем по словам + if len(text) > 2 and any('А' <= c <= 'Я' or c == 'Ё' for c in text): + has_lower = any('а' <= c <= 'я' or c == 'ё' for c in text) + if not has_lower: + text = _normalize_caps(text) + return text + + +def import_152fz(filepath: str, log_fn: Optional[Callable] = None, settings_path: Optional[str] = None) -> dict: + """Импорт данных из опросного листа Excel (152-ФЗ). + + settings_path — путь к JSON-файлу настроек импорта (что и откуда брать). + Если None — файл import_settings.json рядом с программой; если его нет — + используется встроенный словарь по умолчанию. + """ + if openpyxl is None: + raise ImportError("openpyxl не установлен. Установите: pip install openpyxl") + + if log_fn is None: + log_fn = lambda msg: None + + settings = load_settings(settings_path) + log_fn(f"Загрузка опросного листа: {filepath}") + + data = { + 'fullName': '', 'shortName': '', 'addressLegal': '', 'addressActual': '', + 'email': '', 'phone': '', 'inn': '', 'ogrn': '', 'kpp': '', + 'okved': '', 'chiefPosition': '', 'chiefFio': '', + 'ispdnFio': '', 'ispdnPosition': '', 'paperDocuments': '', 'paperStorage': '', + 'ispdnDepartment': '', 'ispdnEmail': '', 'ispdnPhone': '', + 'informationSystems': [], 'commission': [], 'employeesAccess': [], + } + + try: + wb = openpyxl.load_workbook(filepath, data_only=True, read_only=False) + except Exception as e: + log_fn(f"❌ Ошибка открытия файла: {e}") + return data + + try: + _import_152_common(wb, data, settings, log_fn) + _import_152_is_list(wb, data, settings, log_fn) + _import_152_employees(wb, data, settings, log_fn) + except Exception as e: + log_fn(f"❌ Ошибка при импорте: {e}") + log_fn(traceback.format_exc()) + finally: + wb.close() + + # Копирование полей ИСПДн в админа, если админ не заполнен + for src, dst in [('ispdnFio', 'administratorFio'), ('ispdnFio', 'adminFio'), + ('ispdnPosition', 'administratorPosition'), ('ispdnPosition', 'adminPosition'), + ('ispdnPhone', 'phone'), ('ispdnEmail', 'email')]: + if data.get(src) and not data.get(dst): + data[dst] = data[src] + + # Нормализация адресов: «Г Крымск» → «г. Крымск», «Р-Н» → «р-н.» и т.п. + for key in ('addressLegal', 'addressActual'): + if data.get(key): + data[key] = normalize_address(data[key]) + + _validate_company_data(data, log_fn) + return data + + +def _import_152_common(wb, data, settings, log_fn): + """Импорт общих сведений по словарю настроек. + + Для каждого поля: сначала пробуем конкретную ячейку (cell), + если она задана и заполнена; иначе ищем строку по ключевым + словам (keywords) в колонке A листа «Общие сведения». + """ + sheet_name = None + sheet_kw = settings.get('common_sheet_keyword', 'общие') + for sn in wb.sheetnames: + if sheet_kw in sn.lower(): + sheet_name = sn + break + if not sheet_name: + log_fn(f"⚠ Лист по ключу '{sheet_kw}' не найден") + return + + ws = wb[sheet_name] + value_col = int(settings.get('value_column', 1)) + + # Кэш значений: для каждого поля ищем по строке один раз + row_cache = {} + for row in ws.iter_rows(min_row=1, values_only=True): + field_raw = clean_value(row[0]) if row[0] is not None else '' + if not field_raw: + continue + row_cache[field_raw.lower()] = ( + clean_value(row[value_col]) if len(row) > value_col else '' + ) + + for rule in settings.get('fields', []): + key = rule.get('var', '') + if not key: + continue + name = rule.get('name', key) + cell_ref = rule.get('cell', '') + keywords = rule.get('keywords', '') + + # 1) Конкретная ячейка + if cell_ref: + sheet_part, coord = parse_cell_ref(cell_ref) + target_ws = ws + if sheet_part: + for sn in wb.sheetnames: + if sn.lower() == sheet_part.lower(): + target_ws = wb[sn] + break + val = clean_value(cell_value(target_ws, coord)) + if val: + data[key] = val + log_fn(f" {name} [ячейка {coord}]: {val}") + continue + + # 2) Поиск по ключевым словам + if keywords: + try: + rx = re.compile(keywords, re.IGNORECASE) + except re.error: + rx = None + if rx: + for field_lower, val in row_cache.items(): + if rx.search(field_lower): + if val: + data[key] = val + log_fn(f" {name} [по ключу]: {val}") + break + + +def _import_152_is_list(wb, data, settings, log_fn): + """Импорт информационных систем из остальных листов.""" + exclude_kws = settings.get('is_sheet_exclude_keywords', ['общие сведения', 'сотрудники']) + is_sheets = [] + for sn in wb.sheetnames: + sn_lower = sn.lower() + if any(kw in sn_lower for kw in exclude_kws): + continue + is_sheets.append(sn) + if not is_sheets: + log_fn("⚠ Листы ИС не найдены") + return + + is_list = [] + for sheet_name in is_sheets: + ws = wb[sheet_name] + rows = [] + for row in ws.iter_rows(min_row=1, values_only=True): + vals = [clean_value(c) for c in row] + field = (vals[0] or '').strip() if vals else '' + if not field: + continue + rows.append(vals) + + if len(rows) < 2: + continue + + is_obj = { + 'name': '', 'description': '', 'room': '', 'employee': '', 'position': '', + 'personalDataList': [], 'pd_subjects_list': [], 'pd_actions': [], + 'usersList': [], 'isInternet': False, 'personalDataCount': 'менее 100 000', + 'defense_tools': [], 'processing_mode': '', + } + + current_section = 'header' + pd_items_raw = [] + subjects_raw = [] + actions_raw = [] + + for vals in rows: + field = (vals[0] or '').strip() + field_lower = field.lower() + val_b = clean_value(vals[1]) if len(vals) > 1 else '' + is_checked = val_b.upper() in ('ДА', 'YES', '✓', '✔', '1', 'TRUE', 'ЕСТЬ') + + if 'цель обработки пд' in field_lower or ('цель обработки' in field_lower and ':' in field): + current_section = 'purpose' + continue + if 'перечисленные пдн принадлежат' in field_lower or 'принадлеж' in field_lower: + current_section = 'subjects' + continue + if 'структура ис' in field_lower or 'структура информационной' in field_lower: + if val_b: + is_obj['structure'] = val_b + elif ':' in field: + # Значение может быть записано в той же ячейке после двоеточия + tail = field.split(':', 1)[1].strip() + if tail and 'выбрать' not in tail.lower(): + is_obj['structure'] = tail + continue + if 'названия программ' in field_lower or ('программ' in field_lower and 'ос взаимодейств' in field_lower): + if val_b: + is_obj['software'] = val_b + continue + if 'локальную сеть' in field_lower: + is_obj['is_has_lan'] = is_checked + continue + if 'количество записей' in field_lower or 'количество субъект' in field_lower: + if val_b: + is_obj['personalDataCount'] = val_b + continue + if 'перечень действий с пд' in field_lower or ('действи' in field_lower and 'пд' in field_lower): + current_section = 'actions' + continue + if 'способ обработки пд' in field_lower: + if val_b: + is_obj['processing_mode'] = val_b + continue + if 'интернет' in field_lower: + is_obj['isInternet'] = is_checked + continue + + if current_section == 'header': + if 'перечень пд' in field_lower: + current_section = 'pd_items' + elif 'название ис' in field_lower or 'наименование ис' in field_lower or 'наименование информационной' in field_lower: + is_obj['name'] = val_b + log_fn(f" ИС: {val_b}") + elif 'описание ис' in field_lower or 'описание информационной' in field_lower: + if val_b: + is_obj['description'] = val_b + log_fn(f" Описание: {val_b[:60]}") + continue + elif 'сотрудник' in field_lower and 'должност' not in field_lower: + is_obj['employee'] = val_b + if val_b: + parts = [p.strip() for p in val_b.replace('\n', ';').split(';') if p.strip()] + for part in parts: + cleaned = part.strip() + if ':' in cleaned and len(cleaned.split(':')[0]) < 20: + cleaned = cleaned.split(':', 1)[1].strip() + if cleaned and cleaned not in is_obj['usersList']: + is_obj['usersList'].append(cleaned) + elif 'должность сотрудника' in field_lower: + is_obj['position'] = val_b + elif 'наименование отдела' in field_lower: + pass + elif 'номер кабинета' in field_lower: + is_obj['room'] = val_b + elif 'значение' in field_lower or 'примечание' in field_lower: + continue + elif val_b and 'выбрать' not in field_lower: + if not is_obj['description']: + is_obj['description'] = f"{field}: {val_b}" + else: + is_obj['description'] += f"\n{field}: {val_b}" + elif current_section == 'pd_items': + if is_checked and 'выбрать' not in field_lower and field_lower != 'значение': + pd_items_raw.append(field) + if 'цель обработки' in field_lower: + current_section = 'purpose' + elif current_section == 'purpose': + purpose_val = val_b or field or '' + if purpose_val and 'выбрать' not in purpose_val.lower() and ':' not in purpose_val[:40]: + is_obj['purpose'] = purpose_val + log_fn(f" Цель: {purpose_val[:80]}") + current_section = 'header' + elif current_section == 'subjects': + if is_checked and 'выбрать' not in field_lower: + subjects_raw.append(field) + if 'количество записей' in field_lower or 'действи' in field_lower: + continue + elif current_section == 'actions': + if is_checked and 'выбрать' not in field_lower and 'защита' not in field_lower[:20]: + actions_raw.append(field) + if 'защита' in field_lower and 'действи' not in field_lower: + current_section = 'defense' + elif current_section == 'defense': + defense_keywords = { + 'антивирус': 'Антивирус', + 'крипто': 'СКЗИ', + 'межсетев': 'Межсетевой экран', + 'сзи.*от несанкционирован': 'СЗИ от НСД', + 'обнаружен.*вторжен': 'СОВ', + 'программные.*средств.*модул': 'Доверенная загрузка', + 'доверен.*загрузк': 'Доверенная загрузка', + 'гарантирован': 'Доверенная загрузка', + } + matched_label = None + for kw, label in defense_keywords.items(): + if re.search(kw, field_lower): + matched_label = label + break + if matched_label and val_b and 'выбрать' not in field_lower: + # Без префикса категории: просто название средства (Kaspersky, КриптоПро) + is_obj['defense_tools'].append(val_b) + elif matched_label: + pass + elif 'куда' in field_lower or 'передаются' in field_lower: + current_section = 'header' + elif 'способ обработки' in field_lower or 'локальную сеть' in field_lower or 'интернет' in field_lower: + current_section = 'header' + + if pd_items_raw: + is_obj['personalDataList'] = pd_items_raw + log_fn(f" ПДн: {len(pd_items_raw)}") + if subjects_raw: + is_obj['pd_subjects_list'] = subjects_raw + log_fn(f" Субъектов: {len(subjects_raw)}") + if actions_raw: + is_obj['pd_actions'] = actions_raw + log_fn(f" Действий: {len(actions_raw)}") + + if not is_obj['name']: + is_obj['name'] = sheet_name.strip() + log_fn(f" ⚠ ИС без названия — использовано имя листа: {is_obj['name']}") + + # Автоопределение категорий ПДн + pd_cats = [] + all_pd_text = ' '.join(p.lower() for p in pd_items_raw) + special_kw = ['состоян.*здоров', 'национальн', 'политическ', 'религиозн', 'философ', 'судимост', 'интимн'] + if any(re.search(kw, all_pd_text) for kw in special_kw): + pd_cats.append('специальные') + bio_kw = ['биометрическ', 'изображен.*лиц', 'голос.*человек', 'папилляр', 'дактилоскоп', 'фото.*изображен'] + if any(re.search(kw, all_pd_text) for kw in bio_kw): + pd_cats.append('биометрические') + if pd_items_raw: + pd_cats.append('иные') + if pd_cats: + is_obj['personalDataCategory'] = pd_cats + log_fn(f" Категории ПДн: {', '.join(pd_cats)}") + + is_list.append(is_obj) + log_fn(f" ✅ {is_obj['name']} — импортирована") + + if is_list: + data['informationSystems'] = is_list + log_fn(f"\n✅ Всего импортировано ИС: {len(is_list)}") + + +def _import_152_employees(wb, data, settings, log_fn): + """Импорт комиссии и сотрудников из листа «Сотрудники».""" + sheet_name = None + sheet_kw = settings.get('employees_sheet_keyword', 'сотруд') + for sn in wb.sheetnames: + if sheet_kw in sn.lower(): + sheet_name = sn + break + if not sheet_name: + log_fn(f"⚠ Лист по ключу '{sheet_kw}' не найден") + return + + ws = wb[sheet_name] + rows = [] + for row in ws.iter_rows(min_row=1, values_only=True): + vals = [clean_value(c) for c in row] + if not vals or not vals[0]: + continue + rows.append(vals) + + if not rows: + log_fn("⚠ Лист «Сотрудники» пуст") + return + + commission = [] + in_commission = False + COMMISSION_ROLES = { + 'председатель': 'Председатель комиссии', + 'секретарь': 'Секретарь комиссии', + 'заместитель председателя': 'Заместитель председателя комиссии', + } + employees = [] + in_employees = False + + for vals in rows: + col_a = (vals[0] or '').strip() + col_b = clean_value(vals[1]) if len(vals) > 1 else '' + col_c = clean_value(vals[2]) if len(vals) > 2 else '' + col_d = clean_value(vals[3]) if len(vals) > 3 else '' + col_e = clean_value(vals[4]) if len(vals) > 4 else '' + a_lower = col_a.lower() + + if 'состав комиссии' in a_lower: + in_commission = True + in_employees = False + log_fn(" Секция: состав комиссии") + continue + if 'перечень сотрудников' in a_lower: + in_commission = False + in_employees = True + log_fn(" Секция: сотрудники с доступом к ПДн") + continue + if a_lower in ('№ п/п', '№', 'номер', ''): + continue + + if in_commission: + role = 'Член комиссии' + # Роль ищем в колонках E (было) и D (Примечание — новый формат) + for col in (col_e, col_d): + if col: + col_lower = col.lower() + for keyword, role_name in COMMISSION_ROLES.items(): + if keyword in col_lower: + role = role_name + break + if role != 'Член комиссии': + break + if not col_b and not col_c: + continue + commission.append({ + 'role': role, + 'position': col_b, + 'fio': col_c.replace('\n', ' '), + }) + + if in_employees: + fio = col_c if col_c else col_d + if not fio: + continue + employee = { + 'position': col_b, + 'fio': fio.replace('\n', ' '), + } + if col_e: + employee['is_list'] = [s.strip() for s in col_e.split('\n') if s.strip()] + employees.append(employee) + + if commission: + commission = [c for c in commission if c['position'] or c['fio']] + data['commission'] = commission + log_fn(f" Комиссия: {len(commission)} чел.") + for m in commission[:3]: + log_fn(f" {m['role']}: {m['position'][:40]} — {m['fio'][:40]}") + + if employees: + data['employeesAccess'] = employees + log_fn(f" Сотрудников с доступом: {len(employees)}") + + +def _validate_company_data(data, log_fn): + """Проверка обязательных полей.""" + required = ['fullName', 'inn'] + missing = [f for f in required if not data.get(f)] + if missing: + log_fn(f"⚠️ ОБЯЗАТЕЛЬНЫЕ ПОЛЯ НЕ ЗАПОЛНЕНЫ: {', '.join(missing)}") + else: + log_fn("✅ Все обязательные поля заполнены") + + inn = (data.get('inn') or '').replace(' ', '') + if inn and (not inn.isdigit() or len(inn) not in (10, 12)): + log_fn(f"⚠️ ИНН подозрительного формата: «{inn}»") diff --git a/dokogen/models.py b/dokogen/models.py new file mode 100644 index 0000000..4b51360 --- /dev/null +++ b/dokogen/models.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Модели данных (только 152-ФЗ, только нужные поля)""" + +from dataclasses import dataclass, field, asdict +from typing import List, Dict, Any + + +@dataclass +class CommissionMember: + """Член комиссии по ПДн""" + role: str = "член комиссии" + position: str = "" + fio: str = "" + + +@dataclass +class InformationSystem: + """Информационная система (обработка ПДн)""" + name: str = "" + description: str = "" + is_local_network: bool = False + is_internet: bool = False + defence_level: str = "" + threat_type: str = "3" + category: str = "" + pd_list: str = "" + pd_count: str = "менее 100 000" + users: str = "" + defense_tools_list: List[str] = field(default_factory=list) + processing_modes: str = "" + subject_type: str = "работники организации" + pd_actions_list: List[str] = field(default_factory=list) + pd_subjects_list: List[str] = field(default_factory=list) + personal_data_list: List[str] = field(default_factory=list) + users_list: List[str] = field(default_factory=list) + personal_data_category: List[str] = field(default_factory=list) + purpose: str = "" + room: str = "" + structure: str = "" # Структура информационной системы + software: str = "" # Программное обеспечение + + +@dataclass +class PaperDocument: + """Бумажный документ с ПДн""" + name: str = "" + storage: str = "" + + +@dataclass +class Company: + """Организация — основной объект (152-ФЗ)""" + # Полное название + full_name: str = "" + company_name_1: str = "" # именительный + company_name_2: str = "" # родительный + company_name_3: str = "" # дательный + company_name_4: str = "" # винительный + company_name_5: str = "" # творительный + company_name_6: str = "" # предложный + + # Краткое название + short_name: str = "" + short_name_1: str = "" + short_name_2: str = "" + short_name_3: str = "" + short_name_4: str = "" + short_name_5: str = "" + short_name_6: str = "" + + # Адрес и реквизиты + address: str = "" + city_name: str = "" + inn: str = "" + ogrn: str = "" + ogrn_date: str = "" + kpp: str = "" + + # Должностные лица + chief_position: str = "" + chief_fio: str = "" + responsible_position: str = "" + responsible_fio: str = "" + admin_position: str = "" + admin_fio: str = "" + + # Документ + contract_number: str = "" + contract_date: str = "" + + # Списки + commission: List[CommissionMember] = field(default_factory=list) + information_systems: List[InformationSystem] = field(default_factory=list) + paper_documents_list: List[PaperDocument] = field(default_factory=list) + employees_access: List[Dict[str, str]] = field(default_factory=list) + + # Наименование структурного подразделения по безопасности + security_department: str = "" + + def update_declensions(self, inflect_func): + """Обновить склонения полного названия""" + if self.full_name: + inflected = inflect_func(self.full_name) + self.company_name_1 = self.full_name + self.company_name_2 = inflected.get("gent", "") + self.company_name_3 = inflected.get("datv", "") + self.company_name_4 = inflected.get("accs", "") + self.company_name_5 = inflected.get("ablt", "") + self.company_name_6 = inflected.get("loct", "") + + def update_short_declensions(self, inflect_func): + """Обновить склонения сокращённого названия""" + if self.short_name: + inflected = inflect_func(self.short_name) + self.short_name_1 = self.short_name + self.short_name_2 = inflected.get("gent", "") + self.short_name_3 = inflected.get("datv", "") + self.short_name_4 = inflected.get("accs", "") + self.short_name_5 = inflected.get("ablt", "") + self.short_name_6 = inflected.get("loct", "") + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Company": + d = dict(data) + if "commission" in d and isinstance(d["commission"], list): + d["commission"] = [ + CommissionMember(**c) if isinstance(c, dict) else c + for c in d["commission"] + ] + if "information_systems" in d and isinstance(d["information_systems"], list): + d["information_systems"] = [ + InformationSystem(**isys) if isinstance(isys, dict) else isys + for isys in d["information_systems"] + ] + if "paper_documents_list" in d and isinstance(d["paper_documents_list"], list): + d["paper_documents_list"] = [ + PaperDocument(**p) if isinstance(p, dict) else p + for p in d["paper_documents_list"] + ] + if "employees_access" in d and not isinstance(d["employees_access"], list): + d["employees_access"] = [] + valid = {f.name for f in cls.__dataclass_fields__.values()} + return cls(**{k: v for k, v in d.items() if k in valid}) + + def get_folder_name(self) -> str: + if self.short_name: + return self.short_name + if self.full_name: + return self.full_name[:30] + return "Новая_организация" diff --git a/dokogen/ui.py b/dokogen/ui.py new file mode 100644 index 0000000..e11012b --- /dev/null +++ b/dokogen/ui.py @@ -0,0 +1,2240 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Пользовательский интерфейс (tkinter)""" + +import tkinter as tk +from tkinter import ttk, messagebox, filedialog, simpledialog, scrolledtext +import os +import json +import sys +import threading +from datetime import datetime +from typing import List, Optional + +from . import ( + Company, CommissionMember, InformationSystem, PaperDocument, + inflect_name, + build_replacements, VARIABLE_DEFS, + process_template, + get_short_fio, get_initials, +) + +from .verify import ( + check_declension_morpher, check_company_dadata, + check_declension_ai, check_company_ai, + COMPANY_FIELDS, compare_company_values, +) +from .api_settings import load_api_settings, save_api_settings + + +class DokoGenApp: + """Главное окно приложения.""" + + def __init__(self, root: tk.Tk): + self.root = root + root.title("ДоКоГеНеРаТоР — 152-ФЗ (Персональные данные)") + root.geometry("1200x800") + root.minsize(900, 600) + + self.model = Company() + self.current_file: Optional[str] = None + self._current_is_idx: Optional[int] = None + self._template_files: List[str] = [] + self._selected_templates: set = set() + self._last_template_dir: str = "" + self._autosave_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '.autosave', 'autosave_152.json' + ) + + # Путь к файлу настроек импорта (словарь «что и откуда брать») + # None = import_settings.json рядом с программой (создаётся автоматически) + self.import_settings_path: Optional[str] = None + + # Файловый лог: рядом с exe (frozen) или рядом с модулем + if getattr(sys, 'frozen', False): + _base = os.path.dirname(sys.executable) + else: + _base = os.path.dirname(os.path.abspath(__file__)) + self._log_path = os.path.join(_base, 'dokogen.log') + + # Переменные + self.doc_number_var = tk.StringVar(value="") + self.doc_date_var = tk.StringVar(value="") + self.templates_var = tk.StringVar() + self.save_var = tk.StringVar() + self.status_var = tk.StringVar(value="Готов") + + # Словари для хранения виджетов + self._company_entries: dict = {} + self._official_entries: dict = {} + self.commission_entries: list = [] + + self._build_menu() + self._build_ui() + self._enable_context_menu() + self._init_company() + + # Привязка закрытия + root.protocol("WM_DELETE_WINDOW", self._on_close) + + # Автосохранение + self._load_autosave() + self._start_autosave_timer() + + # ==================== МЕНЮ ==================== + def _build_menu(self): + menubar = tk.Menu(self.root) + self.root.config(menu=menubar) + + file_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Файл", menu=file_menu) + file_menu.add_command(label="Новое", command=self.new_data, accelerator="Ctrl+N") + file_menu.add_command(label="Открыть", command=self.open_data, accelerator="Ctrl+O") + file_menu.add_command(label="Сохранить", command=self.save_data, accelerator="Ctrl+S") + file_menu.add_command(label="Сохранить как", command=self.save_as_data, accelerator="Ctrl+Shift+S") + file_menu.add_separator() + + self.recent_menu = tk.Menu(file_menu, tearoff=0) + file_menu.add_cascade(label="Последние файлы", menu=self.recent_menu) + file_menu.add_separator() + file_menu.add_command(label="Настройки…", command=self.open_api_settings) + file_menu.add_separator() + file_menu.add_command(label="Выход", command=self._on_close) + + gen_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Генерация", menu=gen_menu) + gen_menu.add_command(label="Импорт из опросника", command=self.import_from_survey) + gen_menu.add_command(label="Файл настроек импорта…", command=self.select_import_settings) + gen_menu.add_separator() + gen_menu.add_command(label="Сгенерировать всё", command=self.generate_all) + + help_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Справка", menu=help_menu) + help_menu.add_command(label="О программе", command=self.show_about) + help_menu.add_command(label="Инструкция пользователя (Word)…", command=self.download_user_manual) + help_menu.add_command(label="Инструкция администратора (Word)…", command=self.download_admin_manual) + help_menu.add_command(label="Переменные шаблонов (Word)…", command=self.download_variables_doc) + help_menu.add_command(label="Пустой опросник (Excel)…", command=self.download_survey) + + # ==================== ИНТЕРФЕЙС ==================== + def _build_ui(self): + # Панель инструментов + toolbar = ttk.Frame(self.root) + toolbar.pack(side=tk.TOP, fill=tk.X, padx=5, pady=3) + + ttk.Button(toolbar, text="📂 Открыть", command=self.open_data).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="💾 Сохранить", command=self.save_data).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="📥 Импорт", command=self.import_from_survey).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="📁 Шаблоны", command=self.select_templates).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="⚡ Генерация", command=self.generate_all).pack(side=tk.LEFT, padx=2) + + # Ноутбук (вкладки) + self.notebook = ttk.Notebook(self.root) + self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=2) + + self._build_company_tab() + self._build_is_tab() + self._build_commission_tab() + self._build_users_tab() + self._build_paper_tab() + self._build_generation_tab() + self._build_log_tab() + + # Статус-бар + statusbar = ttk.Label(self.root, textvariable=self.status_var, + relief=tk.SUNKEN, anchor=tk.W) + statusbar.pack(side=tk.BOTTOM, fill=tk.X) + + # Горячие клавиши + self.root.bind("", lambda e: self.new_data()) + self.root.bind("", lambda e: self.open_data()) + self.root.bind("", lambda e: self.save_data()) + self.root.bind("", lambda e: self.save_as_data()) + + # ---------- ВКЛАДКА "ОРГАНИЗАЦИЯ" ---------- + def _build_company_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Организация") + + canvas = tk.Canvas(tab, highlightthickness=0) + scrollbar = ttk.Scrollbar(tab, orient=tk.VERTICAL, command=canvas.yview) + scrollable = ttk.Frame(canvas) + scrollable.bind("", + lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=scrollable, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + main = ttk.Frame(scrollable, padding=15) + main.pack(fill=tk.BOTH, expand=True) + + left = ttk.Frame(main) + left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # --- Название организации --- + f1 = ttk.LabelFrame(left, text="Наименование организации", padding=10) + f1.pack(fill=tk.X, pady=5) + + ttk.Label(f1, text="Полное название:").grid(row=0, column=0, sticky=tk.W, pady=2) + self.company_name_entry = ttk.Entry(f1, width=60) + self.company_name_entry.grid(row=0, column=1, padx=10, sticky=tk.EW) + ttk.Button(f1, text="Склонять", command=self._update_company_declensions).grid( + row=0, column=2, padx=5) + + ttk.Label(f1, text="Краткое название:").grid(row=1, column=0, sticky=tk.W, pady=2) + self.short_name_entry = ttk.Entry(f1, width=60) + self.short_name_entry.grid(row=1, column=1, padx=10, sticky=tk.EW) + ttk.Button(f1, text="Склонять", command=self._update_short_declensions).grid( + row=1, column=2, padx=5) + f1.columnconfigure(1, weight=1) + + # Склонения полного названия + decl_frame = ttk.Frame(f1) + decl_frame.grid(row=2, column=0, columnspan=3, sticky=tk.EW, pady=(5, 0)) + f1.columnconfigure(0, weight=0) + f1.columnconfigure(1, weight=1) + f1.columnconfigure(2, weight=0) + + self.decl_entries = {} + cases = [ + ("Именительный (Кто? Что?)", "nomn"), + ("Родительный (Кого? Чего?)", "gent"), + ("Дательный (Кому? Чему?)", "datv"), + ("Винительный (Кого? Что?)", "accs"), + ("Творительный (Кем? Чем?)", "ablt"), + ("Предложный (О ком? О чём?)", "loct"), + ] + for i, (label, code) in enumerate(cases): + ttk.Label(decl_frame, text=label, font=('', 8)).grid(row=i, column=0, sticky=tk.W, padx=(0, 5)) + entry = ttk.Entry(decl_frame, width=55, font=('', 8)) + entry.grid(row=i, column=1, sticky=tk.EW, pady=1) + self.decl_entries[code] = entry + decl_frame.columnconfigure(1, weight=1) + + # Склонения краткого названия + ttk.Label(decl_frame, text="", font=('', 5)).grid(row=6, column=0) + self.short_decl_entries = {} + for i, (label, code) in enumerate(cases): + row = i + 7 + ttk.Label(decl_frame, text=f"Кратк. {label.lower()}", + font=('', 8)).grid(row=row, column=0, sticky=tk.W, padx=(0, 5)) + entry = ttk.Entry(decl_frame, width=55, font=('', 8)) + entry.grid(row=row, column=1, sticky=tk.EW, pady=1) + self.short_decl_entries[code] = entry + decl_frame.columnconfigure(1, weight=1) + + # --- Адрес и реквизиты --- + f2 = ttk.LabelFrame(left, text="Адрес и реквизиты", padding=10) + f2.pack(fill=tk.X, pady=5) + + addr_fields = [ + ("Юридический адрес:", "address"), + ("Город:", "city_name"), + ("ИНН:", "inn"), + ("КПП:", "kpp"), + ("ОГРН:", "ogrn"), + ("Дата ОГРН:", "ogrn_date"), + ] + for i, (label, key) in enumerate(addr_fields): + ttk.Label(f2, text=label).grid(row=i, column=0, sticky=tk.W, pady=2) + entry = ttk.Entry(f2, width=50) + entry.grid(row=i, column=1, padx=10, sticky=tk.EW, pady=1) + self._company_entries[key] = entry + + # Привязка автозаполнения города из адреса + addr_entry = self._company_entries.get('address') + if addr_entry: + addr_entry.bind('', self._on_address_changed) + + f2.columnconfigure(1, weight=1) + + # --- Должностные лица --- + f3 = ttk.LabelFrame(left, text="Должностные лица", padding=10) + f3.pack(fill=tk.X, pady=5) + + of_fields = [ + ("Руководитель (должность):", "chief_position"), + ("Руководитель (ФИО):", "chief_fio"), + ("Ответственный (должность):", "responsible_position"), + ("Ответственный (ФИО):", "responsible_fio"), + ("Администратор (должность):", "admin_position"), + ("Администратор (ФИО):", "admin_fio"), + ] + for i, (label, key) in enumerate(of_fields): + ttk.Label(f3, text=label).grid(row=i, column=0, sticky=tk.W, pady=2) + entry = ttk.Entry(f3, width=50) + entry.grid(row=i, column=1, padx=10, sticky=tk.EW, pady=1) + self._official_entries[key] = entry + f3.columnconfigure(1, weight=1) + + # --- Документ --- + f4 = ttk.LabelFrame(left, text="Документ", padding=10) + f4.pack(fill=tk.X, pady=5) + ttk.Label(f4, text="Номер договора:").grid(row=0, column=0, sticky=tk.W) + ttk.Entry(f4, textvariable=self.doc_number_var, width=25).grid( + row=0, column=1, padx=10, sticky=tk.W) + ttk.Label(f4, text="Дата договора:").grid(row=1, column=0, sticky=tk.W) + ttk.Entry(f4, textvariable=self.doc_date_var, width=25).grid( + row=1, column=1, padx=10, sticky=tk.W) + + # --- Правая панель: проверка данных --- + right = ttk.LabelFrame(main, text="Проверка данных", padding=10) + right.pack(side=tk.RIGHT, fill=tk.Y, padx=(10, 0)) + + ttk.Label(right, text="Склонение названия:", font=('', 9, 'bold')).pack(anchor=tk.W) + ttk.Button(right, text="🌐 Склонение — Морфер 3.0", + command=self._check_declension_web).pack(fill=tk.X, pady=2) + ttk.Button(right, text="🤖 Склонение — ИИ", + command=self._check_declension_ai).pack(fill=tk.X, pady=2) + + ttk.Label(right, text="Реквизиты организации:", font=('', 9, 'bold')).pack(anchor=tk.W, pady=(10, 0)) + ttk.Button(right, text="🌐 Реквизиты — DaData", + command=self._check_company_web).pack(fill=tk.X, pady=2) + ttk.Button(right, text="🤖 Реквизиты — ИИ", + command=self._check_company_ai).pack(fill=tk.X, pady=2) + + ttk.Label(right, text="Результат проверки:", font=('', 9, 'bold')).pack(anchor=tk.W, pady=(10, 0)) + self._verify_text = scrolledtext.ScrolledText( + right, width=44, height=14, state=tk.DISABLED, wrap=tk.WORD, font=('', 9)) + self._verify_text.pack(fill=tk.BOTH, expand=True, pady=4) + + btn_row = ttk.Frame(right) + btn_row.pack(fill=tk.X) + ttk.Button(btn_row, text="✓ Применить", command=self._verify_apply).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_row, text="Очистить", command=self._verify_clear).pack(side=tk.LEFT, padx=2) + + # Выборочное применение: чекбоксы «что подставлять» + self._apply_flags = { + 'declension': tk.BooleanVar(value=True), + 'short_declension': tk.BooleanVar(value=True), + 'address': tk.BooleanVar(value=True), + 'requisites': tk.BooleanVar(value=True), + 'chief': tk.BooleanVar(value=True), + } + apply_row = ttk.Frame(right) + apply_row.pack(fill=tk.X, pady=(4, 0)) + ttk.Label(apply_row, text="Подставлять:", font=('', 8)).pack(side=tk.LEFT, padx=(0, 4)) + ttk.Checkbutton(apply_row, text="склонения", variable=self._apply_flags['declension']).pack(side=tk.LEFT) + ttk.Checkbutton(apply_row, text="кр. склон.", variable=self._apply_flags['short_declension']).pack(side=tk.LEFT) + ttk.Checkbutton(apply_row, text="адрес", variable=self._apply_flags['address']).pack(side=tk.LEFT) + ttk.Checkbutton(apply_row, text="ИНН/КПП/ОГРН", variable=self._apply_flags['requisites']).pack(side=tk.LEFT) + ttk.Checkbutton(apply_row, text="руковод.", variable=self._apply_flags['chief']).pack(side=tk.LEFT) + self._verify_result = None + + # ---------- ПРОВЕРКА ДАННЫХ (Морфер / DaData / ИИ) ---------- + def open_api_settings(self): + """Окно настроек API-ключей (меню «Файл → Настройки»).""" + win = tk.Toplevel(self.root) + win.title("Настройки") + win.geometry("540x460") + win.transient(self.root) + win.grab_set() + + nb = ttk.Notebook(win) + nb.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # Вкладка API-ключи + api_tab = ttk.Frame(nb, padding=10) + nb.add(api_tab, text="API-ключи") + + settings = load_api_settings() + + ttk.Label(api_tab, text="DaData — проверка организации по ИНН/ОГРН", + font=('', 9, 'bold')).pack(anchor=tk.W) + ttk.Label(api_tab, text="API-ключ (получить на https://dadata.ru):").pack(anchor=tk.W, pady=(6, 0)) + dadata_var = tk.StringVar(value=settings.get('dadata_token', '')) + ttk.Entry(api_tab, textvariable=dadata_var, width=64).pack(fill=tk.X, pady=2) + + ttk.Label(api_tab, text="ИИ-проверка (OpenAI-совместимый API: DeepSeek, ChatGPT и др.)", + font=('', 9, 'bold')).pack(anchor=tk.W, pady=(14, 0)) + ttk.Label(api_tab, text="API-ключ:").pack(anchor=tk.W, pady=(6, 0)) + ds_var = tk.StringVar(value=settings.get('ai_api_key', '')) + ttk.Entry(api_tab, textvariable=ds_var, width=64).pack(fill=tk.X, pady=2) + ttk.Label(api_tab, text="Модель (например: deepseek-chat, gpt-4o-mini, gpt-4o):").pack(anchor=tk.W, pady=(6, 0)) + model_var = tk.StringVar(value=settings.get('ai_model', 'deepseek-chat')) + ttk.Entry(api_tab, textvariable=model_var, width=64).pack(fill=tk.X, pady=2) + ttk.Label(api_tab, text="Базовый URL API (для DeepSeek можно оставить пустым):").pack(anchor=tk.W, pady=(6, 0)) + url_var = tk.StringVar(value=settings.get('ai_base_url', '')) + ttk.Entry(api_tab, textvariable=url_var, width=64).pack(fill=tk.X, pady=2) + + ttk.Label(api_tab, text="Морфер 3.0 работает без ключа (бесплатный склонятор).", + foreground='gray').pack(anchor=tk.W, pady=(14, 0)) + + def _save(): + save_api_settings({ + 'dadata_token': dadata_var.get().strip(), + 'ai_api_key': ds_var.get().strip(), + 'ai_model': model_var.get().strip() or 'deepseek-chat', + 'ai_base_url': url_var.get().strip(), + }) + self.log("✅ Настройки API сохранены") + win.destroy() + + ttk.Button(api_tab, text="💾 Сохранить", command=_save).pack(anchor=tk.W, pady=12) + + # ---------- ПРОВЕРКИ ---------- + _CASE_TO_CODE = {'И': 'nomn', 'Р': 'gent', 'Д': 'datv', 'В': 'accs', 'Т': 'ablt', 'П': 'loct'} + + def _verify_log(self, text): + self._verify_text.config(state=tk.NORMAL) + self._verify_text.delete('1.0', tk.END) + self._verify_text.insert('1.0', text) + self._verify_text.config(state=tk.DISABLED) + + def _verify_clear(self): + self._verify_result = None + self._verify_log('') + + def _check_declension_web(self): + name = self.company_name_entry.get().strip() + short = self.short_name_entry.get().strip() + if not name and not short: + messagebox.showwarning("Проверка", "Сначала введите полное или краткое название организации") + return + self.log(f"🌐 Запрос к Морфер 3.0: «{name or short}»") + self._verify_log("Запрос к Морфер 3.0…") + + def _run(): + # Склоняем и полное, и краткое название (если заполнено) + results = [] + if name: + r1, e1 = check_declension_morpher(name) + if e1: + self.root.after(0, lambda: self._verify_log(f"❌ {e1}")) + return + results.append(('declension', r1)) + if short: + r2, e2 = check_declension_morpher(short) + if e2: + self.root.after(0, lambda: self._verify_log(f"❌ Краткое: {e2}")) + return + results.append(('short_declension', r2)) + self.root.after(0, lambda: self._verify_declension_done(results, None)) + + threading.Thread(target=_run, daemon=True).start() + + def _check_declension_ai(self): + name = self.company_name_entry.get().strip() + short = self.short_name_entry.get().strip() + if not name and not short: + messagebox.showwarning("Проверка", "Сначала введите полное или краткое название организации") + return + settings = load_api_settings() + if not settings.get('ai_api_key'): + messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»") + return + self.log(f"🤖 Запрос к ИИ ({settings.get('ai_model') or 'deepseek-chat'}): склонение «{name or short}»") + self._verify_log("Запрос к ИИ…") + + def _run(): + results = [] + if name: + r1, e1 = check_declension_ai(name, settings['ai_api_key'], + settings.get('ai_model'), settings.get('ai_base_url')) + if e1: + self.root.after(0, lambda: self._verify_log(f"❌ {e1}")) + return + results.append(('declension', r1)) + if short: + r2, e2 = check_declension_ai(short, settings['ai_api_key'], + settings.get('ai_model'), settings.get('ai_base_url')) + if e2: + self.root.after(0, lambda: self._verify_log(f"❌ Краткое: {e2}")) + return + results.append(('short_declension', r2)) + self.root.after(0, lambda: self._verify_declension_done(results, None)) + + threading.Thread(target=_run, daemon=True).start() + + def _verify_declension_done(self, results, err): + if err: + self._verify_log(f"❌ {err}") + self.log(f"❌ Проверка склонений: {err}") + return + # Собираем единый результат для применения + combined = { + 'source': results[0][1]['source'] if results else 'ИИ', + 'source_url': results[0][1]['source_url'] if results else '', + 'disclaimer': results[0][1].get('disclaimer', '') if results else '', + 'kind': 'declension', + 'data': {}, + 'apply': {}, + } + lines = [f"Источник: {combined['source']} ({combined['source_url']})"] + if combined['disclaimer']: + lines.append(combined['disclaimer']) + for key, res in results: + label = "Полное название" if key == 'declension' else "Краткое название" + lines.append(f"\n{label}:") + for code, name_case in [('И', 'Именительный'), ('Р', 'Родительный'), ('Д', 'Дательный'), ('В', 'Винительный'), + ('Т', 'Творительный'), ('П', 'Предложный')]: + if code in res['data']: + lines.append(f" {name_case}: {res['data'][code]}") + if res.get('comment'): + lines.append(f" Комментарий: {res['comment']}") + combined['apply'][key] = res['apply']['declension'] + lines.append("\nНажмите «✓ Применить», чтобы подставить склонения") + self._verify_result = combined + self._verify_log('\n'.join(lines)) + # Общий лог с ответом сервиса + sample = '' + for key, res in results: + sample = res['data'].get('П', '') + if sample: + break + self.log(f"{combined['source']}: склонение проверено → «{sample}»") + + def _check_company_web(self): + inn_entry = self._company_entries.get('inn') + inn = inn_entry.get().strip() if inn_entry else '' + if not inn: + messagebox.showwarning("Проверка", "Сначала введите ИНН организации") + return + settings = load_api_settings() + if not settings.get('dadata_token'): + messagebox.showwarning("Настройки", "Не задан API-ключ DaData.\nМеню «Файл → Настройки»") + return + self.log(f"🌐 Запрос к DaData: ИНН/ОГРН {inn}") + self._verify_log("Запрос к DaData…") + + def _run(): + result, err = check_company_dadata(inn, settings['dadata_token']) + self.root.after(0, lambda: self._verify_company_done(result, err)) + + threading.Thread(target=_run, daemon=True).start() + + def _check_company_ai(self): + self._collect_company() + c = self.model + settings = load_api_settings() + if not settings.get('ai_api_key'): + messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»") + return + company_data = { + 'full_name': c.full_name, 'short_name': c.short_name, + 'address': c.address, 'inn': c.inn, 'kpp': c.kpp, 'ogrn': c.ogrn, + 'ogrn_date': c.ogrn_date, + 'chief_position': c.chief_position, 'chief_fio': c.chief_fio, + } + self.log(f"🤖 Запрос к ИИ ({settings.get('ai_model') or 'deepseek-chat'}): реквизиты (ИНН {company_data.get('inn') or '—'})") + self._verify_log("Запрос к ИИ…") + + def _run(): + result, err = check_company_ai(company_data, settings['ai_api_key'], + settings.get('ai_model'), settings.get('ai_base_url')) + self.root.after(0, lambda: self._verify_company_done(result, err)) + + threading.Thread(target=_run, daemon=True).start() + + def _verify_company_done(self, result, err): + if err: + self._verify_log(f"❌ {err}") + self.log(f"❌ Проверка реквизитов: {err}") + return + self._verify_result = result + lines = [f"Источник: {result['source']} ({result['source_url']})"] + if result.get('disclaimer'): + lines.append(result['disclaimer']) + d = result['data'] + if 'full_name' in d: + # DaData — фактические данные + for key, label in [('full_name', 'Наименование'), ('short_name', 'Краткое'), + ('address', 'Адрес'), ('inn', 'ИНН'), ('kpp', 'КПП'), + ('ogrn', 'ОГРН'), ('ogrn_date', 'Дата ОГРН'), + ('chief_position', 'Руководитель'), ('chief_fio', 'ФИО')]: + if d.get(key): + lines.append(f"{label}: {d[key]}") + else: + # ИИ-рецензия: показываем снимок полей (что ИИ увидел), замечания и предложения + lines.append(f"Оценка: {d.get('rating', '')}") + fields = d.get('fields') or {} + if fields: + lines.append("\nПроверенные поля:") + for key, label in COMPANY_FIELDS: + info = fields.get(key) + if isinstance(info, dict): + status = info.get('статус', '') + comment = info.get('комментарий', '') + lines.append(f" {label}: {status}" + (f" — {comment}" if comment else "")) + for n in (d.get('notes') or []): + lines.append(f"• {n}") + props = d.get('proposals') or {} + if any(props.values()): + lines.append("\nПредложения ИИ:") + for key, label in COMPANY_FIELDS: + v = props.get(key) + if v: + lines.append(f" {label}: {v}") + lines.append("\nНажмите «✓ Применить», чтобы подставить данные") + self._verify_log('\n'.join(lines)) + + # Сравнение полученных значений с введёнными (серьёзные расхождения — красным) + current = { + 'full_name': self.model.full_name, + 'short_name': self.model.short_name, + 'address': self.model.address, + 'inn': self.model.inn, + 'kpp': self.model.kpp, + 'ogrn': self.model.ogrn, + 'ogrn_date': self.model.ogrn_date, + 'chief_position': self.model.chief_position, + 'chief_fio': self.model.chief_fio, + } + if 'full_name' in d: + new_vals = d # DaData — фактические данные + else: + new_vals = d.get('proposals') or {} # ИИ — предложения + diffs = compare_company_values(current, new_vals) + if diffs: + warn = "⚠ ВНИМАНИЕ! НАЙДЕНЫ РАСХОЖДЕНИЯ:\n" + for label, old_v, new_v in diffs: + warn += f"• {label}: было «{old_v}», стало «{new_v}»\n" + self._verify_text.tag_configure('warn', foreground='red', + font=('Arial', 14, 'bold')) + self._verify_text.config(state=tk.NORMAL) + self._verify_text.insert(tk.END, '\n' + warn, 'warn') + self._verify_text.config(state=tk.DISABLED) + self.log(f"⚠ ВНИМАНИЕ: найдены расхождения ({len(diffs)}): " + + '; '.join(f"{l}: {o} → {n}" for l, o, n in diffs[:3])) + + # Общий лог с ответом сервиса + summary = '' + if 'full_name' in d: + summary = f"{d.get('full_name', '')[:60]} (ИНН {d.get('inn', '')})" + else: + summary = f"оценка {d.get('rating', '')}, замечаний: {len(d.get('notes') or [])}" + self.log(f"{result['source']}: проверка реквизитов → {summary}") + + def _verify_apply(self): + result = self._verify_result + if not result: + return + apply_data = result.get('apply', {}) + flags = getattr(self, '_apply_flags', None) + + def want(key): + if flags is None: + return True + return flags[key].get() if key in flags else True + + idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6} + if 'declension' in apply_data and want('declension'): + for case, val in apply_data['declension'].items(): + code = self._CASE_TO_CODE.get(case) + if code and code in self.decl_entries: + self.decl_entries[code].delete(0, tk.END) + self.decl_entries[code].insert(0, val) + setattr(self.model, f'company_name_{idx_map[code]}', val) + self.log("✅ Склонения полного названия применены") + if 'short_declension' in apply_data and want('short_declension'): + for case, val in apply_data['short_declension'].items(): + code = self._CASE_TO_CODE.get(case) + if code and code in self.short_decl_entries: + self.short_decl_entries[code].delete(0, tk.END) + self.short_decl_entries[code].insert(0, val) + setattr(self.model, f'short_name_{idx_map[code]}', val) + self.log("✅ Склонения краткого названия применены") + if 'company' in apply_data: + comp = apply_data['company'] + if comp.get('full_name'): + self.company_name_entry.delete(0, tk.END) + self.company_name_entry.insert(0, comp['full_name']) + if comp.get('short_name'): + self.short_name_entry.delete(0, tk.END) + self.short_name_entry.insert(0, comp['short_name']) + if want('address') and comp.get('address'): + if 'address' in self._company_entries: + self._company_entries['address'].delete(0, tk.END) + self._company_entries['address'].insert(0, comp['address']) + if want('requisites'): + for data_key, ui_key in {'kpp': 'kpp', 'ogrn': 'ogrn', + 'ogrn_date': 'ogrn_date', 'inn': 'inn'}.items(): + if comp.get(data_key) and ui_key in self._company_entries: + self._company_entries[ui_key].delete(0, tk.END) + self._company_entries[ui_key].insert(0, comp[data_key]) + if want('chief'): + if comp.get('chief_position') and 'chief_position' in self._official_entries: + self._official_entries['chief_position'].delete(0, tk.END) + self._official_entries['chief_position'].insert(0, comp['chief_position']) + if comp.get('chief_fio') and 'chief_fio' in self._official_entries: + self._official_entries['chief_fio'].delete(0, tk.END) + self._official_entries['chief_fio'].insert(0, comp['chief_fio']) + self.log("✅ Реквизиты применены") + + # ---------- ВКЛАДКА "ИНФОРМАЦИОННЫЕ СИСТЕМЫ" ---------- + def _build_is_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Информационные системы") + + # Левая панель — список ИС + left = ttk.Frame(tab) + left.pack(side=tk.LEFT, fill=tk.Y, padx=5, pady=5) + ttk.Label(left, text="Информационные системы:").pack() + self.is_listbox = tk.Listbox(left, width=35, height=18, exportselection=False) + self.is_listbox.pack(fill=tk.BOTH, expand=True, pady=5) + self.is_listbox.bind('<>', self._on_is_select) + + btn_frame = ttk.Frame(left) + btn_frame.pack(fill=tk.X) + ttk.Button(btn_frame, text="+ Добавить", command=self._add_is).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="Копировать", command=self._copy_is).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="- Удалить", command=self._delete_is).pack(side=tk.LEFT, padx=2) + + # Правая панель — редактирование + self._is_edit_panel = ttk.LabelFrame(tab, text="Редактирование ИС") + self._is_edit_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) + + fields_frame = ttk.Frame(self._is_edit_panel, padding=10) + fields_frame.pack(fill=tk.X) + + # Основные поля + ttk.Label(fields_frame, text="Наименование:").grid(row=0, column=0, sticky=tk.W, pady=2) + self.is_name_entry = ttk.Entry(fields_frame, width=55) + self.is_name_entry.grid(row=0, column=1, padx=10, sticky=tk.EW) + + ttk.Label(fields_frame, text="Описание:").grid(row=1, column=0, sticky=tk.W, pady=2) + self.is_description_entry = ttk.Entry(fields_frame, width=55) + self.is_description_entry.grid(row=1, column=1, padx=10, sticky=tk.EW) + + # Чекбоксы + chk_frame = ttk.Frame(fields_frame) + chk_frame.grid(row=2, column=1, sticky=tk.W, pady=5) + self.is_lan_var = tk.BooleanVar() + ttk.Checkbutton(chk_frame, text="ЛВС", variable=self.is_lan_var).pack(side=tk.LEFT, padx=5) + self.is_internet_var = tk.BooleanVar() + ttk.Checkbutton(chk_frame, text="Интернет", variable=self.is_internet_var).pack(side=tk.LEFT, padx=5) + + # Тип угроз + self.is_threat_var = tk.StringVar(value="3") + self.is_threat_var.trace_add("write", lambda *_: self._on_categories_or_count_changed()) + ttk.Label(fields_frame, text="Тип угроз:").grid(row=3, column=0, sticky=tk.W, pady=2) + threat_frame = ttk.Frame(fields_frame) + threat_frame.grid(row=3, column=1, sticky=tk.W, padx=10) + for val, text in [("1", "1 (НДВ в СПО)"), ("2", "2 (НДВ в ППО)"), ("3", "3 (НДВ нет)")]: + ttk.Radiobutton(threat_frame, text=text, variable=self.is_threat_var, + value=val).pack(side=tk.LEFT, padx=3) + + # Уровень защищённости + self.is_defence_var = tk.StringVar(value="4") + ttk.Label(fields_frame, text="Ур. защищённости:").grid(row=4, column=0, sticky=tk.W, pady=2) + def_frame = ttk.Frame(fields_frame) + def_frame.grid(row=4, column=1, sticky=tk.W, padx=10) + for lvl in ["1", "2", "3", "4"]: + ttk.Radiobutton(def_frame, text=f"{lvl} ур.", + variable=self.is_defence_var, value=lvl).pack(side=tk.LEFT, padx=3) + + + # Категории ПДн + ttk.Label(fields_frame, text="Категории ПДн:").grid(row=5, column=0, sticky=tk.NW, pady=2) + cat_frame = ttk.Frame(fields_frame) + cat_frame.grid(row=5, column=1, sticky=tk.W, padx=10) + self.category_vars = [] + for cat in ["специальные", "биометрические", "общедоступные", "иные"]: + var = tk.BooleanVar() + var.trace_add('write', lambda *_: self._on_categories_or_count_changed()) + cb = ttk.Checkbutton(cat_frame, text=cat, variable=var) + cb.pack(anchor=tk.W) + self.category_vars.append((cat, var)) + + # Количество записей ПДн (выпадающий список) + ttk.Label(fields_frame, text="Кол-во записей:").grid(row=6, column=0, sticky=tk.W, pady=2) + self.is_pd_count_var = tk.StringVar(value="менее 100 000") + self.is_pd_count_combo = ttk.Combobox(fields_frame, textvariable=self.is_pd_count_var, + values=["менее 100 000", "более 100 000"], + state='readonly', width=28) + self.is_pd_count_combo.grid(row=6, column=1, padx=10, sticky=tk.W) + self.is_pd_count_combo.bind('<>', lambda e: self._on_categories_or_count_changed()) + + # Структура ИС + ttk.Label(fields_frame, text="Структура ИС:").grid(row=7, column=0, sticky=tk.W, pady=2) + self.is_structure_entry = ttk.Entry(fields_frame, width=55) + self.is_structure_entry.grid(row=7, column=1, padx=10, sticky=tk.EW) + + fields_frame.columnconfigure(1, weight=1) + + # Вложенные списки + nb2 = ttk.Notebook(self._is_edit_panel) + nb2.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # Список ПДн + pd_tab = ttk.Frame(nb2) + nb2.add(pd_tab, text="Перечень ПДн") + pd_btn_frame = ttk.Frame(pd_tab) + pd_btn_frame.pack(fill=tk.X) + ttk.Button(pd_btn_frame, text="+ Добавить", command=self._add_pd_item).pack(side=tk.LEFT, padx=2) + ttk.Button(pd_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.pd_listbox)).pack(side=tk.LEFT, padx=2) + ttk.Button(pd_btn_frame, text="Выбрать из списка", command=self._select_from_pd_list).pack(side=tk.LEFT, padx=2) + self.pd_listbox = tk.Listbox(pd_tab, height=6) + self.pd_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Субъекты ПДн + subj_tab = ttk.Frame(nb2) + nb2.add(subj_tab, text="Субъекты ПДн") + subj_btn_frame = ttk.Frame(subj_tab) + subj_btn_frame.pack(fill=tk.X) + ttk.Button(subj_btn_frame, text="+ Добавить", command=self._add_pd_subjects_item).pack(side=tk.LEFT, padx=2) + ttk.Button(subj_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.pd_subjects_listbox)).pack(side=tk.LEFT, padx=2) + ttk.Button(subj_btn_frame, text="Выбрать из списка", command=self._select_from_subjects_list).pack(side=tk.LEFT, padx=2) + self.pd_subjects_listbox = tk.Listbox(subj_tab, height=6) + self.pd_subjects_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Действия с ПДн + act_tab = ttk.Frame(nb2) + nb2.add(act_tab, text="Действия с ПДн") + act_btn_frame = ttk.Frame(act_tab) + act_btn_frame.pack(fill=tk.X) + ttk.Button(act_btn_frame, text="+ Добавить", command=self._add_pd_action_item).pack(side=tk.LEFT, padx=2) + ttk.Button(act_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.pd_actions_listbox)).pack(side=tk.LEFT, padx=2) + ttk.Button(act_btn_frame, text="Выбрать из списка", command=self._select_from_actions_list).pack(side=tk.LEFT, padx=2) + self.pd_actions_listbox = tk.Listbox(act_tab, height=6) + self.pd_actions_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # СЗИ + def_tab = ttk.Frame(nb2) + nb2.add(def_tab, text="Средства защиты") + def_btn_frame = ttk.Frame(def_tab) + def_btn_frame.pack(fill=tk.X) + ttk.Button(def_btn_frame, text="+ Добавить", command=self._add_defense_item).pack(side=tk.LEFT, padx=2) + ttk.Button(def_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.defense_tools_listbox)).pack(side=tk.LEFT, padx=2) + ttk.Button(def_btn_frame, text="Выбрать из списка", command=self._select_from_defense_list).pack(side=tk.LEFT, padx=2) + self.defense_tools_listbox = tk.Listbox(def_tab, height=6) + self.defense_tools_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Пользователи + user_tab = ttk.Frame(nb2) + nb2.add(user_tab, text="Пользователи ИС") + self.users_listbox = tk.Listbox(user_tab, height=6) + self.users_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + btnf = ttk.Frame(user_tab) + btnf.pack(pady=3) + ttk.Button(btnf, text="👥 Выбрать из списка", command=self._select_users_from_employees).pack(side=tk.LEFT, padx=2) + ttk.Button(btnf, text="+ Добавить", command=self._add_user_to_is).pack(side=tk.LEFT, padx=2) + ttk.Button(btnf, text="- Удалить", command=self._del_user_from_is).pack(side=tk.LEFT, padx=2) + + self._set_is_edit_state(False) + + # ---------- ВКЛАДКА "КОМИССИЯ" ---------- + def _build_commission_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Комиссия") + + canvas = tk.Canvas(tab, highlightthickness=0) + scrollbar = ttk.Scrollbar(tab, orient=tk.VERTICAL, command=canvas.yview) + inner = ttk.Frame(canvas) + inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=inner, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + frame = ttk.Frame(inner, padding=10) + frame.pack(fill=tk.X) + + ttk.Label(frame, text="Состав комиссии", font=('Arial', 11, 'bold')).pack( + anchor=tk.W, pady=(0, 10)) + + # Заголовки + header = ttk.Frame(frame) + header.pack(fill=tk.X) + for w, col in [(20, "Роль"), (30, "Должность"), (25, "ФИО")]: + ttk.Label(header, text=col, font=('', 9, 'bold'), + width=w, anchor=tk.W).pack(side=tk.LEFT, padx=2) + + self._comm_members_frame = ttk.Frame(frame) + self._comm_members_frame.pack(fill=tk.X) + + ttk.Button(frame, text="+ Добавить члена комиссии", + command=self._add_commission_row_ui).pack(pady=10) + + # ---------- ВКЛАДКА "ПОЛЬЗОВАТЕЛИ" ---------- + def _build_users_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Пользователи") + + canvas = tk.Canvas(tab, highlightthickness=0) + scrollbar = ttk.Scrollbar(tab, orient=tk.VERTICAL, command=canvas.yview) + scrollable = ttk.Frame(canvas) + scrollable.bind("", + lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=scrollable, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + frame = ttk.Frame(scrollable, padding=10) + frame.pack(fill=tk.BOTH, expand=True) + + ttk.Label(frame, text="Сотрудники, имеющие доступ к ПДн", + font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=(0, 10)) + + # Заголовки + header = ttk.Frame(frame) + header.pack(fill=tk.X) + for w, col in [(25, "Должность"), (30, "ФИО")]: + ttk.Label(header, text=col, font=('', 9, 'bold'), + width=w, anchor=tk.W).pack(side=tk.LEFT, padx=2) + + self._employees_frame = ttk.Frame(frame) + self._employees_frame.pack(fill=tk.X, pady=5) + + btn_frame = ttk.Frame(frame) + btn_frame.pack(fill=tk.X, pady=5) + ttk.Button(btn_frame, text="+ Добавить сотрудника", + command=self._add_employee_row_ui).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="- Удалить выбранного", + command=self._delete_employee_selected).pack(side=tk.LEFT, padx=2) + + # Счётчик + self._emp_count_label = ttk.Label(frame, text="Добавлено: 0") + self._emp_count_label.pack(anchor=tk.W, pady=5) + + self._employee_entries = [] + # Загрузка существующих + for emp in self.model.employees_access: + self._add_employee_row( + emp.get('position', ''), + emp.get('fio', ''), + + ) + self._update_emp_count() + + def _add_employee_row(self, position="", fio=""): + """Добавляет одну строку сотрудника.""" + row = ttk.Frame(self._employees_frame) + row.pack(fill=tk.X, pady=2) + + pos_entry = ttk.Entry(row, width=25) + pos_entry.insert(0, position) + pos_entry.pack(side=tk.LEFT, padx=2) + + fio_entry = ttk.Entry(row, width=30) + fio_entry.insert(0, fio) + fio_entry.pack(side=tk.LEFT, padx=2) + + + del_btn = ttk.Button(row, text="✖", width=3, + command=lambda: self._delete_employee_row(row)) + del_btn.pack(side=tk.LEFT, padx=5) + + self._employee_entries.append((row, pos_entry, fio_entry)) + self._update_emp_count() + + def _add_employee_row_ui(self): + self._add_employee_row() + + def _delete_employee_row(self, row_frame): + self._employee_entries = [e for e in self._employee_entries if e[0] != row_frame] + row_frame.destroy() + self._update_emp_count() + + def _delete_employee_selected(self): + # Удаляем последнюю строку, если есть + if self._employee_entries: + row_frame = self._employee_entries[-1][0] + self._delete_employee_row(row_frame) + + def _update_emp_count(self): + count = len(self._employee_entries) + if hasattr(self, '_emp_count_label'): + self._emp_count_label.config(text=f"Добавлено: {count}") + + def _collect_employees(self): + """Собирает данные сотрудников из GUI в модель.""" + self.model.employees_access.clear() + for row, pos_e, fio_e in self._employee_entries: + emp = { + 'position': pos_e.get().strip(), + 'fio': fio_e.get().strip(), + } + if emp['position'] or emp['fio']: + self.model.employees_access.append(emp) + + # ---------- ВКЛАДКА "БУМАЖНЫЕ ДОКУМЕНТЫ" ---------- + def _build_paper_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Бумажные документы") + + canvas = tk.Canvas(tab, highlightthickness=0) + scrollbar = ttk.Scrollbar(tab, orient=tk.VERTICAL, command=canvas.yview) + scrollable = ttk.Frame(canvas) + scrollable.bind("", + lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=scrollable, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + frame = ttk.Frame(scrollable, padding=10) + frame.pack(fill=tk.BOTH, expand=True) + + ttk.Label(frame, text="Бумажные носители ПДн (документы)", + font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=(0, 10)) + + # Заголовки + header = ttk.Frame(frame) + header.pack(fill=tk.X) + for w, col in [(45, "Название документа"), (45, "Место хранения")]: + ttk.Label(header, text=col, font=('', 9, 'bold'), + width=w, anchor=tk.W).pack(side=tk.LEFT, padx=2) + + self._paper_frame = ttk.Frame(frame) + self._paper_frame.pack(fill=tk.X, pady=5) + + btn_frame = ttk.Frame(frame) + btn_frame.pack(fill=tk.X, pady=5) + ttk.Button(btn_frame, text="+ Добавить документ", + command=self._add_paper_row_ui).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="- Удалить выбранный", + command=self._delete_paper_selected).pack(side=tk.LEFT, padx=2) + + self._paper_count_label = ttk.Label(frame, text="Добавлено: 0") + self._paper_count_label.pack(anchor=tk.W, pady=5) + + self._paper_entries = [] + # Загрузка существующих + for p in self.model.paper_documents_list: + self._add_paper_row(p.name, p.storage) + self._update_paper_count() + + def _add_paper_row(self, name="", storage=""): + """Добавляет одну строку документа.""" + row = ttk.Frame(self._paper_frame) + row.pack(fill=tk.X, pady=2) + + name_entry = ttk.Entry(row, width=45) + name_entry.insert(0, name) + name_entry.pack(side=tk.LEFT, padx=2) + + storage_entry = ttk.Entry(row, width=45) + storage_entry.insert(0, storage) + storage_entry.pack(side=tk.LEFT, padx=2) + + del_btn = ttk.Button(row, text="✖", width=3, + command=lambda: self._delete_paper_row(row)) + del_btn.pack(side=tk.LEFT, padx=5) + + self._paper_entries.append((row, name_entry, storage_entry)) + self._update_paper_count() + + def _add_paper_row_ui(self): + self._add_paper_row() + + def _delete_paper_row(self, row_frame): + self._paper_entries = [e for e in self._paper_entries if e[0] != row_frame] + row_frame.destroy() + self._update_paper_count() + + def _delete_paper_selected(self): + if self._paper_entries: + row_frame = self._paper_entries[-1][0] + self._delete_paper_row(row_frame) + + def _update_paper_count(self): + count = len(self._paper_entries) + if hasattr(self, '_paper_count_label'): + self._paper_count_label.config(text=f"Добавлено: {count}") + + def _collect_paper_documents(self): + """Собирает данные бумажных документов из GUI в модель.""" + self.model.paper_documents_list.clear() + for row, name_e, storage_e in self._paper_entries: + name = name_e.get().strip() + storage = storage_e.get().strip() + if name or storage: + self.model.paper_documents_list.append( + PaperDocument(name=name, storage=storage) + ) + + # ---------- ВКЛАДКА "ГЕНЕРАЦИЯ" ---------- + def _build_generation_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Генерация") + + frame = ttk.Frame(tab, padding=10) + frame.pack(fill=tk.BOTH, expand=True) + + ttk.Label(frame, text="Параметры генерации", font=('Arial', 11, 'bold')).pack( + anchor=tk.W, pady=(0, 10)) + + ttk.Label(frame, text="Папка шаблонов:").pack(anchor=tk.W) + tf = ttk.Frame(frame) + tf.pack(fill=tk.X, pady=(0, 5)) + ttk.Entry(tf, textvariable=self.templates_var).pack(side=tk.LEFT, fill=tk.X, expand=True) + ttk.Button(tf, text="Обзор", command=self.select_templates).pack(side=tk.RIGHT, padx=5) + + ttk.Label(frame, text="Папка результатов:").pack(anchor=tk.W) + sf = ttk.Frame(frame) + sf.pack(fill=tk.X, pady=(0, 5)) + ttk.Entry(sf, textvariable=self.save_var).pack(side=tk.LEFT, fill=tk.X, expand=True) + ttk.Button(sf, text="Обзор", command=self._select_save_dir).pack(side=tk.RIGHT, padx=5) + + ttk.Label(frame, text="Шаблоны для генерации:", font=('Arial', 10, 'bold')).pack( + anchor=tk.W, pady=(10, 0)) + + btn_frame = ttk.Frame(frame) + btn_frame.pack(fill=tk.X, pady=(0, 5)) + ttk.Button(btn_frame, text="Выделить всё", + command=lambda: self._select_templates_all(True)).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(btn_frame, text="Снять всё", + command=lambda: self._select_templates_all(False)).pack(side=tk.LEFT) + + self.template_listbox = tk.Listbox(frame, height=8, selectmode=tk.EXTENDED) + self.template_listbox.pack(fill=tk.BOTH, expand=True, pady=5) + self.template_listbox.bind('<>', self._on_template_select) + + ttk.Button(frame, text="⚡ Сгенерировать выбранные документы", + command=self.generate_all).pack(fill=tk.X, pady=10) + + # ---------- ВКЛАДКА "ЛОГ" ---------- + def _build_log_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Лог") + + top = ttk.Frame(tab) + top.pack(fill=tk.X, padx=5, pady=3) + ttk.Button(top, text="💾 Экспорт логов…", command=self._export_logs).pack(side=tk.LEFT) + ttk.Label(top, text="Журнал пишется в dokogen.log рядом с программой", + foreground='gray').pack(side=tk.LEFT, padx=8) + + self.log_text = scrolledtext.ScrolledText(tab, height=10) + self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + self.log_text.insert(tk.END, "Лог готов\n") + self.log_text.configure(state='disabled') + + # ==================== МЕТОДЫ РАБОТЫ С ДАННЫМИ ==================== + def _init_company(self): + """Инициализация модели.""" + self.model = Company() + # 3 члена комиссии по умолчанию + for _ in range(3): + self.model.commission.append(CommissionMember()) + + def _collect_company(self): + """Собирает данные из GUI в модель.""" + c = self.model + c.full_name = self.company_name_entry.get().strip() + c.short_name = self.short_name_entry.get().strip() + for key, entry in self._company_entries.items(): + setattr(c, key, entry.get().strip()) + for key, entry in self._official_entries.items(): + setattr(c, key, entry.get().strip()) + c.contract_number = self.doc_number_var.get() + c.contract_date = self.doc_date_var.get() + + def _collect_is(self): + """Собирает данные ИС из GUI в модель.""" + if self._current_is_idx is not None and self._current_is_idx < len(self.model.information_systems): + isys = self.model.information_systems[self._current_is_idx] + isys.name = self.is_name_entry.get().strip() + isys.description = self.is_description_entry.get().strip() + if hasattr(self, 'is_structure_entry'): + isys.structure = self.is_structure_entry.get().strip() + if hasattr(self, 'is_software_entry'): + isys.software = self.is_software_entry.get().strip() + isys.is_local_network = self.is_lan_var.get() + isys.is_internet = self.is_internet_var.get() + isys.threat_type = self.is_threat_var.get() + isys.defence_level = self.is_defence_var.get() + cats = [c for c, v in self.category_vars if v.get()] + isys.personal_data_category = cats + if hasattr(self, 'is_pd_count_entry'): + isys.pd_count = self.is_pd_count_entry.get().strip() or 'менее 100 000' + # Списки + isys.personal_data_list = list(self.pd_listbox.get(0, tk.END)) + isys.pd_subjects_list = list(self.pd_subjects_listbox.get(0, tk.END)) + isys.pd_actions_list = list(self.pd_actions_listbox.get(0, tk.END)) + isys.defense_tools_list = list(self.defense_tools_listbox.get(0, tk.END)) + isys.users_list = list(self.users_listbox.get(0, tk.END)) + + def _collect_commission(self): + """Собирает комиссию из GUI.""" + self.model.commission.clear() + for row_frame, role_var, pos_entry, fio_entry in self.commission_entries: + self.model.commission.append( + CommissionMember( + role=role_var.get(), + position=pos_entry.get().strip(), + fio=fio_entry.get().strip(), + ) + ) + + def _company_to_gui(self): + """Заполняет GUI из модели.""" + c = self.model + self.company_name_entry.delete(0, tk.END) + self.company_name_entry.insert(0, c.full_name or "") + self.short_name_entry.delete(0, tk.END) + self.short_name_entry.insert(0, c.short_name or "") + + for key, entry in self._company_entries.items(): + entry.delete(0, tk.END) + entry.insert(0, getattr(c, key, "") or "") + + for key, entry in self._official_entries.items(): + entry.delete(0, tk.END) + entry.insert(0, getattr(c, key, "") or "") + + self.doc_number_var.set(c.contract_number or "") + self.doc_date_var.set(c.contract_date or "") + + # Склонения + idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6} + for code, entry in self.decl_entries.items(): + entry.delete(0, tk.END) + entry.insert(0, getattr(c, f"company_name_{idx_map[code]}", "") or "") + for code, entry in self.short_decl_entries.items(): + entry.delete(0, tk.END) + entry.insert(0, getattr(c, f"short_name_{idx_map[code]}", "") or "") + + # Комиссия + for frame in list(self._comm_members_frame.winfo_children()): + frame.destroy() + self.commission_entries.clear() + for member in c.commission: + self._add_commission_row(member.role, member.position, member.fio) + if not self.commission_entries: + for _ in range(3): + self._add_commission_row() + + # ИС + self._refresh_is_listbox() + if self.model.information_systems: + self.is_listbox.selection_set(0) + self._on_is_select() + + # Пользователи + if hasattr(self, '_employee_entries'): + for row_frame, _, _ in self._employee_entries: + row_frame.destroy() + self._employee_entries.clear() + for emp in c.employees_access: + self._add_employee_row( + emp.get('position', ''), + emp.get('fio', ''), + ) + self._update_emp_count() + + # Бумажные документы + if hasattr(self, '_paper_entries'): + for row_frame, _, _ in self._paper_entries: + row_frame.destroy() + self._paper_entries.clear() + for p in c.paper_documents_list: + self._add_paper_row(p.name, p.storage) + self._update_paper_count() + + # Шаблоны + self._on_templates_changed() + + # Автоопределение города из адреса после загрузки + self._on_address_changed() + + # ==================== ГОРОД ИЗ АДРЕСА ==================== + def _extract_city_from_address(self, address: str) -> str: + """Извлекает название города из адреса (простая эвристика).""" + if not address: + return '' + # Пробуем найти "г. Город" или "Г Город" (без точки, заглавная) + import re + m = re.search(r'\b[гГ]\.?\s*([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address) + if m: + return m.group(1).strip() + # Пробуем найти "город Город" + m = re.search(r'\bгород\s+([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address, re.IGNORECASE) + if m: + return m.group(1).strip() + # Пробуем первую часть до запятой, если там известный город + parts = [p.strip() for p in address.split(',')] + if parts: + first = parts[0] + # Если первая часть — короткая и не похожа на улицу/дом + if len(first) <= 25 and first and not first.startswith(('ул', 'пр', 'пер', 'шоссе')): + # Проверяем, что это похоже на город + city_keywords = ['ск', 'цк', 'бург', 'град', 'полис', 'поль', 'стан', 'горск', 'чинск', 'янск', 'овск'] + if any(first.lower().endswith(kw) for kw in city_keywords): + return first + return '' + + def _on_address_changed(self, event=None): + """Обработчик изменения адреса — автозаполняет город.""" + addr_entry = self._company_entries.get('address') + city_entry = self._company_entries.get('city_name') + if not addr_entry or not city_entry: + return + address = addr_entry.get().strip() + if not address: + return + current_city = city_entry.get().strip() + if current_city: + return # Не перезаписываем, если уже заполнен + city = self._extract_city_from_address(address) + if city: + city_entry.delete(0, tk.END) + city_entry.insert(0, city) + self.log(f"🏙 Город определён: {city}") + + # ==================== АВТОРАСЧЁТ УРОВНЯ ЗАЩИЩЁННОСТИ ==================== + def _auto_calc_defence_level(self): + """Автоматический расчёт УЗ по ПП №1119: категории ПДн, тип субъектов, + кол-во записей, тип угроз.""" + if self._current_is_idx is None: + return + + categories = [cat for cat, var in self.category_vars if var.get()] + pd_count = self.is_pd_count_var.get() if hasattr(self, 'is_pd_count_var') else 'менее 100 000' + threat_type = self.is_threat_var.get() if hasattr(self, 'is_threat_var') else '3' + is_large = 'более' in pd_count.lower() + # Автоопределение типа субъектов: если в Субъектах ПДн выбраны только "Сотрудники" → работники, иначе → иные + subj_items = self.pd_subjects_listbox.get(0, tk.END) if hasattr(self, 'pd_subjects_listbox') else [] + has_only_employees = len(subj_items) > 0 and all('сотрудник' in s.lower() for s in subj_items) + is_employees = has_only_employees or len(subj_items) == 0 + + # Таблица ПП №1119: (категория, тип_субъектов, объём, тип_угроз) → УЗ + def lookup(cat_group, employees, large, threat): + # Общедоступные → всегда 4 + if cat_group == 'public': + return 4 + # Специальные/биометрические + if cat_group == 'special_bio': + tbl = { + ('emp', False, '1'): 2, ('emp', True, '1'): 1, + ('other', False, '1'): 1, ('other', True, '1'): 1, + ('emp', False, '2'): 2, ('emp', True, '2'): 1, + ('other', False, '2'): 2, ('other', True, '2'): 1, + ('emp', False, '3'): 3, ('emp', True, '3'): 2, + ('other', False, '3'): 3, ('other', True, '3'): 2, + } + else: # иные + tbl = { + ('emp', False, '1'): 3, ('emp', True, '1'): 2, + ('other', False, '1'): 2, ('other', True, '1'): 1, + ('emp', False, '2'): 4, ('emp', True, '2'): 3, + ('other', False, '2'): 3, ('other', True, '2'): 2, + ('emp', False, '3'): 4, ('emp', True, '3'): 4, + ('other', False, '3'): 4, ('other', True, '3'): 3, + } + key = ('emp' if employees else 'other', large, threat) + return tbl.get(key, 4) + + # Определяем группу категорий + if 'общедоступные' in categories and not any(c in categories for c in ['специальные', 'биометрические', 'иные']): + cat_group = 'public' + elif any(c in categories for c in ['специальные', 'биометрические']): + cat_group = 'special_bio' + else: + cat_group = 'other' + + level = lookup(cat_group, is_employees, is_large, threat_type) + self.is_defence_var.set(str(level)) + + def _on_categories_or_count_changed(self): + """Обработчик изменения категорий ПДн или количества записей.""" + if self._current_is_idx is not None: + self._auto_calc_defence_level() + + # ==================== КОМИССИЯ ==================== + def _add_commission_row(self, role="Член комиссии", position="", fio=""): + row = ttk.Frame(self._comm_members_frame) + row.pack(fill=tk.X, pady=2) + + role_var = tk.StringVar(value=role) + role_cb = ttk.Combobox(row, textvariable=role_var, state='readonly', width=22, + values=["Председатель комиссии", "Секретарь комиссии", + "Член комиссии"]) + role_cb.pack(side=tk.LEFT, padx=2) + + pos_entry = ttk.Entry(row, width=30) + pos_entry.insert(0, position) + pos_entry.pack(side=tk.LEFT, padx=2) + + fio_entry = ttk.Entry(row, width=25) + fio_entry.insert(0, fio) + fio_entry.pack(side=tk.LEFT, padx=2) + + ttk.Button(row, text="✖", width=3, + command=lambda: self._delete_commission_row(row)).pack(side=tk.LEFT, padx=5) + + self.commission_entries.append((row, role_var, pos_entry, fio_entry)) + + def _add_commission_row_ui(self): + self._add_commission_row() + + def _delete_commission_row(self, row_frame): + self.commission_entries = [e for e in self.commission_entries if e[0] != row_frame] + row_frame.destroy() + + # ==================== ИНФОРМАЦИОННЫЕ СИСТЕМЫ ==================== + def _refresh_is_listbox(self): + self.is_listbox.delete(0, tk.END) + for isys in self.model.information_systems: + self.is_listbox.insert(tk.END, isys.name or "(без названия)") + if not self.model.information_systems: + self._is_edit_panel.pack_forget() + self._is_edit_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) + self._set_is_edit_state(False) + else: + self._is_edit_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) + + def _set_is_edit_state(self, enabled): + state = 'normal' if enabled else 'disabled' + self.is_lan_var.set(False) + self.is_internet_var.set(False) + + def _on_is_select(self, event=None): + sel = self.is_listbox.curselection() + if not sel: + self._current_is_idx = None + return + + # Сохраняем текущую ИС + self._collect_is() + + idx = sel[0] + if idx >= len(self.model.information_systems): + return + self._current_is_idx = idx + isys = self.model.information_systems[idx] + + self.is_name_entry.delete(0, tk.END) + self.is_name_entry.insert(0, isys.name or "") + self.is_description_entry.delete(0, tk.END) + self.is_description_entry.insert(0, isys.description or "") + if hasattr(self, 'is_structure_entry'): + self.is_structure_entry.delete(0, tk.END) + self.is_structure_entry.insert(0, isys.structure or "") + if hasattr(self, 'is_software_entry'): + self.is_software_entry.delete(0, tk.END) + self.is_software_entry.insert(0, isys.software or "") + self.is_lan_var.set(isys.is_local_network) + self.is_internet_var.set(isys.is_internet) + self.is_threat_var.set(isys.threat_type or "3") + self.is_defence_var.set(isys.defence_level or "4") + for cat, var in self.category_vars: + var.set(cat in isys.personal_data_category) + # Заполняем кол-во записей ПДн + if hasattr(self, 'is_pd_count_entry'): + self.is_pd_count_entry.delete(0, tk.END) + self.is_pd_count_entry.insert(0, isys.pd_count or 'менее 100 000') + + # Списки + for lb, items in [ + (self.pd_listbox, isys.personal_data_list or []), + (self.pd_subjects_listbox, isys.pd_subjects_list or []), + (self.pd_actions_listbox, isys.pd_actions_list or []), + (self.defense_tools_listbox, isys.defense_tools_list or []), + (self.users_listbox, isys.users_list or []), + ]: + lb.delete(0, tk.END) + for item in items: + lb.insert(tk.END, item) + + def _add_is(self): + # Сохраняем текущую ИС перед добавлением новой + self._collect_is() + name = simpledialog.askstring("Добавить ИС", "Введите наименование ИС:") + if name: + isys = InformationSystem(name=name) + self.model.information_systems.append(isys) + self._refresh_is_listbox() + self.is_listbox.selection_set(tk.END) + self._on_is_select() + + def _copy_is(self): + if self._current_is_idx is None: + return + self._collect_is() + src = self.model.information_systems[self._current_is_idx] + copy = InformationSystem( + name=src.name + " (копия)", + description=src.description, + is_local_network=src.is_local_network, + is_internet=src.is_internet, + defence_level=src.defence_level, + subject_type=src.subject_type, + threat_type=src.threat_type, + personal_data_category=list(src.personal_data_category), + personal_data_list=list(src.personal_data_list), + pd_actions_list=list(src.pd_actions_list), + pd_subjects_list=list(src.pd_subjects_list), + defense_tools_list=list(src.defense_tools_list), + users_list=list(src.users_list), + ) + self.model.information_systems.append(copy) + self._refresh_is_listbox() + self.is_listbox.selection_set(tk.END) + self._on_is_select() + + def _delete_is(self): + if self._current_is_idx is None: + return + self._collect_is() + del self.model.information_systems[self._current_is_idx] + self._current_is_idx = None + self._refresh_is_listbox() + if self.model.information_systems: + self.is_listbox.selection_set(0) + self._on_is_select() + + def _add_pd_item(self): + item = simpledialog.askstring("Добавить ПДн", "Введите персональные данные:") + if item: + self.pd_listbox.insert(tk.END, item) + + def _add_pd_subjects_item(self): + item = simpledialog.askstring("Добавить категорию", "Введите категорию субъектов:") + if item: + self.pd_subjects_listbox.insert(tk.END, item) + + def _add_defense_item(self): + item = simpledialog.askstring("Добавить СЗИ", "Введите средство защиты:") + if item: + self.defense_tools_listbox.insert(tk.END, item) + + def _add_user_to_is(self): + user = simpledialog.askstring("Добавить пользователя", "Введите ФИО или должность:") + if user: + self.users_listbox.insert(tk.END, user) + + def _select_users_from_employees(self): + """Выбор пользователей ИС из списка сотрудников организации (вкладка «Пользователи»).""" + names = [] + for emp in self.model.employees_access: + fio = (emp.get('fio') or '').strip() + if fio and fio not in names: + names.append(fio) + # Дополнительно: пользователи из других ИС + for isys in self.model.information_systems: + for u in (isys.users_list or []): + u = u.strip() + if u and u not in names: + names.append(u) + if not names: + messagebox.showinfo( + "Выбор пользователей", + "Список пуст.\nЗаполните вкладку «Пользователи» (сотрудники с доступом к ПДн) " + "или добавьте пользователей в другой ИС." + ) + return + self._select_from_dialog("Выбор пользователей ИС", names, self.users_listbox) + + def _del_user_from_is(self): + sel = self.users_listbox.curselection() + if sel: + self.users_listbox.delete(sel[0]) + + def _del_from_listbox(self, listbox): + """Удаление выбранного элемента из любого listbox.""" + sel = listbox.curselection() + if sel: + listbox.delete(sel[0]) + + # ==================== ВЫБОР ИЗ СПИСКА (словари) ==================== + def _select_from_dialog(self, title: str, items: list, listbox): + """Открывает окно с чекбоксами для выбора элементов из списка.""" + dialog = tk.Toplevel(self.root) + dialog.title(title) + dialog.geometry("400x500") + dialog.transient(self.root) + dialog.grab_set() + + frame = ttk.Frame(dialog, padding=10) + frame.pack(fill=tk.BOTH, expand=True) + + ttk.Label(frame, text=title, font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=(0, 5)) + + # Уже выбранные элементы + existing = set(listbox.get(0, tk.END)) + + canvas = tk.Canvas(frame, highlightthickness=0) + scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas.yview) + check_frame = ttk.Frame(canvas) + check_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=check_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + vars = {} + for item in items: + var = tk.BooleanVar(value=item in existing) + cb = ttk.Checkbutton(check_frame, text=item, variable=var) + cb.pack(anchor=tk.W, pady=1) + vars[item] = var + + btn_frame = ttk.Frame(dialog, padding=10) + btn_frame.pack(fill=tk.X) + + def _apply(): + selected = [item for item, var in vars.items() if var.get()] + listbox.delete(0, tk.END) + for item in selected: + listbox.insert(tk.END, item) + dialog.destroy() + + def _select_all(): + for var in vars.values(): + var.set(True) + + def _clear_all(): + for var in vars.values(): + var.set(False) + + ttk.Button(btn_frame, text="Выбрать всё", command=_select_all).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="Снять всё", command=_clear_all).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="✅ Применить", command=_apply).pack(side=tk.RIGHT, padx=2) + + dialog.wait_window() + + def _add_pd_action_item(self): + item = simpledialog.askstring("Добавить действие", "Введите действие с ПДн:") + if item: + self.pd_actions_listbox.insert(tk.END, item) + + def _select_from_actions_list(self): + """Выбор действий с ПДн из JSON словаря.""" + import json + dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'pd_actions.json') + try: + with open(dict_path, 'r', encoding='utf-8') as f: + items = json.load(f) + self._select_from_dialog("Выбор действий с персональными данными", items, self.pd_actions_listbox) + except Exception as e: + messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}") + + def _select_from_pd_list(self): + """Выбор ПДн из JSON словаря.""" + import json + dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'pd_items.json') + try: + with open(dict_path, 'r', encoding='utf-8') as f: + items = json.load(f) + self._select_from_dialog("Выбор персональных данных", items, self.pd_listbox) + except Exception as e: + messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}") + + def _select_from_subjects_list(self): + """Выбор категорий субъектов из JSON словаря.""" + import json + dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'pd_categories.json') + try: + with open(dict_path, 'r', encoding='utf-8') as f: + items = json.load(f) + self._select_from_dialog("Выбор категорий субъектов ПДн", items, self.pd_subjects_listbox) + except Exception as e: + messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}") + + def _select_from_defense_list(self): + """Выбор СЗИ из JSON словаря.""" + import json + dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'defense_tools.json') + try: + with open(dict_path, 'r', encoding='utf-8') as f: + items = json.load(f) + self._select_from_dialog("Выбор средств защиты информации", items, self.defense_tools_listbox) + except Exception as e: + messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}") + + # ==================== ШАБЛОНЫ ==================== + def select_templates(self): + folder = filedialog.askdirectory(title="Папка шаблонов") + if folder: + self.templates_var.set(folder) + self._on_templates_changed() + + def _select_save_dir(self): + folder = filedialog.askdirectory(title="Папка для результатов") + if folder: + self.save_var.set(folder) + + def _on_templates_changed(self, *_): + """Обновить список шаблонов при смене папки.""" + folder = self.templates_var.get() + self.template_listbox.delete(0, tk.END) + self._template_files = [] + if folder and os.path.isdir(folder): + for f in sorted(os.listdir(folder)): + if f.endswith('.docx') and not f.endswith('_result.docx'): + self.template_listbox.insert(tk.END, f) + self._template_files.append(f) + # Выделяем все по умолчанию + self.template_listbox.selection_set(0, tk.END) + self._selected_templates = set(self._template_files) + self.log(f"Найдено шаблонов: {len(self._template_files)}") + + def _on_template_select(self, event=None): + sel = self.template_listbox.curselection() + self._selected_templates = {self._template_files[i] for i in sel} + + def _select_templates_all(self, select: bool): + if select: + self.template_listbox.selection_set(0, tk.END) + else: + self.template_listbox.selection_clear(0, tk.END) + self._on_template_select() + + # ==================== ГЕНЕРАЦИЯ ==================== + def generate_all(self): + """Генерация всех выбранных документов.""" + templates_dir = self.templates_var.get() + output_dir = self.save_var.get() + + if not templates_dir or not os.path.isdir(templates_dir): + messagebox.showerror("Ошибка", "Выберите папку с шаблонами") + return + if not output_dir: + output_dir = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "Сгенерированные_документы" + ) + self.save_var.set(output_dir) + + selected = self._selected_templates + if not selected: + messagebox.showwarning("Предупреждение", "Не выбран ни один шаблон") + return + + # Собираем данные + self._collect_company() + self._collect_commission() + self._collect_is() + self._collect_employees() + self._collect_paper_documents() + + # Проверка + if not self._validate_before_generate(): + return + + os.makedirs(output_dir, exist_ok=True) + + # Окно прогресса + progress_win = tk.Toplevel(self.root) + progress_win.title("Генерация документов") + progress_win.geometry("350x100") + progress_win.transient(self.root) + progress_win.grab_set() + progress_win.resizable(False, False) + ttk.Label(progress_win, text="Генерация документов...", + font=('Arial', 10)).pack(pady=(15, 5)) + pb = ttk.Progressbar(progress_win, mode='indeterminate', length=300) + pb.pack(pady=5) + pb.start() + + def _close_progress(): + try: + pb.stop() + progress_win.grab_release() + progress_win.destroy() + except Exception: + pass + + # Запускаем в отдельном потоке + def _run(): + try: + replacements = build_replacements( + company=self.model, + is_list=self.model.information_systems, + doc_number=self.doc_number_var.get(), + doc_date=self.doc_date_var.get(), + direction='152fz', + ) + + results = [] + for fname in selected: + template_path = os.path.join(templates_dir, fname) + if not os.path.isfile(template_path): + self.log(f"❌ Файл не найден: {fname}") + continue + + base = os.path.splitext(fname)[0] + output_path = os.path.join(output_dir, f"{base}.docx") + + try: + process_template(template_path, replacements, output_path, self.log) + results.append(output_path) + except Exception as e: + self.log(f"❌ {fname}: {e}") + + self.log(f"✅ Готово: {len(results)} из {len(selected)}") + self.root.after(0, _close_progress) + self.root.after(0, lambda: messagebox.showinfo( + "Готово", f"Сгенерировано {len(results)} документов\n" + f"Папка: {output_dir}" + )) + except Exception as e: + self.log(f"❌ Ошибка: {e}") + self.root.after(0, _close_progress) + self.root.after(0, lambda: messagebox.showerror("Ошибка", str(e))) + + threading.Thread(target=_run, daemon=True).start() + + def _validate_before_generate(self): + warnings = [] + if not self.model.full_name: + warnings.append("Полное наименование организации") + if not self.model.inn: + warnings.append("ИНН") + if not self.model.chief_fio: + warnings.append("ФИО руководителя") + if not self.model.information_systems: + warnings.append("Нет информационных систем") + if warnings: + msg = "Не заполнены:\n" + "\n".join(warnings) + return messagebox.askyesno("Внимание", msg + "\n\nПродолжить?") + return True + + # ==================== СКЛОНЕНИЕ ==================== + def _update_company_declensions(self): + name = self.company_name_entry.get().strip() + if not name: + return + self.model.full_name = name + self.model.update_declensions(inflect_name) + idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6} + for code, entry in self.decl_entries.items(): + entry.delete(0, tk.END) + entry.insert(0, getattr(self.model, f"company_name_{idx_map[code]}", "")) + self.log("✅ Склонения полного названия обновлены") + + def _update_short_declensions(self): + name = self.short_name_entry.get().strip() + if not name: + return + self.model.short_name = name + self.model.update_short_declensions(inflect_name) + idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6} + for code, entry in self.short_decl_entries.items(): + entry.delete(0, tk.END) + entry.insert(0, getattr(self.model, f"short_name_{idx_map[code]}", "")) + self.log("✅ Склонения краткого названия обновлены") + + # ==================== ИМПОРТ ==================== + def select_import_settings(self): + """Выбор файла настроек импорта (JSON-словарь).""" + from .import_settings import default_import_settings_path, ensure_default_file + path = filedialog.askopenfilename( + title="Файл настроек импорта (что и откуда брать)", + filetypes=[("Настройки импорта", "*.json")] + ) + if not path: + self.log(f"Настройки импорта: по умолчанию ({default_import_settings_path()})") + self.import_settings_path = None + return + self.import_settings_path = path + self.log(f"Настройки импорта: {path}") + + def import_from_survey(self): + path = filedialog.askopenfilename( + title="Выберите опросный лист (152-ФЗ)", + filetypes=[("Excel", "*.xlsx *.xls")] + ) + if not path: + return + self.log(f"Импорт: {path}") + + # Импорт в отдельном потоке + def _run(): + try: + from .importer import import_152fz + data = import_152fz(path, self.log, settings_path=self.import_settings_path) + if data: + self._apply_imported_data(data) + except ImportError: + self.log("❌ Модуль импорта не найден") + except Exception as e: + self.log(f"❌ Ошибка импорта: {e}") + + threading.Thread(target=_run, daemon=True).start() + + def _apply_imported_data(self, data: dict): + def _apply(): + # Название организации + if data.get('fullName'): + self.company_name_entry.delete(0, tk.END) + self.company_name_entry.insert(0, data['fullName']) + self.model.full_name = data['fullName'] + if data.get('shortName'): + self.short_name_entry.delete(0, tk.END) + self.short_name_entry.insert(0, data['shortName']) + self.model.short_name = data['shortName'] + + # Адрес и реквизиты (маппинг camelCase → snake_case) + addr_map = { + 'address': 'addressLegal', + 'city_name': None, + 'inn': 'inn', + 'kpp': 'kpp', + 'ogrn': 'ogrn', + 'ogrn_date': 'ogrnDate', + } + for entry_key, data_key in addr_map.items(): + entry = self._company_entries.get(entry_key) + if not entry: + continue + val = '' + if data_key and data.get(data_key): + val = data[data_key] + elif data.get(entry_key): + val = data[entry_key] + if val: + entry.delete(0, tk.END) + entry.insert(0, val) + + # Автоопределение города из адреса после импорта + self._on_address_changed() + + # Должностные лица (маппинг) + officials_map = { + 'chief_position': 'chiefPosition', + 'chief_fio': 'chiefFio', + 'responsible_position': 'responsiblePosition', + 'responsible_fio': 'responsibleFio', + 'admin_position': ('administratorPosition', 'ispdnPosition'), + 'admin_fio': ('administratorFio', 'ispdnFio'), + } + for entry_key, data_keys in officials_map.items(): + entry = self._official_entries.get(entry_key) + if not entry: + continue + val = '' + if isinstance(data_keys, tuple): + for dk in data_keys: + if data.get(dk): + val = data[dk] + break + elif data.get(data_keys): + val = data[data_keys] + if val: + entry.delete(0, tk.END) + entry.insert(0, val) + + # Номер и дата документа + if data.get('contractNumber'): + self.doc_number_var.set(data['contractNumber']) + if data.get('contractDate'): + self.doc_date_var.set(data['contractDate']) + + # Наименование структурного подразделения по безопасности + if data.get('ispdnDepartment'): + self.model.security_department = data['ispdnDepartment'] + + # Информационные системы + if data.get('informationSystems'): + self.model.information_systems.clear() + for is_data in data['informationSystems']: + isys = InformationSystem( + name=is_data.get('name', ''), + description=is_data.get('description', ''), + is_local_network=is_data.get('is_has_lan', is_data.get('is_local_network', False)), + is_internet=is_data.get('isInternet', False), + pd_count=is_data.get('personalDataCount', 'менее 100 000'), + personal_data_list=is_data.get('personalDataList', []), + pd_subjects_list=is_data.get('pd_subjects_list', []), + defense_tools_list=is_data.get('defense_tools', []), + users_list=is_data.get('usersList', []), + personal_data_category=is_data.get('personalDataCategory', []), + processing_modes=is_data.get('processing_mode', ''), + purpose=is_data.get('description', ''), + room=is_data.get('room', ''), + structure=is_data.get('structure', ''), + pd_actions_list=is_data.get('pd_actions', []), + ) + self.model.information_systems.append(isys) + self._refresh_is_listbox() + if self.model.information_systems: + self.is_listbox.selection_set(0) + self._on_is_select() + + # Комиссия + if data.get('commission'): + self.model.commission.clear() + # Очистим UI + for row_frame, _, _, _ in self.commission_entries: + row_frame.destroy() + self.commission_entries.clear() + for m in data['commission']: + self.model.commission.append( + CommissionMember( + role=m.get('role', 'Член комиссии'), + position=m.get('position', ''), + fio=m.get('fio', ''), + ) + ) + self._add_commission_row( + m.get('role', 'Член комиссии'), + m.get('position', ''), + m.get('fio', ''), + ) + + # Сотрудники с доступом + if data.get('employeesAccess'): + self.model.employees_access.clear() + if hasattr(self, '_employee_entries'): + for row_frame, _, _ in self._employee_entries: + row_frame.destroy() + self._employee_entries.clear() + for emp in data['employeesAccess']: + self.model.employees_access.append({ + 'position': emp.get('position', ''), + 'fio': emp.get('fio', ''), + }) + self._add_employee_row( + emp.get('position', ''), + emp.get('fio', ''), + ) + self._update_emp_count() + + # Бумажные документы (из «Названия документов» / «Место хранения») + if data.get('paperDocuments'): + self.model.paper_documents_list.clear() + names = [n.strip() for n in data['paperDocuments'].replace('\n', ';').split(';') if n.strip()] + storages = [s.strip() for s in (data.get('paperStorage') or '').replace('\n', ';').split(';') if s.strip()] + for i, name in enumerate(names): + storage = storages[i] if i < len(storages) else (storages[0] if storages else '') + self.model.paper_documents_list.append( + PaperDocument(name=name, storage=storage) + ) + # Обновляем GUI + if hasattr(self, '_paper_entries'): + for row_frame, _, _ in self._paper_entries: + row_frame.destroy() + self._paper_entries.clear() + for p in self.model.paper_documents_list: + self._add_paper_row(p.name, p.storage) + self._update_paper_count() + self.log(f" Бумажные документы: {len(self.model.paper_documents_list)} шт.") + + self.log("✅ Импорт применён") + self.root.after(0, _apply) + + # ==================== ФАЙЛОВЫЕ ОПЕРАЦИИ ==================== + def new_data(self): + if self.model.full_name: + if not messagebox.askyesno("Новое", "Текущие данные будут потеряны. Продолжить?"): + return + self._init_company() + self.current_file = None + self._company_to_gui() + self.log("Новые данные") + + def open_data(self): + filename = filedialog.askopenfilename(filetypes=[("JSON", "*.json")]) + if filename: + try: + with open(filename, 'r', encoding='utf-8') as f: + data = json.load(f) + self.model = Company.from_dict(data) + self.current_file = filename + self._company_to_gui() + self.log(f"Загружено: {filename}") + except Exception as e: + messagebox.showerror("Ошибка", str(e)) + + def save_data(self): + if self.current_file: + self._save_to(self.current_file) + else: + self.save_as_data() + + def save_as_data(self): + filename = filedialog.asksaveasfilename( + defaultextension=".json", filetypes=[("JSON", "*.json")] + ) + if filename: + self._save_to(filename) + + def _save_to(self, filename): + self._collect_company() + self._collect_commission() + self._collect_is() + self._collect_employees() + self._collect_paper_documents() + try: + with open(filename, 'w', encoding='utf-8') as f: + json.dump(self.model.to_dict(), f, ensure_ascii=False, indent=2) + self.current_file = filename + self.log(f"Сохранено: {filename}") + except Exception as e: + messagebox.showerror("Ошибка", str(e)) + + # ==================== АВТОСОХРАНЕНИЕ ==================== + def _start_autosave_timer(self): + self._autosave() + self._autosave_timer_id = self.root.after(120000, self._start_autosave_timer) + + def _stop_autosave_timer(self): + if hasattr(self, '_autosave_timer_id') and self._autosave_timer_id: + try: + self.root.after_cancel(self._autosave_timer_id) + except: + pass + + def _autosave(self): + self._collect_company() + self._collect_commission() + self._collect_is() + self._collect_employees() + self._collect_paper_documents() + try: + os.makedirs(os.path.dirname(self._autosave_path), exist_ok=True) + with open(self._autosave_path, 'w', encoding='utf-8') as f: + json.dump(self.model.to_dict(), f, ensure_ascii=False, indent=2) + except Exception: + pass + + def _load_autosave(self): + try: + if not os.path.isfile(self._autosave_path): + return + mtime = os.path.getmtime(self._autosave_path) + if datetime.now().timestamp() - mtime > 86400: + return + with open(self._autosave_path, 'r', encoding='utf-8') as f: + data = json.load(f) + self.model = Company.from_dict(data) + self._company_to_gui() + self.log("📂 Данные восстановлены из автосохранения") + except Exception: + pass + + def _on_close(self): + self._stop_autosave_timer() + self._autosave() + self.root.destroy() + + # ==================== КОНТЕКСТНОЕ МЕНЮ ==================== + def _enable_context_menu(self): + def _show_menu(event): + widget = event.widget + if not isinstance(widget, (tk.Entry, ttk.Entry, tk.Text, tk.Listbox)): + return + menu = tk.Menu(self.root, tearoff=0) + def _copy(): + try: + text = "" + if isinstance(widget, (tk.Entry, ttk.Entry)): + text = widget.selection_get() if widget.selection_present() else "" + elif isinstance(widget, tk.Text): + text = widget.selection_get() if widget.tag_ranges(tk.SEL) else "" + if text: + self.root.clipboard_clear() + self.root.clipboard_append(text) + except: + pass + def _paste(): + try: + text = self.root.clipboard_get() + if not text: return + if isinstance(widget, (tk.Entry, ttk.Entry)): + try: widget.delete(tk.SEL_FIRST, tk.SEL_LAST) + except: pass + widget.insert(tk.INSERT, text) + elif isinstance(widget, tk.Text): + try: + if widget.tag_ranges(tk.SEL): + widget.delete(tk.SEL_FIRST, tk.SEL_LAST) + except: pass + widget.insert(tk.INSERT, text) + except: + pass + menu.add_command(label="Копировать", command=_copy) + menu.add_command(label="Вставить", command=_paste) + menu.add_separator() + menu.add_command(label="Выделить всё", command=lambda: widget.select_range(0, tk.END) if hasattr(widget, 'select_range') else None) + menu.tk_popup(event.x_root, event.y_root) + self.root.bind_all('', _show_menu, add='+') + + # ==================== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ==================== + def log(self, message: str): + ts = datetime.now().strftime("%H:%M:%S") + try: + self.log_text.configure(state='normal') + self.log_text.insert(tk.END, f"[{ts}] {message}\n") + self.log_text.see(tk.END) + self.log_text.configure(state='disabled') + except Exception: + pass + # Дублируем в файловый журнал + try: + with open(self._log_path, 'a', encoding='utf-8') as f: + f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}\n") + except Exception: + pass + self.status_var.set(message) + + def _export_logs(self): + """Экспорт лога в файл.""" + filename = filedialog.asksaveasfilename( + title="Экспорт логов", + defaultextension=".log", + initialfile=f"dokogen_log_{datetime.now().strftime('%Y%m%d_%H%M')}.log", + filetypes=[("Лог-файл", "*.log"), ("Текстовый файл", "*.txt"), ("Все файлы", "*.*")] + ) + if not filename: + return + try: + content = self.log_text.get('1.0', tk.END) + with open(filename, 'w', encoding='utf-8') as f: + f.write(content) + self.log(f"💾 Лог экспортирован: {filename}") + except Exception as e: + messagebox.showerror("Ошибка", f"Не удалось экспортировать лог: {e}") + + def show_about(self): + messagebox.showinfo("О программе", + "ДоКоГеНеРаТоР — 152-ФЗ\n" + "Версия 1.0\n" + "Генератор документов из шаблонов") + + def show_variables(self): + """Показ переменных шаблонов — теперь через скачивание Word-документа.""" + self.download_variables_doc() + + # ---------- СПРАВКА: СКАЧИВАНИЕ ИНСТРУКЦИЙ / ОПРОСНИКА ---------- + def _save_resource(self, source_path, default_name, title): + """Копирует ресурс (docx/xlsx) в выбранное пользователем место.""" + if not source_path or not os.path.exists(source_path): + messagebox.showerror("Справка", f"Файл не найден:\n{source_path}") + return + filename = filedialog.asksaveasfilename( + title=title, + defaultextension=os.path.splitext(default_name)[1], + initialfile=default_name, + filetypes=[("Документ", "*.docx"), ("Все файлы", "*.*")] + ) + if not filename: + return + try: + import shutil + shutil.copy(source_path, filename) + self.log(f"💾 Сохранено: {filename}") + messagebox.showinfo("Справка", f"Файл сохранён:\n{filename}") + except Exception as e: + messagebox.showerror("Ошибка", f"Не удалось сохранить: {e}") + + def download_user_manual(self): + from .help_docs import ensure_docs, user_manual_path + ensure_docs() + self._save_resource(user_manual_path(), "Инструкция_пользователя.docx", "Сохранить инструкцию пользователя") + + def download_admin_manual(self): + from .help_docs import ensure_docs, admin_manual_path + ensure_docs() + self._save_resource(admin_manual_path(), "Инструкция_администратора.docx", "Сохранить инструкцию администратора") + + def download_variables_doc(self): + from .help_docs import ensure_docs, variables_reference_path + ensure_docs() + self._save_resource(variables_reference_path(), "Переменные_шаблонов.docx", "Сохранить справочник переменных") + + def download_survey(self): + from .help_docs import ensure_docs, survey_path + ensure_docs() + self._save_resource(survey_path(), "Опросный_лист_152_ФЗ.xlsx", "Сохранить пустой опросник") + + +def main(): + """Точка входа в приложение.""" + root = tk.Tk() + app = DokoGenApp(root) + root.mainloop() diff --git a/dokogen/variables.py b/dokogen/variables.py new file mode 100644 index 0000000..dd79232 --- /dev/null +++ b/dokogen/variables.py @@ -0,0 +1,521 @@ +# -*- coding: utf-8 -*- +"""DokoGen — Справочник переменных шаблонов и резолвер значений""" + +from datetime import datetime +from typing import Dict, Any, List, Optional +import re + +from . import declension as dc +from .models import Company, CommissionMember + + +def _norm_name(s): + """Нормализация ФИО для сравнения: регистр, пробелы, точки, тире игнорируются.""" + s = (s or '').lower().replace('ё', 'е') + return re.sub(r'[\s.,;:()«»"\'\-]+', '', s) + + +# ============================================================ +# ОПИСАНИЕ ПЕРЕМЕННЫХ +# ============================================================ +# Формат: (имя_переменной, группа, описание, ключ_резолвера) +# Группы: A — общие, B — 152-ФЗ + +VARIABLE_DEFS = [ + # === Полное название организации (6 падежей) === + ('{{company_full_name}}', 'A', 'Полное наименование организации (им.п.)', 'company:full_name'), + ('{{company_full_name_genitive}}', 'A', 'Полное наименование (род.п.)', 'decl:full_name:gent'), + ('{{company_full_name_dative}}', 'A', 'Полное наименование (дат.п.)', 'decl:full_name:datv'), + ('{{company_full_name_accs}}', 'A', 'Полное наименование (вин.п.)', 'decl:full_name:accs'), + ('{{company_full_name_ablt}}', 'A', 'Полное наименование (твор.п.)', 'decl:full_name:ablt'), + ('{{company_full_name_loct}}', 'A', 'Полное наименование (пр.п.)', 'decl:full_name:loct'), + + # === Краткое название организации (6 падежей) === + ('{{company_short_name}}', 'A', 'Краткое наименование (им.п.)', 'company:short_name'), + ('{{company_short_name_genitive}}', 'A', 'Краткое наименование (род.п.)', 'decl:short_name:gent'), + ('{{company_short_name_dative}}', 'A', 'Краткое наименование (дат.п.)', 'decl:short_name:datv'), + ('{{company_short_name_accs}}', 'A', 'Краткое наименование (вин.п.)', 'decl:short_name:accs'), + ('{{company_short_name_ablt}}', 'A', 'Краткое наименование (твор.п.)', 'decl:short_name:ablt'), + ('{{company_short_name_loct}}', 'A', 'Краткое наименование (пр.п.)', 'decl:short_name:loct'), + + # === Должностные лица === + ('{{chief_position}}', 'A', 'Должность руководителя', 'company:chief_position'), + ('{{chief_fio_initials}}', 'A', 'И.О. Фамилия руководителя', 'initials:chief'), + ('{{chief_fio_short}}', 'A', 'Фамилия И.О. руководителя', 'short_fio:chief'), + ('{{responsible_position}}', 'A', 'Должность ответственного', 'company:responsible_position'), + ('{{responsible_fio_initials}}', 'A', 'И.О. Фамилия ответственного', 'initials:responsible'), + ('{{responsible_fio_short}}', 'A', 'Фамилия И.О. ответственного', 'short_fio:responsible'), + ('{{admin_position}}', 'A', 'Должность администратора', 'company:admin_position'), + ('{{admin_fio_initials}}', 'A', 'И.О. Фамилия администратора', 'initials:admin'), + ('{{admin_fio_short}}', 'A', 'Фамилия И.О. администратора', 'short_fio:admin'), + + # === Сведения об организации === + ('{{company_address}}', 'A', 'Адрес организации', 'company:address'), + ('{{city_name}}', 'A', 'Город', 'company:city_name'), + ('{{company_inn}}', 'A', 'ИНН', 'company:inn'), + ('{{company_ogrn}}', 'A', 'ОГРН', 'company:ogrn'), + ('{{company_ogrn_date}}', 'A', 'Дата ОГРН', 'company:ogrn_date'), + ('{{company_kpp}}', 'A', 'КПП', 'company:kpp'), + ('{{security_department}}', 'A', 'Наименование структурного подразделения по безопасности', 'company:security_department'), + ('{{item.pd_actions}}', 'B', 'Действия с ПДн (в цикле ИС)', 'item:pd_actions'), + + # === Комиссия === + ('{{commission_list}}', 'A', 'Список комиссии', 'commission_list'), + + # === Специальные переменные === + ('{{date_year}}', 'A', 'Текущий год', 'date:year'), + ('{{date}}', 'A', 'Текущая дата', 'date:full'), + ('{{document_date}}', 'A', 'Дата документа', 'doc_date'), + ('{{document_number}}', 'A', 'Номер документа', 'doc_number'), + + # === 152-ФЗ === + ('{{is_security_requirements}}', 'B', 'Требования уровня защищённости', 'security_reqs'), + ('{{is_list}}', 'B', 'Список всех ИС (нумерованный)', 'is_list'), + ('{{is_name}}', 'B', 'Наименование первой ИС', 'is:name'), + ('{{name}}', 'B', 'Название ИС (алиас)', 'is:name'), + ('{{description}}', 'B', 'Описание ИС (алиас)', 'is:description'), + ('{{category}}', 'B', 'Категория ИС (алиас)', 'is:category'), + ('{{number}}', 'B', 'Номер (алиас)', 'doc_number'), + ('{{pd_count}}', 'B', 'Количество записей ПДн (алиас)', 'is:pd_count'), + ('{{pd_list}}', 'B', 'Список ПДн (алиас)', 'is:pd_list'), + ('{{pd_subjects}}', 'B', 'Субъекты ПДн (алиас)', 'is:pd_subjects'), + ('{{threat_type}}', 'B', 'Тип угроз (алиас)', 'is:threat_type'), + ('{{users}}', 'B', 'Пользователи ИС (алиас)', 'is:users'), + ('{{defence_level}}', 'B', 'Уровень защищённости (алиас)', 'is:defence_level'), + ('{{full_name_genitive}}', 'A', 'Полное наименование (род.п., алиас)', 'decl:full_name:gent'), + ('{{document}}', 'A', 'Название бумажного документа (алиас)', 'company:paper_documents'), + ('{{storage}}', 'A', 'Место хранения (алиас)', 'company:paper_storage'), + ('{{responsible_fio_accs}}', 'A', 'ФИО ответственного (вин.п.)', 'fio_acl:responsible'), + ('{{commission1}}', 'A', 'Должность члена комиссии 1', 'commission_member:0:position'), + ('{{commission1_fio}}', 'A', 'ФИО члена комиссии 1', 'commission_member:0:fio'), + ('{{commission2}}', 'A', 'Должность члена комиссии 2', 'commission_member:1:position'), + ('{{commission2_fio}}', 'A', 'ФИО члена комиссии 2', 'commission_member:1:fio'), + ('{{commission3}}', 'A', 'Должность члена комиссии 3', 'commission_member:2:position'), + ('{{commission3_fio}}', 'A', 'ФИО члена комиссии 3', 'commission_member:2:fio'), +] + +# Маппинг старых переменных <...> → {{...}} +OLD_VAR_MAP = { + '': '{{company_full_name}}', + '': '{{company_short_name}}', + '': '{{company_full_name_genitive}}', + '': '{{company_short_name_genitive}}', + '': '{{company_full_name_dative}}', + '': '{{company_short_name_dative}}', + '': '{{company_full_name_accs}}', + '': '{{company_short_name_accs}}', + '': '{{company_full_name_ablt}}', + '': '{{company_short_name_ablt}}', + '': '{{company_full_name_loct}}', + '': '{{company_short_name_loct}}', + '
': '{{company_address}}', + '': '{{company_inn}}', + '': '{{company_ogrn}}', + '': '{{company_kpp}}', + '': '{{chief_fio_initials}}', + '': '{{chief_fio_short}}', + '': '{{chief_position}}', + '': '{{responsible_fio_initials}}', + '': '{{responsible_fio_short}}', + '': '{{responsible_position}}', + '': '{{commission_list}}', + '': '{{document_number}}', + '': '{{document_date}}', + '': '{{date}}', + '': '{{document_date}}', +} + +VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}') +OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>') + + +# ============================================================ +# ПОСТРОЕНИЕ СЛОВАРЯ ЗАМЕН +# ============================================================ + +def build_replacements( + company: Company, + is_list: List = None, + doc_number: str = "", + doc_date: str = "", + direction: str = "152fz", +) -> Dict[str, str]: + """Строит полный словарь замен {{переменная}} → значение.""" + if is_list is None: + is_list = [] + + # ---------- Комиссия ---------- + commission_data = _build_commission_data(company) + + # ---------- Контекст ---------- + ctx = { + 'company': company, + 'is_list': is_list, + 'commission_data': commission_data, + 'doc_number': doc_number or '', + 'doc_date': doc_date or '', + 'direction': direction, + } + + replacements = {} + for var_name, group, desc, source in VARIABLE_DEFS: + if direction == '152fz' and group not in ('A', 'B'): + continue + value = _resolve(source, ctx) + replacements[var_name] = str(value) if value else '—' + + # Индексированные переменные ИС (is_name_1, is_pd_list_2 и т.д.) + for i, isys in enumerate(is_list, 1): + _add_is_indexed_vars(replacements, isys, i) + + # Циклы + replacements['_loops'] = _build_loop_data(company, is_list) + return replacements + + +def _commission_role_display(members) -> List[str]: + """Группирует роли комиссии для вывода: + - Председатель/Секретарь/Заместитель — отдельной строкой со своей ролью; + - обычные члены — первый получает «Члены комиссии», остальные — пустую роль + (чтобы не повторять «Член комиссии» в каждой строке).""" + result = [] + member_used = False + for m in members: + role = (m.role or 'член комиссии').strip() + role_lower = role.lower() + if ('председател' in role_lower or 'секретар' in role_lower + or 'заместител' in role_lower or 'зам. председателя' in role_lower): + result.append(role) + continue + # обычный член комиссии + if not member_used: + result.append('Члены комиссии') + member_used = True + else: + result.append('') + return result + + +def _build_commission_data(company: Company) -> Dict[str, str]: + data = {} + members = company.commission or [] + + display_roles = _commission_role_display(members) + lines = [] + for i, (m, role_disp) in enumerate(zip(members, display_roles), 1): + if role_disp: + line = (f"{i}. {role_disp}: {m.position} — {m.fio}" if m.position + else f"{i}. {role_disp}: {m.fio}") + else: + line = (f"{i}. {m.position} — {m.fio}" if m.position + else f"{i}. {m.fio}") + lines.append(line) + data['commission_list'] = '\n'.join(lines) if lines else '—' + + return data + + +def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int): + fields = { + 'is_name': isys.name or '—', + 'is_address': getattr(isys, 'address', '') or '—', + 'is_description': isys.description or '—', + 'is_has_internet': 'да' if isys.is_internet else 'нет', + 'is_defence_level': str(isys.defence_level or '—'), + 'is_category': ', '.join(isys.personal_data_category) or isys.category or '—', + 'is_pd_list': ', '.join(isys.personal_data_list or []) or '—', + 'is_users': ', '.join(isys.users_list or []) or '—', + 'is_pd_count': str(isys.pd_count or '—'), + 'is_purpose': isys.purpose or '—', + 'is_structure': getattr(isys, 'structure', '') or '—', + } + for f, v in fields.items(): + replacements[f'{{{{{f}_{idx}}}}}'] = str(v) + + +def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]: + loops = {} + + # Комиссия + members = company.commission or [] + if members: + display_roles = _commission_role_display(members) + items = [] + for m, role_disp in zip(members, display_roles): + pos = m.position or '—' + fio = m.fio or '—' + if role_disp: + full = f"{role_disp} {pos} — {fio}" if pos != '—' else f"{role_disp} — {fio}" + else: + full = f"{pos} — {fio}" if pos != '—' else fio + # Строка подписи: «___________ / » (только подчёркивание и слэш). + # Роль НЕ включаем — в шаблонах она выводится отдельной колонкой {{item.role}}, + # иначе получается дубль «Председатель комиссии | Председатель комиссии: ___». + if 'Председатель' in role_disp: + sig = f"{'_' * 11} / " + sig_full = f"{role_disp}: {'_' * 11} / " + elif role_disp: + sig = f"{'_' * 17} / " + sig_full = f"{role_disp}: {'_' * 17} / " + else: + sig = f"{'_' * 17} / " + sig_full = f"{'_' * 17} / " + initials = dc.get_initials(fio) if fio != '—' else '' + items.append({ + 'role': role_disp, + 'position': pos, + 'fio': fio, + 'fio_initials': dc.get_initials(fio) if fio != '—' else '', + 'fio_short': dc.get_short_fio(fio) if fio != '—' else '', + 'full': full, + # Рамка подписи без роли и ФИО: «___________ / » (ФИО добавится) + 'signature': sig, + # Полная подпись с ролью и ФИО: «Роль: ___________ / И.О. Фамилия» + 'signature_full': (sig_full + initials).strip(), + }) + loops['commission'] = items + + # ИС + if is_list: + items = [] + for isys in is_list: + # Пользователи ИС для вложенного цикла + # ВСЕ пользователи, включая администратора (без дублей по ФИО) + seen = set() + users_loop = [] + for u in (isys.users_list or []): + key = _norm_name(u) + if key and key in seen: + continue + seen.add(key) + users_loop.append({'fio': u, 'position': ''}) + items.append({ + 'name': isys.name or '—', + 'description': isys.description or '—', + 'address': getattr(isys, 'address', '') or '—', + 'is_has_lan': 'Да' if isys.is_local_network else 'Нет', + 'has_internet': 'да' if isys.is_internet else 'нет', + 'defence_level': str(isys.defence_level or '—'), + 'threat_type': str(isys.threat_type or '3'), + 'category': ', '.join(isys.personal_data_category) or isys.category or '—', + 'pd_list': ', '.join(isys.personal_data_list or []) or '—', + 'pd_count': str(isys.pd_count or '—'), + 'users': ', '.join(isys.users_list or []) or '—', + 'users_loop': users_loop, + 'defense_tools_list': ', '.join(isys.defense_tools_list or []) or '—', + 'processing_modes': isys.processing_modes or '—', + 'pd_subjects': '; '.join(isys.pd_subjects_list or []) or '—', + 'is_purpose': isys.purpose or '—', + 'structure': getattr(isys, 'structure', '') or '—', + 'software': getattr(isys, 'software', '') or '—', + 'pd_actions': '; '.join(isys.pd_actions_list or []) or '—', + }) + loops['information_systems'] = items + + # Бумажные документы + def _storage_with_prep(storage: str) -> str: + """«сейф» → «в сейфе» (предлог + предложный падеж). + Если уже начинается с предлога — оставить как есть.""" + if not storage: + return '' + s = storage.strip() + low = s.lower() + if low.startswith(('в ', 'на ', 'под ', 'за ', 'у ', 'при ', 'из ', 'со ', 'во ', 'над ', 'перед ')): + return s + words = s.split() + out = [] + # Исключения предложного падежа: «в шкафу», «в углу» (не «в шкафе») + loct_exceptions = {'шкаф': 'шкафу', 'угол': 'углу', 'край': 'краю', 'рот': 'рту', 'мост': 'мосту'} + for w in words: + core = w.strip('.,;') + if not core or core.isdigit() or core.startswith('№'): + out.append(w) + continue + if core.lower() in loct_exceptions: + out.append(loct_exceptions[core.lower()]) + continue + try: + p = dc.morph.parse(core)[0] + inf = p.inflect({'loct'}) + if inf: + out.append(inf.word) + continue + except Exception: + pass + out.append(w) + return 'в ' + ' '.join(out) + + paper_list = getattr(company, 'paper_documents_list', None) or [] + if paper_list: + items = [] + for p in paper_list: + storage = p.storage or '' + items.append({ + 'document': p.name or '', + 'storage': _storage_with_prep(storage), # «в сейфе» + 'storage_raw': storage, # «сейф» (как введено) + }) + loops['paper_documents'] = items + + # Сотрудники с доступом + employees = getattr(company, 'employees_access', None) or [] + if employees: + items = [] + for e in employees: + items.append({ + 'position': e.get('position', ''), + 'fio': e.get('fio', ''), + }) + loops['employees_access'] = items + + return loops + + +# ============================================================ +# РЕЗОЛВЕР ЗНАЧЕНИЙ +# ============================================================ + +def _resolve(source: str, ctx: Dict) -> str: + """Преобразует ключ-источник в значение, подставляя из контекста.""" + company = ctx.get('company') + is_list = ctx.get('is_list', []) + commission_data = ctx.get('commission_data', {}) + doc_number = ctx.get('doc_number', '') + doc_date = ctx.get('doc_date', '') + + # Прямые поля Company + if source.startswith('company:'): + field = source[8:] + return str(getattr(company, field, '') or '') + + # Склонение названия + if source.startswith('decl:'): + _, field, case = source.split(':') + name = getattr(company, field, '') or '' + case_gr = dc._normalize_case(case) + # Сначала берём ГОТОВОЕ склонение из модели (пользователь мог + # подставить через Морфер/ИИ или ввести вручную), пересчитываем + # только если поле пустое + case_idx = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}.get(case_gr) + if case_idx: + if field == 'full_name': + ready = getattr(company, f'company_name_{case_idx}', '') or '' + if ready: + return ready + elif field == 'short_name': + ready = getattr(company, f'short_name_{case_idx}', '') or '' + if ready: + return ready + return dc.company_name_decline(name, case_gr) if name else '—' + + # Форматы ФИО + if source.startswith('short_fio:'): + who = source.split(':', 1)[1] + fio = _get_fio(company, who) + return dc.get_short_fio(fio) if fio else '—' + if source.startswith('initials:'): + who = source.split(':', 1)[1] + fio = _get_fio(company, who) + return dc.get_initials(fio) if fio else '—' + + # Комиссия + if source in commission_data: + return str(commission_data[source]) + + # Даты + if source.startswith('date:'): + fmt = source.split(':', 1)[1] + now = datetime.now() + if fmt == 'year': + return str(now.year) + return now.strftime('%d.%m.%Y') + if source == 'doc_date': + return doc_date or datetime.now().strftime('%d.%m.%Y') + if source == 'doc_number': + num = str(doc_number).strip() + return re.sub(r'^[Nn]?[oо]?[№#]\s*', '', num) or '—' + + # Требования защищённости + if source == 'security_reqs': + return _get_security_text(ctx) + + # IS-алиасы (первая ИС) + if source.startswith('is:'): + field = source[3:] + if is_list: + first = is_list[0] + field_map = { + 'name': 'name', + 'description': 'description', + 'category': 'category', + 'defence_level': 'defence_level', + 'threat_type': 'threat_type', + 'pd_list': lambda: ', '.join(first.personal_data_list or []), + 'pd_count': 'pd_count', + 'pd_subjects': lambda: '; '.join(first.pd_subjects_list or []), + 'users': lambda: ', '.join(first.users_list or []), + } + if field in field_map: + val = field_map[field] + if callable(val): + return val() or '—' + return str(getattr(first, val, '') or '—') if isinstance(val, str) else '—' + return '—' + + # Список ИС + if source == 'is_list': + if is_list: + return '\n'.join(f"{i}. {isys.name}" for i, isys in enumerate(is_list, 1)) + return '—' + if source == 'is_list_comma': + if is_list: + return ', '.join(isys.name for isys in is_list) + return '—' + + # Члены комиссии + if source.startswith('commission_member:'): + parts = source.split(':') + idx, field = int(parts[1]), parts[2] + members = getattr(company, 'commission', None) or [] + if idx < len(members): + return str(getattr(members[idx], field, '') or '—') + return '—' + + # Склонение ФИО по падежам + if source.startswith('fio_acl:'): + who = source.split(':', 1)[1] + fio = _get_fio(company, who) + return dc.decline(fio, 'accs') if fio else '—' + + return '—' + + +def _get_fio(company: Company, who: str) -> str: + mapping = { + 'chief': 'chief_fio', + 'responsible': 'responsible_fio', + 'admin': 'admin_fio', + } + field = mapping.get(who, who + '_fio') + return getattr(company, field, '') or '' + + +def _get_security_text(ctx: Dict) -> str: + """Возвращает текст требований для уровня защищённости.""" + defence_level = ctx.get('extended_fields', {}).get('defence_level', '4') + is_list = ctx.get('is_list', []) + if not defence_level and is_list: + defence_level = str(getattr(is_list[0], 'defence_level', '4') or '4') + dl = str(defence_level).strip()[0] + + TEXTS = { + '1': """Для обеспечения 1-го уровня защищённости...""", + '2': """Для обеспечения 2-го уровня защищённости...""", + '3': """Для обеспечения 3-го уровня защищённости...""", + '4': """Для обеспечения 4-го уровня защищённости персональных данных необходимо: +— режим безопасности помещений; +— сохранность носителей ПДн; +— утверждение перечня лиц с доступом к ПДн; +— использование сертифицированных СЗИ.""", + } + return TEXTS.get(dl, TEXTS['4']) diff --git a/dokogen/verify.py b/dokogen/verify.py new file mode 100644 index 0000000..98c0b83 --- /dev/null +++ b/dokogen/verify.py @@ -0,0 +1,440 @@ +# -*- coding: utf-8 -*- +"""DokoGen — проверка данных через внешние сервисы. + +Провайдеры: +- Морфер 3.0 (morpher.ru) — склонение ФИО/названий по падежам. Бесплатно, без ключа. +- DaData (dadata.ru) — проверка организации по ИНН/ОГРН (наименование, адрес, КПП). + Нужен API-ключ (бесплатный тариф ~10 000 запросов/день). +- ИИ (OpenAI-совместимый) — ИИ-проверка склонений и реквизитов (DeepSeek, ChatGPT и др.). + Нужен API-ключ и модель. Данные ИИ ВСЕГДА помечаются дисклеймером. + +Каждая функция возвращает (result, error): +- result: dict с полями {source, source_url, data, disclaimer, apply} +- error: строка ошибки или None +""" + +import json +import re +from datetime import datetime, timedelta +import urllib.parse +import urllib.request +import urllib.error + +MORPHER_URL = "https://ws3.morpher.ru/russian/declension" +DADATA_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party" +DEFAULT_AI_URL = "https://api.deepseek.com/chat/completions" + +CASES = ['И', 'Р', 'Д', 'В', 'Т', 'П'] +CASE_NAMES = { + 'И': 'Именительный', 'Р': 'Родительный', 'Д': 'Дательный', + 'В': 'Винительный', 'Т': 'Творительный', 'П': 'Предложный', +} + + +def _http_json(url, data=None, headers=None, timeout=30): + """HTTP-запрос, возвращает (json, error_str).""" + body = None + if data is not None: + body = json.dumps(data).encode('utf-8') + req = urllib.request.Request(url, data=body, headers=headers or {}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode('utf-8') + return json.loads(raw), None + except urllib.error.HTTPError as e: + try: + err_body = e.read().decode('utf-8') + except Exception: + err_body = '' + return None, f"HTTP {e.code}: {err_body[:300]}" + except Exception as e: + return None, str(e) + + +# ============================================================ +# НОРМАЛИЗАЦИЯ ТЕКСТА (КАПС → нормальный регистр, сокращения адреса) +# ============================================================ +_CAPS_ABBR = { + 'АРМ', 'СКУД', 'СЗИ', 'СКЗИ', 'СОВ', 'ЛВС', 'ИНН', 'КПП', 'ОГРН', 'ЦОД', + 'МБУ', 'МКУ', 'ООО', 'СНИЛС', 'СБИС', 'ЕМСЭД', 'ФИО', 'ИСПДН', 'ККТ', +} + +_ADDRESS_ABBR = { + 'Г': 'г.', 'УЛ': 'ул.', 'Д': 'д.', 'КВ': 'кв.', 'ПР': 'пр.', + 'ПЕР': 'пер.', 'ПЛ': 'пл.', 'ОБЛ': 'обл.', 'ПОС': 'пос.', + 'СТ': 'ст.', 'Ш': 'ш.', 'Р-Н': 'р-н.', 'Б-Р': 'б-р.', 'МКР': 'мкр.', + 'НАБ': 'наб.', 'ТУП': 'туп.', 'ПР-Д': 'пр-д.', 'КРП': 'крп.', + 'Г.': 'г.', 'УЛ.': 'ул.', 'Д.': 'д.', 'Р-Н.': 'р-н.', +} + + +def _normalize_text(text): + """КАПС → нормальный регистр по словам (сохраняя аббревиатуры и числа).""" + if not text: + return '' + words = str(text).split() + out = [] + for w in words: + pre = '' + post = '' + core = w + while core and not core[0].isalnum(): + pre += core[0] + core = core[1:] + while core and not core[-1].isalnum(): + post = core[-1] + post + core = core[:-1] + if not core: + out.append(w) + continue + if any('а' <= c <= 'я' or c == 'ё' for c in core): + out.append(w) + continue + if not any('А' <= c <= 'Я' or c == 'Ё' for c in core): + out.append(w) + continue + letters = [c for c in core if c.isalpha()] + if len(letters) <= 2: + out.append(w) + continue + if core.upper() in _CAPS_ABBR: + out.append(w) + continue + out.append(pre + core[0].upper() + core[1:].lower() + post) + return ' '.join(out) + + +def normalize_address(text): + """«Г Крымск» → «г. Крымск», «Р-Н» → «р-н.» и т.п.""" + if not text: + return '' + parts = re.split(r'(\s+|[,\;])', text) + out = [] + for p in parts: + if p.strip() and p.strip().upper() in _ADDRESS_ABBR: + out.append(_ADDRESS_ABBR[p.strip().upper()]) + else: + out.append(p) + return ''.join(out) + + +def _fmt_date(value): + """Дата: '2005-02-09' → '09.02.2005'; миллисекунды 1107907200000 → дата.""" + if not value: + return '' + # миллисекунды с эпохи (DaData иногда отдаёт так) + if isinstance(value, (int, float)) and value > 10 ** 10: + try: + dt = datetime(1970, 1, 1) + timedelta(milliseconds=value) + return dt.strftime('%d.%m.%Y') + except Exception: + pass + s = str(value).strip() + parts = s.split('-') + if len(parts) == 3 and all(p.isdigit() for p in parts): + return f"{parts[2]}.{parts[1]}.{parts[0]}" + return s + + +# ============================================================ +# МОРФЕР 3.0 — склонение (бесплатно, без ключа) +# ============================================================ +def check_declension_morpher(text): + """Склонение слова/ФИО/названия через Морфер 3.0.""" + if not text or not text.strip(): + return None, "Пустое значение для склонения" + url = MORPHER_URL + '?' + urllib.parse.urlencode({'s': text.strip(), 'format': 'json'}) + js, err = _http_json(url) + if err: + return None, err + if not isinstance(js, dict): + return None, "Неожиданный ответ Морфера" + + decl = {} + for c in CASES: + val = js.get(c) or js.get(c.lower()) or '' + if isinstance(val, str) and val: + decl[c] = val + + if not decl: + return None, "Морфер не смог просклонять (возможно, это не слово/ФИО)" + + # Морфер не возвращает именительный падеж (он равен исходному слову) — + # добавляем его сами, иначе поле «Именительный» остаётся пустым + if 'И' not in decl: + decl['И'] = text.strip() + + return { + 'source': 'Морфер 3.0', + 'source_url': 'https://morpher.ru', + 'disclaimer': '', + 'kind': 'declension', + 'original': text.strip(), + 'data': decl, + 'apply': {'declension': decl}, + }, None + + +# ============================================================ +# DADATA — проверка организации по ИНН (нужен API-ключ) +# ============================================================ +def check_company_dadata(inn, token): + """Проверка организации по ИНН/ОГРН через DaData. + + Данные нормализуются: регистр (КАПС → обычный), адрес (г., ул., д.), + дата ОГРН (в т.ч. из миллисекунд). + """ + if not token: + return None, "Не задан API-ключ DaData (Файл → Настройки → API)" + inn = (inn or '').replace(' ', '') + if not inn or not inn.isdigit(): + return None, "Введите ИНН или ОГРН для проверки" + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Token {token}', + } + js, err = _http_json(DADATA_URL, data={'query': inn}, headers=headers) + if err: + return None, err + suggestions = (js or {}).get('suggestions') or [] + if not suggestions: + return None, f"DaData: организация с ИНН/ОГРН {inn} не найдена" + s = suggestions[0] + d = s.get('data') or {} + name = d.get('name') or {} + address = d.get('address') or {} + management = d.get('management') or {} + + full_name = _normalize_text(name.get('full_with_opf') or s.get('value', '')) + short_name = _normalize_text(name.get('short_with_opf') or name.get('short') or '') + addr = normalize_address(address.get('value', '')) + # DaData отдаёт индекс отдельным полем postal_code — добавляем его + # в начало адреса, если его там ещё нет + postal = (address.get('data') or {}).get('postal_code') or address.get('postal_code') or '' + postal = str(postal).strip() + if postal and postal.isdigit(): + addr_first = (addr or '').split(',')[0].strip() + if not addr_first.startswith(postal): + addr = f"{postal}, {addr}" if addr else postal + chief_position = _normalize_text(management.get('post', '')) + chief_fio = _normalize_text(management.get('name', '')) + ogrn_date = _fmt_date(d.get('ogrn_date') or '') + + return { + 'source': 'DaData', + 'source_url': 'https://dadata.ru', + 'disclaimer': '', + 'kind': 'company', + 'query': inn, + 'data': { + 'full_name': full_name, + 'short_name': short_name, + 'address': addr, + 'kpp': d.get('kpp', ''), + 'ogrn': d.get('ogrn', ''), + 'ogrn_date': ogrn_date, + 'chief_position': chief_position, + 'chief_fio': chief_fio, + 'inn': d.get('inn', inn), + }, + 'apply': {'company': { + 'full_name': full_name, + 'short_name': short_name, + 'address': addr, + 'kpp': d.get('kpp', ''), + 'ogrn': d.get('ogrn', ''), + 'ogrn_date': ogrn_date, + 'chief_position': chief_position, + 'chief_fio': chief_fio, + }}, + }, None + + +# ============================================================ +# ИИ (OpenAI-совместимый: DeepSeek, ChatGPT и др.) +# ============================================================ +AI_DISCLAIMER = ("⚠ Сгенерировано ИИ. Информацию рекомендуется перепроверить " + "по официальным источникам.") + + +def _ai_json(api_key, model, base_url, system, user, timeout=60): + """Запрос к OpenAI-совместимому API (DeepSeek, ChatGPT и т.п.).""" + if not api_key: + return None, "Не задан API-ключ ИИ (Файл → Настройки → API)" + url = (base_url or DEFAULT_AI_URL).rstrip('/') + if not url.endswith('/chat/completions'): + url += '/chat/completions' + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {api_key}', + } + payload = { + 'model': model or 'deepseek-chat', + 'messages': [ + {'role': 'system', 'content': system}, + {'role': 'user', 'content': user}, + ], + 'temperature': 0.2, + 'response_format': {'type': 'json_object'}, + } + js, err = _http_json(url, data=payload, headers=headers, timeout=timeout) + if err: + return None, err + try: + content = js['choices'][0]['message']['content'] + return json.loads(content), None + except Exception: + return None, "ИИ вернул неожиданный ответ" + + +def check_declension_ai(text, api_key, model='deepseek-chat', base_url=None): + """ИИ-рецензия склонений: проверяет падежи и предлагает исправления.""" + if not text or not text.strip(): + return None, "Пустое значение для склонения" + system = ( + "Ты — эксперт по русскому языку. Склоняй слово/ФИО/название организации " + "по падежам. Верни ТОЛЬКО JSON: " + '{"И": "...", "Р": "...", "Д": "...", "В": "...", "Т": "...", "П": "...", "комментарий": "..."}' + ) + user = f"Просклоняй: {text.strip()}" + js, err = _ai_json(api_key, model, base_url, system, user) + if err: + return None, err + + decl = {} + for c in CASES: + val = js.get(c) or js.get(c.lower()) or '' + if isinstance(val, str) and val: + decl[c] = val + comment = js.get('комментарий', '') or '' + + if not decl: + return None, "ИИ не смог просклонять" + + # Именительный падеж равен исходному слову — подставляем, если ИИ его не вернул + if 'И' not in decl: + decl['И'] = text.strip() + + return { + 'source': 'ИИ', + 'source_url': 'openai-совместимый API', + 'disclaimer': AI_DISCLAIMER, + 'kind': 'declension', + 'original': text.strip(), + 'data': decl, + 'comment': comment, + 'apply': {'declension': decl}, + }, None + + +# Поля организации для ИИ-проверки (для читаемого вывода) +COMPANY_FIELDS = [ + ('full_name', 'Полное наименование'), + ('short_name', 'Краткое наименование'), + ('address', 'Юридический адрес'), + ('inn', 'ИНН'), + ('kpp', 'КПП'), + ('ogrn', 'ОГРН'), + ('ogrn_date', 'Дата ОГРН'), + ('chief_position', 'Руководитель (должность)'), + ('chief_fio', 'Руководитель (ФИО)'), +] + +# Поля для сравнения: (ключ, подпись, тип) +# type: text — регистр/пунктуация игнорируются; digits — любое отличие = расхождение +_COMPARE_FIELDS = [ + ('full_name', 'Наименование', 'text'), + ('short_name', 'Краткое наименование', 'text'), + ('address', 'Юридический адрес', 'text'), + ('inn', 'ИНН', 'digits'), + ('kpp', 'КПП', 'digits'), + ('ogrn', 'ОГРН', 'digits'), + ('ogrn_date', 'Дата ОГРН', 'text'), + ('chief_position', 'Руководитель (должность)', 'text'), + ('chief_fio', 'Руководитель (ФИО)', 'text'), +] + + +def _norm_compare(s): + """Нормализация для сравнения: регистр, ё→е, пробелы/точки/дефисы/кавычки убираются.""" + s = (s or '').lower().replace('ё', 'е') + s = re.sub(r'[\s.,;:()«»"\'\-—–]+', '', s) + return s + + +def _norm_digits(s): + """Только цифры.""" + return re.sub(r'\D', '', s or '') + + +def compare_company_values(old_data, new_data): + """Сравнивает старые и новые значения реквизитов организации. + + Возвращает список расхождений: [(подпись, было, стало), ...]. + - text: разница в регистре/пунктуации (ИВАНОВ И.И. vs Иванов И.И.) НЕ считается; + - digits (ИНН/КПП/ОГРН): любое отличие цифр = расхождение. + """ + diffs = [] + for key, label, ftype in _COMPARE_FIELDS: + old = (old_data or {}).get(key) or '' + new = (new_data or {}).get(key) or '' + if not old or not new: + continue + if ftype == 'digits': + if _norm_digits(old) != _norm_digits(new): + diffs.append((label, old, new)) + else: + if _norm_compare(old) != _norm_compare(new): + diffs.append((label, old, new)) + return diffs + + +def check_company_ai(company_data, api_key, model='deepseek-chat', base_url=None): + """ИИ-проверка реквизитов организации. + + Возвращает оценку, замечания, предложения и полный «снимок» данных + (что ИИ увидел и как оценил каждое поле) — чтобы пользователь видел + не только «ОК», но и информацию по организации. + """ + if not api_key: + return None, "Не задан API-ключ ИИ (Файл → Настройки → API)" + system = ( + "Ты — юрист по защите персональных данных (152-ФЗ). Проверь реквизиты организации. " + "Верни ТОЛЬКО JSON: " + '{"оценка": "ok|warning|error", ' + '"замечания": ["..."], ' + '"предложения": {"full_name": "...", "short_name": "...", "address": "...", ' + '"inn": "...", "kpp": "...", "ogrn": "...", "ogrn_date": "...", ' + '"chief_position": "...", "chief_fio": "..."}, ' + '"поля": {"full_name": {"статус": "ok|пусто|ошибка", "комментарий": "..."}, ...}} ' + "В «предложениях» указывай исправленные значения только если уверен, иначе пустые строки. " + "В «полях» дай оценку каждого поля (ok/пусто/ошибка) и комментарий." + ) + user = json.dumps(company_data, ensure_ascii=False) + js, err = _ai_json(api_key, model, base_url, system, user) + if err: + return None, err + + notes = js.get('замечания') or [] + if isinstance(notes, str): + notes = [notes] + proposals = js.get('предложения') or {} + if not isinstance(proposals, dict): + proposals = {} + fields = js.get('поля') or {} + if not isinstance(fields, dict): + fields = {} + + return { + 'source': 'ИИ', + 'source_url': 'openai-совместимый API', + 'disclaimer': AI_DISCLAIMER, + 'kind': 'company', + 'data': { + 'rating': js.get('оценка', 'warning'), + 'notes': notes, + 'proposals': proposals, + 'fields': fields, + }, + 'apply': {'company': {k: v for k, v in proposals.items() if v}}, + }, None diff --git a/main.py b/main.py new file mode 100644 index 0000000..2a3cce2 --- /dev/null +++ b/main.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""DokoGen — Генератор документов 152-ФЗ +Точка входа: python main.py +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from dokogen.ui import main + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..98a2cec --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +python-docx>=1.1.0 +pymorphy3>=2.0.0 +openpyxl>=3.1.0 +lxml>=5.0.0 diff --git a/test_generator.py b/test_generator.py new file mode 100644 index 0000000..91ae39d --- /dev/null +++ b/test_generator.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""DokoGen — test: verify generator with nested loops""" + +import sys, os, json, tempfile, zipfile, shutil, re +from datetime import datetime + +# Add to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from dokogen.models import Company, CommissionMember, InformationSystem +from dokogen.variables import build_replacements +from dokogen.generator import process_template, expand_loops_in_zip + +def create_test_template(): + """Creates a test .docx with nested loops for verification.""" + from docx import Document + from docx.shared import Pt + + doc = Document() + + # Title + doc.add_heading('Test Document with Nested Loops', 0) + + # Simple variable + doc.add_paragraph('Company: {{company_full_name}}') + doc.add_paragraph('Date: {{date}}') + doc.add_paragraph('') + + # Outer loop: information_systems + doc.add_paragraph('') + doc.add_paragraph('=== IS: {{item.name}} ===') + doc.add_paragraph('Category: {{item.category}}') + doc.add_paragraph('Threat type: {{item.threat_type}}') + doc.add_paragraph('Defence level: {{item.defence_level}}') + doc.add_paragraph('') + + # Inner loop: commission + doc.add_paragraph('Commission members:') + doc.add_paragraph('') + doc.add_paragraph(' - {{item.role}}: {{item.position}} {{item.fio}}') + doc.add_paragraph('') + + doc.add_paragraph('') + doc.add_paragraph('--- end of IS ---') + doc.add_paragraph('') + doc.add_paragraph('') + + doc.add_paragraph('') + doc.add_paragraph('Footer: Generated automatically') + + path = '/tmp/test_template_nested.docx' + doc.save(path) + print(f'✅ Test template created: {path}') + return path + +def test_zip_loop_expansion(): + """Test ZIP-level loop expansion with mock data.""" + print('\n' + '='*60) + print('TEST: ZIP-level loop expansion') + print('='*60) + + # Create test data + company = Company( + full_name='ООО "Ромашка"', + short_name='Ромашка', + inn='7701234567', + chief_fio='Иванов Иван Иванович', + chief_position='Генеральный директор', + commission=[ + CommissionMember(role='Председатель', position='Директор', fio='Иванов И.И.'), + CommissionMember(role='Секретарь', position='Секретарь', fio='Петров П.П.'), + CommissionMember(role='Член комиссии', position='Бухгалтер', fio='Сидорова А.А.'), + ], + information_systems=[ + InformationSystem(name='ИС-1 "Бухгалтерия"', category='специальные', + pd_count='менее 100 000', threat_type='1', defence_level='2'), + InformationSystem(name='ИС-2 "Кадры"', category='иные', + pd_count='более 100 000', threat_type='3', defence_level='4'), + InformationSystem(name='ИС-3 "Склад"', category='общедоступные', + pd_count='менее 100 000', threat_type='2', defence_level='3'), + ] + ) + + # Build replacements + replacements = build_replacements( + company=company, + is_list=company.information_systems, + doc_number='001', + doc_date='31.07.2026', + direction='152fz' + ) + + loops = replacements.get('_loops', {}) + print(f'\nLoops found: {list(loops.keys())}') + print(f'IS items: {len(loops.get("information_systems", []))}') + print(f'Commission items: {len(loops.get("commission", []))}') + + # Create test template + template_path = create_test_template() + + # Verify template has markers + with zipfile.ZipFile(template_path, 'r') as z: + doc_xml = z.read('word/document.xml').decode('utf-8') + + start_count = doc_xml.count('loop:') + end_count = doc_xml.count('loop_end') + print(f'\nMarkers in template: {start_count} starts, {end_count} ends') + + # Process with ZIP-level loop expansion + print('\nProcessing loops via ZIP...') + result_path = expand_loops_in_zip(template_path, loops) + + # Verify result + with zipfile.ZipFile(result_path, 'r') as z: + result_xml = z.read('word/document.xml').decode('utf-8') + + remaining_starts = len(re.findall(r'loop:(\w+)', result_xml)) + remaining_ends = result_xml.count('loop_end') + + print(f'Markers after expansion: {remaining_starts} starts, {remaining_ends} ends') + + if remaining_starts == 0 and remaining_ends == 0: + print('✅ ALL LOOPS EXPANDED SUCCESSFULLY') + else: + print(f'⚠️ {remaining_starts} loop markers remaining') + + # Now test full process_template + print('\n' + '='*60) + print('TEST: Full process_template with all replacements') + print('='*60) + + # Rebuild replacements (they were consumed) + replacements2 = build_replacements( + company=company, + is_list=company.information_systems, + doc_number='001', + doc_date='31.07.2026', + direction='152fz' + ) + + try: + output_path = process_template( + template_path=template_path, + replacements=replacements2, + output_path='/tmp/test_output.docx', + log_func=print + ) + print(f'\n✅ Output generated: {output_path}') + + # Verify output + with zipfile.ZipFile(output_path, 'r') as z: + out_xml = z.read('word/document.xml').decode('utf-8') + + # Check no remaining variables + remaining_vars = re.findall(r'\{\{[^}]+\}\}', out_xml) + remaining_old = re.findall(r'<[A-Za-z_]+>', out_xml) + + if remaining_vars: + print(f'⚠️ Remaining {{...}} vars: {remaining_vars[:5]}') + else: + print('✅ No remaining {{...}} variables') + + if remaining_old: + print(f'⚠️ Remaining <...> vars: {remaining_old[:5]}') + else: + print('✅ No remaining <...> variables') + + # Check IS names appear + for isys in company.information_systems: + if isys.name in out_xml: + print(f'✅ Found IS name: {isys.name}') + else: + print(f'⚠️ MISSING IS name: {isys.name}') + + # Check commission members appear + for m in company.commission: + if m.fio in out_xml: + print(f'✅ Found commission: {m.fio}') + else: + print(f'⚠️ MISSING commission: {m.fio}') + + except Exception as e: + print(f'❌ Error: {e}') + import traceback + traceback.print_exc() + + # Cleanup + os.unlink(template_path) + if os.path.exists(result_path): + os.unlink(result_path) + if os.path.exists('/tmp/test_output.docx'): + os.unlink('/tmp/test_output.docx') + + print('\n' + '='*60) + print('TEST COMPLETE') + print('='*60) + +if __name__ == '__main__': + test_zip_loop_expansion()