feat(site-finder): «Пересчитать §7 с этой программой» из блока альтернатив (#2181)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 15s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m43s
CI / openapi-codegen-check (pull_request) Successful in 3m21s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 15s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m43s
CI / openapi-codegen-check (pull_request) Successful in 3m21s
Хвост плана #2181: одним кликом применить вариант из блока «Как участок сходится» к §7 «Концепция». - SectionAlternatives: на каждой карточке варианта secondary-CTA (--accent-2, orange), передаёт класс/этажность/тип застройки варианта в §7 и плавно скроллит к #section-7. Дизейбл, пока §7 считает. - Section7Concept: useEffect на pendingProgram — раскрывает §7, подбирает тип секции + пятно из каталога по КЛАССУ варианта (pickTypeForClass, как pickDefaultType но для заданного класса), выставляет этажность/класс/тип, densify=false, запускает handleGenerate; после старта зовёт onProgramApplied (родитель обнуляет pending → повторный клик снова срабатывает). autoRan/ autoRetried refs не трогаются — ручной пересчёт всегда разрешён. Тонкий канал onPendingChange(concept.isPending) вверх для дизейбла CTA. - AnalysisPageContent: state pendingProgram + conceptPending, проброс между двумя секциями. - Новый узкий тип PendingConceptProgram в types/site-finder. tsc --noEmit чисто, next lint без предупреждений. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6f5bdffbe4
commit
1684363b5a
4 changed files with 209 additions and 9 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
|
@ -16,7 +16,10 @@ import { Section6Forecast } from "@/components/site-finder/analysis/Section6Fore
|
||||||
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
|
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
|
||||||
import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives";
|
import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives";
|
||||||
import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api";
|
import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api";
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type {
|
||||||
|
ParcelAnalysis,
|
||||||
|
PendingConceptProgram,
|
||||||
|
} from "@/types/site-finder";
|
||||||
|
|
||||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -29,6 +32,22 @@ interface Props {
|
||||||
export function AnalysisPageContent({ cad }: Props) {
|
export function AnalysisPageContent({ cad }: Props) {
|
||||||
const [horizon, setHorizon] = useState<number>(12);
|
const [horizon, setHorizon] = useState<number>(12);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// «Пересчитать §7 с этой программой» (план #2181, хвост): блок «Как участок
|
||||||
|
// сходится» кладёт сюда программу выбранного варианта, §7 «Концепция» её
|
||||||
|
// подхватывает (раскрывается + пересчитывает) и зовёт onProgramApplied →
|
||||||
|
// обнуляем pending, чтобы повторный клик по тому же варианту снова сработал.
|
||||||
|
const [pendingProgram, setPendingProgram] =
|
||||||
|
useState<PendingConceptProgram | null>(null);
|
||||||
|
// §7 сообщает свой concept.isPending — блок альтернатив дизейблит CTA, пока
|
||||||
|
// идёт пересчёт (одна раскладка за раз).
|
||||||
|
const [conceptPending, setConceptPending] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const handleApplyToConcept = useCallback(
|
||||||
|
(program: PendingConceptProgram) => setPendingProgram(program),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleProgramApplied = useCallback(() => setPendingProgram(null), []);
|
||||||
const { data, isLoading, isFetching, error } = useParcelAnalyzeQuery(
|
const { data, isLoading, isFetching, error } = useParcelAnalyzeQuery(
|
||||||
cad,
|
cad,
|
||||||
horizon,
|
horizon,
|
||||||
|
|
@ -226,13 +245,23 @@ export function AnalysisPageContent({ cad }: Props) {
|
||||||
{/* 7. Концепция — генеративный дизайн застройки (#1965 Stage 1).
|
{/* 7. Концепция — генеративный дизайн застройки (#1965 Stage 1).
|
||||||
Seeded из анализа (geom + financial_estimate), считается по
|
Seeded из анализа (geom + financial_estimate), считается по
|
||||||
запросу; result lazy-mounted (Leaflet). */}
|
запросу; result lazy-mounted (Leaflet). */}
|
||||||
<Section7Concept analysis={analysis} />
|
<Section7Concept
|
||||||
|
analysis={analysis}
|
||||||
|
pendingProgram={pendingProgram}
|
||||||
|
onProgramApplied={handleProgramApplied}
|
||||||
|
onPendingChange={setConceptPending}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* «Как участок сходится» (#2181) — авто-ранжир вариантов программы
|
{/* «Как участок сходится» (#2181) — авто-ранжир вариантов программы
|
||||||
(класс × этажность) по NPV при отрицательном фин-вердикте. Backend
|
(класс × этажность) по NPV при отрицательном фин-вердикте. Backend
|
||||||
кладёт program_alternatives только тогда; иначе блок не рендерится.
|
кладёт program_alternatives только тогда; иначе блок не рендерится.
|
||||||
Не дубль §7: без карт/3D, статичный топ-3. */}
|
Не дубль §7: без карт/3D, статичный топ-3. Кнопка «Пересчитать §7 с
|
||||||
<SectionAlternatives alternatives={analysis.program_alternatives} />
|
этой программой» (хвост #2181) прокидывает вариант в §7. */}
|
||||||
|
<SectionAlternatives
|
||||||
|
alternatives={analysis.program_alternatives}
|
||||||
|
onApplyToConcept={handleApplyToConcept}
|
||||||
|
conceptPending={conceptPending}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,10 @@ import {
|
||||||
type HousingClass,
|
type HousingClass,
|
||||||
type HouseTypeCatalogItem,
|
type HouseTypeCatalogItem,
|
||||||
} from "@/lib/concept-api";
|
} from "@/lib/concept-api";
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type {
|
||||||
|
ParcelAnalysis,
|
||||||
|
PendingConceptProgram,
|
||||||
|
} from "@/types/site-finder";
|
||||||
|
|
||||||
// ConceptVariantsResult hosts a Leaflet placement map — lazy-mount, no SSR, so
|
// ConceptVariantsResult hosts a Leaflet placement map — lazy-mount, no SSR, so
|
||||||
// it never bloats the main report bundle (loaded only when results exist).
|
// it never bloats the main report bundle (loaded only when results exist).
|
||||||
|
|
@ -123,16 +126,49 @@ function pickDefaultType(
|
||||||
return catalog.find((ht) => ht.housing_class === "comfort") ?? catalog[0];
|
return catalog.find((ht) => ht.housing_class === "comfort") ?? catalog[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каталожный тип секции ДЛЯ ЗАДАННОГО класса (apply-alternative, план #2181).
|
||||||
|
* Как pickDefaultType, но фильтр по housing_class варианта, а не по comfort:
|
||||||
|
* первый тип нужного класса → иначе pickDefaultType (comfort/первый). Так §7
|
||||||
|
* подставляет пятно/этажность из типа, подходящего к пришедшему классу.
|
||||||
|
*/
|
||||||
|
function pickTypeForClass(
|
||||||
|
catalog: HouseTypeCatalogItem[],
|
||||||
|
housingClass: HousingClass,
|
||||||
|
): HouseTypeCatalogItem | null {
|
||||||
|
return (
|
||||||
|
catalog.find((ht) => ht.housing_class === housingClass) ??
|
||||||
|
pickDefaultType(catalog)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** The /analyze response the report already holds — no re-fetch / re-entry. */
|
/** The /analyze response the report already holds — no re-fetch / re-entry. */
|
||||||
analysis: ParcelAnalysis;
|
analysis: ParcelAnalysis;
|
||||||
|
/**
|
||||||
|
* «Пересчитать §7 с этой программой» (план #2181, хвост). Не null → §7
|
||||||
|
* раскрывается, форма выставляется под класс/этажность/тип варианта (тип
|
||||||
|
* секции и пятно берём из каталога по классу), запускается пересчёт. После
|
||||||
|
* запуска §7 зовёт onProgramApplied() — родитель обнуляет pending, чтобы
|
||||||
|
* повторный клик по тому же варианту снова сработал.
|
||||||
|
*/
|
||||||
|
pendingProgram?: PendingConceptProgram | null;
|
||||||
|
/** Вызывается после старта пересчёта по pendingProgram — родитель чистит pending. */
|
||||||
|
onProgramApplied?: () => void;
|
||||||
|
/** §7 сообщает родителю своё concept.isPending (дизейбл CTA в блоке альтернатив). */
|
||||||
|
onPendingChange?: (pending: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section7Concept ───────────────────────────────────────────────────────────
|
// ── Section7Concept ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function Section7Concept({ analysis }: Props) {
|
export function Section7Concept({
|
||||||
|
analysis,
|
||||||
|
pendingProgram,
|
||||||
|
onProgramApplied,
|
||||||
|
onPendingChange,
|
||||||
|
}: Props) {
|
||||||
// Parcel polygon from the analysis geometry (handles Polygon / MultiPolygon /
|
// Parcel polygon from the analysis geometry (handles Polygon / MultiPolygon /
|
||||||
// null → null; MultiPolygon collapses to its first ring per extractPolygon).
|
// null → null; MultiPolygon collapses to its first ring per extractPolygon).
|
||||||
const polygon = useMemo(
|
const polygon = useMemo(
|
||||||
|
|
@ -262,6 +298,51 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
});
|
});
|
||||||
}, [concept, polygon, catalog, seeded, handleGenerate]);
|
}, [concept, polygon, catalog, seeded, handleGenerate]);
|
||||||
|
|
||||||
|
// ── Apply-alternative («Пересчитать §7 с этой программой», план #2181) ────
|
||||||
|
// Клик по варианту в блоке «Как участок сходится» ставит pendingProgram в
|
||||||
|
// родителе. Здесь: раскрыть §7, подобрать тип секции + пятно из каталога по
|
||||||
|
// КЛАССУ варианта (pickTypeForClass), выставить этажность/класс/тип застройки
|
||||||
|
// варианта, densify=false → запустить пересчёт этой программой. Ручной запуск
|
||||||
|
// всегда разрешён (autoRanRef/autoRetriedRef НЕ трогаем; concept.mutate сам
|
||||||
|
// ресетит предыдущий результат). После старта зовём onProgramApplied() —
|
||||||
|
// родитель обнуляет pending, чтобы повторный клик по тому же варианту сработал.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingProgram || !polygon || catalog.length === 0) return;
|
||||||
|
const hc = asHousingClass(pendingProgram.housing_class) ?? "comfort";
|
||||||
|
const dt = asDevelopmentType(pendingProgram.development_type) ?? "mid_rise";
|
||||||
|
const type = pickTypeForClass(catalog, hc);
|
||||||
|
if (!type) return;
|
||||||
|
const floors =
|
||||||
|
pendingProgram.floors > 0 ? pendingProgram.floors : FLOORS_FALLBACK;
|
||||||
|
setExpanded(true);
|
||||||
|
handleGenerate({
|
||||||
|
corpuses: CORPUSES_DEFAULT,
|
||||||
|
floors,
|
||||||
|
floor_height_m: 3.0,
|
||||||
|
section_type: type.section_type,
|
||||||
|
footprint_w_m: type.footprint_w_m,
|
||||||
|
footprint_d_m: type.footprint_d_m,
|
||||||
|
housing_class: hc,
|
||||||
|
development_type: dt,
|
||||||
|
land_cost_rub: seeded.land_cost_rub,
|
||||||
|
densify: false,
|
||||||
|
});
|
||||||
|
onProgramApplied?.();
|
||||||
|
}, [
|
||||||
|
pendingProgram,
|
||||||
|
polygon,
|
||||||
|
catalog,
|
||||||
|
seeded.land_cost_rub,
|
||||||
|
handleGenerate,
|
||||||
|
onProgramApplied,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Сообщаем родителю concept.isPending — блок альтернатив дизейблит CTA, пока §7
|
||||||
|
// считает (одна раскладка за раз). Тонкий канал вверх, без подъёма мутации.
|
||||||
|
useEffect(() => {
|
||||||
|
onPendingChange?.(concept.isPending);
|
||||||
|
}, [concept.isPending, onPendingChange]);
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
const handleToggle = useCallback(
|
||||||
(e: React.SyntheticEvent<HTMLDetailsElement>) => {
|
(e: React.SyntheticEvent<HTMLDetailsElement>) => {
|
||||||
if (e.currentTarget.open) setExpanded(true);
|
if (e.currentTarget.open) setExpanded(true);
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,11 @@ import {
|
||||||
type DevelopmentType,
|
type DevelopmentType,
|
||||||
type HousingClass,
|
type HousingClass,
|
||||||
} from "@/lib/concept-api";
|
} from "@/lib/concept-api";
|
||||||
import type { ProgramAlternatives, ProgramVariant } from "@/types/site-finder";
|
import type {
|
||||||
|
PendingConceptProgram,
|
||||||
|
ProgramAlternatives,
|
||||||
|
ProgramVariant,
|
||||||
|
} from "@/types/site-finder";
|
||||||
|
|
||||||
const SCROLL_MARGIN = 72;
|
const SCROLL_MARGIN = 72;
|
||||||
const TOP_N = 3;
|
const TOP_N = 3;
|
||||||
|
|
@ -113,6 +117,15 @@ function priceSourceLabel(source: string): string {
|
||||||
interface Props {
|
interface Props {
|
||||||
/** program_alternatives из ответа /analyze (null/пусто → блок не рендерится). */
|
/** program_alternatives из ответа /analyze (null/пусто → блок не рендерится). */
|
||||||
alternatives?: ProgramAlternatives | null;
|
alternatives?: ProgramAlternatives | null;
|
||||||
|
/**
|
||||||
|
* «Пересчитать §7 с этой программой» (план #2181, хвост). Если задан — на
|
||||||
|
* каждой карточке появляется secondary-CTA, по клику передающая класс/этажность/
|
||||||
|
* тип застройки варианта в §7 «Концепция» (родитель ставит pendingProgram +
|
||||||
|
* скроллит к #section-7). Не задан → кнопки нет (блок остаётся статичным топ-3).
|
||||||
|
*/
|
||||||
|
onApplyToConcept?: (program: PendingConceptProgram) => void;
|
||||||
|
/** §7 сейчас считает — CTA дизейблится (одна раскладка за раз). */
|
||||||
|
conceptPending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Variant card ──────────────────────────────────────────────────────────────
|
// ── Variant card ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -120,9 +133,14 @@ interface Props {
|
||||||
function VariantCard({
|
function VariantCard({
|
||||||
variant,
|
variant,
|
||||||
isBest,
|
isBest,
|
||||||
|
onApply,
|
||||||
|
applyDisabled,
|
||||||
}: {
|
}: {
|
||||||
variant: ProgramVariant;
|
variant: ProgramVariant;
|
||||||
isBest: boolean;
|
isBest: boolean;
|
||||||
|
/** Задан → рендерим CTA «Пересчитать §7 с этой программой». */
|
||||||
|
onApply?: (program: PendingConceptProgram) => void;
|
||||||
|
applyDisabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const npvPositive = variant.npv_rub > 0;
|
const npvPositive = variant.npv_rub > 0;
|
||||||
const npvColor = npvPositive
|
const npvColor = npvPositive
|
||||||
|
|
@ -212,17 +230,73 @@ function VariantCard({
|
||||||
Цена {formatPricePerSqm(variant.price_per_sqm_used)} (
|
Цена {formatPricePerSqm(variant.price_per_sqm_used)} (
|
||||||
{priceSourceLabel(variant.price_source)})
|
{priceSourceLabel(variant.price_source)})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CTA «Пересчитать §7» — secondary orange (--accent-2), не primary: одна
|
||||||
|
primary на экран (в §7 — «Рассчитать концепцию»). Передаём только
|
||||||
|
класс/этажность/тип; §7 добирает тип секции и пятно из каталога. */}
|
||||||
|
{onApply && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={applyDisabled}
|
||||||
|
aria-label="Пересчитать раздел 7 «Концепция» с программой этого варианта"
|
||||||
|
onClick={() =>
|
||||||
|
onApply({
|
||||||
|
housing_class: variant.housing_class,
|
||||||
|
floors: variant.floors,
|
||||||
|
development_type: variant.development_type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 12px",
|
||||||
|
height: 32,
|
||||||
|
background: applyDisabled
|
||||||
|
? "var(--bg-card-alt)"
|
||||||
|
: "var(--accent-2)",
|
||||||
|
color: applyDisabled ? "var(--fg-tertiary)" : "#fff",
|
||||||
|
border: applyDisabled ? "1px solid var(--border-card)" : "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: applyDisabled ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{applyDisabled ? "§7 считает…" : "Пересчитать §7 с этой программой"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SectionAlternatives ───────────────────────────────────────────────────────
|
// ── SectionAlternatives ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SectionAlternatives({ alternatives }: Props) {
|
export function SectionAlternatives({
|
||||||
|
alternatives,
|
||||||
|
onApplyToConcept,
|
||||||
|
conceptPending = false,
|
||||||
|
}: Props) {
|
||||||
// Рендер-guard: нет поля / пустой список → блок не рендерится вовсе.
|
// Рендер-guard: нет поля / пустой список → блок не рендерится вовсе.
|
||||||
const variants = alternatives?.variants ?? [];
|
const variants = alternatives?.variants ?? [];
|
||||||
if (!alternatives || variants.length === 0) return null;
|
if (!alternatives || variants.length === 0) return null;
|
||||||
|
|
||||||
|
// Клик по CTA: сначала ставим pendingProgram в родителе (это триггерит §7),
|
||||||
|
// затем плавно скроллим к §7, чтобы пользователь увидел пересчёт. Скролл здесь
|
||||||
|
// (не в родителе) — секция §7 всегда в DOM, якорь #section-7 гарантированно есть.
|
||||||
|
const handleApply = onApplyToConcept
|
||||||
|
? (program: PendingConceptProgram) => {
|
||||||
|
onApplyToConcept(program);
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document
|
||||||
|
.getElementById("section-7")
|
||||||
|
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const topVariants = variants.slice(0, TOP_N);
|
const topVariants = variants.slice(0, TOP_N);
|
||||||
const { any_viable, grid_size, caveat } = alternatives;
|
const { any_viable, grid_size, caveat } = alternatives;
|
||||||
|
|
||||||
|
|
@ -265,6 +339,8 @@ export function SectionAlternatives({ alternatives }: Props) {
|
||||||
key={`${variant.housing_class}-${variant.floors}-${variant.development_type}`}
|
key={`${variant.housing_class}-${variant.floors}-${variant.development_type}`}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
isBest={i === bestIndex}
|
isBest={i === bestIndex}
|
||||||
|
onApply={handleApply}
|
||||||
|
applyDisabled={conceptPending}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -580,6 +580,20 @@ export interface ProgramVariant {
|
||||||
schedule_is_default: boolean;
|
schedule_is_default: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Программа варианта, ПЕРЕДАВАЕМАЯ из блока «Как участок сходится» (#2181) в §7
|
||||||
|
* «Концепция» по клику «Пересчитать §7 с этой программой» (план #2181, хвост).
|
||||||
|
* Узкий срез ProgramVariant: только класс/этажность/тип застройки — §7 сам
|
||||||
|
* подбирает тип секции и пятно из каталога по housing_class (как pickDefaultType).
|
||||||
|
* housing_class / development_type — строки backend'а; §7 нарроуит их к литералам
|
||||||
|
* (asHousingClass / asDevelopmentType) с fallback comfort / mid_rise.
|
||||||
|
*/
|
||||||
|
export interface PendingConceptProgram {
|
||||||
|
housing_class: string;
|
||||||
|
floors: number;
|
||||||
|
development_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Блок «Как участок сходится» (#2181) — ранжир вариантов программы. Присутствует
|
* Блок «Как участок сходится» (#2181) — ранжир вариантов программы. Присутствует
|
||||||
* только при отрицательном базовом фин-вердикте; иначе null / отсутствует.
|
* только при отрицательном базовом фин-вердикте; иначе null / отсутствует.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue