# -*- 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('<>', 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( "", 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}" )