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/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 index 6a1962f..7ffcd5b 100644 --- a/dokogen/generator.py +++ b/dokogen/generator.py @@ -461,10 +461,31 @@ def _apply_highlight_color(run_el, color: str): 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)) @@ -483,9 +504,7 @@ def _replace_text_in_para(para, replacements: Dict[str, str]) -> bool: continue # текст до переменной if m.start() > pos: - run = para.add_run(full[pos:m.start()]) - if first is not None: - _copy_run_style(first, run) + _add_run_with_tabs(para, full[pos:m.start()], first) # значение переменной val_str = str(val) run = para.add_run(val_str) @@ -497,9 +516,7 @@ def _replace_text_in_para(para, replacements: Dict[str, str]) -> bool: pos = m.end() # хвост после последней переменной if pos < len(full): - run = para.add_run(full[pos:]) - if first is not None: - _copy_run_style(first, run) + _add_run_with_tabs(para, full[pos:], first) return changed @@ -566,21 +583,20 @@ def _highlight_unmatched(doc): return # Пересобираем параграф: обычный текст и переменные отдельными run'ами, - # переменные — с красным маркером. Сохраняем стиль первого run. + # переменные — с красным маркером. Сохраняем стиль первого run и табы. first = para.runs[0] if para.runs else None para.clear() pos = 0 for m in matches: if m.start() > pos: - run = para.add_run(full[pos:m.start()]) - if first is not None: - _copy_run_style(first, run) + _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) 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/ui.py b/dokogen/ui.py index a8bca00..349d0d0 100644 --- a/dokogen/ui.py +++ b/dokogen/ui.py @@ -49,6 +49,13 @@ class DokoGenApp: # 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="") @@ -103,7 +110,10 @@ class DokoGenApp: 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="Переменные шаблонов", command=self.show_variables) + 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): @@ -364,6 +374,7 @@ class DokoGenApp: if not name and not short: messagebox.showwarning("Проверка", "Сначала введите полное или краткое название организации") return + self.log(f"🌐 Запрос к Морфер 3.0: «{name or short}»") self._verify_log("Запрос к Морфер 3.0…") def _run(): @@ -395,6 +406,7 @@ class DokoGenApp: 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(): @@ -420,6 +432,7 @@ class DokoGenApp: def _verify_declension_done(self, results, err): if err: self._verify_log(f"❌ {err}") + self.log(f"❌ Проверка склонений: {err}") return # Собираем единый результат для применения combined = { @@ -446,6 +459,13 @@ class DokoGenApp: 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') @@ -457,6 +477,7 @@ class DokoGenApp: if not settings.get('dadata_token'): messagebox.showwarning("Настройки", "Не задан API-ключ DaData.\nМеню «Файл → Настройки»") return + self.log(f"🌐 Запрос к DaData: ИНН/ОГРН {inn}") self._verify_log("Запрос к DaData…") def _run(): @@ -478,6 +499,7 @@ class DokoGenApp: '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(): @@ -490,6 +512,7 @@ class DokoGenApp: 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']})"] @@ -527,6 +550,13 @@ class DokoGenApp: lines.append(f" {label}: {v}") lines.append("\nНажмите «✓ Применить», чтобы подставить данные") self._verify_log('\n'.join(lines)) + # Общий лог с ответом сервиса + 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 @@ -1000,6 +1030,13 @@ class DokoGenApp: 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") @@ -2057,8 +2094,32 @@ class DokoGenApp: 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" @@ -2066,10 +2127,50 @@ class DokoGenApp: "Генератор документов из шаблонов") def show_variables(self): - text = "Переменные шаблонов:\n\n" - for var, group, desc, _ in VARIABLE_DEFS: - text += f"{var} — {desc} ({'общ.' if group == 'A' else '152-ФЗ'})\n" - messagebox.showinfo("Переменные шаблонов", text) + """Показ переменных шаблонов — теперь через скачивание 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():