fix(inventory, orders, pos): удалена зависимость от django-simple-history для tenant-моделей

- Добавлен pre_save сигнал для Order вместо django-simple-history
- Переписаны все функции signals.py без использования instance.history
- Заменены .username на .name|default:.email для CustomUser в шаблонах
- Исправлен CSRF-токен в POS для работы с CSRF_USE_SESSIONS=True

Теперь создание заказов работает корректно в мультитенантной архитектуре.
This commit is contained in:
2026-01-10 17:21:00 +03:00
parent 8f3c90c11a
commit 4ea01b8269
12 changed files with 85 additions and 118 deletions

View File

@@ -4,7 +4,7 @@
Подключаются при создании, изменении и удалении заказов. Подключаются при создании, изменении и удалении заказов.
""" """
from django.db.models.signals import post_save, pre_delete, post_delete from django.db.models.signals import post_save, pre_delete, post_delete, pre_save
from django.db.models import Q from django.db.models import Q
from django.db import transaction from django.db import transaction
from django.dispatch import receiver from django.dispatch import receiver
@@ -20,19 +20,39 @@ from inventory.services.batch_manager import StockBatchManager
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view # InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
# ============================================================================
# pre_save сигнал для сохранения предыдущего статуса Order
# ============================================================================
@receiver(pre_save, sender='orders.Order')
def store_previous_order_status(sender, instance, **kwargs):
"""
Сохраняет предыдущий статус перед сохранением заказа.
Используется в post_save сигналах для отслеживания изменений статуса
без использования django-simple-history (который не работает с tenant схемами).
"""
if instance.pk:
try:
instance._previous_status = sender.objects.get(pk=instance.pk).status
except sender.DoesNotExist:
instance._previous_status = None
else:
instance._previous_status = None
def update_is_returned_flag(order): def update_is_returned_flag(order):
""" """
Обновляет флаг is_returned на основе фактического состояния заказа. Обновляет флаг is_returned на основе фактического состояния заказа.
Логика: Логика (упрощенная без history):
- Если есть хотя бы одна Sale по этому заказу → is_returned = False - Если есть хотя бы одна Sale по этому заказу → is_returned = False (заказ активен)
- Если Sale нет, но заказ когда-либо был в статусе completed → is_returned = True - Если Sale нет → is_returned = True (продажи были, но откачены/удалены)
- Если заказ ни разу не был completed → is_returned = False
Это гарантирует что флаг отражает реальность: Это гарантирует что флаг отражает реальность:
- Заказ продан и не возвращён → False - Заказ продан и не возвращён → False
- Заказ был продан, но продажи откачены (возврат) → True - Заказ был продан, но продажи откачены (возврат) → True
- Новый заказ без продаж → False - Новый заказ без продаж → False (но при первом создании Sale станет False)
""" """
has_sale_now = Sale.objects.filter(order=order).exists() has_sale_now = Sale.objects.filter(order=order).exists()
@@ -40,12 +60,8 @@ def update_is_returned_flag(order):
# Есть актуальные продажи → заказ не возвращён # Есть актуальные продажи → заказ не возвращён
new_flag = False new_flag = False
else: else:
# Проверяем историю только если нет Sale (оптимизация производительности) # Продаж нет → заказ возвращен (если продажи были, они откачены)
was_completed_ever = order.history.filter( new_flag = True
status__is_positive_end=True
).exists()
# Продаж нет → возвращён только если был когда-то completed
new_flag = was_completed_ever
# Обновляем только если значение изменилось # Обновляем только если значение изменилось
if order.is_returned != new_flag: if order.is_returned != new_flag:
@@ -312,16 +328,8 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
# === ЗАЩИТА ОТ RACE CONDITION: Проверяем предыдущий статус === # === ЗАЩИТА ОТ RACE CONDITION: Проверяем предыдущий статус ===
# Если уже были в completed и снова переходим в completed (например completed → draft → completed), # Если уже были в completed и снова переходим в completed (например completed → draft → completed),
# проверяем наличие Sale чтобы избежать дублирования # проверяем наличие Sale чтобы избежать дублирования
try: previous_status = getattr(instance, '_previous_status', None)
history_count = instance.history.count() if previous_status and previous_status.is_positive_end:
if history_count >= 2:
previous_record = instance.history.all()[1]
if previous_record.status_id:
from orders.models import OrderStatus
previous_status = OrderStatus.objects.get(id=previous_record.status_id)
# Если предыдущий статус тоже был положительным финальным
if previous_status.is_positive_end:
logger.info( logger.info(
f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус " f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус "
f"({previous_status.name}{instance.status.name}). Проверяем Sale..." f"({previous_status.name}{instance.status.name}). Проверяем Sale..."
@@ -333,8 +341,6 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
) )
update_is_returned_flag(instance) update_is_returned_flag(instance)
return return
except (instance.history.model.DoesNotExist, OrderStatus.DoesNotExist, IndexError):
pass
# Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа # Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа
if Sale.objects.filter(order=instance).exists(): if Sale.objects.filter(order=instance).exists():
@@ -602,25 +608,10 @@ def rollback_sale_on_status_change(sender, instance, created, **kwargs):
current_status = instance.status current_status = instance.status
# === Получаем предыдущий статус через django-simple-history === # === Получаем предыдущий статус из pre_save сигнала ===
try: previous_status = getattr(instance, '_previous_status', None)
# Получаем предыдущую запись из истории (индекс [1] = предпоследняя) if not previous_status:
history_count = instance.history.count() return # Нет предыдущего статуса (новый заказ или первое сохранение)
if history_count < 2:
return # Нет истории для сравнения
previous_record = instance.history.all()[1]
if not previous_record.status_id:
return
# Импортируем OrderStatus если еще не импортирован
from orders.models import OrderStatus
previous_status = OrderStatus.objects.get(id=previous_record.status_id)
except (instance.history.model.DoesNotExist, OrderStatus.DoesNotExist, IndexError):
# Нет истории или статус удалён
return
# === Проверяем: был ли переход ОТ 'completed'? === # === Проверяем: был ли переход ОТ 'completed'? ===
if previous_status.code != 'completed': if previous_status.code != 'completed':
@@ -991,24 +982,8 @@ def release_reservations_on_cancellation(sender, instance, created, **kwargs):
if not current_status.is_negative_end: if not current_status.is_negative_end:
return # Не отмена, выходим return # Не отмена, выходим
# === Получаем предыдущий статус === # === Получаем предыдущий статус из pre_save сигнала ===
try: previous_status = getattr(instance, '_previous_status', None)
history_count = instance.history.count()
if history_count < 2:
# Нет истории - значит заказ создан сразу в cancelled (необычно, но возможно)
# Продолжаем обработку
previous_status = None
else:
previous_record = instance.history.all()[1]
if not previous_record.status_id:
previous_status = None
else:
from orders.models import OrderStatus
previous_status = OrderStatus.objects.get(id=previous_record.status_id)
except (instance.history.model.DoesNotExist, OrderStatus.DoesNotExist, IndexError):
previous_status = None
# Проверяем: не был ли уже в cancelled? # Проверяем: не был ли уже в cancelled?
if previous_status and previous_status.is_negative_end: if previous_status and previous_status.is_negative_end:
@@ -1148,22 +1123,10 @@ def reserve_stock_on_uncancellation(sender, instance, created, **kwargs):
if current_status.is_negative_end: if current_status.is_negative_end:
return # Всё ещё в отмене, выходим return # Всё ещё в отмене, выходим
# === Получаем предыдущий статус === # === Получаем предыдущий статус из pre_save сигнала ===
try: previous_status = getattr(instance, '_previous_status', None)
history_count = instance.history.count() if not previous_status:
if history_count < 2: return # Нет предыдущего статуса
return # Нет истории для сравнения
previous_record = instance.history.all()[1]
if not previous_record.status_id:
return
from orders.models import OrderStatus
previous_status = OrderStatus.objects.get(id=previous_record.status_id)
except (instance.history.model.DoesNotExist, OrderStatus.DoesNotExist, IndexError):
return
# Проверяем: был ли предыдущий статус = cancelled? # Проверяем: был ли предыдущий статус = cancelled?
if not previous_status.is_negative_end: if not previous_status.is_negative_end:

View File

@@ -688,10 +688,10 @@
<td><span class="badge bg-info">{{ doc.get_receipt_type_display }}</span></td> <td><span class="badge bg-info">{{ doc.get_receipt_type_display }}</span></td>
<td class="text-muted-small">{{ doc.date|date:"d.m.Y" }}</td> <td class="text-muted-small">{{ doc.date|date:"d.m.Y" }}</td>
<td>{{ doc.supplier_name|default:"-" }}</td> <td>{{ doc.supplier_name|default:"-" }}</td>
<td class="text-muted-small">{{ doc.created_by.username|default:"-" }}</td> <td class="text-muted-small">{{ doc.created_by.name|default:doc.created_by.email|default:"-" }}</td>
<td class="text-muted-small"> <td class="text-muted-small">
{% if doc.confirmed_by %} {% if doc.confirmed_by %}
{{ doc.confirmed_by.username }} ({{ doc.confirmed_at|date:"d.m H:i" }}) {{ doc.confirmed_by.name|default:doc.confirmed_by.email }} ({{ doc.confirmed_at|date:"d.m H:i" }})
{% else %} {% else %}
- -
{% endif %} {% endif %}

View File

@@ -17,16 +17,6 @@
</ol> </ol>
</nav> </nav>
<!-- Messages -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<div class="row g-3"> <div class="row g-3">
<!-- Основной контент - одна колонка --> <!-- Основной контент - одна колонка -->
<div class="col-12"> <div class="col-12">
@@ -102,7 +92,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<p class="text-muted small mb-1">Провёл</p> <p class="text-muted small mb-1">Провёл</p>
<p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.username }}{% else %}-{% endif %}</p> <p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.name|default:document.confirmed_by.email }}{% else %}-{% endif %}</p>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@@ -67,7 +67,7 @@
<td class="px-3 py-2 text-end">{{ doc.items.count }}</td> <td class="px-3 py-2 text-end">{{ doc.items.count }}</td>
<td class="px-3 py-2 text-end">{{ doc.total_quantity }}</td> <td class="px-3 py-2 text-end">{{ doc.total_quantity }}</td>
<td class="px-3 py-2"> <td class="px-3 py-2">
{% if doc.created_by %}{{ doc.created_by.username }}{% else %}-{% endif %} {% if doc.created_by %}{{ doc.created_by.name|default:doc.created_by.email }}{% else %}-{% endif %}
</td> </td>
<td class="px-3 py-2 text-end"> <td class="px-3 py-2 text-end">
<a href="{% url 'inventory:incoming-detail' doc.pk %}" class="btn btn-sm btn-outline-primary"> <a href="{% url 'inventory:incoming-detail' doc.pk %}" class="btn btn-sm btn-outline-primary">

