commit ec82b528a3eb7df9fa47dab0c9974b5a488a93e8 Author: jze9 Date: Tue Mar 10 20:21:31 2026 +0500 test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8372ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +output +data +./data/ +./output/ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.db +*.sqlite +*.log +*.bak +*.tmp +*.swp +*.swo +*.DS_Store +*.idea/ +*.vscode/ +*.ipynb_checkpoints/ +./.venv/ +.venv/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..8b52f6d --- /dev/null +++ b/main.py @@ -0,0 +1,284 @@ +""" +Программа 17: Заполнение формы по выявленным-1 +Графический интерфейс (tkinter) + запуск обработки. +""" + +import sys +import threading +import traceback +import tkinter as tk +from tkinter import filedialog, messagebox, ttk +from pathlib import Path + +from services.data_loader import load_ki, load_table2, build_mif_index +from services.form_filler import fill_form +from services.output_generator import make_filename, combine_forms, generate_table4 + +# ─────────────────────────────── Цвета / стиль ──────────────────────────── # + +BG = "#1C3557" # тёмно-синий фон +BG_FRAME = "#243F6B" # фон панелей +FG = "#FFFFFF" # белый текст +FG_DIM = "#A8C4E0" # приглушённый текст +BTN_RUN = "#2E7D32" # зелёная кнопка «Запустить» +BTN_HELP = "#1565C0" # синяя кнопка «Справка» +FONT = ("Arial", 11) +FONT_H = ("Arial", 13, "bold") +FONT_LOG = ("Courier New", 9) + +# ─────────────────────────────── Основная логика ────────────────────────── # + +def run_processing(table1_path: str, table2_path: str, mif_dir: str, + template_path: str, output_dir: str, + log_callback, progress_callback, done_callback): + """Выполняется в отдельном потоке.""" + try: + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + + log_callback("[1/4] Загрузка исходных данных...") + progress_callback(10) + ki = load_ki(table1_path) + t2_rows = load_table2(table2_path) + mif_idx = build_mif_index(mif_dir) + log_callback(f" КИ: {next(iter(ki.values()), '?')}") + log_callback(f" Таблица 2: {len(t2_rows)} строк") + log_callback(f" MIF-файлов: {len(mif_idx)}") + + log_callback("\n[2/4] Генерация форм...") + generated, errors = [], [] + total = sum(1 for r in t2_rows if r[0]) + + for i, t2_row in enumerate(t2_rows, 1): + zu_file = t2_row[0] if t2_row[0] else "" + kv = t2_row[1] if t2_row[1] else "" + if not zu_file: + continue + filename = make_filename(str(kv), str(zu_file)) + out_path = out / filename + log_callback(f" [{len(generated)+len(errors)+1}/{total}] {filename} ...", end="") + try: + fill_form(template_path, str(out_path), ki, t2_row, mif_idx) + generated.append({"name": filename, "path": str(out_path), + "kv": str(kv), "zu_file": str(zu_file)}) + log_callback(" OK") + except Exception as exc: + log_callback(f" ОШИБКА: {exc}") + errors.append((filename, str(exc))) + progress_callback(10 + int(70 * (len(generated) + len(errors)) / max(total, 1))) + + log_callback(f"\n Создано: {len(generated)}, ошибок: {len(errors)}") + + if generated: + log_callback("\n[3/4] Создание объединённого файла...") + progress_callback(82) + combined = out / "ВСЕ_ФОРМЫ.xlsx" + try: + combine_forms([r["path"] for r in generated], str(combined)) + log_callback(f" → {combined}") + except Exception as exc: + log_callback(f" ОШИБКА объединения: {exc}") + + log_callback("\n[4/4] Создание Таблицы 4...") + progress_callback(95) + t4 = out / "Таблица4_Перечень_форм.xlsx" + generate_table4(generated, str(t4)) + log_callback(f" → {t4}") + + progress_callback(100) + done_callback(generated, errors, str(out)) + + except Exception as exc: + log_callback(f"\nКРИТИЧЕСКАЯ ОШИБКА:\n{traceback.format_exc()}") + done_callback([], [(str(exc), str(exc))], output_dir) + +# ─────────────────────────────── GUI ────────────────────────────────────── # + +class App(tk.Tk): + def __init__(self): + super().__init__() + self.title("Программа 17 — Выявленные-1") + self.geometry("700x600") + self.minsize(600, 550) + self.configure(bg=BG) + self._build_ui() + + # ── Построение интерфейса ────────────────────────────────────────────── # + + def _build_ui(self): + # Заголовок + tk.Label(self, text="Программа 17: Заполнение формы по Выявленным-1", + font=FONT_H, bg=BG, fg=FG).pack(pady=(14, 8)) + + # Карточка с полями ввода + card = tk.Frame(self, bg=BG_FRAME, bd=0) + card.pack(fill=tk.X, padx=18, pady=4) + + self.var_t1 = self._add_field(card, "Таблица 1 (КИ):", "xlsx", 0) + self.var_t2 = self._add_field(card, "Таблица 2 (данные):", "xlsx", 1) + self.var_mif = self._add_field(card, "Папка MIF:", "dir", 2) + self.var_template = self._add_field(card, "Шаблон XLSX:", "xlsx", 3) + self.var_output = self._add_field(card, "Папка вывода:", "dir", 4) + + # Дефолты + base = Path(__file__).parent + data = base / "data" / "Исх.данные" + self.var_t1.set(str(data / "Таблица1 КИ-Неустроева.xlsx")) + self.var_t2.set(str(data / "Таблица 2 Перечень выявленных исх.данные.xlsx")) + self.var_mif.set(str(data / "1. 74 34 0400007 ГК вымпел-73")) + self.var_template.set(str(base / "data" / "ШАБЛОН ВЫЯВЛЕННЫЕ-1.xlsx")) + self.var_output.set(str(base / "output")) + + # Прогресс-бар + pf = tk.Frame(self, bg=BG) + pf.pack(fill=tk.X, padx=18, pady=(10, 2)) + self.progress = ttk.Progressbar(pf, length=600, mode="determinate") + self.progress.pack(fill=tk.X) + self.progress_label = tk.Label(pf, text="Готов к работе", + font=("Arial", 9), bg=BG, fg=FG_DIM) + self.progress_label.pack(anchor="w") + + # Лог + lf = tk.Frame(self, bg=BG) + lf.pack(fill=tk.BOTH, expand=True, padx=18, pady=(4, 4)) + tk.Label(lf, text="Журнал:", font=("Arial", 9), bg=BG, fg=FG_DIM).pack(anchor="w") + self.log_text = tk.Text(lf, font=FONT_LOG, bg="#0D1F33", fg="#C8E6FF", + height=10, state=tk.DISABLED, wrap=tk.WORD) + sb = tk.Scrollbar(lf, command=self.log_text.yview) + self.log_text.configure(yscrollcommand=sb.set) + sb.pack(side=tk.RIGHT, fill=tk.Y) + self.log_text.pack(fill=tk.BOTH, expand=True) + + # Кнопки + bf = tk.Frame(self, bg=BG) + bf.pack(fill=tk.X, padx=18, pady=(6, 12)) + tk.Button(bf, text="Справка", command=self._show_help, + font=FONT, bg=BTN_HELP, fg=FG, + padx=14, pady=8, relief=tk.FLAT).pack(side=tk.LEFT) + self.btn_run = tk.Button(bf, text="▶ Запустить", + command=self._start, font=FONT_H, + bg=BTN_RUN, fg=FG, padx=20, pady=8, relief=tk.FLAT) + self.btn_run.pack(side=tk.RIGHT) + + def _add_field(self, parent, label: str, kind: str, row_idx: int): + """Добавить строку: метка + поле + кнопка «...».""" + var = tk.StringVar() + tk.Label(parent, text=label, font=FONT, bg=BG_FRAME, fg=FG, + anchor="w").grid(row=row_idx * 2, column=0, columnspan=3, + sticky="w", padx=12, pady=(8, 0)) + entry = tk.Entry(parent, textvariable=var, font=FONT, width=55) + entry.grid(row=row_idx * 2 + 1, column=0, padx=(12, 4), pady=(0, 2), sticky="ew") + btn = tk.Button(parent, text="…", font=FONT, width=3, + command=lambda k=kind, v=var: self._browse(k, v)) + btn.grid(row=row_idx * 2 + 1, column=1, padx=(0, 12), pady=(0, 2)) + parent.columnconfigure(0, weight=1) + return var + + def _browse(self, kind: str, var: tk.StringVar): + if kind == "dir": + path = filedialog.askdirectory(title="Выберите папку") + else: + path = filedialog.askopenfilename( + title="Выберите файл", + filetypes=[("Excel файлы", "*.xlsx *.xls"), ("Все файлы", "*.*")] + ) + if path: + var.set(path) + + # ── Запуск ──────────────────────────────────────────────────────────── # + + def _start(self): + t1 = self.var_t1.get().strip() + t2 = self.var_t2.get().strip() + mif = self.var_mif.get().strip() + tmpl = self.var_template.get().strip() + out = self.var_output.get().strip() + + missing = [name for name, v in [("Таблица 1", t1), ("Таблица 2", t2), + ("Папка MIF", mif), ("Шаблон", tmpl), + ("Папка вывода", out)] if not v] + if missing: + messagebox.showerror("Ошибка", "Не заполнены поля:\n• " + "\n• ".join(missing)) + return + + self._log_clear() + self._set_progress(0, "Запуск...") + self.btn_run.config(state=tk.DISABLED) + + thread = threading.Thread( + target=run_processing, + args=(t1, t2, mif, tmpl, out, + self._log_append, + lambda v: self.after(0, self._set_progress, v, None), + self._on_done), + daemon=True, + ) + thread.start() + + # ── Обратные вызовы из потока ────────────────────────────────────────── # + + def _log_append(self, text: str, end: str = "\n"): + def _do(): + self.log_text.config(state=tk.NORMAL) + self.log_text.insert(tk.END, text + end) + self.log_text.see(tk.END) + self.log_text.config(state=tk.DISABLED) + self.after(0, _do) + + def _log_clear(self): + self.log_text.config(state=tk.NORMAL) + self.log_text.delete("1.0", tk.END) + self.log_text.config(state=tk.DISABLED) + + def _set_progress(self, value: int, label_text=None): + self.progress["value"] = value + if label_text is not None: + self.progress_label.config(text=label_text) + elif value == 100: + self.progress_label.config(text="Готово!") + + def _on_done(self, generated: list, errors: list, out_dir: str): + def _do(): + self.btn_run.config(state=tk.NORMAL) + if errors and not generated: + messagebox.showerror("Ошибка", f"Обработка завершилась с ошибками:\n{errors[0][1]}") + return + msg = ( + f"Обработка завершена!\n\n" + f"Создано форм: {len(generated)}\n" + f"Ошибок: {len(errors)}\n\n" + f"Результаты в папке:\n{out_dir}" + ) + if errors: + msg += f"\n\nФайлы с ошибками ({len(errors)}):\n" + \ + "\n".join(f"• {n}" for n, _ in errors[:10]) + messagebox.showinfo("Готово", msg) + self.after(0, _do) + + # ── Справка ─────────────────────────────────────────────────────────── # + + def _show_help(self): + messagebox.showinfo("Справка", """\ +Программа 17: Заполнение формы по Выявленным-1 + +Поля ввода: +• Таблица 1 — файл xlsx с данными кадастрового инженера +• Таблица 2 — файл xlsx со списком выявленных объектов +• Папка MIF — папка с MIF-файлами контуров (*.mif) +• Шаблон — файл шаблона XLSX +• Папка вывода — куда сохранять результаты + +Результаты: +• Отдельный xlsx для каждой строки Таблицы 2 +• ВСЕ_ФОРМЫ.xlsx — все формы на одном листе +• Таблица4_Перечень_форм.xlsx — перечень файлов + +Шрифт всех ячеек: Times New Roman 11 pt. +""") + + +# ─────────────────────────────── Точка входа ────────────────────────────── # + +if __name__ == "__main__": + app = App() + app.mainloop() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..13e4b4b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "kadastr-17" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "openpyxl>=3.1", +] diff --git a/read_mif_file.py b/read_mif_file.py new file mode 100644 index 0000000..1324743 --- /dev/null +++ b/read_mif_file.py @@ -0,0 +1,229 @@ +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Dict, Any, Optional, Tuple, Union + + +@dataclass +class MifFile: + name: str + path: str + headers: Dict[str, Any] = field(default_factory=dict) + features: List[Dict[str, Any]] = field(default_factory=list) + + +def _unquote(s: str) -> str: + s = s.strip() + if s.startswith('"') and s.endswith('"'): + return s[1:-1] + return s + + +def parse_mif(file_path: str) -> Union[MifFile, List[MifFile]]: + """ + Примитивный парсер MIF-файла. Обрабатывает заголовки (Version, Charset, Delimiter, + CoordSys, Columns) и простые объекты в блоке Data: Region, Text, Center/Centre, Pen, Brush. + + Не претендует на полную спецификацию MapInfo MIF, но удобен для быстрого извлечения структур. + """ + p = Path(file_path) + if not p.exists(): + raise FileNotFoundError(file_path) + + # If a directory is passed, parse all .mif files inside and return a list + if p.is_dir(): + return load_mif_files(str(p)) + + mif = MifFile(name=p.name, path=str(p.resolve())) + lines = [ln.rstrip('\n') for ln in p.read_text(encoding='utf-8', errors='replace').splitlines()] + + i = 0 + n = len(lines) + # parse header until "Data" + while i < n: + line = lines[i].strip() + if not line: + i += 1 + continue + up = line.upper() + if up.startswith('VERSION'): + mif.headers['version'] = line.split(None, 1)[1].strip() if len(line.split()) > 1 else None + i += 1 + continue + if up.startswith('CHARSET'): + # Charset "WindowsCyrillic" + rest = line.split(None, 1)[1] if len(line.split()) > 1 else '' + mif.headers['charset'] = _unquote(rest) + i += 1 + continue + if up.startswith('DELIMITER'): + rest = line.split(None, 1)[1] if len(line.split()) > 1 else '' + mif.headers['delimiter'] = _unquote(rest) + i += 1 + continue + if up.startswith('COORDSYS'): + mif.headers['coordsys'] = line[len('CoordSys'):].strip() + i += 1 + continue + if up.startswith('COLUMNS'): + # next N lines are column definitions + parts = line.split() + cnt = int(parts[1]) if len(parts) > 1 else 0 + i += 1 + cols: List[str] = [] + for _ in range(cnt): + if i >= n: + break + cols.append(lines[i].strip()) + i += 1 + mif.headers['columns'] = cols + continue + if up == 'DATA': + i += 1 + break + i += 1 + + # parse data features + while i < n: + raw = lines[i].lstrip() + if not raw: + i += 1 + continue + parts = raw.split(None, 1) + typ = parts[0] + if typ.upper() == 'REGION': + feat: Dict[str, Any] = {'type': 'Region', 'props': {}} + # Region — количество контуров (полигонов) в объекте + try: + n_polygons = int(parts[1]) if len(parts) > 1 else 1 + except (ValueError, IndexError): + n_polygons = 1 + i += 1 + + all_points: List[Tuple[float, float]] = [] + contours: List[List[Tuple[float, float]]] = [] + + for _poly in range(n_polygons): + # Первая строка каждого контура — количество точек + if i >= n: + break + cnt_line = lines[i].strip() + try: + pts_cnt = int(cnt_line) + i += 1 + except ValueError: + pts_cnt = 0 + + poly_pts: List[Tuple[float, float]] = [] + for _ in range(pts_cnt): + if i >= n: + break + xy = lines[i].strip().split() + if len(xy) >= 2: + try: + poly_pts.append((float(xy[0]), float(xy[1]))) + except ValueError: + pass + i += 1 + + if poly_pts: + contours.append(poly_pts) + all_points.extend(poly_pts) + + # Если один контур — points == список точек (как было раньше) + # Если несколько — points == точки всех контуров подряд, contours — отдельно + feat['props']['points'] = all_points + feat['props']['contours'] = contours # List[List[(x,y)]] + + # Читаем атрибуты (Pen, Brush, Center) до начала следующего объекта + while i < n: + t = lines[i].lstrip() + if not t: + i += 1 + continue + up2 = t.upper() + if up2.startswith('PEN'): + feat['props']['pen'] = t[len('Pen'):].strip() + i += 1 + continue + if up2.startswith('BRUSH'): + feat['props']['brush'] = t[len('Brush'):].strip() + i += 1 + continue + if up2.startswith('CENTER') or up2.startswith('CENTRE'): + rest = t.split(None, 1)[1] if len(t.split()) > 1 else '' + coords = rest.split() + try: + cx = float(coords[0]); cy = float(coords[1]) + feat['props']['center'] = (cx, cy) + except Exception: + pass + i += 1 + continue + if up2.split()[0] in ('REGION', 'TEXT', 'POINT', 'PLINE', 'LINE'): + break + i += 1 + + mif.features.append(feat) + continue + + if typ.upper() == 'TEXT': + feat = {'type': 'Text', 'props': {}} + # next line: quoted string + i += 1 + if i < n: + txt_line = lines[i].strip() + feat['props']['text'] = _unquote(txt_line) + i += 1 + # next line: bounding coordinates (often 4 numbers) + if i < n: + coords = lines[i].strip().split() + try: + nums = [float(x) for x in coords] + feat['props']['bbox'] = nums + except Exception: + pass + i += 1 + # optional Font line + if i < n and lines[i].lstrip().upper().startswith('FONT'): + feat['props']['font'] = lines[i].lstrip()[len('Font'):].strip() + i += 1 + + mif.features.append(feat) + continue + + # simple POINT parser + if typ.upper() == 'POINT': + feat = {'type': 'Point', 'props': {}} + rest = parts[1] if len(parts) > 1 else '' + coords = rest.split() + try: + feat['props']['xy'] = (float(coords[0]), float(coords[1])) + except Exception: + feat['props']['xy'] = None + i += 1 + mif.features.append(feat) + continue + + # unknown / unsupported object: skip one line + i += 1 + + return mif + + +def load_mif_files(dir_path: str) -> List[MifFile]: + """Найти все .mif файлы в директории (рекурсивно) и распарсить их в объекты MifFile.""" + root = Path(dir_path) + if not root.exists(): + raise FileNotFoundError(dir_path) + + mif_files: List[MifFile] = [] + for p in root.rglob('*.mif'): + try: + mif_files.append(parse_mif(str(p))) + except Exception: + # при ошибке парсинга — пропустить файл + continue + return mif_files + + +__all__ = ["MifFile", "parse_mif", "load_mif_files"] \ No newline at end of file diff --git a/read_one_file_xlsx.py b/read_one_file_xlsx.py new file mode 100644 index 0000000..cdb7473 --- /dev/null +++ b/read_one_file_xlsx.py @@ -0,0 +1,49 @@ +from pathlib import Path +from typing import Any, Dict, List + +def read_xlsx(file_path: str) -> Dict[str, Any]: + """ + Прочитать .xlsx файл и вернуть простой объект со всеми данными. + + Возвращаемая структура: + { + 'name': , + 'path': , + 'sheets': [ + {'name': , 'rows': [[cell_value, ...], ...]}, + ... + ] + } + + Читает документ сверху-вниз, сохраняя значения ячеек в их питоновских типах. + """ + p = Path(file_path) + if not p.exists(): + raise FileNotFoundError(file_path) + + # try openpyxl first + try: + from openpyxl import load_workbook + except Exception as e: + raise RuntimeError("openpyxl is required to read .xlsx files") from e + + wb = load_workbook(filename=str(p), data_only=True) + out = {"name": p.name, "path": str(p.resolve()), "sheets": []} + for sheet in wb.worksheets: + rows: List[List[Any]] = [] + for row in sheet.iter_rows(values_only=True): + rows.append(list(row)) + out["sheets"].append({"name": sheet.title, "rows": rows}) + + return out + + +if __name__ == "__main__": + import sys, json + + target = sys.argv[1] if len(sys.argv) > 1 else "data/Исх.данные/Таблица1 КИ-Неустроева.xlsx" + try: + data = read_xlsx(target) + print(json.dumps(data, ensure_ascii=False, indent=2, default=str)) + except Exception as exc: + print('Error:', exc) diff --git a/read_path.py b/read_path.py new file mode 100644 index 0000000..c72432a --- /dev/null +++ b/read_path.py @@ -0,0 +1,32 @@ +from pathlib import Path +from typing import List + + +def list_files_in_dir(dir_path: str, recursive: bool = False) -> List[str]: + """ + Возвращает список файлов в директории с их полными путями. + + Args: + dir_path: путь к директории (строка или Path-подобный). + recursive: если True, включает файлы во всех поддиректориях. + + Returns: + Список путей (строк) к файлам. + + Raises: + FileNotFoundError: если директория не найдена. + """ + p = Path(dir_path) + if not p.exists(): + raise FileNotFoundError(f"Directory not found: {dir_path}") + + if recursive: + files = [str(f.resolve()) for f in p.rglob("*") if f.is_file()] + else: + files = [str(f.resolve()) for f in p.iterdir() if f.is_file()] + + return files + + +__all__ = ["list_files_in_dir"] + diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..0274469 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +# services package diff --git a/services/data_loader.py b/services/data_loader.py new file mode 100644 index 0000000..30f535f --- /dev/null +++ b/services/data_loader.py @@ -0,0 +1,46 @@ +"""Загрузка исходных данных: Таблица 1 (КИ), Таблица 2, индекс MIF-файлов.""" + +from pathlib import Path + +from openpyxl import load_workbook + + +def load_ki(path: str) -> dict: + """Таблица 1: {название_поля → значение}.""" + wb = load_workbook(path, data_only=True) + ws = wb.active + ki = {} + for row in ws.iter_rows(values_only=True): + if row[0] is not None and row[1] is not None: + ki[str(row[0]).strip()] = str(row[1]).strip() + return ki + + +def ki_val(ki: dict, keyword: str) -> str: + """Найти значение в словаре КИ по подстроке ключа (без учёта регистра).""" + for k, v in ki.items(): + if keyword.lower() in k.lower(): + return v + return "" + + +def load_table2(path: str) -> list: + """Таблица 2: список строк (кортежи). Заголовок и пустые строки пропускаются.""" + wb = load_workbook(path, data_only=True) + ws = wb.active + rows = [] + for i, row in enumerate(ws.iter_rows(values_only=True)): + if i == 0: + continue + if all(v is None for v in row[:5]): + continue + rows.append(row) + return rows + + +def build_mif_index(mif_dir: str) -> dict: + """Индекс MIF-файлов: stem → полный путь.""" + index = {} + for p in Path(mif_dir).rglob("*.mif"): + index[p.stem] = str(p) + return index diff --git a/services/form_filler.py b/services/form_filler.py new file mode 100644 index 0000000..0795953 --- /dev/null +++ b/services/form_filler.py @@ -0,0 +1,195 @@ +"""Заполнение одной формы XLSX по шаблону.""" + +import copy +import datetime + +from openpyxl import load_workbook +from openpyxl.styles import Font + +from services.data_loader import ki_val +from services.mif_processor import get_contours, build_coord_rows + +# ─────────────────────────────── Константы ──────────────────────────────── # + +BASE_FONT = Font(name="Times New Roman", size=11) + +# Позиции строк шаблона (1-based, до вставки доп. строк координат) +TMPL = dict( + KV=2, # A2: кадастровый квартал + DATE=5, # F5: дата подготовки + FIO=11, # F11: ФИО КИ + SNILS=12, # F12: СНИЛС + PHONE=13, # F13: телефон + EMAIL=15, # A15: email + адрес + SRO=17, # A17: наименование СРО + REGNR=19, # A19: рег. номер + SOGL=24, # A24: соглашение + LOC_ZU=29, # F29: местоположение ЗУ + AREA_ZU=31, # F31: площадь ЗУ + OBOSN_ZU=33, # F33: обоснование ЗУ + ZU_COORD_START=37, # первая строка данных координат ЗУ + ZU_COORD_PRE=5, # кол-во предзаполненных строк (37-41) + LOC_OKS=54, # F54: местоположение ОКС (до смещения) + OBOSN_OKS=58, # F58: обоснование ОКС (до смещения) + OKS_COORD_START=62, # первая строка данных ОКС (до смещения) + OKS_COORD_PRE=1, # кол-во предзаполненных строк ОКС +) + +# ──────────────────────────── Вспомогательные ───────────────────────────── # + +def _sv(ws, row: int, col: int, value, font=None): + cell = ws.cell(row=row, column=col) + cell.value = value + if font: + cell.font = copy.copy(font) + + +def _safe_insert_rows(ws, idx: int, amount: int): + """ + Вставить строки с корректным сдвигом слиянных ячеек. + openpyxl's ws.insert_rows() имеет известный баг: не сдвигает все merge-диапазоны. + Решение: вручную снимаем/пересчитываем merge до вызова insert_rows. + """ + # Собираем все диапазоны, которые пересекаются с точкой вставки или ниже неё + to_readjust = [] + for rng in list(ws.merged_cells.ranges): + if rng.max_row >= idx: + to_readjust.append((rng.min_row, rng.min_col, rng.max_row, rng.max_col)) + ws.merged_cells.remove(rng) + + ws.insert_rows(idx, amount) + + # Восстанавливаем диапазоны с поправкой + for min_r, min_c, max_r, max_c in to_readjust: + new_min_r = min_r + amount if min_r >= idx else min_r + new_max_r = max_r + amount if max_r >= idx else max_r + ws.merge_cells( + start_row=new_min_r, start_column=min_c, + end_row=new_max_r, end_column=max_c, + ) + + +def _copy_row_style(ws, src_row: int, dst_row: int): + for col in range(1, ws.max_column + 1): + src = ws.cell(row=src_row, column=col) + dst = ws.cell(row=dst_row, column=col) + if src.has_style: + dst.font = copy.copy(src.font) + dst.border = copy.copy(src.border) + dst.fill = copy.copy(src.fill) + dst.number_format = src.number_format + dst.alignment = copy.copy(src.alignment) + + +def _insert_coord_rows(ws, coord_data: list, start_row: int, pre_allocated: int) -> int: + """Вставить строки координат. Возвращает кол-во добавленных строк (смещение).""" + needed = len(coord_data) + extra = max(0, needed - pre_allocated) + if extra > 0: + _safe_insert_rows(ws, start_row + pre_allocated, extra) + for i in range(extra): + _copy_row_style(ws, start_row, start_row + pre_allocated + i) + + F = BASE_FONT + for i, entry in enumerate(coord_data): + row_idx = start_row + i + if entry is None: + continue + num, x_str, y_str, method, mt = entry + _sv(ws, row_idx, 1, num, F) + _sv(ws, row_idx, 3, x_str, F) + _sv(ws, row_idx, 4, y_str, F) + _sv(ws, row_idx, 5, method, F) + _sv(ws, row_idx, 6, mt, F) + + return extra + + +def _apply_font_all(ws): + """Применить Times New Roman 11 ко всем заполненным ячейкам листа.""" + for row in ws.iter_rows(): + for cell in row: + if cell.value is not None: + old = cell.font + cell.font = Font( + name="Times New Roman", size=11, + bold=old.bold if old else False, + italic=old.italic if old else False, + underline=old.underline if old else None, + ) + +# ─────────────────────────────── Публичная функция ──────────────────────── # + +def fill_form(template_path: str, output_path: str, + ki: dict, t2_row: tuple, mif_index: dict): + """Заполнить шаблон данными одной строки Таблицы 2 и сохранить файл.""" + + def col(i, default=None): + return t2_row[i] if len(t2_row) > i and t2_row[i] is not None else default + + zu_file = col(0, "") + kv = col(1, "") + date_prep = col(2) + sogl = col(3, "") + loc_zu = col(4, "") + area_zu = col(5, "") + err_zu = col(6, "") + obosn_zu = col(7, "") + method_zu = col(8, "") + mt_zu = col(9, "") + oks_file = col(10, "") + loc_oks = col(11, "") + obosn_oks = col(12, "") + method_oks = col(13, "") + mt_oks = col(14, "") + + wb = load_workbook(template_path) + ws = wb.active + F = BASE_FONT + T = TMPL + + # Шапка + _sv(ws, T["KV"], 1, str(kv), F) + # Дата: конвертируем datetime в строку dd.mm.yyyy + if isinstance(date_prep, datetime.datetime): + date_str = date_prep.strftime("%d.%m.%Y") + else: + date_str = str(date_prep) if date_prep else "" + _sv(ws, T["DATE"], 6, date_str, F) + + # Раздел 2: КИ + _sv(ws, T["FIO"], 6, ki_val(ki, "ФИО"), F) + _sv(ws, T["SNILS"], 6, ki_val(ki, "Страховой"), F) + _sv(ws, T["PHONE"], 6, ki_val(ki, "Контактный"), F) + _sv(ws, T["EMAIL"], 1, ki_val(ki, "Адрес электронной почты"), F) + _sv(ws, T["SRO"], 1, ki_val(ki, "Наименование саморегулируемой"), F) + _sv(ws, T["REGNR"], 1, ki_val(ki, "Уникальный"), F) + + # Раздел 3: Основания + _sv(ws, T["SOGL"], 1, sogl, F) + + # Раздел 4: ЗУ + _sv(ws, T["LOC_ZU"], 6, loc_zu, F) + area_str = f"{area_zu}±{err_zu}" if (area_zu or err_zu) else "" + _sv(ws, T["AREA_ZU"], 6, area_str, F) + _sv(ws, T["OBOSN_ZU"], 6, obosn_zu, F) + + # Раздел 5: координаты ЗУ + zu_path = mif_index.get(str(zu_file).strip()) + zu_contours = get_contours(zu_path) if zu_path else [] + zu_coord_data = build_coord_rows(zu_contours, str(method_zu), mt_zu) + zu_offset = _insert_coord_rows(ws, zu_coord_data, T["ZU_COORD_START"], T["ZU_COORD_PRE"]) + + # Разделы 7-9: ОКС (со смещением) + ofs = zu_offset + _sv(ws, T["LOC_OKS"] + ofs, 6, loc_oks or "-", F) + _sv(ws, T["OBOSN_OKS"] + ofs, 6, obosn_oks or "-", F) + + oks_path = mif_index.get(str(oks_file).strip()) if oks_file else None + oks_contours = get_contours(oks_path) if oks_path else [] + oks_coord_data = build_coord_rows(oks_contours, str(method_oks or ""), mt_oks) + if oks_coord_data: + _insert_coord_rows(ws, oks_coord_data, T["OKS_COORD_START"] + ofs, T["OKS_COORD_PRE"]) + + _apply_font_all(ws) + wb.save(output_path) diff --git a/services/mif_processor.py b/services/mif_processor.py new file mode 100644 index 0000000..7ceb10f --- /dev/null +++ b/services/mif_processor.py @@ -0,0 +1,59 @@ +"""Извлечение контуров из MIF-файлов и формирование строк координат.""" + +import sys +from pathlib import Path + +# read_mif_file лежит в корне проекта — добавляем его в путь поиска +_PROJECT_ROOT = str(Path(__file__).parent.parent) +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + +from read_mif_file import parse_mif # noqa: E402 + + +def get_contours(mif_path: str) -> list: + """ + Извлечь список контуров (Region) из MIF-файла. + Возвращает: [[(x, y), ...], ...] — список контуров. + Region может содержать несколько полигонов (contours). + """ + result = parse_mif(mif_path) + if isinstance(result, list): + return [] + all_contours = [] + for feat in result.features: + if feat["type"] == "Region": + # contours — список полигонов (исправленный парсер) + for poly in feat["props"].get("contours", []): + if poly: + all_contours.append(list(poly)) + return all_contours + + +def build_coord_rows(contours: list, method: str, mt) -> list: + """ + Сформировать список строк для раздела координат. + + Каждый элемент: (номер, X_str, Y_str, method, mt) + или None — пустой разделитель между контурами. + + Правила: + - Первая координата MIF → X (колонка C), вторая → Y (D). + - Все точки нумеруются подряд по всем контурам. + - Последняя (замыкающая) точка контура получает номер первой точки этого контура. + """ + rows = [] + global_num = 1 + for ci, contour in enumerate(contours): + if ci > 0: + rows.append(None) + if not contour: + continue + first_num = global_num + for mx, my in contour[:-1]: + rows.append((global_num, f"{mx:.2f}", f"{my:.2f}", method, mt)) + global_num += 1 + if len(contour) >= 2: + mx, my = contour[-1] + rows.append((first_num, f"{mx:.2f}", f"{my:.2f}", method, mt)) + return rows diff --git a/services/output_generator.py b/services/output_generator.py new file mode 100644 index 0000000..d85c36d --- /dev/null +++ b/services/output_generator.py @@ -0,0 +1,80 @@ +"""Генерация итоговых файлов: объединённая форма и Таблица 4.""" + +import copy +import re + +from openpyxl import load_workbook, Workbook +from openpyxl.styles import Font + +from services.form_filler import BASE_FONT + + +def make_filename(kv: str, zu_file: str) -> str: + """ + Формат: {кв_через_подчёркивание}_{zu_file}.xlsx + Пример: 74_34_0400007_выявл1_{вымпел_100}.xlsx + """ + kv_clean = str(kv).replace(":", "_") + safe = re.sub(r'[<>"/\\|?*]', "_", str(zu_file)) + return f"{kv_clean}_{safe}.xlsx" + + +def combine_forms(form_paths: list, combined_path: str): + """Объединить все формы в один лист.""" + combined_wb = Workbook() + combined_ws = combined_wb.active + combined_ws.title = "Все формы" + + current_row = 1 + for path in form_paths: + src_wb = load_workbook(path, data_only=True) + src_ws = src_wb.active + last_cell = None + for src_row in src_ws.iter_rows(): + for cell in src_row: + dst = combined_ws.cell( + row=current_row + cell.row - 1, + column=cell.column, + ) + dst.value = cell.value + if cell.has_style: + dst.font = copy.copy(cell.font) + dst.border = copy.copy(cell.border) + dst.fill = copy.copy(cell.fill) + dst.number_format = cell.number_format + dst.alignment = copy.copy(cell.alignment) + last_cell = cell + if last_cell: + src_row_dim = src_ws.row_dimensions[last_cell.row] + combined_ws.row_dimensions[current_row + last_cell.row - 1].height = src_row_dim.height + + current_row += src_ws.max_row + + if form_paths: + src_wb = load_workbook(form_paths[0]) + src_ws = src_wb.active + for col_letter, col_dim in src_ws.column_dimensions.items(): + combined_ws.column_dimensions[col_letter].width = col_dim.width + + combined_wb.save(combined_path) + + +def generate_table4(generated: list, output_path: str): + """Перечень заполненных форм (Таблица 4).""" + wb = Workbook() + ws = wb.active + ws.title = "Перечень форм" + F = BASE_FONT + headers = ["№ п/п", "Кадастровый квартал", "Название файла ЗУ", "Имя файла", "Путь"] + for col_idx, h in enumerate(headers, 1): + ws.cell(row=1, column=col_idx, value=h).font = Font( + name="Times New Roman", size=11, bold=True + ) + for i, rec in enumerate(generated, 1): + row = i + 1 + ws.cell(row=row, column=1, value=i).font = copy.copy(F) + ws.cell(row=row, column=2, value=rec["kv"]).font = copy.copy(F) + ws.cell(row=row, column=3, value=rec["zu_file"]).font = copy.copy(F) + ws.cell(row=row, column=4, value=rec["name"]).font = copy.copy(F) + ws.cell(row=row, column=5, value=rec["path"]).font = copy.copy(F) + wb.save(output_path) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8da1009 --- /dev/null +++ b/uv.lock @@ -0,0 +1,35 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "kadastr-17" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "openpyxl" }, +] + +[package.metadata] +requires-dist = [{ name = "openpyxl", specifier = ">=3.1" }] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +]