commit 7886edc0affe5f287004b6734d91457b6f4b303f Author: DokoGen Date: Mon Aug 3 09:19:16 2026 +0400 feat: ImportMapper — универсальный редактор импорта из Excel-опросников - Профили: 152-ФЗ, 187-ФЗ, 117-ФЗ, МУ, КЗИ, ПЗИ - Сканирование Excel: что импортируется и куда - Галочки: включить/выключить поля, добавить свои привязки - Сохранение настроек по типу опросника - Выгрузка данных в JSON (export/export_<тип>.json) - Тесты: отключение полей, пользовательские привязки, секции, ИС diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8be114d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +settings/ +export/ +.venv/ +venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5ce6fc --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# ImportMapper + +Универсальный редактор импорта из Excel-опросников. +Показывает, какие данные из файла опросника импортируются и куда, +позволяет включить/выключить поля и добавить свои привязки. + +Поддерживаемые типы опросников (профили в `profiles/`): + +- `152` — 152-ФЗ «О персональных данных» +- `187` — 187-ФЗ «О безопасности КИИ» +- `117` — 117-ФЗ (безопасность объектов КИИ / ГИС) +- `mu` — МУ (методические указания) +- `kzi` — КЗИ (комплекс средств защиты информации) +- `pzi` — ПЗИ (программные средства защиты информации) + +## Запуск + +```bash +pip install -r requirements.txt +python3 main.py +``` + +## Как пользоваться + +1. Выберите тип опросника в выпадающем списке. +2. Нажмите «Обзор» и укажите Excel-файл опросника. +3. Нажмите «Сканировать» — программа покажет, что импортируется. +4. Галочками включите/выключите нужные поля. +5. «💾 Сохранить настройки» — сохраняет настройки для этого типа опросника. +6. «📤 Выгрузить данные» — собирает данные из Excel в JSON по настройкам + (файл `export/export_<тип>.json`), который может использовать генератор. + +## Структура + +- `main.py` — точка входа +- `editor.py` — окно редактора +- `core.py` — профили, сканирование Excel, извлечение данных +- `profiles/*.json` — правила импорта для каждого типа опросника +- `settings/settings_<тип>.json` — настройки пользователя (создаются автоматически) +- `export/export_<тип>.json` — выгруженные данные (создаются автоматически) + +## Профили + +Каждый профиль — JSON-файл: + +```json +{ + "id": "152", + "name": "152-ФЗ (персональные данные)", + "common_sheet": "общие", + "employee_sheet": "сотруд", + "is_enabled": true, + "rules": [ + {"pattern": "полное наименование организации", "field": "fullName", "desc": "Полное наименование организации"} + ], + "sections": { + "commission": {"header": "состав комиссии"} + } +} +``` + +- `common_sheet` — лист с общими сведениями (подстрока имени листа) +- `employee_sheet` — лист с комиссией/сотрудниками (подстрока имени листа) +- `is_enabled` — включать ли разбор остальных листов как информационных систем +- `rules` — правила: регулярное выражение заголовка → поле программы +- `sections` — секции на листе сотрудников (заголовок секции → id) + +Правила для 187/117/МУ/КЗИ/ПЗИ — базовые; их можно дополнить под конкретные +опросники через редактор (галочка «Найдено, но не импортируется») или +отредактировав JSON профиля. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a7f0860 --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""ImportMapper — универсальный редактор импорта из Excel-опросников.""" diff --git a/core.py b/core.py new file mode 100644 index 0000000..2f5110b --- /dev/null +++ b/core.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +"""ImportMapper — ядро: профили опросников, сканирование Excel, извлечение данных.""" + +import os +import re +import json +from datetime import datetime, timedelta + +try: + import openpyxl +except ImportError: + openpyxl = None + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +PROFILES_DIR = os.path.join(BASE_DIR, 'profiles') +SETTINGS_DIR = os.path.join(BASE_DIR, 'settings') +EXPORT_DIR = os.path.join(BASE_DIR, 'export') + + +# ============================================================ +# НОРМАЛИЗАЦИЯ ЗНАЧЕНИЙ +# ============================================================ +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) > 4 and any('А' <= c <= 'Я' or c == 'Ё' for c in text): + has_lower = any('а' <= c <= 'я' or c == 'ё' for c in text) + if not has_lower: + words = text.split() + text = text.title() if len(words) >= 3 else text.capitalize() + return text + + +def is_noise_label(val: str) -> bool: + """Служебные строки опросника, которые импортировать не нужно.""" + noise = {'№ п/п', '№', 'номер', 'п/п', '№пп', '№ пп', 'значение', 'примечание', + 'вопрос', 'ответ', 'выбрать', 'да', 'нет', 'нет данных', 'поле'} + if val.strip() in noise: + return True + if re.fullmatch(r'\d+', val.strip()): + return True + return False + + +# ============================================================ +# ПРОФИЛИ ОПРОСНИКОВ +# ============================================================ +def list_profiles(): + """Список доступных профилей опросников.""" + result = [] + if not os.path.isdir(PROFILES_DIR): + return result + for fn in sorted(os.listdir(PROFILES_DIR)): + if fn.endswith('.json'): + try: + with open(os.path.join(PROFILES_DIR, fn), encoding='utf-8') as f: + profile = json.load(f) + result.append(profile) + except Exception: + pass + return result + + +def load_profile(profile_id): + for p in list_profiles(): + if p.get('id') == profile_id: + return p + return None + + +def settings_path(profile_id): + os.makedirs(SETTINGS_DIR, exist_ok=True) + return os.path.join(SETTINGS_DIR, f'settings_{profile_id}.json') + + +def load_settings(profile_id): + try: + with open(settings_path(profile_id), encoding='utf-8') as f: + return json.load(f) + except Exception: + return {'disabled': [], 'custom': []} + + +def save_settings(profile_id, settings): + with open(settings_path(profile_id), 'w', encoding='utf-8') as f: + json.dump(settings, f, ensure_ascii=False, indent=2) + + +def is_disabled(settings, key): + return key in settings.get('disabled', []) + + +# ============================================================ +# СКАНИРОВАНИЕ EXCEL +# ============================================================ +def scan_excel(filepath, profile): + """Сканирует Excel по правилам профиля. + + Возвращает (detected, unmapped, error): + detected — пары (заголовок → поле), которые импортируются + unmapped — найденные заголовки без привязки + """ + if not openpyxl: + return [], [], 'openpyxl не установлен. Установите: pip install openpyxl' + + rules = profile.get('rules', []) + common_sheet = profile.get('common_sheet', 'общие') + employee_sheet = profile.get('employee_sheet', 'сотруд') + + detected = [] + unmapped = [] + + try: + wb = openpyxl.load_workbook(filepath, data_only=True, read_only=False) + except Exception as e: + return [], [], f'Не удалось открыть файл: {e}' + + try: + for sheet_name in wb.sheetnames: + sheet_lower = sheet_name.lower() + if common_sheet and common_sheet.lower() in sheet_lower: + sheet_type = 'common' + elif employee_sheet and employee_sheet.lower() in sheet_lower: + sheet_type = 'employees' + else: + sheet_type = 'is' + + ws = wb[sheet_name] + for row in ws.iter_rows(min_row=1, values_only=True): + label = str(row[0]).strip() if row and row[0] is not None else '' + value = str(row[1]).strip() if len(row) > 1 and row[1] is not None else '' + if not label: + continue + + label_lower = label.lower() + mapped = False + for rule in rules: + pattern = rule.get('pattern', '') + if pattern and re.search(pattern, label_lower): + detected.append({ + 'field': rule.get('field', ''), + 'excel_text': label, + 'value': value[:60], + 'sheet': sheet_name, + 'desc': rule.get('desc', rule.get('field', '')), + }) + mapped = True + break + + if not mapped: + if is_noise_label(label_lower): + continue + unmapped.append({ + 'excel_text': label, + 'sheet': sheet_name, + 'suggested': _suggest_field(label, sheet_type, profile), + }) + finally: + try: + wb.close() + except Exception: + pass + + return detected, unmapped, '' + + +def _suggest_field(value, sheet_type, profile): + """Пытается предположить, какому полю соответствует заголовок.""" + val = value.lower().strip() + if is_noise_label(val): + return '' + + for rule in profile.get('rules', []): + pattern = rule.get('pattern', '') + if pattern and re.search(pattern, val): + return rule.get('field', '') + + keywords = { + 'fullName': ['полное наименование', 'наименование организации', 'полное название'], + 'shortName': ['сокращенное наименование', 'краткое наименование', 'сокращ'], + 'addressLegal': ['адрес', 'место нахождения'], + 'chiefFio': ['руководител', 'глава', 'директор'], + 'inn': ['инн', 'идентификационный номер'], + 'ogrn': ['огрн', 'основной государствен'], + 'kpp': ['кпп'], + 'phone': ['телефон', 'контактный'], + 'email': ['почта', 'email', 'e-mail'], + 'is_name': ['наименование ис', 'название ис', 'наименование информационной'], + 'is_software': ['программное обеспечение', 'программный'], + } + for field, kws in keywords.items(): + for kw in kws: + if kw in val: + return field + + return 'unknown_' + value[:20].replace(' ', '_').lower() + + +# ============================================================ +# ИЗВЛЕЧЕНИЕ ДАННЫХ (ПО НАСТРОЙКАМ РЕДАКТОРА) +# ============================================================ +def extract_data(filepath, profile, settings): + """Извлекает данные из опросника с учётом настроек редактора. + + Возвращает dict: {'fields': {...}, 'sections': {...}, 'is_list': [...]} + """ + if not openpyxl: + return {'error': 'openpyxl не установлен'} + + disabled = set(settings.get('disabled', [])) + custom = settings.get('custom', []) + rules = profile.get('rules', []) + common_sheet = profile.get('common_sheet', 'общие') + employee_sheet = profile.get('employee_sheet', 'сотруд') + + result = {'fields': {}, 'sections': {}, 'is_list': []} + + # Все секции профиля всегда присутствуют в выгрузке (пустые, если отключены) + for sec_id in profile.get('sections', {}): + result['sections'][sec_id] = [] + + try: + wb = openpyxl.load_workbook(filepath, data_only=True, read_only=False) + except Exception as e: + return {'error': str(e)} + + try: + # --- Общие сведения --- + common_sn = None + for sn in wb.sheetnames: + if common_sheet and common_sheet.lower() in sn.lower(): + common_sn = sn + break + if common_sn: + ws = wb[common_sn] + for row in ws.iter_rows(min_row=1, values_only=True): + label = clean_value(row[0]) if row and row[0] is not None else '' + value = clean_value(row[1]) if len(row) > 1 else '' + if not label: + continue + label_lower = label.lower() + for rule in rules: + pattern = rule.get('pattern', '') + field = rule.get('field', '') + if pattern and re.search(pattern, label_lower): + if not is_disabled(settings, field) and value: + result['fields'][field] = value + break + + # --- Секции (комиссия, сотрудники) --- + sections = profile.get('sections', {}) + emp_sn = None + for sn in wb.sheetnames: + if employee_sheet and employee_sheet.lower() in sn.lower(): + emp_sn = sn + break + if emp_sn and sections: + ws = wb[emp_sn] + current = None + for row in ws.iter_rows(min_row=1, values_only=True): + col_a = clean_value(row[0]) if row and row[0] is not None else '' + a_lower = col_a.lower() + header_hit = None + for sec_id, sec_cfg in sections.items(): + if is_disabled(settings, sec_id): + continue + if re.search(sec_cfg.get('header', ''), a_lower): + header_hit = sec_id + break + if header_hit: + current = header_hit + result['sections'].setdefault(current, []) + continue + if current: + # Заголовок таблицы внутри секции — пропускаем + if a_lower in ('№ п/п', '№', 'номер', 'п/п', '№пп', '№ пп'): + continue + row_data = [clean_value(c) if c is not None else '' + for c in row[:6]] + if any(row_data): + # Номер строки в первой колонке не нужен + if re.fullmatch(r'\d+', row_data[0].strip()): + row_data[0] = '' + result['sections'][current].append(row_data) + + # --- Информационные системы (листы, отличные от общих/сотрудников) --- + if profile.get('is_enabled'): + for sn in wb.sheetnames: + if common_sheet and common_sheet.lower() in sn.lower(): + continue + if employee_sheet and employee_sheet.lower() in sn.lower(): + continue + ws = wb[sn] + is_obj = {'name': '', 'rows': []} + for row in ws.iter_rows(min_row=1, values_only=True): + vals = [clean_value(c) for c in row] + label = (vals[0] or '').strip() if vals else '' + if not label: + continue + is_obj['rows'].append(vals) + label_lower = label.lower() + if re.search(r'название ис|наименование ис|наименование информационной', + label_lower) and not is_disabled(settings, 'is_name'): + if len(vals) > 1: + is_obj['name'] = vals[1] + if is_obj['rows']: + if not is_obj['name']: + is_obj['name'] = sn.strip() + result['is_list'].append(is_obj) + + # --- Пользовательские привязки --- + for cm in custom: + excel_text = (cm.get('excel_text') or '').strip().lower() + sheet = (cm.get('sheet') or '').strip() + field = (cm.get('field') or '').strip() + if not excel_text or not field: + continue + target_sheets = [sheet] if sheet in wb.sheetnames else wb.sheetnames + for sn in target_sheets: + ws = wb[sn] + for row in ws.iter_rows(min_row=1, values_only=True): + label = clean_value(row[0]) if row and row[0] is not None else '' + if label.lower().strip() == excel_text: + value = clean_value(row[1]) if len(row) > 1 else '' + if value: + result['fields'][field] = value + break + finally: + try: + wb.close() + except Exception: + pass + + return result diff --git a/editor.py b/editor.py new file mode 100644 index 0000000..0a59801 --- /dev/null +++ b/editor.py @@ -0,0 +1,363 @@ +# -*- coding: utf-8 -*- +"""ImportMapper — окно редактора импорта (простая версия) + +Как пользоваться: +1. Выберите тип опросника (152-ФЗ, 187-ФЗ и т.д.). +2. Укажите Excel-файл и нажмите «Сканировать». +3. Галочками включите/выключите нужные поля. +4. «Сохранить настройки» — настройки сохранятся для этого типа опросника. +5. «Выгрузить данные» — соберёт данные из Excel в JSON по настройкам. +""" + +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +import os +import json +from typing import Dict, List, Optional + +from .core import ( + list_profiles, load_profile, load_settings, save_settings, + scan_excel, extract_data, +) + + +class ImportMapperWindow: + def __init__(self, parent, excel_path: str = None): + self.parent = parent + self.excel_path = excel_path + + self.profiles = list_profiles() + self.profile = self.profiles[0] if self.profiles else None + + self.detected = [] # найденные по правилам профиля + self.unmapped = [] # найденные, но без привязки + self.disabled = set() # выключенные поля + self.custom = [] # добавленные пользователем привязки + + self._row_map = {} + self._vars = {} + + self.window = tk.Toplevel(parent) + self.window.title("Редактор импорта из Excel-опросников") + self.window.geometry("860x640") + self.window.minsize(700, 500) + self.window.transient(parent) + self.window.grab_set() + + self._build_ui() + self._load_current_settings() + + if excel_path: + self.file_var.set(excel_path) + self._scan() + + # ---------- ПОСТРОЕНИЕ ОКНА ---------- + def _build_ui(self): + top = ttk.Frame(self.window, padding=6) + top.pack(fill=tk.X) + + ttk.Label(top, text="Тип опросника:").pack(side=tk.LEFT) + self.profile_var = tk.StringVar() + self.profile_combo = ttk.Combobox( + top, textvariable=self.profile_var, state='readonly', width=34, + values=[p['name'] for p in self.profiles]) + self.profile_combo.pack(side=tk.LEFT, padx=4) + if self.profile: + self.profile_combo.current(0) + self.profile_combo.bind('<>', self._on_profile_change) + + ttk.Label(top, text="Файл:").pack(side=tk.LEFT, padx=(10, 2)) + self.file_var = tk.StringVar(value=self.excel_path or '') + ttk.Entry(top, textvariable=self.file_var, width=30).pack(side=tk.LEFT) + ttk.Button(top, text="Обзор", command=self._browse_file).pack(side=tk.LEFT, padx=2) + ttk.Button(top, text="Сканировать", command=self._scan).pack(side=tk.LEFT, padx=2) + + # Список + canvas = tk.Canvas(self.window, highlightthickness=0) + scrollbar = ttk.Scrollbar(self.window, orient=tk.VERTICAL, command=canvas.yview) + self._list_frame = ttk.Frame(canvas) + self._list_frame.bind( + "", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=self._list_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(6, 0), pady=4) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y, pady=4) + + # Низ + bottom = ttk.Frame(self.window, padding=6) + bottom.pack(fill=tk.X) + self.status_var = tk.StringVar(value="Выберите файл опросника") + ttk.Label(bottom, textvariable=self.status_var).pack(side=tk.LEFT) + ttk.Button(bottom, text="📤 Выгрузить данные", + command=self._export_data).pack(side=tk.RIGHT, padx=2) + ttk.Button(bottom, text="💾 Сохранить настройки", + command=self._save).pack(side=tk.RIGHT, padx=2) + ttk.Button(bottom, text="Закрыть", + command=self.window.destroy).pack(side=tk.RIGHT, padx=2) + + # ---------- ПРОФИЛИ ---------- + def _on_profile_change(self, event=None): + idx = self.profile_combo.current() + if 0 <= idx < len(self.profiles): + self.profile = self.profiles[idx] + self._load_current_settings() + if self.file_var.get().strip(): + self._scan() + + def _load_current_settings(self): + if not self.profile: + return + settings = load_settings(self.profile['id']) + self.disabled = set(settings.get('disabled', [])) + self.custom = settings.get('custom', []) + + # ---------- ФАЙЛ ---------- + def _browse_file(self): + path = filedialog.askopenfilename( + title="Выберите опросный лист (Excel)", + filetypes=[("Excel", "*.xlsx *.xls")] + ) + if path: + self.file_var.set(path) + self.excel_path = path + self._scan() + + def _scan(self): + path = self.file_var.get().strip() + if not path or not os.path.isfile(path): + messagebox.showwarning("Предупреждение", "Укажите существующий файл опросника") + return + if not self.profile: + messagebox.showwarning("Предупреждение", "Не выбран тип опросника") + return + + self.status_var.set("Сканирование…") + self.window.update() + + detected, unmapped, error = scan_excel(path, self.profile) + if error: + messagebox.showerror("Ошибка", error) + self.status_var.set(f"Ошибка: {error}") + return + + self.detected = detected + self.unmapped = unmapped + self._render() + + # ---------- ОТРИСОВКА ---------- + def _render(self): + for w in self._list_frame.winfo_children(): + w.destroy() + self._row_map.clear() + self._vars.clear() + + custom_keys = {(c['excel_text'], c['sheet']) for c in self.custom} + + ttk.Label(self._list_frame, + text="Импортируется из опросника (снимите галочку, чтобы отключить)", + font=('', 10, 'bold')).pack(anchor=tk.W, pady=(4, 2)) + + rows = [] + for d in self.detected: + rows.append(('detected', d)) + detected_keys = {(d['excel_text'], d['sheet']) for d in self.detected} + for c in self.custom: + if (c['excel_text'], c['sheet']) not in detected_keys: + rows.append(('custom', c)) + + if not rows: + ttk.Label(self._list_frame, text="— ничего не найдено —", + foreground='#888').pack(anchor=tk.W, padx=24) + for kind, info in rows: + self._add_row(kind, info) + + ttk.Label(self._list_frame, + text="Найдено в файле, но не импортируется (отметьте галочкой, чтобы добавить)", + font=('', 10, 'bold')).pack(anchor=tk.W, pady=(12, 2)) + + addable = [u for u in self.unmapped + if (u['excel_text'], u['sheet']) not in custom_keys] + if not addable: + ttk.Label(self._list_frame, text="— таких строк нет —", + foreground='#888').pack(anchor=tk.W, padx=24) + for u in addable: + self._add_row('unmapped', u) + + imported = sum(1 for kind, info in rows + if (kind == 'detected' and info['field'] not in self.disabled) + or kind == 'custom') + self.status_var.set( + f"Импортируется полей: {imported} | Отключено: {len(self.disabled)} | " + f"Можно добавить: {len(addable)}" + ) + + def _add_row(self, kind: str, info: dict): + row_id = f"{kind}_{len(self._row_map)}" + row = ttk.Frame(self._list_frame) + row.pack(fill=tk.X, pady=1) + + if kind == 'detected': + checked = info['field'] not in self.disabled + label = info['excel_text'] + desc = info['desc'] + sheet = info['sheet'] + elif kind == 'custom': + checked = True + label = info['excel_text'] + desc = info.get('desc') or info['field'] + sheet = info['sheet'] + else: # unmapped + checked = False + label = info['excel_text'] + suggested = info['suggested'] + desc = suggested if suggested and not suggested.startswith('unknown_') else '—' + sheet = info['sheet'] + + var = tk.BooleanVar(value=checked) + self._vars[row_id] = var + self._row_map[row_id] = {'kind': kind, 'info': info} + + ttk.Checkbutton(row, variable=var, + command=lambda rid=row_id: self._toggle(rid)).pack(side=tk.LEFT) + + label_text = label[:45] + ('…' if len(label) > 45 else '') + ttk.Label(row, text=f"«{label_text}»", width=48, anchor=tk.W).pack( + side=tk.LEFT, padx=(2, 6)) + ttk.Label(row, text=f"→ {desc}", anchor=tk.W, + foreground='#1a7a3c' if checked else '#888').pack( + side=tk.LEFT, fill=tk.X, expand=True) + ttk.Label(row, text=f"(лист: {sheet[:20]})", foreground='#999', + font=('', 8)).pack(side=tk.RIGHT, padx=4) + + # ---------- ПЕРЕКЛЮЧЕНИЕ ГАЛОЧЕК ---------- + def _toggle(self, row_id: str): + info = self._row_map.get(row_id) + if not info: + return + kind = info['kind'] + data = info['info'] + checked = self._vars[row_id].get() + + if kind == 'detected': + field = data['field'] + if checked: + self.disabled.discard(field) + else: + self.disabled.add(field) + + elif kind == 'custom': + if not checked: + self.custom = [c for c in self.custom + if not (c['excel_text'] == data['excel_text'] + and c['sheet'] == data['sheet'])] + + elif kind == 'unmapped': + if checked: + field = data['suggested'] + if not field or field.startswith('unknown_'): + field = self._ask_field() + if not field: + self._vars[row_id].set(False) + return + self.custom.append({ + 'excel_text': data['excel_text'], + 'sheet': data['sheet'], + 'field': field, + }) + else: + self.custom = [c for c in self.custom + if not (c['excel_text'] == data['excel_text'] + and c['sheet'] == data['sheet'])] + + self._render() + + def _ask_field(self) -> Optional[str]: + """Простой выбор: куда импортировать найденную строку.""" + dialog = tk.Toplevel(self.window) + dialog.title("Куда импортировать?") + dialog.geometry("480x200") + dialog.transient(self.window) + dialog.grab_set() + + ttk.Label(dialog, text="Выберите, куда подставить это значение:").pack(pady=(12, 4)) + + choices = [] + field_by_desc = {} + for rule in (self.profile or {}).get('rules', []): + desc = rule.get('desc') or rule.get('field') + if desc not in field_by_desc: + field_by_desc[desc] = rule.get('field') + choices.append(desc) + + var = tk.StringVar() + combo = ttk.Combobox(dialog, textvariable=var, values=choices, + state='readonly', width=52) + combo.pack(padx=10, pady=4) + if choices: + combo.current(0) + combo.focus() + + result = [] + + def _ok(): + result.append(var.get()) + dialog.destroy() + + ttk.Button(dialog, text="OK", command=_ok).pack(side=tk.LEFT, padx=20, pady=10) + ttk.Button(dialog, text="Отмена", command=dialog.destroy).pack( + side=tk.RIGHT, padx=20, pady=10) + + dialog.wait_window() + if not result: + return None + return field_by_desc.get(result[0]) + + # ---------- СОХРАНЕНИЕ И ВЫГРУЗКА ---------- + def _save(self): + if not self.profile: + return + settings = { + 'disabled': sorted(self.disabled), + 'custom': self.custom, + } + save_settings(self.profile['id'], settings) + self.status_var.set("✅ Настройки сохранены") + messagebox.showinfo( + "Готово", + f"Настройки импорта для «{self.profile['name']}» сохранены.\n\n" + f"Файл: settings/settings_{self.profile['id']}.json" + ) + + def _export_data(self): + path = self.file_var.get().strip() + if not path or not os.path.isfile(path): + messagebox.showwarning("Предупреждение", "Сначала укажите файл опросника") + return + if not self.profile: + return + + settings = { + 'disabled': sorted(self.disabled), + 'custom': self.custom, + } + data = extract_data(path, self.profile, settings) + if 'error' in data: + messagebox.showerror("Ошибка", data['error']) + return + + from .core import EXPORT_DIR + os.makedirs(EXPORT_DIR, exist_ok=True) + out_path = os.path.join(EXPORT_DIR, f"export_{self.profile['id']}.json") + with open(out_path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + n_fields = len(data.get('fields', {})) + n_sections = sum(len(v) for v in data.get('sections', {}).values()) + n_is = len(data.get('is_list', [])) + self.status_var.set( + f"✅ Выгружено: полей {n_fields}, строк секций {n_sections}, ИС {n_is}") + messagebox.showinfo( + "Готово", + f"Данные выгружены в:\n{out_path}\n\n" + f"Поля: {n_fields} | Строки секций: {n_sections} | ИС: {n_is}" + ) diff --git a/main.py b/main.py new file mode 100644 index 0000000..56dc1ed --- /dev/null +++ b/main.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""ImportMapper — универсальный редактор импорта из Excel-опросников. + +Запуск: + python3 main.py +""" + +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import tkinter as tk +from editor import ImportMapperWindow + + +def main(): + root = tk.Tk() + root.title("ImportMapper — редактор импорта из опросников") + root.geometry("300x120") + root.withdraw() # главное окно скрыто, работает дочернее + + ImportMapperWindow(root) + + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/profiles/117.json b/profiles/117.json new file mode 100644 index 0000000..d8358ac --- /dev/null +++ b/profiles/117.json @@ -0,0 +1,18 @@ +{ + "id": "117", + "name": "117-ФЗ (безопасность объектов КИИ / ГИС)", + "common_sheet": "общие", + "employee_sheet": "сотруд", + "is_enabled": false, + "rules": [ + {"pattern": "полное наименование организации", "field": "fullName", "desc": "Полное наименование организации"}, + {"pattern": "сокращенное наименование организации", "field": "shortName", "desc": "Сокращённое наименование"}, + {"pattern": "инн", "field": "inn", "desc": "ИНН"}, + {"pattern": "кпп", "field": "kpp", "desc": "КПП"}, + {"pattern": "огрн", "field": "ogrn", "desc": "ОГРН"}, + {"pattern": "адрес", "field": "addressLegal", "desc": "Адрес организации"}, + {"pattern": "должность руководителя", "field": "chiefPosition", "desc": "Должность руководителя"}, + {"pattern": "фио руководителя", "field": "chiefFio", "desc": "ФИО руководителя"} + ], + "sections": {} +} diff --git a/profiles/152.json b/profiles/152.json new file mode 100644 index 0000000..1cc8b4f --- /dev/null +++ b/profiles/152.json @@ -0,0 +1,52 @@ +{ + "id": "152", + "name": "152-ФЗ (персональные данные)", + "common_sheet": "общие", + "employee_sheet": "сотруд", + "is_enabled": true, + "rules": [ + {"pattern": "полное наименование организации", "field": "fullName", "desc": "Полное наименование организации"}, + {"pattern": "сокращенное наименование организации", "field": "shortName", "desc": "Сокращённое наименование"}, + {"pattern": "адрес организации \\(юридический\\)", "field": "addressLegal", "desc": "Юридический адрес"}, + {"pattern": "адрес организации \\(фактический\\)", "field": "addressActual", "desc": "Фактический адрес"}, + {"pattern": "инн", "field": "inn", "desc": "ИНН"}, + {"pattern": "дата.*огрн", "field": "ogrnDate", "desc": "Дата ОГРН"}, + {"pattern": "огрн", "field": "ogrn", "desc": "ОГРН"}, + {"pattern": "кпп", "field": "kpp", "desc": "КПП"}, + {"pattern": "основной оквэд", "field": "okved", "desc": "Основной ОКВЭД"}, + {"pattern": "должность руководителя", "field": "chiefPosition", "desc": "Должность руководителя"}, + {"pattern": "фио руководителя", "field": "chiefFio", "desc": "ФИО руководителя"}, + {"pattern": "должность.*защит.*информ|должность.*администрат", "field": "ispdnPosition", "desc": "Должность ответственного за ПДн"}, + {"pattern": "фио.*защит.*информ|фио.*администрат", "field": "ispdnFio", "desc": "ФИО ответственного за ПДн"}, + {"pattern": "электронная почта.*защит|электронная почта.*ответств|электронная почта.*специалист", "field": "ispdnEmail", "desc": "Email ответственного за ПДн"}, + {"pattern": "телефон.*защит|телефон.*ответств|телефон.*специалист", "field": "ispdnPhone", "desc": "Телефон ответственного за ПДн"}, + {"pattern": "структур.*подраздел", "field": "ispdnDepartment", "desc": "Подразделение ответственного за ПДн"}, + {"pattern": "номер.*договора|номер.*контракта", "field": "contractNumber", "desc": "Номер договора"}, + {"pattern": "дата.*договора|дата.*контракта", "field": "contractDate", "desc": "Дата договора"}, + {"pattern": "названи.*документ", "field": "paperDocuments", "desc": "Перечень бумажных документов"}, + {"pattern": "место.*хранен", "field": "paperStorage", "desc": "Место хранения документов"}, + {"pattern": "должность.*ответственн", "field": "responsiblePosition", "desc": "Должность ответственного за обработку ПДн"}, + {"pattern": "фио.*ответственн", "field": "responsibleFio", "desc": "ФИО ответственного за обработку ПДн"}, + {"pattern": "сайт организации", "field": "site_name", "desc": "Сайт организации"}, + {"pattern": "электронная почта", "field": "email", "desc": "Электронная почта"}, + {"pattern": "телефон", "field": "phone", "desc": "Телефон"}, + {"pattern": "название ис|наименование ис|наименование информационной", "field": "is_name", "desc": "Название информационной системы"}, + {"pattern": "цель обработки", "field": "is_purpose", "desc": "Цель обработки ПДн"}, + {"pattern": "сотрудник", "field": "is_employee", "desc": "Сотрудники с доступом к ИС"}, + {"pattern": "должность сотрудника", "field": "is_position", "desc": "Должность сотрудника"}, + {"pattern": "номер кабинета", "field": "is_room", "desc": "Номер кабинета"}, + {"pattern": "количество записей|количество субъект", "field": "is_personalDataCount", "desc": "Количество записей ПДн"}, + {"pattern": "перечень пд", "field": "is_personalDataList", "desc": "Перечень ПДн"}, + {"pattern": "принадлеж", "field": "is_pd_subjects_list", "desc": "Категории субъектов ПДн"}, + {"pattern": "перечень действий", "field": "is_pd_actions", "desc": "Перечень действий с ПДн"}, + {"pattern": "способ обработки", "field": "is_processing_mode", "desc": "Способ обработки ПДн"}, + {"pattern": "интернет", "field": "is_isInternet", "desc": "Использование интернета"}, + {"pattern": "защита", "field": "is_defense_tools", "desc": "Средства защиты информации"}, + {"pattern": "состав комиссии", "field": "commission", "desc": "Состав комиссии"}, + {"pattern": "перечень сотрудников", "field": "employeesAccess", "desc": "Сотрудники с доступом к ПДн"} + ], + "sections": { + "commission": {"header": "состав комиссии"}, + "employeesAccess": {"header": "перечень сотрудников"} + } +} diff --git a/profiles/187.json b/profiles/187.json new file mode 100644 index 0000000..2ab6b82 --- /dev/null +++ b/profiles/187.json @@ -0,0 +1,20 @@ +{ + "id": "187", + "name": "187-ФЗ (КИИ — критическая информационная инфраструктура)", + "common_sheet": "общие", + "employee_sheet": "сотруд", + "is_enabled": false, + "rules": [ + {"pattern": "полное наименование организации", "field": "fullName", "desc": "Полное наименование организации"}, + {"pattern": "сокращенное наименование организации", "field": "shortName", "desc": "Сокращённое наименование"}, + {"pattern": "инн", "field": "inn", "desc": "ИНН"}, + {"pattern": "кпп", "field": "kpp", "desc": "КПП"}, + {"pattern": "огрн", "field": "ogrn", "desc": "ОГРН"}, + {"pattern": "адрес", "field": "addressLegal", "desc": "Адрес организации"}, + {"pattern": "должность руководителя", "field": "chiefPosition", "desc": "Должность руководителя"}, + {"pattern": "фио руководителя", "field": "chiefFio", "desc": "ФИО руководителя"}, + {"pattern": "электронная почта", "field": "email", "desc": "Электронная почта"}, + {"pattern": "телефон", "field": "phone", "desc": "Телефон"} + ], + "sections": {} +} diff --git a/profiles/kzi.json b/profiles/kzi.json new file mode 100644 index 0000000..4be1580 --- /dev/null +++ b/profiles/kzi.json @@ -0,0 +1,18 @@ +{ + "id": "kzi", + "name": "КЗИ (комплекс средств защиты информации)", + "common_sheet": "общие", + "employee_sheet": "сотруд", + "is_enabled": false, + "rules": [ + {"pattern": "полное наименование организации", "field": "fullName", "desc": "Полное наименование организации"}, + {"pattern": "сокращенное наименование организации", "field": "shortName", "desc": "Сокращённое наименование"}, + {"pattern": "инн", "field": "inn", "desc": "ИНН"}, + {"pattern": "кпп", "field": "kpp", "desc": "КПП"}, + {"pattern": "огрн", "field": "ogrn", "desc": "ОГРН"}, + {"pattern": "адрес", "field": "addressLegal", "desc": "Адрес организации"}, + {"pattern": "должность руководителя", "field": "chiefPosition", "desc": "Должность руководителя"}, + {"pattern": "фио руководителя", "field": "chiefFio", "desc": "ФИО руководителя"} + ], + "sections": {} +} diff --git a/profiles/mu.json b/profiles/mu.json new file mode 100644 index 0000000..2e51e19 --- /dev/null +++ b/profiles/mu.json @@ -0,0 +1,18 @@ +{ + "id": "mu", + "name": "МУ (методические указания)", + "common_sheet": "общие", + "employee_sheet": "сотруд", + "is_enabled": false, + "rules": [ + {"pattern": "полное наименование организации", "field": "fullName", "desc": "Полное наименование организации"}, + {"pattern": "сокращенное наименование организации", "field": "shortName", "desc": "Сокращённое наименование"}, + {"pattern": "инн", "field": "inn", "desc": "ИНН"}, + {"pattern": "кпп", "field": "kpp", "desc": "КПП"}, + {"pattern": "огрн", "field": "ogrn", "desc": "ОГРН"}, + {"pattern": "адрес", "field": "addressLegal", "desc": "Адрес организации"}, + {"pattern": "должность руководителя", "field": "chiefPosition", "desc": "Должность руководителя"}, + {"pattern": "фио руководителя", "field": "chiefFio", "desc": "ФИО руководителя"} + ], + "sections": {} +} diff --git a/profiles/pzi.json b/profiles/pzi.json new file mode 100644 index 0000000..0fc9b9e --- /dev/null +++ b/profiles/pzi.json @@ -0,0 +1,18 @@ +{ + "id": "pzi", + "name": "ПЗИ (программные средства защиты информации)", + "common_sheet": "общие", + "employee_sheet": "сотруд", + "is_enabled": false, + "rules": [ + {"pattern": "полное наименование организации", "field": "fullName", "desc": "Полное наименование организации"}, + {"pattern": "сокращенное наименование организации", "field": "shortName", "desc": "Сокращённое наименование"}, + {"pattern": "инн", "field": "inn", "desc": "ИНН"}, + {"pattern": "кпп", "field": "kpp", "desc": "КПП"}, + {"pattern": "огрн", "field": "ogrn", "desc": "ОГРН"}, + {"pattern": "адрес", "field": "addressLegal", "desc": "Адрес организации"}, + {"pattern": "должность руководителя", "field": "chiefPosition", "desc": "Должность руководителя"}, + {"pattern": "фио руководителя", "field": "chiefFio", "desc": "ФИО руководителя"} + ], + "sections": {} +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5ad9175 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +openpyxl>=3.0