projects/org/pirotehnika/app/pim/PRICE_FIELDS_REFACTORING.md

Рефакторинг полей цен в pim_products

Дата: 2025-12-28
Цель: Оставить ТОЛЬКО base_price и fixed_price, убрать дубликаты


Текущее состояние (в БД)

Из TABLE_STRUCTURES.md, строки 335-377:

Поля цен:

| `cost_price` | numeric | YES | |           УДАЛИТЬ (расчётное поле)
| `base_price` | numeric | YES | |           ОСТАВИТЬ
| `fixed_cost_price` | numeric | YES | |     УДАЛИТЬ (дубликат fixed_price)
| `fixed_price` | numeric | YES | |          ОСТАВИТЬ
| `target_cost` | numeric | YES | |          УДАЛИТЬ (переместить в price_cost_rules)

Поля премиум:

| `is_premium` | boolean | YES | |            УДАЛИТЬ (теперь в tier)
| `premium_reason` | varchar | YES | |        УДАЛИТЬ (теперь в tier)
| `tier` | varchar | YES | 'standard'       ОСТАВИТЬ (standard/premium/vip)

Целевое состояние (после рефакторинга)

Поля в pim_products

CREATE TABLE pim_products (
    article VARCHAR PRIMARY KEY,
    name VARCHAR NOT NULL,

    -- ДВА поля для входящих цен:
    base_price NUMERIC(10,2),      -- Прайс производителя (с возможной скидкой)
    fixed_price NUMERIC(10,2),     -- Цена поставщика (финальная)

    -- БЕЗ cost_price! Вычисляется функцией.
    ...
);

Вычисление себестоимости

Функция: calculate_cost_price(article TEXT) RETURNS NUMERIC

CREATE OR REPLACE FUNCTION calculate_cost_price(p_article TEXT)
RETURNS NUMERIC AS $$
DECLARE
    v_base_price NUMERIC;
    v_fixed_price NUMERIC;
    v_brand VARCHAR;
    v_brand_discount NUMERIC := 0;
    v_result NUMERIC;
BEGIN
    -- 1. Получить цены и бренд
    SELECT base_price, fixed_price, brand
    INTO v_base_price, v_fixed_price, v_brand
    FROM pim_products
    WHERE article = p_article;

    -- 2. Если есть base_price → применить скидку бренда
    IF v_base_price IS NOT NULL THEN
        -- Получить скидку бренда
        SELECT discount_percent INTO v_brand_discount
        FROM price_cost_rules
        WHERE brand = v_brand
        LIMIT 1;

        -- Расчёт с учётом скидки
        v_result := v_base_price * (1 - COALESCE(v_brand_discount, 0) / 100);

        RETURN v_result;
    END IF;

    -- 3. Иначе → вернуть fixed_price (без скидки)
    IF v_fixed_price IS NOT NULL THEN
        RETURN v_fixed_price;
    END IF;

    -- 4. Если ничего нет → NULL
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

VIEW для быстрого доступа

CREATE OR REPLACE VIEW v_products_with_cost AS
SELECT
    p.article,
    p.name,
    p.brand,
    p.base_price,
    p.fixed_price,

    -- Вычисляемая себестоимость
    calculate_cost_price(p.article) as cost_price,

    -- Источник цены (для отладки)
    CASE
        WHEN p.base_price IS NOT NULL THEN 'base_price'
        WHEN p.fixed_price IS NOT NULL THEN 'fixed_price'
        ELSE 'none'
    END as price_source

FROM pim_products p;

План миграции

Миграция 029: Упрощение полей цен

Файл: migrations/029_simplify_price_fields.sql
Скрипт применения: apply_price_migration.sh

Что делает миграция:

  1. Добавляет fixed_price (если нет)
  2. Мигрирует данные: fixed_cost_pricefixed_price
  3. Мигрирует данные: is_premiumtier
  4. Удаляет поля: cost_price, fixed_cost_price, target_cost, is_premium, premium_reason
  5. Создаёт функцию calculate_cost_price(article)
  6. Создаёт функцию calculate_retail_price(article)
  7. Создаёт VIEW v_products_with_cost
  8. Создаёт VIEW v_retail_prices

Применение:

cd /opt/claude-workspace/projects/org/pirotehnika/app/pim
./apply_price_migration.sh

SQL миграции: См. файл migrations/029_simplify_price_fields.sql

Ключевые изменения:

-- Удаление дубликатов цен
ALTER TABLE pim_products
    DROP COLUMN cost_price,           -- → вычисляется функцией
    DROP COLUMN fixed_cost_price,     -- → дубликат fixed_price
    DROP COLUMN target_cost;          -- → в price_cost_rules

