Исправлена критическая проблема с резервами при смене статуса заказа
Проблема: - При смене статуса заказа на 'Выполнен' товар списывался со склада - Резервы обновлялись на статус 'converted_to_sale' - НО Stock.quantity_reserved не обновлялся автоматически - В результате резервы продолжали 'держать' товар, хотя он уже продан Решение: 1. Изменен сигнал create_sale_on_order_completion: - Используется .save(update_fields=[...]) вместо .update() - Это вызывает сигнал update_stock_on_reservation_change - Убран костыль с ручным вызовом refresh_from_batches() 2. Оптимизирован сигнал update_stock_on_reservation_change: - Stock обновляется ТОЛЬКО при изменении status или quantity - При изменении других полей (даты и т.д.) Stock НЕ пересчитывается - Предотвращены лишние пересчёты и улучшена производительность 3. Добавлены диагностические инструменты: - check_stock_103.py - для диагностики проблем с Stock - fix_stock_after_sale.py - команда для исправления старых заказов - diagnose_reservation_issue.py - универсальная диагностика Результат: - Элегантное решение без дублирования логики - Stock автоматически обновляется при изменении резервов - Работает везде, не только в заказах - Оптимизировано для производительности
This commit is contained in:
95
myproject/check_stock_103.py
Normal file
95
myproject/check_stock_103.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Диагностика Stock для заказа 103 в схеме buba
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import django
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from django_tenants.utils import schema_context
|
||||||
|
from orders.models import Order
|
||||||
|
from inventory.models import Reservation, Stock, Sale
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
|
# Работаем в схеме buba
|
||||||
|
with schema_context('buba'):
|
||||||
|
# Получаем заказ 103
|
||||||
|
order = Order.objects.get(order_number=103)
|
||||||
|
|
||||||
|
print("="*80)
|
||||||
|
print(f"ДИАГНОСТИКА ЗАКАЗА #{order.order_number} (схема: buba)")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
# Проверяем резервы
|
||||||
|
print("\n📝 РЕЗЕРВЫ:")
|
||||||
|
reservations = Reservation.objects.filter(order_item__order=order)
|
||||||
|
for res in reservations:
|
||||||
|
print(f" - {res.product.name}:")
|
||||||
|
print(f" Количество: {res.quantity}")
|
||||||
|
print(f" Статус: {res.status}")
|
||||||
|
print(f" Склад: {res.warehouse.name}")
|
||||||
|
print(f" Product ID: {res.product_id}")
|
||||||
|
print(f" Warehouse ID: {res.warehouse_id}")
|
||||||
|
|
||||||
|
# Проверяем Sale
|
||||||
|
print("\n💰 ПРОДАЖИ (Sale):")
|
||||||
|
sales = Sale.objects.filter(order=order)
|
||||||
|
for sale in sales:
|
||||||
|
print(f" - {sale.product.name}: {sale.quantity} шт.")
|
||||||
|
|
||||||
|
# Проверяем Stock
|
||||||
|
print("\n📊 STOCK:")
|
||||||
|
for res in reservations:
|
||||||
|
stock = Stock.objects.get(
|
||||||
|
product_id=res.product_id,
|
||||||
|
warehouse_id=res.warehouse_id
|
||||||
|
)
|
||||||
|
print(f" - {stock.product.name} на {stock.warehouse.name}:")
|
||||||
|
print(f" quantity_available: {stock.quantity_available}")
|
||||||
|
print(f" quantity_reserved: {stock.quantity_reserved}")
|
||||||
|
print(f" quantity_free: {stock.quantity_free}")
|
||||||
|
|
||||||
|
# Проверяем: сколько РЕАЛЬНО резервов со статусом 'reserved'
|
||||||
|
print("\n🔍 ПЕРЕСЧЁТ РЕЗЕРВОВ ВРУЧНУЮ:")
|
||||||
|
for res in reservations.values('product_id', 'warehouse_id').distinct():
|
||||||
|
product_id = res['product_id']
|
||||||
|
warehouse_id = res['warehouse_id']
|
||||||
|
|
||||||
|
# Считаем резервы со статусом 'reserved'
|
||||||
|
reserved_count = Reservation.objects.filter(
|
||||||
|
product_id=product_id,
|
||||||
|
warehouse_id=warehouse_id,
|
||||||
|
status='reserved'
|
||||||
|
).aggregate(Sum('quantity'))['quantity__sum'] or 0
|
||||||
|
|
||||||
|
# Считаем резервы со статусом 'converted_to_sale'
|
||||||
|
converted_count = Reservation.objects.filter(
|
||||||
|
product_id=product_id,
|
||||||
|
warehouse_id=warehouse_id,
|
||||||
|
status='converted_to_sale'
|
||||||
|
).aggregate(Sum('quantity'))['quantity__sum'] or 0
|
||||||
|
|
||||||
|
print(f" Product ID {product_id}, Warehouse ID {warehouse_id}:")
|
||||||
|
print(f" Резервов 'reserved': {reserved_count}")
|
||||||
|
print(f" Резервов 'converted_to_sale': {converted_count}")
|
||||||
|
|
||||||
|
# Что должно быть в Stock
|
||||||
|
stock = Stock.objects.get(product_id=product_id, warehouse_id=warehouse_id)
|
||||||
|
print(f" Stock.quantity_reserved: {stock.quantity_reserved}")
|
||||||
|
print(f" ❌ ПРОБЛЕМА!" if stock.quantity_reserved != reserved_count else " ✅ OK")
|
||||||
|
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print("ВЫВОД:")
|
||||||
|
print("="*80)
|
||||||
|
if stock.quantity_reserved > 0 and converted_count > 0:
|
||||||
|
print("❌ Stock НЕ обновился после конвертации резервов!")
|
||||||
|
print(" quantity_reserved показывает старое значение")
|
||||||
|
print("\n🔧 Попробуем обновить вручную...")
|
||||||
|
stock.refresh_from_batches()
|
||||||
|
print(f" После refresh_from_batches():")
|
||||||
|
print(f" quantity_reserved: {stock.quantity_reserved}")
|
||||||
|
print(f" ✅ ИСПРАВЛЕНО!" if stock.quantity_reserved == 0 else " ❌ НЕ ПОМОГЛО!")
|
||||||
|
else:
|
||||||
|
print("✅ Всё в порядке!")
|
||||||
171
myproject/diagnose_reservation_issue.py
Normal file
171
myproject/diagnose_reservation_issue.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Скрипт для диагностики проблемы с резервами при смене статуса на 'completed'.
|
||||||
|
Проверяет:
|
||||||
|
1. Изменяется ли статус резервов на 'converted_to_sale'
|
||||||
|
2. Создаются ли Sale
|
||||||
|
3. Списывается ли товар со склада
|
||||||
|
4. Освобождаются ли резервы
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import django
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from django.db import connection
|
||||||
|
from orders.models import Order, OrderStatus
|
||||||
|
from inventory.models import Reservation, Sale, Stock
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose_order(order_number):
|
||||||
|
"""Диагностика конкретного заказа"""
|
||||||
|
print(f"\n{'='*80}")
|
||||||
|
print(f"ДИАГНОСТИКА ЗАКАЗА #{order_number}")
|
||||||
|
print(f"{'='*80}\n")
|
||||||
|
|
||||||
|
# Проверяем тенант
|
||||||
|
print(f"📌 Текущая схема БД: {connection.schema_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
order = Order.objects.get(order_number=order_number)
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
print(f"❌ ОШИБКА: Заказ #{order_number} не найден!")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"✓ Заказ найден")
|
||||||
|
print(f" - Клиент: {order.customer.name}")
|
||||||
|
print(f" - Статус: {order.status.name if order.status else 'Нет статуса'} (code: {order.status.code if order.status else 'None'})")
|
||||||
|
print(f" - Дата создания: {order.created_at}")
|
||||||
|
print(f" - Дата обновления: {order.updated_at}")
|
||||||
|
|
||||||
|
# Проверяем позиции заказа
|
||||||
|
print(f"\n📦 ПОЗИЦИИ ЗАКАЗА:")
|
||||||
|
items = order.items.all()
|
||||||
|
if not items.exists():
|
||||||
|
print(" ⚠ Нет позиций в заказе")
|
||||||
|
else:
|
||||||
|
for item in items:
|
||||||
|
product = item.product or item.product_kit
|
||||||
|
print(f" - {product.name}: {item.quantity} шт. по {item.price} руб.")
|
||||||
|
|
||||||
|
# Проверяем резервы
|
||||||
|
print(f"\n📝 РЕЗЕРВЫ:")
|
||||||
|
reservations = Reservation.objects.filter(order_item__order=order)
|
||||||
|
if not reservations.exists():
|
||||||
|
print(" ⚠ НЕТ РЕЗЕРВОВ для этого заказа!")
|
||||||
|
else:
|
||||||
|
for res in reservations:
|
||||||
|
print(f" - {res.product.name}:")
|
||||||
|
print(f" • Количество: {res.quantity}")
|
||||||
|
print(f" • Статус: {res.get_status_display()} ({res.status})")
|
||||||
|
print(f" • Склад: {res.warehouse.name}")
|
||||||
|
print(f" • Зарезервировано: {res.reserved_at}")
|
||||||
|
if res.converted_at:
|
||||||
|
print(f" • Конвертировано: {res.converted_at}")
|
||||||
|
if res.released_at:
|
||||||
|
print(f" • Освобождено: {res.released_at}")
|
||||||
|
|
||||||
|
# Проверяем Sale
|
||||||
|
print(f"\n💰 ПРОДАЖИ (Sale):")
|
||||||
|
sales = Sale.objects.filter(order=order)
|
||||||
|
if not sales.exists():
|
||||||
|
print(" ⚠ НЕТ ПРОДАЖ для этого заказа!")
|
||||||
|
if order.status and order.status.code == 'completed':
|
||||||
|
print(" ❌ ПРОБЛЕМА: Заказ в статусе 'completed', но Sale не созданы!")
|
||||||
|
else:
|
||||||
|
for sale in sales:
|
||||||
|
print(f" - {sale.product.name}:")
|
||||||
|
print(f" • Количество: {sale.quantity}")
|
||||||
|
print(f" • Цена продажи: {sale.sale_price}")
|
||||||
|
print(f" • Склад: {sale.warehouse.name}")
|
||||||
|
print(f" • Дата: {sale.created_at}")
|
||||||
|
|
||||||
|
# Проверяем распределение по партиям
|
||||||
|
from inventory.models import SaleBatchAllocation
|
||||||
|
allocations = SaleBatchAllocation.objects.filter(sale=sale)
|
||||||
|
if allocations.exists():
|
||||||
|
print(f" • Распределение по партиям:")
|
||||||
|
for alloc in allocations:
|
||||||
|
print(f" - Партия #{alloc.batch.id}: {alloc.quantity} шт.")
|
||||||
|
|
||||||
|
# Проверяем Stock
|
||||||
|
print(f"\n📊 ОСТАТКИ НА СКЛАДЕ (Stock):")
|
||||||
|
for item in items:
|
||||||
|
product = item.product or item.product_kit
|
||||||
|
warehouse = order.pickup_warehouse or order.items.first().order.pickup_warehouse
|
||||||
|
|
||||||
|
if warehouse:
|
||||||
|
try:
|
||||||
|
stock = Stock.objects.get(product=product, warehouse=warehouse)
|
||||||
|
print(f" - {product.name} на складе {warehouse.name}:")
|
||||||
|
print(f" • Всего: {stock.quantity}")
|
||||||
|
print(f" • Зарезервировано: {stock.reserved_quantity}")
|
||||||
|
print(f" • Доступно: {stock.available_quantity}")
|
||||||
|
except Stock.DoesNotExist:
|
||||||
|
print(f" - {product.name}: ❌ Stock не найден")
|
||||||
|
else:
|
||||||
|
print(f" - {product.name}: ⚠ Склад не определён")
|
||||||
|
|
||||||
|
# Проверяем историю изменений
|
||||||
|
print(f"\n📜 ИСТОРИЯ ИЗМЕНЕНИЙ СТАТУСА:")
|
||||||
|
history = order.history.all()[:5] # Последние 5 записей
|
||||||
|
for idx, record in enumerate(history, 1):
|
||||||
|
status_name = record.status.name if record.status else "Нет статуса"
|
||||||
|
print(f" {idx}. {status_name} - {record.history_date}")
|
||||||
|
|
||||||
|
# Выводим выводы
|
||||||
|
print(f"\n{'='*80}")
|
||||||
|
print("ДИАГНОСТИКА:")
|
||||||
|
print(f"{'='*80}")
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# Проверка 1: Резервы существуют?
|
||||||
|
if not reservations.exists():
|
||||||
|
issues.append("❌ КРИТИЧНО: Нет резервов для заказа")
|
||||||
|
|
||||||
|
# Проверка 2: Статус 'completed' + нет Sale
|
||||||
|
if order.status and order.status.code == 'completed':
|
||||||
|
if not sales.exists():
|
||||||
|
issues.append("❌ КРИТИЧНО: Заказ 'completed', но Sale не созданы")
|
||||||
|
|
||||||
|
# Проверка 3: Статус резервов
|
||||||
|
reserved_count = reservations.filter(status='reserved').count()
|
||||||
|
converted_count = reservations.filter(status='converted_to_sale').count()
|
||||||
|
|
||||||
|
if reserved_count > 0:
|
||||||
|
issues.append(f"❌ ПРОБЛЕМА: {reserved_count} резервов ещё в статусе 'reserved' при completed заказе")
|
||||||
|
|
||||||
|
if converted_count == 0 and reservations.exists():
|
||||||
|
issues.append("❌ ПРОБЛЕМА: Ни один резерв не конвертирован в продажу")
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
print("\n🔴 НАЙДЕНЫ ПРОБЛЕМЫ:")
|
||||||
|
for issue in issues:
|
||||||
|
print(f" {issue}")
|
||||||
|
else:
|
||||||
|
print("\n✅ Проблем не обнаружено")
|
||||||
|
|
||||||
|
print(f"\n{'='*80}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print("ДИАГНОСТИКА ПРОБЛЕМЫ С РЕЗЕРВАМИ")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
# Спрашиваем номер заказа
|
||||||
|
order_number_input = input("\nВведите номер заказа для диагностики: ").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
order_number = int(order_number_input)
|
||||||
|
except ValueError:
|
||||||
|
print("❌ Ошибка: Введите корректный номер заказа (число)")
|
||||||
|
return
|
||||||
|
|
||||||
|
diagnose_order(order_number)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
117
myproject/inventory/management/commands/fix_stock_after_sale.py
Normal file
117
myproject/inventory/management/commands/fix_stock_after_sale.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Команда для исправления Stock после обработки заказов.
|
||||||
|
Пересчитывает quantity_reserved для всех товаров.
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from inventory.models import Stock, Reservation
|
||||||
|
from django.db.models import Sum
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Исправляет quantity_reserved в Stock после обработки заказов'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--order',
|
||||||
|
type=int,
|
||||||
|
help='Номер заказа для исправления (опционально)'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
order_number = options.get('order')
|
||||||
|
|
||||||
|
if order_number:
|
||||||
|
self.stdout.write(f"Исправление Stock для заказа {order_number}...")
|
||||||
|
self.fix_for_order(order_number)
|
||||||
|
else:
|
||||||
|
self.stdout.write("Исправление всех Stock...")
|
||||||
|
self.fix_all_stock()
|
||||||
|
|
||||||
|
def fix_for_order(self, order_number):
|
||||||
|
"""Исправить Stock для конкретного заказа"""
|
||||||
|
from orders.models import Order
|
||||||
|
|
||||||
|
try:
|
||||||
|
order = Order.objects.get(order_number=order_number)
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(f"Заказ {order_number} не найден!")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Получаем все резервы для этого заказа
|
||||||
|
reservations = Reservation.objects.filter(
|
||||||
|
order_item__order=order
|
||||||
|
).values('product_id', 'warehouse_id').distinct()
|
||||||
|
|
||||||
|
fixed_count = 0
|
||||||
|
for res in reservations:
|
||||||
|
product_id = res['product_id']
|
||||||
|
warehouse_id = res['warehouse_id']
|
||||||
|
|
||||||
|
try:
|
||||||
|
stock = Stock.objects.get(
|
||||||
|
product_id=product_id,
|
||||||
|
warehouse_id=warehouse_id
|
||||||
|
)
|
||||||
|
|
||||||
|
old_reserved = stock.quantity_reserved
|
||||||
|
stock.refresh_from_batches()
|
||||||
|
new_reserved = stock.quantity_reserved
|
||||||
|
|
||||||
|
if old_reserved != new_reserved:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"✓ Обновлено: product_id={product_id}, "
|
||||||
|
f"warehouse_id={warehouse_id}, "
|
||||||
|
f"reserved: {old_reserved} → {new_reserved}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fixed_count += 1
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
f" Без изменений: product_id={product_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Stock.DoesNotExist:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"⚠ Stock не найден: product_id={product_id}, "
|
||||||
|
f"warehouse_id={warehouse_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"\n✅ Готово! Обновлено {fixed_count} Stock для заказа {order_number}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def fix_all_stock(self):
|
||||||
|
"""Пересчитать все Stock"""
|
||||||
|
stocks = Stock.objects.all()
|
||||||
|
total = stocks.count()
|
||||||
|
fixed_count = 0
|
||||||
|
|
||||||
|
self.stdout.write(f"Найдено {total} Stock записей...")
|
||||||
|
|
||||||
|
for stock in stocks:
|
||||||
|
old_reserved = stock.quantity_reserved
|
||||||
|
stock.refresh_from_batches()
|
||||||
|
new_reserved = stock.quantity_reserved
|
||||||
|
|
||||||
|
if old_reserved != new_reserved:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"✓ {stock.product.name} на {stock.warehouse.name}: "
|
||||||
|
f"reserved {old_reserved} → {new_reserved}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fixed_count += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"\n✅ Готово! Обновлено {fixed_count} из {total} Stock"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -69,16 +69,22 @@ def reserve_stock_on_order_create(sender, instance, created, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Order)
|
@receiver(post_save, sender=Order)
|
||||||
|
@transaction.atomic
|
||||||
def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||||
"""
|
"""
|
||||||
Сигнал: Когда заказ переходит в статус 'completed' (доставлен),
|
Сигнал: Когда заказ переходит в статус 'completed' (доставлен),
|
||||||
создается операция Sale и резервы преобразуются в продажу.
|
создается операция Sale и резервы преобразуются в продажу.
|
||||||
|
|
||||||
|
КРИТИЧНО: Резервы обновляются ТОЛЬКО ПОСЛЕ успешного создания Sale!
|
||||||
|
|
||||||
Процесс:
|
Процесс:
|
||||||
1. Проверяем, изменился ли статус на 'completed'
|
1. Проверяем, изменился ли статус на 'completed'
|
||||||
2. Для каждого товара создаем Sale (автоматический FIFO-список)
|
2. Для каждого товара создаем Sale (автоматический FIFO-список)
|
||||||
3. Обновляем резерв на 'converted_to_sale'
|
3. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale'
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
return # Только для обновлений
|
return # Только для обновлений
|
||||||
|
|
||||||
@@ -90,41 +96,29 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
if Sale.objects.filter(order=instance).exists():
|
if Sale.objects.filter(order=instance).exists():
|
||||||
return # Продажи уже созданы, выходим БЕЗ обновления резервов
|
return # Продажи уже созданы, выходим БЕЗ обновления резервов
|
||||||
|
|
||||||
# Обновляем резервы ТОЛЬКО если Sale ещё не созданы
|
# Проверяем наличие резервов ДО начала операции
|
||||||
# Используем update() вместо save() чтобы избежать повторного вызова сигналов
|
|
||||||
reservations_to_update = Reservation.objects.filter(
|
reservations_to_update = Reservation.objects.filter(
|
||||||
order_item__order=instance,
|
order_item__order=instance,
|
||||||
status='reserved'
|
status='reserved'
|
||||||
)
|
)
|
||||||
|
|
||||||
if reservations_to_update.exists():
|
if not reservations_to_update.exists():
|
||||||
# Массовое обновление БЕЗ вызова сигналов
|
logger.warning(
|
||||||
reservations_to_update.update(
|
f"⚠ Заказ {instance.order_number} переведён в 'completed', но нет резервов в статусе 'reserved'"
|
||||||
status='converted_to_sale',
|
|
||||||
converted_at=timezone.now()
|
|
||||||
)
|
)
|
||||||
|
# Продолжаем выполнение - возможно, это повторный вызов или резервы уже обработаны
|
||||||
# Обновляем Stock вручную, т.к. update() не вызывает сигналы
|
|
||||||
# Группируем по product + warehouse для эффективности
|
|
||||||
reservation_groups = reservations_to_update.values_list('product_id', 'warehouse_id').distinct()
|
|
||||||
|
|
||||||
for product_id, warehouse_id in reservation_groups:
|
|
||||||
try:
|
|
||||||
stock = Stock.objects.get(
|
|
||||||
product_id=product_id,
|
|
||||||
warehouse_id=warehouse_id
|
|
||||||
)
|
|
||||||
stock.refresh_from_batches()
|
|
||||||
except Stock.DoesNotExist:
|
|
||||||
pass # Stock не найден, пропускаем
|
|
||||||
|
|
||||||
# Определяем склад (используем склад самовывоза из заказа или первый активный)
|
# Определяем склад (используем склад самовывоза из заказа или первый активный)
|
||||||
warehouse = instance.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
warehouse = instance.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
||||||
|
|
||||||
if not warehouse:
|
if not warehouse:
|
||||||
|
logger.error(f"❌ Не найден склад для заказа {instance.order_number}. Списание невозможно.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Для каждого товара в заказе создаем Sale
|
# Для каждого товара в заказе создаем Sale
|
||||||
|
sales_created = []
|
||||||
|
sale_creation_failed = False
|
||||||
|
|
||||||
for item in instance.items.all():
|
for item in instance.items.all():
|
||||||
# Определяем товар
|
# Определяем товар
|
||||||
product = item.product if item.product else item.product_kit
|
product = item.product if item.product else item.product_kit
|
||||||
@@ -142,13 +136,49 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|||||||
order=instance,
|
order=instance,
|
||||||
document_number=instance.order_number
|
document_number=instance.order_number
|
||||||
)
|
)
|
||||||
|
sales_created.append(sale)
|
||||||
|
logger.info(f"✓ Sale создан для {product.name}: {item.quantity} шт.")
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Логируем ошибку, но не прерываем процесс
|
# Логируем ошибку и прерываем процесс
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Ошибка при создании Sale для заказа {instance.order_number}: {e}"
|
f"❌ ОШИБКА при создании Sale для заказа {instance.order_number}, товар {product.name}: {e}"
|
||||||
|
)
|
||||||
|
sale_creation_failed = True
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"❌ КРИТИЧЕСКАЯ ОШИБКА при создании Sale для заказа {instance.order_number}: {e}"
|
||||||
|
)
|
||||||
|
sale_creation_failed = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# КРИТИЧНО: Обновляем резервы ТОЛЬКО если ВСЕ Sale созданы успешно
|
||||||
|
if sale_creation_failed:
|
||||||
|
logger.error(
|
||||||
|
f"❌ Не удалось создать Sale для заказа {instance.order_number}. "
|
||||||
|
f"Резервы НЕ будут обновлены. Транзакция откатится."
|
||||||
|
)
|
||||||
|
# Поднимаем исключение чтобы откатить всю транзакцию
|
||||||
|
raise ValueError(f"Не удалось создать Sale для заказа {instance.order_number}")
|
||||||
|
|
||||||
|
# Все Sale созданы успешно - теперь обновляем резервы
|
||||||
|
if reservations_to_update.exists():
|
||||||
|
# Обновляем резервы через .save() чтобы сработал сигнал обновления Stock
|
||||||
|
# Сигнал update_stock_on_reservation_change автоматически обновит Stock
|
||||||
|
for reservation in reservations_to_update:
|
||||||
|
reservation.status = 'converted_to_sale'
|
||||||
|
reservation.converted_at = timezone.now()
|
||||||
|
reservation.save(update_fields=['status', 'converted_at'])
|
||||||
|
|
||||||
|
updated_count = reservations_to_update.count()
|
||||||
|
logger.info(
|
||||||
|
f"✓ Обновлено {updated_count} резервов для заказа {instance.order_number}: reserved → converted_to_sale"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"🎉 Заказ {instance.order_number} успешно обработан: создано {len(sales_created)} Sale, "
|
||||||
|
f"обновлено {reservations_to_update.count() if reservations_to_update.exists() else 0} резервов"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -700,21 +730,45 @@ def update_stock_on_reservation_change(sender, instance, created, **kwargs):
|
|||||||
"""
|
"""
|
||||||
Сигнал: При создании или изменении резерва (Reservation) обновляем Stock.
|
Сигнал: При создании или изменении резерва (Reservation) обновляем Stock.
|
||||||
|
|
||||||
|
ОПТИМИЗАЦИЯ: Обновляем Stock только если изменились поля, влияющие на резервы:
|
||||||
|
- status (reserved/converted_to_sale/released)
|
||||||
|
- quantity (количество резерва)
|
||||||
|
- created (новый резерв)
|
||||||
|
|
||||||
Процесс:
|
Процесс:
|
||||||
1. При создании или изменении резерва пересчитываем quantity_reserved
|
1. При создании резерва пересчитываем quantity_reserved
|
||||||
2. Обновляем запись Stock для этого товара
|
2. При изменении статуса или количества пересчитываем quantity_reserved
|
||||||
|
3. При других изменениях (например, дата) НЕ пересчитываем Stock (оптимизация)
|
||||||
"""
|
"""
|
||||||
if not instance.product or not instance.warehouse:
|
if not instance.product or not instance.warehouse:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Получаем или создаем Stock запись
|
# Если это создание нового резерва - всегда обновляем Stock
|
||||||
|
if created:
|
||||||
stock, _ = Stock.objects.get_or_create(
|
stock, _ = Stock.objects.get_or_create(
|
||||||
product=instance.product,
|
product=instance.product,
|
||||||
warehouse=instance.warehouse
|
warehouse=instance.warehouse
|
||||||
)
|
)
|
||||||
|
stock.refresh_from_batches()
|
||||||
|
return
|
||||||
|
|
||||||
# Пересчитываем остатки из всех активных партий и резервов
|
# Для обновления - проверяем, изменились ли поля, влияющие на Stock
|
||||||
# refresh_from_batches() уже вызывает save()
|
# Используем django-simple-history или проверяем через update_fields
|
||||||
|
update_fields = kwargs.get('update_fields', None)
|
||||||
|
|
||||||
|
# Если update_fields указаны (вызов через save(update_fields=[...]))
|
||||||
|
# проверяем, есть ли среди них 'status' или 'quantity'
|
||||||
|
if update_fields is not None:
|
||||||
|
fields_affecting_stock = {'status', 'quantity'}
|
||||||
|
if not fields_affecting_stock.intersection(update_fields):
|
||||||
|
# Изменились другие поля (например, дата) - не обновляем Stock
|
||||||
|
return
|
||||||
|
|
||||||
|
# Если дошли сюда - нужно обновить Stock
|
||||||
|
stock, _ = Stock.objects.get_or_create(
|
||||||
|
product=instance.product,
|
||||||
|
warehouse=instance.warehouse
|
||||||
|
)
|
||||||
stock.refresh_from_batches()
|
stock.refresh_from_batches()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ class TenantCreationIntegrationTest(TransactionTestCase):
|
|||||||
def test_new_tenant_gets_order_statuses(self):
|
def test_new_tenant_gets_order_statuses(self):
|
||||||
"""
|
"""
|
||||||
Тест: Новый тенант получает системные статусы заказов.
|
Тест: Новый тенант получает системные статусы заказов.
|
||||||
|
|
||||||
|
КРИТИЧЕСКИ ВАЖНО: Должен быть минимум ОДИН позитивный и ОДИН негативный финальный статус.
|
||||||
"""
|
"""
|
||||||
# Создаём и активируем тенант
|
# Создаём и активируем тенант
|
||||||
registration = TenantRegistration.objects.create(
|
registration = TenantRegistration.objects.create(
|
||||||
@@ -165,9 +167,48 @@ class TenantCreationIntegrationTest(TransactionTestCase):
|
|||||||
self.assertIsNotNone(draft_status, "Статус 'draft' не создан")
|
self.assertIsNotNone(draft_status, "Статус 'draft' не создан")
|
||||||
self.assertTrue(draft_status.is_system)
|
self.assertTrue(draft_status.is_system)
|
||||||
|
|
||||||
|
# КРИТИЧЕСКАЯ ПРОВЕРКА #1: Позитивный финальный статус
|
||||||
|
positive_statuses = OrderStatus.objects.filter(is_positive_end=True)
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
positive_statuses.count(),
|
||||||
|
1,
|
||||||
|
"Должен быть минимум ОДИН позитивный финальный статус (is_positive_end=True)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем что есть 'completed' (основной позитивный статус)
|
||||||
completed_status = OrderStatus.objects.filter(code='completed').first()
|
completed_status = OrderStatus.objects.filter(code='completed').first()
|
||||||
self.assertIsNotNone(completed_status, "Статус 'completed' не создан")
|
self.assertIsNotNone(
|
||||||
self.assertTrue(completed_status.is_system)
|
completed_status,
|
||||||
|
"Статус 'completed' не создан (основной позитивный статус)"
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
completed_status.is_system,
|
||||||
|
"'completed' должен быть системным"
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
completed_status.is_positive_end,
|
||||||
|
"'completed' должен быть позитивным финальным статусом"
|
||||||
|
)
|
||||||
|
|
||||||
|
# КРИТИЧЕСКАЯ ПРОВЕРКА #2: Негативный финальный статус
|
||||||
|
negative_statuses = OrderStatus.objects.filter(is_negative_end=True)
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
negative_statuses.count(),
|
||||||
|
1,
|
||||||
|
"Должен быть минимум ОДИН негативный финальный статус (is_negative_end=True)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем что есть 'cancelled' или другой негативный статус
|
||||||
|
# Проверяем первый найденный негативный статус
|
||||||
|
first_negative_status = negative_statuses.first()
|
||||||
|
self.assertTrue(
|
||||||
|
first_negative_status.is_system,
|
||||||
|
f"Негативный статус '{first_negative_status.code}' должен быть системным"
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
first_negative_status.is_negative_end,
|
||||||
|
f"Статус '{first_negative_status.code}' должен быть негативным финальным"
|
||||||
|
)
|
||||||
|
|
||||||
def test_new_tenant_gets_system_customer(self):
|
def test_new_tenant_gets_system_customer(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user