feat(layouts): PDF ТЗ endpoint + BestLayoutsBlock UI (#113 PR D) #199
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
вторичка
ИРД
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#199
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/113-pdf-tz-and-ui-v2"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Final PR (D из 4) для Issue #113 Layout Analysis. Builds on PR #193 (A) + PR #194 (B) + PR #196 (C).
(PR #198 closed — stale branch from PR C base SHA; this is replacement, identical content cherry-picked onto current main)
Backend (~330 LOC)
POST /api/v1/parcels/{cad_num}/best-layouts/pdf— WeasyPrint PDF "ТЗ на проектирование (data-driven)"backend/app/services/exporters/layout_tz_pdf.py(NEW) —render_layout_tz_pdf()с XSS-escape (html.escape) дляcad_num/parcel_address/rationale_text/time_windowOSError|ImportErrorguard)Frontend (~800 LOC)
BestLayoutsBlock.tsx(NEW) — DataQualityCard + TopLayoutsTable + RecommendationCard + кнопка «Скачать ТЗ (PDF)» (Blob +revokeObjectURL)useBestLayouts.ts(NEW) — TanStackuseMutationhooktypes/best-layouts.ts(NEW) — manual TS typesMarketTab.tsx(+4 LOC) — integrationPre-push review fixes
sold_pct_of_supply— убрать×100(backend уже 0..100 percent)alert()→pdfErrorstateisNaN()→Number.isNaN()BytesIOunused import removedOSError|ImportErrorPhase 2.1 complete
Завершает #113. Phase 2.2 (
layout_type+balcony_countvia B2B Объектив #52) — отдельный issue.Test plan
pytest tests/test_layout_tz_pdf.py -v— 5 tests (skip Windows, run Docker CI)pytest tests/api/v1/test_parcel_best_layouts.py tests/test_layout_signature.py -q— 53/53 passtsc --noEmit,npm run lint,npm run build— all clean (17/17 pages)curl POST /best-layouts/pdf→ 200 + valid PDF/site-finder/{cad}→ MarketTab → BestLayoutsBlock visible + PDF download worksCloses #113.
Related: #197 (test-infra integration gate follow-up).
Backend (~330 LOC): - POST /api/v1/parcels/{cad_num}/best-layouts/pdf via WeasyPrint - services/exporters/layout_tz_pdf.py — HTML template с XSS-escape (html.escape) для cad_num/parcel_address/rationale_text/time_window - 5 unit tests (skip on Windows если WeasyPrint deps missing) Frontend (~800 LOC): - BestLayoutsBlock.tsx — DataQualityCard + TopLayoutsTable + RecommendationCard + PDF download button (Blob → revokeObjectURL) - useBestLayouts.ts — TanStack useMutation hook - types/best-layouts.ts — manual TS types - MarketTab.tsx +4 LOC integration после competitors Pre-push fixes per review: - XSS escape user-data в PDF HTML - sold_pct_of_supply — убрать double ×100 (backend уже 0..100) - alert() → pdfError state - isNaN → Number.isNaN - 7 inline-styles → Tailwind utilities Phase 2.1 complete. Phase 2.2 (layout_type/balcony_count via B2B Объектив #52) — follow-up issue.Deep Code Review — verdict
Summary
5878596· basead16fc0e· mergeable: true🔴 Critical
none
🟠 High
none
🟡 Medium (желательно fix, но не блокирующее)
backend/app/api/v1/parcels.py:2147— inconsistent 404 handling/best-layoutsJSON endpoint convertsValueError("Геометрия для … не найдена")→ 404, но новый/best-layouts/pdfendpoint только ловитHTTPException+ genericException→ возвращает 500 вместо 404 для несуществующего участка. Fix:backend/app/services/exporters/layout_tz_pdf.py:51-58, 61-67— XSS escape incomplete (defense-in-depth)Escaped:
cad_num,parcel_address,time_window,rationale_text✅НЕ escaped:
r.room_bucket,r.area_bin,r.signature,m.room_bucket(внутри<td>…</td>). Pydantic schemaTopLayoutRow.room_bucket— простоstr(неLiteral), значения сейчас идут из доверенного MVmv_layout_velocity, но без escape любой будущий шаг (например prompt → ML генерит buckets) станет XSS-vector. Рекомендую обернуть_html.escape(r.room_bucket)etc для consistency.frontend/src/components/site-finder/BestLayoutsBlock.tsx:329-331— даты выводятся как raw ISOPydantic сериализует
dt.dateкак"2026-02-01". UI получит2026-02-01 — 2026-05-01вместо01.02.2026 — 01.05.2026. Не критично, но не соответствует ru-локали (которая консистентно используется выше для чисел черезtoLocaleString("ru-RU")).backend/app/api/v1/parcels.py:2137— syncget_best_layouts()в async handler блокирует event loopasync def get_parcel_best_layouts_pdf(...)→ вызывает syncget_best_layouts(db, ...)+ syncrender_layout_tz_pdf(...)(WeasyPrint CPU-bound, ~100-500ms). Для PDF это особенно ощутимо (CPU expensive). Это established pattern в codebase (тот же/best-layoutsJSON endpoint так делает) — не блокирующее, но в идеале PDF gen уехала бы вasyncio.to_thread()или Celery task. Отметить как follow-up.🟢 Low / nits
backend/app/api/v1/parcels.py:2160—Content-Dispositionfilename без RFC 5987 fallbackf'attachment; filename="{filename}"'— cad_num в проекте всегда ASCII (\d+:\d+:…), но если в будущем filename получит Cyrillic (например черезparcel_address), нуженfilename*=UTF-8''…. Сейчас OK.frontend/src/components/site-finder/BestLayoutsBlock.tsx:467— клиентский input допускает > backend maxtarget_total_flatsbackend Pydanticle=10000, frontend<input type="number" max={10000}>—maxэто hint, paste может проскочить. На invalid backend вернёт 422 → попадёт в error handler. Можно clamp на клиенте доMath.min(parsed, 10000).Cross-file impact analysis
✅
BestLayoutsRequestshape:parcel.py:151-160(backend) vstypes/best-layouts.ts:10-18(frontend) — match 1:1 (все 7 полей с правильной optional/required семантикой).✅
BestLayoutsResponse/TopLayoutRow/LayoutTzMixRow/LayoutTzRecommendation/LayoutDataQuality— все 5 schemas match между Pydantic и TS.✅
apiFetch(lib/api.ts:5-20) уже setsContent-Type: application/json—useBestLayouts.tsиспользует корректно. PDF endpoint вBestLayoutsBlock.tsx:451-459использует rawfetch(apiFetch не подходит т.к. возвращает binary blob, не JSON) — это правильное решение.✅ Endpoint URL
/api/v1/parcels/{encoded}/best-layouts/pdfв frontend (BestLayoutsBlock.tsx:454) match backend route (parcels.py:2136).✅ Auth: новый
/best-layouts/pdfНЕ требует_check_token— consistent с/best-layoutsи/competitors(весь/parcels/*namespace public-read в этом проекте, не auth issue).✅
get_best_layouts()(PR #196) reused, не дублирован. Renderer изолирован в новом модулеservices/exporters/layout_tz_pdf.py.✅
MarketTab.tsxintegration — +4 LOC, минимальная, без breaking changes для других tabs.✅ Note: existing
services/exporters/pdf.pyэто 4-строчная заглушка ("Stage 1c") —layout_tz_pdf.pyэто первая реальная WeasyPrint реализация в prod. Pattern reference в docstring корректен (заглушку как pattern не возьмёшь, но архитектурно правильно).Vault cross-check
✅
domains/api/endpoints/Endpoint_Parcel_BestLayouts.mdуже создан и описывает PDF endpoint + renderer + tests (соответствует CLAUDE.md rule #6: "vault обновляется вместе с кодом").✅
domains/api/endpoints/api-endpoints-MOC.md— entry "Endpoint_Parcel_BestLayouts — JSON + PDF ТЗ (#113 PR D)" присутствует.✅ Поиск "WeasyPrint" в vault: только meta/Stages + TechStack mentions, нет prior bugs / fixes — это первая WeasyPrint feature в prod, нет regression risks.
✅
fixes/— нет похожих PDF / XSS bugs которые могли бы быть повторены.✅
decisions/— нет архитектурных решений, нарушаемых этим PR.Conventions
logger.*notprint()uv run ruff check backend/app/services/exporters/layout_tz_pdf.py backend/app/api/v1/parcels.py backend/tests/test_layout_tz_pdf.py→ All checks passedany, manual types match Pydantic (codegen может быть позже)useMutationдля POST (правильно — это mutation, не query)"use client"в каждом client componenttry/except (OSError, ImportError) → pytest.skip(allow_module_level=True)— корректный workaround для Windows-без-GTKTests coverage
5 тестов покрывают:
%PDF)⚠️ Не покрыто: 404 path (если
get_best_layoutsraises ValueError), но это можно покрыть через integration test endpoint'а отдельным PR.Positive observations
html.escape()на critical user-data inputs (cad_num/parcel_address/rationale_text)URL.revokeObjectURL()после Blob download — нет memory leakNumber.isNaN()вместоisNaN()(per ESLint best practice)alert()→pdfErrorstate (per UX best practice)frontend.md)cad_num.replace(":", "-")) — нет path traversalPre-flight
feat/113-pdf-tz-and-ui-v2ad16fc0e= main HEAD (includes merged #193/#194/#196)Complexity / blast radius
Recommended next steps
parcels.py:2147— catchValueError → 404for consistency с JSON endpoint (10 LOC change, безопасно). Можно отдельным follow-up PR.layout_tz_pdf.py—_html.escape()наroom_bucket/area_bin/signatureв loop generators (defense-in-depth).BestLayoutsBlock.tsxrecommendation card meta./best-layouts/pdf+ chrome-devtools UI smoke. Не повторяю — внешний qa-tester subagent сделает.Verdict: ⚠️ MINOR — eligible for human merge. Все 🟡 medium issues — non-blocking, можно адресовать follow-up. Scope (
backend/app/api/v1/**+backend/app/services/**) blocked для auto-merge pergit-pr.md— нужен human approval. Per user policy ("MINOR не мержим, только ✅ APPROVE") — main session должна спросить user перед merge.cc: main session — verdict готов, scope blocked, не мержу.
Deep Code Review — re-verdict (after fixup)
Summary
434341e(was5878596)Previous 🟡 items — all addressed
parcels.pyтеперь ловитValueError → 404перед catch-allException → 500. Consistent с JSON/best-layoutsendpoint.room_bucket/area_bin)layout_tz_pdf.py—_html.escape()добавлен в top_rows наr.room_bucket+r.area_bin, и в mix_rows наm.room_bucket.signatureв HTML не рендерится (только в TS types) → escape не требуется.dd.MM.yyyy)BestLayoutsBlock.tsx—new Date(rec.data_window_start).toLocaleDateString("ru-RU")для start + end.target_total_flatsclient clampbuildRequest()—Math.min(Math.max(parsed, 1), 10000). Backend Pydanticle=10000остаётся как defense-in-depth.New issues introduced — none 🔴/🟠/🟡
Reviewed +11 lines line-by-line:
_html.escape(r.room_bucket/area_bin, m.room_bucket)— values typed как str enum-ish в Pydantic schemas, escape safe ✅except ValueError → 404ordering правильный:HTTPExceptionfirst (re-raise 4xx from inner), затемValueError, затем catch-all ✅Math.min(Math.max(parsed, 1), 10000)— корректно clamps paste-attacks (99999999→ 10000, negative → 1). NaN guarded upstream черезNumber.isNaN(parsed)✅toLocaleDateString("ru-RU")— runs корректно в Node SSR + browser, рендерит форматdd.MM.yyyy✅🟢 Low / nits (informational, не блокирующее)
_price_cell()вlayout_tz_pdf.pyиспользует:,.0fформатирование +.replace(",", " ")— стандартный Python idiom для thousand separator. Альтернатива черезbabel.numbers.format_decimal(locale='ru')была бы cleaner, но требует extra dep. Существующий подход OK для PDF гена.Cross-file impact
parcels.pyimport добавлен (from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf) — service module exists, signature совпадает ✅types/best-layouts.tsсовпадают с Pydantic schemas вschemas/parcel.py✅MarketTab.tsxintegration —<BestLayoutsBlock cadNum={data.cad_num} />без props mismatch ✅Pre-flight
feat/113-pdf-tz-and-ui-v2✅ (не main)true, no conflicts ✅--amend/--no-verifytraces ✅gpg.error.not_signed_commit— это default для repo (предыдущие коммиты тоже unsigned), не блокерScope / merge action
backend/app/api/v1/parcels.py+backend/app/services/exporters/**— оба в blocked-list.Risk / blast radius
Approved for merge by human / main session.