Files
dokogen187/dokogen/importer.py
T
prog1764 b044b05eaf feat: финансовые показатели организации + балансовая стоимость объекта
- модели: Company.profit (сумма прибыли), Company.profit_tax (налог с прибыли),
  InformationSystem.balance_value (балансовая стоимость объекта КИИ)
- импорт из опросника: «Сумма прибыли за предыдущий год» и «Сумма налога с прибыли»
  (Общие сведения) → profit/profitTax; «Балансовая стоимость объекта КИИ в рублях»
  (каждый ОБЪЕКТ-лист) → balanceValue, больше не падает в описание
- UI: блок «Финансовые показатели» во вкладке «Организация»; поле
  «Балансовая стоимость (руб.)» в объекте КИИ; маппинги collect/select/copy/import
- генератор: {{company_profit}}, {{company_profit_tax}}, {{item.balance_value}}
2026-08-04 19:17:37 +04:00

690 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""DokoGen — Модуль импорта из Excel (опросный лист 187-ФЗ)"""
import re
import traceback
from datetime import datetime, timedelta
from typing import Callable, Optional
try:
import openpyxl
except ImportError:
openpyxl = None
from .import_settings import load_settings, parse_cell_ref, cell_value
# Известные аббревиатуры, которые сохраняются КАПСОМ при нормализации
_CAPS_ABBR = {
'АРМ', 'СКУД', 'СЗИ', 'СКЗИ', 'СОВ', 'ЛВС', 'ИНН', 'КПП', 'ОГРН', 'ЦОД',
'МБУ', 'МКУ', 'ООО', 'СНИЛС', 'СБИС', 'ЕМСЭД', 'ФИО', 'ИСПДН', 'ККТ',
}
def _normalize_caps(text: str) -> str:
"""Приводит КАПС-текст к нормальному регистру по словам.
Сохраняет:
- аббревиатуры из белого списка (АРМ, СКУД, ООО, ИНН и т.п.)
- инициалы и короткие служебные слова (И., Г., УЛ., №1)
- числа и номера (350000, №1)
- слова, уже содержащие строчные буквы
Длинные слова целиком заглавными (ДИРЕКТОР, КРАСНОДАР, ПЕТРОВ) → «Директор», «Краснодар».
"""
words = text.split()
result = []
for w in words:
# отделяем пунктуацию по краям слова
pre = ''
post = ''
core = w
while core and not core[0].isalnum():
pre += core[0]
core = core[1:]
while core and not core[-1].isalnum():
post = core[-1] + post
core = core[:-1]
if not core:
result.append(w)
continue
# в слове уже есть строчные буквы — не трогаем
if any('а' <= c <= 'я' or c == 'ё' for c in core):
result.append(w)
continue
# нет заглавных кириллических букв (числа, латиница и т.п.) — не трогаем
if not any('А' <= c <= 'Я' or c == 'Ё' for c in core):
result.append(w)
continue
letters = [c for c in core if c.isalpha()]
# инициалы и служебные слова (1-2 буквы: И., Г., УЛ., ООО — но ООО в белом списке)
if len(letters) <= 2:
result.append(w)
continue
# известная аббревиатура — сохраняем как есть
if core.upper() in _CAPS_ABBR:
result.append(w)
continue
# обычное длинное слово: первая заглавная, остальные строчные
normalized = core[0].upper() + core[1:].lower()
result.append(pre + normalized + post)
return ' '.join(result)
_ADDRESS_ABBR = {
# нормальные сокращения для адресов: «Г» → «г.», «Р-Н» → «р-н.» и т.п.
'Г': 'г.', 'УЛ': 'ул.', 'Д': 'д.', 'КВ': 'кв.', 'ПР': 'пр.',
'ПЕР': 'пер.', 'ПЛ': 'пл.', 'ОБЛ': 'обл.', 'ПОС': 'пос.',
'СТ': 'ст.', 'Ш': 'ш.', 'Р-Н': 'р-н.', 'Б-Р': 'б-р.', 'МКР': 'мкр.',
'НАБ': 'наб.', 'ТУП': 'туп.', 'ПР-Д': 'пр-д.', 'КРП': 'крп.',
'Г.': 'г.', 'УЛ.': 'ул.', 'Д.': 'д.', 'Р-Н.': 'р-н.',
}
def normalize_address(text: str) -> str:
"""Приводит адрес к нормальному виду: «Г Крымск» → «г. Крымск»,
«Р-Н Крымский» → «р-н. Крымский», «УЛ Карла» → «ул. Карла»."""
if not text:
return ''
parts = re.split(r'(\s+|[,\;])', text)
out = []
for p in parts:
if p.strip() and p.strip().upper() in _ADDRESS_ABBR:
out.append(_ADDRESS_ABBR[p.strip().upper()])
else:
out.append(p)
return ''.join(out)
def clean_value(val):
"""Очистка и нормализация значения ячейки Excel."""
if val is None:
return ''
if isinstance(val, (int, float)):
# Попытка распознать дату (серийный номер Excel)
if 10000 < val < 80000:
serial = int(val)
if serial > 60:
serial -= 1
dt = datetime(1899, 12, 30) + timedelta(days=serial)
return dt.strftime('%d.%m.%Y')
if isinstance(val, float) and val == int(val):
return str(int(val))
return str(val)
text = str(val).strip()
text = text.replace('\n', ' ').replace('\r', ' ')
text = ' '.join(text.split())
# Если весь текст КАПСОМ (нет строчных букв) — нормализуем по словам
if len(text) > 2 and any('А' <= c <= 'Я' or c == 'Ё' for c in text):
has_lower = any('а' <= c <= 'я' or c == 'ё' for c in text)
if not has_lower:
text = _normalize_caps(text)
return text
def import_187fz(filepath: str, log_fn: Optional[Callable] = None, settings_path: Optional[str] = None) -> dict:
"""Импорт данных из опросного листа Excel (187-ФЗ).
settings_path — путь к JSON-файлу настроек импорта (что и откуда брать).
Если None — файл import_settings.json рядом с программой; если его нет —
используется встроенный словарь по умолчанию.
"""
if openpyxl is None:
raise ImportError("openpyxl не установлен. Установите: pip install openpyxl")
if log_fn is None:
log_fn = lambda msg: None
settings = load_settings(settings_path)
log_fn(f"Загрузка опросного листа: {filepath}")
data = {
'fullName': '', 'shortName': '', 'addressLegal': '', 'addressActual': '',
'email': '', 'phone': '', 'inn': '', 'ogrn': '', 'kpp': '',
'okved': '', 'okvedExtra': '', 'chiefPosition': '', 'chiefFio': '',
'ispdnFio': '', 'ispdnPosition': '',
'ispdnDepartment': '', 'ispdnEmail': '', 'ispdnPhone': '',
'informationSystems': [], 'commission': [], 'employeesAccess': [],
}
try:
wb = openpyxl.load_workbook(filepath, data_only=True, read_only=False)
except Exception as e:
log_fn(f"❌ Ошибка открытия файла: {e}")
return data
try:
_import_187_common(wb, data, settings, log_fn)
_import_187_is_list(wb, data, settings, log_fn)
_import_187_employees(wb, data, settings, log_fn)
except Exception as e:
log_fn(f"❌ Ошибка при импорте: {e}")
log_fn(traceback.format_exc())
finally:
wb.close()
# Копирование полей ИСПДн в админа, если админ не заполнен
for src, dst in [('ispdnFio', 'administratorFio'), ('ispdnFio', 'adminFio'),
('ispdnPosition', 'administratorPosition'), ('ispdnPosition', 'adminPosition'),
('ispdnPhone', 'phone'), ('ispdnEmail', 'email')]:
if data.get(src) and not data.get(dst):
data[dst] = data[src]
# Нормализация адресов: «Г Крымск» → «г. Крымск», «Р-Н» → «р-н.» и т.п.
for key in ('addressLegal', 'addressActual'):
if data.get(key):
data[key] = normalize_address(data[key])
_validate_company_data(data, log_fn)
return data
def _extract_single_fio(text):
"""Из строки, где может быть несколько ФИО (через запятую, точку с запятой,
перевод строки или просто подряд), выделяет одно первое полное ФИО.
Пример: «Коростелев Павел Сергеевич Хрящев Сергей Владимирович …» → «Коростелев Павел Сергеевич»
"""
if not text:
return text
text = str(text).strip()
# 1) Явные разделители — берём первую часть
for sep in (',', ';', '\n', '\r'):
if sep in text:
text = text.split(sep)[0].strip()
break
# 2) Если осталось больше 3 слов — это несколько ФИО подряд, оставляем первые 3
words = [w for w in text.split() if w]
if len(words) > 3:
text = ' '.join(words[:3])
return text.strip()
def _import_187_common(wb, data, settings, log_fn):
"""Импорт общих сведений по словарю настроек.
Для каждого поля: сначала пробуем конкретную ячейку (cell),
если она задана и заполнена; иначе ищем строку по ключевым
словам (keywords) в колонке A листа «Общие сведения».
"""
sheet_name = None
sheet_kw = settings.get('common_sheet_keyword', 'общие')
for sn in wb.sheetnames:
if sheet_kw in sn.lower():
sheet_name = sn
break
if not sheet_name:
log_fn(f"⚠ Лист по ключу '{sheet_kw}' не найден")
return
ws = wb[sheet_name]
value_col = int(settings.get('value_column', 1))
# Кэш значений: для каждого поля ищем по строке один раз
row_cache = {}
for row in ws.iter_rows(min_row=1, values_only=True):
field_raw = clean_value(row[0]) if row[0] is not None else ''
if not field_raw:
continue
row_cache[field_raw.lower()] = (
clean_value(row[value_col]) if len(row) > value_col else ''
)
for rule in settings.get('fields', []):
key = rule.get('var', '')
if not key:
continue
name = rule.get('name', key)
cell_ref = rule.get('cell', '')
keywords = rule.get('keywords', '')
# 1) Конкретная ячейка
if cell_ref:
sheet_part, coord = parse_cell_ref(cell_ref)
target_ws = ws
if sheet_part:
for sn in wb.sheetnames:
if sn.lower() == sheet_part.lower():
target_ws = wb[sn]
break
val = clean_value(cell_value(target_ws, coord))
if val:
if key in ('chiefFio', 'responsibleFio', 'ispdnFio', 'adminFio'):
val = _extract_single_fio(val)
data[key] = val
log_fn(f" {name} [ячейка {coord}]: {val}")
continue
# 2) Поиск по ключевым словам
if keywords:
try:
rx = re.compile(keywords, re.IGNORECASE)
except re.error:
rx = None
if rx:
for field_lower, val in row_cache.items():
if rx.search(field_lower):
if val:
if key in ('chiefFio', 'responsibleFio', 'ispdnFio', 'adminFio'):
val = _extract_single_fio(val)
data[key] = val
log_fn(f" {name} [по ключу]: {val}")
break
def _import_187_is_list(wb, data, settings, log_fn):
"""Импорт объектов КИИ из остальных листов (ОБЪЕКТ №1, №2, ...).
Заполняет поля 236 приказа ФСТЭК: сфера, назначение, тип, архитектура,
сети электросвязи, ПО, СЗИ, сведения о субъекте.
"""
exclude_kws = settings.get('is_sheet_exclude_keywords', ['общие сведения', 'сотрудники'])
is_sheets = []
for sn in wb.sheetnames:
sn_lower = sn.lower()
if any(kw in sn_lower for kw in exclude_kws):
continue
is_sheets.append(sn)
if not is_sheets:
log_fn("⚠ Листы объектов КИИ не найдены")
return
is_list = []
for sheet_name in is_sheets:
ws = wb[sheet_name]
rows = []
raw_rows = []
for row in ws.iter_rows(min_row=1, values_only=True):
raw_vals = list(row)
vals = [clean_value(c) for c in row]
field = (vals[0] or '').strip() if vals else ''
if not field:
continue
rows.append(vals)
raw_rows.append(raw_vals)
if len(rows) < 2:
continue
is_obj = {
'name': '', 'description': '', 'address': '', 'usersList': [],
'isInternet': False, 'defense_tools': [], 'software': '',
# 236 приказ
'sphere': '', 'purpose': '', 'object_type': '', 'architecture': '',
'typical_object': '',
'subject_name': '', 'subject_address': '',
'subject_element': '',
'subject_inn': '', 'subject_kpp': '',
'net_category': '', 'net_operator': '', 'net_purpose': '', 'net_method': '',
'hw_sw': '', 'os_sw': '', 'app_sw': '',
'attacker_category': '', 'threats': '', 'incidents': '',
'criteria_values': '', 'criteria_justification': '',
'org_measures': '', 'tech_measures': '',
}
defense_keywords = {
'антивирус': 'Антивирус',
'крипто': 'СКЗИ',
'межсетев': 'Межсетевой экран',
'сзи.*от несанкционирован': 'СЗИ от НСД',
'обнаружен.*вторжен': 'СОВ',
'программные.*средств.*модул': 'Доверенная загрузка',
'доверен.*загрузк': 'Доверенная загрузка',
'гарантирован': 'Доверенная загрузка',
}
HINT_MARKERS = [
'формулировка должна', 'например', 'пример:', 'формат:', 'выберите из',
'если не используются', 'при необходимости можно', 'указать', 'вписать',
'если адресов несколько', 'по логике', 'произвольное описание',
]
def _is_hint(text):
"""True, если значение похоже на подсказку/образец из примечания."""
low = (text or '').lower()
for m in HINT_MARKERS:
if m in low:
return True
return False
def _set(field_lower, key, val):
"""Записать значение, если ключевое слово найдено в вопросе
и значение не является подсказкой из примечания."""
if val and 'выбрать' not in field_lower and not _is_hint(val):
is_obj[key] = val
return True
return False
def _clean_multiline(text):
"""Чистит многострочное значение, сохраняя переносы строк."""
if text is None:
return ''
s = str(text).replace('\r\n', '\n').replace('\r', '\n')
lines = [ln.strip() for ln in s.split('\n')]
return '\n'.join(ln for ln in lines if ln)
def _split_entries(text):
"""Разбивает список средств защиты на ОТДЕЛЬНЫЕ программы.
В опроснике каждая программа записана с новой строки (разделитель \n),
также поддерживаются ';' и ','. Название программы при этом НЕ режется
(напр. 'Kaspersky Endpoint Security' остаётся одним средством, KES ≠ KSC).
"""
if text is None:
return []
# 1) разбиваем по переносам строк и ';'
parts = re.split(r'[\n\r;]+', str(text))
out = []
for p in parts:
# 2) дополнительно по запятой (если список через запятую)
for sub in re.split(r'\s*,\s*', p):
sub = sub.strip()
if not sub:
continue
# 3) запасной вариант: слипшиеся без разделителя «15.1.0.22239Kaspersky»
sub_parts = re.split(r'(?<=[0-9.])(?=[А-ЯЁA-Z])', sub)
for s in sub_parts:
s = s.strip()
if not s:
continue
cleaned = clean_value(s)
if cleaned and cleaned not in out:
out.append(cleaned)
return out
for idx, vals in enumerate(rows):
field = (vals[0] or '').strip()
field_lower = field.lower()
raw_row = raw_rows[idx] if idx < len(raw_rows) else vals
raw_b = raw_row[1] if len(raw_row) > 1 else None # сырое значение (с переносами строк!)
val_b = clean_value(raw_b) if raw_b is not None else ''
is_checked = val_b.upper() in ('ДА', 'YES', '✓', '✔', '1', 'TRUE', 'ЕСТЬ')
# --- Наименование объекта ---
if 'наименование объекта' in field_lower or 'название объекта' in field_lower:
is_obj['name'] = val_b
log_fn(f" Объект КИИ: {val_b}")
continue
# --- Адрес размещения объекта (в отдельное поле, не в описание) ---
if 'адрес размещения' in field_lower or 'адрес расположения' in field_lower or 'местонахождение объекта' in field_lower:
is_obj['address'] = val_b
log_fn(f" Адрес объекта: {val_b[:60]}")
continue
# --- Описание ---
if 'описание окки' in field_lower or 'описание объекта' in field_lower:
if val_b:
is_obj['description'] = val_b
continue
# --- Сфера деятельности ---
if 'сфера' in field_lower and 'деятельност' in field_lower:
if _set(field_lower, 'sphere', val_b):
log_fn(f" Сфера: {val_b[:60]}")
continue
# --- Назначение объекта ---
if 'назначение объекта' in field_lower:
if _set(field_lower, 'purpose', val_b):
log_fn(f" Назначение: {val_b[:60]}")
continue
# --- Тип объекта ---
if 'тип объекта' in field_lower:
if _set(field_lower, 'object_type', val_b):
log_fn(f" Тип объекта: {val_b[:60]}")
continue
# --- Архитектура ---
if 'архитектура' in field_lower:
if _set(field_lower, 'architecture', val_b):
log_fn(f" Архитектура: {val_b[:60]}")
continue
# --- Типовой отраслевой объект ---
if 'типового отраслевого' in field_lower or 'типовой отраслевой' in field_lower:
if _set(field_lower, 'typical_object', val_b):
log_fn(f" Типовой объект: {val_b[:60]}")
continue
# --- Субъект ---
if 'наименование организации эксплуатирующ' in field_lower or ('наименование субъекта' in field_lower):
if _set(field_lower, 'subject_name', val_b):
log_fn(f" Субъект: {val_b[:60]}")
continue
if 'адрес местонахождения' in field_lower:
if _set(field_lower, 'subject_address', val_b):
log_fn(f" Адрес субъекта: {val_b[:60]}")
continue
if 'инн лица, эксплуатирующего' in field_lower or 'инн субъекта' in field_lower:
if _set(field_lower, 'subject_inn', val_b):
log_fn(f" ИНН субъекта: {val_b[:60]}")
continue
if 'кпп его обособленных' in field_lower or 'кпп субъекта' in field_lower:
if _set(field_lower, 'subject_kpp', val_b):
log_fn(f" КПП субъекта: {val_b[:60]}")
continue
if 'элемент (компонент) объекта' in field_lower or 'элемент объекта' in field_lower:
if _set(field_lower, 'subject_element', val_b):
log_fn(f" Элемент объекта: {val_b[:60]}")
continue
# --- Сети электросвязи ---
if 'категория сети электросвязи' in field_lower:
if _set(field_lower, 'net_category', val_b):
log_fn(f" Категория сети: {val_b[:60]}")
continue
if 'наименование провайдера' in field_lower or 'оператор связи' in field_lower:
if _set(field_lower, 'net_operator', val_b):
log_fn(f" Оператор: {val_b[:60]}")
continue
if 'цель взаимодействия' in field_lower:
if _set(field_lower, 'net_purpose', val_b):
log_fn(f" Цель взаимодействия: {val_b[:60]}")
continue
if 'способ взаимодействия' in field_lower:
if _set(field_lower, 'net_method', val_b):
log_fn(f" Способ взаимодействия: {val_b[:60]}")
continue
# --- ПО и аппаратные средства (сохраняем переносы строк) ---
if 'наименования программно-аппаратных средств' in field_lower:
if raw_b and 'выбрать' not in field_lower and not _is_hint(raw_b):
is_obj['hw_sw'] = _clean_multiline(raw_b)
log_fn(f" ПО/аппаратные средства: {str(raw_b)[:60]}")
continue
if 'операционная' in field_lower and 'система' in field_lower:
if raw_b and 'выбрать' not in field_lower and not _is_hint(raw_b):
is_obj['os_sw'] = _clean_multiline(raw_b)
log_fn(f" ОС: {str(raw_b)[:60]}")
continue
if 'прикладные программы' in field_lower:
if raw_b and 'выбрать' not in field_lower and not _is_hint(raw_b):
is_obj['app_sw'] = _clean_multiline(raw_b)
log_fn(f" Прикладные программы: {str(raw_b)[:60]}")
continue
if 'общесистемн' in field_lower:
if raw_b and 'выбрать' not in field_lower and not _is_hint(raw_b):
is_obj['os_sw'] = _clean_multiline(raw_b)
log_fn(f" Общесистемное ПО: {str(raw_b)[:60]}")
continue
# --- Безопасность / 236 приказ ---
if 'категория нарушителя' in field_lower:
if _set(field_lower, 'attacker_category', val_b):
log_fn(f" Категория нарушителя: {val_b[:60]}")
continue
if 'основные угрозы' in field_lower or 'угрозы безопасности' in field_lower:
if _set(field_lower, 'threats', val_b):
log_fn(f" Угрозы: {val_b[:60]}")
continue
if 'компьютерных инцидентов' in field_lower or 'инцидент' in field_lower:
if _set(field_lower, 'incidents', val_b):
log_fn(f" Инциденты: {val_b[:60]}")
continue
if 'показателей критериев' in field_lower or 'показатели критериев' in field_lower:
if _set(field_lower, 'criteria_values', val_b):
log_fn(f" Показатели: {val_b[:60]}")
continue
if 'обоснование полученных' in field_lower or 'обоснование значений' in field_lower:
if _set(field_lower, 'criteria_justification', val_b):
log_fn(f" Обоснование: {val_b[:60]}")
continue
if 'организационные меры' in field_lower:
if _set(field_lower, 'org_measures', val_b):
log_fn(f" Орг. меры: {val_b[:60]}")
continue
if 'технические меры' in field_lower:
if _set(field_lower, 'tech_measures', val_b):
log_fn(f" Тех. меры: {val_b[:60]}")
continue
if 'интернет' in field_lower:
is_obj['isInternet'] = is_checked
continue
if 'сотрудник' in field_lower and 'должност' not in field_lower:
if val_b:
parts = [p.strip() for p in val_b.replace('\n', ';').split(';') if p.strip()]
for part in parts:
cleaned = part.strip()
if ':' in cleaned and len(cleaned.split(':')[0]) < 20:
cleaned = cleaned.split(':', 1)[1].strip()
if cleaned and cleaned not in is_obj['usersList']:
is_obj['usersList'].append(cleaned)
continue
# --- Средства защиты ---
matched = False
for kw, label in defense_keywords.items():
if re.search(kw, field_lower):
if val_b and 'выбрать' not in field_lower:
for tool in _split_entries(raw_b if raw_b is not None else val_b):
if tool not in is_obj['defense_tools']:
is_obj['defense_tools'].append(tool)
matched = True
break
if matched:
continue
if val_b and 'выбрать' not in field_lower and field_lower not in ('значение', 'примечание'):
# Балансовая стоимость объекта КИИ — в отдельное поле
if 'балансовая стоимость' in field_lower or 'стоимость объекта' in field_lower:
is_obj['balanceValue'] = val_b
continue
# Остальные заполненные поля — в описание (адрес и т.д.)
if not is_obj['description']:
is_obj['description'] = f"{field}: {val_b}"
else:
is_obj['description'] += f"\n{field}: {val_b}"
if not is_obj['name']:
is_obj['name'] = sheet_name.strip()
log_fn(f" ⚠ Объект без названия — использовано имя листа: {is_obj['name']}")
is_list.append(is_obj)
log_fn(f" ✅ {is_obj['name']} — импортирован")
if is_list:
data['informationSystems'] = is_list
log_fn(f"\n✅ Всего импортировано объектов КИИ: {len(is_list)}")
def _import_187_employees(wb, data, settings, log_fn):
"""Импорт комиссии и сотрудников из листа «Сотрудники»."""
sheet_name = None
sheet_kw = settings.get('employees_sheet_keyword', 'сотруд')
for sn in wb.sheetnames:
if sheet_kw in sn.lower():
sheet_name = sn
break
if not sheet_name:
log_fn(f"⚠ Лист по ключу '{sheet_kw}' не найден")
return
ws = wb[sheet_name]
rows = []
for row in ws.iter_rows(min_row=1, values_only=True):
vals = [clean_value(c) for c in row]
if not vals or not vals[0]:
continue
rows.append(vals)
if not rows:
log_fn("⚠ Лист «Сотрудники» пуст")
return
commission = []
in_commission = False
COMMISSION_ROLES = {
'председатель': 'Председатель комиссии',
'секретарь': 'Секретарь комиссии',
'заместитель председателя': 'Заместитель председателя комиссии',
}
employees = []
in_employees = False
for vals in rows:
col_a = (vals[0] or '').strip()
col_b = clean_value(vals[1]) if len(vals) > 1 else ''
col_c = clean_value(vals[2]) if len(vals) > 2 else ''
col_d = clean_value(vals[3]) if len(vals) > 3 else ''
col_e = clean_value(vals[4]) if len(vals) > 4 else ''
a_lower = col_a.lower()
if 'состав комиссии' in a_lower:
in_commission = True
in_employees = False
log_fn(" Секция: состав комиссии")
continue
if 'перечень сотрудников' in a_lower:
in_commission = False
in_employees = True
log_fn(" Секция: сотрудники с доступом к объектам КИИ")
continue
if a_lower in ('№ п/п', '№', 'номер', ''):
continue
if in_commission:
role = 'Член комиссии'
# Роль ищем в колонках E (было) и D (Примечание — новый формат)
for col in (col_e, col_d):
if col:
col_lower = col.lower()
for keyword, role_name in COMMISSION_ROLES.items():
if keyword in col_lower:
role = role_name
break
if role != 'Член комиссии':
break
if not col_b and not col_c:
continue
commission.append({
'role': role,
'position': col_b,
'fio': _extract_single_fio(col_c.replace('\n', ' ')),
})
if in_employees:
fio = col_c if col_c else col_d
if not fio:
continue
employee = {
'position': col_b,
'fio': _extract_single_fio(fio.replace('\n', ' ')),
}
if col_e:
employee['is_list'] = [s.strip() for s in col_e.split('\n') if s.strip()]
employees.append(employee)
if commission:
commission = [c for c in commission if c['position'] or c['fio']]
data['commission'] = commission
log_fn(f" Комиссия: {len(commission)} чел.")
for m in commission[:3]:
log_fn(f" {m['role']}: {m['position'][:40]}{m['fio'][:40]}")
if employees:
data['employeesAccess'] = employees
log_fn(f" Сотрудников с доступом: {len(employees)}")
def _validate_company_data(data, log_fn):
"""Проверка обязательных полей."""
required = ['fullName', 'inn']
missing = [f for f in required if not data.get(f)]
if missing:
log_fn(f"⚠️ ОБЯЗАТЕЛЬНЫЕ ПОЛЯ НЕ ЗАПОЛНЕНЫ: {', '.join(missing)}")
else:
log_fn("✅ Все обязательные поля заполнены")
inn = (data.get('inn') or '').replace(' ', '')
if inn and (not inn.isdigit() or len(inn) not in (10, 12)):
log_fn(f"⚠️ ИНН подозрительного формата: «{inn}»")