feat: ImportMapper — универсальный редактор импорта из Excel-опросников

- Профили: 152-ФЗ, 187-ФЗ, 117-ФЗ, МУ, КЗИ, ПЗИ
- Сканирование Excel: что импортируется и куда
- Галочки: включить/выключить поля, добавить свои привязки
- Сохранение настроек по типу опросника
- Выгрузка данных в JSON (export/export_<тип>.json)
- Тесты: отключение полей, пользовательские привязки, секции, ИС
This commit is contained in:
2026-08-03 09:19:16 +04:00
commit 7886edc0af
13 changed files with 966 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
settings/
export/
.venv/
venv/
+70
View File
@@ -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 профиля.
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""ImportMapper — универсальный редактор импорта из Excel-опросников."""
+350
View File
@@ -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
+363
View File
@@ -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('<<ComboboxSelected>>', 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(
"<Configure>", 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}"
)
+30
View File
@@ -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()
+18
View File
@@ -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": {}
}
+52
View File
@@ -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": "перечень сотрудников"}
}
}
+20
View File
@@ -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": {}
}
+18
View File
@@ -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": {}
}
+18
View File
@@ -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": {}
}
+18
View File
@@ -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": {}
}
+1
View File
@@ -0,0 +1 @@
openpyxl>=3.0