feat: ImportMapper — универсальный редактор импорта из Excel-опросников
- Профили: 152-ФЗ, 187-ФЗ, 117-ФЗ, МУ, КЗИ, ПЗИ - Сканирование Excel: что импортируется и куда - Галочки: включить/выключить поля, добавить свои привязки - Сохранение настроек по типу опросника - Выгрузка данных в JSON (export/export_<тип>.json) - Тесты: отключение полей, пользовательские привязки, секции, ИС
This commit is contained in:
@@ -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}"
|
||||
)
|
||||
Reference in New Issue
Block a user