Исходный код DokoGen (генератор документов 152-ФЗ)

This commit is contained in:
2026-08-03 21:46:55 +04:00
commit 71eda2ec3f
27 changed files with 6104 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""DokoGen — Пакет генератора документов 152-ФЗ"""
from .models import Company, CommissionMember, InformationSystem, PaperDocument
from .declension import inflect_name, decline, company_name_decline, get_short_fio, get_initials
from .variables import build_replacements, VARIABLE_DEFS
from .generator import process_template
# Импорт из Excel
from . import importer
__version__ = "1.0.0"
__app_name__ = "ДоКоГеНеРаТоР"
+13
View File
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""DokoGen — Точка входа"""
import sys
import os
# Добавляем путь к пакету
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ui import main
if __name__ == "__main__":
main()
+70
View File
@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""DokoGen — настройки API для внешних сервисов проверки.
Хранятся в JSON рядом с программой (api_settings.json).
Поля:
- dadata_token — API-ключ DaData (проверка организации по ИНН)
- ai_api_key — API-ключ ИИ (OpenAI-совместимый: DeepSeek, ChatGPT и др.)
- ai_model — модель ИИ (deepseek-chat, gpt-4o-mini, gpt-4o и т.п.)
- ai_base_url — адрес API ИИ (по умолчанию DeepSeek)
"""
import os
import sys
import json
DEFAULT_SETTINGS = {
'dadata_token': '',
'ai_api_key': '',
'ai_model': 'deepseek-chat',
'ai_base_url': 'https://api.deepseek.com',
}
# Старые ключи (для совместимости со старым файлом api_settings.json)
_LEGACY_KEYS = {
'deepseek_api_key': 'ai_api_key',
'deepseek_model': 'ai_model',
}
def default_api_settings_path():
"""Путь к файлу настроек: рядом с exe (frozen) или рядом с модулем."""
if getattr(sys, 'frozen', False):
base = os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, 'api_settings.json')
def load_api_settings(path=None):
"""Загрузка настроек API из JSON-файла. Если файла нет — дефолт."""
if path is None:
path = default_api_settings_path()
if not path or not os.path.exists(path):
return dict(DEFAULT_SETTINGS)
try:
with open(path, encoding='utf-8-sig') as f:
loaded = json.load(f)
merged = dict(DEFAULT_SETTINGS)
for k, v in loaded.items():
# переносим старые ключи в новые
if k in _LEGACY_KEYS and not loaded.get(_LEGACY_KEYS[k]):
merged[_LEGACY_KEYS[k]] = v
elif k in merged:
merged[k] = v
return merged
except Exception:
return dict(DEFAULT_SETTINGS)
def save_api_settings(settings, path=None):
"""Сохранение настроек API. Возвращает True/False."""
if path is None:
path = default_api_settings_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
json.dump(settings, f, ensure_ascii=False, indent=2)
return True
except Exception:
return False
Binary file not shown.
+233
View File
@@ -0,0 +1,233 @@
# -*- coding: utf-8 -*-
"""DokoGen — Склонение (pymorphy3 + правила для ФИО/организаций)"""
from functools import lru_cache
from typing import Dict
import re
try:
import pymorphy3
morph = pymorphy3.MorphAnalyzer()
MORPH_AVAILABLE = True
except ImportError:
morph = None
MORPH_AVAILABLE = False
CASES = ["nomn", "gent", "datv", "accs", "ablt", "loct"]
CASE_MAP = {
"nominative": "nomn", "им": "nomn",
"genitive": "gent", "род": "gent",
"dative": "datv", "дат": "datv",
"accusative": "accs", "вин": "accs",
"instrumental": "ablt", "твор": "ablt",
"prepositional": "loct", "пр": "loct",
}
CASE_NAMES = {
"nomn": "Именительный (Кто? Что?)",
"gent": "Родительный (Кого? Чего?)",
"datv": "Дательный (Кому? Чему?)",
"accs": "Винительный (Кого? Что?)",
"ablt": "Творительный (Кем? Чем?)",
"loct": "Предложный (О ком? О чём?)",
}
CASE_SHORT = {
"nomn": "им.п.", "gent": "род.п.", "datv": "дат.п.",
"accs": "вин.п.", "ablt": "твор.п.", "loct": "пр.п.",
}
def _normalize_case(case: str) -> str:
return CASE_MAP.get(case, "nomn" if case not in CASES else case)
def inflect_name(name: str) -> Dict[str, str]:
"""Склонение названия организации по всем падежам."""
return {gr: company_name_decline(name, gr) for gr in CASES}
@lru_cache(maxsize=512)
def company_name_decline(name: str, case: str) -> str:
"""Склонение названия организации."""
if not name:
return ""
case_gr = _normalize_case(case)
if case_gr == "nomn":
return name
m = re.match(r'^(.+?)\s*([\u00ab\"].*[\u00bb\"])$', name.strip())
if m:
prefix = m.group(1).strip()
quoted = m.group(2)
if prefix:
return decline(prefix, case_gr, decline_all=True) + " " + quoted
return name
return decline(name, case_gr, decline_all=True)
@lru_cache(maxsize=512)
def decline(text: str, case: str, decline_all: bool = False) -> str:
"""Склонение строки (ФИО, фразы) по падежам."""
if not text:
return ""
case_gr = _normalize_case(case)
if case_gr == "nomn":
return text
clean_text, quoted = _extract_quoted(text)
tokens = re.split(r"(\s+)", clean_text)
content_tokens = [t for t in tokens if not t.isspace() and t.strip()]
only_first = False
if not decline_all and len(content_tokens) >= 2:
for ct in content_tokens[1:]:
if ct[0].islower():
only_first = True
break
result_parts = []
content_idx = 0
for token in tokens:
if token.isspace() or not token.strip():
result_parts.append(token)
continue
stripped = token.strip()
if only_first and content_idx > 0:
result_parts.append(token)
content_idx += 1
continue
if _is_quoted(stripped):
result_parts.append(token)
content_idx += 1
continue
if stripped.isupper() and len(stripped) > 1:
result_parts.append(token)
content_idx += 1
continue
inflected = _inflect_word(stripped, case_gr)
if stripped[0].isupper():
inflected = inflected[0].upper() + inflected[1:]
result_parts.append(inflected)
content_idx += 1
return _restore_quoted("".join(result_parts), quoted)
def _inflect_word(word: str, case_gr: str) -> str:
"""Склонение одного слова через pymorphy3 с fallback на ручные правила."""
if not word or len(word) < 2 or case_gr == "nomn":
return word
if not MORPH_AVAILABLE or morph is None:
return _rules_decline(word, case_gr) or word
try:
parsed = morph.parse(word)
if not parsed:
return _rules_decline(word, case_gr) or word
best = parsed[0]
inflected = best.inflect({case_gr})
if inflected:
return inflected.word
return _rules_decline(word, case_gr) or word
except Exception:
return _rules_decline(word, case_gr) or word
def _rules_decline(word: str, case_gr: str) -> str:
"""Ручное склонение фамилий."""
w = word.lower()
# Мужские фамилии на -ов/-ев/-ёв
if w.endswith(('ов', 'ев', 'ёв')) and not w.endswith(('ова', 'ева', 'ёва')):
return {
'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а',
'ablt': word + 'ым', 'loct': word + 'е'
}.get(case_gr, word)
# Мужские фамилии на -ин
if w.endswith('ин') and not w.endswith('ина'):
return {
'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а',
'ablt': word + 'ым', 'loct': word + 'е'
}.get(case_gr, word)
# Женские фамилии на -ова/-ева/-ёва
if w.endswith(('ова', 'ева', 'ёва')):
stem = word[:-1]
return {
'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у',
'ablt': stem + 'ой', 'loct': stem + 'ой'
}.get(case_gr, word)
# Женские фамилии на -ина
if w.endswith('ина'):
stem = word[:-1]
return {
'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у',
'ablt': stem + 'ой', 'loct': stem + 'ой'
}.get(case_gr, word)
return word
def _extract_quoted(text: str):
"""Извлекает подстроки в кавычках, заменяет плейсхолдерами."""
if not text:
return text, {}
pattern = re.compile(r'(\u00ab[^\u00bb]*\u00bb|"[^"]*")')
placeholders = {}
counter = [0]
def _replace(m):
ph = f'__Q_{counter[0]}__'
placeholders[ph] = m.group(0)
counter[0] += 1
return ph
return pattern.sub(_replace, text), placeholders
def _restore_quoted(text: str, placeholders: dict) -> str:
for ph, original in placeholders.items():
text = text.replace(ph, original)
return text
def _is_quoted(word: str) -> bool:
w = word.strip()
if not w:
return False
return (w.startswith("\u00ab") and w.endswith("\u00bb")) or \
(w.startswith('"') and w.endswith('"'))
# ==================== ФОРМАТИРОВАНИЕ ФИО ====================
def _initial(word: str) -> str:
"""Инициал из слова: если уже с точкой — вернуть как есть,
иначе первая буква + точка. «В.Н.» → «В.Н.», «Владимир» → «В.»"""
w = word.strip()
if not w:
return ''
if w.endswith('.'):
return w
return w[0] + '.'
def get_short_fio(fio: str) -> str:
"""Фамилия И.О."""
if not fio:
return ""
parts = fio.split()
if len(parts) >= 2:
initials = _initial(parts[1])
if len(parts) >= 3:
initials += _initial(parts[2])
return parts[0] + " " + initials
return fio
def get_initials(fio: str) -> str:
"""И.О. Фамилия"""
if not fio:
return ""
parts = fio.split()
if len(parts) >= 2:
initials = _initial(parts[1])
if len(parts) >= 3:
initials += _initial(parts[2])
return initials + " " + parts[0]
return fio
+14
View File
@@ -0,0 +1,14 @@
[
"Антивирусное ПО",
"СКЗИ (криптография)",
"Межсетевой экран",
"СЗИ от НСД",
"СОВ (обнаружение вторжений)",
"Доверенная загрузка",
"SIEM-система",
"DLP-система",
"VPN",
"Система парольной защиты",
"Антиспам",
"Средства резервного копирования"
]
+22
View File
@@ -0,0 +1,22 @@
[
"Сбор персональных данных",
"Запись персональных данных",
"Систематизация персональных данных",
"Накопление персональных данных",
"Хранение персональных данных",
"Уточнение (обновление, изменение) персональных данных",
"Извлечение персональных данных",
"Использование персональных данных",
"Передача (распространение, предоставление, доступ) персональных данных",
"Обезличивание персональных данных",
"Блокирование персональных данных",
"Удаление персональных данных",
"Уничтожение персональных данных",
"Трансграничная передача персональных данных",
"Автоматизированная обработка персональных данных",
"Неавтоматизированная обработка персональных данных",
"Смешанная обработка персональных данных",
"Формирование и ведение баз данных",
"Передача данных третьим лицам",
"Внутренняя передача данных между подразделениями"
]
+8
View File
@@ -0,0 +1,8 @@
[
"работники оператора",
"клиенты",
"контрагенты",
"родственники работников",
"соискатели",
"иные лица"
]
+28
View File
@@ -0,0 +1,28 @@
[
"Фамилия, имя, отчество",
"Дата рождения",
"Место рождения",
"Паспортные данные",
"Адрес регистрации",
"Адрес проживания",
"ИНН",
"СНИЛС",
"Номер телефона",
"Адрес электронной почты",
"Образование",
"Профессия",
"Сведения о доходах",
"Семейное положение",
"Состав семьи",
"Состояние здоровья",
"Национальность",
"Гражданство",
"Сведения о судимости",
"Фотография",
"Биометрические данные",
"Рабочий e-mail",
"Должность",
"Табельный номер",
"Сведения о воинском учете",
"Сведения о командировках"
]
Binary file not shown.
+862
View File
@@ -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'(?:&lt;!--|<!--)\s*loop:(\w+)\s*(?:--&gt;|-->)')
LOOP_END = re.compile(r'(?:&lt;!--|<!--)\s*loop_end\s*(?:--&gt;|-->)')
# Маппинг старых переменных <...> → {{...}}
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
+281
View File
@@ -0,0 +1,281 @@
# -*- coding: utf-8 -*-
"""DokoGen — справка и инструкции (Word) + пустой опросник (Excel).
Файлы генерируются при первом обращении и кладутся в папку docs/
рядом с программой (при сборке .exe — рядом с exe):
- Инструкция_пользователя.docx
- Инструкция_администратора.docx
- Опросный_лист_152_ФЗ.xlsx (пустой шаблон)
"""
import os
import sys
import shutil
try:
import docx
from docx.shared import Pt, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
DOCX_AVAILABLE = True
except ImportError:
DOCX_AVAILABLE = False
def docs_dir():
"""Папка docs рядом с exe (frozen) или рядом с модулем."""
if getattr(sys, 'frozen', False):
base = os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.abspath(__file__))
d = os.path.join(base, 'docs')
os.makedirs(d, exist_ok=True)
return d
def user_manual_path():
return os.path.join(docs_dir(), 'Инструкция_пользователя.docx')
def admin_manual_path():
return os.path.join(docs_dir(), 'Инструкция_администратора.docx')
def variables_reference_path():
return os.path.join(docs_dir(), 'Переменные_шаблонов.docx')
def survey_path():
return os.path.join(docs_dir(), 'Опросный_лист_152_ФЗ.xlsx')
def _h(doc, text, size=14):
p = doc.add_paragraph()
run = p.add_run(text)
run.bold = True
run.font.size = Pt(size)
return p
def _p(doc, text, bold=False):
p = doc.add_paragraph()
run = p.add_run(text)
run.bold = bold
run.font.size = Pt(11)
return p
def _bullet(doc, text):
p = doc.add_paragraph(style='List Bullet')
run = p.add_run(text)
run.font.size = Pt(11)
return p
def _generate_variables_reference(path):
"""Справочник переменных шаблонов (Word)."""
try:
from .variables import VARIABLE_DEFS
except Exception:
VARIABLE_DEFS = []
doc = docx.Document()
_h(doc, 'Переменные шаблонов')
_p(doc, 'Список переменных для шаблонов Word. Переменная подставляется в текст '
'как есть: {{имя_переменной}}. Циклы: <!-- loop:имя --> ... <!-- loop_end -->.')
groups = {'A': 'Общие переменные', 'B': '152-ФЗ (персональные данные)'}
by_group = {'A': [], 'B': []}
for var, group, desc, _ in VARIABLE_DEFS:
by_group.setdefault(group, []).append((var, desc))
for gkey, title in groups.items():
items = by_group.get(gkey, [])
if not items:
continue
_h(doc, title, 12)
for var, desc in items:
_bullet(doc, f'{var}{desc}')
_h(doc, 'Циклы', 12)
_bullet(doc, 'information_systems — ИС ({{item.name}}, {{item.description}}, {{item.structure}}, {{item.pd_actions}}, {{item.defense_tools_list}}, {{item.users}}, {{item.category}}, {{item.pd_count}} и др.).')
_bullet(doc, 'commission — комиссия ({{item.role}}, {{item.position}}, {{item.fio}}, {{item.fio_initials}}, {{item.fio_short}}, {{item.signature}}).')
_bullet(doc, 'employees_access — сотрудники с доступом ({{item.position}}, {{item.fio}}).')
_bullet(doc, 'paper_documents — бумажные документы ({{item.document}}, {{item.storage}}).')
doc.save(path)
def _generate_user_manual(path):
"""Инструкция пользователя: программа, циклы, переменные, проверки."""
doc = docx.Document()
_h(doc, 'ДоКоГеНеРаТоР — 152-ФЗ')
_h(doc, 'Инструкция пользователя', 13)
_h(doc, '1. Что это за программа', 12)
_p(doc, 'Программа автоматически заполняет шаблоны документов по 152-ФЗ '
'(акты обследования, политики, инструкции, приказы и т.д.) данными '
'об организации, информационных системах, комиссии и сотрудниках. '
'Данные можно ввести вручную на вкладках или импортировать из '
'опросного листа Excel.')
_h(doc, '2. Основные вкладки', 12)
_bullet(doc, 'Организация — реквизиты, адреса, руководитель, склонения названий, проверка данных (Морфер / DaData / ИИ).')
_bullet(doc, 'Информационные системы — ИС, категории ПДн, действия с ПДн, средства защиты, пользователи.')
_bullet(doc, 'Комиссия — состав комиссии по классификации ИСПДн.')
_bullet(doc, 'Пользователи — сотрудники с доступом к ПДн.')
_bullet(doc, 'Бумажные документы — бумажные носители ПДн и места хранения.')
_bullet(doc, 'Генерация — выбор шаблонов и запуск генерации.')
_bullet(doc, 'Лог — журнал действий, экспорт в файл.')
_h(doc, '3. Переменные в шаблонах', 12)
_p(doc, 'В шаблонах Word используются переменные в двойных фигурных скобках, '
'например {{company_full_name}}, {{inn}}, {{chief_fio_initials}}. '
'Полный список — в пункте меню «Справка → Переменные шаблонов».')
_p(doc, 'Переменные, которые не удалось заменить (нет данных), подсвечиваются '
'КРАСНЫМ маркером. Незаполненная информация заменяется прочерком «—» '
'и подсвечивается ЖЁЛТЫМ.')
_h(doc, '4. Циклы (повторяющиеся блоки)', 12)
_p(doc, 'Если в документе нужно повторить блок для каждой ИС, комиссии, '
'сотрудника или бумажного документа — используются маркеры цикла:')
_p(doc, '<!-- loop:information_systems --> ... {{item.name}} ... <!-- loop_end -->', bold=True)
_p(doc, 'Доступные циклы:')
_bullet(doc, 'information_systems — ИС ({{item.name}}, {{item.description}}, {{item.structure}}, {{item.pd_actions}}, {{item.defense_tools_list}}, {{item.users}}, {{item.category}}, {{item.pd_count}} и др.).')
_bullet(doc, 'commission — комиссия ({{item.role}}, {{item.position}}, {{item.fio}}, {{item.fio_initials}}, {{item.fio_short}}, {{item.signature}}).')
_bullet(doc, 'employees_access — сотрудники с доступом ({{item.position}}, {{item.fio}}).')
_bullet(doc, 'paper_documents — бумажные документы ({{item.document}}, {{item.storage}}).')
_p(doc, 'Внутри цикла переменные пишутся через точку: {{item.поле}}. '
'Допускается и синтаксис без точки: {{item_поле}}.')
_h(doc, '5. Импорт из опросника', 12)
_p(doc, 'Меню «Генерация → Импорт из опросника» (или кнопка «📥 Импорт»). '
'Выберите заполненный опросный лист Excel — данные подтянутся '
'автоматически. Что именно импортировать и откуда — настраивается '
'в файле import_settings.json (см. инструкцию администратора).')
_h(doc, '6. Проверка данных', 12)
_bullet(doc, 'Склонение — Морфер 3.0 (бесплатно, без ключа) или ИИ.')
_bullet(doc, 'Реквизиты — DaData (по ИНН) или ИИ.')
_bullet(doc, 'ИИ-результаты помечаются дисклеймером «сгенерировано ИИ — перепроверьте».')
_bullet(doc, 'Ключи API задаются в меню «Файл → Настройки».')
_h(doc, '7. Генерация', 12)
_p(doc, '1) Укажите папку с шаблонами (кнопка «📁 Шаблоны»).\n'
'2) Выберите нужные шаблоны в списке.\n'
'3) Укажите папку для результатов.\n'
'4) Нажмите «⚡ Генерация».')
_p(doc, 'Результаты сохраняются в выбранную папку. Если какой-то документ '
'не создался — смотрите вкладку «Лог»: там будет причина.')
doc.save(path)
def _generate_admin_manual(path):
"""Инструкция администратора: словари, API-ключи, лимиты."""
doc = docx.Document()
_h(doc, 'ДоКоГеНеРаТоР — 152-ФЗ')
_h(doc, 'Инструкция администратора', 13)
_h(doc, '1. Файлы настроек рядом с программой', 12)
_bullet(doc, 'api_settings.json — API-ключи (DaData, ИИ).')
_bullet(doc, 'import_settings.json — словарь импорта: что и откуда брать из опросника.')
_bullet(doc, 'dictionaries/ — справочники: defense_tools.json, pd_actions.json, pd_items.json, pd_categories.json.')
_bullet(doc, 'dokogen.log — журнал работы (пишется автоматически).')
_bullet(doc, 'docs/ — инструкции и пустой опросник (создаются автоматически).')
_h(doc, '2. Словари (папка dictionaries)', 12)
_p(doc, 'JSON-файлы со списками значений для выпадающих списков. '
'Формат — простой массив строк:')
_p(doc, '[\n "Антивирусное ПО",\n "СКЗИ (криптография)",\n "Межсетевой экран"\n]', bold=True)
_p(doc, 'Чтобы добавить значение — откройте файл в Блокноте, добавьте строку '
'через запятую и сохраните. Программа перечитает файл при следующем '
'открытии окна выбора.')
_h(doc, '3. Словарь импорта (import_settings.json)', 12)
_p(doc, 'Определяет, какие поля опросника импортируются и откуда. Каждое правило:')
_p(doc, '{"name": "Полное наименование организации", "var": "fullName", '
'"cell": "\'Общие сведения\'!B2", "keywords": "полное наименование организации"}', bold=True)
_bullet(doc, 'cell — конкретная ячейка (если заполнена — берём оттуда);')
_bullet(doc, 'keywords — регулярное выражение для поиска строки-вопроса в колонке A;')
_bullet(doc, 'var — ключ в данных программы (fullName, inn, kpp, ogrn и т.д.).')
_p(doc, 'Также настраиваются: common_sheet_keyword (лист «Общие сведения»), '
'employees_sheet_keyword (лист «Сотрудники»), is_sheet_exclude_keywords '
'(какие листы НЕ ИС), value_column (колонка значения, 1 = B).')
_h(doc, '4. API-ключи (меню «Файл → Настройки»)', 12)
_p(doc, 'DaData — проверка организации по ИНН/ОГРН (наименование, адрес, КПП, руководитель).')
_bullet(doc, 'Где взять: https://dadata.ru → регистрация → раздел API. Бесплатный тариф ~10 000 запросов в день.')
_bullet(doc, 'Ключ: секция «Статический API-ключ» (формат: буквы и цифры).')
_p(doc, 'ИИ (OpenAI-совместимый: DeepSeek, ChatGPT и др.) — проверка склонений и реквизитов.')
_bullet(doc, 'DeepSeek: https://platform.deepseek.com → API Keys. Модель deepseek-chat. Дешёвая, ключ пополняется деньгами.')
_bullet(doc, 'ChatGPT/OpenAI: https://platform.openai.com → API Keys. Модели gpt-4o-mini, gpt-4o.')
_bullet(doc, 'Базовый URL: для DeepSeek можно оставить пустым; для OpenAI — https://api.openai.com.')
_p(doc, 'Лимиты запросов: зависят от тарифа. DaData — ~10 000/сутки на бесплатном тарифе. '
'DeepSeek/OpenAI — оплата за токены, лимиты настраиваются в личном кабинете. '
'Рекомендуется не превышать несколько сотен проверок в день, чтобы не упереться в лимиты.')
_h(doc, '5. Морфер 3.0', 12)
_p(doc, 'Бесплатный веб-сервис склонения слов и ФИО. Ключ не требуется — '
'программа обращается к https://ws3.morpher.ru автоматически.')
_h(doc, '6. Логи', 12)
_p(doc, 'Все действия (импорт, генерация, обращения к Морфер/DaData/ИИ с ответами) '
'записываются в файл dokogen.log рядом с программой. Экспорт лога — '
'кнопка «💾 Экспорт логов» на вкладке «Лог». При проблемах присылайте '
'этот файл разработчику.')
_h(doc, '7. Сборка .exe', 12)
_p(doc, 'Программа собирается с PyInstaller: pyinstaller --onefile --windowed main.py. '
'Файлы настроек (api_settings.json, import_settings.json, dictionaries/, docs/) '
'создаются автоматически рядом с .exe при первом запуске и остаются '
'редактируемыми.')
doc.save(path)
def _ensure_survey():
"""Копирует пустой опросник в docs/, если его ещё нет."""
target = survey_path()
if os.path.exists(target):
return target
# Ищем шаблон опросника: рядом с программой или встроенный
candidates = [
os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'Опросный_лист_152_ФЗ.xlsx'),
os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Опросный_лист_152_ФЗ.xlsx'),
]
for c in candidates:
if os.path.exists(c):
shutil.copy(c, target)
return target
return None
def ensure_docs():
"""Создаёт инструкции и опросник при первом обращении."""
created = []
if DOCX_AVAILABLE:
up = user_manual_path()
if not os.path.exists(up):
try:
_generate_user_manual(up)
created.append(up)
except Exception:
pass
ap = admin_manual_path()
if not os.path.exists(ap):
try:
_generate_admin_manual(ap)
created.append(ap)
except Exception:
pass
vp = variables_reference_path()
if not os.path.exists(vp):
try:
_generate_variables_reference(vp)
created.append(vp)
except Exception:
pass
sp = _ensure_survey()
if sp and os.path.exists(sp):
created.append(sp)
return created
+209
View File
@@ -0,0 +1,209 @@
{
"common_sheet_keyword": "общие",
"employees_sheet_keyword": "сотруд",
"is_sheet_exclude_keywords": [
"общие сведения",
"сотрудники"
],
"value_column": 1,
"fields": [
{
"name": "Полное наименование организации",
"var": "fullName",
"cell": "",
"keywords": "полное наименование организации"
},
{
"name": "Сокращённое наименование организации",
"var": "shortName",
"cell": "",
"keywords": "сокращенное наименование организации"
},
{
"name": "Юридический адрес",
"var": "addressLegal",
"cell": "",
"keywords": "адрес организации \\(юридический\\)"
},
{
"name": "Фактический адрес",
"var": "addressActual",
"cell": "",
"keywords": "адрес организации \\(фактический\\)"
},
{
"name": "ИНН",
"var": "inn",
"cell": "",
"keywords": "инн"
},
{
"name": "Дата ОГРН",
"var": "ogrnDate",
"cell": "",
"keywords": "дата.*огрн"
},
{
"name": "ОГРН",
"var": "ogrn",
"cell": "",
"keywords": "огрн"
},
{
"name": "КПП",
"var": "kpp",
"cell": "",
"keywords": "кпп"
},
{
"name": "Основной ОКВЭД",
"var": "okved",
"cell": "",
"keywords": "основной оквэд"
},
{
"name": "Должность руководителя",
"var": "chiefPosition",
"cell": "",
"keywords": "должность руководителя"
},
{
"name": "ФИО руководителя",
"var": "chiefFio",
"cell": "",
"keywords": "фио руководителя"
},
{
"name": "Должность ответственного за ПДн",
"var": "ispdnPosition",
"cell": "",
"keywords": "должность.*защит.*информ|должность.*администрат"
},
{
"name": "ФИО ответственного за ПДн",
"var": "ispdnFio",
"cell": "",
"keywords": "фио.*защит.*информ|фио.*администрат"
},
{
"name": "Email ответственного за ПДн",
"var": "ispdnEmail",
"cell": "",
"keywords": "электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист"
},
{
"name": "Телефон ответственного за ПДн",
"var": "ispdnPhone",
"cell": "",
"keywords": "телефон.*защит|телефон.*ответств|телефон.*специалист"
},
{
"name": "Структурное подразделение по безопасности",
"var": "ispdnDepartment",
"cell": "",
"keywords": "структур.*подраздел"
},
{
"name": "Номер договора",
"var": "contractNumber",
"cell": "",
"keywords": "номер.*договора|номер.*контракта"
},
{
"name": "Дата договора",
"var": "contractDate",
"cell": "",
"keywords": "дата.*договора|дата.*контракта"
},
{
"name": "Перечень бумажных документов",
"var": "paperDocuments",
"cell": "",
"keywords": "названи.*документ"
},
{
"name": "Место хранения документов",
"var": "paperStorage",
"cell": "",
"keywords": "место.*хранен"
},
{
"name": "Должность ответственного за обработку ПДн",
"var": "responsiblePosition",
"cell": "",
"keywords": "должность.*ответственн"
},
{
"name": "ФИО ответственного за обработку ПДн",
"var": "responsibleFio",
"cell": "",
"keywords": "фио.*ответственн"
},
{
"name": "Должность администратора безопасности",
"var": "administratorPosition",
"cell": "",
"keywords": "должность.*администрат.*(?:безопас|защит|пд)"
},
{
"name": "ФИО администратора безопасности",
"var": "administratorFio",
"cell": "",
"keywords": "фио.*администрат.*(?:безопас|защит|пд)"
},
{
"name": "Сайт организации",
"var": "site_name",
"cell": "",
"keywords": "сайт организации"
},
{
"name": "Должность ответственного за сайт",
"var": "site_responsible_position",
"cell": "",
"keywords": "должность ответственного за сайт|должность.*отв.*сайт"
},
{
"name": "ФИО ответственного за сайт",
"var": "site_responsible_fio",
"cell": "",
"keywords": "фио.*отв.*сайт|фио.*сайт"
},
{
"name": "Разработчик сайта",
"var": "site_service_provider",
"cell": "",
"keywords": "наименование организации.*разрабат.*сайт|наименование.*размещала.*сайт|разрабат.*сайт.*организац"
},
{
"name": "ИНН разработчика сайта",
"var": "site_service_provider_inn",
"cell": "",
"keywords": "инн.*разрабат.*сайт|инн.*размещала.*сайт"
},
{
"name": "Размещение сайта (хостинг)",
"var": "site_hosting",
"cell": "",
"keywords": "размещение сайта|адрес расположения серверов|адрес.*сервер.*сайт"
},
{
"name": "Адрес хостинга",
"var": "site_hosting_address",
"cell": "",
"keywords": "адрес.*хостинг|адрес.*располож.*сервер"
},
{
"name": "Электронная почта",
"var": "email",
"cell": "",
"keywords": "электронная почта"
},
{
"name": "Телефон",
"var": "phone",
"cell": "",
"keywords": "телефон"
}
]
}
+174
View File
@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
"""DokoGen — настройки импорта из опросника (редактируемый словарь).
Идея: «что импортировать и откуда» хранится НЕ в коде, а в текстовом
JSON-файле рядом с программой (import_settings.json). При сборке .exe
файл остаётся редактируемым — можно править пути к ячейкам и ключевые
слова без пересборки.
Формат файла:
{
"common_sheet_keyword": "общие", // ключевое слово листа «Общие сведения»
"employees_sheet_keyword": "сотруд", // ключевое слово листа «Сотрудники»
"is_sheet_exclude_keywords": ["общие сведения", "сотрудники"], // листы, которые НЕ ИС
"value_column": 1, // колонка значения (0=A, 1=B, ...)
"fields": [
{
"name": "Полное наименование организации", // человекочитаемое название
"var": "fullName", // ключ в данных программы
"cell": "'Общие сведения'!B2", // (необязательно) конкретная ячейка
"keywords": "полное наименование организации" // (необязательно) регэксп поиска по колонке A
}
]
}
Порядок применения для каждого поля:
1) если задана cell и ячейка заполнена — берём значение оттуда;
2) иначе ищем строку, где колонка A совпадает с keywords (регэксп),
и берём значение из колонки value_column.
"""
import os
import re
import sys
import json
# ============================================================
# ПУТЬ К ФАЙЛУ НАСТРОЕК ПО УМОЛЧАНИЮ
# ============================================================
def default_import_settings_path():
"""Путь к файлу настроек: рядом с exe (frozen) или рядом с модулем."""
if getattr(sys, 'frozen', False):
base = os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, 'import_settings.json')
# ============================================================
# ДЕФОЛТНЫЙ СЛОВАРЬ (используется, если файл не найден)
# ============================================================
def _f(name, var, cell='', keywords=''):
return {'name': name, 'var': var, 'cell': cell, 'keywords': keywords}
DEFAULT_SETTINGS = {
'common_sheet_keyword': 'общие',
'employees_sheet_keyword': 'сотруд',
'is_sheet_exclude_keywords': ['общие сведения', 'сотрудники'],
'value_column': 1,
'fields': [
_f('Полное наименование организации', 'fullName', keywords=r'полное наименование организации'),
_f('Сокращённое наименование организации', 'shortName', keywords=r'сокращенное наименование организации'),
_f('Юридический адрес', 'addressLegal', keywords=r'адрес организации \(юридический\)'),
_f('Фактический адрес', 'addressActual', keywords=r'адрес организации \(фактический\)'),
_f('ИНН', 'inn', keywords=r'инн'),
_f('Дата ОГРН', 'ogrnDate', keywords=r'дата.*огрн'),
_f('ОГРН', 'ogrn', keywords=r'огрн'),
_f('КПП', 'kpp', keywords=r'кпп'),
_f('Основной ОКВЭД', 'okved', keywords=r'основной оквэд'),
_f('Должность руководителя', 'chiefPosition', keywords=r'должность руководителя'),
_f('ФИО руководителя', 'chiefFio', keywords=r'фио руководителя'),
_f('Должность ответственного за ПДн', 'ispdnPosition', keywords=r'должность.*защит.*информ|должность.*администрат'),
_f('ФИО ответственного за ПДн', 'ispdnFio', keywords=r'фио.*защит.*информ|фио.*администрат'),
_f('Email ответственного за ПДн', 'ispdnEmail', keywords=r'электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист'),
_f('Телефон ответственного за ПДн', 'ispdnPhone', keywords=r'телефон.*защит|телефон.*ответств|телефон.*специалист'),
_f('Структурное подразделение по безопасности', 'ispdnDepartment', keywords=r'структур.*подраздел'),
_f('Номер договора', 'contractNumber', keywords=r'номер.*договора|номер.*контракта'),
_f('Дата договора', 'contractDate', keywords=r'дата.*договора|дата.*контракта'),
_f('Перечень бумажных документов', 'paperDocuments', keywords=r'названи.*документ'),
_f('Место хранения документов', 'paperStorage', keywords=r'место.*хранен'),
_f('Должность ответственного за обработку ПДн', 'responsiblePosition', keywords=r'должность.*ответственн'),
_f('ФИО ответственного за обработку ПДн', 'responsibleFio', keywords=r'фио.*ответственн'),
_f('Должность администратора безопасности', 'administratorPosition', keywords=r'должность.*администрат.*(?:безопас|защит|пд)'),
_f('ФИО администратора безопасности', 'administratorFio', keywords=r'фио.*администрат.*(?:безопас|защит|пд)'),
_f('Сайт организации', 'site_name', keywords=r'сайт организации'),
_f('Должность ответственного за сайт', 'site_responsible_position', keywords=r'должность ответственного за сайт|должность.*отв.*сайт'),
_f('ФИО ответственного за сайт', 'site_responsible_fio', keywords=r'фио.*отв.*сайт|фио.*сайт'),
_f('Разработчик сайта', 'site_service_provider', keywords=r'наименование организации.*разрабат.*сайт|наименование.*размещала.*сайт|разрабат.*сайт.*организац'),
_f('ИНН разработчика сайта', 'site_service_provider_inn', keywords=r'инн.*разрабат.*сайт|инн.*размещала.*сайт'),
_f('Размещение сайта (хостинг)', 'site_hosting', keywords=r'размещение сайта|адрес расположения серверов|адрес.*сервер.*сайт'),
_f('Адрес хостинга', 'site_hosting_address', keywords=r'адрес.*хостинг|адрес.*располож.*сервер'),
_f('Электронная почта', 'email', keywords=r'электронная почта'),
_f('Телефон', 'phone', keywords=r'телефон'),
],
}
# ============================================================
# ЗАГРУЗКА НАСТРОЕК
# ============================================================
def load_settings(path=None):
"""Загрузка словаря импорта из JSON-файла.
path=None -> файл по умолчанию рядом с программой.
Если файла нет или он битый — возвращается встроенный дефолт.
"""
if path is None:
path = default_import_settings_path()
if not path or not os.path.exists(path):
return DEFAULT_SETTINGS
try:
with open(path, encoding='utf-8-sig') as f:
loaded = json.load(f)
# Дополняем недостающие ключи из дефолта
merged = dict(DEFAULT_SETTINGS)
for k, v in loaded.items():
if k == 'fields' and isinstance(v, list):
merged['fields'] = v
else:
merged[k] = v
return merged
except Exception:
return DEFAULT_SETTINGS
def ensure_default_file(path=None):
"""Создаёт файл настроек import_settings.json, если его ещё нет.
Возвращает путь к файлу. Нужно, чтобы пользователь видел словарь
импорта и мог править его в блокноте без пересборки программы.
"""
if path is None:
path = default_import_settings_path()
if os.path.exists(path):
return path
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
json.dump(DEFAULT_SETTINGS, f, ensure_ascii=False, indent=2)
except Exception:
pass
return path
def parse_cell_ref(cell_ref):
"""Разбор ссылки на ячейку: 'Общие сведения'!B2 / Общие сведения!B2 / B2.
Возвращает (sheet_name или None, координаты 'B2').
"""
if not cell_ref:
return None, ''
ref = cell_ref.strip()
if ref.startswith('='):
ref = ref[1:].strip()
if '!' in ref:
sheet_part, coord = ref.split('!', 1)
sheet_part = sheet_part.strip()
if sheet_part.startswith("'") and sheet_part.endswith("'"):
sheet_part = sheet_part[1:-1]
return sheet_part or None, coord.strip().upper()
return None, ref.upper()
def cell_value(ws, coord):
"""Значение ячейки по координате 'B2' (с нормализацией)."""
if not coord:
return ''
try:
val = ws[coord]
return val.value if val is not None else ''
except Exception:
return ''
+560
View File
@@ -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}»")
+153
View File
@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
"""DokoGen — Модели данных (только 152-ФЗ, только нужные поля)"""
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Any
@dataclass
class CommissionMember:
"""Член комиссии по ПДн"""
role: str = "член комиссии"
position: str = ""
fio: str = ""
@dataclass
class InformationSystem:
"""Информационная система (обработка ПДн)"""
name: str = ""
description: str = ""
is_local_network: bool = False
is_internet: bool = False
defence_level: str = ""
threat_type: str = "3"
category: str = ""
pd_list: str = ""
pd_count: str = "менее 100 000"
users: str = ""
defense_tools_list: List[str] = field(default_factory=list)
processing_modes: str = ""
subject_type: str = "работники организации"
pd_actions_list: List[str] = field(default_factory=list)
pd_subjects_list: List[str] = field(default_factory=list)
personal_data_list: List[str] = field(default_factory=list)
users_list: List[str] = field(default_factory=list)
personal_data_category: List[str] = field(default_factory=list)
purpose: str = ""
room: str = ""
structure: str = "" # Структура информационной системы
software: str = "" # Программное обеспечение
@dataclass
class PaperDocument:
"""Бумажный документ с ПДн"""
name: str = ""
storage: str = ""
@dataclass
class Company:
"""Организация — основной объект (152-ФЗ)"""
# Полное название
full_name: str = ""
company_name_1: str = "" # именительный
company_name_2: str = "" # родительный
company_name_3: str = "" # дательный
company_name_4: str = "" # винительный
company_name_5: str = "" # творительный
company_name_6: str = "" # предложный
# Краткое название
short_name: str = ""
short_name_1: str = ""
short_name_2: str = ""
short_name_3: str = ""
short_name_4: str = ""
short_name_5: str = ""
short_name_6: str = ""
# Адрес и реквизиты
address: str = ""
city_name: str = ""
inn: str = ""
ogrn: str = ""
ogrn_date: str = ""
kpp: str = ""
# Должностные лица
chief_position: str = ""
chief_fio: str = ""
responsible_position: str = ""
responsible_fio: str = ""
admin_position: str = ""
admin_fio: str = ""
# Документ
contract_number: str = ""
contract_date: str = ""
# Списки
commission: List[CommissionMember] = field(default_factory=list)
information_systems: List[InformationSystem] = field(default_factory=list)
paper_documents_list: List[PaperDocument] = field(default_factory=list)
employees_access: List[Dict[str, str]] = field(default_factory=list)
# Наименование структурного подразделения по безопасности
security_department: str = ""
def update_declensions(self, inflect_func):
"""Обновить склонения полного названия"""
if self.full_name:
inflected = inflect_func(self.full_name)
self.company_name_1 = self.full_name
self.company_name_2 = inflected.get("gent", "")
self.company_name_3 = inflected.get("datv", "")
self.company_name_4 = inflected.get("accs", "")
self.company_name_5 = inflected.get("ablt", "")
self.company_name_6 = inflected.get("loct", "")
def update_short_declensions(self, inflect_func):
"""Обновить склонения сокращённого названия"""
if self.short_name:
inflected = inflect_func(self.short_name)
self.short_name_1 = self.short_name
self.short_name_2 = inflected.get("gent", "")
self.short_name_3 = inflected.get("datv", "")
self.short_name_4 = inflected.get("accs", "")
self.short_name_5 = inflected.get("ablt", "")
self.short_name_6 = inflected.get("loct", "")
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Company":
d = dict(data)
if "commission" in d and isinstance(d["commission"], list):
d["commission"] = [
CommissionMember(**c) if isinstance(c, dict) else c
for c in d["commission"]
]
if "information_systems" in d and isinstance(d["information_systems"], list):
d["information_systems"] = [
InformationSystem(**isys) if isinstance(isys, dict) else isys
for isys in d["information_systems"]
]
if "paper_documents_list" in d and isinstance(d["paper_documents_list"], list):
d["paper_documents_list"] = [
PaperDocument(**p) if isinstance(p, dict) else p
for p in d["paper_documents_list"]
]
if "employees_access" in d and not isinstance(d["employees_access"], list):
d["employees_access"] = []
valid = {f.name for f in cls.__dataclass_fields__.values()}
return cls(**{k: v for k, v in d.items() if k in valid})
def get_folder_name(self) -> str:
if self.short_name:
return self.short_name
if self.full_name:
return self.full_name[:30]
return "Новая_организация"
+2240
View File
File diff suppressed because it is too large Load Diff
+521
View File
@@ -0,0 +1,521 @@
# -*- coding: utf-8 -*-
"""DokoGen — Справочник переменных шаблонов и резолвер значений"""
from datetime import datetime
from typing import Dict, Any, List, Optional
import re
from . import declension as dc
from .models import Company, CommissionMember
def _norm_name(s):
"""Нормализация ФИО для сравнения: регистр, пробелы, точки, тире игнорируются."""
s = (s or '').lower().replace('ё', 'е')
return re.sub(r'[\s.,;:()«»"\'\-]+', '', s)
# ============================================================
# ОПИСАНИЕ ПЕРЕМЕННЫХ
# ============================================================
# Формат: (имя_переменной, группа, описание, ключ_резолвера)
# Группы: A — общие, B — 152-ФЗ
VARIABLE_DEFS = [
# === Полное название организации (6 падежей) ===
('{{company_full_name}}', 'A', 'Полное наименование организации (им.п.)', 'company:full_name'),
('{{company_full_name_genitive}}', 'A', 'Полное наименование (род.п.)', 'decl:full_name:gent'),
('{{company_full_name_dative}}', 'A', 'Полное наименование (дат.п.)', 'decl:full_name:datv'),
('{{company_full_name_accs}}', 'A', 'Полное наименование (вин.п.)', 'decl:full_name:accs'),
('{{company_full_name_ablt}}', 'A', 'Полное наименование (твор.п.)', 'decl:full_name:ablt'),
('{{company_full_name_loct}}', 'A', 'Полное наименование (пр.п.)', 'decl:full_name:loct'),
# === Краткое название организации (6 падежей) ===
('{{company_short_name}}', 'A', 'Краткое наименование (им.п.)', 'company:short_name'),
('{{company_short_name_genitive}}', 'A', 'Краткое наименование (род.п.)', 'decl:short_name:gent'),
('{{company_short_name_dative}}', 'A', 'Краткое наименование (дат.п.)', 'decl:short_name:datv'),
('{{company_short_name_accs}}', 'A', 'Краткое наименование (вин.п.)', 'decl:short_name:accs'),
('{{company_short_name_ablt}}', 'A', 'Краткое наименование (твор.п.)', 'decl:short_name:ablt'),
('{{company_short_name_loct}}', 'A', 'Краткое наименование (пр.п.)', 'decl:short_name:loct'),
# === Должностные лица ===
('{{chief_position}}', 'A', 'Должность руководителя', 'company:chief_position'),
('{{chief_fio_initials}}', 'A', 'И.О. Фамилия руководителя', 'initials:chief'),
('{{chief_fio_short}}', 'A', 'Фамилия И.О. руководителя', 'short_fio:chief'),
('{{responsible_position}}', 'A', 'Должность ответственного', 'company:responsible_position'),
('{{responsible_fio_initials}}', 'A', 'И.О. Фамилия ответственного', 'initials:responsible'),
('{{responsible_fio_short}}', 'A', 'Фамилия И.О. ответственного', 'short_fio:responsible'),
('{{admin_position}}', 'A', 'Должность администратора', 'company:admin_position'),
('{{admin_fio_initials}}', 'A', 'И.О. Фамилия администратора', 'initials:admin'),
('{{admin_fio_short}}', 'A', 'Фамилия И.О. администратора', 'short_fio:admin'),
# === Сведения об организации ===
('{{company_address}}', 'A', 'Адрес организации', 'company:address'),
('{{city_name}}', 'A', 'Город', 'company:city_name'),
('{{company_inn}}', 'A', 'ИНН', 'company:inn'),
('{{company_ogrn}}', 'A', 'ОГРН', 'company:ogrn'),
('{{company_ogrn_date}}', 'A', 'Дата ОГРН', 'company:ogrn_date'),
('{{company_kpp}}', 'A', 'КПП', 'company:kpp'),
('{{security_department}}', 'A', 'Наименование структурного подразделения по безопасности', 'company:security_department'),
('{{item.pd_actions}}', 'B', 'Действия с ПДн (в цикле ИС)', 'item:pd_actions'),
# === Комиссия ===
('{{commission_list}}', 'A', 'Список комиссии', 'commission_list'),
# === Специальные переменные ===
('{{date_year}}', 'A', 'Текущий год', 'date:year'),
('{{date}}', 'A', 'Текущая дата', 'date:full'),
('{{document_date}}', 'A', 'Дата документа', 'doc_date'),
('{{document_number}}', 'A', 'Номер документа', 'doc_number'),
# === 152-ФЗ ===
('{{is_security_requirements}}', 'B', 'Требования уровня защищённости', 'security_reqs'),
('{{is_list}}', 'B', 'Список всех ИС (нумерованный)', 'is_list'),
('{{is_name}}', 'B', 'Наименование первой ИС', 'is:name'),
('{{name}}', 'B', 'Название ИС (алиас)', 'is:name'),
('{{description}}', 'B', 'Описание ИС (алиас)', 'is:description'),
('{{category}}', 'B', 'Категория ИС (алиас)', 'is:category'),
('{{number}}', 'B', 'Номер (алиас)', 'doc_number'),
('{{pd_count}}', 'B', 'Количество записей ПДн (алиас)', 'is:pd_count'),
('{{pd_list}}', 'B', 'Список ПДн (алиас)', 'is:pd_list'),
('{{pd_subjects}}', 'B', 'Субъекты ПДн (алиас)', 'is:pd_subjects'),
('{{threat_type}}', 'B', 'Тип угроз (алиас)', 'is:threat_type'),
('{{users}}', 'B', 'Пользователи ИС (алиас)', 'is:users'),
('{{defence_level}}', 'B', 'Уровень защищённости (алиас)', 'is:defence_level'),
('{{full_name_genitive}}', 'A', 'Полное наименование (род.п., алиас)', 'decl:full_name:gent'),
('{{document}}', 'A', 'Название бумажного документа (алиас)', 'company:paper_documents'),
('{{storage}}', 'A', 'Место хранения (алиас)', 'company:paper_storage'),
('{{responsible_fio_accs}}', 'A', 'ФИО ответственного (вин.п.)', 'fio_acl:responsible'),
('{{commission1}}', 'A', 'Должность члена комиссии 1', 'commission_member:0:position'),
('{{commission1_fio}}', 'A', 'ФИО члена комиссии 1', 'commission_member:0:fio'),
('{{commission2}}', 'A', 'Должность члена комиссии 2', 'commission_member:1:position'),
('{{commission2_fio}}', 'A', 'ФИО члена комиссии 2', 'commission_member:1:fio'),
('{{commission3}}', 'A', 'Должность члена комиссии 3', 'commission_member:2:position'),
('{{commission3_fio}}', 'A', 'ФИО члена комиссии 3', 'commission_member:2:fio'),
]
# Маппинг старых переменных <...> → {{...}}
OLD_VAR_MAP = {
'<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}}',
'<SurveyDate>': '{{document_date}}',
}
VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}')
OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>')
# ============================================================
# ПОСТРОЕНИЕ СЛОВАРЯ ЗАМЕН
# ============================================================
def build_replacements(
company: Company,
is_list: List = None,
doc_number: str = "",
doc_date: str = "",
direction: str = "152fz",
) -> Dict[str, str]:
"""Строит полный словарь замен {{переменная}} → значение."""
if is_list is None:
is_list = []
# ---------- Комиссия ----------
commission_data = _build_commission_data(company)
# ---------- Контекст ----------
ctx = {
'company': company,
'is_list': is_list,
'commission_data': commission_data,
'doc_number': doc_number or '',
'doc_date': doc_date or '',
'direction': direction,
}
replacements = {}
for var_name, group, desc, source in VARIABLE_DEFS:
if direction == '152fz' and group not in ('A', 'B'):
continue
value = _resolve(source, ctx)
replacements[var_name] = str(value) if value else ''
# Индексированные переменные ИС (is_name_1, is_pd_list_2 и т.д.)
for i, isys in enumerate(is_list, 1):
_add_is_indexed_vars(replacements, isys, i)
# Циклы
replacements['_loops'] = _build_loop_data(company, is_list)
return replacements
def _commission_role_display(members) -> List[str]:
"""Группирует роли комиссии для вывода:
- Председатель/Секретарь/Заместитель отдельной строкой со своей ролью;
- обычные члены первый получает «Члены комиссии», остальные пустую роль
(чтобы не повторять «Член комиссии» в каждой строке)."""
result = []
member_used = False
for m in members:
role = (m.role or 'член комиссии').strip()
role_lower = role.lower()
if ('председател' in role_lower or 'секретар' in role_lower
or 'заместител' in role_lower or 'зам. председателя' in role_lower):
result.append(role)
continue
# обычный член комиссии
if not member_used:
result.append('Члены комиссии')
member_used = True
else:
result.append('')
return result
def _build_commission_data(company: Company) -> Dict[str, str]:
data = {}
members = company.commission or []
display_roles = _commission_role_display(members)
lines = []
for i, (m, role_disp) in enumerate(zip(members, display_roles), 1):
if role_disp:
line = (f"{i}. {role_disp}: {m.position}{m.fio}" if m.position
else f"{i}. {role_disp}: {m.fio}")
else:
line = (f"{i}. {m.position}{m.fio}" if m.position
else f"{i}. {m.fio}")
lines.append(line)
data['commission_list'] = '\n'.join(lines) if lines else ''
return data
def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int):
fields = {
'is_name': isys.name or '',
'is_address': getattr(isys, 'address', '') or '',
'is_description': isys.description or '',
'is_has_internet': 'да' if isys.is_internet else 'нет',
'is_defence_level': str(isys.defence_level or ''),
'is_category': ', '.join(isys.personal_data_category) or isys.category or '',
'is_pd_list': ', '.join(isys.personal_data_list or []) or '',
'is_users': ', '.join(isys.users_list or []) or '',
'is_pd_count': str(isys.pd_count or ''),
'is_purpose': isys.purpose or '',
'is_structure': getattr(isys, 'structure', '') or '',
}
for f, v in fields.items():
replacements[f'{{{{{f}_{idx}}}}}'] = str(v)
def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
loops = {}
# Комиссия
members = company.commission or []
if members:
display_roles = _commission_role_display(members)
items = []
for m, role_disp in zip(members, display_roles):
pos = m.position or ''
fio = m.fio or ''
if role_disp:
full = f"{role_disp} {pos}{fio}" if pos != '' else f"{role_disp}{fio}"
else:
full = f"{pos}{fio}" if pos != '' else fio
# Строка подписи: «___________ / » (только подчёркивание и слэш).
# Роль НЕ включаем — в шаблонах она выводится отдельной колонкой {{item.role}},
# иначе получается дубль «Председатель комиссии | Председатель комиссии: ___».
if 'Председатель' in role_disp:
sig = f"{'_' * 11} / "
sig_full = f"{role_disp}: {'_' * 11} / "
elif role_disp:
sig = f"{'_' * 17} / "
sig_full = f"{role_disp}: {'_' * 17} / "
else:
sig = f"{'_' * 17} / "
sig_full = f"{'_' * 17} / "
initials = dc.get_initials(fio) if fio != '' else ''
items.append({
'role': role_disp,
'position': pos,
'fio': fio,
'fio_initials': dc.get_initials(fio) if fio != '' else '',
'fio_short': dc.get_short_fio(fio) if fio != '' else '',
'full': full,
# Рамка подписи без роли и ФИО: «___________ / » (ФИО добавится)
'signature': sig,
# Полная подпись с ролью и ФИО: «Роль: ___________ / И.О. Фамилия»
'signature_full': (sig_full + initials).strip(),
})
loops['commission'] = items
# ИС
if is_list:
items = []
for isys in is_list:
# Пользователи ИС для вложенного цикла <!-- loop:users_loop -->
# ВСЕ пользователи, включая администратора (без дублей по ФИО)
seen = set()
users_loop = []
for u in (isys.users_list or []):
key = _norm_name(u)
if key and key in seen:
continue
seen.add(key)
users_loop.append({'fio': u, 'position': ''})
items.append({
'name': isys.name or '',
'description': isys.description or '',
'address': getattr(isys, 'address', '') or '',
'is_has_lan': 'Да' if isys.is_local_network else 'Нет',
'has_internet': 'да' if isys.is_internet else 'нет',
'defence_level': str(isys.defence_level or ''),
'threat_type': str(isys.threat_type or '3'),
'category': ', '.join(isys.personal_data_category) or isys.category or '',
'pd_list': ', '.join(isys.personal_data_list or []) or '',
'pd_count': str(isys.pd_count or ''),
'users': ', '.join(isys.users_list or []) or '',
'users_loop': users_loop,
'defense_tools_list': ', '.join(isys.defense_tools_list or []) or '',
'processing_modes': isys.processing_modes or '',
'pd_subjects': '; '.join(isys.pd_subjects_list or []) or '',
'is_purpose': isys.purpose or '',
'structure': getattr(isys, 'structure', '') or '',
'software': getattr(isys, 'software', '') or '',
'pd_actions': '; '.join(isys.pd_actions_list or []) or '',
})
loops['information_systems'] = items
# Бумажные документы
def _storage_with_prep(storage: str) -> str:
"""«сейф» → «в сейфе» (предлог + предложный падеж).
Если уже начинается с предлога оставить как есть."""
if not storage:
return ''
s = storage.strip()
low = s.lower()
if low.startswith(('в ', 'на ', 'под ', 'за ', 'у ', 'при ', 'из ', 'со ', 'во ', 'над ', 'перед ')):
return s
words = s.split()
out = []
# Исключения предложного падежа: «в шкафу», «в углу» (не «в шкафе»)
loct_exceptions = {'шкаф': 'шкафу', 'угол': 'углу', 'край': 'краю', 'рот': 'рту', 'мост': 'мосту'}
for w in words:
core = w.strip('.,;')
if not core or core.isdigit() or core.startswith(''):
out.append(w)
continue
if core.lower() in loct_exceptions:
out.append(loct_exceptions[core.lower()])
continue
try:
p = dc.morph.parse(core)[0]
inf = p.inflect({'loct'})
if inf:
out.append(inf.word)
continue
except Exception:
pass
out.append(w)
return 'в ' + ' '.join(out)
paper_list = getattr(company, 'paper_documents_list', None) or []
if paper_list:
items = []
for p in paper_list:
storage = p.storage or ''
items.append({
'document': p.name or '',
'storage': _storage_with_prep(storage), # «в сейфе»
'storage_raw': storage, # «сейф» (как введено)
})
loops['paper_documents'] = items
# Сотрудники с доступом
employees = getattr(company, 'employees_access', None) or []
if employees:
items = []
for e in employees:
items.append({
'position': e.get('position', ''),
'fio': e.get('fio', ''),
})
loops['employees_access'] = items
return loops
# ============================================================
# РЕЗОЛВЕР ЗНАЧЕНИЙ
# ============================================================
def _resolve(source: str, ctx: Dict) -> str:
"""Преобразует ключ-источник в значение, подставляя из контекста."""
company = ctx.get('company')
is_list = ctx.get('is_list', [])
commission_data = ctx.get('commission_data', {})
doc_number = ctx.get('doc_number', '')
doc_date = ctx.get('doc_date', '')
# Прямые поля Company
if source.startswith('company:'):
field = source[8:]
return str(getattr(company, field, '') or '')
# Склонение названия
if source.startswith('decl:'):
_, field, case = source.split(':')
name = getattr(company, field, '') or ''
case_gr = dc._normalize_case(case)
# Сначала берём ГОТОВОЕ склонение из модели (пользователь мог
# подставить через Морфер/ИИ или ввести вручную), пересчитываем
# только если поле пустое
case_idx = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}.get(case_gr)
if case_idx:
if field == 'full_name':
ready = getattr(company, f'company_name_{case_idx}', '') or ''
if ready:
return ready
elif field == 'short_name':
ready = getattr(company, f'short_name_{case_idx}', '') or ''
if ready:
return ready
return dc.company_name_decline(name, case_gr) if name else ''
# Форматы ФИО
if source.startswith('short_fio:'):
who = source.split(':', 1)[1]
fio = _get_fio(company, who)
return dc.get_short_fio(fio) if fio else ''
if source.startswith('initials:'):
who = source.split(':', 1)[1]
fio = _get_fio(company, who)
return dc.get_initials(fio) if fio else ''
# Комиссия
if source in commission_data:
return str(commission_data[source])
# Даты
if source.startswith('date:'):
fmt = source.split(':', 1)[1]
now = datetime.now()
if fmt == 'year':
return str(now.year)
return now.strftime('%d.%m.%Y')
if source == 'doc_date':
return doc_date or datetime.now().strftime('%d.%m.%Y')
if source == 'doc_number':
num = str(doc_number).strip()
return re.sub(r'^[Nn]?[oо]?[№#]\s*', '', num) or ''
# Требования защищённости
if source == 'security_reqs':
return _get_security_text(ctx)
# IS-алиасы (первая ИС)
if source.startswith('is:'):
field = source[3:]
if is_list:
first = is_list[0]
field_map = {
'name': 'name',
'description': 'description',
'category': 'category',
'defence_level': 'defence_level',
'threat_type': 'threat_type',
'pd_list': lambda: ', '.join(first.personal_data_list or []),
'pd_count': 'pd_count',
'pd_subjects': lambda: '; '.join(first.pd_subjects_list or []),
'users': lambda: ', '.join(first.users_list or []),
}
if field in field_map:
val = field_map[field]
if callable(val):
return val() or ''
return str(getattr(first, val, '') or '') if isinstance(val, str) else ''
return ''
# Список ИС
if source == 'is_list':
if is_list:
return '\n'.join(f"{i}. {isys.name}" for i, isys in enumerate(is_list, 1))
return ''
if source == 'is_list_comma':
if is_list:
return ', '.join(isys.name for isys in is_list)
return ''
# Члены комиссии
if source.startswith('commission_member:'):
parts = source.split(':')
idx, field = int(parts[1]), parts[2]
members = getattr(company, 'commission', None) or []
if idx < len(members):
return str(getattr(members[idx], field, '') or '')
return ''
# Склонение ФИО по падежам
if source.startswith('fio_acl:'):
who = source.split(':', 1)[1]
fio = _get_fio(company, who)
return dc.decline(fio, 'accs') if fio else ''
return ''
def _get_fio(company: Company, who: str) -> str:
mapping = {
'chief': 'chief_fio',
'responsible': 'responsible_fio',
'admin': 'admin_fio',
}
field = mapping.get(who, who + '_fio')
return getattr(company, field, '') or ''
def _get_security_text(ctx: Dict) -> str:
"""Возвращает текст требований для уровня защищённости."""
defence_level = ctx.get('extended_fields', {}).get('defence_level', '4')
is_list = ctx.get('is_list', [])
if not defence_level and is_list:
defence_level = str(getattr(is_list[0], 'defence_level', '4') or '4')
dl = str(defence_level).strip()[0]
TEXTS = {
'1': """Для обеспечения 1-го уровня защищённости...""",
'2': """Для обеспечения 2-го уровня защищённости...""",
'3': """Для обеспечения 3-го уровня защищённости...""",
'4': """Для обеспечения 4-го уровня защищённости персональных данных необходимо:
режим безопасности помещений;
сохранность носителей ПДн;
утверждение перечня лиц с доступом к ПДн;
использование сертифицированных СЗИ.""",
}
return TEXTS.get(dl, TEXTS['4'])
+440
View File
@@ -0,0 +1,440 @@
# -*- coding: utf-8 -*-
"""DokoGen — проверка данных через внешние сервисы.
Провайдеры:
- Морфер 3.0 (morpher.ru) склонение ФИО/названий по падежам. Бесплатно, без ключа.
- DaData (dadata.ru) проверка организации по ИНН/ОГРН (наименование, адрес, КПП).
Нужен API-ключ (бесплатный тариф ~10 000 запросов/день).
- ИИ (OpenAI-совместимый) ИИ-проверка склонений и реквизитов (DeepSeek, ChatGPT и др.).
Нужен API-ключ и модель. Данные ИИ ВСЕГДА помечаются дисклеймером.
Каждая функция возвращает (result, error):
- result: dict с полями {source, source_url, data, disclaimer, apply}
- error: строка ошибки или None
"""
import json
import re
from datetime import datetime, timedelta
import urllib.parse
import urllib.request
import urllib.error
MORPHER_URL = "https://ws3.morpher.ru/russian/declension"
DADATA_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party"
DEFAULT_AI_URL = "https://api.deepseek.com/chat/completions"
CASES = ['И', 'Р', 'Д', 'В', 'Т', 'П']
CASE_NAMES = {
'И': 'Именительный', 'Р': 'Родительный', 'Д': 'Дательный',
'В': 'Винительный', 'Т': 'Творительный', 'П': 'Предложный',
}
def _http_json(url, data=None, headers=None, timeout=30):
"""HTTP-запрос, возвращает (json, error_str)."""
body = None
if data is not None:
body = json.dumps(data).encode('utf-8')
req = urllib.request.Request(url, data=body, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode('utf-8')
return json.loads(raw), None
except urllib.error.HTTPError as e:
try:
err_body = e.read().decode('utf-8')
except Exception:
err_body = ''
return None, f"HTTP {e.code}: {err_body[:300]}"
except Exception as e:
return None, str(e)
# ============================================================
# НОРМАЛИЗАЦИЯ ТЕКСТА (КАПС → нормальный регистр, сокращения адреса)
# ============================================================
_CAPS_ABBR = {
'АРМ', 'СКУД', 'СЗИ', 'СКЗИ', 'СОВ', 'ЛВС', 'ИНН', 'КПП', 'ОГРН', 'ЦОД',
'МБУ', 'МКУ', 'ООО', 'СНИЛС', 'СБИС', 'ЕМСЭД', 'ФИО', 'ИСПДН', 'ККТ',
}
_ADDRESS_ABBR = {
'Г': 'г.', 'УЛ': 'ул.', 'Д': 'д.', 'КВ': 'кв.', 'ПР': 'пр.',
'ПЕР': 'пер.', 'ПЛ': 'пл.', 'ОБЛ': 'обл.', 'ПОС': 'пос.',
'СТ': 'ст.', 'Ш': 'ш.', 'Р-Н': 'р-н.', 'Б-Р': 'б-р.', 'МКР': 'мкр.',
'НАБ': 'наб.', 'ТУП': 'туп.', 'ПР-Д': 'пр-д.', 'КРП': 'крп.',
'Г.': 'г.', 'УЛ.': 'ул.', 'Д.': 'д.', 'Р-Н.': 'р-н.',
}
def _normalize_text(text):
"""КАПС → нормальный регистр по словам (сохраняя аббревиатуры и числа)."""
if not text:
return ''
words = str(text).split()
out = []
for w in words:
pre = ''
post = ''
core = w
while core and not core[0].isalnum():
pre += core[0]
core = core[1:]
while core and not core[-1].isalnum():
post = core[-1] + post
core = core[:-1]
if not core:
out.append(w)
continue
if any('а' <= c <= 'я' or c == 'ё' for c in core):
out.append(w)
continue
if not any('А' <= c <= 'Я' or c == 'Ё' for c in core):
out.append(w)
continue
letters = [c for c in core if c.isalpha()]
if len(letters) <= 2:
out.append(w)
continue
if core.upper() in _CAPS_ABBR:
out.append(w)
continue
out.append(pre + core[0].upper() + core[1:].lower() + post)
return ' '.join(out)
def normalize_address(text):
"""«Г Крымск» → «г. Крымск», «Р-Н» → «р-н.» и т.п."""
if not text:
return ''
parts = re.split(r'(\s+|[,\;])', text)
out = []
for p in parts:
if p.strip() and p.strip().upper() in _ADDRESS_ABBR:
out.append(_ADDRESS_ABBR[p.strip().upper()])
else:
out.append(p)
return ''.join(out)
def _fmt_date(value):
"""Дата: '2005-02-09''09.02.2005'; миллисекунды 1107907200000 → дата."""
if not value:
return ''
# миллисекунды с эпохи (DaData иногда отдаёт так)
if isinstance(value, (int, float)) and value > 10 ** 10:
try:
dt = datetime(1970, 1, 1) + timedelta(milliseconds=value)
return dt.strftime('%d.%m.%Y')
except Exception:
pass
s = str(value).strip()
parts = s.split('-')
if len(parts) == 3 and all(p.isdigit() for p in parts):
return f"{parts[2]}.{parts[1]}.{parts[0]}"
return s
# ============================================================
# МОРФЕР 3.0 — склонение (бесплатно, без ключа)
# ============================================================
def check_declension_morpher(text):
"""Склонение слова/ФИО/названия через Морфер 3.0."""
if not text or not text.strip():
return None, "Пустое значение для склонения"
url = MORPHER_URL + '?' + urllib.parse.urlencode({'s': text.strip(), 'format': 'json'})
js, err = _http_json(url)
if err:
return None, err
if not isinstance(js, dict):
return None, "Неожиданный ответ Морфера"
decl = {}
for c in CASES:
val = js.get(c) or js.get(c.lower()) or ''
if isinstance(val, str) and val:
decl[c] = val
if not decl:
return None, "Морфер не смог просклонять (возможно, это не слово/ФИО)"
# Морфер не возвращает именительный падеж (он равен исходному слову) —
# добавляем его сами, иначе поле «Именительный» остаётся пустым
if 'И' not in decl:
decl['И'] = text.strip()
return {
'source': 'Морфер 3.0',
'source_url': 'https://morpher.ru',
'disclaimer': '',
'kind': 'declension',
'original': text.strip(),
'data': decl,
'apply': {'declension': decl},
}, None
# ============================================================
# DADATA — проверка организации по ИНН (нужен API-ключ)
# ============================================================
def check_company_dadata(inn, token):
"""Проверка организации по ИНН/ОГРН через DaData.
Данные нормализуются: регистр (КАПС обычный), адрес (г., ул., д.),
дата ОГРН (в т.ч. из миллисекунд).
"""
if not token:
return None, "Не задан API-ключ DaData (Файл → Настройки → API)"
inn = (inn or '').replace(' ', '')
if not inn or not inn.isdigit():
return None, "Введите ИНН или ОГРН для проверки"
headers = {
'Content-Type': 'application/json',
'Authorization': f'Token {token}',
}
js, err = _http_json(DADATA_URL, data={'query': inn}, headers=headers)
if err:
return None, err
suggestions = (js or {}).get('suggestions') or []
if not suggestions:
return None, f"DaData: организация с ИНН/ОГРН {inn} не найдена"
s = suggestions[0]
d = s.get('data') or {}
name = d.get('name') or {}
address = d.get('address') or {}
management = d.get('management') or {}
full_name = _normalize_text(name.get('full_with_opf') or s.get('value', ''))
short_name = _normalize_text(name.get('short_with_opf') or name.get('short') or '')
addr = normalize_address(address.get('value', ''))
# DaData отдаёт индекс отдельным полем postal_code — добавляем его
# в начало адреса, если его там ещё нет
postal = (address.get('data') or {}).get('postal_code') or address.get('postal_code') or ''
postal = str(postal).strip()
if postal and postal.isdigit():
addr_first = (addr or '').split(',')[0].strip()
if not addr_first.startswith(postal):
addr = f"{postal}, {addr}" if addr else postal
chief_position = _normalize_text(management.get('post', ''))
chief_fio = _normalize_text(management.get('name', ''))
ogrn_date = _fmt_date(d.get('ogrn_date') or '')
return {
'source': 'DaData',
'source_url': 'https://dadata.ru',
'disclaimer': '',
'kind': 'company',
'query': inn,
'data': {
'full_name': full_name,
'short_name': short_name,
'address': addr,
'kpp': d.get('kpp', ''),
'ogrn': d.get('ogrn', ''),
'ogrn_date': ogrn_date,
'chief_position': chief_position,
'chief_fio': chief_fio,
'inn': d.get('inn', inn),
},
'apply': {'company': {
'full_name': full_name,
'short_name': short_name,
'address': addr,
'kpp': d.get('kpp', ''),
'ogrn': d.get('ogrn', ''),
'ogrn_date': ogrn_date,
'chief_position': chief_position,
'chief_fio': chief_fio,
}},
}, None
# ============================================================
# ИИ (OpenAI-совместимый: DeepSeek, ChatGPT и др.)
# ============================================================
AI_DISCLAIMER = ("⚠ Сгенерировано ИИ. Информацию рекомендуется перепроверить "
"по официальным источникам.")
def _ai_json(api_key, model, base_url, system, user, timeout=60):
"""Запрос к OpenAI-совместимому API (DeepSeek, ChatGPT и т.п.)."""
if not api_key:
return None, "Не задан API-ключ ИИ (Файл → Настройки → API)"
url = (base_url or DEFAULT_AI_URL).rstrip('/')
if not url.endswith('/chat/completions'):
url += '/chat/completions'
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}',
}
payload = {
'model': model or 'deepseek-chat',
'messages': [
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
'temperature': 0.2,
'response_format': {'type': 'json_object'},
}
js, err = _http_json(url, data=payload, headers=headers, timeout=timeout)
if err:
return None, err
try:
content = js['choices'][0]['message']['content']
return json.loads(content), None
except Exception:
return None, "ИИ вернул неожиданный ответ"
def check_declension_ai(text, api_key, model='deepseek-chat', base_url=None):
"""ИИ-рецензия склонений: проверяет падежи и предлагает исправления."""
if not text or not text.strip():
return None, "Пустое значение для склонения"
system = (
"Ты — эксперт по русскому языку. Склоняй слово/ФИО/название организации "
"по падежам. Верни ТОЛЬКО JSON: "
'{"И": "...", "Р": "...", "Д": "...", "В": "...", "Т": "...", "П": "...", "комментарий": "..."}'
)
user = f"Просклоняй: {text.strip()}"
js, err = _ai_json(api_key, model, base_url, system, user)
if err:
return None, err
decl = {}
for c in CASES:
val = js.get(c) or js.get(c.lower()) or ''
if isinstance(val, str) and val:
decl[c] = val
comment = js.get('комментарий', '') or ''
if not decl:
return None, "ИИ не смог просклонять"
# Именительный падеж равен исходному слову — подставляем, если ИИ его не вернул
if 'И' not in decl:
decl['И'] = text.strip()
return {
'source': 'ИИ',
'source_url': 'openai-совместимый API',
'disclaimer': AI_DISCLAIMER,
'kind': 'declension',
'original': text.strip(),
'data': decl,
'comment': comment,
'apply': {'declension': decl},
}, None
# Поля организации для ИИ-проверки (для читаемого вывода)
COMPANY_FIELDS = [
('full_name', 'Полное наименование'),
('short_name', 'Краткое наименование'),
('address', 'Юридический адрес'),
('inn', 'ИНН'),
('kpp', 'КПП'),
('ogrn', 'ОГРН'),
('ogrn_date', 'Дата ОГРН'),
('chief_position', 'Руководитель (должность)'),
('chief_fio', 'Руководитель (ФИО)'),
]
# Поля для сравнения: (ключ, подпись, тип)
# type: text — регистр/пунктуация игнорируются; digits — любое отличие = расхождение
_COMPARE_FIELDS = [
('full_name', 'Наименование', 'text'),
('short_name', 'Краткое наименование', 'text'),
('address', 'Юридический адрес', 'text'),
('inn', 'ИНН', 'digits'),
('kpp', 'КПП', 'digits'),
('ogrn', 'ОГРН', 'digits'),
('ogrn_date', 'Дата ОГРН', 'text'),
('chief_position', 'Руководитель (должность)', 'text'),
('chief_fio', 'Руководитель (ФИО)', 'text'),
]
def _norm_compare(s):
"""Нормализация для сравнения: регистр, ё→е, пробелы/точки/дефисы/кавычки убираются."""
s = (s or '').lower().replace('ё', 'е')
s = re.sub(r'[\s.,;:()«»"\'\-—–]+', '', s)
return s
def _norm_digits(s):
"""Только цифры."""
return re.sub(r'\D', '', s or '')
def compare_company_values(old_data, new_data):
"""Сравнивает старые и новые значения реквизитов организации.
Возвращает список расхождений: [(подпись, было, стало), ...].
- text: разница в регистре/пунктуации (ИВАНОВ И.И. vs Иванов И.И.) НЕ считается;
- digits (ИНН/КПП/ОГРН): любое отличие цифр = расхождение.
"""
diffs = []
for key, label, ftype in _COMPARE_FIELDS:
old = (old_data or {}).get(key) or ''
new = (new_data or {}).get(key) or ''
if not old or not new:
continue
if ftype == 'digits':
if _norm_digits(old) != _norm_digits(new):
diffs.append((label, old, new))
else:
if _norm_compare(old) != _norm_compare(new):
diffs.append((label, old, new))
return diffs
def check_company_ai(company_data, api_key, model='deepseek-chat', base_url=None):
"""ИИ-проверка реквизитов организации.
Возвращает оценку, замечания, предложения и полный «снимок» данных
(что ИИ увидел и как оценил каждое поле) чтобы пользователь видел
не только «ОК», но и информацию по организации.
"""
if not api_key:
return None, "Не задан API-ключ ИИ (Файл → Настройки → API)"
system = (
"Ты — юрист по защите персональных данных (152-ФЗ). Проверь реквизиты организации. "
"Верни ТОЛЬКО JSON: "
'{"оценка": "ok|warning|error", '
'"замечания": ["..."], '
'"предложения": {"full_name": "...", "short_name": "...", "address": "...", '
'"inn": "...", "kpp": "...", "ogrn": "...", "ogrn_date": "...", '
'"chief_position": "...", "chief_fio": "..."}, '
'"поля": {"full_name": {"статус": "ok|пусто|ошибка", "комментарий": "..."}, ...}} '
"В «предложениях» указывай исправленные значения только если уверен, иначе пустые строки. "
"В «полях» дай оценку каждого поля (ok/пусто/ошибка) и комментарий."
)
user = json.dumps(company_data, ensure_ascii=False)
js, err = _ai_json(api_key, model, base_url, system, user)
if err:
return None, err
notes = js.get('замечания') or []
if isinstance(notes, str):
notes = [notes]
proposals = js.get('предложения') or {}
if not isinstance(proposals, dict):
proposals = {}
fields = js.get('поля') or {}
if not isinstance(fields, dict):
fields = {}
return {
'source': 'ИИ',
'source_url': 'openai-совместимый API',
'disclaimer': AI_DISCLAIMER,
'kind': 'company',
'data': {
'rating': js.get('оценка', 'warning'),
'notes': notes,
'proposals': proposals,
'fields': fields,
},
'apply': {'company': {k: v for k, v in proposals.items() if v}},
}, None