Раньше root прятался (withdraw), а редактор открывался Toplevel — на Windows окно могло не появиться. Теперь редактор строится прямо на главном окне. Ошибки запуска пишутся в importmapper_error.log.
364 lines
15 KiB
Python
364 lines
15 KiB
Python
# -*- 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 = {}
|
|
|
|
# Окно редактора — прямо на переданном root (главное окно, не Toplevel)
|
|
self.window = parent
|
|
self.window.title("Редактор импорта из Excel-опросников")
|
|
self.window.geometry("860x640")
|
|
self.window.minsize(700, 500)
|
|
self.window.deiconify() # на всякий случай показываем окно
|
|
|
|
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}"
|
|
)
|