Исправлены баги витринных комплектов: резервы и валидация восстановления заказов
This commit is contained in:
@@ -732,76 +732,102 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs):
|
|||||||
f"converted_to_sale → {reservation_target_status}"
|
f"converted_to_sale → {reservation_target_status}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Витринные временные комплекты ВСЕГДА возвращаются в reserved (остаются на витрине)
|
# Витринные временные комплекты: логика зависит от сценария
|
||||||
if showcase_count > 0:
|
if showcase_count > 0:
|
||||||
for reservation in showcase_kit_reservations:
|
if is_cancellation:
|
||||||
reservation.status = 'reserved'
|
# Сценарий Б: Отмена - возвращаем на витрину
|
||||||
# Очищаем ТОЛЬКО блокировки корзины при отмене заказа
|
for reservation in showcase_kit_reservations:
|
||||||
# НЕ трогаем order_item - он нужен для повторной продажи при возврате в completed
|
reservation.status = 'reserved'
|
||||||
reservation.cart_lock_expires_at = None
|
# КРИТИЧНО: Отвязываем резервы от заказа при отмене
|
||||||
reservation.locked_by_user = None
|
reservation.order_item = None
|
||||||
reservation.cart_session_id = None
|
# Очищаем блокировки корзины
|
||||||
# Не трогаем showcase и product_kit - они остаются привязанными
|
reservation.cart_lock_expires_at = None
|
||||||
# converted_at оставляем (для истории)
|
reservation.locked_by_user = None
|
||||||
reservation.save(update_fields=['status', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
|
reservation.cart_session_id = None
|
||||||
|
# showcase_item и product_kit остаются - букет на витрине
|
||||||
logger.info(
|
# converted_at оставляем (для истории)
|
||||||
f"✓ Обновлено {showcase_count} резервов витринных комплектов: "
|
reservation.save(update_fields=['status', 'order_item', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
|
||||||
f"converted_to_sale → reserved (возвращены на витрину, блокировки сняты, order_item сохранён)"
|
|
||||||
)
|
logger.info(
|
||||||
|
f"✓ Обновлено {showcase_count} резервов витринных комплектов: "
|
||||||
|
f"converted_to_sale → reserved (возвращены на витрину, отвязаны от заказа, блокировки сняты)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Сценарий А: Возврат к нейтральному - резервы ОСТАЮТСЯ в заказе
|
||||||
|
for reservation in showcase_kit_reservations:
|
||||||
|
reservation.status = 'reserved'
|
||||||
|
# Очищаем ТОЛЬКО блокировки корзины
|
||||||
|
# order_item НЕ ТРОГАЕМ - резерв остаётся за заказом!
|
||||||
|
reservation.cart_lock_expires_at = None
|
||||||
|
reservation.locked_by_user = None
|
||||||
|
reservation.cart_session_id = None
|
||||||
|
# converted_at оставляем (для истории)
|
||||||
|
reservation.save(update_fields=['status', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✓ Обновлено {showcase_count} резервов витринных комплектов: "
|
||||||
|
f"converted_to_sale → reserved (остаются в заказе, блокировки сняты)"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"⚠ Для заказа {instance.order_number} нет резервов в статусе 'converted_to_sale'"
|
f"⚠ Для заказа {instance.order_number} нет резервов в статусе 'converted_to_sale'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Возвращаем витринные экземпляры обратно на витрину ===
|
# === Возвращаем витринные экземпляры обратно на витрину ===
|
||||||
from inventory.models import ShowcaseItem
|
# ТОЛЬКО при отмене (отрицательный статус)!
|
||||||
|
if is_cancellation:
|
||||||
# Находим все ShowcaseItem, проданные в рамках этого заказа
|
from inventory.models import ShowcaseItem
|
||||||
showcase_items = ShowcaseItem.objects.filter(
|
|
||||||
sold_order_item__order=instance,
|
# Находим все ShowcaseItem, проданные в рамках этого заказа
|
||||||
status='sold'
|
showcase_items = ShowcaseItem.objects.filter(
|
||||||
)
|
sold_order_item__order=instance,
|
||||||
|
status='sold'
|
||||||
showcase_items_count = showcase_items.count()
|
|
||||||
|
|
||||||
if showcase_items_count > 0:
|
|
||||||
logger.info(
|
|
||||||
f"🔄 Возвращаем {showcase_items_count} витринных экземпляров обратно на витрину..."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Возвращаем каждый экземпляр на витрину
|
showcase_items_count = showcase_items.count()
|
||||||
for item in showcase_items:
|
|
||||||
item.status = 'available'
|
|
||||||
item.sold_order_item = None
|
|
||||||
item.sold_at = None
|
|
||||||
# showcase и product_kit не трогаем - букет остаётся на той же витрине
|
|
||||||
item.save(update_fields=['status', 'sold_order_item', 'sold_at', 'updated_at'])
|
|
||||||
|
|
||||||
# КРИТИЧНО: Восстанавливаем связь между ShowcaseItem и Reservation
|
|
||||||
# Находим все резервы этого комплекта для данного OrderItem
|
|
||||||
order_item = item.sold_order_item if hasattr(item, '_original_sold_order_item') else None
|
|
||||||
if not order_item:
|
|
||||||
# Пытаемся найти через заказ и product_kit
|
|
||||||
order_items = instance.items.filter(product_kit=item.product_kit)
|
|
||||||
if order_items.exists():
|
|
||||||
order_item = order_items.first()
|
|
||||||
|
|
||||||
if order_item:
|
|
||||||
# Восстанавливаем связь showcase_item в резервах
|
|
||||||
reservations_updated = Reservation.objects.filter(
|
|
||||||
order_item=order_item,
|
|
||||||
product_kit=item.product_kit,
|
|
||||||
status='reserved'
|
|
||||||
).update(showcase_item=item)
|
|
||||||
|
|
||||||
if reservations_updated > 0:
|
|
||||||
logger.debug(
|
|
||||||
f" ✅ Восстановлена связь для {reservations_updated} резервов ShowcaseItem #{item.id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if showcase_items_count > 0:
|
||||||
|
logger.info(
|
||||||
|
f"🔄 Возвращаем {showcase_items_count} витринных экземпляров обратно на витрину..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Возвращаем каждый экземпляр на витрину
|
||||||
|
for item in showcase_items:
|
||||||
|
item.status = 'available'
|
||||||
|
item.sold_order_item = None
|
||||||
|
item.sold_at = None
|
||||||
|
# showcase и product_kit не трогаем - букет остаётся на той же витрине
|
||||||
|
item.save(update_fields=['status', 'sold_order_item', 'sold_at', 'updated_at'])
|
||||||
|
|
||||||
|
# КРИТИЧНО: Восстанавливаем связь между ShowcaseItem и Reservation
|
||||||
|
# Находим все резервы этого комплекта для данного OrderItem
|
||||||
|
order_item = item.sold_order_item if hasattr(item, '_original_sold_order_item') else None
|
||||||
|
if not order_item:
|
||||||
|
# Пытаемся найти через заказ и product_kit
|
||||||
|
order_items = instance.items.filter(product_kit=item.product_kit)
|
||||||
|
if order_items.exists():
|
||||||
|
order_item = order_items.first()
|
||||||
|
|
||||||
|
if order_item:
|
||||||
|
# Восстанавливаем связь showcase_item в резервах
|
||||||
|
reservations_updated = Reservation.objects.filter(
|
||||||
|
order_item=order_item,
|
||||||
|
product_kit=item.product_kit,
|
||||||
|
status='reserved'
|
||||||
|
).update(showcase_item=item)
|
||||||
|
|
||||||
|
if reservations_updated > 0:
|
||||||
|
logger.debug(
|
||||||
|
f" ✅ Восстановлена связь для {reservations_updated} резервов ShowcaseItem #{item.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✅ {showcase_items_count} витринных экземпляров вернулись на витрину: sold → available со связью с резервами"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Сценарий А: Возврат к нейтральному - ShowcaseItem ОСТАЁТСЯ sold
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ {showcase_items_count} витринных экземпляров вернулись на витрину: sold → available со связью с резервами"
|
f"ℹ️ Сценарий А: Витринные экземпляры остаются в статусе 'sold' (заказ в нейтральном статусе)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Обновляем is_returned ===
|
# === Обновляем is_returned ===
|
||||||
@@ -916,15 +942,16 @@ def release_reservations_on_cancellation(sender, instance, created, **kwargs):
|
|||||||
|
|
||||||
if showcase_count > 0:
|
if showcase_count > 0:
|
||||||
# Для витринных комплектов очищаем блокировки корзины
|
# Для витринных комплектов очищаем блокировки корзины
|
||||||
# НЕ трогаем order_item - он нужен для повторной продажи если статус вернётся в completed
|
# КРИТИЧНО: Отвязываем резервы от заказа при отмене
|
||||||
for reservation in showcase_kit_reservations:
|
for reservation in showcase_kit_reservations:
|
||||||
|
reservation.order_item = None
|
||||||
reservation.cart_lock_expires_at = None
|
reservation.cart_lock_expires_at = None
|
||||||
reservation.locked_by_user = None
|
reservation.locked_by_user = None
|
||||||
reservation.cart_session_id = None
|
reservation.cart_session_id = None
|
||||||
reservation.save(update_fields=['cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
|
reservation.save(update_fields=['order_item', 'cart_lock_expires_at', 'locked_by_user', 'cart_session_id'])
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"ℹ️ Найдено {showcase_count} резервов витринных комплектов - остаются в reserved (на витрине, блокировки сняты, order_item сохранён)"
|
f"ℹ️ Найдено {showcase_count} резервов витринных комплектов - остаются в reserved (на витрине, отвязаны от заказа, блокировки сняты)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if normal_count == 0 and showcase_count == 0:
|
if normal_count == 0 and showcase_count == 0:
|
||||||
@@ -996,7 +1023,7 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs):
|
|||||||
return # Не было перехода от cancelled, выходим
|
return # Не было перехода от cancelled, выходим
|
||||||
|
|
||||||
# === Резервируем товар заново ===
|
# === Резервируем товар заново ===
|
||||||
# Ищем резервы в статусе 'released'
|
# Ищем резервы в статусе 'released' (обычные резервы)
|
||||||
reservations = Reservation.objects.filter(
|
reservations = Reservation.objects.filter(
|
||||||
order_item__order=instance,
|
order_item__order=instance,
|
||||||
status='released'
|
status='released'
|
||||||
@@ -1023,6 +1050,52 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs):
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"ℹ️ Для заказа {instance.order_number} нет резервов в статусе 'released'"
|
f"ℹ️ Для заказа {instance.order_number} нет резервов в статусе 'released'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# === Привязываем витринные резервы обратно к заказу ===
|
||||||
|
# Витринные резервы остаются в статусе 'reserved' при отмене,
|
||||||
|
# но отвязываются от order_item. При возврате нужно привязать их обратно.
|
||||||
|
|
||||||
|
# Находим все OrderItem витринных комплектов в этом заказе
|
||||||
|
showcase_order_items = instance.items.filter(
|
||||||
|
product_kit__is_temporary=True,
|
||||||
|
product_kit__showcase__isnull=False
|
||||||
|
).select_related('product_kit')
|
||||||
|
|
||||||
|
showcase_items_count = showcase_order_items.count()
|
||||||
|
|
||||||
|
if showcase_items_count > 0:
|
||||||
|
logger.info(
|
||||||
|
f"🔄 Найдено {showcase_items_count} витринных комплектов в заказе. "
|
||||||
|
f"Привязываем резервы обратно к заказу..."
|
||||||
|
)
|
||||||
|
|
||||||
|
for order_item in showcase_order_items:
|
||||||
|
kit = order_item.product_kit
|
||||||
|
|
||||||
|
# Находим витринные резервы для этого комплекта
|
||||||
|
# (они в статусе 'reserved', но order_item=None)
|
||||||
|
showcase_reservations = Reservation.objects.filter(
|
||||||
|
product_kit=kit,
|
||||||
|
showcase__isnull=False,
|
||||||
|
status='reserved',
|
||||||
|
order_item__isnull=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if showcase_reservations.exists():
|
||||||
|
# Привязываем резервы обратно к OrderItem
|
||||||
|
updated_count = showcase_reservations.update(order_item=order_item)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f" ✅ Витринный комплект '{kit.name}': привязано {updated_count} резервов к OrderItem #{order_item.id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f" ⚠ Витринный комплект '{kit.name}': не найдено витринных резервов без order_item"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✅ Обработано {showcase_items_count} витринных комплектов"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=Order)
|
@receiver(pre_delete, sender=Order)
|
||||||
|
|||||||
@@ -292,6 +292,7 @@
|
|||||||
<th>Единица продажи</th>
|
<th>Единица продажи</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>Заказ</th>
|
<th>Заказ</th>
|
||||||
|
<th>Витрина</th>
|
||||||
<th>Создан</th>
|
<th>Создан</th>
|
||||||
<th>Преобразован</th>
|
<th>Преобразован</th>
|
||||||
<th>Освобожден</th>
|
<th>Освобожден</th>
|
||||||
@@ -341,6 +342,15 @@
|
|||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if res.showcase %}
|
||||||
|
<span class="badge bg-warning text-dark" title="Витрина: {{ res.showcase.name }}">🌺 {{ res.showcase.name }}</span>
|
||||||
|
{% elif res.showcase_item %}
|
||||||
|
<span class="badge bg-warning text-dark" title="Витрина: {{ res.showcase_item.showcase.name }}">🌺 {{ res.showcase_item.showcase.name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="text-muted-small">{{ res.reserved_at|date:"d.m.Y H:i:s" }}</td>
|
<td class="text-muted-small">{{ res.reserved_at|date:"d.m.Y H:i:s" }}</td>
|
||||||
<td class="text-muted-small">
|
<td class="text-muted-small">
|
||||||
{% if res.converted_at %}{{ res.converted_at|date:"d.m.Y H:i:s" }}{% else %}-{% endif %}
|
{% if res.converted_at %}{{ res.converted_at|date:"d.m.Y H:i:s" }}{% else %}-{% endif %}
|
||||||
@@ -350,7 +360,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="11" class="text-center text-muted">Нет резервов</td></tr>
|
<tr><td colspan="12" class="text-center text-muted">Нет резервов</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ class Order(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
# Генерируем уникальный номер заказа при создании (начиная с 100 для 3-значного поиска)
|
# Генерируем уникальный номер заказа при создании (начиная с 100 для 3-значного поиска)
|
||||||
if not self.order_number:
|
if not self.order_number:
|
||||||
last_order = Order.objects.order_by('-order_number').first()
|
last_order = Order.objects.order_by('-order_number').first()
|
||||||
@@ -185,6 +187,81 @@ class Order(models.Model):
|
|||||||
self.order_number = max(last_order.order_number + 1, 100)
|
self.order_number = max(last_order.order_number + 1, 100)
|
||||||
else:
|
else:
|
||||||
self.order_number = 100
|
self.order_number = 100
|
||||||
|
|
||||||
|
# === ВАЛИДАЦИЯ: Проверяем доступность витринных комплектов ===
|
||||||
|
# При переходе ИЗ cancelled к любому не-отменённому статусу
|
||||||
|
if self.pk: # Только при редактировании
|
||||||
|
try:
|
||||||
|
# Получаем старый статус из БД
|
||||||
|
old_instance = Order.objects.get(pk=self.pk)
|
||||||
|
old_status = old_instance.status
|
||||||
|
new_status = self.status
|
||||||
|
|
||||||
|
# Проверяем: переход от cancelled к не-cancelled?
|
||||||
|
if (old_status and old_status.is_negative_end and
|
||||||
|
new_status and not new_status.is_negative_end):
|
||||||
|
|
||||||
|
# Находим все витринные комплекты в этом заказе
|
||||||
|
from orders.models import OrderItem
|
||||||
|
showcase_items = OrderItem.objects.filter(
|
||||||
|
order=self,
|
||||||
|
product_kit__is_temporary=True,
|
||||||
|
product_kit__showcase__isnull=False
|
||||||
|
).select_related('product_kit')
|
||||||
|
|
||||||
|
if showcase_items.exists():
|
||||||
|
# Проверяем доступность резервов для каждого комплекта
|
||||||
|
from inventory.models import Reservation
|
||||||
|
unavailable_kits = []
|
||||||
|
|
||||||
|
for item in showcase_items:
|
||||||
|
kit = item.product_kit
|
||||||
|
|
||||||
|
# КРИТИЧНО: Ищем ВСЕ витринные резервы этого комплекта
|
||||||
|
# Проверяем не привязаны ли они к ДРУГОМУ заказу (в ЛЮБОМ статусе)
|
||||||
|
occupied_reservations = Reservation.objects.filter(
|
||||||
|
product_kit=kit,
|
||||||
|
showcase__isnull=False,
|
||||||
|
order_item__isnull=False # Привязаны к какому-то заказу
|
||||||
|
).exclude(
|
||||||
|
order_item__order=self # Исключаем текущий заказ
|
||||||
|
).select_related('order_item__order')
|
||||||
|
|
||||||
|
if occupied_reservations.exists():
|
||||||
|
# Резервы заняты другим заказом - блокируем переход
|
||||||
|
occupied_res = occupied_reservations.first()
|
||||||
|
other_order_number = occupied_res.order_item.order.order_number
|
||||||
|
other_order_status = occupied_res.order_item.order.status.name if occupied_res.order_item.order.status else 'неизвестен'
|
||||||
|
|
||||||
|
unavailable_kits.append(
|
||||||
|
f"Витринный комплект '{kit.name}' занят заказом #{other_order_number} (статус: {other_order_status})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Проверяем что вообще есть резервы для этого комплекта
|
||||||
|
any_reservations = Reservation.objects.filter(
|
||||||
|
product_kit=kit,
|
||||||
|
showcase__isnull=False
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if not any_reservations:
|
||||||
|
# Комплект демонтирован или удалён
|
||||||
|
unavailable_kits.append(
|
||||||
|
f"Витринный комплект '{kit.name}' больше не существует на витрине"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Если есть недоступные комплекты - блокируем переход
|
||||||
|
if unavailable_kits:
|
||||||
|
error_message = (
|
||||||
|
f"Невозможно восстановить заказ #{self.order_number}. "
|
||||||
|
f"Витринные комплекты уже проданы:\n\n" +
|
||||||
|
"\n".join(f"\u2022 {msg}" for msg in unavailable_kits) +
|
||||||
|
f"\n\nОтмените сначала соответствующие заказы или удалите эти позиции из заказа."
|
||||||
|
)
|
||||||
|
raise ValidationError(error_message)
|
||||||
|
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
# Заказ ещё не создан в БД (не должно произойти, но на всякий случай)
|
||||||
|
pass
|
||||||
|
|
||||||
# Оборачиваем в транзакцию чтобы ValidationError в сигналах откатывал save()
|
# Оборачиваем в транзакцию чтобы ValidationError в сигналах откатывал save()
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
|||||||
Reference in New Issue
Block a user