Files
ImportMapper/editor.py
T
prog1764 7886edc0af feat: ImportMapper — универсальный редактор импорта из Excel-опросников
- Профили: 152-ФЗ, 187-ФЗ, 117-ФЗ, МУ, КЗИ, ПЗИ
- Сканирование Excel: что импортируется и куда
- Галочки: включить/выключить поля, добавить свои привязки
- Сохранение настроек по типу опросника
- Выгрузка данных в JSON (export/export_<тип>.json)
- Тесты: отключение полей, пользовательские привязки, секции, ИС
2026-08-03 09:19:16 +04:00

364 lines
14 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 = {}
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}"
)