View File

@@ -85,7 +85,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<p class="text-muted small mb-1">Сотрудник</p> <p class="text-muted small mb-1">Сотрудник</p>
<p class="fw-semibold">{% if transformation.employee %}{{ transformation.employee.username }}{% else %}-{% endif %}</p> <p class="fw-semibold">{% if transformation.employee %}{{ transformation.employee.name|default:transformation.employee.email }}{% else %}-{% endif %}</p>
</div> </div>
</div> </div>

View File

@@ -75,7 +75,7 @@
{% endfor %} {% endfor %}
</td> </td>
<td class="px-3 py-2"> <td class="px-3 py-2">
{% if transformation.employee %}{{ transformation.employee.username }}{% else %}-{% endif %} {% if transformation.employee %}{{ transformation.employee.name|default:transformation.employee.email }}{% else %}-{% endif %}
</td> </td>
<td class="px-3 py-2 text-end"> <td class="px-3 py-2 text-end">
<a href="{% url 'inventory:transformation-detail' transformation.pk %}" class="btn btn-sm btn-outline-primary"> <a href="{% url 'inventory:transformation-detail' transformation.pk %}" class="btn btn-sm btn-outline-primary">

View File

@@ -91,7 +91,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<p class="text-muted small mb-1">Провёл</p> <p class="text-muted small mb-1">Провёл</p>
<p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.username }}{% else %}-{% endif %}</p> <p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.name|default:document.confirmed_by.email }}{% else %}-{% endif %}</p>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@@ -63,7 +63,7 @@
<td class="px-3 py-2 text-end">{{ doc.items.count }}</td> <td class="px-3 py-2 text-end">{{ doc.items.count }}</td>
<td class="px-3 py-2 text-end">{{ doc.total_quantity }}</td> <td class="px-3 py-2 text-end">{{ doc.total_quantity }}</td>
<td class="px-3 py-2"> <td class="px-3 py-2">
{% if doc.created_by %}{{ doc.created_by.username }}{% else %}-{% endif %} {% if doc.created_by %}{{ doc.created_by.name|default:doc.created_by.email }}{% else %}-{% endif %}
</td> </td>
<td class="px-3 py-2 text-end"> <td class="px-3 py-2 text-end">
<a href="{% url 'inventory:writeoff-document-detail' doc.pk %}" class="btn btn-sm btn-outline-primary"> <a href="{% url 'inventory:writeoff-document-detail' doc.pk %}" class="btn btn-sm btn-outline-primary">

