56 Commits
Author SHA1 Message Date
prog1764 9d57dbe0e5 Удалён архив После генерации.rar (перенесён на сервер) 2026-08-03 22:55:04 +04:00
prog1764 12f94d0927 Удалён архив Шаблоны.rar (перенесён на сервер) 2026-08-03 22:55:02 +04:00
kneon223557 d68eef4bbe Загрузить файлы в «/» 2026-08-03 22:45:36 +04:00
prog1764 6ccea72dff fix: _set_app_icon разрывал __init__ (NameError: root is not defined) 2026-08-03 22:29:54 +04:00
prog1764 fb01a14c37 feat: иконка на окне программы (заголовок/панель задач) + в ресурсах exe 2026-08-03 22:02:32 +04:00
prog1764 5efc3a0e99 feat: иконка — 64/128px из нового оригинала (512x512) 2026-08-03 21:59:26 +04:00
prog1764 771bc6987f feat: иконка — 48px из нового оригинала (512) 2026-08-03 21:58:11 +04:00
prog1764 faf675e861 feat: иконка — 48px из отдельного оригинала (все размеры из своих файлов) 2026-08-03 21:56:50 +04:00
prog1764 7ebcf04f63 feat: иконка — каждый размер из отдельного оригинала (16/24/32/48/64/128/256) 2026-08-03 21:56:02 +04:00
prog1764 3134d19089 feat: иконка — все размеры из оригинальных рисунков (16px отдельный, 24-32 из малого, 48-256 из большого) 2026-08-03 21:55:11 +04:00
prog1764 dff3f7b971 feat: иконка — комбинированный ICO (мелкие 16-32 из малого рисунка, крупные 48-256 из большого) 2026-08-03 21:53:56 +04:00
prog1764 307f632363 feat: иконка 1024x1024 (ICO 16-256px) для exe 2026-08-03 21:52:29 +04:00
prog1764 9d156bfc0d feat: иконка приложения (dokogen/icon.ico) подключена к сборке 2026-08-03 21:49:12 +04:00
prog1764 5d7d727ef9 fix: build_exe.bat переписан в ASCII (кириллица в UTF-8 ломала cmd) 2026-08-03 21:30:56 +04:00
prog1764 2c3d25e790 feat: сборка в exe (PyInstaller)
- dokogen.spec: упаковка dictionaries/, data/, словарей pymorphy3,
  скрытые импорты lxml/openpyxl; один файл dist/DokoGen.exe
- build_exe.bat: установка PyInstaller + зависимостей + сборка
2026-08-03 20:35:17 +04:00
prog1764 5c75a72b51 fix: ttk.Checkbutton не принимает опцию font (TclError unknown option) 2026-08-03 20:27:59 +04:00
prog1764 7828684b7d fix: жёлтая подсветка пустых значений в циклах + выборочный Применить
- Циклы (item.*, user_*, алиасы): пустые значения '—'/'' теперь
  подсвечиваются жёлтым маркером (раньше подстановка шла на уровне
  XML простым replace — подсветка терялась)
- Вложенные циклы (users_loop): та же подсветка
- Панель «Проверка данных»: чекбоксы «Подставлять: склонения /
  кр. склон. / адрес / ИНН-КПП-ОГРН / руковод.» — выборочный Применить
- get_short_fio/get_initials: не теряют отчество в сокращённом ФИО
2026-08-03 19:53:08 +04:00
prog1764 1498e56579 fix: инициалы ФИО не теряли отчество
- get_short_fio/get_initials: 'Черник В.Н.' превращалось в 'Черник В.'
  (терялся инициал отчества) — добавлена функция _initial, которая
  сохраняет уже сокращённые инициалы ('В.Н.') как есть
2026-08-03 17:40:04 +04:00
prog1764 a16f5740cb fix: генератор берёт склонения из модели (интерфейса), а не пересчитывает
- {{company_full_name_genitive}} и др. теперь сначала берут готовое
  склонение из company_name_N / short_name_N (то, что пользователь
  подставил через Морфер/ИИ или ввёл вручную)
- пересчёт через pymorphy — только если поле пустое
- раньше документ всегда пересчитывал заново → расхождение с
  интерфейсом
2026-08-03 17:34:04 +04:00
prog1764 eb8801d78e fix: DaData — индекс подставляется в адрес (postal_code)
- DaData отдаёт индекс отдельным полем postal_code, а в адресе его
  не было — теперь добавляем индекс в начало адреса, если его там нет
- если индекс уже есть — не дублируем
2026-08-03 17:20:47 +04:00
prog1764 fd6fd60507 fix: именительный падеж при веб-склонении (Морфер/ИИ)
- Морфер и ИИ не всегда возвращают именительный падеж (он равен
  исходному слову) — теперь подставляем его сами
- табло результатов склонения показывает ВСЕ 6 падежей, включая
  именительный (раньше показывал только Р,Д,В,Т,П)
2026-08-03 16:59:28 +04:00
prog1764 17a99627bc fix: табы больше не съедаются при обработке циклов
- _normalize_loop_markers при склейке run'ов выбрасывал <w:tab/>
- теперь табы превращаются в символ \t при сборе и восстанавливаются
  как <w:tab/> при пересборке параграфа
2026-08-03 16:16:22 +04:00
prog1764 d02e2ac9a0 fix: дубль роли в подписи комиссии
- {{item.signature}} больше НЕ содержит роль (роль выводится
  отдельной колонкой {{item.role}}) — иначе «Председатель комиссии |
  Председатель комиссии: ___»
- signature = «___________ / » (только подчёркивание и слэш)
- signature_full по-прежнему полная: «Роль: ___________ / И.О. Фамилия»
2026-08-03 16:12:48 +04:00
prog1764 26b51b53ce fix: незаменённые переменные снова подсвечиваются красным
- при пересборке параграфа незаменённая переменная оставалась
  в тексте (раньше выбрасывалась) → красный маркер снова работает
