fix(site-finder): раскрыть proxy-fraction в EKB-median velocity-бенчмарке (#2445-A4) (#2448)
This commit is contained in:
parent
c8c96a87ee
commit
b40995780b
2 changed files with 152 additions and 26 deletions
|
|
@ -95,6 +95,11 @@ class VelocityResult:
|
|||
proxy_used: bool = False
|
||||
# proxy_sqm_per_deal — допущение «м² на сделку» для fallback (45.0); None на objective/none.
|
||||
proxy_sqm_per_deal: float | None = None
|
||||
# Audit #2445 A4: доля report_month-строк ЕКБ-бенчмарка (_get_ekb_median), где
|
||||
# deals_total_vol_m2 IS NULL и объём фабриковался через count×45.0 (тот же proxy,
|
||||
# что и rosreestr_fallback, но внутри city-wide нормализующей медианы). 0.0 —
|
||||
# медиана целиком из реальных deals_total_vol_m2 (или бенчмарк не считался/fallback-константа).
|
||||
ekb_median_proxy_fraction: float = 0.0
|
||||
# RANK 1 (DCF velocity schedule): абсорбция ОДНОГО типового конкурента района,
|
||||
# м²/мес = monthly_velocity_sqm / max(n_with_sales, 1). Это НЕ суммарная скорость
|
||||
# района (monthly_velocity_sqm), а темп выбытия одного участника рынка — им и
|
||||
|
|
@ -120,6 +125,7 @@ class VelocityResult:
|
|||
"objective_coverage_pct": self.objective_coverage_pct,
|
||||
"proxy_used": self.proxy_used,
|
||||
"proxy_sqm_per_deal": self.proxy_sqm_per_deal,
|
||||
"ekb_median_proxy_fraction": round(self.ekb_median_proxy_fraction, 3),
|
||||
"project_absorption_sqm_per_month": (
|
||||
round(self.project_absorption_sqm_per_month, 1)
|
||||
if self.project_absorption_sqm_per_month is not None
|
||||
|
|
@ -307,9 +313,16 @@ def compute_velocity(
|
|||
# SF#1871 P2: surface objective-покрытие в payload (доля конкурентов с маппингом+данными).
|
||||
coverage_pct = round(mapped_ratio * 100, 1)
|
||||
|
||||
ekb_median = (
|
||||
_get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
|
||||
)
|
||||
# Audit #2445 A4: _get_ekb_median возвращает (median, proxy_row_fraction) — доля
|
||||
# строк бенчмарка, где объём фабриковался через count*45.0. При DB-fallback
|
||||
# (None — нет данных) proxy_fraction неизвестна → 0.0 (используем calibrated
|
||||
# константу, а не contaminated медиану, так что disclosure не нужен).
|
||||
_ekb_median_result = _get_ekb_median(db, months_window=months_window)
|
||||
if _ekb_median_result is not None:
|
||||
ekb_median, ekb_median_proxy_fraction = _ekb_median_result
|
||||
else:
|
||||
ekb_median = _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
|
||||
ekb_median_proxy_fraction = 0.0
|
||||
n_comps = len(comp_rows)
|
||||
sample_no_data = sorted(
|
||||
[
|
||||
|
|
@ -342,6 +355,7 @@ def compute_velocity(
|
|||
ekb_median=ekb_median,
|
||||
sample_competitors=sample_no_data,
|
||||
objective_coverage_pct=coverage_pct,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
)
|
||||
if rr_result is not None:
|
||||
return rr_result
|
||||
|
|
@ -363,6 +377,7 @@ def compute_velocity(
|
|||
objective_coverage_pct=coverage_pct,
|
||||
proxy_used=False,
|
||||
proxy_sqm_per_deal=None,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
|
||||
)
|
||||
|
||||
|
|
@ -470,6 +485,7 @@ def compute_velocity(
|
|||
ekb_median=ekb_median,
|
||||
sample_competitors=sample_no_data,
|
||||
objective_coverage_pct=coverage_pct,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
)
|
||||
if rr_result is not None:
|
||||
return rr_result
|
||||
|
|
@ -503,6 +519,7 @@ def compute_velocity(
|
|||
objective_coverage_pct=coverage_pct,
|
||||
proxy_used=False,
|
||||
proxy_sqm_per_deal=None,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
|
||||
)
|
||||
|
||||
|
|
@ -580,6 +597,7 @@ def compute_velocity(
|
|||
objective_coverage_pct=coverage_pct,
|
||||
proxy_used=False,
|
||||
proxy_sqm_per_deal=None,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
project_absorption_sqm_per_month=project_absorption,
|
||||
)
|
||||
|
||||
|
|
@ -592,6 +610,7 @@ def _compute_rosreestr_fallback(
|
|||
ekb_median: float,
|
||||
sample_competitors: list[dict[str, Any]],
|
||||
objective_coverage_pct: float = 0.0,
|
||||
ekb_median_proxy_fraction: float = 0.0,
|
||||
) -> VelocityResult | None:
|
||||
"""Fallback velocity через rosreestr_deals JOIN по cad_quarter участка.
|
||||
|
||||
|
|
@ -681,13 +700,17 @@ def _compute_rosreestr_fallback(
|
|||
objective_coverage_pct=objective_coverage_pct,
|
||||
proxy_used=True, # объём = deal_count × avg_area_per_deal (фабрикуется)
|
||||
proxy_sqm_per_deal=avg_area_per_deal,
|
||||
# Audit #2445 A4: пробрасываем proxy-долю бенчмарка (ekb_median посчитан
|
||||
# ДО вызова этого fallback) — даже здесь denominator может быть частично
|
||||
# contaminated count*45.0-строками.
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
# RANK 1: квартальный total deal-count БЕЗ per-project декомпозиции —
|
||||
# per-project absorption ill-defined → None, НЕ драйвит DCF-график.
|
||||
project_absorption_sqm_per_month=None,
|
||||
)
|
||||
|
||||
|
||||
def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
|
||||
def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, float] | None:
|
||||
"""ЕКБ-wide медиана monthly velocity (м²/мес) per ЖК — benchmark.
|
||||
|
||||
Источник: objective_corpus_room_month (актуальные данные, обновляется еженедельно).
|
||||
|
|
@ -697,6 +720,24 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
|
|||
|
||||
Учитываются только ЖК с ≥3 месяцами данных за окно (стабильный сигнал).
|
||||
Fallback к _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH если нет данных в БД.
|
||||
|
||||
Audit #2445 A4: report_month-строки с deals_total_count>0, но
|
||||
deals_total_vol_m2 IS NULL, фабрикуют объём через COALESCE(..., count*45.0) —
|
||||
тот же proxy-приём, что и в rosreestr_fallback (avg_area_per_deal), только
|
||||
здесь он был необъявленным и контаминировал city-wide бенчмарк, к которому
|
||||
нормализуется velocity_score КАЖДОГО участка. Строки не исключаются (иначе
|
||||
продающийся, но без м²-данных ЖК просто выпадает из бенчмарка — тоже
|
||||
искажение), но доля таких строк считается и возвращается вызывающему коду
|
||||
для disclosure (см. VelocityResult.ekb_median_proxy_fraction), а не
|
||||
проглатывается молча как чистые данные.
|
||||
|
||||
Возвращает (median, proxy_row_fraction) либо None если данных нет.
|
||||
proxy_row_fraction — доля (project, room, month)-строк (среди вошедших в
|
||||
медиану project'ов, ≥3 отчётных месяца), где объём взят из count*45.0,
|
||||
а не из реального deals_total_vol_m2. Считается на уровне строк
|
||||
objective_corpus_room_month (таблица хранит строку per project×room×month —
|
||||
см. data/sql/100_fix_mv_layout_velocity_weighted_avg.sql), НЕ на уровне
|
||||
report_month, чтобы не менять исходный HAVING-гейт «≥3 месяца данных».
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
|
|
@ -704,23 +745,38 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
|
|||
db.execute(
|
||||
text(
|
||||
"""
|
||||
WITH per_project AS (
|
||||
WITH source_rows AS (
|
||||
SELECT
|
||||
project_name,
|
||||
SUM(COALESCE(deals_total_vol_m2,
|
||||
deals_total_count * 45.0)) AS total_sqm,
|
||||
COUNT(DISTINCT report_month) AS months_data
|
||||
report_month,
|
||||
COALESCE(deals_total_vol_m2,
|
||||
deals_total_count * 45.0) AS row_sqm,
|
||||
(deals_total_vol_m2 IS NULL) AS is_proxy_row
|
||||
FROM objective_corpus_room_month
|
||||
WHERE report_month >= (CURRENT_DATE - CAST(:window_interval AS interval))
|
||||
AND deals_total_count > 0
|
||||
),
|
||||
per_project AS (
|
||||
SELECT
|
||||
project_name,
|
||||
SUM(row_sqm) AS total_sqm,
|
||||
COUNT(DISTINCT report_month) AS months_data,
|
||||
COUNT(*) AS rows_data,
|
||||
COUNT(*) FILTER (WHERE is_proxy_row) AS proxy_rows
|
||||
FROM source_rows
|
||||
GROUP BY project_name
|
||||
HAVING COUNT(DISTINCT report_month) >= 3
|
||||
),
|
||||
per_project_velocity AS (
|
||||
SELECT total_sqm / months_data AS velocity
|
||||
SELECT total_sqm / months_data AS velocity,
|
||||
rows_data,
|
||||
proxy_rows
|
||||
FROM per_project
|
||||
)
|
||||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median
|
||||
SELECT
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median,
|
||||
COALESCE(SUM(proxy_rows), 0) AS proxy_rows_total,
|
||||
COALESCE(SUM(rows_data), 0) AS rows_total
|
||||
FROM per_project_velocity
|
||||
"""
|
||||
),
|
||||
|
|
@ -734,6 +790,10 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
|
|||
# SAVEPOINT auto-rollback'нут
|
||||
return None
|
||||
|
||||
if row and row["median"] is not None:
|
||||
return float(row["median"])
|
||||
return None
|
||||
if row is None or row["median"] is None:
|
||||
return None
|
||||
|
||||
rows_total = int(row["rows_total"] or 0)
|
||||
proxy_rows_total = int(row["proxy_rows_total"] or 0)
|
||||
proxy_fraction = (proxy_rows_total / rows_total) if rows_total > 0 else 0.0
|
||||
return float(row["median"]), proxy_fraction
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ def test_healthy_sales_returns_result():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ def test_few_competitors_low_confidence():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
@ -164,7 +164,7 @@ def test_medium_confidence():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
@ -184,6 +184,9 @@ def test_ekb_median_fallback_used_when_none():
|
|||
|
||||
assert result is not None
|
||||
assert result.ekb_median_sqm == _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
|
||||
# Audit #2445 A4: DB-fallback (no benchmark data at all) uses the calibrated
|
||||
# constant, not a contaminated median -> proxy_fraction is 0.0 (nothing to disclose).
|
||||
assert result.ekb_median_proxy_fraction == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_score_capped_at_1():
|
||||
|
|
@ -196,7 +199,7 @@ def test_score_capped_at_1():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
@ -219,7 +222,7 @@ def test_score_zero_when_no_sales_sqm():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
@ -247,6 +250,7 @@ def test_as_dict_structure():
|
|||
objective_coverage_pct=66.7,
|
||||
proxy_used=False,
|
||||
proxy_sqm_per_deal=None,
|
||||
ekb_median_proxy_fraction=0.25,
|
||||
)
|
||||
d = vr.as_dict()
|
||||
assert "competitors_count" in d
|
||||
|
|
@ -265,6 +269,8 @@ def test_as_dict_structure():
|
|||
assert d["objective_coverage_pct"] == pytest.approx(66.7)
|
||||
assert d["proxy_used"] is False
|
||||
assert d["proxy_sqm_per_deal"] is None
|
||||
# Audit #2445 A4: ekb_median_proxy_fraction disclosure field, rounded to 3dp.
|
||||
assert d["ekb_median_proxy_fraction"] == pytest.approx(0.25)
|
||||
# RANK 1: project_absorption_sqm_per_month присутствует (None по умолчанию).
|
||||
assert "project_absorption_sqm_per_month" in d
|
||||
assert d["project_absorption_sqm_per_month"] is None
|
||||
|
|
@ -279,7 +285,7 @@ def test_sample_competitors_top5():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
@ -306,7 +312,7 @@ def test_by_room_bucket_aggregation():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
@ -330,7 +336,7 @@ def test_by_room_bucket_empty_when_no_bucket_data():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
@ -356,7 +362,7 @@ def test_mapping_confidence_gate_in_sales_query():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
@ -376,6 +382,66 @@ def test_fallback_constant_is_conservative():
|
|||
assert _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH == pytest.approx(750.0)
|
||||
|
||||
|
||||
def test_ekb_median_proxy_fraction_surfaced_on_objective_path():
|
||||
"""Audit #2445 A4: когда _get_ekb_median сигнализирует, что часть строк
|
||||
city-wide бенчмарка (COALESCE(deals_total_vol_m2, count*45.0)) фабриковалась,
|
||||
эта доля должна долететь до VelocityResult.ekb_median_proxy_fraction —
|
||||
а не проглатываться молча (проверяем и dataclass-поле, и as_dict()).
|
||||
"""
|
||||
n = 6
|
||||
comp_rows = [_comp_row(i) for i in range(1, n + 1)]
|
||||
sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)]
|
||||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
||||
|
||||
# 40% строк city-wide медианы — proxy (count*45.0), не реальный deals_total_vol_m2.
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.4),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
assert result is not None
|
||||
assert result.velocity_source == "objective"
|
||||
assert result.ekb_median_proxy_fraction == pytest.approx(0.4)
|
||||
assert result.as_dict()["ekb_median_proxy_fraction"] == pytest.approx(0.4)
|
||||
|
||||
|
||||
def test_ekb_median_proxy_fraction_surfaced_on_rosreestr_fallback():
|
||||
"""Audit #2445 A4: proxy_fraction бенчмарка пробрасывается и через
|
||||
rosreestr_fallback путь (ekb_median посчитан ДО вызова fallback, contamination
|
||||
в denominator остаётся релевантной вне зависимости от источника velocity)."""
|
||||
comp_rows = [_comp_row(i) for i in range(1, 3)]
|
||||
# Ниже _OBJECTIVE_COVERAGE_MIN_RATIO -> forces rosreestr_fallback path.
|
||||
sales_rows = [_sales_row(1, total_sqm=0.0, months=0, has_mapping=False)]
|
||||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
||||
|
||||
rr_row = MagicMock()
|
||||
start_d = datetime.date.fromisoformat("2025-01-01")
|
||||
end_d = datetime.date.fromisoformat("2025-06-01")
|
||||
rr_row.__getitem__ = lambda self, k: {
|
||||
"total_deals": 60,
|
||||
"period_start": start_d,
|
||||
"period_end": end_d,
|
||||
}[k]
|
||||
rr_result_mock = MagicMock()
|
||||
rr_result_mock.mappings.return_value.first.return_value = rr_row
|
||||
|
||||
# db.execute called: comp / sales / (rosreestr) — patch execute to append rr result.
|
||||
original_side_effect = list(db.execute.side_effect)
|
||||
db.execute.side_effect = [*original_side_effect, rr_result_mock]
|
||||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.6),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT, cad_quarter="66:41:0702048")
|
||||
|
||||
assert result is not None
|
||||
assert result.velocity_source == "rosreestr_fallback"
|
||||
assert result.ekb_median_proxy_fraction == pytest.approx(0.6)
|
||||
assert result.as_dict()["ekb_median_proxy_fraction"] == pytest.approx(0.6)
|
||||
|
||||
|
||||
def test_objective_path_surfaces_coverage_no_proxy():
|
||||
"""SF#1871 P2 Fix2: objective-путь → proxy_used=False, coverage% посчитан, proxy_sqm=None."""
|
||||
n = 6
|
||||
|
|
@ -385,7 +451,7 @@ def test_objective_path_surfaces_coverage_no_proxy():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
@ -449,7 +515,7 @@ def test_objective_path_sets_project_absorption_per_competitor():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
@ -469,7 +535,7 @@ def test_objective_path_project_absorption_none_when_no_sales():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
@ -525,7 +591,7 @@ def test_sample_competitors_include_by_room_bucket():
|
|||
|
||||
with patch(
|
||||
"app.services.site_finder.velocity._get_ekb_median",
|
||||
return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
|
||||
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
|
||||
):
|
||||
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue