This commit is contained in:
2026-03-12 01:50:57 +05:00
parent ec82b528a3
commit c33befe7c2
12 changed files with 1553 additions and 69 deletions

View File

@@ -1,9 +1,11 @@
"""Генерация итоговых файлов: объединённая форма и Таблица 4."""
import copy
import io
import re
import zipfile
from openpyxl import load_workbook, Workbook
from openpyxl import Workbook
from openpyxl.styles import Font
from services.form_filler import BASE_FONT
@@ -19,62 +21,311 @@ def make_filename(kv: str, zu_file: str) -> str:
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 = "Все формы"
"""
Объединить все заполненные формы в один xlsx, сохраняя стили, merge-ячейки
и встроенные изображения.
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,
Работает на уровне 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
)
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
else:
base_sheet = base_sheet.replace('</worksheet>',
new_mc + '</worksheet>')
else:
base_sheet = re.sub(r'<mergeCells[^>]*>.*?</mergeCells>', '',
base_sheet, flags=re.DOTALL)
current_row += src_ws.max_row
# Новый 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>'
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
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())
combined_wb.save(combined_path)
def generate_table4(generated: list, output_path: str):
"""Перечень заполненных форм (Таблица 4)."""
"""
Таблица 4: Сведения о выявленных фактах незаконного использования земель.
Структура согласно шаблону «Шаблон Таблицы4.xlsx»:
B1 — заголовок таблицы
R2 — заголовки 11 столбцов (AK)
R3/4 — уточнение статуса в J3/J4
R5 — нумерация столбцов 111
R6 — строка ИТОГО + формула COUNTA в G6
R7+ — данные (B=74, C=Челябинская область, D=УРФО,
E=кад.квартал, G=нет, H=местоположение ЗУ)
"""
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)
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: заголовки столбцов AK
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: нумерация столбцов 111
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)