2026-08-03 16:08:59 +04:00
prog1764 0929f2c551 fix: добавлен import sys в ui.py (NameError при запуске) 2026-08-03 15:58:35 +04:00
prog1764 7c88351252 feat: вложенный цикл users_loop — все пользователи ИС включая администратора
- <!-- loop:users_loop --> внутри цикла ИС: {{user_fio}}, {{user_position}}
- администратор НЕ исключается (как просил Костя), убираются только
  дубли по ФИО (нормализованное сравнение)
2026-08-03 15:20:11 +04:00
prog1764 208e449953 feat: сравнение полученных значений с введёнными
- после проверки DaData/ИИ программа сравнивает результат с тем,
  что заполнено в программе
- серьёзные расхождения выводятся КРАСНЫМИ БОЛЬШИМИ буквами:
  «ВНИМАНИЕ! НАЙДЕНЫ РАСХОЖДЕНИЯ: Руководитель (ФИО): было
  «Иванов И.И.», стало «Петров П.П.»»
- регистр/пунктуация (ИВАНОВ.И.И. vs Иванов И.И.) НЕ считаются
  расхождением; ИНН/КПП/ОГРН — любое отличие цифр = ошибка
- расхождения также пишутся в лог
2026-08-03 14:35:28 +04:00
prog1764 50d3afdbf2 feat: логи, фикс табов, справка с инструкциями
1) Логи: все действия пишутся в dokogen.log (импорт, генерация,
   обращения к Морфер/DaData/ИИ с ответами), кнопка «Экспорт логов»
   на вкладке «Лог»
2) Табы: при пересборке параграфа табуляция сохраняется как <w:tab/>
   (раньше Word «съедал» табы между «_____» и датой)
3) Справка: инструкция пользователя (циклы, программа), инструкция
   администратора (словари, API-ключи, лимиты), справочник переменных,
   пустой опросник Excel — всё скачивается в Word/Excel
2026-08-03 14:32:59 +04:00
prog1764 01d317eab1 feat: импорт «Описание ИС» из опросника
- новая строка 8 «Описание ИС» на листах ИС → {{item.description}}
- значение кладётся напрямую в description (без префикса «Описание ИС:»)
2026-08-03 14:07:01 +04:00
prog1764 e7b1af3f05 fix: {{item.signature}} — рамка без ФИО (дубль с {{item.fio_short}})
- раньше signature содержала ФИО внутри: при шаблоне
  {{item.signature}}{{item.fio_short}} получалось «Черник В.Н.Черник В.Н.»
- теперь: signature = «Роль: ___________ / » (только рамка),
  signature_full = полная подпись «Роль: ___________ / И.О. Фамилия»
  для тех, кто использует одну переменную
2026-08-03 14:03:39 +04:00
prog1764 fc374a4974 fix: категории ПДн — {{category}} берётся из галочек
- раньше переменная читала отдельное поле category (пустое при
  работе с галочками) → в документ вставлялось «—»
- теперь: ', '.join(personal_data_category) — галочки «иные»,
  «специальные» и т.д. попадают в документ
2026-08-03 13:59:02 +04:00
prog1764 0b540f093b fix: проверка информации — 4 замечания
1) ИИ универсален: вместо DeepSeek — «ИИ» (OpenAI-совместимый:
   DeepSeek, ChatGPT и др.). В настройках: API-ключ, модель, базовый URL
2) склонение проверяет И полное, И краткое название (Морфер и ИИ)
3) ИИ-проверка реквизитов показывает снимок полей (статус каждого),
   замечания и предложения — а не просто «ОК»
4) DaData: регистр нормализуется (КАПС → обычный), адрес (г., ул., д.),
   дата ОГРН из миллисекунд → 09.02.2005
- совместимость со старым api_settings.json (deepseek_* → ai_*)
2026-08-03 13:29:09 +04:00
prog1764 9d2690e3e0 fix: 6 замечаний по генератору
1) нормализация адреса: «Р-Н Крымский» → «р-н. Крымский»,
   «Г Крымск» → «г. Крымск», «УЛ» → «ул.», «Д.» → «д.»
2) убрана строчка «ПО» на вкладке ИС
3) СЗИ без категорий: «Kaspersky, КриптоПро» (через запятую)
4) кнопка «Выбрать из списка» в подвкладке «Пользователи ИС»
5) бумажные документы: «хранятся в сейфе» (авто-предлог+падеж)
6) незаполненные данные «—» подсвечиваются жёлтым (незаменённые
   переменные — красным, как было)
2026-08-03 13:23:52 +04:00
prog1764 2f4a08b2e4 feat: проверка данных через Морфер/DaData/DeepSeek (ИИ)
- правая панель на вкладке «Организация»: проверка склонений
  (Морфер 3.0 — бесплатно, без ключа; DeepSeek — ИИ) и реквизитов
  (DaData по ИНН; DeepSeek — ИИ-рецензия)
- мини-табло результата: источник, дисклеймер для ИИ («сгенерировано
  ИИ — перепроверьте»), кнопки «Применить»/«Очистить»
- меню «Файл → Настройки…»: вкладка API-ключей (DaData, DeepSeek,
  модель), сохраняются в api_settings.json рядом с программой
- веб-проверки показывают источник (morpher.ru / dadata.ru)
2026-08-03 12:59:51 +04:00
prog1764 3a4cd2fde4 fix: нормализация КАПСа по словам (город + должности)
- раньше: если в тексте есть аббревиатура (ООО, И.О.) или цифры
  (индекс в адресе) — весь текст оставался КАПСОМ
