Причина: внутри _calc() в ветках стояли импорты 'from .variables import CRITERION_QUANT_THRESHOLDS...' — Python считает эти имена локальными для всей функции, а условие 'if (num in CRITERION_QUANT_THRESHOLDS ...)' в начале _calc обращалось к ним до присваивания → UnboundLocalError при любом расчёте. Исправление: все импорты из .variables вынесены на уровень _open_calc_dialog (начало функции); внутренние импорты из _calc() и дубликаты в теле окна убраны.
5319 lines
286 KiB
Python
5319 lines
286 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""DokoGen — Пользовательский интерфейс (tkinter)"""
|
||
|
||
import tkinter as tk
|
||
from tkinter import ttk, messagebox, filedialog, simpledialog, scrolledtext
|
||
import os
|
||
import json
|
||
import re
|
||
import sys
|
||
import threading
|
||
from datetime import datetime
|
||
from typing import List, Optional
|
||
|
||
from . import (
|
||
Company, CommissionMember, InformationSystem,
|
||
inflect_name,
|
||
build_replacements, VARIABLE_DEFS,
|
||
process_template,
|
||
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, normalize_threats,
|
||
)
|
||
from .api_settings import load_api_settings, save_api_settings
|
||
|
||
# Бусредн — средний доход федерального бюджета за 3 года (данные Минфина).
|
||
# ВАЖНО: значение устаревает — обновлять ежегодно по данным Минфина или
|
||
# запрашивать у оператора в диалоге авторасчёта (поле редактируется).
|
||
DEFAULT_BUDGET_AVG = 21228053357700.0
|
||
|
||
|
||
class DokoGenApp:
|
||
"""Главное окно приложения."""
|
||
|
||
def __init__(self, root: tk.Tk):
|
||
self.root = root
|
||
root.title("ДоКоГеНеРаТоР — 187-ФЗ (КИИ)")
|
||
root.geometry("1200x800")
|
||
root.minsize(900, 600)
|
||
self._set_app_icon()
|
||
|
||
self.model = Company()
|
||
self.current_file: Optional[str] = None
|
||
self._current_is_idx: Optional[int] = None
|
||
self._template_files: List[str] = []
|
||
self._selected_templates: set = set()
|
||
self._last_template_dir: str = ""
|
||
self._autosave_path = os.path.join(
|
||
os.path.dirname(os.path.abspath(__file__)),
|
||
'.autosave', 'autosave_187.json'
|
||
)
|
||
|
||
# Путь к файлу настроек импорта (словарь «что и откуда брать»)
|
||
# None = import_settings.json рядом с программой (создаётся автоматически)
|
||
self.import_settings_path: Optional[str] = None
|
||
|
||
# Файловый лог: рядом с exe (frozen) или рядом с модулем
|
||
if getattr(sys, 'frozen', False):
|
||
_base = os.path.dirname(sys.executable)
|
||
else:
|
||
_base = os.path.dirname(os.path.abspath(__file__))
|
||
self._log_path = os.path.join(_base, 'dokogen.log')
|
||
|
||
# Переменные
|
||
self.doc_number_var = tk.StringVar(value="")
|
||
self.doc_date_var = tk.StringVar(value="")
|
||
self.templates_var = tk.StringVar()
|
||
self.save_var = tk.StringVar()
|
||
self.status_var = tk.StringVar(value="Готов")
|
||
|
||
# Словари для хранения виджетов
|
||
self._company_entries: dict = {}
|
||
self._official_entries: dict = {}
|
||
self.commission_entries: list = []
|
||
|
||
self._build_menu()
|
||
self._build_ui()
|
||
self._enable_context_menu()
|
||
self._init_company()
|
||
|
||
# Привязка закрытия
|
||
root.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
|
||
# Автосохранение
|
||
self._load_autosave()
|
||
self._start_autosave_timer()
|
||
|
||
def _set_app_icon(self):
|
||
"""Устанавливает иконку приложения на окно (заголовок, панель задач).
|
||
|
||
Работает и из исходников, и из exe (frozen):
|
||
- исходники: dokogen/icon.ico рядом с модулем
|
||
- exe: dokogen/icon.ico из _MEIPASS (ресурсы PyInstaller)
|
||
"""
|
||
try:
|
||
if getattr(sys, 'frozen', False):
|
||
base = getattr(sys, '_MEIPASS', os.path.dirname(sys.executable))
|
||
ico = os.path.join(base, 'dokogen', 'icon.ico')
|
||
if not os.path.exists(ico):
|
||
ico = os.path.join(os.path.dirname(sys.executable), 'icon.ico')
|
||
else:
|
||
ico = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'icon.ico')
|
||
if os.path.exists(ico):
|
||
self.root.iconbitmap(ico)
|
||
except Exception:
|
||
pass
|
||
|
||
# ==================== МЕНЮ ====================
|
||
def _build_menu(self):
|
||
menubar = tk.Menu(self.root)
|
||
self.root.config(menu=menubar)
|
||
|
||
file_menu = tk.Menu(menubar, tearoff=0)
|
||
menubar.add_cascade(label="Файл", menu=file_menu)
|
||
file_menu.add_command(label="Новое", command=self.new_data, accelerator="Ctrl+N")
|
||
file_menu.add_command(label="Открыть", command=self.open_data, accelerator="Ctrl+O")
|
||
file_menu.add_command(label="Сохранить", command=self.save_data, accelerator="Ctrl+S")
|
||
file_menu.add_command(label="Сохранить как", command=self.save_as_data, accelerator="Ctrl+Shift+S")
|
||
file_menu.add_separator()
|
||
|
||
self.recent_menu = tk.Menu(file_menu, tearoff=0)
|
||
file_menu.add_cascade(label="Последние файлы", menu=self.recent_menu)
|
||
file_menu.add_separator()
|
||
file_menu.add_command(label="Настройки…", command=self.open_api_settings)
|
||
file_menu.add_separator()
|
||
file_menu.add_command(label="Выход", command=self._on_close)
|
||
|
||
gen_menu = tk.Menu(menubar, tearoff=0)
|
||
menubar.add_cascade(label="Генерация", menu=gen_menu)
|
||
gen_menu.add_command(label="Импорт из опросника", command=self.import_from_survey)
|
||
gen_menu.add_command(label="Файл настроек импорта…", command=self.select_import_settings)
|
||
gen_menu.add_separator()
|
||
gen_menu.add_command(label="Сгенерировать всё", command=self.generate_all)
|
||
|
||
help_menu = tk.Menu(menubar, tearoff=0)
|
||
menubar.add_cascade(label="Справка", menu=help_menu)
|
||
help_menu.add_command(label="О программе", command=self.show_about)
|
||
help_menu.add_command(label="Инструкция пользователя (Word)…", command=self.download_user_manual)
|
||
help_menu.add_command(label="Инструкция администратора (Word)…", command=self.download_admin_manual)
|
||
help_menu.add_command(label="Переменные шаблонов (Word)…", command=self.download_variables_doc)
|
||
help_menu.add_command(label="Пустой опросник (Excel)…", command=self.download_survey)
|
||
|
||
# ==================== ИНТЕРФЕЙС ====================
|
||
def _build_ui(self):
|
||
# Панель инструментов
|
||
toolbar = ttk.Frame(self.root)
|
||
toolbar.pack(side=tk.TOP, fill=tk.X, padx=5, pady=3)
|
||
|
||
ttk.Button(toolbar, text="📂 Открыть", command=self.open_data).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(toolbar, text="💾 Сохранить", command=self.save_data).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(toolbar, text="📥 Импорт", command=self.import_from_survey).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(toolbar, text="📁 Шаблоны", command=self.select_templates).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(toolbar, text="⚡ Генерация", command=self.generate_all).pack(side=tk.LEFT, padx=2)
|
||
|
||
# Ноутбук (вкладки)
|
||
self.notebook = ttk.Notebook(self.root)
|
||
self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=2)
|
||
|
||
self._build_company_tab()
|
||
self._build_is_tab()
|
||
self._build_commission_tab()
|
||
self._build_users_tab()
|
||
self._build_generation_tab()
|
||
self._build_log_tab()
|
||
|
||
# Статус-бар
|
||
statusbar = ttk.Label(self.root, textvariable=self.status_var,
|
||
relief=tk.SUNKEN, anchor=tk.W)
|
||
statusbar.pack(side=tk.BOTTOM, fill=tk.X)
|
||
|
||
# Горячие клавиши
|
||
self.root.bind("<Control-n>", lambda e: self.new_data())
|
||
self.root.bind("<Control-o>", lambda e: self.open_data())
|
||
self.root.bind("<Control-s>", lambda e: self.save_data())
|
||
self.root.bind("<Control-S>", lambda e: self.save_as_data())
|
||
|
||
# ---------- ВКЛАДКА "ОРГАНИЗАЦИЯ" ----------
|
||
def _build_company_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)
|
||
|
||
main = ttk.Frame(scrollable, padding=15)
|
||
main.pack(fill=tk.BOTH, expand=True)
|
||
|
||
left = ttk.Frame(main)
|
||
left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
|
||
# --- Название организации ---
|
||
f1 = ttk.LabelFrame(left, text="Наименование организации", padding=10)
|
||
f1.pack(fill=tk.X, pady=5)
|
||
|
||
ttk.Label(f1, text="Полное название:").grid(row=0, column=0, sticky=tk.W, pady=2)
|
||
self.company_name_entry = ttk.Entry(f1, width=60)
|
||
self.company_name_entry.grid(row=0, column=1, padx=10, sticky=tk.EW)
|
||
ttk.Button(f1, text="Склонять", command=self._update_company_declensions).grid(
|
||
row=0, column=2, padx=5)
|
||
|
||
ttk.Label(f1, text="Краткое название:").grid(row=1, column=0, sticky=tk.W, pady=2)
|
||
self.short_name_entry = ttk.Entry(f1, width=60)
|
||
self.short_name_entry.grid(row=1, column=1, padx=10, sticky=tk.EW)
|
||
ttk.Button(f1, text="Склонять", command=self._update_short_declensions).grid(
|
||
row=1, column=2, padx=5)
|
||
f1.columnconfigure(1, weight=1)
|
||
|
||
# Склонения полного названия
|
||
decl_frame = ttk.Frame(f1)
|
||
decl_frame.grid(row=2, column=0, columnspan=3, sticky=tk.EW, pady=(5, 0))
|
||
f1.columnconfigure(0, weight=0)
|
||
f1.columnconfigure(1, weight=1)
|
||
f1.columnconfigure(2, weight=0)
|
||
|
||
self.decl_entries = {}
|
||
cases = [
|
||
("Именительный (Кто? Что?)", "nomn"),
|
||
("Родительный (Кого? Чего?)", "gent"),
|
||
("Дательный (Кому? Чему?)", "datv"),
|
||
("Винительный (Кого? Что?)", "accs"),
|
||
("Творительный (Кем? Чем?)", "ablt"),
|
||
("Предложный (О ком? О чём?)", "loct"),
|
||
]
|
||
for i, (label, code) in enumerate(cases):
|
||
ttk.Label(decl_frame, text=label, font=('', 8)).grid(row=i, column=0, sticky=tk.W, padx=(0, 5))
|
||
entry = ttk.Entry(decl_frame, width=55, font=('', 8))
|
||
entry.grid(row=i, column=1, sticky=tk.EW, pady=1)
|
||
self.decl_entries[code] = entry
|
||
decl_frame.columnconfigure(1, weight=1)
|
||
|
||
# Склонения краткого названия
|
||
ttk.Label(decl_frame, text="", font=('', 5)).grid(row=6, column=0)
|
||
self.short_decl_entries = {}
|
||
for i, (label, code) in enumerate(cases):
|
||
row = i + 7
|
||
ttk.Label(decl_frame, text=f"Кратк. {label.lower()}",
|
||
font=('', 8)).grid(row=row, column=0, sticky=tk.W, padx=(0, 5))
|
||
entry = ttk.Entry(decl_frame, width=55, font=('', 8))
|
||
entry.grid(row=row, column=1, sticky=tk.EW, pady=1)
|
||
self.short_decl_entries[code] = entry
|
||
decl_frame.columnconfigure(1, weight=1)
|
||
|
||
# --- Адрес и реквизиты ---
|
||
f2 = ttk.LabelFrame(left, text="Адрес и реквизиты", padding=10)
|
||
f2.pack(fill=tk.X, pady=5)
|
||
|
||
addr_fields = [
|
||
("Юридический адрес:", "address"),
|
||
("Город:", "city_name"),
|
||
("ИНН:", "inn"),
|
||
("КПП:", "kpp"),
|
||
("ОГРН:", "ogrn"),
|
||
("Дата ОГРН:", "ogrn_date"),
|
||
("Доменное имя и внешний IP:", "domain_name"),
|
||
]
|
||
for i, (label, key) in enumerate(addr_fields):
|
||
ttk.Label(f2, text=label).grid(row=i, column=0, sticky=tk.W, pady=2)
|
||
entry = ttk.Entry(f2, width=50)
|
||
entry.grid(row=i, column=1, padx=10, sticky=tk.EW, pady=1)
|
||
self._company_entries[key] = entry
|
||
|
||
# Привязка автозаполнения города из адреса
|
||
addr_entry = self._company_entries.get('address')
|
||
if addr_entry:
|
||
addr_entry.bind('<KeyRelease>', self._on_address_changed)
|
||
|
||
f2.columnconfigure(1, weight=1)
|
||
|
||
# --- ОКВЭД ---
|
||
f_okved = ttk.LabelFrame(left, text="ОКВЭД", padding=10)
|
||
f_okved.pack(fill=tk.X, pady=5)
|
||
|
||
# Основной ОКВЭД — один, из словаря или вручную
|
||
ttk.Label(f_okved, text="Основной ОКВЭД:").grid(row=0, column=0, sticky=tk.W, pady=2)
|
||
okved_entry = ttk.Entry(f_okved, width=50)
|
||
okved_entry.grid(row=0, column=1, padx=10, sticky=tk.EW, pady=1)
|
||
self._company_entries['okved'] = okved_entry
|
||
ttk.Button(f_okved, text="Выбрать из словаря",
|
||
command=self._select_main_okved).grid(row=0, column=2, padx=5)
|
||
|
||
# Дополнительные ОКВЭД — сколько угодно
|
||
ttk.Label(f_okved, text="Дополнительные ОКВЭД:").grid(row=1, column=0, sticky=tk.NW, pady=2)
|
||
self.okved_extra_listbox = tk.Listbox(f_okved, height=4, width=50)
|
||
self.okved_extra_listbox.grid(row=1, column=1, padx=10, sticky=tk.EW, pady=1)
|
||
okved_btn = ttk.Frame(f_okved)
|
||
okved_btn.grid(row=1, column=2, padx=5, sticky=tk.N)
|
||
ttk.Button(okved_btn, text="Выбрать из словаря",
|
||
command=self._select_extra_okved).pack(fill=tk.X, pady=1)
|
||
ttk.Button(okved_btn, text="+ Добавить",
|
||
command=self._add_okved_manual).pack(fill=tk.X, pady=1)
|
||
ttk.Button(okved_btn, text="- Удалить",
|
||
command=self._delete_okved_extra).pack(fill=tk.X, pady=1)
|
||
|
||
f_okved.columnconfigure(1, weight=1)
|
||
|
||
# --- Должностные лица ---
|
||
f3 = ttk.LabelFrame(left, text="Должностные лица", padding=10)
|
||
f3.pack(fill=tk.X, pady=5)
|
||
|
||
of_fields = [
|
||
("Руководитель (должность):", "chief_position"),
|
||
("Руководитель (ФИО):", "chief_fio"),
|
||
("Ответственный (должность):", "responsible_position"),
|
||
("Ответственный (ФИО):", "responsible_fio"),
|
||
("Администратор (должность):", "admin_position"),
|
||
("Администратор (ФИО):", "admin_fio"),
|
||
]
|
||
for i, (label, key) in enumerate(of_fields):
|
||
ttk.Label(f3, text=label).grid(row=i, column=0, sticky=tk.W, pady=2)
|
||
entry = ttk.Entry(f3, width=50)
|
||
entry.grid(row=i, column=1, padx=10, sticky=tk.EW, pady=1)
|
||
self._official_entries[key] = entry
|
||
f3.columnconfigure(1, weight=1)
|
||
|
||
# --- Документ ---
|
||
f4 = ttk.LabelFrame(left, text="Документ", padding=10)
|
||
f4.pack(fill=tk.X, pady=5)
|
||
ttk.Label(f4, text="Номер договора:").grid(row=0, column=0, sticky=tk.W)
|
||
ttk.Entry(f4, textvariable=self.doc_number_var, width=25).grid(
|
||
row=0, column=1, padx=10, sticky=tk.W)
|
||
ttk.Label(f4, text="Дата договора:").grid(row=1, column=0, sticky=tk.W)
|
||
ttk.Entry(f4, textvariable=self.doc_date_var, width=25).grid(
|
||
row=1, column=1, padx=10, sticky=tk.W)
|
||
|
||
# --- Финансовые показатели (опросник) ---
|
||
f_fin = ttk.LabelFrame(left, text="Финансовые показатели", padding=10)
|
||
f_fin.pack(fill=tk.X, pady=5)
|
||
fin_fields = [
|
||
("Сумма прибыли за предыдущий год (руб.):", "profit"),
|
||
("Сумма налога с прибыли (руб.):", "profit_tax"),
|
||
]
|
||
for i, (label, key) in enumerate(fin_fields):
|
||
ttk.Label(f_fin, text=label).grid(row=i, column=0, sticky=tk.W, pady=2)
|
||
entry = ttk.Entry(f_fin, width=50)
|
||
entry.grid(row=i, column=1, padx=10, sticky=tk.EW, pady=1)
|
||
self._company_entries[key] = entry
|
||
|
||
# Показатель 8 (ущерб субъекту КИИ): доход за 5 лет (суммарно) + суммарный ущерб
|
||
ttk.Button(f_fin, text="🌐 Загрузить доходы из DaData (по ИНН)",
|
||
command=self._load_incomes_from_dadata).grid(
|
||
row=len(fin_fields), column=2, padx=5, sticky=tk.W)
|
||
income_fields = [
|
||
("Доход за 5 лет (суммарно, руб.):", "income_5y"),
|
||
("Снижение уровня дохода от нарушения критических процессов (руб.):", "income_loss"),
|
||
]
|
||
for j, (label, key) in enumerate(income_fields):
|
||
ttk.Label(f_fin, text=label).grid(row=len(fin_fields) + 1 + j, column=0, sticky=tk.W, pady=2)
|
||
entry = ttk.Entry(f_fin, width=50)
|
||
entry.grid(row=len(fin_fields) + 1 + j, column=1, padx=10, sticky=tk.EW, pady=1)
|
||
self._company_entries[key] = entry
|
||
f_fin.columnconfigure(1, weight=1)
|
||
|
||
# --- Правая панель: проверка данных ---
|
||
right = ttk.LabelFrame(main, text="Проверка данных", padding=10)
|
||
right.pack(side=tk.RIGHT, fill=tk.Y, padx=(10, 0))
|
||
|
||
ttk.Label(right, text="Склонение названия:", font=('', 9, 'bold')).pack(anchor=tk.W)
|
||
ttk.Button(right, text="🌐 Склонение — Морфер 3.0",
|
||
command=self._check_declension_web).pack(fill=tk.X, pady=2)
|
||
ttk.Button(right, text="🤖 Склонение — ИИ",
|
||
command=self._check_declension_ai).pack(fill=tk.X, pady=2)
|
||
|
||
ttk.Label(right, text="Реквизиты организации:", font=('', 9, 'bold')).pack(anchor=tk.W, pady=(10, 0))
|
||
ttk.Button(right, text="🌐 Реквизиты — DaData",
|
||
command=self._check_company_web).pack(fill=tk.X, pady=2)
|
||
ttk.Button(right, text="🤖 Реквизиты — ИИ",
|
||
command=self._check_company_ai).pack(fill=tk.X, pady=2)
|
||
|
||
ttk.Label(right, text="Результат проверки:", font=('', 9, 'bold')).pack(anchor=tk.W, pady=(10, 0))
|
||
self._verify_text = scrolledtext.ScrolledText(
|
||
right, width=44, height=14, state=tk.DISABLED, wrap=tk.WORD, font=('', 9))
|
||
self._verify_text.pack(fill=tk.BOTH, expand=True, pady=4)
|
||
|
||
btn_row = ttk.Frame(right)
|
||
btn_row.pack(fill=tk.X)
|
||
ttk.Button(btn_row, text="✓ Применить", command=self._verify_apply).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_row, text="Очистить", command=self._verify_clear).pack(side=tk.LEFT, padx=2)
|
||
|
||
# Выборочное применение: чекбоксы «что подставлять»
|
||
self._apply_flags = {
|
||
'declension': tk.BooleanVar(value=True),
|
||
'short_declension': tk.BooleanVar(value=True),
|
||
'address': tk.BooleanVar(value=True),
|
||
'requisites': tk.BooleanVar(value=True),
|
||
'chief': tk.BooleanVar(value=True),
|
||
'okved': tk.BooleanVar(value=True),
|
||
'okved_extra': tk.BooleanVar(value=True),
|
||
}
|
||
apply_row = ttk.Frame(right)
|
||
apply_row.pack(fill=tk.X, pady=(4, 0))
|
||
ttk.Label(apply_row, text="Подставлять:", font=('', 8)).pack(side=tk.LEFT, padx=(0, 4))
|
||
ttk.Checkbutton(apply_row, text="склонения", variable=self._apply_flags['declension']).pack(side=tk.LEFT)
|
||
ttk.Checkbutton(apply_row, text="кр. склон.", variable=self._apply_flags['short_declension']).pack(side=tk.LEFT)
|
||
ttk.Checkbutton(apply_row, text="адрес", variable=self._apply_flags['address']).pack(side=tk.LEFT)
|
||
ttk.Checkbutton(apply_row, text="ИНН/КПП/ОГРН", variable=self._apply_flags['requisites']).pack(side=tk.LEFT)
|
||
ttk.Checkbutton(apply_row, text="руковод.", variable=self._apply_flags['chief']).pack(side=tk.LEFT)
|
||
apply_row2 = ttk.Frame(right)
|
||
apply_row2.pack(fill=tk.X, pady=(2, 0))
|
||
ttk.Label(apply_row2, text="", font=('', 8), width=10).pack(side=tk.LEFT)
|
||
ttk.Checkbutton(apply_row2, text="ОКВЭД осн.", variable=self._apply_flags['okved']).pack(side=tk.LEFT)
|
||
ttk.Checkbutton(apply_row2, text="ОКВЭД доп.", variable=self._apply_flags['okved_extra']).pack(side=tk.LEFT)
|
||
self._verify_result = None
|
||
|
||
# ---------- ПРОВЕРКА ДАННЫХ (Морфер / DaData / ИИ) ----------
|
||
def open_api_settings(self):
|
||
"""Окно настроек API-ключей (меню «Файл → Настройки»)."""
|
||
win = tk.Toplevel(self.root)
|
||
win.title("Настройки")
|
||
win.geometry("540x460")
|
||
win.transient(self.root)
|
||
win.grab_set()
|
||
|
||
nb = ttk.Notebook(win)
|
||
nb.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||
|
||
# Вкладка API-ключи
|
||
api_tab = ttk.Frame(nb, padding=10)
|
||
nb.add(api_tab, text="API-ключи")
|
||
|
||
settings = load_api_settings()
|
||
|
||
ttk.Label(api_tab, text="DaData — проверка организации по ИНН/ОГРН",
|
||
font=('', 9, 'bold')).pack(anchor=tk.W)
|
||
ttk.Label(api_tab, text="API-ключ (получить на https://dadata.ru):").pack(anchor=tk.W, pady=(6, 0))
|
||
dadata_var = tk.StringVar(value=settings.get('dadata_token', ''))
|
||
ttk.Entry(api_tab, textvariable=dadata_var, width=64).pack(fill=tk.X, pady=2)
|
||
|
||
ttk.Label(api_tab, text="ИИ-проверка (OpenAI-совместимый API: DeepSeek, ChatGPT и др.)",
|
||
font=('', 9, 'bold')).pack(anchor=tk.W, pady=(14, 0))
|
||
ttk.Label(api_tab, text="API-ключ:").pack(anchor=tk.W, pady=(6, 0))
|
||
ds_var = tk.StringVar(value=settings.get('ai_api_key', ''))
|
||
ttk.Entry(api_tab, textvariable=ds_var, width=64).pack(fill=tk.X, pady=2)
|
||
ttk.Label(api_tab, text="Модель (например: deepseek-chat, gpt-4o-mini, gpt-4o):").pack(anchor=tk.W, pady=(6, 0))
|
||
model_var = tk.StringVar(value=settings.get('ai_model', 'deepseek-chat'))
|
||
ttk.Entry(api_tab, textvariable=model_var, width=64).pack(fill=tk.X, pady=2)
|
||
ttk.Label(api_tab, text="Базовый URL API (для DeepSeek можно оставить пустым):").pack(anchor=tk.W, pady=(6, 0))
|
||
url_var = tk.StringVar(value=settings.get('ai_base_url', ''))
|
||
ttk.Entry(api_tab, textvariable=url_var, width=64).pack(fill=tk.X, pady=2)
|
||
|
||
ttk.Label(api_tab, text="Морфер 3.0 работает без ключа (бесплатный склонятор).",
|
||
foreground='gray').pack(anchor=tk.W, pady=(14, 0))
|
||
|
||
def _save():
|
||
save_api_settings({
|
||
'dadata_token': dadata_var.get().strip(),
|
||
'ai_api_key': ds_var.get().strip(),
|
||
'ai_model': model_var.get().strip() or 'deepseek-chat',
|
||
'ai_base_url': url_var.get().strip(),
|
||
})
|
||
self.log("✅ Настройки API сохранены")
|
||
win.destroy()
|
||
|
||
ttk.Button(api_tab, text="💾 Сохранить", command=_save).pack(anchor=tk.W, pady=12)
|
||
|
||
# ---------- ПРОВЕРКИ ----------
|
||
_CASE_TO_CODE = {'И': 'nomn', 'Р': 'gent', 'Д': 'datv', 'В': 'accs', 'Т': 'ablt', 'П': 'loct'}
|
||
|
||
def _verify_log(self, text):
|
||
self._verify_text.config(state=tk.NORMAL)
|
||
self._verify_text.delete('1.0', tk.END)
|
||
self._verify_text.insert('1.0', text)
|
||
self._verify_text.config(state=tk.DISABLED)
|
||
|
||
def _verify_clear(self):
|
||
self._verify_result = None
|
||
self._verify_log('')
|
||
|
||
def _check_declension_web(self):
|
||
name = self.company_name_entry.get().strip()
|
||
short = self.short_name_entry.get().strip()
|
||
if not name and not short:
|
||
messagebox.showwarning("Проверка", "Сначала введите полное или краткое название организации")
|
||
return
|
||
self.log(f"🌐 Запрос к Морфер 3.0: «{name or short}»")
|
||
self._verify_log("Запрос к Морфер 3.0…")
|
||
|
||
def _run():
|
||
# Склоняем и полное, и краткое название (если заполнено)
|
||
results = []
|
||
if name:
|
||
r1, e1 = check_declension_morpher(name)
|
||
if e1:
|
||
self.root.after(0, lambda: self._verify_log(f"❌ {e1}"))
|
||
return
|
||
results.append(('declension', r1))
|
||
if short:
|
||
r2, e2 = check_declension_morpher(short)
|
||
if e2:
|
||
self.root.after(0, lambda: self._verify_log(f"❌ Краткое: {e2}"))
|
||
return
|
||
results.append(('short_declension', r2))
|
||
self.root.after(0, lambda: self._verify_declension_done(results, None))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _check_declension_ai(self):
|
||
name = self.company_name_entry.get().strip()
|
||
short = self.short_name_entry.get().strip()
|
||
if not name and not short:
|
||
messagebox.showwarning("Проверка", "Сначала введите полное или краткое название организации")
|
||
return
|
||
settings = load_api_settings()
|
||
if not settings.get('ai_api_key'):
|
||
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
|
||
return
|
||
self.log(f"🤖 Запрос к ИИ ({settings.get('ai_model') or 'deepseek-chat'}): склонение «{name or short}»")
|
||
self._verify_log("Запрос к ИИ…")
|
||
|
||
def _run():
|
||
results = []
|
||
if name:
|
||
r1, e1 = check_declension_ai(name, settings['ai_api_key'],
|
||
settings.get('ai_model'), settings.get('ai_base_url'))
|
||
if e1:
|
||
self.root.after(0, lambda: self._verify_log(f"❌ {e1}"))
|
||
return
|
||
results.append(('declension', r1))
|
||
if short:
|
||
r2, e2 = check_declension_ai(short, settings['ai_api_key'],
|
||
settings.get('ai_model'), settings.get('ai_base_url'))
|
||
if e2:
|
||
self.root.after(0, lambda: self._verify_log(f"❌ Краткое: {e2}"))
|
||
return
|
||
results.append(('short_declension', r2))
|
||
self.root.after(0, lambda: self._verify_declension_done(results, None))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _verify_declension_done(self, results, err):
|
||
if err:
|
||
self._verify_log(f"❌ {err}")
|
||
self.log(f"❌ Проверка склонений: {err}")
|
||
return
|
||
# Собираем единый результат для применения
|
||
combined = {
|
||
'source': results[0][1]['source'] if results else 'ИИ',
|
||
'source_url': results[0][1]['source_url'] if results else '',
|
||
'disclaimer': results[0][1].get('disclaimer', '') if results else '',
|
||
'kind': 'declension',
|
||
'data': {},
|
||
'apply': {},
|
||
}
|
||
lines = [f"Источник: {combined['source']} ({combined['source_url']})"]
|
||
if combined['disclaimer']:
|
||
lines.append(combined['disclaimer'])
|
||
for key, res in results:
|
||
label = "Полное название" if key == 'declension' else "Краткое название"
|
||
lines.append(f"\n{label}:")
|
||
for code, name_case in [('И', 'Именительный'), ('Р', 'Родительный'), ('Д', 'Дательный'), ('В', 'Винительный'),
|
||
('Т', 'Творительный'), ('П', 'Предложный')]:
|
||
if code in res['data']:
|
||
lines.append(f" {name_case}: {res['data'][code]}")
|
||
if res.get('comment'):
|
||
lines.append(f" Комментарий: {res['comment']}")
|
||
combined['apply'][key] = res['apply']['declension']
|
||
lines.append("\nНажмите «✓ Применить», чтобы подставить склонения")
|
||
self._verify_result = combined
|
||
self._verify_log('\n'.join(lines))
|
||
# Общий лог с ответом сервиса
|
||
sample = ''
|
||
for key, res in results:
|
||
sample = res['data'].get('П', '')
|
||
if sample:
|
||
break
|
||
self.log(f"{combined['source']}: склонение проверено → «{sample}»")
|
||
|
||
def _check_company_web(self):
|
||
inn_entry = self._company_entries.get('inn')
|
||
inn = inn_entry.get().strip() if inn_entry else ''
|
||
if not inn:
|
||
messagebox.showwarning("Проверка", "Сначала введите ИНН организации")
|
||
return
|
||
settings = load_api_settings()
|
||
if not settings.get('dadata_token'):
|
||
messagebox.showwarning("Настройки", "Не задан API-ключ DaData.\nМеню «Файл → Настройки»")
|
||
return
|
||
self.log(f"🌐 Запрос к DaData: ИНН/ОГРН {inn}")
|
||
self._verify_log("Запрос к DaData…")
|
||
|
||
def _run():
|
||
result, err = check_company_dadata(inn, settings['dadata_token'])
|
||
self.root.after(0, lambda: self._verify_company_done(result, err))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _check_company_ai(self):
|
||
self._collect_company()
|
||
c = self.model
|
||
settings = load_api_settings()
|
||
if not settings.get('ai_api_key'):
|
||
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
|
||
return
|
||
company_data = {
|
||
'full_name': c.full_name, 'short_name': c.short_name,
|
||
'address': c.address, 'inn': c.inn, 'kpp': c.kpp, 'ogrn': c.ogrn,
|
||
'ogrn_date': c.ogrn_date,
|
||
'chief_position': c.chief_position, 'chief_fio': c.chief_fio,
|
||
}
|
||
self.log(f"🤖 Запрос к ИИ ({settings.get('ai_model') or 'deepseek-chat'}): реквизиты (ИНН {company_data.get('inn') or '—'})")
|
||
self._verify_log("Запрос к ИИ…")
|
||
|
||
def _run():
|
||
result, err = check_company_ai(company_data, settings['ai_api_key'],
|
||
settings.get('ai_model'), settings.get('ai_base_url'))
|
||
self.root.after(0, lambda: self._verify_company_done(result, err))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _verify_company_done(self, result, err):
|
||
if err:
|
||
self._verify_log(f"❌ {err}")
|
||
self.log(f"❌ Проверка реквизитов: {err}")
|
||
return
|
||
self._verify_result = result
|
||
lines = [f"Источник: {result['source']} ({result['source_url']})"]
|
||
if result.get('disclaimer'):
|
||
lines.append(result['disclaimer'])
|
||
d = result['data']
|
||
if 'full_name' in d:
|
||
# DaData — фактические данные
|
||
for key, label in [('full_name', 'Наименование'), ('short_name', 'Краткое'),
|
||
('address', 'Адрес'), ('inn', 'ИНН'), ('kpp', 'КПП'),
|
||
('ogrn', 'ОГРН'), ('ogrn_date', 'Дата ОГРН'),
|
||
('okved', 'Основной ОКВЭД'),
|
||
('chief_position', 'Руководитель'), ('chief_fio', 'ФИО')]:
|
||
if d.get(key):
|
||
lines.append(f"{label}: {d[key]}")
|
||
if d.get('okved_extra'):
|
||
lines.append(f"Доп. ОКВЭД: {', '.join(d['okved_extra'])}")
|
||
else:
|
||
# ИИ-рецензия: показываем снимок полей (что ИИ увидел), замечания и предложения
|
||
lines.append(f"Оценка: {d.get('rating', '')}")
|
||
fields = d.get('fields') or {}
|
||
if fields:
|
||
lines.append("\nПроверенные поля:")
|
||
for key, label in COMPANY_FIELDS:
|
||
info = fields.get(key)
|
||
if isinstance(info, dict):
|
||
status = info.get('статус', '')
|
||
comment = info.get('комментарий', '')
|
||
lines.append(f" {label}: {status}" + (f" — {comment}" if comment else ""))
|
||
for n in (d.get('notes') or []):
|
||
lines.append(f"• {n}")
|
||
props = d.get('proposals') or {}
|
||
if any(props.values()):
|
||
lines.append("\nПредложения ИИ:")
|
||
for key, label in COMPANY_FIELDS:
|
||
v = props.get(key)
|
||
if v:
|
||
lines.append(f" {label}: {v}")
|
||
lines.append("\nНажмите «✓ Применить», чтобы подставить данные")
|
||
self._verify_log('\n'.join(lines))
|
||
|
||
# Сравнение полученных значений с введёнными (серьёзные расхождения — красным)
|
||
current = {
|
||
'full_name': self.model.full_name,
|
||
'short_name': self.model.short_name,
|
||
'address': self.model.address,
|
||
'inn': self.model.inn,
|
||
'kpp': self.model.kpp,
|
||
'ogrn': self.model.ogrn,
|
||
'ogrn_date': self.model.ogrn_date,
|
||
'chief_position': self.model.chief_position,
|
||
'chief_fio': self.model.chief_fio,
|
||
}
|
||
if 'full_name' in d:
|
||
new_vals = d # DaData — фактические данные
|
||
else:
|
||
new_vals = d.get('proposals') or {} # ИИ — предложения
|
||
diffs = compare_company_values(current, new_vals)
|
||
if diffs:
|
||
warn = "⚠ ВНИМАНИЕ! НАЙДЕНЫ РАСХОЖДЕНИЯ:\n"
|
||
for label, old_v, new_v in diffs:
|
||
warn += f"• {label}: было «{old_v}», стало «{new_v}»\n"
|
||
self._verify_text.tag_configure('warn', foreground='red',
|
||
font=('Arial', 14, 'bold'))
|
||
self._verify_text.config(state=tk.NORMAL)
|
||
self._verify_text.insert(tk.END, '\n' + warn, 'warn')
|
||
self._verify_text.config(state=tk.DISABLED)
|
||
self.log(f"⚠ ВНИМАНИЕ: найдены расхождения ({len(diffs)}): " +
|
||
'; '.join(f"{l}: {o} → {n}" for l, o, n in diffs[:3]))
|
||
|
||
# Общий лог с ответом сервиса
|
||
summary = ''
|
||
if 'full_name' in d:
|
||
summary = f"{d.get('full_name', '')[:60]} (ИНН {d.get('inn', '')})"
|
||
else:
|
||
summary = f"оценка {d.get('rating', '')}, замечаний: {len(d.get('notes') or [])}"
|
||
self.log(f"{result['source']}: проверка реквизитов → {summary}")
|
||
|
||
def _verify_apply(self):
|
||
result = self._verify_result
|
||
if not result:
|
||
return
|
||
apply_data = result.get('apply', {})
|
||
flags = getattr(self, '_apply_flags', None)
|
||
|
||
def want(key):
|
||
if flags is None:
|
||
return True
|
||
return flags[key].get() if key in flags else True
|
||
|
||
idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}
|
||
if 'declension' in apply_data and want('declension'):
|
||
for case, val in apply_data['declension'].items():
|
||
code = self._CASE_TO_CODE.get(case)
|
||
if code and code in self.decl_entries:
|
||
self.decl_entries[code].delete(0, tk.END)
|
||
self.decl_entries[code].insert(0, val)
|
||
setattr(self.model, f'company_name_{idx_map[code]}', val)
|
||
self.log("✅ Склонения полного названия применены")
|
||
if 'short_declension' in apply_data and want('short_declension'):
|
||
for case, val in apply_data['short_declension'].items():
|
||
code = self._CASE_TO_CODE.get(case)
|
||
if code and code in self.short_decl_entries:
|
||
self.short_decl_entries[code].delete(0, tk.END)
|
||
self.short_decl_entries[code].insert(0, val)
|
||
setattr(self.model, f'short_name_{idx_map[code]}', val)
|
||
self.log("✅ Склонения краткого названия применены")
|
||
if 'company' in apply_data:
|
||
comp = apply_data['company']
|
||
if comp.get('full_name'):
|
||
self.company_name_entry.delete(0, tk.END)
|
||
self.company_name_entry.insert(0, comp['full_name'])
|
||
if comp.get('short_name'):
|
||
self.short_name_entry.delete(0, tk.END)
|
||
self.short_name_entry.insert(0, comp['short_name'])
|
||
if want('address') and comp.get('address'):
|
||
if 'address' in self._company_entries:
|
||
self._company_entries['address'].delete(0, tk.END)
|
||
self._company_entries['address'].insert(0, comp['address'])
|
||
if want('requisites'):
|
||
for data_key, ui_key in {'kpp': 'kpp', 'ogrn': 'ogrn',
|
||
'ogrn_date': 'ogrn_date', 'inn': 'inn'}.items():
|
||
if comp.get(data_key) and ui_key in self._company_entries:
|
||
self._company_entries[ui_key].delete(0, tk.END)
|
||
self._company_entries[ui_key].insert(0, comp[data_key])
|
||
# ОКВЭД из DaData (с названием из словаря/интернета)
|
||
from .verify import resolve_okved
|
||
from .api_settings import load_api_settings as _las3
|
||
_tok3 = (_las3().get('dadata_token') or '')
|
||
if want('okved') and comp.get('okved') and 'okved' in self._company_entries:
|
||
self._company_entries['okved'].delete(0, tk.END)
|
||
self._company_entries['okved'].insert(0, resolve_okved(comp['okved'], _tok3))
|
||
if want('okved_extra') and comp.get('okved_extra') and hasattr(self, 'okved_extra_listbox'):
|
||
self.okved_extra_listbox.delete(0, tk.END)
|
||
from .verify import split_okved_extra
|
||
_applied = 0
|
||
for code in split_okved_extra(comp['okved_extra']):
|
||
if str(code).strip():
|
||
self.okved_extra_listbox.insert(tk.END, resolve_okved(str(code).strip(), _tok3))
|
||
_applied += 1
|
||
self.log(f"✅ Доп. ОКВЭД из DaData: применено {_applied} (всего в ответе: {len(comp['okved_extra'])})")
|
||
if want('chief'):
|
||
if comp.get('chief_position') and 'chief_position' in self._official_entries:
|
||
self._official_entries['chief_position'].delete(0, tk.END)
|
||
self._official_entries['chief_position'].insert(0, comp['chief_position'])
|
||
if comp.get('chief_fio') and 'chief_fio' in self._official_entries:
|
||
self._official_entries['chief_fio'].delete(0, tk.END)
|
||
self._official_entries['chief_fio'].insert(0, comp['chief_fio'])
|
||
self.log("✅ Реквизиты применены")
|
||
|
||
# ---------- ВКЛАДКА "ИНФОРМАЦИОННЫЕ СИСТЕМЫ" ----------
|
||
def _build_is_tab(self):
|
||
tab = ttk.Frame(self.notebook)
|
||
self.notebook.add(tab, text="Объекты КИИ")
|
||
|
||
# Левая панель — список объектов КИИ
|
||
left = ttk.Frame(tab)
|
||
left.pack(side=tk.LEFT, fill=tk.Y, padx=5, pady=5)
|
||
ttk.Label(left, text="Объекты КИИ:").pack()
|
||
self.is_listbox = tk.Listbox(left, width=35, height=18, exportselection=False)
|
||
self.is_listbox.pack(fill=tk.BOTH, expand=True, pady=5)
|
||
self.is_listbox.bind('<<ListboxSelect>>', self._on_is_select)
|
||
|
||
btn_frame = ttk.Frame(left)
|
||
btn_frame.pack(fill=tk.X)
|
||
ttk.Button(btn_frame, text="+ Добавить", command=self._add_is).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_frame, text="Копировать", command=self._copy_is).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_frame, text="- Удалить", command=self._delete_is).pack(side=tk.LEFT, padx=2)
|
||
|
||
# Правая панель — редактирование
|
||
self._is_edit_panel = ttk.LabelFrame(tab, text="Редактирование объекта КИИ")
|
||
self._is_edit_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||
|
||
fields_frame = ttk.Frame(self._is_edit_panel, padding=10)
|
||
fields_frame.pack(fill=tk.X)
|
||
|
||
# Основные поля (сверху — только название, описание, категория)
|
||
ttk.Label(fields_frame, text="Наименование:").grid(row=0, column=0, sticky=tk.W, pady=2)
|
||
self.is_name_entry = ttk.Entry(fields_frame, width=60)
|
||
self.is_name_entry.grid(row=0, column=1, padx=10, sticky=tk.EW)
|
||
|
||
ttk.Label(fields_frame, text="Описание:").grid(row=1, column=0, sticky=tk.W, pady=2)
|
||
self.is_description_entry = ttk.Entry(fields_frame, width=60)
|
||
self.is_description_entry.grid(row=1, column=1, padx=10, sticky=tk.EW)
|
||
|
||
# Категория значимости объекта КИИ
|
||
self.is_category_var = tk.StringVar(value="без категории")
|
||
ttk.Label(fields_frame, text="Категория значимости:").grid(row=2, column=0, sticky=tk.W, pady=2)
|
||
cat_frame = ttk.Frame(fields_frame)
|
||
cat_frame.grid(row=2, column=1, sticky=tk.W, padx=10)
|
||
for val in ["1", "2", "3", "без категории"]:
|
||
ttk.Radiobutton(cat_frame, text=val, variable=self.is_category_var,
|
||
value=val).pack(side=tk.LEFT, padx=3)
|
||
|
||
ttk.Label(fields_frame, text="Балансовая стоимость (руб.):").grid(
|
||
row=3, column=0, sticky=tk.W, pady=2)
|
||
self.is_balance_entry = ttk.Entry(fields_frame, width=60)
|
||
self.is_balance_entry.grid(row=3, column=1, padx=10, sticky=tk.EW, pady=2)
|
||
|
||
fields_frame.columnconfigure(1, weight=1)
|
||
|
||
# ---- Вложенные вкладки ----
|
||
nb2 = ttk.Notebook(self._is_edit_panel)
|
||
nb2.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||
|
||
# ===== Вкладка «Основное» =====
|
||
main_tab = ttk.Frame(nb2)
|
||
nb2.add(main_tab, text="Основное")
|
||
main_frame = ttk.Frame(main_tab, padding=8)
|
||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
# Назначение объекта
|
||
ttk.Label(main_frame, text="Назначение объекта:").grid(row=0, column=0, sticky=tk.W, pady=2)
|
||
self.is_purpose_entry = ttk.Entry(main_frame, width=70)
|
||
self.is_purpose_entry.grid(row=0, column=1, padx=8, sticky=tk.EW, pady=1)
|
||
|
||
# Сфера деятельности — словарик + выпадающий список
|
||
ttk.Label(main_frame, text="Сфера деятельности:").grid(row=1, column=0, sticky=tk.W, pady=2)
|
||
self.is_sphere_combo = ttk.Combobox(main_frame, width=68, state='readonly')
|
||
self.is_sphere_combo.grid(row=1, column=1, padx=8, sticky=tk.EW, pady=1)
|
||
try:
|
||
import json as _json
|
||
_sp = os.path.join(os.path.dirname(__file__), 'dictionaries', 'spheres.json')
|
||
with open(_sp, encoding='utf-8') as _f:
|
||
self.is_sphere_combo['values'] = [''] + _json.load(_f)
|
||
except Exception:
|
||
pass
|
||
# Словарь типовых отраслевых объектов по сферам (Распоряжение ПРФ № 360-р)
|
||
try:
|
||
import json as _json
|
||
_tio = os.path.join(os.path.dirname(__file__), 'dictionaries', 'typical_industry_objects.json')
|
||
with open(_tio, encoding='utf-8') as _f:
|
||
self._typical_industry = _json.load(_f)
|
||
except Exception:
|
||
self._typical_industry = {}
|
||
self.is_sphere_combo.bind('<<ComboboxSelected>>', self._on_sphere_changed)
|
||
|
||
# Тип объекта — словарик + выпадающий список
|
||
ttk.Label(main_frame, text="Тип объекта:").grid(row=2, column=0, sticky=tk.W, pady=2)
|
||
self.is_object_type_combo = ttk.Combobox(main_frame, width=68, state='readonly')
|
||
self.is_object_type_combo.grid(row=2, column=1, padx=8, sticky=tk.EW, pady=1)
|
||
try:
|
||
_ot = os.path.join(os.path.dirname(__file__), 'dictionaries', 'object_types.json')
|
||
with open(_ot, encoding='utf-8') as _f:
|
||
self.is_object_type_combo['values'] = [''] + _json.load(_f)
|
||
except Exception:
|
||
pass
|
||
|
||
# Архитектура объекта — словарик + выпадающий список
|
||
ttk.Label(main_frame, text="Архитектура объекта:").grid(row=3, column=0, sticky=tk.W, pady=2)
|
||
self.is_architecture_combo = ttk.Combobox(main_frame, width=68, state='readonly')
|
||
self.is_architecture_combo.grid(row=3, column=1, padx=8, sticky=tk.EW, pady=1)
|
||
try:
|
||
_ar = os.path.join(os.path.dirname(__file__), 'dictionaries', 'architectures.json')
|
||
with open(_ar, encoding='utf-8') as _f:
|
||
self.is_architecture_combo['values'] = [''] + _json.load(_f)
|
||
except Exception:
|
||
pass
|
||
|
||
# Типовой отраслевой объект КИИ — словарик + выпадающий список
|
||
ttk.Label(main_frame, text="Типовой отраслевой объект КИИ:").grid(row=4, column=0, sticky=tk.W, pady=2)
|
||
self.is_typical_object_combo = ttk.Combobox(main_frame, width=68, state='readonly')
|
||
self.is_typical_object_combo.grid(row=4, column=1, padx=8, sticky=tk.EW, pady=1)
|
||
try:
|
||
_to = os.path.join(os.path.dirname(__file__), 'dictionaries', 'typical_objects.json')
|
||
with open(_to, encoding='utf-8') as _f:
|
||
self._typical_objects_all = [''] + _json.load(_f)
|
||
except Exception:
|
||
self._typical_objects_all = ['']
|
||
self.is_typical_object_combo['values'] = self._typical_objects_all
|
||
|
||
# Кнопки подбора характеристик: программа (типовой объект) и ИИ (всё)
|
||
suggest_row = ttk.Frame(main_frame)
|
||
suggest_row.grid(row=5, column=0, columnspan=2, sticky=tk.W, pady=(4, 0))
|
||
ttk.Button(suggest_row, text="⚙️ Подобрать типовой объект (программа)",
|
||
command=self._suggest_typical_program_click).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(suggest_row, text="🤖 Подобрать характеристики (ИИ)",
|
||
command=self._suggest_object_params_ai_click).pack(side=tk.LEFT, padx=2)
|
||
ttk.Label(suggest_row, text="Подбор по названию/назначению объекта — как рекомендация, оператор подтверждает.",
|
||
foreground='gray').pack(side=tk.LEFT, padx=8)
|
||
|
||
main_frame.columnconfigure(1, weight=1)
|
||
|
||
# ===== Вкладка «Субъект» =====
|
||
subj_tab = ttk.Frame(nb2)
|
||
nb2.add(subj_tab, text="Субъект")
|
||
subj_frame = ttk.Frame(subj_tab, padding=8)
|
||
subj_frame.pack(fill=tk.BOTH, expand=True)
|
||
subj_fields = [
|
||
("Наименование организации, эксплуатирующей объект:", "is_subject_name_entry"),
|
||
("Адрес местонахождения:", "is_subject_address_entry"),
|
||
("Элемент (компонент) объекта, который эксплуатируется лицом:", "is_subject_element_entry"),
|
||
("ИНН лица, эксплуатирующего объект:", "is_subject_inn_entry"),
|
||
("КПП его обособленных подразделений:", "is_subject_kpp_entry"),
|
||
]
|
||
for i, (label, attr) in enumerate(subj_fields):
|
||
ttk.Label(subj_frame, text=label).grid(row=i, column=0, sticky=tk.W, pady=2)
|
||
entry = ttk.Entry(subj_frame, width=70)
|
||
entry.grid(row=i, column=1, padx=8, sticky=tk.EW, pady=1)
|
||
setattr(self, attr, entry)
|
||
ttk.Button(subj_frame, text="📋 Заполнить из данных организации",
|
||
command=self._fill_subject_from_company).grid(
|
||
row=len(subj_fields), column=0, columnspan=2, sticky=tk.W, pady=(8, 0))
|
||
subj_frame.columnconfigure(1, weight=1)
|
||
|
||
# ===== Вкладка «Сеть» =====
|
||
net_tab = ttk.Frame(nb2)
|
||
nb2.add(net_tab, text="Сеть")
|
||
net_frame = ttk.Frame(net_tab, padding=8)
|
||
net_frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
# Категория сети — словарик
|
||
ttk.Label(net_frame, text="Категория сети электросвязи:").grid(row=1, column=0, sticky=tk.W, pady=2)
|
||
self.is_net_category_combo = ttk.Combobox(net_frame, width=68, state='readonly')
|
||
self.is_net_category_combo.grid(row=1, column=1, padx=8, sticky=tk.EW, pady=1)
|
||
try:
|
||
import json as _json
|
||
_nc = os.path.join(os.path.dirname(__file__), 'dictionaries', 'net_categories.json')
|
||
with open(_nc, encoding='utf-8') as _f:
|
||
self.is_net_category_combo['values'] = [''] + _json.load(_f)
|
||
except Exception:
|
||
pass
|
||
|
||
# Провайдер
|
||
ttk.Label(net_frame, text="Наименование провайдера:").grid(row=2, column=0, sticky=tk.W, pady=2)
|
||
self.is_net_operator_entry = ttk.Entry(net_frame, width=70)
|
||
self.is_net_operator_entry.grid(row=2, column=1, padx=8, sticky=tk.EW, pady=1)
|
||
|
||
# Цель взаимодействия — словарик
|
||
ttk.Label(net_frame, text="Цель взаимодействия с сетью электросвязи:").grid(row=3, column=0, sticky=tk.W, pady=2)
|
||
self.is_net_purpose_combo = ttk.Combobox(net_frame, width=68, state='readonly')
|
||
self.is_net_purpose_combo.grid(row=3, column=1, padx=8, sticky=tk.EW, pady=1)
|
||
try:
|
||
_np = os.path.join(os.path.dirname(__file__), 'dictionaries', 'net_purposes.json')
|
||
with open(_np, encoding='utf-8') as _f:
|
||
self.is_net_purpose_combo['values'] = [''] + _json.load(_f)
|
||
except Exception:
|
||
pass
|
||
|
||
# Способ взаимодействия — словарик
|
||
ttk.Label(net_frame, text="Способ взаимодействия (тип доступа):").grid(row=4, column=0, sticky=tk.W, pady=2)
|
||
self.is_net_method_combo = ttk.Combobox(net_frame, width=68, state='readonly')
|
||
self.is_net_method_combo.grid(row=4, column=1, padx=8, sticky=tk.EW, pady=1)
|
||
try:
|
||
_nm = os.path.join(os.path.dirname(__file__), 'dictionaries', 'net_methods.json')
|
||
with open(_nm, encoding='utf-8') as _f:
|
||
self.is_net_method_combo['values'] = [''] + _json.load(_f)
|
||
except Exception:
|
||
pass
|
||
|
||
net_frame.columnconfigure(1, weight=1)
|
||
|
||
# ===== Вкладка «ПО» (списки, как «Средства защиты») =====
|
||
sw_tab = ttk.Frame(nb2)
|
||
nb2.add(sw_tab, text="ПО")
|
||
sw_frame = ttk.Frame(sw_tab, padding=8)
|
||
sw_frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
sw_fields = [
|
||
("ПО и аппаратные средства (модели, производители, количество):", "is_hw_sw_listbox", "hw_sw.json"),
|
||
("Операционные системы:", "is_os_sw_listbox", "os_sw.json"),
|
||
("Прикладные программы (MS Office, OpenOffice и пр.):", "is_app_sw_listbox", "app_sw.json"),
|
||
]
|
||
for i, (label, attr, dict_name) in enumerate(sw_fields):
|
||
row = i * 2
|
||
ttk.Label(sw_frame, text=label).grid(row=row, column=0, sticky=tk.W, pady=(4, 0))
|
||
box = tk.Listbox(sw_frame, height=4, exportselection=False)
|
||
box.grid(row=row + 1, column=0, sticky=tk.EW, padx=(0, 4), pady=1)
|
||
sb = ttk.Scrollbar(sw_frame, orient=tk.VERTICAL, command=box.yview)
|
||
sb.grid(row=row + 1, column=1, sticky=tk.NS)
|
||
box.configure(yscrollcommand=sb.set)
|
||
btn = ttk.Frame(sw_frame)
|
||
btn.grid(row=row + 1, column=2, sticky=tk.N, padx=(4, 0))
|
||
ttk.Button(btn, text="+ Добавить",
|
||
command=lambda a=attr: self._add_sw_item(getattr(self, a))).pack(fill=tk.X, pady=1)
|
||
ttk.Button(btn, text="- Удалить",
|
||
command=lambda a=attr: self._del_sw_item(getattr(self, a))).pack(fill=tk.X, pady=1)
|
||
ttk.Button(btn, text="✏️ Редактировать",
|
||
command=lambda a=attr: self._edit_sw_item(getattr(self, a))).pack(fill=tk.X, pady=1)
|
||
ttk.Button(btn, text="📚 Из словаря",
|
||
command=lambda a=attr, d=dict_name: self._select_sw_from_dict(getattr(self, a), d, allow_refine=True)).pack(fill=tk.X, pady=1)
|
||
setattr(self, attr, box)
|
||
sw_frame.columnconfigure(0, weight=1)
|
||
|
||
# ===== Вкладка «Безопасность» =====
|
||
# ===== Вкладка «Безопасность» — с вложенными подвкладками =====
|
||
sec_tab = ttk.Frame(nb2)
|
||
nb2.add(sec_tab, text="Безопасность")
|
||
sec_frame = ttk.Frame(sec_tab, padding=6)
|
||
sec_frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
nb_sec = ttk.Notebook(sec_frame)
|
||
nb_sec.pack(fill=tk.BOTH, expand=True)
|
||
|
||
# --- Подвкладка 1: Основные данные (нарушитель + автозаполнение) ---
|
||
sec_main = ttk.Frame(nb_sec, padding=8)
|
||
nb_sec.add(sec_main, text="Основные данные")
|
||
|
||
ttk.Label(sec_main, text="Модель нарушителя (Методика ФСТЭК от 05.02.2021):",
|
||
font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=(0, 4))
|
||
|
||
row1 = ttk.Frame(sec_main)
|
||
row1.pack(fill=tk.X, pady=3)
|
||
ttk.Label(row1, text="Категория нарушителя:", width=26).pack(side=tk.LEFT)
|
||
self.is_attacker_category_combo = ttk.Combobox(row1, state='readonly', width=50)
|
||
self.is_attacker_category_combo.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||
try:
|
||
import json as _json
|
||
_ac = os.path.join(os.path.dirname(__file__), 'dictionaries', 'attacker_categories.json')
|
||
with open(_ac, encoding='utf-8') as _f:
|
||
_cats = _json.load(_f)
|
||
self.is_attacker_category_combo['values'] = [''] + [c['name'] for c in _cats]
|
||
except Exception:
|
||
pass
|
||
|
||
row2 = ttk.Frame(sec_main)
|
||
row2.pack(fill=tk.X, pady=3)
|
||
ttk.Label(row2, text="Уровень возможностей нарушителя:", width=26).pack(side=tk.LEFT)
|
||
self.is_attacker_level_combo = ttk.Combobox(row2, state='readonly', width=50)
|
||
self.is_attacker_level_combo.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||
try:
|
||
import json as _json
|
||
_al = os.path.join(os.path.dirname(__file__), 'dictionaries', 'attacker_levels.json')
|
||
with open(_al, encoding='utf-8') as _f:
|
||
_levels = _json.load(_f)
|
||
self.is_attacker_level_combo['values'] = [''] + [
|
||
f"{l['level']} — {l['name']}" for l in _levels]
|
||
except Exception:
|
||
pass
|
||
|
||
ttk.Label(sec_main, text="Виды нарушителей:").pack(anchor=tk.W, pady=(6, 2))
|
||
sec_at_row = ttk.Frame(sec_main)
|
||
sec_at_row.pack(fill=tk.BOTH, expand=True)
|
||
self.is_attacker_types_listbox = tk.Listbox(sec_at_row, height=6, exportselection=False)
|
||
self.is_attacker_types_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
sec_at_btn = ttk.Frame(sec_at_row)
|
||
sec_at_btn.pack(side=tk.RIGHT, padx=(4, 0))
|
||
ttk.Button(sec_at_btn, text="-", width=2,
|
||
command=lambda: self._del_sw_item(self.is_attacker_types_listbox)).pack(pady=1)
|
||
ttk.Button(sec_at_btn, text="📚", width=3,
|
||
command=lambda: self._select_sw_from_dict(self.is_attacker_types_listbox, 'attacker_types.json')).pack(pady=1)
|
||
|
||
sec_main_btns = ttk.Frame(sec_main)
|
||
sec_main_btns.pack(fill=tk.X, pady=(8, 0))
|
||
ttk.Button(sec_main_btns, text="⚡ Автозаполнить (нарушитель, угрозы, меры)",
|
||
command=self._auto_fill_security_click).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(sec_main_btns, text="🤖 Проверить безопасность через ИИ",
|
||
command=self._check_security_ai).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(sec_main_btns, text="🧩 Проверить объект по частям (ИИ)",
|
||
command=self._check_object_partial_click).pack(side=tk.LEFT, padx=2)
|
||
ttk.Label(sec_main_btns,
|
||
text="Подбор по данным объекта — как рекомендация, оператор подтверждает.",
|
||
foreground='gray').pack(side=tk.LEFT, padx=8)
|
||
|
||
# --- Подвкладка 2: Угрозы ---
|
||
sec_threats = ttk.Frame(nb_sec, padding=8)
|
||
nb_sec.add(sec_threats, text="Угрозы")
|
||
|
||
thr_actions = ttk.Frame(sec_threats)
|
||
thr_actions.pack(fill=tk.X, pady=(0, 6))
|
||
ttk.Button(thr_actions, text="⚡ Подобрать угрозы автоматически",
|
||
command=self._suggest_threats_program_click).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(thr_actions, text="🤖 Подобрать через ИИ",
|
||
command=self._suggest_threats_ai_click).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(thr_actions, text="🤖 Проверить через ИИ",
|
||
command=self._check_threats_ai_click).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(thr_actions, text="🎯 Подобрать угрозы (программа+ИИ)",
|
||
command=self._open_threats_picker).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(thr_actions, text="⬇️ Загрузить угрозы ФСТЭК",
|
||
command=self._download_fstec_threats).pack(side=tk.LEFT, padx=2)
|
||
self._threats_date_label = ttk.Label(thr_actions, text="", foreground='gray')
|
||
self._threats_date_label.pack(side=tk.LEFT, padx=8)
|
||
|
||
thr_row = ttk.Frame(sec_threats)
|
||
thr_row.pack(fill=tk.BOTH, expand=True)
|
||
self.is_threats_listbox = tk.Listbox(thr_row, height=10, exportselection=False)
|
||
self.is_threats_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
thr_btn = ttk.Frame(thr_row)
|
||
thr_btn.pack(side=tk.RIGHT, padx=(4, 0))
|
||
ttk.Button(thr_btn, text="- Удалить",
|
||
command=lambda: self._del_sw_item(self.is_threats_listbox)).pack(fill=tk.X, pady=1)
|
||
ttk.Button(thr_btn, text="📚 Из словаря",
|
||
command=lambda: self._select_sw_from_dict(self.is_threats_listbox, 'threats_fstec.json')).pack(fill=tk.X, pady=1)
|
||
|
||
# --- Подвкладка 3: Инциденты и критерии ---
|
||
sec_inc = ttk.Frame(nb_sec, padding=8)
|
||
nb_sec.add(sec_inc, text="Инциденты и критерии")
|
||
|
||
def _sec_row(parent, label, attr, dict_name=None, height=2, allow_add=False):
|
||
"""Ряд: label + listbox + кнопки (+/-/✏️/📚)."""
|
||
ttk.Label(parent, text=label).pack(anchor=tk.W)
|
||
row = ttk.Frame(parent)
|
||
row.pack(fill=tk.X, pady=(2, 4))
|
||
lb = tk.Listbox(row, height=height, exportselection=False)
|
||
lb.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
setattr(self, attr, lb)
|
||
btns = ttk.Frame(row)
|
||
btns.pack(side=tk.RIGHT, padx=(4, 0))
|
||
if allow_add or not dict_name:
|
||
ttk.Button(btns, text="+", width=2,
|
||
command=lambda l=lb: self._add_sw_item(l)).pack(pady=1)
|
||
ttk.Button(btns, text="-", width=2,
|
||
command=lambda l=lb: self._del_sw_item(l)).pack(pady=1)
|
||
ttk.Button(btns, text="✏️", width=3,
|
||
command=lambda l=lb: self._edit_sw_item(l)).pack(pady=1)
|
||
if dict_name:
|
||
ttk.Button(btns, text="📚", width=3,
|
||
command=lambda l=lb, d=dict_name: self._select_sw_from_dict(l, d)).pack(pady=1)
|
||
|
||
_sec_row(sec_inc, "Типы компьютерных инцидентов:",
|
||
'is_incidents_listbox', 'incidents.json', height=3)
|
||
_sec_row(sec_inc, "Показатели критериев значимости:",
|
||
'is_criteria_values_listbox', 'criteria_indicators.json', height=3)
|
||
btns_inc = ttk.Frame(sec_inc)
|
||
btns_inc.pack(anchor=tk.W, pady=(0, 4))
|
||
ttk.Button(btns_inc, text="📋 Заполнить по таблице ПП № 127",
|
||
command=self._open_criteria_127_dialog).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btns_inc, text="🤖 Проверить категории через ИИ",
|
||
command=self._check_criteria_ai).pack(side=tk.LEFT, padx=2)
|
||
_sec_row(sec_inc, "Обоснование значений показателей:",
|
||
'is_criteria_justification_listbox', None, height=3)
|
||
|
||
# --- Подвкладка 4: Организационные и технические меры ---
|
||
sec_meas = ttk.Frame(nb_sec, padding=8)
|
||
nb_sec.add(sec_meas, text="Орг. и тех. меры")
|
||
|
||
_sec_row(sec_meas, "Организационные меры:",
|
||
'is_org_measures_listbox', 'org_measures.json', height=5, allow_add=True)
|
||
_sec_row(sec_meas, "Технические меры:",
|
||
'is_tech_measures_listbox', 'tech_measures.json', height=5, allow_add=True)
|
||
|
||
# --- Подвкладка 5: Средства защиты (СЗИ) — внутри «Безопасности» ---
|
||
sec_def = ttk.Frame(nb_sec, padding=8)
|
||
nb_sec.add(sec_def, text="Средства защиты (СЗИ)")
|
||
def_btn_frame = ttk.Frame(sec_def)
|
||
def_btn_frame.pack(fill=tk.X, pady=(0, 4))
|
||
ttk.Button(def_btn_frame, text="+ Добавить", command=self._add_defense_item).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(def_btn_frame, text="- Удалить", command=lambda: self._del_from_listbox(self.defense_tools_listbox)).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(def_btn_frame, text="Выбрать из списка", command=self._select_from_defense_list).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(def_btn_frame, text="🤖 Рекомендации ИИ по СЗИ",
|
||
command=self._suggest_defense_ai).pack(side=tk.LEFT, padx=2)
|
||
self.defense_tools_listbox = tk.Listbox(sec_def, height=6)
|
||
self.defense_tools_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
|
||
# ===== Вкладка «Пользователи ОКИИ» =====
|
||
user_tab = ttk.Frame(nb2)
|
||
nb2.add(user_tab, text="Пользователи ОКИИ")
|
||
self.users_listbox = tk.Listbox(user_tab, height=6)
|
||
self.users_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
btnf = ttk.Frame(user_tab)
|
||
btnf.pack(pady=3)
|
||
ttk.Button(btnf, text="👥 Выбрать из списка", command=self._select_users_from_employees).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btnf, text="+ Добавить", command=self._add_user_to_is).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btnf, text="- Удалить", command=self._del_user_from_is).pack(side=tk.LEFT, padx=2)
|
||
|
||
self._set_is_edit_state(False)
|
||
# Дата актуализации списка угроз ФСТЭК (если уже скачивался)
|
||
self._update_threats_date_label()
|
||
|
||
# ---------- ВКЛАДКА "КОМИССИЯ" ----------
|
||
def _build_commission_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)
|
||
inner = ttk.Frame(canvas)
|
||
inner.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||
canvas.create_window((0, 0), window=inner, 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(inner, padding=10)
|
||
frame.pack(fill=tk.X)
|
||
|
||
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 [(20, "Роль"), (30, "Должность"), (25, "ФИО")]:
|
||
ttk.Label(header, text=col, font=('', 9, 'bold'),
|
||
width=w, anchor=tk.W).pack(side=tk.LEFT, padx=2)
|
||
|
||
self._comm_members_frame = ttk.Frame(frame)
|
||
self._comm_members_frame.pack(fill=tk.X)
|
||
|
||
ttk.Button(frame, text="+ Добавить члена комиссии",
|
||
command=self._add_commission_row_ui).pack(pady=10)
|
||
|
||
# ---------- ВКЛАДКА "ПОЛЬЗОВАТЕЛИ" ----------
|
||
def _build_users_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 [(25, "Должность"), (30, "ФИО")]:
|
||
ttk.Label(header, text=col, font=('', 9, 'bold'),
|
||
width=w, anchor=tk.W).pack(side=tk.LEFT, padx=2)
|
||
|
||
self._employees_frame = ttk.Frame(frame)
|
||
self._employees_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_employee_row_ui).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_frame, text="- Удалить выбранного",
|
||
command=self._delete_employee_selected).pack(side=tk.LEFT, padx=2)
|
||
|
||
# Счётчик
|
||
self._emp_count_label = ttk.Label(frame, text="Добавлено: 0")
|
||
self._emp_count_label.pack(anchor=tk.W, pady=5)
|
||
|
||
self._employee_entries = []
|
||
# Загрузка существующих
|
||
for emp in self.model.employees_access:
|
||
self._add_employee_row(
|
||
emp.get('position', ''),
|
||
emp.get('fio', ''),
|
||
|
||
)
|
||
self._update_emp_count()
|
||
|
||
def _add_employee_row(self, position="", fio=""):
|
||
"""Добавляет одну строку сотрудника."""
|
||
row = ttk.Frame(self._employees_frame)
|
||
row.pack(fill=tk.X, pady=2)
|
||
|
||
pos_entry = ttk.Entry(row, width=25)
|
||
pos_entry.insert(0, position)
|
||
pos_entry.pack(side=tk.LEFT, padx=2)
|
||
|
||
fio_entry = ttk.Entry(row, width=30)
|
||
fio_entry.insert(0, fio)
|
||
fio_entry.pack(side=tk.LEFT, padx=2)
|
||
|
||
|
||
del_btn = ttk.Button(row, text="✖", width=3,
|
||
command=lambda: self._delete_employee_row(row))
|
||
del_btn.pack(side=tk.LEFT, padx=5)
|
||
|
||
self._employee_entries.append((row, pos_entry, fio_entry))
|
||
self._update_emp_count()
|
||
|
||
def _add_employee_row_ui(self):
|
||
self._add_employee_row()
|
||
|
||
def _delete_employee_row(self, row_frame):
|
||
self._employee_entries = [e for e in self._employee_entries if e[0] != row_frame]
|
||
row_frame.destroy()
|
||
self._update_emp_count()
|
||
|
||
def _delete_employee_selected(self):
|
||
# Удаляем последнюю строку, если есть
|
||
if self._employee_entries:
|
||
row_frame = self._employee_entries[-1][0]
|
||
self._delete_employee_row(row_frame)
|
||
|
||
def _update_emp_count(self):
|
||
count = len(self._employee_entries)
|
||
if hasattr(self, '_emp_count_label'):
|
||
self._emp_count_label.config(text=f"Добавлено: {count}")
|
||
|
||
def _collect_employees(self):
|
||
"""Собирает данные сотрудников из GUI в модель."""
|
||
self.model.employees_access.clear()
|
||
for row, pos_e, fio_e in self._employee_entries:
|
||
emp = {
|
||
'position': pos_e.get().strip(),
|
||
'fio': fio_e.get().strip(),
|
||
}
|
||
if emp['position'] or emp['fio']:
|
||
self.model.employees_access.append(emp)
|
||
|
||
# ---------- ВКЛАДКА "ГЕНЕРАЦИЯ" ----------
|
||
def _build_generation_tab(self):
|
||
tab = ttk.Frame(self.notebook)
|
||
self.notebook.add(tab, text="Генерация")
|
||
|
||
frame = ttk.Frame(tab, padding=10)
|
||
frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
ttk.Label(frame, text="Параметры генерации", font=('Arial', 11, 'bold')).pack(
|
||
anchor=tk.W, pady=(0, 10))
|
||
|
||
ttk.Label(frame, text="Папка шаблонов:").pack(anchor=tk.W)
|
||
tf = ttk.Frame(frame)
|
||
tf.pack(fill=tk.X, pady=(0, 5))
|
||
ttk.Entry(tf, textvariable=self.templates_var).pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||
ttk.Button(tf, text="Обзор", command=self.select_templates).pack(side=tk.RIGHT, padx=5)
|
||
|
||
ttk.Label(frame, text="Папка результатов:").pack(anchor=tk.W)
|
||
sf = ttk.Frame(frame)
|
||
sf.pack(fill=tk.X, pady=(0, 5))
|
||
ttk.Entry(sf, textvariable=self.save_var).pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||
ttk.Button(sf, text="Обзор", command=self._select_save_dir).pack(side=tk.RIGHT, padx=5)
|
||
|
||
ttk.Label(frame, text="Шаблоны для генерации:", font=('Arial', 10, 'bold')).pack(
|
||
anchor=tk.W, pady=(10, 0))
|
||
|
||
btn_frame = ttk.Frame(frame)
|
||
btn_frame.pack(fill=tk.X, pady=(0, 5))
|
||
ttk.Button(btn_frame, text="Выделить всё",
|
||
command=lambda: self._select_templates_all(True)).pack(side=tk.LEFT, padx=(0, 5))
|
||
ttk.Button(btn_frame, text="Снять всё",
|
||
command=lambda: self._select_templates_all(False)).pack(side=tk.LEFT)
|
||
|
||
self.template_listbox = tk.Listbox(frame, height=8, selectmode=tk.EXTENDED)
|
||
self.template_listbox.pack(fill=tk.BOTH, expand=True, pady=5)
|
||
self.template_listbox.bind('<<ListboxSelect>>', self._on_template_select)
|
||
|
||
ttk.Button(frame, text="⚡ Сгенерировать выбранные документы",
|
||
command=self.generate_all).pack(fill=tk.X, pady=10)
|
||
|
||
# ---------- ВКЛАДКА "ЛОГ" ----------
|
||
def _build_log_tab(self):
|
||
tab = ttk.Frame(self.notebook)
|
||
self.notebook.add(tab, text="Лог")
|
||
|
||
top = ttk.Frame(tab)
|
||
top.pack(fill=tk.X, padx=5, pady=3)
|
||
ttk.Button(top, text="💾 Экспорт логов…", command=self._export_logs).pack(side=tk.LEFT)
|
||
ttk.Label(top, text="Журнал пишется в dokogen.log рядом с программой",
|
||
foreground='gray').pack(side=tk.LEFT, padx=8)
|
||
|
||
self.log_text = scrolledtext.ScrolledText(tab, height=10)
|
||
self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||
self.log_text.insert(tk.END, "Лог готов\n")
|
||
self.log_text.configure(state='disabled')
|
||
|
||
# ==================== МЕТОДЫ РАБОТЫ С ДАННЫМИ ====================
|
||
def _init_company(self):
|
||
"""Инициализация модели."""
|
||
self.model = Company()
|
||
# 3 члена комиссии по умолчанию
|
||
for _ in range(3):
|
||
self.model.commission.append(CommissionMember())
|
||
|
||
def _collect_company(self):
|
||
"""Собирает данные из GUI в модель."""
|
||
c = self.model
|
||
c.full_name = self.company_name_entry.get().strip()
|
||
c.short_name = self.short_name_entry.get().strip()
|
||
for key, entry in self._company_entries.items():
|
||
setattr(c, key, entry.get().strip())
|
||
for key, entry in self._official_entries.items():
|
||
setattr(c, key, entry.get().strip())
|
||
c.contract_number = self.doc_number_var.get()
|
||
c.contract_date = self.doc_date_var.get()
|
||
if hasattr(self, 'okved_extra_listbox'):
|
||
c.okved_extra = list(self.okved_extra_listbox.get(0, tk.END))
|
||
|
||
def _collect_is(self):
|
||
"""Собирает данные объекта КИИ из GUI в модель."""
|
||
if self._current_is_idx is not None and self._current_is_idx < len(self.model.information_systems):
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
isys.name = self.is_name_entry.get().strip()
|
||
isys.description = self.is_description_entry.get().strip()
|
||
if hasattr(self, 'is_software_entry'):
|
||
isys.software = self.is_software_entry.get().strip()
|
||
isys.category = self.is_category_var.get()
|
||
if hasattr(self, 'is_balance_entry'):
|
||
isys.balance_value = self.is_balance_entry.get().strip()
|
||
# Поля 236 приказа
|
||
for attr, model_field in [
|
||
('is_purpose_entry', 'purpose'),
|
||
('is_sphere_combo', 'sphere'),
|
||
('is_object_type_combo', 'object_type'),
|
||
('is_architecture_combo', 'architecture'),
|
||
('is_typical_object_combo', 'typical_object'),
|
||
('is_subject_name_entry', 'subject_name'),
|
||
('is_subject_address_entry', 'subject_address'),
|
||
('is_subject_element_entry', 'subject_element'),
|
||
('is_subject_inn_entry', 'subject_inn'),
|
||
('is_subject_kpp_entry', 'subject_kpp'),
|
||
('is_net_category_combo', 'net_category'),
|
||
('is_net_operator_entry', 'net_operator'),
|
||
('is_net_purpose_combo', 'net_purpose'),
|
||
('is_net_method_combo', 'net_method'),
|
||
('is_hw_sw_listbox', 'hw_sw'),
|
||
('is_os_sw_listbox', 'os_sw'),
|
||
('is_app_sw_listbox', 'app_sw'),
|
||
('is_attacker_category_combo', 'attacker_category'),
|
||
('is_attacker_level_combo', 'attacker_level'),
|
||
('is_attacker_types_listbox', 'attacker_types'),
|
||
('is_threats_listbox', 'threats'),
|
||
('is_incidents_listbox', 'incidents'),
|
||
('is_criteria_values_listbox', 'criteria_values'),
|
||
('is_criteria_justification_listbox', 'criteria_justification'),
|
||
('is_org_measures_listbox', 'org_measures'),
|
||
('is_tech_measures_listbox', 'tech_measures'),
|
||
]:
|
||
w = getattr(self, attr, None)
|
||
if w is not None:
|
||
if isinstance(w, tk.Listbox):
|
||
setattr(isys, model_field, '\n'.join(w.get(0, tk.END)))
|
||
elif isinstance(w, tk.Text):
|
||
setattr(isys, model_field, w.get('1.0', 'end-1c').strip())
|
||
else:
|
||
setattr(isys, model_field, w.get().strip())
|
||
# Списки
|
||
isys.defense_tools_list = list(self.defense_tools_listbox.get(0, tk.END))
|
||
isys.users_list = list(self.users_listbox.get(0, tk.END))
|
||
|
||
def _collect_commission(self):
|
||
"""Собирает комиссию из GUI."""
|
||
self.model.commission.clear()
|
||
for row_frame, role_var, pos_entry, fio_entry in self.commission_entries:
|
||
self.model.commission.append(
|
||
CommissionMember(
|
||
role=role_var.get(),
|
||
position=pos_entry.get().strip(),
|
||
fio=fio_entry.get().strip(),
|
||
)
|
||
)
|
||
|
||
def _company_to_gui(self):
|
||
"""Заполняет GUI из модели."""
|
||
c = self.model
|
||
self.company_name_entry.delete(0, tk.END)
|
||
self.company_name_entry.insert(0, c.full_name or "")
|
||
self.short_name_entry.delete(0, tk.END)
|
||
self.short_name_entry.insert(0, c.short_name or "")
|
||
|
||
for key, entry in self._company_entries.items():
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, getattr(c, key, "") or "")
|
||
|
||
for key, entry in self._official_entries.items():
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, getattr(c, key, "") or "")
|
||
|
||
self.doc_number_var.set(c.contract_number or "")
|
||
self.doc_date_var.set(c.contract_date or "")
|
||
|
||
# Дополнительные ОКВЭД — нарезаем по кодам и нормализуем в «код название»
|
||
if hasattr(self, 'okved_extra_listbox'):
|
||
self.okved_extra_listbox.delete(0, tk.END)
|
||
try:
|
||
from .verify import split_okved_extra, resolve_okved
|
||
from .api_settings import load_api_settings as _las_g
|
||
_tok_g = (_las_g().get('dadata_token') or '')
|
||
for item in split_okved_extra(c.okved_extra or []):
|
||
self.okved_extra_listbox.insert(tk.END, resolve_okved(item, _tok_g))
|
||
except Exception:
|
||
for item in (c.okved_extra or []):
|
||
self.okved_extra_listbox.insert(tk.END, item)
|
||
|
||
# Склонения
|
||
idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}
|
||
for code, entry in self.decl_entries.items():
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, getattr(c, f"company_name_{idx_map[code]}", "") or "")
|
||
for code, entry in self.short_decl_entries.items():
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, getattr(c, f"short_name_{idx_map[code]}", "") or "")
|
||
|
||
# Комиссия
|
||
for frame in list(self._comm_members_frame.winfo_children()):
|
||
frame.destroy()
|
||
self.commission_entries.clear()
|
||
for member in c.commission:
|
||
self._add_commission_row(member.role, member.position, member.fio)
|
||
if not self.commission_entries:
|
||
for _ in range(3):
|
||
self._add_commission_row()
|
||
|
||
# ИС
|
||
self._refresh_is_listbox()
|
||
if self.model.information_systems:
|
||
self.is_listbox.selection_set(0)
|
||
self._on_is_select()
|
||
|
||
# Пользователи
|
||
if hasattr(self, '_employee_entries'):
|
||
for row_frame, _, _ in self._employee_entries:
|
||
row_frame.destroy()
|
||
self._employee_entries.clear()
|
||
for emp in c.employees_access:
|
||
self._add_employee_row(
|
||
emp.get('position', ''),
|
||
emp.get('fio', ''),
|
||
)
|
||
self._update_emp_count()
|
||
|
||
# Шаблоны
|
||
self._on_templates_changed()
|
||
|
||
# Автоопределение города из адреса после загрузки
|
||
self._on_address_changed()
|
||
|
||
# ==================== ГОРОД ИЗ АДРЕСА ====================
|
||
def _extract_city_from_address(self, address: str) -> str:
|
||
"""Извлекает название города из адреса (простая эвристика)."""
|
||
if not address:
|
||
return ''
|
||
# Пробуем найти "г. Город" или "Г Город" (без точки, заглавная)
|
||
import re
|
||
m = re.search(r'\b[гГ]\.?\s*([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address)
|
||
if m:
|
||
return m.group(1).strip()
|
||
# Пробуем найти "город Город"
|
||
m = re.search(r'\bгород\s+([А-ЯЁ][а-яёA-Za-z-]+(?:\s+[А-ЯЁ][а-яёA-Za-z-]+)*)', address, re.IGNORECASE)
|
||
if m:
|
||
return m.group(1).strip()
|
||
# Пробуем первую часть до запятой, если там известный город
|
||
parts = [p.strip() for p in address.split(',')]
|
||
if parts:
|
||
first = parts[0]
|
||
# Если первая часть — короткая и не похожа на улицу/дом
|
||
if len(first) <= 25 and first and not first.startswith(('ул', 'пр', 'пер', 'шоссе')):
|
||
# Проверяем, что это похоже на город
|
||
city_keywords = ['ск', 'цк', 'бург', 'град', 'полис', 'поль', 'стан', 'горск', 'чинск', 'янск', 'овск']
|
||
if any(first.lower().endswith(kw) for kw in city_keywords):
|
||
return first
|
||
return ''
|
||
|
||
def _on_address_changed(self, event=None):
|
||
"""Обработчик изменения адреса — автозаполняет город."""
|
||
addr_entry = self._company_entries.get('address')
|
||
city_entry = self._company_entries.get('city_name')
|
||
if not addr_entry or not city_entry:
|
||
return
|
||
address = addr_entry.get().strip()
|
||
if not address:
|
||
return
|
||
current_city = city_entry.get().strip()
|
||
if current_city:
|
||
return # Не перезаписываем, если уже заполнен
|
||
city = self._extract_city_from_address(address)
|
||
if city:
|
||
city_entry.delete(0, tk.END)
|
||
city_entry.insert(0, city)
|
||
self.log(f"🏙 Город определён: {city}")
|
||
|
||
# ==================== АВТОРАСЧЁТ УРОВНЯ ЗАЩИЩЁННОСТИ ====================
|
||
# ==================== КОМИССИЯ ====================
|
||
def _add_commission_row(self, role="Член комиссии", position="", fio=""):
|
||
row = ttk.Frame(self._comm_members_frame)
|
||
row.pack(fill=tk.X, pady=2)
|
||
|
||
role_var = tk.StringVar(value=role)
|
||
role_cb = ttk.Combobox(row, textvariable=role_var, state='readonly', width=22,
|
||
values=["Председатель комиссии", "Секретарь комиссии",
|
||
"Член комиссии"])
|
||
role_cb.pack(side=tk.LEFT, padx=2)
|
||
|
||
pos_entry = ttk.Entry(row, width=30)
|
||
pos_entry.insert(0, position)
|
||
pos_entry.pack(side=tk.LEFT, padx=2)
|
||
|
||
fio_entry = ttk.Entry(row, width=25)
|
||
fio_entry.insert(0, fio)
|
||
fio_entry.pack(side=tk.LEFT, padx=2)
|
||
|
||
ttk.Button(row, text="✖", width=3,
|
||
command=lambda: self._delete_commission_row(row)).pack(side=tk.LEFT, padx=5)
|
||
|
||
self.commission_entries.append((row, role_var, pos_entry, fio_entry))
|
||
|
||
def _add_commission_row_ui(self):
|
||
self._add_commission_row()
|
||
|
||
def _delete_commission_row(self, row_frame):
|
||
self.commission_entries = [e for e in self.commission_entries if e[0] != row_frame]
|
||
row_frame.destroy()
|
||
|
||
# ==================== ИНФОРМАЦИОННЫЕ СИСТЕМЫ ====================
|
||
def _refresh_is_listbox(self):
|
||
self.is_listbox.delete(0, tk.END)
|
||
for isys in self.model.information_systems:
|
||
self.is_listbox.insert(tk.END, isys.name or "(без названия)")
|
||
if not self.model.information_systems:
|
||
self._is_edit_panel.pack_forget()
|
||
self._is_edit_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||
self._set_is_edit_state(False)
|
||
else:
|
||
self._is_edit_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||
|
||
def _set_is_edit_state(self, enabled):
|
||
state = 'normal' if enabled else 'disabled'
|
||
|
||
def _suggest_typical_program_click(self):
|
||
"""⚙️ Подобрать типовой отраслевой объект программой (по названию/назначению объекта)."""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("Подбор", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
self._collect_is()
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
try:
|
||
from .threat_rules import suggest_typical_object_program
|
||
sphere = self.is_sphere_combo.get() or None
|
||
results = suggest_typical_object_program(isys, limit=12, sphere=sphere)
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", str(e))
|
||
return
|
||
if not results:
|
||
messagebox.showinfo("Подбор",
|
||
"Не удалось подобрать типовой объект по названию/назначению.\n"
|
||
"Попробуйте кнопку «🤖 Подобрать характеристики (ИИ)».")
|
||
return
|
||
|
||
dlg = tk.Toplevel(self.root)
|
||
dlg.title("⚙️ Подбор типового отраслевого объекта")
|
||
dlg.geometry("860x520")
|
||
dlg.transient(self.root)
|
||
frm = ttk.Frame(dlg, padding=8)
|
||
frm.pack(fill=tk.BOTH, expand=True)
|
||
|
||
ttk.Label(frm, text=f"Объект: {isys.name}",
|
||
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
|
||
ttk.Label(frm, text="Наиболее подходящие типовые отраслевые объекты (по совпадению слов "
|
||
"с названием/назначением). Выберите и нажмите «Применить».",
|
||
foreground='gray', wraplength=820, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
|
||
|
||
lb = tk.Listbox(frm, height=16, exportselection=False)
|
||
lb.pack(fill=tk.BOTH, expand=True)
|
||
for r in results:
|
||
lb.insert(tk.END, f"[{r['score']}] ({r['sphere']}) {r['name']}")
|
||
|
||
btn_row = ttk.Frame(frm)
|
||
btn_row.pack(fill=tk.X, pady=(8, 0))
|
||
ttk.Button(btn_row, text="✔️ Применить выбранное",
|
||
command=lambda: self._apply_typical_selection(lb, results, dlg)).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_row, text="Отмена", command=dlg.destroy).pack(side=tk.LEFT, padx=2)
|
||
|
||
def _apply_typical_selection(self, lb, results, dlg):
|
||
sel = lb.curselection()
|
||
if not sel:
|
||
messagebox.showinfo("Подбор", "Выберите типовой объект из списка")
|
||
return
|
||
r = results[sel[0]]
|
||
# Устанавливаем сферу и типовой объект
|
||
if hasattr(self, 'is_sphere_combo'):
|
||
self.is_sphere_combo.set(r['sphere'])
|
||
self._update_typical_objects_by_sphere()
|
||
if hasattr(self, 'is_typical_object_combo'):
|
||
self.is_typical_object_combo.set(r['name'])
|
||
self._collect_is()
|
||
self.log(f"⚙️ Типовой отраслевой объект: {r['name'][:70]}")
|
||
dlg.destroy()
|
||
|
||
def _suggest_object_params_ai_click(self):
|
||
"""🤖 Подобрать характеристики объекта через ИИ (назначение, сфера, тип, архитектура, типовой объект)."""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("Подбор", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
settings = load_api_settings()
|
||
if not settings.get('ai_api_key'):
|
||
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
|
||
return
|
||
self._collect_is()
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
# Перечень типовых объектов для выбора ИИ (всех сфер)
|
||
typical_list = []
|
||
if hasattr(self, '_typical_industry'):
|
||
for sph, items in self._typical_industry.items():
|
||
if sph == 'Иная сфера деятельности':
|
||
continue
|
||
for it in items:
|
||
typical_list.append(it.get('name', ''))
|
||
data = {
|
||
'name': isys.name or '',
|
||
'description': isys.description or '',
|
||
'purpose': isys.purpose or '',
|
||
'sphere': isys.sphere or '',
|
||
'object_type': isys.object_type or '',
|
||
}
|
||
# Полные списки допустимых значений — ИИ выбирает ТОЛЬКО из них
|
||
spheres = list(self.is_sphere_combo['values']) if hasattr(self, 'is_sphere_combo') else []
|
||
object_types = list(self.is_object_type_combo['values']) if hasattr(self, 'is_object_type_combo') else []
|
||
architectures = list(self.is_architecture_combo['values']) if hasattr(self, 'is_architecture_combo') else []
|
||
self.log(f"🤖 Запрос к ИИ: подбор характеристик для «{isys.name}»")
|
||
self._verify_log("Подбор характеристик через ИИ…")
|
||
|
||
def _run():
|
||
try:
|
||
from .verify import suggest_object_params_ai
|
||
res, err = suggest_object_params_ai(
|
||
data, settings['ai_api_key'], settings.get('ai_model'),
|
||
settings.get('ai_base_url'), typical_objects=typical_list,
|
||
spheres=spheres, object_types=object_types, architectures=architectures)
|
||
self.root.after(0, lambda: self._suggest_object_params_ai_done(res, err, isys.name))
|
||
except Exception as e:
|
||
self.root.after(0, lambda: self._suggest_object_params_ai_done(None, str(e), isys.name))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _suggest_object_params_ai_done(self, res, err, obj_name):
|
||
if err:
|
||
self._verify_log(f"❌ {err}")
|
||
self.log(f"❌ Подбор характеристик ИИ: {err}")
|
||
return
|
||
if not res:
|
||
self._verify_log("❌ ИИ не вернул характеристики")
|
||
return
|
||
|
||
dlg = tk.Toplevel(self.root)
|
||
dlg.title("🤖 Рекомендации ИИ по характеристикам объекта")
|
||
dlg.geometry("880x620")
|
||
dlg.transient(self.root)
|
||
frm = ttk.Frame(dlg, padding=8)
|
||
frm.pack(fill=tk.BOTH, expand=True)
|
||
|
||
ttk.Label(frm, text=f"Объект: {obj_name} — рекомендации ИИ. Отметьте галочками, "
|
||
f"какие поля применить (ИИ может ошибаться — проверяйте).",
|
||
font=('Arial', 10, 'bold'), wraplength=840, justify=tk.LEFT).pack(anchor=tk.W)
|
||
|
||
rows = [
|
||
('назначение', 'Назначение объекта (форма: «Объект предназначен для … с целью …»)'),
|
||
('сфера', 'Сфера деятельности'),
|
||
('тип', 'Тип объекта'),
|
||
('архитектура', 'Архитектура объекта'),
|
||
('типовой_объект', 'Типовой отраслевой объект КИИ'),
|
||
]
|
||
entries = {}
|
||
checks = {}
|
||
for key, label in rows:
|
||
row = ttk.Frame(frm)
|
||
row.pack(fill=tk.X, pady=(6, 0))
|
||
var = tk.BooleanVar(value=True)
|
||
cb = ttk.Checkbutton(row, text=label, variable=var)
|
||
cb.pack(anchor=tk.W)
|
||
e = tk.Text(frm, height=2 if key in ('типовой_объект', 'назначение') else 1, wrap=tk.WORD)
|
||
e.pack(fill=tk.X)
|
||
e.insert('1.0', res.get(key, '') or '')
|
||
entries[key] = e
|
||
checks[key] = var
|
||
|
||
ttk.Label(frm, text="Обоснование ИИ:").pack(anchor=tk.W, pady=(6, 0))
|
||
just = tk.Text(frm, height=2, wrap=tk.WORD)
|
||
just.pack(fill=tk.X)
|
||
just.insert('1.0', res.get('обоснование', '') or '')
|
||
|
||
btn_row = ttk.Frame(frm)
|
||
btn_row.pack(fill=tk.X, pady=(8, 0))
|
||
ttk.Button(btn_row, text="✔️ Применить отмеченное",
|
||
command=lambda: self._apply_ai_params(entries, dlg, checks)).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_row, text="Отмена", command=dlg.destroy).pack(side=tk.LEFT, padx=2)
|
||
|
||
def _apply_ai_params(self, entries, dlg, checks=None):
|
||
"""Применить рекомендации ИИ к полям объекта (только отмеченные галочками)."""
|
||
def _get(e):
|
||
return e.get('1.0', 'end-1c').strip()
|
||
|
||
def _wanted(key):
|
||
return checks is None or checks.get(key, tk.BooleanVar(value=True)).get()
|
||
|
||
purpose = _get(entries['назначение']) if _wanted('назначение') else ''
|
||
sphere = _get(entries['сфера']) if _wanted('сфера') else ''
|
||
otype = _get(entries['тип']) if _wanted('тип') else ''
|
||
arch = _get(entries['архитектура']) if _wanted('архитектура') else ''
|
||
typical = _get(entries['типовой_объект']) if _wanted('типовой_объект') else ''
|
||
|
||
if purpose and hasattr(self, 'is_purpose_entry'):
|
||
self.is_purpose_entry.delete(0, tk.END)
|
||
self.is_purpose_entry.insert(0, purpose)
|
||
|
||
# Сфера — проверим, есть ли в списке
|
||
if sphere and hasattr(self, 'is_sphere_combo'):
|
||
values = list(self.is_sphere_combo['values'])
|
||
exact = next((v for v in values if v.lower() == sphere.lower()), None)
|
||
if exact:
|
||
self.is_sphere_combo.set(exact)
|
||
self._update_typical_objects_by_sphere()
|
||
else:
|
||
self.is_sphere_combo.set(sphere)
|
||
|
||
if otype and hasattr(self, 'is_object_type_combo'):
|
||
values = list(self.is_object_type_combo['values'])
|
||
exact = next((v for v in values if v.lower() == otype.lower()), None)
|
||
self.is_object_type_combo.set(exact or otype)
|
||
|
||
if arch and hasattr(self, 'is_architecture_combo'):
|
||
values = list(self.is_architecture_combo['values'])
|
||
exact = next((v for v in values if v.lower() == arch.lower()), None)
|
||
self.is_architecture_combo.set(exact or arch)
|
||
|
||
if typical and hasattr(self, 'is_typical_object_combo'):
|
||
values = list(self.is_typical_object_combo['values'])
|
||
exact = next((v for v in values if v.lower() == typical.lower()), None)
|
||
self.is_typical_object_combo.set(exact or typical)
|
||
|
||
self._collect_is()
|
||
self.log("🤖 Рекомендации ИИ применены к характеристикам объекта")
|
||
dlg.destroy()
|
||
|
||
def _on_sphere_changed(self, event=None):
|
||
"""При смене сферы объекта — подставляем типовые отраслевые объекты этой сферы (Распоряжение ПРФ № 360-р)."""
|
||
self._update_typical_objects_by_sphere()
|
||
|
||
def _update_typical_objects_by_sphere(self):
|
||
"""Обновляет список типовых отраслевых объектов по выбранной сфере объекта КИИ."""
|
||
try:
|
||
sphere = self.is_sphere_combo.get()
|
||
if sphere and hasattr(self, '_typical_industry') and sphere in self._typical_industry:
|
||
items = self._typical_industry[sphere]
|
||
if items:
|
||
values = [''] + [o['name'] for o in items]
|
||
self.is_typical_object_combo['values'] = values
|
||
return
|
||
# Сферы нет / словарь пуст — общий словарь типовых объектов
|
||
if hasattr(self, '_typical_objects_all'):
|
||
self.is_typical_object_combo['values'] = self._typical_objects_all
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_is_select(self, event=None):
|
||
sel = self.is_listbox.curselection()
|
||
if not sel:
|
||
self._current_is_idx = None
|
||
return
|
||
|
||
# Сохраняем текущую ИС
|
||
self._collect_is()
|
||
|
||
idx = sel[0]
|
||
if idx >= len(self.model.information_systems):
|
||
return
|
||
self._current_is_idx = idx
|
||
isys = self.model.information_systems[idx]
|
||
|
||
self.is_name_entry.delete(0, tk.END)
|
||
self.is_name_entry.insert(0, isys.name or "")
|
||
self.is_description_entry.delete(0, tk.END)
|
||
self.is_description_entry.insert(0, isys.description or "")
|
||
if hasattr(self, 'is_software_entry'):
|
||
self.is_software_entry.delete(0, tk.END)
|
||
self.is_software_entry.insert(0, isys.software or "")
|
||
self.is_category_var.set(isys.category or "без категории")
|
||
if hasattr(self, 'is_balance_entry'):
|
||
self.is_balance_entry.delete(0, tk.END)
|
||
self.is_balance_entry.insert(0, isys.balance_value or "")
|
||
|
||
# Поля 236 приказа
|
||
for attr, model_field in [
|
||
('is_purpose_entry', 'purpose'),
|
||
('is_sphere_combo', 'sphere'),
|
||
('is_object_type_combo', 'object_type'),
|
||
('is_architecture_combo', 'architecture'),
|
||
('is_typical_object_combo', 'typical_object'),
|
||
('is_subject_name_entry', 'subject_name'),
|
||
('is_subject_address_entry', 'subject_address'),
|
||
('is_subject_element_entry', 'subject_element'),
|
||
('is_subject_inn_entry', 'subject_inn'),
|
||
('is_subject_kpp_entry', 'subject_kpp'),
|
||
('is_net_category_combo', 'net_category'),
|
||
('is_net_operator_entry', 'net_operator'),
|
||
('is_net_purpose_combo', 'net_purpose'),
|
||
('is_net_method_combo', 'net_method'),
|
||
('is_hw_sw_listbox', 'hw_sw'),
|
||
('is_os_sw_listbox', 'os_sw'),
|
||
('is_app_sw_listbox', 'app_sw'),
|
||
('is_attacker_category_combo', 'attacker_category'),
|
||
('is_threats_listbox', 'threats'),
|
||
('is_incidents_listbox', 'incidents'),
|
||
('is_criteria_values_listbox', 'criteria_values'),
|
||
('is_criteria_justification_listbox', 'criteria_justification'),
|
||
('is_org_measures_listbox', 'org_measures'),
|
||
('is_tech_measures_listbox', 'tech_measures'),
|
||
]:
|
||
w = getattr(self, attr, None)
|
||
if w is not None:
|
||
val = getattr(isys, model_field, '') or ''
|
||
if isinstance(w, ttk.Combobox):
|
||
w.set(val)
|
||
elif isinstance(w, tk.Listbox):
|
||
w.delete(0, tk.END)
|
||
val = str(val)
|
||
if model_field in ('threats', 'incidents'):
|
||
val = normalize_threats(val)
|
||
lines = [ln.strip() for ln in val.split('\n') if ln.strip()]
|
||
if model_field == 'threats':
|
||
from .threat_rules import sort_threats_by_ubi
|
||
lines = sort_threats_by_ubi(lines)
|
||
for line in lines:
|
||
w.insert(tk.END, line)
|
||
elif isinstance(w, tk.Text):
|
||
w.delete('1.0', tk.END)
|
||
w.insert('1.0', val)
|
||
else:
|
||
w.delete(0, tk.END)
|
||
w.insert(0, val)
|
||
|
||
# Типовые отраслевые объекты — по сфере объекта (Распоряжение ПРФ № 360-р)
|
||
self._update_typical_objects_by_sphere()
|
||
|
||
# Списки
|
||
for lb, items in [
|
||
(self.defense_tools_listbox, isys.defense_tools_list or []),
|
||
(self.users_listbox, isys.users_list or []),
|
||
]:
|
||
lb.delete(0, tk.END)
|
||
for item in items:
|
||
lb.insert(tk.END, item)
|
||
|
||
def _add_is(self):
|
||
# Сохраняем текущую ИС перед добавлением новой
|
||
self._collect_is()
|
||
name = simpledialog.askstring("Добавить ИС", "Введите наименование ИС:")
|
||
if name:
|
||
isys = InformationSystem(name=name)
|
||
self.model.information_systems.append(isys)
|
||
self._refresh_is_listbox()
|
||
self.is_listbox.selection_set(tk.END)
|
||
self._on_is_select()
|
||
|
||
def _copy_is(self):
|
||
if self._current_is_idx is None:
|
||
return
|
||
self._collect_is()
|
||
src = self.model.information_systems[self._current_is_idx]
|
||
copy = InformationSystem(
|
||
name=src.name + " (копия)",
|
||
description=src.description,
|
||
is_local_network=src.is_local_network,
|
||
is_internet=src.is_internet,
|
||
category=src.category,
|
||
defense_tools_list=list(src.defense_tools_list),
|
||
users_list=list(src.users_list),
|
||
purpose=src.purpose,
|
||
sphere=src.sphere,
|
||
object_type=src.object_type,
|
||
architecture=src.architecture,
|
||
typical_object=src.typical_object,
|
||
subject_name=src.subject_name,
|
||
subject_address=src.subject_address,
|
||
subject_element=src.subject_element,
|
||
subject_inn=src.subject_inn,
|
||
subject_kpp=src.subject_kpp,
|
||
net_category=src.net_category,
|
||
net_operator=src.net_operator,
|
||
net_purpose=src.net_purpose,
|
||
net_method=src.net_method,
|
||
hw_sw=src.hw_sw,
|
||
os_sw=src.os_sw,
|
||
app_sw=src.app_sw,
|
||
balance_value=src.balance_value,
|
||
attacker_category=src.attacker_category,
|
||
attacker_level=src.attacker_level,
|
||
attacker_types=src.attacker_types,
|
||
threats=src.threats,
|
||
incidents=src.incidents,
|
||
criteria_values=src.criteria_values,
|
||
criteria_justification=src.criteria_justification,
|
||
org_measures=src.org_measures,
|
||
tech_measures=src.tech_measures,
|
||
)
|
||
self.model.information_systems.append(copy)
|
||
self._refresh_is_listbox()
|
||
self.is_listbox.selection_set(tk.END)
|
||
self._on_is_select()
|
||
|
||
def _delete_is(self):
|
||
if self._current_is_idx is None:
|
||
return
|
||
self._collect_is()
|
||
del self.model.information_systems[self._current_is_idx]
|
||
self._current_is_idx = None
|
||
self._refresh_is_listbox()
|
||
if self.model.information_systems:
|
||
self.is_listbox.selection_set(0)
|
||
self._on_is_select()
|
||
|
||
def _add_defense_item(self):
|
||
item = simpledialog.askstring("Добавить СЗИ", "Введите средство защиты:")
|
||
if item:
|
||
self.defense_tools_listbox.insert(tk.END, item)
|
||
|
||
def _select_sw_from_dict(self, listbox, dict_name, allow_refine=False):
|
||
"""Выбор позиций из словаря (JSON) — можно несколько.
|
||
|
||
allow_refine=True — показывать кнопку «✏️ Уточнить модель/кол-во»
|
||
(только для ПО/оборудования, где это имеет смысл).
|
||
"""
|
||
import json
|
||
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', dict_name)
|
||
try:
|
||
with open(dict_path, 'r', encoding='utf-8') as f:
|
||
items = json.load(f)
|
||
title = "Выбор из словаря"
|
||
self._select_from_dialog(title, items, listbox, allow_refine=allow_refine)
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}")
|
||
|
||
def _sw_dialog(self, title, initial=""):
|
||
"""Большое окно для ввода/редактирования позиции ПО."""
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title(title)
|
||
dialog.geometry("560x220")
|
||
dialog.transient(self.root)
|
||
dialog.grab_set()
|
||
dialog.resizable(True, True)
|
||
|
||
frame = ttk.Frame(dialog, padding=10)
|
||
frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
ttk.Label(frame, text="Текст позиции:").pack(anchor=tk.W)
|
||
txt = tk.Text(frame, width=70, height=6, wrap=tk.WORD)
|
||
txt.pack(fill=tk.BOTH, expand=True, pady=5)
|
||
txt.insert('1.0', initial)
|
||
txt.focus_set()
|
||
|
||
result = {}
|
||
|
||
def _ok():
|
||
result['val'] = txt.get('1.0', 'end-1c').strip()
|
||
dialog.destroy()
|
||
|
||
def _cancel():
|
||
result['val'] = None
|
||
dialog.destroy()
|
||
|
||
btn_frame = ttk.Frame(frame)
|
||
btn_frame.pack(fill=tk.X)
|
||
ttk.Button(btn_frame, text="✅ ОК", command=_ok).pack(side=tk.RIGHT, padx=2)
|
||
ttk.Button(btn_frame, text="Отмена", command=_cancel).pack(side=tk.RIGHT, padx=2)
|
||
|
||
dialog.bind('<Return>', lambda e: _ok())
|
||
dialog.bind('<Escape>', lambda e: _cancel())
|
||
dialog.wait_window()
|
||
return result.get('val')
|
||
|
||
def _add_sw_item(self, listbox):
|
||
"""Добавить строку в список ПО (большое окно)."""
|
||
item = self._sw_dialog("Добавить позицию")
|
||
if item:
|
||
listbox.insert(tk.END, item)
|
||
|
||
def _del_sw_item(self, listbox):
|
||
"""Удалить выбранную строку из списка ПО."""
|
||
sel = listbox.curselection()
|
||
if sel:
|
||
listbox.delete(sel[0])
|
||
|
||
def _edit_sw_item(self, listbox):
|
||
"""Редактировать выбранную строку списка ПО (большое окно)."""
|
||
sel = listbox.curselection()
|
||
if not sel:
|
||
messagebox.showinfo("Редактировать", "Сначала выберите позицию в списке")
|
||
return
|
||
current = listbox.get(sel[0])
|
||
item = self._sw_dialog("Редактировать позицию", initial=current)
|
||
if item is not None:
|
||
listbox.delete(sel[0])
|
||
listbox.insert(sel[0], item)
|
||
|
||
def _add_user_to_is(self):
|
||
user = simpledialog.askstring("Добавить пользователя", "Введите ФИО или должность:")
|
||
if user:
|
||
self.users_listbox.insert(tk.END, user)
|
||
|
||
def _open_criteria_127_dialog(self):
|
||
"""Окно заполнения показателей критериев значимости по ПП РФ № 127.
|
||
|
||
Таблица перенесена дословно из Постановления Правительства РФ от 08.02.2018 № 127:
|
||
5 критериев (социальная, политическая, экономическая, экологическая,
|
||
значимость для обороны страны), показатели 1-14 с подпунктами,
|
||
значения сразу привязаны к категориям III / II / I.
|
||
|
||
Категория значимости объекта определяется по наибольшей категории,
|
||
которой соответствует хотя бы один выбранный показатель:
|
||
I → 1-я категория, II → 2-я, III → 3-я, иначе «без категории».
|
||
"""
|
||
import json as _json
|
||
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'criteria_127.json')
|
||
try:
|
||
with open(dict_path, encoding='utf-8') as f:
|
||
criteria = _json.load(f)
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", f"Не удалось загрузить таблицу ПП 127: {e}")
|
||
return
|
||
|
||
# Текущий объект КИИ (для авторасчёта п. 8/9: доходы, ущерб, балансовая стоимость)
|
||
isys = None
|
||
if self._current_is_idx is not None and self._current_is_idx < len(self.model.information_systems):
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
|
||
# Применимость показателей по сфере объекта (Распоряжение ПРФ № 360-р)
|
||
applicable = None # None = все показатели применимы
|
||
try:
|
||
_app_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'criteria_applicability.json')
|
||
with open(_app_path, encoding='utf-8') as _f:
|
||
_app = _json.load(_f)
|
||
_sphere = self.is_sphere_combo.get() if hasattr(self, 'is_sphere_combo') else ''
|
||
if _sphere and _sphere in _app:
|
||
applicable = set(_app[_sphere])
|
||
except Exception:
|
||
applicable = None
|
||
|
||
# Словарь обоснований неприменимости показателей по сферам
|
||
# (примеры формулировок; для сфер без записей — пусто)
|
||
justifications = {}
|
||
try:
|
||
_j_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'criteria_justifications.json')
|
||
with open(_j_path, encoding='utf-8') as _f:
|
||
justifications = _json.load(_f)
|
||
except Exception:
|
||
justifications = {}
|
||
_sphere_just = self.is_sphere_combo.get() if hasattr(self, 'is_sphere_combo') else ''
|
||
_just = justifications.get(_sphere_just, {}) if _sphere_just else {}
|
||
|
||
def _just_text(num):
|
||
"""Обоснование для показателя: точный ключ, иначе группа по первым цифрам
|
||
(2а→2, 3б→3, 10.6→10, 13а→13)."""
|
||
if num in _just:
|
||
return _just[num]
|
||
m = _re.match(r'(\d+)', num)
|
||
if m and m.group(1) in _just:
|
||
return _just[m.group(1)]
|
||
return None
|
||
|
||
def _unlock(combo, var, lbl_num, lbl_name, name, btn=None, full_options=None):
|
||
"""Разблокировать конкретный показатель: вернуть категории и «—» (прочерк)."""
|
||
try:
|
||
combo.config(state='readonly')
|
||
combo.config(values=full_options if full_options else ['', '—'])
|
||
var.set('')
|
||
lbl_num.config(foreground='black')
|
||
lbl_name.config(foreground='black', text=name)
|
||
if btn is not None:
|
||
btn.destroy()
|
||
except Exception:
|
||
pass
|
||
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title("Показатели критериев значимости (ПП РФ № 127)")
|
||
dialog.geometry("980x700")
|
||
dialog.transient(self.root)
|
||
dialog.grab_set()
|
||
|
||
outer = ttk.Frame(dialog, padding=8)
|
||
outer.pack(fill=tk.BOTH, expand=True)
|
||
|
||
canvas = tk.Canvas(outer, highlightthickness=0)
|
||
scrollbar = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview)
|
||
body = ttk.Frame(canvas)
|
||
body.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||
canvas.create_window((0, 0), window=body, 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)
|
||
|
||
ttk.Label(body, text="Показатели критериев значимости объекта КИИ",
|
||
font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=(0, 2))
|
||
ttk.Label(body, text="Постановление Правительства РФ от 08.02.2018 № 127 «Об утверждении Правил категорирования "
|
||
"объектов критической информационной инфраструктуры РФ» (приложение). "
|
||
"По каждому показателю выберите значение, которому соответствует объект. "
|
||
"Категория определяется по наивысшей из выбранных.",
|
||
foreground='gray', wraplength=920, justify=tk.LEFT).pack(anchor=tk.W, pady=(0, 4))
|
||
if applicable is not None:
|
||
_sphere_txt = self.is_sphere_combo.get() if hasattr(self, 'is_sphere_combo') else ''
|
||
ttk.Label(body, text=f"Сфера объекта: «{_sphere_txt}». Показатели, не применимые для этой сферы "
|
||
f"(по Распоряжению ПРФ № 360-р), автоматически помечены «не применим» и "
|
||
f"заблокированы. Кнопка «🔓» разблокирует конкретный показатель, если оператор "
|
||
f"считает его применимым.",
|
||
foreground='#1a6e1a', wraplength=920, justify=tk.LEFT).pack(anchor=tk.W, pady=(0, 8))
|
||
|
||
def _open_calc_dialog(num, name, var, combo):
|
||
"""Авторасчёт показателя 8 или 9 по «Рекомендациям по оценке показателей
|
||
критериев экономической значимости» ФСТЭК (2021). Показывает расчёт
|
||
и предлагает категорию как рекомендацию."""
|
||
import re as _re2
|
||
from .variables import (CRITERION_QUANT_THRESHOLDS, CRITERION_CHOICE_INDICATORS,
|
||
CRITERION_QUANT_UNITS, _calc_criterion_other,
|
||
_calc_criterion_13_opk, _criterion_10_cat)
|
||
|
||
# Сфера ОПК — для показателей 8/13а/13б используются формулы ПП РФ № 796
|
||
_sphere_opk = False
|
||
try:
|
||
_sphere_opk = (self.is_sphere_combo.get().strip() == 'Оборонная промышленность')
|
||
except Exception:
|
||
_sphere_opk = False
|
||
|
||
def _num(s):
|
||
# «121 118 270 руб», «1 000», «906 779 руб», «2 212.5» → число
|
||
s = str(s or '').strip()
|
||
if not s:
|
||
return None
|
||
s = s.replace('\u00a0', ' ').replace(' ', '')\
|
||
.replace('руб', '').replace('₽', '').replace('р.', '').replace(',', '.')
|
||
try:
|
||
return float(s)
|
||
except Exception:
|
||
return None
|
||
|
||
# Данные организации (прибыль/налог) из модели
|
||
profit = getattr(self.model, 'profit', '') or ''
|
||
profit_tax = getattr(self.model, 'profit_tax', '') or ''
|
||
|
||
# Родитель — окно ПП 127 (dialog), а не главное окно;
|
||
# grab_set + lift — чтобы окно не уходило на задний план
|
||
w = tk.Toplevel(dialog)
|
||
w.title(f"🧮 Авторасчёт показателя {num} — {name[:60]}")
|
||
w.geometry("980x820")
|
||
w.minsize(760, 560)
|
||
w.resizable(True, True)
|
||
w.transient(dialog)
|
||
w.lift()
|
||
w.focus_force()
|
||
|
||
# Внешняя рамка: тело со скроллом + кнопки снизу (всегда видны)
|
||
outer = ttk.Frame(w, padding=10)
|
||
outer.pack(fill=tk.BOTH, expand=True)
|
||
|
||
canvas = tk.Canvas(outer, highlightthickness=0)
|
||
scrollbar = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview)
|
||
frm = ttk.Frame(canvas)
|
||
frm.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||
canvas.create_window((0, 0), window=frm, 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)
|
||
|
||
if num in ('13а', '13б') and _sphere_opk:
|
||
# ОПК: 13а — V = (1−(V_max−V_инц)/V_ГОЗ)×100%, 13б — T = (1−(t_норм/t_ненорм))×100%
|
||
# (ПП РФ № 796 от 27.06.2026, п. 34, 38)
|
||
ttk.Label(frm, text=f"Показатель {num} — по отраслевым особенностям ОПК (ПП РФ № 796)",
|
||
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
|
||
if num == '13а':
|
||
ttk.Label(frm, text="Формула: V = (1 − (V_max − V_инц) / V_ГОЗ) × 100%, где\n"
|
||
"V_max — максимальный объём продукции ГОЗ при максимальной скорости выпуска;\n"
|
||
"V_инц — объём, поставленный во время устранения компьютерного инцидента;\n"
|
||
"V_ГОЗ — полный объём продукции по ГОЗ.",
|
||
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
|
||
|
||
def _row(label, key, default=''):
|
||
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
|
||
e = ttk.Entry(frm, width=80)
|
||
e.insert(0, str(default or ''))
|
||
e.pack(fill=tk.X)
|
||
return e
|
||
|
||
_ci13a = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
|
||
e_vmax = _row("V_max — максимальный объём продукции ГОЗ (ед.):", 'vmax', _ci13a.get('13a_vmax', ''))
|
||
e_vinc = _row("V_инц — объём продукции, поставленный во время устранения инцидента (ед.):", 'vinc', _ci13a.get('13a_vinc', ''))
|
||
e_vgoz = _row("V_ГОЗ — полный объём продукции по ГОЗ (ед.):", 'vgoz', _ci13a.get('13a_vgoz', ''))
|
||
else:
|
||
ttk.Label(frm, text="Формула: T = (1 − (t_норм / t_ненорм)) × 100%, где\n"
|
||
"t_норм — сумма времени операций по производству единицы продукции ГОЗ\n"
|
||
"при штатном функционировании объекта КИИ;\n"
|
||
"t_ненорм — сумма времени тех же операций при ненормальном функционировании.",
|
||
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
|
||
|
||
def _row(label, key, default=''):
|
||
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
|
||
e = ttk.Entry(frm, width=80)
|
||
e.insert(0, str(default or ''))
|
||
e.pack(fill=tk.X)
|
||
return e
|
||
|
||
_ci13b = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
|
||
e_tnorm = _row("t_норм — время операций при штатном функционировании (час):", 'tnorm', _ci13b.get('13b_tnorm', ''))
|
||
e_tnon = _row("t_ненорм — время операций при ненормальном функционировании (час):", 'tnon', _ci13b.get('13b_tnonorm', ''))
|
||
result_frame = ttk.Frame(frm)
|
||
result_frame.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
|
||
result = tk.Text(result_frame, height=10, wrap=tk.WORD, state=tk.NORMAL)
|
||
result_scroll = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=result.yview)
|
||
result.configure(yscrollcommand=result_scroll.set)
|
||
result.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
result_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||
elif (num in CRITERION_QUANT_THRESHOLDS or num in CRITERION_CHOICE_INDICATORS):
|
||
# Остальные показатели (1–7, 11–14): количественные — ввод значения,
|
||
# качественные — выбор варианта (III/II/I) из справочника ПП-127
|
||
is_choice = num in CRITERION_CHOICE_INDICATORS
|
||
ttk.Label(frm, text=f"Показатель {num} — по порогам ПП-127",
|
||
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
|
||
if is_choice:
|
||
ttk.Label(frm, text="Выберите один из вариантов значений (соответствует III/II/I категории):",
|
||
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
|
||
else:
|
||
_unit = CRITERION_QUANT_UNITS.get(num, '')
|
||
ttk.Label(frm, text=f"Введите значение показателя ({_unit}): категория определяется "
|
||
"по пороговым значениям ПП-127.",
|
||
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
|
||
|
||
def _row(label, key, default=''):
|
||
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
|
||
e = ttk.Entry(frm, width=80)
|
||
e.insert(0, str(default or ''))
|
||
e.pack(fill=tk.X)
|
||
return e
|
||
|
||
if is_choice:
|
||
# Варианты значений для качественного показателя (из criteria_127.json)
|
||
_opts = []
|
||
for _g in (criteria if isinstance(criteria, list) else []):
|
||
for _ind in _g.get('indicators', []):
|
||
if str(_ind.get('num', '')) == num:
|
||
_vals = _ind.get('values', {}) or {}
|
||
_opts = [str(v) for v in _vals.values() if str(v).strip()]
|
||
break
|
||
e_choice = ttk.Combobox(frm, width=90, state='readonly', values=_opts)
|
||
e_choice.pack(fill=tk.X, pady=4)
|
||
else:
|
||
e_value = _row(f"Значение показателя ({_unit}):", 'value', '')
|
||
result_frame = ttk.Frame(frm)
|
||
result_frame.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
|
||
result = tk.Text(result_frame, height=10, wrap=tk.WORD, state=tk.NORMAL)
|
||
result_scroll = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=result.yview)
|
||
result.configure(yscrollcommand=result_scroll.set)
|
||
result.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
result_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||
elif num == '10' or str(num).startswith('10.'):
|
||
# Показатель 10 — прекращение финансовых операций (банковская сфера)
|
||
is_base = str(num) == '10'
|
||
if is_base:
|
||
ttk.Label(frm, text="Показатель 10 — прекращение (нарушение) проведения клиентами операций "
|
||
"по переводу денежных средств",
|
||
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
|
||
ttk.Label(frm, text="Применим для: системно значимых кредитных организаций, операторов услуг "
|
||
"платёжной инфраструктуры значимых платёжных систем, операторов платформы "
|
||
"цифрового рубля и др.\n"
|
||
"Формула: №10 = операции за год (млн ед.) / календарные дни в году "
|
||
"(среднедневное количество операций).\n"
|
||
"Пороги: ≤ 70 млн/день → III; > 70 и ≤ 120 → II; > 120 → I.",
|
||
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
|
||
else:
|
||
ttk.Label(frm, text=f"Показатель {num} — прекращение (нарушение) проведения операций "
|
||
"(подпоказатель 10.x)",
|
||
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
|
||
ttk.Label(frm, text="Значение показателя вводится в единицах, установленных ПП-127 "
|
||
"(трлн руб., тыс. шт., млрд руб., количество договоров, млн ед.); "
|
||
"категория определяется по порогам ПП-127.",
|
||
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
|
||
|
||
def _row(label, key, default=''):
|
||
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
|
||
e = ttk.Entry(frm, width=80)
|
||
e.insert(0, str(default or ''))
|
||
e.pack(fill=tk.X)
|
||
return e
|
||
|
||
_ops = getattr(self.model, 'ops_count', '') or ''
|
||
_days = getattr(self.model, 'calendar_days', '') or '365'
|
||
_v10 = getattr(self.model, 'criterion10_value', '') or ''
|
||
if is_base:
|
||
e_ops = _row("Количество операций за прошедший год (млн ед.):", 'ops', _ops)
|
||
e_days = _row("Количество календарных дней в году (по умолчанию 365):", 'days', _days)
|
||
else:
|
||
e_val10 = _row("Значение показателя (в единицах ПП-127):", 'val10', _v10)
|
||
result_frame = ttk.Frame(frm)
|
||
result_frame.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
|
||
result = tk.Text(result_frame, height=12, wrap=tk.WORD, state=tk.NORMAL)
|
||
result_scroll = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=result.yview)
|
||
result.configure(yscrollcommand=result_scroll.set)
|
||
result.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
result_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||
elif num == '9':
|
||
ttk.Label(frm, text="Показатель 9 — ущерб бюджетам РФ (применим ко всем субъектам КИИ)",
|
||
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
|
||
ttk.Label(frm, text="Формула: №9 = ΔБ / Бусредн × 100%, где ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС\n"
|
||
"ΔНП = ΔДоходов × (налог на прибыль / прибыль); если ΔДоходов > прибыли — весь налог.",
|
||
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
|
||
|
||
def _row(label, key, default=''):
|
||
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
|
||
e = ttk.Entry(frm, width=80)
|
||
e.insert(0, str(default or ''))
|
||
e.pack(fill=tk.X)
|
||
return e
|
||
|
||
e_profit = _row("Прибыль субъекта КИИ за прошедший год (руб.):", 'profit', profit)
|
||
e_tax = _row("Налог на прибыль за прошедший год (руб.):", 'tax', profit_tax)
|
||
# ΔДоходов: если есть балансовая стоимость объекта КИИ — предзаполняем её
|
||
_bv9 = getattr(isys, 'balance_value', '') or ''
|
||
_bvn9 = _num(_bv9)
|
||
e_dincome = _row("Снижение уровня доходов ΔДоходов (руб., наихудший сценарий):", 'dincome',
|
||
f"{_bvn9:,.0f}" if _bvn9 else '')
|
||
if _bvn9:
|
||
ttk.Label(frm, text="⚠️ ΔДоходов — подставлена балансовая стоимость объекта КИИ "
|
||
f"({_bvn9:,.0f} руб.); можно поправить.",
|
||
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
|
||
else:
|
||
ttk.Label(frm, text="⚠️ ΔДоходов — не заполнено: введите вручную или заполните "
|
||
"балансовую стоимость объекта КИИ во вкладке «Объект».",
|
||
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
|
||
e_budget = _row("Прогнозируемый годовой доход федерального бюджета, усреднённый за планируемый трёхлетний период — Бусредн (руб.):", 'budget',
|
||
f'{DEFAULT_BUDGET_AVG:.0f}')
|
||
ttk.Label(frm, text="⚠️ Бусредн — прогнозируемый годовой доход федерального бюджета, "
|
||
"усреднённый за планируемый трёхлетний период (данные Минфина; "
|
||
"подставлено значение по умолчанию, при необходимости уточните).",
|
||
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
|
||
ttk.Label(frm, text="Прочие составляющие ΔБ (если применимы, иначе 0):",
|
||
font=('', 9, 'bold')).pack(anchor=tk.W, pady=(8, 0))
|
||
e_ndpi = _row("Снижение выплат от налога на добычу ПИ ΔНДПИ (руб.):", 'ndpi', '0')
|
||
e_div = _row("Снижение выплат от дивидендов ΔДИВ (руб.):", 'div', '0')
|
||
e_vso = _row("Снижение выплат сторонних организаций ΔВСО (руб.):", 'vso', '0')
|
||
e_ns = _row("Снижение сборов ΔНС (руб.):", 'ns', '0')
|
||
result_frame = ttk.Frame(frm)
|
||
result_frame.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
|
||
result = tk.Text(result_frame, height=12, wrap=tk.WORD, state=tk.NORMAL)
|
||
result_scroll = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=result.yview)
|
||
result.configure(yscrollcommand=result_scroll.set)
|
||
result.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
result_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||
elif num == '8' and _sphere_opk:
|
||
# ОПК: U_8 = U_у8 / R_год × 100%, U_у8 = C + A + X (ПП РФ № 796, п. 18–20)
|
||
ttk.Label(frm, text="Показатель 8 — ущерб субъекту КИИ (отраслевые особенности ОПК, ПП РФ № 796)",
|
||
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
|
||
ttk.Label(frm, text="Формула: U_8 = U_у8 / R_год × 100%, где U_у8 = C + A + X\n"
|
||
"C — суммарные затраты на объект КИИ;\n"
|
||
"A — затраты на возмещение ущерба потерпевшим (если ущерба нет, A = 0);\n"
|
||
"X — ущерб от ранее произошедших компьютерных инцидентов (если не было, X = 0);\n"
|
||
"R_год — усреднённый объём годового дохода за прошедший 5-летний период.",
|
||
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
|
||
|
||
def _row(label, key, default=''):
|
||
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
|
||
e = ttk.Entry(frm, width=80)
|
||
e.insert(0, str(default or ''))
|
||
e.pack(fill=tk.X)
|
||
return e
|
||
|
||
_ci8 = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
|
||
_bv8 = getattr(isys, 'balance_value', '') or ''
|
||
_bvn8 = _num(_bv8)
|
||
_c_def = _num(_ci8.get('8_c', '') or '') or _bvn8
|
||
e_c = _row("C — суммарные затраты на объект КИИ (руб.):", 'c',
|
||
f"{_c_def:,.0f}" if _c_def else '')
|
||
if _bvn8 and not _num(_ci8.get('8_c', '') or ''):
|
||
ttk.Label(frm, text="⚠️ C — подставлена балансовая стоимость объекта КИИ "
|
||
f"({_bvn8:,.0f} руб.); можно поправить.",
|
||
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
|
||
e_a = _row("A — затраты на возмещение ущерба потерпевшим (руб., по умолчанию 0):", 'a',
|
||
_ci8.get('8_a', ''))
|
||
e_x = _row("X — ущерб от ранее произошедших компьютерных инцидентов (руб., по умолчанию 0):", 'x',
|
||
_ci8.get('8_x', ''))
|
||
_incs_raw8 = [getattr(self.model, f'income_year_{i}', '') or '' for i in range(1, 6)]
|
||
_incs8 = [_num(x) for x in _incs_raw8]
|
||
_incs8_f = [x for x in _incs8 if x is not None]
|
||
_inc58 = _num(getattr(self.model, 'income_5y', '') or '')
|
||
if _inc58:
|
||
_d_avg8 = _inc58 / 5.0
|
||
elif _incs8_f:
|
||
_d_avg8 = sum(_incs8_f) / len(_incs8_f)
|
||
else:
|
||
_d_avg8 = None
|
||
e_r = _row("R_год — усреднённый годовой доход за 5 лет (руб.):", 'r',
|
||
f"{_d_avg8:,.0f}" if _d_avg8 else '')
|
||
ttk.Label(frm, text="⚠️ R_год — среднее по заполненным годам; ПП-127 требует усреднение "
|
||
"за прошедший пятилетний период.",
|
||
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
|
||
result_frame = ttk.Frame(frm)
|
||
result_frame.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
|
||
result = tk.Text(result_frame, height=12, wrap=tk.WORD, state=tk.NORMAL)
|
||
result_scroll = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=result.yview)
|
||
result.configure(yscrollcommand=result_scroll.set)
|
||
result.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
result_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||
else:
|
||
ttk.Label(frm, text="Показатель 8 — ущерб субъекту КИИ",
|
||
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
|
||
ttk.Label(frm, text="Применим только для: госкорпорации, ГУП, госкомпании, стратегического АО/предприятия "
|
||
"(Указ Президента № 1009).\nФормула: №8 = ΔДоходов / Дусредн × 100%.",
|
||
foreground='gray', wraplength=760, justify=tk.LEFT).pack(anchor=tk.W, pady=(2, 6))
|
||
|
||
def _row(label, key, default=''):
|
||
ttk.Label(frm, text=label, wraplength=900, justify=tk.LEFT).pack(anchor=tk.W, pady=(4, 0))
|
||
e = ttk.Entry(frm, width=80)
|
||
e.insert(0, str(default or ''))
|
||
e.pack(fill=tk.X)
|
||
return e
|
||
|
||
e_profit = e_tax = None
|
||
# Доходы за 5 лет — из данных организации (поле «Доход за 5 лет (суммарно)»,
|
||
# иначе — обратная совместимость по годам)
|
||
_incs_raw = [getattr(self.model, f'income_year_{i}', '') or '' for i in range(1, 6)]
|
||
_incs = [_num(x) for x in _incs_raw]
|
||
_incs_filled = [x for x in _incs if x is not None]
|
||
_inc5 = _num(getattr(self.model, 'income_5y', '') or '')
|
||
if _inc5:
|
||
_d_avg = _inc5 / 5.0
|
||
_n_years = 5
|
||
elif _incs_filled:
|
||
_d_avg = sum(_incs_filled) / len(_incs_filled)
|
||
_n_years = len(_incs_filled)
|
||
else:
|
||
_d_avg = None
|
||
_n_years = 0
|
||
_d_loss = _num(getattr(self.model, 'income_loss', '') or '')
|
||
_bv8 = getattr(isys, 'balance_value', '') or ''
|
||
_bvn8 = _num(_bv8)
|
||
_dincome_def = _d_loss or _bvn8
|
||
_dincome_est = False
|
||
if not _dincome_def and _d_avg:
|
||
# Фолбэк: ущерб не заполнен → оценка «доход за год × 5» (наихудший сценарий)
|
||
_dincome_def = _d_avg * 5
|
||
_dincome_est = True
|
||
e_dincome = _row("Снижение уровня дохода от нарушения критических процессов (руб.):",
|
||
'dincome', f"{_dincome_def:,.0f}" if _dincome_def else '')
|
||
if _d_loss:
|
||
ttk.Label(frm, text="⚠️ ΔДоходов — подставлено из вкладки «Организация» "
|
||
f"({_d_loss:,.0f} руб.); можно поправить.",
|
||
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
|
||
elif _bvn8:
|
||
ttk.Label(frm, text="⚠️ ΔДоходов — подставлена балансовая стоимость объекта КИИ "
|
||
f"({_bvn8:,.0f} руб.); можно поправить.",
|
||
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
|
||
elif _dincome_est:
|
||
ttk.Label(frm, text="⚠️ ΔДоходов — не заполнено, принята оценка «доход за год × 5» "
|
||
f"({_d_avg:,.0f} × 5 = {_dincome_def:,.0f} руб., наихудший сценарий); можно поправить.",
|
||
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
|
||
else:
|
||
ttk.Label(frm, text="⚠️ ΔДоходов — не заполнено: введите вручную или заполните "
|
||
"«Снижение уровня дохода» во вкладке «Организация».",
|
||
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
|
||
e_budget = _row("Доходы субъекта КИИ, усреднённые за 5 лет (руб.):",
|
||
'budget', f"{_d_avg:,.0f}" if _d_avg else '')
|
||
_n_years = len(_incs_filled)
|
||
ttk.Label(frm, text=f"⚠️ Доходы за 5 лет — среднее по заполненным годам ({_n_years} из 5). "
|
||
"ПП-127 требует усреднение за прошедший пятилетний период: "
|
||
"при неполных данных результат методологически некорректен — заполните все 5 лет.",
|
||
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
|
||
result_frame = ttk.Frame(frm)
|
||
result_frame.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
|
||
result = tk.Text(result_frame, height=12, wrap=tk.WORD, state=tk.NORMAL)
|
||
result_scroll = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=result.yview)
|
||
result.configure(yscrollcommand=result_scroll.set)
|
||
result.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
result_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||
|
||
def _calc():
|
||
lines = []
|
||
if num in ('13а', '13б') and _sphere_opk:
|
||
# Сохраняем введённые составляющие формулы в criterion_input (для шаблона)
|
||
if isys:
|
||
_ci = dict(getattr(isys, 'criterion_input', None) or {})
|
||
if num == '13а':
|
||
_ci['13a_vmax'] = e_vmax.get().strip()
|
||
_ci['13a_vinc'] = e_vinc.get().strip()
|
||
_ci['13a_vgoz'] = e_vgoz.get().strip()
|
||
else:
|
||
_ci['13b_tnorm'] = e_tnorm.get().strip()
|
||
_ci['13b_tnonorm'] = e_tnon.get().strip()
|
||
isys.criterion_input = _ci
|
||
text, cat = _calc_criterion_13_opk(self.model, isys, num)
|
||
if text.startswith('Заполните'):
|
||
result.delete('1.0', tk.END)
|
||
result.insert('1.0', text)
|
||
return
|
||
lines.append(text)
|
||
if cat:
|
||
lines.append(f"Категория значимости: {cat} (по порогам ПП-127)")
|
||
else:
|
||
lines.append("Категория значимости: — (значение ниже порога III)")
|
||
elif (num in CRITERION_QUANT_THRESHOLDS or num in CRITERION_CHOICE_INDICATORS):
|
||
is_choice = num in CRITERION_CHOICE_INDICATORS
|
||
if is_choice:
|
||
choice = e_choice.get().strip()
|
||
if not choice:
|
||
result.delete('1.0', tk.END)
|
||
result.insert('1.0', "Выберите вариант значения показателя.")
|
||
return
|
||
text, cat = _calc_criterion_other(self.model, isys, num, choice=choice)
|
||
else:
|
||
val = _num(e_value.get())
|
||
if val is None:
|
||
result.delete('1.0', tk.END)
|
||
result.insert('1.0', "Введите числовое значение показателя.")
|
||
return
|
||
text, cat = _calc_criterion_other(self.model, isys, num, value=val)
|
||
lines.append(text)
|
||
if cat:
|
||
lines.append(f"Категория значимости: {cat} (по порогам ПП-127)")
|
||
else:
|
||
lines.append("Категория значимости: — (значение ниже порога III)")
|
||
elif num == '10' or str(num).startswith('10.'):
|
||
if str(num) == '10':
|
||
ops = _num(e_ops.get())
|
||
days = _num(e_days.get())
|
||
if days is None or days <= 0:
|
||
days = 365
|
||
if ops is None:
|
||
result.delete('1.0', tk.END)
|
||
result.insert('1.0', "Заполните количество операций за год (млн ед.).")
|
||
return
|
||
val = ops / days
|
||
lines.append(f"Количество операций за прошедший год: {ops:,.0f} млн ед.")
|
||
lines.append(f"Количество календарных дней в году: {days:.0f}")
|
||
lines.append(f"№10 = операции / дни = {ops:,.0f} / {days:.0f} = {val:.6f} млн ед./день")
|
||
else:
|
||
val = _num(e_val10.get())
|
||
if val is None:
|
||
result.delete('1.0', tk.END)
|
||
result.insert('1.0', "Заполните значение показателя.")
|
||
return
|
||
lines.append(f"Значение показателя {num}: {val:,.6f}")
|
||
cat = _criterion_10_cat(num, val)
|
||
if cat:
|
||
lines.append(f"Категория значимости: {cat} (по порогам ПП-127)")
|
||
else:
|
||
lines.append("Категория значимости: — (значение ниже порога III)")
|
||
elif num == '8' and _sphere_opk:
|
||
c = _num(e_c.get())
|
||
a = _num(e_a.get()) or 0
|
||
x = _num(e_x.get()) or 0
|
||
r = _num(e_r.get())
|
||
if c is None or r is None or r <= 0:
|
||
result.delete('1.0', tk.END)
|
||
result.insert('1.0', "Заполните C (суммарные затраты на объект) и R_год (доходы за 5 лет).")
|
||
return
|
||
if isys:
|
||
_ci = dict(getattr(isys, 'criterion_input', None) or {})
|
||
_ci['8_c'] = str(e_c.get().strip())
|
||
_ci['8_a'] = str(e_a.get().strip())
|
||
_ci['8_x'] = str(e_x.get().strip())
|
||
isys.criterion_input = _ci
|
||
u_y8 = c + a + x
|
||
val = u_y8 / r * 100
|
||
lines.append(f"Суммарные затраты на объект КИИ: C = {c:,.0f} руб.")
|
||
lines.append(f"Затраты на возмещение ущерба потерпевшим: A = {a:,.0f} руб.")
|
||
lines.append(f"Ущерб от ранее произошедших инцидентов: X = {x:,.0f} руб.")
|
||
lines.append(f"U_у8 = C + A + X = {c:,.0f} + {a:,.0f} + {x:,.0f} = {u_y8:,.0f} руб.")
|
||
lines.append(f"Усреднённый годовой доход за 5 лет: R_год = {r:,.0f} руб.")
|
||
lines.append(f"U_8 = U_у8 / R_год × 100% = {u_y8:,.0f} / {r:,.0f} × 100 = {val:.2f} %")
|
||
if val > 20:
|
||
cat = 'I'
|
||
elif val > 10:
|
||
cat = 'II'
|
||
elif val >= 1:
|
||
cat = 'III'
|
||
else:
|
||
cat = None
|
||
if cat:
|
||
lines.append(f"Категория значимости: {cat} (более 20% → I; 10-20% → II; 1-10% → III)")
|
||
else:
|
||
lines.append("Значение менее 1% — ни одна категория не присваивается («—»).")
|
||
elif num == '8':
|
||
d = _num(e_dincome.get())
|
||
b = _num(e_budget.get())
|
||
if d is None or b is None or b <= 0:
|
||
result.delete('1.0', tk.END)
|
||
result.insert('1.0', "Заполните оба поля (ущерб и доходы за 5 лет).")
|
||
return
|
||
val = d / b * 100
|
||
lines.append(f"Снижение уровня дохода от нарушения критических процессов: {d:,.0f} руб.")
|
||
lines.append(f"Доходы субъекта КИИ, усреднённые за 5 лет: {b:,.0f} руб.")
|
||
lines.append(f"№8 = ущерб / доходы за 5 лет × 100% = {d:,.0f} / {b:,.0f} × 100 = {val:.2f} %")
|
||
if val > 20:
|
||
cat = 'I'
|
||
elif val > 10:
|
||
cat = 'II'
|
||
elif val >= 1:
|
||
cat = 'III'
|
||
else:
|
||
cat = None
|
||
if cat:
|
||
lines.append(f"Категория значимости: {cat} (более 20% → I; 10-20% → II; 1-10% → III)")
|
||
else:
|
||
lines.append("Значение менее 1% — ни одна категория не присваивается («—»).")
|
||
else:
|
||
p = _num(e_profit.get())
|
||
t = _num(e_tax.get())
|
||
d = _num(e_dincome.get())
|
||
b = _num(e_budget.get())
|
||
if d is None or b is None or b <= 0:
|
||
result.delete('1.0', tk.END)
|
||
result.insert('1.0', "Заполните ΔДоходов и Бусредн (прибыль/налог — из данных организации).")
|
||
return
|
||
if p and t is not None and p > 0:
|
||
knp = t / p
|
||
dnp = d * knp
|
||
if d > p:
|
||
dnp = t
|
||
lines.append(f"Коэффициент КНП = налог на прибыль / прибыль = {t:,.0f} / {p:,.0f} = {knp:.4f}")
|
||
lines.append(f"ΔНП = ΔДоходов × КНП = {d:,.0f} × {knp:.4f} = {dnp:,.1f} руб."
|
||
+ (" (ΔДоходов > прибыли → весь налог)" if d > p else ""))
|
||
else:
|
||
dnp = 0
|
||
lines.append("Прибыль/налог не заполнены — ΔНП принят равным 0 (расчёт неполный).")
|
||
ndpi = _num(e_ndpi.get()) or 0
|
||
div = _num(e_div.get()) or 0
|
||
vso = _num(e_vso.get()) or 0
|
||
ns = _num(e_ns.get()) or 0
|
||
db = dnp + ndpi + div + vso + ns
|
||
lines.append(f"ΔНДПИ = {ndpi:,.0f} руб., ΔДИВ = {div:,.0f} руб., ΔВСО = {vso:,.0f} руб., ΔНС = {ns:,.0f} руб.")
|
||
lines.append(f"ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС = {db:,.1f} руб.")
|
||
lines.append(f"Бусредн (прогноз годового дохода федбюджета, усреднённый за планируемый трёхлетний период): {b:,.0f} руб.")
|
||
val = db / b * 100
|
||
lines.append(f"№9 = ΔБ / Бусредн × 100% = {db:,.1f} / {b:,.0f} × 100 = {val:.6f} %")
|
||
if val > 0.001:
|
||
cat = 'I'
|
||
elif val > 0.0006:
|
||
cat = 'II'
|
||
elif val > 0.0003:
|
||
cat = 'III'
|
||
else:
|
||
cat = None
|
||
if cat:
|
||
lines.append(f"Категория значимости: {cat} (более 0,001% → I; 0,0006-0,001% → II; 0,0003-0,0006% → III)")
|
||
else:
|
||
lines.append("Значение ≤ 0,0003% — ни одна категория не присваивается («—»).")
|
||
result.delete('1.0', tk.END)
|
||
result.insert('1.0', '\n'.join(lines))
|
||
|
||
def _apply():
|
||
_calc()
|
||
txt = result.get('1.0', 'end-1c').strip()
|
||
# Если расчёт не удался (ошибка «Заполните…») — не применяем и не пишем в обоснование
|
||
if txt.startswith('Заполните'):
|
||
return
|
||
# Сохраняем введённое значение показателя в модель (для автогенерации шаблона)
|
||
if (num in CRITERION_QUANT_THRESHOLDS or num in CRITERION_CHOICE_INDICATORS) and isys:
|
||
try:
|
||
if num in CRITERION_CHOICE_INDICATORS:
|
||
_v = e_choice.get().strip()
|
||
else:
|
||
_v = e_value.get().strip()
|
||
if _v:
|
||
_ci = dict(getattr(isys, 'criterion_input', None) or {})
|
||
_ci[num] = _v
|
||
isys.criterion_input = _ci
|
||
except Exception:
|
||
pass
|
||
lines = txt.split('\n')
|
||
cat_line = next((l for l in lines if l.startswith('Категория значимости:')), '')
|
||
cat = None
|
||
m = _re2.search(r'Категория значимости:\s*(I{1,3})\b', cat_line)
|
||
if m:
|
||
cat = m.group(1)
|
||
if cat:
|
||
opts = list(combo['values'])
|
||
target = next((o for o in opts if o.startswith(f"{cat} категория")), None)
|
||
if target:
|
||
var.set(target)
|
||
prefix = f"Показатель {num}. {name} — {cat} категория."
|
||
cur = justify_txt.get('1.0', 'end-1c').strip()
|
||
if prefix not in cur:
|
||
_append_justify(prefix, txt)
|
||
else:
|
||
var.set('—')
|
||
prefix = f"Показатель {num}. {name} — применим, категория не присвоена (—)."
|
||
cur = justify_txt.get('1.0', 'end-1c').strip()
|
||
if prefix not in cur:
|
||
_append_justify(prefix, txt)
|
||
w.grab_release()
|
||
w.destroy()
|
||
|
||
# Кнопки — вне скролла, всегда видны
|
||
btns = ttk.Frame(outer)
|
||
btns.pack(fill=tk.X, pady=(8, 0))
|
||
ttk.Button(btns, text="🧮 Рассчитать", command=_calc).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btns, text="✔️ Применить категорию", command=_apply).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btns, text="Отмена",
|
||
command=lambda: (w.grab_release(), w.destroy())).pack(side=tk.LEFT, padx=2)
|
||
|
||
# Захват фокуса — окно не уходит на задний план
|
||
try:
|
||
w.grab_set()
|
||
except Exception:
|
||
pass
|
||
w.lift()
|
||
|
||
rows = [] # (num, name, values{III:..,II:..,I:..}, var, applicable)
|
||
_combos = {} # num -> комбобокс (для автозаполнения)
|
||
|
||
import re as _re
|
||
for crit in criteria:
|
||
grp = ttk.LabelFrame(body, text=crit.get('criteria', ''), padding=6)
|
||
grp.pack(fill=tk.X, pady=4)
|
||
for ind in crit.get('indicators', []):
|
||
num = ind.get('num', '')
|
||
name = ind.get('name', '')
|
||
values = ind.get('values', {})
|
||
# Группа показателя: '1'→'1', '2а'→'2', '10.1'→'10', '13б'→'13'
|
||
m = _re.match(r'(\d+)', num)
|
||
grp_num = m.group(1) if m else num
|
||
applicable_here = applicable is None or grp_num in applicable
|
||
row = ttk.Frame(grp)
|
||
row.pack(fill=tk.X, pady=2)
|
||
# Номер показателя
|
||
lbl_num = ttk.Label(row, text=num, width=4, font=('Arial', 9, 'bold'))
|
||
lbl_num.pack(side=tk.LEFT)
|
||
# Текст показателя
|
||
lbl_name = ttk.Label(row, text=name, wraplength=640, justify=tk.LEFT)
|
||
lbl_name.pack(side=tk.LEFT)
|
||
# Выбор значения:
|
||
# - применимый показатель: пусто / «—» (применим, категория не присвоена) /
|
||
# «не применим» (показатель не применим) / III / II / I
|
||
# - неприменимый к сфере: только «не применим» (заблокирован, авто-обоснование из словаря)
|
||
var = tk.StringVar(value='')
|
||
full_options = ['', '—', 'не применим']
|
||
for cat in ('III', 'II', 'I'):
|
||
v = values.get(cat, '')
|
||
if v:
|
||
full_options.append(f"{cat} категория: {v}")
|
||
options = full_options
|
||
combo = ttk.Combobox(row, textvariable=var, state='readonly', width=34,
|
||
values=options)
|
||
combo.pack(side=tk.RIGHT, padx=6)
|
||
_combos[num] = combo
|
||
|
||
# Кнопка «🧮» — авторасчёт показателя:
|
||
# 8/9/10 — экономические формулы; остальные (1–7, 11–14) — по порогам ПП-127
|
||
if (grp_num in ('8', '9', '10')
|
||
or num in CRITERION_QUANT_THRESHOLDS
|
||
or num in CRITERION_CHOICE_INDICATORS):
|
||
ttk.Button(row, text='🧮', width=3,
|
||
command=lambda n=num, nm=name, v=var, c=combo:
|
||
_open_calc_dialog(n, nm, v, c)
|
||
).pack(side=tk.RIGHT, padx=(2, 0))
|
||
|
||
def _on_pick(_e, _num=num, _var=var, _name=name):
|
||
# При выборе «не применим» — автоподстановка обоснования из словаря сферы
|
||
if _var.get().strip() == 'не применим':
|
||
txt = _just_text(_num)
|
||
prefix = f"Показатель {_num}. {_name} — не применим."
|
||
if txt:
|
||
cur = justify_txt.get('1.0', 'end-1c').strip()
|
||
if prefix not in cur:
|
||
_append_justify(prefix, txt)
|
||
combo.bind('<<ComboboxSelected>>', _on_pick)
|
||
|
||
if not applicable_here:
|
||
# Показатель не применим для сферы объекта:
|
||
# в списке только «не применим», выбран автоматически и заблокирован.
|
||
# Кнопка «🔓» — разблокировать конкретный показатель (вернуть категории и «—»)
|
||
options = ['не применим']
|
||
combo.config(values=options)
|
||
var.set('не применим')
|
||
combo.config(state='disabled')
|
||
lbl_num.config(foreground='gray')
|
||
lbl_name.config(foreground='gray')
|
||
lbl_name.config(text=name + ' (не применим для сферы)')
|
||
btn_unlock = ttk.Button(row, text='🔓', width=3,
|
||
command=lambda c=combo, v=var,
|
||
ln=lbl_num, lname=lbl_name,
|
||
b=None, nm=name, fo=full_options:
|
||
_unlock(c, v, ln, lname, nm, b, fo))
|
||
btn_unlock.pack(side=tk.RIGHT, padx=(2, 0))
|
||
btn_unlock.config(command=lambda c=combo, v=var, ln=lbl_num,
|
||
lname=lbl_name, b=btn_unlock, nm=name, fo=full_options:
|
||
_unlock(c, v, ln, lname, nm, b, fo))
|
||
rows.append((num, name, values, var, applicable_here))
|
||
|
||
# Обоснование
|
||
ttk.Label(body, text="Обоснование полученных значений:", font=('Arial', 9, 'bold')).pack(
|
||
anchor=tk.W, pady=(10, 2))
|
||
justify_txt = tk.Text(body, width=110, height=5, wrap=tk.WORD)
|
||
justify_txt.pack(fill=tk.X, pady=(0, 6))
|
||
# Висячий отступ: заголовок «Показатель N. …» — первая строка без отступа,
|
||
# перенесённые строки — с отступом (чтобы продолжение не выглядело новым пунктом);
|
||
# тело расчёта — с отступом слева; между показателями — пустая строка.
|
||
justify_txt.tag_configure('just_head', lmargin1=0, lmargin2=30, spacing1=6, spacing3=2)
|
||
justify_txt.tag_configure('just_body', lmargin1=30, lmargin2=30, spacing3=4)
|
||
|
||
def _append_justify(prefix, body):
|
||
"""Добавить запись в обоснование: заголовок с висячим отступом + тело."""
|
||
cur = justify_txt.get('1.0', 'end-1c').strip()
|
||
if cur:
|
||
justify_txt.insert('end', '\n\n')
|
||
justify_txt.insert('end', str(prefix), 'just_head')
|
||
if body:
|
||
justify_txt.insert('end', '\n' + str(body), 'just_body')
|
||
|
||
if hasattr(self, 'is_criteria_justification_listbox') and self.is_criteria_justification_listbox.size():
|
||
justify_txt.insert('1.0', '\n'.join(
|
||
self.is_criteria_justification_listbox.get(i)
|
||
for i in range(self.is_criteria_justification_listbox.size())))
|
||
|
||
result_lbl = ttk.Label(body, text="Категория значимости: —", font=('Arial', 10, 'bold'))
|
||
result_lbl.pack(anchor=tk.W, pady=(6, 0))
|
||
|
||
def _selected():
|
||
"""Список выбранных: [(num, name, категория, значение)].
|
||
|
||
«не применим» возвращается как ('—', 'не применим') и не влияет на категорию,
|
||
но сохраняется в список показателей и в обоснование.
|
||
"""
|
||
out = []
|
||
for num, name, values, var, applicable_here in rows:
|
||
s = var.get().strip()
|
||
if not s:
|
||
continue
|
||
if s == '—':
|
||
out.append((num, name, '—', 'прочерк')) # применим, категория не присвоена
|
||
continue
|
||
if s == 'не применим':
|
||
out.append((num, name, '—', 'не применим'))
|
||
continue
|
||
cat = s.split(' ')[0] # III/II/I
|
||
val = s.split(': ', 1)[1] if ': ' in s else ''
|
||
out.append((num, name, cat, val))
|
||
return out
|
||
|
||
def _calc_category(selected):
|
||
"""Категория по наивысшей из выбранных."""
|
||
cats = [c for _, _, c, _ in selected]
|
||
if 'I' in cats:
|
||
return '1'
|
||
if 'II' in cats:
|
||
return '2'
|
||
if 'III' in cats:
|
||
return '3'
|
||
return 'без категории'
|
||
|
||
def _calc():
|
||
selected = _selected()
|
||
# «не применим» не влияет на категорию — считаем только реально выбранные категории
|
||
chosen = [x for x in selected if x[2] != '—']
|
||
if not chosen:
|
||
if applicable is not None and not any(a for _, _, _, _, a in rows):
|
||
result_lbl.config(text="Категория значимости: без категории (для этой сферы нет применимых показателей)")
|
||
else:
|
||
result_lbl.config(text="Категория значимости: — (ничего не выбрано)")
|
||
return
|
||
cat = _calc_category(chosen)
|
||
result_lbl.config(text=f"Категория значимости: {cat} (выбрано показателей: {len(chosen)})")
|
||
|
||
def _save():
|
||
selected = _selected()
|
||
# Заполняем список показателей (включая «—» и «не применим»)
|
||
lines = []
|
||
for num, name, cat, val in selected:
|
||
if cat == '—' and val == 'не применим':
|
||
lines.append(f"Показатель {num}. {name} — не применим")
|
||
elif cat == '—':
|
||
lines.append(f"Показатель {num}. {name} — применим, категория не присвоена (—)")
|
||
else:
|
||
lines.append(f"Показатель {num}. {name} — {cat} категория: {val}")
|
||
if hasattr(self, 'is_criteria_values_listbox'):
|
||
self.is_criteria_values_listbox.delete(0, tk.END)
|
||
for line in lines:
|
||
self.is_criteria_values_listbox.insert(tk.END, line)
|
||
# Категория значимости — только по реально выбранным категориям
|
||
chosen = [x for x in selected if x[2] != '—']
|
||
not_applicable = [x for x in selected if x[2] == '—']
|
||
cat = _calc_category(chosen)
|
||
if hasattr(self, 'is_category_var'):
|
||
self.is_category_var.set(cat)
|
||
# Обоснование: если поле пустое — сформируем автоматически
|
||
# (по формату: категория + выбранные показатели + неприменимые с обоснованиями)
|
||
j = justify_txt.get('1.0', 'end-1c').strip()
|
||
auto_parts = []
|
||
if chosen:
|
||
auto_parts.append(
|
||
"Категория значимости объекта КИИ определена в соответствии с Правилами категорирования "
|
||
"объектов критической информационной инфраструктуры РФ (ПП РФ от 08.02.2018 № 127). "
|
||
f"Объект отнесён к {cat} категории значимости. Основания: "
|
||
+ "; ".join(f"показатель {num} ({c} категория)" for num, _, c, _ in chosen) + "."
|
||
)
|
||
for num, name, _, val in not_applicable:
|
||
if val == 'не применим':
|
||
base = f"Показатель {num}. {name} — не применим"
|
||
jtxt = _just_text(num)
|
||
if jtxt:
|
||
auto_parts.append(base + ": " + jtxt)
|
||
else:
|
||
auto_parts.append(base + ".")
|
||
else:
|
||
auto_parts.append(
|
||
f"Показатель {num}. {name} — применим, категория значимости не присвоена.")
|
||
if not_applicable and not chosen:
|
||
auto_parts.insert(0, "Категория значимости: не присвоена (показатели критериев значимости неприменимы к объекту).")
|
||
auto_just = "\n".join(auto_parts) if auto_parts else ""
|
||
if not j:
|
||
j = auto_just
|
||
if hasattr(self, 'is_criteria_justification_listbox'):
|
||
self.is_criteria_justification_listbox.delete(0, tk.END)
|
||
for line in j.split('\n'):
|
||
if line.strip():
|
||
self.is_criteria_justification_listbox.insert(tk.END, line.strip())
|
||
self.log(f"📋 Критерии значимости (ПП 127) сохранены: категория {cat}, показателей: {len(chosen)}")
|
||
dialog.destroy()
|
||
|
||
def _auto_fill_criteria():
|
||
"""⚡ Автозаполнение показателей ПП 127 по данным организации/объекта (рекомендации).
|
||
|
||
- показатели, не применимые к сфере — уже «не применим» (при открытии);
|
||
- п. 8: если организация не госкорпорация/ГУП/госкомпания/стратегическое АО
|
||
→ «не применим» с обоснованием;
|
||
- п. 9: если есть прибыль и налог на прибыль → расчёт по «Рекомендациям»
|
||
ФСТЭК 2021 (КНП → ΔНП → №9 → категория), оператор вводит ΔДоходов;
|
||
- остальные применимые показатели — оставляются оператору.
|
||
"""
|
||
def _num2(s):
|
||
s = str(s or '').strip()
|
||
if not s:
|
||
return None
|
||
s = s.replace('\u00a0', ' ').replace(' ', '')\
|
||
.replace('руб', '').replace('₽', '').replace('р.', '').replace(',', '.')
|
||
try:
|
||
return float(s)
|
||
except Exception:
|
||
return None
|
||
|
||
org = ' '.join([getattr(self.model, 'full_name', '') or '',
|
||
getattr(self.model, 'short_name', '') or '']).lower()
|
||
is_special = any(k in org for k in [
|
||
'госкорпорац', 'государственная корпорация', 'гуп', 'фгуп',
|
||
'государственное унитарное предприятие', 'государственная компания',
|
||
'росатом', 'роскосмос', 'ростех', 'вэб',
|
||
'оборонно-промышленн', 'опк', 'стратегическ'])
|
||
# Показатель 8 применим и для организаций ОПК (сфера объекта) —
|
||
# «организация оборонно-промышленного комплекса» (п. 8 ПП 127)
|
||
if not is_special:
|
||
_sphere8 = (getattr(isys, 'sphere', '') or '').lower()
|
||
if any(k in _sphere8 for k in ['оборон', 'опк']):
|
||
is_special = True
|
||
filled = []
|
||
for num, name, values, var, applicable_here in rows:
|
||
if not applicable_here:
|
||
continue # уже «не применим» (заблокирован)
|
||
m = _re.match(r'(\d+)', num)
|
||
g = m.group(1) if m else num
|
||
if g == '8':
|
||
if not is_special:
|
||
var.set('не применим')
|
||
txt = _just_text(num) or ('Субъект КИИ не относится к типам предприятий, '
|
||
'указанных в п. 8 перечня показателей критериев '
|
||
'значимости объектов КИИ.')
|
||
prefix = f"Показатель {num}. {name} — не применим."
|
||
cur = justify_txt.get('1.0', 'end-1c').strip()
|
||
if prefix not in cur:
|
||
_append_justify(prefix, txt)
|
||
filled.append(f"п. {num}: не применим (тип организации)")
|
||
elif g == '9':
|
||
profit = _num2(getattr(self.model, 'profit', '') or '')
|
||
tax = _num2(getattr(self.model, 'profit_tax', '') or '')
|
||
if profit and tax:
|
||
# ΔДоходов: если есть балансовая стоимость объекта КИИ —
|
||
# подставляем её автоматически (наихудший сценарий = объект
|
||
# выходит из строя, его надо восстанавливать/покупать заново)
|
||
_bv = getattr(isys, 'balance_value', '') or ''
|
||
_bvn = _num2(_bv)
|
||
if _bvn:
|
||
dincome = _bvn
|
||
_dincome_note = (f"ΔДоходов = балансовая стоимость объекта КИИ "
|
||
f"({dincome:,.0f} руб.) — подставлено автоматически")
|
||
else:
|
||
dincome = self._ask_amount(
|
||
dialog, "Показатель 9 — ущерб бюджетам",
|
||
"Снижение уровня доходов субъекта КИИ ΔДоходов (руб.):\n"
|
||
"(наихудший сценарий компьютерной атаки на объект)")
|
||
if dincome is None:
|
||
continue
|
||
_dincome_note = f"ΔДоходов (введено оператором) = {dincome:,.0f} руб."
|
||
budget = DEFAULT_BUDGET_AVG # Бусредн (прогноз годового дохода федбюджета, усреднённый за 3 года)
|
||
knp = tax / profit
|
||
dnp = dincome * knp
|
||
if dincome > profit:
|
||
dnp = tax
|
||
# Унификация с авторасчётом: ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС
|
||
ndpi = _num2(getattr(self.model, 'ndpi', '') or '') or 0
|
||
div = _num2(getattr(self.model, 'div', '') or '') or 0
|
||
vso = _num2(getattr(self.model, 'vso', '') or '') or 0
|
||
ns = _num2(getattr(self.model, 'ns', '') or '') or 0
|
||
db = dnp + ndpi + div + vso + ns
|
||
val = db / budget * 100
|
||
if val > 0.001:
|
||
cat = 'I'
|
||
elif val > 0.0006:
|
||
cat = 'II'
|
||
elif val > 0.0003:
|
||
cat = 'III'
|
||
else:
|
||
cat = None
|
||
combo = _combos.get(num)
|
||
if cat and combo is not None:
|
||
target = next((o for o in combo['values']
|
||
if str(o).startswith(f"{cat} категория")), None)
|
||
if target:
|
||
var.set(target)
|
||
calc_txt = (
|
||
f"{_dincome_note}\n"
|
||
f"Коэффициент КНП = налог/прибыль = {tax:,.0f} / {profit:,.0f} = {knp:.4f}\n"
|
||
f"ΔНП = ΔДоходов × КНП = {dincome:,.0f} × {knp:.4f} = {dnp:,.1f} руб.\n"
|
||
f"ΔНДПИ = {ndpi:,.0f}, ΔДИВ = {div:,.0f}, ΔВСО = {vso:,.0f}, ΔНС = {ns:,.0f} руб.\n"
|
||
f"ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС = {db:,.1f} руб.\n"
|
||
f"Бусредн (прогноз годового дохода федбюджета, усреднённый за 3 года) = {budget:,.0f} руб.\n"
|
||
f"№9 = ΔБ / Бусредн × 100% = {db:,.1f} / {budget:,.0f} × 100 = {val:.6f} %\n"
|
||
f"Категория значимости: {cat or '— (не присваивается)'}"
|
||
)
|
||
prefix = f"Показатель {num}. {name} — рекомендация по расчёту:"
|
||
cur = justify_txt.get('1.0', 'end-1c').strip()
|
||
if prefix not in cur:
|
||
_append_justify(prefix, calc_txt)
|
||
filled.append(f"п. {num}: №9 = {val:.6f}% → {cat or '—'}")
|
||
# Что осталось незаполненным (применимые, без значения)
|
||
left = []
|
||
for num, name, values, var, applicable_here in rows:
|
||
if applicable_here and not var.get().strip():
|
||
left.append(num)
|
||
_calc() # пересчитать категорию сразу
|
||
messagebox.showinfo(
|
||
"⚡ Автозаполнение ПП 127",
|
||
"Заполнено автоматически (рекомендации):\n" +
|
||
("\n".join(filled) if filled else "— нет") +
|
||
(f"\n\nОсталось заполнить вручную (нет данных для расчёта): {', '.join(left)}"
|
||
if left else "\n\nВсе применимые показатели заполнены!") +
|
||
"\n\nКатегория объекта пересчитана по наивысшей из выбранных.")
|
||
|
||
|
||
btn_frame = ttk.Frame(body)
|
||
btn_frame.pack(fill=tk.X, pady=(8, 0))
|
||
ttk.Button(btn_frame, text="⚡ Заполнить автоматически (по данным)",
|
||
command=_auto_fill_criteria).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_frame, text="🧮 Рассчитать категорию", command=_calc).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_frame, text="💾 Сохранить", command=_save).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_frame, text="Отмена", command=dialog.destroy).pack(side=tk.LEFT, padx=2)
|
||
|
||
dialog.wait_window()
|
||
|
||
def _download_fstec_threats(self):
|
||
"""Скачать перечень угроз с сайта ФСТЭК (БДУ) и обновить список."""
|
||
try:
|
||
from .fstec_threats import download_fstec_threats, get_threats_date
|
||
except ImportError as e:
|
||
messagebox.showerror("Ошибка", f"Модуль fstec_threats не найден: {e}")
|
||
return
|
||
|
||
self.log("⬇️ Загрузка перечня угроз с bdu.fstec.ru…")
|
||
ok, msg, threats = download_fstec_threats()
|
||
if not ok:
|
||
messagebox.showerror("Загрузка угроз",
|
||
f"Не удалось загрузить:\n{msg}\n\n"
|
||
"Проверьте доступ к интернету или попробуйте позже.")
|
||
self.log(f"❌ Загрузка угроз: {msg}")
|
||
return
|
||
|
||
# Обновляем label даты актуализации
|
||
self._update_threats_date_label()
|
||
# Если список угроз уже открыт — можно показать диалог выбора
|
||
messagebox.showinfo("Загрузка угроз",
|
||
f"{msg}\n\nСписок сохранён. Теперь нажмите «📚 Из словаря» "
|
||
"в разделе «Основные угрозы БИ», чтобы выбрать нужные угрозы.")
|
||
self.log(f"✅ {msg}")
|
||
|
||
def _update_threats_date_label(self):
|
||
"""Обновить подпись даты актуализации списка угроз."""
|
||
try:
|
||
from .fstec_threats import get_threats_date
|
||
d = get_threats_date()
|
||
except Exception:
|
||
d = None
|
||
if hasattr(self, '_threats_date_label'):
|
||
if d:
|
||
self._threats_date_label.config(
|
||
text=f"Актуально на: {d}", foreground='#2e7d32')
|
||
else:
|
||
self._threats_date_label.config(
|
||
text="⚠ Список угроз ещё не скачан — нажмите «⬇️ Загрузить угрозы ФСТЭК»",
|
||
foreground='#c62828')
|
||
|
||
def _auto_fill_security_click(self):
|
||
"""⚡ Автозаполнение вкладки «Безопасность» по данным объекта (логическое).
|
||
|
||
Заполняет только пустые поля:
|
||
категория/уровень/виды нарушителя — по интернету и типу объекта;
|
||
угрозы — подбор программой (скоринг);
|
||
инциденты — из подобранных угроз;
|
||
орг./тех. меры — базовый набор + по средствам защиты объекта.
|
||
"""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("Автозаполнение", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
self._collect_is()
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
try:
|
||
from .threat_rules import auto_fill_security, load_threats
|
||
threats_full = load_threats()
|
||
fill = auto_fill_security(isys, threats=threats_full)
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", str(e))
|
||
return
|
||
if not fill:
|
||
messagebox.showinfo("Автозаполнение", "Все поля безопасности уже заполнены.")
|
||
return
|
||
|
||
# Применяем к виджетам
|
||
if 'attacker_category' in fill and hasattr(self, 'is_attacker_category_combo'):
|
||
self.is_attacker_category_combo.set(fill['attacker_category'])
|
||
if 'attacker_level' in fill and hasattr(self, 'is_attacker_level_combo'):
|
||
self.is_attacker_level_combo.set(fill['attacker_level'])
|
||
for key, attr in [('attacker_types', 'is_attacker_types_listbox'),
|
||
('threats', 'is_threats_listbox'),
|
||
('incidents', 'is_incidents_listbox'),
|
||
('org_measures', 'is_org_measures_listbox'),
|
||
('tech_measures', 'is_tech_measures_listbox')]:
|
||
if key in fill and hasattr(self, attr):
|
||
lb = getattr(self, attr)
|
||
lb.delete(0, tk.END)
|
||
lines = [ln.strip() for ln in fill[key].split('\n') if ln.strip()]
|
||
if key == 'threats':
|
||
from .threat_rules import sort_threats_by_ubi
|
||
lines = sort_threats_by_ubi(lines)
|
||
for line in lines:
|
||
lb.insert(tk.END, line)
|
||
|
||
self._collect_is() # сохранить в модель
|
||
filled = ', '.join({
|
||
'attacker_category': 'нарушитель',
|
||
'attacker_level': 'уровень',
|
||
'attacker_types': 'виды нарушителя',
|
||
'threats': 'угрозы',
|
||
'incidents': 'инциденты',
|
||
'org_measures': 'орг. меры',
|
||
'tech_measures': 'тех. меры',
|
||
}.get(k, k) for k in fill)
|
||
self.log(f"⚡ Автозаполнение выполнено: {filled}")
|
||
messagebox.showinfo("Автозаполнение",
|
||
f"Заполнено: {filled}.\n\nНезаполненные поля (если есть) требуют ручного ввода "
|
||
f"или данных из опросника.")
|
||
|
||
def _suggest_threats_program_click(self):
|
||
"""Подобрать угрозы логикой программы по характеристикам объекта."""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("Угрозы", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
self._collect_is()
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
try:
|
||
from .threat_rules import suggest_threats_program
|
||
threats = suggest_threats_program(isys)
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", str(e))
|
||
return
|
||
if not threats:
|
||
messagebox.showinfo("Угрозы", "Не удалось подобрать угрозы.\n"
|
||
"Скачайте перечень ФСТЭК: кнопка «⬇️ Загрузить угрозы ФСТЭК».")
|
||
return
|
||
lb = self.is_threats_listbox
|
||
lb.delete(0, tk.END)
|
||
from .threat_rules import sort_threats_by_ubi
|
||
for t in sort_threats_by_ubi(threats):
|
||
lb.insert(tk.END, t)
|
||
self._collect_is() # сохранить в модель — галочки в словаре будут стоять
|
||
self.log(f"⚙️ Угрозы подобраны программой: {len(threats)} шт.")
|
||
|
||
def _suggest_threats_ai_click(self):
|
||
"""Подобрать угрозы через ИИ по характеристикам объекта."""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("Угрозы", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
settings = load_api_settings()
|
||
if not settings.get('ai_api_key'):
|
||
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
|
||
return
|
||
self._collect_is()
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
from .threat_rules import _has_internet, load_threats
|
||
has_internet = _has_internet(isys)
|
||
data = {
|
||
'name': isys.name or '',
|
||
'description': isys.description or '',
|
||
'object_type': isys.object_type or '',
|
||
'sphere': isys.sphere or '',
|
||
'architecture': isys.architecture or '',
|
||
'net_category': isys.net_category or '',
|
||
'attacker_category': isys.attacker_category or '',
|
||
'has_internet': has_internet,
|
||
'summary': (
|
||
f"Объект КИИ: {isys.name or '—'}. "
|
||
f"Тип: {isys.object_type or '—'}. "
|
||
f"Сфера: {isys.sphere or '—'}. "
|
||
f"Выход в интернет: {'есть' if has_internet else 'нет'}. "
|
||
f"Категория нарушителя: {isys.attacker_category or '—'}. "
|
||
f"Сети: {isys.net_category or '—'}"
|
||
),
|
||
}
|
||
threats_full = load_threats() # свежий перечень с сайта ФСТЭК
|
||
self.log(f"🤖 Запрос к ИИ: подбор угроз для «{isys.name}» (перечень: {len(threats_full)} угроз)")
|
||
self._verify_log("Подбор угроз через ИИ…")
|
||
|
||
def _run():
|
||
try:
|
||
from .verify import suggest_threats_ai
|
||
threats, err = suggest_threats_ai(
|
||
data, settings['ai_api_key'], settings.get('ai_model'),
|
||
settings.get('ai_base_url'), threats=threats_full)
|
||
self.root.after(0, lambda: self._suggest_threats_ai_done(threats, err))
|
||
except Exception as e:
|
||
self.root.after(0, lambda: self._suggest_threats_ai_done(None, str(e)))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _suggest_threats_ai_done(self, threats, err):
|
||
if err:
|
||
self._verify_log(f"❌ {err}")
|
||
self.log(f"❌ Подбор угроз ИИ: {err}")
|
||
return
|
||
if not threats:
|
||
self._verify_log("❌ ИИ не вернул угрозы")
|
||
return
|
||
# Запоминаем рекомендации ИИ (уже отфильтрованы по словарю УБИ) —
|
||
# не заменяем список молча, а открываем окно подбора с галочками.
|
||
self._last_ai_threats = list(threats)
|
||
self.log(f"🤖 ИИ рекомендует угроз: {len(threats)}")
|
||
self._verify_log(f"🤖 ИИ: {len(threats)} угроз (отфильтровано по словарю УБИ)")
|
||
self._open_threats_picker()
|
||
|
||
def _ask_amount(self, parent, title, prompt, initial=''):
|
||
"""Свой диалог ввода суммы: принимает «121 118 270 руб», «1 000» и т.п.
|
||
|
||
simpledialog.askfloat НЕ умеет парсить такие строки (ошибка
|
||
«not a floating-point value please try again») — поэтому свой диалог.
|
||
Возвращает float или None (отмена).
|
||
"""
|
||
def _parse(s):
|
||
s = str(s or '').strip()
|
||
if not s:
|
||
return None
|
||
s = s.replace('\u00a0', ' ').replace(' ', '')\
|
||
.replace('руб', '').replace('₽', '').replace('р.', '').replace(',', '.')
|
||
try:
|
||
return float(s)
|
||
except Exception:
|
||
return None
|
||
|
||
result = {'val': None}
|
||
dlg = tk.Toplevel(parent)
|
||
dlg.title(title)
|
||
dlg.geometry("560x200")
|
||
dlg.transient(parent)
|
||
dlg.grab_set()
|
||
ttk.Label(dlg, text=prompt, wraplength=520, justify=tk.LEFT).pack(
|
||
padx=12, pady=(12, 6), anchor=tk.W)
|
||
e = ttk.Entry(dlg, width=42)
|
||
e.insert(0, str(initial or ''))
|
||
e.pack(padx=12, pady=4, fill=tk.X)
|
||
err = ttk.Label(dlg, text="", foreground='red')
|
||
err.pack(anchor=tk.W, padx=12)
|
||
|
||
def _ok():
|
||
v = _parse(e.get())
|
||
if v is None:
|
||
err.config(text="Введите число, например: 121 118 270")
|
||
return
|
||
result['val'] = v
|
||
dlg.grab_release()
|
||
dlg.destroy()
|
||
|
||
def _cancel():
|
||
dlg.grab_release()
|
||
dlg.destroy()
|
||
|
||
btns = ttk.Frame(dlg)
|
||
btns.pack(pady=8)
|
||
ttk.Button(btns, text="ОК", command=_ok).pack(side=tk.LEFT, padx=4)
|
||
ttk.Button(btns, text="Отмена", command=_cancel).pack(side=tk.LEFT, padx=4)
|
||
e.bind('<Return>', lambda _ev: _ok())
|
||
dlg.lift()
|
||
dlg.focus_force()
|
||
parent.wait_window(dlg)
|
||
return result['val']
|
||
|
||
def _open_threats_picker(self):
|
||
"""🎯 Окно подбора угроз: программа (синий) + ИИ (красный) + совпавшие (зелёный).
|
||
|
||
Оператор отмечает галочками нужные угрозы (как в словаре) и жмёт
|
||
«✅ Применить выбранные» — список угроз заменяется выбранными.
|
||
По умолчанию отмечены все подобранные (программа + ИИ + текущие),
|
||
оператор снимает галочки с неугодных.
|
||
"""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("Угрозы", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
self._collect_is()
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
from .threat_rules import suggest_threats_program
|
||
program = suggest_threats_program(isys)
|
||
current = [str(self.is_threats_listbox.get(i)) for i in range(self.is_threats_listbox.size())]
|
||
ai = list(getattr(self, '_last_ai_threats', None) or [])
|
||
|
||
# Нормализация строк угроз: ключ сравнения — код УБИ (если есть),
|
||
# иначе — нормализованный текст.
|
||
def _ubi_code(s):
|
||
m = re.search(r'уби\.?\s*(\d+)', str(s).lower())
|
||
return m.group(1) if m else None
|
||
|
||
def _norm(s):
|
||
s = re.sub(r'^\d+[.)]?\s*', '', str(s).strip()) # ведущий номер «1. »
|
||
s = re.sub(r'\s+', ' ', s).strip() # схлопнуть пробелы
|
||
return s.lower()
|
||
|
||
def _key(s):
|
||
code = _ubi_code(s)
|
||
return ('ubi', code) if code else ('name', _norm(s))
|
||
|
||
# Объединяем все источники: key → {text, src: {program, ai, current}}
|
||
merged = {}
|
||
def _add(t, src):
|
||
if not str(t or '').strip():
|
||
return
|
||
k = _key(t)
|
||
if k not in merged:
|
||
merged[k] = {'text': str(t).strip(), 'src': set()}
|
||
merged[k]['src'].add(src)
|
||
for t in program:
|
||
_add(t, 'program')
|
||
for t in current:
|
||
_add(t, 'current')
|
||
for t in ai:
|
||
_add(t, 'ai')
|
||
|
||
items = sorted(merged.values(),
|
||
key=lambda v: (0, int(_ubi_code(v['text'])))
|
||
if _ubi_code(v['text']) else (1, _norm(v['text'])))
|
||
|
||
# Окно с чекбоксами (как выбор из словаря)
|
||
dlg = tk.Toplevel(self.root)
|
||
dlg.title(f"Подбор угроз: {isys.name or ''}")
|
||
dlg.geometry("980x700")
|
||
dlg.transient(self.root)
|
||
dlg.grab_set()
|
||
|
||
frame = ttk.Frame(dlg, padding=10)
|
||
frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
legend = ttk.Label(frame,
|
||
text="🟦 синий — рекомендация программы | 🟥 красный — рекомендация ИИ | "
|
||
"🟩 зелёный — совпало | ⬜ белый — уже было в списке",
|
||
foreground='gray')
|
||
legend.pack(anchor=tk.W, pady=(0, 5))
|
||
|
||
# Поиск
|
||
search_var = tk.StringVar()
|
||
search_entry = ttk.Entry(frame, textvariable=search_var)
|
||
search_entry.pack(fill=tk.X, pady=(0, 5))
|
||
search_entry.focus_set()
|
||
|
||
canvas = tk.Canvas(frame, highlightthickness=0)
|
||
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas.yview)
|
||
check_frame = ttk.Frame(canvas)
|
||
check_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||
canvas.create_window((0, 0), window=check_frame, 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)
|
||
|
||
vars = {}
|
||
widgets = {}
|
||
for v in items:
|
||
src = v['src']
|
||
has_p = 'program' in src
|
||
has_a = 'ai' in src
|
||
if has_p and has_a:
|
||
bg = '#c8e6c9' # зелёный — совпало
|
||
elif has_p:
|
||
bg = '#b3d4fc' # синий — только программа
|
||
elif has_a:
|
||
bg = '#ffd0d0' # красный — только ИИ
|
||
else:
|
||
bg = '#ffffff' # белый — только текущий список
|
||
var = tk.BooleanVar(value=True) # по умолчанию отмечено всё
|
||
cb = tk.Checkbutton(check_frame, text=v['text'], variable=var, anchor=tk.W,
|
||
bg=bg, activebackground=bg, selectcolor='white',
|
||
justify=tk.LEFT, wraplength=900)
|
||
cb.pack(fill=tk.X, pady=1)
|
||
vars[v['text']] = var
|
||
widgets[v['text']] = cb
|
||
|
||
def _apply_search(*_):
|
||
q = search_var.get().strip().lower()
|
||
for text, cb in widgets.items():
|
||
if q and q not in text.lower():
|
||
cb.pack_forget()
|
||
else:
|
||
cb.pack(fill=tk.X, pady=1)
|
||
search_var.trace_add('write', _apply_search)
|
||
|
||
btn_frame = ttk.Frame(dlg, padding=10)
|
||
btn_frame.pack(fill=tk.X)
|
||
|
||
def _select_all():
|
||
for var in vars.values():
|
||
var.set(True)
|
||
|
||
def _clear_all():
|
||
for var in vars.values():
|
||
var.set(False)
|
||
|
||
def _apply():
|
||
selected = [t for t, var in vars.items() if var.get()]
|
||
selected.sort(key=lambda s: (0, int(_ubi_code(s)))
|
||
if _ubi_code(s) else (1, _norm(s)))
|
||
lb = self.is_threats_listbox
|
||
lb.delete(0, tk.END)
|
||
for t in selected:
|
||
lb.insert(tk.END, t)
|
||
self._collect_is()
|
||
self.log(f"🎯 Применено угроз: {len(selected)}")
|
||
dlg.destroy()
|
||
|
||
ttk.Button(btn_frame, text="Выбрать всё", command=_select_all).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_frame, text="Снять всё", command=_clear_all).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_frame, text="✅ Применить выбранные", command=_apply).pack(side=tk.RIGHT, padx=2)
|
||
ttk.Button(btn_frame, text="Отмена", command=dlg.destroy).pack(side=tk.RIGHT, padx=2)
|
||
|
||
dlg.wait_window()
|
||
|
||
def _check_threats_ai_click(self):
|
||
"""🤖 Проверить текущий список угроз через ИИ (не подбирать заново).
|
||
|
||
Программа/оператор уже подобрали угрозы — ИИ проверяет, что лишнее,
|
||
чего не хватает, и даёт комментарий.
|
||
"""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("Угрозы", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
settings = load_api_settings()
|
||
if not settings.get('ai_api_key'):
|
||
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
|
||
return
|
||
current = [str(self.is_threats_listbox.get(i)) for i in range(self.is_threats_listbox.size())]
|
||
if not current:
|
||
messagebox.showinfo("Угрозы", "Сначала подберите угрозы (программой или ИИ).")
|
||
return
|
||
self._collect_is()
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
from .threat_rules import _has_internet
|
||
has_internet = _has_internet(isys)
|
||
data = {
|
||
'name': isys.name or '',
|
||
'description': isys.description or '',
|
||
'object_type': isys.object_type or '',
|
||
'sphere': isys.sphere or '',
|
||
'architecture': isys.architecture or '',
|
||
'net_category': isys.net_category or '',
|
||
'attacker_category': isys.attacker_category or '',
|
||
'has_internet': has_internet,
|
||
}
|
||
self.log(f"🤖 Запрос к ИИ: проверка угроз для «{isys.name}» (в списке: {len(current)})")
|
||
self._verify_log("Проверка угроз через ИИ…")
|
||
|
||
def _run():
|
||
try:
|
||
from .verify import check_threats_ai
|
||
res, err = check_threats_ai(
|
||
data, current, settings['ai_api_key'], settings.get('ai_model'),
|
||
settings.get('ai_base_url'))
|
||
self.root.after(0, lambda: self._check_threats_ai_done(res, err, isys.name))
|
||
except Exception as e:
|
||
self.root.after(0, lambda: self._check_threats_ai_done(None, str(e), isys.name))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _check_threats_ai_done(self, res, err, obj_name):
|
||
"""Показ результата проверки угроз ИИ."""
|
||
if err:
|
||
self.log(f"❌ Проверка угроз: {err}")
|
||
self._verify_log(f"❌ {err}")
|
||
return
|
||
if not isinstance(res, dict):
|
||
self.log("❌ ИИ не вернул результат проверки")
|
||
return
|
||
extra = res.get('лишние') or []
|
||
missing = res.get('недостающие') or []
|
||
comment = res.get('комментарий') or ''
|
||
if isinstance(extra, str):
|
||
extra = [extra]
|
||
if isinstance(missing, str):
|
||
missing = [missing]
|
||
lines = [f"🤖 Проверка угроз для «{obj_name}»:"]
|
||
lines.append(f"Всего в списке: {self.is_threats_listbox.size()}")
|
||
if extra:
|
||
lines.append(f"\n❌ Возможно ЛИШНИЕ ({len(extra)}):")
|
||
for t in extra:
|
||
lines.append(f" • {t}")
|
||
else:
|
||
lines.append("\n❌ Лишних не найдено")
|
||
if missing:
|
||
lines.append(f"\n➕ Возможно НЕ ХВАТАЕТ ({len(missing)}):")
|
||
for t in missing:
|
||
lines.append(f" • {t}")
|
||
else:
|
||
lines.append("\n➕ Недостающих не найдено")
|
||
if comment:
|
||
lines.append(f"\n💬 Комментарий: {comment}")
|
||
lines.append("\nРекомендации ИИ — оператор принимает решение.")
|
||
|
||
dlg = tk.Toplevel(self.root)
|
||
dlg.title(f"Проверка угроз ИИ — {obj_name}")
|
||
dlg.geometry("860x600")
|
||
dlg.transient(self.root)
|
||
txt = tk.Text(dlg, wrap=tk.WORD, font=('Arial', 10))
|
||
txt.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)
|
||
txt.insert('1.0', '\n'.join(lines))
|
||
txt.config(state=tk.DISABLED)
|
||
|
||
def _ubi_code(s):
|
||
m = re.search(r'уби\.?\s*(\d+)', str(s).lower())
|
||
return m.group(1) if m else None
|
||
|
||
def _add_missing():
|
||
"""Добавить недостающие (только из словаря УБИ, полные названия)."""
|
||
lb = self.is_threats_listbox
|
||
existing = {(_ubi_code(lb.get(i)) or lb.get(i).strip().lower())
|
||
for i in range(lb.size())}
|
||
added = 0
|
||
for t in missing:
|
||
code = _ubi_code(t)
|
||
key = code or str(t).strip().lower()
|
||
if key in existing:
|
||
continue
|
||
lb.insert(tk.END, t)
|
||
existing.add(key)
|
||
added += 1
|
||
if added:
|
||
# сортируем по возрастанию кода УБИ
|
||
from .threat_rules import sort_threats_by_ubi
|
||
items = [str(lb.get(i)) for i in range(lb.size())]
|
||
lb.delete(0, tk.END)
|
||
for t in sort_threats_by_ubi(items):
|
||
lb.insert(tk.END, t)
|
||
self._collect_is()
|
||
self.log(f"➕ Добавлено недостающих угроз (по словарю): {added}")
|
||
else:
|
||
self.log("➕ Недостающие уже есть в списке — ничего не добавлено")
|
||
dlg.destroy()
|
||
|
||
def _remove_extra():
|
||
"""Убрать лишние (только те, что реально есть в списке, по коду УБИ)."""
|
||
lb = self.is_threats_listbox
|
||
extra_codes = {c for c in (_ubi_code(t) for t in extra) if c}
|
||
removed = 0
|
||
for i in range(lb.size() - 1, -1, -1):
|
||
code = _ubi_code(lb.get(i))
|
||
if code and code in extra_codes:
|
||
lb.delete(i)
|
||
removed += 1
|
||
if removed:
|
||
self._collect_is()
|
||
self.log(f"➖ Убрано лишних угроз: {removed}")
|
||
else:
|
||
self.log("➖ Лишние не найдены в списке — ничего не убрано")
|
||
dlg.destroy()
|
||
|
||
btns = ttk.Frame(dlg)
|
||
btns.pack(pady=(0, 8))
|
||
if missing:
|
||
ttk.Button(btns, text="➕ Добавить недостающие (по словарю)",
|
||
command=_add_missing).pack(side=tk.LEFT, padx=4)
|
||
if extra:
|
||
ttk.Button(btns, text="➖ Убрать лишние",
|
||
command=_remove_extra).pack(side=tk.LEFT, padx=4)
|
||
ttk.Button(btns, text="Закрыть",
|
||
command=dlg.destroy).pack(side=tk.LEFT, padx=4)
|
||
self.log(f"🤖 Проверка угроз ИИ: лишних {len(extra)}, недостающих {len(missing)}")
|
||
self._verify_log('\n'.join(lines))
|
||
|
||
def _check_security_ai(self):
|
||
"""Проверка вкладки «Безопасность» через ИИ (DeepSeek/OpenAI-совместимый API)."""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("Безопасность", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
settings = load_api_settings()
|
||
if not settings.get('ai_api_key'):
|
||
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
|
||
return
|
||
|
||
# Собираем текущие данные безопасности объекта
|
||
self._collect_is()
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
security_data = {
|
||
'name': isys.name or '',
|
||
'description': isys.description or '',
|
||
'category': self._norm_category(isys.category) or '',
|
||
'sphere': isys.sphere or '',
|
||
'object_type': isys.object_type or '',
|
||
'architecture': isys.architecture or '',
|
||
'attacker_category': isys.attacker_category or '',
|
||
'threats': isys.threats or '',
|
||
'incidents': isys.incidents or '',
|
||
'criteria_values': isys.criteria_values or '',
|
||
'criteria_justification': isys.criteria_justification or '',
|
||
'org_measures': isys.org_measures or '',
|
||
'tech_measures': isys.tech_measures or '',
|
||
}
|
||
self.log(f"🤖 Запрос к ИИ ({settings.get('ai_model') or 'deepseek-chat'}): проверка безопасности")
|
||
self._verify_log("Проверка безопасности через ИИ…")
|
||
|
||
def _run():
|
||
try:
|
||
from .verify import check_security_ai
|
||
result, err = check_security_ai(
|
||
security_data,
|
||
settings['ai_api_key'],
|
||
settings.get('ai_model'),
|
||
settings.get('ai_base_url'),
|
||
)
|
||
self.root.after(0, lambda: self._security_ai_done(result, err))
|
||
except Exception as e:
|
||
self.root.after(0, lambda: self._security_ai_done(None, str(e)))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _security_ai_done(self, result, err):
|
||
"""Обработка ответа ИИ по безопасности."""
|
||
if err:
|
||
self._verify_log(f"❌ {err}")
|
||
self.log(f"❌ Проверка безопасности: {err}")
|
||
return
|
||
d = result['data']
|
||
notes = d.get('notes') or []
|
||
props = d.get('proposals') or {}
|
||
self._verify_log(f"🤖 ИИ: оценка {d.get('rating', 'warning')}, замечаний: {len(notes)}")
|
||
self.log(f"🤖 ИИ: оценка {d.get('rating', 'warning')}, замечаний: {len(notes)}")
|
||
self._show_security_ai_result(notes, props, d.get('rating', 'warning'), result.get('source', 'ИИ'))
|
||
|
||
def _show_security_ai_result(self, lines, proposals=None, rating='', source='ИИ'):
|
||
"""Окно с результатом ИИ-проверки безопасности.
|
||
|
||
Если переданы proposals — показываем каждый раздел с галочкой
|
||
и кнопкой «Применить отмеченное» (применение по галочкам, ИИ может ошибаться).
|
||
Иначе — просто текстовый результат (для остальных вызовов)."""
|
||
try:
|
||
if proposals is None:
|
||
win = tk.Toplevel(self.root)
|
||
win.title("🤖 Проверка безопасности — результат ИИ")
|
||
win.geometry("760x560")
|
||
win.transient(self.root)
|
||
txt = scrolledtext.ScrolledText(win, wrap=tk.WORD, font=('', 10), state=tk.NORMAL)
|
||
txt.pack(fill=tk.BOTH, expand=True, padx=10, pady=(10, 4))
|
||
txt.insert('1.0', '\n'.join(lines))
|
||
txt.config(state=tk.DISABLED)
|
||
ttk.Button(win, text="Закрыть", command=win.destroy).pack(pady=(0, 10))
|
||
win.grab_set()
|
||
return
|
||
|
||
# Режим с галочками: предложения ИИ по разделам безопасности
|
||
win = tk.Toplevel(self.root)
|
||
win.title("🤖 Проверка безопасности — предложения ИИ")
|
||
win.geometry("960x760")
|
||
win.transient(self.root)
|
||
frm = ttk.Frame(win, padding=8)
|
||
frm.pack(fill=tk.BOTH, expand=True)
|
||
|
||
header = f"Оценка ИИ: {rating}. Отметьте галочками, что применить в объект (ИИ может ошибаться — проверяйте)."
|
||
ttk.Label(frm, text=header, font=('Arial', 10, 'bold'),
|
||
wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
|
||
|
||
if notes_lines := [l for l in (lines or []) if l]:
|
||
ttk.Label(frm, text="Замечания:", font=('', 9, 'bold')).pack(anchor=tk.W, pady=(6, 0))
|
||
t_notes = scrolledtext.ScrolledText(frm, height=4, wrap=tk.WORD, font=('', 9))
|
||
t_notes.pack(fill=tk.X)
|
||
t_notes.insert('1.0', '\n'.join(notes_lines))
|
||
t_notes.config(state=tk.DISABLED)
|
||
|
||
sections = [
|
||
('attacker_category', 'Нарушитель (категория/уровень)'),
|
||
('threats', 'Угрозы'),
|
||
('incidents', 'Инциденты'),
|
||
('criteria_values', 'Показатели критериев'),
|
||
('criteria_justification', 'Обоснование значений'),
|
||
('org_measures', 'Орг. меры'),
|
||
('tech_measures', 'Тех. меры'),
|
||
]
|
||
entries, checks = {}, {}
|
||
for key, label in sections:
|
||
v = proposals.get(key)
|
||
if not v:
|
||
continue
|
||
row = ttk.Frame(frm)
|
||
row.pack(fill=tk.X, pady=(4, 0))
|
||
var = tk.BooleanVar(value=True)
|
||
ttk.Checkbutton(row, text=f"{label}:", variable=var).pack(side=tk.LEFT)
|
||
checks[key] = var
|
||
e = scrolledtext.ScrolledText(frm, height=2, wrap=tk.WORD, font=('', 9))
|
||
e.pack(fill=tk.X)
|
||
e.insert('1.0', v)
|
||
entries[key] = e
|
||
|
||
btn_row = ttk.Frame(frm)
|
||
btn_row.pack(fill=tk.X, pady=(8, 0))
|
||
ttk.Button(btn_row, text="✔️ Применить отмеченное",
|
||
command=lambda: self._apply_security_ai_proposals(entries, checks, win)).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_row, text="Отмена", command=win.destroy).pack(side=tk.LEFT, padx=2)
|
||
win.grab_set()
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_security_ai_proposals(self, entries, checks, win):
|
||
"""Применить отмеченные предложения ИИ к полям безопасности объекта."""
|
||
def _wanted(key):
|
||
return checks.get(key, tk.BooleanVar(value=True)).get()
|
||
|
||
def _text(key):
|
||
e = entries.get(key)
|
||
return e.get('1.0', 'end-1c').strip() if e else ''
|
||
|
||
def _fill_listbox(lb, text, sort_ubi=False):
|
||
if not lb:
|
||
return
|
||
lb.delete(0, tk.END)
|
||
import re as _re3
|
||
lines = []
|
||
for line in _re3.split(r'[\n,;]+', text or ''):
|
||
line = line.strip().lstrip('•-–— ')
|
||
if line:
|
||
lines.append(line)
|
||
if sort_ubi:
|
||
from .threat_rules import sort_threats_by_ubi
|
||
lines = sort_threats_by_ubi(lines)
|
||
for line in lines:
|
||
lb.insert(tk.END, line)
|
||
|
||
try:
|
||
if _wanted('attacker_category'):
|
||
val = _text('attacker_category')
|
||
if val and hasattr(self, 'is_attacker_category_combo'):
|
||
values = list(self.is_attacker_category_combo['values'])
|
||
exact = next((v for v in values if v.lower() == val.lower()), None)
|
||
self.is_attacker_category_combo.set(exact or val)
|
||
if _wanted('threats'):
|
||
_fill_listbox(getattr(self, 'is_threats_listbox', None), _text('threats'), sort_ubi=True)
|
||
if _wanted('incidents'):
|
||
_fill_listbox(getattr(self, 'is_incidents_listbox', None), _text('incidents'))
|
||
if _wanted('criteria_values'):
|
||
_fill_listbox(getattr(self, 'is_criteria_values_listbox', None), _text('criteria_values'))
|
||
if _wanted('criteria_justification'):
|
||
_fill_listbox(getattr(self, 'is_criteria_justification_listbox', None), _text('criteria_justification'))
|
||
if _wanted('org_measures'):
|
||
_fill_listbox(getattr(self, 'is_org_measures_listbox', None), _text('org_measures'))
|
||
if _wanted('tech_measures'):
|
||
_fill_listbox(getattr(self, 'is_tech_measures_listbox', None), _text('tech_measures'))
|
||
self._collect_is()
|
||
self.log("🤖 Предложения ИИ по безопасности применены (по галочкам)")
|
||
win.destroy()
|
||
messagebox.showinfo("Безопасность", "Отмеченные предложения ИИ применены к объекту.\nПроверьте результат и при необходимости поправьте вручную.")
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", f"Не удалось применить: {e}")
|
||
|
||
def _fill_subject_from_company(self):
|
||
"""Заполнить поля субъекта данными организации (если объект в той же организации)."""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("Субъект", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
c = self.model
|
||
if not (c.full_name or c.inn):
|
||
messagebox.showinfo("Субъект",
|
||
"Вкладка «Организация» пуста.\nСначала заполните данные организации "
|
||
"(или импортируйте опросник).")
|
||
return
|
||
|
||
def _set_entry(attr, value):
|
||
w = getattr(self, attr, None)
|
||
if w is not None:
|
||
w.delete(0, tk.END)
|
||
w.insert(0, value or '')
|
||
|
||
_set_entry('is_subject_name_entry', c.full_name or c.short_name)
|
||
_set_entry('is_subject_address_entry', c.address)
|
||
_set_entry('is_subject_inn_entry', c.inn)
|
||
_set_entry('is_subject_kpp_entry', c.kpp)
|
||
# Элемент (компонент) объекта — оставляем как есть, заполняется вручную
|
||
self.log("📋 Субъект заполнен данными организации")
|
||
messagebox.showinfo("Субъект",
|
||
"Поля «Наименование», «Адрес», «ИНН», «КПП» заполнены из вкладки «Организация».\n"
|
||
"«Элемент (компонент) объекта» заполните вручную.")
|
||
|
||
def _select_users_from_employees(self):
|
||
"""Выбор пользователей ИС из списка сотрудников организации (вкладка «Пользователи»)."""
|
||
names = []
|
||
for emp in self.model.employees_access:
|
||
fio = (emp.get('fio') or '').strip()
|
||
if fio and fio not in names:
|
||
names.append(fio)
|
||
# Дополнительно: пользователи из других ИС
|
||
for isys in self.model.information_systems:
|
||
for u in (isys.users_list or []):
|
||
u = u.strip()
|
||
if u and u not in names:
|
||
names.append(u)
|
||
if not names:
|
||
messagebox.showinfo(
|
||
"Выбор пользователей",
|
||
"Список пуст.\nЗаполните вкладку «Пользователи» (сотрудники с доступом к объектам КИИ) "
|
||
"или добавьте пользователей в другой ИС."
|
||
)
|
||
return
|
||
self._select_from_dialog("Выбор пользователей ИС", names, self.users_listbox)
|
||
|
||
def _del_user_from_is(self):
|
||
sel = self.users_listbox.curselection()
|
||
if sel:
|
||
self.users_listbox.delete(sel[0])
|
||
|
||
def _del_from_listbox(self, listbox):
|
||
"""Удаление выбранного элемента из любого listbox."""
|
||
sel = listbox.curselection()
|
||
if sel:
|
||
listbox.delete(sel[0])
|
||
|
||
# ==================== ВЫБОР ИЗ СПИСКА (словари) ====================
|
||
def _select_from_dialog(self, title: str, items: list, listbox, allow_refine=False,
|
||
display_map=None):
|
||
"""Открывает окно с чекбоксами для выбора элементов из списка.
|
||
|
||
allow_refine=True — показывать кнопку «✏️ Уточнить модель/кол-во».
|
||
display_map — dict {элемент: текст_для_отображения} (пометки, например «★»),
|
||
при применении сохраняется сам элемент (без пометки).
|
||
"""
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title(title)
|
||
dialog.geometry("720x680")
|
||
dialog.transient(self.root)
|
||
dialog.grab_set()
|
||
|
||
frame = ttk.Frame(dialog, padding=10)
|
||
frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
ttk.Label(frame, text=title, font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=(0, 5))
|
||
|
||
# Поиск (важно для большого перечня угроз ФСТЭК)
|
||
search_var = tk.StringVar()
|
||
search_entry = ttk.Entry(frame, textvariable=search_var)
|
||
search_entry.pack(fill=tk.X, pady=(0, 5))
|
||
search_entry.insert(0, "")
|
||
search_entry.focus_set()
|
||
|
||
# Уже выбранные элементы
|
||
existing = set(listbox.get(0, tk.END))
|
||
|
||
canvas = tk.Canvas(frame, highlightthickness=0)
|
||
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas.yview)
|
||
check_frame = ttk.Frame(canvas)
|
||
check_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||
canvas.create_window((0, 0), window=check_frame, 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)
|
||
|
||
vars = {}
|
||
items_map = {}
|
||
for item in items:
|
||
var = tk.BooleanVar(value=item in existing)
|
||
label = (display_map or {}).get(item, item)
|
||
cb = ttk.Checkbutton(check_frame, text=label, variable=var)
|
||
cb.pack(anchor=tk.W, pady=1)
|
||
vars[item] = var
|
||
items_map[item] = (var, cb)
|
||
if display_map:
|
||
ttk.Label(check_frame, text="★ — код подходит под 187-ФЗ (из Распоряжения ПРФ № 360-р)",
|
||
foreground='#1a6e1a').pack(anchor=tk.W, pady=(4, 0))
|
||
|
||
# Фильтр по поиску: скрываем неподходящие чекбоксы
|
||
def _apply_search(*_):
|
||
q = search_var.get().strip().lower()
|
||
for item, (var, cb) in items_map.items():
|
||
if q and q not in item.lower():
|
||
cb.pack_forget()
|
||
else:
|
||
cb.pack(anchor=tk.W, pady=1)
|
||
|
||
search_var.trace_add('write', _apply_search)
|
||
|
||
btn_frame = ttk.Frame(dialog, padding=10)
|
||
btn_frame.pack(fill=tk.X)
|
||
|
||
def _apply():
|
||
selected = [item for item, var in vars.items() if var.get()]
|
||
listbox.delete(0, tk.END)
|
||
for item in selected:
|
||
listbox.insert(tk.END, item)
|
||
dialog.destroy()
|
||
|
||
def _select_all():
|
||
for var in vars.values():
|
||
var.set(True)
|
||
|
||
def _clear_all():
|
||
for var in vars.values():
|
||
var.set(False)
|
||
|
||
def _refine_selected():
|
||
"""Для отмеченных позиций запросить модель/версию и количество."""
|
||
for item, (var, cb) in list(items_map.items()):
|
||
if not var.get():
|
||
continue
|
||
new_text = self._refine_dict_item_dialog(item)
|
||
if new_text is None or new_text == item:
|
||
continue
|
||
# Обновляем текст чекбокса и ключ в словаре
|
||
cb.config(text=new_text)
|
||
vars.pop(item, None)
|
||
items_map.pop(item, None)
|
||
vars[new_text] = var
|
||
items_map[new_text] = (var, cb)
|
||
|
||
if allow_refine:
|
||
ttk.Button(btn_frame, text="✏️ Уточнить модель/кол-во",
|
||
command=_refine_selected).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_frame, text="Выбрать всё", command=_select_all).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_frame, text="Снять всё", command=_clear_all).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(btn_frame, text="✅ Применить", command=_apply).pack(side=tk.RIGHT, padx=2)
|
||
|
||
dialog.wait_window()
|
||
|
||
def _refine_dict_item_dialog(self, base_item):
|
||
"""Окно уточнения модели и количества для позиции из словаря.
|
||
|
||
Возвращает полную строку вида «Windows 10 Pro 5 шт.» или
|
||
«Сервер Supermicro SSG-2027R -E1R24N 2 шт.» или None.
|
||
"""
|
||
import re
|
||
# Убираем из базы маркер «(уточнить …)»
|
||
base = re.sub(r'\s*\([^)]*уточн[^)]*\)', '', base_item, flags=re.I).strip() or base_item
|
||
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title("Уточнить модель и количество")
|
||
dialog.geometry("500x180")
|
||
dialog.transient(self.root)
|
||
dialog.grab_set()
|
||
|
||
frame = ttk.Frame(dialog, padding=10)
|
||
frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
ttk.Label(frame, text=f"Позиция: {base}", wraplength=470,
|
||
font=('Arial', 9, 'bold')).pack(anchor=tk.W, pady=(0, 8))
|
||
|
||
ttk.Label(frame, text="Модель / версия / конфигурация (необязательно):").pack(anchor=tk.W)
|
||
model_entry = ttk.Entry(frame, width=60)
|
||
model_entry.pack(fill=tk.X, pady=(2, 6))
|
||
model_entry.focus_set()
|
||
|
||
ttk.Label(frame, text="Количество:").pack(anchor=tk.W)
|
||
count_entry = ttk.Entry(frame, width=20)
|
||
count_entry.pack(anchor=tk.W, pady=(2, 6))
|
||
|
||
result = {}
|
||
|
||
def _ok():
|
||
model = model_entry.get().strip()
|
||
count = count_entry.get().strip()
|
||
text = base
|
||
if model:
|
||
text = f"{text} {model}"
|
||
if count:
|
||
text = f"{text} {count} шт."
|
||
result['val'] = text
|
||
dialog.destroy()
|
||
|
||
def _cancel():
|
||
result['val'] = None
|
||
dialog.destroy()
|
||
|
||
btn_frame = ttk.Frame(frame)
|
||
btn_frame.pack(fill=tk.X, pady=(6, 0))
|
||
ttk.Button(btn_frame, text="✅ ОК", command=_ok).pack(side=tk.RIGHT, padx=2)
|
||
ttk.Button(btn_frame, text="Отмена", command=_cancel).pack(side=tk.RIGHT, padx=2)
|
||
|
||
dialog.bind('<Return>', lambda e: _ok())
|
||
dialog.bind('<Escape>', lambda e: _cancel())
|
||
dialog.wait_window()
|
||
return result.get('val')
|
||
|
||
def _suggest_defense_ai(self):
|
||
"""🤖 Рекомендации ИИ по средствам защиты (чего не хватает для категории)."""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("СЗИ", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
settings = load_api_settings()
|
||
if not settings.get('ai_api_key'):
|
||
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
|
||
return
|
||
self._collect_is()
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
data = {
|
||
'name': isys.name or '',
|
||
'category': isys.category or '',
|
||
'sphere': isys.sphere or '',
|
||
'object_type': isys.object_type or '',
|
||
'description': isys.description or '',
|
||
'architecture': isys.architecture or '',
|
||
'hw_sw': isys.hw_sw or '',
|
||
'os_sw': isys.os_sw or '',
|
||
'app_sw': isys.app_sw or '',
|
||
'net_category': isys.net_category or '',
|
||
'is_internet': bool(getattr(isys, 'is_internet', False)),
|
||
'threats': isys.threats or '',
|
||
'attacker_category': isys.attacker_category or '',
|
||
'defense_tools': list(self.defense_tools_listbox.get(0, tk.END)) or [],
|
||
}
|
||
self._verify_log("🤖 Запрос к ИИ: рекомендации по СЗИ…")
|
||
self.log(f"🤖 Запрос к ИИ: СЗИ для «{isys.name}» (категория {isys.category or '—'})")
|
||
|
||
def _run():
|
||
try:
|
||
from .verify import suggest_defense_ai
|
||
res, err = suggest_defense_ai(
|
||
data, settings['ai_api_key'], settings.get('ai_model'),
|
||
settings.get('ai_base_url'))
|
||
self.root.after(0, lambda: self._suggest_defense_ai_done(res, err))
|
||
except Exception as e:
|
||
self.root.after(0, lambda: self._suggest_defense_ai_done(None, str(e)))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _suggest_defense_ai_done(self, res, err):
|
||
if err:
|
||
self._verify_log(f"❌ {err}")
|
||
self.log(f"❌ Рекомендации ИИ по СЗИ: {err}")
|
||
return
|
||
if not res:
|
||
self._verify_log("❌ ИИ не вернул рекомендации")
|
||
return
|
||
data = res.get('data', {})
|
||
lines = [f"🤖 Рекомендации ИИ по СЗИ (категория: {self.model.information_systems[self._current_is_idx].category or '—'})", ""]
|
||
recs = data.get('рекомендации') or []
|
||
if recs:
|
||
lines.append("Чего не хватает:")
|
||
for r in recs:
|
||
if isinstance(r, dict):
|
||
lines.append(f"• {r.get('сзи', '')} — {r.get('зачем', '')}")
|
||
else:
|
||
lines.append(f"• {r}")
|
||
else:
|
||
lines.append("ИИ не указал конкретных средств.")
|
||
tips = data.get('общие_советы') or []
|
||
if tips:
|
||
lines.append("")
|
||
lines.append("Общие советы:")
|
||
for t in tips:
|
||
lines.append(f"• {t}")
|
||
self._show_security_ai_result(lines)
|
||
self._verify_log("🤖 Рекомендации ИИ по СЗИ получены")
|
||
self.log(f"🤖 Рекомендации ИИ по СЗИ: {len(recs)} позиций")
|
||
|
||
def _suggest_defense_ai(self):
|
||
"""🤖 Рекомендации ИИ по средствам защиты (чего не хватает для категории)."""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("СЗИ", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
settings = load_api_settings()
|
||
if not settings.get('ai_api_key'):
|
||
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
|
||
return
|
||
self._collect_is()
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
data = {
|
||
'name': isys.name or '',
|
||
'category': isys.category or '',
|
||
'sphere': isys.sphere or '',
|
||
'object_type': isys.object_type or '',
|
||
'description': isys.description or '',
|
||
'architecture': isys.architecture or '',
|
||
'hw_sw': isys.hw_sw or '',
|
||
'os_sw': isys.os_sw or '',
|
||
'app_sw': isys.app_sw or '',
|
||
'net_category': isys.net_category or '',
|
||
'is_internet': bool(getattr(isys, 'is_internet', False)),
|
||
'threats': isys.threats or '',
|
||
'attacker_category': isys.attacker_category or '',
|
||
'defense_tools': list(self.defense_tools_listbox.get(0, tk.END)) or [],
|
||
}
|
||
self._verify_log("🤖 Запрос к ИИ: рекомендации по СЗИ…")
|
||
self.log(f"🤖 Запрос к ИИ: СЗИ для «{isys.name}» (категория {isys.category or '—'})")
|
||
|
||
def _run():
|
||
try:
|
||
from .verify import suggest_defense_ai
|
||
res, err = suggest_defense_ai(
|
||
data, settings['ai_api_key'], settings.get('ai_model'),
|
||
settings.get('ai_base_url'))
|
||
self.root.after(0, lambda: self._suggest_defense_ai_done(res, err))
|
||
except Exception as e:
|
||
self.root.after(0, lambda: self._suggest_defense_ai_done(None, str(e)))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _suggest_defense_ai_done(self, res, err):
|
||
if err:
|
||
self._verify_log(f"❌ {err}")
|
||
self.log(f"❌ Рекомендации ИИ по СЗИ: {err}")
|
||
return
|
||
if not res:
|
||
self._verify_log("❌ ИИ не вернул рекомендации")
|
||
return
|
||
data = res.get('data', {})
|
||
lines = [f"🤖 Рекомендации ИИ по СЗИ (категория: {self.model.information_systems[self._current_is_idx].category or '—'})", ""]
|
||
recs = data.get('рекомендации') or []
|
||
if recs:
|
||
lines.append("Чего не хватает:")
|
||
for r in recs:
|
||
if isinstance(r, dict):
|
||
lines.append(f"• {r.get('сзи', '')} — {r.get('зачем', '')}")
|
||
else:
|
||
lines.append(f"• {r}")
|
||
else:
|
||
lines.append("ИИ не указал конкретных средств.")
|
||
tips = data.get('общие_советы') or []
|
||
if tips:
|
||
lines.append("")
|
||
lines.append("Общие советы:")
|
||
for t in tips:
|
||
lines.append(f"• {t}")
|
||
self._show_security_ai_result(lines)
|
||
self._verify_log("🤖 Рекомендации ИИ по СЗИ получены")
|
||
self.log(f"🤖 Рекомендации ИИ по СЗИ: {len(recs)} позиций")
|
||
|
||
def _check_criteria_ai(self):
|
||
"""🤖 Проверка категорий значимости по ПП 127 через ИИ."""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("Категории", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
settings = load_api_settings()
|
||
if not settings.get('ai_api_key'):
|
||
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
|
||
return
|
||
self._collect_is()
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
data = {
|
||
'name': isys.name or '',
|
||
'description': isys.description or '',
|
||
'category': self._norm_category(isys.category) or '',
|
||
'sphere': isys.sphere or '',
|
||
'object_type': isys.object_type or '',
|
||
'criteria_values': isys.criteria_values or '',
|
||
'criteria_justification': isys.criteria_justification or '',
|
||
}
|
||
self._verify_log("🤖 Запрос к ИИ: проверка категорий по ПП 127…")
|
||
self.log(f"🤖 Запрос к ИИ: категории ПП 127 для «{isys.name}»")
|
||
|
||
def _run():
|
||
try:
|
||
from .verify import check_criteria_ai
|
||
res, err = check_criteria_ai(
|
||
data, settings['ai_api_key'], settings.get('ai_model'),
|
||
settings.get('ai_base_url'))
|
||
self.root.after(0, lambda: self._check_criteria_ai_done(res, err))
|
||
except Exception as e:
|
||
self.root.after(0, lambda: self._check_criteria_ai_done(None, str(e)))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _check_criteria_ai_done(self, res, err):
|
||
if err:
|
||
self._verify_log(f"❌ {err}")
|
||
self.log(f"❌ Проверка категорий ИИ: {err}")
|
||
return
|
||
if not res:
|
||
self._verify_log("❌ ИИ не вернул результат")
|
||
return
|
||
data = res.get('data', {})
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
lines = [f"🤖 Проверка категорий по ПП № 127 — «{isys.name}»", ""]
|
||
lines.append(f"Оценка: {data.get('rating', 'warning')}")
|
||
if data.get('final_category'):
|
||
lines.append(f"Итоговая категория (по мнению ИИ): {data['final_category']}")
|
||
notes = data.get('notes') or []
|
||
if notes:
|
||
lines.append("")
|
||
lines.append("Замечания:")
|
||
for n in notes:
|
||
if isinstance(n, dict):
|
||
lines.append(f"• Показатель {n.get('показатель', '?')} ({n.get('категория', '—')}): "
|
||
f"{n.get('комментарий', '')}")
|
||
else:
|
||
lines.append(f"• {n}")
|
||
props = data.get('proposals') or {}
|
||
if props.get('criteria_values') or props.get('criteria_justification'):
|
||
lines.append("")
|
||
lines.append("Предложения ИИ (можно применить вручную):")
|
||
if props.get('criteria_values'):
|
||
lines.append(f"Показатели: {props['criteria_values']}")
|
||
if props.get('criteria_justification'):
|
||
lines.append(f"Обоснование: {props['criteria_justification']}")
|
||
self._show_security_ai_result(lines)
|
||
self._verify_log("🤖 Проверка категорий ИИ завершена")
|
||
self.log("🤖 Проверка категорий ИИ завершена")
|
||
|
||
@staticmethod
|
||
def _norm_category(cat):
|
||
"""Нормализация категории значимости: '1'/'2'/'3' → 'I'/'II'/'III'."""
|
||
cat = (cat or '').strip()
|
||
return {'1': 'I', '2': 'II', '3': 'III'}.get(cat, cat or '')
|
||
|
||
def _check_object_partial_click(self):
|
||
"""🧩 ИИ-проверка всего объекта по частям: каждый блок — отдельный запрос,
|
||
данные и ответы ИИ пишутся во временную папку (dokogen_ai_*)."""
|
||
if self._current_is_idx is None:
|
||
messagebox.showinfo("Проверка", "Сначала выберите объект КИИ в списке слева")
|
||
return
|
||
settings = load_api_settings()
|
||
if not settings.get('ai_api_key'):
|
||
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
|
||
return
|
||
self._collect_company()
|
||
self._collect_is()
|
||
c = self.model
|
||
isys = self.model.information_systems[self._current_is_idx]
|
||
|
||
blocks = [
|
||
{'name': 'Реквизиты организации', 'prompt': 'check_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}},
|
||
{'name': 'Характеристики объекта КИИ', 'prompt': 'suggest_object_params',
|
||
'data': {'name': isys.name, 'description': isys.description, 'purpose': isys.purpose,
|
||
'sphere': isys.sphere, 'object_type': isys.object_type,
|
||
'architecture': isys.architecture, 'typical_object': isys.typical_object,
|
||
'net_category': isys.net_category, 'net_purpose': isys.net_purpose}},
|
||
{'name': 'Безопасность (нарушитель, угрозы, меры)', 'prompt': 'check_security',
|
||
'data': {'name': isys.name, 'description': isys.description,
|
||
'category': self._norm_category(isys.category),
|
||
'sphere': isys.sphere, 'attacker_category': isys.attacker_category,
|
||
'attacker_level': isys.attacker_level, 'attacker_types': isys.attacker_types,
|
||
'threats': isys.threats, 'incidents': isys.incidents,
|
||
'org_measures': isys.org_measures, 'tech_measures': isys.tech_measures}},
|
||
{'name': 'Категории значимости (ПП 127)', 'prompt': 'check_criteria',
|
||
'data': {'name': isys.name, 'description': isys.description,
|
||
'category': self._norm_category(isys.category),
|
||
'sphere': isys.sphere, 'object_type': isys.object_type,
|
||
'criteria_values': isys.criteria_values,
|
||
'criteria_justification': isys.criteria_justification}},
|
||
{'name': 'Средства защиты (СЗИ)', 'prompt': 'suggest_defense',
|
||
'data': {'name': isys.name, 'category': isys.category, 'sphere': isys.sphere,
|
||
'threats': isys.threats,
|
||
'defense_tools': list(self.defense_tools_listbox.get(0, tk.END)) or []}},
|
||
]
|
||
self._verify_log(f"🧩 ИИ-проверка объекта «{isys.name}» по частям ({len(blocks)} блоков)…")
|
||
self.log(f"🧩 ИИ-проверка объекта «{isys.name}» по частям: {len(blocks)} блоков")
|
||
|
||
def _run():
|
||
try:
|
||
from .verify import check_object_partial
|
||
res, err = check_object_partial(
|
||
blocks, settings['ai_api_key'], settings.get('ai_model'),
|
||
settings.get('ai_base_url'))
|
||
self.root.after(0, lambda: self._check_object_partial_done(res, err, isys.name))
|
||
except Exception as e:
|
||
self.root.after(0, lambda: self._check_object_partial_done(None, str(e), isys.name))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _check_object_partial_done(self, res, err, obj_name):
|
||
if err:
|
||
self._verify_log(f"❌ {err}")
|
||
self.log(f"❌ Проверка объекта по частям: {err}")
|
||
return
|
||
if not res:
|
||
self._verify_log("❌ ИИ не вернул результат")
|
||
return
|
||
lines = [f"🧩 ИИ-проверка объекта «{obj_name}» по частям", ""]
|
||
for r in res:
|
||
status = "✅" if r.get('ok') else "❌"
|
||
lines.append(f"{status} {r['name']}")
|
||
if r.get('files', {}).get('out'):
|
||
lines.append(f" ответ ИИ: {r['files']['out']}")
|
||
lines.append("")
|
||
lines.append("Полные ответы ИИ по каждому блоку — во временных файлах выше.")
|
||
lines.append("Рекомендации применяются по кнопкам разделов (по галочкам).")
|
||
self._show_security_ai_result(lines)
|
||
self._verify_log("🧩 Проверка объекта по частям завершена")
|
||
self.log("🧩 Проверка объекта по частям завершена")
|
||
|
||
def _select_from_defense_list(self):
|
||
"""Выбор СЗИ из JSON словаря."""
|
||
import json
|
||
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'defense_tools.json')
|
||
try:
|
||
with open(dict_path, 'r', encoding='utf-8') as f:
|
||
items = json.load(f)
|
||
self._select_from_dialog("Выбор средств защиты информации", items, self.defense_tools_listbox)
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}")
|
||
|
||
# ==================== ОКВЭД ====================
|
||
def _load_okved_dict(self):
|
||
"""Загружает словарь ОКВЭД (dictionaries/okved.json)."""
|
||
import json
|
||
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'okved.json')
|
||
try:
|
||
with open(dict_path, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь ОКВЭД:\n{e}")
|
||
return []
|
||
|
||
def _load_okved_187_codes(self):
|
||
"""Коды ОКВЭД, подходящие под 187-ФЗ (из Распоряжения ПРФ № 360-р)."""
|
||
import json
|
||
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', 'okved_187.json')
|
||
try:
|
||
with open(dict_path, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
return []
|
||
|
||
def _is_okved_187(self, item):
|
||
"""Подходит ли строка ОКВЭД под 187-ФЗ (код совпадает/является подкодом из 360-р)."""
|
||
import re
|
||
m = re.match(r'^(\d+(?:\.\d+)*)', item or '')
|
||
if not m:
|
||
return False
|
||
code = m.group(1)
|
||
codes = getattr(self, '_okved_187_cache', None)
|
||
if codes is None:
|
||
codes = self._load_okved_187_codes()
|
||
self._okved_187_cache = codes
|
||
for c in codes:
|
||
if code == c or code.startswith(c + '.') or c.startswith(code + '.'):
|
||
return True
|
||
return False
|
||
|
||
def _load_incomes_from_dadata(self):
|
||
"""🌐 Загрузить доходы организации за 5 лет из DaData (finance_history / finance)."""
|
||
from .api_settings import load_api_settings
|
||
settings = load_api_settings()
|
||
if not settings.get('dadata_token'):
|
||
messagebox.showwarning("DaData", "Не задан API-ключ DaData.\nМеню «Файл → Настройки»")
|
||
return
|
||
inn = self._company_entries.get('inn', None)
|
||
inn_val = (inn.get().strip() if inn else '') or getattr(self.model, 'inn', '') or ''
|
||
if not inn_val:
|
||
messagebox.showinfo("DaData", "Сначала укажите ИНН организации (вкладка «Организация»).")
|
||
return
|
||
self.log(f"🌐 Запрос доходов из DaData (ИНН {inn_val})…")
|
||
|
||
def _run():
|
||
try:
|
||
from .verify import get_company_finance_dadata
|
||
res, err = get_company_finance_dadata(inn_val, settings['dadata_token'])
|
||
self.root.after(0, lambda: self._load_incomes_done(res, err))
|
||
except Exception as e:
|
||
self.root.after(0, lambda: self._load_incomes_done(None, str(e)))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _load_incomes_done(self, res, err):
|
||
if err:
|
||
messagebox.showerror("DaData", f"Не удалось получить доходы:\n{err}")
|
||
self.log(f"❌ Доходы DaData: {err}")
|
||
return
|
||
finance = res.get('finance') or {}
|
||
history = res.get('history') or []
|
||
# Доход за последний год из finance (income/revenue)
|
||
last_income = finance.get('income') or finance.get('revenue') or None
|
||
last_year = finance.get('year') or None
|
||
|
||
# history: список {year, income, revenue, ...} — сортируем по году
|
||
rows = []
|
||
for h in history:
|
||
y = h.get('year')
|
||
inc = h.get('income') or h.get('revenue') or None
|
||
if y and inc:
|
||
rows.append((int(y), float(inc)))
|
||
if last_year and last_income:
|
||
rows.append((int(last_year), float(last_income)))
|
||
rows = sorted(set(rows), key=lambda x: x[0])
|
||
|
||
if not rows:
|
||
messagebox.showinfo(
|
||
"DaData",
|
||
f"Финансовые показатели для ИНН {res.get('inn')} в ответе DaData отсутствуют "
|
||
f"(нужен тариф DaData с финансовой отчётностью).\n"
|
||
f"Заполните доходы за 5 лет вручную.")
|
||
return
|
||
|
||
# Последние 5 лет: income_year_5 (самый свежий) ... income_year_1 (самый старый)
|
||
last5 = rows[-5:]
|
||
mapping = []
|
||
total = 0.0
|
||
for i, (y, inc) in enumerate(last5):
|
||
# y — календарный год; income_year_1 — самый старый из пяти
|
||
mapping.append((f'income_year_{i+1}', y, inc))
|
||
total += inc
|
||
# Основное поле формы — «Доход за 5 лет (суммарно)»
|
||
entry = self._company_entries.get('income_5y')
|
||
if entry:
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, f"{total:,.0f}".replace(',', ' '))
|
||
for key, y, inc in mapping:
|
||
entry = self._company_entries.get(key)
|
||
if entry:
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, f"{inc:,.0f}".replace(',', ' '))
|
||
self._collect_company()
|
||
years = ', '.join(f"{y}: {inc:,.0f} руб." for _, y, inc in mapping)
|
||
self.log(f"🌐 Доходы из DaData загружены: {years}")
|
||
messagebox.showinfo("DaData", f"Доходы загружены из DaData (сумма за 5 лет: {total:,.0f} руб.):\n{years}")
|
||
|
||
def _select_main_okved(self):
|
||
"""Выбор основного ОКВЭД из словаря (всегда один)."""
|
||
items = self._load_okved_dict()
|
||
if not items:
|
||
return
|
||
current = self._company_entries.get('okved').get().strip() if 'okved' in self._company_entries else ''
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title("Основной ОКВЭД")
|
||
dialog.geometry("500x500")
|
||
dialog.transient(self.root)
|
||
dialog.grab_set()
|
||
|
||
frame = ttk.Frame(dialog, padding=10)
|
||
frame.pack(fill=tk.BOTH, expand=True)
|
||
ttk.Label(frame, text="Выберите основной ОКВЭД (один):",
|
||
font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=(0, 5))
|
||
|
||
canvas = tk.Canvas(frame, highlightthickness=0)
|
||
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas.yview)
|
||
check_frame = ttk.Frame(canvas)
|
||
check_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||
canvas.create_window((0, 0), window=check_frame, 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)
|
||
|
||
# Радио-кнопки: основной всегда один (★ — подходит под 187-ФЗ)
|
||
var = tk.StringVar(value=current)
|
||
for item in items:
|
||
label = ('★ ' + item) if self._is_okved_187(item) else item
|
||
ttk.Radiobutton(check_frame, text=label, variable=var,
|
||
value=item).pack(anchor=tk.W, pady=1)
|
||
ttk.Label(check_frame, text="★ — код подходит под 187-ФЗ (из Распоряжения ПРФ № 360-р)",
|
||
foreground='#1a6e1a').pack(anchor=tk.W, pady=(4, 0))
|
||
|
||
btn_frame = ttk.Frame(dialog, padding=10)
|
||
btn_frame.pack(fill=tk.X)
|
||
|
||
def _apply():
|
||
val = var.get()
|
||
if val:
|
||
entry = self._company_entries.get('okved')
|
||
if entry:
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, val)
|
||
dialog.destroy()
|
||
|
||
ttk.Button(btn_frame, text="✅ Применить", command=_apply).pack(side=tk.RIGHT, padx=2)
|
||
ttk.Button(btn_frame, text="Отмена", command=dialog.destroy).pack(side=tk.RIGHT, padx=2)
|
||
dialog.wait_window()
|
||
|
||
def _select_extra_okved(self):
|
||
"""Выбор дополнительных ОКВЭД из словаря (можно несколько)."""
|
||
items = self._load_okved_dict()
|
||
if not items:
|
||
return
|
||
display_map = {item: ('★ ' + item) if self._is_okved_187(item) else item
|
||
for item in items}
|
||
self._select_from_dialog("Выбор дополнительных ОКВЭД", items, self.okved_extra_listbox,
|
||
display_map=display_map)
|
||
|
||
def _add_okved_manual(self):
|
||
"""Ручное добавление дополнительного ОКВЭД."""
|
||
item = simpledialog.askstring("Добавить ОКВЭД", "Введите код ОКВЭД:")
|
||
if item:
|
||
self.okved_extra_listbox.insert(tk.END, item.strip())
|
||
|
||
def _delete_okved_extra(self):
|
||
sel = self.okved_extra_listbox.curselection()
|
||
if sel:
|
||
self.okved_extra_listbox.delete(sel[0])
|
||
|
||
# ==================== ШАБЛОНЫ ====================
|
||
def select_templates(self):
|
||
folder = filedialog.askdirectory(title="Папка шаблонов")
|
||
if folder:
|
||
self.templates_var.set(folder)
|
||
self._on_templates_changed()
|
||
|
||
def _select_save_dir(self):
|
||
folder = filedialog.askdirectory(title="Папка для результатов")
|
||
if folder:
|
||
self.save_var.set(folder)
|
||
|
||
def _on_templates_changed(self, *_):
|
||
"""Обновить список шаблонов при смене папки."""
|
||
folder = self.templates_var.get()
|
||
self.template_listbox.delete(0, tk.END)
|
||
self._template_files = []
|
||
if folder and os.path.isdir(folder):
|
||
for f in sorted(os.listdir(folder)):
|
||
if f.endswith('.docx') and not f.endswith('_result.docx'):
|
||
self.template_listbox.insert(tk.END, f)
|
||
self._template_files.append(f)
|
||
# Выделяем все по умолчанию
|
||
self.template_listbox.selection_set(0, tk.END)
|
||
self._selected_templates = set(self._template_files)
|
||
self.log(f"Найдено шаблонов: {len(self._template_files)}")
|
||
|
||
def _on_template_select(self, event=None):
|
||
sel = self.template_listbox.curselection()
|
||
self._selected_templates = {self._template_files[i] for i in sel}
|
||
|
||
def _select_templates_all(self, select: bool):
|
||
if select:
|
||
self.template_listbox.selection_set(0, tk.END)
|
||
else:
|
||
self.template_listbox.selection_clear(0, tk.END)
|
||
self._on_template_select()
|
||
|
||
# ==================== ГЕНЕРАЦИЯ ====================
|
||
def generate_all(self):
|
||
"""Генерация всех выбранных документов."""
|
||
templates_dir = self.templates_var.get()
|
||
output_dir = self.save_var.get()
|
||
|
||
if not templates_dir or not os.path.isdir(templates_dir):
|
||
messagebox.showerror("Ошибка", "Выберите папку с шаблонами")
|
||
return
|
||
if not output_dir:
|
||
output_dir = os.path.join(
|
||
os.path.dirname(os.path.abspath(__file__)),
|
||
"Сгенерированные_документы"
|
||
)
|
||
self.save_var.set(output_dir)
|
||
|
||
selected = self._selected_templates
|
||
if not selected:
|
||
messagebox.showwarning("Предупреждение", "Не выбран ни один шаблон")
|
||
return
|
||
|
||
# Собираем данные
|
||
self._collect_company()
|
||
self._collect_commission()
|
||
self._collect_is()
|
||
self._collect_employees()
|
||
|
||
# Проверка
|
||
if not self._validate_before_generate():
|
||
return
|
||
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
|
||
# Окно прогресса
|
||
progress_win = tk.Toplevel(self.root)
|
||
progress_win.title("Генерация документов")
|
||
progress_win.geometry("350x100")
|
||
progress_win.transient(self.root)
|
||
progress_win.grab_set()
|
||
progress_win.resizable(False, False)
|
||
ttk.Label(progress_win, text="Генерация документов...",
|
||
font=('Arial', 10)).pack(pady=(15, 5))
|
||
pb = ttk.Progressbar(progress_win, mode='indeterminate', length=300)
|
||
pb.pack(pady=5)
|
||
pb.start()
|
||
|
||
def _close_progress():
|
||
try:
|
||
pb.stop()
|
||
progress_win.grab_release()
|
||
progress_win.destroy()
|
||
except Exception:
|
||
pass
|
||
|
||
# Запускаем в отдельном потоке
|
||
def _run():
|
||
try:
|
||
replacements = build_replacements(
|
||
company=self.model,
|
||
is_list=self.model.information_systems,
|
||
doc_number=self.doc_number_var.get(),
|
||
doc_date=self.doc_date_var.get(),
|
||
direction='187fz',
|
||
)
|
||
|
||
results = []
|
||
for fname in selected:
|
||
template_path = os.path.join(templates_dir, fname)
|
||
if not os.path.isfile(template_path):
|
||
self.log(f"❌ Файл не найден: {fname}")
|
||
continue
|
||
|
||
base = os.path.splitext(fname)[0]
|
||
output_path = os.path.join(output_dir, f"{base}.docx")
|
||
|
||
try:
|
||
process_template(template_path, replacements, output_path, self.log)
|
||
results.append(output_path)
|
||
except Exception as e:
|
||
self.log(f"❌ {fname}: {e}")
|
||
|
||
self.log(f"✅ Готово: {len(results)} из {len(selected)}")
|
||
self.root.after(0, _close_progress)
|
||
self.root.after(0, lambda: messagebox.showinfo(
|
||
"Готово", f"Сгенерировано {len(results)} документов\n"
|
||
f"Папка: {output_dir}"
|
||
))
|
||
except Exception as e:
|
||
self.log(f"❌ Ошибка: {e}")
|
||
self.root.after(0, _close_progress)
|
||
self.root.after(0, lambda: messagebox.showerror("Ошибка", str(e)))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _validate_before_generate(self):
|
||
warnings = []
|
||
if not self.model.full_name:
|
||
warnings.append("Полное наименование организации")
|
||
if not self.model.inn:
|
||
warnings.append("ИНН")
|
||
if not self.model.chief_fio:
|
||
warnings.append("ФИО руководителя")
|
||
if not self.model.information_systems:
|
||
warnings.append("Нет информационных систем")
|
||
if warnings:
|
||
msg = "Не заполнены:\n" + "\n".join(warnings)
|
||
return messagebox.askyesno("Внимание", msg + "\n\nПродолжить?")
|
||
return True
|
||
|
||
# ==================== СКЛОНЕНИЕ ====================
|
||
def _update_company_declensions(self):
|
||
name = self.company_name_entry.get().strip()
|
||
if not name:
|
||
return
|
||
self.model.full_name = name
|
||
self.model.update_declensions(inflect_name)
|
||
idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}
|
||
for code, entry in self.decl_entries.items():
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, getattr(self.model, f"company_name_{idx_map[code]}", ""))
|
||
self.log("✅ Склонения полного названия обновлены")
|
||
|
||
def _update_short_declensions(self):
|
||
name = self.short_name_entry.get().strip()
|
||
if not name:
|
||
return
|
||
self.model.short_name = name
|
||
self.model.update_short_declensions(inflect_name)
|
||
idx_map = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}
|
||
for code, entry in self.short_decl_entries.items():
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, getattr(self.model, f"short_name_{idx_map[code]}", ""))
|
||
self.log("✅ Склонения краткого названия обновлены")
|
||
|
||
# ==================== ИМПОРТ ====================
|
||
def select_import_settings(self):
|
||
"""Выбор файла настроек импорта (JSON-словарь)."""
|
||
from .import_settings import default_import_settings_path, ensure_default_file
|
||
path = filedialog.askopenfilename(
|
||
title="Файл настроек импорта (что и откуда брать)",
|
||
filetypes=[("Настройки импорта", "*.json")]
|
||
)
|
||
if not path:
|
||
self.log(f"Настройки импорта: по умолчанию ({default_import_settings_path()})")
|
||
self.import_settings_path = None
|
||
return
|
||
self.import_settings_path = path
|
||
self.log(f"Настройки импорта: {path}")
|
||
|
||
def import_from_survey(self):
|
||
path = filedialog.askopenfilename(
|
||
title="Выберите опросный лист (187-ФЗ)",
|
||
filetypes=[("Excel", "*.xlsx *.xls")]
|
||
)
|
||
if not path:
|
||
return
|
||
self.log(f"Импорт: {path}")
|
||
|
||
# Импорт в отдельном потоке
|
||
def _run():
|
||
try:
|
||
from .importer import import_187fz
|
||
data = import_187fz(path, self.log, settings_path=self.import_settings_path)
|
||
if data:
|
||
self._apply_imported_data(data)
|
||
except ImportError:
|
||
self.log("❌ Модуль импорта не найден")
|
||
except Exception as e:
|
||
self.log(f"❌ Ошибка импорта: {e}")
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _apply_imported_data(self, data: dict):
|
||
def _apply():
|
||
# Название организации
|
||
if data.get('fullName'):
|
||
self.company_name_entry.delete(0, tk.END)
|
||
self.company_name_entry.insert(0, data['fullName'])
|
||
self.model.full_name = data['fullName']
|
||
if data.get('shortName'):
|
||
self.short_name_entry.delete(0, tk.END)
|
||
self.short_name_entry.insert(0, data['shortName'])
|
||
self.model.short_name = data['shortName']
|
||
|
||
# Адрес и реквизиты (маппинг camelCase → snake_case)
|
||
addr_map = {
|
||
'address': 'addressLegal',
|
||
'city_name': None,
|
||
'inn': 'inn',
|
||
'kpp': 'kpp',
|
||
'ogrn': 'ogrn',
|
||
'ogrn_date': 'ogrnDate',
|
||
}
|
||
for entry_key, data_key in addr_map.items():
|
||
entry = self._company_entries.get(entry_key)
|
||
if not entry:
|
||
continue
|
||
val = ''
|
||
if data_key and data.get(data_key):
|
||
val = data[data_key]
|
||
elif data.get(entry_key):
|
||
val = data[entry_key]
|
||
if val:
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, val)
|
||
|
||
# Автоопределение города из адреса после импорта
|
||
self._on_address_changed()
|
||
|
||
# Ответы по физической безопасности (для автогенерации орг. мер)
|
||
if data.get('physicalSecurity'):
|
||
self.model.physical_security = dict(data['physicalSecurity'])
|
||
|
||
# Должностные лица (маппинг)
|
||
officials_map = {
|
||
'chief_position': 'chiefPosition',
|
||
'chief_fio': 'chiefFio',
|
||
'responsible_position': 'responsiblePosition',
|
||
'responsible_fio': 'responsibleFio',
|
||
'admin_position': 'administratorPosition',
|
||
'admin_fio': 'administratorFio',
|
||
}
|
||
for entry_key, data_keys in officials_map.items():
|
||
entry = self._official_entries.get(entry_key)
|
||
if not entry:
|
||
continue
|
||
val = ''
|
||
if isinstance(data_keys, tuple):
|
||
for dk in data_keys:
|
||
if data.get(dk):
|
||
val = data[dk]
|
||
break
|
||
elif data.get(data_keys):
|
||
val = data[data_keys]
|
||
if val:
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, val)
|
||
|
||
# Номер и дата документа
|
||
if data.get('contractNumber'):
|
||
self.doc_number_var.set(data['contractNumber'])
|
||
if data.get('contractDate'):
|
||
self.doc_date_var.set(data['contractDate'])
|
||
|
||
# Финансовые показатели (прибыль, налог)
|
||
for entry_key, data_keys in [('profit', 'profit'), ('profit_tax', 'profitTax')]:
|
||
entry = self._company_entries.get(entry_key)
|
||
if entry and data.get(data_keys):
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, data[data_keys])
|
||
|
||
# Показатель 8: доходы за 5 лет + суммарный ущерб от нарушения критических процессов
|
||
income_map = [
|
||
('income_5y', ('income5Y', 'income_5y', 'income5y')),
|
||
('income_year_1', ('incomeYear1', 'income_year_1')),
|
||
('income_year_2', ('incomeYear2', 'income_year_2')),
|
||
('income_year_3', ('incomeYear3', 'income_year_3')),
|
||
('income_year_4', ('incomeYear4', 'income_year_4')),
|
||
('income_year_5', ('incomeYear5', 'income_year_5')),
|
||
('income_loss', ('incomeLoss', 'income_loss')),
|
||
]
|
||
for entry_key, data_keys in income_map:
|
||
entry = self._company_entries.get(entry_key)
|
||
if not entry:
|
||
continue
|
||
val = None
|
||
for dk in data_keys:
|
||
if data.get(dk):
|
||
val = data[dk]
|
||
break
|
||
if val:
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, val)
|
||
|
||
# Наименование структурного подразделения по безопасности
|
||
if data.get('ispdnDepartment'):
|
||
self.model.security_department = data['ispdnDepartment']
|
||
|
||
# Доменное имя и внешний сетевой адрес
|
||
if data.get('domainName'):
|
||
entry = self._company_entries.get('domain_name')
|
||
if entry:
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, data['domainName'])
|
||
|
||
# Доменное имя и внешний сетевой адрес
|
||
if data.get('domainName'):
|
||
entry = self._company_entries.get('domain_name')
|
||
if entry:
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, data['domainName'])
|
||
|
||
# ОКВЭД (основной и дополнительные)
|
||
if data.get('okved'):
|
||
entry = self._company_entries.get('okved')
|
||
if entry:
|
||
from .verify import resolve_okved
|
||
from .api_settings import load_api_settings as _las
|
||
_tok = (_las().get('dadata_token') or '')
|
||
entry.delete(0, tk.END)
|
||
entry.insert(0, resolve_okved(data['okved'], _tok))
|
||
if data.get('okvedExtra'):
|
||
if hasattr(self, 'okved_extra_listbox'):
|
||
from .verify import resolve_okved, split_okved_extra
|
||
from .api_settings import load_api_settings as _las2
|
||
_tok2 = (_las2().get('dadata_token') or '')
|
||
self.okved_extra_listbox.delete(0, tk.END)
|
||
# Доп. ОКВЭД могут быть склеены с переносами внутри названий
|
||
# («25.93 — Производство изделий из проволоки\nцепей и пружин 32.12.1 — …») —
|
||
# нарезаем по кодам ОКВЭД, затем нормализуем каждый в «код название».
|
||
for p in split_okved_extra(data['okvedExtra']):
|
||
self.okved_extra_listbox.insert(tk.END, resolve_okved(p, _tok2))
|
||
|
||
# Объекты КИИ
|
||
if data.get('informationSystems'):
|
||
self.model.information_systems.clear()
|
||
for is_data in data['informationSystems']:
|
||
# Фильтр по сфере: оставляем только показатели, применимые к сфере объекта
|
||
# (организация заполняет в опроснике только свои; лишнее отбрасываем).
|
||
_crit_in = dict(is_data.get('criterion_input', {}) or {})
|
||
if _crit_in:
|
||
import re as _re_sphere
|
||
_appl = None
|
||
try:
|
||
import json as _json_sphere
|
||
_ap_path = os.path.join(os.path.dirname(__file__),
|
||
'dictionaries', 'criteria_applicability.json')
|
||
with open(_ap_path, encoding='utf-8') as _f_sphere:
|
||
_appl_all = _json_sphere.load(_f_sphere)
|
||
_appl = _appl_all.get(is_data.get('sphere', '') or '')
|
||
except Exception:
|
||
_appl = None
|
||
if _appl:
|
||
_filtered = {}
|
||
for _n, _v in _crit_in.items():
|
||
_m = _re_sphere.match(r'(\d+)', str(_n))
|
||
_g = _m.group(1) if _m else str(_n)
|
||
if _g in _appl:
|
||
_filtered[_n] = _v
|
||
_crit_in = _filtered
|
||
isys = InformationSystem(
|
||
name=is_data.get('name', ''),
|
||
description=is_data.get('description', ''),
|
||
is_local_network=is_data.get('is_has_lan', is_data.get('is_local_network', False)),
|
||
is_internet=is_data.get('isInternet', False),
|
||
category=is_data.get('category', ''),
|
||
defense_tools_list=is_data.get('defense_tools', []),
|
||
users_list=is_data.get('usersList', []),
|
||
purpose=is_data.get('purpose', ''),
|
||
structure=is_data.get('structure', ''),
|
||
software=is_data.get('software', ''),
|
||
sphere=is_data.get('sphere', ''),
|
||
object_type=is_data.get('object_type', ''),
|
||
architecture=is_data.get('architecture', ''),
|
||
typical_object=is_data.get('typical_object', ''),
|
||
subject_name=is_data.get('subject_name', ''),
|
||
subject_address=is_data.get('subject_address', ''),
|
||
subject_element=is_data.get('subject_element', ''),
|
||
subject_inn=is_data.get('subject_inn', ''),
|
||
subject_kpp=is_data.get('subject_kpp', ''),
|
||
net_category=is_data.get('net_category', ''),
|
||
net_operator=is_data.get('net_operator', ''),
|
||
net_purpose=is_data.get('net_purpose', ''),
|
||
net_method=is_data.get('net_method', ''),
|
||
hw_sw=is_data.get('hw_sw', ''),
|
||
os_sw=is_data.get('os_sw', ''),
|
||
app_sw=is_data.get('app_sw', ''),
|
||
balance_value=is_data.get('balanceValue', is_data.get('balance_value', '')),
|
||
attacker_category=is_data.get('attacker_category', ''),
|
||
attacker_level=is_data.get('attacker_level', ''),
|
||
attacker_types=is_data.get('attacker_types', ''),
|
||
threats=normalize_threats(is_data.get('threats', '')),
|
||
incidents=normalize_threats(is_data.get('incidents', '')),
|
||
criterion_input=_crit_in,
|
||
criteria_values=is_data.get('criteria_values', ''),
|
||
criteria_justification=is_data.get('criteria_justification', ''),
|
||
org_measures=is_data.get('org_measures', ''),
|
||
tech_measures=is_data.get('tech_measures', ''),
|
||
)
|
||
self.model.information_systems.append(isys)
|
||
self._refresh_is_listbox()
|
||
if self.model.information_systems:
|
||
self.is_listbox.selection_set(0)
|
||
self._on_is_select()
|
||
|
||
# Комиссия
|
||
if data.get('commission'):
|
||
self.model.commission.clear()
|
||
# Очистим UI
|
||
for row_frame, _, _, _ in self.commission_entries:
|
||
row_frame.destroy()
|
||
self.commission_entries.clear()
|
||
for m in data['commission']:
|
||
self.model.commission.append(
|
||
CommissionMember(
|
||
role=m.get('role', 'Член комиссии'),
|
||
position=m.get('position', ''),
|
||
fio=m.get('fio', ''),
|
||
)
|
||
)
|
||
self._add_commission_row(
|
||
m.get('role', 'Член комиссии'),
|
||
m.get('position', ''),
|
||
m.get('fio', ''),
|
||
)
|
||
|
||
# Сотрудники с доступом
|
||
if data.get('employeesAccess'):
|
||
self.model.employees_access.clear()
|
||
if hasattr(self, '_employee_entries'):
|
||
for row_frame, _, _ in self._employee_entries:
|
||
row_frame.destroy()
|
||
self._employee_entries.clear()
|
||
for emp in data['employeesAccess']:
|
||
fio = str(emp.get('fio', '')).strip()
|
||
pos = str(emp.get('position', '')).strip()
|
||
if fio.lower().startswith('формат:') or fio.lower().startswith('пример:'):
|
||
continue
|
||
if not fio and not pos:
|
||
continue
|
||
self.model.employees_access.append({
|
||
'position': pos,
|
||
'fio': fio,
|
||
})
|
||
self._add_employee_row(pos, fio)
|
||
self._update_emp_count()
|
||
|
||
# ⚡ Автозаполнение безопасности по каждому объекту (логика программы).
|
||
# После импорта опросника программа сама заполняет всё, что можно вывести
|
||
# логически; остальное оператор добивает вручную.
|
||
try:
|
||
from .threat_rules import auto_fill_security, load_threats
|
||
_threats_full = load_threats()
|
||
_filled_any = 0
|
||
for _isys in self.model.information_systems:
|
||
_fill = auto_fill_security(_isys, threats=_threats_full)
|
||
if _fill.get('attacker_category'):
|
||
_isys.attacker_category = _fill['attacker_category']
|
||
if _fill.get('attacker_level'):
|
||
_isys.attacker_level = _fill['attacker_level']
|
||
if _fill.get('attacker_types'):
|
||
_isys.attacker_types = _fill['attacker_types']
|
||
if _fill.get('threats'):
|
||
_isys.threats = _fill['threats']
|
||
if _fill.get('incidents'):
|
||
_isys.incidents = _fill['incidents']
|
||
if _fill.get('org_measures'):
|
||
_isys.org_measures = _fill['org_measures']
|
||
if _fill.get('tech_measures'):
|
||
_isys.tech_measures = _fill['tech_measures']
|
||
if _fill:
|
||
_filled_any += 1
|
||
if _filled_any:
|
||
self.log(f"⚡ Автозаполнение безопасности: обработано объектов: {_filled_any}")
|
||
# Обновляем форму выбранного объекта, если он есть
|
||
self._on_is_select()
|
||
except Exception as _e:
|
||
self.log(f"⚠ Автозаполнение безопасности: {_e}")
|
||
|
||
self.log("✅ Импорт применён")
|
||
self.root.after(0, _apply)
|
||
|
||
# ==================== ФАЙЛОВЫЕ ОПЕРАЦИИ ====================
|
||
def new_data(self):
|
||
if self.model.full_name:
|
||
if not messagebox.askyesno("Новое", "Текущие данные будут потеряны. Продолжить?"):
|
||
return
|
||
self._init_company()
|
||
self.current_file = None
|
||
self._company_to_gui()
|
||
self.log("Новые данные")
|
||
|
||
def open_data(self):
|
||
filename = filedialog.askopenfilename(filetypes=[("JSON", "*.json")])
|
||
if filename:
|
||
try:
|
||
with open(filename, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
self.model = Company.from_dict(data)
|
||
self.current_file = filename
|
||
self._company_to_gui()
|
||
self.log(f"Загружено: {filename}")
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", str(e))
|
||
|
||
def save_data(self):
|
||
if self.current_file:
|
||
self._save_to(self.current_file)
|
||
else:
|
||
self.save_as_data()
|
||
|
||
def save_as_data(self):
|
||
filename = filedialog.asksaveasfilename(
|
||
defaultextension=".json", filetypes=[("JSON", "*.json")]
|
||
)
|
||
if filename:
|
||
self._save_to(filename)
|
||
|
||
def _save_to(self, filename):
|
||
self._collect_company()
|
||
self._collect_commission()
|
||
self._collect_is()
|
||
self._collect_employees()
|
||
try:
|
||
with open(filename, 'w', encoding='utf-8') as f:
|
||
json.dump(self.model.to_dict(), f, ensure_ascii=False, indent=2)
|
||
self.current_file = filename
|
||
self.log(f"Сохранено: {filename}")
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", str(e))
|
||
|
||
# ==================== АВТОСОХРАНЕНИЕ ====================
|
||
def _start_autosave_timer(self):
|
||
self._autosave()
|
||
self._autosave_timer_id = self.root.after(120000, self._start_autosave_timer)
|
||
|
||
def _stop_autosave_timer(self):
|
||
if hasattr(self, '_autosave_timer_id') and self._autosave_timer_id:
|
||
try:
|
||
self.root.after_cancel(self._autosave_timer_id)
|
||
except:
|
||
pass
|
||
|
||
def _autosave(self):
|
||
self._collect_company()
|
||
self._collect_commission()
|
||
self._collect_is()
|
||
self._collect_employees()
|
||
try:
|
||
os.makedirs(os.path.dirname(self._autosave_path), exist_ok=True)
|
||
with open(self._autosave_path, 'w', encoding='utf-8') as f:
|
||
json.dump(self.model.to_dict(), f, ensure_ascii=False, indent=2)
|
||
except Exception:
|
||
pass
|
||
|
||
def _load_autosave(self):
|
||
try:
|
||
if not os.path.isfile(self._autosave_path):
|
||
return
|
||
mtime = os.path.getmtime(self._autosave_path)
|
||
if datetime.now().timestamp() - mtime > 86400:
|
||
return
|
||
with open(self._autosave_path, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
self.model = Company.from_dict(data)
|
||
self._company_to_gui()
|
||
self.log("📂 Данные восстановлены из автосохранения")
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_close(self):
|
||
self._stop_autosave_timer()
|
||
self._autosave()
|
||
self.root.destroy()
|
||
|
||
# ==================== КОНТЕКСТНОЕ МЕНЮ ====================
|
||
def _enable_context_menu(self):
|
||
def _show_menu(event):
|
||
widget = event.widget
|
||
if not isinstance(widget, (tk.Entry, ttk.Entry, tk.Text, tk.Listbox)):
|
||
return
|
||
menu = tk.Menu(self.root, tearoff=0)
|
||
def _copy():
|
||
try:
|
||
text = ""
|
||
if isinstance(widget, (tk.Entry, ttk.Entry)):
|
||
text = widget.selection_get() if widget.selection_present() else ""
|
||
elif isinstance(widget, tk.Text):
|
||
text = widget.selection_get() if widget.tag_ranges(tk.SEL) else ""
|
||
if text:
|
||
self.root.clipboard_clear()
|
||
self.root.clipboard_append(text)
|
||
except:
|
||
pass
|
||
def _paste():
|
||
try:
|
||
text = self.root.clipboard_get()
|
||
if not text: return
|
||
if isinstance(widget, (tk.Entry, ttk.Entry)):
|
||
try: widget.delete(tk.SEL_FIRST, tk.SEL_LAST)
|
||
except: pass
|
||
widget.insert(tk.INSERT, text)
|
||
elif isinstance(widget, tk.Text):
|
||
try:
|
||
if widget.tag_ranges(tk.SEL):
|
||
widget.delete(tk.SEL_FIRST, tk.SEL_LAST)
|
||
except: pass
|
||
widget.insert(tk.INSERT, text)
|
||
except:
|
||
pass
|
||
menu.add_command(label="Копировать", command=_copy)
|
||
menu.add_command(label="Вставить", command=_paste)
|
||
menu.add_separator()
|
||
menu.add_command(label="Выделить всё", command=lambda: widget.select_range(0, tk.END) if hasattr(widget, 'select_range') else None)
|
||
menu.tk_popup(event.x_root, event.y_root)
|
||
self.root.bind_all('<Button-3>', _show_menu, add='+')
|
||
|
||
# ==================== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ====================
|
||
def log(self, message: str):
|
||
ts = datetime.now().strftime("%H:%M:%S")
|
||
try:
|
||
self.log_text.configure(state='normal')
|
||
self.log_text.insert(tk.END, f"[{ts}] {message}\n")
|
||
self.log_text.see(tk.END)
|
||
self.log_text.configure(state='disabled')
|
||
except Exception:
|
||
pass
|
||
# Дублируем в файловый журнал
|
||
try:
|
||
with open(self._log_path, 'a', encoding='utf-8') as f:
|
||
f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}\n")
|
||
except Exception:
|
||
pass
|
||
self.status_var.set(message)
|
||
|
||
def _export_logs(self):
|
||
"""Экспорт лога в файл."""
|
||
filename = filedialog.asksaveasfilename(
|
||
title="Экспорт логов",
|
||
defaultextension=".log",
|
||
initialfile=f"dokogen_log_{datetime.now().strftime('%Y%m%d_%H%M')}.log",
|
||
filetypes=[("Лог-файл", "*.log"), ("Текстовый файл", "*.txt"), ("Все файлы", "*.*")]
|
||
)
|
||
if not filename:
|
||
return
|
||
try:
|
||
content = self.log_text.get('1.0', tk.END)
|
||
with open(filename, 'w', encoding='utf-8') as f:
|
||
f.write(content)
|
||
self.log(f"💾 Лог экспортирован: {filename}")
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", f"Не удалось экспортировать лог: {e}")
|
||
|
||
def show_about(self):
|
||
messagebox.showinfo("О программе",
|
||
"ДоКоГеНеРаТоР — 187-ФЗ\n"
|
||
"Версия 1.0\n"
|
||
"Генератор документов из шаблонов")
|
||
|
||
def show_variables(self):
|
||
"""Показ переменных шаблонов — теперь через скачивание Word-документа."""
|
||
self.download_variables_doc()
|
||
|
||
# ---------- СПРАВКА: СКАЧИВАНИЕ ИНСТРУКЦИЙ / ОПРОСНИКА ----------
|
||
def _save_resource(self, source_path, default_name, title):
|
||
"""Копирует ресурс (docx/xlsx) в выбранное пользователем место."""
|
||
if not source_path or not os.path.exists(source_path):
|
||
messagebox.showerror("Справка", f"Файл не найден:\n{source_path}")
|
||
return
|
||
filename = filedialog.asksaveasfilename(
|
||
title=title,
|
||
defaultextension=os.path.splitext(default_name)[1],
|
||
initialfile=default_name,
|
||
filetypes=[("Документ", "*.docx"), ("Все файлы", "*.*")]
|
||
)
|
||
if not filename:
|
||
return
|
||
try:
|
||
import shutil
|
||
shutil.copy(source_path, filename)
|
||
self.log(f"💾 Сохранено: {filename}")
|
||
messagebox.showinfo("Справка", f"Файл сохранён:\n{filename}")
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", f"Не удалось сохранить: {e}")
|
||
|
||
def download_user_manual(self):
|
||
from .help_docs import ensure_docs, user_manual_path
|
||
ensure_docs()
|
||
self._save_resource(user_manual_path(), "Инструкция_пользователя.docx", "Сохранить инструкцию пользователя")
|
||
|
||
def download_admin_manual(self):
|
||
from .help_docs import ensure_docs, admin_manual_path
|
||
ensure_docs()
|
||
self._save_resource(admin_manual_path(), "Инструкция_администратора.docx", "Сохранить инструкцию администратора")
|
||
|
||
def download_variables_doc(self):
|
||
from .help_docs import ensure_docs, variables_reference_path
|
||
ensure_docs()
|
||
self._save_resource(variables_reference_path(), "Переменные_шаблонов.docx", "Сохранить справочник переменных")
|
||
|
||
def download_survey(self):
|
||
from .help_docs import ensure_docs, survey_path
|
||
ensure_docs()
|
||
self._save_resource(survey_path(), "Опросный_лист_187_ФЗ.xlsx", "Сохранить пустой опросник")
|
||
|
||
|
||
def main():
|
||
"""Точка входа в приложение."""
|
||
root = tk.Tk()
|
||
app = DokoGenApp(root)
|
||
root.mainloop()
|