Backend (quarter_dump_lookup.py): - _acquire_harvest_lock: Redis SETNX TTL=120s на quarter, защищает от burst N concurrent analyze, ставящих N одинаковых harvest task в очередь - _trigger_harvest: использует lock перед apply_async, возвращает False если lock уже взят (другой запрос триггернул раньше) - make_empty_result/EMPTY_DUMP_RESULT: новое поле harvest_eta_seconds в nspd_dump dict, типичный harvest_quarter = 60с - /analyze: пробрасывает поле через nspd_dump dict (нет typed schema — response_model=None для /analyze endpoint, dict уходит как есть) Frontend (NspdFreshnessBadge, NspdZoningBlock): - Countdown «НСПД: загрузка ~Nс» вместо бесконечного спиннера - После остановки countdown (remaining=0) NspdZoningBlock показывает «загрузка дольше обычного» + ссылку на ПКК вместо infinite skeleton Tests: 5 новых unit + 2 для empty_result schema (всего +7, pass) Closes #234 (UX-side; data-side resolves когда Sub-PR B + D merged).
275 lines
7.7 KiB
TypeScript
275 lines
7.7 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
|
||
import type { NspdDumpMeta, NspdZoning } from "@/types/nspd";
|
||
|
||
interface Props {
|
||
data: NspdZoning | null | undefined;
|
||
dump: NspdDumpMeta | null | undefined;
|
||
cadNum: string;
|
||
}
|
||
|
||
export function NspdZoningBlock({ data, dump, cadNum }: Props) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
|
||
const isHarvesting = dump?.harvest_triggered && !dump?.available;
|
||
const eta = dump?.harvest_eta_seconds ?? null;
|
||
|
||
// Issue #234: countdown пока harvest идёт.
|
||
const [remaining, setRemaining] = useState<number | null>(eta);
|
||
useEffect(() => {
|
||
setRemaining(eta);
|
||
}, [eta]);
|
||
useEffect(() => {
|
||
if (!isHarvesting) return;
|
||
if (remaining === null || remaining <= 0) return;
|
||
const id = setInterval(() => {
|
||
setRemaining((v) => (v === null || v <= 1 ? 0 : v - 1));
|
||
}, 1000);
|
||
return () => clearInterval(id);
|
||
}, [isHarvesting, remaining]);
|
||
|
||
// Issue #234: после ETA*1.5 показываем «не дождались», fallthrough к no-data UI.
|
||
// remaining === 0 значит таймер успел истечь (eta был известен).
|
||
const harvestTimedOut =
|
||
isHarvesting && !data && remaining !== null && remaining <= 0;
|
||
if (harvestTimedOut) {
|
||
return (
|
||
<div
|
||
style={{
|
||
background: "#fef3c7",
|
||
border: "1px solid #fde68a",
|
||
borderRadius: 10,
|
||
padding: "14px 18px",
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 13, color: "#92400e", marginBottom: 6 }}>
|
||
Данные НСПД загружаются дольше обычного. Попробуйте обновить страницу
|
||
через минуту.
|
||
</div>
|
||
<a
|
||
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(cadNum)}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{
|
||
display: "inline-block",
|
||
marginTop: 6,
|
||
padding: "6px 14px",
|
||
background: "#92400e",
|
||
color: "#fff",
|
||
borderRadius: 6,
|
||
fontSize: 13,
|
||
fontWeight: 500,
|
||
textDecoration: "none",
|
||
}}
|
||
>
|
||
Открыть в ПКК
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Loading skeleton when harvest is in progress
|
||
if (isHarvesting && !data) {
|
||
const label =
|
||
remaining !== null && remaining > 0
|
||
? `Загружаем данные НСПД, осталось ~${remaining} с…`
|
||
: "Загружаем данные НСПД…";
|
||
return (
|
||
<div
|
||
style={{
|
||
background: "#eff6ff",
|
||
border: "1px solid #bfdbfe",
|
||
borderRadius: 10,
|
||
padding: "14px 18px",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
width: 10,
|
||
height: 10,
|
||
borderRadius: "50%",
|
||
background: "#3b82f6",
|
||
animation: "pulse 1.5s infinite",
|
||
}}
|
||
/>
|
||
<span style={{ fontSize: 13, color: "#1d4ed8", fontWeight: 500 }}>
|
||
{label}
|
||
</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
height: 12,
|
||
background: "#bfdbfe",
|
||
borderRadius: 4,
|
||
width: "60%",
|
||
marginBottom: 6,
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
height: 12,
|
||
background: "#bfdbfe",
|
||
borderRadius: 4,
|
||
width: "40%",
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Data not available, no harvest triggered
|
||
if (!data) {
|
||
return (
|
||
<div
|
||
style={{
|
||
background: "#f9fafb",
|
||
border: "1px solid #e5e7eb",
|
||
borderRadius: 10,
|
||
padding: "14px 18px",
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||
Данные ПЗЗ из НСПД недоступны. Используйте{" "}
|
||
<a
|
||
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(cadNum)}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ color: "#1d4ed8", textDecoration: "none" }}
|
||
>
|
||
Публичную кадастровую карту
|
||
</a>{" "}
|
||
для кад. номера <strong style={{ color: "#374151" }}>{cadNum}</strong>
|
||
.
|
||
</div>
|
||
<a
|
||
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(cadNum)}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{
|
||
display: "inline-block",
|
||
marginTop: 10,
|
||
padding: "6px 14px",
|
||
background: "#1d4ed8",
|
||
color: "#fff",
|
||
borderRadius: 6,
|
||
fontSize: 13,
|
||
fontWeight: 500,
|
||
textDecoration: "none",
|
||
}}
|
||
>
|
||
Открыть в ПКК
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
background: "#f9fafb",
|
||
border: "1px solid #e5e7eb",
|
||
borderRadius: 10,
|
||
padding: "14px 18px",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
marginBottom: 10,
|
||
}}
|
||
>
|
||
{data.zone_code && (
|
||
<span
|
||
style={{
|
||
background: "#dbeafe",
|
||
color: "#1e40af",
|
||
borderRadius: 6,
|
||
padding: "2px 10px",
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
letterSpacing: 0.2,
|
||
}}
|
||
>
|
||
{data.zone_code}
|
||
</span>
|
||
)}
|
||
{data.zone_name && (
|
||
<span style={{ fontSize: 13, color: "#374151" }}>
|
||
{data.zone_name}
|
||
</span>
|
||
)}
|
||
{!data.zone_code && !data.zone_name && (
|
||
<span style={{ fontSize: 13, color: "#6b7280" }}>
|
||
Зона не определена
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{data.raw_props && Object.keys(data.raw_props).length > 0 && (
|
||
<div style={{ marginTop: 8 }}>
|
||
<button
|
||
onClick={() => setExpanded((v) => !v)}
|
||
style={{
|
||
fontSize: 12,
|
||
color: "#6b7280",
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
padding: 0,
|
||
textDecoration: "underline",
|
||
}}
|
||
>
|
||
{expanded ? "Скрыть свойства" : "Показать все свойства НСПД"}
|
||
</button>
|
||
{expanded && (
|
||
<div
|
||
style={{
|
||
marginTop: 8,
|
||
background: "#f3f4f6",
|
||
borderRadius: 6,
|
||
padding: "8px 12px",
|
||
fontSize: 12,
|
||
color: "#374151",
|
||
maxHeight: 200,
|
||
overflowY: "auto",
|
||
}}
|
||
>
|
||
{Object.entries(data.raw_props).map(([k, v]) => (
|
||
<div key={k} style={{ marginBottom: 2 }}>
|
||
<span style={{ color: "#6b7280" }}>{k}: </span>
|
||
<span>{String(v ?? "—")}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{dump?.fetched_at_utc && (
|
||
<div style={{ marginTop: 8, fontSize: 11, color: "#9ca3af" }}>
|
||
НСПД: данные от{" "}
|
||
{new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU")}
|
||
{dump.stale && (
|
||
<span style={{ color: "#f59e0b", marginLeft: 4 }}>
|
||
(устаревшие)
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|