- теперь: каждое слово нормализуется отдельно, аббревиатуры
  (АРМ, СКУД, ООО, ИНН и т.п.) и инициалы сохраняются
- город: '350000, Г. КРАСНОДАР...' → 'Г. Краснодар' → город
  снова определяется при импорте
- должность: 'ГЛАВА' → 'Глава', ФИО: 'ИВАНОВ ИВАН ИВАНОВИЧ' → 'Иванов Иван Иванович'
2026-08-03 12:45:10 +04:00
prog1764 4fca29e7d6 fix: структура ИС — чтение и из колонки B, и из той же ячейки после ':'
- если значение записано в B7 — берём оттуда
- если в A7 «Структура ИС: АРМ 2 шт.» — извлекаем после двоеточия
2026-08-03 12:39:42 +04:00
prog1764 12817588ff fix: ЛВС не импортировалась в модель ИС (галочка терялась)
- is_local_network=is_data.get('is_has_lan') при создании ИС из импорта
- проверено: АРМ №1 → ЛВС=да, Интернет=да
2026-08-03 12:33:02 +04:00
prog1764 1faf6196af feat: вкладка «Бумажные документы» + импорт из опросника
- Новая вкладка: название документа + место хранения, добавить/удалить
- Импорт из опросника: «Названия документов» (строка 61) и «Место
  хранения» (строка 62) → цикл {{paper_documents}} ({{document}}/{{storage}})
- Сохранение/автосохранение/генерация собирают данные вкладки
- Загрузка файла проекта восстанавливает список документов
2026-08-03 12:27:32 +04:00
prog1764 353e7403d0 fix: импорт под новый формат опросника 5.25
- clean_value не портит аббревиатуры/номера (АРМ №1, СКУД)
- ПО: 'Названия программ...' → {{item.software}}
- 'локальную сеть' → is_has_lan (поле is_local_network)
- Цель обработки ПД — значение из объединённой ячейки (val_b)
- Роли комиссии ищутся и в колонке E, и в Примечании (D)
- Проверено на присланном опроснике: КПП, подразделение по
  безопасности, действия с ПДн, структура ИС — импортируются
2026-08-03 12:22:49 +04:00
prog1764 79952bf735 feat: настройки импорта вынесены в текстовый файл-словарь
- import_settings.json рядом с программой (создаётся автоматически):
  что импортировать (var), откуда (cell: 'Общие сведения'!B2 или
  keywords: регэксп поиска по колонке A)
- importer.py читает словарь: сначала конкретная ячейка, потом ключевые слова
- листы общих сведений/сотрудников/ИС тоже настраиваются (ключевые слова)
- UI: меню «Генерация → Файл настроек импорта…» — выбор пути к словарю
- при сборке .exe файл остаётся редактируемым без пересборки
2026-08-03 12:09:13 +04:00
prog1764 9998410dd6 feat: импорт новых переменных из опросника 152-ФЗ
- {{security_department}} — наименование структурного подразделения
  по безопасности (из поля 'структурное подразделение')
- {{item.pd_actions}} — действия с ПДн в цикле ИС
- {{item.structure}} — структура ИС импортируется из листов ИС
- КПП уже импортировался ({{company_kpp}})
- UI: применение подразделения, структуры и действий при импорте
2026-08-03 12:00:57 +04:00
prog1764 ee76fabdf6 feat: поддержка синтаксиса {{item_поле}} (без точки) в циклах
Работают оба варианта: {{item.fio_initials}} и {{item_fio_initials}},
{{item.fio_short}} и {{item_fio_short}}.
Проверено: И.О. Фамилия -> В.Н. Черник, Фамилия И.О. -> Черник В.Н.
2026-08-03 11:20:20 +04:00
prog1764 c5f03164c8 feat: недостающие переменные из инструкции 152-ФЗ
- {{item.fio_initials}} (И.О. Фамилия) и {{item.fio_short}} (Фамилия И.О.)
  для комиссии
- {{item.software}} (ПО) для ИС + поле «ПО» в интерфейсе
- модель: поле software у InformationSystem
2026-08-03 11:17:20 +04:00
prog1764 4b4fcbc59d fix: убраны пустые строки между итерациями цикла + сохранены разрывы страниц
Word оставляет пустые параграфы и обрывки вокруг маркеров <!-- loop:... -->.
При размножении цикла они копировались между строками (комиссия слипалась
с пустыми строками). Теперь:
- template разделяется на prefix/core/suffix, пустые параграфы в core чистятся
- остатки маркеров удаляются после обработки (не трогая таблицы и page breaks)
- разрыв страницы из параграфа loop_end выносится между итерациями

Проверено: комиссия без пустых строк, акт 6 с 2 ИС — 2 разрыва страниц, XML валиден.
2026-08-03 11:14:10 +04:00
prog1764 24956cd27c feat: переменная подписи {{item.signature}} в цикле комиссии
Председатель комиссии: ___________ / А.А. Емельянов
Члены комиссии: _________________ / К.Е. Кузина
_________________ / Т.Б. Ахаева
2026-08-03 10:58:15 +04:00
prog1764 47630dbbfb feat: переменная «Структура ИС» (structure) в цикле information_systems
- models.py: поле structure у InformationSystem
- variables.py: {{item.structure}} / {{structure}} в цикле,
  {{is_structure_N}} в индексированных
- ui.py: поле «Структура ИС» на вкладке ИС
2026-08-03 10:56:35 +04:00
prog1764 577bed22d6 feat: клавиши (Ctrl/Alt/Del) в исключения + подсветка маркером только переменной
- IGNORED_OLD_VARS: <Ctrl>, <Alt>, <Del> и др. клавиши больше не
  считаются незаменёнными переменными (7.6 Инструкция Пользователя)
- Подсветка: вместо красного цвета текста — красный маркер
  (w:highlight) на самой переменной, а не на всём абзаце
