fix(report): дата сборки в POST-ответе на fast-path из кэша (последний хвост R2 #2259) #2347

Merged
bot-backend merged 1 commit from fix/report-generated-at-fastpath into main 2026-07-03 23:44:20 +00:00
5 changed files with 22 additions and 5 deletions

View file

@ -1626,10 +1626,15 @@ def build_parcel_report(
analyze_at = _iso_or_none(getattr(analyze_run, "created_at", None)) analyze_at = _iso_or_none(getattr(analyze_run, "created_at", None))
forecast_at = _iso_or_none(getattr(forecast_run, "created_at", None)) forecast_at = _iso_or_none(getattr(forecast_run, "created_at", None))
# Готовый кэш → 200 ready (не enqueue'им повторно). # Готовый кэш → 200 ready (не enqueue'им повторно). Отдаём и дату генерации PDF из
if _cached_report_result(db, cad_num) is not None: # метадаты рана — чтобы fast-path кнопка показала дату без отдельного GET /status.
cached = _cached_report_result(db, cad_num)
if cached is not None:
return ReportBuildResponse( return ReportBuildResponse(
status="ready", analyze_run_at=analyze_at, forecast_run_at=forecast_at status="ready",
analyze_run_at=analyze_at,
forecast_run_at=forecast_at,
report_generated_at=cached.get("generated_at"),
) )
# Enqueue фоновой сборки. Lazy import таски (как forecast в analyze_parcel) — атрибут # Enqueue фоновой сборки. Lazy import таски (как forecast в analyze_parcel) — атрибут

View file

@ -270,6 +270,9 @@ class ReportBuildResponse(BaseModel):
# Даты базовых ранов (ISO) — контекст, по каким данным собирается/собран отчёт. # Даты базовых ранов (ISO) — контекст, по каким данным собирается/собран отчёт.
analyze_run_at: str | None = None analyze_run_at: str | None = None
forecast_run_at: str | None = None forecast_run_at: str | None = None
# Дата генерации готового PDF (ISO) — только при status="ready" (кэш-хит); на
# building-ветке None (файла ещё нет). Даёт fast-path кнопке дату без GET /status.
report_generated_at: str | None = None
class ReportStatusResponse(BaseModel): class ReportStatusResponse(BaseModel):

View file

@ -95,6 +95,8 @@ def test_post_report_enqueues_and_returns_202() -> None:
body = resp.json() body = resp.json()
assert body["status"] == "building" assert body["status"] == "building"
assert body["analyze_run_at"] is not None assert body["analyze_run_at"] is not None
# Отчёт ещё не собран → даты генерации PDF нет.
assert body["report_generated_at"] is None
delay_mock.assert_called_once_with(_CAD) delay_mock.assert_called_once_with(_CAD)
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
@ -127,7 +129,10 @@ def test_post_report_cache_hit_returns_200_ready(tmp_path: Path) -> None:
client = TestClient(app) client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/report") resp = client.post(f"/api/v1/parcels/{_CAD}/report")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "ready" body = resp.json()
assert body["status"] == "ready"
# Fast-path: POST-ответ несёт дату генерации PDF из метадаты рана (без GET /status).
assert body["report_generated_at"] == "2026-07-03T09:00:00+00:00"
delay_mock.assert_not_called() # кэш есть → не enqueue'им delay_mock.assert_not_called() # кэш есть → не enqueue'им
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()

View file

@ -294,7 +294,9 @@ export function useFullReport(cad: string): UseFullReportResult {
} }
const reportGeneratedAt = const reportGeneratedAt =
(uiState === "ready" ? statusData?.report_generated_at : null) ?? null; (uiState === "ready"
? (statusData?.report_generated_at ?? enqueue.data?.report_generated_at)
: null) ?? null;
return { return {
uiState, uiState,

View file

@ -5255,6 +5255,8 @@ export interface components {
analyze_run_at?: string | null; analyze_run_at?: string | null;
/** Forecast Run At */ /** Forecast Run At */
forecast_run_at?: string | null; forecast_run_at?: string | null;
/** Report Generated At */
report_generated_at?: string | null;
}; };
/** /**
* ReportStatusResponse * ReportStatusResponse