Приём документов → реестр + журнал

Внесение данных из файлов приёма в реестр невостребованных документов
и заполнение журнала. Дата внесения — текущая; журнал заполняется чисто
(без строк-примеров шаблона).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-06-26 03:11:18 +05:00
commit b5f7060a1f
20 changed files with 1571 additions and 0 deletions

286
gui.py Normal file
View File

@@ -0,0 +1,286 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Десктоп-интерфейс для внесения принятых документов в реестр.
Запуск: python3 gui.py
Окно позволяет выбрать реестр, журнал и папку «прием», задать дату внесения
и нажать «Внести данные». Обработка идёт в фоновом потоке, поэтому окно не
зависает; ход работы и аномалии видны в логе. Результат пишется рядом с
исходными файлами с суффиксом «_ЗАПОЛНЕНО» — оригиналы не изменяются.
"""
from __future__ import annotations
import datetime
import logging
import queue
import threading
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from priem_register import Pipeline, config
log = logging.getLogger("priem")
PAD = 8
def derive_output(path: str) -> Path:
"""Путь результата: рядом с исходным, с суффиксом «_ЗАПОЛНЕНО»."""
p = Path(path)
return p.with_name(p.stem + "_ЗАПОЛНЕНО" + p.suffix)
class QueueHandler(logging.Handler):
"""Перекладывает записи лога в очередь — читает их главный поток GUI."""
def __init__(self, q: "queue.Queue"):
super().__init__()
self.q = q
def emit(self, record: logging.LogRecord) -> None:
self.q.put(("log", record.levelno, self.format(record)))
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("Приём документов → реестр")
self.geometry("780x580")
self.minsize(680, 500)
self.msg_queue: "queue.Queue" = queue.Queue()
self.worker: threading.Thread | None = None
self.register_var = tk.StringVar(value=str(config.REGISTER_SRC))
self.journal_var = tk.StringVar(value=str(config.JOURNAL_SRC))
self.priem_var = tk.StringVar(value=str(config.PRIEM_DIR))
self.date_var = tk.StringVar(value=config.ENTRY_DATE.strftime("%d.%m.%Y"))
self._build_form()
self._build_log()
self._attach_logging()
self.after(120, self._poll)
# --- построение интерфейса ------------------------------------------
def _build_form(self) -> None:
frm = ttk.Frame(self, padding=PAD)
frm.pack(fill="x")
frm.columnconfigure(1, weight=1)
self._file_row(frm, 0, "Реестр (.ods):", self.register_var,
[("Таблицы ODF", "*.ods"), ("Все файлы", "*.*")])
self._file_row(frm, 1, "Журнал (.xlsx):", self.journal_var,
[("Книги Excel", "*.xlsx"), ("Все файлы", "*.*")])
self._dir_row(frm, 2, "Папка «прием»:", self.priem_var)
ttk.Label(frm, text="Дата внесения:").grid(
row=3, column=0, sticky="w", pady=4)
ttk.Entry(frm, textvariable=self.date_var, width=14).grid(
row=3, column=1, sticky="w", pady=4)
ttk.Label(frm, text="дд.мм.гггг", foreground="#888").grid(
row=3, column=2, sticky="w")
bar = ttk.Frame(self, padding=(PAD, 0))
bar.pack(fill="x")
self.run_btn = ttk.Button(bar, text="Внести данные", command=self._start)
self.run_btn.pack(side="left", pady=PAD)
self.progress = ttk.Progressbar(bar, mode="indeterminate", length=180)
self.status = ttk.Label(bar, text="Готово к работе", foreground="#444")
self.status.pack(side="right")
def _file_row(self, frm, row, label, var, patterns) -> None:
ttk.Label(frm, text=label).grid(row=row, column=0, sticky="w", pady=4)
ttk.Entry(frm, textvariable=var).grid(row=row, column=1, sticky="ew", pady=4)
ttk.Button(frm, text="Обзор…",
command=lambda: self._pick_file(var, patterns)).grid(
row=row, column=2, padx=(PAD, 0))
def _dir_row(self, frm, row, label, var) -> None:
ttk.Label(frm, text=label).grid(row=row, column=0, sticky="w", pady=4)
ttk.Entry(frm, textvariable=var).grid(row=row, column=1, sticky="ew", pady=4)
ttk.Button(frm, text="Обзор…",
command=lambda: self._pick_dir(var)).grid(
row=row, column=2, padx=(PAD, 0))
def _build_log(self) -> None:
frm = ttk.Frame(self, padding=PAD)
frm.pack(fill="both", expand=True)
ttk.Label(frm, text="Ход работы:").pack(anchor="w")
self.log_text = tk.Text(frm, height=16, wrap="word", state="disabled",
font=("monospace", 10))
scroll = ttk.Scrollbar(frm, command=self.log_text.yview)
self.log_text.configure(yscrollcommand=scroll.set)
self.log_text.pack(side="left", fill="both", expand=True)
scroll.pack(side="right", fill="y")
self.log_text.tag_configure("warn", foreground="#b35900")
self.log_text.tag_configure("err", foreground="#c0392b")
self.log_text.tag_configure("ok", foreground="#1e7d34")
def _attach_logging(self) -> None:
handler = QueueHandler(self.msg_queue)
handler.setFormatter(logging.Formatter("%(message)s"))
log.setLevel(logging.INFO)
log.addHandler(handler)
# --- выбор файлов ---------------------------------------------------
def _pick_file(self, var, patterns) -> None:
start = Path(var.get()).parent if var.get() else Path.cwd()
path = filedialog.askopenfilename(initialdir=str(start), filetypes=patterns)
if path:
var.set(path)
def _pick_dir(self, var) -> None:
start = var.get() or str(Path.cwd())
path = filedialog.askdirectory(initialdir=start)
if path:
var.set(path)
# --- запуск обработки -----------------------------------------------
def _start(self) -> None:
if self.worker and self.worker.is_alive():
return
try:
params = self._validate()
except ValueError as exc:
messagebox.showerror("Проверьте поля", str(exc))
return
self._clear_log()
self.run_btn.configure(state="disabled")
self.progress.pack(side="left", padx=PAD)
self.progress.start(12)
self.status.configure(text="Обработка…", foreground="#444")
self.worker = threading.Thread(target=self._work, args=(params,), daemon=True)
self.worker.start()
def _validate(self) -> dict:
register = self.register_var.get().strip()
journal = self.journal_var.get().strip()
priem = self.priem_var.get().strip()
if not (register and journal and priem):
raise ValueError("Заполните все три пути: реестр, журнал и папку «прием».")
if not Path(register).is_file():
raise ValueError("Файл реестра не найден:\n%s" % register)
if not Path(journal).is_file():
raise ValueError("Файл журнала не найден:\n%s" % journal)
if not Path(priem).is_dir():
raise ValueError("Папка «прием» не найдена:\n%s" % priem)
raw = self.date_var.get().strip()
try:
date = datetime.datetime.strptime(raw, "%d.%m.%Y").date()
except ValueError:
raise ValueError("Дата должна быть в формате дд.мм.гггг, например 08.06.2026.")
return {
"register_src": register, "journal_src": journal,
"priem_dir": priem, "entry_date": date,
"register_out": derive_output(register),
"journal_out": derive_output(journal),
}
def _work(self, params: dict) -> None:
"""Выполняется в фоновом потоке. Связь с GUI — только через очередь."""
try:
pipeline = Pipeline(
register_src=params["register_src"],
register_out=params["register_out"],
journal_src=params["journal_src"],
journal_out=params["journal_out"],
priem_dir=params["priem_dir"],
entry_date=params["entry_date"],
)
result = pipeline.run()
self.msg_queue.put(("done", {
"total": result.total,
"files": len(result.entries),
"notes": list(result.notes),
"warnings": list(result.warnings),
"register_out": str(params["register_out"]),
"journal_out": str(params["journal_out"]),
}))
except Exception as exc: # noqa: BLE001 — показываем пользователю
log.exception("Сбой обработки")
self.msg_queue.put(("error", str(exc)))
# --- обновление GUI из очереди --------------------------------------
def _poll(self) -> None:
try:
while True:
kind, *payload = self.msg_queue.get_nowait()
if kind == "log":
levelno, text = payload
tag = "warn" if levelno >= logging.WARNING else None
self._append(text, tag)
elif kind == "done":
self._on_done(payload[0])
elif kind == "error":
self._on_error(payload[0])
except queue.Empty:
pass
self.after(120, self._poll)
def _on_done(self, info: dict) -> None:
self._finish()
n_warn = len(info["warnings"])
n_note = len(info["notes"])
if n_warn:
self.status.configure(text="Готово, требуют внимания: %d" % n_warn,
foreground="#b35900")
self._append("\nВыполнено. %d строк не привязаны — проверьте выше."
% n_warn, "warn")
else:
self.status.configure(text="Готово", foreground="#1e7d34")
self._append("\nВыполнено. Все данные внесены.", "ok")
if n_note:
self._append("Присоединено заметок к записям: %d (см. «•» выше)." % n_note)
self._append(
"\nРеестр: %s\nЖурнал: %s" % (info["register_out"], info["journal_out"]))
summary = ("Внесено записей: %d\nОбработано файлов: %d\n"
"Присоединено заметок: %d\nНе привязано строк: %d\n\n"
"Реестр: %s\nЖурнал: %s") % (
info["total"], info["files"], n_note, n_warn,
info["register_out"], info["journal_out"])
if n_warn:
summary += ("\n\n%d строк не удалось привязать к записи "
"(см. лог) — проверьте вручную." % n_warn)
messagebox.showwarning("Готово (есть непривязанные строки)", summary)
else:
messagebox.showinfo("Готово", summary)
def _on_error(self, message: str) -> None:
self._finish()
self.status.configure(text="Ошибка", foreground="#c0392b")
self._append("\nОШИБКА: %s" % message, "err")
messagebox.showerror("Ошибка обработки", message)
def _finish(self) -> None:
self.progress.stop()
self.progress.pack_forget()
self.run_btn.configure(state="normal")
# --- вывод в лог ----------------------------------------------------
def _append(self, text: str, tag: str | None = None) -> None:
self.log_text.configure(state="normal")
self.log_text.insert("end", text + "\n", (tag,) if tag else ())
self.log_text.see("end")
self.log_text.configure(state="disabled")
def _clear_log(self) -> None:
self.log_text.configure(state="normal")
self.log_text.delete("1.0", "end")
self.log_text.configure(state="disabled")
def main() -> None:
App().mainloop()
if __name__ == "__main__":
main()