This commit is contained in:
2026-02-23 16:40:06 +03:00
commit a51d12bc89
169 changed files with 7973 additions and 0 deletions

3
db/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
*
!*.sql
!Dockerfile

1
db/00_extensions.sql Normal file
View File

@@ -0,0 +1 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;

9
db/05_common.sql Normal file
View File

@@ -0,0 +1,9 @@
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;

7
db/10_categories.sql Normal file
View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS categories (
id UUID PRIMARY KEY,
parent_id UUID NULL REFERENCES categories(id) ON DELETE SET NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);

5
db/20_brands.sql Normal file
View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS brands (
id UUID PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE
);

26
db/30_products.sql Normal file
View File

@@ -0,0 +1,26 @@
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'product_unit') THEN
CREATE TYPE product_unit AS ENUM ('pcs', 'kg', 'm', 'm2', 'm3', 'bag', 'pack');
END IF;
END;
$$;
CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category_id UUID NOT NULL REFERENCES categories(id),
brand_id UUID NULL REFERENCES brands(id),
unit product_unit NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
DROP TRIGGER IF EXISTS trg_products_updated_at ON products;
CREATE TRIGGER trg_products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

6
db/40_product_images.sql Normal file
View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS product_images (
id UUID PRIMARY KEY,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
url TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 1
);

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS product_attributes (
id UUID PRIMARY KEY,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
unit TEXT NULL
);

5
db/60_stocks.sql Normal file
View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS stocks (
product_id UUID PRIMARY KEY REFERENCES products(id) ON DELETE CASCADE,
qty NUMERIC(12, 3) NOT NULL DEFAULT 0 CHECK (qty >= 0),
reserved_qty NUMERIC(12, 3) NOT NULL DEFAULT 0 CHECK (reserved_qty >= 0)
);

6
db/70_prices.sql Normal file
View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS prices (
product_id UUID PRIMARY KEY REFERENCES products(id) ON DELETE CASCADE,
currency CHAR(3) NOT NULL DEFAULT 'RUB',
price NUMERIC(12, 2) NOT NULL CHECK (price >= 0),
old_price NUMERIC(12, 2) NULL CHECK (old_price IS NULL OR old_price >= 0)
);

7
db/80_indexes.sql Normal file
View File

