# -*- coding: utf-8 -*- """DokoGen — Справочник переменных шаблонов и резолвер значений""" from datetime import datetime from typing import Dict, Any, List, Optional import re from . import declension as dc from .models import Company, CommissionMember def _norm_name(s): """Нормализация ФИО для сравнения: регистр, пробелы, точки, тире игнорируются.""" s = (s or '').lower().replace('ё', 'е') return re.sub(r'[\s.,;:()«»"\'\-]+', '', s) # ============================================================ # ОПИСАНИЕ ПЕРЕМЕННЫХ # ============================================================ # Формат: (имя_переменной, группа, описание, ключ_резолвера) # Группы: A — общие, B — 187-ФЗ VARIABLE_DEFS = [ # === Полное название организации (6 падежей) === ('{{company_full_name}}', 'A', 'Полное наименование организации (им.п.)', 'company:full_name'), ('{{company_full_name_genitive}}', 'A', 'Полное наименование (род.п.)', 'decl:full_name:gent'), ('{{company_full_name_dative}}', 'A', 'Полное наименование (дат.п.)', 'decl:full_name:datv'), ('{{company_full_name_accs}}', 'A', 'Полное наименование (вин.п.)', 'decl:full_name:accs'), ('{{company_full_name_ablt}}', 'A', 'Полное наименование (твор.п.)', 'decl:full_name:ablt'), ('{{company_full_name_loct}}', 'A', 'Полное наименование (пр.п.)', 'decl:full_name:loct'), # === Краткое название организации (6 падежей) === ('{{company_short_name}}', 'A', 'Краткое наименование (им.п.)', 'company:short_name'), ('{{company_short_name_genitive}}', 'A', 'Краткое наименование (род.п.)', 'decl:short_name:gent'), ('{{company_short_name_dative}}', 'A', 'Краткое наименование (дат.п.)', 'decl:short_name:datv'), ('{{company_short_name_accs}}', 'A', 'Краткое наименование (вин.п.)', 'decl:short_name:accs'), ('{{company_short_name_ablt}}', 'A', 'Краткое наименование (твор.п.)', 'decl:short_name:ablt'), ('{{company_short_name_loct}}', 'A', 'Краткое наименование (пр.п.)', 'decl:short_name:loct'), # === Должностные лица === ('{{chief_position}}', 'A', 'Должность руководителя', 'company:chief_position'), ('{{chief_fio_initials}}', 'A', 'И.О. Фамилия руководителя', 'initials:chief'), ('{{chief_fio_short}}', 'A', 'Фамилия И.О. руководителя', 'short_fio:chief'), ('{{responsible_position}}', 'A', 'Должность ответственного', 'company:responsible_position'), ('{{responsible_fio_initials}}', 'A', 'И.О. Фамилия ответственного', 'initials:responsible'), ('{{responsible_fio_short}}', 'A', 'Фамилия И.О. ответственного', 'short_fio:responsible'), ('{{admin_position}}', 'A', 'Должность администратора', 'company:admin_position'), ('{{admin_fio_initials}}', 'A', 'И.О. Фамилия администратора', 'initials:admin'), ('{{admin_fio_short}}', 'A', 'Фамилия И.О. администратора', 'short_fio:admin'), # === Сведения об организации === ('{{company_address}}', 'A', 'Адрес организации', 'company:address'), ('{{city_name}}', 'A', 'Город', 'company:city_name'), ('{{company_inn}}', 'A', 'ИНН', 'company:inn'), ('{{company_ogrn}}', 'A', 'ОГРН', 'company:ogrn'), ('{{company_ogrn_date}}', 'A', 'Дата ОГРН', 'company:ogrn_date'), ('{{company_kpp}}', 'A', 'КПП', 'company:kpp'), ('{{security_department}}', 'A', 'Наименование структурного подразделения по безопасности', 'company:security_department'), ('{{company_okved}}', 'A', 'Основной ОКВЭД', 'company:okved'), ('{{company_okved_extra}}', 'A', 'Дополнительные ОКВЭД', 'company:okved_extra'), ('{{company_profit}}', 'A', 'Сумма прибыли за предыдущий год (руб.)', 'company:profit'), ('{{company_profit_tax}}', 'A', 'Сумма налога с прибыли (руб.)', 'company:profit_tax'), ('{{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'), ('{{company_income_year_5}}', 'A', 'Доход за прошлый год (руб.) — показатель 8', 'company:income_year_5'), ('{{company_income_loss}}', 'A', 'Суммарный экономический ущерб от нарушения критических процессов (руб.) — показатель 8', 'company:income_loss'), # === Комиссия === ('{{commission_list}}', 'A', 'Список комиссии', 'commission_list'), ('{{fstek_district}}', 'A', 'Наименование управления ФСТЭК России (по адресу организации)', 'fstek_district'), ('{{fstek_recipient_address}}', 'A', 'Адрес управления ФСТЭК России для направления сведений', 'fstek_recipient_address'), # === Специальные переменные === ('{{date_year}}', 'A', 'Текущий год', 'date:year'), ('{{date}}', 'A', 'Текущая дата', 'date:full'), ('{{document_date}}', 'A', 'Дата документа', 'doc_date'), ('{{document_number}}', 'A', 'Номер документа', 'doc_number'), # === 187-ФЗ === ('{{is_security_requirements}}', 'B', 'Требования к защите объекта КИИ', 'security_reqs'), ('{{is_list}}', 'B', 'Список всех объектов КИИ (нумерованный)', 'is_list'), ('{{is_name}}', 'B', 'Наименование первого объекта КИИ', 'is:name'), ('{{name}}', 'B', 'Название объекта КИИ (алиас)', 'is:name'), ('{{description}}', 'B', 'Описание объекта КИИ (алиас)', 'is:description'), ('{{category}}', 'B', 'Категория значимости объекта КИИ (алиас)', 'is:category'), ('{{number}}', 'B', 'Номер (алиас)', 'doc_number'), ('{{users}}', 'B', 'Пользователи ОКИИ (алиас)', 'is:users'), ('{{full_name_genitive}}', 'A', 'Полное наименование (род.п., алиас)', 'decl:full_name:gent'), ('{{responsible_fio_accs}}', 'A', 'ФИО ответственного (вин.п.)', 'fio_acl:responsible'), ('{{commission1}}', 'A', 'Должность члена комиссии 1', 'commission_member:0:position'), ('{{commission1_fio}}', 'A', 'ФИО члена комиссии 1', 'commission_member:0:fio'), ('{{commission2}}', 'A', 'Должность члена комиссии 2', 'commission_member:1:position'), ('{{commission2_fio}}', 'A', 'ФИО члена комиссии 2', 'commission_member:1:fio'), ('{{commission3}}', 'A', 'Должность члена комиссии 3', 'commission_member:2:position'), ('{{commission3_fio}}', 'A', 'ФИО члена комиссии 3', 'commission_member:2:fio'), ] # Маппинг старых переменных <...> → {{...}} OLD_VAR_MAP = { '': '{{company_full_name}}', '': '{{company_short_name}}', '': '{{company_full_name_genitive}}', '': '{{company_short_name_genitive}}', '': '{{company_full_name_dative}}', '': '{{company_short_name_dative}}', '': '{{company_full_name_accs}}', '': '{{company_short_name_accs}}', '': '{{company_full_name_ablt}}', '': '{{company_short_name_ablt}}', '': '{{company_full_name_loct}}', '': '{{company_short_name_loct}}', '
': '{{company_address}}', '': '{{company_inn}}', '': '{{company_ogrn}}', '': '{{company_kpp}}', '': '{{chief_fio_initials}}', '': '{{chief_fio_short}}', '': '{{chief_position}}', '': '{{responsible_fio_initials}}', '': '{{responsible_fio_short}}', '': '{{responsible_position}}', '': '{{commission_list}}', '': '{{document_number}}', '': '{{document_date}}', '': '{{date}}', '': '{{document_date}}', } VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}') OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>') # ============================================================ # ПОСТРОЕНИЕ СЛОВАРЯ ЗАМЕН # ============================================================ def build_replacements( company: Company, is_list: List = None, doc_number: str = "", doc_date: str = "", direction: str = "187fz", ) -> Dict[str, str]: """Строит полный словарь замен {{переменная}} → значение.""" if is_list is None: is_list = [] # ---------- Комиссия ---------- commission_data = _build_commission_data(company) # ---------- Контекст ---------- ctx = { 'company': company, 'is_list': is_list, 'commission_data': commission_data, 'doc_number': doc_number or '', 'doc_date': doc_date or '', 'direction': direction, } replacements = {} for var_name, group, desc, source in VARIABLE_DEFS: if direction == '187fz' and group not in ('A', 'B'): continue value = _resolve(source, ctx) replacements[var_name] = str(value) if value else '—' # Индексированные переменные ИС (is_name_1, is_pd_list_2 и т.д.) for i, isys in enumerate(is_list, 1): _add_is_indexed_vars(replacements, isys, i) # Циклы replacements['_loops'] = _build_loop_data(company, is_list) return replacements def _commission_role_display(members) -> List[str]: """Группирует роли комиссии для вывода: - Председатель/Секретарь/Заместитель — отдельной строкой со своей ролью; - обычные члены — первый получает «Члены комиссии», остальные — пустую роль (чтобы не повторять «Член комиссии» в каждой строке).""" result = [] member_used = False for m in members: role = (m.role or 'член комиссии').strip() role_lower = role.lower() if ('председател' in role_lower or 'секретар' in role_lower or 'заместител' in role_lower or 'зам. председателя' in role_lower): result.append(role) continue # обычный член комиссии if not member_used: result.append('Члены комиссии') member_used = True else: result.append('') return result def _build_commission_data(company: Company) -> Dict[str, str]: data = {} members = company.commission or [] display_roles = _commission_role_display(members) lines = [] for i, (m, role_disp) in enumerate(zip(members, display_roles), 1): if role_disp: line = (f"{i}. {role_disp}: {m.position} — {m.fio}" if m.position else f"{i}. {role_disp}: {m.fio}") else: line = (f"{i}. {m.position} — {m.fio}" if m.position else f"{i}. {m.fio}") lines.append(line) data['commission_list'] = '\n'.join(lines) if lines else '—' return data def _add_is_indexed_vars(replacements: Dict[str, str], isys, idx: int): fields = { 'is_name': isys.name or '—', 'is_address': getattr(isys, 'address', '') or '—', 'is_description': isys.description or '—', 'is_has_internet': 'да' if isys.is_internet else 'нет', 'is_category': isys.category or '—', 'is_users': ', '.join(isys.users_list or []) or '—', 'is_purpose': isys.purpose or '—', 'is_structure': getattr(isys, 'structure', '') or '—', 'is_software': getattr(isys, 'software', '') or '—', 'is_sphere': getattr(isys, 'sphere', '') or '—', 'is_object_type': getattr(isys, 'object_type', '') or '—', 'is_architecture': getattr(isys, 'architecture', '') or '—', 'is_typical_object': getattr(isys, 'typical_object', '') or '—', 'is_net_category': getattr(isys, 'net_category', '') or '—', 'is_net_operator': getattr(isys, 'net_operator', '') or '—', 'is_net_purpose': getattr(isys, 'net_purpose', '') or '—', 'is_net_method': getattr(isys, 'net_method', '') or '—', 'is_hw_sw': getattr(isys, 'hw_sw', '') or '—', 'is_os_sw': getattr(isys, 'os_sw', '') or '—', 'is_app_sw': getattr(isys, 'app_sw', '') or '—', } for f, v in fields.items(): replacements[f'{{{{{f}_{idx}}}}}'] = str(v) def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]: loops = {} # Комиссия members = company.commission or [] if members: display_roles = _commission_role_display(members) items = [] for m, role_disp in zip(members, display_roles): pos = m.position or '—' fio = m.fio or '—' if role_disp: full = f"{role_disp} {pos} — {fio}" if pos != '—' else f"{role_disp} — {fio}" else: full = f"{pos} — {fio}" if pos != '—' else fio # Строка подписи: «___________ / » (только подчёркивание и слэш). # Роль НЕ включаем — в шаблонах она выводится отдельной колонкой {{item.role}}, # иначе получается дубль «Председатель комиссии | Председатель комиссии: ___». if 'Председатель' in role_disp: sig = f"{'_' * 11} / " sig_full = f"{role_disp}: {'_' * 11} / " elif role_disp: sig = f"{'_' * 17} / " sig_full = f"{role_disp}: {'_' * 17} / " else: sig = f"{'_' * 17} / " sig_full = f"{'_' * 17} / " initials = dc.get_initials(fio) if fio != '—' else '' items.append({ 'role': role_disp, 'position': pos, 'fio': fio, 'fio_initials': dc.get_initials(fio) if fio != '—' else '', 'fio_short': dc.get_short_fio(fio) if fio != '—' else '', 'full': full, # Рамка подписи без роли и ФИО: «___________ / » (ФИО добавится) 'signature': sig, # Полная подпись с ролью и ФИО: «Роль: ___________ / И.О. Фамилия» 'signature_full': (sig_full + initials).strip(), }) loops['commission'] = items # ИС if is_list: items = [] for isys in is_list: # Пользователи ИС для вложенного цикла # ВСЕ пользователи, включая администратора (без дублей по ФИО) seen = set() users_loop = [] for u in (isys.users_list or []): key = _norm_name(u) if key and key in seen: continue seen.add(key) users_loop.append({'fio': u, 'position': ''}) items.append({ 'name': isys.name or '—', 'description': isys.description or '—', 'address': getattr(isys, 'address', '') or '—', 'is_has_lan': 'Да' if isys.is_local_network else 'Нет', 'has_internet': 'да' if isys.is_internet else 'нет', 'category': isys.category or '—', 'users': ', '.join(isys.users_list or []) or '—', 'users_loop': users_loop, 'defense_tools_list': ', '.join(isys.defense_tools_list or []) or '—', 'is_purpose': isys.purpose or '—', 'structure': getattr(isys, 'structure', '') or '—', 'software': getattr(isys, 'software', '') or '—', # 236 приказ 'purpose': isys.purpose or '—', 'sphere': getattr(isys, 'sphere', '') or '—', 'object_type': getattr(isys, 'object_type', '') or '—', 'architecture': getattr(isys, 'architecture', '') or '—', 'typical_object': getattr(isys, 'typical_object', '') or '—', 'subject_name': getattr(isys, 'subject_name', '') or '—', 'subject_address': getattr(isys, 'subject_address', '') or '—', 'subject_element': getattr(isys, 'subject_element', '') or '—', 'subject_inn': getattr(isys, 'subject_inn', '') or '—', 'subject_kpp': getattr(isys, 'subject_kpp', '') or '—', 'net_category': getattr(isys, 'net_category', '') or '—', 'net_operator': getattr(isys, 'net_operator', '') or '—', 'net_purpose': getattr(isys, 'net_purpose', '') or '—', 'net_method': getattr(isys, 'net_method', '') or '—', 'hw_sw': getattr(isys, 'hw_sw', '') or '—', 'os_sw': getattr(isys, 'os_sw', '') or '—', 'app_sw': getattr(isys, 'app_sw', '') or '—', 'balance_value': getattr(isys, 'balance_value', '') or '—', 'attacker_category': getattr(isys, 'attacker_category', '') or '—', 'attacker_level': getattr(isys, 'attacker_level', '') or '—', 'attacker_types': getattr(isys, 'attacker_types', '') or '—', 'threats': getattr(isys, 'threats', '') or '—', # Алиасы под шаблон «Сведения о результатах категорирования» (ФСТЭК) 'objective': getattr(isys, 'purpose', '') or '—', 'standard_name': getattr(isys, 'typical_object', '') or '—', 'network_category': getattr(isys, 'net_category', '') or '—', 'provider': getattr(isys, 'net_operator', '') or '—', 'interaction_purpose': getattr(isys, 'net_purpose', '') or '—', 'interaction_method': getattr(isys, 'net_method', '') or '—', 'hardware': getattr(isys, 'hw_sw', '') or '—', 'system_software': getattr(isys, 'os_sw', '') or '—', 'defense_tools': ', '.join(isys.defense_tools_list or []) or '—', 'component': getattr(isys, 'subject_element', '') or '—', 'exploiting_org': getattr(isys, 'subject_name', '') or '—', 'exploiting_inn': getattr(isys, 'subject_inn', '') or '—', 'domain': getattr(isys, 'domain_name', '') or '—', 'threat_actor': getattr(isys, 'attacker_category', '') or '—', 'threat_justification': getattr(isys, 'threats', '') or '—', '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 '') or _build_org_measures_text(company, isys), 'tech_measures': getattr(isys, 'tech_measures', '') or '—', }) loops['information_systems'] = items # Сотрудники с доступом employees = getattr(company, 'employees_access', None) or [] if employees: items = [] for e in employees: items.append({ 'position': e.get('position', ''), 'fio': e.get('fio', ''), }) loops['employees_access'] = items # --- Шаблон «Сведения о результатах категорирования» (ФСТЭК) --- # Цикл объектов КИИ — тот же список, что information_systems. if is_list: loops['kii_objects'] = loops.get('information_systems', []) # Цикл показателей критериев значимости (ПП-127): автоматически # формируем строки таблицы с обоснованием «применим/не применим» по сфере # и расчётом для показателей 8 и 9 (как в примере заказчика). loops['categorization_indicators'] = _build_categorization_indicators(company, is_list) return loops # ============================================================ # ПОКАЗАТЕЛИ КРИТЕРИЕВ ЗНАЧИМОСТИ (ПП-127) # ============================================================ def _num(s): """«121 118 270 руб», «1 000», «2 212.5» → число (float) или None.""" s = str(s or '').strip() if not s: return None s = s.replace('\u00a0', ' ').replace(' ', '')\ .replace('руб', '').replace('₽', '').replace('р.', '').replace(',', '.') try: return float(s) except Exception: return None def _load_json(name): import json as _json import os as _os p = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), 'dictionaries', name) try: with open(p, encoding='utf-8') as f: return _json.load(f) except Exception: return {} def _calc_criterion_8_9(company, isys, num): """Расчёт показателя 8 (ущерб субъекту КИИ) или 9 (ущерб бюджету РФ). Возвращает (текст_обоснования, категория или None). Логика — как в авторасчёте программы (окно ПП-127), текст — по образцу заполнения заказчика для ОПК. """ # ΔДоходов: ущерб из вкладки «Организация» или балансовая стоимость объекта d = _num(getattr(company, 'income_loss', '') or '') if d is None: d = _num(getattr(isys, 'balance_value', '') or '') # Доходы за 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) val_pct = d / 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"Реализация компьютерных атак на объект не приведет к снижению доходов субъекта КИИ. " f"Объем среднегодового дохода субъекта КИИ (с учетом НДС), усредненный за прошедший " 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), по данному показателю объекту " f"присваивается {cat}-я категория значимости.") else: text += (f" Так как {val_pct:.6f} % менее 1% (пороговое значение для 3-й категории " f"значимости согласно Постановлению № 127), по данному показателю объекту " f"категория значимости не присваивается.") return text, cat # num == '9' if d is None: return ("Показатель применим. Для расчёта заполните данные организации: " "«Снижение уровня дохода» или балансовую стоимость объекта КИИ.", None) profit = _num(getattr(company, 'profit', '') or '') tax = _num(getattr(company, 'profit_tax', '') or '') if profit and tax is not None and profit > 0: knp = tax / profit dnp = d * knp if d > profit: dnp = tax else: knp = 0.0 dnp = 0.0 DEFAULT_BUDGET_AVG = 21228053357700.0 # Бусредн — прогноз годового дохода федбюджета, усреднённый за 3 года b = DEFAULT_BUDGET_AVG val_pct = dnp / b * 100 if val_pct > 0.001: cat = 'I' elif val_pct > 0.0006: cat = 'II' elif val_pct > 0.0003: cat = 'III' else: cat = None text = (f"Реализация компьютерных атак на объект не приведет к снижению доходов федерального бюджета. " f"Объем прогнозируемого годового дохода федерального бюджета Российской Федерации, " f"усредненный за планируемый трехлетний период, составляет {b:,.2f} руб. " f"Коэффициент выплат (отчислений) от налога на прибыль субъекта КИИ = {knp:.4f}. " f"В случае выхода из строя объекта КИИ потребуется замена, установка и настройка, " f"стоимость которых составит около {d:,.2f} руб. " f"Снижение выплат (отчислений) от налога на прибыль субъекта КИИ в бюджеты Российской Федерации " f"Уабс = {d:,.2f} × {knp:.4f} = {dnp:,.2f} руб. (приближённо). " f"Показатель ущерба бюджетам = {val_pct:.6f} %.") if cat: text += (f" Так как {val_pct:.6f} % более 0,0003% (пороговое значение для 3-й категории " f"значимости согласно Постановлению № 127), по данному показателю объекту " f"присваивается {cat}-я категория значимости.") else: text += (f" Так как {val_pct:.6f} % менее 0,0003% (пороговое значение для 3-й категории " f"значимости согласно Постановлению № 127), по данному показателю объекту " f"категория значимости не присваивается.") 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в', '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). Для каждого показателя ПП-127 (5 групп, показатели 1–14 с подпунктами): section, name, number, value, justification, category. - Если показатель не применим для сферы объекта — value = «Показатель не применим.», justification — текст из словаря обоснований по сфере (или заготовка). - Если применим (8/9) — автоматический расчёт из данных организации. """ import re as _re criteria = _load_json('criteria_127.json') applicability = _load_json('criteria_applicability.json') justifications = _load_json('criteria_justifications.json') if not isinstance(criteria, list): criteria = [] sphere = getattr(is_list[0], 'sphere', '') if is_list else '' sphere = (sphere or '').strip() appl = applicability.get(sphere) if isinstance(applicability, dict) else None just = (justifications.get(sphere) or {}) if isinstance(justifications, dict) else {} 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', []): num = str(ind.get('num', '')) name = ind.get('name', '') m = _re.match(r'(\d+)', num) grp_num = m.group(1) if m else num applicable = appl is None or grp_num in appl value = '—' justification = '—' category = '—' 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 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 = '—' else: # Применимый показатель без формульного авторасчёта (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, 'name': name, 'number': num, 'value': value, 'justification': justification, 'category': category, }) return rows # ============================================================ # РЕЗОЛВЕР ЗНАЧЕНИЙ # ============================================================ def _find_fstek_office(address_text: str): """Определяет управление ФСТЭК России по адресу организации. Ищет город/регион из адреса в словаре fstek_offices.json. Возвращает dict {'name': ..., 'address': ...} или None. """ import json as _json import os as _os if not address_text: return None dict_path = _os.path.join(_os.path.dirname(__file__), 'dictionaries', 'fstek_offices.json') try: with open(dict_path, encoding='utf-8') as _f: offices = _json.load(_f) except Exception: return None hay = address_text.lower() # Нормализация сокращений: обл.→область, респ.→республика, г.о.→городской округ hay = hay.replace('обл.', 'область').replace('обл,', 'область') hay = hay.replace('респ.', 'республика').replace('респ,', 'республика') hay = hay.replace('г.о.', 'городской округ').replace('г.о,', 'городской округ') hay = hay.replace('ао', 'автономный округ') best = None best_len = 0 for off in offices: # Ищем по городам for city in off.get('cities', []): if city in hay and len(city) > best_len: best = off best_len = len(city) # Ищем по регионам (область/край/республика) for region in off.get('regions', []): if region in hay and len(region) > best_len: best = off best_len = len(region) return best def _resolve(source: str, ctx: Dict) -> str: """Преобразует ключ-источник в значение, подставляя из контекста.""" company = ctx.get('company') is_list = ctx.get('is_list', []) commission_data = ctx.get('commission_data', {}) doc_number = ctx.get('doc_number', '') doc_date = ctx.get('doc_date', '') # Прямые поля Company if source.startswith('company:'): field = source[8:] return str(getattr(company, field, '') or '') # Склонение названия if source.startswith('decl:'): _, field, case = source.split(':') name = getattr(company, field, '') or '' case_gr = dc._normalize_case(case) # Сначала берём ГОТОВОЕ склонение из модели (пользователь мог # подставить через Морфер/ИИ или ввести вручную), пересчитываем # только если поле пустое case_idx = {'nomn': 1, 'gent': 2, 'datv': 3, 'accs': 4, 'ablt': 5, 'loct': 6}.get(case_gr) if case_idx: if field == 'full_name': ready = getattr(company, f'company_name_{case_idx}', '') or '' if ready: return ready elif field == 'short_name': ready = getattr(company, f'short_name_{case_idx}', '') or '' if ready: return ready return dc.company_name_decline(name, case_gr) if name else '—' # Форматы ФИО if source.startswith('short_fio:'): who = source.split(':', 1)[1] fio = _get_fio(company, who) return dc.get_short_fio(fio) if fio else '—' if source.startswith('initials:'): who = source.split(':', 1)[1] fio = _get_fio(company, who) return dc.get_initials(fio) if fio else '—' # Комиссия if source in commission_data: return str(commission_data[source]) # Управление ФСТЭК (адресат отправки сведений о категорировании) if source in ('fstek_recipient_address', 'fstek_district', 'fstek_office_name'): addr = getattr(company, 'address', '') or '' off = _find_fstek_office(addr) if off: if source == 'fstek_recipient_address': return off.get('address', '') or '' return off.get('name', '') or '' return '' # Даты if source.startswith('date:'): fmt = source.split(':', 1)[1] now = datetime.now() if fmt == 'year': return str(now.year) return now.strftime('%d.%m.%Y') if source == 'doc_date': return doc_date or datetime.now().strftime('%d.%m.%Y') if source == 'doc_number': num = str(doc_number).strip() return re.sub(r'^[Nn]?[oо]?[№#]\s*', '', num) or '—' # Требования защищённости if source == 'security_reqs': return _get_security_text(ctx) # IS-алиасы (первая ИС) if source.startswith('is:'): field = source[3:] if is_list: first = is_list[0] field_map = { 'name': 'name', 'description': 'description', 'category': 'category', 'users': lambda: ', '.join(first.users_list or []), } if field in field_map: val = field_map[field] if callable(val): return val() or '—' return str(getattr(first, val, '') or '—') if isinstance(val, str) else '—' return '—' # Список ИС if source == 'is_list': if is_list: return '\n'.join(f"{i}. {isys.name}" for i, isys in enumerate(is_list, 1)) return '—' if source == 'is_list_comma': if is_list: return ', '.join(isys.name for isys in is_list) return '—' # Члены комиссии if source.startswith('commission_member:'): parts = source.split(':') idx, field = int(parts[1]), parts[2] members = getattr(company, 'commission', None) or [] if idx < len(members): return str(getattr(members[idx], field, '') or '—') return '—' # Склонение ФИО по падежам if source.startswith('fio_acl:'): who = source.split(':', 1)[1] fio = _get_fio(company, who) return dc.decline(fio, 'accs') if fio else '—' return '—' def _get_fio(company: Company, who: str) -> str: mapping = { 'chief': 'chief_fio', 'responsible': 'responsible_fio', 'admin': 'admin_fio', } field = mapping.get(who, who + '_fio') return getattr(company, field, '') or '' def _get_security_text(ctx: Dict) -> str: """Возвращает текст требований к защите объекта КИИ по категории значимости.""" is_list = ctx.get('is_list', []) category = '' if is_list: category = str(getattr(is_list[0], 'category', '') or '').strip() TEXTS = { '1': """Для значимого объекта КИИ 1-й категории значимости необходимо: — создание системы безопасности значимого объекта КИИ; — обеспечение защиты информации от компьютерных атак; — категорирование объектов КИИ и контроль его результатов.""", '2': """Для значимого объекта КИИ 2-й категории значимости необходимо: — создание системы безопасности значимого объекта КИИ; — обеспечение защиты информации от компьютерных атак.""", '3': """Для значимого объекта КИИ 3-й категории значимости необходимо: — обеспечение защиты информации от компьютерных атак.""", } if category in TEXTS: return TEXTS[category] return """Для объекта КИИ, не являющегося значимым, необходимо: — принятие мер по защите информации, предусмотренных законодательством РФ о безопасности КИИ."""