- OLD_VAR_MAP: ShortCompanyName1-6 (склонения краткого названия),
  IspdnName → {{is_name_1}}
2026-08-03 10:53:59 +04:00
prog1764 f5e9d3523d feat: группировка комиссии — председатель отдельно, члены без повтора роли
- Председатель/Секретарь/Заместитель — своей ролью
- Первый обычный член получает «Члены комиссии», остальные — пустая роль
- Пустая строка в цикле больше не превращается в «—»
Проверено: 1 председатель + 4 члена в шаблоне «6. Акт...»
2026-08-03 10:34:05 +04:00
prog1764 65480ea5ed fix: сохранять разрыв страницы (w:br type=page) при развороте циклов
Word хранит перенос страницы отдельным run в параграфе с <!-- loop_end -->.
Нормализация раньше удаляла его при склейке текста — акты слипались.
Теперь page break сохраняется и копируется в каждую итерацию цикла.
Проверено: 2 ИС -> 2 разрыва страниц, XML валиден.
2026-08-03 10:29:10 +04:00
prog1764 f4306f130e fix: заменять переменные без префикса item. внутри циклов
Word разбивает на run'ы не только маркеры циклов, но и переменные
({{name}}, {{number}}, {{pd_list}} и т.д.). Нормализация теперь
склеивает все параграфы с {{...}}, а размножение цикла подставляет
и {{item.field}}, и {{field}} (шаблон '0. Акт обследования.docx')
2026-08-03 10:12:09 +04:00
prog1764 ab96991a72 fix: Word разбивает маркеры циклов/переменные на run'ы + адрес ИС
- _normalize_loop_markers: склейка текста параграфа в один run перед
  поиском <!-- loop:... --> и {{item.*}}, иначе циклы не разворачивались
  (шаблон '6. Акт определения уровня защищенности ПДн.docx')
