projects/org/pirotehnika/app/pim/docs/PRICING_OUTBOUND.md

Исходящие цены — Расчёт продажных цен

Версия: 2.0.0
Дата: 2025-12-28
Проект: pirotehnikaPIM


Что это

Инструкция по расчёту продажных цен для всех каналов: розница, опт, маркетплейсы.

Концепция: PRICING_PLATFORM.md


Три типа исходящих цен

Тип Канал Формула Где используется
retail_price Розница cost × (1 + retail_markup) Сайты (pirotehnika.spb.ru, feyerverk.spb.ru)
wholesale_price Опт cost × (1 + wholesale_markup) @piroopt_bot, оптовые клиенты
marketplace_price Маркетплейсы cost × (1 + mp_markup) - комиссия OZON, Wildberries

Таблица price_sale_rules (правила наценки)

Структура

CREATE TABLE price_sale_rules (
    id SERIAL PRIMARY KEY,
    level VARCHAR(20),           -- product, category, brand, global
    entity_id VARCHAR(100),      -- article, category, brand, или NULL для global
    price_type VARCHAR(20),      -- retail, wholesale, marketplace
    markup NUMERIC(5,2),         -- наценка в процентах
    markup_base VARCHAR(20) DEFAULT 'base_price', -- base_price, cost_price, fixed_price
    valid_from DATE,
    valid_to DATE,
    created_at TIMESTAMP DEFAULT NOW()
);

COMMENT ON COLUMN price_sale_rules.markup_base IS 'База для расчёта наценки: base_price (по умолчанию), cost_price, fixed_price';

Уровни иерархии

Приоритет (от высшего к низшему):

1. product     Правило для конкретного товара
2. category    Правило для категории
3. brand       Правило для бренда
4. global      Глобальное правило (запасной вариант)

Логика: Система ищет первое подходящее правило сверху вниз.

Примеры записей

INSERT INTO price_sale_rules (level, entity_id, price_type, markup, markup_base) VALUES

-- Глобальные правила (запасные)
-- По умолчанию от base_price (можно не указывать markup_base)
('global', NULL, 'retail', 50.00, 'base_price'),
('global', NULL, 'wholesale', 30.00, 'base_price'),
('global', NULL, 'marketplace', 45.00, 'base_price'),

-- Правила по брендам (от base_price)
('brand', 'JF Pyro', 'retail', 55.00, 'base_price'),
('brand', 'Гордеев', 'retail', 48.00, 'base_price'),

-- Правила по категориям (можно от cost_price)
('category', 'Цветной дым', 'retail', 60.00, 'cost_price'),
('category', 'Батареи салютов', 'wholesale', 25.00, 'cost_price'),

-- Правила для конкретных товаров (высший приоритет)
('product', 'JF-DM30', 'retail', 65.00, 'base_price');

VIEW: v_retail_prices (автоматический расчёт)

Назначение

Автоматически рассчитывает розничную цену для каждого товара на основе иерархии правил.

SQL определение

CREATE OR REPLACE VIEW v_retail_prices AS
WITH price_rules AS (
    -- Выбрать подходящее правило по иерархии
    SELECT DISTINCT ON (p.article)
        p.article,
        p.base_price,
        p.fixed_price,
        calculate_cost_price(p.article) as cost_price,
        r.markup,
        r.markup_base,
        r.level
    FROM pim_products p
    LEFT JOIN price_sale_rules r ON (
        (r.level = 'product' AND r.entity_id = p.article AND r.price_type = 'retail')
        OR (r.level = 'category' AND r.entity_id = p.category AND r.price_type = 'retail')
        OR (r.level = 'brand' AND r.entity_id = p.brand AND r.price_type = 'retail')
        OR (r.level = 'global' AND r.price_type = 'retail')
    )
    ORDER BY p.article,
        CASE r.level
            WHEN 'product' THEN 1
            WHEN 'category' THEN 2
            WHEN 'brand' THEN 3
            WHEN 'global' THEN 4
            ELSE 5
        END
)
SELECT
    article,
    base_price,
    cost_price,
    markup,
    markup_base,
    level as rule_source,
    -- Расчёт в зависимости от markup_base
    CASE COALESCE(markup_base, 'base_price')
        WHEN 'base_price' THEN ROUND(base_price * (1 + markup/100), 2)
        WHEN 'cost_price' THEN ROUND(cost_price * (1 + markup/100), 2)
        WHEN 'fixed_price' THEN ROUND(fixed_price * (1 + markup/100), 2)
        ELSE ROUND(base_price * (1 + markup/100), 2)  -- fallback
    END as retail_price
FROM price_rules;

Использование

-- Получить розничные цены всех товаров
SELECT * FROM v_retail_prices;

-- Розничная цена конкретного товара
SELECT retail_price FROM v_retail_prices WHERE article = 'JF-DM30';

-- Товары с наценкой выше 60%
SELECT * FROM v_retail_prices WHERE markup > 60;

Процесс расчёта розничных цен

Шаг 1: Определение базы для наценки

Поле markup_base в правиле:

Значение Источник Формула Когда использовать
base_price Цена бренда retail = base_price × (1 + markup/100) По умолчанию (рекомендуется)
cost_price Себестоимость retail = cost_price × (1 + markup/100) Когда нужна наценка от реальной себестоимости
fixed_price Цена поставщика retail = fixed_price × (1 + markup/100) Особые случаи, поставщик без base_price

По умолчанию: Если markup_base не указан или NULL, используется base_price.

Примеры:

Пример 1: Наценка от base_price (по умолчанию):

INSERT INTO price_sale_rules (level, entity_id, price_type, markup, markup_base)
VALUES ('brand', 'JF Pyro', 'retail', 55.00, 'base_price');

-- Расчёт:
base_price = 1000 
retail_price = 1000 × 1.55 = 1550 

Пример 2: Наценка от cost_price:

INSERT INTO price_sale_rules (level, entity_id, price_type, markup, markup_base)
VALUES ('category', 'Цветной дым', 'retail', 70.00, 'cost_price');

-- Расчёт:
base_price = 1000 
cost_price = 800   (base_price с учётом скидки 20%)
retail_price = 800 × 1.70 = 1360 

Шаг 2: Поиск правила по иерархии

def find_markup_rule(article: str, category: str, brand: str) -> float:
    # 1. Правило для товара (высший приоритет)
    rule = db.query("""
        SELECT markup FROM price_sale_rules
        WHERE level='product' AND entity_id=? AND price_type='retail'
    """, article)
    if rule:
        return rule.markup

    # 2. Правило для категории
    rule = db.query("""
        SELECT markup FROM price_sale_rules
        WHERE level='category' AND entity_id=? AND price_type='retail'
    """, category)
    if rule:
        return rule.markup

    # 3. Правило для бренда
    rule = db.query("""
        SELECT markup FROM price_sale_rules
        WHERE level='brand' AND entity_id=? AND price_type='retail'
    """, brand)
    if rule:
        return rule.markup

    # 4. Глобальное правило (запасной вариант)
    rule = db.query("""
        SELECT markup FROM price_sale_rules
        WHERE level='global' AND price_type='retail'
    """)
    return rule.markup if rule else 50.0  # fallback

Шаг 3: Применение наценки

retail_price = cost_price * (1 + markup / 100)

Шаг 4: Округление

Правило: До рублей, без копеек (для розницы).

retail_price = round(retail_price, 0)  # 1549.99 → 1550

Кеширование в pim_products

Зачем

Поле retail_price в pim_products

ALTER TABLE pim_products
ADD COLUMN retail_price NUMERIC(10,2);

CREATE INDEX idx_pim_products_retail_price ON pim_products(retail_price);

Триггер обновления

CREATE OR REPLACE FUNCTION update_retail_price()
RETURNS TRIGGER AS $$
BEGIN
    -- Пересчитать при изменении cost_price
    IF OLD.cost_price IS DISTINCT FROM NEW.cost_price THEN
        UPDATE pim_products
        SET retail_price = (
            SELECT retail_price FROM v_retail_prices WHERE article = NEW.article
        )
        WHERE article = NEW.article;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER retail_price_update
AFTER UPDATE ON pim_products
FOR EACH ROW
EXECUTE FUNCTION update_retail_price();

Массовое обновление

-- Пересчитать все цены
UPDATE pim_products p
SET retail_price = v.retail_price
FROM v_retail_prices v
WHERE p.article = v.article;

Команда:

psql -d pim -c "UPDATE pim_products p SET retail_price = (SELECT retail_price FROM v_retail_prices WHERE article = p.article);"

Оптовые цены (wholesale_price)

Логика

Оптовая цена ниже розничной (меньше наценка).

Правило: wholesale_markup < retail_markup

Примеры правил

INSERT INTO price_sale_rules (level, entity_id, price_type, markup) VALUES
-- Глобальная оптовая наценка
('global', NULL, 'wholesale', 30.00),

-- По категориям
('category', 'Батареи салютов', 'wholesale', 25.00),
('category', 'Цветной дым', 'wholesale', 28.00),

-- По брендам
('brand', 'Гордеев', 'wholesale', 22.00);

Расчёт

wholesale_price = cost_price × (1 + wholesale_markup / 100)

Хранение

Вариант 1: Отдельное поле в pim_products:

ALTER TABLE pim_products ADD COLUMN wholesale_price NUMERIC(10,2);

Вариант 2: Вычисляемое поле (VIEW):

CREATE VIEW v_wholesale_prices AS
SELECT
    article,
    cost_price,
    cost_price * (1 + markup/100) as wholesale_price
FROM pim_products p
LEFT JOIN price_sale_rules r ON ...
WHERE r.price_type = 'wholesale';

Использование

@piroopt_bot:

# Получить оптовую цену для клиента
price = db.query(
    "SELECT wholesale_price FROM pim_products WHERE article = ?",
    article
).scalar()

Цены для маркетплейсов (marketplace_price)

Особенности

  1. Комиссия маркетплейса вычитается ДО установки цены
  2. Минимальная цена (OZON требует >= cost × 1.15)
  3. Динамическое ценообразование (акции, скидки)

Формула с учётом комиссии

# OZON комиссия 15%
marketplace_price = cost_price × (1 + markup) / (1 - commission)

# Пример:
cost = 1000 
markup = 45%
commission = 15%
 marketplace_price = 1000 × 1.45 / 0.85 = 1705.88 

Минимальная цена (OZON)

Правило OZON: цена >= cost × 1.15

min_price = cost_price * 1.15
marketplace_price = max(marketplace_price, min_price)

Таблица маркетплейсов

CREATE TABLE marketplace_settings (
    marketplace VARCHAR(50) PRIMARY KEY,
    commission_percent NUMERIC(5,2),
    min_markup_percent NUMERIC(5,2),
    rounding_rule VARCHAR(20)  -- none, to_ruble, to_10_rub
);

INSERT INTO marketplace_settings VALUES
('ozon', 15.00, 15.00, 'to_ruble'),
('wildberries', 18.00, 20.00, 'to_10_rub');

Функция расчёта

def calculate_marketplace_price(
    cost_price: float,
    markup: float,
    marketplace: str = 'ozon'
) -> float:
    settings = get_marketplace_settings(marketplace)

    # Базовая цена с наценкой
    price = cost_price * (1 + markup / 100)

    # Учёт комиссии
    price = price / (1 - settings.commission / 100)

    # Минимальная цена
    min_price = cost_price * (1 + settings.min_markup / 100)
    price = max(price, min_price)

    # Округление
    if settings.rounding == 'to_ruble':
        price = round(price, 0)
    elif settings.rounding == 'to_10_rub':
        price = round(price / 10) * 10

    return price

Экспорт цен на каналы

1. Розничные сайты (Webasyst, OpenCart)

Процесс:

v_retail_prices
       ↓
sync_pim_to_webasyst.py
       ↓
UPDATE shop_product SET price = ?

Скрипт:

cd /opt/claude-workspace/projects/org/pirotehnika/app/site/feyerverk.spb.ru/solution
python sync_pim_to_webasyst.py --prices-only

Расписание:

0 8,14,20 * * * python sync_pim_to_webasyst.py --prices-only

2. OZON API

Процесс:

calculate_marketplace_price()
       ↓
POST /v1/product/import/prices

Скрипт:

from library.connectors.api.ozon import OzonClient

client = OzonClient(client_id=..., api_key=...)

prices = []
for product in pim_products:
    prices.append({
        "offer_id": product.article,
        "price": calculate_marketplace_price(
            product.cost_price,
            markup=45,
            marketplace='ozon'
        )
    })

client.update_prices(prices)

Расписание:

0 9,15,21 * * * python sync_pim_to_ozon.py --prices

3. Телеграм бот @piroopt_bot

Процесс:

pim_products.wholesale_price
       ↓
Telegram Bot API
       ↓
Сообщение клиенту

Код бота:

@bot.message_handler(commands=['price'])
def get_price(message):
    article = message.text.split()[1]

    price = db.query(
        "SELECT wholesale_price FROM pim_products WHERE article = ?",
        article
    ).scalar()

    bot.reply_to(message, f"Оптовая цена: {price} ₽")

Специальные случаия

Акции и скидки

Таблица:

CREATE TABLE price_promotions (
    id SERIAL PRIMARY KEY,
    article VARCHAR(100),
    discount_percent NUMERIC(5,2),
    valid_from DATE,
    valid_to DATE
);

Применение:

SELECT
    p.article,
    p.retail_price,
    pr.discount_percent,
    p.retail_price * (1 - pr.discount_percent/100) as promo_price
FROM pim_products p
LEFT JOIN price_promotions pr ON p.article = pr.article
WHERE pr.valid_from <= CURRENT_DATE
  AND pr.valid_to >= CURRENT_DATE;

Минималки (минимальные цены)

Проблема: Нельзя продавать ниже себестоимости.

Проверка:

ALTER TABLE pim_products
ADD CONSTRAINT check_retail_above_cost
CHECK (retail_price >= cost_price);

В коде:

def set_retail_price(article: str, price: float):
    cost = get_cost_price(article)

    if price < cost:
        raise ValueError(f"Цена {price} ниже себестоимости {cost}")

    db.execute(
        "UPDATE pim_products SET retail_price = ? WHERE article = ?",
        price, article
    )

Цены с НДС / без НДС

Правило: Все цены в PIM — БЕЗ НДС.

Расчёт с НДС:

price_with_vat = retail_price * 1.20  # НДС 20%

Хранение:

ALTER TABLE pim_products
ADD COLUMN vat_rate NUMERIC(5,2) DEFAULT 20.00;

-- VIEW с НДС
CREATE VIEW v_prices_with_vat AS
SELECT
    article,
    retail_price,
    vat_rate,
    retail_price * (1 + vat_rate/100) as price_with_vat
FROM pim_products;

Команды

Пересчитать все розничные цены

psql -d pim -c "
UPDATE pim_products p
SET retail_price = (SELECT retail_price FROM v_retail_prices WHERE article = p.article);
"

Проверить правила наценки

psql -d pim -c "SELECT * FROM price_sale_rules ORDER BY level, entity_id;"

Товары без правил наценки

psql -d pim -c "
SELECT p.article, p.brand, p.category
FROM pim_products p
LEFT JOIN v_retail_prices v ON p.article = v.article
WHERE v.retail_price IS NULL;
"

Добавить глобальное правило

psql -d pim -c "
INSERT INTO price_sale_rules (level, price_type, markup, base_value_source)
VALUES ('global', 'retail', 50.00, 'from_cost');
"

Экспорт цен на OZON

cd /opt/claude-workspace/projects/org/pirotehnika/app/ozon
python sync_prices_to_ozon.py --account O1

Мониторинг

Товары с низкой наценкой

SELECT
    article,
    cost_price,
    retail_price,
    ROUND((retail_price / cost_price - 1) * 100, 2) as actual_markup
FROM pim_products
WHERE retail_price / cost_price < 1.30  -- наценка < 30%
ORDER BY actual_markup ASC;

Товары дороже конкурентов

SELECT
    p.article,
    p.retail_price as our_price,
    c.price as competitor_price,
    p.retail_price - c.price as difference
FROM pim_products p
LEFT JOIN competitor_prices c ON p.article = c.article
WHERE p.retail_price > c.price * 1.10
ORDER BY difference DESC;

История изменений цен

SELECT
    article,
    price_type,
    old_value,
    new_value,
    changed_at,
    changed_by
FROM price_history
WHERE price_type IN ('retail_price', 'wholesale_price')
  AND changed_at >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY changed_at DESC;

Связанные документы

Документ Описание
PRICING_PLATFORM.md Общая концепция ценообразования
PRICING_INBOUND.md Установка входящих цен
../ARCHITECTURE.md Архитектура PIM
../../ozon/README.md Интеграция с OZON

Версия: 1.0.0