Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d57dbe0e5 | ||
|
|
12f94d0927 | ||
|
|
d68eef4bbe | ||
|
|
6ccea72dff | ||
|
|
fb01a14c37 | ||
|
|
5efc3a0e99 | ||
|
|
771bc6987f | ||
|
|
faf675e861 | ||
|
|
7ebcf04f63 | ||
|
|
3134d19089 | ||
|
|
dff3f7b971 | ||
|
|
307f632363 | ||
|
|
9d156bfc0d | ||
|
|
5d7d727ef9 | ||
|
|
2c3d25e790 | ||
|
|
5c75a72b51 | ||
|
|
7828684b7d | ||
|
|
1498e56579 | ||
|
|
a16f5740cb | ||
|
|
eb8801d78e | ||
|
|
fd6fd60507 | ||
|
|
17a99627bc | ||
|
|
d02e2ac9a0 | ||
|
|
26b51b53ce | ||
|
|
0929f2c551 | ||
|
|
7c88351252 | ||
|
|
208e449953 | ||
|
|
50d3afdbf2 | ||
|
|
01d317eab1 | ||
|
|
e7b1af3f05 | ||
|
|
fc374a4974 | ||
|
|
0b540f093b | ||
|
|
9d2690e3e0 | ||
|
|
2f4a08b2e4 | ||
|
|
3a4cd2fde4 | ||
|
|
4fca29e7d6 | ||
|
|
12817588ff | ||
|
|
1faf6196af | ||
|
|
353e7403d0 | ||
|
|
79952bf735 | ||
|
|
9998410dd6 | ||
|
|
ee76fabdf6 | ||
|
|
c5f03164c8 | ||
|
|
4b4fcbc59d | ||
|
|
24956cd27c | ||
|
|
47630dbbfb | ||
|
|
577bed22d6 | ||
|
|
f5e9d3523d | ||
|
|
65480ea5ed | ||
|
|
f4306f130e | ||
|
|
ab96991a72 | ||
|
|
28ac7f69ab | ||
|
|
12df5f106c | ||
|
|
dd909792d4 | ||
|
|
44b4060021 | ||
|
|
66753f6607 |
@@ -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
|
||||||
@@ -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',
|
||||||
|
)
|
||||||
@@ -6,5 +6,8 @@ from .declension import inflect_name, decline, company_name_decline, get_short_f
|
|||||||
from .variables import build_replacements, VARIABLE_DEFS
|
from .variables import build_replacements, VARIABLE_DEFS
|
||||||
from .generator import process_template
|
from .generator import process_template
|
||||||
|
|
||||||
|
# Импорт из Excel
|
||||||
|
from . import importer
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.0.0"
|
||||||
__app_name__ = "ДоКоГеНеРаТоР"
|
__app_name__ = "ДоКоГеНеРаТоР"
|
||||||
@@ -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.
@@ -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:
|
def get_short_fio(fio: str) -> str:
|
||||||
"""Фамилия И.О."""
|
"""Фамилия И.О."""
|
||||||
if not fio:
|
if not fio:
|
||||||
return ""
|
return ""
|
||||||
parts = fio.split()
|
parts = fio.split()
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
initials = parts[1][0] + "."
|
initials = _initial(parts[1])
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
initials += parts[2][0] + "."
|
initials += _initial(parts[2])
|
||||||
return parts[0] + " " + initials
|
return parts[0] + " " + initials
|
||||||
return fio
|
return fio
|
||||||
|
|
||||||
@@ -215,8 +226,8 @@ def get_initials(fio: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
parts = fio.split()
|
parts = fio.split()
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
initials = parts[1][0] + "."
|
initials = _initial(parts[1])
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
initials += parts[2][0] + "."
|
initials += _initial(parts[2])
|
||||||
return initials + " " + parts[0]
|
return initials + " " + parts[0]
|
||||||
return fio
|
return fio
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
"Антивирусное ПО",
|
||||||
|
"СКЗИ (криптография)",
|
||||||
|
"Межсетевой экран",
|
||||||
|
"СЗИ от НСД",
|
||||||
|
"СОВ (обнаружение вторжений)",
|
||||||
|
"Доверенная загрузка",
|
||||||
|
"SIEM-система",
|
||||||
|
"DLP-система",
|
||||||
|
"VPN",
|
||||||
|
"Система парольной защиты",
|
||||||
|
"Антиспам",
|
||||||
|
"Средства резервного копирования"
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
"Сбор персональных данных",
|
||||||
|
"Запись персональных данных",
|
||||||
|
"Систематизация персональных данных",
|
||||||
|
"Накопление персональных данных",
|
||||||
|
"Хранение персональных данных",
|
||||||
|
"Уточнение (обновление, изменение) персональных данных",
|
||||||
|
"Извлечение персональных данных",
|
||||||
|
"Использование персональных данных",
|
||||||
|
"Передача (распространение, предоставление, доступ) персональных данных",
|
||||||
|
"Обезличивание персональных данных",
|
||||||
|
"Блокирование персональных данных",
|
||||||
|
"Удаление персональных данных",
|
||||||
|
"Уничтожение персональных данных",
|
||||||
|
"Трансграничная передача персональных данных",
|
||||||
|
"Автоматизированная обработка персональных данных",
|
||||||
|
"Неавтоматизированная обработка персональных данных",
|
||||||
|
"Смешанная обработка персональных данных",
|
||||||
|
"Формирование и ведение баз данных",
|
||||||
|
"Передача данных третьим лицам",
|
||||||
|
"Внутренняя передача данных между подразделениями"
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
"работники оператора",
|
||||||
|
"клиенты",
|
||||||
|
"контрагенты",
|
||||||
|
"родственники работников",
|
||||||
|
"соискатели",
|
||||||
|
"иные лица"
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[
|
||||||
|
"Фамилия, имя, отчество",
|
||||||
|
"Дата рождения",
|
||||||
|
"Место рождения",
|
||||||
|
"Паспортные данные",
|
||||||
|
"Адрес регистрации",
|
||||||
|
"Адрес проживания",
|
||||||
|
"ИНН",
|
||||||
|
"СНИЛС",
|
||||||
|
"Номер телефона",
|
||||||
|
"Адрес электронной почты",
|
||||||
|
"Образование",
|
||||||
|
"Профессия",
|
||||||
|
"Сведения о доходах",
|
||||||
|
"Семейное положение",
|
||||||
|
"Состав семьи",
|
||||||
|
"Состояние здоровья",
|
||||||
|
"Национальность",
|
||||||
|
"Гражданство",
|
||||||
|
"Сведения о судимости",
|
||||||
|
"Фотография",
|
||||||
|
"Биометрические данные",
|
||||||
|
"Рабочий e-mail",
|
||||||
|
"Должность",
|
||||||
|
"Табельный номер",
|
||||||
|
"Сведения о воинском учете",
|
||||||
|
"Сведения о командировках"
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,862 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""DokoGen — Генератор документов из шаблонов DOCX (чистая версия)"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import io
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from typing import Dict, List, Optional, Callable, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
try:
|
||||||
|
from docx import Document
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
|
from lxml import etree
|
||||||
|
DOCX_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
Document = None
|
||||||
|
DOCX_AVAILABLE = False
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. ПЕРЕМЕННЫЕ ШАБЛОНОВ И РЕГЕКСЫ
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Паттерны для поиска переменных и маркеров циклов
|
||||||
|
VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}')
|
||||||
|
OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>')
|
||||||
|
UNMATCHED_PATTERN = re.compile(r'\{\{[^}]+\}\}|<[A-Za-z0-9_]+>')
|
||||||
|
|
||||||
|
# «Переменные», которые на самом деле не переменные, а клавиши в тексте
|
||||||
|
# инструкций (Ctrl+Alt+Del и т.п.) — их не считаем незаменёнными
|
||||||
|
IGNORED_OLD_VARS = {
|
||||||
|
'<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'(?:<!--|<!--)\s*loop:(\w+)\s*(?:-->|-->)')
|
||||||
|
LOOP_END = re.compile(r'(?:<!--|<!--)\s*loop_end\s*(?:-->|-->)')
|
||||||
|
|
||||||
|
# Маппинг старых переменных <...> → {{...}}
|
||||||
|
OLD_VAR_MAP = {
|
||||||
|
'<CompanyName1>': '{{company_full_name}}',
|
||||||
|
'<CompanyName2>': '{{company_short_name}}',
|
||||||
|
'<CompanyName1Gent>': '{{company_full_name_genitive}}',
|
||||||
|
'<CompanyName2Gent>': '{{company_short_name_genitive}}',
|
||||||
|
'<CompanyName1Datv>': '{{company_full_name_dative}}',
|
||||||
|
'<CompanyName2Datv>': '{{company_short_name_dative}}',
|
||||||
|
'<CompanyName1Accs>': '{{company_full_name_accs}}',
|
||||||
|
'<CompanyName2Accs>': '{{company_short_name_accs}}',
|
||||||
|
'<CompanyName1Ablt>': '{{company_full_name_ablt}}',
|
||||||
|
'<CompanyName2Ablt>': '{{company_short_name_ablt}}',
|
||||||
|
'<CompanyName1Loct>': '{{company_full_name_loct}}',
|
||||||
|
'<CompanyName2Loct>': '{{company_short_name_loct}}',
|
||||||
|
'<Address>': '{{company_address}}',
|
||||||
|
'<INN>': '{{company_inn}}',
|
||||||
|
'<OGRN>': '{{company_ogrn}}',
|
||||||
|
'<KPP>': '{{company_kpp}}',
|
||||||
|
'<ChiefFio>': '{{chief_fio_initials}}',
|
||||||
|
'<ChiefFioShort>': '{{chief_fio_short}}',
|
||||||
|
'<ChiefPosition>': '{{chief_position}}',
|
||||||
|
'<RspFio>': '{{responsible_fio_initials}}',
|
||||||
|
'<RspFioShort>': '{{responsible_fio_short}}',
|
||||||
|
'<RspPosition>': '{{responsible_position}}',
|
||||||
|
'<CommissionList>': '{{commission_list}}',
|
||||||
|
'<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}}',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 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 — гарантирует корректную вложенность.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
docx_path: путь к шаблону .docx
|
||||||
|
loops: словарь {имя_цикла: [{field: value}, ...]}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
путь к обработанному .docx (временный файл)
|
||||||
|
"""
|
||||||
|
if not loops:
|
||||||
|
return docx_path
|
||||||
|
|
||||||
|
# Временный файл
|
||||||
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.docx')
|
||||||
|
tmp.close()
|
||||||
|
|
||||||
|
wrote = False # была ли хоть одна запись в tmp
|
||||||
|
max_passes = 30
|
||||||
|
for _ in range(max_passes):
|
||||||
|
# Читаем ВСЕ файлы из исходного ZIP
|
||||||
|
all_files = {}
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(docx_path if _ == 0 else tmp.name, 'r') as z:
|
||||||
|
for name in z.namelist():
|
||||||
|
all_files[name] = z.read(name)
|
||||||
|
doc_xml = all_files.get('word/document.xml', b'').decode('utf-8')
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not doc_xml:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Word мог разбить маркеры циклов на несколько run'ов — склеиваем
|
||||||
|
doc_xml = _normalize_loop_markers(doc_xml)
|
||||||
|
|
||||||
|
# Находим ВСЕ маркеры циклов
|
||||||
|
start_markers = list(LOOP_START.finditer(doc_xml))
|
||||||
|
end_markers = list(LOOP_END.finditer(doc_xml))
|
||||||
|
|
||||||
|
if not start_markers:
|
||||||
|
break # нет больше циклов
|
||||||
|
|
||||||
|
# Внешний цикл = первый start (самый ранний в документе)
|
||||||
|
outer = start_markers[0]
|
||||||
|
outer_key = outer.group(1)
|
||||||
|
|
||||||
|
# Соответствующий end = первый end после outer (или последний, если вложенные)
|
||||||
|
# Считаем глубину: каждый start +1, каждый end -1
|
||||||
|
depth = 1
|
||||||
|
outer_end = None
|
||||||
|
all_markers = sorted(
|
||||||
|
[('start', m) for m in start_markers] +
|
||||||
|
[('end', m) for m in end_markers],
|
||||||
|
key=lambda x: x[1].start()
|
||||||
|
)
|
||||||
|
found_start = False
|
||||||
|
for kind, m in all_markers:
|
||||||
|
if m.start() <= outer.start():
|
||||||
|
continue
|
||||||
|
if kind == 'start':
|
||||||
|
depth += 1
|
||||||
|
elif kind == 'end':
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
outer_end = m
|
||||||
|
break
|
||||||
|
|
||||||
|
if outer_end is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
items = loops.get(outer_key, [])
|
||||||
|
if not items:
|
||||||
|
# Удаляем блок цикла полностью
|
||||||
|
doc_xml = doc_xml[:outer.start()] + doc_xml[outer_end.end():]
|
||||||
|
all_files['word/document.xml'] = doc_xml.encode('utf-8')
|
||||||
|
with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for name, data in all_files.items():
|
||||||
|
z.writestr(name, data)
|
||||||
|
wrote = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Извлекаем шаблон (всё между start и end маркерами)
|
||||||
|
template = doc_xml[outer.end():outer_end.start()]
|
||||||
|
|
||||||
|
# Word оставляет обрывки параграфов вокруг маркеров и пустые параграфы.
|
||||||
|
# Отделяем обрывки (prefix/suffix) от тела цикла и чистим пустые
|
||||||
|
# параграфы в теле — иначе между итерациями появляются пустые строки.
|
||||||
|
prefix, core, suffix = _split_template_edges(template)
|
||||||
|
core = _strip_edge_empty_paras(core)
|
||||||
|
|
||||||
|
# НЕ удаляем loop_end из шаблона — они принадлежат вложенным циклам!
|
||||||
|
template_clean = core
|
||||||
|
|
||||||
|
# Разрыв страницы рядом с маркером конца цикла (Word хранит его
|
||||||
|
# отдельным run в параграфе с <!-- 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():
|
||||||
|
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: обрывок параграфа маркера начала (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
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 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, если были замены.
|
||||||
|
|
||||||
|
Значения «—» (незаполненная информация) подсвечиваются жёлтым маркером.
|
||||||
|
Табуляция в параграфе сохраняется (<w:tab/>).
|
||||||
|
"""
|
||||||
|
full = para.text
|
||||||
|
matches = list(VAR_PATTERN.finditer(full))
|
||||||
|
if not matches:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Собираем параграф заново: обычный текст + значения переменных
|
||||||
|
first = para.runs[0] if para.runs else None
|
||||||
|
para.clear()
|
||||||
|
pos = 0
|
||||||
|
changed = False
|
||||||
|
for m in matches:
|
||||||
|
var = m.group(0)
|
||||||
|
val = replacements.get(var)
|
||||||
|
# текст до переменной
|
||||||
|
if m.start() > pos:
|
||||||
|
_add_run_with_tabs(para, full[pos:m.start()], first)
|
||||||
|
if val is None:
|
||||||
|
# Переменная не заменена — оставляем как есть,
|
||||||
|
# красным её подсветит _highlight_unmatched на шаге 7
|
||||||
|
run = para.add_run(var)
|
||||||
|
if first is not None:
|
||||||
|
_copy_run_style(first, run)
|
||||||
|
pos = m.end()
|
||||||
|
continue
|
||||||
|
# значение переменной
|
||||||
|
val_str = str(val)
|
||||||
|
run = para.add_run(val_str)
|
||||||
|
if first is not None:
|
||||||
|
_copy_run_style(first, run)
|
||||||
|
if val_str == '—' or val_str == '':
|
||||||
|
_apply_highlight_color(run._element, 'yellow')
|
||||||
|
changed = True
|
||||||
|
pos = m.end()
|
||||||
|
# хвост после последней переменной
|
||||||
|
if pos < len(full):
|
||||||
|
_add_run_with_tabs(para, full[pos:], first)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_run_style(source, target):
|
||||||
|
"""Копирует форматирование run."""
|
||||||
|
try:
|
||||||
|
target.bold = source.bold
|
||||||
|
target.italic = source.italic
|
||||||
|
target.underline = source.underline
|
||||||
|
if source.font:
|
||||||
|
target.font.size = source.font.size
|
||||||
|
target.font.name = source.font.name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_in_paragraphs(paragraphs, replacements):
|
||||||
|
for para in paragraphs:
|
||||||
|
_replace_text_in_para(para, replacements)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_in_tables(tables, replacements):
|
||||||
|
for table in tables:
|
||||||
|
for row in table.rows:
|
||||||
|
for cell in row.cells:
|
||||||
|
_replace_in_paragraphs(cell.paragraphs, replacements)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_in_headers_footers(doc, replacements):
|
||||||
|
for section in doc.sections:
|
||||||
|
for hf in [section.header, section.footer,
|
||||||
|
section.first_page_header, section.first_page_footer]:
|
||||||
|
if hf:
|
||||||
|
_replace_in_paragraphs(hf.paragraphs, replacements)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_prooferr(doc):
|
||||||
|
"""Удаляет proofErr из docx (красные волнистые линии)."""
|
||||||
|
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
for elem in doc.element.iter():
|
||||||
|
for child in list(elem):
|
||||||
|
if child.tag == f'{{{ns}}}proofErr':
|
||||||
|
elem.remove(child)
|
||||||
|
|
||||||
|
|
||||||
|
def _highlight_unmatched(doc):
|
||||||
|
"""Выделяет незаменённые переменные красным маркером (только саму переменную)."""
|
||||||
|
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
|
||||||
|
def _apply_highlight(run_el):
|
||||||
|
rpr = run_el.find(f'{{{ns}}}rPr')
|
||||||
|
if rpr is None:
|
||||||
|
rpr = OxmlElement('w:rPr')
|
||||||
|
run_el.insert(0, rpr)
|
||||||
|
hl = OxmlElement('w:highlight')
|
||||||
|
hl.set(f'{{{ns}}}val', 'red')
|
||||||
|
rpr.append(hl)
|
||||||
|
|
||||||
|
def _process_para(para):
|
||||||
|
full = para.text
|
||||||
|
matches = [m for m in UNMATCHED_PATTERN.finditer(full)
|
||||||
|
if m.group(0) not in IGNORED_OLD_VARS]
|
||||||
|
if not matches:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Пересобираем параграф: обычный текст и переменные отдельными run'ами,
|
||||||
|
# переменные — с красным маркером. Сохраняем стиль первого run и табы.
|
||||||
|
first = para.runs[0] if para.runs else None
|
||||||
|
para.clear()
|
||||||
|
pos = 0
|
||||||
|
for m in matches:
|
||||||
|
if m.start() > pos:
|
||||||
|
_add_run_with_tabs(para, full[pos:m.start()], first)
|
||||||
|
run = para.add_run(m.group(0))
|
||||||
|
if first is not None:
|
||||||
|
_copy_run_style(first, run)
|
||||||
|
_apply_highlight(run._element)
|
||||||
|
pos = m.end()
|
||||||
|
if pos < len(full):
|
||||||
|
_add_run_with_tabs(para, full[pos:], first)
|
||||||
|
run = para.add_run(full[pos:])
|
||||||
|
if first is not None:
|
||||||
|
_copy_run_style(first, run)
|
||||||
|
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
_process_para(para)
|
||||||
|
|
||||||
|
for table in doc.tables:
|
||||||
|
for row in table.rows:
|
||||||
|
for cell in row.cells:
|
||||||
|
for para in cell.paragraphs:
|
||||||
|
_process_para(para)
|
||||||
|
|
||||||
|
for section in doc.sections:
|
||||||
|
for hf in [section.header, section.footer,
|
||||||
|
section.first_page_header, section.first_page_footer]:
|
||||||
|
if hf is not None:
|
||||||
|
for para in hf.paragraphs:
|
||||||
|
_process_para(para)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_unmatched(doc, replacements=None, log_func=None):
|
||||||
|
"""Логирует незаменённые переменные (без клавиш-исключений)."""
|
||||||
|
if not log_func:
|
||||||
|
return
|
||||||
|
unmatched = []
|
||||||
|
seen = set()
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
for m in UNMATCHED_PATTERN.finditer(para.text):
|
||||||
|
var = m.group(0)
|
||||||
|
if var in IGNORED_OLD_VARS:
|
||||||
|
continue
|
||||||
|
if var not in seen:
|
||||||
|
seen.add(var)
|
||||||
|
unmatched.append(var)
|
||||||
|
if unmatched:
|
||||||
|
log_func(f"⚠️ Незаменённые переменные ({len(unmatched)}): "
|
||||||
|
f"{', '.join(unmatched[:20])}"
|
||||||
|
f"{' ...' if len(unmatched) > 20 else ''}")
|
||||||
|
else:
|
||||||
|
log_func("✅ Все переменные успешно заменены")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 4. МИГРАЦИЯ СТАРЫХ ПЕРЕМЕННЫХ
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _migrate_old_vars(doc):
|
||||||
|
"""Заменяет <OldVar> на {{new_var}}."""
|
||||||
|
def _migrate_text(text):
|
||||||
|
return OLD_VAR_PATTERN.sub(lambda m: OLD_VAR_MAP.get(m.group(0), m.group(0)), text)
|
||||||
|
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
new_text = _migrate_text(para.text)
|
||||||
|
if new_text != para.text:
|
||||||
|
if para.runs:
|
||||||
|
first = para.runs[0]
|
||||||
|
para.clear()
|
||||||
|
run = para.add_run(new_text)
|
||||||
|
_copy_run_style(first, run)
|
||||||
|
else:
|
||||||
|
para.clear()
|
||||||
|
para.add_run(new_text)
|
||||||
|
|
||||||
|
for table in doc.tables:
|
||||||
|
for row in table.rows:
|
||||||
|
for cell in row.cells:
|
||||||
|
for para in cell.paragraphs:
|
||||||
|
new_text = _migrate_text(para.text)
|
||||||
|
if new_text != para.text:
|
||||||
|
if para.runs:
|
||||||
|
first = para.runs[0]
|
||||||
|
para.clear()
|
||||||
|
run = para.add_run(new_text)
|
||||||
|
_copy_run_style(first, run)
|
||||||
|
else:
|
||||||
|
para.clear()
|
||||||
|
para.add_run(new_text)
|
||||||
|
|
||||||
|
for section in doc.sections:
|
||||||
|
for hf in [section.header, section.footer,
|
||||||
|
section.first_page_header, section.first_page_footer]:
|
||||||
|
if hf:
|
||||||
|
for para in hf.paragraphs:
|
||||||
|
new_text = _migrate_text(para.text)
|
||||||
|
if new_text != para.text:
|
||||||
|
if para.runs:
|
||||||
|
first = para.runs[0]
|
||||||
|
para.clear()
|
||||||
|
run = para.add_run(new_text)
|
||||||
|
_copy_run_style(first, run)
|
||||||
|
else:
|
||||||
|
para.clear()
|
||||||
|
para.add_run(new_text)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 5. ОСНОВНАЯ ФУНКЦИЯ ГЕНЕРАЦИИ
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def process_template(
|
||||||
|
template_path: str,
|
||||||
|
replacements: Dict[str, str],
|
||||||
|
output_path: Optional[str] = None,
|
||||||
|
log_func: Optional[Callable] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Обрабатывает шаблон DOCX: подстановка переменных, циклы, сохранение.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_path: путь к файлу шаблона .docx
|
||||||
|
replacements: словарь {переменная: значение}
|
||||||
|
output_path: путь для сохранения результата
|
||||||
|
log_func: функция логирования (str → None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
путь к сгенерированному файлу
|
||||||
|
"""
|
||||||
|
if not DOCX_AVAILABLE:
|
||||||
|
raise ImportError("python-docx не установлен")
|
||||||
|
|
||||||
|
if not os.path.exists(template_path):
|
||||||
|
raise FileNotFoundError(f"Шаблон не найден: {template_path}")
|
||||||
|
|
||||||
|
if output_path is None:
|
||||||
|
base, ext = os.path.splitext(template_path)
|
||||||
|
output_path = f"{base}_result{ext}"
|
||||||
|
|
||||||
|
# ШАГ 1: Обрабатываем циклы на уровне ZIP/XML (до загрузки python-docx)
|
||||||
|
loops = replacements.pop('_loops', {})
|
||||||
|
working_path = template_path
|
||||||
|
if loops:
|
||||||
|
working_path = expand_loops_in_zip(template_path, loops)
|
||||||
|
replacements['_loops'] = loops # возвращаем на всякий случай
|
||||||
|
|
||||||
|
# ШАГ 2: Загружаем через python-docx
|
||||||
|
doc = Document(working_path)
|
||||||
|
|
||||||
|
# ШАГ 3: Миграция старых переменных <...> → {{...}}
|
||||||
|
_migrate_old_vars(doc)
|
||||||
|
|
||||||
|
# ШАГ 4: Убираем proofErr
|
||||||
|
_strip_prooferr(doc)
|
||||||
|
|
||||||
|
# ШАГ 5: Замена переменных (3 прохода)
|
||||||
|
for _ in range(3):
|
||||||
|
_replace_in_paragraphs(doc.paragraphs, replacements)
|
||||||
|
_replace_in_tables(doc.tables, replacements)
|
||||||
|
_replace_in_headers_footers(doc, replacements)
|
||||||
|
|
||||||
|
# ШАГ 6: Логирование незаменённых
|
||||||
|
_log_unmatched(doc, replacements, log_func)
|
||||||
|
|
||||||
|
# ШАГ 7: Подсветка незаполненных
|
||||||
|
_highlight_unmatched(doc)
|
||||||
|
|
||||||
|
# ШАГ 8: Сохранение
|
||||||
|
doc.save(output_path)
|
||||||
|
|
||||||
|
# ШАГ 9: Если использовали временный файл — удаляем
|
||||||
|
if working_path != template_path:
|
||||||
|
try:
|
||||||
|
os.unlink(working_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if log_func:
|
||||||
|
log_func(f"✅ {os.path.basename(output_path)}")
|
||||||
|
return output_path
|
||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -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": "телефон"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 ''
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""DokoGen — Модуль импорта из Excel (опросный лист 152-ФЗ)"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import openpyxl
|
||||||
|
except ImportError:
|
||||||
|
openpyxl = None
|
||||||
|
|
||||||
|
|
||||||
|
from .import_settings import load_settings, parse_cell_ref, cell_value
|
||||||
|
|
||||||
|
|
||||||
|
# Известные аббревиатуры, которые сохраняются КАПСОМ при нормализации
|
||||||
|
_CAPS_ABBR = {
|
||||||
|
'АРМ', 'СКУД', 'СЗИ', 'СКЗИ', 'СОВ', 'ЛВС', 'ИНН', 'КПП', 'ОГРН', 'ЦОД',
|
||||||
|
'МБУ', 'МКУ', 'ООО', 'СНИЛС', 'СБИС', 'ЕМСЭД', 'ФИО', 'ИСПДН', 'ККТ',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_caps(text: str) -> str:
|
||||||
|
"""Приводит КАПС-текст к нормальному регистру по словам.
|
||||||
|
|
||||||
|
Сохраняет:
|
||||||
|
- аббревиатуры из белого списка (АРМ, СКУД, ООО, ИНН и т.п.)
|
||||||
|
- инициалы и короткие служебные слова (И., Г., УЛ., №1)
|
||||||
|
- числа и номера (350000, №1)
|
||||||
|
- слова, уже содержащие строчные буквы
|
||||||
|
Длинные слова целиком заглавными (ДИРЕКТОР, КРАСНОДАР, ПЕТРОВ) → «Директор», «Краснодар».
|
||||||
|
"""
|
||||||
|
words = text.split()
|
||||||
|
result = []
|
||||||
|
for w in words:
|
||||||
|
# отделяем пунктуацию по краям слова
|
||||||
|
pre = ''
|
||||||
|
post = ''
|
||||||
|
core = w
|
||||||
|
while core and not core[0].isalnum():
|
||||||
|
pre += core[0]
|
||||||
|
core = core[1:]
|
||||||
|
while core and not core[-1].isalnum():
|
||||||
|
post = core[-1] + post
|
||||||
|
core = core[:-1]
|
||||||
|
if not core:
|
||||||
|
result.append(w)
|
||||||
|
continue
|
||||||
|
# в слове уже есть строчные буквы — не трогаем
|
||||||
|
if any('а' <= c <= 'я' or c == 'ё' for c in core):
|
||||||
|
result.append(w)
|
||||||
|
continue
|
||||||
|
# нет заглавных кириллических букв (числа, латиница и т.п.) — не трогаем
|
||||||
|
if not any('А' <= c <= 'Я' or c == 'Ё' for c in core):
|
||||||
|
result.append(w)
|
||||||
|
continue
|
||||||
|
letters = [c for c in core if c.isalpha()]
|
||||||
|
# инициалы и служебные слова (1-2 буквы: И., Г., УЛ., ООО — но ООО в белом списке)
|
||||||
|
if len(letters) <= 2:
|
||||||
|
result.append(w)
|
||||||
|
continue
|
||||||
|
# известная аббревиатура — сохраняем как есть
|
||||||
|
if core.upper() in _CAPS_ABBR:
|
||||||
|
result.append(w)
|
||||||
|
continue
|
||||||
|
# обычное длинное слово: первая заглавная, остальные строчные
|
||||||
|
normalized = core[0].upper() + core[1:].lower()
|
||||||
|
result.append(pre + normalized + post)
|
||||||
|
return ' '.join(result)
|
||||||
|
|
||||||
|
|
||||||
|
_ADDRESS_ABBR = {
|
||||||
|
# нормальные сокращения для адресов: «Г» → «г.», «Р-Н» → «р-н.» и т.п.
|
||||||
|
'Г': 'г.', 'УЛ': 'ул.', 'Д': 'д.', 'КВ': 'кв.', 'ПР': 'пр.',
|
||||||
|
'ПЕР': 'пер.', 'ПЛ': 'пл.', 'ОБЛ': 'обл.', 'ПОС': 'пос.',
|
||||||
|
'СТ': 'ст.', 'Ш': 'ш.', 'Р-Н': 'р-н.', 'Б-Р': 'б-р.', 'МКР': 'мкр.',
|
||||||
|
'НАБ': 'наб.', 'ТУП': 'туп.', 'ПР-Д': 'пр-д.', 'КРП': 'крп.',
|
||||||
|
'Г.': 'г.', 'УЛ.': 'ул.', 'Д.': 'д.', 'Р-Н.': 'р-н.',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_address(text: str) -> str:
|
||||||
|
"""Приводит адрес к нормальному виду: «Г Крымск» → «г. Крымск»,
|
||||||
|
«Р-Н Крымский» → «р-н. Крымский», «УЛ Карла» → «ул. Карла»."""
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
parts = re.split(r'(\s+|[,\;])', text)
|
||||||
|
out = []
|
||||||
|
for p in parts:
|
||||||
|
if p.strip() and p.strip().upper() in _ADDRESS_ABBR:
|
||||||
|
out.append(_ADDRESS_ABBR[p.strip().upper()])
|
||||||
|
else:
|
||||||
|
out.append(p)
|
||||||
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_value(val):
|
||||||
|
"""Очистка и нормализация значения ячейки Excel."""
|
||||||
|
if val is None:
|
||||||
|
return ''
|
||||||
|
if isinstance(val, (int, float)):
|
||||||
|
# Попытка распознать дату (серийный номер Excel)
|
||||||
|
if 10000 < val < 80000:
|
||||||
|
serial = int(val)
|
||||||
|
if serial > 60:
|
||||||
|
serial -= 1
|
||||||
|
dt = datetime(1899, 12, 30) + timedelta(days=serial)
|
||||||
|
return dt.strftime('%d.%m.%Y')
|
||||||
|
if isinstance(val, float) and val == int(val):
|
||||||
|
return str(int(val))
|
||||||
|
return str(val)
|
||||||
|
text = str(val).strip()
|
||||||
|
text = text.replace('\n', ' ').replace('\r', ' ')
|
||||||
|
text = ' '.join(text.split())
|
||||||
|
# Если весь текст КАПСОМ (нет строчных букв) — нормализуем по словам
|
||||||
|
if len(text) > 2 and any('А' <= c <= 'Я' or c == 'Ё' for c in text):
|
||||||
|
has_lower = any('а' <= c <= 'я' or c == 'ё' for c in text)
|
||||||
|
if not has_lower:
|
||||||
|
text = _normalize_caps(text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def import_152fz(filepath: str, log_fn: Optional[Callable] = None, settings_path: Optional[str] = None) -> dict:
|
||||||
|
"""Импорт данных из опросного листа Excel (152-ФЗ).
|
||||||
|
|
||||||
|
settings_path — путь к JSON-файлу настроек импорта (что и откуда брать).
|
||||||
|
Если None — файл import_settings.json рядом с программой; если его нет —
|
||||||
|
используется встроенный словарь по умолчанию.
|
||||||
|
"""
|
||||||
|
if openpyxl is None:
|
||||||
|
raise ImportError("openpyxl не установлен. Установите: pip install openpyxl")
|
||||||
|
|
||||||
|
if log_fn is None:
|
||||||
|
log_fn = lambda msg: None
|
||||||
|
|
||||||
|
settings = load_settings(settings_path)
|
||||||
|
log_fn(f"Загрузка опросного листа: {filepath}")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'fullName': '', 'shortName': '', 'addressLegal': '', 'addressActual': '',
|
||||||
|
'email': '', 'phone': '', 'inn': '', 'ogrn': '', 'kpp': '',
|
||||||
|
'okved': '', 'chiefPosition': '', 'chiefFio': '',
|
||||||
|
'ispdnFio': '', 'ispdnPosition': '', 'paperDocuments': '', 'paperStorage': '',
|
||||||
|
'ispdnDepartment': '', 'ispdnEmail': '', 'ispdnPhone': '',
|
||||||
|
'informationSystems': [], 'commission': [], 'employeesAccess': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
wb = openpyxl.load_workbook(filepath, data_only=True, read_only=False)
|
||||||
|
except Exception as e:
|
||||||
|
log_fn(f"❌ Ошибка открытия файла: {e}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
try:
|
||||||
|
_import_152_common(wb, data, settings, log_fn)
|
||||||
|
_import_152_is_list(wb, data, settings, log_fn)
|
||||||
|
_import_152_employees(wb, data, settings, log_fn)
|
||||||
|
except Exception as e:
|
||||||
|
log_fn(f"❌ Ошибка при импорте: {e}")
|
||||||
|
log_fn(traceback.format_exc())
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
# Копирование полей ИСПДн в админа, если админ не заполнен
|
||||||
|
for src, dst in [('ispdnFio', 'administratorFio'), ('ispdnFio', 'adminFio'),
|
||||||
|
('ispdnPosition', 'administratorPosition'), ('ispdnPosition', 'adminPosition'),
|
||||||
|
('ispdnPhone', 'phone'), ('ispdnEmail', 'email')]:
|
||||||
|
if data.get(src) and not data.get(dst):
|
||||||
|
data[dst] = data[src]
|
||||||
|
|
||||||
|
# Нормализация адресов: «Г Крымск» → «г. Крымск», «Р-Н» → «р-н.» и т.п.
|
||||||
|
for key in ('addressLegal', 'addressActual'):
|
||||||
|
if data.get(key):
|
||||||
|
data[key] = normalize_address(data[key])
|
||||||
|
|
||||||
|
_validate_company_data(data, log_fn)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _import_152_common(wb, data, settings, log_fn):
|
||||||
|
"""Импорт общих сведений по словарю настроек.
|
||||||
|
|
||||||
|
Для каждого поля: сначала пробуем конкретную ячейку (cell),
|
||||||
|
если она задана и заполнена; иначе ищем строку по ключевым
|
||||||
|
словам (keywords) в колонке A листа «Общие сведения».
|
||||||
|
"""
|
||||||
|
sheet_name = None
|
||||||
|
sheet_kw = settings.get('common_sheet_keyword', 'общие')
|
||||||
|
for sn in wb.sheetnames:
|
||||||
|
if sheet_kw in sn.lower():
|
||||||
|
sheet_name = sn
|
||||||
|
break
|
||||||
|
if not sheet_name:
|
||||||
|
log_fn(f"⚠ Лист по ключу '{sheet_kw}' не найден")
|
||||||
|
return
|
||||||
|
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
value_col = int(settings.get('value_column', 1))
|
||||||
|
|
||||||
|
# Кэш значений: для каждого поля ищем по строке один раз
|
||||||
|
row_cache = {}
|
||||||
|
for row in ws.iter_rows(min_row=1, values_only=True):
|
||||||
|
field_raw = clean_value(row[0]) if row[0] is not None else ''
|
||||||
|
if not field_raw:
|
||||||
|
continue
|
||||||
|
row_cache[field_raw.lower()] = (
|
||||||
|
clean_value(row[value_col]) if len(row) > value_col else ''
|
||||||
|
)
|
||||||
|
|
||||||
|
for rule in settings.get('fields', []):
|
||||||
|
key = rule.get('var', '')
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
name = rule.get('name', key)
|
||||||
|
cell_ref = rule.get('cell', '')
|
||||||
|
keywords = rule.get('keywords', '')
|
||||||
|
|
||||||
|
# 1) Конкретная ячейка
|
||||||
|
if cell_ref:
|
||||||
|
sheet_part, coord = parse_cell_ref(cell_ref)
|
||||||
|
target_ws = ws
|
||||||
|
if sheet_part:
|
||||||
|
for sn in wb.sheetnames:
|
||||||
|
if sn.lower() == sheet_part.lower():
|
||||||
|
target_ws = wb[sn]
|
||||||
|
break
|
||||||
|
val = clean_value(cell_value(target_ws, coord))
|
||||||
|
if val:
|
||||||
|
data[key] = val
|
||||||
|
log_fn(f" {name} [ячейка {coord}]: {val}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2) Поиск по ключевым словам
|
||||||
|
if keywords:
|
||||||
|
try:
|
||||||
|
rx = re.compile(keywords, re.IGNORECASE)
|
||||||
|
except re.error:
|
||||||
|
rx = None
|
||||||
|
if rx:
|
||||||
|
for field_lower, val in row_cache.items():
|
||||||
|
if rx.search(field_lower):
|
||||||
|
if val:
|
||||||
|
data[key] = val
|
||||||
|
log_fn(f" {name} [по ключу]: {val}")
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _import_152_is_list(wb, data, settings, log_fn):
|
||||||
|
"""Импорт информационных систем из остальных листов."""
|
||||||
|
exclude_kws = settings.get('is_sheet_exclude_keywords', ['общие сведения', 'сотрудники'])
|
||||||
|
is_sheets = []
|
||||||
|
for sn in wb.sheetnames:
|
||||||
|
sn_lower = sn.lower()
|
||||||
|
if any(kw in sn_lower for kw in exclude_kws):
|
||||||
|
continue
|
||||||
|
is_sheets.append(sn)
|
||||||
|
if not is_sheets:
|
||||||
|
log_fn("⚠ Листы ИС не найдены")
|
||||||
|
return
|
||||||
|
|
||||||
|
is_list = []
|
||||||
|
for sheet_name in is_sheets:
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
rows = []
|
||||||
|
for row in ws.iter_rows(min_row=1, values_only=True):
|
||||||
|
vals = [clean_value(c) for c in row]
|
||||||
|
field = (vals[0] or '').strip() if vals else ''
|
||||||
|
if not field:
|
||||||
|
continue
|
||||||
|
rows.append(vals)
|
||||||
|
|
||||||
|
if len(rows) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_obj = {
|
||||||
|
'name': '', 'description': '', 'room': '', 'employee': '', 'position': '',
|
||||||
|
'personalDataList': [], 'pd_subjects_list': [], 'pd_actions': [],
|
||||||
|
'usersList': [], 'isInternet': False, 'personalDataCount': 'менее 100 000',
|
||||||
|
'defense_tools': [], 'processing_mode': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
current_section = 'header'
|
||||||
|
pd_items_raw = []
|
||||||
|
subjects_raw = []
|
||||||
|
actions_raw = []
|
||||||
|
|
||||||
|
for vals in rows:
|
||||||
|
field = (vals[0] or '').strip()
|
||||||
|
field_lower = field.lower()
|
||||||
|
val_b = clean_value(vals[1]) if len(vals) > 1 else ''
|
||||||
|
is_checked = val_b.upper() in ('ДА', 'YES', '✓', '✔', '1', 'TRUE', 'ЕСТЬ')
|
||||||
|
|
||||||
|
if 'цель обработки пд' in field_lower or ('цель обработки' in field_lower and ':' in field):
|
||||||
|
current_section = 'purpose'
|
||||||
|
continue
|
||||||
|
if 'перечисленные пдн принадлежат' in field_lower or 'принадлеж' in field_lower:
|
||||||
|
current_section = 'subjects'
|
||||||
|
continue
|
||||||
|
if 'структура ис' in field_lower or 'структура информационной' in field_lower:
|
||||||
|
if val_b:
|
||||||
|
is_obj['structure'] = val_b
|
||||||
|
elif ':' in field:
|
||||||
|
# Значение может быть записано в той же ячейке после двоеточия
|
||||||
|
tail = field.split(':', 1)[1].strip()
|
||||||
|
if tail and 'выбрать' not in tail.lower():
|
||||||
|
is_obj['structure'] = tail
|
||||||
|
continue
|
||||||
|
if 'названия программ' in field_lower or ('программ' in field_lower and 'ос взаимодейств' in field_lower):
|
||||||
|
if val_b:
|
||||||
|
is_obj['software'] = val_b
|
||||||
|
continue
|
||||||
|
if 'локальную сеть' in field_lower:
|
||||||
|
is_obj['is_has_lan'] = is_checked
|
||||||
|
continue
|
||||||
|
if 'количество записей' in field_lower or 'количество субъект' in field_lower:
|
||||||
|
if val_b:
|
||||||
|
is_obj['personalDataCount'] = val_b
|
||||||
|
continue
|
||||||
|
if 'перечень действий с пд' in field_lower or ('действи' in field_lower and 'пд' in field_lower):
|
||||||
|
current_section = 'actions'
|
||||||
|
continue
|
||||||
|
if 'способ обработки пд' in field_lower:
|
||||||
|
if val_b:
|
||||||
|
is_obj['processing_mode'] = val_b
|
||||||
|
continue
|
||||||
|
if 'интернет' in field_lower:
|
||||||
|
is_obj['isInternet'] = is_checked
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current_section == 'header':
|
||||||
|
if 'перечень пд' in field_lower:
|
||||||
|
current_section = 'pd_items'
|
||||||
|
elif 'название ис' in field_lower or 'наименование ис' in field_lower or 'наименование информационной' in field_lower:
|
||||||
|
is_obj['name'] = val_b
|
||||||
|
log_fn(f" ИС: {val_b}")
|
||||||
|
elif 'описание ис' in field_lower or 'описание информационной' in field_lower:
|
||||||
|
if val_b:
|
||||||
|
is_obj['description'] = val_b
|
||||||
|
log_fn(f" Описание: {val_b[:60]}")
|
||||||
|
continue
|
||||||
|
elif 'сотрудник' in field_lower and 'должност' not in field_lower:
|
||||||
|
is_obj['employee'] = val_b
|
||||||
|
if val_b:
|
||||||
|
parts = [p.strip() for p in val_b.replace('\n', ';').split(';') if p.strip()]
|
||||||
|
for part in parts:
|
||||||
|
cleaned = part.strip()
|
||||||
|
if ':' in cleaned and len(cleaned.split(':')[0]) < 20:
|
||||||
|
cleaned = cleaned.split(':', 1)[1].strip()
|
||||||
|
if cleaned and cleaned not in is_obj['usersList']:
|
||||||
|
is_obj['usersList'].append(cleaned)
|
||||||
|
elif 'должность сотрудника' in field_lower:
|
||||||
|
is_obj['position'] = val_b
|
||||||
|
elif 'наименование отдела' in field_lower:
|
||||||
|
pass
|
||||||
|
elif 'номер кабинета' in field_lower:
|
||||||
|
is_obj['room'] = val_b
|
||||||
|
elif 'значение' in field_lower or 'примечание' in field_lower:
|
||||||
|
continue
|
||||||
|
elif val_b and 'выбрать' not in field_lower:
|
||||||
|
if not is_obj['description']:
|
||||||
|
is_obj['description'] = f"{field}: {val_b}"
|
||||||
|
else:
|
||||||
|
is_obj['description'] += f"\n{field}: {val_b}"
|
||||||
|
elif current_section == 'pd_items':
|
||||||
|
if is_checked and 'выбрать' not in field_lower and field_lower != 'значение':
|
||||||
|
pd_items_raw.append(field)
|
||||||
|
if 'цель обработки' in field_lower:
|
||||||
|
current_section = 'purpose'
|
||||||
|
elif current_section == 'purpose':
|
||||||
|
purpose_val = val_b or field or ''
|
||||||
|
if purpose_val and 'выбрать' not in purpose_val.lower() and ':' not in purpose_val[:40]:
|
||||||
|
is_obj['purpose'] = purpose_val
|
||||||
|
log_fn(f" Цель: {purpose_val[:80]}")
|
||||||
|
current_section = 'header'
|
||||||
|
elif current_section == 'subjects':
|
||||||
|
if is_checked and 'выбрать' not in field_lower:
|
||||||
|
subjects_raw.append(field)
|
||||||
|
if 'количество записей' in field_lower or 'действи' in field_lower:
|
||||||
|
continue
|
||||||
|
elif current_section == 'actions':
|
||||||
|
if is_checked and 'выбрать' not in field_lower and 'защита' not in field_lower[:20]:
|
||||||
|
actions_raw.append(field)
|
||||||
|
if 'защита' in field_lower and 'действи' not in field_lower:
|
||||||
|
current_section = 'defense'
|
||||||
|
elif current_section == 'defense':
|
||||||
|
defense_keywords = {
|
||||||
|
'антивирус': 'Антивирус',
|
||||||
|
'крипто': 'СКЗИ',
|
||||||
|
'межсетев': 'Межсетевой экран',
|
||||||
|
'сзи.*от несанкционирован': 'СЗИ от НСД',
|
||||||
|
'обнаружен.*вторжен': 'СОВ',
|
||||||
|
'программные.*средств.*модул': 'Доверенная загрузка',
|
||||||
|
'доверен.*загрузк': 'Доверенная загрузка',
|
||||||
|
'гарантирован': 'Доверенная загрузка',
|
||||||
|
}
|
||||||
|
matched_label = None
|
||||||
|
for kw, label in defense_keywords.items():
|
||||||
|
if re.search(kw, field_lower):
|
||||||
|
matched_label = label
|
||||||
|
break
|
||||||
|
if matched_label and val_b and 'выбрать' not in field_lower:
|
||||||
|
# Без префикса категории: просто название средства (Kaspersky, КриптоПро)
|
||||||
|
is_obj['defense_tools'].append(val_b)
|
||||||
|
elif matched_label:
|
||||||
|
pass
|
||||||
|
elif 'куда' in field_lower or 'передаются' in field_lower:
|
||||||
|
current_section = 'header'
|
||||||
|
elif 'способ обработки' in field_lower or 'локальную сеть' in field_lower or 'интернет' in field_lower:
|
||||||
|
current_section = 'header'
|
||||||
|
|
||||||
|
if pd_items_raw:
|
||||||
|
is_obj['personalDataList'] = pd_items_raw
|
||||||
|
log_fn(f" ПДн: {len(pd_items_raw)}")
|
||||||
|
if subjects_raw:
|
||||||
|
is_obj['pd_subjects_list'] = subjects_raw
|
||||||
|
log_fn(f" Субъектов: {len(subjects_raw)}")
|
||||||
|
if actions_raw:
|
||||||
|
is_obj['pd_actions'] = actions_raw
|
||||||
|
log_fn(f" Действий: {len(actions_raw)}")
|
||||||
|
|
||||||
|
if not is_obj['name']:
|
||||||
|
is_obj['name'] = sheet_name.strip()
|
||||||
|
log_fn(f" ⚠ ИС без названия — использовано имя листа: {is_obj['name']}")
|
||||||
|
|
||||||
|
# Автоопределение категорий ПДн
|
||||||
|
pd_cats = []
|
||||||
|
all_pd_text = ' '.join(p.lower() for p in pd_items_raw)
|
||||||
|
special_kw = ['состоян.*здоров', 'национальн', 'политическ', 'религиозн', 'философ', 'судимост', 'интимн']
|
||||||
|
if any(re.search(kw, all_pd_text) for kw in special_kw):
|
||||||
|
pd_cats.append('специальные')
|
||||||
|
bio_kw = ['биометрическ', 'изображен.*лиц', 'голос.*человек', 'папилляр', 'дактилоскоп', 'фото.*изображен']
|
||||||
|
if any(re.search(kw, all_pd_text) for kw in bio_kw):
|
||||||
|
pd_cats.append('биометрические')
|
||||||
|
if pd_items_raw:
|
||||||
|
pd_cats.append('иные')
|
||||||
|
if pd_cats:
|
||||||
|
is_obj['personalDataCategory'] = pd_cats
|
||||||
|
log_fn(f" Категории ПДн: {', '.join(pd_cats)}")
|
||||||
|
|
||||||
|
is_list.append(is_obj)
|
||||||
|
log_fn(f" ✅ {is_obj['name']} — импортирована")
|
||||||
|
|
||||||
|
if is_list:
|
||||||
|
data['informationSystems'] = is_list
|
||||||
|
log_fn(f"\n✅ Всего импортировано ИС: {len(is_list)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _import_152_employees(wb, data, settings, log_fn):
|
||||||
|
"""Импорт комиссии и сотрудников из листа «Сотрудники»."""
|
||||||
|
sheet_name = None
|
||||||
|
sheet_kw = settings.get('employees_sheet_keyword', 'сотруд')
|
||||||
|
for sn in wb.sheetnames:
|
||||||
|
if sheet_kw in sn.lower():
|
||||||
|
sheet_name = sn
|
||||||
|
break
|
||||||
|
if not sheet_name:
|
||||||
|
log_fn(f"⚠ Лист по ключу '{sheet_kw}' не найден")
|
||||||
|
return
|
||||||
|
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
rows = []
|
||||||
|
for row in ws.iter_rows(min_row=1, values_only=True):
|
||||||
|
vals = [clean_value(c) for c in row]
|
||||||
|
if not vals or not vals[0]:
|
||||||
|
continue
|
||||||
|
rows.append(vals)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
log_fn("⚠ Лист «Сотрудники» пуст")
|
||||||
|
return
|
||||||
|
|
||||||
|
commission = []
|
||||||
|
in_commission = False
|
||||||
|
COMMISSION_ROLES = {
|
||||||
|
'председатель': 'Председатель комиссии',
|
||||||
|
'секретарь': 'Секретарь комиссии',
|
||||||
|
'заместитель председателя': 'Заместитель председателя комиссии',
|
||||||
|
}
|
||||||
|
employees = []
|
||||||
|
in_employees = False
|
||||||
|
|
||||||
|
for vals in rows:
|
||||||
|
col_a = (vals[0] or '').strip()
|
||||||
|
col_b = clean_value(vals[1]) if len(vals) > 1 else ''
|
||||||
|
col_c = clean_value(vals[2]) if len(vals) > 2 else ''
|
||||||
|
col_d = clean_value(vals[3]) if len(vals) > 3 else ''
|
||||||
|
col_e = clean_value(vals[4]) if len(vals) > 4 else ''
|
||||||
|
a_lower = col_a.lower()
|
||||||
|
|
||||||
|
if 'состав комиссии' in a_lower:
|
||||||
|
in_commission = True
|
||||||
|
in_employees = False
|
||||||
|
log_fn(" Секция: состав комиссии")
|
||||||
|
continue
|
||||||
|
if 'перечень сотрудников' in a_lower:
|
||||||
|
in_commission = False
|
||||||
|
in_employees = True
|
||||||
|
log_fn(" Секция: сотрудники с доступом к ПДн")
|
||||||
|
continue
|
||||||
|
if a_lower in ('№ п/п', '№', 'номер', ''):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_commission:
|
||||||
|
role = 'Член комиссии'
|
||||||
|
# Роль ищем в колонках E (было) и D (Примечание — новый формат)
|
||||||
|
for col in (col_e, col_d):
|
||||||
|
if col:
|
||||||
|
col_lower = col.lower()
|
||||||
|
for keyword, role_name in COMMISSION_ROLES.items():
|
||||||
|
if keyword in col_lower:
|
||||||
|
role = role_name
|
||||||
|
break
|
||||||
|
if role != 'Член комиссии':
|
||||||
|
break
|
||||||
|
if not col_b and not col_c:
|
||||||
|
continue
|
||||||
|
commission.append({
|
||||||
|
'role': role,
|
||||||
|
'position': col_b,
|
||||||
|
'fio': col_c.replace('\n', ' '),
|
||||||
|
})
|
||||||
|
|
||||||
|
if in_employees:
|
||||||
|
fio = col_c if col_c else col_d
|
||||||
|
if not fio:
|
||||||
|
continue
|
||||||
|
employee = {
|
||||||
|
'position': col_b,
|
||||||
|
'fio': fio.replace('\n', ' '),
|
||||||
|
}
|
||||||
|
if col_e:
|
||||||
|
employee['is_list'] = [s.strip() for s in col_e.split('\n') if s.strip()]
|
||||||
|
employees.append(employee)
|
||||||
|
|
||||||
|
if commission:
|
||||||
|
commission = [c for c in commission if c['position'] or c['fio']]
|
||||||
|
data['commission'] = commission
|
||||||
|
log_fn(f" Комиссия: {len(commission)} чел.")
|
||||||
|
for m in commission[:3]:
|
||||||
|
log_fn(f" {m['role']}: {m['position'][:40]} — {m['fio'][:40]}")
|
||||||
|
|
||||||
|
if employees:
|
||||||
|
data['employeesAccess'] = employees
|
||||||
|
log_fn(f" Сотрудников с доступом: {len(employees)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_company_data(data, log_fn):
|
||||||
|
"""Проверка обязательных полей."""
|
||||||
|
required = ['fullName', 'inn']
|
||||||
|
missing = [f for f in required if not data.get(f)]
|
||||||
|
if missing:
|
||||||
|
log_fn(f"⚠️ ОБЯЗАТЕЛЬНЫЕ ПОЛЯ НЕ ЗАПОЛНЕНЫ: {', '.join(missing)}")
|
||||||
|
else:
|
||||||
|
log_fn("✅ Все обязательные поля заполнены")
|
||||||
|
|
||||||
|
inn = (data.get('inn') or '').replace(' ', '')
|
||||||
|
if inn and (not inn.isdigit() or len(inn) not in (10, 12)):
|
||||||
|
log_fn(f"⚠️ ИНН подозрительного формата: «{inn}»")
|
||||||
@@ -18,7 +18,6 @@ class InformationSystem:
|
|||||||
"""Информационная система (обработка ПДн)"""
|
"""Информационная система (обработка ПДн)"""
|
||||||
name: str = ""
|
name: str = ""
|
||||||
description: str = ""
|
description: str = ""
|
||||||
software: str = ""
|
|
||||||
is_local_network: bool = False
|
is_local_network: bool = False
|
||||||
is_internet: bool = False
|
is_internet: bool = False
|
||||||
defence_level: str = ""
|
defence_level: str = ""
|
||||||
@@ -29,12 +28,16 @@ class InformationSystem:
|
|||||||
users: str = ""
|
users: str = ""
|
||||||
defense_tools_list: List[str] = field(default_factory=list)
|
defense_tools_list: List[str] = field(default_factory=list)
|
||||||
processing_modes: str = ""
|
processing_modes: str = ""
|
||||||
|
subject_type: str = "работники организации"
|
||||||
|
pd_actions_list: List[str] = field(default_factory=list)
|
||||||
pd_subjects_list: List[str] = field(default_factory=list)
|
pd_subjects_list: List[str] = field(default_factory=list)
|
||||||
personal_data_list: List[str] = field(default_factory=list)
|
personal_data_list: List[str] = field(default_factory=list)
|
||||||
users_list: List[str] = field(default_factory=list)
|
users_list: List[str] = field(default_factory=list)
|
||||||
personal_data_category: List[str] = field(default_factory=list)
|
personal_data_category: List[str] = field(default_factory=list)
|
||||||
purpose: str = ""
|
purpose: str = ""
|
||||||
room: str = ""
|
room: str = ""
|
||||||
|
structure: str = "" # Структура информационной системы
|
||||||
|
software: str = "" # Программное обеспечение
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -89,6 +92,10 @@ class Company:
|
|||||||
commission: List[CommissionMember] = field(default_factory=list)
|
commission: List[CommissionMember] = field(default_factory=list)
|
||||||
information_systems: List[InformationSystem] = field(default_factory=list)
|
information_systems: List[InformationSystem] = field(default_factory=list)
|
||||||
paper_documents_list: List[PaperDocument] = field(default_factory=list)
|
paper_documents_list: List[PaperDocument] = field(default_factory=list)
|
||||||
|
employees_access: List[Dict[str, str]] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Наименование структурного подразделения по безопасности
|
||||||
|
security_department: str = ""
|
||||||
|
|
||||||
def update_declensions(self, inflect_func):
|
def update_declensions(self, inflect_func):
|
||||||
"""Обновить склонения полного названия"""
|
"""Обновить склонения полного названия"""
|
||||||
@@ -133,6 +140,8 @@ class Company:
|
|||||||
PaperDocument(**p) if isinstance(p, dict) else p
|
PaperDocument(**p) if isinstance(p, dict) else p
|
||||||
for p in d["paper_documents_list"]
|
for p in d["paper_documents_list"]
|
||||||
]
|
]
|
||||||
|
if "employees_access" in d and not isinstance(d["employees_access"], list):
|
||||||
|
d["employees_access"] = []
|
||||||
valid = {f.name for f in cls.__dataclass_fields__.values()}
|
valid = {f.name for f in cls.__dataclass_fields__.values()}
|
||||||
return cls(**{k: v for k, v in d.items() if k in valid})
|
return cls(**{k: v for k, v in d.items() if k in valid})
|
||||||
|
|
||||||
+2261
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,12 @@ from . import declension as dc
|
|||||||
from .models import Company, CommissionMember
|
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}}', 'A', 'ОГРН', 'company:ogrn'),
|
||||||
('{{company_ogrn_date}}', 'A', 'Дата ОГРН', 'company:ogrn_date'),
|
('{{company_ogrn_date}}', 'A', 'Дата ОГРН', 'company:ogrn_date'),
|
||||||
('{{company_kpp}}', 'A', 'КПП', 'company:kpp'),
|
('{{company_kpp}}', 'A', 'КПП', 'company:kpp'),
|
||||||
|
('{{security_department}}', 'A', 'Наименование структурного подразделения по безопасности', 'company:security_department'),
|
||||||
|
('{{item.pd_actions}}', 'B', 'Действия с ПДн (в цикле ИС)', 'item:pd_actions'),
|
||||||
|
|
||||||
# === Комиссия ===
|
# === Комиссия ===
|
||||||
('{{commission_list}}', 'A', 'Список комиссии', 'commission_list'),
|
('{{commission_list}}', 'A', 'Список комиссии', 'commission_list'),
|
||||||
@@ -62,6 +70,28 @@ VARIABLE_DEFS = [
|
|||||||
|
|
||||||
# === 152-ФЗ ===
|
# === 152-ФЗ ===
|
||||||
('{{is_security_requirements}}', 'B', 'Требования уровня защищённости', 'security_reqs'),
|
('{{is_security_requirements}}', 'B', 'Требования уровня защищённости', 'security_reqs'),
|
||||||
|
('{{is_list}}', 'B', 'Список всех ИС (нумерованный)', 'is_list'),
|
||||||
|
('{{is_name}}', 'B', 'Наименование первой ИС', 'is:name'),
|
||||||
|
('{{name}}', 'B', 'Название ИС (алиас)', 'is:name'),
|
||||||
|
('{{description}}', 'B', 'Описание ИС (алиас)', 'is:description'),
|
||||||
|
('{{category}}', 'B', 'Категория ИС (алиас)', 'is:category'),
|
||||||
|
('{{number}}', 'B', 'Номер (алиас)', 'doc_number'),
|
||||||
|
('{{pd_count}}', 'B', 'Количество записей ПДн (алиас)', 'is:pd_count'),
|
||||||
|
('{{pd_list}}', 'B', 'Список ПДн (алиас)', 'is:pd_list'),
|
||||||
|
('{{pd_subjects}}', 'B', 'Субъекты ПДн (алиас)', 'is:pd_subjects'),
|
||||||
|
('{{threat_type}}', 'B', 'Тип угроз (алиас)', 'is:threat_type'),
|
||||||
|
('{{users}}', 'B', 'Пользователи ИС (алиас)', 'is:users'),
|
||||||
|
('{{defence_level}}', 'B', 'Уровень защищённости (алиас)', 'is:defence_level'),
|
||||||
|
('{{full_name_genitive}}', 'A', 'Полное наименование (род.п., алиас)', 'decl:full_name:gent'),
|
||||||
|
('{{document}}', 'A', 'Название бумажного документа (алиас)', 'company:paper_documents'),
|
||||||
|
('{{storage}}', 'A', 'Место хранения (алиас)', 'company:paper_storage'),
|
||||||
|
('{{responsible_fio_accs}}', 'A', 'ФИО ответственного (вин.п.)', 'fio_acl:responsible'),
|
||||||
|
('{{commission1}}', 'A', 'Должность члена комиссии 1', 'commission_member:0:position'),
|
||||||
|
('{{commission1_fio}}', 'A', 'ФИО члена комиссии 1', 'commission_member:0:fio'),
|
||||||
|
('{{commission2}}', 'A', 'Должность члена комиссии 2', 'commission_member:1:position'),
|
||||||
|
('{{commission2_fio}}', 'A', 'ФИО члена комиссии 2', 'commission_member:1:fio'),
|
||||||
|
('{{commission3}}', 'A', 'Должность члена комиссии 3', 'commission_member:2:position'),
|
||||||
|
('{{commission3_fio}}', 'A', 'ФИО члена комиссии 3', 'commission_member:2:fio'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Маппинг старых переменных <...> → {{...}}
|
# Маппинг старых переменных <...> → {{...}}
|
||||||
@@ -143,14 +173,42 @@ def build_replacements(
|
|||||||
return 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]:
|
def _build_commission_data(company: Company) -> Dict[str, str]:
|
||||||
data = {}
|
data = {}
|
||||||
members = company.commission or []
|
members = company.commission or []
|
||||||
|
|
||||||
|
display_roles = _commission_role_display(members)
|
||||||
lines = []
|
lines = []
|
||||||
for i, m in enumerate(members, 1):
|
for i, (m, role_disp) in enumerate(zip(members, display_roles), 1):
|
||||||
role = m.role or 'член комиссии'
|
if role_disp:
|
||||||
line = f"{i}. {role}: {m.position} — {m.fio}" if m.position else f"{i}. {role}: {m.fio}"
|
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)
|
lines.append(line)
|
||||||
data['commission_list'] = '\n'.join(lines) if lines else '—'
|
data['commission_list'] = '\n'.join(lines) if lines else '—'
|
||||||
|
|
||||||
@@ -164,12 +222,12 @@ def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int):
|
|||||||
'is_description': isys.description or '—',
|
'is_description': isys.description or '—',
|
||||||
'is_has_internet': 'да' if isys.is_internet else 'нет',
|
'is_has_internet': 'да' if isys.is_internet else 'нет',
|
||||||
'is_defence_level': str(isys.defence_level or '—'),
|
'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_pd_list': ', '.join(isys.personal_data_list or []) or '—',
|
||||||
'is_users': ', '.join(isys.users_list or []) or '—',
|
'is_users': ', '.join(isys.users_list or []) or '—',
|
||||||
'is_pd_count': str(isys.pd_count or '—'),
|
'is_pd_count': str(isys.pd_count or '—'),
|
||||||
'is_purpose': isys.purpose or '—',
|
'is_purpose': isys.purpose or '—',
|
||||||
'is_software_list': isys.software or '—',
|
'is_structure': getattr(isys, 'structure', '') or '—',
|
||||||
}
|
}
|
||||||
for f, v in fields.items():
|
for f, v in fields.items():
|
||||||
replacements[f'{{{{{f}_{idx}}}}}'] = str(v)
|
replacements[f'{{{{{f}_{idx}}}}}'] = str(v)
|
||||||
@@ -181,13 +239,39 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
|
|||||||
# Комиссия
|
# Комиссия
|
||||||
members = company.commission or []
|
members = company.commission or []
|
||||||
if members:
|
if members:
|
||||||
|
display_roles = _commission_role_display(members)
|
||||||
items = []
|
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({
|
items.append({
|
||||||
'role': m.role or 'член комиссии',
|
'role': role_disp,
|
||||||
'position': m.position or '—',
|
'position': pos,
|
||||||
'fio': m.fio or '—',
|
'fio': fio,
|
||||||
'full': f"{m.role} {m.position} — {m.fio}" if m.position else f"{m.role} — {m.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
|
loops['commission'] = items
|
||||||
|
|
||||||
@@ -195,33 +279,81 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
|
|||||||
if is_list:
|
if is_list:
|
||||||
items = []
|
items = []
|
||||||
for isys in is_list:
|
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({
|
items.append({
|
||||||
'name': isys.name or '—',
|
'name': isys.name or '—',
|
||||||
'description': isys.description or '—',
|
'description': isys.description or '—',
|
||||||
'software': isys.software or '—',
|
'address': getattr(isys, 'address', '') or '—',
|
||||||
'is_has_lan': 'Да' if isys.is_local_network else 'Нет',
|
'is_has_lan': 'Да' if isys.is_local_network else 'Нет',
|
||||||
'has_internet': 'да' if isys.is_internet else 'нет',
|
'has_internet': 'да' if isys.is_internet else 'нет',
|
||||||
'defence_level': str(isys.defence_level or '—'),
|
'defence_level': str(isys.defence_level or '—'),
|
||||||
'threat_type': str(isys.threat_type or '3'),
|
'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_list': ', '.join(isys.personal_data_list or []) or '—',
|
||||||
'pd_count': str(isys.pd_count or '—'),
|
'pd_count': str(isys.pd_count or '—'),
|
||||||
'users': ', '.join(isys.users_list or []) 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 '—',
|
'processing_modes': isys.processing_modes or '—',
|
||||||
'pd_subjects': '; '.join(isys.pd_subjects_list or []) or '—',
|
'pd_subjects': '; '.join(isys.pd_subjects_list or []) or '—',
|
||||||
'is_purpose': isys.purpose 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
|
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 []
|
paper_list = getattr(company, 'paper_documents_list', None) or []
|
||||||
if paper_list:
|
if paper_list:
|
||||||
items = []
|
items = []
|
||||||
for p in paper_list:
|
for p in paper_list:
|
||||||
|
storage = p.storage or ''
|
||||||
items.append({
|
items.append({
|
||||||
'document': p.name or '',
|
'document': p.name or '',
|
||||||
'storage': p.storage or '',
|
'storage': _storage_with_prep(storage), # «в сейфе»
|
||||||
|
'storage_raw': storage, # «сейф» (как введено)
|
||||||
})
|
})
|
||||||
loops['paper_documents'] = items
|
loops['paper_documents'] = items
|
||||||
|
|
||||||
@@ -260,7 +392,21 @@ def _resolve(source: str, ctx: Dict) -> str:
|
|||||||
if source.startswith('decl:'):
|
if source.startswith('decl:'):
|
||||||
_, field, case = source.split(':')
|
_, field, case = source.split(':')
|
||||||
name = getattr(company, field, '') or ''
|
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:'):
|
if source.startswith('short_fio:'):
|
||||||
@@ -293,6 +439,54 @@ def _resolve(source: str, ctx: Dict) -> str:
|
|||||||
if source == 'security_reqs':
|
if source == 'security_reqs':
|
||||||
return _get_security_text(ctx)
|
return _get_security_text(ctx)
|
||||||
|
|
||||||
|
# IS-алиасы (первая ИС)
|
||||||
|
if source.startswith('is:'):
|
||||||
|
field = source[3:]
|
||||||
|
if is_list:
|
||||||
|
first = is_list[0]
|
||||||
|
field_map = {
|
||||||
|
'name': 'name',
|
||||||
|
'description': 'description',
|
||||||
|
'category': 'category',
|
||||||
|
'defence_level': 'defence_level',
|
||||||
|
'threat_type': 'threat_type',
|
||||||
|
'pd_list': lambda: ', '.join(first.personal_data_list or []),
|
||||||
|
'pd_count': 'pd_count',
|
||||||
|
'pd_subjects': lambda: '; '.join(first.pd_subjects_list or []),
|
||||||
|
'users': lambda: ', '.join(first.users_list or []),
|
||||||
|
}
|
||||||
|
if field in field_map:
|
||||||
|
val = field_map[field]
|
||||||
|
if callable(val):
|
||||||
|
return val() or '—'
|
||||||
|
return str(getattr(first, val, '') or '—') if isinstance(val, str) else '—'
|
||||||
|
return '—'
|
||||||
|
|
||||||
|
# Список ИС
|
||||||
|
if source == 'is_list':
|
||||||
|
if is_list:
|
||||||
|
return '\n'.join(f"{i}. {isys.name}" for i, isys in enumerate(is_list, 1))
|
||||||
|
return '—'
|
||||||
|
if source == 'is_list_comma':
|
||||||
|
if is_list:
|
||||||
|
return ', '.join(isys.name for isys in is_list)
|
||||||
|
return '—'
|
||||||
|
|
||||||
|
# Члены комиссии
|
||||||
|
if source.startswith('commission_member:'):
|
||||||
|
parts = source.split(':')
|
||||||
|
idx, field = int(parts[1]), parts[2]
|
||||||
|
members = getattr(company, 'commission', None) or []
|
||||||
|
if idx < len(members):
|
||||||
|
return str(getattr(members[idx], field, '') or '—')
|
||||||
|
return '—'
|
||||||
|
|
||||||
|
# Склонение ФИО по падежам
|
||||||
|
if source.startswith('fio_acl:'):
|
||||||
|
who = source.split(':', 1)[1]
|
||||||
|
fio = _get_fio(company, who)
|
||||||
|
return dc.decline(fio, 'accs') if fio else '—'
|
||||||
|
|
||||||
return '—'
|
return '—'
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
-378
@@ -1,378 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""DokoGen — Генератор документов из шаблонов DOCX"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import io
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, List, Optional, Callable
|
|
||||||
|
|
||||||
try:
|
|
||||||
from docx import Document
|
|
||||||
from docx.oxml.ns import qn
|
|
||||||
from docx.oxml import OxmlElement
|
|
||||||
from lxml import etree
|
|
||||||
except ImportError:
|
|
||||||
Document = None
|
|
||||||
|
|
||||||
from .variables import OLD_VAR_MAP, OLD_VAR_PATTERN, VAR_PATTERN
|
|
||||||
|
|
||||||
# Стандартный шаблон для незаполненных переменных
|
|
||||||
EMPTY_MARKER = '—'
|
|
||||||
|
|
||||||
|
|
||||||
def process_template(
|
|
||||||
template_path: str,
|
|
||||||
replacements: Dict[str, str],
|
|
||||||
output_path: Optional[str] = None,
|
|
||||||
log_func: Optional[Callable] = None,
|
|
||||||
) -> str:
|
|
||||||
"""Обрабатывает шаблон DOCX: подстановка переменных, циклы, сохранение."""
|
|
||||||
if Document is None:
|
|
||||||
raise ImportError("python-docx не установлен. Установите: pip install python-docx")
|
|
||||||
|
|
||||||
if not os.path.exists(template_path):
|
|
||||||
raise FileNotFoundError(f"Шаблон не найден: {template_path}")
|
|
||||||
|
|
||||||
if output_path is None:
|
|
||||||
base, ext = os.path.splitext(template_path)
|
|
||||||
output_path = f"{base}_result{ext}"
|
|
||||||
|
|
||||||
doc = Document(template_path)
|
|
||||||
|
|
||||||
# 1. Миграция старых переменных <...> → {{...}}
|
|
||||||
_migrate_old_vars(doc)
|
|
||||||
|
|
||||||
# 2. Удаление proofErr
|
|
||||||
_strip_prooferr(doc)
|
|
||||||
|
|
||||||
# 3. Обработка циклов (loop/loop_end)
|
|
||||||
loops = replacements.pop('_loops', {})
|
|
||||||
if loops:
|
|
||||||
_process_loops(doc, loops)
|
|
||||||
|
|
||||||
# 4. Перезагрузка после циклов
|
|
||||||
buf = io.BytesIO()
|
|
||||||
doc.save(buf)
|
|
||||||
buf.seek(0)
|
|
||||||
doc = Document(buf)
|
|
||||||
|
|
||||||
# 5. Плоская замена переменных (2 прохода)
|
|
||||||
for _ in range(2):
|
|
||||||
_replace_in_paragraphs(doc.paragraphs, replacements)
|
|
||||||
_replace_in_tables(doc.tables, replacements)
|
|
||||||
_replace_in_headers_footers(doc, replacements)
|
|
||||||
|
|
||||||
# 6. Подсветка незаполненных
|
|
||||||
_highlight_unmatched(doc)
|
|
||||||
|
|
||||||
# 7. Сохранение
|
|
||||||
doc.save(output_path)
|
|
||||||
|
|
||||||
if log_func:
|
|
||||||
log_func(f"✅ {os.path.basename(output_path)}")
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== МИГРАЦИЯ СТАРЫХ ПЕРЕМЕННЫХ ====================
|
|
||||||
|
|
||||||
def _migrate_old_vars(doc):
|
|
||||||
"""Заменяет <OldVar> на {{new_var}} в документе."""
|
|
||||||
def _migrate_text(text):
|
|
||||||
return OLD_VAR_PATTERN.sub(lambda m: OLD_VAR_MAP.get(m.group(0), m.group(0)), text)
|
|
||||||
|
|
||||||
for para in doc.paragraphs:
|
|
||||||
new_text = _migrate_text(para.text)
|
|
||||||
if new_text != para.text:
|
|
||||||
if para.runs:
|
|
||||||
first = para.runs[0]
|
|
||||||
para.clear()
|
|
||||||
run = para.add_run(new_text)
|
|
||||||
_copy_run_style(first, run)
|
|
||||||
else:
|
|
||||||
para.clear()
|
|
||||||
para.add_run(new_text)
|
|
||||||
|
|
||||||
for table in doc.tables:
|
|
||||||
for row in table.rows:
|
|
||||||
for cell in row.cells:
|
|
||||||
for para in cell.paragraphs:
|
|
||||||
new_text = _migrate_text(para.text)
|
|
||||||
if new_text != para.text:
|
|
||||||
if para.runs:
|
|
||||||
first = para.runs[0]
|
|
||||||
para.clear()
|
|
||||||
run = para.add_run(new_text)
|
|
||||||
_copy_run_style(first, run)
|
|
||||||
else:
|
|
||||||
para.clear()
|
|
||||||
para.add_run(new_text)
|
|
||||||
|
|
||||||
for section in doc.sections:
|
|
||||||
for hf in [section.header, section.footer,
|
|
||||||
section.first_page_header, section.first_page_footer]:
|
|
||||||
if hf:
|
|
||||||
for para in hf.paragraphs:
|
|
||||||
new_text = _migrate_text(para.text)
|
|
||||||
if new_text != para.text:
|
|
||||||
if para.runs:
|
|
||||||
first = para.runs[0]
|
|
||||||
para.clear()
|
|
||||||
run = para.add_run(new_text)
|
|
||||||
_copy_run_style(first, run)
|
|
||||||
else:
|
|
||||||
para.clear()
|
|
||||||
para.add_run(new_text)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== ЗАМЕНА В ЭЛЕМЕНТАХ ====================
|
|
||||||
|
|
||||||
def _replace_in_paragraphs(paragraphs, replacements):
|
|
||||||
for para in paragraphs:
|
|
||||||
full = para.text
|
|
||||||
# Пропускаем строки циклов
|
|
||||||
if 'loop:' in full or 'loop_end' in full:
|
|
||||||
continue
|
|
||||||
matches = VAR_PATTERN.findall(full)
|
|
||||||
if not matches:
|
|
||||||
continue
|
|
||||||
new_text = full
|
|
||||||
for var in matches:
|
|
||||||
val = replacements.get(var)
|
|
||||||
if val is not None:
|
|
||||||
new_text = new_text.replace(var, str(val))
|
|
||||||
if new_text != full:
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_in_tables(tables, replacements):
|
|
||||||
for table in tables:
|
|
||||||
for row in table.rows:
|
|
||||||
for cell in row.cells:
|
|
||||||
for para in cell.paragraphs:
|
|
||||||
full = para.text
|
|
||||||
matches = VAR_PATTERN.findall(full)
|
|
||||||
if not matches:
|
|
||||||
continue
|
|
||||||
new_text = full
|
|
||||||
for var in matches:
|
|
||||||
val = replacements.get(var)
|
|
||||||
if val is not None:
|
|
||||||
new_text = new_text.replace(var, str(val))
|
|
||||||
if new_text != full:
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_in_headers_footers(doc, replacements):
|
|
||||||
for section in doc.sections:
|
|
||||||
for hf in [section.header, section.footer,
|
|
||||||
section.first_page_header, section.first_page_footer]:
|
|
||||||
if hf:
|
|
||||||
_replace_in_paragraphs(hf.paragraphs, replacements)
|
|
||||||
|
|
||||||
|
|
||||||
def _copy_run_style(source, target):
|
|
||||||
"""Копирует форматирование из одного run в другой."""
|
|
||||||
try:
|
|
||||||
target.bold = source.bold
|
|
||||||
target.italic = source.italic
|
|
||||||
target.underline = source.underline
|
|
||||||
if source.font:
|
|
||||||
target.font.size = source.font.size
|
|
||||||
target.font.name = source.font.name
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_prooferr(doc):
|
|
||||||
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
|
||||||
for elem in doc.element.iter():
|
|
||||||
for child in list(elem):
|
|
||||||
if child.tag == f'{{{ns}}}proofErr':
|
|
||||||
elem.remove(child)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== ОБРАБОТКА ЦИКЛОВ ====================
|
|
||||||
|
|
||||||
LOOP_START = re.compile(r'(?:<!--|<!--)?\s*loop:(\w+)(?:\s*(?:-->|-->))?')
|
|
||||||
LOOP_END = re.compile(r'(?:<!--|<!--)?\s*loop_end(?:\s*(?:-->|-->))?')
|
|
||||||
|
|
||||||
|
|
||||||
def _process_loops(doc, loops: Dict[str, List[Dict]]):
|
|
||||||
body = doc.element.body
|
|
||||||
max_passes = 20
|
|
||||||
for _ in range(max_passes):
|
|
||||||
body_str = etree.tostring(body, encoding='unicode')
|
|
||||||
# Находим все ключи циклов
|
|
||||||
found = set(LOOP_START.findall(body_str))
|
|
||||||
if not found:
|
|
||||||
break
|
|
||||||
changed = False
|
|
||||||
for key in sorted(found):
|
|
||||||
items = loops.get(key, [])
|
|
||||||
if not items:
|
|
||||||
_remove_loop_block(body, key)
|
|
||||||
changed = True
|
|
||||||
else:
|
|
||||||
_expand_loop(body, key, items)
|
|
||||||
changed = True
|
|
||||||
body = doc.element.body
|
|
||||||
if not changed:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def _expand_loop(body, key: str, items: List[Dict]):
|
|
||||||
body_str = etree.tostring(body, encoding='unicode')
|
|
||||||
|
|
||||||
markers = []
|
|
||||||
for m in LOOP_START.finditer(body_str):
|
|
||||||
if m.group(1) == key:
|
|
||||||
markers.append((m.start(), True, m.end()))
|
|
||||||
for m in LOOP_END.finditer(body_str):
|
|
||||||
markers.append((m.start(), False, m.end()))
|
|
||||||
|
|
||||||
if len(markers) < 2:
|
|
||||||
return
|
|
||||||
markers.sort()
|
|
||||||
|
|
||||||
start_pos = end_pos = start_end = end_end = None
|
|
||||||
for pos, is_start, end in markers:
|
|
||||||
if is_start and start_pos is None:
|
|
||||||
start_pos = pos
|
|
||||||
start_end = end
|
|
||||||
elif not is_start and start_pos is not None:
|
|
||||||
end_pos = pos
|
|
||||||
end_end = end
|
|
||||||
break
|
|
||||||
if start_pos is None or end_pos is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Определяем структурный элемент (параграф или строка таблицы)
|
|
||||||
s_start, s_end = _find_element_bounds(body_str, start_pos)
|
|
||||||
e_start, e_end = _find_element_bounds(body_str, end_pos)
|
|
||||||
|
|
||||||
if s_start is None or e_end is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Извлекаем шаблон (содержимое между маркерами)
|
|
||||||
template_xml = body_str[start_end:end_pos]
|
|
||||||
# Убираем остатки маркеров
|
|
||||||
template_xml = LOOP_START.sub('', template_xml)
|
|
||||||
template_xml = LOOP_END.sub('', template_xml)
|
|
||||||
|
|
||||||
# Размножаем
|
|
||||||
expanded = []
|
|
||||||
for idx, item in enumerate(items, 1):
|
|
||||||
clone = template_xml
|
|
||||||
for field, value in item.items():
|
|
||||||
clone = clone.replace(f'{{{{item.{field}}}}}', str(value) if value else EMPTY_MARKER)
|
|
||||||
clone = clone.replace(f'{{{{{field}}}}}', str(value) if value else EMPTY_MARKER)
|
|
||||||
clone = clone.replace('{{item.number}}', str(idx))
|
|
||||||
clone = clone.replace('{{number}}', str(idx))
|
|
||||||
clone = clone.replace('\u27f5BR\u27f5', '</w:t><w:br/><w:t xml:space="preserve">')
|
|
||||||
expanded.append(clone)
|
|
||||||
|
|
||||||
new_str = body_str[:s_start] + ''.join(expanded) + body_str[e_end:]
|
|
||||||
try:
|
|
||||||
new_body = etree.fromstring(new_str.encode('utf-8'))
|
|
||||||
parent = body.getparent()
|
|
||||||
if parent is not None:
|
|
||||||
parent.replace(body, new_body)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_loop_block(body, key: str):
|
|
||||||
body_str = etree.tostring(body, encoding='unicode')
|
|
||||||
start_pos = None
|
|
||||||
for m in LOOP_START.finditer(body_str):
|
|
||||||
if m.group(1) == key:
|
|
||||||
start_pos = m.start()
|
|
||||||
break
|
|
||||||
if start_pos is None:
|
|
||||||
return
|
|
||||||
s_start, s_end = _find_element_bounds(body_str, start_pos)
|
|
||||||
end_match = LOOP_END.search(body_str, start_pos)
|
|
||||||
if end_match:
|
|
||||||
_, e_end = _find_element_bounds(body_str, end_match.start())
|
|
||||||
new_str = body_str[:s_start] + body_str[e_end:]
|
|
||||||
else:
|
|
||||||
new_str = body_str[:s_start] + body_str[s_end:]
|
|
||||||
try:
|
|
||||||
new_body = etree.fromstring(new_str.encode('utf-8'))
|
|
||||||
parent = body.getparent()
|
|
||||||
if parent is not None:
|
|
||||||
parent.replace(body, new_body)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _find_element_bounds(xml_str: str, pos: int):
|
|
||||||
"""Находит границы элемента (table row или paragraph), содержащего позицию."""
|
|
||||||
# Пробуем tr
|
|
||||||
tr_open = max(xml_str.rfind('<w:tr ', 0, pos), xml_str.rfind('<w:tr>', 0, pos))
|
|
||||||
if tr_open != -1:
|
|
||||||
tr_close = xml_str.find('</w:tr>', pos)
|
|
||||||
if tr_close != -1:
|
|
||||||
return tr_open, tr_close + len('</w:tr>')
|
|
||||||
# Пробуем p
|
|
||||||
p_open = max(xml_str.rfind('<w:p ', 0, pos), xml_str.rfind('<w:p>', 0, pos))
|
|
||||||
if p_open != -1:
|
|
||||||
p_close = xml_str.find('</w:p>', pos)
|
|
||||||
if p_close != -1:
|
|
||||||
return p_open, p_close + len('</w:p>')
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== ПОДСВЕТКА НЕЗАПОЛНЕННЫХ ====================
|
|
||||||
|
|
||||||
def _highlight_unmatched(doc):
|
|
||||||
"""Подсвечивает незаполненные переменные красным."""
|
|
||||||
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
|
||||||
|
|
||||||
def _highlight_run(run):
|
|
||||||
rpr = run._element.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)
|
|
||||||
sz = OxmlElement('w:sz')
|
|
||||||
sz.set(f'{{{ns}}}val', '22')
|
|
||||||
rpr.append(sz)
|
|
||||||
|
|
||||||
for para in doc.paragraphs:
|
|
||||||
for run in para.runs:
|
|
||||||
if '{{' in run.text and '}}' in run.text:
|
|
||||||
_highlight_run(run)
|
|
||||||
|
|
||||||
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 '{{' in run.text and '}}' in run.text:
|
|
||||||
_highlight_run(run)
|
|
||||||
|
|
||||||
for section in doc.sections:
|
|
||||||
for hf in [section.header, section.footer]:
|
|
||||||
if hf:
|
|
||||||
for para in hf.paragraphs:
|
|
||||||
for run in para.runs:
|
|
||||||
if '{{' in run.text and '}}' in run.text:
|
|
||||||
_highlight_run(run)
|
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""DokoGen — Генератор документов 152-ФЗ
|
"""DokoGen — Генератор документов 152-ФЗ
|
||||||
Точка входа: python3 main.py
|
Точка входа: python main.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Путь к проекту
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from dokogen.ui import main
|
from dokogen.ui import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""DokoGen — test: verify generator with nested loops"""
|
||||||
|
|
||||||
|
import sys, os, json, tempfile, zipfile, shutil, re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Add to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from dokogen.models import Company, CommissionMember, InformationSystem
|
||||||
|
from dokogen.variables import build_replacements
|
||||||
|
from dokogen.generator import process_template, expand_loops_in_zip
|
||||||
|
|
||||||
|
def create_test_template():
|
||||||
|
"""Creates a test .docx with nested loops for verification."""
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt
|
||||||
|
|
||||||
|
doc = Document()
|
||||||
|
|
||||||
|
# Title
|
||||||
|
doc.add_heading('Test Document with Nested Loops', 0)
|
||||||
|
|
||||||
|
# Simple variable
|
||||||
|
doc.add_paragraph('Company: {{company_full_name}}')
|
||||||
|
doc.add_paragraph('Date: {{date}}')
|
||||||
|
doc.add_paragraph('')
|
||||||
|
|
||||||
|
# Outer loop: information_systems
|
||||||
|
doc.add_paragraph('<!-- loop:information_systems -->')
|
||||||
|
doc.add_paragraph('=== IS: {{item.name}} ===')
|
||||||
|
doc.add_paragraph('Category: {{item.category}}')
|
||||||
|
doc.add_paragraph('Threat type: {{item.threat_type}}')
|
||||||
|
doc.add_paragraph('Defence level: {{item.defence_level}}')
|
||||||
|
doc.add_paragraph('')
|
||||||
|
|
||||||
|
# Inner loop: commission
|
||||||
|
doc.add_paragraph('Commission members:')
|
||||||
|
doc.add_paragraph('<!-- loop:commission -->')
|
||||||
|
doc.add_paragraph(' - {{item.role}}: {{item.position}} {{item.fio}}')
|
||||||
|
doc.add_paragraph('<!-- loop_end -->')
|
||||||
|
|
||||||
|
doc.add_paragraph('')
|
||||||
|
doc.add_paragraph('--- end of IS ---')
|
||||||
|
doc.add_paragraph('')
|
||||||
|
doc.add_paragraph('<!-- loop_end -->')
|
||||||
|
|
||||||
|
doc.add_paragraph('')
|
||||||
|
doc.add_paragraph('Footer: Generated automatically')
|
||||||
|
|
||||||
|
path = '/tmp/test_template_nested.docx'
|
||||||
|
doc.save(path)
|
||||||
|
print(f'✅ Test template created: {path}')
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_zip_loop_expansion():
|
||||||
|
"""Test ZIP-level loop expansion with mock data."""
|
||||||
|
print('\n' + '='*60)
|
||||||
|
print('TEST: ZIP-level loop expansion')
|
||||||
|
print('='*60)
|
||||||
|
|
||||||
|
# Create test data
|
||||||
|
company = Company(
|
||||||
|
full_name='ООО "Ромашка"',
|
||||||
|
short_name='Ромашка',
|
||||||
|
inn='7701234567',
|
||||||
|
chief_fio='Иванов Иван Иванович',
|
||||||
|
chief_position='Генеральный директор',
|
||||||
|
commission=[
|
||||||
|
CommissionMember(role='Председатель', position='Директор', fio='Иванов И.И.'),
|
||||||
|
CommissionMember(role='Секретарь', position='Секретарь', fio='Петров П.П.'),
|
||||||
|
CommissionMember(role='Член комиссии', position='Бухгалтер', fio='Сидорова А.А.'),
|
||||||
|
],
|
||||||
|
information_systems=[
|
||||||
|
InformationSystem(name='ИС-1 "Бухгалтерия"', category='специальные',
|
||||||
|
pd_count='менее 100 000', threat_type='1', defence_level='2'),
|
||||||
|
InformationSystem(name='ИС-2 "Кадры"', category='иные',
|
||||||
|
pd_count='более 100 000', threat_type='3', defence_level='4'),
|
||||||
|
InformationSystem(name='ИС-3 "Склад"', category='общедоступные',
|
||||||
|
pd_count='менее 100 000', threat_type='2', defence_level='3'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build replacements
|
||||||
|
replacements = build_replacements(
|
||||||
|
company=company,
|
||||||
|
is_list=company.information_systems,
|
||||||
|
doc_number='001',
|
||||||
|
doc_date='31.07.2026',
|
||||||
|
direction='152fz'
|
||||||
|
)
|
||||||
|
|
||||||
|
loops = replacements.get('_loops', {})
|
||||||
|
print(f'\nLoops found: {list(loops.keys())}')
|
||||||
|
print(f'IS items: {len(loops.get("information_systems", []))}')
|
||||||
|
print(f'Commission items: {len(loops.get("commission", []))}')
|
||||||
|
|
||||||
|
# Create test template
|
||||||
|
template_path = create_test_template()
|
||||||
|
|
||||||
|
# Verify template has markers
|
||||||
|
with zipfile.ZipFile(template_path, 'r') as z:
|
||||||
|
doc_xml = z.read('word/document.xml').decode('utf-8')
|
||||||
|
|
||||||
|
start_count = doc_xml.count('loop:')
|
||||||
|
end_count = doc_xml.count('loop_end')
|
||||||
|
print(f'\nMarkers in template: {start_count} starts, {end_count} ends')
|
||||||
|
|
||||||
|
# Process with ZIP-level loop expansion
|
||||||
|
print('\nProcessing loops via ZIP...')
|
||||||
|
result_path = expand_loops_in_zip(template_path, loops)
|
||||||
|
|
||||||
|
# Verify result
|
||||||
|
with zipfile.ZipFile(result_path, 'r') as z:
|
||||||
|
result_xml = z.read('word/document.xml').decode('utf-8')
|
||||||
|
|
||||||
|
remaining_starts = len(re.findall(r'loop:(\w+)', result_xml))
|
||||||
|
remaining_ends = result_xml.count('loop_end')
|
||||||
|
|
||||||
|
print(f'Markers after expansion: {remaining_starts} starts, {remaining_ends} ends')
|
||||||
|
|
||||||
|
if remaining_starts == 0 and remaining_ends == 0:
|
||||||
|
print('✅ ALL LOOPS EXPANDED SUCCESSFULLY')
|
||||||
|
else:
|
||||||
|
print(f'⚠️ {remaining_starts} loop markers remaining')
|
||||||
|
|
||||||
|
# Now test full process_template
|
||||||
|
print('\n' + '='*60)
|
||||||
|
print('TEST: Full process_template with all replacements')
|
||||||
|
print('='*60)
|
||||||
|
|
||||||
|
# Rebuild replacements (they were consumed)
|
||||||
|
replacements2 = build_replacements(
|
||||||
|
company=company,
|
||||||
|
is_list=company.information_systems,
|
||||||
|
doc_number='001',
|
||||||
|
doc_date='31.07.2026',
|
||||||
|
direction='152fz'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
output_path = process_template(
|
||||||
|
template_path=template_path,
|
||||||
|
replacements=replacements2,
|
||||||
|
output_path='/tmp/test_output.docx',
|
||||||
|
log_func=print
|
||||||
|
)
|
||||||
|
print(f'\n✅ Output generated: {output_path}')
|
||||||
|
|
||||||
|
# Verify output
|
||||||
|
with zipfile.ZipFile(output_path, 'r') as z:
|
||||||
|
out_xml = z.read('word/document.xml').decode('utf-8')
|
||||||
|
|
||||||
|
# Check no remaining variables
|
||||||
|
remaining_vars = re.findall(r'\{\{[^}]+\}\}', out_xml)
|
||||||
|
remaining_old = re.findall(r'<[A-Za-z_]+>', out_xml)
|
||||||
|
|
||||||
|
if remaining_vars:
|
||||||
|
print(f'⚠️ Remaining {{...}} vars: {remaining_vars[:5]}')
|
||||||
|
else:
|
||||||
|
print('✅ No remaining {{...}} variables')
|
||||||
|
|
||||||
|
if remaining_old:
|
||||||
|
print(f'⚠️ Remaining <...> vars: {remaining_old[:5]}')
|
||||||
|
else:
|
||||||
|
print('✅ No remaining <...> variables')
|
||||||
|
|
||||||
|
# Check IS names appear
|
||||||
|
for isys in company.information_systems:
|
||||||
|
if isys.name in out_xml:
|
||||||
|
print(f'✅ Found IS name: {isys.name}')
|
||||||
|
else:
|
||||||
|
print(f'⚠️ MISSING IS name: {isys.name}')
|
||||||
|
|
||||||
|
# Check commission members appear
|
||||||
|
for m in company.commission:
|
||||||
|
if m.fio in out_xml:
|
||||||
|
print(f'✅ Found commission: {m.fio}')
|
||||||
|
else:
|
||||||
|
print(f'⚠️ MISSING commission: {m.fio}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f'❌ Error: {e}')
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
os.unlink(template_path)
|
||||||
|
if os.path.exists(result_path):
|
||||||
|
os.unlink(result_path)
|
||||||
|
if os.path.exists('/tmp/test_output.docx'):
|
||||||
|
os.unlink('/tmp/test_output.docx')
|
||||||
|
|
||||||
|
print('\n' + '='*60)
|
||||||
|
print('TEST COMPLETE')
|
||||||
|
print('='*60)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
test_zip_loop_expansion()
|
||||||
Reference in New Issue
Block a user