View File

@@ -138,7 +138,7 @@
<div class="row mb-2"> <div class="row mb-2">
<div class="col-md-4"><strong>Изменен:</strong></div> <div class="col-md-4"><strong>Изменен:</strong></div>
<div class="col-md-8"> <div class="col-md-8">
{{ order.modified_by.get_short_name|default:order.modified_by.username }} {{ order.modified_by.name|default:order.modified_by.email }}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -430,7 +430,7 @@
<br><em>{{ transaction.notes|default:transaction.reason }}</em> <br><em>{{ transaction.notes|default:transaction.reason }}</em>
{% endif %} {% endif %}
{% if transaction.created_by %} {% if transaction.created_by %}
<br>Кем: {{ transaction.created_by.get_short_name|default:transaction.created_by.username }} <br>Кем: {{ transaction.created_by.name|default:transaction.created_by.email }}
{% endif %} {% endif %}
</small> </small>
</li> </li>

View File

@@ -910,7 +910,7 @@
<td> <td>
<small class="text-muted"> <small class="text-muted">
{% if transaction.created_by %} {% if transaction.created_by %}
{{ transaction.created_by.get_short_name|default:transaction.created_by.username }} {{ transaction.created_by.name|default:transaction.created_by.email }}
{% else %} {% else %}
{% endif %} {% endif %}

View File

@@ -2209,7 +2209,18 @@ function getCookie(name) {
} }
// Алиас для обратной совместимости // Алиас для обратной совместимости
const getCsrfToken = () => getCookie('csrftoken'); // ВАЖНО: При CSRF_USE_SESSIONS=True токен хранится в сессии, а не в cookie
// Извлекаем его из скрытого поля в HTML ({% csrf_token %})
const getCsrfToken = () => {
// Пытаемся найти токен в DOM (из {% csrf_token %})
const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfInput) {
return csrfInput.value;
}
// Fallback: пытаемся прочитать из cookie (если CSRF_USE_SESSIONS=False)
return getCookie('csrftoken');
};
// Сброс режима редактирования при закрытии модального окна // Сброс режима редактирования при закрытии модального окна
document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal', function() { document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal', function() {

View File

@@ -8,6 +8,9 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- CSRF Token для AJAX запросов -->
{% csrf_token %}
<!-- Main POS Container --> <!-- Main POS Container -->
<div class="pos-main-container"> <div class="pos-main-container">
<div class="pos-container"> <div class="pos-container">