-- Удаление полей премиум (теперь в tier)
ALTER TABLE pim_products
    DROP COLUMN is_premium,           -- → tier='premium'
    DROP COLUMN premium_reason;       -- → не нужно

-- Функция расчёта себестоимости
CREATE FUNCTION calculate_cost_price(p_article TEXT) RETURNS NUMERIC
-- Логика:
--   IF base_price EXISTS: cost = base_price × (1 - brand_discount)
--   ELSE IF fixed_price EXISTS: cost = fixed_price
--   ELSE: NULL

-- VIEW с вычисляемой себестоимостью
CREATE VIEW v_products_with_cost AS
SELECT
    p.*,
    calculate_cost_price(p.article) as cost_price
FROM pim_products p;

Обновление кода Python

models/product.py

class PimProduct(Base):
    __tablename__ = "pim_products"
    __table_args__ = {"schema": settings.DATABASE_SCHEMA}

    article = Column(String, primary_key=True)
    name = Column(String, nullable=False)
    brand = Column(String)
    category = Column(String)

    # ТОЛЬКО ДВА поля цен
    base_price = Column(Numeric(10, 2), comment="Прайс производителя")
    fixed_price = Column(Numeric(10, 2), comment="Цена поставщика")

    # Вычисляемое свойство
    @property
    def cost_price(self):
        """Себестоимость - вычисляется на лету"""
        from sqlalchemy import text
        result = db.session.execute(
            text("SELECT calculate_cost_price(:article)"),
            {"article": self.article}
        ).scalar()
        return float(result) if result else None

    @property
    def retail_price(self):
        """Розничная цена - вычисляется на лету"""
        from sqlalchemy import text
        result = db.session.execute(
            text("SELECT calculate_retail_price(:article)"),
            {"article": self.article}
        ).scalar()
        return float(result) if result else None

API возвращает вычисленные цены

# api/products.py
@router.get("/products/{article}")
def get_product(article: str):
    product = db.query(PimProduct).filter_by(article=article).first()

    return {
        "article": product.article,
        "name": product.name,
        "base_price": float(product.base_price) if product.base_price else None,
        "fixed_price": float(product.fixed_price) if product.fixed_price else None,

        # Вычисляемые поля
        "cost_price": product.cost_price,        # через @property
        "retail_price": product.retail_price,    # через @property
    }

Преимущества нового подхода

1. Нет дубликатов

Было:
- cost_price (хранимое)
- fixed_cost_price (дубликат)
- target_cost (непонятное назначение)

Стало:
- base_price (входящая цена от производителя)
- fixed_price (входящая цена от поставщика)
- cost_price → вычисляется функцией

2. Единственный источник правды

Логика расчёта — ТОЛЬКО в функции calculate_cost_price():

IF base_price EXISTS:
    cost = base_price × (1 - brand_discount)
ELSE IF fixed_price EXISTS:
    cost = fixed_price
ELSE:
    cost = NULL

3. Автоматическое обновление

При изменении:
- base_price → cost_price пересчитается автоматически
- Скидки в price_cost_rules → cost_price обновится для всех товаров бренда

4. Чистота данных

pim_products = ТОЛЬКО входящие цены (ввод)
Функции = ТОЛЬКО расчёты (логика)
VIEW = ТОЛЬКО представление (вывод)


Откат

Если что-то пойдёт не так:

-- Восстановить cost_price из бэкапа
ALTER TABLE pim_products ADD COLUMN cost_price NUMERIC(10,2);

UPDATE pim_products
SET cost_price = calculate_cost_price(article);

-- Или из резервной копии таблицы
INSERT INTO pim_products (article, cost_price, ...)
SELECT article, cost_price, ...
FROM pim_products_backup_20251228;

Проверка после миграции

-- 1. Проверить что поля удалены
\d pim_products
-- Должны быть ТОЛЬКО: base_price, fixed_price

-- 2. Проверить функцию
SELECT
    article,
    base_price,
    fixed_price,
    calculate_cost_price(article) as cost_price
FROM pim_products
LIMIT 10;

-- 3. Проверить VIEW
SELECT * FROM v_products_with_cost LIMIT 10;

-- 4. Проверить розничные цены
SELECT * FROM v_retail_prices LIMIT 10;

-- 5. Сравнить со старыми значениями (если есть бэкап)
SELECT
    p.article,
    b.cost_price as old_cost,
    calculate_cost_price(p.article) as new_cost,
    ABS(b.cost_price - calculate_cost_price(p.article)) as diff
FROM pim_products p
JOIN pim_products_backup b ON p.article = b.article
WHERE ABS(b.cost_price - calculate_cost_price(p.article)) > 0.01
ORDER BY diff DESC
LIMIT 20;

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


Версия: 1.0.0