diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index c380d0f6..e31201a4 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -44,6 +44,7 @@ from app.services.analysis_runs.repository import ( persist_analysis_run, ) from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf +from app.services.exporters.report_docx import render_report_docx from app.services.exporters.report_md import ( render_report_markdown, render_report_telegram_summary, @@ -1323,16 +1324,21 @@ def export_parcel_forecast( cad_num: str, db: Annotated[Session, Depends(get_db)], format: Annotated[ - Literal["md", "json", "tg"], - Query(description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | tg (сводка)"), + Literal["md", "json", "tg", "docx"], + Query( + description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | " + "tg (сводка) | docx (Word-документ)" + ), ] = "md", ) -> Response: - """Экспорт §22-форсайта участка — Markdown / JSON (файл) или TG-сводка (#959, EPIC export). + """Экспорт §22-форсайта участка — Markdown / JSON / DOCX (файл) или TG-сводка (#959). - Самый дешёвый, БЕЗ-runtime-dep срез экспорта: читает ПОСЛЕДНИЙ §22-ран schema_version - "1.0" (тот же блоб, что отдаёт GET /{cad}/forecast inline) и отдаёт его в нужной форме. + Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET + /{cad}/forecast inline) и отдаёт его в нужной форме. • format=md → `render_report_markdown(run.result)` — attachment (.md, чистый Markdown). • format=json → сырой `run.result` — attachment (.json, download-вариант inline-чтения). + • format=docx → `render_report_docx(run.result)` — attachment (.docx, Word-документ, + зеркало содержания md/pdf; python-docx). • format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать и отправить, а не файл-download). @@ -1344,10 +1350,10 @@ def export_parcel_forecast( Args: cad_num: кадастровый номер участка (в имени файла `:` → `_`). - format: "md" (default) | "json" | "tg". + format: "md" (default) | "json" | "tg" | "docx". Returns: - Response — для md/json attachment с Content-Disposition (имя + Response — для md/json/docx attachment с Content-Disposition (имя `gendesign_forecast__.`); для tg — inline text/plain сводка. """ run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION) @@ -1365,6 +1371,19 @@ def export_parcel_forecast( today = _dt.date.today().strftime("%Y-%m-%d") base_name = f"gendesign_forecast_{cad_safe}_{today}" + # docx — байты (Word OOXML), а не строка: отдельной веткой (зеркало tg early-return), + # чтобы md/json string-ветки остались байт-в-байт прежними. + if format == "docx": + return Response( + content=render_report_docx(run.result), + media_type=( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ), + headers={ + "Content-Disposition": f'attachment; filename="{base_name}.docx"' + }, + ) + if format == "json": payload = json.dumps(run.result, ensure_ascii=False, default=str) media_type = "application/json" diff --git a/backend/app/services/exporters/report_docx.py b/backend/app/services/exporters/report_docx.py new file mode 100644 index 00000000..d7e95ac8 --- /dev/null +++ b/backend/app/services/exporters/report_docx.py @@ -0,0 +1,441 @@ +"""§22 DOCX-экспортёр итогового советующего отчёта Site Finder v2 (python-docx). + +#959 (EPIC export) — завершает набор форматов выгрузки §22-форсайта (md/json/tg уже +есть): рендерит `SiteFinderReport` (#987) в редактируемый Word-документ (.docx) и +возвращает БАЙТЫ — готовые для +`Response(media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document")`. +Аддитивный двойник PDF/Markdown-экспортёров (`report_pdf` / `report_md`): то же +содержание и ТОТ ЖЕ порядок секций (зеркало `report_md`). + +DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из +`report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря, +`_fmt` — строковое форматирование ячейки/None→"—", `_level_ru`, `_as_dict`/`_as_list`, +`_future_supply_pairs`, `_scenario_deficit_index`) + переиспользуем его named-константы +(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО docx-специфичные +микро-билдеры (заголовки/абзацы/таблицы python-docx). + +ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, БЕЗ БД/сети: только ПОТРЕБЛЯЕТ уже-собранный отчёт (его +наполняет сборщик #988) и раскладывает по секциям. Принимает КАК `SiteFinderReport`- +инстанс, ТАК и его `as_dict()`-словарь (нормализуется через `_normalize`). + +GRACEFUL (дух всего форсайт-стека): частичный/пустой/мусорный отчёт ВАЛИДЕН — пустая +секция рисует «нет данных», экспортёр НИКОГДА не падает (нет KeyError на тонком отчёте) +и возвращает минимальный валидный .docx. Отчёт СОВЕТУЮЩИЙ: после титула — заметный +advisory-дисклеймер (оценка не основание для инвест-решения). + +`python-docx` (PyPI `python-docx`, import `docx`) — чистый Python (lxml), без нативных +системных библиотек: Dockerfile не трогаем. Импортируется ЛОКАЛЬНО внутри функции +(зеркало WeasyPrint в `report_pdf` — не нужен при импорте модуля). +""" + +from __future__ import annotations + +import io +import logging +from typing import TYPE_CHECKING, Any + +from app.services.exporters.report_pdf import ( + _ADVISORY_MARKER, + _DASH, + _NO_DATA, + _as_dict, + _as_list, + _fmt, + _future_supply_pairs, + _level_ru, + _normalize, + _scenario_deficit_index, +) + +if TYPE_CHECKING: + # Только для аннотаций: тяжёлый python-docx импортируем ЛОКАЛЬНО в render_report_docx + # (как WeasyPrint в report_pdf), чтобы импорт модуля был дешёвым и не требовал dep. + from docx.document import Document as _DocxDocument + +logger = logging.getLogger(__name__) + +# ── Named-константы: заголовки (зеркало report_md — те же восемь секций §22) ── + +_TITLE_DOC: str = "Site Finder v2 — советующий отчёт" +_TITLE_SUMMARY: str = "Сводка" +_TITLE_MARKET_NOW: str = "Рынок сейчас" +_TITLE_FUTURE_MARKET: str = "Будущий рынок" +_TITLE_SCENARIOS: str = "Сценарии" +_TITLE_PRODUCT_TZ: str = "Продукт ТЗ" +_TITLE_SCORING: str = "Скоринг" +_TITLE_CONFIDENCE: str = "Уверенность" + +# Краткий RU-дисклеймер (формулировка из ТЗ #959) — после титула, виден сразу. +_ADVISORY_DISCLAIMER: str = "Оценка advisory — не основание для инвест-решения." + +# Стиль таблиц python-docx (встроенный «Table Grid» — есть в любом дефолтном Document, +# рисует видимые границы; не требует кастомного шаблона). Зеркало house-style соседей. +_TABLE_STYLE: str = "Table Grid" + +# Сколько конкурентов выводить в таблицу (top-N — зеркало report_md._COMPETITORS_TOP_N). +_COMPETITORS_TOP_N: int = 5 + +# Основной продуктовый горизонт (мес) — подпись столбца сводного дефицита сценария +# (зеркало report_md._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index). +_PRIMARY_HORIZON_MONTHS: int = 12 + + +# ────────────────────────────────────────────────────────────────────────────── +# docx-специфичные микро-билдеры. Все принимают `doc`/контейнер python-docx и пишут +# в него (мутируют документ). Хелперы нормализации/форматирования — импортированы из +# report_pdf, не дублируются (DRY). Числа/None уже причёсаны `_fmt` → str. +# ────────────────────────────────────────────────────────────────────────────── + + +def _add_kv_lines(doc: _DocxDocument, pairs: list[tuple[str, Any]]) -> None: + """Карточка «**метка:** значение» построчно (bullet-абзацы). Пустой → «нет данных». + + Метка — статичная RU-строка (bold-run), значение — через `_fmt` (None → "—"). + Зеркало `report_md._md_kv_lines` (там Markdown-буллеты; здесь — docx-абзацы). + """ + if not pairs: + doc.add_paragraph(_NO_DATA) + return + for label, value in pairs: + para = doc.add_paragraph(style="List Bullet") + run = para.add_run(f"{label}: ") + run.bold = True + para.add_run(_fmt(value)) + + +def _add_table(doc: _DocxDocument, headers: list[str], rows: list[list[Any]]) -> None: + """Таблица: шапка (статичные RU-метки, bold) + строки данных (через `_fmt`). Graceful. + + Пустой `rows` → одна строка-заглушка «нет данных» под шапкой (таблица всё равно + валидна). Все ячейки данных проходят `_fmt` (str, None → "—"). Зеркало + `report_md._md_table` (GFM-таблица) — здесь python-docx Table со стилем «Table Grid». + """ + table = doc.add_table(rows=1, cols=len(headers)) + table.style = _TABLE_STYLE + header_cells = table.rows[0].cells + for idx, header in enumerate(headers): + cell = header_cells[idx] + cell.text = "" + run = cell.paragraphs[0].add_run(header) + run.bold = True + + if not rows: + empty_cells = table.add_row().cells + empty_cells[0].text = _NO_DATA + return + + for row in rows: + cells = table.add_row().cells + for idx in range(len(headers)): + # Строка может оказаться короче шапки (defensive) — недостающее → "—". + value = row[idx] if idx < len(row) else None + cells[idx].text = _fmt(value) + + +def _add_kv_table(doc: _DocxDocument, data: dict[str, Any]) -> None: + """Таблица «Показатель → Значение» из плоского dict. Пустой → «нет данных». + + Ключи — данные (тоже через `_fmt`). Зеркало `report_md._md_kv_table`. + """ + rows = [[str(key), value] for key, value in data.items()] + _add_table(doc, ["Показатель", "Значение"], rows) + + +def _add_heading(doc: _DocxDocument, text: str, level: int) -> None: + """Заголовок секции (python-docx Heading level). Тонкая обёртка для единообразия.""" + doc.add_heading(text, level=level) + + +def _join_horizons(values: list[Any]) -> Any: + """Свернуть список горизонтов в «6, 12, 18» или None (→ `_fmt` отдаст "—"). PURE.""" + return ", ".join(str(v) for v in values) if values else None + + +# ────────────────────────────────────────────────────────────────────────────── +# Построители секций — по одной на содержательную секцию §22, тот же порядок/набор, +# что и в report_md/report_pdf. Каждый graceful (пустая секция → «нет данных», не +# падает) и ПИШЕТ в переданный `doc` (мутирует документ). +# ────────────────────────────────────────────────────────────────────────────── + + +def _build_header(doc: _DocxDocument, report: dict[str, Any]) -> None: + """Титул + advisory-дисклеймер + карточка meta (cad/район/горизонты/дата/схема).""" + meta = _as_dict(report.get("meta")) + schema_version = report.get("schema_version") or meta.get("schema_version") + + doc.add_heading(_TITLE_DOC, level=0) + disclaimer = doc.add_paragraph(style="Intense Quote") + run = disclaimer.add_run(f"{_ADVISORY_DISCLAIMER} {_ADVISORY_MARKER}") + run.bold = True + + meta_pairs: list[tuple[str, Any]] = [ + ("Кадастровый номер", meta.get("cad_num")), + ("Район", meta.get("district")), + ("Сегмент", meta.get("segment")), + ("Горизонты (мес)", _join_horizons(_as_list(meta.get("horizons")))), + ("Сформировано", meta.get("generated_at")), + ("Версия схемы", schema_version), + ] + _add_kv_lines(doc, meta_pairs) + + +def _build_summary(doc: _DocxDocument, report: dict[str, Any]) -> None: + """§22.1 «Сводка»: headline (bold) + вердикт + общая уверенность + ключевые числа.""" + exec_summary = _as_dict(report.get("exec_summary")) + headline = exec_summary.get("headline") + verdict = exec_summary.get("verdict") + key_numbers = _as_dict(exec_summary.get("key_numbers")) + overall_conf = _level_ru(exec_summary.get("overall_confidence")) + + _add_heading(doc, _TITLE_SUMMARY, level=1) + headline_para = doc.add_paragraph() + headline_para.add_run(_fmt(headline)).bold = True + doc.add_paragraph(_fmt(verdict)) + conf_para = doc.add_paragraph() + conf_para.add_run(f"Общая уверенность: {overall_conf}").italic = True + + _add_heading(doc, "Ключевые числа", level=2) + _add_kv_table(doc, key_numbers) + + +def _build_market_now(doc: _DocxDocument, report: dict[str, Any]) -> None: + """§22.2 «Рынок сейчас»: резюме + метрики + слои предложения + конкуренты (top-N).""" + market_now = _as_dict(report.get("market_now")) + metrics = _as_dict(market_now.get("market_metrics")) + supply = _as_dict(market_now.get("supply_layers")) + competitors = _as_list(market_now.get("competitors")) + + comp_rows = [ + [c.get("comm_name"), c.get("obj_id"), c.get("relevance_weight")] + for c in competitors[:_COMPETITORS_TOP_N] + if isinstance(c, dict) + ] + + _add_heading(doc, _TITLE_MARKET_NOW, level=1) + doc.add_paragraph(_fmt(market_now.get("summary"))) + _add_heading(doc, "Метрики рынка", level=2) + _add_kv_table(doc, metrics) + _add_heading(doc, "Слои предложения", level=2) + _add_kv_table(doc, supply) + _add_heading(doc, "Конкуренты", level=2) + _add_table(doc, ["ЖК", "ID", "Релевантность"], comp_rows) + + +def _build_future_market(doc: _DocxDocument, report: dict[str, Any]) -> None: + """§22.3 «Будущий рынок»: таблица по горизонтам + давление предложения + сценарии.""" + future = _as_dict(report.get("future_market")) + forecasts = _as_list(future.get("forecasts_by_horizon")) + scenarios_summary = _as_dict(future.get("scenarios_summary")) + + forecast_headers = [ + "Горизонт, мес", + "Индекс дефицита", + "Месяцев запаса", + "Спрос", + "Предложение", + "Ставка", + "Уверенность", + ] + forecast_rows = [ + [ + f.get("horizon_months"), + f.get("deficit_index"), + f.get("months_of_inventory"), + f.get("projected_demand_units"), + f.get("projected_supply_units"), + f.get("rate_future"), + _level_ru(f.get("confidence")), + ] + for f in forecasts + if isinstance(f, dict) + ] + + _add_heading(doc, _TITLE_FUTURE_MARKET, level=1) + doc.add_paragraph(_fmt(future.get("summary"))) + _add_heading(doc, "Прогноз по горизонтам", level=2) + _add_table(doc, forecast_headers, forecast_rows) + _add_heading(doc, "Давление будущего предложения", level=2) + _add_kv_table(doc, _future_supply_pairs(future.get("future_supply"))) + _add_heading(doc, "Разброс сценариев (индекс дефицита)", level=2) + _add_kv_table(doc, scenarios_summary) + + +def _build_scenarios(doc: _DocxDocument, report: dict[str, Any]) -> None: + """§22.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path.""" + scenarios = _as_dict(report.get("scenarios")) + by_scenario = _as_dict(scenarios.get("by_scenario")) + + rows: list[list[Any]] = [] + for name, payload in by_scenario.items(): + data = _as_dict(payload) + rate_path = _as_dict(data.get("rate_path")) + rate_str = ( + ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None + ) + rows.append([name, _scenario_deficit_index(data), rate_str, data.get("advisory")]) + + headers = [ + "Сценарий", + f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", + "Траектория ставки", + "Advisory", + ] + _add_heading(doc, _TITLE_SCENARIOS, level=1) + doc.add_paragraph(_fmt(scenarios.get("summary"))) + _add_table(doc, headers, rows) + + +def _build_product_tz(doc: _DocxDocument, report: dict[str, Any]) -> None: + """§22.4 «Продукт ТЗ»: класс + квартирография (mix) + коммерция + USP + причины.""" + product = _as_dict(report.get("product_tz")) + mix = _as_list(product.get("mix")) + commercial = _as_dict(product.get("commercial")) + usp = _as_list(product.get("usp")) + reasons = _as_list(product.get("reasons")) + + head_pairs: list[tuple[str, Any]] = [ + ("Резюме", product.get("summary")), + ("Рекомендованный класс", product.get("obj_class")), + ] + mix_rows = [ + [m.get("bucket"), m.get("pct"), m.get("obj_class"), m.get("deficit_index")] + for m in mix + if isinstance(m, dict) + ] + usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)] + reason_rows = [[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)] + + _add_heading(doc, _TITLE_PRODUCT_TZ, level=1) + _add_kv_lines(doc, head_pairs) + _add_heading(doc, "Квартирография (mix)", level=2) + _add_table(doc, ["Формат", "Доля, %", "Класс", "Индекс дефицита"], mix_rows) + _add_heading(doc, "Коммерция", level=2) + _add_kv_table(doc, commercial) + _add_heading(doc, "USP-ниши", level=2) + _add_table(doc, ["Сегмент", "USP"], usp_rows) + _add_heading(doc, "Причины (§16)", level=2) + _add_table(doc, ["Почему", "Advisory"], reason_rows) + + +def _build_scoring(doc: _DocxDocument, report: dict[str, Any]) -> None: + """§22.6 «Скоринг»: продуктовые скоры (#985) + спец-индексы (#986) + overall.""" + scoring = _as_dict(report.get("scoring")) + product_scores = _as_dict(scoring.get("product_scores")) + special_indices = _as_dict(scoring.get("special_indices")) + + scores = _as_dict(product_scores.get("scores")) + score_rows = [[name, _as_dict(payload).get("value")] for name, payload in scores.items()] + + indices = _as_dict(special_indices.get("indices")) + index_rows = [ + [name, _as_dict(payload).get("value"), _as_dict(payload).get("label")] + for name, payload in indices.items() + ] + + _add_heading(doc, _TITLE_SCORING, level=1) + overall_para = doc.add_paragraph() + overall_para.add_run(f"Итоговый скор (overall): {_fmt(scoring.get('overall'))}").italic = True + _add_heading(doc, "Продуктовые скоры", level=2) + _add_table(doc, ["Скор", "Значение"], score_rows) + _add_heading(doc, "Специальные индексы", level=2) + _add_table(doc, ["Индекс", "Значение", "Метка"], index_rows) + + +def _build_confidence(doc: _DocxDocument, report: dict[str, Any]) -> None: + """§22.7 «Уверенность»: уровень + обоснование + факторы-драйверы (таблица).""" + confidence = _as_dict(report.get("confidence")) + level = _level_ru(confidence.get("level")) + rationale = confidence.get("rationale") + factors = _as_dict(confidence.get("factors")) + + # Факторы #990: {name: {value, level, note}} ИЛИ плоское {name: value}. Defensive: + # если значение — dict, раскладываем на value/level/note; иначе кладём как есть. + factor_rows: list[list[Any]] = [] + for name, payload in factors.items(): + if isinstance(payload, dict): + factor_rows.append( + [name, payload.get("value"), _level_ru(payload.get("level")), payload.get("note")] + ) + else: + factor_rows.append([name, payload, _DASH, _DASH]) + + _add_heading(doc, _TITLE_CONFIDENCE, level=1) + level_para = doc.add_paragraph() + level_para.add_run(f"Уровень: {level}").italic = True + doc.add_paragraph(_fmt(rationale)) + _add_heading(doc, "Факторы уверенности", level=2) + _add_table(doc, ["Фактор", "Значение", "Уровень", "Комментарий"], factor_rows) + + +# Реестр построителей. Порядок = порядок блоков в документе (зеркало report_md: +# Сводка → Рынок сейчас → Будущий рынок → Сценарии → Продукт ТЗ → Скоринг → +# Уверенность; header идёт отдельно первым). +_SECTION_BUILDERS: tuple[Any, ...] = ( + _build_summary, + _build_market_now, + _build_future_market, + _build_scenarios, + _build_product_tz, + _build_scoring, + _build_confidence, +) + + +# ────────────────────────────────────────────────────────────────────────────── +# Публичный API — рендер отчёта в DOCX-байты (без файлового I/O на диск). +# ────────────────────────────────────────────────────────────────────────────── + + +def render_report_docx(report: Any) -> bytes: + """§22 Отрендерить `SiteFinderReport` (#987) в Word-документ (.docx) и вернуть БАЙТЫ. + + Титул + advisory-дисклеймер + карточка meta, далее семь содержательных секций §22 + (Сводка / Рынок сейчас / Будущий рынок / Сценарии / Продукт ТЗ / Скоринг / + Уверенность) — содержание и порядок ЗЕРКАЛЯТ `report_md`/`report_pdf`. Числа + форматируются через импортированный `_fmt` (None → "—"), уровни уверенности — RU- + метками. Заголовки python-docx (Heading 0-2), таблицы со стилем «Table Grid». + + ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и + его `as_dict()`-словарь (нормализуется через импортированный `_normalize`). GRACEFUL: + частичный/пустой/мусорный отчёт → секции с «нет данных», НИКОГДА не падает (нет + KeyError на тонком отчёте) — возвращает минимальный валидный .docx. + + `python-docx` импортируется ЛОКАЛЬНО (как WeasyPrint в `report_pdf`): не нужен при + импорте модуля. Документ собирается в памяти (`io.BytesIO`) — без файлового I/O. + + Args: + report: `SiteFinderReport`-инстанс или его `as_dict()`-словарь (или мусор → {}). + + Returns: + Непустые DOCX-байты (OOXML — это zip, начинаются с `b"PK"`), готовые для + `Response(media_type= + "application/vnd.openxmlformats-officedocument.wordprocessingml.document")`. + """ + # python-docx импортируем локально — тяжёлый (lxml); не нужен при импорте модуля. + try: + from docx import Document + except ImportError as exc: + raise RuntimeError( + "python-docx не установлен. Добавь 'python-docx>=1.1.0' в pyproject.toml." + ) from exc + + data = _normalize(report) + doc = Document() + + _build_header(doc, data) + for builder in _SECTION_BUILDERS: + builder(doc, data) + + buffer = io.BytesIO() + doc.save(buffer) + docx_bytes = buffer.getvalue() + + meta = _as_dict(data.get("meta")) + logger.info( + "render_report_docx: cad_num=%s sections=%d size=%d bytes advisory=%s", + meta.get("cad_num"), + len(_SECTION_BUILDERS), + len(docx_bytes), + data.get("advisory"), + ) + return docx_bytes diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8545756c..493be0c2 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "jinja2>=3.1.0", "ezdxf>=1.3.0", "openpyxl>=3.1.0", + "python-docx>=1.1.0", # DOCX export (import name `docx`) — §22 forecast report (#959) "pandas>=2.2.0", "numpy>=2.0.0", "scikit-learn>=1.5.0", diff --git a/backend/tests/api/v1/test_parcels_forecast.py b/backend/tests/api/v1/test_parcels_forecast.py index a2e5efd8..5136d5f0 100644 --- a/backend/tests/api/v1/test_parcels_forecast.py +++ b/backend/tests/api/v1/test_parcels_forecast.py @@ -471,6 +471,53 @@ def test_export_forecast_tg_no_run_returns_404() -> None: app.dependency_overrides.clear() +def test_export_forecast_docx_returns_word_document() -> None: + """format=docx → 200 wordprocessingml + attachment .docx, тело — байты .docx (PK).""" + from app.core.db import get_db + + fake_run = MagicMock() + fake_run.result = _REPORT_FIXTURE + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf: + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=docx") + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"] == ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + cd = resp.headers["content-disposition"] + assert "attachment;" in cd + assert cd.endswith('.docx"') + # cad-двоеточия санитизированы в имени файла. + assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd + # OOXML/.docx — zip: первые 2 байта «PK». + assert resp.content[:2] == b"PK" + # Читаем именно §22-схему "1.0". + _, kwargs = lrf.call_args + assert kwargs.get("schema_version") == "1.0" + finally: + app.dependency_overrides.clear() + + +def test_export_forecast_docx_no_run_returns_404() -> None: + """format=docx + рана нет → 404 (как md/json export-вариант, НЕ 202/500).""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", return_value=None): + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=docx") + assert resp.status_code == 404, resp.text + assert "посчитан" in resp.json()["detail"] + finally: + app.dependency_overrides.clear() + + def test_export_forecast_no_run_returns_404() -> None: """Рана нет (latest_run_for → None) → 404 (download-вариант, НЕ 202/500).""" from app.core.db import get_db diff --git a/backend/tests/services/exporters/test_report_docx.py b/backend/tests/services/exporters/test_report_docx.py new file mode 100644 index 00000000..7480aa77 --- /dev/null +++ b/backend/tests/services/exporters/test_report_docx.py @@ -0,0 +1,202 @@ +"""Unit-тесты §22 DOCX-экспортёра (#959, EPIC export) — `render_report_docx`. + +Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт): + • реальный fixture-отчёт (frontend mock `parcel-forecast.json` → `report`) рендерится + в НЕПУСТЫЕ байты, начинающиеся с `b"PK"` (OOXML/.docx — это zip-контейнер); + • документ открываемый python-docx и несёт заголовки всех секций + advisory-дисклеймер + + кадастр из meta; + • экспортёр принимает КАК `SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь; + • пустой / частичный / мусорный отчёт → валидный минимальный .docx БЕЗ падения (graceful). + +Детерминированно, без LLM. DATABASE_URL выставляем до импорта app-модулей (зеркало +test_report_md.py) — на случай side-effect'ов импорта пакета. +""" + +from __future__ import annotations + +import io +import json +import os +from pathlib import Path +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from docx import Document + +from app.services.exporters.report_docx import ( + _ADVISORY_DISCLAIMER, + _TITLE_CONFIDENCE, + _TITLE_FUTURE_MARKET, + _TITLE_MARKET_NOW, + _TITLE_PRODUCT_TZ, + _TITLE_SCENARIOS, + _TITLE_SCORING, + _TITLE_SUMMARY, + render_report_docx, +) +from app.services.forecasting.report import ( + ReportExecSummary, + ReportMeta, + SiteFinderReport, +) + +# Реальный fixture: frontend mock §22-форсайта — берём вложенный `report` (форма #987 +# `as_dict()`). Так тест не разойдётся с реальной формой отчёта. +_FIXTURE_PATH = ( + Path(__file__).resolve().parents[4] + / "frontend" + / "src" + / "lib" + / "mocks" + / "parcel-forecast.json" +) + +# OOXML / .docx — это zip-контейнер: первые два байта — сигнатура zip «PK». +_ZIP_SIGNATURE = b"PK" + +_EXPECTED_TITLES: tuple[str, ...] = ( + _TITLE_SUMMARY, + _TITLE_MARKET_NOW, + _TITLE_FUTURE_MARKET, + _TITLE_SCENARIOS, + _TITLE_PRODUCT_TZ, + _TITLE_SCORING, + _TITLE_CONFIDENCE, +) + + +def _fixture_report() -> dict[str, Any]: + """`report`-словарь из frontend-мока §22 (форма SiteFinderReport.as_dict()).""" + data = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8")) + report = data["report"] + assert isinstance(report, dict) + return report + + +def _docx_text(docx_bytes: bytes) -> str: + """Весь текст документа (абзацы + ячейки таблиц) одной строкой — для ассертов.""" + doc = Document(io.BytesIO(docx_bytes)) + parts: list[str] = [p.text for p in doc.paragraphs] + for table in doc.tables: + for row in table.rows: + parts.extend(cell.text for cell in row.cells) + return "\n".join(parts) + + +# ── Реальный fixture-отчёт: байты-сигнатура + структура + ключевые значения ────── + + +class TestFixtureReportRender: + def test_returns_non_empty_bytes(self) -> None: + out = render_report_docx(_fixture_report()) + assert isinstance(out, bytes) + # Нетривиальный документ — далеко не пустой. + assert len(out) > 1000 + + def test_starts_with_zip_signature(self) -> None: + # OOXML/.docx — zip: первые 2 байта обязаны быть «PK». + out = render_report_docx(_fixture_report()) + assert out[:2] == _ZIP_SIGNATURE + + def test_openable_by_python_docx(self) -> None: + # Результат — валидный .docx (открывается python-docx без ошибки). + out = render_report_docx(_fixture_report()) + doc = Document(io.BytesIO(out)) + assert len(doc.paragraphs) > 0 + + def test_advisory_disclaimer_present(self) -> None: + text = _docx_text(render_report_docx(_fixture_report())) + assert _ADVISORY_DISCLAIMER in text + + def test_all_section_headers_present(self) -> None: + text = _docx_text(render_report_docx(_fixture_report())) + for title in _EXPECTED_TITLES: + assert title in text, f"отсутствует заголовок секции «{title}»" + + def test_meta_cad_num_present(self) -> None: + text = _docx_text(render_report_docx(_fixture_report())) + # Кадастр из meta фикстуры попадает в карточку. + assert "66:41:0702048:27" in text + + def test_headline_present(self) -> None: + report = _fixture_report() + headline = report["exec_summary"]["headline"] + text = _docx_text(render_report_docx(report)) + assert headline.split(".")[0] in text + + +# ── Вход: dataclass-инстанс ИЛИ as_dict()-словарь ─────────────────────────────── + + +class TestInputForms: + def test_accepts_dataclass_instance(self) -> None: + report = SiteFinderReport( + exec_summary=ReportExecSummary(headline="Строить комфорт-класс."), + meta=ReportMeta(cad_num="66:41:0000000:1", horizons=[12]), + ) + text = _docx_text(render_report_docx(report)) + assert "Строить комфорт" in text + assert "66:41:0000000:1" in text + + def test_dataclass_and_dict_equivalent_text(self) -> None: + # Байты могут расходиться (zip-метаданные/порядок), но ТЕКСТ — идентичен. + report = SiteFinderReport( + exec_summary=ReportExecSummary(headline="Один заголовок."), + meta=ReportMeta(cad_num="66:41:0000000:9"), + ) + assert _docx_text(render_report_docx(report)) == _docx_text( + render_report_docx(report.as_dict()) + ) + + +# ── Graceful: пустой / частичный / мусорный вход → валидный .docx без падения ──── + + +class TestGraceful: + def test_empty_dict_does_not_crash(self) -> None: + # Пустой отчёт {} → валидный .docx (no KeyError), сигнатура PK, есть дисклеймер. + out = render_report_docx({}) + assert isinstance(out, bytes) + assert out[:2] == _ZIP_SIGNATURE + text = _docx_text(out) + assert _ADVISORY_DISCLAIMER in text + assert "нет данных" in text + # Все секции всё равно отрисованы (стабильный контракт). + for title in _EXPECTED_TITLES: + assert title in text + + def test_none_does_not_crash(self) -> None: + # None → нормализуется в {} → валидный минимальный .docx, не падает. + out = render_report_docx(None) + assert isinstance(out, bytes) + assert out[:2] == _ZIP_SIGNATURE + assert _ADVISORY_DISCLAIMER in _docx_text(out) + + def test_default_report_does_not_crash(self) -> None: + # Полностью дефолтный (пустой) SiteFinderReport — валидный .docx. + out = render_report_docx(SiteFinderReport()) + assert out[:2] == _ZIP_SIGNATURE + text = _docx_text(out) + assert _ADVISORY_DISCLAIMER in text + assert "нет данных" in text + + def test_garbage_input_returns_valid_docx(self) -> None: + # Мусор (int / строка / list) → нормализуется в {} → валидный .docx, не падает. + for junk in (123, "not a report", []): + out = render_report_docx(junk) + assert isinstance(out, bytes) + assert out[:2] == _ZIP_SIGNATURE + + def test_partial_report_some_sections(self) -> None: + report = SiteFinderReport( + exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."), + meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]), + ) + out = render_report_docx(report) + assert out[:2] == _ZIP_SIGNATURE + text = _docx_text(out) + assert "Тонкий анализ" in text + assert "66:41:0000000:2" in text + # Незаполненные секции рисуют «нет данных», но .docx валиден. + assert "нет данных" in text diff --git a/backend/uv.lock b/backend/uv.lock index d62ad8c1..d4e54132 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -578,6 +578,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyproj" }, + { name = "python-docx" }, { name = "pyyaml" }, { name = "redis" }, { name = "rosreestr2coord" }, @@ -620,6 +621,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.7.0" }, { name = "pydantic-settings", specifier = ">=2.3.0" }, { name = "pyproj", specifier = ">=3.6.0" }, + { name = "python-docx", specifier = ">=1.1.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "redis", specifier = ">=5.0.0" }, { name = "rosreestr2coord", specifier = ">=5.0.0" }, @@ -971,6 +973,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, ] +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, +] + [[package]] name = "mako" version = "1.3.11" @@ -1756,6 +1838,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, ] +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2"