Files
kadastr-17/services/form_filler.py
2026-03-25 13:38:54 +05:00

346 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Заполнение одной формы XLSX по шаблону."""
import copy
import datetime
import io
from pathlib import Path
from openpyxl import load_workbook
from openpyxl.drawing.image import Image as XlImage
from openpyxl.drawing.spreadsheet_drawing import OneCellAnchor, AnchorMarker
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)
ZU_SCHEME_START=42, # строка-заголовок раздела 6 (до смещения)
ZU_SCHEME_PRE=1, # r43 = изображение, r44 = масштаб, r45-r50 = легенда
LOC_OKS=54, # F54: местоположение ОКС (до смещения)
OBOSN_OKS=58, # F58: обоснование ОКС (до смещения)
OKS_COORD_START=62, # первая строка данных ОКС (до смещения)
OKS_COORD_PRE=1, # кол-во предзаполненных строк ОКС
OKS_SCHEME_START=63, # строка-заголовок раздела 9 «9. Схема ОКС» (до смещения)
OKS_SCHEME_PRE=1, # r64 = изображение, r65 = масштаб
)
# ──────────────────────────── Вспомогательные ───────────────────────────── #
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 _safe_delete_rows(ws, idx: int, amount: int):
"""
Удалить `amount` строк начиная с `idx`, корректно сдвинув merge-диапазоны.
Диапазоны, целиком попавшие в удаляемый участок, удаляются.
Диапазоны, пересекающие границу — обрезаются/сдвигаются.
"""
end_idx = idx + amount # первая строка ПОСЛЕ удаляемого блока
to_remove = []
to_readjust = []
for rng in list(ws.merged_cells.ranges):
mn, mc, mx, xc = rng.min_row, rng.min_col, rng.max_row, rng.max_col
if mx < idx:
# полностью до зоны удаления — ничего не трогаем
continue
to_remove.append(rng) # нужно снять, а затем, возможно, заново добавить
if mn >= end_idx:
# полностью после — просто сдвигаем вверх
to_readjust.append((mn - amount, mc, mx - amount, xc))
elif mn >= idx and mx < end_idx:
# полностью внутри удаляемого — удаляем без восстановления
pass
elif mn < idx and mx < end_idx:
# начинается до, заканчивается внутри — обрезаем конец
new_mx = idx - 1
if new_mx >= mn:
to_readjust.append((mn, mc, new_mx, xc))
elif mn < idx and mx >= end_idx:
# охватывает весь удаляемый блок — уменьшаем
to_readjust.append((mn, mc, mx - amount, xc))
else:
# начинается внутри, заканчивается после — обрезаем начало
new_mn = idx
to_readjust.append((new_mn, mc, mx - amount, xc))
for rng in to_remove:
ws.merged_cells.remove(rng)
ws.delete_rows(idx, amount)
for min_r, min_c, max_r, max_c in to_readjust:
if min_r <= max_r:
ws.merge_cells(
start_row=min_r, start_column=min_c,
end_row=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 _embed_image(ws, png_bytes: bytes, anchor_row: int, pre_rows: int) -> int:
"""
Вставить PNG ровно в ОДНУ ячейку (строку) начиная с A{anchor_row}.
Стратегия «одна ячейка»:
Все pre_rows строк шаблона, зарезервированных под схему, кроме первой,
удаляются (_safe_delete_rows), чтобы осталась единственная объединённая
строка A{anchor_row}:F{anchor_row}.
• Высота этой строки выставляется равной реальной высоте PNG (pt).
• TwoCellAnchor с editAs='twoCell' привязывает изображение точно к этой
одной строке — от её верхнего левого угла до нижнего правого.
Возвращает изменение числа строк (всегда -(pre_rows-1), т.е. отрицательное).
"""
from PIL import Image as PILImage
pil = PILImage.open(io.BytesIO(png_bytes))
px_w, px_h = pil.size
dpi = pil.info.get("dpi", (96, 96))
if not isinstance(dpi, (tuple, list)) or dpi[0] == 0:
dpi = (96, 96)
pt_w = px_w * 72.0 / dpi[0]
pt_h = px_h * 72.0 / dpi[1]
# Масштаб по ширине листа (~420 pt для 6 колонок)
MAX_PT_W = 420.0
if pt_w > MAX_PT_W:
scale = MAX_PT_W / pt_w
pt_w = MAX_PT_W
pt_h *= scale
# Удаляем все строки зоны схемы кроме первой (anchor_row).
# Строка anchor_row уже является частью объединённой ячейки в шаблоне;
# после удаления нижних строк она станет单独ной строкой высотой pt_h.
rows_to_delete = pre_rows - 1
if rows_to_delete > 0:
_safe_delete_rows(ws, anchor_row + 1, rows_to_delete)
# Задаём высоту единственной оставшейся строки = высоте изображения
ws.row_dimensions[anchor_row].height = pt_h
# OneCellAnchor: верхний-левый угол прибит к ячейке A{anchor_row},
# размер задан явно в EMU (1 pt = 12700 EMU).
# В отличие от TwoCellAnchor, LibreOffice не пересчитывает позицию
# через сумму высот строк → картинка не уплывает вверх при склейке форм.
img = XlImage(io.BytesIO(png_bytes))
# Установим размер картинки в пикселях с учётом масштабирования
scale = 1.0
if pt_w > MAX_PT_W:
scale = MAX_PT_W / pt_w
img.width = int(px_w * scale)
img.height = int(px_h * scale)
# Добавляем изображение привязкой к ячейке A{anchor_row}
try:
ws.add_image(img, f"A{anchor_row}")
except Exception:
try:
cell_anchor = OneCellAnchor()
cell_anchor._from = AnchorMarker(col=0, colOff=0, row=anchor_row - 1, rowOff=0)
cell_anchor.ext.cx = int(pt_w * 12700) # pt → EMU
cell_anchor.ext.cy = int(pt_h * 12700)
img.anchor = cell_anchor
ws.add_image(img)
except Exception:
pass
return -rows_to_delete # отрицательное смещение: строки были удалены
def fill_form(template_path: str, output_path: str,
ki: dict, t2_row: tuple, mif_index: dict,
zu_scheme: bytes = None, oks_scheme: bytes = None,
zu_scale: str = None, oks_scale: str = None):
"""Заполнить шаблон данными одной строки Таблицы 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"])
# Раздел 6 (схема ЗУ):
# r43+offset = изображение (ZU_SCHEME_PRE=1, строки не удаляются)
# r44+offset = масштаб (c1)
if zu_scheme:
scheme_anchor = T["ZU_SCHEME_START"] + zu_offset + 1
_embed_image(ws, zu_scheme, scheme_anchor, T["ZU_SCHEME_PRE"])
if zu_scale:
_sv(ws, scheme_anchor + 1, 1, zu_scale, F)
# Разделы 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)
oks_offset = 0
if oks_coord_data:
oks_offset = _insert_coord_rows(ws, oks_coord_data, T["OKS_COORD_START"] + ofs, T["OKS_COORD_PRE"])
# Раздел 9: схема ОКС (из Таблицы3 по Table2 столбец K «Название файла ОКС»)
# r64+ofs+oks_offset = изображение, r65+ofs+oks_offset = масштаб
if oks_scheme:
oks_scheme_anchor = T["OKS_SCHEME_START"] + ofs + oks_offset + 1
_embed_image(ws, oks_scheme, oks_scheme_anchor, T["OKS_SCHEME_PRE"])
if oks_scale:
_sv(ws, oks_scheme_anchor + 1, 1, oks_scale, F)
_apply_font_all(ws)
wb.save(output_path)