Revert "feat: import_mapper + кнопка в UI"
This reverts commit dd909792d4.
This commit is contained in:
@@ -1,467 +0,0 @@
|
||||
# -*- 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))
|
||||
@@ -100,7 +100,6 @@ 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)
|
||||
|
||||
@@ -1558,18 +1557,6 @@ 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:
|
||||
|
||||
Reference in New Issue
Block a user