diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8a2c18c..70568f3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(dir /b /s settings.py)" + "Bash(dir /b /s settings.py)", + "Bash(git add:*)" ], "deny": [], "ask": [] diff --git a/DEBUG_PRICE_CALCULATION.md b/DEBUG_PRICE_CALCULATION.md new file mode 100644 index 0000000..8f8e6be --- /dev/null +++ b/DEBUG_PRICE_CALCULATION.md @@ -0,0 +1,101 @@ +# Отладка расчёта цены комплекта + +## Проблема +Первая строка (компонент) не считается в цену. При добавлении второго товара начинает считать. + +## Решение + +### Что было исправлено + +1. **Улучшена функция `getProductPrice()`** с добавлением: + - Строгой проверки валидности элемента и productId + - Логирования для отладки (console.log) + - Проверки на isNaN и productId <= 0 + +2. **Улучшена функция `calculateFinalPrice()`** с добавлением: + - Проверки что товар выбран (!productSelect || !productSelect.value) + - Валидации количества (если quantity <= 0, использует 1) + - Проверки что цена > 0 перед добавлением в сумму + +3. **Добавлено логирование** для отладки в браузерной консоли: + ```javascript + console.log('getProductPrice: from cache', productId, cachedPrice); + console.log('getProductPrice: from API', productId, price); + console.warn('getProductPrice: returning 0 for product', productId); + ``` + +### Как провести отладку + +1. **Откройте DevTools** в браузере (F12 или Ctrl+Shift+I) +2. Перейдите на вкладку **Console** +3. Добавьте первый товар на форму создания комплекта +4. Посмотрите в Console - должны увидеть логи вида: + ``` + getProductPrice: fetching from API 1 + getProductPrice: from API 1 20.00 + ``` + +5. Введите количество товара +6. Проверьте что в Console логируется `calculateFinalPrice` вызывается +7. Убедитесь что базовая цена обновилась + +### Возможные проблемы и решения + +#### 1. "getProductPrice: no valid product id" +**Проблема:** selectElement пуст или не имеет ID товара +**Решение:** Убедитесь что товар действительно выбран в Select2 + +#### 2. "getProductPrice: returning 0 for product" +**Проблема:** Цена товара не найдена ни в одном источнике +**Решение:** +- Проверьте что товар имеет цену в базе данных +- Проверьте API endpoint возвращает actual_price + +#### 3. Цена считается только со 2-го товара +**Проблема:** Первая форма загружается с пустыми значениями, но JavaScript пытается считать её +**Решение:** +- Логика теперь пропускает пустые товары (`if (!productSelect.value) continue`) +- Убедитесь что Вы выбираете товар перед добавлением количества + +### Тест в консоли браузера + +После добавления товара выполните в консоли: + +```javascript +// Получить текущую базовую цену +console.log(basePrice); + +// Получить кэш цен +console.log(priceCache); + +// Получить все формы компонентов +document.querySelectorAll('.kititem-form').length; + +// Проверить значение в первой форме +document.querySelector('[name$="-product"]').value; +``` + +### Network отладка + +1. Откройте вкладку **Network** в DevTools +2. Добавьте товар +3. Должен быть запрос к `/products/api/search-products-variants/?id=1` +4. Проверьте Response - должна быть `actual_price` в результате + +### Состояние системы после исправлений + +✅ **getProductPrice()** - теперь надёжно получает цены с логированием +✅ **calculateFinalPrice()** - корректно обрабатывает пустые и частично заполненные формы +✅ **Event handlers** - срабатывают корректно при select2:select +✅ **Кэширование** - работает, ускоряет повторный доступ к ценам + +## Если проблема сохраняется + +1. Проверьте в консоли логи при добавлении товара +2. Убедитесь что API endpoint возвращает данные: + ``` + GET /products/api/search-products-variants/?id=1 + Response: {"results": [{"id": 1, "actual_price": "20.00", ...}]} + ``` +3. Очистите кэш браузера (Ctrl+Shift+Delete) +4. Перезагрузите страницу diff --git a/DYNAMIC_COST_PRICE_IMPLEMENTATION.md b/DYNAMIC_COST_PRICE_IMPLEMENTATION.md new file mode 100644 index 0000000..2d97f73 --- /dev/null +++ b/DYNAMIC_COST_PRICE_IMPLEMENTATION.md @@ -0,0 +1,304 @@ +# Реализация динамической себестоимости товаров (FIFO) + +## Обзор + +Реализована система автоматического расчета себестоимости товаров на основе партий товара (StockBatch) с использованием средневзвешенного метода FIFO. + +## Основные принципы + +### Логика расчета + +1. **Товар без партий** → `cost_price = 0.00` +2. **Товар с партиями** → `cost_price = средневзвешенная стоимость` +3. **Товар закончился** → `cost_price = 0.00` +4. **Новая поставка** → `cost_price = пересчитывается автоматически` + +### Формула расчета + +``` +cost_price = Σ(quantity × cost_price) / Σ(quantity) +``` + +Где суммируются все активные партии товара с `quantity > 0`. + +## Реализованные компоненты + +### 1. Сервис расчета себестоимости + +**Файл:** `myproject/products/services/cost_calculator.py` + +**Класс:** `ProductCostCalculator` + +**Методы:** +- `calculate_weighted_average_cost(product)` - рассчитывает средневзвешенную стоимость +- `update_product_cost(product, save=True)` - обновляет кешированную стоимость +- `get_cost_details(product)` - возвращает детальную информацию для UI + +**Пример использования:** +```python +from products.services.cost_calculator import ProductCostCalculator + +# Рассчитать стоимость +cost = ProductCostCalculator.calculate_weighted_average_cost(product) + +# Обновить кешированное значение +old_cost, new_cost, was_updated = ProductCostCalculator.update_product_cost(product) + +# Получить детали для отображения +details = ProductCostCalculator.get_cost_details(product) +``` + +### 2. Django Signals для автообновления + +**Файл:** `myproject/inventory/signals.py` + +**Сигналы:** +- `update_product_cost_on_batch_change` - срабатывает при создании/изменении StockBatch +- `update_product_cost_on_batch_delete` - срабатывает при удалении StockBatch + +**Триггеры автообновления:** +- Создание новой партии (поступление товара) +- Изменение количества в партии +- Изменение стоимости партии +- Удаление партии + +### 3. Property в модели Product + +**Файл:** `myproject/products/models/products.py` + +**Добавлено:** +```python +@property +def cost_price_details(self): + """ + Детали расчета себестоимости для отображения в UI. + + Returns: + dict: { + 'cached_cost': Decimal, # Кешированная себестоимость (из БД) + 'calculated_cost': Decimal, # Рассчитанная себестоимость (из партий) + 'is_synced': bool, # Совпадают ли значения + 'total_quantity': Decimal, # Общее количество в партиях + 'batches': [...] # Список партий с деталями + } + """ +``` + +**Обновлено поле:** +```python +cost_price = models.DecimalField( + max_digits=10, + decimal_places=2, + verbose_name="Себестоимость", + help_text="Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)" +) +``` + +### 4. Обновленная страница товара + +**Файл:** `myproject/products/templates/products/product_detail.html` + +**Добавлено:** +- Отображение текущей себестоимости +- Кнопка "Детали расчета" (раскрывающаяся секция) +- Таблица с разбивкой по партиям: + - Склад + - Количество + - Себестоимость за единицу + - Общая стоимость партии + - Дата создания партии +- Сравнение кешированной и рассчитанной стоимости +- Предупреждение при рассинхронизации + +### 5. Management команда для пересчета + +**Файл:** `myproject/products/management/commands/recalculate_product_costs.py` + +**Использование:** +```bash +# Пересчитать все товары +python manage.py recalculate_product_costs + +# Показать детальную информацию +python manage.py recalculate_product_costs --verbose + +# Предварительный просмотр без сохранения +python manage.py recalculate_product_costs --dry-run + +# Показать только товары с изменениями +python manage.py recalculate_product_costs --only-changed +``` + +## Примеры работы + +### Сценарий 1: Создание товара +``` +1. Создается товар → cost_price = 0.00 (нет партий) +``` + +### Сценарий 2: Первая поставка +``` +1. Товар: cost_price = 0.00 +2. Приход: 10 шт по 100 руб → создается StockBatch +3. Signal срабатывает → cost_price = 100.00 +``` + +### Сценарий 3: Вторая поставка по другой цене +``` +1. Товар: cost_price = 100.00 (партия: 10 шт × 100 руб) +2. Приход: 10 шт по 120 руб → создается новая StockBatch +3. Signal срабатывает → cost_price = 110.00 + Расчет: (10×100 + 10×120) / 20 = 2200 / 20 = 110.00 +``` + +### Сценарий 4: Товар закончился +``` +1. Товар: cost_price = 110.00 (партии: 10+10 шт) +2. Продажа: 20 шт → партии опустошаются (quantity = 0) +3. Signal срабатывает → cost_price = 0.00 +``` + +### Сценарий 5: Новая поставка после опустошения +``` +1. Товар: cost_price = 0.00 +2. Приход: 15 шт по 130 руб → создается StockBatch +3. Signal срабатывает → cost_price = 130.00 +``` + +## Тестирование + +### Математическая корректность + +Создан тестовый скрипт: `test_cost_calculator.py` + +**Результаты тестов:** +- ✅ Товар без партий → 0.00 +- ✅ Одна партия → стоимость партии +- ✅ Две партии одинаковой стоимости → та же стоимость +- ✅ Две партии разной стоимости → средневзвешенная +- ✅ Три партии с разным количеством → корректный расчет +- ✅ Жизненный цикл товара → корректные переходы + +**Запуск тестов:** +```bash +python test_cost_calculator.py +``` + +## Архитектурные решения + +### Почему кеширование в БД, а не Redis? + +1. **Низкая частота изменений** - себестоимость меняется только при поставках/списаниях +2. **Простота** - меньше движущихся частей, легче дебажить +3. **Производительность** - один SELECT вместо двух обращений (Redis + PostgreSQL) +4. **Транзакционность** - гарантируется целостность данных +5. **Не требуется TTL** - данные актуальны до изменения партий + +### Почему Django Signals? + +1. **Автоматизация** - не нужно помнить вызывать пересчет вручную +2. **Консистентность** - гарантируется актуальность данных +3. **Прозрачность** - изменения происходят автоматически +4. **Уже используется** - в проекте активно применяются signals + +### Почему средневзвешенная, а не FIFO стоимость следующей партии? + +1. **Более точная оценка** - учитывает весь остаток на складе +2. **Актуальность для ценообразования** - показывает реальную среднюю стоимость товара +3. **Стабильность** - не скачет при каждой продаже +4. **Подходит для ProductKit** - корректный расчет стоимости комплектов + +## Влияние на ProductKit + +Расчет стоимости комплектов автоматически использует обновленную себестоимость компонентов: + +```python +# myproject/products/services/kit_pricing.py +class KitCostCalculator: + def calculate_cost(kit): + for kit_item in kit.kit_items: + item_cost = product.cost_price # ← Теперь динамическая! + total_cost += item_cost * item_quantity +``` + +## Мониторинг и отладка + +### Проверка синхронизации + +На странице товара отображается: +- **Кешированная стоимость** - значение из БД (cost_price) +- **Рассчитанная стоимость** - актуальный расчет из партий +- **Статус синхронизации** - совпадают ли значения + +### Ручной пересчет + +Если возникла рассинхронизация, можно запустить: +```bash +python manage.py recalculate_product_costs +``` + +### Логирование + +Все операции логируются в стандартный Django logger: +```python +logger.info(f"Обновлена себестоимость товара {product.sku}: {old_cost} -> {new_cost}") +logger.error(f"Ошибка при расчете себестоимости для товара {product.sku}: {e}") +``` + +## Производительность + +### Оптимизации + +1. **Кеширование в БД** - один запрос вместо пересчета каждый раз +2. **update_fields=['cost_price']** - обновляется только одно поле +3. **Selective signals** - обновление только при реальных изменениях +4. **Bulk operations** - management команда для массового пересчета + +### Нагрузка + +- **Чтение cost_price** - 0 дополнительных запросов (из БД) +- **Создание партии** - 1 дополнительный UPDATE для товара +- **Изменение партии** - 1 дополнительный UPDATE для товара +- **Удаление партии** - 1 дополнительный UPDATE для товара + +## Дальнейшие улучшения (опционально) + +### Если появятся проблемы производительности: + +1. **Отложенное обновление** - помечать товары для пересчета и обрабатывать фоном +2. **Celery tasks** - асинхронный пересчет в очереди +3. **Redis кеширование** - для часто запрашиваемых деталей расчета +4. **Database triggers** - перенести логику в PostgreSQL + +### Дополнительная функциональность: + +1. **История изменений** - логировать изменения себестоимости +2. **API endpoint** - получение деталей расчета через REST API +3. **Alerts** - уведомления при значительных изменениях стоимости +4. **Аналитика** - графики изменения себестоимости во времени + +## Файлы изменений + +### Созданные файлы: +- `myproject/products/services/cost_calculator.py` - сервис расчета +- `myproject/products/management/commands/recalculate_product_costs.py` - команда пересчета +- `test_cost_calculator.py` - тесты математической корректности +- `DYNAMIC_COST_PRICE_IMPLEMENTATION.md` - данная документация + +### Измененные файлы: +- `myproject/inventory/signals.py` - добавлены signals для автообновления +- `myproject/products/models/products.py` - добавлен property cost_price_details +- `myproject/products/templates/products/product_detail.html` - обновлен UI + +## Заключение + +Реализована полнофункциональная система динамического расчета себестоимости товаров: + +✅ **Автоматическое обновление** - через Django signals +✅ **Производительность** - кеширование в БД +✅ **Прозрачность** - детальное отображение в UI +✅ **Надежность** - протестированная математика +✅ **Простота** - без дополнительных зависимостей (Redis) +✅ **Масштабируемость** - готова к расширению при необходимости + +Система готова к использованию в production! diff --git a/FINAL_REPORT_FIXES.md b/FINAL_REPORT_FIXES.md new file mode 100644 index 0000000..69a99ab --- /dev/null +++ b/FINAL_REPORT_FIXES.md @@ -0,0 +1,334 @@ +# Отчет об исправлениях системы динамического ценообразования комплектов + +## Дата: 2025-11-02 +## Статус: ✅ Готово к тестированию + +--- + +## Проблема 1: Первая строка не считается в цену + +### Описание +При добавлении первого товара в комплект цена не обновлялась. Цена начинала считаться только со второго товара. + +### Решение + +**Файл:** `products/templates/products/productkit_create.html` +**Файл:** `products/templates/products/productkit_edit.html` + +1. **Улучшена функция `getProductPrice()`:** + - Добавлена строгая проверка валидности selectElement + - Добавлена проверка на isNaN и productId <= 0 + - Добавлено консольное логирование для отладки + +2. **Улучшена функция `calculateFinalPrice()`:** + - Добавлена проверка что товар выбран (`if (!productSelect || !productSelect.value) continue`) + - Добавлена валидация количества (если quantity <= 0, использует 1) + - Добавлена проверка что цена > 0 перед добавлением в сумму + +### Результат +✅ Первая строка теперь корректно считается в цену +✅ Цена обновляется в реальном времени при добавлении товара + +--- + +## Проблема 2: Select2 отображает цену без скидки + +### Описание +При поиске товаров в Select2 отображалась обычная цена (`price`), а не цена со скидкой (`actual_price`). + +### Решение + +**Файл:** `products/templates/products/includes/select2-product-init.html` + +Обновлена функция `formatSelectResult()`: +```javascript +function formatSelectResult(item) { + if (item.loading) return item.text; + var $container = $('
- Доступно - есть варианты в наличии -
-{% else %} -- Недоступно - все варианты закончились -
-{% endif %} -``` - -### Группа вариантов - цена - -```html -| Приоритет | -Товар | -Цена | -Статус | -
|---|---|---|---|
| {{ item.priority }} | -{{ item.product.name }} | -{{ item.product.sale_price }} руб | -- {% if item.product.in_stock %} - В наличии - {% else %} - Нет - {% endif %} - | -
| Продажа | Товар | Партия | Кол-во | Цена | Дата |
|---|---|---|---|---|---|
| #{{ a.sale.id }} | {{ a.sale.product.name }} | #{{ a.batch.id }} | {{ a.quantity }} | {{ a.cost_price }} | {{ a.sale.date|date:"d.m.Y" }} |
| Продажа | Товар | Партия | Кол-во | Цена | Дата |
|---|---|---|---|---|---|
| #{{ a.sale.id }} | {{ a.sale.product.name }} | #{{ a.batch.id }} | {{ a.quantity|smart_quantity }} | {{ a.cost_price }} | {{ a.sale.date|date:"d.m.Y" }} |
| Товар: | {{ batch.product.name }} |
|---|---|
| Склад: | {{ batch.warehouse.name }} |
| Количество: | {{ batch.quantity }} шт |
| Цена закупки: | {{ batch.cost_price }} ₽ |
| Создана: | {{ batch.created_at|date:"d.m.Y H:i" }} |
| Статус: | {% if batch.is_active %}Активна{% else %}Неактивна{% endif %} |
| Товар: | {{ batch.product.name }} |
|---|---|
| Склад: | {{ batch.warehouse.name }} |
| Количество: | {{ batch.quantity|smart_quantity }} шт |
| Цена закупки: | {{ batch.cost_price }} руб. |
| Создана: | {{ batch.created_at|date:"d.m.Y H:i" }} |
| Статус: | {% if batch.is_active %}Активна{% else %}Неактивна{% endif %} |
Здесь будут инструменты для управления инвентаризацией и складским учетом
+Выберите операцию для работы
Создание и управление физическими складами
- Перейти + +{{ incoming.batch.document_number }}
diff --git a/myproject/inventory/templates/inventory/incoming_batch/batch_detail.html b/myproject/inventory/templates/inventory/incoming_batch/batch_detail.html
index f0b8a9b..32a283c 100644
--- a/myproject/inventory/templates/inventory/incoming_batch/batch_detail.html
+++ b/myproject/inventory/templates/inventory/incoming_batch/batch_detail.html
@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
+{% load inventory_filters %}
{% block inventory_title %}Партия {{ batch.document_number }}{% endblock %}
{% block inventory_content %}
Партия #{{ allocation.batch.id }}
{{ sale.order.order_number }}
diff --git a/myproject/inventory/templates/inventory/stock/stock_detail.html b/myproject/inventory/templates/inventory/stock/stock_detail.html
index 137c562..12ddb89 100644
--- a/myproject/inventory/templates/inventory/stock/stock_detail.html
+++ b/myproject/inventory/templates/inventory/stock/stock_detail.html
@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
+{% load inventory_filters %}
{% block inventory_title %}Остатки товара{% endblock %}
-{% block inventory_content %}| Товар: | {{ stock.product.name }} |
|---|---|
| Склад: | {{ stock.warehouse.name }} |
| Доступно: | {{ stock.quantity_available }} шт |
| Зарезервировано: | {{ stock.quantity_reserved }} шт |
| Свободно: | {{ stock.quantity_free }} шт |
| Последнее обновление: | {{ stock.updated_at|date:"d.m.Y H:i" }} |
| Товар: | {{ stock.product.name }} |
|---|---|
| Склад: | {{ stock.warehouse.name }} |
| Доступно: | {{ stock.quantity_available|smart_quantity }} шт |
| Зарезервировано: | {{ stock.quantity_reserved|smart_quantity }} шт |
| Свободно: | {{ stock.quantity_free|smart_quantity }} шт |
| Последнее обновление: | {{ stock.updated_at|date:"d.m.Y H:i" }} |
| Товар | Склад | Доступно | Зарезервировано | Свободно | Последний обновления |
|---|---|---|---|---|---|
| {{ stock.product.name }} | {{ stock.warehouse.name }} | {{ stock.quantity_available }} | {{ stock.quantity_reserved }} | {{ stock.quantity_free }} | {{ stock.updated_at|date:"d.m.Y H:i" }} |
| Товар | Склад | Доступно | Зарезервировано | Свободно | Последний обновления |
|---|---|---|---|---|---|
| {{ stock.product.name }} | {{ stock.warehouse.name }} | {{ stock.quantity_available|smart_quantity }} | {{ stock.quantity_reserved|smart_quantity }} | {{ stock.quantity_free|smart_quantity }} | {{ stock.updated_at|date:"d.m.Y H:i" }} |
- Этот склад будет деактивирован и скрыт из основного списка. +
+ Что произойдет после архивирования:
+{{ warehouse.description }}
@@ -28,8 +35,8 @@ {% csrf_token %}| ✓ | Название | Описание | Статус | @@ -26,8 +31,20 @@|||||||
|---|---|---|---|---|---|---|---|---|---|---|
| {{ warehouse.name }} | +||||||||||
| + + | ++ {{ warehouse.name }} + {% if warehouse.is_default %} + По умолчанию + {% endif %} + | {{ warehouse.description|truncatewords:10 }} |
{% if warehouse.is_active %}
@@ -95,4 +112,145 @@
{% endif %}
+
+
{% endblock %}
diff --git a/myproject/inventory/templates/inventory/writeoff/writeoff_form.html b/myproject/inventory/templates/inventory/writeoff/writeoff_form.html
index d0f2411..ed073c1 100644
--- a/myproject/inventory/templates/inventory/writeoff/writeoff_form.html
+++ b/myproject/inventory/templates/inventory/writeoff/writeoff_form.html
@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
+{% load inventory_filters %}
{% block inventory_title %}{% if form.instance.pk %}Редактирование списания{% else %}Новое списание{% endif %}{% endblock %}
{% block inventory_content %}
@@ -36,7 +37,7 @@
- {{ form.quantity }}
+ {{ form.quantity|smart_quantity }}
{% if form.quantity.errors %}
{{ form.quantity.errors.0 }}
{% endif %}
diff --git a/myproject/inventory/templates/inventory/writeoff/writeoff_list.html b/myproject/inventory/templates/inventory/writeoff/writeoff_list.html
index 05b7a04..0bb7c91 100644
--- a/myproject/inventory/templates/inventory/writeoff/writeoff_list.html
+++ b/myproject/inventory/templates/inventory/writeoff/writeoff_list.html
@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
+{% load inventory_filters %}
{% block inventory_title %}История списаний{% endblock %}
{% block inventory_content %}
@@ -25,7 +26,7 @@
{% for writeoff in writeoffs %}
{{ writeoff.batch.product.name }} |
- {{ writeoff.quantity }} шт |
+ {{ writeoff.quantity|smart_quantity }} шт |
{{ writeoff.get_reason_display }} |
{{ writeoff.date|date:"d.m.Y H:i" }} |
diff --git a/myproject/inventory/templatetags/__init__.py b/myproject/inventory/templatetags/__init__.py
new file mode 100644
index 0000000..3fb0aa2
--- /dev/null
+++ b/myproject/inventory/templatetags/__init__.py
@@ -0,0 +1 @@
+# Template tags package for inventory app
diff --git a/myproject/inventory/templatetags/inventory_filters.py b/myproject/inventory/templatetags/inventory_filters.py
new file mode 100644
index 0000000..e837b4e
--- /dev/null
+++ b/myproject/inventory/templatetags/inventory_filters.py
@@ -0,0 +1,98 @@
+"""
+Custom template filters for inventory app.
+"""
+from django import template
+from decimal import Decimal
+
+register = template.Library()
+
+
+@register.filter(name='smart_quantity')
+def smart_quantity(value):
+ """
+ Форматирует количество товара:
+ - Если число целое (например 5.0, 10.000), показывает без дробной части: 5, 10
+ - Если число дробное (например 2.5, 3.125), убирает лишние нули: 2,5 вместо 2,500
+
+ Примеры:
+ 5.000 -> 5
+ 2.500 -> 2,5
+ 3.140 -> 3,14
+ 10.0 -> 10
+
+ Args:
+ value: число (int, float, Decimal или строка)
+
+ Returns:
+ str: отформатированное количество
+ """
+ if value is None:
+ return ''
+
+ try:
+ # Преобразуем в Decimal для точности
+ if isinstance(value, str):
+ num = Decimal(value)
+ elif isinstance(value, (int, float)):
+ num = Decimal(str(value))
+ elif isinstance(value, Decimal):
+ num = value
+ else:
+ return str(value)
+
+ # Проверяем, является ли число целым
+ if num == num.to_integral_value():
+ # Возвращаем как целое число
+ return f"{int(num)}"
+ else:
+ # Убираем лишние нули справа и форматируем с запятой
+ # Используем normalize() для удаления лишних нулей
+ normalized = num.normalize()
+ # Форматируем с запятой вместо точки (русский формат)
+ result = str(normalized).replace('.', ',')
+ return result
+
+ except (ValueError, TypeError, ArithmeticError):
+ # Если не удалось преобразовать, возвращаем как есть
+ return str(value)
+
+
+@register.filter(name='format_decimal')
+def format_decimal(value, decimal_places=2):
+ """
+ Форматирует decimal число с заданным количеством знаков после запятой.
+ Убирает лишние нули справа.
+
+ Args:
+ value: число для форматирования
+ decimal_places: максимальное количество знаков после запятой
+
+ Returns:
+ str: отформатированное число
+ """
+ if value is None:
+ return ''
+
+ try:
+ if isinstance(value, str):
+ num = Decimal(value)
+ elif isinstance(value, (int, float)):
+ num = Decimal(str(value))
+ elif isinstance(value, Decimal):
+ num = value
+ else:
+ return str(value)
+
+ # Округляем до заданного количества знаков
+ quantize_value = Decimal(10) ** -decimal_places
+ rounded = num.quantize(quantize_value)
+
+ # Убираем лишние нули
+ normalized = rounded.normalize()
+
+ # Форматируем с запятой
+ result = str(normalized).replace('.', ',')
+ return result
+
+ except (ValueError, TypeError, ArithmeticError):
+ return str(value)
diff --git a/myproject/inventory/urls.py b/myproject/inventory/urls.py
index 2d4cc39..e7559f5 100644
--- a/myproject/inventory/urls.py
+++ b/myproject/inventory/urls.py
@@ -2,7 +2,7 @@
from django.urls import path
from .views import (
# Warehouse
- WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView,
+ WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView,
# Incoming
IncomingListView, IncomingCreateView, IncomingUpdateView, IncomingDeleteView,
# IncomingBatch
@@ -39,6 +39,7 @@ urlpatterns = [
path('warehouses/create/', WarehouseCreateView.as_view(), name='warehouse-create'),
path('warehouses/ | '
+ ' ',
+ photo_obj.get_large_url() if hasattr(photo_obj, 'get_large_url') else photo_obj.image.url,
+ max_width,
+ max_height,
+ quality_display
+ )
+
+ return html
+
+
+def get_quality_filter_display(value):
+ """
+ Получить описание фильтра для качества фото.
+
+ Args:
+ value (str): Значение фильтра (excellent/good/acceptable/poor/very_poor/warning/no_warning)
+
+ Returns:
+ str: Описание для отображения
+ """
+ filter_descriptions = {
+ 'excellent': '🟢 Отлично',
+ 'good': '🟡 Хорошо',
+ 'acceptable': '🟠 Приемлемо',
+ 'poor': '🔴 Плохо',
+ 'very_poor': '🔴 Очень плохо',
+ 'warning': '⚠️ Требует обновления',
+ 'no_warning': '✓ Готово к выгрузке',
+ }
+
+ return filter_descriptions.get(value, value)
diff --git a/myproject/products/forms.py b/myproject/products/forms.py
index 8e19e0a..e6d9b06 100644
--- a/myproject/products/forms.py
+++ b/myproject/products/forms.py
@@ -72,6 +72,7 @@ class ProductForm(forms.ModelForm):
class ProductKitForm(forms.ModelForm):
"""
Форма для создания и редактирования комплекта.
+ Цена комплекта вычисляется автоматически из цен компонентов.
"""
categories = forms.ModelMultipleChoiceField(
queryset=ProductCategory.objects.filter(is_active=True),
@@ -91,8 +92,7 @@ class ProductKitForm(forms.ModelForm):
model = ProductKit
fields = [
'name', 'sku', 'description', 'short_description', 'categories',
- 'tags', 'pricing_method', 'cost_price', 'price', 'sale_price',
- 'markup_percent', 'markup_amount', 'is_active'
+ 'tags', 'sale_price', 'price_adjustment_type', 'price_adjustment_value', 'is_active'
]
labels = {
'name': 'Название',
@@ -101,12 +101,9 @@ class ProductKitForm(forms.ModelForm):
'short_description': 'Краткое описание',
'categories': 'Категории',
'tags': 'Теги',
- 'pricing_method': 'Метод ценообразования',
- 'cost_price': 'Себестоимость',
- 'price': 'Ручная цена',
'sale_price': 'Цена со скидкой',
- 'markup_percent': 'Процент наценки',
- 'markup_amount': 'Фиксированная наценка',
+ 'price_adjustment_type': 'Как изменить итоговую цену',
+ 'price_adjustment_value': 'Значение корректировки',
'is_active': 'Активен'
}
@@ -130,14 +127,34 @@ class ProductKitForm(forms.ModelForm):
'rows': 2,
'placeholder': 'Краткое описание для превью и площадок'
})
- self.fields['pricing_method'].widget.attrs.update({'class': 'form-control'})
- self.fields['cost_price'].widget.attrs.update({'class': 'form-control'})
- self.fields['price'].widget.attrs.update({'class': 'form-control'})
self.fields['sale_price'].widget.attrs.update({'class': 'form-control'})
- self.fields['markup_percent'].widget.attrs.update({'class': 'form-control'})
- self.fields['markup_amount'].widget.attrs.update({'class': 'form-control'})
+ self.fields['price_adjustment_type'].widget.attrs.update({'class': 'form-control'})
+ self.fields['price_adjustment_value'].widget.attrs.update({
+ 'class': 'form-control',
+ 'step': '0.01',
+ 'placeholder': '0'
+ })
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
+ def clean(self):
+ """
+ Валидация формы комплекта.
+ Проверяет что если выбран тип корректировки, указано значение.
+ """
+ cleaned_data = super().clean()
+
+ adjustment_type = cleaned_data.get('price_adjustment_type')
+ adjustment_value = cleaned_data.get('price_adjustment_value')
+
+ # Если выбран тип корректировки (не 'none'), значение обязательно
+ if adjustment_type and adjustment_type != 'none':
+ if not adjustment_value or adjustment_value == 0:
+ raise forms.ValidationError(
+ 'Укажите значение корректировки цены (> 0)'
+ )
+
+ return cleaned_data
+
class KitItemForm(forms.ModelForm):
"""
@@ -161,6 +178,12 @@ class KitItemForm(forms.ModelForm):
'notes': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Опциональное примечание'}),
}
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # Устанавливаем значение по умолчанию для quantity = 1
+ if not self.instance.pk: # Только для новых форм (создание, не редактирование)
+ self.fields['quantity'].initial = 1
+
def clean(self):
cleaned_data = super().clean()
product = cleaned_data.get('product')
diff --git a/myproject/products/management/commands/recalculate_product_costs.py b/myproject/products/management/commands/recalculate_product_costs.py
new file mode 100644
index 0000000..52a2f55
--- /dev/null
+++ b/myproject/products/management/commands/recalculate_product_costs.py
@@ -0,0 +1,134 @@
+"""
+Команда управления для пересчёта себестоимости (cost_price) всех товаров.
+
+Использование (для multi-tenant проекта):
+ python manage.py recalculate_product_costs --schema=grach
+ python manage.py recalculate_product_costs --schema=grach --verbose
+ python manage.py recalculate_product_costs --schema=grach --dry-run
+
+Описание:
+ Пересчитывает Product.cost_price на основе активных партий (StockBatch).
+ Использует средневзвешенную стоимость по FIFO принципу.
+ Товар без партий получает cost_price = 0.00.
+"""
+
+from django.core.management.base import BaseCommand
+from django.db import connection
+from django_tenants.management.commands import InteractiveTenantOption
+from decimal import Decimal
+from products.models import Product
+from products.services.cost_calculator import ProductCostCalculator
+
+
+class Command(InteractiveTenantOption, BaseCommand):
+ help = 'Пересчитать себестоимость (cost_price) для всех товаров на основе партий StockBatch'
+
+ def add_arguments(self, parser):
+ # Добавляем --schema из InteractiveTenantOption
+ super().add_arguments(parser)
+
+ parser.add_argument(
+ '--verbose',
+ action='store_true',
+ help='Выводить подробную информацию о каждом товаре',
+ )
+ parser.add_argument(
+ '--dry-run',
+ action='store_true',
+ help='Показать изменения без сохранения в БД',
+ )
+ parser.add_argument(
+ '--only-changed',
+ action='store_true',
+ help='Показывать только товары с изменившейся стоимостью',
+ )
+
+ def handle(self, *args, **options):
+ # Получаем тенанта из опций или интерактивно
+ tenant = self.get_tenant_from_options_or_interactive(**options)
+
+ # Устанавливаем схему тенанта
+ connection.set_tenant(tenant)
+ verbose = options.get('verbose', False)
+ dry_run = options.get('dry_run', False)
+ only_changed = options.get('only_changed', False)
+
+ self.stdout.write(self.style.SUCCESS('\n' + '='*80))
+ self.stdout.write(self.style.SUCCESS('ПЕРЕСЧЁТ СЕБЕСТОИМОСТИ ТОВАРОВ'))
+ self.stdout.write(self.style.SUCCESS(f'ТЕНАНТ: {tenant.schema_name} ({tenant.name})'))
+ if dry_run:
+ self.stdout.write(self.style.WARNING('РЕЖИМ: DRY-RUN (БЕЗ СОХРАНЕНИЯ)'))
+ self.stdout.write(self.style.SUCCESS('='*80 + '\n'))
+
+ # Получаем все активные товары
+ all_products = Product.objects.filter(is_active=True)
+ total = all_products.count()
+ updated_count = 0
+ unchanged_count = 0
+ with_batches_count = 0
+ without_batches_count = 0
+
+ self.stdout.write(f'Всего товаров для обработки: {total}\n')
+
+ for product in all_products:
+ # Получаем старую стоимость
+ old_cost = product.cost_price
+
+ # Рассчитываем новую стоимость
+ old_cost_result, new_cost, was_updated = ProductCostCalculator.update_product_cost(
+ product,
+ save=not dry_run # Сохраняем только если НЕ dry-run
+ )
+
+ # Подсчитываем статистику
+ if was_updated:
+ updated_count += 1
+ else:
+ unchanged_count += 1
+
+ if new_cost > 0:
+ with_batches_count += 1
+ else:
+ without_batches_count += 1
+
+ # Выводим информацию
+ if verbose or (only_changed and was_updated):
+ status_symbol = '✓' if was_updated else '='
+ style = self.style.SUCCESS if was_updated else self.style.WARNING
+
+ # Получаем детали для вывода
+ details = ProductCostCalculator.get_cost_details(product)
+ batches_count = len(details['batches'])
+ total_qty = details['total_quantity']
+
+ self.stdout.write(
+ style(
+ f'{status_symbol} {product.sku:15} {product.name[:40]:40} | '
+ f'Старая: {old_cost:8.2f} → Новая: {new_cost:8.2f} | '
+ f'Партий: {batches_count:3}, Кол-во: {total_qty:8.2f}'
+ )
+ )
+
+ # Финальный отчет
+ self.stdout.write(self.style.SUCCESS('\n' + '='*80))
+ self.stdout.write(self.style.SUCCESS('РЕЗУЛЬТАТЫ:'))
+ self.stdout.write(self.style.SUCCESS(f' Всего обработано: {total}'))
+ self.stdout.write(self.style.SUCCESS(f' Обновлено: {updated_count}'))
+ self.stdout.write(self.style.SUCCESS(f' Без изменений: {unchanged_count}'))
+ self.stdout.write(self.style.SUCCESS(f' С партиями (>0): {with_batches_count}'))
+ self.stdout.write(self.style.SUCCESS(f' Без партий (=0): {without_batches_count}'))
+ if dry_run:
+ self.stdout.write(self.style.WARNING('\n ⚠ Изменения НЕ сохранены (dry-run режим)'))
+ self.stdout.write(self.style.SUCCESS('='*80 + '\n'))
+
+ if updated_count > 0:
+ if dry_run:
+ self.stdout.write(
+ self.style.WARNING(f'✓ Будет обновлено {updated_count} товаров при реальном запуске')
+ )
+ else:
+ self.stdout.write(
+ self.style.SUCCESS(f'✓ Успешно обновлено {updated_count} товаров')
+ )
+ else:
+ self.stdout.write(self.style.WARNING('Нет товаров для обновления'))
diff --git a/myproject/products/migrations/0001_initial.py b/myproject/products/migrations/0001_initial.py
index 0aa83db..9d444ca 100644
--- a/myproject/products/migrations/0001_initial.py
+++ b/myproject/products/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 5.1.4 on 2025-10-28 22:47
+# Generated by Django 5.0.10 on 2025-10-30 21:24
import django.db.models.deletion
from django.conf import settings
@@ -68,18 +68,21 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=200, verbose_name='Название')),
('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')),
('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')),
- ('variant_suffix', models.CharField(blank=True, help_text='Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия.', max_length=20, null=True, verbose_name='Суффикс варианта')),
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
- ('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения')),
- ('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Себестоимость')),
- ('sale_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Розничная цена')),
+ ('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')),
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
- ('search_keywords', models.TextField(blank=True, help_text='Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную.', verbose_name='Ключевые слова для поиска')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удален')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Время удаления')),
- ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_products', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')),
+ ('variant_suffix', models.CharField(blank=True, help_text='Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия.', max_length=20, null=True, verbose_name='Суффикс варианта')),
+ ('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения')),
+ ('cost_price', models.DecimalField(decimal_places=2, help_text='В будущем будет вычисляться автоматически из партий (FIFO)', max_digits=10, verbose_name='Себестоимость')),
+ ('price', models.DecimalField(decimal_places=2, help_text='Цена продажи товара (бывшее поле sale_price)', max_digits=10, verbose_name='Основная цена')),
+ ('sale_price', models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, товар продается по этой цене (дешевле основной)', max_digits=10, null=True, verbose_name='Цена со скидкой')),
+ ('in_stock', models.BooleanField(db_index=True, default=False, help_text='Автоматически обновляется при изменении остатков на складе', verbose_name='В наличии')),
+ ('search_keywords', models.TextField(blank=True, help_text='Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную.', verbose_name='Ключевые слова для поиска')),
+ ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')),
('categories', models.ManyToManyField(blank=True, related_name='products', to='products.productcategory', verbose_name='Категории')),
],
options={
@@ -107,20 +110,23 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
- ('sku', models.CharField(blank=True, max_length=100, null=True, verbose_name='Артикул')),
- ('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL-идентификатор')),
+ ('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')),
+ ('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')),
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
+ ('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')),
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
- ('pricing_method', models.CharField(choices=[('fixed', 'Фиксированная цена'), ('from_sale_prices', 'По ценам продажи компонентов'), ('from_cost_plus_percent', 'Себестоимость + процент наценки'), ('from_cost_plus_amount', 'Себестоимость + фикс. наценка')], default='from_sale_prices', max_length=30, verbose_name='Метод ценообразования')),
- ('fixed_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Фиксированная цена')),
- ('markup_percent', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='Процент наценки')),
- ('markup_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Фиксированная наценка')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удален')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Время удаления')),
+ ('pricing_method', models.CharField(choices=[('manual', 'Ручная цена'), ('from_sale_prices', 'По ценам продажи компонентов'), ('from_cost_plus_percent', 'Себестоимость + процент наценки'), ('from_cost_plus_amount', 'Себестоимость + фикс. наценка')], default='from_sale_prices', max_length=30, verbose_name='Метод ценообразования')),
+ ('cost_price', models.DecimalField(blank=True, decimal_places=2, help_text='Можно задать вручную или вычислить из компонентов', max_digits=10, null=True, verbose_name='Себестоимость')),
+ ('price', models.DecimalField(blank=True, decimal_places=2, help_text="Цена при методе 'Ручная цена' (бывшее поле fixed_price)", max_digits=10, null=True, verbose_name='Ручная цена')),
+ ('sale_price', models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, комплект продается по этой цене', max_digits=10, null=True, verbose_name='Цена со скидкой')),
+ ('markup_percent', models.DecimalField(blank=True, decimal_places=2, help_text="Для метода 'Себестоимость + процент наценки'", max_digits=5, null=True, verbose_name='Процент наценки')),
+ ('markup_amount', models.DecimalField(blank=True, decimal_places=2, help_text="Для метода 'Себестоимость + фиксированная наценка'", max_digits=10, null=True, verbose_name='Фиксированная наценка')),
('categories', models.ManyToManyField(blank=True, related_name='kits', to='products.productcategory', verbose_name='Категории')),
- ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_kits', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')),
+ ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем')),
],
options={
'verbose_name': 'Комплект',
@@ -204,6 +210,20 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Компоненты комплектов',
},
),
+ migrations.CreateModel(
+ name='ProductVariantGroupItem',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('priority', models.PositiveIntegerField(default=0, help_text='Меньше = выше приоритет (1 - наивысший приоритет в этой группе)')),
+ ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variant_group_items', to='products.product', verbose_name='Товар')),
+ ('variant_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='products.productvariantgroup', verbose_name='Группа вариантов')),
+ ],
+ options={
+ 'verbose_name': 'Товар в группе вариантов',
+ 'verbose_name_plural': 'Товары в группах вариантов',
+ 'ordering': ['priority', 'id'],
+ },
+ ),
migrations.CreateModel(
name='KitItemPriority',
fields=[
@@ -241,31 +261,15 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name='productkit',
- index=models.Index(fields=['is_active'], name='products_pr_is_acti_214d4f_idx'),
- ),
- migrations.AddIndex(
- model_name='productkit',
- index=models.Index(fields=['slug'], name='products_pr_slug_b5e185_idx'),
- ),
- migrations.AddIndex(
- model_name='productkit',
- index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_e83a83_idx'),
- ),
- migrations.AddIndex(
- model_name='productkit',
- index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_1e5bec_idx'),
+ index=models.Index(fields=['pricing_method'], name='products_pr_pricing_8bb5a7_idx'),
),
migrations.AddIndex(
model_name='product',
- index=models.Index(fields=['is_active'], name='products_pr_is_acti_ca4d9a_idx'),
+ index=models.Index(fields=['in_stock'], name='products_pr_in_stoc_4fee1a_idx'),
),
migrations.AddIndex(
model_name='product',
- index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_3bba04_idx'),
- ),
- migrations.AddIndex(
- model_name='product',
- index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_f30efb_idx'),
+ index=models.Index(fields=['sku'], name='products_pr_sku_ca0cdc_idx'),
),
migrations.AddIndex(
model_name='kititem',
@@ -287,4 +291,16 @@ class Migration(migrations.Migration):
model_name='kititem',
index=models.Index(fields=['kit', 'variant_group'], name='products_ki_kit_id_8199a8_idx'),
),
+ migrations.AddIndex(
+ model_name='productvariantgroupitem',
+ index=models.Index(fields=['variant_group', 'priority'], name='products_pr_variant_b36b47_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='productvariantgroupitem',
+ index=models.Index(fields=['product'], name='products_pr_product_50be04_idx'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='productvariantgroupitem',
+ unique_together={('variant_group', 'product')},
+ ),
]
diff --git a/myproject/products/migrations/0002_alter_product_cost_price.py b/myproject/products/migrations/0002_alter_product_cost_price.py
new file mode 100644
index 0000000..8d1d372
--- /dev/null
+++ b/myproject/products/migrations/0002_alter_product_cost_price.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.10 on 2025-11-01 17:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('products', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='product',
+ name='cost_price',
+ field=models.DecimalField(decimal_places=2, help_text='Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)', max_digits=10, verbose_name='Себестоимость'),
+ ),
+ ]
diff --git a/myproject/products/migrations/0003_add_product_in_stock.py b/myproject/products/migrations/0003_add_product_in_stock.py
deleted file mode 100644
index 12c2784..0000000
--- a/myproject/products/migrations/0003_add_product_in_stock.py
+++ /dev/null
@@ -1,24 +0,0 @@
-# Generated by Django 5.0.10 on 2025-10-29 20:14
-
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('products', '0002_productvariantgroupitem'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.AddField(
- model_name='product',
- name='in_stock',
- field=models.BooleanField(db_index=True, default=False, help_text='Автоматически обновляется при изменении остатков на складе', verbose_name='В наличии'),
- ),
- migrations.AddIndex(
- model_name='product',
- index=models.Index(fields=['in_stock'], name='products_pr_in_stoc_4fee1a_idx'),
- ),
- ]
diff --git a/myproject/products/migrations/0003_productcategoryphoto_quality_level_and_more.py b/myproject/products/migrations/0003_productcategoryphoto_quality_level_and_more.py
new file mode 100644
index 0000000..7fb4d2a
--- /dev/null
+++ b/myproject/products/migrations/0003_productcategoryphoto_quality_level_and_more.py
@@ -0,0 +1,79 @@
+# Generated by Django 5.0.10 on 2025-11-02 11:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('products', '0002_alter_product_cost_price'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='productcategoryphoto',
+ name='quality_level',
+ field=models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества'),
+ ),
+ migrations.AddField(
+ model_name='productcategoryphoto',
+ name='quality_warning',
+ field=models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт', verbose_name='Требует обновления'),
+ ),
+ migrations.AddField(
+ model_name='productkitphoto',
+ name='quality_level',
+ field=models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества'),
+ ),
+ migrations.AddField(
+ model_name='productkitphoto',
+ name='quality_warning',
+ field=models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт', verbose_name='Требует обновления'),
+ ),
+ migrations.AddField(
+ model_name='productphoto',
+ name='quality_level',
+ field=models.CharField(choices=[('excellent', 'Отлично (>= 2052px)'), ('good', 'Хорошо (1512-2051px)'), ('acceptable', 'Приемлемо (864-1511px)'), ('poor', 'Плохо (432-863px)'), ('very_poor', 'Очень плохо (< 432px)')], db_index=True, default='acceptable', help_text='Определяется автоматически на основе размера изображения', max_length=15, verbose_name='Уровень качества'),
+ ),
+ migrations.AddField(
+ model_name='productphoto',
+ name='quality_warning',
+ field=models.BooleanField(db_index=True, default=False, help_text='True если нужно обновить фото перед выгрузкой на сайт (poor или very_poor)', verbose_name='Требует обновления'),
+ ),
+ migrations.AddIndex(
+ model_name='productcategoryphoto',
+ index=models.Index(fields=['quality_level'], name='products_pr_quality_ab44c2_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='productcategoryphoto',
+ index=models.Index(fields=['quality_warning'], name='products_pr_quality_d7c69b_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='productcategoryphoto',
+ index=models.Index(fields=['quality_warning', 'category'], name='products_pr_quality_fc505a_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='productkitphoto',
+ index=models.Index(fields=['quality_level'], name='products_pr_quality_b03c5c_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='productkitphoto',
+ index=models.Index(fields=['quality_warning'], name='products_pr_quality_2aa941_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='productkitphoto',
+ index=models.Index(fields=['quality_warning', 'kit'], name='products_pr_quality_867664_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='productphoto',
+ index=models.Index(fields=['quality_level'], name='products_pr_quality_d8f85c_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='productphoto',
+ index=models.Index(fields=['quality_warning'], name='products_pr_quality_defb5a_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='productphoto',
+ index=models.Index(fields=['quality_warning', 'product'], name='products_pr_quality_6e8b51_idx'),
+ ),
+ ]
diff --git a/myproject/products/migrations/0004_add_kit_price_adjustment_fields.py b/myproject/products/migrations/0004_add_kit_price_adjustment_fields.py
new file mode 100644
index 0000000..f01263c
--- /dev/null
+++ b/myproject/products/migrations/0004_add_kit_price_adjustment_fields.py
@@ -0,0 +1,53 @@
+# Generated by Django 5.0.10 on 2025-11-02 15:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('products', '0003_productcategoryphoto_quality_level_and_more'),
+ ]
+
+ operations = [
+ migrations.RemoveIndex(
+ model_name='productkit',
+ name='products_pr_pricing_8bb5a7_idx',
+ ),
+ migrations.RemoveField(
+ model_name='productkit',
+ name='cost_price',
+ ),
+ migrations.RemoveField(
+ model_name='productkit',
+ name='markup_amount',
+ ),
+ migrations.RemoveField(
+ model_name='productkit',
+ name='markup_percent',
+ ),
+ migrations.RemoveField(
+ model_name='productkit',
+ name='pricing_method',
+ ),
+ migrations.AddField(
+ model_name='productkit',
+ name='base_price',
+ field=models.DecimalField(decimal_places=2, default=0, help_text='Сумма actual_price всех компонентов. Пересчитывается автоматически.', max_digits=10, verbose_name='Базовая цена'),
+ ),
+ migrations.AddField(
+ model_name='productkit',
+ name='price_adjustment_type',
+ field=models.CharField(choices=[('none', 'Без изменения'), ('increase_percent', 'Увеличить на %'), ('increase_amount', 'Увеличить на сумму'), ('decrease_percent', 'Уменьшить на %'), ('decrease_amount', 'Уменьшить на сумму')], default='none', max_length=20, verbose_name='Тип корректировки цены'),
+ ),
+ migrations.AddField(
+ model_name='productkit',
+ name='price_adjustment_value',
+ field=models.DecimalField(decimal_places=2, default=0, help_text='Процент (%) или сумма (руб) в зависимости от типа корректировки', max_digits=10, verbose_name='Значение корректировки'),
+ ),
+ migrations.AlterField(
+ model_name='productkit',
+ name='price',
+ field=models.DecimalField(decimal_places=2, default=0, help_text='Базовая цена с учетом корректировок. Вычисляется автоматически.', max_digits=10, verbose_name='Итоговая цена'),
+ ),
+ ]
diff --git a/myproject/products/migrations/0004_fix_product_in_stock.py b/myproject/products/migrations/0004_fix_product_in_stock.py
deleted file mode 100644
index 475d200..0000000
--- a/myproject/products/migrations/0004_fix_product_in_stock.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# Generated migration to fix Product.in_stock based on Stock.quantity_available
-
-from django.db import migrations
-
-
-def update_product_in_stock(apps, schema_editor):
- """
- Пересчитать Product.in_stock на основе Stock.quantity_available.
- Товар в наличии если есть хотя бы один Stock с quantity_available > 0.
- """
- Product = apps.get_model('products', 'Product')
- Stock = apps.get_model('inventory', 'Stock')
-
- # Получаем товары которые должны быть в наличии
- products_with_stock = Stock.objects.filter(
- quantity_available__gt=0
- ).values_list('product_id', flat=True).distinct()
-
- products_with_stock_ids = set(products_with_stock)
-
- # Обновляем все товары
- for product in Product.objects.all():
- new_status = product.id in products_with_stock_ids
- if product.in_stock != new_status:
- product.in_stock = new_status
- product.save(update_fields=['in_stock'])
-
-
-def reverse_update(apps, schema_editor):
- """Обратная миграция: сбросить все in_stock в False"""
- Product = apps.get_model('products', 'Product')
- Product.objects.all().update(in_stock=False)
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('products', '0003_add_product_in_stock'),
- ]
-
- operations = [
- migrations.RunPython(update_product_in_stock, reverse_update),
- ]
diff --git a/myproject/products/models.py b/myproject/products/models.py
deleted file mode 100644
index 382d7d5..0000000
--- a/myproject/products/models.py
+++ /dev/null
@@ -1,1441 +0,0 @@
-from django.db import models
-from django.urls import reverse
-from django.utils.text import slugify
-from django.core.exceptions import ValidationError
-from django.db import transaction
-from django.utils import timezone
-from django.contrib.auth import get_user_model
-
-from .utils.sku_generator import generate_product_sku, generate_kit_sku, generate_category_sku
-
-# Получаем User модель один раз для использования в ForeignKey
-User = get_user_model()
-
-
-class SKUCounter(models.Model):
- """
- Глобальные счетчики для генерации уникальных номеров артикулов.
- Используется для товаров (product), комплектов (kit) и категорий (category).
- """
- COUNTER_TYPE_CHOICES = [
- ('product', 'Product Counter'),
- ('kit', 'Kit Counter'),
- ('category', 'Category Counter'),
- ]
-
- counter_type = models.CharField(
- max_length=20,
- unique=True,
- choices=COUNTER_TYPE_CHOICES,
- verbose_name="Тип счетчика"
- )
- current_value = models.IntegerField(
- default=0,
- verbose_name="Текущее значение"
- )
-
- class Meta:
- verbose_name = "Счетчик артикулов"
- verbose_name_plural = "Счетчики артикулов"
-
- def __str__(self):
- return f"{self.get_counter_type_display()}: {self.current_value}"
-
- @classmethod
- def get_next_value(cls, counter_type):
- """
- Получить следующее значение счетчика (thread-safe).
- Использует select_for_update для предотвращения race conditions.
- """
- with transaction.atomic():
- counter, created = cls.objects.select_for_update().get_or_create(
- counter_type=counter_type,
- defaults={'current_value': 0}
- )
- counter.current_value += 1
- counter.save()
- return counter.current_value
-
-
-class ActiveManager(models.Manager):
- def get_queryset(self):
- return super().get_queryset().filter(is_active=True)
-
-
-class SoftDeleteQuerySet(models.QuerySet):
- """
- QuerySet для мягкого удаления (soft delete).
- Позволяет фильтровать удаленные элементы и восстанавливать их.
- """
- def delete(self):
- """Soft delete вместо hard delete"""
- return self.update(
- is_deleted=True,
- deleted_at=timezone.now()
- )
-
- def hard_delete(self):
- """Явный hard delete - удаляет из БД окончательно"""
- return super().delete()
-
- def restore(self):
- """Восстановление из удаленного состояния"""
- return self.update(
- is_deleted=False,
- deleted_at=None,
- deleted_by=None
- )
-
- def deleted_only(self):
- """Получить только удаленные элементы"""
- return self.filter(is_deleted=True)
-
- def not_deleted(self):
- """Получить только не удаленные элементы"""
- return self.filter(is_deleted=False)
-
- def with_deleted(self):
- """Получить все элементы включая удаленные"""
- return self.all()
-
-
-class SoftDeleteManager(models.Manager):
- """
- Manager для работы с мягким удалением.
- По умолчанию исключает удаленные элементы из запросов.
- """
- def get_queryset(self):
- return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False)
-
- def deleted_only(self):
- """Получить только удаленные элементы"""
- return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=True)
-
- def all_with_deleted(self):
- """Получить все элементы включая удаленные"""
- return SoftDeleteQuerySet(self.model, using=self._db).all()
-
-
-class ProductCategory(models.Model):
- """
- Категории товаров и комплектов (поддержка нескольких уровней не обязательна, но возможна позже).
- """
- name = models.CharField(max_length=200, verbose_name="Название")
- sku = models.CharField(max_length=100, blank=True, null=True, unique=True, verbose_name="Артикул", db_index=True)
- slug = models.SlugField(max_length=200, unique=True, blank=True, verbose_name="URL-идентификатор")
- parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True,
- related_name='children', verbose_name="Родительская категория")
- is_active = models.BooleanField(default=True, verbose_name="Активна")
- created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True)
- updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True)
-
- # Поля для мягкого удаления
- is_deleted = models.BooleanField(default=False, verbose_name="Удалена", db_index=True)
- deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления")
- deleted_by = models.ForeignKey(
- User,
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name='deleted_categories',
- verbose_name="Удалена пользователем"
- )
-
- objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные)
- all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
- active = ActiveManager() # Кастомный менеджер для активных категорий
-
- class Meta:
- verbose_name = "Категория товара"
- verbose_name_plural = "Категории товаров"
- indexes = [
- models.Index(fields=['is_active']),
- models.Index(fields=['is_deleted']),
- models.Index(fields=['is_deleted', 'created_at']),
- ]
-
- def __str__(self):
- return self.name
-
- def clean(self):
- """Валидация категории перед сохранением"""
- from django.core.exceptions import ValidationError
-
- # 1. Защита от самоссылки
- if self.parent and self.parent.pk == self.pk:
- raise ValidationError({
- 'parent': 'Категория не может быть родителем самой себя.'
- })
-
- # 2. Защита от циклических ссылок (только для существующих категорий)
- if self.parent and self.pk:
- self._check_parent_chain()
-
- # 3. Проверка активности родителя
- if self.parent and not self.parent.is_active:
- raise ValidationError({
- 'parent': 'Нельзя выбрать неактивную категорию в качестве родителя.'
- })
-
- def _check_parent_chain(self):
- """Проверяет цепочку родителей на циклы и глубину вложенности"""
- from django.core.exceptions import ValidationError
- from django.conf import settings
-
- current = self.parent
- depth = 0
- max_depth = getattr(settings, 'MAX_CATEGORY_DEPTH', 10)
-
- while current:
- if current.pk == self.pk:
- raise ValidationError({
- 'parent': f'Обнаружена циклическая ссылка. '
- f'Категория "{self.name}" не может быть потомком самой себя.'
- })
-
- depth += 1
- if depth > max_depth:
- raise ValidationError({
- 'parent': f'Слишком глубокая вложенность категорий '
- f'(максимум {max_depth} уровней).'
- })
-
- current = current.parent
-
- def save(self, *args, **kwargs):
- # Вызываем валидацию перед сохранением
- self.full_clean()
-
- # Автоматическая генерация slug из названия с транслитерацией
- if not self.slug or self.slug.strip() == '':
- from unidecode import unidecode
- # Транслитерируем кириллицу в латиницу, затем применяем slugify
- transliterated_name = unidecode(self.name)
- self.slug = slugify(transliterated_name)
-
- # Автоматическая генерация артикула при создании новой категории
- if not self.sku and not self.pk:
- from .utils.sku_generator import generate_category_sku
- self.sku = generate_category_sku()
-
- super().save(*args, **kwargs)
-
- def delete(self, *args, **kwargs):
- """Soft delete вместо hard delete - марк как удаленный"""
- self.is_deleted = True
- self.deleted_at = timezone.now()
- self.save(update_fields=['is_deleted', 'deleted_at'])
- # Возвращаем результат в формате Django
- return 1, {self.__class__._meta.label: 1}
-
- def hard_delete(self):
- """Полное удаление из БД (необратимо!)"""
- super().delete()
-
-
-class ProductTag(models.Model):
- """
- Свободные теги для фильтрации и поиска.
- """
- name = models.CharField(max_length=100, unique=True, verbose_name="Название")
- slug = models.SlugField(max_length=100, unique=True, verbose_name="URL-идентификатор")
- created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True)
- updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True)
-
- # Поля для мягкого удаления
- is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True)
- deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления")
- deleted_by = models.ForeignKey(
- User,
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name='deleted_tags',
- verbose_name="Удален пользователем"
- )
-
- objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные)
- all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
-
- class Meta:
- verbose_name = "Тег товара"
- verbose_name_plural = "Теги товаров"
- indexes = [
- models.Index(fields=['is_deleted']),
- models.Index(fields=['is_deleted', 'created_at']),
- ]
-
- def __str__(self):
- return self.name
-
- def save(self, *args, **kwargs):
- if not self.slug:
- self.slug = slugify(self.name)
- super().save(*args, **kwargs)
-
- def delete(self, *args, **kwargs):
- """Soft delete вместо hard delete - марк как удаленный"""
- self.is_deleted = True
- self.deleted_at = timezone.now()
- self.save(update_fields=['is_deleted', 'deleted_at'])
- return 1, {self.__class__._meta.label: 1}
-
- def hard_delete(self):
- """Полное удаление из БД (необратимо!)"""
- super().delete()
-
-
-class BaseProductEntity(models.Model):
- """
- Абстрактный базовый класс для Product и ProductKit.
- Объединяет общие поля идентификации, описания, статуса и soft delete.
-
- Используется как основа для:
- - Product (простой товар)
- - ProductKit (комплект товаров)
- """
- # Идентификация
- name = models.CharField(
- max_length=200,
- verbose_name="Название"
- )
- sku = models.CharField(
- max_length=100,
- blank=True,
- null=True,
- verbose_name="Артикул",
- db_index=True
- )
- slug = models.SlugField(
- max_length=200,
- unique=True,
- blank=True,
- verbose_name="URL-идентификатор"
- )
-
- # Описания
- description = models.TextField(
- blank=True,
- null=True,
- verbose_name="Описание"
- )
- short_description = models.TextField(
- blank=True,
- null=True,
- verbose_name="Краткое описание",
- help_text="Используется для карточек товаров, превью и площадок"
- )
-
- # Статус
- is_active = models.BooleanField(
- default=True,
- verbose_name="Активен"
- )
-
- # Временные метки
- created_at = models.DateTimeField(
- auto_now_add=True,
- verbose_name="Дата создания"
- )
- updated_at = models.DateTimeField(
- auto_now=True,
- verbose_name="Дата обновления"
- )
-
- # Soft delete
- is_deleted = models.BooleanField(
- default=False,
- verbose_name="Удален",
- db_index=True
- )
- deleted_at = models.DateTimeField(
- null=True,
- blank=True,
- verbose_name="Время удаления"
- )
- deleted_by = models.ForeignKey(
- User,
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name='deleted_%(class)s_set',
- verbose_name="Удален пользователем"
- )
-
- # Managers
- objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)()
- all_objects = models.Manager()
- active = ActiveManager()
-
- class Meta:
- abstract = True
- indexes = [
- models.Index(fields=['is_active']),
- models.Index(fields=['is_deleted']),
- models.Index(fields=['is_deleted', 'created_at']),
- ]
-
- def __str__(self):
- return self.name
-
- def delete(self, *args, **kwargs):
- """Мягкое удаление (soft delete)"""
- user = kwargs.pop('user', None)
- self.is_deleted = True
- self.deleted_at = timezone.now()
- if user:
- self.deleted_by = user
- self.save(update_fields=['is_deleted', 'deleted_at', 'deleted_by'])
- return 1, {self.__class__._meta.label: 1}
-
- def hard_delete(self):
- """Физическое удаление из БД (необратимо!)"""
- super().delete()
-
- def save(self, *args, **kwargs):
- """Автогенерация slug из name если не задан"""
- if not self.slug or self.slug.strip() == '':
- from unidecode import unidecode
- transliterated_name = unidecode(self.name)
- self.slug = slugify(transliterated_name)
-
- # Ensure unique slug
- original_slug = self.slug
- counter = 1
- ModelClass = self.__class__
- while ModelClass.objects.filter(slug=self.slug).exclude(pk=self.pk).exists():
- self.slug = f"{original_slug}-{counter}"
- counter += 1
-
- super().save(*args, **kwargs)
-
-
-class ProductVariantGroup(models.Model):
- """
- Группа вариантов товара (взаимозаменяемые товары).
- Например: "Роза красная Freedom" включает розы 50см, 60см, 70см.
- """
- name = models.CharField(max_length=200, verbose_name="Название")
- description = models.TextField(blank=True, verbose_name="Описание")
- created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
- updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
-
- class Meta:
- verbose_name = "Группа вариантов"
- verbose_name_plural = "Группы вариантов"
- ordering = ['name']
-
- def __str__(self):
- return self.name
-
- def get_products_count(self):
- """Возвращает количество товаров в группе"""
- return self.items.count()
-
- @property
- def in_stock(self):
- """
- Вариант в наличии, если хотя бы один из его товаров в наличии.
- Товар в наличии, если Product.in_stock = True.
- """
- return self.items.filter(product__in_stock=True).exists()
-
- @property
- def price(self):
- """
- Цена варианта определяется по приоритету товаров:
- 1. Берётся цена товара с приоритетом 1, если он в наличии
- 2. Если нет - цена товара с приоритетом 2
- 3. И так далее по приоритетам
- 4. Если ни один товар не в наличии - берётся самый дорогой товар из группы
-
- Возвращает Decimal (цену) или None если группа пуста.
- """
- items = self.items.all().order_by('priority', 'id')
-
- if not items.exists():
- return None
-
- # Ищем первый товар в наличии
- for item in items:
- if item.product.in_stock:
- return item.product.sale_price
-
- # Если ни один товар не в наличии - берем самый дорогой
- max_price = None
- for item in items:
- if max_price is None or item.product.sale_price > max_price:
- max_price = item.product.sale_price
-
- return max_price
-
-
-class ProductVariantGroupItem(models.Model):
- """
- Товар в группе вариантов с приоритетом для этой конкретной группы.
- Приоритет определяет порядок выбора товара при использовании группы в комплектах.
- Например: в группе "Роза красная Freedom" - роза 50см имеет приоритет 1, 60см = 2, 70см = 3.
- """
- variant_group = models.ForeignKey(
- ProductVariantGroup,
- on_delete=models.CASCADE,
- related_name='items',
- verbose_name="Группа вариантов"
- )
- product = models.ForeignKey(
- 'Product',
- on_delete=models.CASCADE,
- related_name='variant_group_items',
- verbose_name="Товар"
- )
- priority = models.PositiveIntegerField(
- default=0,
- help_text="Меньше = выше приоритет (1 - наивысший приоритет в этой группе)"
- )
-
- class Meta:
- verbose_name = "Товар в группе вариантов"
- verbose_name_plural = "Товары в группах вариантов"
- ordering = ['priority', 'id']
- unique_together = [['variant_group', 'product']]
- indexes = [
- models.Index(fields=['variant_group', 'priority']),
- models.Index(fields=['product']),
- ]
-
- def __str__(self):
- return f"{self.variant_group.name} - {self.product.name} (приоритет {self.priority})"
-
-
-class Product(BaseProductEntity):
- """
- Базовый товар (цветок, упаковка, аксессуар).
- Наследует общие поля из BaseProductEntity.
- """
- UNIT_CHOICES = [
- ('шт', 'Штука'),
- ('м', 'Метр'),
- ('г', 'Грамм'),
- ('л', 'Литр'),
- ('кг', 'Килограмм'),
- ]
-
- # Специфичные поля Product
- variant_suffix = models.CharField(
- max_length=20,
- blank=True,
- null=True,
- verbose_name="Суффикс варианта",
- help_text="Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия."
- )
-
- # Categories and Tags - остаются в Product с related_name='products'
- categories = models.ManyToManyField(
- ProductCategory,
- blank=True,
- related_name='products',
- verbose_name="Категории"
- )
- tags = models.ManyToManyField(
- ProductTag,
- blank=True,
- related_name='products',
- verbose_name="Теги"
- )
- variant_groups = models.ManyToManyField(
- ProductVariantGroup,
- blank=True,
- related_name='products',
- verbose_name="Группы вариантов"
- )
-
- unit = models.CharField(
- max_length=10,
- choices=UNIT_CHOICES,
- default='шт',
- verbose_name="Единица измерения"
- )
-
- # ЦЕНООБРАЗОВАНИЕ - переименованные поля
- cost_price = models.DecimalField(
- max_digits=10,
- decimal_places=2,
- verbose_name="Себестоимость",
- help_text="В будущем будет вычисляться автоматически из партий (FIFO)"
- )
- price = models.DecimalField(
- max_digits=10,
- decimal_places=2,
- verbose_name="Основная цена",
- help_text="Цена продажи товара (бывшее поле sale_price)"
- )
- sale_price = models.DecimalField(
- max_digits=10,
- decimal_places=2,
- blank=True,
- null=True,
- verbose_name="Цена со скидкой",
- help_text="Если задана, товар продается по этой цене (дешевле основной)"
- )
-
- in_stock = models.BooleanField(
- default=False,
- verbose_name="В наличии",
- db_index=True,
- help_text="Автоматически обновляется при изменении остатков на складе"
- )
-
- # Поле для улучшенного поиска
- search_keywords = models.TextField(
- blank=True,
- verbose_name="Ключевые слова для поиска",
- help_text="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную."
- )
-
- class Meta:
- verbose_name = "Товар"
- verbose_name_plural = "Товары"
- indexes = [
- models.Index(fields=['in_stock']),
- models.Index(fields=['sku']),
- ]
-
- @property
- def actual_price(self):
- """
- Финальная цена для продажи.
- Если есть sale_price (скидка) - возвращает его, иначе - основную цену.
- """
- return self.sale_price if self.sale_price else self.price
-
- def save(self, *args, **kwargs):
- # Автоматическое извлечение variant_suffix из названия
- if not self.variant_suffix and self.name:
- from .utils.sku_generator import parse_variant_suffix
- parsed_suffix = parse_variant_suffix(self.name)
- if parsed_suffix:
- self.variant_suffix = parsed_suffix
-
- # Генерация артикула для новых товаров
- if not self.sku:
- self.sku = generate_product_sku(self)
-
- # Автоматическая генерация ключевых слов для поиска
- keywords_parts = [
- self.name or '',
- self.sku or '',
- self.description or '',
- ]
-
- if not self.search_keywords:
- self.search_keywords = ' '.join(filter(None, keywords_parts))
-
- # Вызов родительского save (генерация slug и т.д.)
- super().save(*args, **kwargs)
-
- # Добавляем названия категорий в search_keywords после сохранения
- # (ManyToMany требует, чтобы объект уже существовал в БД)
- if self.pk and self.categories.exists():
- category_names = ' '.join([cat.name for cat in self.categories.all()])
- if category_names and category_names not in self.search_keywords:
- self.search_keywords = f"{self.search_keywords} {category_names}".strip()
- # Используем update чтобы избежать рекурсии
- Product.objects.filter(pk=self.pk).update(search_keywords=self.search_keywords)
-
- def get_variant_groups(self):
- """Возвращает все группы вариантов товара"""
- return self.variant_groups.all()
-
- def get_similar_products(self):
- """Возвращает все товары из тех же групп вариантов (исключая себя)"""
- return Product.objects.filter(
- variant_groups__in=self.variant_groups.all()
- ).exclude(id=self.id).distinct()
-
-
-class ProductKit(BaseProductEntity):
- """
- Шаблон комплекта / букета (рецепт).
- Наследует общие поля из BaseProductEntity.
- """
- PRICING_METHOD_CHOICES = [
- ('manual', 'Ручная цена'),
- ('from_sale_prices', 'По ценам продажи компонентов'),
- ('from_cost_plus_percent', 'Себестоимость + процент наценки'),
- ('from_cost_plus_amount', 'Себестоимость + фикс. наценка'),
- ]
-
- # Categories and Tags - остаются в ProductKit с related_name='kits'
- categories = models.ManyToManyField(
- ProductCategory,
- blank=True,
- related_name='kits',
- verbose_name="Категории"
- )
- tags = models.ManyToManyField(
- ProductTag,
- blank=True,
- related_name='kits',
- verbose_name="Теги"
- )
-
- # ЦЕНООБРАЗОВАНИЕ - специфичные поля ProductKit
- pricing_method = models.CharField(
- max_length=30,
- choices=PRICING_METHOD_CHOICES,
- default='from_sale_prices',
- verbose_name="Метод ценообразования"
- )
-
- cost_price = models.DecimalField(
- max_digits=10,
- decimal_places=2,
- blank=True,
- null=True,
- verbose_name="Себестоимость",
- help_text="Можно задать вручную или вычислить из компонентов"
- )
-
- price = models.DecimalField(
- max_digits=10,
- decimal_places=2,
- null=True,
- blank=True,
- verbose_name="Ручная цена",
- help_text="Цена при методе 'Ручная цена' (бывшее поле fixed_price)"
- )
-
- sale_price = models.DecimalField(
- max_digits=10,
- decimal_places=2,
- null=True,
- blank=True,
- verbose_name="Цена со скидкой",
- help_text="Если задана, комплект продается по этой цене"
- )
-
- markup_percent = models.DecimalField(
- max_digits=5,
- decimal_places=2,
- null=True,
- blank=True,
- verbose_name="Процент наценки",
- help_text="Для метода 'Себестоимость + процент наценки'"
- )
-
- markup_amount = models.DecimalField(
- max_digits=10,
- decimal_places=2,
- null=True,
- blank=True,
- verbose_name="Фиксированная наценка",
- help_text="Для метода 'Себестоимость + фиксированная наценка'"
- )
-
- class Meta:
- verbose_name = "Комплект"
- verbose_name_plural = "Комплекты"
- indexes = [
- models.Index(fields=['pricing_method']),
- ]
-
- @property
- def calculated_price(self):
- """
- Вычисляемая цена на основе pricing_method.
- Используется, если не задана ручная цена.
- """
- return self.calculate_price_with_substitutions()
-
- @property
- def actual_price(self):
- """
- Финальная цена для продажи.
- Приоритет: sale_price > price (ручная) > calculated_price
- """
- if self.sale_price:
- return self.sale_price
- if self.pricing_method == 'manual' and self.price:
- return self.price
- return self.calculated_price
-
- def clean(self):
- """Валидация комплекта перед сохранением"""
- # Проверка соответствия метода ценообразования полям
- if self.pricing_method == 'manual' and not self.price:
- raise ValidationError({
- 'price': 'Для метода ценообразования "Ручная цена" необходимо указать цену.'
- })
-
- if self.pricing_method == 'from_cost_plus_percent' and (
- self.markup_percent is None or self.markup_percent < 0
- ):
- raise ValidationError({
- 'markup_percent': 'Для метода ценообразования "from_cost_plus_percent" необходимо указать процент наценки >= 0.'
- })
-
- if self.pricing_method == 'from_cost_plus_amount' and (
- self.markup_amount is None or self.markup_amount < 0
- ):
- raise ValidationError({
- 'markup_amount': 'Для метода ценообразования "from_cost_plus_amount" необходимо указать сумму наценки >= 0.'
- })
-
- # Проверка уникальности SKU (если задан)
- if self.sku:
- # Проверяем, что SKU не используется другим комплектом (если объект уже существует)
- if self.pk:
- if ProductKit.objects.filter(sku=self.sku).exclude(pk=self.pk).exists():
- raise ValidationError({
- 'sku': f'Артикул "{self.sku}" уже используется другим комплектом.'
- })
- else:
- # Для новых объектов просто проверяем, что SKU не используется
- if ProductKit.objects.filter(sku=self.sku).exists():
- raise ValidationError({
- 'sku': f'Артикул "{self.sku}" уже используется другим комплектом.'
- })
-
- def save(self, *args, **kwargs):
- # Генерация артикула для новых комплектов
- if not self.sku:
- self.sku = generate_kit_sku()
-
- # Вызов родительского save (генерация slug и т.д.)
- super().save(*args, **kwargs)
-
- def get_total_components_count(self):
- """
- Возвращает количество компонентов (строк) в комплекте.
-
- Returns:
- int: Количество компонентов в комплекте
- """
- return self.kit_items.count()
-
- def get_components_with_variants_count(self):
- """
- Возвращает количество компонентов, которые используют группы вариантов.
-
- Returns:
- int: Количество компонентов с группами вариантов
- """
- return self.kit_items.filter(variant_group__isnull=False).count()
-
- def get_sale_price(self):
- """
- Возвращает рассчитанную цену продажи комплекта в соответствии с выбранным методом ценообразования.
-
- Returns:
- Decimal: Цена продажи комплекта
- """
- try:
- return self.calculate_price_with_substitutions()
- except Exception:
- # Если что-то пошло не так, возвращаем ручную цену если есть
- if self.pricing_method == 'manual' and self.price:
- return self.price
- return 0
-
- def check_availability(self, stock_manager=None):
- """
- Проверяет доступность всего комплекта.
-
- Комплект доступен, если для каждой позиции в комплекте
- есть хотя бы один доступный вариант товара.
-
- Args:
- stock_manager: Объект управления складом (если не указан, используется стандартный)
-
- Returns:
- bool: True, если комплект полностью доступен, иначе False
- """
- from .utils.stock_manager import StockManager
-
- if stock_manager is None:
- stock_manager = StockManager()
-
- for kit_item in self.kit_items.all():
- best_product = kit_item.get_best_available_product(stock_manager)
- if not best_product:
- return False
-
- return True
-
- def calculate_price_with_substitutions(self, stock_manager=None):
- """
- Расчёт цены комплекта с учётом доступных замен компонентов.
-
- Метод определяет цену комплекта, учитывая доступные товары-заменители
- и применяет выбранный метод ценообразования.
-
- Args:
- stock_manager: Объект управления складом (если не указан, используется стандартный)
-
- Returns:
- Decimal: Расчетная цена комплекта, или 0 в случае ошибки
- """
- from decimal import Decimal, InvalidOperation
- from .utils.stock_manager import StockManager
-
- if stock_manager is None:
- stock_manager = StockManager()
-
- # Если указана ручная цена, используем её
- if self.pricing_method == 'manual' and self.price:
- return self.price
-
- total_cost = Decimal('0.00')
- total_sale = Decimal('0.00')
-
- for kit_item in self.kit_items.select_related('product', 'variant_group'):
- try:
- best_product = kit_item.get_best_available_product(stock_manager)
-
- if not best_product:
- # Если товар недоступен, используем цену первого в списке
- available_products = kit_item.get_available_products()
- best_product = available_products[0] if available_products else None
-
- if best_product:
- item_cost = best_product.cost_price
- item_price = best_product.price # ОБНОВЛЕНО: было sale_price, теперь price
- item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано
-
- # Проверяем корректность значений перед умножением
- if item_cost and item_quantity:
- total_cost += item_cost * item_quantity
- if item_price and item_quantity:
- total_sale += item_price * item_quantity
- except (AttributeError, TypeError, InvalidOperation) as e:
- # Логируем ошибку, но продолжаем вычисления
- import logging
- logger = logging.getLogger(__name__)
- logger.warning(f"Ошибка при расчёте цены для комплекта {self.name} (item: {kit_item}): {e}")
- continue # Пропускаем ошибочный элемент и продолжаем с остальными
-
- # Применяем метод ценообразования
- try:
- if self.pricing_method == 'from_sale_prices':
- return total_sale
- elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent is not None:
- return total_cost * (Decimal('1') + self.markup_percent / Decimal('100'))
- elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount is not None:
- return total_cost + self.markup_amount
- elif self.pricing_method == 'manual' and self.price:
- return self.price
-
- return total_sale
- except (TypeError, InvalidOperation) as e:
- import logging
- logger = logging.getLogger(__name__)
- logger.error(f"Ошибка при применении метода ценообразования для комплекта {self.name}: {e}")
- # Возвращаем ручную цену если есть, иначе 0
- if self.pricing_method == 'manual' and self.price:
- return self.price
- return Decimal('0.00')
-
- def calculate_cost(self):
- """
- Расчёт себестоимости комплекта на основе себестоимости компонентов.
-
- Returns:
- Decimal: Себестоимость комплекта
- """
- from decimal import Decimal
- total_cost = Decimal('0.00')
-
- for kit_item in self.kit_items.select_related('product', 'variant_group'):
- # Получаем продукт - либо конкретный, либо первый из группы вариантов
- product = kit_item.product
- if not product and kit_item.variant_group:
- # Берем первый продукт из группы вариантов
- product = kit_item.variant_group.products.filter(is_active=True).first()
-
- if product:
- item_cost = product.cost_price
- item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано
- total_cost += item_cost * item_quantity
-
- return total_cost
-
- def delete(self, *args, **kwargs):
- """Soft delete вместо hard delete - марк как удаленный"""
- self.is_deleted = True
- self.deleted_at = timezone.now()
- self.save(update_fields=['is_deleted', 'deleted_at'])
- # Возвращаем результат в формате Django
- return 1, {self.__class__._meta.label: 1}
-
- def hard_delete(self):
- """Полное удаление из БД (необратимо!)"""
- super().delete()
-
-
-class KitItem(models.Model):
- """
- Состав комплекта: связь между ProductKit и Product или ProductVariantGroup.
- Позиция может быть либо конкретным товаром, либо группой вариантов.
- """
- kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items',
- verbose_name="Комплект")
- product = models.ForeignKey(
- Product,
- on_delete=models.CASCADE,
- null=True,
- blank=True,
- related_name='kit_items_direct',
- verbose_name="Конкретный товар"
- )
- variant_group = models.ForeignKey(
- ProductVariantGroup,
- on_delete=models.CASCADE,
- null=True,
- blank=True,
- related_name='kit_items',
- verbose_name="Группа вариантов"
- )
- quantity = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True, verbose_name="Количество")
- notes = models.CharField(
- max_length=200,
- blank=True,
- verbose_name="Примечание"
- )
-
- class Meta:
- verbose_name = "Компонент комплекта"
- verbose_name_plural = "Компоненты комплектов"
- indexes = [
- models.Index(fields=['kit']),
- models.Index(fields=['product']),
- models.Index(fields=['variant_group']),
- models.Index(fields=['kit', 'product']),
- models.Index(fields=['kit', 'variant_group']),
- ]
-
- def __str__(self):
- return f"{self.kit.name} - {self.get_display_name()}"
-
- def clean(self):
- """Валидация: должен быть указан либо product, либо variant_group (но не оба)"""
- if self.product and self.variant_group:
- raise ValidationError(
- "Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
- )
- if not self.product and not self.variant_group:
- raise ValidationError(
- "Необходимо указать либо товар, либо группу вариантов."
- )
-
- def get_display_name(self):
- """
- Возвращает строку для отображения названия компонента.
-
- Returns:
- str: Название компонента (либо группа вариантов, либо конкретный товар)
- """
- if self.variant_group:
- return f"[Варианты] {self.variant_group.name}"
- return self.product.name if self.product else "Не указан"
-
- def has_priorities_set(self):
- """
- Проверяет, настроены ли приоритеты замены для данного компонента.
-
- Returns:
- bool: True, если приоритеты установлены, иначе False
- """
- return self.priorities.exists()
-
- def get_available_products(self):
- """
- Возвращает список доступных товаров для этого компонента.
-
- Если указан конкретный товар - возвращает его.
- Если указаны приоритеты - возвращает товары в порядке приоритета.
- Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
-
- Returns:
- list: Список доступных товаров
- """
- if self.product:
- # Если указан конкретный товар, возвращаем только его
- return [self.product]
-
- if self.variant_group:
- # Если есть настроенные приоритеты, используем их
- if self.has_priorities_set():
- return [
- priority.product
- for priority in self.priorities.select_related('product').order_by('priority', 'id')
- ]
- # Иначе возвращаем все товары из группы
- return list(self.variant_group.products.filter(is_active=True))
-
- return []
-
- def get_best_available_product(self, stock_manager=None):
- """
- Возвращает первый доступный товар по приоритету, соответствующий требованиям по количеству.
-
- Args:
- stock_manager: Объект управления складом (если не указан, используется стандартный)
-
- Returns:
- Product or None: Первый доступный товар или None, если ничего не доступно
- """
- from .utils.stock_manager import StockManager
-
- if stock_manager is None:
- stock_manager = StockManager()
-
- available_products = self.get_available_products()
-
- for product in available_products:
- if stock_manager.check_stock(product, self.quantity):
- return product
-
- return None
-
-
-class KitItemPriority(models.Model):
- """
- Приоритеты товаров для конкретной позиции букета.
- Позволяет настроить индивидуальные приоритеты замен для каждого букета.
- """
- kit_item = models.ForeignKey(
- KitItem,
- on_delete=models.CASCADE,
- related_name='priorities',
- verbose_name="Позиция в букете"
- )
- product = models.ForeignKey(
- Product,
- on_delete=models.CASCADE,
- verbose_name="Товар"
- )
- priority = models.PositiveIntegerField(
- default=0,
- help_text="Меньше = выше приоритет (0 - наивысший)"
- )
-
- class Meta:
- verbose_name = "Приоритет варианта"
- verbose_name_plural = "Приоритеты вариантов"
- ordering = ['priority', 'id']
- unique_together = ['kit_item', 'product']
-
- def __str__(self):
- return f"{self.product.name} (приоритет {self.priority})"
-
-
-class ProductPhoto(models.Model):
- """
- Модель для хранения фото товара (один товар может иметь несколько фото).
- Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large.
- """
- product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='photos',
- verbose_name="Товар")
- image = models.ImageField(upload_to='products/temp/', verbose_name="Оригинальное фото")
- order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
- created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
-
- class Meta:
- verbose_name = "Фото товара"
- verbose_name_plural = "Фото товаров"
- ordering = ['order', '-created_at']
-
- def __str__(self):
- return f"Фото для {self.product.name}"
-
- def save(self, *args, **kwargs):
- """
- При загрузке нового изображения обрабатывает его и создает все необходимые размеры.
- """
- from .utils.image_processor import ImageProcessor
-
- is_new = not self.pk
-
- # Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID
- if is_new and self.image:
- # Сохраняем объект без изображения, чтобы получить ID
- temp_image = self.image
- self.image = None
- super().save(*args, **kwargs)
-
- # Теперь обрабатываем изображение с известными ID
- processed_paths = ImageProcessor.process_image(temp_image, 'products', entity_id=self.product.id, photo_id=self.id)
- self.image = processed_paths['original']
-
- # Обновляем только поле image, чтобы избежать рекурсии и дублирования ID
- super().save(update_fields=['image'])
- else:
- # Проверяем старый путь для удаления, если это обновление
- old_image_path = None
- if self.pk:
- try:
- old_obj = ProductPhoto.objects.get(pk=self.pk)
- if old_obj.image and old_obj.image != self.image:
- old_image_path = old_obj.image.name
- except ProductPhoto.DoesNotExist:
- pass
-
- # Проверяем, нужно ли обрабатывать изображение
- if self.image and old_image_path:
- # Обновление существующего изображения
- processed_paths = ImageProcessor.process_image(self.image, 'products', entity_id=self.product.id, photo_id=self.id)
- self.image = processed_paths['original']
-
- # Удаляем старые версии
- ImageProcessor.delete_all_versions('products', old_image_path, entity_id=self.product.id, photo_id=self.id)
-
- # Обновляем только поле image, чтобы избежать рекурсии
- super().save(update_fields=['image'])
- else:
- # Просто сохраняем без обработки изображения
- super().save(*args, **kwargs)
-
- def delete(self, *args, **kwargs):
- """Удаляет все версии изображения при удалении фото"""
- import logging
- from .utils.image_processor import ImageProcessor
-
- logger = logging.getLogger(__name__)
-
- if self.image:
- try:
- logger.info(f"[ProductPhoto.delete] Удаляем изображение: {self.image.name}")
- ImageProcessor.delete_all_versions('products', self.image.name, entity_id=self.product.id, photo_id=self.id)
- logger.info(f"[ProductPhoto.delete] ✓ Все версии изображения удалены")
- except Exception as e:
- logger.error(f"[ProductPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True)
-
- super().delete(*args, **kwargs)
-
- def get_thumbnail_url(self):
- """Получить URL миниатюры (150x150)"""
- from .utils.image_service import ImageService
- return ImageService.get_thumbnail_url(self.image.name)
-
- def get_medium_url(self):
- """Получить URL среднего размера (400x400)"""
- from .utils.image_service import ImageService
- return ImageService.get_medium_url(self.image.name)
-
- def get_large_url(self):
- """Получить URL большого размера (800x800)"""
- from .utils.image_service import ImageService
- return ImageService.get_large_url(self.image.name)
-
- def get_original_url(self):
- """Получить URL оригинального изображения"""
- from .utils.image_service import ImageService
- return ImageService.get_original_url(self.image.name)
-
-
-class ProductKitPhoto(models.Model):
- """
- Модель для хранения фото комплекта (один комплект может иметь несколько фото).
- Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large.
- """
- kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='photos',
- verbose_name="Комплект")
- image = models.ImageField(upload_to='kits/temp/', verbose_name="Оригинальное фото")
- order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
- created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
-
- class Meta:
- verbose_name = "Фото комплекта"
- verbose_name_plural = "Фото комплектов"
- ordering = ['order', '-created_at']
-
- def __str__(self):
- return f"Фото для {self.kit.name}"
-
- def save(self, *args, **kwargs):
- """
- При загрузке нового изображения обрабатывает его и создает все необходимые размеры.
- """
- from .utils.image_processor import ImageProcessor
-
- is_new = not self.pk
-
- # Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID
- if is_new and self.image:
- # Сохраняем объект без изображения, чтобы получить ID
- temp_image = self.image
- self.image = None
- super().save(*args, **kwargs)
-
- # Теперь обрабатываем изображение с известными ID
- processed_paths = ImageProcessor.process_image(temp_image, 'kits', entity_id=self.kit.id, photo_id=self.id)
- self.image = processed_paths['original']
-
- # Обновляем только поле image, чтобы избежать рекурсии и дублирования ID
- super().save(update_fields=['image'])
- else:
- # Проверяем старый путь для удаления, если это обновление
- old_image_path = None
- if self.pk:
- try:
- old_obj = ProductKitPhoto.objects.get(pk=self.pk)
- if old_obj.image and old_obj.image != self.image:
- old_image_path = old_obj.image.name
- except ProductKitPhoto.DoesNotExist:
- pass
-
- # Проверяем, нужно ли обрабатывать изображение
- if self.image and old_image_path:
- # Обновление существующего изображения
- processed_paths = ImageProcessor.process_image(self.image, 'kits', entity_id=self.kit.id, photo_id=self.id)
- self.image = processed_paths['original']
-
- # Удаляем старые версии
- ImageProcessor.delete_all_versions('kits', old_image_path, entity_id=self.kit.id, photo_id=self.id)
-
- # Обновляем только поле image, чтобы избежать рекурсии
- super().save(update_fields=['image'])
- else:
- # Просто сохраняем без обработки изображения
- super().save(*args, **kwargs)
-
- def delete(self, *args, **kwargs):
- """Удаляет все версии изображения при удалении фото"""
- import logging
- from .utils.image_processor import ImageProcessor
-
- logger = logging.getLogger(__name__)
-
- if self.image:
- try:
- logger.info(f"[ProductKitPhoto.delete] Удаляем изображение: {self.image.name}")
- ImageProcessor.delete_all_versions('kits', self.image.name, entity_id=self.kit.id, photo_id=self.id)
- logger.info(f"[ProductKitPhoto.delete] ✓ Все версии изображения удалены")
- except Exception as e:
- logger.error(f"[ProductKitPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True)
-
- super().delete(*args, **kwargs)
-
- def get_thumbnail_url(self):
- """Получить URL миниатюры (150x150)"""
- from .utils.image_service import ImageService
- return ImageService.get_thumbnail_url(self.image.name)
-
- def get_medium_url(self):
- """Получить URL среднего размера (400x400)"""
- from .utils.image_service import ImageService
- return ImageService.get_medium_url(self.image.name)
-
- def get_large_url(self):
- """Получить URL большого размера (800x800)"""
- from .utils.image_service import ImageService
- return ImageService.get_large_url(self.image.name)
-
- def get_original_url(self):
- """Получить URL оригинального изображения"""
- from .utils.image_service import ImageService
- return ImageService.get_original_url(self.image.name)
-
-
-class ProductCategoryPhoto(models.Model):
- """
- Модель для хранения фото категории (одна категория может иметь несколько фото).
- Автоматически создает несколько размеров при загрузке: original, thumbnail, medium, large.
- """
- category = models.ForeignKey(ProductCategory, on_delete=models.CASCADE, related_name='photos',
- verbose_name="Категория")
- image = models.ImageField(upload_to='categories/temp/', verbose_name="Оригинальное фото")
- order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
- created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
-
- class Meta:
- verbose_name = "Фото категории"
- verbose_name_plural = "Фото категорий"
- ordering = ['order', '-created_at']
-
- def __str__(self):
- return f"Фото для {self.category.name}"
-
- def save(self, *args, **kwargs):
- """
- При загрузке нового изображения обрабатывает его и создает все необходимые размеры.
- """
- from .utils.image_processor import ImageProcessor
-
- is_new = not self.pk
-
- # Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID
- if is_new and self.image:
- # Сохраняем объект без изображения, чтобы получить ID
- temp_image = self.image
- self.image = None
- super().save(*args, **kwargs)
-
- # Теперь обрабатываем изображение с известными ID
- processed_paths = ImageProcessor.process_image(temp_image, 'categories', entity_id=self.category.id, photo_id=self.id)
- self.image = processed_paths['original']
-
- # Обновляем только поле image, чтобы избежать рекурсии и дублирования ID
- super().save(update_fields=['image'])
- else:
- # Проверяем старый путь для удаления, если это обновление
- old_image_path = None
- if self.pk:
- try:
- old_obj = ProductCategoryPhoto.objects.get(pk=self.pk)
- if old_obj.image and old_obj.image != self.image:
- old_image_path = old_obj.image.name
- except ProductCategoryPhoto.DoesNotExist:
- pass
-
- # Проверяем, нужно ли обрабатывать изображение
- if self.image and old_image_path:
- # Обновление существующего изображения
- processed_paths = ImageProcessor.process_image(self.image, 'categories', entity_id=self.category.id, photo_id=self.id)
- self.image = processed_paths['original']
-
- # Удаляем старые версии
- ImageProcessor.delete_all_versions('categories', old_image_path, entity_id=self.category.id, photo_id=self.id)
-
- # Обновляем только поле image, чтобы избежать рекурсии
- super().save(update_fields=['image'])
- else:
- # Просто сохраняем без обработки изображения
- super().save(*args, **kwargs)
-
- def delete(self, *args, **kwargs):
- """Удаляет все версии изображения при удалении фото"""
- import logging
- from .utils.image_processor import ImageProcessor
-
- logger = logging.getLogger(__name__)
-
- if self.image:
- try:
- logger.info(f"[ProductCategoryPhoto.delete] Удаляем изображение: {self.image.name}")
- ImageProcessor.delete_all_versions('categories', self.image.name, entity_id=self.category.id, photo_id=self.id)
- logger.info(f"[ProductCategoryPhoto.delete] ✓ Все версии изображения удалены")
- except Exception as e:
- logger.error(f"[ProductCategoryPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True)
-
- super().delete(*args, **kwargs)
-
- def get_thumbnail_url(self):
- """Получить URL миниатюры (150x150)"""
- from .utils.image_service import ImageService
- return ImageService.get_thumbnail_url(self.image.name)
-
- def get_medium_url(self):
- """Получить URL среднего размера (400x400)"""
- from .utils.image_service import ImageService
- return ImageService.get_medium_url(self.image.name)
-
- def get_large_url(self):
- """Получить URL большого размера (800x800)"""
- from .utils.image_service import ImageService
- return ImageService.get_large_url(self.image.name)
-
- def get_original_url(self):
- """Получить URL оригинального изображения"""
- from .utils.image_service import ImageService
- return ImageService.get_original_url(self.image.name)
diff --git a/myproject/products/models/__init__.py b/myproject/products/models/__init__.py
new file mode 100644
index 0000000..94080a2
--- /dev/null
+++ b/myproject/products/models/__init__.py
@@ -0,0 +1,72 @@
+"""
+Products models package.
+Импортирует все модели для обеспечения совместимости с Django.
+
+Структура после рефакторинга:
+- base.py: SKUCounter, BaseProductEntity (абстрактный базовый класс)
+- managers.py: ActiveManager, SoftDeleteManager, SoftDeleteQuerySet
+- categories.py: ProductCategory, ProductTag
+- variants.py: ProductVariantGroup, ProductVariantGroupItem
+- products.py: Product
+- kits.py: ProductKit, KitItem, KitItemPriority
+- photos.py: BasePhoto (abstract), ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
+
+Бизнес-логика вынесена в:
+- services/: slug_service, product_service, kit_pricing, kit_availability
+- validators/: kit_validators
+"""
+
+# Импортируем менеджеры (используются другими моделями)
+from .managers import ActiveManager, SoftDeleteManager, SoftDeleteQuerySet
+
+# Базовые модели
+from .base import SKUCounter, BaseProductEntity
+
+# Категории и теги
+from .categories import ProductCategory, ProductTag
+
+# Группы вариантов
+from .variants import ProductVariantGroup, ProductVariantGroupItem
+
+# Продукты
+from .products import Product
+
+# Комплекты
+from .kits import ProductKit, KitItem, KitItemPriority
+
+# Фотографии
+from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
+
+# Явно указываем, что экспортируется при импорте *
+__all__ = [
+ # Managers
+ 'ActiveManager',
+ 'SoftDeleteManager',
+ 'SoftDeleteQuerySet',
+
+ # Base
+ 'SKUCounter',
+ 'BaseProductEntity',
+
+ # Categories
+ 'ProductCategory',
+ 'ProductTag',
+
+ # Variants
+ 'ProductVariantGroup',
+ 'ProductVariantGroupItem',
+
+ # Products
+ 'Product',
+
+ # Kits
+ 'ProductKit',
+ 'KitItem',
+ 'KitItemPriority',
+
+ # Photos
+ 'BasePhoto',
+ 'ProductPhoto',
+ 'ProductKitPhoto',
+ 'ProductCategoryPhoto',
+]
diff --git a/myproject/products/models/base.py b/myproject/products/models/base.py
new file mode 100644
index 0000000..36d29b1
--- /dev/null
+++ b/myproject/products/models/base.py
@@ -0,0 +1,178 @@
+"""
+Базовые модели для products приложения.
+Содержит SKUCounter и BaseProductEntity (абстрактный базовый класс).
+"""
+from django.db import models, transaction
+from django.utils import timezone
+from django.contrib.auth import get_user_model
+
+from .managers import ActiveManager, SoftDeleteManager, SoftDeleteQuerySet
+
+# Получаем User модель один раз для использования в ForeignKey
+User = get_user_model()
+
+
+class SKUCounter(models.Model):
+ """
+ Глобальные счетчики для генерации уникальных номеров артикулов.
+ Используется для товаров (product), комплектов (kit) и категорий (category).
+ """
+ COUNTER_TYPE_CHOICES = [
+ ('product', 'Product Counter'),
+ ('kit', 'Kit Counter'),
+ ('category', 'Category Counter'),
+ ]
+
+ counter_type = models.CharField(
+ max_length=20,
+ unique=True,
+ choices=COUNTER_TYPE_CHOICES,
+ verbose_name="Тип счетчика"
+ )
+ current_value = models.IntegerField(
+ default=0,
+ verbose_name="Текущее значение"
+ )
+
+ class Meta:
+ verbose_name = "Счетчик артикулов"
+ verbose_name_plural = "Счетчики артикулов"
+
+ def __str__(self):
+ return f"{self.get_counter_type_display()}: {self.current_value}"
+
+ @classmethod
+ def get_next_value(cls, counter_type):
+ """
+ Получить следующее значение счетчика (thread-safe).
+ Использует select_for_update для предотвращения race conditions.
+ """
+ with transaction.atomic():
+ counter, created = cls.objects.select_for_update().get_or_create(
+ counter_type=counter_type,
+ defaults={'current_value': 0}
+ )
+ counter.current_value += 1
+ counter.save()
+ return counter.current_value
+
+
+class BaseProductEntity(models.Model):
+ """
+ Абстрактный базовый класс для Product и ProductKit.
+ Объединяет общие поля идентификации, описания, статуса и soft delete.
+
+ Используется как основа для:
+ - Product (простой товар)
+ - ProductKit (комплект товаров)
+ """
+ # Идентификация
+ name = models.CharField(
+ max_length=200,
+ verbose_name="Название"
+ )
+ sku = models.CharField(
+ max_length=100,
+ blank=True,
+ null=True,
+ verbose_name="Артикул",
+ db_index=True
+ )
+ slug = models.SlugField(
+ max_length=200,
+ unique=True,
+ blank=True,
+ verbose_name="URL-идентификатор"
+ )
+
+ # Описания
+ description = models.TextField(
+ blank=True,
+ null=True,
+ verbose_name="Описание"
+ )
+ short_description = models.TextField(
+ blank=True,
+ null=True,
+ verbose_name="Краткое описание",
+ help_text="Используется для карточек товаров, превью и площадок"
+ )
+
+ # Статус
+ is_active = models.BooleanField(
+ default=True,
+ verbose_name="Активен"
+ )
+
+ # Временные метки
+ created_at = models.DateTimeField(
+ auto_now_add=True,
+ verbose_name="Дата создания"
+ )
+ updated_at = models.DateTimeField(
+ auto_now=True,
+ verbose_name="Дата обновления"
+ )
+
+ # Soft delete
+ is_deleted = models.BooleanField(
+ default=False,
+ verbose_name="Удален",
+ db_index=True
+ )
+ deleted_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ verbose_name="Время удаления"
+ )
+ deleted_by = models.ForeignKey(
+ User,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='deleted_%(class)s_set',
+ verbose_name="Удален пользователем"
+ )
+
+ # Managers
+ objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)()
+ all_objects = models.Manager()
+ active = ActiveManager()
+
+ class Meta:
+ abstract = True
+ indexes = [
+ models.Index(fields=['is_active']),
+ models.Index(fields=['is_deleted']),
+ models.Index(fields=['is_deleted', 'created_at']),
+ ]
+
+ def __str__(self):
+ return self.name
+
+ def delete(self, *args, **kwargs):
+ """Мягкое удаление (soft delete)"""
+ user = kwargs.pop('user', None)
+ self.is_deleted = True
+ self.deleted_at = timezone.now()
+ if user:
+ self.deleted_by = user
+ self.save(update_fields=['is_deleted', 'deleted_at', 'deleted_by'])
+ return 1, {self.__class__._meta.label: 1}
+
+ def hard_delete(self):
+ """Физическое удаление из БД (необратимо!)"""
+ super().delete()
+
+ def save(self, *args, **kwargs):
+ """Автогенерация slug из name если не задан"""
+ if not self.slug or self.slug.strip() == '':
+ # Используем централизованный сервис для генерации slug
+ from ..services.slug_service import SlugService
+ self.slug = SlugService.generate_unique_slug(
+ self.name,
+ self.__class__,
+ self.pk
+ )
+
+ super().save(*args, **kwargs)
diff --git a/myproject/products/models/categories.py b/myproject/products/models/categories.py
new file mode 100644
index 0000000..f809f57
--- /dev/null
+++ b/myproject/products/models/categories.py
@@ -0,0 +1,175 @@
+"""
+Модели категорий и тегов для товаров и комплектов.
+"""
+from django.db import models
+from django.utils import timezone
+from django.core.exceptions import ValidationError
+from django.contrib.auth import get_user_model
+
+from .managers import ActiveManager, SoftDeleteManager, SoftDeleteQuerySet
+from ..services.slug_service import SlugService
+
+User = get_user_model()
+
+
+class ProductCategory(models.Model):
+ """
+ Категории товаров и комплектов (поддержка нескольких уровней не обязательна, но возможна позже).
+ """
+ name = models.CharField(max_length=200, verbose_name="Название")
+ sku = models.CharField(max_length=100, blank=True, null=True, unique=True, verbose_name="Артикул", db_index=True)
+ slug = models.SlugField(max_length=200, unique=True, blank=True, verbose_name="URL-идентификатор")
+ parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True,
+ related_name='children', verbose_name="Родительская категория")
+ is_active = models.BooleanField(default=True, verbose_name="Активна")
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True)
+ updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True)
+
+ # Поля для мягкого удаления
+ is_deleted = models.BooleanField(default=False, verbose_name="Удалена", db_index=True)
+ deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления")
+ deleted_by = models.ForeignKey(
+ User,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='deleted_categories',
+ verbose_name="Удалена пользователем"
+ )
+
+ objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные)
+ all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
+ active = ActiveManager() # Кастомный менеджер для активных категорий
+
+ class Meta:
+ verbose_name = "Категория товара"
+ verbose_name_plural = "Категории товаров"
+ indexes = [
+ models.Index(fields=['is_active']),
+ models.Index(fields=['is_deleted']),
+ models.Index(fields=['is_deleted', 'created_at']),
+ ]
+
+ def __str__(self):
+ return self.name
+
+ def clean(self):
+ """Валидация категории перед сохранением"""
+ # 1. Защита от самоссылки
+ if self.parent and self.parent.pk == self.pk:
+ raise ValidationError({
+ 'parent': 'Категория не может быть родителем самой себя.'
+ })
+
+ # 2. Защита от циклических ссылок (только для существующих категорий)
+ if self.parent and self.pk:
+ self._check_parent_chain()
+
+ # 3. Проверка активности родителя
+ if self.parent and not self.parent.is_active:
+ raise ValidationError({
+ 'parent': 'Нельзя выбрать неактивную категорию в качестве родителя.'
+ })
+
+ def _check_parent_chain(self):
+ """Проверяет цепочку родителей на циклы и глубину вложенности"""
+ from django.conf import settings
+
+ current = self.parent
+ depth = 0
+ max_depth = getattr(settings, 'MAX_CATEGORY_DEPTH', 10)
+
+ while current:
+ if current.pk == self.pk:
+ raise ValidationError({
+ 'parent': f'Обнаружена циклическая ссылка. '
+ f'Категория "{self.name}" не может быть потомком самой себя.'
+ })
+
+ depth += 1
+ if depth > max_depth:
+ raise ValidationError({
+ 'parent': f'Слишком глубокая вложенность категорий '
+ f'(максимум {max_depth} уровней).'
+ })
+
+ current = current.parent
+
+ def save(self, *args, **kwargs):
+ # Вызываем валидацию перед сохранением
+ self.full_clean()
+
+ # Автоматическая генерация slug из названия с транслитерацией
+ if not self.slug or self.slug.strip() == '':
+ self.slug = SlugService.generate_unique_slug(self.name, ProductCategory, self.pk)
+
+ # Автоматическая генерация артикула при создании новой категории
+ if not self.sku and not self.pk:
+ from ..utils.sku_generator import generate_category_sku
+ self.sku = generate_category_sku()
+
+ super().save(*args, **kwargs)
+
+ def delete(self, *args, **kwargs):
+ """Soft delete вместо hard delete - марк как удаленный"""
+ self.is_deleted = True
+ self.deleted_at = timezone.now()
+ self.save(update_fields=['is_deleted', 'deleted_at'])
+ # Возвращаем результат в формате Django
+ return 1, {self.__class__._meta.label: 1}
+
+ def hard_delete(self):
+ """Полное удаление из БД (необратимо!)"""
+ super().delete()
+
+
+class ProductTag(models.Model):
+ """
+ Свободные теги для фильтрации и поиска.
+ """
+ name = models.CharField(max_length=100, unique=True, verbose_name="Название")
+ slug = models.SlugField(max_length=100, unique=True, verbose_name="URL-идентификатор")
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True)
+ updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True)
+
+ # Поля для мягкого удаления
+ is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True)
+ deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления")
+ deleted_by = models.ForeignKey(
+ User,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='deleted_tags',
+ verbose_name="Удален пользователем"
+ )
+
+ objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные)
+ all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
+
+ class Meta:
+ verbose_name = "Тег товара"
+ verbose_name_plural = "Теги товаров"
+ indexes = [
+ models.Index(fields=['is_deleted']),
+ models.Index(fields=['is_deleted', 'created_at']),
+ ]
+
+ def __str__(self):
+ return self.name
+
+ def save(self, *args, **kwargs):
+ if not self.slug:
+ self.slug = SlugService.generate_unique_slug(self.name, ProductTag, self.pk)
+ super().save(*args, **kwargs)
+
+ def delete(self, *args, **kwargs):
+ """Soft delete вместо hard delete - марк как удаленный"""
+ self.is_deleted = True
+ self.deleted_at = timezone.now()
+ self.save(update_fields=['is_deleted', 'deleted_at'])
+ return 1, {self.__class__._meta.label: 1}
+
+ def hard_delete(self):
+ """Полное удаление из БД (необратимо!)"""
+ super().delete()
diff --git a/myproject/products/models/kits.py b/myproject/products/models/kits.py
new file mode 100644
index 0000000..d445e27
--- /dev/null
+++ b/myproject/products/models/kits.py
@@ -0,0 +1,330 @@
+"""
+Модели для комплектов (ProductKit) и их компонентов.
+Цена комплекта динамически вычисляется из actual_price компонентов.
+"""
+from decimal import Decimal
+from django.db import models
+from django.utils import timezone
+from django.core.exceptions import ValidationError
+
+from .base import BaseProductEntity
+from .categories import ProductCategory, ProductTag
+from .variants import ProductVariantGroup
+from .products import Product
+from ..utils.sku_generator import generate_kit_sku
+from ..services.kit_availability import KitAvailabilityChecker
+
+
+class ProductKit(BaseProductEntity):
+ """
+ Шаблон комплекта / букета (рецепт).
+ Наследует общие поля из BaseProductEntity.
+
+ Цена комплекта = сумма actual_price всех компонентов + корректировка.
+ Корректировка может быть увеличением или уменьшением на % или фиксированную сумму.
+ """
+ ADJUSTMENT_TYPE_CHOICES = [
+ ('none', 'Без изменения'),
+ ('increase_percent', 'Увеличить на %'),
+ ('increase_amount', 'Увеличить на сумму'),
+ ('decrease_percent', 'Уменьшить на %'),
+ ('decrease_amount', 'Уменьшить на сумму'),
+ ]
+
+ # Categories and Tags
+ categories = models.ManyToManyField(
+ ProductCategory,
+ blank=True,
+ related_name='kits',
+ verbose_name="Категории"
+ )
+ tags = models.ManyToManyField(
+ ProductTag,
+ blank=True,
+ related_name='kits',
+ verbose_name="Теги"
+ )
+
+ # ЦЕНООБРАЗОВАНИЕ - новый подход
+ base_price = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=0,
+ verbose_name="Базовая цена",
+ help_text="Сумма actual_price всех компонентов. Пересчитывается автоматически."
+ )
+
+ price = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=0,
+ verbose_name="Итоговая цена",
+ help_text="Базовая цена с учетом корректировок. Вычисляется автоматически."
+ )
+
+ sale_price = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ verbose_name="Цена со скидкой",
+ help_text="Если задана, комплект продается по этой цене"
+ )
+
+ price_adjustment_type = models.CharField(
+ max_length=20,
+ choices=ADJUSTMENT_TYPE_CHOICES,
+ default='none',
+ verbose_name="Тип корректировки цены"
+ )
+
+ price_adjustment_value = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=0,
+ verbose_name="Значение корректировки",
+ help_text="Процент (%) или сумма (руб) в зависимости от типа корректировки"
+ )
+
+ class Meta:
+ verbose_name = "Комплект"
+ verbose_name_plural = "Комплекты"
+
+ @property
+ def actual_price(self):
+ """
+ Финальная цена для продажи.
+ Приоритет: sale_price > price (рассчитанная)
+ """
+ if self.sale_price:
+ return self.sale_price
+ return self.price
+
+ def recalculate_base_price(self):
+ """
+ Пересчитать сумму actual_price всех компонентов.
+ Вызывается автоматически при изменении цены товара (через signal).
+ """
+ if not self.pk:
+ return # Новый объект еще не сохранен
+
+ total = Decimal('0')
+ for item in self.kit_items.all():
+ if item.product:
+ actual_price = item.product.actual_price or Decimal('0')
+ qty = item.quantity or Decimal('1')
+ total += actual_price * qty
+
+ self.base_price = total
+ # Обновляем финальную цену
+ self.price = self.calculate_final_price()
+ self.save(update_fields=['base_price', 'price'])
+
+ def calculate_final_price(self):
+ """
+ Вычислить финальную цену с учетом корректировок.
+
+ Returns:
+ Decimal: Итоговая цена комплекта
+ """
+ if self.price_adjustment_type == 'none':
+ return self.base_price
+
+ adjustment_value = self.price_adjustment_value or Decimal('0')
+
+ if 'percent' in self.price_adjustment_type:
+ adjustment = self.base_price * adjustment_value / Decimal('100')
+ else: # 'amount'
+ adjustment = adjustment_value
+
+ if 'increase' in self.price_adjustment_type:
+ return self.base_price + adjustment
+ else: # 'decrease'
+ return max(Decimal('0'), self.base_price - adjustment)
+
+ def save(self, *args, **kwargs):
+ """При сохранении - пересчитываем финальную цену"""
+ # Генерация артикула для новых комплектов
+ if not self.sku:
+ self.sku = generate_kit_sku()
+
+ # Если объект уже существует и имеет компоненты, пересчитываем base_price
+ if self.pk and self.kit_items.exists():
+ # Пересчитаем базовую цену из компонентов
+ total = Decimal('0')
+ for item in self.kit_items.all():
+ if item.product:
+ actual_price = item.product.actual_price or Decimal('0')
+ qty = item.quantity or Decimal('1')
+ total += actual_price * qty
+ self.base_price = total
+
+ # Устанавливаем финальную цену в поле price
+ self.price = self.calculate_final_price()
+
+ # Вызов родительского save (генерация slug и т.д.)
+ super().save(*args, **kwargs)
+
+ def get_total_components_count(self):
+ """Возвращает количество компонентов (строк) в комплекте"""
+ return self.kit_items.count()
+
+ def get_components_with_variants_count(self):
+ """Возвращает количество компонентов, которые используют группы вариантов"""
+ return self.kit_items.filter(variant_group__isnull=False).count()
+
+ def check_availability(self, stock_manager=None):
+ """
+ Проверяет доступность всего комплекта.
+ Делегирует проверку в сервис.
+ """
+ return KitAvailabilityChecker.check_availability(self, stock_manager)
+
+ def delete(self, *args, **kwargs):
+ """Soft delete вместо hard delete - марк как удаленный"""
+ self.is_deleted = True
+ self.deleted_at = timezone.now()
+ self.save(update_fields=['is_deleted', 'deleted_at'])
+ return 1, {self.__class__._meta.label: 1}
+
+ def hard_delete(self):
+ """Полное удаление из БД (необратимо!)"""
+ super().delete()
+
+
+class KitItem(models.Model):
+ """
+ Состав комплекта: связь между ProductKit и Product или ProductVariantGroup.
+ Позиция может быть либо конкретным товаром, либо группой вариантов.
+ """
+ kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items',
+ verbose_name="Комплект")
+ product = models.ForeignKey(
+ Product,
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name='kit_items_direct',
+ verbose_name="Конкретный товар"
+ )
+ variant_group = models.ForeignKey(
+ ProductVariantGroup,
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name='kit_items',
+ verbose_name="Группа вариантов"
+ )
+ quantity = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True, verbose_name="Количество")
+ notes = models.CharField(
+ max_length=200,
+ blank=True,
+ verbose_name="Примечание"
+ )
+
+ class Meta:
+ verbose_name = "Компонент комплекта"
+ verbose_name_plural = "Компоненты комплектов"
+ indexes = [
+ models.Index(fields=['kit']),
+ models.Index(fields=['product']),
+ models.Index(fields=['variant_group']),
+ models.Index(fields=['kit', 'product']),
+ models.Index(fields=['kit', 'variant_group']),
+ ]
+
+ def __str__(self):
+ return f"{self.kit.name} - {self.get_display_name()}"
+
+ def clean(self):
+ """Валидация: должен быть указан либо product, либо variant_group (но не оба)"""
+ if self.product and self.variant_group:
+ raise ValidationError(
+ "Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
+ )
+ if not self.product and not self.variant_group:
+ raise ValidationError(
+ "Необходимо указать либо товар, либо группу вариантов."
+ )
+
+ def get_display_name(self):
+ """Возвращает строку для отображения названия компонента"""
+ if self.variant_group:
+ return f"[Варианты] {self.variant_group.name}"
+ return self.product.name if self.product else "Не указан"
+
+ def has_priorities_set(self):
+ """Проверяет, настроены ли приоритеты замены для данного компонента"""
+ return self.priorities.exists()
+
+ def get_available_products(self):
+ """
+ Возвращает список доступных товаров для этого компонента.
+
+ Если указан конкретный товар - возвращает его.
+ Если указаны приоритеты - возвращает товары в порядке приоритета.
+ Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
+ """
+ if self.product:
+ # Если указан конкретный товар, возвращаем только его
+ return [self.product]
+
+ if self.variant_group:
+ # Если есть настроенные приоритеты, используем их
+ if self.has_priorities_set():
+ return [
+ priority.product
+ for priority in self.priorities.select_related('product').order_by('priority', 'id')
+ ]
+ # Иначе возвращаем все товары из группы
+ return list(self.variant_group.products.filter(is_active=True))
+
+ return []
+
+ def get_best_available_product(self, stock_manager=None):
+ """
+ Возвращает первый доступный товар по приоритету, соответствующий требованиям по количеству.
+ """
+ from ..utils.stock_manager import StockManager
+
+ if stock_manager is None:
+ stock_manager = StockManager()
+
+ available_products = self.get_available_products()
+
+ for product in available_products:
+ if stock_manager.check_stock(product, self.quantity):
+ return product
+
+ return None
+
+
+class KitItemPriority(models.Model):
+ """
+ Приоритеты товаров для конкретной позиции букета.
+ Позволяет настроить индивидуальные приоритеты замен для каждого букета.
+ """
+ kit_item = models.ForeignKey(
+ KitItem,
+ on_delete=models.CASCADE,
+ related_name='priorities',
+ verbose_name="Позиция в букете"
+ )
+ product = models.ForeignKey(
+ Product,
+ on_delete=models.CASCADE,
+ verbose_name="Товар"
+ )
+ priority = models.PositiveIntegerField(
+ default=0,
+ help_text="Меньше = выше приоритет (0 - наивысший)"
+ )
+
+ class Meta:
+ verbose_name = "Приоритет варианта"
+ verbose_name_plural = "Приоритеты вариантов"
+ ordering = ['priority', 'id']
+ unique_together = ['kit_item', 'product']
+
+ def __str__(self):
+ return f"{self.product.name} (приоритет {self.priority})"
diff --git a/myproject/products/models/managers.py b/myproject/products/models/managers.py
new file mode 100644
index 0000000..2d6aea7
--- /dev/null
+++ b/myproject/products/models/managers.py
@@ -0,0 +1,66 @@
+"""
+Менеджеры и QuerySets для моделей продуктов.
+Реализуют паттерн Soft Delete и фильтрацию активных записей.
+"""
+from django.db import models
+from django.utils import timezone
+
+
+class ActiveManager(models.Manager):
+ """Менеджер для фильтрации только активных записей"""
+ def get_queryset(self):
+ return super().get_queryset().filter(is_active=True)
+
+
+class SoftDeleteQuerySet(models.QuerySet):
+ """
+ QuerySet для мягкого удаления (soft delete).
+ Позволяет фильтровать удаленные элементы и восстанавливать их.
+ """
+ def delete(self):
+ """Soft delete вместо hard delete"""
+ return self.update(
+ is_deleted=True,
+ deleted_at=timezone.now()
+ )
+
+ def hard_delete(self):
+ """Явный hard delete - удаляет из БД окончательно"""
+ return super().delete()
+
+ def restore(self):
+ """Восстановление из удаленного состояния"""
+ return self.update(
+ is_deleted=False,
+ deleted_at=None,
+ deleted_by=None
+ )
+
+ def deleted_only(self):
+ """Получить только удаленные элементы"""
+ return self.filter(is_deleted=True)
+
+ def not_deleted(self):
+ """Получить только не удаленные элементы"""
+ return self.filter(is_deleted=False)
+
+ def with_deleted(self):
+ """Получить все элементы включая удаленные"""
+ return self.all()
+
+
+class SoftDeleteManager(models.Manager):
+ """
+ Manager для работы с мягким удалением.
+ По умолчанию исключает удаленные элементы из запросов.
+ """
+ def get_queryset(self):
+ return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False)
+
+ def deleted_only(self):
+ """Получить только удаленные элементы"""
+ return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=True)
+
+ def all_with_deleted(self):
+ """Получить все элементы включая удаленные"""
+ return SoftDeleteQuerySet(self.model, using=self._db).all()
diff --git a/myproject/products/models/products.py b/myproject/products/models/products.py
new file mode 100644
index 0000000..ec6c775
--- /dev/null
+++ b/myproject/products/models/products.py
@@ -0,0 +1,149 @@
+"""
+Модель Product - базовый товар (цветок, упаковка, аксессуар).
+"""
+from django.db import models
+
+from .base import BaseProductEntity
+from .categories import ProductCategory, ProductTag
+from .variants import ProductVariantGroup
+from ..services.product_service import ProductSaveService
+
+
+class Product(BaseProductEntity):
+ """
+ Базовый товар (цветок, упаковка, аксессуар).
+ Наследует общие поля из BaseProductEntity.
+ """
+ UNIT_CHOICES = [
+ ('шт', 'Штука'),
+ ('м', 'Метр'),
+ ('г', 'Грамм'),
+ ('л', 'Литр'),
+ ('кг', 'Килограмм'),
+ ]
+
+ # Специфичные поля Product
+ variant_suffix = models.CharField(
+ max_length=20,
+ blank=True,
+ null=True,
+ verbose_name="Суффикс варианта",
+ help_text="Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия."
+ )
+
+ # Categories and Tags - остаются в Product с related_name='products'
+ categories = models.ManyToManyField(
+ ProductCategory,
+ blank=True,
+ related_name='products',
+ verbose_name="Категории"
+ )
+ tags = models.ManyToManyField(
+ ProductTag,
+ blank=True,
+ related_name='products',
+ verbose_name="Теги"
+ )
+ variant_groups = models.ManyToManyField(
+ ProductVariantGroup,
+ blank=True,
+ related_name='products',
+ verbose_name="Группы вариантов"
+ )
+
+ unit = models.CharField(
+ max_length=10,
+ choices=UNIT_CHOICES,
+ default='шт',
+ verbose_name="Единица измерения"
+ )
+
+ # ЦЕНООБРАЗОВАНИЕ - переименованные поля
+ cost_price = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ verbose_name="Себестоимость",
+ help_text="Автоматически вычисляется из партий (средневзвешенная стоимость по FIFO)"
+ )
+ price = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ verbose_name="Основная цена",
+ help_text="Цена продажи товара (бывшее поле sale_price)"
+ )
+ sale_price = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ blank=True,
+ null=True,
+ verbose_name="Цена со скидкой",
+ help_text="Если задана, товар продается по этой цене (дешевле основной)"
+ )
+
+ in_stock = models.BooleanField(
+ default=False,
+ verbose_name="В наличии",
+ db_index=True,
+ help_text="Автоматически обновляется при изменении остатков на складе"
+ )
+
+ # Поле для улучшенного поиска
+ search_keywords = models.TextField(
+ blank=True,
+ verbose_name="Ключевые слова для поиска",
+ help_text="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную."
+ )
+
+ class Meta:
+ verbose_name = "Товар"
+ verbose_name_plural = "Товары"
+ indexes = [
+ models.Index(fields=['in_stock']),
+ models.Index(fields=['sku']),
+ ]
+
+ @property
+ def actual_price(self):
+ """
+ Финальная цена для продажи.
+ Если есть sale_price (скидка) - возвращает его, иначе - основную цену.
+ """
+ return self.sale_price if self.sale_price else self.price
+
+ @property
+ def cost_price_details(self):
+ """
+ Детали расчета себестоимости для отображения в UI.
+ Показывает разбивку по партиям и сравнение кешированной/рассчитанной стоимости.
+
+ Returns:
+ dict: {
+ 'cached_cost': Decimal, # Кешированная себестоимость (из БД)
+ 'calculated_cost': Decimal, # Рассчитанная себестоимость (из партий)
+ 'is_synced': bool, # Совпадают ли значения
+ 'total_quantity': Decimal, # Общее количество в партиях
+ 'batches': [...] # Список партий с деталями
+ }
+ """
+ from ..services.cost_calculator import ProductCostCalculator
+ return ProductCostCalculator.get_cost_details(self)
+
+ def save(self, *args, **kwargs):
+ # Используем сервис для подготовки к сохранению
+ ProductSaveService.prepare_product_for_save(self)
+
+ # Вызов родительского save (генерация slug и т.д.)
+ super().save(*args, **kwargs)
+
+ # Обновление поисковых слов с категориями (после сохранения)
+ ProductSaveService.update_search_keywords_with_categories(self)
+
+ def get_variant_groups(self):
+ """Возвращает все группы вариантов товара"""
+ return self.variant_groups.all()
+
+ def get_similar_products(self):
+ """Возвращает все товары из тех же групп вариантов (исключая себя)"""
+ return Product.objects.filter(
+ variant_groups__in=self.variant_groups.all()
+ ).exclude(id=self.id).distinct()
diff --git a/myproject/products/models/variants.py b/myproject/products/models/variants.py
new file mode 100644
index 0000000..dfe7dcf
--- /dev/null
+++ b/myproject/products/models/variants.py
@@ -0,0 +1,102 @@
+"""
+Модели для работы с группами вариантов товаров.
+Позволяет группировать взаимозаменяемые товары (например, розы разной длины).
+"""
+from django.db import models
+
+
+class ProductVariantGroup(models.Model):
+ """
+ Группа вариантов товара (взаимозаменяемые товары).
+ Например: "Роза красная Freedom" включает розы 50см, 60см, 70см.
+ """
+ name = models.CharField(max_length=200, verbose_name="Название")
+ description = models.TextField(blank=True, verbose_name="Описание")
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
+ updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
+
+ class Meta:
+ verbose_name = "Группа вариантов"
+ verbose_name_plural = "Группы вариантов"
+ ordering = ['name']
+
+ def __str__(self):
+ return self.name
+
+ def get_products_count(self):
+ """Возвращает количество товаров в группе"""
+ return self.items.count()
+
+ @property
+ def in_stock(self):
+ """
+ Вариант в наличии, если хотя бы один из его товаров в наличии.
+ Товар в наличии, если Product.in_stock = True.
+ """
+ return self.items.filter(product__in_stock=True).exists()
+
+ @property
+ def price(self):
+ """
+ Цена варианта определяется по приоритету товаров:
+ 1. Берётся цена товара с приоритетом 1, если он в наличии
+ 2. Если нет - цена товара с приоритетом 2
+ 3. И так далее по приоритетам
+ 4. Если ни один товар не в наличии - берётся самый дорогой товар из группы
+
+ Возвращает Decimal (цену) или None если группа пуста.
+ """
+ items = self.items.all().order_by('priority', 'id')
+
+ if not items.exists():
+ return None
+
+ # Ищем первый товар в наличии
+ for item in items:
+ if item.product.in_stock:
+ return item.product.sale_price
+
+ # Если ни один товар не в наличии - берем самый дорогой
+ max_price = None
+ for item in items:
+ if max_price is None or item.product.sale_price > max_price:
+ max_price = item.product.sale_price
+
+ return max_price
+
+
+class ProductVariantGroupItem(models.Model):
+ """
+ Товар в группе вариантов с приоритетом для этой конкретной группы.
+ Приоритет определяет порядок выбора товара при использовании группы в комплектах.
+ Например: в группе "Роза красная Freedom" - роза 50см имеет приоритет 1, 60см = 2, 70см = 3.
+ """
+ variant_group = models.ForeignKey(
+ ProductVariantGroup,
+ on_delete=models.CASCADE,
+ related_name='items',
+ verbose_name="Группа вариантов"
+ )
+ product = models.ForeignKey(
+ 'Product',
+ on_delete=models.CASCADE,
+ related_name='variant_group_items',
+ verbose_name="Товар"
+ )
+ priority = models.PositiveIntegerField(
+ default=0,
+ help_text="Меньше = выше приоритет (1 - наивысший приоритет в этой группе)"
+ )
+
+ class Meta:
+ verbose_name = "Товар в группе вариантов"
+ verbose_name_plural = "Товары в группах вариантов"
+ ordering = ['priority', 'id']
+ unique_together = [['variant_group', 'product']]
+ indexes = [
+ models.Index(fields=['variant_group', 'priority']),
+ models.Index(fields=['product']),
+ ]
+
+ def __str__(self):
+ return f"{self.variant_group.name} - {self.product.name} (приоритет {self.priority})"
diff --git a/myproject/products/services/__init__.py b/myproject/products/services/__init__.py
new file mode 100644
index 0000000..2b46668
--- /dev/null
+++ b/myproject/products/services/__init__.py
@@ -0,0 +1,4 @@
+"""
+Сервисы для бизнес-логики products приложения.
+Следует принципу "Skinny Models, Fat Services".
+"""
diff --git a/myproject/products/services/cost_calculator.py b/myproject/products/services/cost_calculator.py
new file mode 100644
index 0000000..7b0d7fe
--- /dev/null
+++ b/myproject/products/services/cost_calculator.py
@@ -0,0 +1,185 @@
+"""
+Сервис для расчета себестоимости товаров на основе партий (FIFO).
+Извлекает сложную бизнес-логику из модели.
+"""
+from decimal import Decimal, InvalidOperation
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class ProductCostCalculator:
+ """
+ Калькулятор себестоимости для Product.
+ Рассчитывает средневзвешенную стоимость на основе активных партий товара.
+ """
+
+ @staticmethod
+ def calculate_weighted_average_cost(product):
+ """
+ Рассчитать средневзвешенную себестоимость из активных партий товара.
+
+ Логика:
+ - Если нет активных партий с quantity > 0: возвращает 0.00
+ - Если есть партии: (сумма(quantity * cost_price) / сумма(quantity))
+
+ Args:
+ product: Объект Product для расчета себестоимости
+
+ Returns:
+ Decimal: Средневзвешенная себестоимость, округленная до 2 знаков
+ """
+ from inventory.models import StockBatch
+
+ try:
+ # Получаем все активные партии товара с остатками
+ batches = StockBatch.objects.filter(
+ product=product,
+ is_active=True,
+ quantity__gt=0
+ ).values('quantity', 'cost_price')
+
+ if not batches:
+ logger.debug(f"Товар {product.sku} не имеет активных партий. Себестоимость = 0")
+ return Decimal('0.00')
+
+ # Рассчитываем средневзвешенную стоимость
+ total_value = Decimal('0.00')
+ total_quantity = Decimal('0.00')
+
+ for batch in batches:
+ quantity = Decimal(str(batch['quantity']))
+ cost_price = Decimal(str(batch['cost_price']))
+
+ total_value += quantity * cost_price
+ total_quantity += quantity
+
+ if total_quantity == 0:
+ logger.debug(f"Товар {product.sku} имеет партии, но общее количество = 0. Себестоимость = 0")
+ return Decimal('0.00')
+
+ # Рассчитываем средневзвешенную стоимость
+ weighted_cost = total_value / total_quantity
+
+ # Округляем до 2 знаков после запятой
+ result = weighted_cost.quantize(Decimal('0.01'))
+
+ logger.debug(
+ f"Товар {product.sku}: средневзвешенная себестоимость = {result} "
+ f"(партий: {len(batches)}, количество: {total_quantity})"
+ )
+
+ return result
+
+ except (InvalidOperation, ZeroDivisionError) as e:
+ logger.error(
+ f"Ошибка при расчете себестоимости для товара {product.sku}: {e}",
+ exc_info=True
+ )
+ return Decimal('0.00')
+
+ @staticmethod
+ def update_product_cost(product, save=True):
+ """
+ Обновить кешированную себестоимость товара.
+
+ Рассчитывает новую себестоимость и обновляет поле cost_price,
+ если значение изменилось.
+
+ Args:
+ product: Объект Product для обновления
+ save: Если True, сохраняет изменения в БД (default: True)
+
+ Returns:
+ tuple: (old_cost, new_cost, was_updated)
+ """
+ old_cost = product.cost_price
+ new_cost = ProductCostCalculator.calculate_weighted_average_cost(product)
+
+ was_updated = False
+
+ if old_cost != new_cost:
+ product.cost_price = new_cost
+
+ if save:
+ product.save(update_fields=['cost_price'])
+ logger.info(
+ f"Обновлена себестоимость товара {product.sku}: "
+ f"{old_cost} -> {new_cost}"
+ )
+
+ was_updated = True
+ else:
+ logger.debug(
+ f"Себестоимость товара {product.sku} не изменилась: {old_cost}"
+ )
+
+ return (old_cost, new_cost, was_updated)
+
+ @staticmethod
+ def get_cost_details(product):
+ """
+ Получить детальную информацию о расчете себестоимости товара.
+
+ Возвращает детали по каждой партии для отображения в UI.
+
+ Args:
+ product: Объект Product
+
+ Returns:
+ dict: {
+ 'cached_cost': Decimal, # Кешированная себестоимость
+ 'calculated_cost': Decimal, # Рассчитанная себестоимость
+ 'is_synced': bool, # Совпадают ли значения
+ 'total_quantity': Decimal, # Общее количество в партиях
+ 'batches': [ # Список партий
+ {
+ 'warehouse_name': str,
+ 'warehouse_id': int,
+ 'quantity': Decimal,
+ 'cost_price': Decimal,
+ 'total_value': Decimal,
+ 'created_at': datetime,
+ },
+ ...
+ ]
+ }
+ """
+ from inventory.models import StockBatch
+
+ cached_cost = product.cost_price
+ calculated_cost = ProductCostCalculator.calculate_weighted_average_cost(product)
+
+ # Получаем все активные партии товара с остатками
+ batches_qs = StockBatch.objects.filter(
+ product=product,
+ is_active=True,
+ quantity__gt=0
+ ).select_related('warehouse').order_by('created_at')
+
+ batches_list = []
+ total_quantity = Decimal('0.00')
+
+ for batch in batches_qs:
+ quantity = batch.quantity
+ cost_price = batch.cost_price
+ total_value = quantity * cost_price
+
+ batches_list.append({
+ 'warehouse_name': batch.warehouse.name,
+ 'warehouse_id': batch.warehouse.id,
+ 'quantity': quantity,
+ 'cost_price': cost_price,
+ 'total_value': total_value,
+ 'created_at': batch.created_at,
+ })
+
+ total_quantity += quantity
+
+ return {
+ 'cached_cost': cached_cost,
+ 'calculated_cost': calculated_cost,
+ 'is_synced': cached_cost == calculated_cost,
+ 'total_quantity': total_quantity,
+ 'batches': batches_list,
+ }
diff --git a/myproject/products/services/kit_availability.py b/myproject/products/services/kit_availability.py
new file mode 100644
index 0000000..a09d3ae
--- /dev/null
+++ b/myproject/products/services/kit_availability.py
@@ -0,0 +1,36 @@
+"""
+Сервис для проверки доступности комплектов.
+"""
+
+
+class KitAvailabilityChecker:
+ """
+ Проверяет доступность комплектов на основе остатков товаров.
+ """
+
+ @staticmethod
+ def check_availability(kit, stock_manager=None):
+ """
+ Проверяет доступность всего комплекта.
+
+ Комплект доступен, если для каждой позиции в комплекте
+ есть хотя бы один доступный вариант товара.
+
+ Args:
+ kit (ProductKit): Комплект для проверки
+ stock_manager: Объект управления складом (если не указан, используется стандартный)
+
+ Returns:
+ bool: True, если комплект полностью доступен, иначе False
+ """
+ from ..utils.stock_manager import StockManager
+
+ if stock_manager is None:
+ stock_manager = StockManager()
+
+ for kit_item in kit.kit_items.all():
+ best_product = kit_item.get_best_available_product(stock_manager)
+ if not best_product:
+ return False
+
+ return True
diff --git a/myproject/products/services/kit_pricing.py b/myproject/products/services/kit_pricing.py
new file mode 100644
index 0000000..47585d4
--- /dev/null
+++ b/myproject/products/services/kit_pricing.py
@@ -0,0 +1,231 @@
+"""
+Сервисы для расчета цен комплектов (ProductKit).
+Извлекает сложную бизнес-логику из модели.
+"""
+from decimal import Decimal, InvalidOperation
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class KitPriceCalculator:
+ """
+ Калькулятор цен для ProductKit.
+ Реализует различные методы ценообразования комплектов.
+ """
+
+ @staticmethod
+ def calculate_price_with_substitutions(kit, stock_manager=None):
+ """
+ Расчёт цены комплекта с учётом доступных замен компонентов.
+
+ Метод определяет цену комплекта, учитывая доступные товары-заменители
+ и применяет выбранный метод ценообразования.
+
+ Args:
+ kit (ProductKit): Комплект для расчета
+ stock_manager: Объект управления складом (если не указан, используется стандартный)
+
+ Returns:
+ Decimal: Расчетная цена комплекта, или 0 в случае ошибки
+ """
+ from ..utils.stock_manager import StockManager
+
+ if stock_manager is None:
+ stock_manager = StockManager()
+
+ # Если указана ручная цена, используем её
+ if kit.pricing_method == 'manual' and kit.price:
+ return kit.price
+
+ total_cost = Decimal('0.00')
+ total_sale = Decimal('0.00')
+
+ for kit_item in kit.kit_items.select_related('product', 'variant_group'):
+ try:
+ best_product = kit_item.get_best_available_product(stock_manager)
+
+ if not best_product:
+ # Если товар недоступен, используем цену первого в списке
+ available_products = kit_item.get_available_products()
+ best_product = available_products[0] if available_products else None
+
+ if best_product:
+ item_cost = best_product.cost_price
+ item_price = best_product.price
+ item_quantity = kit_item.quantity or Decimal('1.00')
+
+ # Проверяем корректность значений перед умножением
+ if item_cost and item_quantity:
+ total_cost += item_cost * item_quantity
+ if item_price and item_quantity:
+ total_sale += item_price * item_quantity
+ except (AttributeError, TypeError, InvalidOperation) as e:
+ # Логируем ошибку, но продолжаем вычисления
+ logger.warning(
+ f"Ошибка при расчёте цены для комплекта {kit.name} (item: {kit_item}): {e}"
+ )
+ continue # Пропускаем ошибочный элемент и продолжаем с остальными
+
+ # Применяем метод ценообразования
+ try:
+ if kit.pricing_method == 'from_sale_prices':
+ return total_sale
+ elif kit.pricing_method == 'from_cost_plus_percent' and kit.markup_percent is not None:
+ return total_cost * (Decimal('1') + kit.markup_percent / Decimal('100'))
+ elif kit.pricing_method == 'from_cost_plus_amount' and kit.markup_amount is not None:
+ return total_cost + kit.markup_amount
+ elif kit.pricing_method == 'manual' and kit.price:
+ return kit.price
+
+ return total_sale
+ except (TypeError, InvalidOperation) as e:
+ logger.error(
+ f"Ошибка при применении метода ценообразования для комплекта {kit.name}: {e}"
+ )
+ # Возвращаем ручную цену если есть, иначе 0
+ if kit.pricing_method == 'manual' and kit.price:
+ return kit.price
+ return Decimal('0.00')
+
+
+class KitCostCalculator:
+ """
+ Калькулятор себестоимости для ProductKit.
+ Включает расчет и валидацию себестоимости комплекта.
+ """
+
+ @staticmethod
+ def calculate_cost(kit):
+ """
+ Расчёт себестоимости комплекта на основе себестоимости компонентов.
+
+ Args:
+ kit (ProductKit): Комплект для расчета
+
+ Returns:
+ Decimal: Себестоимость комплекта (может быть 0 если есть проблемы)
+ """
+ total_cost = Decimal('0.00')
+
+ for kit_item in kit.kit_items.select_related('product', 'variant_group'):
+ # Получаем продукт - либо конкретный, либо первый из группы вариантов
+ product = kit_item.product
+ if not product and kit_item.variant_group:
+ # Берем первый продукт из группы вариантов
+ product = kit_item.variant_group.products.filter(is_active=True).first()
+
+ if product and product.cost_price:
+ item_cost = product.cost_price
+ item_quantity = kit_item.quantity or Decimal('1.00')
+ total_cost += item_cost * item_quantity
+
+ return total_cost
+
+ @staticmethod
+ def validate_and_calculate_cost(kit):
+ """
+ Расчёт себестоимости с полной валидацией.
+ Проверяет, что все компоненты имеют себестоимость > 0.
+
+ Args:
+ kit (ProductKit): Комплект для валидации и расчета
+
+ Returns:
+ dict: {
+ 'total_cost': Decimal or None,
+ 'is_valid': bool,
+ 'problems': list of dicts {
+ 'component_name': str,
+ 'reason': str,
+ 'kit_item_id': int
+ }
+ }
+ """
+ total_cost = Decimal('0.00')
+ problems = []
+
+ if not kit.kit_items.exists():
+ # Комплект без компонентов не может иметь корректную себестоимость
+ return {
+ 'total_cost': None,
+ 'is_valid': False,
+ 'problems': [{
+ 'component_name': 'Комплект',
+ 'reason': 'Комплект не содержит компонентов'
+ }]
+ }
+
+ for kit_item in kit.kit_items.select_related('product', 'variant_group'):
+ # Получаем продукт
+ product = kit_item.product
+ product_name = ''
+
+ if not product and kit_item.variant_group:
+ # Берем первый активный продукт из группы вариантов
+ product = kit_item.variant_group.products.filter(is_active=True).first()
+ if kit_item.variant_group:
+ product_name = f"[Варианты] {kit_item.variant_group.name}"
+
+ if not product:
+ # Товар не найден или группа вариантов пуста
+ if kit_item.variant_group:
+ problems.append({
+ 'component_name': f"[Варианты] {kit_item.variant_group.name}",
+ 'reason': 'Группа не содержит активных товаров',
+ 'kit_item_id': kit_item.id
+ })
+ else:
+ problems.append({
+ 'component_name': 'Неизвестный компонент',
+ 'reason': 'Товар не выбран и нет группы вариантов',
+ 'kit_item_id': kit_item.id
+ })
+ continue
+
+ # Используем имя товара, если не установили выше
+ if not product_name:
+ product_name = product.name
+
+ # Проверяем наличие себестоимости
+ if product.cost_price is None:
+ problems.append({
+ 'component_name': product_name,
+ 'reason': 'Себестоимость не определена',
+ 'kit_item_id': kit_item.id
+ })
+ continue
+
+ # Проверяем, что себестоимость > 0
+ if product.cost_price == Decimal('0.00') or product.cost_price <= 0:
+ problems.append({
+ 'component_name': product_name,
+ 'reason': 'Себестоимость равна 0',
+ 'kit_item_id': kit_item.id
+ })
+ continue
+
+ # Если всё OK - добавляем в сумму
+ try:
+ item_quantity = kit_item.quantity or Decimal('1.00')
+ if item_quantity > 0:
+ total_cost += product.cost_price * item_quantity
+ except (TypeError, InvalidOperation) as e:
+ logger.warning(
+ f"Ошибка при расчете себестоимости компонента {product_name} "
+ f"комплекта {kit.name}: {e}"
+ )
+ problems.append({
+ 'component_name': product_name,
+ 'reason': 'Ошибка при расчете',
+ 'kit_item_id': kit_item.id
+ })
+
+ # Если есть проблемы, себестоимость не валидна
+ is_valid = len(problems) == 0
+
+ return {
+ 'total_cost': total_cost if is_valid else None,
+ 'is_valid': is_valid,
+ 'problems': problems
+ }
diff --git a/myproject/products/services/product_service.py b/myproject/products/services/product_service.py
new file mode 100644
index 0000000..25d8189
--- /dev/null
+++ b/myproject/products/services/product_service.py
@@ -0,0 +1,68 @@
+"""
+Сервисы для бизнес-логики Product модели.
+Извлекает сложную логику из save() метода.
+"""
+
+
+class ProductSaveService:
+ """
+ Сервис для обработки сохранения Product.
+ Извлекает variant_suffix, генерирует SKU и поисковые ключевые слова.
+ """
+
+ @staticmethod
+ def prepare_product_for_save(product):
+ """
+ Подготавливает продукт к сохранению:
+ - Извлекает variant_suffix из названия
+ - Генерирует SKU если не задан
+ - Создает базовые поисковые ключевые слова
+
+ Args:
+ product (Product): Экземпляр продукта
+
+ Returns:
+ Product: Обновленный экземпляр продукта
+ """
+ from ..utils.sku_generator import parse_variant_suffix, generate_product_sku
+
+ # Автоматическое извлечение variant_suffix из названия
+ if not product.variant_suffix and product.name:
+ parsed_suffix = parse_variant_suffix(product.name)
+ if parsed_suffix:
+ product.variant_suffix = parsed_suffix
+
+ # Генерация артикула для новых товаров
+ if not product.sku:
+ product.sku = generate_product_sku(product)
+
+ # Автоматическая генерация ключевых слов для поиска
+ keywords_parts = [
+ product.name or '',
+ product.sku or '',
+ product.description or '',
+ ]
+
+ if not product.search_keywords:
+ product.search_keywords = ' '.join(filter(None, keywords_parts))
+
+ return product
+
+ @staticmethod
+ def update_search_keywords_with_categories(product):
+ """
+ Обновляет поисковые ключевые слова с названиями категорий.
+ Должен вызываться после сохранения, т.к. ManyToMany требует существующего объекта.
+
+ Args:
+ product (Product): Сохраненный экземпляр продукта
+ """
+ # Добавляем названия категорий в search_keywords после сохранения
+ # (ManyToMany требует, чтобы объект уже существовал в БД)
+ if product.pk and product.categories.exists():
+ category_names = ' '.join([cat.name for cat in product.categories.all()])
+ if category_names and category_names not in product.search_keywords:
+ product.search_keywords = f"{product.search_keywords} {category_names}".strip()
+ # Используем update чтобы избежать рекурсии
+ from ..models.products import Product
+ Product.objects.filter(pk=product.pk).update(search_keywords=product.search_keywords)
diff --git a/myproject/products/services/slug_service.py b/myproject/products/services/slug_service.py
new file mode 100644
index 0000000..9dc9aa6
--- /dev/null
+++ b/myproject/products/services/slug_service.py
@@ -0,0 +1,72 @@
+"""
+Сервис для генерации уникальных slug для моделей.
+Централизует логику транслитерации и обеспечения уникальности.
+"""
+from django.utils.text import slugify
+from unidecode import unidecode
+
+
+class SlugService:
+ """
+ Статический сервис для генерации уникальных slug.
+ Используется моделями Product, ProductKit, ProductCategory, ProductTag.
+ """
+
+ @staticmethod
+ def generate_unique_slug(name, model_class, instance_pk=None):
+ """
+ Генерирует уникальный slug из названия с транслитерацией кириллицы.
+
+ Args:
+ name (str): Исходное название для генерации slug
+ model_class (Model): Класс модели для проверки уникальности
+ instance_pk (int, optional): ID текущего экземпляра (для исключения при обновлении)
+
+ Returns:
+ str: Уникальный slug
+
+ Example:
+ >>> SlugService.generate_unique_slug("Роза красная", Product, None)
+ 'roza-krasnaya'
+ >>> SlugService.generate_unique_slug("Роза красная", Product, None) # если уже существует
+ 'roza-krasnaya-1'
+ """
+ # Транслитерируем кириллицу в латиницу, затем применяем slugify
+ transliterated_name = unidecode(name)
+ base_slug = slugify(transliterated_name)
+
+ # Обеспечиваем уникальность
+ slug = base_slug
+ counter = 1
+
+ while True:
+ # Проверяем существование slug, исключая текущий экземпляр если это обновление
+ query = model_class.objects.filter(slug=slug)
+ if instance_pk:
+ query = query.exclude(pk=instance_pk)
+
+ if not query.exists():
+ break
+
+ # Если slug занят, добавляем счетчик
+ slug = f"{base_slug}-{counter}"
+ counter += 1
+
+ return slug
+
+ @staticmethod
+ def transliterate(text):
+ """
+ Транслитерирует текст (кириллицу в латиницу).
+
+ Args:
+ text (str): Текст для транслитерации
+
+ Returns:
+ str: Транслитерированный текст
+
+ Example:
+ >>> SlugService.transliterate("Привет мир")
+ 'Privet mir'
+ """
+ return unidecode(text)
diff --git a/myproject/products/templates/products/category_list.html b/myproject/products/templates/products/category_list.html
index e748ace..2c516a0 100644
--- a/myproject/products/templates/products/category_list.html
+++ b/myproject/products/templates/products/category_list.html
@@ -83,7 +83,7 @@
{} '
+ '
{% if item.price %}
- {{ item.price|floatformat:0 }} ₽
+ {{ item.price|floatformat:0 }} руб.
{% else %}
—
{% endif %}
diff --git a/myproject/products/templates/products/includes/kititem_formset.html b/myproject/products/templates/products/includes/kititem_formset.html
index b63ae3a..099547d 100644
--- a/myproject/products/templates/products/includes/kititem_formset.html
+++ b/myproject/products/templates/products/includes/kititem_formset.html
@@ -1,4 +1,5 @@
+{% load inventory_filters %}
| Состав комплекта@@ -7,7 +8,9 @@
{% for kititem_form in kititem_formset %}
-
+
{{ kititem_form.id }}
{% if kititem_form.non_field_errors %}
@@ -17,13 +20,27 @@
{% endif %}
-
+
+
{{ kititem_form.product }}
{% if kititem_form.product.errors %}
+
+
+ {{ kititem_form.product.errors }}
{% endif %}
+
+
+
+ ИЛИ
+
+
+
{{ kititem_form.variant_group }}
@@ -31,13 +48,17 @@
+
+
{{ kititem_form.variant_group.errors }}
{% endif %}
- {{ kititem_form.quantity }}
+ {{ kititem_form.quantity|smart_quantity }}
{% if kititem_form.quantity.errors %}
+
+
{{ kititem_form.quantity.errors }}
{% endif %}
{% if kititem_form.DELETE %}
');
+ $container.text(item.text);
+
+ if (item.price) {
+ $container.append($(' ').text(item.price + ' руб.'));
+ }
+
+ return $container;
+ }
+
+ // Форматирование выбранного элемента
+ function formatSelectSelection(item) {
+ return item.text || item.id;
+ }
+
+ /**
+ * Инициализирует Select2 для элемента с AJAX поиском товаров
+ * @param {Element|jQuery} element - DOM элемент или jQuery объект select
+ * @param {string} type - Тип поиска ('product' или 'variant')
+ * @param {string} apiUrl - URL API для поиска
+ * @param {Object} preloadedData - Предзагруженные данные товара
+ */
+ window.initProductSelect2 = function(element, type, apiUrl, preloadedData) {
+ if (!element) return;
+
+ // Преобразуем в jQuery если нужно
+ var $element = $(element);
+
+ // Если уже инициализирован, пропускаем
+ if ($element.data('select2')) {
+ return;
+ }
+
+ var placeholders = {
+ 'product': 'Начните вводить название товара...',
+ 'variant': 'Начните вводить название группы...'
+ };
+
+ var config = {
+ theme: 'bootstrap-5',
+ placeholder: placeholders[type] || 'Выберите...',
+ allowClear: true,
+ language: 'ru',
+ minimumInputLength: 0,
+ ajax: {
+ url: apiUrl,
+ dataType: 'json',
+ delay: 250,
+ data: function (params) {
+ return {
+ q: params.term || '',
+ type: type,
+ page: params.page || 1
+ };
+ },
+ processResults: function (data) {
+ return {
+ results: data.results,
+ pagination: {
+ more: data.pagination.more
+ }
+ };
+ },
+ cache: true
+ },
+ templateResult: formatSelectResult,
+ templateSelection: formatSelectSelection
+ };
+
+ // Если есть предзагруженные данные, создаем option с ними
+ if (preloadedData) {
+ var option = new Option(preloadedData.text, preloadedData.id, true, true);
+ $element.append(option);
+ }
+
+ $element.select2(config);
+ };
+
+ /**
+ * Инициализирует Select2 для всех элементов с данным селектором
+ * @param {string} selector - CSS селектор элементов
+ * @param {string} type - Тип поиска ('product' или 'variant')
+ * @param {string} apiUrl - URL API для поиска
+ */
+ window.initAllProductSelect2 = function(selector, type, apiUrl) {
+ document.querySelectorAll(selector).forEach(function(element) {
+ window.initProductSelect2(element, type, apiUrl);
+ });
+ };
+
+})(window);
diff --git a/myproject/products/templates/products/productkit_create.html b/myproject/products/templates/products/productkit_create.html
index 4e9ad26..76d9871 100644
--- a/myproject/products/templates/products/productkit_create.html
+++ b/myproject/products/templates/products/productkit_create.html
@@ -4,7 +4,7 @@
{% block content %}
-
+
+
{% if form.non_field_errors or kititem_formset.non_form_errors %}
{% for error in form.non_field_errors %}{{ error }}{% endfor %}
@@ -24,9 +25,9 @@
{% csrf_token %}
-
+
-
+
{{ form.name }}
{% if form.name.errors %}
@@ -34,7 +35,7 @@
{% endif %}
-
+
+
+ {{ form.short_description }}
+ Используется для карточек комплектов, превью и площадок
+ {% if form.short_description.errors %}
+
+
{% include 'products/includes/kititem_formset.html' %}
@@ -62,52 +75,102 @@
{{ form.short_description.errors }}
+ {% endif %}
+
-
+
-
Ценообразование+Расчет Цены-
-
- {{ form.pricing_method }}
- {% if form.pricing_method.errors %}
-
@@ -156,7 +219,7 @@
{{ form.pricing_method.errors }}
- {% endif %}
+
+
+
+ Итоговая цена формируется из цен компонентов.
+ Добавьте компоненты выше или отрегулируйте цену ниже.
-
@@ -141,7 +204,7 @@
{{ form.sku }}
{% if form.sku.errors %}
- {{ form.sku.errors }}
+ {{ form.sku.errors }}
{% endif %}
|