БОЛЬШОЕ ИЗМЕНЕНИЕ
This commit is contained in:
@@ -1,304 +0,0 @@
|
||||
# Реализация динамической себестоимости товаров (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!
|
||||
@@ -1,248 +0,0 @@
|
||||
# Финальное резюме исправления Race Condition при загрузке корректировки цены
|
||||
|
||||
## Дата: 2025-11-02
|
||||
## Коммит: c7bf23c
|
||||
## Статус: ✅ Готово к тестированию
|
||||
|
||||
---
|
||||
|
||||
## Что было исправлено
|
||||
|
||||
### Критическая проблема
|
||||
Сохранённые значения корректировки цены не отображались надёжно на странице редактирования комплекта:
|
||||
- **1/10 раз:** отображалось правильно ✅
|
||||
- **9/10 раз:** не отображалось вообще ❌
|
||||
- **Пользовательский отзыв:** "Такое ощущение, что оно отображается а потом затирается какой-то переинициализацией"
|
||||
|
||||
### Корневая причина
|
||||
**Race condition:** при установке значения в input-поле срабатывают события `input` и `change`, которые вызывают `calculateFinalPrice()`, которая перезаписывает скрытые поля со значениями по умолчанию, стирая загруженные значения.
|
||||
|
||||
### Решение
|
||||
Трёхуровневая защита от race condition:
|
||||
|
||||
1. **Уровень 1: Подавление событий** (`isLoadingAdjustmentValues` флаг)
|
||||
- Во время загрузки значений: флаг = true
|
||||
- Event listeners видят флаг и пропускают обработку
|
||||
- Предотвращает нежелательные вызовы calculateFinalPrice()
|
||||
|
||||
2. **Уровень 2: Защита скрытых полей** (`isInitializing` флаг)
|
||||
- calculateFinalPrice() проверяет `if (!isInitializing)` перед обновлением скрытых полей
|
||||
- Даже если событие срабатит, скрытые поля не будут перезаписаны
|
||||
|
||||
3. **Уровень 3: Синхронизация с браузером** (`requestAnimationFrame`)
|
||||
- `isInitializing = false` устанавливается в конце frame cycle
|
||||
- Гарантирует правильный порядок выполнения без угадывания timing'а
|
||||
|
||||
---
|
||||
|
||||
## Файлы изменены
|
||||
|
||||
### `productkit_edit.html` (3 основных изменения)
|
||||
|
||||
**Строка 435:** Добавлен флаг подавления событий
|
||||
```javascript
|
||||
let isLoadingAdjustmentValues = false; // Флаг для подавления событий input/change
|
||||
```
|
||||
|
||||
**Строки 683-700:** Event listeners защищены флагом
|
||||
```javascript
|
||||
input.addEventListener('input', () => {
|
||||
if (isLoadingAdjustmentValues) { // ← Уровень 1 защиты
|
||||
console.log('Skipping event during adjustment value loading');
|
||||
return;
|
||||
}
|
||||
validateSingleAdjustment();
|
||||
calculateFinalPrice();
|
||||
});
|
||||
```
|
||||
|
||||
**Строки 912-948:** Загрузка сохранённых значений с использованием флагов
|
||||
```javascript
|
||||
isLoadingAdjustmentValues = true; // Включаем подавление событий
|
||||
// Загружаем значения (события подавляются)
|
||||
increasePercentInput.value = currentAdjustmentValue;
|
||||
// Вызываем валидацию вручную
|
||||
validateSingleAdjustment();
|
||||
isLoadingAdjustmentValues = false; // Выключаем подавление событий
|
||||
|
||||
// calculateFinalPrice с isInitializing = true (не перезапишет скрытые поля)
|
||||
await calculateFinalPrice();
|
||||
|
||||
// requestAnimationFrame для надёжной синхронизации
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
isInitializing = false;
|
||||
console.log('Initialization complete, isInitializing =', isInitializing);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Как проверить исправление
|
||||
|
||||
### Быстрая проверка (1 минута)
|
||||
1. Открыть http://grach.localhost:8000/products/kits/4/update/
|
||||
2. Нажать Ctrl+F5 (очистить кэш)
|
||||
3. Проверить что в блоке "НА СКОЛЬКО БЫЛА ИЗМЕНЕНА ЦЕНА" отображается "Увеличить на %: 10"
|
||||
4. Нажать F5 ещё 5-10 раз
|
||||
5. **Результат:** Должно отображаться каждый раз ✅
|
||||
|
||||
### Полная проверка (10 минут)
|
||||
Смотрите документ **ADJUSTMENT_VALUE_FIX_TESTING.md** для полного плана тестирования
|
||||
|
||||
### Проверка логирования в консоли
|
||||
1. Открыть http://grach.localhost:8000/products/kits/4/update/
|
||||
2. Нажать F12 (DevTools)
|
||||
3. Перейти в Console
|
||||
4. Нажать Ctrl+F5
|
||||
5. **Ожидаемые логи:**
|
||||
```
|
||||
Loading saved adjustment values: {type: 'increase_percent', value: 10}
|
||||
isLoadingAdjustmentValues = true, suppressing input/change events
|
||||
Loaded increase_percent: 10
|
||||
isLoadingAdjustmentValues = false, events are enabled again
|
||||
calculateFinalPrice: calculating...
|
||||
[... логи расчётов ...]
|
||||
Initialization complete, isInitializing = false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Преимущества решения
|
||||
|
||||
| Аспект | Результат |
|
||||
|--------|-----------|
|
||||
| **Надёжность** | 99%+ вместо 10% |
|
||||
| **Логирование** | Можно отследить порядок выполнения в консоли |
|
||||
| **Поддерживаемость** | Понятный код с комментариями |
|
||||
| **Производительность** | Нет влияния на производительность |
|
||||
| **Масштабируемость** | Работает для всех 4 типов корректировки |
|
||||
| **Отладка** | Логи помогают находить проблемы |
|
||||
|
||||
---
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Принципы применённые
|
||||
1. **Explicit Event Suppression** - явное подавление событий вместо угадывания
|
||||
2. **Defense in Depth** - несколько уровней защиты вместо одного
|
||||
3. **Browser Synchronization** - использование requestAnimationFrame вместо setTimeout
|
||||
4. **Logging** - логирование для отладки и понимания потока выполнения
|
||||
|
||||
### Порядок выполнения (с исправлением)
|
||||
```
|
||||
Загрузка страницы (500ms задержка)
|
||||
↓
|
||||
isLoadingAdjustmentValues = true
|
||||
↓
|
||||
Загрузка значений в input-поля
|
||||
↓
|
||||
Events срабатывают но ПОДАВЛЯЮТСЯ флагом
|
||||
↓
|
||||
validateSingleAdjustment() вызывается вручную
|
||||
↓
|
||||
isLoadingAdjustmentValues = false
|
||||
↓
|
||||
calculateFinalPrice() с isInitializing = true
|
||||
↓
|
||||
requestAnimationFrame × 2
|
||||
↓
|
||||
isInitializing = false
|
||||
↓
|
||||
✅ Готово: значения загружены, события работают, скрытые поля защищены
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что дальше
|
||||
|
||||
### Для пользователя
|
||||
1. Протестировать исправление согласно ADJUSTMENT_VALUE_FIX_TESTING.md
|
||||
2. Проверить что значения отображаются 10/10 раз вместо 1/10
|
||||
3. Попробовать редактировать комплекты с разными типами корректировки
|
||||
4. Проверить консоль браузера для понимания порядка выполнения
|
||||
|
||||
### Для разработчика (если потребуется)
|
||||
Если появятся проблемы:
|
||||
1. Проверить логи в консоли (F12 → Console)
|
||||
2. Убедиться что git коммит c7bf23c развернут
|
||||
3. Очистить браузерный кэш (Ctrl+Shift+Delete)
|
||||
4. Нажать Ctrl+F5 на странице редактирования
|
||||
5. Проверить что файл productkit_edit.html содержит все изменения
|
||||
|
||||
---
|
||||
|
||||
## Файлы документации
|
||||
|
||||
### Основные
|
||||
- **ADJUSTMENT_VALUE_FIX_TESTING.md** - План тестирования и проверки
|
||||
- **TECHNICAL_RACE_CONDITION_FIX.md** - Глубокий технический анализ
|
||||
|
||||
### Справочные
|
||||
- **SESSION_SUMMARY.md** - Резюме всей сессии работ
|
||||
- **IMPROVEMENTS_SUMMARY.md** - Обзор всех улучшений системы ценообразования
|
||||
- **KIT_PRICING_SYSTEM_READY.md** - Архитектура всей системы
|
||||
|
||||
---
|
||||
|
||||
## Интеграция с существующей системой
|
||||
|
||||
### Совместимость с другими компонентами
|
||||
- ✅ `validateSingleAdjustment()` - работает как прежде
|
||||
- ✅ `calculateFinalPrice()` - с добавленной защитой скрытых полей
|
||||
- ✅ Event listeners на продукты - не затронуты
|
||||
- ✅ Event listeners на количество - не затронуты
|
||||
- ✅ Select2 интеграция - не затронута
|
||||
|
||||
### Зависимости
|
||||
- Требуется JavaScript ES6+ (async/await, requestAnimationFrame)
|
||||
- Браузеры: Chrome, Firefox, Safari, Edge (все современные версии)
|
||||
|
||||
---
|
||||
|
||||
## Коммит информация
|
||||
|
||||
```
|
||||
Commit: c7bf23c
|
||||
Author: Claude <noreply@anthropic.com>
|
||||
Date: 2025-11-02
|
||||
|
||||
fix: Улучшить загрузку сохранённых значений корректировки цены на странице редактирования
|
||||
|
||||
Исправлена критическая проблема, когда сохранённые значения корректировки цены
|
||||
не отображались надёжно на странице редактирования (отображались только в 1 из 10 случаев).
|
||||
|
||||
Решение: Трёхуровневая защита от race condition
|
||||
1. Подавление событий input/change флагом isLoadingAdjustmentValues
|
||||
2. Защита скрытых полей флагом isInitializing в calculateFinalPrice()
|
||||
3. Синхронизация с браузером через requestAnimationFrame
|
||||
|
||||
Файлы изменены: productkit_edit.html
|
||||
- Добавлена логика подавления событий
|
||||
- Расширена обработка загрузки сохранённых значений
|
||||
- Добавлено логирование для отладки
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Заключение
|
||||
|
||||
Исправление использует проверенные техники синхронизации в JavaScript для полного устранения race condition при загрузке сохранённых значений корректировки цены.
|
||||
|
||||
**Результат:** надёжность возросла с 10% до 99%+
|
||||
|
||||
🎉 **Готово к использованию!**
|
||||
|
||||
---
|
||||
|
||||
## Контакты для вопросов
|
||||
|
||||
Если что-то не работает как ожидается:
|
||||
1. Проверьте консоль браузера (F12 → Console)
|
||||
2. Убедитесь что коммит c7bf23c есть в git
|
||||
3. Очистите кэш браузера (Ctrl+Shift+Delete)
|
||||
4. Нажмите Ctrl+F5 на странице редактирования
|
||||
5. Проверьте что используется правильный тенант ("grach")
|
||||
|
||||
Все логи и диагностическая информация выводится в консоль браузера для удобства отладки.
|
||||
@@ -1,334 +0,0 @@
|
||||
# Отчет об исправлениях системы динамического ценообразования комплектов
|
||||
|
||||
## Дата: 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 = $('<div class="select2-result-item">');
|
||||
$container.text(item.text);
|
||||
|
||||
// Отображаем actual_price (цену со скидкой если она есть), иначе обычную цену
|
||||
var displayPrice = item.actual_price || item.price;
|
||||
if (displayPrice) {
|
||||
$container.append($('<div class="text-muted small">').text(displayPrice + ' руб.'));
|
||||
}
|
||||
return $container;
|
||||
}
|
||||
```
|
||||
|
||||
### Результат
|
||||
✅ Select2 теперь отображает actual_price (цену со скидкой)
|
||||
✅ Это исправление касается обоих случаев: поиск и список по умолчанию
|
||||
|
||||
---
|
||||
|
||||
## Архитектура решения - Полный обзор
|
||||
|
||||
### Модель данных (ProductKit)
|
||||
|
||||
```python
|
||||
class ProductKit(BaseProductEntity):
|
||||
base_price = DecimalField() # Сумма actual_price компонентов
|
||||
price = DecimalField() # Итоговая цена (база + корректировка)
|
||||
price_adjustment_type = CharField() # 'none', 'increase_percent', 'increase_amount', 'decrease_percent', 'decrease_amount'
|
||||
price_adjustment_value = DecimalField() # Значение корректировки
|
||||
|
||||
def calculate_final_price(self):
|
||||
"""Рассчитывает финальную цену с корректировкой"""
|
||||
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)
|
||||
```
|
||||
|
||||
### Поток данных
|
||||
|
||||
```
|
||||
Пользователь выбирает товар
|
||||
↓
|
||||
Select2 запрашивает API (/api/search-products-variants/?id=X)
|
||||
↓
|
||||
API возвращает JSON с actual_price
|
||||
↓
|
||||
getProductPrice() кэширует цену в priceCache
|
||||
↓
|
||||
calculateFinalPrice() вызывается
|
||||
↓
|
||||
Суммирует actual_price × quantity для всех компонентов
|
||||
↓
|
||||
Вычисляет корректировку (автоопределение типа)
|
||||
↓
|
||||
Обновляет basePriceDisplay и finalPriceDisplay в реальном времени
|
||||
↓
|
||||
При сохранении отправляет в БД:
|
||||
- price_adjustment_type
|
||||
- price_adjustment_value
|
||||
- calculated price
|
||||
```
|
||||
|
||||
### JavaScript логика
|
||||
|
||||
#### getProductPrice(selectElement)
|
||||
Получает цену товара с приоритизацией:
|
||||
1. Кэш (самое быстро)
|
||||
2. data-product-price атрибут на форме
|
||||
3. Select2 option data attributes
|
||||
4. AJAX запрос к API
|
||||
|
||||
**Логирование:**
|
||||
```javascript
|
||||
console.log('getProductPrice: from cache', productId, cachedPrice);
|
||||
console.log('getProductPrice: from form data', productId, price);
|
||||
console.log('getProductPrice: from select2 data', productId, price);
|
||||
console.log('getProductPrice: fetching from API', productId);
|
||||
console.log('getProductPrice: from API', productId, price);
|
||||
console.warn('getProductPrice: returning 0 for product', productId);
|
||||
```
|
||||
|
||||
#### calculateFinalPrice()
|
||||
Асинхронная функция которая:
|
||||
1. Получает все формы компонентов
|
||||
2. Для каждой формы:
|
||||
- Проверяет выбран ли товар
|
||||
- Получает quantity (или 1 если не задана)
|
||||
- Ждёт `await getProductPrice()`
|
||||
- Суммирует actual_price × quantity
|
||||
3. Автоматически определяет тип корректировки:
|
||||
- Проверяет какое ОДНО из 4 полей заполнено
|
||||
- Устанавливает price_adjustment_type
|
||||
- Устанавливает price_adjustment_value
|
||||
4. Рассчитывает финальную цену
|
||||
5. Обновляет display элементы
|
||||
|
||||
### API Endpoint
|
||||
|
||||
**URL:** `/products/api/search-products-variants/`
|
||||
|
||||
**Параметры:**
|
||||
- `q` - поисковая строка
|
||||
- `id` - ID товара для получения его данных
|
||||
- `type` - 'product' или 'variant'
|
||||
- `page` - номер страницы
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Роза красная (PROD-000001)",
|
||||
"sku": "PROD-000001",
|
||||
"price": "50.00",
|
||||
"actual_price": "20.00",
|
||||
"in_stock": true,
|
||||
"type": "product"
|
||||
}
|
||||
],
|
||||
"pagination": {"more": false}
|
||||
}
|
||||
```
|
||||
|
||||
### Django Signal для автоматического пересчёта
|
||||
|
||||
**Файл:** `inventory/signals.py`
|
||||
|
||||
```python
|
||||
@receiver(post_save, sender='products.Product')
|
||||
def update_kit_prices_on_product_change(sender, instance, created, **kwargs):
|
||||
"""Пересчитывает все комплекты когда меняется цена товара"""
|
||||
if created:
|
||||
return
|
||||
|
||||
kit_items = KitItem.objects.filter(product=instance)
|
||||
kits_to_update = set(item.kit_id for item in kit_items)
|
||||
|
||||
for kit_id in kits_to_update:
|
||||
kit = ProductKit.objects.get(id=kit_id)
|
||||
kit.recalculate_base_price()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Файлы изменены
|
||||
|
||||
| Файл | Изменение | Версия |
|
||||
|------|-----------|--------|
|
||||
| `products/models/kits.py` | Полная переработка модели ценообразования | ✅ |
|
||||
| `products/forms.py` | Упрощена, удалены старые поля ценообразования | ✅ |
|
||||
| `products/views/api_views.py` | Добавлен actual_price во все responses | ✅ |
|
||||
| `products/views/productkit_views.py` | Добавлен actual_price в context | ✅ |
|
||||
| `products/templates/productkit_create.html` | Переработан UI + исправлены логика getProductPrice + calculateFinalPrice | ✅ |
|
||||
| `products/templates/productkit_edit.html` | То же + загрузка сохранённых значений | ✅ |
|
||||
| `products/templates/includes/kititem_formset.html` | Добавлены data-product-price атрибуты | ✅ |
|
||||
| `products/templates/includes/select2-product-init.html` | Обновлено отображение actual_price вместо price | ✅ |
|
||||
| `inventory/signals.py` | Добавлен signal для автоматического пересчёта | ✅ |
|
||||
| `products/migrations/0004_add_kit_price_adjustment_fields.py` | Migration для новых полей | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Тестовые сценарии
|
||||
|
||||
### Сценарий 1: Создание простого комплекта ✅
|
||||
|
||||
```
|
||||
1. Перейти на http://grach.localhost:8000/products/kits/create/
|
||||
2. Заполнить название: "Букет из 3 роз"
|
||||
3. Добавить товар "Роза красная" (qty: 3)
|
||||
✓ base_price должна быть 60.00 (20.00 × 3)
|
||||
4. Увеличить на 10%
|
||||
✓ final_price должна быть 66.00 (60 × 1.10)
|
||||
5. Сохранить
|
||||
✓ Комплект должен быть создан с price = 66.00
|
||||
```
|
||||
|
||||
### Сценарий 2: Увеличение суммой ✅
|
||||
|
||||
```
|
||||
1. Создать комплект с товарами на сумму 50 руб
|
||||
2. В поле "Увеличить на руб" ввести 10
|
||||
✓ final_price должна быть 60.00
|
||||
✓ price_adjustment_type = 'increase_amount'
|
||||
✓ price_adjustment_value = 10
|
||||
```
|
||||
|
||||
### Сценарий 3: Уменьшение ✅
|
||||
|
||||
```
|
||||
1. Создать комплект базовой ценой 100 руб
|
||||
2. Уменьшить на 20%
|
||||
✓ final_price = 80.00
|
||||
✓ price_adjustment_type = 'decrease_percent'
|
||||
3. Или уменьшить на 15 руб
|
||||
✓ final_price = 85.00
|
||||
✓ price_adjustment_type = 'decrease_amount'
|
||||
```
|
||||
|
||||
### Сценарий 4: Редактирование ✅
|
||||
|
||||
```
|
||||
1. Создать комплект с увеличением на 10%
|
||||
2. Открыть для редактирования
|
||||
✓ Значение 10 должно быть загружено в "Увеличить на %"
|
||||
3. Изменить на 15%
|
||||
✓ final_price пересчитывается в реальном времени
|
||||
4. Сохранить
|
||||
```
|
||||
|
||||
### Сценарий 5: Отображение цены в Select2 ✅
|
||||
|
||||
```
|
||||
1. На форме создания комплекта в поле выбора товара начать вводить "роз"
|
||||
✓ В dropdown должны отображаться товары с actual_price (20.00, а не 50.00)
|
||||
2. При наведении на товар видна цена со скидкой
|
||||
3. При выборе товара берется actual_price для расчёта
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
|
||||
### Просмотр логов в консоли браузера (F12)
|
||||
|
||||
```javascript
|
||||
// При выборе товара должны видеть:
|
||||
getProductPrice: fetching from API 1
|
||||
getProductPrice: from API 1 20.00
|
||||
|
||||
// При расчёте цены:
|
||||
// (логирование каждого товара из calculateFinalPrice)
|
||||
```
|
||||
|
||||
### Проверка данных в Network tab
|
||||
|
||||
```
|
||||
GET /products/api/search-products-variants/?id=1
|
||||
Response: {
|
||||
"results": [{
|
||||
"id": 1,
|
||||
"actual_price": "20.00",
|
||||
...
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Статус готовности
|
||||
|
||||
| Компонент | Статус | Комментарий |
|
||||
|-----------|--------|------------|
|
||||
| Модель ProductKit | ✅ | Применена миграция 0004 |
|
||||
| API endpoint | ✅ | Возвращает actual_price |
|
||||
| Select2 форматирование | ✅ | Отображает actual_price |
|
||||
| Real-time расчёты | ✅ | Все товары считаются корректно |
|
||||
| Сохранение данных | ✅ | price_adjustment_type и value сохраняются |
|
||||
| Редактирование | ✅ | Загружаются сохранённые значения |
|
||||
| Django signal | ✅ | Готов автоматически пересчитывать |
|
||||
| Документация | ✅ | Полная |
|
||||
|
||||
---
|
||||
|
||||
## Готово к тестированию! 🎉
|
||||
|
||||
Система полностью переработана и готова к использованию.
|
||||
|
||||
**URL для тестирования:**
|
||||
- Создание: http://grach.localhost:8000/products/kits/create/
|
||||
- Редактирование: http://grach.localhost:8000/products/kits/
|
||||
- API: http://grach.localhost:8000/products/api/search-products-variants/?q=роз
|
||||
|
||||
**Тестовые товары в тенанте "grach":**
|
||||
1. Роза красная - price: 50.00, sale: 20.00, actual: 20.00 ✓
|
||||
2. Белая роза - price: 5.00, actual: 5.00 ✓
|
||||
3. Ваниль гибискус - price: 6.00, actual: 6.00 ✓
|
||||
4. Хризантема оранжевая - price: 5.00, actual: 5.00 ✓
|
||||
@@ -1,230 +0,0 @@
|
||||
# Финальный отчет - Полная система ценообразования комплектов
|
||||
|
||||
**Дата:** 2025-11-02
|
||||
**Статус:** ✅ Полностью готово к использованию
|
||||
**Коммитов в сессии:** 10
|
||||
|
||||
---
|
||||
|
||||
## 📋 Все исправления в одном месте
|
||||
|
||||
### ✅ Исправление 1: Расчёт цены первого товара
|
||||
**Проблема:** При добавлении первого товара в комплект цена не обновлялась.
|
||||
**Решение:** Улучшена валидация в `getProductPrice()` и `calculateFinalPrice()`.
|
||||
**Файлы:** productkit_create.html, productkit_edit.html
|
||||
**Коммит:** 6c8af5a
|
||||
|
||||
### ✅ Исправление 2: Отображение цены в Select2
|
||||
**Проблема:** Select2 показывал обычную цену вместо цены со скидкой.
|
||||
**Решение:** Обновлена функция `formatSelectResult()` для приоритета `actual_price > price`.
|
||||
**Файл:** select2-product-init.html
|
||||
**Коммит:** 6c8af5a
|
||||
|
||||
### ✅ Исправление 3: Количество по умолчанию
|
||||
**Проблема:** При создании первое поле количества было пусто, второе имело 1.
|
||||
**Решение:** Добавлен `__init__` в `KitItemForm` с `quantity.initial = 1`.
|
||||
**Файл:** forms.py
|
||||
**Коммит:** 6c8af5a
|
||||
|
||||
### ✅ Исправление 4: Auto-select при клике
|
||||
**Проблема:** Нужно было вручную выделять число перед редактированием.
|
||||
**Решение:** Добавлен `focus` handler с `this.select()`.
|
||||
**Файлы:** productkit_create.html, productkit_edit.html
|
||||
**Коммит:** 6c8af5a
|
||||
|
||||
### ✅ Исправление 5: Отображение цены в списке
|
||||
**Проблема:** В таблице productkit_list не было красивого отображения цены.
|
||||
**Решение:** Добавлено отображение со скидкой (зачёркнутая + красная + "Акция").
|
||||
**Файл:** productkit_list.html
|
||||
**Коммит:** 2e305a8
|
||||
|
||||
### ✅ Исправление 6: Валидация одного поля корректировки
|
||||
**Проблема:** Можно было заполнить несколько полей одновременно.
|
||||
**Решение:** Добавлена функция `validateSingleAdjustment()` которая:
|
||||
- Отключает остальные поля когда одно заполнено
|
||||
- Помечает как invalid если несколько заполнено
|
||||
- Очищает лишние и оставляет первое
|
||||
|
||||
**Файлы:** productkit_create.html, productkit_edit.html, forms.py
|
||||
**Коммит:** 390d547
|
||||
|
||||
### ✅ Исправление 7: Сохранение комплекта
|
||||
**Проблема:** Выскакивала ошибка 'ProductKit' object has no attribute 'cost_calculation_info'.
|
||||
**Решение:** Удален вызов старого валидатора `validate_pricing_method_availability()`.
|
||||
**Файл:** productkit_views.py
|
||||
**Коммит:** 045f6a4
|
||||
|
||||
### ✅ Исправление 8: Отображение detail страницы
|
||||
**Проблема:** На странице деталей комплекта показывались старые поля (calculated_price, get_pricing_method_display и т.д.).
|
||||
**Решение:** Обновлен шаблон для новой системы:
|
||||
- Базовая цена (base_price)
|
||||
- Итоговая цена (price)
|
||||
- Скидка (sale_price)
|
||||
- Корректировка (type + value)
|
||||
|
||||
**Файл:** productkit_detail.html
|
||||
**Коммит:** 3c62cce
|
||||
|
||||
### ✅ Исправление 9: Пересчёт базовой цены после сохранения
|
||||
**Проблема:** После сохранения комплекта base_price и price показывали 0.00.
|
||||
**Решение:** Добавлен вызов `recalculate_base_price()` после сохранения компонентов.
|
||||
**Файл:** productkit_views.py (CreateView и UpdateView)
|
||||
**Коммит:** 3c62cce
|
||||
|
||||
### ✅ Исправление 10: Загрузка сохранённых значений при редактировании
|
||||
**Проблема:** При редактировании комплекта поля корректировки были пусты.
|
||||
**Решение:**
|
||||
- Добавлен вызов `validateSingleAdjustment()` после заполнения полей
|
||||
- Заполнены скрытые поля значениями из БД через `{{ form.FIELD.value }}`
|
||||
- Добавлено логирование для отладки
|
||||
|
||||
**Файлы:** productkit_edit.html (2 исправления)
|
||||
**Коммиты:** 3c62cce, c228f80
|
||||
|
||||
---
|
||||
|
||||
## 📊 Архитектура решения
|
||||
|
||||
### Поток создания комплекта:
|
||||
|
||||
```
|
||||
1. Пользователь вводит название и выбирает товары
|
||||
↓
|
||||
2. Для каждого товара JavaScript получает actual_price (async)
|
||||
↓
|
||||
3. calculateFinalPrice() суммирует actual_price × quantity
|
||||
↓
|
||||
4. Пользователь заполняет ОДНО поле корректировки (%, руб, +/-)
|
||||
↓
|
||||
5. validateSingleAdjustment() отключает остальные 3 поля
|
||||
↓
|
||||
6. Финальная цена = base_price +/- корректировка обновляется в реальном времени
|
||||
↓
|
||||
7. При сохранении:
|
||||
- Сохраняется комплект (form.save())
|
||||
- Сохраняются компоненты (formset.save())
|
||||
- Пересчитывается base_price из компонентов
|
||||
- Рассчитывается price с корректировкой
|
||||
- Сохраняются: price_adjustment_type, price_adjustment_value
|
||||
```
|
||||
|
||||
### Поток редактирования комплекта:
|
||||
|
||||
```
|
||||
1. Загружается форма с существующим комплектом
|
||||
↓
|
||||
2. Скрытые поля заполняются значениями из БД:
|
||||
- id_price_adjustment_type = 'increase_percent' (например)
|
||||
- id_price_adjustment_value = 10.00
|
||||
↓
|
||||
3. JavaScript загружает эти значения после загрузки страницы (setTimeout)
|
||||
↓
|
||||
4. Заполняет соответствующее поле (например, increasePercentInput.value = 10)
|
||||
↓
|
||||
5. Вызывает validateSingleAdjustment():
|
||||
- Отключает остальные 3 поля
|
||||
- Помечает текущее как активное
|
||||
↓
|
||||
6. calculateFinalPrice() пересчитывает цену
|
||||
↓
|
||||
7. При сохранении: то же как при создании
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Результат
|
||||
|
||||
### Что работает:
|
||||
|
||||
✅ **Создание комплектов:**
|
||||
- Базовая цена вычисляется из actual_price компонентов
|
||||
- Можно применить корректировку (+/- % или +/- руб)
|
||||
- Только одно поле корректировки заполняется за раз
|
||||
- Финальная цена обновляется в реальном времени
|
||||
- Можно установить sale_price для скидки
|
||||
|
||||
✅ **Редактирование комплектов:**
|
||||
- Все сохранённые значения загружаются
|
||||
- Поле корректировки заполняется корректно
|
||||
- Остальные поля отключены
|
||||
- Можно изменить корректировку
|
||||
- Финальная цена пересчитывается
|
||||
|
||||
✅ **Отображение цен:**
|
||||
- В списке кits: красиво (зачёркнутая + красная + "Акция")
|
||||
- На detail странице: базовая + итоговая + корректировка
|
||||
- В Select2: actual_price вместо обычной цены
|
||||
|
||||
✅ **Валидация:**
|
||||
- Frontend: мгновенная (real-time)
|
||||
- Backend: при сохранении
|
||||
- Одно поле корректировки одновременно
|
||||
|
||||
---
|
||||
|
||||
## 📁 Измененные файлы
|
||||
|
||||
| Файл | Изменения |
|
||||
|------|-----------|
|
||||
| `products/models/kits.py` | ✅ Новая модель ценообразования (отдельная сессия) |
|
||||
| `products/forms.py` | ✅ Добавлен `__init__` для quantity.initial = 1 |
|
||||
| `products/views/api_views.py` | ✅ Добавлен actual_price в JSON responses |
|
||||
| `products/views/productkit_views.py` | ✅ Удален старый валидатор + добавлен recalculate_base_price() |
|
||||
| `products/templates/includes/select2-product-init.html` | ✅ Обновлена formatSelectResult для actual_price |
|
||||
| `products/templates/products/productkit_list.html` | ✅ Красивое отображение цены |
|
||||
| `products/templates/products/productkit_detail.html` | ✅ Обновлен для новой системы |
|
||||
| `products/templates/products/productkit_create.html` | ✅ Функция validateSingleAdjustment + улучшения |
|
||||
| `products/templates/products/productkit_edit.html` | ✅ То же + загрузка сохранённых значений + заполнение скрытых полей |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Git коммиты (в хронологическом порядке)
|
||||
|
||||
```
|
||||
6c8af5a - fix: Улучшения системы ценообразования комплектов
|
||||
2e305a8 - fix: Улучшить отображение цены в списке комплектов
|
||||
9027cca - docs: Добавить финальное резюме сессии улучшений
|
||||
390d547 - feat: Добавить валидацию для заполнения одного поля корректировки цены
|
||||
045f6a4 - fix: Удалить вызов старого валидатора ценообразования
|
||||
3c62cce - fix: Загружать сохранённые значения корректировки цены при редактировании
|
||||
c228f80 - fix: Заполнять скрытые поля корректировки значениями из БД при редактировании
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Ключевые моменты
|
||||
|
||||
### Что сложного было решить:
|
||||
|
||||
1. **Race conditions при загрузке async price**
|
||||
- Решение: кэширование + await в calculateFinalPrice
|
||||
|
||||
2. **Валидация одного поля**
|
||||
- Решение: validateSingleAdjustment() с отключением и проверкой
|
||||
|
||||
3. **Загрузка сохранённых значений**
|
||||
- Решение: setTimeout + проверка скрытых полей формы
|
||||
|
||||
4. **Пересчёт базовой цены**
|
||||
- Решение: recalculate_base_price() после сохранения компонентов
|
||||
|
||||
### Что хорошо работает:
|
||||
|
||||
1. ✅ Real-time price calculation
|
||||
2. ✅ Auto-detection adjustment type (какое поле заполнено)
|
||||
3. ✅ Автоматическое отключение других полей
|
||||
4. ✅ Загрузка сохранённых значений
|
||||
5. ✅ Красивое отображение цен везде
|
||||
6. ✅ Двойная валидация (JS + backend)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Готово к использованию!
|
||||
|
||||
Все функции работают корректно. Система полностью функциональна и готова к работе в production.
|
||||
|
||||
**Точки входа для тестирования:**
|
||||
- Создание: http://grach.localhost:8000/products/kits/create/
|
||||
- Список: http://grach.localhost:8000/products/kits/
|
||||
- Редактирование: http://grach.localhost:8000/products/kits/4/update/
|
||||
- Детали: http://grach.localhost:8000/products/kits/4/
|
||||
@@ -1,104 +0,0 @@
|
||||
# Тестирование системы инвентаризации
|
||||
|
||||
## Статус: ✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ
|
||||
|
||||
### 1. FIFO Логика ✅
|
||||
|
||||
**Тест:** Создание 3 приходов с разными ценами и списание через FIFO
|
||||
|
||||
**Результат:**
|
||||
- ✅ StockBatches создаются автоматически при Incoming (сигнал)
|
||||
- ✅ Продажа автоматически применяет FIFO (сигнал)
|
||||
- ✅ Распределение по партиям корректно: старые партии списываются первыми
|
||||
- ✅ SaleBatchAllocation корректно отслеживают распределение
|
||||
|
||||
**Примеры:**
|
||||
```
|
||||
Приход 1: 10 шт @ 100
|
||||
Приход 2: 15 шт @ 120
|
||||
Приход 3: 20 шт @ 150
|
||||
|
||||
Продажа 1: 18 шт @ 250
|
||||
→ Allocation: 10 (от batch1) + 8 (от batch2)
|
||||
|
||||
Продажа 2: 20 шт @ 250
|
||||
→ Allocation: 7 (от batch2) + 13 (от batch3)
|
||||
```
|
||||
|
||||
### 2. Сигналы на заказы ✅
|
||||
|
||||
**Тест:** Создание заказа, изменение статуса на "in_delivery"
|
||||
|
||||
**Результат:**
|
||||
- ✅ Заказ создан и содержит товар
|
||||
- ✅ При смене статуса на "in_delivery" автоматически создается Sale
|
||||
- ✅ Sale автоматически обрабатывается с FIFO
|
||||
- ✅ SaleBatchAllocation создаются автоматически
|
||||
|
||||
### 3. Инвентаризация (Reconciliation) ✅
|
||||
|
||||
**Тест:** Физический подсчёт с дефицитом
|
||||
|
||||
**Результат:**
|
||||
- ✅ Inventory создаётся и принимает InventoryLines
|
||||
- ✅ При завершении Inventory (status='completed') автоматически обрабатывается (сигнал)
|
||||
- ✅ Дефицит (разница < 0) автоматически создает WriteOff по FIFO
|
||||
- ✅ Stock коррегируется для совпадения с физическим подсчётом
|
||||
|
||||
**Пример:**
|
||||
```
|
||||
Система имела: 7 шт
|
||||
Физический подсчёт: 5 шт
|
||||
Разница: -2 шт
|
||||
|
||||
Результат:
|
||||
→ WriteOff создан на 2 шт
|
||||
→ Stock уменьшен до 5 шт
|
||||
```
|
||||
|
||||
### 4. Django Admin ✅
|
||||
|
||||
**Проверено:**
|
||||
- ✅ StockBatchAdmin показывает quantity с цветовой кодировкой:
|
||||
- Красный (≤0)
|
||||
- Оранжевый (<10)
|
||||
- Зелёный (≥10)
|
||||
- ✅ SaleAdmin показывает inline SaleBatchAllocations
|
||||
- ✅ Статус обработки Sale отображается с визуальным индикатором
|
||||
- ✅ WriteOffAdmin показывает причины списания
|
||||
|
||||
## Архитектура
|
||||
|
||||
### Модели
|
||||
- **StockBatch**: Партия товара с FIFO датой создания
|
||||
- **Incoming**: Приход → автоматически создает StockBatch (сигнал)
|
||||
- **Sale**: Продажа → автоматически применяет FIFO (сигнал)
|
||||
- **SaleBatchAllocation**: Отслеживание какие батчи использованы в продаже
|
||||
- **Inventory/InventoryLine**: Физический подсчёт
|
||||
- **WriteOff**: Списание при дефиците (автоматическое по FIFO)
|
||||
- **Reservation**: Резервирование при создании заказа
|
||||
|
||||
### Сигналы (Автоматизация)
|
||||
1. **create_stock_batch_on_incoming**: Создает StockBatch при Incoming
|
||||
2. **process_sale_fifo**: Применяет FIFO при создании Sale
|
||||
3. **create_sale_on_order_shipment**: Создает Sale при смене статуса заказа на "in_delivery"
|
||||
4. **reserve_stock_on_order_create**: Резервирует товар при создании заказа
|
||||
5. **process_inventory_reconciliation**: Обрабатывает инвентаризацию при завершении
|
||||
|
||||
## Исправленные ошибки
|
||||
|
||||
1. **Двойной вызов сигнала на Sale**: Использован `update()` вместо `save()` чтобы избежать повторного срабатывания
|
||||
2. **WriteOff не создавался при инвентаризации**: Добавлен сигнал на Inventory
|
||||
3. **StockBatch не создавались при Incoming**: Добавлен сигнал на Incoming
|
||||
|
||||
## Готовность к продакшену
|
||||
|
||||
✅ FIFO логика работает корректно
|
||||
✅ Все основные операции автоматизированы через сигналы
|
||||
✅ Admin интерфейс полностью функционален
|
||||
✅ Данные консистентны между таблицами
|
||||
✅ Система многотенантная (работает в рамках каждого тенанта)
|
||||
|
||||
---
|
||||
|
||||
Дата тестирования: 2025-10-27
|
||||
@@ -1,228 +0,0 @@
|
||||
# Тестирование улучшений WriteOff UI
|
||||
|
||||
## Краткая инструкция тестирования
|
||||
|
||||
### Подготовка
|
||||
1. Убедитесь что у вас есть актуальные данные:
|
||||
- Склад (Warehouse)
|
||||
- Товар (Product)
|
||||
- Партия товара (StockBatch) с остатком > 0
|
||||
|
||||
2. Откройте страницу: `http://grach.localhost:8000/inventory/writeoffs/create/`
|
||||
|
||||
---
|
||||
|
||||
## Тест 1: Информация об остатке партии
|
||||
|
||||
**Шаги:**
|
||||
1. Выберите партию из dropdown "Партия"
|
||||
2. **Ожидаемый результат:**
|
||||
- Под dropdown появляется серый блок:
|
||||
```
|
||||
📋 Остаток в партии: X шт
|
||||
```
|
||||
- Где X это количество в выбранной партии
|
||||
|
||||
**Проверка:**
|
||||
- ✅ Блок информации появляется при выборе
|
||||
- ✅ Блок исчезает если выбрать пустое значение
|
||||
- ✅ Количество обновляется при смене партии
|
||||
|
||||
---
|
||||
|
||||
## Тест 2: Реал-тайм предупреждение при превышении
|
||||
|
||||
**Шаги:**
|
||||
1. Выберите партию (например, с остатком 5 шт)
|
||||
2. Введите в поле "Количество" число МЕНЬШЕ остатка (например, 3)
|
||||
3. **Ожидаемый результат:** Никаких предупреждений
|
||||
|
||||
**Шаги 2:**
|
||||
1. Введите в поле "Количество" число БОЛЬШЕ остатка (например, 10)
|
||||
2. **Ожидаемый результат:**
|
||||
```
|
||||
⚠️ Внимание! Вы пытаетесь списать 10 шт,
|
||||
а в партии только 5 шт.
|
||||
Недостаток: 5 шт.
|
||||
```
|
||||
|
||||
**Проверка:**
|
||||
- ✅ Предупреждение НЕ появляется при quantity <= batch.quantity
|
||||
- ✅ Предупреждение ПОЯВЛЯЕТСЯ при quantity > batch.quantity
|
||||
- ✅ Предупреждение обновляется в реал-тайме при каждом вводе
|
||||
- ✅ Правильно рассчитывается недостаток (shortage)
|
||||
|
||||
---
|
||||
|
||||
## Тест 3: Отправка валидных данных
|
||||
|
||||
**Шаги:**
|
||||
1. Выберите партию (остаток = 5 шт)
|
||||
2. Введите количество = 3 (меньше остатка)
|
||||
3. Выберите причину (например, "damage")
|
||||
4. Нажмите кнопку "Сохранить"
|
||||
|
||||
**Ожидаемый результат:**
|
||||
- ✅ Форма отправляется успешно
|
||||
- ✅ Перенаправляется на список списаний
|
||||
- ✅ Появляется зеленое сообщение об успехе
|
||||
- ✅ Остаток в партии уменьшился на 3 (стал 2)
|
||||
|
||||
---
|
||||
|
||||
## Тест 4: Отправка невалидных данных (количество > остатка)
|
||||
|
||||
**Шаги:**
|
||||
1. Выберите партию (остаток = 5 шт)
|
||||
2. Введите количество = 10 (больше остатка)
|
||||
3. Нажмите кнопку "Сохранить"
|
||||
|
||||
**Ожидаемый результат:**
|
||||
- ✅ Форма НЕ отправляется
|
||||
- ✅ На странице появляется красный alert:
|
||||
```
|
||||
❌ Ошибка:
|
||||
Невозможно списать 10 шт из партии,
|
||||
где только 5 шт.
|
||||
Недостаток: 5 шт.
|
||||
```
|
||||
- ✅ Пользователь остается на форме
|
||||
- ✅ Данные в полях сохраняются (он может отредактировать)
|
||||
- ✅ Остаток в партии не изменился (остался 5)
|
||||
|
||||
---
|
||||
|
||||
## Тест 5: Отключенный JavaScript (браузерная консоль)
|
||||
|
||||
Если JavaScript отключен:
|
||||
|
||||
**Шаги:**
|
||||
1. Откройте DevTools (F12)
|
||||
2. Отключите JavaScript
|
||||
3. Обновите страницу
|
||||
4. Заполните форму с quantity > batch.quantity
|
||||
5. Нажмите "Сохранить"
|
||||
|
||||
**Ожидаемый результат:**
|
||||
- ✅ Реал-тайм валидация не работает (это ок - это просто nice-to-have)
|
||||
- ✅ Форма отправляется на сервер
|
||||
- ✅ Django form validation ловит ошибку
|
||||
- ✅ На странице появляется красный alert с ошибкой
|
||||
- ✅ Остаток в партии не изменился (данные защищены)
|
||||
|
||||
---
|
||||
|
||||
## Тест 6: Граничные случаи
|
||||
|
||||
### Граничный случай 1: Количество = остатку
|
||||
**Шаги:**
|
||||
1. Партия имеет остаток = 5 шт
|
||||
2. Введите количество = 5
|
||||
3. **Ожидаемый результат:**
|
||||
- ✅ Никаких предупреждений (это валидное значение)
|
||||
- ✅ Форма отправляется успешно
|
||||
- ✅ Партия полностью списывается (остаток = 0)
|
||||
- ✅ Партия деактивируется (is_active = False)
|
||||
|
||||
### Граничный случай 2: Очень малое количество
|
||||
**Шаги:**
|
||||
1. Введите количество = 0.001 (если поддерживается)
|
||||
2. **Ожидаемый результат:**
|
||||
- ✅ Форма отправляется
|
||||
- ✅ Остаток уменьшается на 0.001
|
||||
- ✅ Все работает корректно
|
||||
|
||||
### Граничный случай 3: Дробные числа
|
||||
**Шаги:**
|
||||
1. Партия имеет остаток = 5.5 шт
|
||||
2. Введите количество = 5.6
|
||||
3. **Ожидаемый результат:**
|
||||
- ✅ Появляется предупреждение о превышении
|
||||
- ✅ Недостаток = 0.1 шт (правильный расчет)
|
||||
|
||||
---
|
||||
|
||||
## Проверочный лист
|
||||
|
||||
### Общие проверки
|
||||
- [ ] JavaScript выполняется без ошибок (проверить консоль F12)
|
||||
- [ ] Стили Bootstrap применены правильно
|
||||
- [ ] Все иконки (⚠️, ❌, 📋) отображаются корректно
|
||||
- [ ] Alert блоки имеют правильные цвета (красный для ошибок, желтый для warning)
|
||||
- [ ] Кнопка закрытия alert работает
|
||||
|
||||
### Функциональные проверки
|
||||
- [ ] Информация об остатке появляется при выборе партии
|
||||
- [ ] Предупреждение появляется при quantity > batch.quantity
|
||||
- [ ] Ошибка валидации отображается красиво
|
||||
- [ ] Форма не отправляется при невалидных данных
|
||||
- [ ] При валидных данных форма отправляется и остаток уменьшается
|
||||
|
||||
### Граничные случаи
|
||||
- [ ] Количество = остатку работает правильно
|
||||
- [ ] Дробные количества поддерживаются
|
||||
- [ ] Партия деактивируется когда количество <= 0
|
||||
- [ ] JavaScript отключение не ломает functionality (только UX)
|
||||
|
||||
---
|
||||
|
||||
## Логирование для отладки
|
||||
|
||||
Если что-то не работает, проверьте:
|
||||
|
||||
1. **Консоль браузера (F12 → Console)**
|
||||
- Ошибки JavaScript
|
||||
- Логи из скрипта
|
||||
|
||||
2. **Django логи**
|
||||
- Ошибки валидации
|
||||
- Ошибки в сохранении модели
|
||||
|
||||
3. **Network tab (F12 → Network)**
|
||||
- Проверить POST запросы
|
||||
- Ответ сервера (200, 400, 500 и т.д.)
|
||||
|
||||
---
|
||||
|
||||
## Быстрая проверка
|
||||
|
||||
Самый быстрый способ проверить что все работает:
|
||||
|
||||
```bash
|
||||
# 1. Откройте форму
|
||||
# http://grach.localhost:8000/inventory/writeoffs/create/
|
||||
|
||||
# 2. Выберите партию - должна появиться информация об остатке
|
||||
|
||||
# 3. Введите количество больше остатка - должно появиться предупреждение
|
||||
|
||||
# 4. Нажмите сохранить - должна появиться ошибка
|
||||
|
||||
# 5. Поправьте количество на меньшее - предупреждение исчезнет
|
||||
|
||||
# 6. Нажмите сохранить - форма отправится успешно
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
1. **Форматирование дробных чисел:** JavaScript использует `.toFixed(3)` для недостатка, может быть 0.100 вместо 0.1
|
||||
- Это не критично, просто визуально
|
||||
|
||||
2. **Локализация запятой/точки:** Код поддерживает оба варианта в regex но может быть проблемы с разными локалями
|
||||
- Проверьте что в вашей локали правильно парсятся дробные числа
|
||||
|
||||
3. **Старые браузеры:** Код использует `addEventListener` и `querySelector` - не поддерживается IE11
|
||||
- Но IE11 давно не поддерживается, это нормально
|
||||
|
||||
---
|
||||
|
||||
## Что дальше?
|
||||
|
||||
Если тестирование прошло успешно:
|
||||
1. ✅ Все остатки партий уменьшаются правильно
|
||||
2. ✅ Никакие отрицательные значения не создаются
|
||||
3. ✅ UI четкий и понятный
|
||||
|
||||
То система готова к использованию!
|
||||
47
test_api.sh
47
test_api.sh
@@ -1,47 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Тест API endpoints для поиска и создания клиентов
|
||||
# Использование: bash test_api.sh
|
||||
|
||||
BASE_URL="http://grach.localhost:8000"
|
||||
|
||||
echo "=================================================="
|
||||
echo "ТЕСТ API ENDPOINTS ДЛЯ ПОИСКА КЛИЕНТОВ"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
|
||||
# Test 1: Поиск по имени
|
||||
echo "TEST 1: Поиск по имени (q=Иван)"
|
||||
echo "URL: $BASE_URL/customers/api/search/?q=Иван"
|
||||
echo ""
|
||||
curl -s "$BASE_URL/customers/api/search/?q=Иван" | python -m json.tool 2>/dev/null || curl -s "$BASE_URL/customers/api/search/?q=Иван"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
|
||||
# Test 2: Поиск по телефону
|
||||
echo "TEST 2: Поиск по телефону (q=375)"
|
||||
echo "URL: $BASE_URL/customers/api/search/?q=375"
|
||||
echo ""
|
||||
curl -s "$BASE_URL/customers/api/search/?q=375" | python -m json.tool 2>/dev/null || curl -s "$BASE_URL/customers/api/search/?q=375"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
|
||||
# Test 3: Пустой поиск (должна вернуться пустая строка results)
|
||||
echo "TEST 3: Пустой поиск (q=)"
|
||||
echo "URL: $BASE_URL/customers/api/search/?q="
|
||||
echo ""
|
||||
curl -s "$BASE_URL/customers/api/search/?q=" | python -m json.tool 2>/dev/null || curl -s "$BASE_URL/customers/api/search/?q="
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
|
||||
# Test 4: Проверка что endpoint существует
|
||||
echo "TEST 4: Проверка доступности endpoint'а"
|
||||
echo "URL: $BASE_URL/customers/api/search/"
|
||||
echo ""
|
||||
curl -i "$BASE_URL/customers/api/search/?q=test" 2>&1 | head -15
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Простой скрипт для проверки API endpoints через Django shell
|
||||
"""
|
||||
import os
|
||||
import django
|
||||
|
||||
# Настройка Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from django.test import Client
|
||||
import json
|
||||
|
||||
client = Client()
|
||||
BASE_URL = '/customers/api/search/'
|
||||
|
||||
print("=" * 60)
|
||||
print("ТЕСТ API ENDPOINTS")
|
||||
print("=" * 60)
|
||||
|
||||
# Test 1: Empty query
|
||||
print("\nТЕСТ 1: Пустой запрос")
|
||||
response = client.get(f'{BASE_URL}?q=')
|
||||
print(f"Статус: {response.status_code}")
|
||||
print(f"Ответ: {response.content.decode()}")
|
||||
|
||||
# Test 2: Search by single letter
|
||||
print("\n" + "=" * 60)
|
||||
print("ТЕСТ 2: Поиск по букве 'И'")
|
||||
response = client.get(f'{BASE_URL}?q=И')
|
||||
print(f"Статус: {response.status_code}")
|
||||
data = json.loads(response.content)
|
||||
print(f"Результатов: {len(data.get('results', []))}")
|
||||
if data.get('results'):
|
||||
for item in data['results'][:3]:
|
||||
print(f" - {item.get('text', 'No text')}")
|
||||
|
||||
# Test 3: Search by number
|
||||
print("\n" + "=" * 60)
|
||||
print("ТЕСТ 3: Поиск по цифрам '29'")
|
||||
response = client.get(f'{BASE_URL}?q=29')
|
||||
print(f"Статус: {response.status_code}")
|
||||
data = json.loads(response.content)
|
||||
print(f"Результатов: {len(data.get('results', []))}")
|
||||
if data.get('results'):
|
||||
for item in data['results'][:3]:
|
||||
print(f" - {item.get('text', 'No text')}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Готово!")
|
||||
print("=" * 60)
|
||||
@@ -1,60 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Добавляем путь к проекту
|
||||
sys.path.insert(0, 'C:/Users/team_/Desktop/test_qwen/myproject')
|
||||
|
||||
# Настройка Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from tenants.models import TenantRegistration, Client
|
||||
from django.contrib.auth import get_user_model
|
||||
from tenants.admin import TenantRegistrationAdmin
|
||||
|
||||
# Получаем заявку
|
||||
registration = TenantRegistration.objects.get(schema_name='shop3')
|
||||
|
||||
print(f"Before approval:")
|
||||
print(f" Status: {registration.status}")
|
||||
print(f" Tenant: {registration.tenant}")
|
||||
|
||||
# Одобряем заявку
|
||||
admin = TenantRegistrationAdmin(TenantRegistration, None)
|
||||
User = get_user_model()
|
||||
|
||||
# Получаем или создаём системного пользователя для логирования
|
||||
try:
|
||||
admin_user = User.objects.get(email='admin@localhost')
|
||||
except User.DoesNotExist:
|
||||
admin_user = User.objects.create_superuser(
|
||||
email='admin@localhost',
|
||||
name='Admin',
|
||||
password='admin'
|
||||
)
|
||||
|
||||
try:
|
||||
admin._approve_registration(registration, admin_user)
|
||||
print("\nApproval successful!")
|
||||
|
||||
# Проверяем результат
|
||||
registration.refresh_from_db()
|
||||
print(f"\nAfter approval:")
|
||||
print(f" Status: {registration.status}")
|
||||
print(f" Tenant: {registration.tenant}")
|
||||
|
||||
# Проверяем, существует ли тенант в БД
|
||||
if registration.tenant:
|
||||
client = Client.objects.get(pk=registration.tenant.pk)
|
||||
print(f" Client created: {client.name} ({client.schema_name})")
|
||||
|
||||
# Проверяем домены
|
||||
from tenants.models import Domain
|
||||
domains = Domain.objects.filter(tenant=client)
|
||||
print(f" Domains: {list(domains.values_list('domain', flat=True))}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Approval failed: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -1,127 +0,0 @@
|
||||
"""
|
||||
Тестовый скрипт для проверки логики расчета себестоимости.
|
||||
Запускается без БД для проверки математической корректности.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def calculate_weighted_average_cost(batches):
|
||||
"""
|
||||
Рассчитать средневзвешенную себестоимость из партий.
|
||||
|
||||
Args:
|
||||
batches: List of dicts with 'quantity' and 'cost_price'
|
||||
|
||||
Returns:
|
||||
Decimal: Средневзвешенная себестоимость
|
||||
"""
|
||||
if not batches:
|
||||
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:
|
||||
return Decimal('0.00')
|
||||
|
||||
weighted_cost = total_value / total_quantity
|
||||
return weighted_cost.quantize(Decimal('0.01'))
|
||||
|
||||
|
||||
# Тестовые сценарии
|
||||
print("="*80)
|
||||
print("ТЕСТИРОВАНИЕ РАСЧЕТА СЕБЕСТОИМОСТИ")
|
||||
print("="*80)
|
||||
|
||||
# Тест 1: Товар без партий
|
||||
print("\nТест 1: Товар без партий")
|
||||
batches = []
|
||||
result = calculate_weighted_average_cost(batches)
|
||||
print(f" Партии: {batches}")
|
||||
print(f" Результат: {result} руб.")
|
||||
print(f" [OK] Ожидаемый результат: 0.00 руб. - {'PASS' if result == Decimal('0.00') else 'FAIL'}")
|
||||
|
||||
# Тест 2: Одна партия
|
||||
print("\nТест 2: Одна партия")
|
||||
batches = [
|
||||
{'quantity': 10, 'cost_price': 100}
|
||||
]
|
||||
result = calculate_weighted_average_cost(batches)
|
||||
print(f" Партии: {batches}")
|
||||
print(f" Результат: {result} руб.")
|
||||
print(f" [OK] Ожидаемый результат: 100.00 руб. - {'PASS' if result == Decimal('100.00') else 'FAIL'}")
|
||||
|
||||
# Тест 3: Две партии с одинаковой стоимостью
|
||||
print("\nТест 3: Две партии с одинаковой стоимостью")
|
||||
batches = [
|
||||
{'quantity': 10, 'cost_price': 100},
|
||||
{'quantity': 5, 'cost_price': 100}
|
||||
]
|
||||
result = calculate_weighted_average_cost(batches)
|
||||
print(f" Партии: {batches}")
|
||||
print(f" Результат: {result} руб.")
|
||||
print(f" [OK] Ожидаемый результат: 100.00 руб. - {'PASS' if result == Decimal('100.00') else 'FAIL'}")
|
||||
|
||||
# Тест 4: Две партии с разной стоимостью (реальный FIFO сценарий)
|
||||
print("\nТест 4: Две партии с разной стоимостью")
|
||||
batches = [
|
||||
{'quantity': 10, 'cost_price': 100}, # Старая партия
|
||||
{'quantity': 10, 'cost_price': 120} # Новая партия
|
||||
]
|
||||
result = calculate_weighted_average_cost(batches)
|
||||
expected = Decimal('110.00') # (10*100 + 10*120) / 20 = 2200 / 20 = 110
|
||||
print(f" Партии: {batches}")
|
||||
print(f" Расчет: (10*100 + 10*120) / (10+10) = 2200 / 20 = 110.00")
|
||||
print(f" Результат: {result} руб.")
|
||||
print(f" [OK] Ожидаемый результат: {expected} руб. - {'PASS' if result == expected else 'FAIL'}")
|
||||
|
||||
# Тест 5: Три партии с разным количеством и ценой
|
||||
print("\nТест 5: Три партии с разным количеством и ценой")
|
||||
batches = [
|
||||
{'quantity': 5, 'cost_price': 80}, # Самая старая
|
||||
{'quantity': 15, 'cost_price': 100}, # Средняя
|
||||
{'quantity': 10, 'cost_price': 120} # Самая новая
|
||||
]
|
||||
result = calculate_weighted_average_cost(batches)
|
||||
# (5*80 + 15*100 + 10*120) / (5+15+10) = (400 + 1500 + 1200) / 30 = 3100 / 30 = 103.33
|
||||
expected = Decimal('103.33')
|
||||
print(f" Партии: {batches}")
|
||||
print(f" Расчет: (5*80 + 15*100 + 10*120) / (5+15+10) = 3100 / 30 = 103.33")
|
||||
print(f" Результат: {result} руб.")
|
||||
print(f" [OK] Ожидаемый результат: {expected} руб. - {'PASS' if result == expected else 'FAIL'}")
|
||||
|
||||
# Тест 6: Реальный сценарий - товар закончился и появился снова
|
||||
print("\nТест 6: Товар закончился и пришла новая поставка")
|
||||
print(" Шаг 1: Создан товар, партий нет")
|
||||
batches = []
|
||||
result1 = calculate_weighted_average_cost(batches)
|
||||
print(f" Себестоимость: {result1} руб.")
|
||||
|
||||
print(" Шаг 2: Пришла первая поставка")
|
||||
batches = [{'quantity': 20, 'cost_price': 100}]
|
||||
result2 = calculate_weighted_average_cost(batches)
|
||||
print(f" Себестоимость: {result2} руб.")
|
||||
|
||||
print(" Шаг 3: Товар продали полностью (партии опустошились)")
|
||||
batches = []
|
||||
result3 = calculate_weighted_average_cost(batches)
|
||||
print(f" Себестоимость: {result3} руб.")
|
||||
|
||||
print(" Шаг 4: Пришла новая поставка по другой цене")
|
||||
batches = [{'quantity': 15, 'cost_price': 120}]
|
||||
result4 = calculate_weighted_average_cost(batches)
|
||||
print(f" Себестоимость: {result4} руб.")
|
||||
|
||||
print(f"\n [OK] Жизненный цикл: 0.00 -> 100.00 -> 0.00 -> 120.00 - PASS")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("ВСЕ ТЕСТЫ ЗАВЕРШЕНЫ")
|
||||
print("="*80)
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Тест API эндпоинтов для поиска и создания клиентов
|
||||
|
||||
API_HOST="http://grach.localhost:8000"
|
||||
|
||||
echo "=== Тест API поиска клиентов ==="
|
||||
echo ""
|
||||
|
||||
# Тест 1: Поиск по имени
|
||||
echo "1. Поиск по имени 'Иван':"
|
||||
curl -s -X GET "${API_HOST}/customers/api/search/?q=Иван" \
|
||||
-H "Accept: application/json" | python -m json.tool || echo "Ошибка в запросе"
|
||||
|
||||
echo ""
|
||||
echo "2. Поиск по частичному номеру телефона '2912':"
|
||||
curl -s -X GET "${API_HOST}/customers/api/search/?q=2912" \
|
||||
-H "Accept: application/json" | python -m json.tool || echo "Ошибка в запросе"
|
||||
|
||||
echo ""
|
||||
echo "3. Поиск по email 'ivan':"
|
||||
curl -s -X GET "${API_HOST}/customers/api/search/?q=ivan" \
|
||||
-H "Accept: application/json" | python -m json.tool || echo "Ошибка в запросе"
|
||||
|
||||
echo ""
|
||||
echo "=== Тесты завершены ==="
|
||||
105
test_fifo.py
105
test_fifo.py
@@ -1,105 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
from decimal import Decimal
|
||||
|
||||
sys.path.insert(0, 'C:/Users/team_/Desktop/test_qwen/myproject')
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from tenants.models import Client
|
||||
from django.db import connection
|
||||
from products.models import Product
|
||||
from inventory.models import Warehouse, Incoming, Sale, SaleBatchAllocation, StockBatch
|
||||
|
||||
# Переключаемся в grach
|
||||
grach = Client.objects.get(schema_name='grach')
|
||||
connection.set_tenant(grach)
|
||||
|
||||
print("=== FIFO Test ===\n")
|
||||
|
||||
# Получаем товар и склад
|
||||
product = Product.objects.get(sku='FLOWER-001')
|
||||
warehouse = Warehouse.objects.get(name='Main Warehouse')
|
||||
|
||||
print("Step 1: Creating 3 incoming batches\n")
|
||||
|
||||
# Создаём 3 прихода
|
||||
incoming1 = Incoming.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=Decimal('10'),
|
||||
cost_price=Decimal('100.00')
|
||||
)
|
||||
print(" Incoming 1: qty=10, cost=100.00")
|
||||
|
||||
incoming2 = Incoming.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=Decimal('15'),
|
||||
cost_price=Decimal('120.00')
|
||||
)
|
||||
print(" Incoming 2: qty=15, cost=120.00")
|
||||
|
||||
incoming3 = Incoming.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=Decimal('20'),
|
||||
cost_price=Decimal('150.00')
|
||||
)
|
||||
print(" Incoming 3: qty=20, cost=150.00\n")
|
||||
|
||||
print("Step 2: Checking StockBatches\n")
|
||||
|
||||
batches = StockBatch.objects.filter(product=product, warehouse=warehouse).order_by('created_at')
|
||||
print("StockBatches count: {}\n".format(batches.count()))
|
||||
|
||||
for i, batch in enumerate(batches, 1):
|
||||
print(" Batch {}: qty={}, cost={}, active={}".format(i, batch.quantity, batch.cost_price, batch.is_active))
|
||||
|
||||
print("\nStep 3: Creating Sale (qty=18)\n")
|
||||
|
||||
try:
|
||||
sale = Sale.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=Decimal('18'),
|
||||
sale_price=Decimal('250.00')
|
||||
)
|
||||
print("Sale created: qty=18, price=250.00\n")
|
||||
|
||||
print("Step 4: Checking SaleBatchAllocations\n")
|
||||
|
||||
allocations = SaleBatchAllocation.objects.filter(sale=sale).order_by('batch__created_at')
|
||||
total = Decimal('0')
|
||||
|
||||
for i, alloc in enumerate(allocations, 1):
|
||||
print(" Allocation {}: qty={}, batch_cost={}".format(i, alloc.quantity, alloc.batch.cost_price))
|
||||
total += alloc.quantity
|
||||
|
||||
print("\nTotal allocated: {}".format(total))
|
||||
|
||||
if total == Decimal('18'):
|
||||
print("PASS: Correct total")
|
||||
else:
|
||||
print("FAIL: Expected 18, got {}".format(total))
|
||||
|
||||
# Check FIFO order
|
||||
expected_order = [Decimal('10'), Decimal('8')]
|
||||
actual_order = [alloc.quantity for alloc in allocations]
|
||||
|
||||
if actual_order == expected_order:
|
||||
print("PASS: FIFO order correct (batch1 10 + batch2 8)")
|
||||
else:
|
||||
print("FAIL: Expected {}, got {}".format(expected_order, actual_order))
|
||||
|
||||
except Exception as e:
|
||||
print("ERROR: {}".format(str(e)))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\nStep 5: Checking remaining stock\n")
|
||||
|
||||
remaining = StockBatch.objects.filter(product=product, warehouse=warehouse).order_by('created_at')
|
||||
for i, batch in enumerate(remaining, 1):
|
||||
print(" Batch {}: qty={}, active={}".format(i, batch.quantity, batch.is_active))
|
||||
@@ -1,67 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Добавляем путь к проекту
|
||||
sys.path.insert(0, 'C:/Users/team_/Desktop/test_qwen/myproject')
|
||||
|
||||
# Настройка Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from tenants.models import TenantRegistration, Client
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
from tenants.admin import TenantRegistrationAdmin
|
||||
import uuid
|
||||
|
||||
# Уникальное имя
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
schema_name = f'test_{unique_id}'
|
||||
|
||||
# Создаём новую заявку
|
||||
registration = TenantRegistration.objects.create(
|
||||
shop_name='Fresh Test',
|
||||
schema_name=schema_name,
|
||||
owner_email='fresh@example.com',
|
||||
owner_name='Fresh User',
|
||||
phone='+375291234567',
|
||||
status=TenantRegistration.STATUS_PENDING
|
||||
)
|
||||
|
||||
print(f"\n=== Testing Fresh Superuser Creation ===")
|
||||
print(f"Schema: {schema_name}\n")
|
||||
|
||||
# Получаем админа из public
|
||||
public_tenant = Client.objects.get(schema_name='public')
|
||||
connection.set_tenant(public_tenant)
|
||||
|
||||
User = get_user_model()
|
||||
admin_user = User.objects.first()
|
||||
|
||||
# Одобряем заявку
|
||||
admin = TenantRegistrationAdmin(TenantRegistration, None)
|
||||
|
||||
try:
|
||||
print("Approving registration...")
|
||||
client = admin._approve_registration(registration, admin_user)
|
||||
print("Approval completed!\n")
|
||||
|
||||
# Переключаемся в новый тенант
|
||||
connection.set_tenant(client)
|
||||
|
||||
# Проверим пользователей
|
||||
all_users = User.objects.all().order_by('email')
|
||||
print(f"All users in {client.schema_name} schema ({all_users.count()} total):")
|
||||
|
||||
for u in all_users:
|
||||
status = "SUPERUSER" if u.is_superuser else "regular"
|
||||
print(f" - {u.email} ({u.name}) [{status}]")
|
||||
|
||||
if not all_users.exists():
|
||||
print(" NO USERS FOUND - THIS IS A PROBLEM!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -1,61 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, 'C:/Users/team_/Desktop/test_qwen/myproject')
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
# Проверяем, какой пароль в settings
|
||||
from django.conf import settings
|
||||
print(f"Settings TENANT_ADMIN_PASSWORD: {settings.TENANT_ADMIN_PASSWORD}")
|
||||
print(f"Settings TENANT_ADMIN_EMAIL: {settings.TENANT_ADMIN_EMAIL}\n")
|
||||
|
||||
from tenants.models import TenantRegistration, Client, Domain
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
from tenants.admin import TenantRegistrationAdmin
|
||||
|
||||
# Создаём новую заявку
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
schema_name = f'test_{unique_id}'
|
||||
|
||||
registration = TenantRegistration.objects.create(
|
||||
shop_name=f'Fresh Test {unique_id}',
|
||||
schema_name=schema_name,
|
||||
owner_email='fresh@example.com',
|
||||
owner_name='Fresh User',
|
||||
phone='+375291234567',
|
||||
status=TenantRegistration.STATUS_PENDING
|
||||
)
|
||||
|
||||
print(f"Created registration for: {schema_name}\n")
|
||||
|
||||
# Переключаемся в public
|
||||
public_tenant = Client.objects.get(schema_name='public')
|
||||
connection.set_tenant(public_tenant)
|
||||
|
||||
User = get_user_model()
|
||||
admin_user = User.objects.first()
|
||||
|
||||
# Одобряем заявку
|
||||
admin = TenantRegistrationAdmin(TenantRegistration, None)
|
||||
admin._approve_registration(registration, admin_user)
|
||||
|
||||
# Переключаемся в новый тенант
|
||||
registration.refresh_from_db()
|
||||
tenant = registration.tenant
|
||||
connection.set_tenant(tenant)
|
||||
|
||||
# Проверяем пароль
|
||||
admin_in_tenant = User.objects.get(email='admin@localhost')
|
||||
print(f"Checking password for admin@localhost in {schema_name}:")
|
||||
print(f" Password 'AdminPassword123': {admin_in_tenant.check_password('AdminPassword123')}")
|
||||
|
||||
if admin_in_tenant.check_password('AdminPassword123'):
|
||||
print(f"\nSUCCESS! You can login with:")
|
||||
print(f" Email: admin@localhost")
|
||||
print(f" Password: AdminPassword123")
|
||||
else:
|
||||
print(f"\nFAILED! Password doesn't work")
|
||||
@@ -1,113 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
from decimal import Decimal
|
||||
|
||||
sys.path.insert(0, 'C:/Users/team_/Desktop/test_qwen/myproject')
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from tenants.models import Client
|
||||
from django.db import connection
|
||||
from products.models import Product
|
||||
from inventory.models import Warehouse, StockBatch, Incoming, Sale
|
||||
|
||||
# Переключаемся в grach
|
||||
grach = Client.objects.get(schema_name='grach')
|
||||
connection.set_tenant(grach)
|
||||
|
||||
print("=== FIFO Inventory Test ===\n")
|
||||
|
||||
# 1. Проверяем, есть ли товары
|
||||
products = Product.objects.all()
|
||||
if not products.exists():
|
||||
print("ERROR: No products in grach!")
|
||||
sys.exit(1)
|
||||
|
||||
product = products.first()
|
||||
print(f"Using product: {product.name} (ID: {product.id})\n")
|
||||
|
||||
# 2. Создаём или получаем склад
|
||||
warehouse, created = Warehouse.objects.get_or_create(
|
||||
name='Main Warehouse',
|
||||
defaults={'location': 'Default Location', 'is_active': True}
|
||||
)
|
||||
print(f"Warehouse: {warehouse.name} ({'created' if created else 'exists'})\n")
|
||||
|
||||
# 3. Создаём 3 прихода с разными ценами (FIFO тест)
|
||||
print("Creating incoming batches...")
|
||||
|
||||
incoming1 = Incoming.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=Decimal('10'),
|
||||
cost_price=Decimal('100.00'),
|
||||
notes='Batch 1 - oldest'
|
||||
)
|
||||
print(f" Batch 1: qty=10, price=100 (created: {incoming1.created_at})")
|
||||
|
||||
incoming2 = Incoming.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=Decimal('15'),
|
||||
cost_price=Decimal('120.00'),
|
||||
notes='Batch 2 - middle'
|
||||
)
|
||||
print(f" Batch 2: qty=15, price=120 (created: {incoming2.created_at})")
|
||||
|
||||
incoming3 = Incoming.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=Decimal('20'),
|
||||
cost_price=Decimal('150.00'),
|
||||
notes='Batch 3 - newest'
|
||||
)
|
||||
print(f" Batch 3: qty=20, price=150 (created: {incoming3.created_at})\n")
|
||||
|
||||
# 4. Проверяем StockBatches
|
||||
batches = StockBatch.objects.filter(product=product, warehouse=warehouse).order_by('created_at')
|
||||
print(f"StockBatches created: {batches.count()}")
|
||||
for batch in batches:
|
||||
print(f" - qty={batch.quantity}, price={batch.cost_price}, created={batch.created_at}")
|
||||
|
||||
print("\n--- Creating Sale ---\n")
|
||||
|
||||
# 5. Создаём продажу на 18 единиц (должна взять из batch1 (10) + batch2 (8))
|
||||
try:
|
||||
sale = Sale.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=Decimal('18'),
|
||||
sale_price=Decimal('200.00'),
|
||||
notes='Test sale for FIFO'
|
||||
)
|
||||
print(f"Sale created: qty=18, price=200\n")
|
||||
|
||||
# 6. Проверяем SaleBatchAllocation (как батчи были распределены)
|
||||
from inventory.models import SaleBatchAllocation
|
||||
|
||||
allocations = SaleBatchAllocation.objects.filter(sale=sale).order_by('batch__created_at')
|
||||
print(f"SaleBatchAllocations ({allocations.count()}):")
|
||||
|
||||
total_allocated = Decimal('0')
|
||||
for alloc in allocations:
|
||||
print(f" - Batch (created {alloc.batch.created_at.strftime('%H:%M:%S')}): qty={alloc.quantity}, cost={alloc.batch.cost_price}")
|
||||
total_allocated += alloc.quantity
|
||||
|
||||
print(f"\nTotal allocated: {total_allocated}")
|
||||
|
||||
if total_allocated == Decimal('18'):
|
||||
print("✓ FIFO allocation correct!")
|
||||
else:
|
||||
print(f"✗ ERROR: Expected 18, got {total_allocated}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR creating sale: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 7. Проверяем оставшиеся батчи
|
||||
print("\n--- Remaining Stock ---\n")
|
||||
remaining_batches = StockBatch.objects.filter(product=product, warehouse=warehouse).order_by('created_at')
|
||||
for batch in remaining_batches:
|
||||
print(f" - Batch qty={batch.quantity}, active={batch.is_active}")
|
||||
@@ -1,88 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
from decimal import Decimal
|
||||
|
||||
sys.path.insert(0, 'C:/Users/team_/Desktop/test_qwen/myproject')
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from tenants.models import Client
|
||||
from django.db import connection
|
||||
from products.models import Product
|
||||
from inventory.models import Warehouse, Inventory, InventoryLine, WriteOff
|
||||
|
||||
grach = Client.objects.get(schema_name='grach')
|
||||
connection.set_tenant(grach)
|
||||
|
||||
print("=== Inventory Reconciliation Test ===\n")
|
||||
|
||||
product = Product.objects.get(sku='FLOWER-001')
|
||||
warehouse = Warehouse.objects.get(name='Main Warehouse')
|
||||
|
||||
# Проверяем текущий остаток
|
||||
print("Step 1: Current stock status\n")
|
||||
|
||||
current_qty = sum(b.quantity for b in product.stock_batches.filter(warehouse=warehouse))
|
||||
print("Current system stock: {} units".format(current_qty))
|
||||
|
||||
# Создаём инвентаризацию
|
||||
print("\nStep 2: Creating inventory (physical count)\n")
|
||||
|
||||
inventory = Inventory.objects.create(
|
||||
warehouse=warehouse,
|
||||
status='draft'
|
||||
)
|
||||
print("Inventory created (status=draft)")
|
||||
|
||||
# Добавляем строку с физическим подсчётом (меньше, чем в системе - ДЕФИЦИТ)
|
||||
print("\nStep 3: Adding inventory line with deficit\n")
|
||||
|
||||
# Физический подсчёт: 5 единиц (система имеет 7)
|
||||
inventory_line = InventoryLine.objects.create(
|
||||
inventory=inventory,
|
||||
product=product,
|
||||
quantity_system=current_qty,
|
||||
quantity_fact=Decimal('5')
|
||||
)
|
||||
print("Inventory line: system={}, fact={}, difference={}".format(
|
||||
inventory_line.quantity_system,
|
||||
inventory_line.quantity_fact,
|
||||
inventory_line.difference
|
||||
))
|
||||
|
||||
# Завершаем инвентаризацию
|
||||
print("\nStep 4: Finishing inventory (processing reconciliation)\n")
|
||||
|
||||
inventory.status = 'completed'
|
||||
inventory.save()
|
||||
print("Inventory status: completed")
|
||||
|
||||
# Проверяем все WriteOff операции
|
||||
print("\nStep 5: Checking WriteOff operations\n")
|
||||
|
||||
writeoffs = WriteOff.objects.all().order_by('-date')
|
||||
print("Total WriteOff records: {}".format(writeoffs.count()))
|
||||
|
||||
if writeoffs.exists():
|
||||
print("Recent WriteOffs:")
|
||||
for wo in writeoffs[:3]:
|
||||
print(" - qty={}, reason={}".format(
|
||||
wo.quantity,
|
||||
wo.reason
|
||||
))
|
||||
print("PASS: WriteOff created for deficit")
|
||||
else:
|
||||
print(" No WriteOff found")
|
||||
|
||||
# Проверяем новый остаток
|
||||
print("\nStep 6: Final stock status\n")
|
||||
|
||||
final_qty = sum(b.quantity for b in product.stock_batches.filter(warehouse=warehouse))
|
||||
print("Final system stock: {} units".format(final_qty))
|
||||
print("Expected: 5 units")
|
||||
|
||||
if final_qty == Decimal('5'):
|
||||
print("PASS: Stock reconciled correctly")
|
||||
else:
|
||||
print("FAIL: Stock mismatch (expected 5, got {})".format(final_qty))
|
||||
@@ -1,116 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.insert(0, 'C:/Users/team_/Desktop/test_qwen/myproject')
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from tenants.models import Client
|
||||
from django.db import connection
|
||||
from products.models import Product
|
||||
from inventory.models import Warehouse, Incoming, Reservation, Sale
|
||||
from orders.models import Order, OrderItem
|
||||
from customers.models import Customer
|
||||
|
||||
grach = Client.objects.get(schema_name='grach')
|
||||
connection.set_tenant(grach)
|
||||
|
||||
print("=== Order Signals Test ===\n")
|
||||
|
||||
# Получаем товар и склад
|
||||
product = Product.objects.get(sku='FLOWER-001')
|
||||
warehouse = Warehouse.objects.get(name='Main Warehouse')
|
||||
|
||||
# Создаем приходы
|
||||
print("Step 1: Creating incoming batches\n")
|
||||
|
||||
Incoming.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=Decimal('50'),
|
||||
cost_price=Decimal('100.00')
|
||||
)
|
||||
print(" Incoming: qty=50, cost=100.00")
|
||||
|
||||
# Создаем клиента
|
||||
print("\nStep 2: Creating customer\n")
|
||||
|
||||
customer, created = Customer.objects.get_or_create(
|
||||
phone='+375291234567',
|
||||
defaults={
|
||||
'name': 'Test Customer',
|
||||
'email': 'test@example.com'
|
||||
}
|
||||
)
|
||||
print(" Customer: {} ({})".format(customer.name, customer.phone))
|
||||
|
||||
# Создаем заказ
|
||||
print("\nStep 3: Creating order\n")
|
||||
|
||||
try:
|
||||
delivery_date = datetime.now() + timedelta(days=1)
|
||||
|
||||
order = Order.objects.create(
|
||||
order_number='ORD-001',
|
||||
customer=customer,
|
||||
status='pending',
|
||||
delivery_type='courier',
|
||||
delivery_date=delivery_date
|
||||
)
|
||||
print(" Order created: ORD-001, status=pending")
|
||||
|
||||
# Создаем позицию заказа
|
||||
order_item = OrderItem.objects.create(
|
||||
order=order,
|
||||
product=product,
|
||||
quantity=Decimal('5'),
|
||||
price=Decimal('200.00')
|
||||
)
|
||||
print(" OrderItem: 5 units")
|
||||
|
||||
# Проверяем резервирование
|
||||
print("\nStep 4: Checking reservations\n")
|
||||
|
||||
reservations = Reservation.objects.filter(order_item=order_item, status='reserved')
|
||||
print("Reservations count: {}".format(reservations.count()))
|
||||
|
||||
if reservations.exists():
|
||||
for res in reservations:
|
||||
print(" PASS: Reservation created - qty={}, status={}".format(res.quantity, res.status))
|
||||
else:
|
||||
print(" FAIL: No reservations found!")
|
||||
|
||||
# Меняем статус на in_delivery
|
||||
print("\nStep 5: Changing order status to in_delivery\n")
|
||||
|
||||
order.status = 'in_delivery'
|
||||
order.save()
|
||||
print(" Order status changed to in_delivery")
|
||||
|
||||
# Проверяем продажу
|
||||
print("\nStep 6: Checking sales\n")
|
||||
|
||||
sales = Sale.objects.filter(order=order)
|
||||
print("Sales count: {}".format(sales.count()))
|
||||
|
||||
if sales.exists():
|
||||
for sale in sales:
|
||||
print(" PASS: Sale created - qty={}, price={}".format(sale.quantity, sale.sale_price))
|
||||
print(" processed={}".format(sale.processed))
|
||||
else:
|
||||
print(" FAIL: No sales found!")
|
||||
|
||||
# Проверяем обновленное резервирование
|
||||
print("\nStep 7: Checking updated reservations\n")
|
||||
|
||||
updated_reservations = Reservation.objects.filter(order_item=order_item)
|
||||
for res in updated_reservations:
|
||||
print(" Reservation status: {}".format(res.status))
|
||||
|
||||
except Exception as e:
|
||||
print("ERROR: {}".format(str(e)))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -1,89 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
from decimal import Decimal
|
||||
|
||||
sys.path.insert(0, 'C:/Users/team_/Desktop/test_qwen/myproject')
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from tenants.models import Client
|
||||
from django.db import connection
|
||||
from orders.models import Order
|
||||
from inventory.models import Reservation, Sale, SaleBatchAllocation
|
||||
|
||||
grach = Client.objects.get(schema_name='grach')
|
||||
connection.set_tenant(grach)
|
||||
|
||||
print("=== Order Signals Test with Existing Order ===\n")
|
||||
|
||||
# Получаем существующий заказ
|
||||
try:
|
||||
order = Order.objects.get(order_number__contains='ORD-20251027')
|
||||
print("Found order: {}".format(order.order_number))
|
||||
print("Customer: {}".format(order.customer.name))
|
||||
print("Items in order: {}".format(order.items.count()))
|
||||
|
||||
# Показываем items
|
||||
print("\nOrder items:")
|
||||
for item in order.items.all():
|
||||
product_name = item.product.name if item.product else item.product_kit.name
|
||||
print(" - {} qty={} price={}".format(product_name, item.quantity, item.price))
|
||||
|
||||
# Проверяем резервирования
|
||||
print("\n--- Checking Reservations ---\n")
|
||||
|
||||
reservations = Reservation.objects.filter(order_item__order=order)
|
||||
print("Total reservations: {}".format(reservations.count()))
|
||||
|
||||
if reservations.exists():
|
||||
for res in reservations:
|
||||
print(" - qty={}, status={}, warehouse={}".format(
|
||||
res.quantity, res.status, res.warehouse.name
|
||||
))
|
||||
else:
|
||||
print(" No reservations found - might not have been created yet")
|
||||
|
||||
# Меняем статус на in_delivery
|
||||
print("\n--- Changing status to in_delivery ---\n")
|
||||
|
||||
order.status = 'in_delivery'
|
||||
order.save()
|
||||
print("Order status changed to: {}".format(order.status))
|
||||
|
||||
# Проверяем продажи
|
||||
print("\n--- Checking Sales ---\n")
|
||||
|
||||
sales = Sale.objects.filter(order=order)
|
||||
print("Total sales: {}".format(sales.count()))
|
||||
|
||||
if sales.exists():
|
||||
for sale in sales:
|
||||
print("\nSale:")
|
||||
print(" qty={}, price={}".format(sale.quantity, sale.sale_price))
|
||||
print(" processed={}".format(sale.processed))
|
||||
|
||||
# Проверяем allocations
|
||||
allocations = SaleBatchAllocation.objects.filter(sale=sale)
|
||||
print(" Allocations: {}".format(allocations.count()))
|
||||
|
||||
for alloc in allocations:
|
||||
print(" - batch qty={}, cost={}".format(
|
||||
alloc.quantity, alloc.cost_price
|
||||
))
|
||||
else:
|
||||
print(" No sales found")
|
||||
|
||||
# Проверяем финальный статус резервирования
|
||||
print("\n--- Final Reservations Status ---\n")
|
||||
|
||||
final_reservations = Reservation.objects.filter(order_item__order=order)
|
||||
for res in final_reservations:
|
||||
print(" Reservation status: {}".format(res.status))
|
||||
|
||||
except Order.DoesNotExist:
|
||||
print("ERROR: Order not found!")
|
||||
except Exception as e:
|
||||
print("ERROR: {}".format(str(e)))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -1,28 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Добавляем путь к проекту
|
||||
sys.path.insert(0, 'C:/Users/team_/Desktop/test_qwen/myproject')
|
||||
|
||||
# Настройка Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from tenants.models import TenantRegistration
|
||||
|
||||
# Создаём заявку на регистрацию
|
||||
registration = TenantRegistration.objects.create(
|
||||
shop_name='Test Shop 3',
|
||||
schema_name='shop3',
|
||||
owner_email='shop3@example.com',
|
||||
owner_name='John Doe',
|
||||
phone='+375291234567',
|
||||
status=TenantRegistration.STATUS_PENDING
|
||||
)
|
||||
|
||||
print(f"Registration created: {registration.id}")
|
||||
print(f" Shop: {registration.shop_name}")
|
||||
print(f" Schema: {registration.schema_name}")
|
||||
print(f" Email: {registration.owner_email}")
|
||||
print(f" Status: {registration.status}")
|
||||
@@ -1,61 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
from decimal import Decimal
|
||||
|
||||
sys.path.insert(0, 'C:/Users/team_/Desktop/test_qwen/myproject')
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from tenants.models import Client
|
||||
from django.db import connection
|
||||
from products.models import Product
|
||||
from inventory.models import Warehouse, Sale, SaleBatchAllocation
|
||||
|
||||
grach = Client.objects.get(schema_name='grach')
|
||||
connection.set_tenant(grach)
|
||||
|
||||
print("=== Second Sale Test ===\n")
|
||||
|
||||
product = Product.objects.get(sku='FLOWER-001')
|
||||
warehouse = Warehouse.objects.get(name='Main Warehouse')
|
||||
|
||||
# Создаем вторую продажу на 20 единиц
|
||||
print("Creating second sale (qty=20)\n")
|
||||
|
||||
sale2 = Sale.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=Decimal('20'),
|
||||
sale_price=Decimal('250.00')
|
||||
)
|
||||
|
||||
print("Sale 2 created: qty=20\n")
|
||||
|
||||
# Проверяем allocations
|
||||
allocations = SaleBatchAllocation.objects.filter(sale=sale2)
|
||||
total = sum(a.quantity for a in allocations)
|
||||
|
||||
print("Allocations for Sale 2:")
|
||||
for alloc in allocations:
|
||||
print(" - qty={}, cost={}".format(alloc.quantity, alloc.cost_price))
|
||||
|
||||
print("\nTotal allocated: {}".format(total))
|
||||
print("Sale qty: {}".format(sale2.quantity))
|
||||
|
||||
if total == sale2.quantity:
|
||||
print("PASS: Allocations match Sale qty")
|
||||
else:
|
||||
print("FAIL: Allocations ({}) != Sale qty ({})".format(total, sale2.quantity))
|
||||
|
||||
# Проверяем порядок FIFO (должны взяться из batch2 (7), batch3 (13))
|
||||
expected = [Decimal('7'), Decimal('13')]
|
||||
actual = [a.quantity for a in allocations]
|
||||
|
||||
print("\nExpected FIFO order: {}".format(expected))
|
||||
print("Actual order: {}".format(actual))
|
||||
|
||||
if actual == expected:
|
||||
print("PASS: FIFO order correct")
|
||||
else:
|
||||
print("FAIL: FIFO order incorrect")
|
||||
@@ -1,86 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
import logging
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
|
||||
# Добавляем путь к проекту
|
||||
sys.path.insert(0, 'C:/Users/team_/Desktop/test_qwen/myproject')
|
||||
|
||||
# Настройка Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from tenants.models import TenantRegistration, Client
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
from tenants.admin import TenantRegistrationAdmin
|
||||
|
||||
# Создаём новую заявку
|
||||
registration = TenantRegistration.objects.create(
|
||||
shop_name='Test Shop 5',
|
||||
schema_name='shop5',
|
||||
owner_email='shop5@example.com',
|
||||
owner_name='Jane Doe',
|
||||
phone='+375291234567',
|
||||
status=TenantRegistration.STATUS_PENDING
|
||||
)
|
||||
|
||||
print(f"\n=== Testing Superuser Creation ===")
|
||||
print(f"Created registration: {registration.id} for {registration.schema_name}\n")
|
||||
|
||||
# Одобряем заявку
|
||||
admin = TenantRegistrationAdmin(TenantRegistration, None)
|
||||
User = get_user_model()
|
||||
|
||||
# Получаем системного пользователя
|
||||
public_tenant = Client.objects.get(schema_name='public')
|
||||
connection.set_tenant(public_tenant)
|
||||
|
||||
try:
|
||||
admin_user = User.objects.get(email='admin@localhost')
|
||||
except User.DoesNotExist:
|
||||
admin_user = User.objects.create_superuser(
|
||||
email='admin@localhost',
|
||||
name='Admin',
|
||||
password='admin'
|
||||
)
|
||||
|
||||
try:
|
||||
print("Approving registration...")
|
||||
admin._approve_registration(registration, admin_user)
|
||||
print("\nApproval completed!\n")
|
||||
|
||||
# Проверяем, был ли создан суперпользователь в новом тенанте
|
||||
registration.refresh_from_db()
|
||||
tenant = registration.tenant
|
||||
|
||||
print(f"Tenant created: {tenant.name} ({tenant.schema_name})")
|
||||
|
||||
# Переключаемся на новый тенант и проверяем пользователей
|
||||
connection.set_tenant(tenant)
|
||||
|
||||
superusers = User.objects.filter(is_superuser=True)
|
||||
print(f"\nSuperusers in {tenant.schema_name} schema:")
|
||||
|
||||
if superusers.exists():
|
||||
for su in superusers:
|
||||
print(f" OK: {su.email} ({su.name})")
|
||||
else:
|
||||
print(" FAIL: No superusers found!")
|
||||
|
||||
# Проверим ВСЕ пользователей
|
||||
all_users = User.objects.all()
|
||||
print(f"\nAll users in {tenant.schema_name} schema:")
|
||||
if all_users.exists():
|
||||
for u in all_users:
|
||||
print(f" - {u.email} (superuser={u.is_superuser})")
|
||||
else:
|
||||
print(" - No users found at all!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during approval: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -1,279 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Скрипт для тестирования системы наличия товаров и цен вариантов.
|
||||
Проверяет:
|
||||
1. Обновление Product.in_stock при создании Stock
|
||||
2. Свойство ProductVariantGroup.in_stock (вариант в наличии если хотя бы один товар в наличии)
|
||||
3. Свойство ProductVariantGroup.price (берётся цена по приоритету)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Добавляем путь к myproject
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'myproject'))
|
||||
os.chdir(os.path.join(os.path.dirname(__file__), 'myproject'))
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from decimal import Decimal
|
||||
from products.models import Product, ProductVariantGroup, ProductVariantGroupItem
|
||||
from inventory.models import Stock, StockBatch, Warehouse, Incoming, IncomingBatch
|
||||
from django.utils import timezone
|
||||
|
||||
def clear_data():
|
||||
"""Очищаем тестовые данные"""
|
||||
Product.objects.all().delete()
|
||||
ProductVariantGroup.objects.all().delete()
|
||||
Stock.objects.all().delete()
|
||||
StockBatch.objects.all().delete()
|
||||
Warehouse.objects.all().delete()
|
||||
IncomingBatch.objects.all().delete()
|
||||
print("OK Данные очищены")
|
||||
|
||||
def create_test_data():
|
||||
"""Создаём тестовые данные"""
|
||||
|
||||
# Создаём склад
|
||||
warehouse = Warehouse.objects.create(
|
||||
name="Основной склад",
|
||||
description="Тестовый склад",
|
||||
is_active=True,
|
||||
is_default=True
|
||||
)
|
||||
print(f"OK Создан склад: {warehouse.name}")
|
||||
|
||||
# Создаём товары (розы разной длины)
|
||||
products = []
|
||||
prices = [Decimal('50.00'), Decimal('60.00'), Decimal('70.00')]
|
||||
|
||||
for i, price in enumerate(prices, 1):
|
||||
product = Product.objects.create(
|
||||
name=f"Роза красная Freedom {50 + i*10}см",
|
||||
sku=f"ROSE-RED-{50 + i*10}",
|
||||
cost_price=Decimal('30.00'),
|
||||
sale_price=price,
|
||||
unit='шт',
|
||||
is_active=True,
|
||||
in_stock=False # По умолчанию нет в наличии
|
||||
)
|
||||
products.append(product)
|
||||
print(f"OK Создан товар: {product.name} (цена: {price}, in_stock={product.in_stock})")
|
||||
|
||||
# Создаём группу вариантов
|
||||
variant_group = ProductVariantGroup.objects.create(
|
||||
name="Роза красная Freedom",
|
||||
description="Розы разной высоты"
|
||||
)
|
||||
print(f"OK Создана группа вариантов: {variant_group.name}")
|
||||
|
||||
# Добавляем товары в группу с приоритетами
|
||||
items = []
|
||||
for priority, product in enumerate(products, 1):
|
||||
item = ProductVariantGroupItem.objects.create(
|
||||
variant_group=variant_group,
|
||||
product=product,
|
||||
priority=priority
|
||||
)
|
||||
items.append(item)
|
||||
print(f" - {product.name} (приоритет {priority})")
|
||||
|
||||
return warehouse, products, variant_group, items
|
||||
|
||||
def test_scenario_1():
|
||||
"""
|
||||
Тест 1: Создание товара в наличии и проверка автоматического обновления in_stock
|
||||
"""
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТ 1: Обновление Product.in_stock при создании Stock")
|
||||
print("="*80)
|
||||
|
||||
warehouse, products, variant_group, items = create_test_data()
|
||||
|
||||
# Получаем первый товар
|
||||
product = products[0]
|
||||
print(f"\nПроверяем товар: {product.name}")
|
||||
print(f"Текущий статус in_stock: {product.in_stock}")
|
||||
|
||||
# Создаём приход товара (это должно создать Stock и обновить in_stock)
|
||||
incoming_batch = IncomingBatch.objects.create(
|
||||
warehouse=warehouse,
|
||||
document_number="IN-0001",
|
||||
supplier_name="Тестовый поставщик"
|
||||
)
|
||||
print(f"\nOK Создана партия поступления: {incoming_batch.document_number}")
|
||||
|
||||
# Добавляем товар в приход
|
||||
incoming = Incoming.objects.create(
|
||||
batch=incoming_batch,
|
||||
product=product,
|
||||
quantity=Decimal('100.00'),
|
||||
cost_price=product.cost_price
|
||||
)
|
||||
print(f"OK Добавлен товар в приход: {incoming.quantity} шт")
|
||||
|
||||
# Проверяем что Stock был создан и in_stock обновлён
|
||||
stock = Stock.objects.get(product=product, warehouse=warehouse)
|
||||
print(f"\nOK Stock создан:")
|
||||
print(f" - quantity_available: {stock.quantity_available}")
|
||||
print(f" - quantity_reserved: {stock.quantity_reserved}")
|
||||
print(f" - quantity_free: {stock.quantity_free}")
|
||||
|
||||
# Обновляем товар из БД чтобы получить новое значение
|
||||
product.refresh_from_db()
|
||||
print(f"\nOK Product.in_stock обновлён: {product.in_stock}")
|
||||
|
||||
if product.in_stock:
|
||||
print("PASS: ТЕСТ 1 ПРОЙДЕН: Product.in_stock = True")
|
||||
else:
|
||||
print("FAIL: ТЕСТ 1 ПРОВАЛЕН: Product.in_stock должен быть True")
|
||||
|
||||
return warehouse, products, variant_group, items
|
||||
|
||||
def test_scenario_2(warehouse, products, variant_group, items):
|
||||
"""
|
||||
Тест 2: Проверка свойства ProductVariantGroup.in_stock
|
||||
"""
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТ 2: Свойство ProductVariantGroup.in_stock")
|
||||
print("="*80)
|
||||
|
||||
# Обновляем товары из БД
|
||||
for product in products:
|
||||
product.refresh_from_db()
|
||||
|
||||
print(f"\nГруппа вариантов: {variant_group.name}")
|
||||
print(f"Товары в группе:")
|
||||
for item in variant_group.items.all():
|
||||
print(f" - {item.product.name} (приоритет {item.priority}, in_stock={item.product.in_stock})")
|
||||
|
||||
# Первый товар в наличии, поэтому вариант должен быть в наличии
|
||||
print(f"\nСвойство variant_group.in_stock: {variant_group.in_stock}")
|
||||
|
||||
if variant_group.in_stock:
|
||||
print("PASS: ТЕСТ 2 ПРОЙДЕН: Вариант в наличии (хотя бы один товар доступен)")
|
||||
else:
|
||||
print("FAIL: ТЕСТ 2 ПРОВАЛЕН: Вариант должен быть в наличии")
|
||||
|
||||
def test_scenario_3(warehouse, products, variant_group, items):
|
||||
"""
|
||||
Тест 3: Проверка свойства ProductVariantGroup.price
|
||||
"""
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТ 3: Свойство ProductVariantGroup.price")
|
||||
print("="*80)
|
||||
|
||||
print(f"\nГруппа вариантов: {variant_group.name}")
|
||||
|
||||
# Обновляем товары из БД
|
||||
for product in products:
|
||||
product.refresh_from_db()
|
||||
|
||||
print(f"Товары в приоритете:")
|
||||
for item in variant_group.items.all().order_by('priority'):
|
||||
status = "OK В наличии" if item.product.in_stock else "NO Нет в наличии"
|
||||
print(f" {item.priority}. {item.product.name} - {item.product.sale_price} руб {status}")
|
||||
|
||||
# Цена должна быть из первого в наличии (приоритет 1, цена 50.00)
|
||||
price = variant_group.price
|
||||
expected_price = Decimal('50.00')
|
||||
|
||||
print(f"\nЦена варианта: {price} руб")
|
||||
print(f"Ожидаемая цена: {expected_price} руб")
|
||||
|
||||
if price == expected_price:
|
||||
print("PASS: ТЕСТ 3 ПРОЙДЕН: Берётся цена товара с приоритетом 1")
|
||||
else:
|
||||
print(f"FAIL: ТЕСТ 3 ПРОВАЛЕН: Цена должна быть {expected_price}, получена {price}")
|
||||
|
||||
def test_scenario_4():
|
||||
"""
|
||||
Тест 4: Проверка цены когда нет товара в наличии (должна быть максимальная)
|
||||
"""
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТ 4: Цена варианта когда ни один товар не в наличии")
|
||||
print("="*80)
|
||||
|
||||
# Очищаем данные
|
||||
clear_data()
|
||||
|
||||
# Создаём новые данные без Stock (товары не в наличии)
|
||||
warehouse = Warehouse.objects.create(
|
||||
name="Тестовый склад",
|
||||
is_active=True,
|
||||
is_default=True
|
||||
)
|
||||
|
||||
# Создаём товары с разными ценами
|
||||
products = []
|
||||
prices = [Decimal('100.00'), Decimal('150.00'), Decimal('200.00')]
|
||||
|
||||
for i, price in enumerate(prices, 1):
|
||||
product = Product.objects.create(
|
||||
name=f"Товар {i}",
|
||||
sku=f"PRODUCT-{i}",
|
||||
cost_price=Decimal('50.00'),
|
||||
sale_price=price,
|
||||
unit='шт',
|
||||
is_active=True,
|
||||
in_stock=False # Нет в наличии
|
||||
)
|
||||
products.append(product)
|
||||
|
||||
# Создаём группу вариантов
|
||||
variant_group = ProductVariantGroup.objects.create(
|
||||
name="Группа товаров без наличия"
|
||||
)
|
||||
|
||||
# Добавляем товары в группу
|
||||
for priority, product in enumerate(products, 1):
|
||||
ProductVariantGroupItem.objects.create(
|
||||
variant_group=variant_group,
|
||||
product=product,
|
||||
priority=priority
|
||||
)
|
||||
|
||||
print(f"\nГруппа: {variant_group.name}")
|
||||
print(f"Товары (все без наличия):")
|
||||
for item in variant_group.items.all().order_by('priority'):
|
||||
print(f" {item.priority}. {item.product.name} - {item.product.sale_price} руб (in_stock={item.product.in_stock})")
|
||||
|
||||
# Цена должна быть максимальная = 200.00
|
||||
price = variant_group.price
|
||||
expected_price = Decimal('200.00')
|
||||
|
||||
print(f"\nЦена варианта (максимальная): {price} руб")
|
||||
print(f"Ожидаемая цена (максимальная): {expected_price} руб")
|
||||
|
||||
if price == expected_price:
|
||||
print("PASS: ТЕСТ 4 ПРОЙДЕН: Берётся максимальная цена из товаров")
|
||||
else:
|
||||
print(f"FAIL: ТЕСТ 4 ПРОВАЛЕН: Цена должна быть {expected_price}, получена {price}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("\n" + "="*80)
|
||||
print("= ТЕСТИРОВАНИЕ СИСТЕМЫ НАЛИЧИЯ ТОВАРОВ И ЦЕН ВАРИАНТОВ")
|
||||
print("="*80)
|
||||
|
||||
try:
|
||||
# Очищаем старые данные
|
||||
clear_data()
|
||||
|
||||
# Тесты 1-3
|
||||
warehouse, products, variant_group, items = test_scenario_1()
|
||||
test_scenario_2(warehouse, products, variant_group, items)
|
||||
test_scenario_3(warehouse, products, variant_group, items)
|
||||
|
||||
# Тест 4
|
||||
test_scenario_4()
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("= ТЕСТИРОВАНИЕ ЗАВЕРШЕНО")
|
||||
print("="*80 + "\n")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nОШИБКА: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Test script to verify WriteOff form validation prevents over-listing.
|
||||
This tests that WriteOffForm.clean() prevents creating WriteOff with quantity > batch.quantity
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Setup Django
|
||||
sys.path.insert(0, 'myproject')
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from inventory.forms import WriteOffForm
|
||||
from inventory.models import WriteOff, StockBatch, Warehouse, Product
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
print("=" * 70)
|
||||
print("Testing WriteOff Validation")
|
||||
print("=" * 70)
|
||||
|
||||
# Create test data
|
||||
warehouse = Warehouse.objects.filter(is_active=True).first()
|
||||
if not warehouse:
|
||||
print("ERROR: No active warehouse found. Please create one first.")
|
||||
sys.exit(1)
|
||||
|
||||
product = Product.objects.filter(tenant=warehouse.tenant).first()
|
||||
if not product:
|
||||
print("ERROR: No product found. Please create one first.")
|
||||
sys.exit(1)
|
||||
|
||||
# Create a batch with 2 items
|
||||
batch = StockBatch.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=2,
|
||||
cost_price=100.0,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
print(f"\n✓ Created StockBatch with quantity=2")
|
||||
print(f" Product: {product.name}")
|
||||
print(f" Warehouse: {warehouse.name}")
|
||||
print(f" Batch ID: {batch.id}")
|
||||
|
||||
# Test 1: Valid WriteOff (quantity = batch.quantity)
|
||||
print("\n" + "=" * 70)
|
||||
print("Test 1: Valid WriteOff with quantity=2 (equal to batch quantity)")
|
||||
print("=" * 70)
|
||||
|
||||
form_data = {
|
||||
'batch': batch.id,
|
||||
'quantity': 2,
|
||||
'reason': 'damage',
|
||||
'document_number': 'DOC001',
|
||||
'notes': 'Test valid writeoff'
|
||||
}
|
||||
|
||||
form = WriteOffForm(data=form_data)
|
||||
if form.is_valid():
|
||||
print("✓ PASS: Form is valid (quantity <= batch.quantity)")
|
||||
else:
|
||||
print(f"✗ FAIL: Form should be valid but has errors: {form.errors}")
|
||||
|
||||
# Test 2: Invalid WriteOff (quantity > batch.quantity)
|
||||
print("\n" + "=" * 70)
|
||||
print("Test 2: Invalid WriteOff with quantity=3 (exceeds batch quantity=2)")
|
||||
print("=" * 70)
|
||||
|
||||
form_data = {
|
||||
'batch': batch.id,
|
||||
'quantity': 3,
|
||||
'reason': 'damage',
|
||||
'document_number': 'DOC002',
|
||||
'notes': 'Test invalid writeoff - should be rejected'
|
||||
}
|
||||
|
||||
form = WriteOffForm(data=form_data)
|
||||
if not form.is_valid():
|
||||
print("✓ PASS: Form validation correctly rejects over-listing")
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
print(f" Error: {error}")
|
||||
else:
|
||||
print("✗ FAIL: Form should reject quantity > batch.quantity")
|
||||
|
||||
# Test 3: Invalid WriteOff (quantity = 0)
|
||||
print("\n" + "=" * 70)
|
||||
print("Test 3: Invalid WriteOff with quantity=0 (should be > 0)")
|
||||
print("=" * 70)
|
||||
|
||||
form_data = {
|
||||
'batch': batch.id,
|
||||
'quantity': 0,
|
||||
'reason': 'damage',
|
||||
'document_number': 'DOC003',
|
||||
'notes': 'Test zero quantity'
|
||||
}
|
||||
|
||||
form = WriteOffForm(data=form_data)
|
||||
if not form.is_valid():
|
||||
print("✓ PASS: Form validation correctly rejects zero quantity")
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
print(f" Error: {error}")
|
||||
else:
|
||||
print("✗ FAIL: Form should reject quantity <= 0")
|
||||
|
||||
# Cleanup
|
||||
batch.delete()
|
||||
print("\n" + "=" * 70)
|
||||
print("Test completed successfully!")
|
||||
print("=" * 70)
|
||||
Reference in New Issue
Block a user