"""Генерация итоговых файлов: объединённая форма и Таблица 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: """Строка имеет содержимое, если хотя бы одна ячейка несёт или .""" 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 = [] # строки ... со сдвинутыми номерами 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']*>(.*?)', 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'(]*>)(.*?)()', 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= в tag = re.sub( r'\br="(\d+)"', lambda m: f'r="{int(m.group(1)) + row_offset}"', tag, count=1 ) # Сдвинуть r= в ячейках 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']*>(.*?)', 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']+)/>', 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'()', 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+)()', 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 = '' + ''.join(all_row_xmls) + '' base_sheet = re.sub( r']*>.*?', new_sheet_data, base_sheet, flags=re.DOTALL ) if all_merges: new_mc = ( f'' + ''.join(f'' for r in all_merges) + '' ) if re.search(r']*>.*?', new_mc, base_sheet, flags=re.DOTALL ) else: base_sheet = base_sheet.replace('', new_mc + '') else: base_sheet = re.sub(r']*>.*?', '', 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'(]*>)', base_drw) root_open = (root_m.group(1) if root_m else '') new_drawing = root_open + ''.join(all_anchors) + '' new_drawing_rels = ( '' f'' + ''.join( f'' for rid, tgt in all_rels ) + '' ) # Записываем итоговый 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)