2 Commits
15 changed files with 144 additions and 2634 deletions
-70
View File
@@ -1,70 +0,0 @@
# -*- 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.
Binary file not shown.
+43 -458
View File
@@ -30,17 +30,6 @@ VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}')
OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>') OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>')
UNMATCHED_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_START = re.compile(r'(?:&lt;!--|<!--)\s*loop:(\w+)\s*(?:--&gt;|-->)')
LOOP_END = re.compile(r'(?:&lt;!--|<!--)\s*loop_end\s*(?:--&gt;|-->)') LOOP_END = re.compile(r'(?:&lt;!--|<!--)\s*loop_end\s*(?:--&gt;|-->)')
@@ -72,14 +61,6 @@ OLD_VAR_MAP = {
'<DocumentNumber>': '{{document_number}}', '<DocumentNumber>': '{{document_number}}',
'<DocumentDate>': '{{document_date}}', '<DocumentDate>': '{{document_date}}',
'<Date>': '{{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}}',
} }
@@ -87,268 +68,6 @@ OLD_VAR_MAP = {
# 2. ОБРАБОТКА ЦИКЛОВ НА УРОВНЕ ZIP/XML # 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 _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 = clone.replace('{{user_%s}}' % f, val)
clone = clone.replace('{{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: def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
"""Обрабатывает циклы напрямую в ZIP/DOCX на уровне XML. """Обрабатывает циклы напрямую в ZIP/DOCX на уровне XML.
Работает до загрузки python-docx — гарантирует корректную вложенность. Работает до загрузки python-docx — гарантирует корректную вложенность.
@@ -367,7 +86,6 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.docx') tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.docx')
tmp.close() tmp.close()
wrote = False # была ли хоть одна запись в tmp
max_passes = 30 max_passes = 30
for _ in range(max_passes): for _ in range(max_passes):
# Читаем ВСЕ файлы из исходного ZIP # Читаем ВСЕ файлы из исходного ZIP
@@ -383,9 +101,6 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
if not doc_xml: if not doc_xml:
break break
# Word мог разбить маркеры циклов на несколько run'ов — склеиваем
doc_xml = _normalize_loop_markers(doc_xml)
# Находим ВСЕ маркеры циклов # Находим ВСЕ маркеры циклов
start_markers = list(LOOP_START.finditer(doc_xml)) start_markers = list(LOOP_START.finditer(doc_xml))
end_markers = list(LOOP_END.finditer(doc_xml)) end_markers = list(LOOP_END.finditer(doc_xml))
@@ -429,90 +144,32 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z: with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
for name, data in all_files.items(): for name, data in all_files.items():
z.writestr(name, data) z.writestr(name, data)
wrote = True
continue continue
# Извлекаем шаблон (всё между start и end маркерами) # Извлекаем шаблон (всё между start и end маркерами)
template = doc_xml[outer.end():outer_end.start()] 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 из шаблона — они принадлежат вложенным циклам! # НЕ удаляем loop_end из шаблона — они принадлежат вложенным циклам!
template_clean = core template_clean = template
# Разрыв страницы рядом с маркером конца цикла (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 = [] expanded_parts = []
for idx, item in enumerate(items, 1): for idx, item in enumerate(items, 1):
clone = template_clean clone = template_clean
# Вложенные циклы (users_loop и т.п.) — разворачиваем ДО подстановки
# обычных полей, чтобы {{user_fio}} внутри них не затёрлись алиасами
clone = _expand_nested_loops(clone, item)
for field, value in item.items(): for field, value in item.items():
if isinstance(value, list): clone = clone.replace('{{item.%s}}' % field, str(value) if value else '')
continue # списки — данные вложенных циклов, не подставляем
val_str = str(value) if value else ('' if value is None else '')
# С префиксом item.
clone = clone.replace('{{item.%s}}' % field, val_str)
# Без префикса (алиасы {{name}}, {{pd_list}}, {{document}} и т.д.)
clone = clone.replace('{{%s}}' % field, val_str)
# Альтернативный синтаксис {{item_поле}} (без точки)
clone = clone.replace('{{item_%s}}' % field, val_str)
clone = clone.replace('{{item.number}}', str(idx)) clone = clone.replace('{{item.number}}', str(idx))
clone = clone.replace('{{number}}', str(idx)) clone = clone.replace('{{number}}', str(idx))
expanded_parts.append(clone) 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) один раз, # Собираем новый XML
# затем размноженное тело цикла, затем обрывок параграфа маркера конца (suffix) new_xml = doc_xml[:outer.start()] + ''.join(expanded_parts) + doc_xml[outer_end.end():]
new_xml = (doc_xml[:outer.start()] + prefix
+ ''.join(expanded_parts) + suffix
+ doc_xml[outer_end.end():])
# Сохраняем ВСЕ файлы ZIP, обновляя только document.xml # Сохраняем ВСЕ файлы ZIP, обновляя только document.xml
all_files['word/document.xml'] = new_xml.encode('utf-8') all_files['word/document.xml'] = new_xml.encode('utf-8')
with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z: with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
for name, data in all_files.items(): for name, data in all_files.items():
z.writestr(name, data) 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 return tmp.name
@@ -521,81 +178,32 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
# 3. ЗАМЕНА ПЕРЕМЕННЫХ В ЭЛЕМЕНТАХ DOCX # 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: def _replace_text_in_para(para, replacements: Dict[str, str]) -> bool:
"""Заменяет {{var}} в параграфе. Возвращает True, если были замены. """Заменяет {{var}} в параграфе. Возвращает True, если были замены."""
Значения «—» (незаполненная информация) подсвечиваются жёлтым маркером.
Табуляция в параграфе сохраняется (<w:tab/>).
"""
full = para.text full = para.text
matches = list(VAR_PATTERN.finditer(full)) matches = VAR_PATTERN.findall(full)
if not matches: if not matches:
return False return False
# Собираем параграф заново: обычный текст + значения переменных new_text = full
first = para.runs[0] if para.runs else None for var in matches:
para.clear()
pos = 0
changed = False
for m in matches:
var = m.group(0)
val = replacements.get(var) val = replacements.get(var)
# текст до переменной if val is not None:
if m.start() > pos: new_text = new_text.replace(var, str(val))
_add_run_with_tabs(para, full[pos:m.start()], first)
if val is None: if new_text == full:
# Переменная не заменена — оставляем как есть, return False
# красным её подсветит _highlight_unmatched на шаге 7
run = para.add_run(var) # Сохраняем форматирование первого run
if first is not None: if para.runs:
_copy_run_style(first, run) first = para.runs[0]
pos = m.end() para.clear()
continue run = para.add_run(new_text)
# значение переменной _copy_run_style(first, run)
val_str = str(val) else:
run = para.add_run(val_str) para.clear()
if first is not None: para.add_run(new_text)
_copy_run_style(first, run) return True
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): def _copy_run_style(source, target):
@@ -641,63 +249,42 @@ def _strip_prooferr(doc):
def _highlight_unmatched(doc): def _highlight_unmatched(doc):
"""Выделяет незаменённые переменные красным маркером (только саму переменную).""" """Подсвечивает незаменённые переменные красным."""
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def _apply_highlight(run_el): def _highlight_run(run):
rpr = run_el.find(f'{{{ns}}}rPr') rpr = run._element.find(f'{{{ns}}}rPr')
if rpr is None: if rpr is None:
rpr = OxmlElement('w:rPr') rpr = OxmlElement('w:rPr')
run_el.insert(0, rpr) run._element.insert(0, rpr)
hl = OxmlElement('w:highlight') color = OxmlElement('w:color')
hl.set(f'{{{ns}}}val', 'red') color.set(f'{{{ns}}}val', 'FF0000')
rpr.append(hl) rpr.append(color)
def _process_para(para):
full = para.text
matches = [m for m in UNMATCHED_PATTERN.finditer(full)
if m.group(0) not in IGNORED_OLD_VARS]
if not matches:
return
# Пересобираем параграф: обычный текст и переменные отдельными run'ами,
# переменные — с красным маркером. Сохраняем стиль первого run и табы.
first = para.runs[0] if para.runs else None
para.clear()
pos = 0
for m in matches:
if m.start() > pos:
_add_run_with_tabs(para, full[pos:m.start()], first)
run = para.add_run(m.group(0))
if first is not None:
_copy_run_style(first, run)
_apply_highlight(run._element)
pos = m.end()
if pos < len(full):
_add_run_with_tabs(para, full[pos:], first)
run = para.add_run(full[pos:])
if first is not None:
_copy_run_style(first, run)
for para in doc.paragraphs: for para in doc.paragraphs:
_process_para(para) for run in para.runs:
if UNMATCHED_PATTERN.search(run.text or ''):
_highlight_run(run)
for table in doc.tables: for table in doc.tables:
for row in table.rows: for row in table.rows:
for cell in row.cells: for cell in row.cells:
for para in cell.paragraphs: for para in cell.paragraphs:
_process_para(para) for run in para.runs:
if UNMATCHED_PATTERN.search(run.text or ''):
_highlight_run(run)
for section in doc.sections: for section in doc.sections:
for hf in [section.header, section.footer, for hf in [section.header, section.footer]:
section.first_page_header, section.first_page_footer]: if hf:
if hf is not None:
for para in hf.paragraphs: for para in hf.paragraphs:
_process_para(para) for run in para.runs:
if UNMATCHED_PATTERN.search(run.text or ''):
_highlight_run(run)
def _log_unmatched(doc, replacements=None, log_func=None): def _log_unmatched(doc, replacements=None, log_func=None):
"""Логирует незаменённые переменные (без клавиш-исключений).""" """Логирует незаменённые переменные."""
if not log_func: if not log_func:
return return
unmatched = [] unmatched = []
@@ -705,8 +292,6 @@ def _log_unmatched(doc, replacements=None, log_func=None):
for para in doc.paragraphs: for para in doc.paragraphs:
for m in UNMATCHED_PATTERN.finditer(para.text): for m in UNMATCHED_PATTERN.finditer(para.text):
var = m.group(0) var = m.group(0)
if var in IGNORED_OLD_VARS:
continue
if var not in seen: if var not in seen:
seen.add(var) seen.add(var)
unmatched.append(var) unmatched.append(var)
-281
View File
@@ -1,281 +0,0 @@
# -*- 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
@@ -1,209 +0,0 @@
{
"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
@@ -1,174 +0,0 @@
# -*- 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 ''
+74 -204
View File
@@ -12,90 +12,6 @@ except ImportError:
openpyxl = None 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): def clean_value(val):
"""Очистка и нормализация значения ячейки Excel.""" """Очистка и нормализация значения ячейки Excel."""
if val is None: if val is None:
@@ -114,28 +30,26 @@ def clean_value(val):
text = str(val).strip() text = str(val).strip()
text = text.replace('\n', ' ').replace('\r', ' ') text = text.replace('\n', ' ').replace('\r', ' ')
text = ' '.join(text.split()) text = ' '.join(text.split())
# Если весь текст КАПСОМ (нет строчных букв) — нормализуем по словам # Приведение регистра: если текст содержит только заглавные буквы РУ
if len(text) > 2 and any('А' <= c <= 'Я' or c == 'Ё' for c in text): if len(text) > 4 and any('А' <= c <= 'Я' or c == 'Ё' for c in text):
has_lower = any('а' <= c <= 'я' or c == 'ё' for c in text) has_lower = any('а' <= c <= 'я' or c == 'ё' for c in text)
if not has_lower: if not has_lower:
text = _normalize_caps(text) words = text.split()
if len(words) >= 3:
text = text.title()
else:
text = text.capitalize()
return text return text
def import_152fz(filepath: str, log_fn: Optional[Callable] = None, settings_path: Optional[str] = None) -> dict: def import_152fz(filepath: str, log_fn: Optional[Callable] = None) -> dict:
"""Импорт данных из опросного листа Excel (152-ФЗ). """Импорт данных из опросного листа Excel (152-ФЗ)."""
settings_path — путь к JSON-файлу настроек импорта (что и откуда брать).
Если None — файл import_settings.json рядом с программой; если его нет —
используется встроенный словарь по умолчанию.
"""
if openpyxl is None: if openpyxl is None:
raise ImportError("openpyxl не установлен. Установите: pip install openpyxl") raise ImportError("openpyxl не установлен. Установите: pip install openpyxl")
if log_fn is None: if log_fn is None:
log_fn = lambda msg: None log_fn = lambda msg: None
settings = load_settings(settings_path)
log_fn(f"Загрузка опросного листа: {filepath}") log_fn(f"Загрузка опросного листа: {filepath}")
data = { data = {
@@ -154,9 +68,9 @@ def import_152fz(filepath: str, log_fn: Optional[Callable] = None, settings_path
return data return data
try: try:
_import_152_common(wb, data, settings, log_fn) _import_152_common(wb, data, log_fn)
_import_152_is_list(wb, data, settings, log_fn) _import_152_is_list(wb, data, log_fn)
_import_152_employees(wb, data, settings, log_fn) _import_152_employees(wb, data, log_fn)
except Exception as e: except Exception as e:
log_fn(f"❌ Ошибка при импорте: {e}") log_fn(f"❌ Ошибка при импорте: {e}")
log_fn(traceback.format_exc()) log_fn(traceback.format_exc())
@@ -170,92 +84,75 @@ def import_152fz(filepath: str, log_fn: Optional[Callable] = None, settings_path
if data.get(src) and not data.get(dst): if data.get(src) and not data.get(dst):
data[dst] = data[src] 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) _validate_company_data(data, log_fn)
return data return data
def _import_152_common(wb, data, settings, log_fn): def _import_152_common(wb, data, log_fn):
"""Импорт общих сведений по словарю настроек. """Импорт общих сведений из листа 'Общие сведения'."""
Для каждого поля: сначала пробуем конкретную ячейку (cell),
если она задана и заполнена; иначе ищем строку по ключевым
словам (keywords) в колонке A листа «Общие сведения».
"""
sheet_name = None sheet_name = None
sheet_kw = settings.get('common_sheet_keyword', 'общие')
for sn in wb.sheetnames: for sn in wb.sheetnames:
if sheet_kw in sn.lower(): if 'общие' in sn.lower():
sheet_name = sn sheet_name = sn
break break
if not sheet_name: if not sheet_name:
log_fn(f"⚠ Лист по ключу '{sheet_kw}' не найден") log_fn("⚠ Лист 'Общие сведения' не найден")
return return
ws = wb[sheet_name] ws = wb[sheet_name]
value_col = int(settings.get('value_column', 1)) field_map = [
('полное наименование организации', 'fullName'),
('сокращенное наименование организации', 'shortName'),
('адрес организации \\(юридический\\)', 'addressLegal'),
('адрес организации \\(фактический\\)', 'addressActual'),
('инн', 'inn'),
('дата.*огрн', 'ogrnDate'),
('огрн', 'ogrn'),
('кпп', 'kpp'),
('основной оквэд', 'okved'),
('должность руководителя', 'chiefPosition'),
('фио руководителя', 'chiefFio'),
('должность.*защит.*информ|должность.*администрат', 'ispdnPosition'),
('фио.*защит.*информ|фио.*администрат', 'ispdnFio'),
('электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист', 'ispdnEmail'),
('телефон.*защит|телефон.*ответств|телефон.*специалист', 'ispdnPhone'),
('структур.*подраздел', 'ispdnDepartment'),
('номер.*договора|номер.*контракта', 'contractNumber'),
('дата.*договора|дата.*контракта', 'contractDate'),
('названи.*документ', 'paperDocuments'),
('место.*хранен', 'paperStorage'),
('должность.*ответственн', 'responsiblePosition'),
('фио.*ответственн', 'responsibleFio'),
('должность.*администрат.*(?:безопас|защит|пд)', 'administratorPosition'),
('фио.*администрат.*(?:безопас|защит|пд)', 'administratorFio'),
('сайт организации', 'site_name'),
('должность ответственного за сайт|должность.*отв.*сайт', 'site_responsible_position'),
('фио.*отв.*сайт|фио.*сайт', 'site_responsible_fio'),
('наименование организации.*разрабат.*сайт|наименование.*размещала.*сайт|разрабат.*сайт.*организац', 'site_service_provider'),
('инн.*разрабат.*сайт|инн.*размещала.*сайт', 'site_service_provider_inn'),
('размещение сайта|адрес расположения серверов|адрес.*сервер.*сайт', 'site_hosting'),
('адрес.*хостинг|адрес.*располож.*сервер', 'site_hosting_address'),
('электронная почта', 'email'),
('телефон', 'phone'),
]
# Кэш значений: для каждого поля ищем по строке один раз
row_cache = {}
for row in ws.iter_rows(min_row=1, values_only=True): for row in ws.iter_rows(min_row=1, values_only=True):
field_raw = clean_value(row[0]) if row[0] is not None else '' field_raw = clean_value(row[0]) if row[0] is not None else ''
if not field_raw: field = field_raw.lower()
value = clean_value(row[1]) if len(row) > 1 else ''
if not field:
continue continue
row_cache[field_raw.lower()] = ( for keyword, key in field_map:
clean_value(row[value_col]) if len(row) > value_col else '' if re.search(keyword, field):
) if value:
data[key] = value
for rule in settings.get('fields', []): log_fn(f" {keyword}: {value}")
key = rule.get('var', '') break
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): def _import_152_is_list(wb, data, log_fn):
"""Импорт информационных систем из остальных листов.""" """Импорт информационных систем из остальных листов."""
exclude_kws = settings.get('is_sheet_exclude_keywords', ['общие сведения', 'сотрудники']) is_sheets = [sn for sn in wb.sheetnames if sn not in ('Общие сведения', 'Сотрудники')]
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: if not is_sheets:
log_fn("⚠ Листы ИС не найдены") log_fn("⚠ Листы ИС не найдены")
return return
@@ -298,22 +195,6 @@ def _import_152_is_list(wb, data, settings, log_fn):
if 'перечисленные пдн принадлежат' in field_lower or 'принадлеж' in field_lower: if 'перечисленные пдн принадлежат' in field_lower or 'принадлеж' in field_lower:
current_section = 'subjects' current_section = 'subjects'
continue 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 'количество записей' in field_lower or 'количество субъект' in field_lower:
if val_b: if val_b:
is_obj['personalDataCount'] = val_b is_obj['personalDataCount'] = val_b
@@ -335,11 +216,6 @@ def _import_152_is_list(wb, data, settings, log_fn):
elif 'название ис' in field_lower or 'наименование ис' in field_lower or 'наименование информационной' in field_lower: elif 'название ис' in field_lower or 'наименование ис' in field_lower or 'наименование информационной' in field_lower:
is_obj['name'] = val_b is_obj['name'] = val_b
log_fn(f" ИС: {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: elif 'сотрудник' in field_lower and 'должност' not in field_lower:
is_obj['employee'] = val_b is_obj['employee'] = val_b
if val_b: if val_b:
@@ -369,8 +245,8 @@ def _import_152_is_list(wb, data, settings, log_fn):
if 'цель обработки' in field_lower: if 'цель обработки' in field_lower:
current_section = 'purpose' current_section = 'purpose'
elif current_section == 'purpose': elif current_section == 'purpose':
purpose_val = val_b or field or '' purpose_val = field or val_b or ''
if purpose_val and 'выбрать' not in purpose_val.lower() and ':' not in purpose_val[:40]: if purpose_val and 'выбрать' not in purpose_val.lower():
is_obj['purpose'] = purpose_val is_obj['purpose'] = purpose_val
log_fn(f" Цель: {purpose_val[:80]}") log_fn(f" Цель: {purpose_val[:80]}")
current_section = 'header' current_section = 'header'
@@ -401,8 +277,7 @@ def _import_152_is_list(wb, data, settings, log_fn):
matched_label = label matched_label = label
break break
if matched_label and val_b and 'выбрать' not in field_lower: if matched_label and val_b and 'выбрать' not in field_lower:
# Без префикса категории: просто название средства (Kaspersky, КриптоПро) is_obj['defense_tools'].append(f"{matched_label}: {val_b}")
is_obj['defense_tools'].append(val_b)
elif matched_label: elif matched_label:
pass pass
elif 'куда' in field_lower or 'передаются' in field_lower: elif 'куда' in field_lower or 'передаются' in field_lower:
@@ -447,16 +322,15 @@ def _import_152_is_list(wb, data, settings, log_fn):
log_fn(f"\n✅ Всего импортировано ИС: {len(is_list)}") log_fn(f"\n✅ Всего импортировано ИС: {len(is_list)}")
def _import_152_employees(wb, data, settings, log_fn): def _import_152_employees(wb, data, log_fn):
"""Импорт комиссии и сотрудников из листа «Сотрудники».""" """Импорт комиссии и сотрудников из листа 'Сотрудники'."""
sheet_name = None sheet_name = None
sheet_kw = settings.get('employees_sheet_keyword', 'сотруд')
for sn in wb.sheetnames: for sn in wb.sheetnames:
if sheet_kw in sn.lower(): if 'сотруд' in sn.lower():
sheet_name = sn sheet_name = sn
break break
if not sheet_name: if not sheet_name:
log_fn(f"⚠ Лист по ключу '{sheet_kw}' не найден") log_fn("⚠ Лист «Сотрудники» не найден")
return return
ws = wb[sheet_name] ws = wb[sheet_name]
@@ -504,15 +378,11 @@ def _import_152_employees(wb, data, settings, log_fn):
if in_commission: if in_commission:
role = 'Член комиссии' role = 'Член комиссии'
# Роль ищем в колонках E (было) и D (Примечание — новый формат) if col_e:
for col in (col_e, col_d): e_lower = col_e.lower()
if col: for keyword, role_name in COMMISSION_ROLES.items():
col_lower = col.lower() if keyword in e_lower:
for keyword, role_name in COMMISSION_ROLES.items(): role = role_name
if keyword in col_lower:
role = role_name
break
if role != 'Член комиссии':
break break
if not col_b and not col_c: if not col_b and not col_c:
continue continue
-5
View File
@@ -36,8 +36,6 @@ class InformationSystem:
personal_data_category: List[str] = field(default_factory=list) personal_data_category: List[str] = field(default_factory=list)
purpose: str = "" purpose: str = ""
room: str = "" room: str = ""
structure: str = "" # Структура информационной системы
software: str = "" # Программное обеспечение
@dataclass @dataclass
@@ -94,9 +92,6 @@ class Company:
paper_documents_list: List[PaperDocument] = field(default_factory=list) paper_documents_list: List[PaperDocument] = field(default_factory=list)
employees_access: List[Dict[str, str]] = field(default_factory=list) employees_access: List[Dict[str, str]] = field(default_factory=list)
# Наименование структурного подразделения по безопасности
security_department: str = ""
def update_declensions(self, inflect_func): def update_declensions(self, inflect_func):
"""Обновить склонения полного названия""" """Обновить склонения полного названия"""
if self.full_name: if self.full_name:
+13 -653
View File
@@ -5,7 +5,6 @@ import tkinter as tk
from tkinter import ttk, messagebox, filedialog, simpledialog, scrolledtext from tkinter import ttk, messagebox, filedialog, simpledialog, scrolledtext
import os import os
import json import json
import sys
import threading import threading
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
@@ -18,13 +17,6 @@ from . import (
get_short_fio, get_initials, get_short_fio, get_initials,
) )
from .verify import (
check_declension_morpher, check_company_dadata,
check_declension_ai, check_company_ai,
COMPANY_FIELDS, compare_company_values,
)
from .api_settings import load_api_settings, save_api_settings
class DokoGenApp: class DokoGenApp:
"""Главное окно приложения.""" """Главное окно приложения."""
@@ -46,17 +38,6 @@ class DokoGenApp:
'.autosave', 'autosave_152.json' '.autosave', 'autosave_152.json'
) )
# Путь к файлу настроек импорта (словарь «что и откуда брать»)
# None = import_settings.json рядом с программой (создаётся автоматически)
self.import_settings_path: Optional[str] = None
# Файловый лог: рядом с exe (frozen) или рядом с модулем
if getattr(sys, 'frozen', False):
_base = os.path.dirname(sys.executable)
else:
_base = os.path.dirname(os.path.abspath(__file__))
self._log_path = os.path.join(_base, 'dokogen.log')
# Переменные # Переменные
self.doc_number_var = tk.StringVar(value="") self.doc_number_var = tk.StringVar(value="")
self.doc_date_var = tk.StringVar(value="") self.doc_date_var = tk.StringVar(value="")
@@ -97,24 +78,18 @@ class DokoGenApp:
self.recent_menu = tk.Menu(file_menu, tearoff=0) self.recent_menu = tk.Menu(file_menu, tearoff=0)
file_menu.add_cascade(label="Последние файлы", menu=self.recent_menu) file_menu.add_cascade(label="Последние файлы", menu=self.recent_menu)
file_menu.add_separator() file_menu.add_separator()
file_menu.add_command(label="Настройки…", command=self.open_api_settings)
file_menu.add_separator()
file_menu.add_command(label="Выход", command=self._on_close) file_menu.add_command(label="Выход", command=self._on_close)
gen_menu = tk.Menu(menubar, tearoff=0) gen_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Генерация", menu=gen_menu) menubar.add_cascade(label="Генерация", menu=gen_menu)
gen_menu.add_command(label="Импорт из опросника", command=self.import_from_survey) gen_menu.add_command(label="Импорт из опросника", command=self.import_from_survey)
gen_menu.add_command(label="Файл настроек импорта…", command=self.select_import_settings)
gen_menu.add_separator() gen_menu.add_separator()
gen_menu.add_command(label="Сгенерировать всё", command=self.generate_all) gen_menu.add_command(label="Сгенерировать всё", command=self.generate_all)
help_menu = tk.Menu(menubar, tearoff=0) help_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Справка", menu=help_menu) menubar.add_cascade(label="Справка", menu=help_menu)
help_menu.add_command(label="О программе", command=self.show_about) help_menu.add_command(label="О программе", command=self.show_about)
help_menu.add_command(label="Инструкция пользователя (Word)…", command=self.download_user_manual) help_menu.add_command(label="Переменные шаблонов", command=self.show_variables)
help_menu.add_command(label="Инструкция администратора (Word)…", command=self.download_admin_manual)
help_menu.add_command(label="Переменные шаблонов (Word)…", command=self.download_variables_doc)
help_menu.add_command(label="Пустой опросник (Excel)…", command=self.download_survey)
# ==================== ИНТЕРФЕЙС ==================== # ==================== ИНТЕРФЕЙС ====================
def _build_ui(self): def _build_ui(self):
@@ -136,7 +111,6 @@ class DokoGenApp:
self._build_is_tab() self._build_is_tab()
self._build_commission_tab() self._build_commission_tab()
self._build_users_tab() self._build_users_tab()
self._build_paper_tab()
self._build_generation_tab() self._build_generation_tab()
self._build_log_tab() self._build_log_tab()
@@ -169,11 +143,8 @@ class DokoGenApp:
main = ttk.Frame(scrollable, padding=15) main = ttk.Frame(scrollable, padding=15)
main.pack(fill=tk.BOTH, expand=True) main.pack(fill=tk.BOTH, expand=True)
left = ttk.Frame(main)
left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# --- Название организации --- # --- Название организации ---
f1 = ttk.LabelFrame(left, text="Наименование организации", padding=10) f1 = ttk.LabelFrame(main, text="Наименование организации", padding=10)
f1.pack(fill=tk.X, pady=5) f1.pack(fill=tk.X, pady=5)
ttk.Label(f1, text="Полное название:").grid(row=0, column=0, sticky=tk.W, pady=2) ttk.Label(f1, text="Полное название:").grid(row=0, column=0, sticky=tk.W, pady=2)
@@ -225,7 +196,7 @@ class DokoGenApp:
decl_frame.columnconfigure(1, weight=1) decl_frame.columnconfigure(1, weight=1)
# --- Адрес и реквизиты --- # --- Адрес и реквизиты ---
f2 = ttk.LabelFrame(left, text="Адрес и реквизиты", padding=10) f2 = ttk.LabelFrame(main, text="Адрес и реквизиты", padding=10)
f2.pack(fill=tk.X, pady=5) f2.pack(fill=tk.X, pady=5)
addr_fields = [ addr_fields = [
@@ -250,7 +221,7 @@ class DokoGenApp:
f2.columnconfigure(1, weight=1) f2.columnconfigure(1, weight=1)
# --- Должностные лица --- # --- Должностные лица ---
f3 = ttk.LabelFrame(left, text="Должностные лица", padding=10) f3 = ttk.LabelFrame(main, text="Должностные лица", padding=10)
f3.pack(fill=tk.X, pady=5) f3.pack(fill=tk.X, pady=5)
of_fields = [ of_fields = [
@@ -269,7 +240,7 @@ class DokoGenApp:
f3.columnconfigure(1, weight=1) f3.columnconfigure(1, weight=1)
# --- Документ --- # --- Документ ---
f4 = ttk.LabelFrame(left, text="Документ", padding=10) f4 = ttk.LabelFrame(main, text="Документ", padding=10)
f4.pack(fill=tk.X, pady=5) f4.pack(fill=tk.X, pady=5)
ttk.Label(f4, text="Номер договора:").grid(row=0, column=0, sticky=tk.W) ttk.Label(f4, text="Номер договора:").grid(row=0, column=0, sticky=tk.W)
ttk.Entry(f4, textvariable=self.doc_number_var, width=25).grid( ttk.Entry(f4, textvariable=self.doc_number_var, width=25).grid(
@@ -278,361 +249,6 @@ class DokoGenApp:
ttk.Entry(f4, textvariable=self.doc_date_var, width=25).grid( ttk.Entry(f4, textvariable=self.doc_date_var, width=25).grid(
row=1, column=1, padx=10, sticky=tk.W) row=1, column=1, padx=10, sticky=tk.W)
# --- Правая панель: проверка данных ---
right = ttk.LabelFrame(main, text="Проверка данных", padding=10)
right.pack(side=tk.RIGHT, fill=tk.Y, padx=(10, 0))
ttk.Label(right, text="Склонение названия:", font=('', 9, 'bold')).pack(anchor=tk.W)
ttk.Button(right, text="🌐 Склонение — Морфер 3.0",
command=self._check_declension_web).pack(fill=tk.X, pady=2)
ttk.Button(right, text="🤖 Склонение — ИИ",
command=self._check_declension_ai).pack(fill=tk.X, pady=2)
ttk.Label(right, text="Реквизиты организации:", font=('', 9, 'bold')).pack(anchor=tk.W, pady=(10, 0))
ttk.Button(right, text="🌐 Реквизиты — DaData",
command=self._check_company_web).pack(fill=tk.X, pady=2)
ttk.Button(right, text="🤖 Реквизиты — ИИ",
command=self._check_company_ai).pack(fill=tk.X, pady=2)
ttk.Label(right, text="Результат проверки:", font=('', 9, 'bold')).pack(anchor=tk.W, pady=(10, 0))
self._verify_text = scrolledtext.ScrolledText(
right, width=44, height=14, state=tk.DISABLED, wrap=tk.WORD, font=('', 9))
self._verify_text.pack(fill=tk.BOTH, expand=True, pady=4)
btn_row = ttk.Frame(right)
btn_row.pack(fill=tk.X)
ttk.Button(btn_row, text="✓ Применить", command=self._verify_apply).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_row, text="Очистить", command=self._verify_clear).pack(side=tk.LEFT, padx=2)
self._verify_result = None
# ---------- ПРОВЕРКА ДАННЫХ (Морфер / DaData / ИИ) ----------
def open_api_settings(self):
"""Окно настроек API-ключей (меню «Файл → Настройки»)."""
win = tk.Toplevel(self.root)
win.title("Настройки")
win.geometry("540x460")
win.transient(self.root)
win.grab_set()
nb = ttk.Notebook(win)
nb.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Вкладка API-ключи
api_tab = ttk.Frame(nb, padding=10)
nb.add(api_tab, text="API-ключи")
settings = load_api_settings()
ttk.Label(api_tab, text="DaData — проверка организации по ИНН/ОГРН",
font=('', 9, 'bold')).pack(anchor=tk.W)
ttk.Label(api_tab, text="API-ключ (получить на https://dadata.ru):").pack(anchor=tk.W, pady=(6, 0))
dadata_var = tk.StringVar(value=settings.get('dadata_token', ''))
ttk.Entry(api_tab, textvariable=dadata_var, width=64).pack(fill=tk.X, pady=2)
ttk.Label(api_tab, text="ИИ-проверка (OpenAI-совместимый API: DeepSeek, ChatGPT и др.)",
font=('', 9, 'bold')).pack(anchor=tk.W, pady=(14, 0))
ttk.Label(api_tab, text="API-ключ:").pack(anchor=tk.W, pady=(6, 0))
ds_var = tk.StringVar(value=settings.get('ai_api_key', ''))
ttk.Entry(api_tab, textvariable=ds_var, width=64).pack(fill=tk.X, pady=2)
ttk.Label(api_tab, text="Модель (например: deepseek-chat, gpt-4o-mini, gpt-4o):").pack(anchor=tk.W, pady=(6, 0))
model_var = tk.StringVar(value=settings.get('ai_model', 'deepseek-chat'))
ttk.Entry(api_tab, textvariable=model_var, width=64).pack(fill=tk.X, pady=2)
ttk.Label(api_tab, text="Базовый URL API (для DeepSeek можно оставить пустым):").pack(anchor=tk.W, pady=(6, 0))
url_var = tk.StringVar(value=settings.get('ai_base_url', ''))
ttk.Entry(api_tab, textvariable=url_var, width=64).pack(fill=tk.X, pady=2)
ttk.Label(api_tab, text="Морфер 3.0 работает без ключа (бесплатный склонятор).",
foreground='gray').pack(anchor=tk.W, pady=(14, 0))
def _save():
save_api_settings({
'dadata_token': dadata_var.get().strip(),
'ai_api_key': ds_var.get().strip(),
'ai_model': model_var.get().strip() or 'deepseek-chat',
'ai_base_url': url_var.get().strip(),
})
self.log("✅ Настройки API сохранены")
win.destroy()
ttk.Button(api_tab, text="💾 Сохранить", command=_save).pack(anchor=tk.W, pady=12)
# ---------- ПРОВЕРКИ ----------
_CASE_TO_CODE = {'И': 'nomn', 'Р': 'gent', 'Д': 'datv', 'В': 'accs', 'Т': 'ablt', 'П': 'loct'}
def _verify_log(self, text):
self._verify_text.config(state=tk.NORMAL)
self._verify_text.delete('1.0', tk.END)
self._verify_text.insert('1.0', text)
self._verify_text.config(state=tk.DISABLED)
def _verify_clear(self):
self._verify_result = None
self._verify_log('')
def _check_declension_web(self):
name = self.company_name_entry.get().strip()
short = self.short_name_entry.get().strip()
if not name and not short:
messagebox.showwarning("Проверка", "Сначала введите полное или краткое название организации")
return
self.log(f"🌐 Запрос к Морфер 3.0: «{name or short}»")
self._verify_log("Запрос к Морфер 3.0…")
def _run():
# Склоняем и полное, и краткое название (если заполнено)
results = []
if name:
r1, e1 = check_declension_morpher(name)
if e1:
self.root.after(0, lambda: self._verify_log(f"{e1}"))
return
results.append(('declension', r1))
if short:
r2, e2 = check_declension_morpher(short)
if e2:
self.root.after(0, lambda: self._verify_log(f"❌ Краткое: {e2}"))
return
results.append(('short_declension', r2))
self.root.after(0, lambda: self._verify_declension_done(results, None))
threading.Thread(target=_run, daemon=True).start()
def _check_declension_ai(self):
name = self.company_name_entry.get().strip()
short = self.short_name_entry.get().strip()
if not name and not short:
messagebox.showwarning("Проверка", "Сначала введите полное или краткое название организации")
return
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
self.log(f"🤖 Запрос к ИИ ({settings.get('ai_model') or 'deepseek-chat'}): склонение «{name or short}»")
self._verify_log("Запрос к ИИ…")
def _run():
results = []
if name:
r1, e1 = check_declension_ai(name, settings['ai_api_key'],
settings.get('ai_model'), settings.get('ai_base_url'))
if e1:
self.root.after(0, lambda: self._verify_log(f"{e1}"))
return
results.append(('declension', r1))
if short:
r2, e2 = check_declension_ai(short, settings['ai_api_key'],
settings.get('ai_model'), settings.get('ai_base_url'))
if e2:
self.root.after(0, lambda: self._verify_log(f"❌ Краткое: {e2}"))
return
results.append(('short_declension', r2))
self.root.after(0, lambda: self._verify_declension_done(results, None))
threading.Thread(target=_run, daemon=True).start()
def _verify_declension_done(self, results, err):
if err:
self._verify_log(f"{err}")
self.log(f"❌ Проверка склонений: {err}")
return
# Собираем единый результат для применения
combined = {
'source': results[0][1]['source'] if results else 'ИИ',
'source_url': results[0][1]['source_url'] if results else '',
'disclaimer': results[0][1].get('disclaimer', '') if results else '',
'kind': 'declension',
'data': {},
'apply': {},
}
lines = [f"Источник: {combined['source']} ({combined['source_url']})"]
if combined['disclaimer']:
lines.append(combined['disclaimer'])
for key, res in results:
label = "Полное название" if key == 'declension' else "Краткое название"
lines.append(f"\n{label}:")
for code, name_case in [('И', 'Именительный'), ('Р', 'Родительный'), ('Д', 'Дательный'), ('В', 'Винительный'),
('Т', 'Творительный'), ('П', 'Предложный')]:
if code in res['data']:
lines.append(f" {name_case}: {res['data'][code]}")
if res.get('comment'):
lines.append(f" Комментарий: {res['comment']}")
combined['apply'][key] = res['apply']['declension']
lines.append("\nНажмите «✓ Применить», чтобы подставить склонения")
self._verify_result = combined
self._verify_log('\n'.join(lines))
# Общий лог с ответом сервиса
sample = ''
for key, res in results:
sample = res['data'].get('П', '')
if sample:
break
self.log(f"{combined['source']}: склонение проверено → «{sample}»")
def _check_company_web(self):
inn_entry = self._company_entries.get('inn')
inn = inn_entry.get().strip() if inn_entry else ''
if not inn:
messagebox.showwarning("Проверка", "Сначала введите ИНН организации")
return
settings = load_api_settings()
if not settings.get('dadata_token'):
messagebox.showwarning("Настройки", "Не задан API-ключ DaData.\nМеню «Файл → Настройки»")
return
self.log(f"🌐 Запрос к DaData: ИНН/ОГРН {inn}")
self._verify_log("Запрос к DaData…")
def _run():
result, err = check_company_dadata(inn, settings['dadata_token'])
self.root.after(0, lambda: self._verify_company_done(result, err))
threading.Thread(target=_run, daemon=True).start()
def _check_company_ai(self):
self._collect_company()
c = self.model
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
company_data = {
'full_name': c.full_name, 'short_name': c.short_name,
'address': c.address, 'inn': c.inn, 'kpp': c.kpp, 'ogrn': c.ogrn,
'ogrn_date': c.ogrn_date,
'chief_position': c.chief_position, 'chief_fio': c.chief_fio,
}
self.log(f"🤖 Запрос к ИИ ({settings.get('ai_model') or 'deepseek-chat'}): реквизиты (ИНН {company_data.get('inn') or ''})")
self._verify_log("Запрос к ИИ…")
def _run():
result, err = check_company_ai(company_data, settings['ai_api_key'],
settings.get('ai_model'), settings.get('ai_base_url'))
self.root.after(0, lambda: self._verify_company_done(result, err))
threading.Thread(target=_run, daemon=True).start()
def _verify_company_done(self, result, err):
if err:
self._verify_log(f"{err}")
self.log(f"❌ Проверка реквизитов: {err}")
return
self._verify_result = result
lines = [f"Источник: {result['source']} ({result['source_url']})"]
if result.get('disclaimer'):
lines.append(result['disclaimer'])
d = result['data']
if 'full_name' in d:
# DaData — фактические данные
for key, label in [('full_name', 'Наименование'), ('short_name', 'Краткое'),
('address', 'Адрес'), ('inn', 'ИНН'), ('kpp', 'КПП'),
('ogrn', 'ОГРН'), ('ogrn_date', 'Дата ОГРН'),
('chief_position', 'Руководитель'), ('chief_fio', 'ФИО')]:
if d.get(key):
lines.append(f"{label}: {d[key]}")
else:
# ИИ-рецензия: показываем снимок полей (что ИИ увидел), замечания и предложения
lines.append(f"Оценка: {d.get('rating', '')}")
fields = d.get('fields') or {}
if fields:
lines.append("\nПроверенные поля:")
for key, label in COMPANY_FIELDS:
info = fields.get(key)
if isinstance(info, dict):
status = info.get('статус', '')
comment = info.get('комментарий', '')
lines.append(f" {label}: {status}" + (f"{comment}" if comment else ""))
for n in (d.get('notes') or []):
lines.append(f"{n}")
props = d.get('proposals') or {}
if any(props.values()):
lines.append("\nПредложения ИИ:")
for key, label in COMPANY_FIELDS:
v = props.get(key)
if v:
lines.append(f" {label}: {v}")
lines.append("\nНажмите «✓ Применить», чтобы подставить данные")
self._verify_log('\n'.join(lines))
# Сравнение полученных значений с введёнными (серьёзные расхождения — красным)
current = {
'full_name': self.model.full_name,
'short_name': self.model.short_name,
'address': self.model.address,
'inn': self.model.inn,
'kpp': self.model.kpp,
'ogrn': self.model.ogrn,
'ogrn_date': self.model.ogrn_date,
'chief_position': self.model.chief_position,
'chief_fio': self.model.chief_fio,
}
if 'full_name' in d:
new_vals = d # DaData — фактические данные
else:
new_vals = d.get('proposals') or {} # ИИ — предложения
diffs = compare_company_values(current, new_vals)
if diffs:
warn = "⚠ ВНИМАНИЕ! НАЙДЕНЫ РАСХОЖДЕНИЯ:\n"
for label, old_v, new_v in diffs:
warn += f"{label}: было «{old_v}», стало «{new_v}»\n"
self._verify_text.tag_configure('warn', foreground='red',
font=('Arial', 14, 'bold'))
self._verify_text.config(state=tk.NORMAL)
self._verify_text.insert(tk.END, '\n' + warn, 'warn')
self._verify_text.config(state=tk.DISABLED)
self.log(f"⚠ ВНИМАНИЕ: найдены расхождения ({len(diffs)}): " +
'; '.join(f"{l}: {o}{n}" for l, o, n in diffs[:3]))
# Общий лог с ответом сервиса
summary = ''
if 'full_name' in d:
summary = f"{d.get('full_name', '')[:60]} (ИНН {d.get('inn', '')})"
else:
summary = f"оценка {d.get('rating', '')}, замечаний: {len(d.get('notes') or [])}"
self.log(f"{result['source']}: проверка реквизитов → {summary}")
def _verify_apply(self):
result = self._verify_result
if not result:
return
apply_data = result.get('apply', {})
idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}
if 'declension' in apply_data:
for case, val in apply_data['declension'].items():
code = self._CASE_TO_CODE.get(case)
if code and code in self.decl_entries:
self.decl_entries[code].delete(0, tk.END)
self.decl_entries[code].insert(0, val)
setattr(self.model, f'company_name_{idx_map[code]}', val)
self.log("✅ Склонения полного названия применены")
if 'short_declension' in apply_data:
for case, val in apply_data['short_declension'].items():
code = self._CASE_TO_CODE.get(case)
if code and code in self.short_decl_entries:
self.short_decl_entries[code].delete(0, tk.END)
self.short_decl_entries[code].insert(0, val)
setattr(self.model, f'short_name_{idx_map[code]}', val)
self.log("✅ Склонения краткого названия применены")
if 'company' in apply_data:
comp = apply_data['company']
if comp.get('full_name'):
self.company_name_entry.delete(0, tk.END)
self.company_name_entry.insert(0, comp['full_name'])
if comp.get('short_name'):
self.short_name_entry.delete(0, tk.END)
self.short_name_entry.insert(0, comp['short_name'])
mapping = {'address': 'address', 'kpp': 'kpp', 'ogrn': 'ogrn',
'ogrn_date': 'ogrn_date', 'inn': 'inn'}
for data_key, ui_key in mapping.items():
if comp.get(data_key) and ui_key in self._company_entries:
self._company_entries[ui_key].delete(0, tk.END)
self._company_entries[ui_key].insert(0, comp[data_key])
if comp.get('chief_position') and 'chief_position' in self._official_entries:
self._official_entries['chief_position'].delete(0, tk.END)
self._official_entries['chief_position'].insert(0, comp['chief_position'])
if comp.get('chief_fio') and 'chief_fio' in self._official_entries:
self._official_entries['chief_fio'].delete(0, tk.END)
self._official_entries['chief_fio'].insert(0, comp['chief_fio'])
self.log("✅ Реквизиты применены")
# ---------- ВКЛАДКА "ИНФОРМАЦИОННЫЕ СИСТЕМЫ" ---------- # ---------- ВКЛАДКА "ИНФОРМАЦИОННЫЕ СИСТЕМЫ" ----------
def _build_is_tab(self): def _build_is_tab(self):
tab = ttk.Frame(self.notebook) tab = ttk.Frame(self.notebook)
@@ -717,10 +333,7 @@ class DokoGenApp:
self.is_pd_count_combo.grid(row=6, column=1, padx=10, sticky=tk.W) self.is_pd_count_combo.grid(row=6, column=1, padx=10, sticky=tk.W)
self.is_pd_count_combo.bind('<<ComboboxSelected>>', lambda e: self._on_categories_or_count_changed()) self.is_pd_count_combo.bind('<<ComboboxSelected>>', lambda e: self._on_categories_or_count_changed())
# Структура ИС
ttk.Label(fields_frame, text="Структура ИС:").grid(row=7, column=0, sticky=tk.W, pady=2)
self.is_structure_entry = ttk.Entry(fields_frame, width=55)
self.is_structure_entry.grid(row=7, column=1, padx=10, sticky=tk.EW)
fields_frame.columnconfigure(1, weight=1) fields_frame.columnconfigure(1, weight=1)
@@ -779,7 +392,6 @@ class DokoGenApp:
self.users_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.users_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
btnf = ttk.Frame(user_tab) btnf = ttk.Frame(user_tab)
btnf.pack(pady=3) btnf.pack(pady=3)
ttk.Button(btnf, text="👥 Выбрать из списка", command=self._select_users_from_employees).pack(side=tk.LEFT, padx=2)
ttk.Button(btnf, text="+ Добавить", command=self._add_user_to_is).pack(side=tk.LEFT, padx=2) ttk.Button(btnf, text="+ Добавить", command=self._add_user_to_is).pack(side=tk.LEFT, padx=2)
ttk.Button(btnf, text="- Удалить", command=self._del_user_from_is).pack(side=tk.LEFT, padx=2) ttk.Button(btnf, text="- Удалить", command=self._del_user_from_is).pack(side=tk.LEFT, padx=2)
@@ -921,102 +533,6 @@ class DokoGenApp:
if emp['position'] or emp['fio']: if emp['position'] or emp['fio']:
self.model.employees_access.append(emp) self.model.employees_access.append(emp)
# ---------- ВКЛАДКА "БУМАЖНЫЕ ДОКУМЕНТЫ" ----------
def _build_paper_tab(self):
tab = ttk.Frame(self.notebook)
self.notebook.add(tab, text="Бумажные документы")
canvas = tk.Canvas(tab, highlightthickness=0)
scrollbar = ttk.Scrollbar(tab, orient=tk.VERTICAL, command=canvas.yview)
scrollable = ttk.Frame(canvas)
scrollable.bind("<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=scrollable, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
frame = ttk.Frame(scrollable, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(frame, text="Бумажные носители ПДн (документы)",
font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=(0, 10))
# Заголовки
header = ttk.Frame(frame)
header.pack(fill=tk.X)
for w, col in [(45, "Название документа"), (45, "Место хранения")]:
ttk.Label(header, text=col, font=('', 9, 'bold'),
width=w, anchor=tk.W).pack(side=tk.LEFT, padx=2)
self._paper_frame = ttk.Frame(frame)
self._paper_frame.pack(fill=tk.X, pady=5)
btn_frame = ttk.Frame(frame)
btn_frame.pack(fill=tk.X, pady=5)
ttk.Button(btn_frame, text="+ Добавить документ",
command=self._add_paper_row_ui).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="- Удалить выбранный",
command=self._delete_paper_selected).pack(side=tk.LEFT, padx=2)
self._paper_count_label = ttk.Label(frame, text="Добавлено: 0")
self._paper_count_label.pack(anchor=tk.W, pady=5)
self._paper_entries = []
# Загрузка существующих
for p in self.model.paper_documents_list:
self._add_paper_row(p.name, p.storage)
self._update_paper_count()
def _add_paper_row(self, name="", storage=""):
"""Добавляет одну строку документа."""
row = ttk.Frame(self._paper_frame)
row.pack(fill=tk.X, pady=2)
name_entry = ttk.Entry(row, width=45)
name_entry.insert(0, name)
name_entry.pack(side=tk.LEFT, padx=2)
storage_entry = ttk.Entry(row, width=45)
storage_entry.insert(0, storage)
storage_entry.pack(side=tk.LEFT, padx=2)
del_btn = ttk.Button(row, text="", width=3,
command=lambda: self._delete_paper_row(row))
del_btn.pack(side=tk.LEFT, padx=5)
self._paper_entries.append((row, name_entry, storage_entry))
self._update_paper_count()
def _add_paper_row_ui(self):
self._add_paper_row()
def _delete_paper_row(self, row_frame):
self._paper_entries = [e for e in self._paper_entries if e[0] != row_frame]
row_frame.destroy()
self._update_paper_count()
def _delete_paper_selected(self):
if self._paper_entries:
row_frame = self._paper_entries[-1][0]
self._delete_paper_row(row_frame)
def _update_paper_count(self):
count = len(self._paper_entries)
if hasattr(self, '_paper_count_label'):
self._paper_count_label.config(text=f"Добавлено: {count}")
def _collect_paper_documents(self):
"""Собирает данные бумажных документов из GUI в модель."""
self.model.paper_documents_list.clear()
for row, name_e, storage_e in self._paper_entries:
name = name_e.get().strip()
storage = storage_e.get().strip()
if name or storage:
self.model.paper_documents_list.append(
PaperDocument(name=name, storage=storage)
)
# ---------- ВКЛАДКА "ГЕНЕРАЦИЯ" ---------- # ---------- ВКЛАДКА "ГЕНЕРАЦИЯ" ----------
def _build_generation_tab(self): def _build_generation_tab(self):
tab = ttk.Frame(self.notebook) tab = ttk.Frame(self.notebook)
@@ -1061,13 +577,6 @@ class DokoGenApp:
def _build_log_tab(self): def _build_log_tab(self):
tab = ttk.Frame(self.notebook) tab = ttk.Frame(self.notebook)
self.notebook.add(tab, text="Лог") self.notebook.add(tab, text="Лог")
top = ttk.Frame(tab)
top.pack(fill=tk.X, padx=5, pady=3)
ttk.Button(top, text="💾 Экспорт логов…", command=self._export_logs).pack(side=tk.LEFT)
ttk.Label(top, text="Журнал пишется в dokogen.log рядом с программой",
foreground='gray').pack(side=tk.LEFT, padx=8)
self.log_text = scrolledtext.ScrolledText(tab, height=10) self.log_text = scrolledtext.ScrolledText(tab, height=10)
self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.log_text.insert(tk.END, "Лог готов\n") self.log_text.insert(tk.END, "Лог готов\n")
@@ -1099,10 +608,6 @@ class DokoGenApp:
isys = self.model.information_systems[self._current_is_idx] isys = self.model.information_systems[self._current_is_idx]
isys.name = self.is_name_entry.get().strip() isys.name = self.is_name_entry.get().strip()
isys.description = self.is_description_entry.get().strip() isys.description = self.is_description_entry.get().strip()
if hasattr(self, 'is_structure_entry'):
isys.structure = self.is_structure_entry.get().strip()
if hasattr(self, 'is_software_entry'):
isys.software = self.is_software_entry.get().strip()
isys.is_local_network = self.is_lan_var.get() isys.is_local_network = self.is_lan_var.get()
isys.is_internet = self.is_internet_var.get() isys.is_internet = self.is_internet_var.get()
isys.threat_type = self.is_threat_var.get() isys.threat_type = self.is_threat_var.get()
@@ -1176,7 +681,7 @@ class DokoGenApp:
# Пользователи # Пользователи
if hasattr(self, '_employee_entries'): if hasattr(self, '_employee_entries'):
for row_frame, _, _ in self._employee_entries: for row_frame, _, _, _ in self._employee_entries:
row_frame.destroy() row_frame.destroy()
self._employee_entries.clear() self._employee_entries.clear()
for emp in c.employees_access: for emp in c.employees_access:
@@ -1186,15 +691,6 @@ class DokoGenApp:
) )
self._update_emp_count() self._update_emp_count()
# Бумажные документы
if hasattr(self, '_paper_entries'):
for row_frame, _, _ in self._paper_entries:
row_frame.destroy()
self._paper_entries.clear()
for p in c.paper_documents_list:
self._add_paper_row(p.name, p.storage)
self._update_paper_count()
# Шаблоны # Шаблоны
self._on_templates_changed() self._on_templates_changed()
@@ -1371,12 +867,6 @@ class DokoGenApp:
self.is_name_entry.insert(0, isys.name or "") self.is_name_entry.insert(0, isys.name or "")
self.is_description_entry.delete(0, tk.END) self.is_description_entry.delete(0, tk.END)
self.is_description_entry.insert(0, isys.description or "") self.is_description_entry.insert(0, isys.description or "")
if hasattr(self, 'is_structure_entry'):
self.is_structure_entry.delete(0, tk.END)
self.is_structure_entry.insert(0, isys.structure or "")
if hasattr(self, 'is_software_entry'):
self.is_software_entry.delete(0, tk.END)
self.is_software_entry.insert(0, isys.software or "")
self.is_lan_var.set(isys.is_local_network) self.is_lan_var.set(isys.is_local_network)
self.is_internet_var.set(isys.is_internet) self.is_internet_var.set(isys.is_internet)
self.is_threat_var.set(isys.threat_type or "3") self.is_threat_var.set(isys.threat_type or "3")
@@ -1467,28 +957,6 @@ class DokoGenApp:
if user: if user:
self.users_listbox.insert(tk.END, user) self.users_listbox.insert(tk.END, user)
def _select_users_from_employees(self):
"""Выбор пользователей ИС из списка сотрудников организации (вкладка «Пользователи»)."""
names = []
for emp in self.model.employees_access:
fio = (emp.get('fio') or '').strip()
if fio and fio not in names:
names.append(fio)
# Дополнительно: пользователи из других ИС
for isys in self.model.information_systems:
for u in (isys.users_list or []):
u = u.strip()
if u and u not in names:
names.append(u)
if not names:
messagebox.showinfo(
"Выбор пользователей",
"Список пуст.\nЗаполните вкладку «Пользователи» (сотрудники с доступом к ПДн) "
"или добавьте пользователей в другой ИС."
)
return
self._select_from_dialog("Выбор пользователей ИС", names, self.users_listbox)
def _del_user_from_is(self): def _del_user_from_is(self):
sel = self.users_listbox.curselection() sel = self.users_listbox.curselection()
if sel: if sel:
@@ -1670,7 +1138,6 @@ class DokoGenApp:
self._collect_commission() self._collect_commission()
self._collect_is() self._collect_is()
self._collect_employees() self._collect_employees()
self._collect_paper_documents()
# Проверка # Проверка
if not self._validate_before_generate(): if not self._validate_before_generate():
@@ -1780,20 +1247,6 @@ class DokoGenApp:
self.log("✅ Склонения краткого названия обновлены") self.log("✅ Склонения краткого названия обновлены")
# ==================== ИМПОРТ ==================== # ==================== ИМПОРТ ====================
def select_import_settings(self):
"""Выбор файла настроек импорта (JSON-словарь)."""
from .import_settings import default_import_settings_path, ensure_default_file
path = filedialog.askopenfilename(
title="Файл настроек импорта (что и откуда брать)",
filetypes=[("Настройки импорта", "*.json")]
)
if not path:
self.log(f"Настройки импорта: по умолчанию ({default_import_settings_path()})")
self.import_settings_path = None
return
self.import_settings_path = path
self.log(f"Настройки импорта: {path}")
def import_from_survey(self): def import_from_survey(self):
path = filedialog.askopenfilename( path = filedialog.askopenfilename(
title="Выберите опросный лист (152-ФЗ)", title="Выберите опросный лист (152-ФЗ)",
@@ -1807,7 +1260,7 @@ class DokoGenApp:
def _run(): def _run():
try: try:
from .importer import import_152fz from .importer import import_152fz
data = import_152fz(path, self.log, settings_path=self.import_settings_path) data = import_152fz(path, self.log)
if data: if data:
self._apply_imported_data(data) self._apply_imported_data(data)
except ImportError: except ImportError:
@@ -1885,10 +1338,6 @@ class DokoGenApp:
if data.get('contractDate'): if data.get('contractDate'):
self.doc_date_var.set(data['contractDate']) self.doc_date_var.set(data['contractDate'])
# Наименование структурного подразделения по безопасности
if data.get('ispdnDepartment'):
self.model.security_department = data['ispdnDepartment']
# Информационные системы # Информационные системы
if data.get('informationSystems'): if data.get('informationSystems'):
self.model.information_systems.clear() self.model.information_systems.clear()
@@ -1896,7 +1345,6 @@ class DokoGenApp:
isys = InformationSystem( isys = InformationSystem(
name=is_data.get('name', ''), name=is_data.get('name', ''),
description=is_data.get('description', ''), description=is_data.get('description', ''),
is_local_network=is_data.get('is_has_lan', is_data.get('is_local_network', False)),
is_internet=is_data.get('isInternet', False), is_internet=is_data.get('isInternet', False),
pd_count=is_data.get('personalDataCount', 'менее 100 000'), pd_count=is_data.get('personalDataCount', 'менее 100 000'),
personal_data_list=is_data.get('personalDataList', []), personal_data_list=is_data.get('personalDataList', []),
@@ -1907,8 +1355,6 @@ class DokoGenApp:
processing_modes=is_data.get('processing_mode', ''), processing_modes=is_data.get('processing_mode', ''),
purpose=is_data.get('description', ''), purpose=is_data.get('description', ''),
room=is_data.get('room', ''), room=is_data.get('room', ''),
structure=is_data.get('structure', ''),
pd_actions_list=is_data.get('pd_actions', []),
) )
self.model.information_systems.append(isys) self.model.information_systems.append(isys)
self._refresh_is_listbox() self._refresh_is_listbox()
@@ -1941,7 +1387,7 @@ class DokoGenApp:
if data.get('employeesAccess'): if data.get('employeesAccess'):
self.model.employees_access.clear() self.model.employees_access.clear()
if hasattr(self, '_employee_entries'): if hasattr(self, '_employee_entries'):
for row_frame, _, _ in self._employee_entries: for row_frame, _, _, _ in self._employee_entries:
row_frame.destroy() row_frame.destroy()
self._employee_entries.clear() self._employee_entries.clear()
for emp in data['employeesAccess']: for emp in data['employeesAccess']:
@@ -1955,26 +1401,6 @@ class DokoGenApp:
) )
self._update_emp_count() self._update_emp_count()
# Бумажные документы (из «Названия документов» / «Место хранения»)
if data.get('paperDocuments'):
self.model.paper_documents_list.clear()
names = [n.strip() for n in data['paperDocuments'].replace('\n', ';').split(';') if n.strip()]
storages = [s.strip() for s in (data.get('paperStorage') or '').replace('\n', ';').split(';') if s.strip()]
for i, name in enumerate(names):
storage = storages[i] if i < len(storages) else (storages[0] if storages else '')
self.model.paper_documents_list.append(
PaperDocument(name=name, storage=storage)
)
# Обновляем GUI
if hasattr(self, '_paper_entries'):
for row_frame, _, _ in self._paper_entries:
row_frame.destroy()
self._paper_entries.clear()
for p in self.model.paper_documents_list:
self._add_paper_row(p.name, p.storage)
self._update_paper_count()
self.log(f" Бумажные документы: {len(self.model.paper_documents_list)} шт.")
self.log("✅ Импорт применён") self.log("✅ Импорт применён")
self.root.after(0, _apply) self.root.after(0, _apply)
@@ -2019,7 +1445,6 @@ class DokoGenApp:
self._collect_commission() self._collect_commission()
self._collect_is() self._collect_is()
self._collect_employees() self._collect_employees()
self._collect_paper_documents()
try: try:
with open(filename, 'w', encoding='utf-8') as f: with open(filename, 'w', encoding='utf-8') as f:
json.dump(self.model.to_dict(), f, ensure_ascii=False, indent=2) json.dump(self.model.to_dict(), f, ensure_ascii=False, indent=2)
@@ -2045,7 +1470,6 @@ class DokoGenApp:
self._collect_commission() self._collect_commission()
self._collect_is() self._collect_is()
self._collect_employees() self._collect_employees()
self._collect_paper_documents()
try: try:
os.makedirs(os.path.dirname(self._autosave_path), exist_ok=True) os.makedirs(os.path.dirname(self._autosave_path), exist_ok=True)
with open(self._autosave_path, 'w', encoding='utf-8') as f: with open(self._autosave_path, 'w', encoding='utf-8') as f:
@@ -2125,32 +1549,8 @@ class DokoGenApp:
self.log_text.configure(state='disabled') self.log_text.configure(state='disabled')
except Exception: except Exception:
pass pass
# Дублируем в файловый журнал
try:
with open(self._log_path, 'a', encoding='utf-8') as f:
f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}\n")
except Exception:
pass
self.status_var.set(message) self.status_var.set(message)
def _export_logs(self):
"""Экспорт лога в файл."""
filename = filedialog.asksaveasfilename(
title="Экспорт логов",
defaultextension=".log",
initialfile=f"dokogen_log_{datetime.now().strftime('%Y%m%d_%H%M')}.log",
filetypes=[("Лог-файл", "*.log"), ("Текстовый файл", "*.txt"), ("Все файлы", "*.*")]
)
if not filename:
return
try:
content = self.log_text.get('1.0', tk.END)
with open(filename, 'w', encoding='utf-8') as f:
f.write(content)
self.log(f"💾 Лог экспортирован: {filename}")
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось экспортировать лог: {e}")
def show_about(self): def show_about(self):
messagebox.showinfo("О программе", messagebox.showinfo("О программе",
"ДоКоГеНеРаТоР — 152-ФЗ\n" "ДоКоГеНеРаТоР — 152-ФЗ\n"
@@ -2158,50 +1558,10 @@ class DokoGenApp:
"Генератор документов из шаблонов") "Генератор документов из шаблонов")
def show_variables(self): def show_variables(self):
"""Показ переменных шаблонов — теперь через скачивание Word-документа.""" text = "Переменные шаблонов:\n\n"
self.download_variables_doc() for var, group, desc, _ in VARIABLE_DEFS:
text += f"{var}{desc} ({'общ.' if group == 'A' else '152-ФЗ'})\n"
# ---------- СПРАВКА: СКАЧИВАНИЕ ИНСТРУКЦИЙ / ОПРОСНИКА ---------- messagebox.showinfo("Переменные шаблонов", text)
def _save_resource(self, source_path, default_name, title):
"""Копирует ресурс (docx/xlsx) в выбранное пользователем место."""
if not source_path or not os.path.exists(source_path):
messagebox.showerror("Справка", f"Файл не найден:\n{source_path}")
return
filename = filedialog.asksaveasfilename(
title=title,
defaultextension=os.path.splitext(default_name)[1],
initialfile=default_name,
filetypes=[("Документ", "*.docx"), ("Все файлы", "*.*")]
)
if not filename:
return
try:
import shutil
shutil.copy(source_path, filename)
self.log(f"💾 Сохранено: {filename}")
messagebox.showinfo("Справка", f"Файл сохранён:\n{filename}")
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось сохранить: {e}")
def download_user_manual(self):
from .help_docs import ensure_docs, user_manual_path
ensure_docs()
self._save_resource(user_manual_path(), "Инструкция_пользователя.docx", "Сохранить инструкцию пользователя")
def download_admin_manual(self):
from .help_docs import ensure_docs, admin_manual_path
ensure_docs()
self._save_resource(admin_manual_path(), "Инструкция_администратора.docx", "Сохранить инструкцию администратора")
def download_variables_doc(self):
from .help_docs import ensure_docs, variables_reference_path
ensure_docs()
self._save_resource(variables_reference_path(), "Переменные_шаблонов.docx", "Сохранить справочник переменных")
def download_survey(self):
from .help_docs import ensure_docs, survey_path
ensure_docs()
self._save_resource(survey_path(), "Опросный_лист_152_ФЗ.xlsx", "Сохранить пустой опросник")
def main(): def main():
+13 -139
View File
@@ -9,12 +9,6 @@ from . import declension as dc
from .models import Company, CommissionMember from .models import Company, CommissionMember
def _norm_name(s):
"""Нормализация ФИО для сравнения: регистр, пробелы, точки, тире игнорируются."""
s = (s or '').lower().replace('ё', 'е')
return re.sub(r'[\s.,;:()«»"\'\-]+', '', s)
# ============================================================ # ============================================================
# ОПИСАНИЕ ПЕРЕМЕННЫХ # ОПИСАНИЕ ПЕРЕМЕННЫХ
# ============================================================ # ============================================================
@@ -56,8 +50,6 @@ VARIABLE_DEFS = [
('{{company_ogrn}}', 'A', 'ОГРН', 'company:ogrn'), ('{{company_ogrn}}', 'A', 'ОГРН', 'company:ogrn'),
('{{company_ogrn_date}}', 'A', 'Дата ОГРН', 'company:ogrn_date'), ('{{company_ogrn_date}}', 'A', 'Дата ОГРН', 'company:ogrn_date'),
('{{company_kpp}}', 'A', 'КПП', 'company:kpp'), ('{{company_kpp}}', 'A', 'КПП', 'company:kpp'),
('{{security_department}}', 'A', 'Наименование структурного подразделения по безопасности', 'company:security_department'),
('{{item.pd_actions}}', 'B', 'Действия с ПДн (в цикле ИС)', 'item:pd_actions'),
# === Комиссия === # === Комиссия ===
('{{commission_list}}', 'A', 'Список комиссии', 'commission_list'), ('{{commission_list}}', 'A', 'Список комиссии', 'commission_list'),
@@ -173,42 +165,14 @@ def build_replacements(
return replacements return replacements
def _commission_role_display(members) -> List[str]:
"""Группирует роли комиссии для вывода:
- Председатель/Секретарь/Заместитель отдельной строкой со своей ролью;
- обычные члены первый получает «Члены комиссии», остальные пустую роль
(чтобы не повторять «Член комиссии» в каждой строке)."""
result = []
member_used = False
for m in members:
role = (m.role or 'член комиссии').strip()
role_lower = role.lower()
if ('председател' in role_lower or 'секретар' in role_lower
or 'заместител' in role_lower or 'зам. председателя' in role_lower):
result.append(role)
continue
# обычный член комиссии
if not member_used:
result.append('Члены комиссии')
member_used = True
else:
result.append('')
return result
def _build_commission_data(company: Company) -> Dict[str, str]: def _build_commission_data(company: Company) -> Dict[str, str]:
data = {} data = {}
members = company.commission or [] members = company.commission or []
display_roles = _commission_role_display(members)
lines = [] lines = []
for i, (m, role_disp) in enumerate(zip(members, display_roles), 1): for i, m in enumerate(members, 1):
if role_disp: role = m.role or 'член комиссии'
line = (f"{i}. {role_disp}: {m.position}{m.fio}" if m.position line = f"{i}. {role}: {m.position}{m.fio}" if m.position else f"{i}. {role}: {m.fio}"
else f"{i}. {role_disp}: {m.fio}")
else:
line = (f"{i}. {m.position}{m.fio}" if m.position
else f"{i}. {m.fio}")
lines.append(line) lines.append(line)
data['commission_list'] = '\n'.join(lines) if lines else '' data['commission_list'] = '\n'.join(lines) if lines else ''
@@ -222,12 +186,11 @@ def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int):
'is_description': isys.description or '', 'is_description': isys.description or '',
'is_has_internet': 'да' if isys.is_internet else 'нет', 'is_has_internet': 'да' if isys.is_internet else 'нет',
'is_defence_level': str(isys.defence_level or ''), 'is_defence_level': str(isys.defence_level or ''),
'is_category': ', '.join(isys.personal_data_category) or isys.category or '', 'is_category': isys.category or '',
'is_pd_list': ', '.join(isys.personal_data_list or []) or '', 'is_pd_list': ', '.join(isys.personal_data_list or []) or '',
'is_users': ', '.join(isys.users_list or []) or '', 'is_users': ', '.join(isys.users_list or []) or '',
'is_pd_count': str(isys.pd_count or ''), 'is_pd_count': str(isys.pd_count or ''),
'is_purpose': isys.purpose or '', 'is_purpose': isys.purpose or '',
'is_structure': getattr(isys, 'structure', '') or '',
} }
for f, v in fields.items(): for f, v in fields.items():
replacements[f'{{{{{f}_{idx}}}}}'] = str(v) replacements[f'{{{{{f}_{idx}}}}}'] = str(v)
@@ -239,39 +202,13 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
# Комиссия # Комиссия
members = company.commission or [] members = company.commission or []
if members: if members:
display_roles = _commission_role_display(members)
items = [] items = []
for m, role_disp in zip(members, display_roles): for m in members:
pos = m.position or ''
fio = m.fio or ''
if role_disp:
full = f"{role_disp} {pos}{fio}" if pos != '' else f"{role_disp}{fio}"
else:
full = f"{pos}{fio}" if pos != '' else fio
# Строка подписи: «___________ / » (только подчёркивание и слэш).
# Роль НЕ включаем — в шаблонах она выводится отдельной колонкой {{item.role}},
# иначе получается дубль «Председатель комиссии | Председатель комиссии: ___».
if 'Председатель' in role_disp:
sig = f"{'_' * 11} / "
sig_full = f"{role_disp}: {'_' * 11} / "
elif role_disp:
sig = f"{'_' * 17} / "
sig_full = f"{role_disp}: {'_' * 17} / "
else:
sig = f"{'_' * 17} / "
sig_full = f"{'_' * 17} / "
initials = dc.get_initials(fio) if fio != '' else ''
items.append({ items.append({
'role': role_disp, 'role': m.role or 'член комиссии',
'position': pos, 'position': m.position or '',
'fio': fio, 'fio': m.fio or '',
'fio_initials': dc.get_initials(fio) if fio != '' else '', 'full': f"{m.role} {m.position}{m.fio}" if m.position else f"{m.role}{m.fio}",
'fio_short': dc.get_short_fio(fio) if fio != '' else '',
'full': full,
# Рамка подписи без роли и ФИО: «___________ / » (ФИО добавится)
'signature': sig,
# Полная подпись с ролью и ФИО: «Роль: ___________ / И.О. Фамилия»
'signature_full': (sig_full + initials).strip(),
}) })
loops['commission'] = items loops['commission'] = items
@@ -279,81 +216,32 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
if is_list: if is_list:
items = [] items = []
for isys in is_list: for isys in is_list:
# Пользователи ИС для вложенного цикла <!-- loop:users_loop -->
# ВСЕ пользователи, включая администратора (без дублей по ФИО)
seen = set()
users_loop = []
for u in (isys.users_list or []):
key = _norm_name(u)
if key and key in seen:
continue
seen.add(key)
users_loop.append({'fio': u, 'position': ''})
items.append({ items.append({
'name': isys.name or '', 'name': isys.name or '',
'description': isys.description or '', 'description': isys.description or '',
'address': getattr(isys, 'address', '') or '',
'is_has_lan': 'Да' if isys.is_local_network else 'Нет', 'is_has_lan': 'Да' if isys.is_local_network else 'Нет',
'has_internet': 'да' if isys.is_internet else 'нет', 'has_internet': 'да' if isys.is_internet else 'нет',
'defence_level': str(isys.defence_level or ''), 'defence_level': str(isys.defence_level or ''),
'threat_type': str(isys.threat_type or '3'), 'threat_type': str(isys.threat_type or '3'),
'category': ', '.join(isys.personal_data_category) or isys.category or '', 'category': isys.category or '',
'pd_list': ', '.join(isys.personal_data_list or []) or '', 'pd_list': ', '.join(isys.personal_data_list or []) or '',
'pd_count': str(isys.pd_count or ''), 'pd_count': str(isys.pd_count or ''),
'users': ', '.join(isys.users_list or []) or '', 'users': ', '.join(isys.users_list or []) or '',
'users_loop': users_loop, 'defense_tools_list': '; '.join(isys.defense_tools_list or []) or '',
'defense_tools_list': ', '.join(isys.defense_tools_list or []) or '',
'processing_modes': isys.processing_modes or '', 'processing_modes': isys.processing_modes or '',
'pd_subjects': '; '.join(isys.pd_subjects_list or []) or '', 'pd_subjects': '; '.join(isys.pd_subjects_list or []) or '',
'is_purpose': isys.purpose or '', 'is_purpose': isys.purpose or '',
'structure': getattr(isys, 'structure', '') or '',
'software': getattr(isys, 'software', '') or '',
'pd_actions': '; '.join(isys.pd_actions_list or []) or '',
}) })
loops['information_systems'] = items loops['information_systems'] = items
# Бумажные документы # Бумажные документы
def _storage_with_prep(storage: str) -> str:
"""«сейф» → «в сейфе» (предлог + предложный падеж).
Если уже начинается с предлога оставить как есть."""
if not storage:
return ''
s = storage.strip()
low = s.lower()
if low.startswith(('в ', 'на ', 'под ', 'за ', 'у ', 'при ', 'из ', 'со ', 'во ', 'над ', 'перед ')):
return s
words = s.split()
out = []
# Исключения предложного падежа: «в шкафу», «в углу» (не «в шкафе»)
loct_exceptions = {'шкаф': 'шкафу', 'угол': 'углу', 'край': 'краю', 'рот': 'рту', 'мост': 'мосту'}
for w in words:
core = w.strip('.,;')
if not core or core.isdigit() or core.startswith(''):
out.append(w)
continue
if core.lower() in loct_exceptions:
out.append(loct_exceptions[core.lower()])
continue
try:
p = dc.morph.parse(core)[0]
inf = p.inflect({'loct'})
if inf:
out.append(inf.word)
continue
except Exception:
pass
out.append(w)
return 'в ' + ' '.join(out)
paper_list = getattr(company, 'paper_documents_list', None) or [] paper_list = getattr(company, 'paper_documents_list', None) or []
if paper_list: if paper_list:
items = [] items = []
for p in paper_list: for p in paper_list:
storage = p.storage or ''
items.append({ items.append({
'document': p.name or '', 'document': p.name or '',
'storage': _storage_with_prep(storage), # «в сейфе» 'storage': p.storage or '',
'storage_raw': storage, # «сейф» (как введено)
}) })
loops['paper_documents'] = items loops['paper_documents'] = items
@@ -392,21 +280,7 @@ def _resolve(source: str, ctx: Dict) -> str:
if source.startswith('decl:'): if source.startswith('decl:'):
_, field, case = source.split(':') _, field, case = source.split(':')
name = getattr(company, field, '') or '' name = getattr(company, field, '') or ''
case_gr = dc._normalize_case(case) return dc.company_name_decline(name, case) if name else ''
# Сначала берём ГОТОВОЕ склонение из модели (пользователь мог
# подставить через Морфер/ИИ или ввести вручную), пересчитываем
# только если поле пустое
case_idx = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}.get(case_gr)
if case_idx:
if field == 'full_name':
ready = getattr(company, f'company_name_{case_idx}', '') or ''
if ready:
return ready
elif field == 'short_name':
ready = getattr(company, f'short_name_{case_idx}', '') or ''
if ready:
return ready
return dc.company_name_decline(name, case_gr) if name else ''
# Форматы ФИО # Форматы ФИО
if source.startswith('short_fio:'): if source.startswith('short_fio:'):
-440
View File
@@ -1,440 +0,0 @@
# -*- 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