Compare commits

..
20 Commits
Author SHA1 Message Date
LocoAgent 691b167725 Фикс UnboundLocalError в окне авторасчёта ПП-127
Причина: внутри _calc() в ветках стояли импорты
'from .variables import CRITERION_QUANT_THRESHOLDS...' — Python считает
эти имена локальными для всей функции, а условие
'if (num in CRITERION_QUANT_THRESHOLDS ...)' в начале _calc обращалось
к ним до присваивания → UnboundLocalError при любом расчёте.

Исправление: все импорты из .variables вынесены на уровень
_open_calc_dialog (начало функции); внутренние импорты из _calc()
и дубликаты в теле окна убраны.
2026-08-05 17:03:05 +04:00
LocoAgent a7878804e4 Отраслевые ПП: применимость показателей + формулы ОПК
- criteria_applicability.json: Атомная (ПП-4: без 4, 10-10.7, 14; +6),
  Связь (ПП-402: +6, 8, 9), Оборонная (ПП-796: 1-4, 6-13.1, без 5/10/14),
  Транспорт (+8, 9, 13, 14), Наука (1, 7-9, 11, 13)
- variables.py: _calc_criterion_8_opk (U8=(C+A+X)/Rгод*100%, ПП-796 п.18-20),
  _calc_criterion_13_opk (13а: V=(1-(Vmax-Vинц)/VГОЗ)*100%, 13б: T=(1-tнорм/tненорм)*100%)
- ui.py: ОПК-поля в окне авторасчёта (C/A/X, Vmax/Vинц/VГОЗ, tнорм/tненорм),
  сохранение в criterion_input
