diff --git a/dokogen/import_mapper.py b/dokogen/import_mapper.py new file mode 100644 index 0000000..8ffcc83 --- /dev/null +++ b/dokogen/import_mapper.py @@ -0,0 +1,467 @@ +# -*- 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)) diff --git a/dokogen/ui.py b/dokogen/ui.py index e0dce99..2259446 100644 --- a/dokogen/ui.py +++ b/dokogen/ui.py @@ -100,6 +100,7 @@ class DokoGenApp: ttk.Button(toolbar, text="📂 Открыть", command=self.open_data).pack(side=tk.LEFT, padx=2) ttk.Button(toolbar, text="💾 Сохранить", command=self.save_data).pack(side=tk.LEFT, padx=2) ttk.Button(toolbar, text="📥 Импорт", command=self.import_from_survey).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="✏ Редактор импорта", command=self.open_import_mapper).pack(side=tk.LEFT, padx=2) ttk.Button(toolbar, text="📁 Шаблоны", command=self.select_templates).pack(side=tk.LEFT, padx=2) ttk.Button(toolbar, text="⚡ Генерация", command=self.generate_all).pack(side=tk.LEFT, padx=2) @@ -1557,6 +1558,18 @@ class DokoGenApp: "Версия 1.0\n" "Генератор документов из шаблонов") + + + def open_import_mapper(self): + """Открывает визуальный редактор маппинга импорта.""" + try: + from .import_mapper import ImportMapperWindow + ImportMapperWindow(self.root) + except ImportError as e: + messagebox.showerror("Ошибка", f"Модуль редактора импорта не загружен: {e}") + except Exception as e: + messagebox.showerror("Ошибка", str(e)) + def show_variables(self): text = "Переменные шаблонов:\n\n" for var, group, desc, _ in VARIABLE_DEFS: