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

Входящие цены — Установка себестоимости

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


Что это

Инструкция по установке входящих цен от производителей и поставщиков в PIM систему.

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


Два типа источников

Производители (base_price)

Кто: Гордеев, JF Pyro, Maxsem, КТС Пиро.

Что дают:
- Базовый прайс-лист (base_price)
- Скидка по договору (discount_percent)

Как обрабатывается:

base_price → применить скидку → cost_price

Пример:

Товар: Гордеев MC150 (батарея салютов)
base_price = 2000  (прайс производителя)
brand_discount = 20% (скидка Гордеев)
 cost_price = 2000 × (1 - 0.20) = 1600 

Поставщики (fixed_price)

Кто: Ольгино, ПироСнаб, мелкие поставщики.

Что дают:
- Финальная цена (fixed_price)
- Скидка УЖЕ учтена

Как обрабатывается:

fixed_price → cost_price (без изменений)

Пример:

Товар: Ольгино CC2600 (фонтан)
fixed_price = 850  (цена от Ольгино)
 cost_price = 850  (напрямую)

Таблица pim_products

Поля входящих цен

Поле Тип Источник Обязательное Описание
base_price numeric Производитель Нет* Базовый прайс (подлежит скидке)
fixed_price numeric Поставщик Нет* Финальная цена (без скидки)
cost_price numeric Расчёт Да Себестоимость (автоматически)

* Правило: Хотя бы ОДНО из полей (base_price или fixed_price) должно быть заполнено.

Формула cost_price

cost_price = CASE
    WHEN base_price IS NOT NULL THEN
        base_price * (1 - COALESCE(brand_discount, 0) / 100)
    WHEN fixed_price IS NOT NULL THEN
        fixed_price
    ELSE
        NULL  -- Ошибка: нет входящей цены
END

Приоритет: Если заполнены ОБА поля → используется base_price.


Процесс 1: Импорт из 1С (производители)

Источник данных

Файл: /mnt/beget-s3/projects/pirotehnika/products/1c_nomenclature.json

Формат:

{
  "Номенклатура": [
    {
      "Ref_Key": "uuid",
      "Description": "Название товара",
      "Артикул": "JF-DM30",
      "Цена": 1000.00,
      "БрендНоменклатуры_Key": "uuid-jf-pyro"
    }
  ]
}

Шаги обработки

  1. Чтение JSON:
python -m pim.import_from_1c
  1. Маппинг полей:
{
    "Артикул": "article",
    "Цена": "base_price",
    "БрендНоменклатуры_Key": "brand_id"
}
  1. Получение скидки бренда:
SELECT discount_percent
FROM price_cost_rules
WHERE brand = (SELECT name FROM brands WHERE id = brand_id);
  1. Расчёт cost_price:
cost_price = base_price * (1 - brand_discount / 100)
  1. Сохранение в БД:
INSERT INTO pim_products (article, base_price, cost_price, brand)
VALUES (?, ?, ?, ?)
ON CONFLICT (article) DO UPDATE
SET base_price = EXCLUDED.base_price,
    cost_price = EXCLUDED.cost_price;

Лог изменений

Все изменения записываются в price_history:

INSERT INTO price_history (article, price_type, old_value, new_value, changed_by)
SELECT
    NEW.article,
    'base_price',
    OLD.base_price,
    NEW.base_price,
    'import_1c'
FROM pim_products
WHERE article = NEW.article;

Расписание

Cron: Ежедневно в 06:00

0 6 * * * cd /opt/claude-workspace/projects/org/pirotehnika/app/pim && python -m pim.import_from_1c

Документация: ../1C_PIM_MASTER_MAPPING.md


Процесс 2: Импорт прайса поставщика (fixed_price)

Источник данных

Загрузка: Через веб-интерфейс http://upload.0kt.ru/pirotehnika/ или API.

Формат: Excel (.xlsx) с колонками:
- Артикул — обязательно
- Цена — обязательно
- Название — опционально
- Остаток — опционально

Пример (Ольгино):

Артикул       | Название                  | Цена  | Остаток
--------------+---------------------------+-------+--------
CC2600        | Фонтан "Золотой дождь"    | 850   | 45
VH100         | Вертушка                   | 320   | 120

Шаги обработки

  1. Загрузка файла:

API:

curl -X POST http://docs.0kt.ru:8000/prices/upload \
  -F "file=@price_olgino.xlsx" \
  -F "supplier=olgino"

CLI:

python -m pim.import_price /path/to/price_olgino.xlsx --supplier olgino
  1. Запись в staging:
INSERT INTO pim_price_file_uploads (filename, supplier, uploaded_by, status)
VALUES ('price_olgino.xlsx', 'olgino', 'admin', 'pending');

INSERT INTO pim_staging_products (upload_id, article, price, ...)
SELECT upload_id, Артикул, Цена, ...
FROM excel_data;
  1. Валидация:
# Проверки:
- Артикул не пустой
- Цена > 0
- Артикул существует в pim_products (или создать новый)

# Статус:
'pending'  'validated' или 'failed'
  1. Импорт в pim_products:
UPDATE pim_products p
SET
    fixed_price = s.price,
    cost_price = s.price,  -- для fixed_price без скидки
    updated_at = NOW()
FROM pim_staging_products s
WHERE p.article = s.article
  AND s.upload_id = ?
  AND s.status = 'validated';
  1. Статусы:
pending → processing → validated → imported
                      ↓
                    failed

API Endpoints

Endpoint Метод Назначение
/prices/upload POST Загрузить прайс
/prices/uploads/{id} GET Статус загрузки
/prices/uploads/{id}/validate POST Валидация
/prices/uploads/{id}/import POST Импорт в каталог
/prices/uploads/{id}/rollback POST Откат изменений

Документация API: ../API_DOCUMENTATION.md


Таблица price_cost_rules (скидки брендов)

Структура

CREATE TABLE price_cost_rules (
    id SERIAL PRIMARY KEY,
    brand VARCHAR(100),
    discount_percent NUMERIC(5,2),  -- 0.00 - 100.00
    valid_from DATE,
    valid_to DATE,
    created_at TIMESTAMP DEFAULT NOW()
);

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

INSERT INTO price_cost_rules (brand, discount_percent) VALUES
('JF Pyro', 25.00),
('Гордеев', 20.00),
('Maxsem', 22.00),
('КТС Пиро', 18.00);

Применение

При импорте из 1С система автоматически применяет скидку:

def calculate_cost_price(base_price: float, brand: str) -> float:
    discount = db.query(
        "SELECT discount_percent FROM price_cost_rules WHERE brand = ?",
        brand
    ).scalar() or 0

    return base_price * (1 - discount / 100)

Таблица price_supplier_cost (реальная себестоимость)

Назначение: Хранить РЕАЛЬНУЮ себестоимость от каждого поставщика отдельно.

Структура

CREATE TABLE price_supplier_cost (
    id SERIAL PRIMARY KEY,
    supplier VARCHAR(100),
    article VARCHAR(100),
    real_cost_price NUMERIC(10,2),
    quantity_available INTEGER,
    updated_at TIMESTAMP DEFAULT NOW()
);

Когда заполняется

Автоматически при импорте прайса поставщика:

INSERT INTO price_supplier_cost (supplier, article, real_cost_price, quantity_available)
SELECT
    'olgino',
    s.article,
    s.price,
    s.stock
FROM pim_staging_products s
WHERE s.upload_id = ?
ON CONFLICT (supplier, article) DO UPDATE
SET real_cost_price = EXCLUDED.real_cost_price,
    quantity_available = EXCLUDED.quantity_available,
    updated_at = NOW();

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

Для аналитики и выбора поставщика:

-- У кого дешевле CC2600?
SELECT supplier, real_cost_price, quantity_available
FROM price_supplier_cost
WHERE article = 'CC2600'
ORDER BY real_cost_price ASC;

Результат:

supplier      | real_cost_price | quantity_available
--------------+-----------------+-------------------
olgino        | 850.00          | 45
pirosnab      | 920.00          | 18

Валидация входящих цен

Правила

  1. Обязательность:
    - Хотя бы одно поле заполнено: base_price IS NOT NULL OR fixed_price IS NOT NULL

  2. Диапазон:
    - base_price >= 0 (если заполнен)
    - fixed_price >= 0 (если заполнен)
    - base_price <= 1000000 (защита от опечаток)

  3. Логика:
    - Если base_price заполнен → должен быть бренд (для скидки)
    - Если fixed_price заполнен → должен быть поставщик

  4. Конфликты:
    - Если заполнены ОБА → приоритет у base_price
    - Предупреждение: "Заполнены оба поля, используется base_price"

Реализация (PostgreSQL CHECK)

ALTER TABLE pim_products
ADD CONSTRAINT check_price_exists
CHECK (base_price IS NOT NULL OR fixed_price IS NOT NULL);

ALTER TABLE pim_products
ADD CONSTRAINT check_base_price_positive
CHECK (base_price IS NULL OR base_price >= 0);

ALTER TABLE pim_products
ADD CONSTRAINT check_fixed_price_positive
CHECK (fixed_price IS NULL OR fixed_price >= 0);

Предупреждения в staging

def validate_staging_product(row):
    warnings = []

    if row.price <= 0:
        warnings.append("Цена должна быть > 0")

    if row.price > 100000:
        warnings.append("Подозрительно высокая цена")

    if not row.article:
        warnings.append("Отсутствует артикул")

    return warnings

Обработка конфликтов

Сценарий: Товар есть в 1С И в прайсе поставщика

Проблема:
- 1С даёт base_price = 2000
- Поставщик даёт fixed_price = 1600

Решение: Приоритет у base_price, но fixed_price сохраняется для аналитики.

UPDATE pim_products
SET
    base_price = 2000,      -- из 1С
    fixed_price = 1600,     -- из прайса
    cost_price = 2000 * (1 - 0.20) = 1600  -- из base_price
WHERE article = 'CC2600';

Результат: cost_price одинаковый, но источники зафиксированы.

Сценарий: Товар только у поставщика (нет в 1С)

INSERT INTO pim_products (article, fixed_price, cost_price)
VALUES ('OLGINO-NEW-ITEM', 1200, 1200);

Результат: base_price = NULL, fixed_price = 1200, cost_price = 1200.


История изменений (price_history)

Структура

CREATE TABLE price_history (
    id SERIAL PRIMARY KEY,
    article VARCHAR(100),
    price_type VARCHAR(50),  -- base_price, fixed_price, cost_price
    old_value NUMERIC(10,2),
    new_value NUMERIC(10,2),
    changed_at TIMESTAMP DEFAULT NOW(),
    changed_by VARCHAR(100)  -- import_1c, import_price, admin_user
);

Триггер автологирования

CREATE OR REPLACE FUNCTION log_price_change()
RETURNS TRIGGER AS $$
BEGIN
    IF OLD.base_price IS DISTINCT FROM NEW.base_price THEN
        INSERT INTO price_history (article, price_type, old_value, new_value, changed_by)
        VALUES (NEW.article, 'base_price', OLD.base_price, NEW.base_price, current_user);
    END IF;

    IF OLD.fixed_price IS DISTINCT FROM NEW.fixed_price THEN
        INSERT INTO price_history (article, price_type, old_value, new_value, changed_by)
        VALUES (NEW.article, 'fixed_price', OLD.fixed_price, NEW.fixed_price, current_user);
    END IF;

    IF OLD.cost_price IS DISTINCT FROM NEW.cost_price THEN
        INSERT INTO price_history (article, price_type, old_value, new_value, changed_by)
        VALUES (NEW.article, 'cost_price', OLD.cost_price, NEW.cost_price, current_user);
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER price_change_log
AFTER UPDATE ON pim_products
FOR EACH ROW
EXECUTE FUNCTION log_price_change();

Просмотр истории

-- История изменения цены товара CC2600
SELECT
    price_type,
    old_value,
    new_value,
    changed_at,
    changed_by
FROM price_history
WHERE article = 'CC2600'
ORDER BY changed_at DESC
LIMIT 10;

Результат:

price_type   | old_value | new_value | changed_at          | changed_by
-------------+-----------+-----------+---------------------+--------------
fixed_price  | 800.00    | 850.00    | 2025-12-28 10:30:00 | import_price
cost_price   | 800.00    | 850.00    | 2025-12-28 10:30:00 | import_price
base_price   | 1800.00   | 2000.00   | 2025-12-27 06:05:12 | import_1c

Команды

Импорт из 1С

cd /opt/claude-workspace/projects/org/pirotehnika/app/pim

# Полный импорт
python -m pim.import_from_1c

# С обновлением только цен
python -m pim.import_from_1c --prices-only

Импорт прайса поставщика

# CLI
python -m pim.import_price /path/to/price.xlsx --supplier olgino

# С превью (без сохранения)
python -m pim.import_price /path/to/price.xlsx --supplier olgino --dry-run

# API
curl -X POST http://docs.0kt.ru:8000/prices/upload \
  -F "file=@price.xlsx" \
  -F "supplier=olgino"

Проверка скидок брендов

psql -d pim -c "SELECT * FROM price_cost_rules;"

Просмотр staging

psql -d pim -c "SELECT * FROM pim_staging_products WHERE upload_id = 123;"

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

Документ Описание
PRICING_PLATFORM.md Общая концепция ценообразования
PRICING_OUTBOUND.md Расчёт исходящих цен
../1C_PIM_MASTER_MAPPING.md Маппинг полей из 1С
../API_DOCUMENTATION.md API для импорта прайсов

Версия: 1.0.0