2026-08-05 16:57:49 +04:00
LocoAgent 8e97e10714 Опросник: добавлен словарь criterion_fields (20 показателей ПП-127), импортёр распознаёт строки «Показатель N …» в листе объекта и складывает в criterion_input; фильтр по сфере (берутся только применимые показатели) 2026-08-05 15:39:06 +04:00
LocoAgent dbf8c613dd Окно авторасчёта сохраняет введённые значения показателей в criterion_input (модель) — автогенерация шаблона использует их 2026-08-05 15:28:35 +04:00
LocoAgent b2b68258fd Авторасчёт всех показателей ПП-127: поле criterion_input в модели (введённые значения из опросника/окна), автогенерация шаблона считает категории по введённым значениям (1–7, 11–14); фикс остановки расчёта (I-я категория вместо startswith('I')) 2026-08-05 15:27:24 +04:00
LocoAgent 1840f1e2d4 Авторасчёт остальных показателей ПП-127 (1–7, 11–14): количественные — ввод значения → категория по порогам (человек, тыс. чел., часы, %), качественные — выбор варианта III/II/I из справочника; кнопка «🧮» для всех показателей 2026-08-05 15:19:11 +04:00
LocoAgent e15610ba29 Удалить случайно загруженный документ (методичка Минздрава по категорированию КИИ сферы здравоохранения) — файл сохранён локально в dokogen/documents 2026-08-05 15:12:21 +04:00
kneon223557 36d2d5a87b Загрузить файлы в «/» 2026-08-05 15:02:44 +04:00
LocoAgent c90660a0f8 ПП-127 п.6: остановка расчёта по остальным показателям при присвоении I категории (в автогенерации шаблона категорирования — «расчёт не проводится») 2026-08-05 15:01:12 +04:00
LocoAgent 5bb6a2f5e9 Вкладка «Организация»: вместо 5 полей доходов по годам — одна строка «Доход за 5 лет (суммарно)» + «Снижение уровня дохода», жирный заголовок убран; миграция старых файлов (сумма по годам → income_5y), авторасчёт/автозаполнение/DaData/шаблоны обновлены 2026-08-05 14:54:31 +04:00
LocoAgent 829a1a98e3 Отображение после загрузки проекта: доп. ОКВЭД нарезаются по кодам и нормализуются (не показываются «сырыми» склеенными записями), угрозы/инциденты с переносом внутри названия склеиваются в списке 2026-08-05 14:51:35 +04:00
LocoAgent ad54d3e0e2 Импорт: доп. ОКВЭД нарезаются по кодам (переносы внутри названий больше не рвут записи), угрозы с переносом внутри названия склеиваются (УБИ.209 «памяти\r\nядра процессора» → одна строка) 2026-08-05 14:46:39 +04:00
LocoAgent 9f8bf3999d Тех. меры: только при присвоенной категории и по факту СЗИ (без СЗИ нет «Регистрации и мониторинга»/«Антивируса»); промт СЗИ: при «без категории» — базовый минимум + провести категорирование, переданы характеристики объекта (интернет/ПО/архитектура); показатель 10 и 10.1–10.7: авторасчёт по методичке ФСТЭК (операции за год / дни) и порогам ПП-127 2026-08-05 14:39:03 +04:00
LocoAgent 07bf25ebc3 Обоснование ПП-127: висячий отступ — перенесённые строки «Показатель N. …» и текст расчёта с отступом, между показателями пустая строка (продолжение больше не выглядит отдельным пунктом) 2026-08-05 14:26:51 +04:00
LocoAgent 0b3ef66125 Окно авторасчёта ПП-127: результат больше не обрезается — окно растягиваемое (980x820, minsize, resizable), поле результата выше (12 строк) и со скроллбаром (показатели 8 и 9) 2026-08-05 14:17:57 +04:00
LocoAgent 1793182735 Показатель 8: если «Снижение уровня дохода» не заполнено — оценка ущерба «доход за год × 5» (наихудший сценарий), с пометкой в обосновании и окне авторасчёта ПП-127 2026-08-05 14:16:12 +04:00
LocoAgent 52b4ee6f1a Угрозы: список сортируется по возрастанию кода УБИ (УБИ.001 → УБИ.211) во всех местах — подбор программой, ИИ, автозаполнение, импорт, добавление недостающих 2026-08-05 14:14:06 +04:00
LocoAgent c9dffdd4ca ОКВЭД: полный справочник ОКВЭД2 (2770 записей) в едином формате «код название», resolve_okved без дефиса, импорт доп. ОКВЭД без переносов; угрозы: умный подбор — исключение неиспользуемых технологий (виртуализация, облако, мобильные, АСУ ТП, грид, суперкомпьютер, большие данные, беспроводные сети, контейнеры, smart-карты, ИИ), базовые угрозы локальных сетей 2026-08-05 14:08:53 +04:00
LocoAgent 136c58c948 fix: генерация циклов — вложенные циклы (показатели значимости внутри объекта КИИ) брали данные только из item; теперь используют общий словарь loops, документ больше не пустой 2026-08-05 13:52:29 +04:00
LocoAgent 28fa7edaa8 орг. меры: словарик заменён на текст заказчика (15 пунктов), автогенерация с учётом ответов по физической безопасности из опросника (да/нет), импорт ответов в модель 2026-08-05 13:39:24 +04:00
12 changed files with 4388 additions and 448 deletions
@@ -16,20 +16,27 @@
],
"Наука": [
"1",
"11",
"13",
"7",
"8",
"9"
"9",
"11",
"13"
],
"Транспорт": [
"1",
"2",
"3",
"11"
"8",
"9",
"11",
"13",
"14"
],
"Связь": [
"4"
"4",
"6",
"8",
"9"
],
"Энергетика": [
"1",
@@ -85,20 +92,26 @@
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14"
"13"
],
"Оборонная промышленность": [
"1",
"2",
"3",
"4",
"6",
"7",
"8",
"9",
"11",
"12",
"13"
],
"Ракетно-космическая промышленность": [
File diff suppressed because it is too large Load Diff
+15 -7
View File
@@ -1,9 +1,17 @@
[
"Установление контролируемой зоны",
"Контроль физического доступа к объекту",
"Разработка документов (регламентов, инструкций, руководств) по обеспечению безопасности объекта",
"Назначение ответственных за обеспечение безопасности объекта",
"Обучение и инструктаж персонала",
"Регламентация действий при компьютерных инцидентах",
"Утверждение перечня лиц, имеющих доступ к объекту"
"Установлены границы контролируемой зоны.",
"Физическая охрана периметра обеспечена бетонным забором с колючей проволокой, исключающим прямой доступ к зданиям, и турникетом на проходной; въезд и вход осуществляются по пропускам с применением СКУД, ведется учет посетителей.",
"Имеется система видеонаблюдения.",
"Здания не имеют окон, выходящих во вне, входная группа оснащена металлической дверью.",
"Установлены охранно-пожарная сигнализация и тревожная кнопка.",
"Исключена возможность неконтролируемого пребывания посторонних лиц в помещениях, где ведется работа с объектами критической информационной инфраструктуры.",
"Назначены: ответственный за организацию обеспечения безопасности объектов критической информационной инфраструктуры, администратор и администратор безопасности объектов критической информационной инфраструктуры, утверждены инструкции, регламентирующие работу с данными и объектами критической информационной инфраструктуры.",
"Осуществляется внутренний контроль соответствия обеспечения безопасности объектов критической информационной инфраструктуры установленным требованиям.",
"Работники, непосредственно осуществляющие работу с объектами критической информационной инфраструктуры, ознакомлены с положениями законодательства Российской Федерации об объектах критической информационной инфраструктуры, документами, определяющими политику организации в отношении работы с объектами критической информационной инфраструктуры.",
"Обеспечивается учет машинных носителей объектов критической информационной инфраструктуры.",
"Обеспечивается резервирование и восстановление данных, модифицированных или уничтоженных вследствие несанкционированного доступа к ним.",
"Разработаны правила доступа к данным, обрабатываемым в объектах критической информационной инфраструктуры.",
"Определён перечень сотрудников, осуществляющих работу с объектами критической информационной инфраструктуры.",
"Ведётся учёт всех защищаемых носителей информации с помощью их маркировки и занесения учетных данных в журнал учета с отметкой об их выдаче (приеме).",
"Обеспечено раздельное хранение материальных носителей, обработка которых осуществляется в различных целях."
]
@@ -1,4 +1,4 @@
{
"system": "Ты — эксперт по безопасности критической информационной инфраструктуры (187-ФЗ, приказ ФСТЭК России № 239 и № 236). По категории значимости объекта КИИ, актуальным угрозам и уже выбранным средствам защиты информации (СЗИ) определи, каких средств не хватает, и дай рекомендации. Учитывай требования по категориям: для I категории — максимальный набор (антивирус, СКЗИ, SIEM, межсетевые экраны, средства анти-APT и т.д.), для II — расширенный набор, для III — базовый набор. Выбирай СЗИ ТОЛЬКО из реально существующих классов средств защиты (антивирусная защита, СКЗИ, межсетевой экран, SIEM, DLP, IDS/IPS, средства доверенной загрузки, контроля целостности, управления доступом и т.п.).",
"system": "Ты — эксперт по безопасности критической информационной инфраструктуры (187-ФЗ, приказ ФСТЭК России № 239 и № 236). По категории значимости объекта КИИ, характеристикам объекта, актуальным угрозам и уже выбранным средствам защиты информации (СЗИ) определи, каких средств не хватает, и дай рекомендации. Обязательно учитывай следующее:\n1) КАТЕГОРИЯ ЗНАЧИМОСТИ: если категория «без категории», «—», пусто или не присвоена — объект НЕ является значимым объектом КИИ, полный набор СЗИ по приказу № 239 для него НЕ требуется. В этом случае рекомендую только минимальный базовый набор (антивирусная защита, резервное копирование, управление доступом) и главный совет — провести категорирование объекта. НЕ перечисляй средства для I категории (СКЗИ, SIEM, анти-APT, доверенная загрузка и т.п.), если категория не присвоена. Для I категории — максимальный набор, для II — расширенный, для III — базовый набор по приказу № 239.\n2) ХАРАКТЕРИСТИКИ ОБЪЕКТА: внимательно читай переданные характеристики (наличие интернета, архитектура, ПО, тип объекта). НЕ рекомендуй СЗИ для технологий, которых нет у объекта: нет интернета/внешних сетей → не рекомендуй DDoS-защиту, VPN, веб-защиту, защиту от удалённых атак из интернета; нет виртуализации/контейнеров → не рекомендуй защиту виртуальной инфраструктуры и контейнеров; нет мобильных устройств → не рекомендуй защиту мобильных; объект не АСУ ТП → не рекомендуй защиту АСУ ТП; нет гостайны → не рекомендуй средства для гостайны. Отмечай только реально применимые классы СЗИ.\n3) ОБЪЁМ: максимум 5–7 рекомендаций, только самые важные для данной категории и объекта. Если текущий набор СЗИ достаточен — верни пустой список рекомендаций и «достаточно»: true.\nВыбирай СЗИ ТОЛЬКО из реально существующих классов средств защиты (антивирусная защита, СКЗИ, межсетевой экран, SIEM, DLP, IDS/IPS, средства доверенной загрузки, контроля целостности, управления доступом и т.п.).",
"response_format": "Верни ТОЛЬКО JSON: {\"рекомендации\": [{\"сзи\": \"название класса/средства\", \"зачем\": \"для чего нужно\"}], \"общие_советы\": [\"...\"], \"достаточно\": true|false}. Поле «достаточно» — true, если текущий набор СЗИ покрывает требования категории. Не придумывай конкретные торговые марки, если их нет в списке — указывай класс средства."
}
+10 -4
View File
@@ -310,10 +310,14 @@ def _subst_var_xml(xml: str, var: str, val: str) -> str:
+ '</w:r><w:r><w:t xml:space="preserve">')
def _expand_nested_loops(template: str, item: Dict) -> str:
def _expand_nested_loops(template: str, item: Dict, loops: Dict = None) -> str:
"""Разворачивает вложенные циклы вида <!-- loop:users_loop --> ... <!-- loop_end -->
данными из item (список словарей). Используется для циклов внутри цикла ИС,
например пользователи ИС. Переменные внутри: {{user_fio}}, {{user_position}}."""
данными из item (список словарей) или из общего словаря циклов loops.
Используется для циклов внутри цикла ИС, например пользователи ИС,
а также для вложенных таблиц (показатели значимости внутри объекта КИИ).
Переменные внутри: {{user_fio}}, {{user_position}}, {{item.*}}."""
if loops is None:
loops = {}
result = template
for _ in range(20):
m_start = LOOP_START.search(result)
@@ -321,6 +325,8 @@ def _expand_nested_loops(template: str, item: Dict) -> str:
break
key = m_start.group(1)
data = item.get(key)
if not isinstance(data, list):
data = loops.get(key)
if not isinstance(data, list):
# Нет данных для вложенного цикла — удаляем блок целиком
depth = 1
@@ -478,7 +484,7 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
clone = template_clean
# Вложенные циклы (users_loop и т.п.) — разворачиваем ДО подстановки
# обычных полей, чтобы {{user_fio}} внутри них не затёрлись алиасами
clone = _expand_nested_loops(clone, item)
clone = _expand_nested_loops(clone, item, loops)
for field, value in item.items():
if isinstance(value, list):
continue # списки — данные вложенных циклов, не подставляем
+434 -43
View File
@@ -7,48 +7,439 @@
],
"value_column": 1,
"fields": [
{"name": "Полное наименование организации", "var": "fullName", "cell": "", "keywords": "полное наименование организации"},
{"name": "Сокращённое наименование организации", "var": "shortName", "cell": "", "keywords": "сокращенное наименование организации"},
{"name": "Юридический адрес", "var": "addressLegal", "cell": "", "keywords": "адрес организации \\(юридический\\)"},
{"name": "Фактический адрес", "var": "addressActual", "cell": "", "keywords": "адрес организации \\(фактический\\)"},
{"name": "Электронная почта", "var": "email", "cell": "", "keywords": "электронная почта"},
{"name": "Телефон", "var": "phone", "cell": "", "keywords": "телефон"},
{"name": "ИНН", "var": "inn", "cell": "", "keywords": "инн"},
{"name": "ОГРН", "var": "ogrn", "cell": "", "keywords": "огрн"},
{"name": "Дата получения ОГРН", "var": "ogrnDate", "cell": "", "keywords": "дата получения огрн|дата.*огрн"},
{"name": "КПП", "var": "kpp", "cell": "", "keywords": "кпп"},
{"name": "Основной ОКВЭД", "var": "okved", "cell": "", "keywords": "основной оквэд"},
{"name": "Дополнительные ОКВЭД", "var": "okvedExtra", "cell": "", "keywords": "дополнительные оквэд"},
{"name": "Должность руководителя", "var": "chiefPosition", "cell": "", "keywords": "должность руководителя"},
{"name": "ФИО руководителя", "var": "chiefFio", "cell": "", "keywords": "фио руководителя"},
{"name": "Должность ответственного лица (безопасность ЗОКИИ)", "var": "responsiblePosition", "cell": "", "keywords": "должность ответственного лица"},
{"name": "ФИО ответственного лица (безопасность ЗОКИИ)", "var": "responsibleFio", "cell": "", "keywords": "фио ответственного лица"},
{"name": "Подразделение по безопасности ОКИИ", "var": "ispdnDepartment", "cell": "", "keywords": "структур.*подраздел"},
{"name": "Рабочий телефон ответственного", "var": "ispdnPhone", "cell": "", "keywords": "рабочий телефон ответственного|мобильный телефон ответственного"},
{"name": "Электронная почта ответственного", "var": "ispdnEmail", "cell": "", "keywords": "электронная почта ответственного"},
{"name": "Должность специалиста по ЗИ/администратора", "var": "administratorPosition", "cell": "", "keywords": "должность специалиста по защите информации|должность.*администрат"},
{"name": "ФИО специалиста по ЗИ/администратора", "var": "administratorFio", "cell": "", "keywords": "фио специалиста по защите информации|фио.*администрат"},
{"name": "Право владения зданием", "var": "buildingRight", "cell": "", "keywords": "право владения зданием"},
{"name": "Сумма прибыли за год", "var": "profit", "cell": "", "keywords": "сумма прибыли"},
{"name": "Сумма налога с прибыли", "var": "profitTax", "cell": "", "keywords": "сумма налога с прибыли"},
{"name": "Доменное имя и внешний IP", "var": "domainName", "cell": "", "keywords": "доменное имя и внешний сетевой адрес"},
{"name": "Сайт организации", "var": "site_name", "cell": "", "keywords": "сайт организации"},
{"name": "Должность ответственного за сайт", "var": "site_responsible_position", "cell": "", "keywords": "должность ответственного за сайт"},
{"name": "ФИО ответственного за сайт", "var": "site_responsible_fio", "cell": "", "keywords": "фио ответственного за сайт"},
{"name": "Разработчик сайта", "var": "site_service_provider", "cell": "", "keywords": "организации, которая разрабатывала и размещала корпоративный сайт"},
{"name": "ИНН разработчика сайта", "var": "site_service_provider_inn", "cell": "", "keywords": "инн организации, которая разрабатывала"},
{"name": "Размещение сайта", "var": "site_hosting", "cell": "", "keywords": "размещение сайта"},
{"name": "Адрес расположения серверов", "var": "site_hosting_address", "cell": "", "keywords": "адрес расположения серверов"},
{"name": "Облачная платформа", "var": "cloud_platform", "cell": "", "keywords": "облачную платформу"},
{"name": "Аренда выделенного сервера", "var": "cloud_dedicated_server", "cell": "", "keywords": "выделенный сервер"},
{"name": "Аренда виртуального ЦОД", "var": "cloud_vdc", "cell": "", "keywords": "виртуальный дата-центр"},
{"name": "Серверы в дата-центре", "var": "cloud_datacenter", "cell": "", "keywords": "сервера в дата-центре"},
{"name": "Средства контейнеризации", "var": "cloud_container", "cell": "", "keywords": "контейнеризации"},
{"name": "Собственная серверная (ЦОД)", "var": "cloud_own_server", "cell": "", "keywords": "собственная серверная"},
{"name": "Общее количество компьютеров", "var": "computers_total", "cell": "", "keywords": "общее количество компьютеров"},
{"name": "Компьютеры, подключённые к объектам КИИ", "var": "computers_kii", "cell": "", "keywords": "компьютеров подключенных к объектам кии"},
{"name": "Центр мониторинга", "var": "monitoring_center", "cell": "", "keywords": "к какому центру подключены"},
{"name": "Детали подключения к центру мониторинга", "var": "monitoring_details", "cell": "", "keywords": "детали подключения"},
{"name": "Дополнительная информация", "var": "additionalInfo", "cell": "", "keywords": "дополнительная информация"}
{
"name": "Полное наименование организации",
"var": "fullName",
"cell": "",
"keywords": "полное наименование организации"
},
{
"name": "Сокращённое наименование организации",
"var": "shortName",
"cell": "",
"keywords": "сокращенное наименование организации"
},
{
"name": "Юридический адрес",
"var": "addressLegal",
"cell": "",
"keywords": "адрес организации \\(юридический\\)"
},
{
"name": "Фактический адрес",
"var": "addressActual",
"cell": "",
"keywords": "адрес организации \\(фактический\\)"
},
{
"name": "Электронная почта",
"var": "email",
"cell": "",
"keywords": "электронная почта"
},
{
"name": "Телефон",
"var": "phone",
"cell": "",
"keywords": "телефон"
},
{
"name": "ИНН",
"var": "inn",
"cell": "",
"keywords": "инн"
},
{
"name": "ОГРН",
"var": "ogrn",
"cell": "",
"keywords": "огрн"
},
{
"name": "Дата получения ОГРН",
"var": "ogrnDate",
"cell": "",
"keywords": "дата получения огрн|дата.*огрн"
},
{
"name": "КПП",
"var": "kpp",
"cell": "",
"keywords": "кпп"
},
{
"name": "Основной ОКВЭД",
"var": "okved",
"cell": "",
"keywords": "основной оквэд"
},
{
"name": "Дополнительные ОКВЭД",
"var": "okvedExtra",
"cell": "",
"keywords": "дополнительные оквэд"
},
{
"name": "Должность руководителя",
"var": "chiefPosition",
"cell": "",
"keywords": "должность руководителя"
},
{
"name": "ФИО руководителя",
"var": "chiefFio",
"cell": "",
"keywords": "фио руководителя"
},
{
"name": "Должность ответственного лица (безопасность ЗОКИИ)",
"var": "responsiblePosition",
"cell": "",
"keywords": "должность ответственного лица"
},
{
"name": "ФИО ответственного лица (безопасность ЗОКИИ)",
"var": "responsibleFio",
"cell": "",
"keywords": "фио ответственного лица"
},
{
"name": "Подразделение по безопасности ОКИИ",
"var": "ispdnDepartment",
"cell": "",
"keywords": "структур.*подраздел"
},
{
"name": "Рабочий телефон ответственного",
"var": "ispdnPhone",
"cell": "",
"keywords": "рабочий телефон ответственного|мобильный телефон ответственного"
},
{
"name": "Электронная почта ответственного",
"var": "ispdnEmail",
"cell": "",
"keywords": "электронная почта ответственного"
},
{
"name": "Должность специалиста по ЗИ/администратора",
"var": "administratorPosition",
"cell": "",
"keywords": "должность специалиста по защите информации|должность.*администрат"
},
{
"name": "ФИО специалиста по ЗИ/администратора",
"var": "administratorFio",
"cell": "",
"keywords": "фио специалиста по защите информации|фио.*администрат"
},
{
"name": "Право владения зданием",
"var": "buildingRight",
"cell": "",
"keywords": "право владения зданием"
},
{
"name": "Сумма прибыли за год",
"var": "profit",
"cell": "",
"keywords": "сумма прибыли"
},
{
"name": "Сумма налога с прибыли",
"var": "profitTax",
"cell": "",
"keywords": "сумма налога с прибыли"
},
{
"name": "Доменное имя и внешний IP",
"var": "domainName",
"cell": "",
"keywords": "доменное имя и внешний сетевой адрес"
},
{
"name": "Сайт организации",
"var": "site_name",
"cell": "",
"keywords": "сайт организации"
},
{
"name": "Должность ответственного за сайт",
"var": "site_responsible_position",
"cell": "",
"keywords": "должность ответственного за сайт"
},
{
"name": "ФИО ответственного за сайт",
"var": "site_responsible_fio",
"cell": "",
"keywords": "фио ответственного за сайт"
},
{
"name": "Разработчик сайта",
"var": "site_service_provider",
"cell": "",
"keywords": "организации, которая разрабатывала и размещала корпоративный сайт"
},
{
"name": "ИНН разработчика сайта",
"var": "site_service_provider_inn",
"cell": "",
"keywords": "инн организации, которая разрабатывала"
},
{
"name": "Размещение сайта",
"var": "site_hosting",
"cell": "",
"keywords": "размещение сайта"
},
{
"name": "Адрес расположения серверов",
"var": "site_hosting_address",
"cell": "",
"keywords": "адрес расположения серверов"
},
{
"name": "Облачная платформа",
"var": "cloud_platform",
"cell": "",
"keywords": "облачную платформу"
},
{
"name": "Аренда выделенного сервера",
"var": "cloud_dedicated_server",
"cell": "",
"keywords": "выделенный сервер"
},
{
"name": "Аренда виртуального ЦОД",
"var": "cloud_vdc",
"cell": "",
"keywords": "виртуальный дата-центр"
},
{
"name": "Серверы в дата-центре",
"var": "cloud_datacenter",
"cell": "",
"keywords": "сервера в дата-центре"
},
{
"name": "Средства контейнеризации",
"var": "cloud_container",
"cell": "",
"keywords": "контейнеризации"
},
{
"name": "Собственная серверная (ЦОД)",
"var": "cloud_own_server",
"cell": "",
"keywords": "собственная серверная"
},
{
"name": "Общее количество компьютеров",
"var": "computers_total",
"cell": "",
"keywords": "общее количество компьютеров"
},
{
"name": "Компьютеры, подключённые к объектам КИИ",
"var": "computers_kii",
"cell": "",
"keywords": "компьютеров подключенных к объектам кии"
},
{
"name": "Центр мониторинга",
"var": "monitoring_center",
"cell": "",
"keywords": "к какому центру подключены"
},
{
"name": "Детали подключения к центру мониторинга",
"var": "monitoring_details",
"cell": "",
"keywords": "детали подключения"
},
{
"name": "Дополнительная информация",
"var": "additionalInfo",
"cell": "",
"keywords": "дополнительная информация"
},
{
"name": "Физическая охрана (ЧОП, сторожа, охранники)",
"var": "physicalGuard",
"cell": "",
"keywords": "физическая охрана"
},
{
"name": "Пожарная сигнализация",
"var": "physicalFireAlarm",
"cell": "",
"keywords": "пожарная сигнализация"
},
{
"name": "Тревожная кнопка",
"var": "physicalPanicButton",
"cell": "",
"keywords": "тревожная кнопка"
},
{
"name": "Решётки на окнах",
"var": "physicalBars",
"cell": "",
"keywords": "решетки на окнах"
},
{
"name": "Металлические двери",
"var": "physicalMetalDoors",
"cell": "",
"keywords": "металлические двери"
},
{
"name": "Система видеонаблюдения",
"var": "physicalVideoSurveillance",
"cell": "",
"keywords": "система видеонаблюдения"
},
{
"name": "Видеодомофон",
"var": "physicalVideoIntercom",
"cell": "",
"keywords": "видеодомофон"
},
{
"name": "Турникет на входе",
"var": "physicalTurnstile",
"cell": "",
"keywords": "турникет на входе"
},
{
"name": "Журнал учёта посетителей",
"var": "physicalVisitorLog",
"cell": "",
"keywords": "журнал учета посетителей|журнал учёта посетителей"
}
],
"criterion_fields": [
{
"num": "1",
"name": "Максимальное число пострадавших людей",
"keywords": "пострадавш",
"unit": "человек"
},
{
"num": "2б",
"name": "Люди с нарушенными условиями жизнедеятельности",
"keywords": "условия жизнедеятельности",
"unit": "тыс. человек"
},
{
"num": "3б",
"name": "Люди, для которых недоступны транспортные услуги",
"keywords": "транспортн",
"unit": "тыс. человек"
},
{
"num": "4а",
"name": "Абоненты, для которых недоступны услуги связи",
"keywords": "абонент",
"unit": "тыс. человек"
},
{
"num": "5а",
"name": "Максимальное время недоступности государственной услуги",
"keywords": "недоступна государственная услуга|недоступности государственной",
"unit": "часов"
},
{
"num": "5б",
"name": "Время, в течение которого госуслуга не может быть оказана",
"keywords": "регламентом|административным регламентом",
"unit": "%"
},
{
"num": "11б",
"name": "Люди, подверженные вредным воздействиям на окружающую среду",
"keywords": "вредным воздействиям",
"unit": "тыс. человек"
},
{
"num": "13а",
"name": "Снижение объёма продукции ГОЗ",
"keywords": "объем продукции|объём продукции",
"unit": "%"
},
{
"num": "13б",
"name": "Увеличение времени изготовления единицы продукции ГОЗ",
"keywords": "время изготовления",
"unit": "%"
},
{
"num": "14",
"name": "Максимальное время недоступности ИС (оборона)",
"keywords": "недоступна пользователю",
"unit": "часов"
},
{
"num": "2а",
"name": "Территория нарушения жизнеобеспечения населения",
"keywords": "территория.*жизнедеятельности|жизнедеятельности населения",
"unit": "вариант"
},
{
"num": "3а",
"name": "Территория нарушения транспортного сообщения",
"keywords": "территория.*транспорт",
"unit": "вариант"
},
{
"num": "3в",
"name": "Типы нарушенных перевозок и грузов",
"keywords": "типам нарушенных перевозок|типы нарушенных перевозок",
"unit": "вариант"
},
{
"num": "3г",
"name": "Категория объекта транспортной инфраструктуры",
"keywords": "категорированию объектов транспортной|категорию объекта транспортной",
"unit": "вариант"
},
{
"num": "4б",
"name": "Уровень органа власти, для которого недоступны услуги связи",
"keywords": "уровню органа государственной власти",
"unit": "вариант"
},
{
"num": "6",
"name": "Уровень государственного органа (невыполнение функции)",
"keywords": "функционирования государственного органа|государственного органа в части",
"unit": "вариант"
},
{
"num": "7",
"name": "Уровень международного договора",
"keywords": "международного договора",
"unit": "вариант"
},
{
"num": "11а",
"name": "Территория вредных воздействий на окружающую среду",
"keywords": "территория.*окружающую среду|окружающая среда может",
"unit": "вариант"
},
{
"num": "12",
"name": "Уровень пункта управления (ситуационного центра)",
"keywords": "пункта управления|ситуационного центра",
"unit": "вариант"
},
{
"num": "13.1",
"name": "Статус в кооперации головного исполнителя ГОЗ",
"keywords": "кооперации головного исполнителя|статусу в кооперации",
"unit": "вариант"
}
]
}
+26
View File
@@ -174,6 +174,21 @@ def import_187fz(filepath: str, log_fn: Optional[Callable] = None, settings_path
finally:
wb.close()
# Ответы раздела «Физическая безопасность» (да/нет) — собираем в словарь,
# чтобы текст организационных мер генерировался с учётом реальных средств.
ps_keys = [
'physicalGuard', 'physicalFireAlarm', 'physicalPanicButton',
'physicalBars', 'physicalMetalDoors', 'physicalVideoSurveillance',
'physicalVideoIntercom', 'physicalTurnstile', 'physicalVisitorLog',
]
ps = {}
for k in ps_keys:
v = str(data.get(k) or '').strip()
if v:
ps[k] = v
if ps:
data['physicalSecurity'] = ps
# Копирование полей ИСПДн в админа, если админ не заполнен
for src, dst in [('ispdnFio', 'administratorFio'), ('ispdnFio', 'adminFio'),
('ispdnPosition', 'administratorPosition'), ('ispdnPosition', 'adminPosition'),
@@ -541,6 +556,17 @@ def _import_187_is_list(wb, data, settings, log_fn):
if _set(field_lower, 'tech_measures', val_b):
log_fn(f" Тех. меры: {val_b[:60]}")
continue
# --- Показатели критериев значимости (ПП-127) → criterion_input ---
# Строки вида «Показатель 1. …» / «Показатель 2а. …» / «Показатель 13.1. …»
m_crit = re.match(r'^показател[ья]?\s*(\d+(?:\.\d+)?)([а-яА-Я]?)', field_lower)
if m_crit:
_num_crit = m_crit.group(1) + m_crit.group(2)
# нормализация: «10.1» → «10.1», «13.1» → «13.1», «2а» → «2а»
_num_crit = _num_crit.replace(' .', '.').strip()
if val_b and 'выбрать' not in field_lower and not _is_hint(val_b):
is_obj.setdefault('criterion_input', {})[_num_crit] = str(val_b).strip()
log_fn(f" Показатель {_num_crit}: {str(val_b)[:60]}")
continue
if 'интернет' in field_lower:
is_obj['isInternet'] = is_checked
continue
+33 -1
View File
@@ -80,6 +80,12 @@ class InformationSystem:
org_measures: str = "" # Организационные меры (\n — разделитель)
tech_measures: str = "" # Технические меры (\n — разделитель)
# Введённые значения показателей ПП-127 для авторасчёта: {num: значение}
# num — «1», «2а», «2б», …, «14»; значение — число (количественные)
# или выбранный вариант (качественные). Заполняется окном авторасчёта,
# импортом из опросника или вручную; используется в _build_categorization_indicators.
criterion_input: Dict[str, str] = field(default_factory=dict)
@dataclass
class Company:
@@ -130,7 +136,9 @@ class Company:
profit_tax: str = "" # Сумма налога с прибыли за предыдущий год
# Показатель 8 (ущерб субъекту КИИ): доходы за 5 лет и суммарный ущерб
income_year_1: str = "" # Доход за 5 лет назад (руб.)
# В форме — одна строка «Доход за 5 лет (суммарно)»; по годам разбивает сам оператор.
income_5y: str = "" # Доход за 5 лет суммарно (руб.)
income_year_1: str = "" # Доход за 5 лет назад (руб.) — обратная совместимость
income_year_2: str = "" # Доход за 4 года назад (руб.)
income_year_3: str = "" # Доход за 3 года назад (руб.)
income_year_4: str = "" # Доход за 2 года назад (руб.)
@@ -144,6 +152,11 @@ class Company:
vso: str = "" # Снижение выплат сторонним организациям ΔВСО (руб.)
ns: str = "" # Снижение сборов ΔНС (руб.)
# Показатель 10 (прекращение финансовых операций): операции за год (млн ед.)
ops_count: str = "" # Количество операций за прошедший год (млн ед.)
calendar_days: str = "" # Количество календарных дней в году (по умолчанию 365)
criterion10_value: str = "" # Значение показателя 10.1–10.7 (в своих единицах)
# Списки
commission: List[CommissionMember] = field(default_factory=list)
information_systems: List[InformationSystem] = field(default_factory=list)
@@ -152,6 +165,11 @@ class Company:
# Наименование структурного подразделения по безопасности
security_department: str = ""
# Ответы раздела «Физическая безопасность» опросника (да/нет):
# охрана, пожарная сигнализация, тревожная кнопка, решётки, металлические двери,
# видеонаблюдение, видеодомофон, турникет, журнал посетителей.
physical_security: Dict[str, str] = field(default_factory=dict)
def update_declensions(self, inflect_func):
"""Обновить склонения полного названия"""
if self.full_name:
@@ -214,6 +232,20 @@ class Company:
d["okved_extra"] = []
valid = {f.name for f in cls.__dataclass_fields__.values()}
obj = cls(**{k: v for k, v in d.items() if k in valid})
# Миграция: старые файлы с доходами по годам → суммарный доход за 5 лет
if not (getattr(obj, 'income_5y', '') or '').strip():
_sum = 0.0
_has = False
for i in range(1, 6):
v = str(getattr(obj, f'income_year_{i}', '') or '').strip()
if v:
try:
_sum += float(v.replace(' ', '').replace(',', '.').replace('\u00a0', ''))
_has = True
except Exception:
pass
if _has:
obj.income_5y = f"{_sum:,.0f}".replace(',', ' ')
# Если в поле ФИО несколько человек — оставить одного (старые опросники)
for fio_field in ('chief_fio', 'responsible_fio', 'admin_fio'):
val = getattr(obj, fio_field, '')
+102 -16
View File
@@ -11,6 +11,24 @@
import os
import json
import re
def _ubi_num(text):
"""Код УБИ из строки → int: «УБИ.069 Название» → 69. None, если кода нет."""
m = re.search(r'уби\.?\s*(\d+)', str(text), re.IGNORECASE)
return int(m.group(1)) if m else None
def sort_threats_by_ubi(items):
"""Сортировка угроз по возрастанию кода УБИ (УБИ.001, УБИ.002, …).
Строки без кода УБИ уходят в конец (по алфавиту).
"""
def _key(t):
n = _ubi_num(t)
return (0, n) if n is not None else (1, str(t).lower())
return sorted(items, key=_key)
def load_threats(dict_dir=None):
@@ -42,8 +60,26 @@ BASE_KEYWORDS = [
'вредоносн', 'несанкционированн', 'утечк', 'перехват', 'модификац',
'уничтожен', 'отказ в обслуживании', 'нсд', 'биос', 'пароль',
'криптографическ', 'шифрован', 'аутентификац',
# базовые угрозы локальных сетей / клиент-серверных архитектур
'доступ', 'данн', 'систем', 'конфигурац', 'целостност', 'информац',
'программн', 'пользовател', 'авторизац', 'сеанс', 'журнал', 'реестр',
'файл', 'память', 'буфер', 'ресурс', 'сервер', 'клиент', 'учётн',
'учетн', 'идентификац', 'привилег', 'средств', 'сетев', 'локальн',
]
# Технологии, которые по умолчанию НЕ используются на объекте.
# Если в характеристиках объекта есть явные упоминания — группа возвращается.
TECH_GROUPS = {
'виртуализац': ['виртуал', 'гипервизор', 'контейнер'],
'облако': ['облачн', 'облак'],
'мобильн': ['мобильн', 'смартфон', 'планшет'],
'грид': ['грид', 'суперкомпьютер'],
'большие данные': ['больших данных', 'хранилищ'],
'беспроводн': ['беспроводн', 'wi-fi', 'wifi'],
'smart-карты': ['smart-карт', 'смарт-карт'],
'машинное обучение': ['машинного обучения', 'искусственного интеллекта', 'нейросет'],
}
# Объект имеет подключение к сети Интернет / сеть электросвязи
INTERNET_KEYWORDS = [
'удалённ', 'удаленн', 'сетев', 'фишинг', 'ddos', 'отказ в обслуживании',
@@ -153,6 +189,21 @@ def suggest_threats_program(isys, threats=None, limit=None):
is_asu = _is_asu(isys)
is_network = _is_network(isys)
# --- Технологии, которые явно НЕ используются на объекте ---
# Если в характеристиках объекта (описание, архитектура, ПО, сфера) нет
# упоминаний технологии — угрозы этой группы исключаем.
tech_context = ' '.join([
str(getattr(isys, 'description', '') or ''),
str(getattr(isys, 'architecture', '') or ''),
str(getattr(isys, 'hw_sw', '') or ''),
str(getattr(isys, 'os_sw', '') or ''),
str(getattr(isys, 'app_sw', '') or ''),
str(getattr(isys, 'object_type', '') or ''),
str(getattr(isys, 'sphere', '') or ''),
str(getattr(isys, 'net_category', '') or ''),
str(getattr(isys, 'typical_object', '') or ''),
]).lower()
# --- Слова-исключения (если встретились в названии угрозы — она неактуальна) ---
exclude = []
@@ -167,6 +218,19 @@ def suggest_threats_program(isys, threats=None, limit=None):
if is_asu:
# АСУ: нет BIOS/виртуализации/браузера в привычном понимании
exclude += ['биос', 'грид', 'гипервизор', 'виртуальн', 'браузер']
else:
# Не АСУ — исключаем угрозы АСУ ТП / технологических процессов
exclude += ['технологическ', 'датчик', 'исполнительн', 'асутп', 'scada',
'промышленн', 'контроллер', 'числовым программным управлением',
'программируемых логических']
# Технологии, не упомянутые в характеристиках объекта → исключаем
for tech_name, kws in TECH_GROUPS.items():
if any(kw in tech_context for kw in kws):
continue # технология используется — угрозы оставляем
# Технология не упомянута — исключаем её угрозы
for kw in kws:
exclude.append(kw)
if not is_network and not is_asu:
# Обычная ИС: неактуальны чисто сетевые/телеком-угрозы без указания
@@ -198,9 +262,9 @@ def suggest_threats_program(isys, threats=None, limit=None):
sc = sum(1 for kw in pos if kw in tl)
scored.append((sc, t))
# Сортируем по релевантности (сначала — с наибольшим совпадением)
scored.sort(key=lambda x: -x[0])
result = [t for sc, t in scored if sc > 0]
# Сортируем по возрастанию кода УБИ (УБИ.001 → УБИ.211…)
scored = [t for sc, t in scored if sc > 0]
result = sort_threats_by_ubi(scored)
if limit and len(result) > limit:
result = result[:limit]
@@ -234,19 +298,18 @@ BASE_ORG_MEASURES = [
'Утверждение перечня лиц, имеющих доступ к объекту',
]
# Базовый набор тех. мер
# Базовый набор тех. мер — только универсальные, не требующие конкретных СЗИ.
# Меры, зависящие от средств защиты (антивирус, СЗИ от НСД, регистрация событий
# и т.п.), добавляются отдельно по факту наличия СЗИ (DEFENSE_TOOL_MEASURES).
BASE_TECH_MEASURES = [
'Идентификация и аутентификация пользователей',
'Управление доступом',
'Ограничение программной среды',
'Антивирусная защита',
'Защита информации от несанкционированного доступа (СЗИ от НСД)',
'Регистрация и мониторинг событий безопасности',
'Резервное копирование и восстановление',
'Защита технических средств и линий связи',
]
# Средства защиты → доп. тех. меры
# Средства защиты → доп. тех. меры (по факту наличия СЗИ у объекта)
DEFENSE_TOOL_MEASURES = [
('межсетев', 'Межсетевое экранирование'),
('firewall', 'Межсетевое экранирование'),
@@ -260,6 +323,23 @@ DEFENSE_TOOL_MEASURES = [
('виртуализ', 'Защита среды виртуализации'),
('vmware', 'Защита среды виртуализации'),
('hyper-v', 'Защита среды виртуализации'),
('антивирус', 'Антивирусная защита'),
('kaspersky', 'Антивирусная защита'),
('eset', 'Антивирусная защита'),
('dr.web', 'Антивирусная защита'),
('аваст', 'Антивирусная защита'),
('avast', 'Антивирусная защита'),
('сзи', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'),
('от нсд', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'),
('dallas', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'),
('secret net', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'),
('аккорд', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'),
('siem', 'Регистрация и мониторинг событий безопасности'),
('dlp', 'Регистрация и мониторинг событий безопасности'),
('мониторинг', 'Регистрация и мониторинг событий безопасности'),
('регистрац', 'Регистрация и мониторинг событий безопасности'),
('доверенная загрузка', 'Контроль (доверенная) загрузки'),
('резервн', 'Резервное копирование и восстановление'),
]
@@ -331,15 +411,21 @@ def auto_fill_security(isys, threats=None):
if not (getattr(isys, 'org_measures', '') or '').strip():
res['org_measures'] = '\n'.join(BASE_ORG_MEASURES)
# 7. Тех. меры — базовые + по СЗИ объекта
# 7. Тех. меры — базовые + по СЗИ объекта.
# Только если объекту присвоена категория значимости (1/2/3):
# для незначимого объекта технические меры не требуются.
if not (getattr(isys, 'tech_measures', '') or '').strip():
tech = list(BASE_TECH_MEASURES)
defense = ' '.join(getattr(isys, 'defense_tools_list', []) or [])
dlow = defense.lower()
for kw, measure in DEFENSE_TOOL_MEASURES:
if kw in dlow and measure not in tech:
tech.append(measure)
res['tech_measures'] = '\n'.join(tech)
cat = (getattr(isys, 'category', '') or '').strip()
has_category = cat in ('1', '2', '3')
if has_category:
tech = list(BASE_TECH_MEASURES)
defense = ' '.join(getattr(isys, 'defense_tools_list', []) or [])
dlow = defense.lower()
for kw, measure in DEFENSE_TOOL_MEASURES:
if kw in dlow and measure not in tech:
tech.append(measure)
res['tech_measures'] = '\n'.join(tech)
# else: категория не присвоена — технические меры не заполняем
return res
+485 -71
View File
@@ -22,7 +22,7 @@ from . import (
from .verify import (
check_declension_morpher, check_company_dadata,
check_declension_ai, check_company_ai,
COMPANY_FIELDS, compare_company_values,
COMPANY_FIELDS, compare_company_values, normalize_threats,
)
from .api_settings import load_api_settings, save_api_settings
@@ -345,19 +345,12 @@ class DokoGenApp:
entry.grid(row=i, column=1, padx=10, sticky=tk.EW, pady=1)
self._company_entries[key] = entry
# Показатель 8 (ущерб субъекту КИИ): доходы за 5 лет + суммарный ущерб
ttk.Label(f_fin, text="Доходы за 5 лет (показатель 8):",
font=('', 9, 'bold')).grid(row=len(fin_fields), column=0, columnspan=2,
sticky=tk.W, pady=(8, 2))
# Показатель 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_year_1"),
("Доход за 4 года назад (руб.):", "income_year_2"),
("Доход за 3 года назад (руб.):", "income_year_3"),
("Доход за 2 года назад (руб.):", "income_year_4"),
("Доход за прошлый год (руб.):", "income_year_5"),
("Доход за 5 лет (суммарно, руб.):", "income_5y"),
("Снижение уровня дохода от нарушения критических процессов (руб.):", "income_loss"),
]
for j, (label, key) in enumerate(income_fields):
@@ -761,8 +754,9 @@ class DokoGenApp:
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 comp['okved_extra']:
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
@@ -1482,11 +1476,18 @@ class DokoGenApp:
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)
for item in (c.okved_extra or []):
self.okved_extra_listbox.insert(tk.END, item)
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}
@@ -1908,9 +1909,15 @@ class DokoGenApp:
w.set(val)
elif isinstance(w, tk.Listbox):
w.delete(0, tk.END)
for line in str(val).split('\n'):
if line.strip():
w.insert(tk.END, line.strip())
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)
@@ -2197,6 +2204,16 @@ class DokoGenApp:
критериев экономической значимости» ФСТЭК (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» → число
@@ -2218,7 +2235,9 @@ class DokoGenApp:
# grab_set + lift — чтобы окно не уходило на задний план
w = tk.Toplevel(dialog)
w.title(f"🧮 Авторасчёт показателя {num}{name[:60]}")
w.geometry("980x760")
w.geometry("980x820")
w.minsize(760, 560)
w.resizable(True, True)
w.transient(dialog)
w.lift()
w.focus_force()
@@ -2236,7 +2255,141 @@ class DokoGenApp:
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
if num == '9':
if num in ('13а', '13б') and _sphere_opk:
# ОПК: 13а — V = (1(V_maxV_инц)/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"
@@ -2277,8 +2430,67 @@ class DokoGenApp:
e_div = _row("Снижение выплат от дивидендов ΔДИВ (руб.):", 'div', '0')
e_vso = _row("Снижение выплат сторонних организаций ΔВСО (руб.):", 'vso', '0')
e_ns = _row("Снижение сборов ΔНС (руб.):", 'ns', '0')
result = tk.Text(frm, height=8, wrap=tk.WORD, state=tk.NORMAL)
result.pack(fill=tk.BOTH, expand=True, pady=(8, 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, п. 1820)
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)
@@ -2294,15 +2506,30 @@ class DokoGenApp:
return e
e_profit = e_tax = None
# Доходы за 5 лет — из данных организации (поля «Доходы за 5 лет»)
# Доходы за 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]
_d_avg = (sum(_incs_filled) / len(_incs_filled)) if _incs_filled else 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:
@@ -2313,6 +2540,10 @@ class DokoGenApp:
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="⚠️ ΔДоходов — не заполнено: введите вручную или заполните "
"«Снижение уровня дохода» во вкладке «Организация».",
@@ -2324,12 +2555,121 @@ class DokoGenApp:
"ПП-127 требует усреднение за прошедший пятилетний период: "
"при неполных данных результат методологически некорректен — заполните все 5 лет.",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
result = tk.Text(frm, height=8, wrap=tk.WORD, state=tk.NORMAL)
result.pack(fill=tk.BOTH, expand=True, pady=(8, 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)
def _calc():
lines = []
if num == '8':
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:
@@ -2403,6 +2743,19 @@ class DokoGenApp:
# Если расчёт не удался (ошибка «Заполните…») — не применяем и не пишем в обоснование
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
@@ -2415,23 +2768,15 @@ class DokoGenApp:
if target:
var.set(target)
prefix = f"Показатель {num}. {name}{cat} категория."
full = prefix + "\n" + txt
cur = justify_txt.get('1.0', 'end-1c').strip()
if prefix not in cur:
if cur:
justify_txt.insert('end', '\n' + full)
else:
justify_txt.insert('1.0', full)
_append_justify(prefix, txt)
else:
var.set('')
prefix = f"Показатель {num}. {name} — применим, категория не присвоена (—)."
full = prefix + "\n" + txt
cur = justify_txt.get('1.0', 'end-1c').strip()
if prefix not in cur:
if cur:
justify_txt.insert('end', '\n' + full)
else:
justify_txt.insert('1.0', full)
_append_justify(prefix, txt)
w.grab_release()
w.destroy()
@@ -2489,8 +2834,11 @@ class DokoGenApp:
combo.pack(side=tk.RIGHT, padx=6)
_combos[num] = combo
# Кнопка «🧮» — авторасчёт показателя (п. 8 «ущерб субъекту КИИ» и п. 9 «ущерб бюджетам»)
if grp_num in ('8', '9'):
# Кнопка «🧮» — авторасчёт показателя:
# 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)
@@ -2502,13 +2850,9 @@ class DokoGenApp:
txt = _just_text(_num)
prefix = f"Показатель {_num}. {_name} — не применим."
if txt:
full = prefix + " " + txt
cur = justify_txt.get('1.0', 'end-1c').strip()
if prefix not in cur:
if cur:
justify_txt.insert('end', '\n' + full)
else:
justify_txt.insert('1.0', full)
_append_justify(prefix, txt)
combo.bind('<<ComboboxSelected>>', _on_pick)
if not applicable_here:
@@ -2538,6 +2882,21 @@ class DokoGenApp:
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)
@@ -2695,13 +3054,9 @@ class DokoGenApp:
'указанных в п. 8 перечня показателей критериев '
'значимости объектов КИИ.')
prefix = f"Показатель {num}. {name} — не применим."
full = prefix + " " + txt
cur = justify_txt.get('1.0', 'end-1c').strip()
if prefix not in cur:
if cur:
justify_txt.insert('end', '\n' + full)
else:
justify_txt.insert('1.0', full)
_append_justify(prefix, txt)
filled.append(f"п. {num}: не применим (тип организации)")
elif g == '9':
profit = _num2(getattr(self.model, 'profit', '') or '')
@@ -2761,13 +3116,9 @@ class DokoGenApp:
f"Категория значимости: {cat or '— (не присваивается)'}"
)
prefix = f"Показатель {num}. {name} — рекомендация по расчёту:"
full = prefix + "\n" + calc_txt
cur = justify_txt.get('1.0', 'end-1c').strip()
if prefix not in cur:
if cur:
justify_txt.insert('end', '\n' + full)
else:
justify_txt.insert('1.0', full)
_append_justify(prefix, calc_txt)
filled.append(f"п. {num}: №9 = {val:.6f}% → {cat or ''}")
# Что осталось незаполненным (применимые, без значения)
left = []
@@ -2873,9 +3224,12 @@ class DokoGenApp:
if key in fill and hasattr(self, attr):
lb = getattr(self, attr)
lb.delete(0, tk.END)
for line in fill[key].split('\n'):
if line.strip():
lb.insert(tk.END, line.strip())
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({
@@ -2911,7 +3265,8 @@ class DokoGenApp:
return
lb = self.is_threats_listbox
lb.delete(0, tk.END)
for t in threats:
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)} шт.")
@@ -3286,6 +3641,12 @@ class DokoGenApp:
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:
@@ -3462,15 +3823,21 @@ class DokoGenApp:
e = entries.get(key)
return e.get('1.0', 'end-1c').strip() if e else ''
def _fill_listbox(lb, text):
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:
lb.insert(tk.END, 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'):
@@ -3480,7 +3847,7 @@ class DokoGenApp:
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'))
_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'):
@@ -3738,6 +4105,13 @@ class DokoGenApp:
'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 [],
@@ -3803,6 +4177,13 @@ class DokoGenApp:
'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 [],
@@ -4114,9 +4495,16 @@ class DokoGenApp:
# Последние 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:
@@ -4125,7 +4513,7 @@ class DokoGenApp:
self._collect_company()
years = ', '.join(f"{y}: {inc:,.0f} руб." for _, y, inc in mapping)
self.log(f"🌐 Доходы из DaData загружены: {years}")
messagebox.showinfo("DaData", f"Доходы загружены из DaData:\n{years}")
messagebox.showinfo("DaData", f"Доходы загружены из DaData (сумма за 5 лет: {total:,.0f} руб.):\n{years}")
def _select_main_okved(self):
"""Выбор основного ОКВЭД из словаря (всегда один)."""
@@ -4446,6 +4834,10 @@ class DokoGenApp:
# Автоопределение города из адреса после импорта
self._on_address_changed()
# Ответы по физической безопасности (для автогенерации орг. мер)
if data.get('physicalSecurity'):
self.model.physical_security = dict(data['physicalSecurity'])
# Должностные лица (маппинг)
officials_map = {
'chief_position': 'chiefPosition',
@@ -4486,6 +4878,7 @@ class DokoGenApp:
# Показатель 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')),
@@ -4535,23 +4928,43 @@ class DokoGenApp:
entry.insert(0, resolve_okved(data['okved'], _tok))
if data.get('okvedExtra'):
if hasattr(self, 'okved_extra_listbox'):
from .verify import resolve_okved
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)
extra = data['okvedExtra']
if isinstance(extra, str):
import re as _re
parts = [p.strip() for p in _re.split(r'[;,\n]+', extra) if p.strip()]
else:
parts = [str(p).strip() for p in extra if str(p).strip()]
for p in parts:
# Доп. ОКВЭД могут быть склеены с переносами внутри названий
# («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', ''),
@@ -4583,8 +4996,9 @@ class DokoGenApp:
attacker_category=is_data.get('attacker_category', ''),
attacker_level=is_data.get('attacker_level', ''),
attacker_types=is_data.get('attacker_types', ''),
threats=is_data.get('threats', ''),
incidents=is_data.get('incidents', ''),
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', ''),
+427 -17
View File
@@ -61,7 +61,8 @@ VARIABLE_DEFS = [
('{{company_okved_extra}}', 'A', 'Дополнительные ОКВЭД', 'company:okved_extra'),
('{{company_profit}}', 'A', 'Сумма прибыли за предыдущий год (руб.)', 'company:profit'),
('{{company_profit_tax}}', 'A', 'Сумма налога с прибыли (руб.)', 'company:profit_tax'),
('{{company_income_year_1}}', 'A', 'Доход за 5 лет назад (руб.) — показатель 8', 'company:income_year_1'),
('{{company_income_5y}}', 'A', 'Доход за 5 лет суммарно (руб.) — показатель 8', 'company:income_5y'),
('{{company_income_year_1}}', 'A', 'Доход за 5 лет назад (руб.) — показатель 8 (обратная совместимость)', 'company:income_year_1'),
('{{company_income_year_2}}', 'A', 'Доход за 4 года назад (руб.) — показатель 8', 'company:income_year_2'),
('{{company_income_year_3}}', 'A', 'Доход за 3 года назад (руб.) — показатель 8', 'company:income_year_3'),
('{{company_income_year_4}}', 'A', 'Доход за 2 года назад (руб.) — показатель 8', 'company:income_year_4'),
@@ -357,7 +358,10 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
'incidents': getattr(isys, 'incidents', '') or '',
'criteria_values': getattr(isys, 'criteria_values', '') or '',
'criteria_justification': getattr(isys, 'criteria_justification', '') or '',
'org_measures': getattr(isys, 'org_measures', '') or '',
# Орг. меры: если не заполнены вручную — автогенерация по опроснику
# (с учётом ответов раздела «Физическая безопасность»).
'org_measures': (getattr(isys, 'org_measures', '') or '')
or _build_org_measures_text(company, isys),
'tech_measures': getattr(isys, 'tech_measures', '') or '',
})
loops['information_systems'] = items
@@ -425,12 +429,21 @@ def _calc_criterion_8_9(company, isys, num):
d = _num(getattr(company, 'income_loss', '') or '')
if d is None:
d = _num(getattr(isys, 'balance_value', '') or '')
# Доходы за 5 лет
incs = [_num(getattr(company, f'income_year_{i}', '') or '') for i in range(1, 6)]
incs = [x for x in incs if x is not None]
r_avg = (sum(incs) / len(incs)) if incs else None
# Доходы за 5 лет: основное поле income_5y (суммарно), иначе — обратная совместимость по годам
inc5 = _num(getattr(company, 'income_5y', '') or '')
if inc5:
r_avg = inc5 / 5.0
else:
incs = [_num(getattr(company, f'income_year_{i}', '') or '') for i in range(1, 6)]
incs = [x for x in incs if x is not None]
r_avg = (sum(incs) / len(incs)) if incs else None
if num == '8':
# Фолбэк: ущерб не заполнен → оценка «доход за год × 5» (наихудший сценарий)
d_estimated = False
if d is None and r_avg:
d = r_avg * 5
d_estimated = True
if d is None or not r_avg:
return ("Показатель применим. Для расчёта заполните данные организации: "
"«Снижение уровня дохода» и доходы за 5 лет, или балансовую стоимость объекта КИИ.", None)
@@ -445,11 +458,16 @@ def _calc_criterion_8_9(company, isys, num):
cat = None
text = (f"Реализация компьютерных атак на объект не приведет к снижению доходов субъекта КИИ. "
f"Объем среднегодового дохода субъекта КИИ (с учетом НДС), усредненный за прошедший "
f"5-летний период, составляет {r_avg:,.2f} руб. В случае выхода из строя объекта КИИ "
f"потребуется замена, установка и настройка, стоимость которых составит около {d:,.2f} руб. "
f"Абсолютный ущерб субъекта КИИ: U={d:,.2f} руб. "
f"Показатель ущерба субъекту КИИ: U_8 = U / R_год = {d:,.2f} / {r_avg:,.2f} "
f"= {val_pct:.6f} %.")
f"5-летний период, составляет {r_avg:,.2f} руб. ")
if d_estimated:
text += (f"Снижение уровня дохода не заполнено, поэтому оценка ущерба принята по наихудшему "
f"сценарию: доход за год × 5 лет = {r_avg:,.2f} × 5 = {d:,.2f} руб. ")
else:
text += (f"В случае выхода из строя объекта КИИ потребуется замена, установка и настройка, "
f"стоимость которых составит около {d:,.2f} руб. ")
text += (f"Абсолютный ущерб субъекта КИИ: U={d:,.2f} руб. "
f"Показатель ущерба субъекту КИИ: U_8 = U / R_год = {d:,.2f} / {r_avg:,.2f} "
f"= {val_pct:.6f} %.")
if cat:
text += (f" Так как {val_pct:.6f} % более 1% (пороговое значение для 3-й категории "
f"значимости согласно Постановлению № 127), по данному показателю объекту "
@@ -505,6 +523,332 @@ def _calc_criterion_8_9(company, isys, num):
return text, cat
def _calc_criterion_8_opk(company, isys):
"""Показатель 8 (ущерб субъекту КИИ) по отраслевым особенностям ОПК
(ПП РФ 796 от 27.06.2026, п. 1820):
U_8 = U_у8 / R_год × 100%,
U_у8 = C + A + X,
где C суммарные затраты на объект КИИ;
A затраты на возмещение ущерба потерпевшим и их имуществу (если инцидент
не влечёт ущерба, A = 0);
X ущерб от ранее произошедших компьютерных инцидентов (если инцидентов
не было или нет данных, X = 0);
R_год усреднённый объём годового дохода за прошедший 5-летний период.
Значения C/A/X берутся из criterion_input (ключи 8_c, 8_a, 8_x);
если C не заполнен используется балансовая стоимость объекта КИИ.
Возвращает (текст_обоснования, категория или None).
"""
crit = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
c_val = _num(crit.get('8_c', '') or '') or _num(getattr(isys, 'balance_value', '') or '')
a_val = _num(crit.get('8_a', '') or '') or 0.0
x_val = _num(crit.get('8_x', '') or '') or 0.0
inc5 = _num(getattr(company, 'income_5y', '') or '')
if inc5:
r_avg = inc5 / 5.0
else:
incs = [_num(getattr(company, f'income_year_{i}', '') or '') for i in range(1, 6)]
incs = [x for x in incs if x is not None]
r_avg = (sum(incs) / len(incs)) if incs else None
if c_val is None or not r_avg:
return ("Показатель применим (отраслевые особенности ОПК, ПП РФ № 796). "
"Для расчёта заполните суммарные затраты на объект КИИ (C) и доходы за 5 лет "
"(или балансовую стоимость объекта КИИ).", None)
u_y8 = c_val + a_val + x_val
val_pct = u_y8 / r_avg * 100
if val_pct > 20:
cat = 'I'
elif val_pct > 10:
cat = 'II'
elif val_pct >= 1:
cat = 'III'
else:
cat = None
text = (f"Показатель 8 рассчитывается по отраслевым особенностям ОПК "
f"(ПП РФ № 796 от 27.06.2026): U_8 = U_у8 / R_год × 100%, U_у8 = C + A + X. "
f"Суммарные затраты на объект КИИ: C = {c_val:,.2f} руб. "
f"Затраты на возмещение ущерба потерпевшим: A = {a_val:,.2f} руб. "
f"Ущерб от ранее произошедших компьютерных инцидентов: X = {x_val:,.2f} руб. "
f"Ущерб субъекту КИИ: U_у8 = {c_val:,.2f} + {a_val:,.2f} + {x_val:,.2f} "
f"= {u_y8:,.2f} руб. "
f"Усреднённый годовой доход за 5-летний период: R_год = {r_avg:,.2f} руб. "
f"Показатель ущерба: U_8 = {u_y8:,.2f} / {r_avg:,.2f} × 100% = {val_pct:.6f} %.")
if cat:
text += (f" Так как {val_pct:.6f} % более 1% (пороговое значение для 3-й категории "
f"значимости), по данному показателю объекту присваивается {cat}-я категория значимости.")
else:
text += (f" Так как {val_pct:.6f} % менее 1% (пороговое значение для 3-й категории "
f"значимости), по данному показателю объекту категория значимости не присваивается.")
return text, cat
def _calc_criterion_13_opk(company, isys, num):
"""Показатели 13а/13б по отраслевым особенностям ОПК (ПП РФ № 796, п. 34, 38):
13а: V = (1 (V_max V_инц) / V_ГОЗ) × 100% (снижение объёма продукции ГОЗ)
13б: T = (1 (t_норм / t_ненорм)) × 100% (увеличение времени изготовления)
Значения берутся из criterion_input: 13a_vmax, 13a_vinc, 13a_vgoz;
13b_tnorm, 13b_tnonorm. Категория по порогам ПП-127 (CRITERION_QUANT_THRESHOLDS).
Возвращает (текст_обоснования, категория или None).
"""
crit = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
num = str(num)
if num == '13а':
vmax = _num(crit.get('13a_vmax', '') or '')
vinc = _num(crit.get('13a_vinc', '') or '')
vgoz = _num(crit.get('13a_vgoz', '') or '')
if vmax is None or vinc is None or not vgoz:
return ("Показатель 13а рассчитывается по отраслевым особенностям ОПК "
"(ПП РФ № 796): V = (1 (V_max V_инц) / V_ГОЗ) × 100%. "
"Заполните V_max, V_инц, V_ГОЗ (объёмы продукции ГОЗ).", None)
val = (1 - (vmax - vinc) / vgoz) * 100
formula = (f"V = (1 ({vmax:,.2f} {vinc:,.2f}) / {vgoz:,.2f}) × 100% = {val:.6f} %")
else: # 13б
tnorm = _num(crit.get('13b_tnorm', '') or '')
tnon = _num(crit.get('13b_tnonorm', '') or '')
if tnorm is None or tnon is None or tnon <= 0:
return ("Показатель 13б рассчитывается по отраслевым особенностям ОПК "
"(ПП РФ № 796): T = (1 − (t_норм / t_ненорм)) × 100%. "
"Заполните t_норм и t_ненорм (время операций по ГОЗ).", None)
val = (1 - tnorm / tnon) * 100
formula = (f"T = (1 ({tnorm:,.2f} / {tnon:,.2f})) × 100% = {val:.6f} %")
cat = _criterion_other_cat(num, val)
text = (f"Показатель {num} рассчитывается по отраслевым особенностям ОПК "
f"(ПП РФ № 796 от 27.06.2026): {formula}.")
if cat:
text += (f" По порогам ПП-127 это соответствует {cat}-й категории значимости.")
else:
text += " По порогам ПП-127 категория значимости не присваивается."
return text, cat
# Пороги показателя 10 и подпоказателей 10.1–10.7 (ПП-127, «Значение показателя»)
CRITERION_10_THRESHOLDS = {
# num: (категория → (нижняя граница, верхняя граница)), границы «включительно»
# Показатель 10 — среднедневное количество операций (млн ед.):
# III — менее или равно 70, II — более 70 но менее или равно 120, I — более 120.
'10': {'I': (120, None), 'II': (70, 120), 'III': (None, 70)},
'10.1': {'I': (10, None), 'II': (1, 10), 'III': (None, 1)}, # трлн руб.
'10.2': {'I': (25, None), 'II': (10, 25), 'III': (None, 10)}, # тыс. шт.
'10.3': {'I': (2000, None), 'II': (1000, 2000), 'III': (50, 1000)}, # млрд руб.
'10.4': {'I': (5000, None), 'II': (1500, 5000), 'III': (100, 1500)}, # млрд руб.
'10.5': {'I': (150, None), 'II': (100, 150), 'III': (25, 100)}, # договоров
'10.6': {'I': (5000, None), 'II': (1000, 5000), 'III': (100, 1000)}, # млрд руб.
'10.7': {'I': (500, None), 'II': (200, 500), 'III': (30, 200)}, # млн ед.
}
def _criterion_10_cat(num, value):
"""Категория значимости по показателю 10 / 10.x (ПП-127).
value численное значение показателя. Возвращает 'I'/'II'/'III' или None.
"""
thr = CRITERION_10_THRESHOLDS.get(str(num))
if not thr or value is None:
return None
for cat in ('I', 'II', 'III'):
lo, hi = thr[cat]
if lo is not None and value < lo:
continue
if hi is not None and value > hi:
continue
return cat
return None
def _calc_criterion_10(company, isys, num):
"""Расчёт показателя 10 (прекращение финансовых операций) и подпоказателей 10.1–10.7.
Показатель 10: среднедневное количество операций = операции за год (млн ед.) / дни.
Подпоказатели 10.x: значение показателя (в своих единицах) сравнивается с порогами.
Возвращает (текст_обоснования, категория или None).
"""
num = str(num)
if num not in CRITERION_10_THRESHOLDS:
return None, None
if num == '10':
ops = _num(getattr(company, 'ops_count', '') or '')
days = _num(getattr(company, 'calendar_days', '') or '')
if days is None or days <= 0:
days = 365
if ops is None:
return ("Показатель применим. Для расчёта заполните данные организации: "
"«Количество операций за прошедший год (млн ед.)».", None)
val = ops / days
cat = _criterion_10_cat('10', val)
text = (f"Показатель прекращения финансовых операций оценивается среднедневным количеством "
f"операций, проведенных клиентами по банковским счетам и (или) без открытия банковского "
f"счета. Количество операций за прошедший год: {ops:,.2f} млн ед. Количество календарных "
f"дней в году: {days:.0f}. Среднедневное количество операций: {ops:,.2f} / {days:.0f} "
f"= {val:.6f} млн ед./день.")
else:
value = _num(getattr(company, 'criterion10_value', '') or '')
if value is None:
return (f"Показатель применим. Для расчёта заполните значение показателя "
f"«{num}» в данных организации.", None)
val = value
cat = _criterion_10_cat(num, val)
text = (f"Показатель {num} оценивается значением показателя: {val:,.6f} "
f"(в единицах, установленных ПП-127).")
if cat:
text += (f" Согласно Постановлению № 127 полученное значение соответствует "
f"{cat}-й категории значимости, по данному показателю объекту присваивается "
f"{cat}-я категория значимости.")
else:
text += (" Значение не достигает порога III категории — по данному показателю "
"категория значимости не присваивается.")
return text, cat
# Пороги количественных показателей 1–7, 11–14 (ПП-127), кроме 8/9/10:
# num → [(категория, lo, lo_incl, hi, hi_incl)] — значение соответствует, если
# (lo is None или value > lo при not lo_incl / value >= lo при lo_incl) и
# (hi is None или value < hi при not hi_incl / value <= hi при hi_incl).
CRITERION_QUANT_THRESHOLDS = {
'1': [('III', 1, True, 50, True), ('II', 50, False, 500, True), ('I', 500, False, None, False)], # человек
'2б': [('III', 2, True, 1000, False), ('II', 1000, True, 5000, False), ('I', 5000, True, None, False)], # тыс. чел.
'3б': [('III', 2, True, 1000, False), ('II', 1000, True, 5000, False), ('I', 5000, True, None, False)], # тыс. чел.
'4а': [('III', 3, True, 1000, False), ('II', 1000, True, 5000, False), ('I', 5000, True, None, False)], # тыс. абонентов
'5а': [('III', 12, False, 24, True), ('II', 6, False, 12, True), ('I', None, False, 6, True)], # часов
'5б': [('III', None, False, 30, True), ('II', 30, False, 70, True), ('I', 70, False, None, False)], # %
'11б': [('III', 2, True, 1000, False), ('II', 1000, True, 5000, False), ('I', 5000, True, None, False)], # тыс. чел.
'13а': [('III', 0, False, 10, True), ('II', 10, False, 15, True), ('I', 15, False, None, False)], # %
'13б': [('III', 0, False, 10, True), ('II', 10, False, 40, True), ('I', 40, False, None, False)], # %
'14': [('III', 2, False, 4, True), ('II', 1, False, 2, True), ('I', None, False, 1, True)], # часов
}
# Качественные показатели (выбор одного из вариантов III/II/I): 2а, 3а, 3в, 3г, 4б, 6, 7, 11а, 12, 13.1
CRITERION_CHOICE_INDICATORS = {'2а', '3а', '', '3г', '4б', '6', '7', '11а', '12', '13.1'}
# Единицы измерения количественных показателей (для подписи в окне авторасчёта)
CRITERION_QUANT_UNITS = {
'1': 'человек', '2б': 'тыс. человек', '3б': 'тыс. человек', '4а': 'тыс. абонентов',
'5а': 'часов', '5б': '%', '11б': 'тыс. человек', '13а': '%', '13б': '%', '14': 'часов',
}
def _criterion_other_cat(num, value):
"""Категория количественного показателя (кроме 8/9/10) по значению (ПП-127)."""
rules = CRITERION_QUANT_THRESHOLDS.get(str(num))
if not rules or value is None:
return None
for cat, lo, lo_incl, hi, hi_incl in rules:
ok = True
if lo is not None:
ok = ok and ((value >= lo) if lo_incl else (value > lo))
if hi is not None:
ok = ok and ((value <= hi) if hi_incl else (value < hi))
if ok:
return cat
return None
def _calc_criterion_other(company, isys, num, value=None, choice=None):
"""Расчёт остальных показателей критериев значимости (1–7, 11–14).
Количественные (1, 2б, 3б, 4а, 5а, 5б, 11б, 13а, 13б, 14): value число,
категория по порогам ПП-127 (CRITERION_QUANT_THRESHOLDS).
Качественные (2а, 3а, 3в, 3г, 4б, 6, 7, 11а, 12, 13.1): choice текст
выбранного варианта из criteria_127.json (values), категория его ключ.
Возвращает (текст_обоснования, категория или None).
"""
num = str(num)
if num in CRITERION_CHOICE_INDICATORS:
# Качественный показатель: выбираем один из вариантов III/II/I
criteria = _load_json('criteria_127.json')
for grp in (criteria if isinstance(criteria, list) else []):
for ind in grp.get('indicators', []):
if str(ind.get('num', '')) == num:
vals = ind.get('values', {}) or {}
if not choice:
return (f"Показатель применим. Для расчёта выберите один из вариантов "
f"(III/II/I категория) в окне авторасчёта.", None)
cat = None
for c, txt in vals.items():
if txt and str(txt).strip() == str(choice).strip():
cat = c
break
if not cat:
return (f"Показатель применим. Выбранный вариант не найден в перечне значений.", None)
text = (f"Показатель {num}: {choice}. Согласно Постановлению № 127 данному значению "
f"соответствует {cat}-я категория значимости.")
return text, cat
return (f"Показатель применим. Варианты значений не найдены в справочнике.", None)
# Количественный показатель
if value is None:
unit = CRITERION_QUANT_UNITS.get(num, '')
return (f"Показатель применим. Для расчёта введите значение показателя"
+ (f" ({unit})" if unit else "") + ".", None)
cat = _criterion_other_cat(num, value)
unit = CRITERION_QUANT_UNITS.get(num, '')
text = (f"Показатель {num}: значение показателя — {value:,.6f} {unit}. "
f"Согласно Постановлению № 127 ")
if cat:
text += (f"данному значению соответствует {cat}-я категория значимости.")
else:
text += ("значение не достигает порога III категории — категория значимости не присваивается.")
return text, cat
def _build_org_measures_text(company, isys=None):
"""Формирует текст организационных мер из словаря org_measures.json
с учётом ответов раздела «Физическая безопасность» опросника.
Если в опроснике указано «нет» (охрана, сигнализация, тревожная кнопка,
видеонаблюдение, металлические двери и т.п.) соответствующие пункты
из текста убираются. Пункты, не привязанные к средствам, остаются всегда.
"""
import os as _os
items = _load_json('org_measures.json')
if not isinstance(items, list):
items = []
ps = getattr(company, 'physical_security', None) or {}
def _no(*keys):
"""True, если хотя бы по одному ключу явно указано «нет»/«не имеется»."""
for k in keys:
v = str(ps.get(k) or '').strip().lower()
if v in ('нет', 'не имеется', 'нет,', '0', 'false'):
return True
return False
rules = [
# (подстрока пункта, ключи physical_security, при которых пункт УБИРАЕМ)
('Физическая охрана периметра', ('physicalGuard', 'physicalTurnstile', 'physicalVisitorLog')),
('Имеется система видеонаблюдения', ('physicalVideoSurveillance',)),
('Здания не имеют окон', ('physicalMetalDoors',)),
('охранно-пожарная сигнализация и тревожная кнопка', ('physicalFireAlarm', 'physicalPanicButton')),
('охранно-пожарная сигнализация', ('physicalFireAlarm',)),
('тревожная кнопка', ('physicalPanicButton',)),
('турникетом на проходной', ('physicalTurnstile',)),
('СКУД', ('physicalTurnstile',)),
('учет посетителей', ('physicalVisitorLog',)),
]
out = []
for line in items:
line = str(line).strip()
if not line:
continue
drop = False
for sub, keys in rules:
if sub in line and _no(*keys):
drop = True
break
if not drop:
out.append(line)
return '\n'.join(out) if out else ''
def _build_categorization_indicators(company, is_list):
"""Строки таблицы показателей критериев значимости для шаблона
«Сведения о результатах категорирования» (цикл categorization_indicators).
@@ -530,6 +874,9 @@ def _build_categorization_indicators(company, is_list):
isys = is_list[0] if is_list else None
rows = []
first_cat_num = None # номер показателя, по которому объект отнесён к I категории (п. 6 ПП-127)
# Введённые значения показателей (из опросника/окна авторасчёта): {num: значение}
crit_in = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
for grp in criteria:
section = grp.get('criteria', '')
for ind in grp.get('indicators', []):
@@ -542,23 +889,86 @@ def _build_categorization_indicators(company, is_list):
value = ''
justification = ''
category = ''
if not applicable:
if first_cat_num is not None:
# Остановка расчёта: объект уже отнесён к I категории по одному из показателей
# (п. 6 Правил, утв. ПП РФ № 127: «расчет по остальным показателям не проводится»).
if applicable:
value = ''
justification = (f"Расчёт по данному показателю не проводится: объект отнесён к "
f"I категории значимости по показателю {first_cat_num} "
f"(п. 6 Правил категорирования, утв. ПП РФ № 127).")
category = ''
else:
value = 'Показатель не применим.'
justification = (just.get(num) or just.get(grp_num)
or f"Показатель не применим к объекту КИИ организации сферы «{sphere or ''}».")
elif not applicable:
value = 'Показатель не применим.'
justification = (just.get(num) or just.get(grp_num)
or f"Показатель не применим к объекту КИИ организации сферы «{sphere or ''}».")
elif grp_num in ('8', '9'):
text, cat = _calc_criterion_8_9(company, isys, grp_num)
if grp_num == '8' and sphere == 'Оборонная промышленность':
# ОПК: U_8 = (C+A+X)/R_год×100% (ПП РФ № 796, п. 1820)
text, cat = _calc_criterion_8_opk(company, isys)
else:
text, cat = _calc_criterion_8_9(company, isys, grp_num)
if text:
value = text
if cat:
category = f"{cat}-я категория"
else:
category = ''
elif grp_num == '10':
text, cat = _calc_criterion_10(company, isys, num)
if text:
value = text
if cat:
category = f"{cat}-я категория"
else:
category = ''
elif num in ('13а', '13б') and sphere == 'Оборонная промышленность':
# ОПК: V = (1(V_maxV_инц)/V_ГОЗ)×100%, T = (1(t_норм/t_ненорм))×100%
# (ПП РФ № 796, п. 34, 38) — при наличии введённых составляющих
_opk_crit = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
_opk_ready = bool(_opk_crit.get('13a_vmax', '') or _opk_crit.get('13a_vinc', '') or _opk_crit.get('13a_vgoz', '') or _opk_crit.get('13b_tnorm', '') or _opk_crit.get('13b_tnonorm', ''))
if _opk_ready:
text, cat = _calc_criterion_13_opk(company, isys, num)
if text:
value = text
if cat:
category = f"{cat}-я категория"
else:
category = ''
else:
value = ''
justification = (just.get(num) or just.get(grp_num)
or 'Показатель применим к объекту КИИ; значение определяется по данным объекта (для ОПК — по формулам ПП РФ № 796: V = (1(V_maxV_инц)/V_ГОЗ)×100%, T = (1(t_норм/t_ненорм))×100%).')
else:
# Применимый показатель без авторасчёта — оператор заполняет значение
value = ''
justification = (just.get(num) or just.get(grp_num)
or 'Показатель применим к объекту КИИ; значение определяется по данным объекта.')
# Применимый показатель без формульного авторасчёта (1–7, 11–14):
# если значение введено (опросник/окно авторасчёта) — считаем категорию по порогам
if num in crit_in and str(crit_in.get(num, '')).strip():
raw = str(crit_in.get(num, '')).strip()
# Число или выбранный вариант (качественный)
try:
_v = float(raw.replace(' ', '').replace(',', '.').replace('\u00a0', ''))
text, cat = _calc_criterion_other(company, isys, num, value=_v)
except ValueError:
text, cat = _calc_criterion_other(company, isys, num, choice=raw)
if text:
value = text
if cat:
category = f"{cat}-я категория"
else:
category = ''
else:
value = ''
justification = (just.get(num) or just.get(grp_num)
or 'Показатель применим к объекту КИИ; значение определяется по данным объекта.')
# Если по показателю присвоена I категория — фиксируем для остановки расчёта
# (точное совпадение «I-я категория», а не startswith('I'), иначе ловится II/III)
if first_cat_num is None and category.startswith('I-я'):
first_cat_num = num
rows.append({
'section': section,
+61 -10
View File
@@ -161,15 +161,20 @@ def normalize_address(text):
def resolve_okved(code, dadata_token=None):
"""Код ОКВЭД → «код - название».
"""Код ОКВЭД → «код название» (без дефиса), например «27.32.2 Производство силовых кабелей».
Принимает и голый код («27.32.2»), и строку с названием
(«27.32.2 - Производство силовых кабелей» / «27.32.2 Производство силовых кабелей»).
1) Ищем в словаре dictionaries/okved.json (строки вида «27.32.2 Название»).
2) Если не нашли запрашиваем DaData suggest okved2 (если есть токен).
3) Иначе возвращаем код как есть.
3) Иначе возвращаем нормализованную строку «код название» как есть.
"""
code = (code or '').strip()
if not code:
return ''
# Извлекаем код из входной строки: «27.32.2 - Производство силовых кабелей» → «27.32.2»
m = re.match(r'^\s*(\d+(?:\.\d+)*)', code)
code_only = m.group(1) if m else code.split()[0].rstrip('.')
# 1) Словарь
try:
import os, json
@@ -178,12 +183,8 @@ def resolve_okved(code, dadata_token=None):
items = json.load(f)
for item in items:
item = str(item).strip()
# совпадение по началу: «27.32.2» или «27.32.2 - ...» / «27.32.2 ...»
if item.split()[0].rstrip('.') == code.rstrip('.'):
# нормализуем: «код - название»
rest = item[len(item.split()[0]):].strip().lstrip('- ').strip()
if rest:
return f"{code} - {rest}"
# совпадение по коду: «27.32.2» или «27.32.2 Название»
if item.split()[0].rstrip('.') == code_only.rstrip('.'):
return item
except Exception:
pass
@@ -191,7 +192,7 @@ def resolve_okved(code, dadata_token=None):
if dadata_token:
try:
js, err = _http_json('https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/okved2',
data={'query': code, 'count': 1},
data={'query': code_only, 'count': 1},
headers={'Content-Type': 'application/json',
'Authorization': f'Token {dadata_token}'})
if not err and js:
@@ -200,7 +201,12 @@ def resolve_okved(code, dadata_token=None):
return sugg[0]['value']
except Exception:
pass
return code
# 3) Нормализуем вход: «27.32.2 - Название» → «27.32.2 Название» (без дефиса)
rest = code[len(m.group(1)):].strip() if m else ''
rest = rest.lstrip('- ').strip()
if rest:
return f"{code_only} {rest}"
return code_only
def _fmt_date(value):
@@ -538,6 +544,51 @@ def _norm_threat_text(text):
return s.lower()
def normalize_threats(text):
"""Склеить переносы внутри названий угроз.
В опросниках встречается «УБИ.209 Угроза памяти\r\nядра процессора»
перенос строки внутри одного названия. Строка, не начинающаяся с «УБИ.»,
считается продолжением предыдущей угрозы.
"""
if not text:
return ''
lines = [ln.strip() for ln in str(text).split('\n') if ln.strip()]
out = []
for ln in lines:
if re.match(r'^уби\.?\s*\d+', ln, re.IGNORECASE):
out.append(ln)
elif out:
out[-1] = out[-1] + ' ' + ln
else:
out.append(ln)
return '\n'.join(out)
def split_okved_extra(value):
"""Разбить okvedExtra (строка или список строк с переносами) на записи «код название».
В опросниках доп. ОКВЭД часто идут склеенными:
«25.93 Производство изделий из проволоки\nцепей и пружин 32.12.1 »
Здесь «цепей и пружин» продолжение названия 25.93, а 32.12.1 новый код.
Нарезаем по кодам ОКВЭД (двузначный код с точками в начале записи).
"""
if isinstance(value, (list, tuple)):
text = ' '.join(str(x) for x in value)
else:
text = str(value or '')
text = text.replace('\r', ' ').replace('\n', ' ')
# Нарезаем по началу кода ОКВЭД: «25.93», «32.12.1», «43.2» …
# Перед кодом не должно быть цифры или точки (чтобы не разрезать «25.93» на «25» и «93»)
parts = re.split(r'(?<!\d)(?<!\.)(?=\d{2}(?:\.\d+)*\b)', text)
out = []
for p in parts:
p = p.strip().strip('-—– ').strip()
if p and re.match(r'^\d{2}(?:\.\d+)*', p):
out.append(p)
return out
def filter_threats_by_dict(lines, threats_full=None):
"""Приводит строки угроз (от ИИ или оператора) к каноническому виду по словарю УБИ.