- FastAPI backend: PostGIS estimator + 3 scrapers (Avito/Cian/Yandex)
- Next.js 15 frontend: tradein.html mockup design, basePath=/trade-in
- WeasyPrint PDF (Брусника-style 4-page report)
- Address autocomplete с typo-tolerance + 6 EKB presets
- Изолированный docker stack gendesign-tradein (отдельная postgres БД)
- Caddy inline routes: gendsgn.ru/trade-in/* и /trade-in/api/v1/*
- Forgejo Actions: .forgejo/workflows/deploy-tradein.yml (shell-based GHCR login)
- Триггер только по paths: tradein-mvp/** (не пересекается с deploy.yml)
- Образы: ghcr.io/lekss361/gendesign-tradein-{backend,frontend}:latest
Первый запуск на сервере (вручную, один раз):
- создать /opt/gendesign/tradein-mvp/.env.runtime (postgres pwd, contact email)
- docker network create gendesign_shared (если нет)
- docker compose -p gendesign-tradein up -d
- docker compose -p gendesign exec caddy caddy reload
147 lines
6.6 KiB
PL/PgSQL
147 lines
6.6 KiB
PL/PgSQL
-- Trade-In MVP — Seed данные для разработки (v2, фикс распределения).
|
||
-- 120 синтетических объявлений + 60 фактических сделок в центре ЕКБ.
|
||
-- Использовать пока парсеры не подключены к продакшну.
|
||
|
||
BEGIN;
|
||
|
||
TRUNCATE listings, deals RESTART IDENTITY CASCADE;
|
||
|
||
-- ── Helper: 10 улиц ЕКБ с координатами ─────────────────────────────────────
|
||
WITH streets(idx, name, lat, lon) AS (VALUES
|
||
(0, 'ул. Малышева', 56.8332::float, 60.5944::float),
|
||
(1, 'ул. Куйбышева', 56.8282, 60.6114),
|
||
(2, 'ул. 8 Марта', 56.8240, 60.6000),
|
||
(3, 'ул. Белинского', 56.8160, 60.6080),
|
||
(4, 'пр. Ленина', 56.8400, 60.6100),
|
||
(5, 'ул. Толмачёва', 56.8390, 60.6020),
|
||
(6, 'ул. Радищева', 56.8300, 60.5890),
|
||
(7, 'ул. Мамина-Сибиряка', 56.8420, 60.6190),
|
||
(8, 'ул. Луначарского', 56.8350, 60.6280),
|
||
(9, 'ул. Первомайская', 56.8440, 60.6240)
|
||
),
|
||
-- 5 типов квартир (rooms, базовая площадь, базовая цена в руб 2026)
|
||
flat_types(idx, rooms, base_area, base_price) AS (VALUES
|
||
(0, 0, 28.0::float, 6500000::bigint),
|
||
(1, 1, 40.0, 9000000),
|
||
(2, 2, 60.0, 12500000),
|
||
(3, 3, 80.0, 17000000),
|
||
(4, 4, 110.0, 22000000)
|
||
),
|
||
sources(idx, name) AS (VALUES
|
||
(0, 'avito'),
|
||
(1, 'cian'),
|
||
(2, 'domklik')
|
||
),
|
||
counts(rooms, n) AS (VALUES
|
||
(0, 12), -- студии
|
||
(1, 30), -- 1к
|
||
(2, 45), -- 2к
|
||
(3, 25), -- 3к
|
||
(4, 8) -- 4+к
|
||
)
|
||
INSERT INTO listings (
|
||
source, source_url, dedup_hash, address, lat, lon, region_code,
|
||
rooms, area_m2, floor, total_floors, year_built, house_type,
|
||
price_rub, price_per_m2, listing_date, days_on_market, scraped_at
|
||
)
|
||
SELECT
|
||
src.name AS source,
|
||
'https://' || src.name || '.example/lot/' || row_num AS source_url,
|
||
md5(src.name || row_num::text || street.name || ft.rooms::text) AS dedup_hash,
|
||
street.name || ', ' || (10 + (row_num * 7) % 150)::text AS address,
|
||
street.lat + ((random() - 0.5) * 0.012) AS lat, -- ≈±650м jitter
|
||
street.lon + ((random() - 0.5) * 0.012) AS lon,
|
||
66 AS region_code,
|
||
ft.rooms,
|
||
ft.base_area + (random() * 6 - 3) AS area_m2, -- ±3м² jitter
|
||
1 + (row_num * 3) % 18 AS floor,
|
||
9 + (row_num * 5) % 14 AS total_floors,
|
||
1960 + (row_num * 11) % 64 AS year_built,
|
||
(ARRAY['panel', 'brick', 'monolith', 'monolith_brick'])[1 + (row_num * 7) % 4] AS house_type,
|
||
(ft.base_price * (0.85 + random() * 0.30))::bigint AS price_rub,
|
||
((ft.base_price * (0.85 + random() * 0.30)) / (ft.base_area + (random() * 6 - 3)))::int AS price_per_m2,
|
||
CURRENT_DATE - ((row_num % 90)::text || ' days')::interval AS listing_date,
|
||
row_num % 90 AS days_on_market,
|
||
NOW() - ((row_num % 14)::text || ' days')::interval AS scraped_at
|
||
FROM (
|
||
-- Раскрываем counts в rows: для rooms=0 12 строк, для rooms=1 30 строк, ...
|
||
SELECT
|
||
c.rooms,
|
||
row_number() OVER (PARTITION BY c.rooms) AS rooms_row,
|
||
row_number() OVER () AS row_num
|
||
FROM counts c
|
||
CROSS JOIN LATERAL generate_series(1, c.n) gs
|
||
) AS expanded
|
||
JOIN flat_types ft ON ft.rooms = expanded.rooms
|
||
JOIN streets street ON street.idx = expanded.row_num % 10
|
||
JOIN sources src ON src.idx = expanded.row_num % 3;
|
||
|
||
|
||
-- ── DEALS (60 шт., цены на ~7% ниже листингов) ────────────────────────────
|
||
WITH streets(idx, name, lat, lon) AS (VALUES
|
||
(0, 'ул. Малышева', 56.8332::float, 60.5944::float),
|
||
(1, 'ул. Куйбышева', 56.8282, 60.6114),
|
||
(2, 'ул. 8 Марта', 56.8240, 60.6000),
|
||
(3, 'ул. Белинского', 56.8160, 60.6080),
|
||
(4, 'пр. Ленина', 56.8400, 60.6100),
|
||
(5, 'ул. Толмачёва', 56.8390, 60.6020),
|
||
(6, 'ул. Радищева', 56.8300, 60.5890),
|
||
(7, 'ул. Мамина-Сибиряка', 56.8420, 60.6190)
|
||
),
|
||
flat_types(idx, rooms, base_area, base_price) AS (VALUES
|
||
(1, 1, 40.0::float, 9000000::bigint),
|
||
(2, 2, 60.0, 12500000),
|
||
(3, 3, 80.0, 17000000)
|
||
),
|
||
deal_counts(rooms, n) AS (VALUES
|
||
(1, 18),
|
||
(2, 28),
|
||
(3, 14)
|
||
)
|
||
INSERT INTO deals (
|
||
source, dedup_hash, address, lat, lon, region_code,
|
||
rooms, area_m2, floor, total_floors, year_built, house_type,
|
||
deal_date, price_rub, price_per_m2, days_on_market, scraped_at
|
||
)
|
||
SELECT
|
||
'rosreestr' AS source,
|
||
md5('deal:' || row_num::text || street.name || ft.rooms::text) AS dedup_hash,
|
||
street.name || ', ' || (10 + (row_num * 5) % 140)::text AS address,
|
||
street.lat + ((random() - 0.5) * 0.012) AS lat,
|
||
street.lon + ((random() - 0.5) * 0.012) AS lon,
|
||
66,
|
||
ft.rooms,
|
||
ft.base_area + (random() * 6 - 3) AS area_m2,
|
||
1 + (row_num * 3) % 18 AS floor,
|
||
9 + (row_num * 5) % 14 AS total_floors,
|
||
1960 + (row_num * 11) % 64 AS year_built,
|
||
(ARRAY['panel', 'brick', 'monolith'])[1 + (row_num * 7) % 3] AS house_type,
|
||
CURRENT_DATE - ((row_num * 6)::text || ' days')::interval AS deal_date,
|
||
-- Сделки на 5-12% ниже листингов (вывод PDF Брусники)
|
||
(ft.base_price * 0.92 * (0.85 + random() * 0.20))::bigint AS price_rub,
|
||
((ft.base_price * 0.92 * (0.85 + random() * 0.20)) / (ft.base_area + (random() * 6 - 3)))::int AS price_per_m2,
|
||
20 + (row_num % 100) AS days_on_market,
|
||
NOW() - ((row_num * 6)::text || ' days')::interval AS scraped_at
|
||
FROM (
|
||
SELECT
|
||
c.rooms,
|
||
row_number() OVER () AS row_num
|
||
FROM deal_counts c
|
||
CROSS JOIN LATERAL generate_series(1, c.n) gs
|
||
) AS expanded
|
||
JOIN flat_types ft ON ft.rooms = expanded.rooms
|
||
JOIN streets street ON street.idx = expanded.row_num % 8;
|
||
|
||
|
||
-- ── Sanity ─────────────────────────────────────────────────────────────────
|
||
DO $$
|
||
DECLARE
|
||
n_listings int;
|
||
n_deals int;
|
||
BEGIN
|
||
SELECT COUNT(*) INTO n_listings FROM listings;
|
||
SELECT COUNT(*) INTO n_deals FROM deals;
|
||
RAISE NOTICE 'Seed: % listings, % deals', n_listings, n_deals;
|
||
END $$;
|
||
|
||
COMMIT;
|