# -*- coding: utf-8 -*- """DokoGen — Визуальный редактор маппинга импорта из Excel Показывает, какие ячейки Excel → какие поля программы, позволяет добавлять/удалять/изменять привязки.""" import tkinter as tk from tkinter import ttk, messagebox, filedialog import re import json import os from typing import Dict, List, Optional try: import openpyxl except ImportError: openpyxl = None # ============================================================ # БАЗОВЫЙ МАППИНГ (стандартные правила импорта) # ============================================================ # Откуда эти правила: из importer.py (поля __import_152_common и др.) # Формат: (регулярка Excel_заголовка, имя_поля_программы, описание) DEFAULT_MAPPINGS = [ # Общие сведения ('полное наименование организации', 'fullName', 'Полное название организации'), ('сокращенное наименование организации', 'shortName', 'Сокращенное название'), ('адрес организации \\(юридический\\)', 'address_legal', 'Юридический адрес'), ('адрес организации \\(фактический\\)', 'address_factual', 'Фактический адрес'), ('инн', 'inn', 'ИНН'), ('огрн', 'ogrn', 'ОГРН'), ('дата.*огрн', 'ogrn_date', 'Дата ОГРН'), ('кпп', 'kpp', 'КПП'), ('должность руководителя', 'chief_position', 'Должность руководителя'), ('фио руководителя', 'chief_fio', 'ФИО руководителя'), ('должность.*ответственн', 'responsible_position', 'Должность ответственного'), ('фио.*ответственн', 'responsible_fio', 'ФИО ответственного'), ('должность.*защит.*информ|должность.*администрат', 'admin_position', 'Должность администратора'), ('фио.*защит.*информ|фио.*администрат', 'admin_fio', 'ФИО администратора'), ('номер.*договора|номер.*контракта', 'contract_number', 'Номер договора'), ('дата.*договора|дата.*контракта', 'contract_date', 'Дата договора'), ('названи.*документ', 'paper_documents', 'Перечень бумажных документов'), ('место.*хранен', 'paper_storage', 'Место хранения документов'), ('основной оквэд', 'okved_main', 'Основной ОКВЭД'), ('электронная почта', 'email', 'Email'), ('телефон', 'phone', 'Телефон'), # ИС ('название ис|наименование ис|наименование информационной', 'is_name', 'Название ИС'), ('сотрудник', 'is_employee', 'Сотрудник ИС'), ('должность сотрудника', 'is_position', 'Должность сотрудника ИС'), ('цель обработки пд', 'is_purpose', 'Цель обработки ПДн'), ('количество записей|количество субъект', 'is_pd_count', 'Кол-во записей ПДн'), ('обработки пд|способ обработки', 'is_processing_mode', 'Способ обработки'), ('интернет', 'is_internet', 'Наличие интернета'), # Комиссия ('состав комиссии', 'section_commission', 'Секция: состав комиссии'), ('перечень сотрудников', 'section_employees', 'Секция: сотрудники с доступом'), ] # Поля для информационных систем (используются при импорте листов ИС) IS_FIELD_MAP = { 'is_name': 'name', 'is_description': 'description', 'is_pd_count': 'pd_count', 'is_purpose': 'purpose', 'is_processing_mode': 'processing_modes', 'is_internet': 'is_internet', 'is_employee': 'employee', 'is_position': 'position', } def scan_excel(filepath: str) -> Dict: """Сканирует Excel-файл и возвращает структуру: листы, строки, колонки.""" if not openpyxl: return {'error': 'openpyxl не установлен'} result = { 'sheets': [], 'total_cells': 0, 'detected_mappings': [], 'unmapped_cells': [], } try: wb = openpyxl.load_workbook(filepath, data_only=True, read_only=False) for sheet_name in wb.sheetnames: ws = wb[sheet_name] sheet_info = { 'name': sheet_name, 'rows': 0, 'cols': 0, 'cells': [], } for row_idx, row in enumerate(ws.iter_rows(min_row=1, values_only=True), 1): has_data = False row_cells = [] for col_idx, val in enumerate(row, 1): if val is not None: str_val = str(val).strip() if str_val: has_data = True cell_info = { 'row': row_idx, 'col': col_idx, 'value': str_val[:200], 'sheet': sheet_name, } row_cells.append(cell_info) result['total_cells'] += 1 if row_cells: sheet_info['cells'].extend(row_cells) sheet_info['rows'] += 1 elif not has_data and sheet_info['rows'] > 0: break # пустая строка после данных = конец result['sheets'].append(sheet_info) wb.close() # Анализируем: какие ячейки подходят под маппинг for sheet in result['sheets']: for cell in sheet['cells']: val_lower = cell['value'].lower() mapped = False for pattern, field, desc in DEFAULT_MAPPINGS: if re.search(pattern, val_lower): result['detected_mappings'].append({ 'pattern': pattern, 'field': field, 'description': desc, 'cell': cell, 'value': cell['value'][:100], }) mapped = True break # Определяем тип листа sheet_type = 'common' if 'сотруд' in sheet['name'].lower(): sheet_type = 'employees' elif sheet['name'].lower() not in ('общие сведения', 'сотрудники', ''): sheet_type = 'is' if not mapped and cell['col'] <= 2: result['unmapped_cells'].append({ 'cell': cell, 'sheet_type': sheet_type, 'suggested_field': _suggest_field(cell['value'], sheet_type), }) except Exception as e: result['error'] = str(e) return result def _suggest_field(value: str, sheet_type: str) -> str: """Пытается предположить, какому полю может соответствовать ячейка.""" val = value.lower().strip() # Пробуем найти совпадение в базовых маппингах for pattern, field, desc in DEFAULT_MAPPINGS: if re.search(pattern, val): return field # По ключевым словам keywords = { 'full_name': ['полное наименование', 'наименование организации', 'полное название'], 'short_name': ['сокращенное наименование', 'краткое наименование', 'сокращ'], 'address': ['адрес', 'место нахождения'], 'chief_fio': ['руководител', 'глава', 'директор'], 'inn': ['инн', 'идентификационный номер'], 'ogrn': ['огрн', 'основной государствен'], 'kpp': ['кпп'], 'phone': ['телефон', 'контактный'], 'email': ['почта', 'email', 'e-mail'], 'is_name': ['наименование ис', 'название ис', 'наименование информационной'], 'is_software': ['программ', 'по'], 'is_pd': ['персональные данные', 'пдн', 'перечень пд'], } for field, kws in keywords.items(): for kw in kws: if kw in val: return field return 'unknown_' + value[:20].replace(' ', '_').lower() # ============================================================ # GUI: РЕДАКТОР МАППИНГА # ============================================================ class ImportMapperWindow: """Окно редактора импорта: показывает откуда что берётся, позволяет изменять привязки, добавлять новые, удалять.""" def __init__(self, parent, excel_path: str = None): self.parent = parent self.excel_path = excel_path self.mappings = list(DEFAULT_MAPPINGS) # копия для редактирования self.scan_result = None self.custom_mappings = [] self.removed_mappings = set() self.window = tk.Toplevel(parent) self.window.title("Редактор импорта из Excel") self.window.geometry("1000x700") self.window.minsize(800, 500) self.window.transient(parent) self.window.grab_set() self._build_ui() if excel_path: self._load_excel(excel_path) def _build_ui(self): # Верхняя панель — выбор файла top = ttk.Frame(self.window, padding=5) top.pack(fill=tk.X) ttk.Label(top, text="Файл опросника:").pack(side=tk.LEFT) self.file_var = tk.StringVar(value=self.excel_path or '') ttk.Entry(top, textvariable=self.file_var, width=60).pack(side=tk.LEFT, padx=5) ttk.Button(top, text="Обзор", command=self._browse_file).pack(side=tk.LEFT, padx=2) ttk.Button(top, text="Сканировать", command=self._scan_file).pack(side=tk.LEFT, padx=5) # Основная область main = ttk.PanedWindow(self.window, orient=tk.HORIZONTAL) main.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # Левая панель — Обнаруженные привязки left = ttk.LabelFrame(main, text="Обнаруженные привязки", padding=5) main.add(left, weight=1) self.mapping_tree = ttk.Treeview(left, columns=('field', 'excel', 'sheet', 'status'), show='tree headings', height=20) self.mapping_tree.heading('field', text='Поле программы') self.mapping_tree.heading('excel', text='Значение в Excel') self.mapping_tree.heading('sheet', text='Лист') self.mapping_tree.heading('status', text='Статус') self.mapping_tree.column('field', width=180) self.mapping_tree.column('excel', width=250) self.mapping_tree.column('sheet', width=100) self.mapping_tree.column('status', width=80) self.mapping_tree.pack(fill=tk.BOTH, expand=True) # Кнопки управления привязками btn_frame = ttk.Frame(left) btn_frame.pack(fill=tk.X, pady=3) ttk.Button(btn_frame, text="✏ Изменить привязку", command=self._edit_mapping).pack(side=tk.LEFT, padx=2) ttk.Button(btn_frame, text="🗑 Удалить привязку", command=self._remove_mapping).pack(side=tk.LEFT, padx=2) # Правая панель — Непривязанные ячейки right = ttk.LabelFrame(main, text="Непривязанные ячейки", padding=5) main.add(right, weight=1) self.unmapped_tree = ttk.Treeview(right, columns=('value', 'sheet', 'suggest'), show='tree headings', height=20) self.unmapped_tree.heading('value', text='Значение в Excel') self.unmapped_tree.heading('sheet', text='Лист') self.unmapped_tree.heading('suggest', text='Предполагаемое поле') self.unmapped_tree.column('value', width=220) self.unmapped_tree.column('sheet', width=100) self.unmapped_tree.column('suggest', width=150) self.unmapped_tree.pack(fill=tk.BOTH, expand=True) btn_frame2 = ttk.Frame(right) btn_frame2.pack(fill=tk.X, pady=3) ttk.Button(btn_frame2, text="➕ Добавить привязку", command=self._add_mapping).pack(side=tk.LEFT, padx=2) # Нижняя панель — статистика и кнопки bottom = ttk.Frame(self.window, padding=5) bottom.pack(fill=tk.X) self.status_label = ttk.Label(bottom, text="Загрузите Excel-файл для анализа") self.status_label.pack(side=tk.LEFT) ttk.Button(bottom, text="💾 Сохранить настройки импорта", command=self._save_settings).pack(side=tk.RIGHT, padx=2) ttk.Button(bottom, text="❌ Закрыть", command=self.window.destroy).pack(side=tk.RIGHT, padx=2) 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._load_excel(path) def _load_excel(self, path: str): self.status_label.config(text="Сканирование...") self.window.update() result = scan_excel(path) self.scan_result = result if 'error' in result: messagebox.showerror("Ошибка", result['error']) self.status_label.config(text=f"Ошибка: {result['error']}") return # Заполняем дерево привязок self._populate_mappings(result) # Заполняем дерево непривязанных self._populate_unmapped(result) self.status_label.config( text=f"Всего ячеек: {result['total_cells']} | " f"Привязок: {len(result['detected_mappings'])} | " f"Непривязанных: {len(result['unmapped_cells'])}" ) def _populate_mappings(self, result: Dict): self.mapping_tree.delete(*self.mapping_tree.get_children()) for m in result['detected_mappings']: status = '✅' if m['field'] not in self.removed_mappings else '❌ удалено' self.mapping_tree.insert('', tk.END, values=( m['field'], m['value'][:60], m['cell']['sheet'], status )) # Добавляем пользовательские привязки for cm in self.custom_mappings: self.mapping_tree.insert('', tk.END, values=( cm['field'], cm['excel_value'][:60], cm['sheet'], '➕ добавлено' )) def _populate_unmapped(self, result: Dict): self.unmapped_tree.delete(*self.unmapped_tree.get_children()) for u in result['unmapped_cells']: self.unmapped_tree.insert('', tk.END, values=( u['cell']['value'][:60], u['cell']['sheet'], u['suggested_field'] )) def _scan_file(self): path = self.file_var.get().strip() if path and os.path.isfile(path): self._load_excel(path) else: messagebox.showwarning("Предупреждение", "Укажите существующий файл") def _edit_mapping(self): selection = self.mapping_tree.selection() if not selection: messagebox.showinfo("Подсказка", "Выберите привязку для редактирования") return item = self.mapping_tree.item(selection[0]) values = item['values'] if not values: return old_field = values[0] new_field = self._ask_field_name("Изменить поле", old_field) if new_field and new_field != old_field: # Обновляем в custom_mappings или removed_mappings for cm in self.custom_mappings: if cm['field'] == old_field: cm['field'] = new_field if old_field in self.removed_mappings: self.removed_mappings.discard(old_field) self._populate_mappings(self.scan_result) def _remove_mapping(self): selection = self.mapping_tree.selection() if not selection: return item = self.mapping_tree.item(selection[0]) values = item['values'] if values and values[0]: field = values[0] self.removed_mappings.add(field) self._populate_mappings(self.scan_result) self.status_label.config(text=f"Привязка '{field}' удалена") def _add_mapping(self): selection = self.unmapped_tree.selection() if not selection: messagebox.showinfo("Подсказка", "Выберите ячейку из правого списка") return item = self.unmapped_tree.item(selection[0]) values = item['values'] if not values: return excel_val = values[0] suggested = values[2] if len(values) > 2 else '' sheet = values[1] if len(values) > 1 else '' field = self._ask_field_name("Новая привязка", suggested) if field: self.custom_mappings.append({ 'field': field, 'excel_value': excel_val, 'sheet': sheet, }) self._populate_mappings(self.scan_result) # Удаляем из непривязанных self.unmapped_tree.delete(selection[0]) self.status_label.config(text=f"Добавлена привязка: {excel_val} → {field}") def _ask_field_name(self, title: str, default: str) -> Optional[str]: """Диалог ввода имени поля.""" dialog = tk.Toplevel(self.window) dialog.title(title) dialog.geometry("500x250") dialog.transient(self.window) dialog.grab_set() ttk.Label(dialog, text="Имя поля в программе:").pack(pady=(10, 0)) var = tk.StringVar(value=default) entry = ttk.Entry(dialog, textvariable=var, width=50) entry.pack(padx=10, pady=5) entry.select_range(0, tk.END) entry.focus() # Подсказка: существующие поля ttk.Label(dialog, text="Подсказка: доступные поля", font=('', 8)).pack() fields_text = tk.Text(dialog, height=6, width=60) fields_text.pack(padx=10, pady=5) existing = sorted(set(m[1] for m in DEFAULT_MAPPINGS)) fields_text.insert(tk.END, ', '.join(existing)) fields_text.configure(state='disabled') result = [] def _ok(): result.append(var.get().strip()) dialog.destroy() def _cancel(): dialog.destroy() ttk.Button(dialog, text="OK", command=_ok).pack(side=tk.LEFT, padx=10, pady=10) ttk.Button(dialog, text="Отмена", command=_cancel).pack(side=tk.RIGHT, padx=10, pady=10) dialog.wait_window() return result[0] if result else None def _save_settings(self): """Сохраняет пользовательские настройки маппинга в JSON.""" path = filedialog.asksaveasfilename( defaultextension=".json", filetypes=[("JSON", "*.json")], title="Сохранить настройки импорта" ) if not path: return settings = { 'removed_mappings': list(self.removed_mappings), 'custom_mappings': self.custom_mappings, } try: with open(path, 'w', encoding='utf-8') as f: json.dump(settings, f, ensure_ascii=False, indent=2) messagebox.showinfo("Готово", f"Настройки сохранены:\n{path}") except Exception as e: messagebox.showerror("Ошибка", str(e))