Раздел 5: База данных
Проект: ÐŸÑ€ÐµÐ´Ð¸ÐºÑ‚Ð¸Ð²Ð½Ð°Ñ Ð°Ð½Ð°Ð»Ð¸Ñ‚Ð¸ÐºÐ° товарных ниш
Модуль: Scout / Database
ВерÑиÑ: 1.0
Дата: Январь 2026
5.1 Обзор Ñхемы данных
ER-диаграмма
СпиÑок таблиц
| Таблица | Ðазначение | Примерный объём |
|---|---|---|
scout_analyses | ИÑÑ‚Ð¾Ñ€Ð¸Ñ Ð°Ð½Ð°Ð»Ð¸Ð·Ð¾Ð² | ~1-2 KB/запиÑÑŒ |
scout_marketplace_rates | Ставки раÑходов МП | ~10 запиÑей |
scout_trend_cache | КÑш трендов (долгоÑрочный) | ~500 B/запиÑÑŒ |
scout_settings | ÐаÑтройки Ð¼Ð¾Ð´ÑƒÐ»Ñ | ~20 запиÑей |
scout_export_jobs | Задачи ÑкÑпорта | ~200 B/запиÑÑŒ |
5.2 Таблица scout_analyses
5.2.1 Ðазначение
Хранение полной иÑтории анализов ниш Ñ Ñ€ÐµÐ·ÑƒÐ»ÑŒÑ‚Ð°Ñ‚Ð°Ð¼Ð¸, метриками и рекомендациÑми.
5.2.2 DDL
CREATE TABLE scout_analyses (
-- ИдентификациÑ
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Входные данные
query VARCHAR(500) NOT NULL,
marketplaces VARCHAR(50)[] NOT NULL DEFAULT ARRAY['wildberries', 'ozon', 'yandex_market'],
cogs DECIMAL(12, 2) NOT NULL,
cogs_min DECIMAL(12, 2),
cogs_max DECIMAL(12, 2),
-- Вердикт
verdict VARCHAR(20) NOT NULL CHECK (verdict IN ('GO', 'CONSIDER', 'RISKY')),
color VARCHAR(10) NOT NULL CHECK (color IN ('green', 'yellow', 'red')),
confidence DECIMAL(4, 3) NOT NULL DEFAULT 0.5 CHECK (confidence BETWEEN 0 AND 1),
-- Метрики (агрегированные)
metrics JSONB NOT NULL DEFAULT '{}',
/*
{
"trend_slope": 0.08,
"trend_status": "yellow",
"monopoly_rate": 0.52,
"monopoly_status": "yellow",
"expected_margin": 18.5,
"margin_status": "yellow"
}
*/
-- Детальные результаты
trend_result JSONB NOT NULL DEFAULT '{}',
/*
{
"trend_slope": 0.08,
"confidence": 0.85,
"total_volume": 125000,
"monthly_data": [...],
"seasonality_detected": true,
"related_queries": [...]
}
*/
competitor_results JSONB NOT NULL DEFAULT '{}',
/*
{
"wildberries": {
"monopoly_rate": 0.48,
"top_sellers": [...],
"price_analysis": {...},
"entry_barrier_score": 0.45
},
"ozon": {...}
}
*/
unit_economics JSONB NOT NULL DEFAULT '{}',
/*
{
"wildberries": {
"selling_price": 2450,
"cogs": 500,
"net_profit": 362,
"net_margin_pct": 14.8,
...
},
"ozon": {...}
}
*/
-- AI-анализ
summary TEXT,
detailed_analysis JSONB DEFAULT '{}',
/*
{
"trend_assessment": "...",
"competition_assessment": "...",
"economics_assessment": "..."
}
*/
recommendations JSONB DEFAULT '[]',
/* ["Ð ÐµÐºÐ¾Ð¼ÐµÐ½Ð´Ð°Ñ†Ð¸Ñ 1", "Ð ÐµÐºÐ¾Ð¼ÐµÐ½Ð´Ð°Ñ†Ð¸Ñ 2", ...] */
risks JSONB DEFAULT '[]',
/*
[
{
"risk": "Ð’Ñ‹ÑÐ¾ÐºÐ°Ñ ÐºÐ¾Ð½ÐºÑƒÑ€ÐµÐ½Ñ†Ð¸Ñ",
"probability": "high",
"mitigation": "..."
}
]
*/
opportunities JSONB DEFAULT '[]',
/* ["ВозможноÑть 1", "ВозможноÑть 2", ...] */
action_plan JSONB DEFAULT '{}',
/*
{
"if_go": ["Шаг 1", "Шаг 2"],
"if_not": ["Ðльтернатива 1"]
}
*/
price_recommendations JSONB DEFAULT '{}',
/*
{
"optimal_price": 2800,
"min_viable_price": 2200,
"premium_price": 3500,
"reasoning": "..."
}
*/
-- Метаданные
data_sources VARCHAR(50)[] NOT NULL DEFAULT '{}',
processing_time_ms INT NOT NULL DEFAULT 0,
user_id INT NOT NULL REFERENCES users(id),
analyzed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Комментарии
COMMENT ON TABLE scout_analyses IS 'ИÑÑ‚Ð¾Ñ€Ð¸Ñ Ð°Ð½Ð°Ð»Ð¸Ð·Ð¾Ð² товарных ниш';
COMMENT ON COLUMN scout_analyses.verdict IS 'Итоговый вердикт: GO, CONSIDER, RISKY';
COMMENT ON COLUMN scout_analyses.confidence IS 'УверенноÑть в вердикте от 0 до 1';
COMMENT ON COLUMN scout_analyses.metrics IS 'Ðгрегированные метрики Ð´Ð»Ñ Ð±Ñ‹Ñтрого доÑтупа';
COMMENT ON COLUMN scout_analyses.trend_result IS 'Полные данные анализа трендов';
COMMENT ON COLUMN scout_analyses.competitor_results IS 'Данные конкурентного анализа по маркетплейÑам';
COMMENT ON COLUMN scout_analyses.unit_economics IS 'РаÑчёты unit-Ñкономики по маркетплейÑам';5.2.3 ИндекÑÑ‹
-- ПоиÑк по пользователю и дате
CREATE INDEX idx_scout_analyses_user_date
ON scout_analyses(user_id, analyzed_at DESC);
-- ПоиÑк по запроÑу (полнотекÑтовый)
CREATE INDEX idx_scout_analyses_query_gin
ON scout_analyses USING GIN (to_tsvector('russian', query));
-- Фильтр по вердикту
CREATE INDEX idx_scout_analyses_verdict
ON scout_analyses(verdict);
-- ПоиÑк по метрикам (JSONB)
CREATE INDEX idx_scout_analyses_metrics_gin
ON scout_analyses USING GIN (metrics jsonb_path_ops);
-- Фильтр по маркетплейÑам
CREATE INDEX idx_scout_analyses_marketplaces_gin
ON scout_analyses USING GIN (marketplaces);
-- ПоиÑк по дате Ð´Ð»Ñ Ð°Ñ€Ñ…Ð¸Ð²Ð°Ñ†Ð¸Ð¸
CREATE INDEX idx_scout_analyses_analyzed_at
ON scout_analyses(analyzed_at);5.2.4 Примеры запроÑов
-- Получение иÑтории Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ Ñ Ð¿Ð°Ð³Ð¸Ð½Ð°Ñ†Ð¸ÐµÐ¹
SELECT
id,
query,
marketplaces,
verdict,
metrics->>'expected_margin' as margin,
analyzed_at
FROM scout_analyses
WHERE user_id = $1
ORDER BY analyzed_at DESC
LIMIT $2 OFFSET $3;
-- ПоиÑк по запроÑу
SELECT *
FROM scout_analyses
WHERE user_id = $1
AND to_tsvector('russian', query) @@ plainto_tsquery('russian', $2)
ORDER BY analyzed_at DESC
LIMIT 20;
-- Фильтр по вердикту и марже
SELECT *
FROM scout_analyses
WHERE user_id = $1
AND verdict = 'GO'
AND (metrics->>'expected_margin')::decimal > 25
ORDER BY analyzed_at DESC;
-- СтатиÑтика по вердиктам
SELECT
verdict,
COUNT(*) as count,
AVG((metrics->>'expected_margin')::decimal) as avg_margin
FROM scout_analyses
WHERE user_id = $1
AND analyzed_at > NOW() - INTERVAL '30 days'
GROUP BY verdict;
-- ИÑÑ‚Ð¾Ñ€Ð¸Ñ Ð°Ð½Ð°Ð»Ð¸Ð·Ð¾Ð² одной ниши (Ð´Ð»Ñ Ð¾Ñ‚ÑÐ»ÐµÐ¶Ð¸Ð²Ð°Ð½Ð¸Ñ Ð´Ð¸Ð½Ð°Ð¼Ð¸ÐºÐ¸)
SELECT
id,
analyzed_at,
verdict,
metrics->>'trend_slope' as trend,
metrics->>'monopoly_rate' as monopoly,
metrics->>'expected_margin' as margin
FROM scout_analyses
WHERE user_id = $1
AND query ILIKE '%' || $2 || '%'
ORDER BY analyzed_at DESC
LIMIT 10;5.3 Таблица scout_marketplace_rates
5.3.1 Ðазначение
Хранение наÑтраиваемых Ñтавок раÑходов маркетплейÑов Ð´Ð»Ñ Ñ€Ð°Ñчёта unit-Ñкономики.
5.3.2 DDL
CREATE TABLE scout_marketplace_rates (
id SERIAL PRIMARY KEY,
-- ИдентификациÑ
marketplace VARCHAR(50) NOT NULL CHECK (marketplace IN ('wildberries', 'ozon', 'yandex_market')),
category VARCHAR(100) NOT NULL DEFAULT 'default',
-- Ставки (в процентах)
commission_pct DECIMAL(5, 2) NOT NULL DEFAULT 15.0 CHECK (commission_pct BETWEEN 0 AND 100),
logistics_pct DECIMAL(5, 2) NOT NULL DEFAULT 5.0 CHECK (logistics_pct BETWEEN 0 AND 100),
return_logistics_pct DECIMAL(5, 2) NOT NULL DEFAULT 3.0 CHECK (return_logistics_pct BETWEEN 0 AND 100),
storage_pct DECIMAL(5, 2) NOT NULL DEFAULT 1.0 CHECK (storage_pct BETWEEN 0 AND 100),
acquiring_pct DECIMAL(5, 2) NOT NULL DEFAULT 0.0 CHECK (acquiring_pct BETWEEN 0 AND 100),
-- Метаданные
updated_by INT REFERENCES users(id),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- УникальноÑть
CONSTRAINT uq_scout_rates_mp_category UNIQUE (marketplace, category)
);
-- Комментарии
COMMENT ON TABLE scout_marketplace_rates IS 'Ставки раÑходов маркетплейÑов Ð´Ð»Ñ unit-Ñкономики';
COMMENT ON COLUMN scout_marketplace_rates.category IS 'ÐšÐ°Ñ‚ÐµÐ³Ð¾Ñ€Ð¸Ñ Ñ‚Ð¾Ð²Ð°Ñ€Ð¾Ð² (default Ð´Ð»Ñ Ð¾Ð±Ñ‰Ð¸Ñ… Ñтавок)';
COMMENT ON COLUMN scout_marketplace_rates.commission_pct IS 'КомиÑÑÐ¸Ñ Ð¼Ð°Ñ€ÐºÐµÑ‚Ð¿Ð»ÐµÐ¹Ñа (%)';
COMMENT ON COLUMN scout_marketplace_rates.logistics_pct IS 'ЛогиÑтика до Ð¿Ð¾ÐºÑƒÐ¿Ð°Ñ‚ÐµÐ»Ñ (%)';
COMMENT ON COLUMN scout_marketplace_rates.return_logistics_pct IS 'ÐžÐ±Ñ€Ð°Ñ‚Ð½Ð°Ñ Ð»Ð¾Ð³Ð¸Ñтика (%)';
COMMENT ON COLUMN scout_marketplace_rates.storage_pct IS 'Хранение на Ñкладе (%)';
COMMENT ON COLUMN scout_marketplace_rates.acquiring_pct IS 'Ðквайринг (%)';
-- Триггер Ð¾Ð±Ð½Ð¾Ð²Ð»ÐµÐ½Ð¸Ñ updated_at
CREATE OR REPLACE FUNCTION update_scout_rates_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_scout_rates_updated
BEFORE UPDATE ON scout_marketplace_rates
FOR EACH ROW
EXECUTE FUNCTION update_scout_rates_timestamp();5.3.3 Ðачальные данные
-- Ставки по умолчанию Ð´Ð»Ñ ÐºÐ°Ñ‚ÐµÐ³Ð¾Ñ€Ð¸Ð¸ "Одежда"
INSERT INTO scout_marketplace_rates
(marketplace, category, commission_pct, logistics_pct, return_logistics_pct, storage_pct, acquiring_pct)
VALUES
('wildberries', 'default', 15.0, 5.0, 3.0, 1.0, 0.0),
('ozon', 'default', 18.0, 6.0, 4.0, 1.5, 0.0),
('yandex_market', 'default', 15.0, 7.0, 4.0, 1.0, 1.5);5.3.4 Примеры запроÑов
-- Получение Ñтавок Ð´Ð»Ñ Ð¼Ð°Ñ€ÐºÐµÑ‚Ð¿Ð»ÐµÐ¹Ñа
SELECT
marketplace,
category,
commission_pct,
logistics_pct,
return_logistics_pct,
storage_pct,
acquiring_pct,
(commission_pct + logistics_pct + return_logistics_pct + storage_pct + acquiring_pct) as total_overhead_pct
FROM scout_marketplace_rates
WHERE marketplace = $1
AND (category = $2 OR category = 'default')
ORDER BY CASE WHEN category = $2 THEN 0 ELSE 1 END
LIMIT 1;
-- Получение вÑех Ñтавок
SELECT
marketplace,
category,
commission_pct,
logistics_pct,
return_logistics_pct,
storage_pct,
acquiring_pct,
(commission_pct + logistics_pct + return_logistics_pct + storage_pct + acquiring_pct) as total_overhead_pct,
updated_at,
updated_by
FROM scout_marketplace_rates
WHERE category = 'default'
ORDER BY marketplace;
-- Обновление Ñтавок
UPDATE scout_marketplace_rates
SET
commission_pct = COALESCE($3, commission_pct),
logistics_pct = COALESCE($4, logistics_pct),
return_logistics_pct = COALESCE($5, return_logistics_pct),
storage_pct = COALESCE($6, storage_pct),
acquiring_pct = COALESCE($7, acquiring_pct),
updated_by = $8
WHERE marketplace = $1 AND category = $2;5.4 Таблица scout_trend_cache
5.4.1 Ðазначение
ДолгоÑрочное кÑширование данных о трендах Ð´Ð»Ñ ÑƒÑÐºÐ¾Ñ€ÐµÐ½Ð¸Ñ Ð¿Ð¾Ð²Ñ‚Ð¾Ñ€Ð½Ñ‹Ñ… анализов и периодичеÑкого обновлениÑ.
5.4.2 DDL
CREATE TABLE scout_trend_cache (
id SERIAL PRIMARY KEY,
-- ИдентификациÑ
query_hash VARCHAR(32) NOT NULL UNIQUE,
query VARCHAR(500) NOT NULL,
-- Данные трендов
trend_data JSONB NOT NULL DEFAULT '{}',
/*
{
"wordstat": {...},
"ozon_analytics": {...},
"wb_analytics": {...},
"external": {...}
}
*/
-- Ðгрегированные метрики (Ð´Ð»Ñ Ð±Ñ‹Ñтрого доÑтупа)
trend_slope DECIMAL(6, 4),
trend_status VARCHAR(10) CHECK (trend_status IN ('green', 'yellow', 'red')),
total_volume INT,
confidence DECIMAL(4, 3),
-- ИÑточники
sources_used VARCHAR(50)[] NOT NULL DEFAULT '{}',
sources_failed VARCHAR(50)[] DEFAULT '{}',
-- Метаданные
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Комментарии
COMMENT ON TABLE scout_trend_cache IS 'КÑш данных о трендах ÑпроÑа';
COMMENT ON COLUMN scout_trend_cache.query_hash IS 'MD5-Ñ…Ñш нормализованного запроÑа';
COMMENT ON COLUMN scout_trend_cache.trend_data IS 'Сырые данные из иÑточников';
COMMENT ON COLUMN scout_trend_cache.expires_at IS 'Ð’Ñ€ÐµÐ¼Ñ Ð¸ÑÑ‚ÐµÑ‡ÐµÐ½Ð¸Ñ ÐºÑша';
-- Триггер Ð¾Ð±Ð½Ð¾Ð²Ð»ÐµÐ½Ð¸Ñ updated_at
CREATE TRIGGER trg_scout_trend_cache_updated
BEFORE UPDATE ON scout_trend_cache
FOR EACH ROW
EXECUTE FUNCTION update_scout_rates_timestamp();5.4.3 ИндекÑÑ‹
-- ПоиÑк по Ñ…Ñшу запроÑа
CREATE INDEX idx_scout_trend_cache_hash
ON scout_trend_cache(query_hash);
-- ПоиÑк невалидного кÑша Ð´Ð»Ñ Ð¾Ð±Ð½Ð¾Ð²Ð»ÐµÐ½Ð¸Ñ
CREATE INDEX idx_scout_trend_cache_expires
ON scout_trend_cache(expires_at)
WHERE expires_at < NOW();
-- ПолнотекÑтовый поиÑк по запроÑу
CREATE INDEX idx_scout_trend_cache_query_gin
ON scout_trend_cache USING GIN (to_tsvector('russian', query));5.4.4 Примеры запроÑов
-- Получение из кÑша
SELECT
trend_data,
trend_slope,
trend_status,
total_volume,
confidence,
sources_used
FROM scout_trend_cache
WHERE query_hash = $1
AND expires_at > NOW();
-- Сохранение/обновление кÑша
INSERT INTO scout_trend_cache
(query_hash, query, trend_data, trend_slope, trend_status, total_volume, confidence, sources_used, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW() + INTERVAL '24 hours')
ON CONFLICT (query_hash)
DO UPDATE SET
trend_data = EXCLUDED.trend_data,
trend_slope = EXCLUDED.trend_slope,
trend_status = EXCLUDED.trend_status,
total_volume = EXCLUDED.total_volume,
confidence = EXCLUDED.confidence,
sources_used = EXCLUDED.sources_used,
expires_at = EXCLUDED.expires_at,
updated_at = NOW();
-- ОчиÑтка уÑтаревшего кÑша
DELETE FROM scout_trend_cache
WHERE expires_at < NOW() - INTERVAL '7 days';
-- Получение кÑша Ð´Ð»Ñ Ð¾Ð±Ð½Ð¾Ð²Ð»ÐµÐ½Ð¸Ñ (Celery task)
SELECT query_hash, query
FROM scout_trend_cache
WHERE expires_at < NOW()
ORDER BY expires_at
LIMIT 100;5.5 Таблица scout_settings
5.5.1 Ðазначение
Хранение наÑтроек модулÑ: пороги Ñветофора, API-ключи внешних ÑервиÑов, параметры кÑшированиÑ.
5.5.2 DDL
CREATE TABLE scout_settings (
id SERIAL PRIMARY KEY,
-- ИдентификациÑ
key VARCHAR(100) NOT NULL UNIQUE,
-- Значение
value JSONB NOT NULL,
-- Метаданные
description TEXT,
is_secret BOOLEAN NOT NULL DEFAULT FALSE,
updated_by INT REFERENCES users(id),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Комментарии
COMMENT ON TABLE scout_settings IS 'ÐаÑтройки Ð¼Ð¾Ð´ÑƒÐ»Ñ Scout';
COMMENT ON COLUMN scout_settings.key IS 'Ключ наÑтройки (уникальный)';
COMMENT ON COLUMN scout_settings.value IS 'Значение в формате JSON';
COMMENT ON COLUMN scout_settings.is_secret IS 'Флаг Ñекретного Ð·Ð½Ð°Ñ‡ÐµÐ½Ð¸Ñ (не показывать в UI)';
-- Триггер обновлениÑ
CREATE TRIGGER trg_scout_settings_updated
BEFORE UPDATE ON scout_settings
FOR EACH ROW
EXECUTE FUNCTION update_scout_rates_timestamp();5.5.3 Ðачальные данные
-- Пороги Ñветофора
INSERT INTO scout_settings (key, value, description) VALUES
('thresholds.trend_slope',
'{"green": 0.15, "yellow": 0, "red": -999}',
'Пороги Trend Slope: green > 0.15, yellow > 0, red < 0'),
('thresholds.monopoly_rate',
'{"green": 0.5, "yellow": 0.7, "red": 1.0}',
'Пороги Monopoly Rate: green < 50%, yellow < 70%, red >= 70%'),
('thresholds.expected_margin',
'{"green": 25, "yellow": 15, "red": 0}',
'Пороги Expected Margin: green > 25%, yellow > 15%, red <= 15%');
-- ÐаÑтройки кÑшированиÑ
INSERT INTO scout_settings (key, value, description) VALUES
('cache.trend_ttl_hours', '24', 'TTL кÑша трендов в чаÑах'),
('cache.category_ttl_hours', '12', 'TTL кÑша категорий в чаÑах'),
('cache.rates_ttl_minutes', '60', 'TTL кÑша Ñтавок в минутах');
-- API-ключи (Ñекретные)
INSERT INTO scout_settings (key, value, description, is_secret) VALUES
('api.wordstat_token', '"__ENCRYPTED__"', 'OAuth-токен ЯндекÑ.Директ', TRUE),
('api.ozon_client_id', '"__ENCRYPTED__"', 'Client ID Ozon Seller API', TRUE),
('api.ozon_api_key', '"__ENCRYPTED__"', 'API Key Ozon Seller API', TRUE),
('api.similarweb_key', '"__ENCRYPTED__"', 'API Key SimilarWeb', TRUE),
('api.serpstat_key', '"__ENCRYPTED__"', 'API Key Serpstat', TRUE);
-- Параметры анализа
INSERT INTO scout_settings (key, value, description) VALUES
('analysis.default_period_months', '3', 'Период анализа трендов по умолчанию'),
('analysis.top_products_limit', '50', 'КоличеÑтво товаров Ð´Ð»Ñ Ð°Ð½Ð°Ð»Ð¸Ð·Ð° конкурентов'),
('analysis.max_processing_time_sec', '120', 'МакÑимальное Ð²Ñ€ÐµÐ¼Ñ Ð°Ð½Ð°Ð»Ð¸Ð·Ð°');
-- Параметры ÑкÑпорта
INSERT INTO scout_settings (key, value, description) VALUES
('export.url_expiry_hours', '24', 'Ð’Ñ€ÐµÐ¼Ñ Ð¶Ð¸Ð·Ð½Ð¸ ÑÑылки на Ñкачивание'),
('export.max_file_size_mb', '10', 'МакÑимальный размер файла ÑкÑпорта');5.5.4 Примеры запроÑов
-- Получение наÑтройки
SELECT value
FROM scout_settings
WHERE key = $1;
-- Получение вÑех порогов
SELECT key, value
FROM scout_settings
WHERE key LIKE 'thresholds.%';
-- Обновление наÑтройки (Admin)
UPDATE scout_settings
SET value = $2, updated_by = $3
WHERE key = $1;
-- Получение неÑекретных наÑтроек Ð´Ð»Ñ UI
SELECT key, value, description
FROM scout_settings
WHERE is_secret = FALSE
ORDER BY key;5.6 Таблица scout_export_jobs
5.6.1 Ðазначение
ОтÑлеживание задач ÑкÑпорта отчётов и хранение ÑÑылок Ð´Ð»Ñ ÑкачиваниÑ.
5.6.2 DDL
CREATE TABLE scout_export_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- СвÑзь Ñ Ð°Ð½Ð°Ð»Ð¸Ð·Ð¾Ð¼
analysis_id UUID NOT NULL REFERENCES scout_analyses(id) ON DELETE CASCADE,
-- Параметры ÑкÑпорта
format VARCHAR(10) NOT NULL CHECK (format IN ('pdf', 'xlsx', 'json')),
-- СтатуÑ
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
error_message TEXT,
-- Результат
file_path VARCHAR(500),
download_url VARCHAR(500),
file_size_bytes INT,
-- Метаданные
user_id INT NOT NULL REFERENCES users(id),
expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE
);
-- Комментарии
COMMENT ON TABLE scout_export_jobs IS 'Задачи ÑкÑпорта отчётов';
COMMENT ON COLUMN scout_export_jobs.status IS 'СтатуÑ: pending, processing, completed, failed';
COMMENT ON COLUMN scout_export_jobs.expires_at IS 'Ð’Ñ€ÐµÐ¼Ñ Ð¸ÑÑ‚ÐµÑ‡ÐµÐ½Ð¸Ñ ÑÑылки на Ñкачивание';5.6.3 ИндекÑÑ‹
-- ПоиÑк по пользователю
CREATE INDEX idx_scout_export_jobs_user
ON scout_export_jobs(user_id, created_at DESC);
-- ПоиÑк по анализу
CREATE INDEX idx_scout_export_jobs_analysis
ON scout_export_jobs(analysis_id);
-- ПоиÑк pending задач Ð´Ð»Ñ Celery
CREATE INDEX idx_scout_export_jobs_pending
ON scout_export_jobs(status, created_at)
WHERE status = 'pending';
-- ОчиÑтка иÑтёкших файлов
CREATE INDEX idx_scout_export_jobs_expired
ON scout_export_jobs(expires_at)
WHERE expires_at IS NOT NULL AND status = 'completed';5.6.4 Примеры запроÑов
-- Создание задачи ÑкÑпорта
INSERT INTO scout_export_jobs (analysis_id, format, user_id)
VALUES ($1, $2, $3)
RETURNING id;
-- Обновление ÑтатуÑа (Celery worker)
UPDATE scout_export_jobs
SET
status = 'completed',
file_path = $2,
download_url = $3,
file_size_bytes = $4,
expires_at = NOW() + INTERVAL '24 hours',
completed_at = NOW()
WHERE id = $1;
-- Получение ÑтатуÑа ÑкÑпорта
SELECT status, download_url, file_size_bytes, expires_at, error_message
FROM scout_export_jobs
WHERE id = $1 AND user_id = $2;
-- Получение поÑледнего ÑкÑпорта анализа
SELECT id, format, status, download_url, expires_at
FROM scout_export_jobs
WHERE analysis_id = $1 AND status = 'completed'
ORDER BY created_at DESC
LIMIT 1;
-- ОчиÑтка иÑтёкших ÑкÑпортов
DELETE FROM scout_export_jobs
WHERE expires_at < NOW() - INTERVAL '1 day';5.7 Ð’Ñпомогательные функции
5.7.1 Ð¤ÑƒÐ½ÐºÑ†Ð¸Ñ Ñ€Ð°Ñчёта Ñ…Ñша запроÑа
CREATE OR REPLACE FUNCTION scout_query_hash(query TEXT)
RETURNS VARCHAR(32)
LANGUAGE plpgsql
IMMUTABLE
AS $$
BEGIN
-- ÐормализациÑ: lowercase, trim, remove extra spaces
RETURN MD5(LOWER(TRIM(REGEXP_REPLACE(query, '\s+', ' ', 'g'))));
END;
$$;
COMMENT ON FUNCTION scout_query_hash IS 'ВычиÑление MD5-Ñ…Ñша нормализованного запроÑа';5.7.2 Ð¤ÑƒÐ½ÐºÑ†Ð¸Ñ Ð¿Ð¾Ð»ÑƒÑ‡ÐµÐ½Ð¸Ñ Ñтавок Ñ fallback
CREATE OR REPLACE FUNCTION scout_get_rates(
p_marketplace VARCHAR(50),
p_category VARCHAR(100) DEFAULT 'default'
)
RETURNS TABLE (
commission_pct DECIMAL(5,2),
logistics_pct DECIMAL(5,2),
return_logistics_pct DECIMAL(5,2),
storage_pct DECIMAL(5,2),
acquiring_pct DECIMAL(5,2),
total_overhead_pct DECIMAL(5,2)
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
r.commission_pct,
r.logistics_pct,
r.return_logistics_pct,
r.storage_pct,
r.acquiring_pct,
(r.commission_pct + r.logistics_pct + r.return_logistics_pct +
r.storage_pct + r.acquiring_pct) as total_overhead_pct
FROM scout_marketplace_rates r
WHERE r.marketplace = p_marketplace
AND (r.category = p_category OR r.category = 'default')
ORDER BY CASE WHEN r.category = p_category THEN 0 ELSE 1 END
LIMIT 1;
END;
$$;
COMMENT ON FUNCTION scout_get_rates IS 'Получение Ñтавок МП Ñ fallback на default';5.7.3 Ð¤ÑƒÐ½ÐºÑ†Ð¸Ñ Ð¾Ð¿Ñ€ÐµÐ´ÐµÐ»ÐµÐ½Ð¸Ñ Ð²ÐµÑ€Ð´Ð¸ÐºÑ‚Ð°
CREATE OR REPLACE FUNCTION scout_determine_verdict(
p_trend_status VARCHAR(10),
p_monopoly_status VARCHAR(10),
p_margin_status VARCHAR(10)
)
RETURNS TABLE (
verdict VARCHAR(20),
color VARCHAR(10)
)
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
v_green_count INT := 0;
v_yellow_count INT := 0;
v_red_count INT := 0;
BEGIN
-- ПодÑчёт ÑтатуÑов
IF p_trend_status = 'green' THEN v_green_count := v_green_count + 1;
ELSIF p_trend_status = 'yellow' THEN v_yellow_count := v_yellow_count + 1;
ELSE v_red_count := v_red_count + 1;
END IF;
IF p_monopoly_status = 'green' THEN v_green_count := v_green_count + 1;
ELSIF p_monopoly_status = 'yellow' THEN v_yellow_count := v_yellow_count + 1;
ELSE v_red_count := v_red_count + 1;
END IF;
IF p_margin_status = 'green' THEN v_green_count := v_green_count + 1;
ELSIF p_margin_status = 'yellow' THEN v_yellow_count := v_yellow_count + 1;
ELSE v_red_count := v_red_count + 1;
END IF;
-- Определение вердикта
IF v_red_count > 0 OR v_yellow_count = 3 THEN
verdict := 'RISKY';
color := 'red';
ELSIF v_green_count = 3 OR (v_green_count = 2 AND v_yellow_count = 1) THEN
verdict := 'GO';
color := 'green';
ELSE
verdict := 'CONSIDER';
color := 'yellow';
END IF;
RETURN NEXT;
END;
$$;
COMMENT ON FUNCTION scout_determine_verdict IS 'Определение вердикта на оÑнове ÑтатуÑов метрик';5.7.4 Ð¤ÑƒÐ½ÐºÑ†Ð¸Ñ ÑтатиÑтики по анализам
CREATE OR REPLACE FUNCTION scout_user_stats(
p_user_id INT,
p_days INT DEFAULT 30
)
RETURNS TABLE (
total_analyses INT,
go_count INT,
consider_count INT,
risky_count INT,
avg_margin DECIMAL(5,2),
avg_processing_time_ms INT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(*)::INT as total_analyses,
COUNT(*) FILTER (WHERE verdict = 'GO')::INT as go_count,
COUNT(*) FILTER (WHERE verdict = 'CONSIDER')::INT as consider_count,
COUNT(*) FILTER (WHERE verdict = 'RISKY')::INT as risky_count,
AVG((metrics->>'expected_margin')::DECIMAL)::DECIMAL(5,2) as avg_margin,
AVG(processing_time_ms)::INT as avg_processing_time_ms
FROM scout_analyses
WHERE user_id = p_user_id
AND analyzed_at > NOW() - (p_days || ' days')::INTERVAL;
END;
$$;
COMMENT ON FUNCTION scout_user_stats IS 'СтатиÑтика анализов Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ Ð·Ð° период';5.8 Миграции
5.8.1 ÐœÐ¸Ð³Ñ€Ð°Ñ†Ð¸Ñ ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ Ñхемы
-- migrations/001_create_scout_tables.sql
BEGIN;
-- Проверка ÑущеÑÑ‚Ð²Ð¾Ð²Ð°Ð½Ð¸Ñ Ñ‚Ð°Ð±Ð»Ð¸Ñ†
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_tables WHERE tablename = 'scout_analyses') THEN
RAISE EXCEPTION 'Scout tables already exist';
END IF;
END $$;
-- Создание таблиц (DDL из разделов выше)
-- scout_analyses
-- scout_marketplace_rates
-- scout_trend_cache
-- scout_settings
-- scout_export_jobs
-- Создание индекÑов
-- Создание функций
-- Ðачальные данные
INSERT INTO scout_marketplace_rates ...
INSERT INTO scout_settings ...
-- ВерÑÐ¸Ñ Ð¼Ð¸Ð³Ñ€Ð°Ñ†Ð¸Ð¸
INSERT INTO schema_migrations (version, description, applied_at)
VALUES ('001', 'Create Scout module tables', NOW());
COMMIT;5.8.2 Rollback миграциÑ
-- migrations/001_create_scout_tables_rollback.sql
BEGIN;
DROP TABLE IF EXISTS scout_export_jobs CASCADE;
DROP TABLE IF EXISTS scout_settings CASCADE;
DROP TABLE IF EXISTS scout_trend_cache CASCADE;
DROP TABLE IF EXISTS scout_marketplace_rates CASCADE;
DROP TABLE IF EXISTS scout_analyses CASCADE;
DROP FUNCTION IF EXISTS scout_query_hash CASCADE;
DROP FUNCTION IF EXISTS scout_get_rates CASCADE;
DROP FUNCTION IF EXISTS scout_determine_verdict CASCADE;
DROP FUNCTION IF EXISTS scout_user_stats CASCADE;
DELETE FROM schema_migrations WHERE version = '001';
COMMIT;5.9 Политики доÑтупа
5.9.1 Row Level Security
-- Включение RLS
ALTER TABLE scout_analyses ENABLE ROW LEVEL SECURITY;
ALTER TABLE scout_export_jobs ENABLE ROW LEVEL SECURITY;
-- Политика: пользователь видит только Ñвои анализы
CREATE POLICY scout_analyses_user_policy ON scout_analyses
FOR ALL
TO authenticated
USING (user_id = current_user_id());
-- Политика: Admin видит вÑе
CREATE POLICY scout_analyses_admin_policy ON scout_analyses
FOR ALL
TO admin
USING (TRUE);
-- Политика Ð´Ð»Ñ ÑкÑпортов
CREATE POLICY scout_export_jobs_user_policy ON scout_export_jobs
FOR ALL
TO authenticated
USING (user_id = current_user_id());5.9.2 Гранты
-- Роль scout_reader (Ð´Ð»Ñ API)
CREATE ROLE scout_reader;
GRANT SELECT ON scout_analyses TO scout_reader;
GRANT SELECT ON scout_marketplace_rates TO scout_reader;
GRANT SELECT ON scout_trend_cache TO scout_reader;
GRANT SELECT ON scout_settings TO scout_reader;
GRANT SELECT ON scout_export_jobs TO scout_reader;
-- Роль scout_writer (Ð´Ð»Ñ Celery)
CREATE ROLE scout_writer;
GRANT ALL ON scout_analyses TO scout_writer;
GRANT ALL ON scout_trend_cache TO scout_writer;
GRANT ALL ON scout_export_jobs TO scout_writer;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO scout_writer;
-- Роль scout_admin (Ð´Ð»Ñ Ð°Ð´Ð¼Ð¸Ð½Ð¸Ñтраторов)
CREATE ROLE scout_admin;
GRANT ALL ON ALL TABLES IN SCHEMA public TO scout_admin;
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO scout_admin;5.10 ОбÑлуживание
5.10.1 ÐÑ€Ñ…Ð¸Ð²Ð°Ñ†Ð¸Ñ Ñтарых анализов
-- ÐŸÐµÑ€ÐµÐ½Ð¾Ñ Ð°Ð½Ð°Ð»Ð¸Ð·Ð¾Ð² Ñтарше 12 меÑÑцев в архивную таблицу
CREATE TABLE scout_analyses_archive (LIKE scout_analyses INCLUDING ALL);
-- Процедура архивации
CREATE OR REPLACE PROCEDURE scout_archive_old_analyses()
LANGUAGE plpgsql
AS $$
DECLARE
v_count INT;
BEGIN
-- ÐŸÐµÑ€ÐµÐ½Ð¾Ñ Ð² архив
WITH moved AS (
DELETE FROM scout_analyses
WHERE analyzed_at < NOW() - INTERVAL '12 months'
RETURNING *
)
INSERT INTO scout_analyses_archive
SELECT * FROM moved;
GET DIAGNOSTICS v_count = ROW_COUNT;
RAISE NOTICE 'Archived % analyses', v_count;
END;
$$;5.10.2 ОчиÑтка кÑша
-- Процедура очиÑтки уÑтаревшего кÑша
CREATE OR REPLACE PROCEDURE scout_cleanup_cache()
LANGUAGE plpgsql
AS $$
DECLARE
v_trend_count INT;
v_export_count INT;
BEGIN
-- ОчиÑтка trend_cache
DELETE FROM scout_trend_cache
WHERE expires_at < NOW() - INTERVAL '7 days';
GET DIAGNOSTICS v_trend_count = ROW_COUNT;
-- ОчиÑтка export_jobs
DELETE FROM scout_export_jobs
WHERE expires_at < NOW() - INTERVAL '1 day';
GET DIAGNOSTICS v_export_count = ROW_COUNT;
RAISE NOTICE 'Cleaned: % trend cache, % export jobs', v_trend_count, v_export_count;
END;
$$;5.10.3 Мониторинг размера таблиц
-- Размер таблиц Ð¼Ð¾Ð´ÑƒÐ»Ñ Scout
SELECT
relname as table_name,
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
pg_size_pretty(pg_relation_size(relid)) as table_size,
pg_size_pretty(pg_indexes_size(relid)) as index_size,
n_live_tup as row_count
FROM pg_stat_user_tables
WHERE relname LIKE 'scout_%'
ORDER BY pg_total_relation_size(relid) DESC;Документ подготовлен: Январь 2026
ВерÑиÑ: 1.0
СтатуÑ: Черновик