@@ -0,0 +1,7 @@
CREATE INDEX IF NOT EXISTS idx_products_name_trgm ON products USING GIN (name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_products_category_id ON products (category_id);
CREATE INDEX IF NOT EXISTS idx_products_brand_id ON products (brand_id);
CREATE INDEX IF NOT EXISTS idx_products_is_active ON products (is_active);
CREATE INDEX IF NOT EXISTS idx_product_attributes_product_key ON product_attributes (product_id, key);
CREATE INDEX IF NOT EXISTS idx_product_attributes_key_value ON product_attributes (key, value);

48
db/90_seed.sql Normal file
View File

@@ -0,0 +1,48 @@
INSERT INTO categories (id, parent_id, name, slug, is_active)
VALUES
('11111111-1111-1111-1111-111111111111', NULL, 'Сухие смеси', 'dry-mixes', TRUE),
('22222222-2222-2222-2222-222222222222', '11111111-1111-1111-1111-111111111111', 'Цемент', 'cement', TRUE),
('33333333-3333-3333-3333-333333333333', '11111111-1111-1111-1111-111111111111', 'Шпаклевки', 'putty', TRUE)
ON CONFLICT (id) DO NOTHING;
INSERT INTO brands (id, name, slug)
VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Holcim', 'holcim'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Knauf', 'knauf')
ON CONFLICT (id) DO NOTHING;
INSERT INTO products (
id, sku, name, description, category_id, brand_id, unit, is_active
)
VALUES (
'99999999-9999-9999-9999-999999999999',
'CEM-500-50',
'Цемент М500, 50 кг',
'Для кладочных и бетонных работ',
'22222222-2222-2222-2222-222222222222',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'bag',
TRUE
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO prices (product_id, currency, price, old_price)
VALUES ('99999999-9999-9999-9999-999999999999', 'RUB', 489.90, 529.90)
ON CONFLICT (product_id) DO NOTHING;
INSERT INTO stocks (product_id, qty, reserved_qty)
VALUES ('99999999-9999-9999-9999-999999999999', 120.000, 5.000)
ON CONFLICT (product_id) DO NOTHING;
INSERT INTO product_attributes (id, product_id, key, value, unit)
VALUES
('aaaa1111-1111-1111-1111-111111111111', '99999999-9999-9999-9999-999999999999', 'weight', '50', 'kg'),
('aaaa2222-2222-2222-2222-222222222222', '99999999-9999-9999-9999-999999999999', 'strength', 'M500', NULL),
('aaaa3333-3333-3333-3333-333333333333', '99999999-9999-9999-9999-999999999999', 'color', 'gray', NULL)
ON CONFLICT (id) DO NOTHING;
INSERT INTO product_images (id, product_id, url, sort_order)
VALUES
('bbbb1111-1111-1111-1111-111111111111', '99999999-9999-9999-9999-999999999999', 'https://images.unsplash.com/photo-1519751138087-5bf79df62d5b', 1),
('bbbb2222-2222-2222-2222-222222222222', '99999999-9999-9999-9999-999999999999', 'https://images.unsplash.com/photo-1581093458791-9d15482442f2', 2)
ON CONFLICT (id) DO NOTHING;

19
db/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1
FROM postgres:16
COPY 00_*.sql /docker-entrypoint-initdb.d/
COPY 05_*.sql /docker-entrypoint-initdb.d/
COPY 10_*.sql /docker-entrypoint-initdb.d/
COPY 20_*.sql /docker-entrypoint-initdb.d/
COPY 30_*.sql /docker-entrypoint-initdb.d/
COPY 40_*.sql /docker-entrypoint-initdb.d/
COPY 50_*.sql /docker-entrypoint-initdb.d/
COPY 60_*.sql /docker-entrypoint-initdb.d/
COPY 70_*.sql /docker-entrypoint-initdb.d/
COPY 80_*.sql /docker-entrypoint-initdb.d/
COPY 90_*.sql /docker-entrypoint-initdb.d/
RUN chmod -R a+r /docker-entrypoint-initdb.d
HEALTHCHECK --interval=5s --timeout=5s --retries=10 \
CMD pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" || exit 1

28
db/README.md Normal file
View File

@@ -0,0 +1,28 @@
# Building Catalog DB
Схема разбита на отдельные файлы по таблицам и служебным объектам.
## Запуск сборки
```bash
chmod +x ./build.sh
./build.sh
```
Переменные окружения:
- `DATABASE_URL` (приоритетный вариант)
- или `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`
Скрипт `build.sh` автоматически создаёт целевую базу, если её ещё нет (по умолчанию `building_catalog`).
Для Docker-образа в `Dockerfile` задан `POSTGRES_DB=building_catalog` как значение по умолчанию.
## Описание таблиц
- `tables/categories.md`
- `tables/brands.md`
- `tables/products.md`
- `tables/product_images.md`
- `tables/product_attributes.md`
- `tables/stocks.md`
- `tables/prices.md`

61
db/build.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_DB_NAME="building_catalog"
if [[ -n "${DATABASE_URL:-}" ]]; then
URL_WITHOUT_QUERY="${DATABASE_URL%%\?*}"
URL_QUERY=""
if [[ "$DATABASE_URL" == *\?* ]]; then
URL_QUERY="?${DATABASE_URL#*\?}"
fi
URL_PATH_PART="${URL_WITHOUT_QUERY#*://}"
URL_DB_NAME=""
URL_PREFIX="$URL_WITHOUT_QUERY"
if [[ "$URL_PATH_PART" == */* ]]; then
URL_DB_NAME="${URL_PATH_PART#*/}"
URL_DB_NAME="${URL_DB_NAME%%/*}"
URL_PREFIX="${URL_WITHOUT_QUERY%/*}"
fi
TARGET_DB="${PGDATABASE:-${URL_DB_NAME:-$DEFAULT_DB_NAME}}"
PSQL_MAINT_CMD=(psql "${URL_PREFIX}/postgres${URL_QUERY}" -v ON_ERROR_STOP=1)
PSQL_CMD=(psql "${URL_PREFIX}/${TARGET_DB}${URL_QUERY}" -v ON_ERROR_STOP=1)
else
TARGET_DB="${PGDATABASE:-$DEFAULT_DB_NAME}"
PSQL_MAINT_CMD=(
psql
-h "${PGHOST:-127.0.0.1}"
-p "${PGPORT:-5432}"
-U "${PGUSER:-postgres}"
-d postgres
-v ON_ERROR_STOP=1
)
PSQL_CMD=(
psql
-h "${PGHOST:-127.0.0.1}"
-p "${PGPORT:-5432}"
-U "${PGUSER:-postgres}"
-d "$TARGET_DB"
-v ON_ERROR_STOP=1
)
fi
if [[ ! "$TARGET_DB" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
printf 'Unsupported database name: %s\n' "$TARGET_DB" >&2
exit 1
fi
DB_EXISTS="$("${PSQL_MAINT_CMD[@]}" -Atc "SELECT 1 FROM pg_database WHERE datname = '$TARGET_DB'")"
if [[ "$DB_EXISTS" != "1" ]]; then
printf 'Creating database: %s\n' "$TARGET_DB"
"${PSQL_MAINT_CMD[@]}" -c "CREATE DATABASE \"$TARGET_DB\""
fi
for sql_file in "$ROOT_DIR"/*.sql; do
if [[ -f "$sql_file" ]]; then
"${PSQL_CMD[@]}" -f "$sql_file"
fi
done

5
db/tables/brands.md Normal file
View File

@@ -0,0 +1,5 @@
# brands
- Справочник брендов.
- `name` и `slug` уникальны.
- Используется связью `products.brand_id`.

6
db/tables/categories.md Normal file
View File

@@ -0,0 +1,6 @@
# categories
- Хранит дерево категорий каталога.
- `parent_id` ссылается на родительскую категорию.
- `slug` уникальный, используется в URL и фильтрах.
- `is_active` позволяет отключать категорию без удаления.

5
db/tables/prices.md Normal file
View File

@@ -0,0 +1,5 @@
# prices
- Текущая цена товара.
- `currency` — код ISO 4217 (например, RUB).
- `old_price` опционально хранит цену до скидки.

View File

@@ -0,0 +1,5 @@
# product_attributes
- EAV-таблица характеристик товара.
- Хранит пары `key/value` и опциональную единицу `unit`.
- Поддерживает фильтрацию каталога по атрибутам.

View File

@@ -0,0 +1,5 @@
# product_images
- Список изображений товара.
- `sort_order` определяет порядок показа.
- Внешний ключ на `products` с каскадным удалением.

7
db/tables/products.md Normal file
View File

@@ -0,0 +1,7 @@
# products
- Основная таблица товаров.
- Хранит SKU, описание, ссылки на категорию и бренд.
- `unit` задан enum `product_unit`.
- `is_active` используется для мягкого удаления.
- `created_at` и `updated_at` ведут аудит изменений.

5
db/tables/stocks.md Normal file
View File

@@ -0,0 +1,5 @@
# stocks
- Остатки по товарам.
- `qty` — общее количество, `reserved_qty` — зарезервированное.
- Доступный остаток считается как `qty - reserved_qty`.