332 lines
16 KiB
Python
332 lines
16 KiB
Python
"""Генерация итоговых файлов: объединённая форма и Таблица 4."""
|
||
|
||
import copy
|
||
import io
|
||
import re
|
||
import zipfile
|
||
|
||
from openpyxl import 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):
|
||
"""
|
||
Объединить все заполненные формы в один xlsx, сохраняя стили, merge-ячейки
|
||
и встроенные изображения.
|
||
|
||
Работает на уровне ZIP-содержимого xlsx. Все формы создаются из одного
|
||
шаблона, поэтому styles.xml одинаков — сдвигать индексы стилей не нужно.
|
||
Строки хранятся inline (inlineStr), shared strings отсутствуют.
|
||
"""
|
||
if not form_paths:
|
||
return
|
||
|
||
IMG_TYPE = ("http://schemas.openxmlformats.org/officeDocument/"
|
||
"2006/relationships/image")
|
||
RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||
|
||
# ── Вспомогательные функции ──────────────────────────────────────────── #
|
||
|
||
def _has_content(row_xml: str) -> bool:
|
||
"""Строка имеет содержимое, если хотя бы одна ячейка несёт <is> или <v>."""
|
||
return bool(re.search(r'<(?:is|v)\b', row_xml))
|
||
|
||
def _shift_cell_ref(ref: str, offset: int) -> str:
|
||
"""'A5' + 3 → 'A8'"""
|
||
col = re.match(r'[A-Z]+', ref).group()
|
||
rn = int(re.search(r'\d+', ref).group())
|
||
return f'{col}{rn + offset}'
|
||
|
||
def _shift_merge_ref(ref: str, offset: int) -> str:
|
||
"""'A1:F3' + 3 → 'A4:F6'"""
|
||
return ':'.join(_shift_cell_ref(p, offset) for p in ref.split(':'))
|
||
|
||
# ── Сбор данных из каждого файла ─────────────────────────────────────── #
|
||
|
||
all_row_xmls = [] # строки <row ...>...</row> со сдвинутыми номерами
|
||
all_merges = [] # строки вида 'A1:F3'
|
||
all_anchors = [] # строки XML якорей изображений
|
||
all_rels = [] # (new_rid, target) для drawing rels
|
||
new_media = {} # new_name → bytes
|
||
|
||
row_offset = 0
|
||
rid_counter = 1
|
||
|
||
for form_path in form_paths:
|
||
with zipfile.ZipFile(form_path, 'r') as zf:
|
||
names = set(zf.namelist())
|
||
|
||
# ── sheet1.xml: строки и merge-ячейки ── #
|
||
sheet_xml = zf.read('xl/worksheets/sheet1.xml').decode('utf-8')
|
||
|
||
sd_m = re.search(r'<sheetData[^>]*>(.*?)</sheetData>',
|
||
sheet_xml, re.DOTALL)
|
||
max_r = 0
|
||
last_content_r = 0 # последняя строка с реальным содержимым
|
||
form_rows = [] # строки текущей формы: (r_num_in_form, xml_str)
|
||
if sd_m:
|
||
for rm in re.finditer(
|
||
r'(<row\b[^>]*>)(.*?)(</row>)',
|
||
sd_m.group(1), re.DOTALL
|
||
):
|
||
tag, content, end_tag = rm.group(1), rm.group(2), rm.group(3)
|
||
r_num = int(re.search(r'\br="(\d+)"', tag).group(1))
|
||
max_r = max(max_r, r_num)
|
||
if _has_content(content):
|
||
last_content_r = r_num
|
||
|
||
if row_offset > 0:
|
||
# Сдвинуть r= в <row r="N">
|
||
tag = re.sub(
|
||
r'\br="(\d+)"',
|
||
lambda m: f'r="{int(m.group(1)) + row_offset}"',
|
||
tag, count=1
|
||
)
|
||
# Сдвинуть r= в ячейках <c r="A5">
|
||
def _sc(m, off=row_offset):
|
||
col = re.match(r'[A-Z]+', m.group(1)).group()
|
||
rn = int(re.search(r'\d+', m.group(1)).group())
|
||
return f'r="{col}{rn + off}"'
|
||
content = re.sub(r'\br="([A-Z]+\d+)"', _sc, content)
|
||
|
||
form_rows.append((r_num, tag + content + end_tag))
|
||
|
||
# Добавляем только строки до last_content_r включительно,
|
||
# отбрасывая пустые хвостовые строки (styled-only rows из шаблона)
|
||
for r_in_form, xml_str in form_rows:
|
||
if r_in_form <= last_content_r:
|
||
all_row_xmls.append(xml_str)
|
||
|
||
# Объединения ячеек (только в пределах last_content_r)
|
||
mc_m = re.search(r'<mergeCells[^>]*>(.*?)</mergeCells>',
|
||
sheet_xml, re.DOTALL)
|
||
if mc_m:
|
||
for ref_m in re.finditer(r'ref="([^"]+)"', mc_m.group(1)):
|
||
# Парсим max_row диапазона
|
||
parts = ref_m.group(1).split(':')
|
||
mc_max_row = max(int(re.search(r'\d+', p).group()) for p in parts)
|
||
if mc_max_row <= last_content_r:
|
||
all_merges.append(_shift_merge_ref(ref_m.group(1), row_offset))
|
||
|
||
# ── drawing1.xml: якоря и медиафайлы ── #
|
||
drawing_name = 'xl/drawings/drawing1.xml'
|
||
rels_name = 'xl/drawings/_rels/drawing1.xml.rels'
|
||
|
||
if drawing_name in names:
|
||
drawing_xml = zf.read(drawing_name).decode('utf-8')
|
||
rels_xml = (zf.read(rels_name).decode('utf-8')
|
||
if rels_name in names else '')
|
||
|
||
# Маппинг старый rId → новый rId
|
||
local_rid_map = {}
|
||
for rel_m in re.finditer(r'<Relationship\b([^>]+)/>', rels_xml):
|
||
attrs = rel_m.group(1)
|
||
id_m = re.search(r'\bId="([^"]+)"', attrs)
|
||
tgt_m = re.search(r'\bTarget="([^"]+)"', attrs)
|
||
if not (id_m and tgt_m):
|
||
continue
|
||
old_rid = id_m.group(1)
|
||
target = tgt_m.group(1) # '/xl/media/imageN.png' или '../media/imageN.png'
|
||
media_zip = 'xl/media/' + target.split('/')[-1]
|
||
if media_zip in names:
|
||
new_name = f'image{rid_counter}.png'
|
||
new_media[new_name] = zf.read(media_zip)
|
||
new_rid = f'rId{rid_counter}'
|
||
all_rels.append((new_rid, f'../media/{new_name}'))
|
||
local_rid_map[old_rid] = new_rid
|
||
rid_counter += 1
|
||
|
||
# Якоря: сдвигаем строки и перемаппируем rId
|
||
for anc_m in re.finditer(
|
||
r'(<(?:xdr:)?(?:twoCellAnchor|oneCellAnchor|absoluteAnchor)\b[^>]*>)'
|
||
r'(.*?)'
|
||
r'(</(?:xdr:)?(?:twoCellAnchor|oneCellAnchor|absoluteAnchor)>)',
|
||
drawing_xml, re.DOTALL
|
||
):
|
||
anc = anc_m.group(1) + anc_m.group(2) + anc_m.group(3)
|
||
|
||
if row_offset > 0:
|
||
anc = re.sub(
|
||
r'(<(?:xdr:)?row>)(\d+)(</(?:xdr:)?row>)',
|
||
lambda m, off=row_offset:
|
||
f'{m.group(1)}{int(m.group(2)) + off}{m.group(3)}',
|
||
anc
|
||
)
|
||
|
||
for old_rid, new_rid in local_rid_map.items():
|
||
anc = anc.replace(f'embed="{old_rid}"',
|
||
f'embed="{new_rid}"')
|
||
all_anchors.append(anc)
|
||
|
||
row_offset += last_content_r
|
||
|
||
# ── Сборка итогового xlsx на основе первого файла ────────────────────── #
|
||
|
||
with zipfile.ZipFile(form_paths[0], 'r') as src_zf:
|
||
base_names = set(src_zf.namelist())
|
||
|
||
# Новый sheet1.xml — заменяем sheetData и mergeCells
|
||
base_sheet = src_zf.read('xl/worksheets/sheet1.xml').decode('utf-8')
|
||
|
||
new_sheet_data = '<sheetData>' + ''.join(all_row_xmls) + '</sheetData>'
|
||
base_sheet = re.sub(
|
||
r'<sheetData[^>]*>.*?</sheetData>',
|
||
new_sheet_data, base_sheet, flags=re.DOTALL
|
||
)
|
||
|
||
if all_merges:
|
||
new_mc = (
|
||
f'<mergeCells count="{len(all_merges)}">' +
|
||
''.join(f'<mergeCell ref="{r}"/>' for r in all_merges) +
|
||
'</mergeCells>'
|
||
)
|
||
if re.search(r'<mergeCells', base_sheet):
|
||
base_sheet = re.sub(
|
||
r'<mergeCells[^>]*>.*?</mergeCells>',
|
||
new_mc, base_sheet, flags=re.DOTALL
|
||
)
|
||
else:
|
||
base_sheet = base_sheet.replace('</worksheet>',
|
||
new_mc + '</worksheet>')
|
||
else:
|
||
base_sheet = re.sub(r'<mergeCells[^>]*>.*?</mergeCells>', '',
|
||
base_sheet, flags=re.DOTALL)
|
||
|
||
# Новый drawing1.xml и его rels (если есть изображения)
|
||
new_drawing = None
|
||
new_drawing_rels = None
|
||
if all_anchors:
|
||
base_drw = (src_zf.read('xl/drawings/drawing1.xml').decode('utf-8')
|
||
if 'xl/drawings/drawing1.xml' in base_names else '')
|
||
root_m = re.match(r'(<wsDr\b[^>]*>)', base_drw)
|
||
root_open = (root_m.group(1) if root_m else
|
||
'<wsDr xmlns:xdr="http://schemas.openxmlformats.org/'
|
||
'drawingml/2006/spreadsheetDrawing" '
|
||
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
|
||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/'
|
||
'2006/relationships" '
|
||
'xmlns="http://schemas.openxmlformats.org/drawingml/'
|
||
'2006/spreadsheetDrawing">')
|
||
new_drawing = root_open + ''.join(all_anchors) + '</wsDr>'
|
||
|
||
new_drawing_rels = (
|
||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
f'<Relationships xmlns="{RELS_NS}">' +
|
||
''.join(
|
||
f'<Relationship Id="{rid}" Type="{IMG_TYPE}" Target="{tgt}"/>'
|
||
for rid, tgt in all_rels
|
||
) +
|
||
'</Relationships>'
|
||
)
|
||
|
||
# Записываем итоговый zip
|
||
out_buf = io.BytesIO()
|
||
with zipfile.ZipFile(out_buf, 'w', zipfile.ZIP_DEFLATED) as out_zf:
|
||
for name in sorted(base_names):
|
||
if name == 'xl/worksheets/sheet1.xml':
|
||
out_zf.writestr(name, base_sheet.encode('utf-8'))
|
||
elif name == 'xl/drawings/drawing1.xml' and new_drawing:
|
||
out_zf.writestr(name, new_drawing.encode('utf-8'))
|
||
elif name == 'xl/drawings/_rels/drawing1.xml.rels' and new_drawing_rels:
|
||
out_zf.writestr(name, new_drawing_rels.encode('utf-8'))
|
||
elif re.match(r'xl/media/', name):
|
||
pass # заменяем новыми ниже
|
||
else:
|
||
out_zf.writestr(name, src_zf.read(name))
|
||
|
||
# Новые медиафайлы
|
||
for mname, mbytes in new_media.items():
|
||
out_zf.writestr(f'xl/media/{mname}', mbytes)
|
||
|
||
with open(combined_path, 'wb') as f:
|
||
f.write(out_buf.getvalue())
|
||
|
||
|
||
|
||
def generate_table4(generated: list, output_path: str):
|
||
"""
|
||
Таблица 4: Сведения о выявленных фактах незаконного использования земель.
|
||
|
||
Структура согласно шаблону «Шаблон Таблицы4.xlsx»:
|
||
B1 — заголовок таблицы
|
||
R2 — заголовки 11 столбцов (A–K)
|
||
R3/4 — уточнение статуса в J3/J4
|
||
R5 — нумерация столбцов 1–11
|
||
R6 — строка ИТОГО + формула COUNTA в G6
|
||
R7+ — данные (B=74, C=Челябинская область, D=УРФО,
|
||
E=кад.квартал, G=нет, H=местоположение ЗУ)
|
||
"""
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "Таблица4"
|
||
F = BASE_FONT
|
||
Fb = Font(name="Times New Roman", size=11, bold=True)
|
||
|
||
TITLE = (
|
||
"Сведения о выявленных в рамках ККР федерального значения фактах "
|
||
"незаконного использования земель."
|
||
)
|
||
HEADERS = [
|
||
"№ п/п",
|
||
"Код субъекта Российской Федерации",
|
||
"Наименование субъекта Российской Федерации",
|
||
"Федеральный округ",
|
||
"Номер кадастрового квартала",
|
||
"Муниципальное образование",
|
||
("Кадастровый номер объекта недвижимости, "
|
||
"в отношении которого направлена"),
|
||
"Адрес объекта недвижимости",
|
||
("Дата поступления информации в Росреестр "
|
||
"(отдел государственного земельного надзора)"),
|
||
"Статус",
|
||
"Примечание",
|
||
]
|
||
|
||
# R1: заголовок в B1
|
||
ws.cell(1, 2, TITLE).font = Fb
|
||
|
||
# R2: заголовки столбцов A–K
|
||
for i, h in enumerate(HEADERS, 1):
|
||
ws.cell(2, i, h).font = Fb
|
||
|
||
# R3/R4: уточнение статуса
|
||
ws.cell(3, 10, "(В работе /").font = copy.copy(F)
|
||
ws.cell(4, 10, "Нарушение устранено)").font = copy.copy(F)
|
||
|
||
# R5: нумерация столбцов 1–11
|
||
for i in range(1, 12):
|
||
ws.cell(5, i, i).font = copy.copy(F)
|
||
|
||
# R6: ИТОГО + формула подсчёта
|
||
ws.cell(6, 1, "ИТОГО").font = Fb
|
||
ws.cell(6, 7, "=COUNTA(G7:G1048576)").font = copy.copy(F)
|
||
|
||
# R7+: строки данных
|
||
for idx, rec in enumerate(generated, 1):
|
||
row = idx + 6 # строки 7, 8, 9, ...
|
||
ws.cell(row, 1, idx).font = copy.copy(F) # A: №
|
||
ws.cell(row, 2, "74").font = copy.copy(F) # B: Код субъекта
|
||
ws.cell(row, 3, "Челябинская область").font = copy.copy(F) # C: Наименование
|
||
ws.cell(row, 4, "УРФО").font = copy.copy(F) # D: Округ
|
||
ws.cell(row, 5, rec.get("kv", "")).font = copy.copy(F) # E: Кад.квартал
|
||
# F: Муниципальное образование — не задано в ТЗ, оставляем пустым
|
||
ws.cell(row, 7, "нет").font = copy.copy(F) # G: Кад.номер ОН
|
||
ws.cell(row, 8, rec.get("loc_zu", "")).font = copy.copy(F) # H: Адрес ОН
|
||
# I: Дата поступления, J: Статус, K: Примечание — не задано в ТЗ
|
||
|
||
wb.save(output_path)
|