- variables.py: поле address в цикле information_systems
2026-08-03 10:08:45 +04:00
prog1764 28ac7f69ab fix: пустой временный файл при отсутствии циклов в шаблоне (Package not found)
expand_loops_in_zip возвращал путь к пустому tmp-файлу, если в шаблоне
не было маркеров <!-- loop:... -->. python-docx падал с
'Package not found'. Теперь при отсутствии циклов возвращается
исходный шаблон, tmp удаляется. Тесты: без циклов, с циклом,
вложенные циклы — всё ок.
2026-08-03 09:28:45 +04:00
prog1764 12df5f106c Revert "feat: import_mapper + кнопка в UI"
This reverts commit dd909792d4.
2026-08-03 09:15:28 +04:00
prog1764 dd909792d4 feat: import_mapper + кнопка в UI 2026-07-31 00:39:07 +04:00
prog1764 44b4060021 fix: unpack 4→3 в _employee_entries (ui.py) 2026-07-31 00:36:57 +04:00
prog1764 66753f6607 merge: рабочий генератор из rewrite + тест пройден 2026-07-31 00:35:52 +04:00
19 changed files with 2824 additions and 150 deletions
+43
View File
@@ -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
+63
View File
@@ -0,0 +1,63 @@
# -*- mode: python ; coding: utf-8 -*-
"""DokoGen spec для PyInstaller.
Сборка: py -m PyInstaller --clean --noconfirm dokogen.spec
Результат: dist/DokoGen.exe (один файл, запускается на любом Windows без Python).
"""
import sys
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
# ---------- Данные приложения ----------
datas = [
# Справочники (defense_tools.json, pd_actions.json, pd_items.json, pd_categories.json)
('dokogen/dictionaries', 'dokogen/dictionaries'),
# Пустой опросник 152-ФЗ (для «Справка → Пустой опросник»)
('dokogen/data', 'dokogen/data'),
# Иконка приложения (для окна программы в exe)
('dokogen/icon.ico', 'dokogen'),
]
# ---------- Словари pymorphy3 (обязательно!) ----------
# Без них склонение организаций/ФИО в exe не будет работать.
datas += collect_data_files('pymorphy3_dicts_ru')
datas += collect_data_files('pymorphy3')
# ---------- Скрытые импорты ----------
hiddenimports = collect_submodules('pymorphy3') + collect_submodules('pymorphy3_dicts_ru')
hiddenimports += [
'lxml', 'lxml.etree', 'lxml._elementpath',
'openpyxl', 'openpyxl.cell._writer',
]
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='DokoGen',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # GUI-приложение, без консоли
disable_windowed_traceback=False,
icon='dokogen/icon.ico',
)
+70
View File
@@ -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
Binary file not shown.
+15 -4
View File
@@ -196,15 +196,26 @@ def _is_quoted(word: str) -> bool:
# ==================== ФОРМАТИРОВАНИЕ ФИО ====================
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 = parts[1][0] + "."
initials = _initial(parts[1])
if len(parts) >= 3:
initials += parts[2][0] + "."
initials += _initial(parts[2])
return parts[0] + " " + initials
return fio
@@ -215,8 +226,8 @@ def get_initials(fio: str) -> str:
return ""
parts = fio.split()
if len(parts) >= 2:
initials = parts[1][0] + "."
initials = _initial(parts[1])
if len(parts) >= 3:
initials += parts[2][0] + "."
initials += _initial(parts[2])
return initials + " " + parts[0]
return fio
Binary file not shown.
+477 -45
View File
@@ -30,6 +30,17 @@ 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 = {
'<Ctrl>', '<Alt>', '<Del>', '<Delete>', '<Shift>', '<Enter>', '<Tab>',
'<Esc>', '<Escape>', '<Insert>', '<Home>', '<End>', '<PageUp>', '<PageDown>',
'<Backspace>', '<Space>', '<CapsLock>', '<NumLock>', '<ScrollLock>',
'<PrintScreen>', '<Pause>', '<Break>', '<Up>', '<Down>', '<Left>', '<Right>',
'<F1>', '<F2>', '<F3>', '<F4>', '<F5>', '<F6>', '<F7>', '<F8>', '<F9>',
'<F10>', '<F11>', '<F12>',
}
LOOP_START = re.compile(r'(?:&lt;!--|<!--)\s*loop:(\w+)\s*(?:--&gt;|-->)')
LOOP_END = re.compile(r'(?:&lt;!--|<!--)\s*loop_end\s*(?:--&gt;|-->)')
@@ -61,6 +72,14 @@ OLD_VAR_MAP = {
'<DocumentNumber>': '{{document_number}}',
'<DocumentDate>': '{{document_date}}',
'<Date>': '{{date}}',
# Краткое наименование по падежам (старые шаблоны)
'<ShortCompanyName1>': '{{company_short_name}}',
'<ShortCompanyName2>': '{{company_short_name_genitive}}',
'<ShortCompanyName3>': '{{company_short_name_dative}}',
'<ShortCompanyName4>': '{{company_short_name_accs}}',
'<ShortCompanyName5>': '{{company_short_name_ablt}}',
'<ShortCompanyName6>': '{{company_short_name_loct}}',
'<IspdnName>': '{{is_name_1}}',
}
@@ -68,6 +87,285 @@ OLD_VAR_MAP = {
# 2. ОБРАБОТКА ЦИКЛОВ НА УРОВНЕ ZIP/XML
# ============================================================
W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def _normalize_loop_markers(doc_xml: str) -> str:
"""Word разбивает маркеры <!-- loop:... --> и переменные {{item.x}}
на несколько run'ов (например '<!-- ' в одном <w:t>, 'loop:info' в другом,
'rmation -->' в третьем). Склеиваем текст каждого параграфа в один 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'ам),
# табы <w:tab/> превращаем в символ \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 с полным текстом параграфа, восстанавливая табы <w:tab/>
new_r = etree.SubElement(p, f'{{{W_NS}}}r')
if rpr is not None:
new_r.append(rpr)
# Разбиваем по \t: обычный текст → <w:t>, таб → <w:tab/>
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 оставляет их вокруг маркеров <!-- loop:... -->, из-за чего
при размножении цикла между строками появляются пустые строки."""
para_re = re.compile(r'<w:p\b[^>]*?(?:/>|>.*?</w:p>)', re.S)
def _is_empty(p: str) -> bool:
# Не пустой, если есть разрыв страницы, рисунок, таблица и т.п.
if '<w:br' in p or '<w:drawing' in p or '<w:tbl' in p or '<w:object' in p:
return False
texts = re.findall(r'<w:t[^>]*>(.*?)</w:t>', 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-фрагменте. Таблицы считаются одним элементом.
Обрывки (незакрытые <w:p в конце фрагмента) не включаются —
они относятся к параграфу маркера конца (suffix)."""
ranges = []
i = 0
n = len(fragment)
while i < n:
if fragment.startswith('<w:tbl>', i) or fragment.startswith('<w:tbl ', i):
j = i
d = 0
while j < n:
if fragment.startswith('<w:tbl>', j) or fragment.startswith('<w:tbl ', j):
d += 1
j += 5
elif fragment.startswith('</w:tbl>', j):
d -= 1
j += 8
if d == 0:
break
else:
j += 1
ranges.append((i, j))
i = j
elif fragment.startswith('<w:p>', i) or fragment.startswith('<w:p ', i):
j = fragment.find('</w:p>', 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 — начало параграфа маркера конца (до <!--)."""
ranges = _top_level_ranges(template)
if not ranges:
return '', template, ''
prefix = template[:ranges[0][0]]
suffix = template[ranges[-1][1]:]
core = template[ranges[0][0]:ranges[-1][1]]
return prefix, core, suffix
def _cleanup_marker_paras(doc_xml: str) -> str:
"""Удаляет параграфы-остатки маркеров циклов: <w:p>...<w:r><w:t></w:t></w:r></w:p>
(пустой run — бывший маркер <!-- loop:... -->). Намеренные пустые строки
(<w:p/> без 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 с <w:highlight w:val="yellow"/>, чтобы
подсветка не затиралась при подстановке на уровне XML (циклы).
"""
token = '{{%s}}' % var
if val not in ('', ''):
return xml.replace(token, val)
hl_run = ('<w:rPr><w:highlight w:val="yellow"/></w:rPr>'
'<w:t xml:space="preserve">%s</w:t>' % val)
return xml.replace(token,
'</w:t></w:r><w:r>' + hl_run
+ '</w:r><w:r><w:t xml:space="preserve">')
def _expand_nested_loops(template: str, item: Dict) -> str:
"""Разворачивает вложенные циклы вида <!-- loop:users_loop --> ... <!-- loop_end -->
данными из 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 — гарантирует корректную вложенность.
@@ -86,6 +384,7 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.docx')
tmp.close()
wrote = False # была ли хоть одна запись в tmp
max_passes = 30
for _ in range(max_passes):
# Читаем ВСЕ файлы из исходного ZIP
@@ -101,6 +400,9 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
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))
@@ -144,32 +446,90 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
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 = template
template_clean = core
# Разрыв страницы рядом с маркером конца цикла (Word хранит его
# отдельным run в параграфе с <!-- loop_end -->). Если он есть —
# каждый акт/блок цикла должен начинаться с новой страницы.
probe = doc_xml[max(0, outer_end.start() - 800):outer_end.end()]
has_page_break = '<w:br w:type="page"/>' in probe
# Если разрыв живёт в «хвосте» (параграф loop_end) — выносим его
# между итерациями, а из хвоста убираем (иначе акты слипаются).
br_in_suffix = has_page_break and '<w:br w:type="page"/>' in suffix
if br_in_suffix:
suffix = re.sub(r'<w:br\b[^>]*?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():
clone = clone.replace('{{item.%s}}' % field, str(value) if value else '')
clone = clone.replace('{{item.number}}', str(idx))
clone = clone.replace('{{number}}', str(idx))
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('<w:p><w:r><w:br w:type="page"/></w:r></w:p>')
# Собираем новый XML
new_xml = doc_xml[:outer.start()] + ''.join(expanded_parts) + doc_xml[outer_end.end():]
# Собираем новый 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
# Финальная зачистка: пустые параграфы-остатки маркеров (<!-- loop:... -->)
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
@@ -178,32 +538,81 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
# 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 с текстом, сохраняя табуляцию как <w:tab/>.
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, если были замены."""
"""Заменяет {{var}} в параграфе. Возвращает True, если были замены.
Значения «—» (незаполненная информация) подсвечиваются жёлтым маркером.
Табуляция в параграфе сохраняется (<w:tab/>).
"""
full = para.text
matches = VAR_PATTERN.findall(full)
matches = list(VAR_PATTERN.finditer(full))
if not matches:
return False
new_text = full
for var in matches:
# Собираем параграф заново: обычный текст + значения переменных
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 val is not None:
new_text = new_text.replace(var, str(val))
if new_text == full:
return False
# Сохраняем форматирование первого run
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)
return True
# текст до переменной
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):
@@ -249,42 +658,63 @@ def _strip_prooferr(doc):
def _highlight_unmatched(doc):
"""Подсвечивает незаменённые переменные красным."""
"""Выделяет незаменённые переменные красным маркером (только саму переменную)."""
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def _highlight_run(run):
rpr = run._element.find(f'{{{ns}}}rPr')
def _apply_highlight(run_el):
rpr = run_el.find(f'{{{ns}}}rPr')
if rpr is None:
rpr = OxmlElement('w:rPr')
run._element.insert(0, rpr)
color = OxmlElement('w:color')
color.set(f'{{{ns}}}val', 'FF0000')
rpr.append(color)
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:
for run in para.runs:
if UNMATCHED_PATTERN.search(run.text or ''):
_highlight_run(run)
_process_para(para)
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
for run in para.runs:
if UNMATCHED_PATTERN.search(run.text or ''):
_highlight_run(run)
_process_para(para)
for section in doc.sections:
for hf in [section.header, section.footer]:
if hf:
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:
for run in para.runs:
if UNMATCHED_PATTERN.search(run.text or ''):
_highlight_run(run)
_process_para(para)
def _log_unmatched(doc, replacements=None, log_func=None):
"""Логирует незаменённые переменные."""
"""Логирует незаменённые переменные (без клавиш-исключений)."""
if not log_func:
return
unmatched = []
@@ -292,6 +722,8 @@ def _log_unmatched(doc, replacements=None, log_func=None):
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)
+281
View File
@@ -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. Переменная подставляется в текст '
'как есть: {{имя_переменной}}. Циклы: <!-- loop:имя --> ... <!-- loop_end -->.')
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, '<!-- loop:information_systems --> ... {{item.name}} ... <!-- loop_end -->', 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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+209
View File
@@ -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": "телефон"
}
]
}
+174
View File
@@ -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 ''
+204 -74
View File
@@ -12,6 +12,90 @@ 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:
@@ -30,26 +114,28 @@ def clean_value(val):
text = str(val).strip()
text = text.replace('\n', ' ').replace('\r', ' ')
text = ' '.join(text.split())
# Приведение регистра: если текст содержит только заглавные буквы РУ
if len(text) > 4 and any('А' <= c <= 'Я' or c == 'Ё' for c in text):
# Если весь текст КАПСОМ (нет строчных букв) — нормализуем по словам
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:
words = text.split()
if len(words) >= 3:
text = text.title()
else:
text = text.capitalize()
text = _normalize_caps(text)
return text
def import_152fz(filepath: str, log_fn: Optional[Callable] = None) -> dict:
"""Импорт данных из опросного листа Excel (152-ФЗ)."""
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 = {
@@ -68,9 +154,9 @@ def import_152fz(filepath: str, log_fn: Optional[Callable] = None) -> dict:
return data
try:
_import_152_common(wb, data, log_fn)
_import_152_is_list(wb, data, log_fn)
_import_152_employees(wb, data, log_fn)
_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())
@@ -84,75 +170,92 @@ def import_152fz(filepath: str, log_fn: Optional[Callable] = None) -> dict:
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, log_fn):
"""Импорт общих сведений из листа 'Общие сведения'."""
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 'общие' in sn.lower():
if sheet_kw in sn.lower():
sheet_name = sn
break
if not sheet_name:
log_fn("⚠ Лист 'Общие сведения' не найден")
log_fn(f"⚠ Лист по ключу '{sheet_kw}' не найден")
return
ws = wb[sheet_name]
field_map = [
('полное наименование организации', 'fullName'),
('сокращенное наименование организации', 'shortName'),
('адрес организации \\(юридический\\)', 'addressLegal'),
('адрес организации \\(фактический\\)', 'addressActual'),
('инн', 'inn'),
('дата.*огрн', 'ogrnDate'),
('огрн', 'ogrn'),
('кпп', 'kpp'),
('основной оквэд', 'okved'),
('должность руководителя', 'chiefPosition'),
('фио руководителя', 'chiefFio'),
('должность.*защит.*информ|должность.*администрат', 'ispdnPosition'),
('фио.*защит.*информ|фио.*администрат', 'ispdnFio'),
('электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист', 'ispdnEmail'),
('телефон.*защит|телефон.*ответств|телефон.*специалист', 'ispdnPhone'),
('структур.*подраздел', 'ispdnDepartment'),
('номер.*договора|номер.*контракта', 'contractNumber'),
('дата.*договора|дата.*контракта', 'contractDate'),
('названи.*документ', 'paperDocuments'),
('место.*хранен', 'paperStorage'),
('должность.*ответственн', 'responsiblePosition'),
('фио.*ответственн', 'responsibleFio'),
('должность.*администрат.*(?:безопас|защит|пд)', 'administratorPosition'),
('фио.*администрат.*(?:безопас|защит|пд)', 'administratorFio'),
('сайт организации', 'site_name'),
('должность ответственного за сайт|должность.*отв.*сайт', 'site_responsible_position'),
('фио.*отв.*сайт|фио.*сайт', 'site_responsible_fio'),
('наименование организации.*разрабат.*сайт|наименование.*размещала.*сайт|разрабат.*сайт.*организац', 'site_service_provider'),
('инн.*разрабат.*сайт|инн.*размещала.*сайт', 'site_service_provider_inn'),
('размещение сайта|адрес расположения серверов|адрес.*сервер.*сайт', 'site_hosting'),
('адрес.*хостинг|адрес.*располож.*сервер', 'site_hosting_address'),
('электронная почта', 'email'),
('телефон', 'phone'),
]
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 ''
field = field_raw.lower()
value = clean_value(row[1]) if len(row) > 1 else ''
if not field:
if not field_raw:
continue
for keyword, key in field_map:
if re.search(keyword, field):
if value:
data[key] = value
log_fn(f" {keyword}: {value}")
break
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, log_fn):
def _import_152_is_list(wb, data, settings, log_fn):
"""Импорт информационных систем из остальных листов."""
is_sheets = [sn for sn in wb.sheetnames if sn not in ('Общие сведения', 'Сотрудники')]
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
@@ -195,6 +298,22 @@ def _import_152_is_list(wb, data, log_fn):
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
@@ -216,6 +335,11 @@ def _import_152_is_list(wb, data, log_fn):
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:
@@ -245,8 +369,8 @@ def _import_152_is_list(wb, data, log_fn):
if 'цель обработки' in field_lower:
current_section = 'purpose'
elif current_section == 'purpose':
purpose_val = field or val_b or ''
if purpose_val and 'выбрать' not in purpose_val.lower():
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'
@@ -277,7 +401,8 @@ def _import_152_is_list(wb, data, log_fn):
matched_label = label
break
if matched_label and val_b and 'выбрать' not in field_lower:
is_obj['defense_tools'].append(f"{matched_label}: {val_b}")
# Без префикса категории: просто название средства (Kaspersky, КриптоПро)
is_obj['defense_tools'].append(val_b)
elif matched_label:
pass
elif 'куда' in field_lower or 'передаются' in field_lower:
@@ -322,15 +447,16 @@ def _import_152_is_list(wb, data, log_fn):
log_fn(f"\n✅ Всего импортировано ИС: {len(is_list)}")
def _import_152_employees(wb, data, log_fn):
"""Импорт комиссии и сотрудников из листа 'Сотрудники'."""
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 'сотруд' in sn.lower():
if sheet_kw in sn.lower():
sheet_name = sn
break
if not sheet_name:
log_fn("⚠ Лист «Сотрудники» не найден")
log_fn(f"⚠ Лист по ключу '{sheet_kw}' не найден")
return
ws = wb[sheet_name]
@@ -378,11 +504,15 @@ def _import_152_employees(wb, data, log_fn):
if in_commission:
role = 'Член комиссии'
if col_e:
e_lower = col_e.lower()
for keyword, role_name in COMMISSION_ROLES.items():
if keyword in e_lower:
role = role_name
# Роль ищем в колонках 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
+5
View File
@@ -36,6 +36,8 @@ class InformationSystem:
personal_data_category: List[str] = field(default_factory=list)
purpose: str = ""
room: str = ""
structure: str = "" # Структура информационной системы
software: str = "" # Программное обеспечение
@dataclass
@@ -92,6 +94,9 @@ class Company:
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:
+703 -13
View File
@@ -5,6 +5,7 @@ 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
@@ -17,6 +18,13 @@ from . import (
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:
"""Главное окно приложения."""
@@ -26,6 +34,7 @@ class DokoGenApp:
root.title("ДоКоГеНеРаТоР — 152-ФЗ (Персональные данные)")
root.geometry("1200x800")
root.minsize(900, 600)
self._set_app_icon()
self.model = Company()
self.current_file: Optional[str] = None
@@ -38,6 +47,17 @@ class DokoGenApp:
'.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="")
@@ -62,6 +82,26 @@ class DokoGenApp:
self._load_autosave()
self._start_autosave_timer()
def _set_app_icon(self):
"""Устанавливает иконку приложения на окно (заголовок, панель задач).
Работает и из исходников, и из exe (frozen):
- исходники: dokogen/icon.ico рядом с модулем
- exe: dokogen/icon.ico из _MEIPASS (ресурсы PyInstaller)
"""
try:
if getattr(sys, 'frozen', False):
base = getattr(sys, '_MEIPASS', os.path.dirname(sys.executable))
ico = os.path.join(base, 'dokogen', 'icon.ico')
if not os.path.exists(ico):
ico = os.path.join(os.path.dirname(sys.executable), 'icon.ico')
else:
ico = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'icon.ico')
if os.path.exists(ico):
self.root.iconbitmap(ico)
except Exception:
pass
# ==================== МЕНЮ ====================
def _build_menu(self):
menubar = tk.Menu(self.root)
@@ -78,18 +118,24 @@ class DokoGenApp:
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="Переменные шаблонов", 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):
@@ -111,6 +157,7 @@ class DokoGenApp:
self._build_is_tab()
self._build_commission_tab()
self._build_users_tab()
self._build_paper_tab()
self._build_generation_tab()
self._build_log_tab()
@@ -143,8 +190,11 @@ class DokoGenApp:
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(main, text="Наименование организации", padding=10)
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)
@@ -196,7 +246,7 @@ class DokoGenApp:
decl_frame.columnconfigure(1, weight=1)
# --- Адрес и реквизиты ---
f2 = ttk.LabelFrame(main, text="Адрес и реквизиты", padding=10)
f2 = ttk.LabelFrame(left, text="Адрес и реквизиты", padding=10)
f2.pack(fill=tk.X, pady=5)
addr_fields = [
@@ -221,7 +271,7 @@ class DokoGenApp:
f2.columnconfigure(1, weight=1)
# --- Должностные лица ---
f3 = ttk.LabelFrame(main, text="Должностные лица", padding=10)
f3 = ttk.LabelFrame(left, text="Должностные лица", padding=10)
f3.pack(fill=tk.X, pady=5)
of_fields = [
@@ -240,7 +290,7 @@ class DokoGenApp:
f3.columnconfigure(1, weight=1)
# --- Документ ---
f4 = ttk.LabelFrame(main, text="Документ", padding=10)
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(
@@ -249,6 +299,390 @@ class DokoGenApp:
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)
@@ -333,7 +767,10 @@ class DokoGenApp:
self.is_pd_count_combo.grid(row=6, column=1, padx=10, sticky=tk.W)
self.is_pd_count_combo.bind('<<ComboboxSelected>>', 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)
@@ -392,6 +829,7 @@ class DokoGenApp:
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)
@@ -533,6 +971,102 @@ class DokoGenApp:
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("<Configure>",
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)
@@ -577,6 +1111,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")
@@ -608,6 +1149,10 @@ class DokoGenApp:
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()
@@ -681,7 +1226,7 @@ class DokoGenApp:
# Пользователи
if hasattr(self, '_employee_entries'):
for row_frame, _, _, _ in self._employee_entries:
for row_frame, _, _ in self._employee_entries:
row_frame.destroy()
self._employee_entries.clear()
for emp in c.employees_access:
@@ -691,6 +1236,15 @@ class DokoGenApp:
)
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()
@@ -867,6 +1421,12 @@ class DokoGenApp:
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")
@@ -957,6 +1517,28 @@ class DokoGenApp:
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:
@@ -1138,6 +1720,7 @@ class DokoGenApp:
self._collect_commission()
self._collect_is()
self._collect_employees()
self._collect_paper_documents()
# Проверка
if not self._validate_before_generate():
@@ -1247,6 +1830,20 @@ class DokoGenApp:
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-ФЗ)",
@@ -1260,7 +1857,7 @@ class DokoGenApp:
def _run():
try:
from .importer import import_152fz
data = import_152fz(path, self.log)
data = import_152fz(path, self.log, settings_path=self.import_settings_path)
if data:
self._apply_imported_data(data)
except ImportError:
@@ -1338,6 +1935,10 @@ class DokoGenApp:
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()
@@ -1345,6 +1946,7 @@ class DokoGenApp:
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', []),
@@ -1355,6 +1957,8 @@ class DokoGenApp:
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()
@@ -1387,7 +1991,7 @@ class DokoGenApp:
if data.get('employeesAccess'):
self.model.employees_access.clear()
if hasattr(self, '_employee_entries'):
for row_frame, _, _, _ in self._employee_entries:
for row_frame, _, _ in self._employee_entries:
row_frame.destroy()
self._employee_entries.clear()
for emp in data['employeesAccess']:
@@ -1401,6 +2005,26 @@ class DokoGenApp:
)
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)
@@ -1445,6 +2069,7 @@ class DokoGenApp:
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)
@@ -1470,6 +2095,7 @@ class DokoGenApp:
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:
@@ -1549,8 +2175,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"
@@ -1558,10 +2208,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():
+139 -13
View File
@@ -9,6 +9,12 @@ 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)
# ============================================================
# ОПИСАНИЕ ПЕРЕМЕННЫХ
# ============================================================
@@ -50,6 +56,8 @@ VARIABLE_DEFS = [
('{{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'),
@@ -165,14 +173,42 @@ def build_replacements(
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 in enumerate(members, 1):
role = m.role or 'член комиссии'
line = f"{i}. {role}: {m.position}{m.fio}" if m.position else f"{i}. {role}: {m.fio}"
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 ''
@@ -186,11 +222,12 @@ def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int):
'is_description': isys.description or '',
'is_has_internet': 'да' if isys.is_internet else 'нет',
'is_defence_level': str(isys.defence_level or ''),
'is_category': isys.category 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)
@@ -202,13 +239,39 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
# Комиссия
members = company.commission or []
if members:
display_roles = _commission_role_display(members)
items = []
for m in members:
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': m.role or 'член комиссии',
'position': m.position or '',
'fio': m.fio or '',
'full': f"{m.role} {m.position}{m.fio}" if m.position else f"{m.role}{m.fio}",
'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
@@ -216,32 +279,81 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
if is_list:
items = []
for isys in is_list:
# Пользователи ИС для вложенного цикла <!-- loop:users_loop -->
# ВСЕ пользователи, включая администратора (без дублей по ФИО)
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': isys.category or '',
'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 '',
'defense_tools_list': '; '.join(isys.defense_tools_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': p.storage or '',
'storage': _storage_with_prep(storage), # «в сейфе»
'storage_raw': storage, # «сейф» (как введено)
})
loops['paper_documents'] = items
@@ -280,7 +392,21 @@ def _resolve(source: str, ctx: Dict) -> str:
if source.startswith('decl:'):
_, field, case = source.split(':')
name = getattr(company, field, '') or ''
return dc.company_name_decline(name, case) if name else ''
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:'):
+440
View File
@@ -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