From 9f8bf3999ddd57e8085541b18748b612d84f5939 Mon Sep 17 00:00:00 2001 From: LocoAgent Date: Wed, 5 Aug 2026 14:39:03 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A2=D0=B5=D1=85.=20=D0=BC=D0=B5=D1=80=D1=8B:?= =?UTF-8?q?=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=81=D0=B2=D0=BE=D0=B5=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D0=BE=20=D1=84=D0=B0=D0=BA=D1=82=D1=83=20?= =?UTF-8?q?=D0=A1=D0=97=D0=98=20(=D0=B1=D0=B5=D0=B7=20=D0=A1=D0=97=D0=98?= =?UTF-8?q?=20=D0=BD=D0=B5=D1=82=20=C2=AB=D0=A0=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=D0=B0=C2=BB/?= =?UTF-8?q?=C2=AB=D0=90=D0=BD=D1=82=D0=B8=D0=B2=D0=B8=D1=80=D1=83=D1=81?= =?UTF-8?q?=D0=B0=C2=BB);=20=D0=BF=D1=80=D0=BE=D0=BC=D1=82=20=D0=A1=D0=97?= =?UTF-8?q?=D0=98:=20=D0=BF=D1=80=D0=B8=20=C2=AB=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B8=C2=BB=20?= =?UTF-8?q?=E2=80=94=20=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D0=BC=D1=83=D0=BC=20+=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=81=D1=82=D0=B8=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5,?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B0=D0=BD=D1=8B=20=D1=85?= =?UTF-8?q?=D0=B0=D1=80=D0=B0=D0=BA=D1=82=D0=B5=D1=80=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=B0?= =?UTF-8?q?=20(=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D0=BD=D0=B5=D1=82/=D0=9F?= =?UTF-8?q?=D0=9E/=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0);=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=2010=20=D0=B8=2010.1=E2=80=9310.7:=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=D0=B8=D1=87=D0=BA=D0=B5?= =?UTF-8?q?=20=D0=A4=D0=A1=D0=A2=D0=AD=D0=9A=20(=D0=BE=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B7=D0=B0=20=D0=B3=D0=BE=D0=B4=20?= =?UTF-8?q?/=20=D0=B4=D0=BD=D0=B8)=20=D0=B8=20=D0=BF=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=D0=BC=20=D0=9F=D0=9F-127?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dictionaries/prompts/suggest_defense.json | 2 +- dokogen/models.py | 5 + dokogen/threat_rules.py | 48 +++++++--- dokogen/ui.py | 95 ++++++++++++++++++- dokogen/variables.py | 86 +++++++++++++++++ 5 files changed, 218 insertions(+), 18 deletions(-) diff --git a/dokogen/dictionaries/prompts/suggest_defense.json b/dokogen/dictionaries/prompts/suggest_defense.json index 6a2c72b..ed65c69 100644 --- a/dokogen/dictionaries/prompts/suggest_defense.json +++ b/dokogen/dictionaries/prompts/suggest_defense.json @@ -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, если текущий набор СЗИ покрывает требования категории. Не придумывай конкретные торговые марки, если их нет в списке — указывай класс средства." } diff --git a/dokogen/models.py b/dokogen/models.py index fb2ac2e..e58cca9 100644 --- a/dokogen/models.py +++ b/dokogen/models.py @@ -144,6 +144,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) diff --git a/dokogen/threat_rules.py b/dokogen/threat_rules.py index 5eb929f..52fd793 100644 --- a/dokogen/threat_rules.py +++ b/dokogen/threat_rules.py @@ -298,19 +298,18 @@ BASE_ORG_MEASURES = [ 'Утверждение перечня лиц, имеющих доступ к объекту', ] -# Базовый набор тех. мер +# Базовый набор тех. мер — только универсальные, не требующие конкретных СЗИ. +# Меры, зависящие от средств защиты (антивирус, СЗИ от НСД, регистрация событий +# и т.п.), добавляются отдельно по факту наличия СЗИ (DEFENSE_TOOL_MEASURES). BASE_TECH_MEASURES = [ 'Идентификация и аутентификация пользователей', 'Управление доступом', 'Ограничение программной среды', - 'Антивирусная защита', - 'Защита информации от несанкционированного доступа (СЗИ от НСД)', - 'Регистрация и мониторинг событий безопасности', 'Резервное копирование и восстановление', 'Защита технических средств и линий связи', ] -# Средства защиты → доп. тех. меры +# Средства защиты → доп. тех. меры (по факту наличия СЗИ у объекта) DEFENSE_TOOL_MEASURES = [ ('межсетев', 'Межсетевое экранирование'), ('firewall', 'Межсетевое экранирование'), @@ -324,6 +323,23 @@ DEFENSE_TOOL_MEASURES = [ ('виртуализ', 'Защита среды виртуализации'), ('vmware', 'Защита среды виртуализации'), ('hyper-v', 'Защита среды виртуализации'), + ('антивирус', 'Антивирусная защита'), + ('kaspersky', 'Антивирусная защита'), + ('eset', 'Антивирусная защита'), + ('dr.web', 'Антивирусная защита'), + ('аваст', 'Антивирусная защита'), + ('avast', 'Антивирусная защита'), + ('сзи', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'), + ('от нсд', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'), + ('dallas', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'), + ('secret net', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'), + ('аккорд', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'), + ('siem', 'Регистрация и мониторинг событий безопасности'), + ('dlp', 'Регистрация и мониторинг событий безопасности'), + ('мониторинг', 'Регистрация и мониторинг событий безопасности'), + ('регистрац', 'Регистрация и мониторинг событий безопасности'), + ('доверенная загрузка', 'Контроль (доверенная) загрузки'), + ('резервн', 'Резервное копирование и восстановление'), ] @@ -395,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 diff --git a/dokogen/ui.py b/dokogen/ui.py index 7771309..e29b1e7 100644 --- a/dokogen/ui.py +++ b/dokogen/ui.py @@ -2241,7 +2241,52 @@ 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 == '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" @@ -2353,7 +2398,34 @@ class DokoGenApp: def _calc(): lines = [] - if num == '8': + if num == '10' or str(num).startswith('10.'): + from .variables import _criterion_10_cat + 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': d = _num(e_dincome.get()) b = _num(e_budget.get()) if d is None or b is None or b <= 0: @@ -2505,8 +2577,9 @@ class DokoGenApp: combo.pack(side=tk.RIGHT, padx=6) _combos[num] = combo - # Кнопка «🧮» — авторасчёт показателя (п. 8 «ущерб субъекту КИИ» и п. 9 «ущерб бюджетам») - if grp_num in ('8', '9'): + # Кнопка «🧮» — авторасчёт показателя (п. 8 «ущерб субъекту КИИ», + # п. 9 «ущерб бюджетам», п. 10 «прекращение финансовых операций») + if grp_num in ('8', '9', '10'): ttk.Button(row, text='🧮', width=3, command=lambda n=num, nm=name, v=var, c=combo: _open_calc_dialog(n, nm, v, c) @@ -3773,6 +3846,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 [], @@ -3838,6 +3918,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 [], diff --git a/dokogen/variables.py b/dokogen/variables.py index c96f764..e14c4d2 100644 --- a/dokogen/variables.py +++ b/dokogen/variables.py @@ -518,6 +518,84 @@ def _calc_criterion_8_9(company, isys, num): 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 + + def _build_org_measures_text(company, isys=None): """Формирует текст организационных мер из словаря org_measures.json с учётом ответов раздела «Физическая безопасность» опросника. @@ -617,6 +695,14 @@ def _build_categorization_indicators(company, is_list): 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 = '—' else: # Применимый показатель без авторасчёта — оператор заполняет значение value = '—'