From 230a392a15af108e6c1bb5b30bd4641fbe509b5c Mon Sep 17 00:00:00 2001 From: LocoAgent Date: Wed, 5 Aug 2026 08:58:17 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=BA=D0=B8=20source=20=E2=80=94=20=D0=BA=D1=80?= =?UTF-8?q?=D0=B8=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20=D0=B8?= =?UTF-8?q?=20=D0=B2=D1=8B=D1=81=D0=BE=D0=BA=D0=B8=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api_settings: убраны реальные API-ключи из кода (чтение из env/файла) — 1.1 - variables: {{is_security_requirements}} больше не подставляет всегда УЗ-4; extended_fields/defence_level передаются в контекст — 1.2 - defence.py: эталонная таблица ПП-1119 (п.9-12), 48 комбинаций; ui.py: авторасчёт УЗ переведён на неё (public больше не 'всегда 4', спец/био/общедоступные/иные разделены) — 2.1 - generator: убран дубль хвоста параграфа в _highlight_unmatched — 2.2 - importer: категории ПДн взаимоисключающие ('иные' только если нет спец/био); УЗ пересчитывается при импорте — 2.3 - import_settings(.py/.json): убран перехват 'администратора безопасности' правилом ответственного за ПДн — 3.1 - variables: {{category}}/{{is.category}} резолвятся из personal_data_category — 3.2 - verify: ИИ-предложения ИНН/ОГРН/КПП валидируются контрольными суммами перед применением — 3.3 - models: defence_level None вместо '' (не маскирует 'не рассчитано') - .gitignore: api_settings.json/.env не попадают в git - test_defence.py: юнит-тест всех 48 комбинаций таблицы УЗ --- .gitignore | 5 +++ dokogen/api_settings.py | 14 ++++-- dokogen/defence.py | 86 ++++++++++++++++++++++++++++++++++++ dokogen/generator.py | 3 -- dokogen/import_settings.json | 4 +- dokogen/import_settings.py | 8 ++-- dokogen/importer.py | 17 ++++++- dokogen/models.py | 4 +- dokogen/ui.py | 40 ++--------------- dokogen/variables.py | 21 +++++++-- dokogen/verify.py | 63 ++++++++++++++++++++++++++ test_defence.py | 69 +++++++++++++++++++++++++++++ 12 files changed, 277 insertions(+), 57 deletions(-) create mode 100644 dokogen/defence.py create mode 100644 test_defence.py diff --git a/.gitignore b/.gitignore index 01aac8c..5b3fbcc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ __pycache__/ .autosave/ Сгенерированные_документы/ *.result.docx + +# Секреты и локальные настройки — НЕ коммитить +api_settings.json +.env +.env.local diff --git a/dokogen/api_settings.py b/dokogen/api_settings.py index 33ed2d7..179f3b6 100644 --- a/dokogen/api_settings.py +++ b/dokogen/api_settings.py @@ -13,11 +13,17 @@ import os import sys import json +# ВНИМАНИЕ: реальные ключи НЕ хранятся в коде (утечка в git-историю — см. аудит). +# Источники ключей (по приоритету): +# 1) переменные окружения DADATA_TOKEN / DEEPSEEK_API_KEY / DEEPSEEK_MODEL / DEEPSEEK_BASE_URL +# 2) файл api_settings.json рядом с программой +# 3) DEFAULT_SETTINGS ниже (пустые значения) + DEFAULT_SETTINGS = { - 'dadata_token': '431aa11706bf83f571cef0319259289aa750bcb1', - 'ai_api_key': 'sk-3c7e6673f245412697d0e14b6096435f', - 'ai_model': 'deepseek-chat', - 'ai_base_url': 'https://api.deepseek.com', + 'dadata_token': os.environ.get('DADATA_TOKEN', ''), + 'ai_api_key': os.environ.get('DEEPSEEK_API_KEY', ''), + 'ai_model': os.environ.get('DEEPSEEK_MODEL', 'deepseek-chat'), + 'ai_base_url': os.environ.get('DEEPSEEK_BASE_URL', 'https://api.deepseek.com'), } # Старые ключи (для совместимости со старым файлом api_settings.json) diff --git a/dokogen/defence.py b/dokogen/defence.py new file mode 100644 index 0000000..bc3071b --- /dev/null +++ b/dokogen/defence.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +"""DokoGen — расчёт уровня защищённости ПДн по ПП РФ № 1119 от 01.11.2012. + +Эталонная таблица (п. 9–12 ПП-1119), сверено с текстом постановления: + +Специальные категории: +- угрозы 1 типа → УЗ-1 (любое количество, п.9а) +- угрозы 2 типа: >100к не-сотрудников → УЗ-1 (п.9б); сотрудники или ≤100к → УЗ-2 (п.10б) +- угрозы 3 типа: >100к не-сотрудников → УЗ-2 (п.10е); сотрудники или ≤100к → УЗ-3 (п.11в) + +Биометрические: +- угрозы 1 типа → УЗ-1 (п.9а); угрозы 2 типа → УЗ-2 (п.10в); угрозы 3 типа → УЗ-3 (п.11г) +(без разбивки по количеству) + +Общедоступные: +- угрозы 1 типа → УЗ-2 (п.10а) +- угрозы 2 типа: >100к → УЗ-2 (п.10г); сотрудники или ≤100к → УЗ-3 (п.11а) +- угрозы 3 типа → УЗ-4 (п.12а) + +Иные категории: +- угрозы 1 типа → УЗ-1 (п.9а) +- угрозы 2 типа: >100к не-сотрудников → УЗ-2 (п.10д); сотрудники или ≤100к → УЗ-3 (п.11б) +- угрозы 3 типа: >100к не-сотрудников → УЗ-3 (п.11д); сотрудники или ≤100к → УЗ-4 (п.12б) + +Используется и в авторасчёте (ui.py), и при импорте (importer.py). +""" + +from typing import List + + +def calc_defence_level( + categories: List[str], + is_employees: bool, + is_large: bool, + threat_type: str, +) -> int: + """Расчёт уровня защищённости ПДн (1–4) по ПП РФ № 1119. + + categories — список категорий ПДн ('специальные', 'биометрические', + 'общедоступные', 'иные'). + is_employees — True, если обрабатываются ПДн только сотрудников оператора. + is_large — True, если количество субъектов > 100 000. + threat_type — тип актуальных угроз: '1', '2' или '3'. + """ + threat = str(threat_type or '3').strip() + has = lambda *names: any(n in categories for n in names) + + # Приоритет категорий: специальные > биометрические > общедоступные > иные + if has('специальные'): + return _special(threat, is_employees, is_large) + if has('биометрические'): + return _bio(threat) + if has('общедоступные') and not has('иные'): + return _public(threat, is_employees, is_large) + return _other(threat, is_employees, is_large) + + +def _special(threat: str, employees: bool, large: bool) -> int: + if threat == '1': + return 1 + if threat == '2': + return 1 if (not employees and large) else 2 + # threat == '3' + return 2 if (not employees and large) else 3 + + +def _bio(threat: str) -> int: + return {'1': 1, '2': 2, '3': 3}.get(threat, 3) + + +def _public(threat: str, employees: bool, large: bool) -> int: + if threat == '1': + return 2 + if threat == '2': + return 2 if (not employees and large) else 3 + # threat == '3' + return 4 + + +def _other(threat: str, employees: bool, large: bool) -> int: + if threat == '1': + return 1 + if threat == '2': + return 2 if (not employees and large) else 3 + # threat == '3' + return 3 if (not employees and large) else 4 diff --git a/dokogen/generator.py b/dokogen/generator.py index 4afffb6..8214cad 100644 --- a/dokogen/generator.py +++ b/dokogen/generator.py @@ -692,9 +692,6 @@ def _highlight_unmatched(doc): pos = m.end() if pos < len(full): _add_run_with_tabs(para, full[pos:], first) - run = para.add_run(full[pos:]) - if first is not None: - _copy_run_style(first, run) for para in doc.paragraphs: _process_para(para) diff --git a/dokogen/import_settings.json b/dokogen/import_settings.json index a98c7cb..d4748b3 100644 --- a/dokogen/import_settings.json +++ b/dokogen/import_settings.json @@ -77,13 +77,13 @@ "name": "Должность ответственного за ПДн", "var": "ispdnPosition", "cell": "", - "keywords": "должность.*защит.*информ|должность.*администрат" + "keywords": "должность.*защит.*информ|должность.*ответственн.*пдн" }, { "name": "ФИО ответственного за ПДн", "var": "ispdnFio", "cell": "", - "keywords": "фио.*защит.*информ|фио.*администрат" + "keywords": "фио.*защит.*информ|фио.*ответственн.*пдн" }, { "name": "Email ответственного за ПДн", diff --git a/dokogen/import_settings.py b/dokogen/import_settings.py index ca33475..db6fe77 100644 --- a/dokogen/import_settings.py +++ b/dokogen/import_settings.py @@ -69,8 +69,10 @@ DEFAULT_SETTINGS = { _f('Основной ОКВЭД', 'okved', keywords=r'основной оквэд'), _f('Должность руководителя', 'chiefPosition', keywords=r'должность руководителя'), _f('ФИО руководителя', 'chiefFio', keywords=r'фио руководителя'), - _f('Должность ответственного за ПДн', 'ispdnPosition', keywords=r'должность.*защит.*информ|должность.*администрат'), - _f('ФИО ответственного за ПДн', 'ispdnFio', keywords=r'фио.*защит.*информ|фио.*администрат'), + _f('Должность администратора безопасности', 'administratorPosition', keywords=r'должность.*администрат.*(?:безопас|защит|пд)'), + _f('ФИО администратора безопасности', 'administratorFio', keywords=r'фио.*администрат.*(?:безопас|защит|пд)'), + _f('Должность ответственного за ПДн', 'ispdnPosition', keywords=r'должность.*защит.*информ|должность.*ответственн.*пдн'), + _f('ФИО ответственного за ПДн', 'ispdnFio', keywords=r'фио.*защит.*информ|фио.*ответственн.*пдн'), _f('Email ответственного за ПДн', 'ispdnEmail', keywords=r'электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист'), _f('Телефон ответственного за ПДн', 'ispdnPhone', keywords=r'телефон.*защит|телефон.*ответств|телефон.*специалист'), _f('Структурное подразделение по безопасности', 'ispdnDepartment', keywords=r'структур.*подраздел'), @@ -80,8 +82,6 @@ DEFAULT_SETTINGS = { _f('Место хранения документов', 'paperStorage', keywords=r'место.*хранен'), _f('Должность ответственного за обработку ПДн', 'responsiblePosition', keywords=r'должность.*ответственн'), _f('ФИО ответственного за обработку ПДн', 'responsibleFio', keywords=r'фио.*ответственн'), - _f('Должность администратора безопасности', 'administratorPosition', keywords=r'должность.*администрат.*(?:безопас|защит|пд)'), - _f('ФИО администратора безопасности', 'administratorFio', keywords=r'фио.*администрат.*(?:безопас|защит|пд)'), _f('Сайт организации', 'site_name', keywords=r'сайт организации'), _f('Должность ответственного за сайт', 'site_responsible_position', keywords=r'должность ответственного за сайт|должность.*отв.*сайт'), _f('ФИО ответственного за сайт', 'site_responsible_fio', keywords=r'фио.*отв.*сайт|фио.*сайт'), diff --git a/dokogen/importer.py b/dokogen/importer.py index 3096a64..9b60181 100644 --- a/dokogen/importer.py +++ b/dokogen/importer.py @@ -425,19 +425,32 @@ def _import_152_is_list(wb, data, settings, log_fn): log_fn(f" ⚠ ИС без названия — использовано имя листа: {is_obj['name']}") # Автоопределение категорий ПДн + # По 152-ФЗ категории взаимоисключающие: если найдены специальные/биометрические — + # «иные» не добавляем, иначе расчёт УЗ по ПП-1119 пойдёт по неверной категории. pd_cats = [] all_pd_text = ' '.join(p.lower() for p in pd_items_raw) special_kw = ['состоян.*здоров', 'национальн', 'политическ', 'религиозн', 'философ', 'судимост', 'интимн'] if any(re.search(kw, all_pd_text) for kw in special_kw): pd_cats.append('специальные') bio_kw = ['биометрическ', 'изображен.*лиц', 'голос.*человек', 'папилляр', 'дактилоскоп', 'фото.*изображен'] - if any(re.search(kw, all_pd_text) for kw in bio_kw): + if not pd_cats and any(re.search(kw, all_pd_text) for kw in bio_kw): pd_cats.append('биометрические') - if pd_items_raw: + if pd_items_raw and not pd_cats: pd_cats.append('иные') if pd_cats: is_obj['personalDataCategory'] = pd_cats log_fn(f" Категории ПДн: {', '.join(pd_cats)}") + # Автопересчёт УЗ по ПП-1119 (тип угроз по умолчанию — 3, оператор уточнит) + try: + from .defence import calc_defence_level + subj = is_obj.get('pd_subjects_list') or [] + has_only_employees = len(subj) > 0 and all('сотрудник' in s.lower() for s in subj) + is_employees = has_only_employees or len(subj) == 0 + is_large = 'более' in str(is_obj.get('personalDataCount', '')).lower() + is_obj['defenceLevel'] = str(calc_defence_level(pd_cats, is_employees, is_large, '3')) + log_fn(f" УЗ (авто, угрозы 3 типа): {is_obj['defenceLevel']}") + except Exception: + pass is_list.append(is_obj) log_fn(f" ✅ {is_obj['name']} — импортирована") diff --git a/dokogen/models.py b/dokogen/models.py index 4b51360..4d330ad 100644 --- a/dokogen/models.py +++ b/dokogen/models.py @@ -2,7 +2,7 @@ """DokoGen — Модели данных (только 152-ФЗ, только нужные поля)""" from dataclasses import dataclass, field, asdict -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional @dataclass @@ -20,7 +20,7 @@ class InformationSystem: description: str = "" is_local_network: bool = False is_internet: bool = False - defence_level: str = "" + defence_level: Optional[str] = None # None = «не рассчитано» (в отличие от пустой строки) threat_type: str = "3" category: str = "" pd_list: str = "" diff --git a/dokogen/ui.py b/dokogen/ui.py index 1984101..35d073d 100644 --- a/dokogen/ui.py +++ b/dokogen/ui.py @@ -23,6 +23,7 @@ from .verify import ( check_declension_ai, check_company_ai, COMPANY_FIELDS, compare_company_values, ) +from .defence import calc_defence_level from .api_settings import load_api_settings, save_api_settings @@ -1311,42 +1312,8 @@ class DokoGenApp: has_only_employees = len(subj_items) > 0 and all('сотрудник' in s.lower() for s in subj_items) is_employees = has_only_employees or len(subj_items) == 0 - # Таблица ПП №1119: (категория, тип_субъектов, объём, тип_угроз) → УЗ - def lookup(cat_group, employees, large, threat): - # Общедоступные → всегда 4 - if cat_group == 'public': - return 4 - # Специальные/биометрические - if cat_group == 'special_bio': - tbl = { - ('emp', False, '1'): 2, ('emp', True, '1'): 1, - ('other', False, '1'): 1, ('other', True, '1'): 1, - ('emp', False, '2'): 2, ('emp', True, '2'): 1, - ('other', False, '2'): 2, ('other', True, '2'): 1, - ('emp', False, '3'): 3, ('emp', True, '3'): 2, - ('other', False, '3'): 3, ('other', True, '3'): 2, - } - else: # иные - tbl = { - ('emp', False, '1'): 3, ('emp', True, '1'): 2, - ('other', False, '1'): 2, ('other', True, '1'): 1, - ('emp', False, '2'): 4, ('emp', True, '2'): 3, - ('other', False, '2'): 3, ('other', True, '2'): 2, - ('emp', False, '3'): 4, ('emp', True, '3'): 4, - ('other', False, '3'): 4, ('other', True, '3'): 3, - } - key = ('emp' if employees else 'other', large, threat) - return tbl.get(key, 4) - - # Определяем группу категорий - if 'общедоступные' in categories and not any(c in categories for c in ['специальные', 'биометрические', 'иные']): - cat_group = 'public' - elif any(c in categories for c in ['специальные', 'биометрические']): - cat_group = 'special_bio' - else: - cat_group = 'other' - - level = lookup(cat_group, is_employees, is_large, threat_type) + # Расчёт УЗ по эталонной таблице ПП РФ №1119 (п.9-12) — dokogen/defence.py + level = calc_defence_level(categories, is_employees, is_large, threat_type) self.is_defence_var.set(str(level)) def _on_categories_or_count_changed(self): @@ -1959,6 +1926,7 @@ class DokoGenApp: room=is_data.get('room', ''), structure=is_data.get('structure', ''), pd_actions_list=is_data.get('pd_actions', []), + defence_level=is_data.get('defenceLevel', ''), ) self.model.information_systems.append(isys) self._refresh_is_listbox() diff --git a/dokogen/variables.py b/dokogen/variables.py index dd79232..8dcec25 100644 --- a/dokogen/variables.py +++ b/dokogen/variables.py @@ -139,8 +139,14 @@ def build_replacements( doc_number: str = "", doc_date: str = "", direction: str = "152fz", + extended_fields: Dict = None, ) -> Dict[str, str]: - """Строит полный словарь замен {{переменная}} → значение.""" + """Строит полный словарь замен {{переменная}} → значение. + + extended_fields — доп. поля для контекста (например, {'defence_level': '2'}). + Если не задано — уровень берётся из первой ИС (is_list[0].defence_level), + чтобы {{is_security_requirements}} не подставлял всегда УЗ-4. + """ if is_list is None: is_list = [] @@ -148,6 +154,10 @@ def build_replacements( commission_data = _build_commission_data(company) # ---------- Контекст ---------- + if not extended_fields and is_list: + dl = getattr(is_list[0], 'defence_level', '') or '' + if dl: + extended_fields = {'defence_level': dl} ctx = { 'company': company, 'is_list': is_list, @@ -155,6 +165,7 @@ def build_replacements( 'doc_number': doc_number or '', 'doc_date': doc_date or '', 'direction': direction, + 'extended_fields': extended_fields or {}, } replacements = {} @@ -447,7 +458,7 @@ def _resolve(source: str, ctx: Dict) -> str: field_map = { 'name': 'name', 'description': 'description', - 'category': 'category', + 'category': lambda: ', '.join(first.personal_data_category) or first.category or '—', 'defence_level': 'defence_level', 'threat_type': 'threat_type', 'pd_list': lambda: ', '.join(first.personal_data_list or []), @@ -502,10 +513,12 @@ def _get_fio(company: Company, who: str) -> str: def _get_security_text(ctx: Dict) -> str: """Возвращает текст требований для уровня защищённости.""" - defence_level = ctx.get('extended_fields', {}).get('defence_level', '4') + defence_level = ctx.get('extended_fields', {}).get('defence_level') or '' is_list = ctx.get('is_list', []) if not defence_level and is_list: - defence_level = str(getattr(is_list[0], 'defence_level', '4') or '4') + defence_level = str(getattr(is_list[0], 'defence_level', '') or '') + if not defence_level: + defence_level = '4' dl = str(defence_level).strip()[0] TEXTS = { diff --git a/dokogen/verify.py b/dokogen/verify.py index 98c0b83..57e0d6d 100644 --- a/dokogen/verify.py +++ b/dokogen/verify.py @@ -389,6 +389,65 @@ def compare_company_values(old_data, new_data): return diffs +# ============================================================ +# ВАЛИДАЦИЯ РЕКВИЗИТОВ (контрольные суммы ИНН/ОГРН/КПП) +# ============================================================ +def _digits(s) -> str: + return re.sub(r'\D', '', str(s or '')) + + +def valid_inn(inn) -> bool: + """Проверка контрольной суммы ИНН (10 цифр — юрлицо, 12 — ИП).""" + d = _digits(inn) + if len(d) == 10: + w = [2, 4, 10, 3, 5, 9, 4, 6, 8] + return (sum(int(d[i]) * w[i] for i in range(9)) % 11) % 10 == int(d[9]) + if len(d) == 12: + w1 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8] + w2 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8] + n10 = (sum(int(d[i]) * w1[i] for i in range(10)) % 11) % 10 + n11 = (sum(int(d[i]) * w2[i] for i in range(11)) % 11) % 10 + return n10 == int(d[10]) and n11 == int(d[11]) + return False + + +def valid_ogrn(ogrn) -> bool: + """Проверка контрольной суммы ОГРН (13 цифр) / ОГРНИП (15 цифр).""" + d = _digits(ogrn) + if len(d) == 13: + return (int(d[:-1]) % 11) % 10 == int(d[-1]) + if len(d) == 15: + return (int(d[:-1]) % 13) % 10 == int(d[-1]) + return False + + +def valid_kpp(kpp) -> bool: + """КПП: 9 цифр (контрольной суммы нет — проверяем формат).""" + d = _digits(kpp) + return len(d) == 9 + + +def validate_requisites(proposals: dict) -> dict: + """Фильтрует предложения ИИ: невалидные ИНН/ОГРН/КПП не применяются. + + Возвращает (валидные_пропозалы, список_замечаний). + """ + if not isinstance(proposals, dict): + return {}, [] + validators = {'inn': valid_inn, 'ogrn': valid_ogrn, 'kpp': valid_kpp} + notes = [] + out = dict(proposals) + for key, fn in validators.items(): + val = out.get(key) + if val and not fn(val): + notes.append(f"ИИ предложил невалидный {key.upper()} ({val}) — значение не применено") + out.pop(key, None) + return out, notes + + +# ============================================================ +# ИИ-ПРОВЕРКА +# ============================================================ def check_company_ai(company_data, api_key, model='deepseek-chat', base_url=None): """ИИ-проверка реквизитов организации. @@ -425,6 +484,10 @@ def check_company_ai(company_data, api_key, model='deepseek-chat', base_url=None if not isinstance(fields, dict): fields = {} + # Валидация контрольных сумм реквизитов перед применением (фикс 3.3) + proposals, req_notes = validate_requisites(proposals) + notes = list(notes) + req_notes + return { 'source': 'ИИ', 'source_url': 'openai-совместимый API', diff --git a/test_defence.py b/test_defence.py new file mode 100644 index 0000000..dcbcfa8 --- /dev/null +++ b/test_defence.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Юнит-тесты расчёта УЗ по ПП РФ № 1119 (все комбинации таблицы). + +Эталонные значения — из текста ПП-1119 п. 9–12 (сверено с legalacts.ru). +Комбинации: 4 группы категорий × 2 признака «только сотрудники» × +2 объёма (>100к) × 3 типа угроз = 48. +""" + +import sys, os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from dokogen.defence import calc_defence_level + + +def _cases(): + """(categories, employees, large, threat, expected_uz)""" + c = [] + # --- Специальные --- + for emp in (False, True): + for large in (False, True): + c.append((['специальные'], emp, large, '1', 1)) # п.9а + c.append((['специальные'], emp, large, '2', + 1 if (not emp and large) else 2)) # п.9б / п.10б + c.append((['специальные'], emp, large, '3', + 2 if (not emp and large) else 3)) # п.10е / п.11в + # --- Биометрические (без разбивки по количеству) --- + for emp in (False, True): + for large in (False, True): + c.append((['биометрические'], emp, large, '1', 1)) # п.9а + c.append((['биометрические'], emp, large, '2', 2)) # п.10в + c.append((['биометрические'], emp, large, '3', 3)) # п.11г + # --- Общедоступные --- + for emp in (False, True): + for large in (False, True): + c.append((['общедоступные'], emp, large, '1', 2)) # п.10а + c.append((['общедоступные'], emp, large, '2', + 2 if (not emp and large) else 3)) # п.10г / п.11а + c.append((['общедоступные'], emp, large, '3', 4)) # п.12а + # --- Иные --- + for emp in (False, True): + for large in (False, True): + c.append((['иные'], emp, large, '1', 1)) # п.9а + c.append((['иные'], emp, large, '2', + 2 if (not emp and large) else 3)) # п.10д / п.11б + c.append((['иные'], emp, large, '3', + 3 if (not emp and large) else 4)) # п.11д / п.12б + return c + + +def main(): + cases = _cases() + assert len(cases) == 48, f"ожидалось 48 комбинаций, получено {len(cases)}" + failed = 0 + for cats, emp, large, threat, expected in cases: + got = calc_defence_level(cats, emp, large, threat) + if got != expected: + failed += 1 + print(f"❌ {cats} emp={emp} large={large} threat={threat}: " + f"ожидалось УЗ-{expected}, получено УЗ-{got}") + if failed: + print(f"\nFAILED: {failed} из {len(cases)}") + return 1 + print(f"✅ УЗ по ПП-1119: все {len(cases)} комбинаций верны") + return 0 + + +if __name__ == '__main__': + sys.exit(main())