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:
@@ -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:
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user