Compare commits
10 Commits
80260c8a34
...
f911a57640
| Author | SHA1 | Date | |
|---|---|---|---|
| f911a57640 | |||
| 94ddb0424b | |||
| 82ed5a409e | |||
| da5d4001b5 | |||
| c62cdb0298 | |||
| 5ead7fdd2e | |||
| 0653ec0545 | |||
| 08e8409a66 | |||
| 3b4785e2ad | |||
| 5df182e030 |
18
myproject/create_wallet_payment_method.py
Normal file
18
myproject/create_wallet_payment_method.py
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Скрипт для создания способа оплаты 'account_balance' для тенанта buba
|
||||
"""
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from django.core.management import call_command
|
||||
from django_tenants.utils import schema_context
|
||||
|
||||
# Создаём способ оплаты для тенанта buba
|
||||
with schema_context('buba'):
|
||||
call_command('create_payment_methods')
|
||||
print("\n✓ Способ оплаты успешно создан для тенанта 'buba'")
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.db import models
|
||||
from .models import Customer
|
||||
from django.utils.html import format_html
|
||||
from .models import Customer, WalletTransaction
|
||||
|
||||
|
||||
class IsSystemCustomerFilter(admin.SimpleListFilter):
|
||||
@@ -28,6 +29,7 @@ class CustomerAdmin(admin.ModelAdmin):
|
||||
'full_name',
|
||||
'email',
|
||||
'phone',
|
||||
'wallet_balance_display',
|
||||
'total_spent',
|
||||
'is_system_customer',
|
||||
'created_at'
|
||||
@@ -43,12 +45,15 @@ class CustomerAdmin(admin.ModelAdmin):
|
||||
)
|
||||
date_hierarchy = 'created_at'
|
||||
ordering = ('-created_at',)
|
||||
readonly_fields = ('created_at', 'updated_at', 'total_spent', 'is_system_customer')
|
||||
readonly_fields = ('created_at', 'updated_at', 'total_spent', 'is_system_customer', 'wallet_balance')
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': ('name', 'email', 'phone', 'is_system_customer')
|
||||
}),
|
||||
('Кошелёк', {
|
||||
'fields': ('wallet_balance',),
|
||||
}),
|
||||
('Статистика покупок', {
|
||||
'fields': ('total_spent',),
|
||||
'classes': ('collapse',)
|
||||
@@ -62,11 +67,22 @@ class CustomerAdmin(admin.ModelAdmin):
|
||||
}),
|
||||
)
|
||||
|
||||
def wallet_balance_display(self, obj):
|
||||
"""Отображение баланса кошелька с цветом"""
|
||||
if obj.wallet_balance > 0:
|
||||
return format_html(
|
||||
'<span style="color: green; font-weight: bold;">{} руб.</span>',
|
||||
obj.wallet_balance
|
||||
)
|
||||
return f'{obj.wallet_balance} руб.'
|
||||
wallet_balance_display.short_description = 'Баланс кошелька'
|
||||
wallet_balance_display.admin_order_field = 'wallet_balance'
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Делаем все поля read-only для системного клиента"""
|
||||
if obj and obj.is_system_customer:
|
||||
# Для системного клиента все поля только для чтения
|
||||
return ['name', 'email', 'phone', 'total_spent', 'is_system_customer', 'notes', 'created_at', 'updated_at']
|
||||
return ['name', 'email', 'phone', 'total_spent', 'is_system_customer', 'wallet_balance', 'notes', 'created_at', 'updated_at']
|
||||
return self.readonly_fields
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
@@ -85,3 +101,56 @@ class CustomerAdmin(admin.ModelAdmin):
|
||||
from django.contrib import messages
|
||||
messages.warning(request, 'Это системный клиент. Редактирование запрещено для обеспечения корректной работы системы.')
|
||||
return super().changeform_view(request, object_id, form_url, extra_context)
|
||||
|
||||
|
||||
class WalletTransactionInline(admin.TabularInline):
|
||||
"""
|
||||
line для отображения транзакций кошелька"""
|
||||
model = WalletTransaction
|
||||
extra = 0
|
||||
can_delete = False
|
||||
readonly_fields = ('transaction_type', 'amount', 'order', 'description', 'created_at', 'created_by')
|
||||
fields = ('created_at', 'transaction_type', 'amount', 'order', 'description', 'created_by')
|
||||
ordering = ('-created_at',)
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
"""Запрещаем ручное создание транзакций - только через сервис"""
|
||||
return False
|
||||
|
||||
|
||||
# Добавляем inline в CustomerAdmin
|
||||
CustomerAdmin.inlines = [WalletTransactionInline]
|
||||
|
||||
|
||||
@admin.register(WalletTransaction)
|
||||
class WalletTransactionAdmin(admin.ModelAdmin):
|
||||
"""Админка для просмотра всех транзакций кошелька"""
|
||||
list_display = ('created_at', 'customer', 'transaction_type', 'amount_display', 'order', 'created_by')
|
||||
list_filter = ('transaction_type', 'created_at')
|
||||
search_fields = ('customer__name', 'customer__email', 'customer__phone', 'description')
|
||||
readonly_fields = ('customer', 'transaction_type', 'amount', 'order', 'description', 'created_at', 'created_by')
|
||||
date_hierarchy = 'created_at'
|
||||
ordering = ('-created_at',)
|
||||
|
||||
def amount_display(self, obj):
|
||||
"""Отображение суммы с цветом"""
|
||||
if obj.transaction_type == 'deposit':
|
||||
return format_html(
|
||||
'<span style="color: green; font-weight: bold;">+{} руб.</span>',
|
||||
obj.amount
|
||||
)
|
||||
elif obj.transaction_type == 'spend':
|
||||
return format_html(
|
||||
'<span style="color: red; font-weight: bold;">-{} руб.</span>',
|
||||
obj.amount
|
||||
)
|
||||
return f'{obj.amount} руб.'
|
||||
amount_display.short_description = 'Сумма'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Запрещаем ручное создание - только через сервис"""
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Запрещаем удаление - аудит должен быть неизменяем"""
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-26 11:34
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customers', '0003_remove_customer_customers_c_loyalty_5162a0_idx_and_more'),
|
||||
('orders', '0004_refactor_models_and_add_payment_method'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customer',
|
||||
name='wallet_balance',
|
||||
field=models.DecimalField(decimal_places=2, default=0, help_text='Остаток переплат клиента, доступный для оплаты заказов', max_digits=10, verbose_name='Баланс кошелька'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WalletTransaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма')),
|
||||
('transaction_type', models.CharField(choices=[('deposit', 'Пополнение'), ('spend', 'Списание'), ('adjustment', 'Корректировка')], max_length=20, verbose_name='Тип транзакции')),
|
||||
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Создано пользователем')),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='wallet_transactions', to='customers.customer', verbose_name='Клиент')),
|
||||
('order', models.ForeignKey(blank=True, help_text='Заказ, к которому относится транзакция (если применимо)', null=True, on_delete=django.db.models.deletion.PROTECT, to='orders.order', verbose_name='Заказ')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Транзакция кошелька',
|
||||
'verbose_name_plural': 'Транзакции кошелька',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['customer', '-created_at'], name='customers_w_custome_572f05_idx'), models.Index(fields=['transaction_type'], name='customers_w_transac_5fda02_idx'), models.Index(fields=['order'], name='customers_w_order_i_5e2527_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -32,6 +32,15 @@ class Customer(models.Model):
|
||||
verbose_name="Общая сумма покупок"
|
||||
)
|
||||
|
||||
# Wallet balance for overpayments
|
||||
wallet_balance = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Баланс кошелька",
|
||||
help_text="Остаток переплат клиента, доступный для оплаты заказов"
|
||||
)
|
||||
|
||||
# System customer flag
|
||||
is_system_customer = models.BooleanField(
|
||||
default=False,
|
||||
@@ -207,3 +216,117 @@ class Customer(models.Model):
|
||||
)
|
||||
return customer, created
|
||||
|
||||
# Методы-обёртки для работы с кошельком (вся логика в WalletService)
|
||||
def pay_from_wallet(self, order, amount, user):
|
||||
"""
|
||||
Оплатить заказ из кошелька клиента.
|
||||
Обёртка над WalletService.pay_with_wallet.
|
||||
|
||||
Args:
|
||||
order: Заказ для оплаты
|
||||
amount: Сумма к списанию
|
||||
user: Пользователь, инициирующий операцию
|
||||
|
||||
Returns:
|
||||
Decimal: Фактически списанная сумма или None
|
||||
"""
|
||||
from customers.services.wallet_service import WalletService
|
||||
return WalletService.pay_with_wallet(order, amount, user)
|
||||
|
||||
def adjust_wallet(self, amount, description, user):
|
||||
"""
|
||||
Корректировка баланса кошелька (для админа).
|
||||
Обёртка над WalletService.adjust_balance.
|
||||
|
||||
Args:
|
||||
amount: Сумма корректировки (может быть отрицательной)
|
||||
description: Обязательное описание причины
|
||||
user: Пользователь, выполняющий корректировку
|
||||
|
||||
Returns:
|
||||
WalletTransaction: Созданная транзакция
|
||||
"""
|
||||
from customers.services.wallet_service import WalletService
|
||||
return WalletService.adjust_balance(self.pk, amount, description, user)
|
||||
|
||||
@property
|
||||
def wallet_transactions_history(self):
|
||||
"""
|
||||
История транзакций кошелька клиента.
|
||||
|
||||
Returns:
|
||||
QuerySet: WalletTransaction для этого клиента
|
||||
"""
|
||||
return self.wallet_transactions.all()
|
||||
|
||||
|
||||
class WalletTransaction(models.Model):
|
||||
"""
|
||||
Транзакция по кошельку клиента.
|
||||
Хранит историю всех пополнений, списаний и корректировок баланса.
|
||||
"""
|
||||
TRANSACTION_TYPE_CHOICES = [
|
||||
('deposit', 'Пополнение'),
|
||||
('spend', 'Списание'),
|
||||
('adjustment', 'Корректировка'),
|
||||
]
|
||||
|
||||
customer = models.ForeignKey(
|
||||
'Customer',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='wallet_transactions',
|
||||
verbose_name="Клиент"
|
||||
)
|
||||
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
verbose_name="Сумма"
|
||||
)
|
||||
|
||||
transaction_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=TRANSACTION_TYPE_CHOICES,
|
||||
verbose_name="Тип транзакции"
|
||||
)
|
||||
|
||||
order = models.ForeignKey(
|
||||
'orders.Order',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name="Заказ",
|
||||
help_text="Заказ, к которому относится транзакция (если применимо)"
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Описание"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата создания"
|
||||
)
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
'accounts.CustomUser',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name="Создано пользователем"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Транзакция кошелька"
|
||||
verbose_name_plural = "Транзакции кошелька"
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['customer', '-created_at']),
|
||||
models.Index(fields=['transaction_type']),
|
||||
models.Index(fields=['order']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_transaction_type_display()} {self.amount} руб. для {self.customer}"
|
||||
|
||||
|
||||
3
myproject/customers/services/__init__.py
Normal file
3
myproject/customers/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Сервисы для работы с клиентами.
|
||||
"""
|
||||
193
myproject/customers/services/wallet_service.py
Normal file
193
myproject/customers/services/wallet_service.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Сервис для работы с кошельком клиента.
|
||||
Обрабатывает пополнения, списания и корректировки баланса.
|
||||
"""
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from django.db import transaction
|
||||
|
||||
|
||||
# Константа для округления до 2 знаков
|
||||
QUANTIZE_2D = Decimal('0.01')
|
||||
|
||||
|
||||
def _quantize(value):
|
||||
"""Округление до 2 знаков после запятой"""
|
||||
if isinstance(value, (int, float)):
|
||||
value = Decimal(str(value))
|
||||
return value.quantize(QUANTIZE_2D, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
class WalletService:
|
||||
"""
|
||||
Сервис для управления кошельком клиента.
|
||||
Все операции атомарны и блокируют запись клиента для избежания race conditions.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def add_overpayment(order, user):
|
||||
"""
|
||||
Обработка переплаты по заказу.
|
||||
Переносит излишек в кошелёк клиента и нормализует amount_paid заказа.
|
||||
|
||||
Args:
|
||||
order: Заказ с переплатой
|
||||
user: Пользователь, инициировавший операцию
|
||||
|
||||
Returns:
|
||||
Decimal: Сумма переплаты или None, если переплаты нет
|
||||
"""
|
||||
from customers.models import Customer, WalletTransaction
|
||||
|
||||
overpayment = order.amount_paid - order.total_amount
|
||||
if overpayment <= 0:
|
||||
return None
|
||||
|
||||
# Блокируем запись клиента для обновления
|
||||
customer = Customer.objects.select_for_update().get(pk=order.customer_id)
|
||||
|
||||
# Округляем переплату до 2 знаков
|
||||
overpayment = _quantize(overpayment)
|
||||
|
||||
# Увеличиваем баланс кошелька
|
||||
customer.wallet_balance = _quantize(customer.wallet_balance + overpayment)
|
||||
customer.save(update_fields=['wallet_balance'])
|
||||
|
||||
# Создаём транзакцию для аудита
|
||||
WalletTransaction.objects.create(
|
||||
customer=customer,
|
||||
amount=overpayment,
|
||||
transaction_type='deposit',
|
||||
order=order,
|
||||
description=f'Переплата по заказу #{order.order_number}',
|
||||
created_by=user
|
||||
)
|
||||
|
||||
# Нормализуем amount_paid заказа до total_amount
|
||||
order.amount_paid = order.total_amount
|
||||
order.save(update_fields=['amount_paid'])
|
||||
|
||||
return overpayment
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def pay_with_wallet(order, amount, user):
|
||||
"""
|
||||
Оплата заказа из кошелька клиента.
|
||||
Списывает средства с кошелька и создаёт платёж в заказе.
|
||||
|
||||
Args:
|
||||
order: Заказ для оплаты
|
||||
amount: Запрашиваемая сумма для списания
|
||||
user: Пользователь, инициировавший операцию
|
||||
|
||||
Returns:
|
||||
Decimal: Фактически списанная сумма или None
|
||||
"""
|
||||
from customers.models import Customer, WalletTransaction
|
||||
from orders.models import Payment, PaymentMethod
|
||||
|
||||
# Округляем запрошенную сумму
|
||||
amount = _quantize(amount)
|
||||
if amount <= 0:
|
||||
return None
|
||||
|
||||
# Блокируем запись клиента
|
||||
customer = Customer.objects.select_for_update().get(pk=order.customer_id)
|
||||
|
||||
# Остаток к оплате по заказу
|
||||
amount_due = order.total_amount - order.amount_paid
|
||||
|
||||
# Определяем фактическую сумму списания (минимум из трёх)
|
||||
usable_amount = min(amount, customer.wallet_balance, amount_due)
|
||||
usable_amount = _quantize(usable_amount)
|
||||
|
||||
if usable_amount <= 0:
|
||||
return None
|
||||
|
||||
# Получаем способ оплаты "С баланса счёта"
|
||||
try:
|
||||
payment_method = PaymentMethod.objects.get(code='account_balance')
|
||||
except PaymentMethod.DoesNotExist:
|
||||
raise ValueError(
|
||||
'Способ оплаты "account_balance" не найден. '
|
||||
'Запустите команду create_payment_methods.'
|
||||
)
|
||||
|
||||
# Создаём платёж в заказе
|
||||
Payment.objects.create(
|
||||
order=order,
|
||||
amount=usable_amount,
|
||||
payment_method=payment_method,
|
||||
created_by=user,
|
||||
notes='Оплата из кошелька клиента'
|
||||
)
|
||||
|
||||
# Уменьшаем баланс кошелька
|
||||
customer.wallet_balance = _quantize(customer.wallet_balance - usable_amount)
|
||||
customer.save(update_fields=['wallet_balance'])
|
||||
|
||||
# Создаём транзакцию для аудита
|
||||
WalletTransaction.objects.create(
|
||||
customer=customer,
|
||||
amount=usable_amount,
|
||||
transaction_type='spend',
|
||||
order=order,
|
||||
description=f'Оплата заказа #{order.order_number} из кошелька',
|
||||
created_by=user
|
||||
)
|
||||
|
||||
return usable_amount
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def adjust_balance(customer_id, amount, description, user):
|
||||
"""
|
||||
Корректировка баланса кошелька администратором.
|
||||
Может быть как положительной (пополнение), так и отрицательной (списание).
|
||||
|
||||
Args:
|
||||
customer_id: ID клиента
|
||||
amount: Сумма корректировки (может быть отрицательной)
|
||||
description: Обязательное описание причины корректировки
|
||||
user: Пользователь, выполнивший корректировку
|
||||
|
||||
Returns:
|
||||
WalletTransaction: Созданная транзакция
|
||||
"""
|
||||
from customers.models import Customer, WalletTransaction
|
||||
|
||||
if not description or not description.strip():
|
||||
raise ValueError('Описание обязательно для корректировки баланса')
|
||||
|
||||
amount = _quantize(amount)
|
||||
if amount == 0:
|
||||
raise ValueError('Сумма корректировки не может быть нулевой')
|
||||
|
||||
# Блокируем запись клиента
|
||||
customer = Customer.objects.select_for_update().get(pk=customer_id)
|
||||
|
||||
# Применяем корректировку
|
||||
new_balance = _quantize(customer.wallet_balance + amount)
|
||||
|
||||
# Проверяем, что баланс не уйдёт в минус
|
||||
if new_balance < 0:
|
||||
raise ValueError(
|
||||
f'Корректировка приведёт к отрицательному балансу '
|
||||
f'({new_balance} руб.). Операция отклонена.'
|
||||
)
|
||||
|
||||
customer.wallet_balance = new_balance
|
||||
customer.save(update_fields=['wallet_balance'])
|
||||
|
||||
# Создаём транзакцию
|
||||
txn = WalletTransaction.objects.create(
|
||||
customer=customer,
|
||||
amount=abs(amount),
|
||||
transaction_type='adjustment',
|
||||
order=None,
|
||||
description=description,
|
||||
created_by=user
|
||||
)
|
||||
|
||||
return txn
|
||||
@@ -42,6 +42,29 @@
|
||||
<th>Сумма покупок:</th>
|
||||
<td>{{ customer.total_spent|floatformat:2 }} руб.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Баланс кошелька:</th>
|
||||
<td>
|
||||
{% if customer.wallet_balance > 0 %}
|
||||
<span class="badge bg-success" style="font-size: 1.1em;">{{ customer.wallet_balance|floatformat:2 }} руб.</span>
|
||||
{% elif customer.wallet_balance == 0 %}
|
||||
<span class="badge bg-secondary">{{ customer.wallet_balance|floatformat:2 }} руб.</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">{{ customer.wallet_balance|floatformat:2 }} руб.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Общий долг по активным заказам:</th>
|
||||
<td>
|
||||
{% if total_debt > 0 %}
|
||||
<span class="text-danger fw-bold">{{ total_debt|floatformat:2 }} руб.</span>
|
||||
<small class="text-muted">(Кол-во заказов: {{ active_orders_count }})</small>
|
||||
{% else %}
|
||||
<span class="text-success">0.00 руб.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Заметки:</th>
|
||||
<td>{{ customer.notes|default:"Нет" }}</td>
|
||||
@@ -58,6 +81,211 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- История транзакций кошелька -->
|
||||
<div class="col-md-12">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5>История кошелька (последние 20)</h5>
|
||||
<span class="badge bg-primary">{{ wallet_transactions|length }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if wallet_transactions %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Тип</th>
|
||||
<th>Сумма</th>
|
||||
<th>Описание</th>
|
||||
<th>Заказ</th>
|
||||
<th>Создал</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for transaction in wallet_transactions %}
|
||||
<tr>
|
||||
<td><small>{{ transaction.created_at|date:"d.m.Y H:i" }}</small></td>
|
||||
<td>
|
||||
{% if transaction.transaction_type == 'deposit' %}
|
||||
<span class="badge bg-success">Пополнение</span>
|
||||
{% elif transaction.transaction_type == 'spend' %}
|
||||
<span class="badge bg-danger">Списание</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Корректировка</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if transaction.transaction_type == 'deposit' or transaction.transaction_type == 'adjustment' and transaction.amount > 0 %}
|
||||
<span class="text-success fw-bold">+{{ transaction.amount|floatformat:2 }} руб.</span>
|
||||
{% else %}
|
||||
<span class="text-danger fw-bold">-{{ transaction.amount|floatformat:2 }} руб.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ transaction.description|default:"-" }}</td>
|
||||
<td>
|
||||
{% if transaction.order %}
|
||||
<a href="{% url 'orders:order-detail' transaction.order.order_number %}" class="btn btn-sm btn-outline-primary">
|
||||
#{{ transaction.order.order_number }}
|
||||
</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><small>{{ transaction.created_by.username|default:"-" }}</small></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">История транзакций пуста.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- История заказов -->
|
||||
<div class="col-md-12">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5>История заказов</h5>
|
||||
<div>
|
||||
<span class="badge bg-primary">{{ orders_page.paginator.count }}</span>
|
||||
<a href="{% url 'orders:order-create' %}?customer={{ customer.pk }}" class="btn btn-sm btn-success ms-2">
|
||||
<i class="bi bi-plus-circle"></i> Новый заказ
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if orders_page %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№</th>
|
||||
<th>Дата создания</th>
|
||||
<th>Дата доставки</th>
|
||||
<th>Статус</th>
|
||||
<th>Оплата</th>
|
||||
<th>Сумма</th>
|
||||
<th>Оплачено</th>
|
||||
<th>Остаток</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in orders_page %}
|
||||
<tr>
|
||||
<td><strong>#{{ order.order_number }}</strong></td>
|
||||
<td><small>{{ order.created_at|date:"d.m.Y H:i" }}</small></td>
|
||||
<td>
|
||||
{% if order.delivery_date %}
|
||||
<strong>{{ order.delivery_date|date:"d.m.Y" }}</strong>
|
||||
{% if order.delivery_time %}
|
||||
<br><small class="text-muted">{{ order.delivery_time }}</small>
|
||||
{% endif %}
|
||||
{% if order.is_delivery %}
|
||||
<br><span class="badge bg-info">Доставка</span>
|
||||
{% else %}
|
||||
<br><span class="badge bg-secondary">Самовывоз</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">Не указана</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if order.status == 'draft' %}
|
||||
<span class="badge bg-secondary">Черновик</span>
|
||||
{% elif order.status == 'pending' %}
|
||||
<span class="badge bg-warning">Ожидает</span>
|
||||
{% elif order.status == 'in_production' %}
|
||||
<span class="badge bg-info">В производстве</span>
|
||||
{% elif order.status == 'ready' %}
|
||||
<span class="badge bg-primary">Готов</span>
|
||||
{% elif order.status == 'delivered' %}
|
||||
<span class="badge bg-success">Доставлен</span>
|
||||
{% elif order.status == 'cancelled' %}
|
||||
<span class="badge bg-danger">Отменён</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ order.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if order.payment_status == 'paid' %}
|
||||
<span class="badge bg-success">Оплачено</span>
|
||||
{% elif order.payment_status == 'partial' %}
|
||||
<span class="badge bg-warning">Частично</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Не оплачено</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><strong>{{ order.total_amount|floatformat:2 }} руб.</strong></td>
|
||||
<td>
|
||||
{% if order.amount_paid > 0 %}
|
||||
<span class="text-success">{{ order.amount_paid|floatformat:2 }} руб.</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0.00 руб.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if order.amount_due > 0 %}
|
||||
<span class="text-danger fw-bold">{{ order.amount_due|floatformat:2 }} руб.</span>
|
||||
{% else %}
|
||||
<span class="text-success">0.00 руб.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'orders:order-detail' order.order_number %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'orders:order-update' order.order_number %}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Пагинация -->
|
||||
{% if orders_page.has_other_pages %}
|
||||
<nav aria-label="Навигация по заказам">
|
||||
<ul class="pagination justify-content-center mt-3">
|
||||
{% if orders_page.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1">Первая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ orders_page.previous_page_number }}">Предыдущая</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active">
|
||||
<span class="page-link">
|
||||
Страница {{ orders_page.number }} из {{ orders_page.paginator.num_pages }}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
{% if orders_page.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ orders_page.next_page_number }}">Следующая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ orders_page.paginator.num_pages }}">Последняя</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">У клиента пока нет заказов.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -85,8 +85,28 @@ def customer_detail(request, pk):
|
||||
if customer.is_system_customer:
|
||||
return render(request, 'customers/customer_system.html')
|
||||
|
||||
# Рассчитываем общий долг по активным заказам
|
||||
active_orders = customer.orders.exclude(payment_status='paid')
|
||||
total_debt = sum(order.amount_due for order in active_orders)
|
||||
|
||||
# История транзакций кошелька (последние 20)
|
||||
from .models import WalletTransaction
|
||||
wallet_transactions = WalletTransaction.objects.filter(
|
||||
customer=customer
|
||||
).select_related('order', 'created_by').order_by('-created_at')[:20]
|
||||
|
||||
# История заказов с пагинацией
|
||||
orders_list = customer.orders.all().order_by('-created_at')
|
||||
paginator = Paginator(orders_list, 10) # 10 заказов на страницу
|
||||
page_number = request.GET.get('page')
|
||||
orders_page = paginator.get_page(page_number)
|
||||
|
||||
context = {
|
||||
'customer': customer,
|
||||
'total_debt': total_debt,
|
||||
'active_orders_count': active_orders.count(),
|
||||
'wallet_transactions': wallet_transactions,
|
||||
'orders_page': orders_page,
|
||||
}
|
||||
return render(request, 'customers/customer_detail.html', context)
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if r.order_item %}
|
||||
<a href="{% url 'orders:order-detail' r.order_item.order.id %}" class="text-decoration-none">
|
||||
<i class="bi bi-receipt me-1"></i>Заказ #{{ r.order_item.order.id }}
|
||||
<a href="{% url 'orders:order-detail' r.order_item.order.order_number %}" class="text-decoration-none">
|
||||
<i class="bi bi-receipt me-1"></i>Заказ #{{ r.order_item.order.order_number }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from .models import Order, OrderItem, Payment, Address, OrderStatus
|
||||
from .models import Order, OrderItem, Payment, PaymentMethod, Address, OrderStatus
|
||||
|
||||
|
||||
class PaymentInline(admin.TabularInline):
|
||||
@@ -94,7 +94,6 @@ class OrderAdmin(admin.ModelAdmin):
|
||||
}),
|
||||
('Оплата', {
|
||||
'fields': (
|
||||
'payment_method',
|
||||
'total_amount',
|
||||
'discount_amount',
|
||||
'amount_paid',
|
||||
@@ -376,3 +375,78 @@ class OrderStatusAdmin(admin.ModelAdmin):
|
||||
if obj.is_system or obj.orders_count > 0:
|
||||
return False
|
||||
return super().has_delete_permission(request, obj)
|
||||
|
||||
|
||||
@admin.register(PaymentMethod)
|
||||
class PaymentMethodAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Админ-панель для управления способами оплаты.
|
||||
"""
|
||||
list_display = [
|
||||
'order_display',
|
||||
'name',
|
||||
'code',
|
||||
'description',
|
||||
'is_active',
|
||||
'is_system',
|
||||
'payments_count',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'is_active',
|
||||
'is_system',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'name',
|
||||
'code',
|
||||
'description',
|
||||
]
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at', 'created_by']
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': ('code', 'name', 'description', 'order')
|
||||
}),
|
||||
('Настройки', {
|
||||
'fields': ('is_active', 'is_system')
|
||||
}),
|
||||
('Системная информация', {
|
||||
'fields': ('created_at', 'updated_at', 'created_by'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
ordering = ['order', 'name']
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Делаем код readonly для системных способов оплаты"""
|
||||
readonly = list(self.readonly_fields)
|
||||
if obj and obj.is_system:
|
||||
readonly.append('code')
|
||||
return readonly
|
||||
|
||||
def order_display(self, obj):
|
||||
"""Отображение порядкового номера с бейджем"""
|
||||
return format_html(
|
||||
'<span style="display: inline-block; background-color: #6c757d; color: white; padding: 2px 8px; border-radius: 10px; font-size: 11px;">{}</span>',
|
||||
obj.order
|
||||
)
|
||||
order_display.short_description = 'Порядок'
|
||||
|
||||
def payments_count(self, obj):
|
||||
"""Количество платежей этим способом"""
|
||||
count = obj.payments.count()
|
||||
if count == 0:
|
||||
return format_html('<span style="color: #999;">{}</span>', count)
|
||||
return format_html('<span style="font-weight: bold;">{}</span>', count)
|
||||
payments_count.short_description = 'Платежей'
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Запрещаем удаление используемых способов оплаты"""
|
||||
if obj:
|
||||
# Разрешаем удаление только если нет связанных платежей
|
||||
if obj.payments.exists():
|
||||
return False
|
||||
return super().has_delete_permission(request, obj)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django import forms
|
||||
from django.forms import inlineformset_factory
|
||||
from .models import Order, OrderItem, Address, OrderStatus
|
||||
from .models import Order, OrderItem, Payment, Address, OrderStatus
|
||||
from customers.models import Customer
|
||||
from inventory.models import Warehouse
|
||||
from products.models import Product, ProductKit
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class OrderForm(forms.ModelForm):
|
||||
@@ -101,7 +102,6 @@ class OrderForm(forms.ModelForm):
|
||||
'recipient_name',
|
||||
'recipient_phone',
|
||||
'status',
|
||||
'payment_method',
|
||||
'discount_amount',
|
||||
'is_anonymous',
|
||||
'special_instructions',
|
||||
@@ -143,6 +143,7 @@ class OrderForm(forms.ModelForm):
|
||||
'class': 'form-select select2',
|
||||
'data-placeholder': 'Выберите адрес доставки'
|
||||
})
|
||||
# Адрес доставки не обязателен при редактировании (создаётся из отдельных полей)
|
||||
self.fields['delivery_address'].required = False
|
||||
|
||||
self.fields['pickup_warehouse'].widget.attrs.update({
|
||||
@@ -221,9 +222,16 @@ class OrderForm(forms.ModelForm):
|
||||
class OrderItemForm(forms.ModelForm):
|
||||
"""Форма для позиции заказа"""
|
||||
|
||||
# Элегантно переопределяем поле формы, чтобы парсить '277,00' как Decimal
|
||||
price = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = OrderItem
|
||||
fields = ['product', 'product_kit', 'quantity', 'price', 'is_custom_price']
|
||||
# ВАЖНО: НЕ включаем 'id' в fields - это предотвращает ошибку валидации
|
||||
widgets = {
|
||||
'quantity': forms.NumberInput(attrs={'min': 1, 'value': 1}),
|
||||
# Скрываем поля product и product_kit - они будут заполняться через JS
|
||||
@@ -254,6 +262,19 @@ class OrderItemForm(forms.ModelForm):
|
||||
# Поле is_custom_price устанавливается через JS
|
||||
self.fields['is_custom_price'].required = False
|
||||
|
||||
def clean_price(self):
|
||||
"""Парсим цену с запятой или точкой и округляем до 2 знаков"""
|
||||
value = self.cleaned_data.get('price')
|
||||
if value in (None, ''):
|
||||
return None
|
||||
value_str = str(value).strip().replace(',', '.')
|
||||
try:
|
||||
price = Decimal(value_str)
|
||||
# Округляем до 2 знаков после запятой
|
||||
return price.quantize(Decimal('0.01'))
|
||||
except Exception:
|
||||
raise forms.ValidationError('Введите число.')
|
||||
|
||||
def clean(self):
|
||||
"""Валидация: должен быть выбран либо товар, либо комплект (не оба, не ни один)"""
|
||||
cleaned_data = super().clean()
|
||||
@@ -443,3 +464,91 @@ TemporaryKitItemFormSet = formset_factory(
|
||||
min_num=1, # Минимум 1 компонент в комплекте
|
||||
validate_min=True,
|
||||
)
|
||||
|
||||
|
||||
# === ПЛАТЕЖИ (СМЕШАННАЯ ОПЛАТА) ===
|
||||
|
||||
class PaymentForm(forms.ModelForm):
|
||||
"""
|
||||
Форма для создания платежа по заказу.
|
||||
Поддерживает смешанную оплату (несколько платежей на один заказ).
|
||||
"""
|
||||
class Meta:
|
||||
model = Payment
|
||||
fields = ['payment_method', 'amount', 'notes']
|
||||
widgets = {
|
||||
'payment_method': forms.Select(attrs={'class': 'form-select'}),
|
||||
'amount': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'step': '0.01',
|
||||
'min': '0',
|
||||
'placeholder': 'Сумма платежа'
|
||||
}),
|
||||
'notes': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': 'Примечания к платежу (опционально)'
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Фильтруем только активные способы оплаты
|
||||
from .models import PaymentMethod
|
||||
self.fields['payment_method'].queryset = PaymentMethod.objects.filter(
|
||||
is_active=True
|
||||
).order_by('order', 'name')
|
||||
|
||||
# Делаем notes опциональным
|
||||
self.fields['notes'].required = False
|
||||
|
||||
def clean(self):
|
||||
"""Валидация платежа, особенно для оплаты из кошелька"""
|
||||
cleaned = super().clean()
|
||||
method = cleaned.get('payment_method')
|
||||
amount = cleaned.get('amount')
|
||||
order = getattr(self.instance, 'order', None)
|
||||
|
||||
# Пустые формы допустимы при удалении
|
||||
if not method and not amount:
|
||||
return cleaned
|
||||
|
||||
# Базовые проверки
|
||||
if amount is None or amount <= 0:
|
||||
self.add_error('amount', 'Введите сумму больше 0.')
|
||||
|
||||
if not order:
|
||||
raise forms.ValidationError('Заказ не найден для платежа.')
|
||||
|
||||
# Проверка для оплаты из кошелька
|
||||
if method and getattr(method, 'code', None) == 'account_balance':
|
||||
wallet_balance = order.customer.wallet_balance if order.customer else Decimal('0')
|
||||
amount_due = max(order.total_amount - order.amount_paid, Decimal('0'))
|
||||
|
||||
if wallet_balance <= 0:
|
||||
self.add_error('payment_method', 'Недостаточно средств в кошельке клиента (баланс 0).')
|
||||
|
||||
if amount and amount > wallet_balance:
|
||||
self.add_error('amount', f'Недостаточно средств в кошельке. Доступно {wallet_balance} руб.')
|
||||
|
||||
if amount and amount > amount_due:
|
||||
self.add_error('amount', f'Сумма превышает остаток к оплате ({amount_due} руб.)')
|
||||
|
||||
if self.errors:
|
||||
# Общее сообщение для блока формы
|
||||
raise forms.ValidationError('Проверьте поля оплаты из кошелька.')
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
# Formset для множественных платежей
|
||||
PaymentFormSet = inlineformset_factory(
|
||||
Order,
|
||||
Payment,
|
||||
form=PaymentForm,
|
||||
extra=0, # Без пустых форм (добавляем через JavaScript)
|
||||
can_delete=True,
|
||||
min_num=0, # Платежи не обязательны при создании черновика
|
||||
validate_min=False,
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Management commands for orders app
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Management commands
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.core.management.base import BaseCommand
|
||||
from orders.models import PaymentMethod
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Создаёт стандартные способы оплаты для цветочного магазина'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
payment_methods = [
|
||||
{
|
||||
'code': 'account_balance',
|
||||
'name': 'С баланса счёта',
|
||||
'description': 'Оплата из кошелька клиента',
|
||||
'is_system': True,
|
||||
'order': 0
|
||||
},
|
||||
{
|
||||
'code': 'cash',
|
||||
'name': 'Наличными',
|
||||
'description': 'Оплата наличными деньгами',
|
||||
'is_system': True,
|
||||
'order': 1
|
||||
},
|
||||
{
|
||||
'code': 'card',
|
||||
'name': 'Картой',
|
||||
'description': 'Оплата банковской картой',
|
||||
'is_system': True,
|
||||
'order': 2
|
||||
},
|
||||
{
|
||||
'code': 'online',
|
||||
'name': 'Онлайн',
|
||||
'description': 'Онлайн оплата через платежную систему',
|
||||
'is_system': True,
|
||||
'order': 3
|
||||
},
|
||||
{
|
||||
'code': 'legal_entity',
|
||||
'name': 'Безнал от ЮРЛИЦ',
|
||||
'description': 'Безналичный расчёт от юридических лиц',
|
||||
'is_system': True,
|
||||
'order': 4
|
||||
},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
for method_data in payment_methods:
|
||||
method, created = PaymentMethod.objects.get_or_create(
|
||||
code=method_data['code'],
|
||||
defaults=method_data
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'✓ Создан способ оплаты: {method.name}')
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'• Уже существует: {method.name}')
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'\nГотово! Создано {created_count} новых способов оплаты.')
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
# Generated by Django 5.0.10 on 2025-11-26 08:06
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('orders', '0003_historicalorderitem_is_from_showcase_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='historicalorder',
|
||||
name='payment_method',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='order',
|
||||
name='payment_method',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentMethod',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.SlugField(help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')", unique=True, verbose_name='Код способа оплаты')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Название способа оплаты')),
|
||||
('description', models.TextField(blank=True, help_text='Дополнительная информация о способе оплаты', verbose_name='Описание')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Отключенные способы оплаты не отображаются при создании заказа', verbose_name='Активен')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')),
|
||||
('is_system', models.BooleanField(default=False, help_text='Системные способы оплаты нельзя удалить через интерфейс', verbose_name='Системный')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_payment_methods', to=settings.AUTH_USER_MODEL, verbose_name='Создано')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Способ оплаты',
|
||||
'verbose_name_plural': 'Способы оплаты',
|
||||
'ordering': ['order', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='payment_method',
|
||||
field=models.ForeignKey(help_text='Способ оплаты данного платежа', on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='orders.paymentmethod', verbose_name='Способ оплаты'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='paymentmethod',
|
||||
index=models.Index(fields=['code'], name='orders_paym_code_f40d7e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='paymentmethod',
|
||||
index=models.Index(fields=['is_active'], name='orders_paym_is_acti_e2be69_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='paymentmethod',
|
||||
index=models.Index(fields=['order'], name='orders_paym_order_94e282_idx'),
|
||||
),
|
||||
]
|
||||
@@ -1,848 +0,0 @@
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from accounts.models import CustomUser
|
||||
from customers.models import Customer
|
||||
from products.models import Product, ProductKit
|
||||
from inventory.models import Warehouse
|
||||
from simple_history.models import HistoricalRecords
|
||||
|
||||
|
||||
class OrderStatus(models.Model):
|
||||
"""
|
||||
Статус заказа, управляется отдельно для каждого тенанта.
|
||||
Благодаря django-tenants в TENANT_APPS, данные изолированы по схемам.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name="Название статуса"
|
||||
)
|
||||
|
||||
code = models.SlugField(
|
||||
unique=True,
|
||||
verbose_name="Код статуса",
|
||||
help_text="Уникальный идентификатор (например: 'completed', 'cancelled')"
|
||||
)
|
||||
|
||||
label = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name="Метка для отображения",
|
||||
blank=True
|
||||
)
|
||||
|
||||
is_system = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Системный статус",
|
||||
help_text="True для встроенных статусов (draft, completed, cancelled)"
|
||||
)
|
||||
|
||||
is_positive_end = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Положительный исход сделки",
|
||||
help_text="True если это финальный успешный статус (Выполнен)"
|
||||
)
|
||||
|
||||
is_negative_end = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Отрицательный исход сделки",
|
||||
help_text="True если это финальный отрицательный статус (Отменен)"
|
||||
)
|
||||
|
||||
order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Порядок отображения"
|
||||
)
|
||||
|
||||
color = models.CharField(
|
||||
max_length=7,
|
||||
blank=True,
|
||||
default='#808080',
|
||||
verbose_name="Цвет (hex)",
|
||||
help_text="Например: #FF5733"
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Описание"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_order_statuses',
|
||||
verbose_name="Создано"
|
||||
)
|
||||
|
||||
updated_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='updated_order_statuses',
|
||||
verbose_name="Последнее изменение"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Статус заказа"
|
||||
verbose_name_plural = "Статусы заказов"
|
||||
ordering = ['order', 'name']
|
||||
indexes = [
|
||||
models.Index(fields=['code']),
|
||||
models.Index(fields=['is_system']),
|
||||
models.Index(fields=['order']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def orders_count(self):
|
||||
"""Количество заказов в этом статусе"""
|
||||
return self.orders.count()
|
||||
|
||||
|
||||
class Address(models.Model):
|
||||
"""
|
||||
Модель адреса доставки для заказа цветочного магазина в Минске.
|
||||
Адрес принадлежит конкретному заказу доставки.
|
||||
"""
|
||||
# Информация о получателе
|
||||
recipient_name = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Имя получателя",
|
||||
help_text="Имя человека, которому будет доставлен заказ"
|
||||
)
|
||||
|
||||
recipient_phone = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Телефон получателя",
|
||||
help_text="Контактный телефон получателя для уточнения адреса"
|
||||
)
|
||||
|
||||
street = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Улица"
|
||||
)
|
||||
|
||||
building_number = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Номер здания"
|
||||
)
|
||||
|
||||
apartment_number = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Номер квартиры/офиса"
|
||||
)
|
||||
|
||||
entrance = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Подъезд",
|
||||
help_text="Номер подъезда/входа"
|
||||
)
|
||||
|
||||
floor = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Этаж"
|
||||
)
|
||||
|
||||
intercom_code = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Код домофона",
|
||||
help_text="Код домофона для входа в здание"
|
||||
)
|
||||
|
||||
# Дополнительная информация для доставки
|
||||
delivery_instructions = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Инструкции для доставки",
|
||||
help_text="Дополнительные инструкции для курьера"
|
||||
)
|
||||
|
||||
confirm_address_with_recipient = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Уточнить адрес у получателя",
|
||||
help_text="Курьер должен уточнить адрес у получателя перед доставкой"
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Адрес доставки"
|
||||
verbose_name_plural = "Адреса доставки"
|
||||
indexes = [
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
# Собираем компоненты адреса
|
||||
address_parts = []
|
||||
if self.street:
|
||||
address_parts.append(self.street)
|
||||
if self.building_number:
|
||||
address_parts.append(self.building_number)
|
||||
if self.apartment_number:
|
||||
address_parts.append(f"кв/офис {self.apartment_number}")
|
||||
|
||||
address_line = ", ".join(address_parts) if address_parts else "Адрес не указан"
|
||||
|
||||
# Формируем строку с именем получателя
|
||||
if self.recipient_name:
|
||||
return f"{self.recipient_name} - {address_line}"
|
||||
return address_line
|
||||
|
||||
@property
|
||||
def full_address(self):
|
||||
"""Полный адрес для доставки"""
|
||||
# Собираем основные компоненты адреса
|
||||
address_parts = []
|
||||
if self.street:
|
||||
address_parts.append(self.street)
|
||||
if self.building_number:
|
||||
address_parts.append(self.building_number)
|
||||
|
||||
# Если нет основных данных, возвращаем сообщение
|
||||
if not address_parts:
|
||||
return "Адрес не указан"
|
||||
|
||||
address = ", ".join(address_parts)
|
||||
|
||||
# Добавляем квартиру/офис
|
||||
if self.apartment_number:
|
||||
address += f", кв/офис {self.apartment_number}"
|
||||
|
||||
# Собираем дополнительные детали
|
||||
details = []
|
||||
if self.entrance:
|
||||
details.append(f"подъезд {self.entrance}")
|
||||
if self.floor:
|
||||
details.append(f"этаж {self.floor}")
|
||||
if details:
|
||||
address += f" ({', '.join(details)})"
|
||||
|
||||
return address
|
||||
|
||||
|
||||
class Order(models.Model):
|
||||
"""
|
||||
Заказ клиента для доставки цветов.
|
||||
"""
|
||||
# Основная информация
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='orders',
|
||||
verbose_name="Клиент"
|
||||
)
|
||||
|
||||
order_number = models.PositiveIntegerField(
|
||||
unique=True,
|
||||
editable=False,
|
||||
verbose_name="Номер заказа",
|
||||
help_text="Уникальный номер заказа"
|
||||
)
|
||||
|
||||
# Тип доставки
|
||||
is_delivery = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="С доставкой",
|
||||
help_text="True - доставка курьером, False - самовывоз"
|
||||
)
|
||||
|
||||
# Адрес доставки (для курьерской доставки)
|
||||
delivery_address = models.OneToOneField(
|
||||
Address,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='order',
|
||||
verbose_name="Адрес доставки",
|
||||
help_text="Обязательно для курьерской доставки"
|
||||
)
|
||||
|
||||
# Склад для самовывоза
|
||||
pickup_warehouse = models.ForeignKey(
|
||||
Warehouse,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='pickup_orders',
|
||||
verbose_name="Склад для самовывоза",
|
||||
help_text="Обязательно для самовывоза"
|
||||
)
|
||||
|
||||
# Дата и время доставки/самовывоза
|
||||
delivery_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Дата доставки/самовывоза",
|
||||
help_text="Может быть заполнено позже"
|
||||
)
|
||||
|
||||
delivery_time_start = models.TimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Время от",
|
||||
help_text="Начало временного интервала"
|
||||
)
|
||||
|
||||
delivery_time_end = models.TimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Время до",
|
||||
help_text="Конец временного интервала"
|
||||
)
|
||||
|
||||
delivery_cost = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Стоимость доставки",
|
||||
help_text="0 для самовывоза"
|
||||
)
|
||||
|
||||
is_custom_delivery_cost = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Стоимость доставки установлена вручную",
|
||||
help_text="True если стоимость доставки была изменена вручную"
|
||||
)
|
||||
|
||||
# Статус заказа
|
||||
status = models.ForeignKey(
|
||||
'OrderStatus',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='orders',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Статус заказа"
|
||||
)
|
||||
|
||||
# Флаг для отслеживания возвратов
|
||||
is_returned = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Возвращен",
|
||||
help_text="True если заказ был выполнен, но потом отменен или возвращен клиентом"
|
||||
)
|
||||
|
||||
# Автосохранение (для черновиков)
|
||||
last_autosave_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Последнее автосохранение",
|
||||
help_text="Время последнего автоматического сохранения черновика"
|
||||
)
|
||||
|
||||
# Оплата
|
||||
PAYMENT_METHOD_CHOICES = [
|
||||
('cash_to_courier', 'Наличные курьеру'),
|
||||
('card_to_courier', 'Карта курьеру'),
|
||||
('online', 'Онлайн оплата'),
|
||||
('bank_transfer', 'Банковский перевод'),
|
||||
]
|
||||
|
||||
payment_method = models.CharField(
|
||||
max_length=20,
|
||||
choices=PAYMENT_METHOD_CHOICES,
|
||||
default='cash_to_courier',
|
||||
verbose_name="Способ оплаты"
|
||||
)
|
||||
|
||||
is_paid = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Оплачен"
|
||||
)
|
||||
|
||||
total_amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Итоговая сумма заказа",
|
||||
help_text="Общая сумма заказа включая доставку"
|
||||
)
|
||||
|
||||
# Скидки
|
||||
discount_amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Сумма скидки",
|
||||
help_text="Применяется вручную или через систему скидок"
|
||||
)
|
||||
|
||||
# Частичная оплата
|
||||
amount_paid = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Оплачено",
|
||||
help_text="Сумма, внесенная клиентом"
|
||||
)
|
||||
|
||||
PAYMENT_STATUS_CHOICES = [
|
||||
('unpaid', 'Не оплачен'),
|
||||
('partial', 'Частично оплачен'),
|
||||
('paid', 'Оплачен полностью'),
|
||||
]
|
||||
|
||||
payment_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=PAYMENT_STATUS_CHOICES,
|
||||
default='unpaid',
|
||||
verbose_name="Статус оплаты",
|
||||
help_text="Обновляется автоматически при добавлении платежей"
|
||||
)
|
||||
|
||||
# Дополнительная информация
|
||||
customer_is_recipient = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="Покупатель является получателем",
|
||||
help_text="Если отмечено, данные получателя не требуются отдельно"
|
||||
)
|
||||
|
||||
# Данные получателя (если покупатель != получатель)
|
||||
recipient_name = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Имя получателя",
|
||||
help_text="Заполняется, если покупатель не является получателем"
|
||||
)
|
||||
|
||||
recipient_phone = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Телефон получателя",
|
||||
help_text="Контактный телефон получателя"
|
||||
)
|
||||
|
||||
is_anonymous = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Анонимная доставка",
|
||||
help_text="Не сообщать получателю имя отправителя"
|
||||
)
|
||||
|
||||
special_instructions = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Особые пожелания",
|
||||
help_text="Комментарии и пожелания к заказу"
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата создания"
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name="Дата обновления"
|
||||
)
|
||||
|
||||
modified_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='modified_orders',
|
||||
verbose_name="Изменен пользователем",
|
||||
help_text="Последний пользователь, изменивший заказ"
|
||||
)
|
||||
|
||||
# История изменений
|
||||
history = HistoricalRecords()
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Заказ"
|
||||
verbose_name_plural = "Заказы"
|
||||
indexes = [
|
||||
models.Index(fields=['customer']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['delivery_date']),
|
||||
models.Index(fields=['is_delivery']),
|
||||
models.Index(fields=['payment_status']),
|
||||
models.Index(fields=['created_at']),
|
||||
models.Index(fields=['order_number']),
|
||||
models.Index(fields=['is_custom_delivery_cost']),
|
||||
]
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Заказ #{self.order_number} - {self.customer}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Генерируем уникальный номер заказа при создании (начиная с 100 для 3-значного поиска)
|
||||
if not self.order_number:
|
||||
last_order = Order.objects.order_by('-order_number').first()
|
||||
if last_order:
|
||||
# Если ранее нумерация была ниже 100, начинаем с 100; иначе инкремент
|
||||
self.order_number = max(last_order.order_number + 1, 100)
|
||||
else:
|
||||
self.order_number = 100
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
"""Валидация модели"""
|
||||
super().clean()
|
||||
|
||||
# Проверка: для доставки обязателен адрес
|
||||
if self.is_delivery and not self.delivery_address:
|
||||
raise ValidationError({
|
||||
'delivery_address': 'Для доставки необходимо указать адрес доставки'
|
||||
})
|
||||
|
||||
# Проверка: для самовывоза обязателен склад
|
||||
if not self.is_delivery and not self.pickup_warehouse:
|
||||
raise ValidationError({
|
||||
'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
|
||||
})
|
||||
|
||||
# Проверка: время окончания должно быть позже времени начала
|
||||
if self.delivery_time_start and self.delivery_time_end:
|
||||
if self.delivery_time_end <= self.delivery_time_start:
|
||||
raise ValidationError({
|
||||
'delivery_time_end': 'Время окончания должно быть позже времени начала'
|
||||
})
|
||||
|
||||
def get_delivery_cost(self):
|
||||
"""
|
||||
Возвращает стоимость доставки:
|
||||
- Если установлена вручную - использует ручное значение
|
||||
- Если автоматическая - вычисляет на основе правил
|
||||
|
||||
Returns:
|
||||
Decimal: Стоимость доставки
|
||||
"""
|
||||
if self.is_custom_delivery_cost:
|
||||
return self.delivery_cost
|
||||
else:
|
||||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||||
return DeliveryCostCalculator.calculate(self)
|
||||
|
||||
def set_delivery_cost(self, cost, is_custom=True):
|
||||
"""
|
||||
Устанавливает стоимость доставки.
|
||||
|
||||
Args:
|
||||
cost: Новая стоимость доставки (Decimal)
|
||||
is_custom: True если устанавливается вручную, False если автоматически
|
||||
"""
|
||||
self.delivery_cost = cost
|
||||
self.is_custom_delivery_cost = is_custom
|
||||
|
||||
def reset_delivery_cost(self):
|
||||
"""
|
||||
Сбрасывает стоимость доставки на автоматический расчет.
|
||||
"""
|
||||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||||
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
||||
self.is_custom_delivery_cost = False
|
||||
|
||||
def recalculate_delivery_cost(self):
|
||||
"""
|
||||
Пересчитывает стоимость доставки, если она не установлена вручную.
|
||||
Используется при изменении параметров заказа (товаров, адреса и т.д.)
|
||||
"""
|
||||
if not self.is_custom_delivery_cost:
|
||||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||||
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
||||
|
||||
def calculate_total(self):
|
||||
"""Рассчитывает итоговую сумму заказа"""
|
||||
items_total = sum(item.get_total_price() for item in self.items.all())
|
||||
|
||||
# Пересчитываем стоимость доставки если она автоматическая
|
||||
self.recalculate_delivery_cost()
|
||||
|
||||
subtotal = items_total + self.delivery_cost
|
||||
self.total_amount = subtotal - self.discount_amount
|
||||
return self.total_amount
|
||||
|
||||
def update_payment_status(self):
|
||||
"""Автоматически обновляет статус оплаты на основе amount_paid"""
|
||||
if self.amount_paid >= self.total_amount:
|
||||
self.payment_status = 'paid'
|
||||
self.is_paid = True
|
||||
elif self.amount_paid > 0:
|
||||
self.payment_status = 'partial'
|
||||
self.is_paid = False
|
||||
else:
|
||||
self.payment_status = 'unpaid'
|
||||
self.is_paid = False
|
||||
self.save()
|
||||
|
||||
def is_draft(self):
|
||||
"""Проверяет, является ли заказ черновиком"""
|
||||
return self.status and self.status.code == 'draft'
|
||||
|
||||
@property
|
||||
def amount_due(self):
|
||||
"""Остаток к оплате"""
|
||||
return max(self.total_amount - self.amount_paid, 0)
|
||||
|
||||
@property
|
||||
def delivery_cost_display(self):
|
||||
"""
|
||||
Возвращает строку для отображения стоимости доставки с пометкой.
|
||||
Полезно в админке и шаблонах.
|
||||
"""
|
||||
cost = self.get_delivery_cost()
|
||||
suffix = " (ручная)" if self.is_custom_delivery_cost else " (авто)"
|
||||
return f"{cost} руб.{suffix}"
|
||||
|
||||
@property
|
||||
def delivery_info(self):
|
||||
"""Информация о доставке для отображения"""
|
||||
if self.is_delivery:
|
||||
if self.delivery_address:
|
||||
return f"Доставка по адресу: {self.delivery_address.full_address}"
|
||||
return "Доставка (адрес не указан)"
|
||||
else:
|
||||
if self.pickup_warehouse:
|
||||
return f"Самовывоз со склада: {self.pickup_warehouse.name}"
|
||||
return "Самовывоз (склад не указан)"
|
||||
|
||||
@property
|
||||
def delivery_time_window(self):
|
||||
"""Временное окно доставки"""
|
||||
if self.delivery_time_start and self.delivery_time_end:
|
||||
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
|
||||
return "Время не указано"
|
||||
|
||||
|
||||
class OrderItem(models.Model):
|
||||
"""
|
||||
Позиция (товар) в заказе.
|
||||
Хранит информацию о товаре или комплекте, количестве и цене на момент заказа.
|
||||
"""
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name="Заказ"
|
||||
)
|
||||
|
||||
# Товар или комплект (один из двух должен быть заполнен)
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='order_items',
|
||||
verbose_name="Товар"
|
||||
)
|
||||
|
||||
product_kit = models.ForeignKey(
|
||||
ProductKit,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='order_items',
|
||||
verbose_name="Комплект товаров"
|
||||
)
|
||||
|
||||
quantity = models.PositiveIntegerField(
|
||||
default=1,
|
||||
verbose_name="Количество"
|
||||
)
|
||||
|
||||
price = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
verbose_name="Цена за единицу",
|
||||
help_text="Цена на момент создания заказа (фиксируется)"
|
||||
)
|
||||
|
||||
is_custom_price = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Цена изменена вручную",
|
||||
help_text="True если цена была изменена вручную при создании заказа"
|
||||
)
|
||||
|
||||
# Витринные продажи
|
||||
is_from_showcase = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="С витрины",
|
||||
help_text="True если товар продан с витрины"
|
||||
)
|
||||
|
||||
showcase = models.ForeignKey(
|
||||
'inventory.Showcase',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='order_items',
|
||||
verbose_name="Витрина",
|
||||
help_text="Витрина, с которой был продан товар"
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата добавления"
|
||||
)
|
||||
|
||||
# История изменений
|
||||
history = HistoricalRecords()
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Позиция заказа"
|
||||
verbose_name_plural = "Позиции заказа"
|
||||
indexes = [
|
||||
models.Index(fields=['order']),
|
||||
models.Index(fields=['product']),
|
||||
models.Index(fields=['product_kit']),
|
||||
models.Index(fields=['is_from_showcase']),
|
||||
models.Index(fields=['showcase']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
item_name = ""
|
||||
if self.product:
|
||||
item_name = self.product.name
|
||||
elif self.product_kit:
|
||||
item_name = self.product_kit.name
|
||||
return f"{item_name} x{self.quantity} в заказе #{self.order.order_number}"
|
||||
|
||||
def clean(self):
|
||||
"""Валидация модели"""
|
||||
super().clean()
|
||||
|
||||
# Проверка: должен быть заполнен либо product, либо product_kit
|
||||
if not self.product and not self.product_kit:
|
||||
raise ValidationError(
|
||||
'Необходимо указать либо товар, либо комплект товаров'
|
||||
)
|
||||
|
||||
# Проверка: не должны быть заполнены оба поля одновременно
|
||||
if self.product and self.product_kit:
|
||||
raise ValidationError(
|
||||
'Нельзя указать одновременно и товар, и комплект'
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Автоматически фиксируем цену при создании, если она не указана
|
||||
if not self.price:
|
||||
if self.product:
|
||||
self.price = self.product.price
|
||||
elif self.product_kit:
|
||||
self.price = self.product_kit.price
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_total_price(self):
|
||||
"""Возвращает общую стоимость позиции"""
|
||||
return self.price * self.quantity
|
||||
|
||||
@property
|
||||
def item_name(self):
|
||||
"""Название товара/комплекта"""
|
||||
if self.product:
|
||||
return self.product.name
|
||||
elif self.product_kit:
|
||||
return self.product_kit.name
|
||||
return "Не указано"
|
||||
|
||||
@property
|
||||
def original_price(self):
|
||||
"""Оригинальная цена товара/комплекта из каталога"""
|
||||
if self.product:
|
||||
return self.product.actual_price
|
||||
elif self.product_kit:
|
||||
return self.product_kit.actual_price
|
||||
return None
|
||||
|
||||
@property
|
||||
def price_difference(self):
|
||||
"""Разница между установленной ценой и оригинальной"""
|
||||
if self.is_custom_price and self.original_price:
|
||||
return self.price - self.original_price
|
||||
return None
|
||||
|
||||
|
||||
class Payment(models.Model):
|
||||
"""
|
||||
Платеж по заказу.
|
||||
Хранит историю всех платежей, включая частичные оплаты.
|
||||
"""
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='payments',
|
||||
verbose_name="Заказ"
|
||||
)
|
||||
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
verbose_name="Сумма платежа"
|
||||
)
|
||||
|
||||
payment_method = models.CharField(
|
||||
max_length=20,
|
||||
choices=Order.PAYMENT_METHOD_CHOICES,
|
||||
verbose_name="Способ оплаты"
|
||||
)
|
||||
|
||||
payment_date = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата и время платежа"
|
||||
)
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='payments_created',
|
||||
verbose_name="Принял платеж"
|
||||
)
|
||||
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Примечания",
|
||||
help_text="Дополнительная информация о платеже"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Платеж"
|
||||
verbose_name_plural = "Платежи"
|
||||
ordering = ['-payment_date']
|
||||
indexes = [
|
||||
models.Index(fields=['order']),
|
||||
models.Index(fields=['payment_date']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Платеж {self.amount} руб. по заказу #{self.order.order_number}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""При сохранении платежа обновляем сумму оплаты в заказе"""
|
||||
super().save(*args, **kwargs)
|
||||
# Пересчитываем общую сумму оплаты в заказе
|
||||
self.order.amount_paid = sum(p.amount for p in self.order.payments.all())
|
||||
self.order.update_payment_status()
|
||||
35
myproject/orders/models/__init__.py
Normal file
35
myproject/orders/models/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Модели приложения Orders.
|
||||
|
||||
Структура:
|
||||
- OrderStatus: Статусы заказов
|
||||
- Address: Адреса доставки
|
||||
- Order: Главная модель заказа
|
||||
- OrderItem: Позиции в заказе
|
||||
- PaymentMethod: Способы оплаты (справочник)
|
||||
- Payment: Платежи по заказам (поддержка смешанной оплаты)
|
||||
"""
|
||||
|
||||
# Порядок импортов по зависимостям:
|
||||
# 1. Независимые модели (справочники)
|
||||
from .status import OrderStatus
|
||||
from .payment import PaymentMethod
|
||||
|
||||
# 2. Модели с зависимостями от справочников
|
||||
from .address import Address
|
||||
|
||||
# 3. Главная модель Order (зависит от Status, Address)
|
||||
from .order import Order
|
||||
|
||||
# 4. Зависимые модели
|
||||
from .order_item import OrderItem
|
||||
from .payment import Payment
|
||||
|
||||
__all__ = [
|
||||
'OrderStatus',
|
||||
'Address',
|
||||
'Order',
|
||||
'OrderItem',
|
||||
'PaymentMethod',
|
||||
'Payment',
|
||||
]
|
||||
142
myproject/orders/models/address.py
Normal file
142
myproject/orders/models/address.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Address(models.Model):
|
||||
"""
|
||||
Модель адреса доставки для заказа цветочного магазина в Минске.
|
||||
Адрес принадлежит конкретному заказу доставки.
|
||||
"""
|
||||
# Информация о получателе
|
||||
recipient_name = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Имя получателя",
|
||||
help_text="Имя человека, которому будет доставлен заказ"
|
||||
)
|
||||
|
||||
recipient_phone = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Телефон получателя",
|
||||
help_text="Контактный телефон получателя для уточнения адреса"
|
||||
)
|
||||
|
||||
street = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Улица"
|
||||
)
|
||||
|
||||
building_number = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Номер здания"
|
||||
)
|
||||
|
||||
apartment_number = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Номер квартиры/офиса"
|
||||
)
|
||||
|
||||
entrance = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Подъезд",
|
||||
help_text="Номер подъезда/входа"
|
||||
)
|
||||
|
||||
floor = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Этаж"
|
||||
)
|
||||
|
||||
intercom_code = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Код домофона",
|
||||
help_text="Код домофона для входа в здание"
|
||||
)
|
||||
|
||||
# Дополнительная информация для доставки
|
||||
delivery_instructions = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Инструкции для доставки",
|
||||
help_text="Дополнительные инструкции для курьера"
|
||||
)
|
||||
|
||||
confirm_address_with_recipient = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Уточнить адрес у получателя",
|
||||
help_text="Курьер должен уточнить адрес у получателя перед доставкой"
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Адрес доставки"
|
||||
verbose_name_plural = "Адреса доставки"
|
||||
indexes = [
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
# Собираем компоненты адреса
|
||||
address_parts = []
|
||||
if self.street:
|
||||
address_parts.append(self.street)
|
||||
if self.building_number:
|
||||
address_parts.append(self.building_number)
|
||||
if self.apartment_number:
|
||||
address_parts.append(f"кв/офис {self.apartment_number}")
|
||||
|
||||
address_line = ", ".join(address_parts) if address_parts else "Адрес не указан"
|
||||
|
||||
# Формируем строку с именем получателя
|
||||
if self.recipient_name:
|
||||
return f"{self.recipient_name} - {address_line}"
|
||||
return address_line
|
||||
|
||||
@property
|
||||
def full_address(self):
|
||||
"""Полный адрес для доставки"""
|
||||
# Собираем основные компоненты адреса
|
||||
address_parts = []
|
||||
if self.street:
|
||||
address_parts.append(self.street)
|
||||
if self.building_number:
|
||||
address_parts.append(self.building_number)
|
||||
|
||||
# Если нет основных данных, возвращаем сообщение
|
||||
if not address_parts:
|
||||
return "Адрес не указан"
|
||||
|
||||
address = ", ".join(address_parts)
|
||||
|
||||
# Добавляем квартиру/офис
|
||||
if self.apartment_number:
|
||||
address += f", кв/офис {self.apartment_number}"
|
||||
|
||||
# Собираем дополнительные детали
|
||||
details = []
|
||||
if self.entrance:
|
||||
details.append(f"подъезд {self.entrance}")
|
||||
if self.floor:
|
||||
details.append(f"этаж {self.floor}")
|
||||
if details:
|
||||
address += f" ({', '.join(details)})"
|
||||
|
||||
return address
|
||||
391
myproject/orders/models/order.py
Normal file
391
myproject/orders/models/order.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from accounts.models import CustomUser
|
||||
from customers.models import Customer
|
||||
from inventory.models import Warehouse
|
||||
from simple_history.models import HistoricalRecords
|
||||
from .status import OrderStatus
|
||||
from .address import Address
|
||||
|
||||
|
||||
class Order(models.Model):
|
||||
"""
|
||||
Заказ клиента для доставки цветов.
|
||||
|
||||
ВАЖНО: Поле payment_method УДАЛЕНО для поддержки смешанной оплаты.
|
||||
Используйте модель Payment (один Order → много Payment) для платежей.
|
||||
"""
|
||||
# Основная информация
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='orders',
|
||||
verbose_name="Клиент"
|
||||
)
|
||||
|
||||
order_number = models.PositiveIntegerField(
|
||||
unique=True,
|
||||
editable=False,
|
||||
verbose_name="Номер заказа",
|
||||
help_text="Уникальный номер заказа"
|
||||
)
|
||||
|
||||
# Тип доставки
|
||||
is_delivery = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="С доставкой",
|
||||
help_text="True - доставка курьером, False - самовывоз"
|
||||
)
|
||||
|
||||
# Адрес доставки (для курьерской доставки)
|
||||
delivery_address = models.OneToOneField(
|
||||
Address,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='order',
|
||||
verbose_name="Адрес доставки",
|
||||
help_text="Обязательно для курьерской доставки"
|
||||
)
|
||||
|
||||
# Склад для самовывоза
|
||||
pickup_warehouse = models.ForeignKey(
|
||||
Warehouse,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='pickup_orders',
|
||||
verbose_name="Склад для самовывоза",
|
||||
help_text="Обязательно для самовывоза"
|
||||
)
|
||||
|
||||
# Дата и время доставки/самовывоза
|
||||
delivery_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Дата доставки/самовывоза",
|
||||
help_text="Может быть заполнено позже"
|
||||
)
|
||||
|
||||
delivery_time_start = models.TimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Время от",
|
||||
help_text="Начало временного интервала"
|
||||
)
|
||||
|
||||
delivery_time_end = models.TimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Время до",
|
||||
help_text="Конец временного интервала"
|
||||
)
|
||||
|
||||
delivery_cost = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Стоимость доставки",
|
||||
help_text="0 для самовывоза"
|
||||
)
|
||||
|
||||
is_custom_delivery_cost = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Стоимость доставки установлена вручную",
|
||||
help_text="True если стоимость доставки была изменена вручную"
|
||||
)
|
||||
|
||||
# Статус заказа
|
||||
status = models.ForeignKey(
|
||||
'OrderStatus',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='orders',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Статус заказа"
|
||||
)
|
||||
|
||||
# Флаг для отслеживания возвратов
|
||||
is_returned = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Возвращен",
|
||||
help_text="True если заказ был выполнен, но потом отменен или возвращен клиентом"
|
||||
)
|
||||
|
||||
# Автосохранение (для черновиков)
|
||||
last_autosave_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Последнее автосохранение",
|
||||
help_text="Время последнего автоматического сохранения черновика"
|
||||
)
|
||||
|
||||
# Оплата
|
||||
# УДАЛЕНО: PAYMENT_METHOD_CHOICES и payment_method поле
|
||||
# Вместо этого используйте модель Payment для смешанной оплаты
|
||||
|
||||
is_paid = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Оплачен"
|
||||
)
|
||||
|
||||
total_amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Итоговая сумма заказа",
|
||||
help_text="Общая сумма заказа включая доставку"
|
||||
)
|
||||
|
||||
# Скидки
|
||||
discount_amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Сумма скидки",
|
||||
help_text="Применяется вручную или через систему скидок"
|
||||
)
|
||||
|
||||
# Частичная оплата
|
||||
amount_paid = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Оплачено",
|
||||
help_text="Сумма, внесенная клиентом"
|
||||
)
|
||||
|
||||
PAYMENT_STATUS_CHOICES = [
|
||||
('unpaid', 'Не оплачен'),
|
||||
('partial', 'Частично оплачен'),
|
||||
('paid', 'Оплачен полностью'),
|
||||
]
|
||||
|
||||
payment_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=PAYMENT_STATUS_CHOICES,
|
||||
default='unpaid',
|
||||
verbose_name="Статус оплаты",
|
||||
help_text="Обновляется автоматически при добавлении платежей"
|
||||
)
|
||||
|
||||
# Дополнительная информация
|
||||
customer_is_recipient = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="Покупатель является получателем",
|
||||
help_text="Если отмечено, данные получателя не требуются отдельно"
|
||||
)
|
||||
|
||||
# Данные получателя (если покупатель != получатель)
|
||||
recipient_name = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Имя получателя",
|
||||
help_text="Заполняется, если покупатель не является получателем"
|
||||
)
|
||||
|
||||
recipient_phone = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Телефон получателя",
|
||||
help_text="Контактный телефон получателя"
|
||||
)
|
||||
|
||||
is_anonymous = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Анонимная доставка",
|
||||
help_text="Не сообщать получателю имя отправителя"
|
||||
)
|
||||
|
||||
special_instructions = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Особые пожелания",
|
||||
help_text="Комментарии и пожелания к заказу"
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата создания"
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name="Дата обновления"
|
||||
)
|
||||
|
||||
modified_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='modified_orders',
|
||||
verbose_name="Изменен пользователем",
|
||||
help_text="Последний пользователь, изменивший заказ"
|
||||
)
|
||||
|
||||
# История изменений
|
||||
history = HistoricalRecords()
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Заказ"
|
||||
verbose_name_plural = "Заказы"
|
||||
indexes = [
|
||||
models.Index(fields=['customer']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['delivery_date']),
|
||||
models.Index(fields=['is_delivery']),
|
||||
models.Index(fields=['payment_status']),
|
||||
models.Index(fields=['created_at']),
|
||||
models.Index(fields=['order_number']),
|
||||
models.Index(fields=['is_custom_delivery_cost']),
|
||||
]
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Заказ #{self.order_number} - {self.customer}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Возвращает канонический URL для заказа"""
|
||||
from django.urls import reverse
|
||||
return reverse('orders:order-detail', kwargs={'order_number': self.order_number})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Генерируем уникальный номер заказа при создании (начиная с 100 для 3-значного поиска)
|
||||
if not self.order_number:
|
||||
last_order = Order.objects.order_by('-order_number').first()
|
||||
if last_order:
|
||||
# Если ранее нумерация была ниже 100, начинаем с 100; иначе инкремент
|
||||
self.order_number = max(last_order.order_number + 1, 100)
|
||||
else:
|
||||
self.order_number = 100
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
"""Валидация модели"""
|
||||
super().clean()
|
||||
|
||||
# Проверка: для самовывоза обязателен склад
|
||||
if not self.is_delivery and not self.pickup_warehouse:
|
||||
raise ValidationError({
|
||||
'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
|
||||
})
|
||||
|
||||
# Проверка: время окончания должно быть позже или равно времени начала
|
||||
# Равные времена означают точное время доставки (например, "к 13:00")
|
||||
if self.delivery_time_start and self.delivery_time_end:
|
||||
if self.delivery_time_end < self.delivery_time_start:
|
||||
raise ValidationError({
|
||||
'delivery_time_end': 'Время окончания не может быть раньше времени начала'
|
||||
})
|
||||
|
||||
def get_delivery_cost(self):
|
||||
"""
|
||||
Возвращает стоимость доставки:
|
||||
- Если установлена вручную - использует ручное значение
|
||||
- Если автоматическая - вычисляет на основе правил
|
||||
|
||||
Returns:
|
||||
Decimal: Стоимость доставки
|
||||
"""
|
||||
if self.is_custom_delivery_cost:
|
||||
return self.delivery_cost
|
||||
else:
|
||||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||||
return DeliveryCostCalculator.calculate(self)
|
||||
|
||||
def set_delivery_cost(self, cost, is_custom=True):
|
||||
"""
|
||||
Устанавливает стоимость доставки.
|
||||
|
||||
Args:
|
||||
cost: Новая стоимость доставки (Decimal)
|
||||
is_custom: True если устанавливается вручную, False если автоматически
|
||||
"""
|
||||
self.delivery_cost = cost
|
||||
self.is_custom_delivery_cost = is_custom
|
||||
|
||||
def reset_delivery_cost(self):
|
||||
"""
|
||||
Сбрасывает стоимость доставки на автоматический расчет.
|
||||
"""
|
||||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||||
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
||||
self.is_custom_delivery_cost = False
|
||||
|
||||
def recalculate_delivery_cost(self):
|
||||
"""
|
||||
Пересчитывает стоимость доставки, если она не установлена вручную.
|
||||
Используется при изменении параметров заказа (товаров, адреса и т.д.)
|
||||
"""
|
||||
if not self.is_custom_delivery_cost:
|
||||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||||
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
||||
|
||||
def calculate_total(self):
|
||||
"""Рассчитывает итоговую сумму заказа"""
|
||||
items_total = sum(item.get_total_price() for item in self.items.all())
|
||||
|
||||
# Пересчитываем стоимость доставки если она автоматическая
|
||||
self.recalculate_delivery_cost()
|
||||
|
||||
subtotal = items_total + self.delivery_cost
|
||||
self.total_amount = subtotal - self.discount_amount
|
||||
return self.total_amount
|
||||
|
||||
def update_payment_status(self):
|
||||
"""Автоматически обновляет статус оплаты на основе amount_paid"""
|
||||
if self.amount_paid >= self.total_amount:
|
||||
self.payment_status = 'paid'
|
||||
self.is_paid = True
|
||||
elif self.amount_paid > 0:
|
||||
self.payment_status = 'partial'
|
||||
self.is_paid = False
|
||||
else:
|
||||
self.payment_status = 'unpaid'
|
||||
self.is_paid = False
|
||||
self.save()
|
||||
|
||||
def is_draft(self):
|
||||
"""Проверяет, является ли заказ черновиком"""
|
||||
return self.status and self.status.code == 'draft'
|
||||
|
||||
@property
|
||||
def amount_due(self):
|
||||
"""Остаток к оплате"""
|
||||
return max(self.total_amount - self.amount_paid, 0)
|
||||
|
||||
@property
|
||||
def delivery_cost_display(self):
|
||||
"""
|
||||
Возвращает строку для отображения стоимости доставки с пометкой.
|
||||
Полезно в админке и шаблонах.
|
||||
"""
|
||||
cost = self.get_delivery_cost()
|
||||
suffix = " (ручная)" if self.is_custom_delivery_cost else " (авто)"
|
||||
return f"{cost} руб.{suffix}"
|
||||
|
||||
@property
|
||||
def delivery_info(self):
|
||||
"""Информация о доставке для отображения"""
|
||||
if self.is_delivery:
|
||||
if self.delivery_address:
|
||||
return f"Доставка по адресу: {self.delivery_address.full_address}"
|
||||
return "Доставка (адрес не указан)"
|
||||
else:
|
||||
if self.pickup_warehouse:
|
||||
return f"Самовывоз со склада: {self.pickup_warehouse.name}"
|
||||
return "Самовывоз (склад не указан)"
|
||||
|
||||
@property
|
||||
def delivery_time_window(self):
|
||||
"""Временное окно доставки"""
|
||||
if self.delivery_time_start and self.delivery_time_end:
|
||||
# Если времена равны - это точное время доставки
|
||||
if self.delivery_time_start == self.delivery_time_end:
|
||||
return f"к {self.delivery_time_start.strftime('%H:%M')}"
|
||||
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
|
||||
return "Время не указано"
|
||||
154
myproject/orders/models/order_item.py
Normal file
154
myproject/orders/models/order_item.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from products.models import Product, ProductKit
|
||||
from simple_history.models import HistoricalRecords
|
||||
from .order import Order
|
||||
|
||||
|
||||
class OrderItem(models.Model):
|
||||
"""
|
||||
Позиция (товар) в заказе.
|
||||
Хранит информацию о товаре или комплекте, количестве и цене на момент заказа.
|
||||
"""
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name="Заказ"
|
||||
)
|
||||
|
||||
# Товар или комплект (один из двух должен быть заполнен)
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='order_items',
|
||||
verbose_name="Товар"
|
||||
)
|
||||
|
||||
product_kit = models.ForeignKey(
|
||||
ProductKit,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='order_items',
|
||||
verbose_name="Комплект товаров"
|
||||
)
|
||||
|
||||
quantity = models.PositiveIntegerField(
|
||||
default=1,
|
||||
verbose_name="Количество"
|
||||
)
|
||||
|
||||
price = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
verbose_name="Цена за единицу",
|
||||
help_text="Цена на момент создания заказа (фиксируется)"
|
||||
)
|
||||
|
||||
is_custom_price = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Цена изменена вручную",
|
||||
help_text="True если цена была изменена вручную при создании заказа"
|
||||
)
|
||||
|
||||
# Витринные продажи
|
||||
is_from_showcase = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="С витрины",
|
||||
help_text="True если товар продан с витрины"
|
||||
)
|
||||
|
||||
showcase = models.ForeignKey(
|
||||
'inventory.Showcase',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='order_items',
|
||||
verbose_name="Витрина",
|
||||
help_text="Витрина, с которой был продан товар"
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата добавления"
|
||||
)
|
||||
|
||||
# История изменений
|
||||
history = HistoricalRecords()
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Позиция заказа"
|
||||
verbose_name_plural = "Позиции заказа"
|
||||
indexes = [
|
||||
models.Index(fields=['order']),
|
||||
models.Index(fields=['product']),
|
||||
models.Index(fields=['product_kit']),
|
||||
models.Index(fields=['is_from_showcase']),
|
||||
models.Index(fields=['showcase']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
item_name = ""
|
||||
if self.product:
|
||||
item_name = self.product.name
|
||||
elif self.product_kit:
|
||||
item_name = self.product_kit.name
|
||||
return f"{item_name} x{self.quantity} в заказе #{self.order.order_number}"
|
||||
|
||||
def clean(self):
|
||||
"""Валидация модели"""
|
||||
super().clean()
|
||||
|
||||
# Проверка: должен быть заполнен либо product, либо product_kit
|
||||
if not self.product and not self.product_kit:
|
||||
raise ValidationError(
|
||||
'Необходимо указать либо товар, либо комплект товаров'
|
||||
)
|
||||
|
||||
# Проверка: не должны быть заполнены оба поля одновременно
|
||||
if self.product and self.product_kit:
|
||||
raise ValidationError(
|
||||
'Нельзя указать одновременно и товар, и комплект'
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Автоматически фиксируем цену при создании, если она не указана
|
||||
if not self.price:
|
||||
if self.product:
|
||||
self.price = self.product.price
|
||||
elif self.product_kit:
|
||||
self.price = self.product_kit.price
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_total_price(self):
|
||||
"""Возвращает общую стоимость позиции"""
|
||||
return self.price * self.quantity
|
||||
|
||||
@property
|
||||
def item_name(self):
|
||||
"""Название товара/комплекта"""
|
||||
if self.product:
|
||||
return self.product.name
|
||||
elif self.product_kit:
|
||||
return self.product_kit.name
|
||||
return "Не указано"
|
||||
|
||||
@property
|
||||
def original_price(self):
|
||||
"""Оригинальная цена товара/комплекта из каталога"""
|
||||
if self.product:
|
||||
return self.product.actual_price
|
||||
elif self.product_kit:
|
||||
return self.product_kit.actual_price
|
||||
return None
|
||||
|
||||
@property
|
||||
def price_difference(self):
|
||||
"""Разница между установленной ценой и оригинальной"""
|
||||
if self.is_custom_price and self.original_price:
|
||||
return self.price - self.original_price
|
||||
return None
|
||||
182
myproject/orders/models/payment.py
Normal file
182
myproject/orders/models/payment.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from django.db import models
|
||||
from accounts.models import CustomUser
|
||||
from decimal import Decimal
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class PaymentMethod(models.Model):
|
||||
"""
|
||||
Способ оплаты заказа.
|
||||
Справочник для управления доступными методами оплаты.
|
||||
"""
|
||||
|
||||
# Код для программного доступа
|
||||
code = models.SlugField(
|
||||
unique=True,
|
||||
max_length=50,
|
||||
verbose_name="Код способа оплаты",
|
||||
help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')"
|
||||
)
|
||||
|
||||
# Отображаемое название
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name="Название способа оплаты"
|
||||
)
|
||||
|
||||
# Описание
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Описание",
|
||||
help_text="Дополнительная информация о способе оплаты"
|
||||
)
|
||||
|
||||
# Активность
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="Активен",
|
||||
help_text="Отключенные способы оплаты не отображаются при создании заказа"
|
||||
)
|
||||
|
||||
# Порядок отображения
|
||||
order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Порядок отображения"
|
||||
)
|
||||
|
||||
# Системный флаг
|
||||
is_system = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Системный",
|
||||
help_text="Системные способы оплаты нельзя удалить через интерфейс"
|
||||
)
|
||||
|
||||
# Аудит
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_payment_methods',
|
||||
verbose_name="Создано"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Способ оплаты"
|
||||
verbose_name_plural = "Способы оплаты"
|
||||
ordering = ['order', 'name']
|
||||
indexes = [
|
||||
models.Index(fields=['code']),
|
||||
models.Index(fields=['is_active']),
|
||||
models.Index(fields=['order']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Payment(models.Model):
|
||||
"""
|
||||
Платеж по заказу.
|
||||
Хранит историю всех платежей, включая частичные оплаты.
|
||||
Поддерживает смешанную оплату (несколько платежей разными способами на один заказ).
|
||||
"""
|
||||
order = models.ForeignKey(
|
||||
'Order',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='payments',
|
||||
verbose_name="Заказ"
|
||||
)
|
||||
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
verbose_name="Сумма платежа"
|
||||
)
|
||||
|
||||
payment_method = models.ForeignKey(
|
||||
'PaymentMethod',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='payments',
|
||||
verbose_name="Способ оплаты",
|
||||
help_text="Способ оплаты данного платежа"
|
||||
)
|
||||
|
||||
payment_date = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата и время платежа"
|
||||
)
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='payments_created',
|
||||
verbose_name="Принял платеж"
|
||||
)
|
||||
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Примечания",
|
||||
help_text="Дополнительная информация о платеже"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Платеж"
|
||||
verbose_name_plural = "Платежи"
|
||||
ordering = ['-payment_date']
|
||||
indexes = [
|
||||
models.Index(fields=['order']),
|
||||
models.Index(fields=['payment_date']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Платеж {self.amount} руб. по заказу #{self.order.order_number}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""При сохранении платежа обновляем сумму оплаты в заказе и обрабатываем кошелёк/переплаты"""
|
||||
is_new = self.pk is None
|
||||
with transaction.atomic():
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Пересчитываем общую сумму оплаты в заказе
|
||||
self.order.amount_paid = sum(p.amount for p in self.order.payments.all())
|
||||
self.order.update_payment_status()
|
||||
|
||||
# Списание из кошелька при новом платеже методом 'account_balance'
|
||||
if is_new and self.payment_method.code == 'account_balance':
|
||||
from customers.models import Customer, WalletTransaction
|
||||
# Блокируем запись клиента
|
||||
customer = Customer.objects.select_for_update().get(pk=self.order.customer_id)
|
||||
if customer.wallet_balance < self.amount:
|
||||
raise ValidationError(f'Недостаточно средств в кошельке (доступно {customer.wallet_balance} руб.)')
|
||||
|
||||
# Списываем и округляем до 2 знаков
|
||||
customer.wallet_balance = (customer.wallet_balance - self.amount).quantize(Decimal('0.01'))
|
||||
customer.save(update_fields=['wallet_balance'])
|
||||
|
||||
# Пишем историю
|
||||
WalletTransaction.objects.create(
|
||||
customer=customer,
|
||||
amount=self.amount,
|
||||
transaction_type='spend',
|
||||
order=self.order,
|
||||
description=f'Оплата из кошелька по заказу #{self.order.order_number}',
|
||||
created_by=self.created_by
|
||||
)
|
||||
|
||||
# Нормализация переплаты: лишнее в кошелёк, amount_paid = total_amount
|
||||
# ТОЛЬКО для новых платежей, чтобы избежать дублирования при обновлении
|
||||
if is_new:
|
||||
try:
|
||||
from customers.services.wallet_service import WalletService
|
||||
WalletService.add_overpayment(self.order, self.created_by)
|
||||
except Exception:
|
||||
# Продолжаем, даже если нормализация переплаты не удалась
|
||||
pass
|
||||
100
myproject/orders/models/status.py
Normal file
100
myproject/orders/models/status.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from django.db import models
|
||||
from accounts.models import CustomUser
|
||||
|
||||
|
||||
class OrderStatus(models.Model):
|
||||
"""
|
||||
Статус заказа, управляется отдельно для каждого тенанта.
|
||||
Благодаря django-tenants в TENANT_APPS, данные изолированы по схемам.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name="Название статуса"
|
||||
)
|
||||
|
||||
code = models.SlugField(
|
||||
unique=True,
|
||||
verbose_name="Код статуса",
|
||||
help_text="Уникальный идентификатор (например: 'completed', 'cancelled')"
|
||||
)
|
||||
|
||||
label = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name="Метка для отображения",
|
||||
blank=True
|
||||
)
|
||||
|
||||
is_system = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Системный статус",
|
||||
help_text="True для встроенных статусов (draft, completed, cancelled)"
|
||||
)
|
||||
|
||||
is_positive_end = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Положительный исход сделки",
|
||||
help_text="True если это финальный успешный статус (Выполнен)"
|
||||
)
|
||||
|
||||
is_negative_end = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Отрицательный исход сделки",
|
||||
help_text="True если это финальный отрицательный статус (Отменен)"
|
||||
)
|
||||
|
||||
order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Порядок отображения"
|
||||
)
|
||||
|
||||
color = models.CharField(
|
||||
max_length=7,
|
||||
blank=True,
|
||||
default='#808080',
|
||||
verbose_name="Цвет (hex)",
|
||||
help_text="Например: #FF5733"
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Описание"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_order_statuses',
|
||||
verbose_name="Создано"
|
||||
)
|
||||
|
||||
updated_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='updated_order_statuses',
|
||||
verbose_name="Последнее изменение"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Статус заказа"
|
||||
verbose_name_plural = "Статусы заказов"
|
||||
ordering = ['order', 'name']
|
||||
indexes = [
|
||||
models.Index(fields=['code']),
|
||||
models.Index(fields=['is_system']),
|
||||
models.Index(fields=['order']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def orders_count(self):
|
||||
"""Количество заказов в этом статусе"""
|
||||
return self.orders.count()
|
||||
@@ -62,7 +62,6 @@ class DraftOrderService:
|
||||
delivery_time_start=data.get('delivery_time_start'),
|
||||
delivery_time_end=data.get('delivery_time_end'),
|
||||
delivery_cost=data.get('delivery_cost', Decimal('0')),
|
||||
payment_method=data.get('payment_method', 'cash_to_courier'),
|
||||
customer_is_recipient=data.get('customer_is_recipient', True),
|
||||
recipient_name=data.get('recipient_name'),
|
||||
recipient_phone=data.get('recipient_phone'),
|
||||
@@ -103,7 +102,7 @@ class DraftOrderService:
|
||||
|
||||
simple_fields = [
|
||||
'is_delivery', 'delivery_date', 'delivery_time_start', 'delivery_time_end',
|
||||
'delivery_cost', 'payment_method', 'customer_is_recipient',
|
||||
'delivery_cost', 'customer_is_recipient',
|
||||
'recipient_name', 'recipient_phone', 'is_anonymous',
|
||||
'special_instructions', 'discount_amount'
|
||||
]
|
||||
@@ -208,6 +207,13 @@ class DraftOrderService:
|
||||
|
||||
setattr(order, field, value)
|
||||
|
||||
# Обрабатываем удаление позиций заказа
|
||||
if 'deleted_item_ids' in data:
|
||||
deleted_ids = data['deleted_item_ids']
|
||||
if deleted_ids:
|
||||
from ..models import OrderItem
|
||||
OrderItem.objects.filter(id__in=deleted_ids, order=order).delete()
|
||||
|
||||
# Обрабатываем позиции заказа (items)
|
||||
if 'items' in data:
|
||||
# Импортируем модели
|
||||
@@ -216,27 +222,17 @@ class DraftOrderService:
|
||||
|
||||
items_data = data['items']
|
||||
|
||||
# Получаем существующие позиции
|
||||
existing_items = list(order.items.all())
|
||||
|
||||
# Удаляем все существующие позиции, которых нет в новых данных
|
||||
items_to_keep_count = len(items_data)
|
||||
for i, existing_item in enumerate(existing_items):
|
||||
if i >= items_to_keep_count:
|
||||
# Удаляем лишние позиции
|
||||
existing_item.delete()
|
||||
|
||||
# Обновляем или создаём позиции
|
||||
for index, item_data in enumerate(items_data):
|
||||
# Обрабатываем каждую позицию
|
||||
for item_data in items_data:
|
||||
item_id = item_data.get('id') # ID существующей позиции (если есть)
|
||||
product_id = item_data.get('product_id')
|
||||
product_kit_id = item_data.get('product_kit_id')
|
||||
quantity = item_data.get('quantity', 1)
|
||||
price = item_data.get('price', 0)
|
||||
price_raw = item_data.get('price', '')
|
||||
|
||||
# Конвертируем в Decimal
|
||||
# Конвертируем количество в Decimal
|
||||
try:
|
||||
quantity = Decimal(str(quantity))
|
||||
price = Decimal(str(price))
|
||||
except (ValueError, TypeError, decimal.InvalidOperation):
|
||||
continue
|
||||
|
||||
@@ -257,22 +253,37 @@ class DraftOrderService:
|
||||
else:
|
||||
continue
|
||||
|
||||
# Определяем, изменилась ли цена
|
||||
# Определяем оригинальную цену из каталога
|
||||
original_price = product.actual_price if product else product_kit.actual_price
|
||||
|
||||
# Конвертируем цену в Decimal, если пустая - используем оригинальную
|
||||
try:
|
||||
price = Decimal(str(price_raw)) if price_raw else Decimal('0')
|
||||
# Если цена 0 или пустая, используем оригинальную цену
|
||||
if price == Decimal('0'):
|
||||
price = original_price
|
||||
is_custom_price = False
|
||||
else:
|
||||
# Определяем, изменилась ли цена
|
||||
is_custom_price = abs(price - original_price) > Decimal('0.01')
|
||||
except (ValueError, TypeError, decimal.InvalidOperation):
|
||||
# В случае ошибки используем оригинальную цену
|
||||
price = original_price
|
||||
is_custom_price = False
|
||||
|
||||
# Обновляем существующую позицию или создаём новую
|
||||
if index < len(existing_items):
|
||||
# Обновляем существующую
|
||||
item = existing_items[index]
|
||||
if item_id:
|
||||
# Обновляем существующую позицию
|
||||
try:
|
||||
item = OrderItem.objects.get(id=item_id, order=order)
|
||||
item.product = product
|
||||
item.product_kit = product_kit
|
||||
item.quantity = quantity
|
||||
item.price = price
|
||||
item.is_custom_price = is_custom_price
|
||||
item.save()
|
||||
else:
|
||||
# Создаём новую
|
||||
except OrderItem.DoesNotExist:
|
||||
# Если позиция не найдена, создаём новую
|
||||
OrderItem.objects.create(
|
||||
order=order,
|
||||
product=product,
|
||||
@@ -281,6 +292,81 @@ class DraftOrderService:
|
||||
price=price,
|
||||
is_custom_price=is_custom_price
|
||||
)
|
||||
else:
|
||||
# Создаём новую позицию
|
||||
OrderItem.objects.create(
|
||||
order=order,
|
||||
product=product,
|
||||
product_kit=product_kit,
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
is_custom_price=is_custom_price
|
||||
)
|
||||
|
||||
# Обрабатываем удаление платежей
|
||||
if 'deleted_payment_ids' in data:
|
||||
deleted_payment_ids = data['deleted_payment_ids']
|
||||
if deleted_payment_ids:
|
||||
from ..models import Payment
|
||||
Payment.objects.filter(id__in=deleted_payment_ids, order=order).delete()
|
||||
|
||||
# Обрабатываем платежи (payments)
|
||||
if 'payments' in data:
|
||||
from ..models import Payment, PaymentMethod
|
||||
payments_data = data['payments']
|
||||
|
||||
# Обрабатываем каждый платеж
|
||||
for payment_data in payments_data:
|
||||
payment_id = payment_data.get('id') # ID существующего платежа (если есть)
|
||||
payment_method_id = payment_data.get('payment_method_id')
|
||||
amount_raw = payment_data.get('amount', '')
|
||||
notes = payment_data.get('notes', '')
|
||||
|
||||
# Пропускаем пустые платежи
|
||||
if not payment_method_id or not amount_raw:
|
||||
continue
|
||||
|
||||
# Конвертируем сумму в Decimal
|
||||
try:
|
||||
amount = Decimal(str(amount_raw))
|
||||
if amount <= 0:
|
||||
continue
|
||||
except (ValueError, TypeError, decimal.InvalidOperation):
|
||||
continue
|
||||
|
||||
# Получаем способ оплаты
|
||||
try:
|
||||
payment_method = PaymentMethod.objects.get(pk=payment_method_id)
|
||||
except PaymentMethod.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Обновляем существующий платеж или создаём новый
|
||||
if payment_id:
|
||||
# Обновляем существующий платеж
|
||||
try:
|
||||
payment = Payment.objects.get(id=payment_id, order=order)
|
||||
payment.payment_method = payment_method
|
||||
payment.amount = amount
|
||||
payment.notes = notes
|
||||
payment.save()
|
||||
except Payment.DoesNotExist:
|
||||
# Если платеж не найден, создаём новый
|
||||
Payment.objects.create(
|
||||
order=order,
|
||||
payment_method=payment_method,
|
||||
amount=amount,
|
||||
notes=notes,
|
||||
created_by=user
|
||||
)
|
||||
else:
|
||||
# Создаём новый платеж
|
||||
Payment.objects.create(
|
||||
order=order,
|
||||
payment_method=payment_method,
|
||||
amount=amount,
|
||||
notes=notes,
|
||||
created_by=user
|
||||
)
|
||||
|
||||
order.modified_by = user
|
||||
order.last_autosave_at = timezone.now()
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
// Конфигурация
|
||||
const CONFIG = {
|
||||
AUTOSAVE_DELAY: 3000, // Задержка перед автосохранением (мс)
|
||||
AUTOSAVE_URL_PATTERN: '/orders/{orderId}/autosave/',
|
||||
AUTOSAVE_URL_PATTERN: '/orders/{orderNumber}/autosave/',
|
||||
STATUS_DISPLAY_DURATION: 5000, // Длительность показа статуса (мс)
|
||||
};
|
||||
|
||||
// Состояние модуля
|
||||
let autosaveTimer = null;
|
||||
let isAutosaving = false;
|
||||
let orderId = null;
|
||||
let orderNumber = null;
|
||||
|
||||
/**
|
||||
* Инициализация модуля автосохранения
|
||||
@@ -35,12 +35,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Получаем ID заказа из URL
|
||||
// Получаем номер заказа из URL
|
||||
const urlMatch = window.location.pathname.match(/\/orders\/(\d+)\/edit\//);
|
||||
if (!urlMatch) {
|
||||
return;
|
||||
}
|
||||
orderId = urlMatch[1];
|
||||
orderNumber = urlMatch[1];
|
||||
|
||||
// Инициализируем UI индикатора
|
||||
initStatusIndicator();
|
||||
@@ -141,7 +141,6 @@
|
||||
'input[name="delivery_time_start"]',
|
||||
'input[name="delivery_time_end"]',
|
||||
'input[name="delivery_cost"]',
|
||||
'select[name="payment_method"]',
|
||||
'textarea[name="special_instructions"]',
|
||||
'input[name="discount_amount"]',
|
||||
'input[type="checkbox"]',
|
||||
@@ -176,6 +175,9 @@
|
||||
|
||||
// Слушаем изменения в формах товаров (formset)
|
||||
observeFormsetChanges();
|
||||
|
||||
// Слушаем изменения в формах платежей (payment formset)
|
||||
observePaymentFormsetChanges();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,7 +215,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = form.querySelectorAll('select, input[type="number"], input[type="checkbox"]');
|
||||
const fields = form.querySelectorAll('select, input[type="number"], input[type="text"], input[type="checkbox"]');
|
||||
|
||||
fields.forEach(field => {
|
||||
if (field.tagName === 'SELECT' || field.type === 'checkbox') {
|
||||
@@ -232,6 +234,55 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Наблюдает за изменениями в формсете платежей
|
||||
*/
|
||||
function observePaymentFormsetChanges() {
|
||||
const paymentsContainer = document.getElementById('payments-container');
|
||||
if (!paymentsContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Наблюдаем за добавлением/удалением форм платежей
|
||||
const observer = new MutationObserver(() => {
|
||||
attachPaymentFormsetEventListeners();
|
||||
});
|
||||
|
||||
observer.observe(paymentsContainer, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Прикрепляем обработчики к существующим формам
|
||||
attachPaymentFormsetEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикрепляет обработчики к полям в формах платежей
|
||||
*/
|
||||
function attachPaymentFormsetEventListeners() {
|
||||
const paymentForms = document.querySelectorAll('.payment-form');
|
||||
|
||||
paymentForms.forEach(form => {
|
||||
// Если уже прикреплены обработчики, пропускаем
|
||||
if (form.dataset.autosavePaymentAttached === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = form.querySelectorAll('select, input[type="number"], input[type="text"], textarea, input[type="checkbox"]');
|
||||
|
||||
fields.forEach(field => {
|
||||
if (field.tagName === 'SELECT' || field.type === 'checkbox') {
|
||||
field.addEventListener('change', scheduleAutosave);
|
||||
} else {
|
||||
field.addEventListener('input', scheduleAutosave);
|
||||
}
|
||||
});
|
||||
|
||||
form.dataset.autosavePaymentAttached = 'true';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Планирует автосохранение с задержкой (debouncing)
|
||||
*/
|
||||
@@ -263,7 +314,7 @@
|
||||
const formData = collectFormData();
|
||||
|
||||
// Отправляем AJAX запрос
|
||||
const url = CONFIG.AUTOSAVE_URL_PATTERN.replace('{orderId}', orderId);
|
||||
const url = CONFIG.AUTOSAVE_URL_PATTERN.replace('{orderNumber}', orderNumber);
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -327,11 +378,6 @@
|
||||
data.delivery_cost = deliveryCostField.value;
|
||||
}
|
||||
|
||||
const paymentMethodField = form.querySelector('select[name="payment_method"]');
|
||||
if (paymentMethodField && paymentMethodField.value) {
|
||||
data.payment_method = paymentMethodField.value;
|
||||
}
|
||||
|
||||
const specialInstructionsField = form.querySelector('textarea[name="special_instructions"]');
|
||||
if (specialInstructionsField) {
|
||||
data.special_instructions = specialInstructionsField.value;
|
||||
@@ -421,7 +467,14 @@
|
||||
}
|
||||
|
||||
// Собираем позиции заказа
|
||||
data.items = collectOrderItems();
|
||||
const orderItemsData = collectOrderItems();
|
||||
data.items = orderItemsData.items;
|
||||
data.deleted_item_ids = orderItemsData.deletedItemIds;
|
||||
|
||||
// Собираем платежи
|
||||
const paymentsData = collectPayments();
|
||||
data.payments = paymentsData.payments;
|
||||
data.deleted_payment_ids = paymentsData.deletedPaymentIds;
|
||||
|
||||
// Флаг для пересчета итоговой суммы
|
||||
data.recalculate = true;
|
||||
@@ -434,13 +487,20 @@
|
||||
*/
|
||||
function collectOrderItems() {
|
||||
const items = [];
|
||||
const deletedItemIds = [];
|
||||
const itemForms = document.querySelectorAll('.order-item-form');
|
||||
|
||||
itemForms.forEach(form => {
|
||||
// Пропускаем удаленные формы
|
||||
// Проверяем, помечена ли форма на удаление
|
||||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||
const idField = form.querySelector('input[name$="-id"]');
|
||||
|
||||
if (deleteCheckbox && deleteCheckbox.checked) {
|
||||
return;
|
||||
// Если форма помечена на удаление и имеет ID, добавляем в список удалённых
|
||||
if (idField && idField.value) {
|
||||
deletedItemIds.push(parseInt(idField.value));
|
||||
}
|
||||
return; // Не добавляем в items
|
||||
}
|
||||
|
||||
// Получаем выбранный товар/комплект
|
||||
@@ -459,9 +519,14 @@
|
||||
|
||||
const item = {
|
||||
quantity: quantityInput.value || '1',
|
||||
price: priceInput.value || '0'
|
||||
price: (priceInput.value || '0').replace(',', '.')
|
||||
};
|
||||
|
||||
// Если есть ID (существующий товар), добавляем его
|
||||
if (idField && idField.value) {
|
||||
item.id = parseInt(idField.value);
|
||||
}
|
||||
|
||||
// Определяем тип: товар или комплект
|
||||
if (itemValue.startsWith('product_')) {
|
||||
item.product_id = parseInt(itemValue.replace('product_', ''));
|
||||
@@ -472,7 +537,54 @@
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
return items;
|
||||
return { items, deletedItemIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает данные о платежах
|
||||
*/
|
||||
function collectPayments() {
|
||||
const payments = [];
|
||||
const deletedPaymentIds = [];
|
||||
const paymentForms = document.querySelectorAll('.payment-form');
|
||||
|
||||
paymentForms.forEach(form => {
|
||||
// Проверяем, помечена ли форма на удаление
|
||||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||
const idField = form.querySelector('input[name$="-id"]');
|
||||
|
||||
if (deleteCheckbox && deleteCheckbox.checked) {
|
||||
// Если форма помечена на удаление и имеет ID, добавляем в список удалённых
|
||||
if (idField && idField.value) {
|
||||
deletedPaymentIds.push(parseInt(idField.value));
|
||||
}
|
||||
return; // Не добавляем в payments
|
||||
}
|
||||
|
||||
// Получаем способ оплаты и сумму
|
||||
const paymentMethodSelect = form.querySelector('select[name$="-payment_method"]');
|
||||
const amountInput = form.querySelector('input[name$="-amount"]');
|
||||
const notesInput = form.querySelector('textarea[name$="-notes"]');
|
||||
|
||||
if (!paymentMethodSelect || !paymentMethodSelect.value || !amountInput || !amountInput.value) {
|
||||
return; // Пропускаем пустые платежи
|
||||
}
|
||||
|
||||
const payment = {
|
||||
payment_method_id: parseInt(paymentMethodSelect.value),
|
||||
amount: (amountInput.value || '0').replace(',', '.'),
|
||||
notes: notesInput ? notesInput.value : ''
|
||||
};
|
||||
|
||||
// Если есть ID (существующий платеж), добавляем его
|
||||
if (idField && idField.value) {
|
||||
payment.id = parseInt(idField.value);
|
||||
}
|
||||
|
||||
payments.push(payment);
|
||||
});
|
||||
|
||||
return { payments, deletedPaymentIds };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -544,4 +656,9 @@
|
||||
init();
|
||||
}
|
||||
|
||||
// Экспортируем функцию scheduleAutosave в глобальную область
|
||||
window.orderAutosave = {
|
||||
scheduleAutosave: scheduleAutosave
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<form method="post" class="mt-4">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'orders:order-detail' order.pk %}" class="btn btn-secondary btn-lg">
|
||||
<a href="{% url 'orders:order-detail' order.order_number %}" class="btn btn-secondary btn-lg">
|
||||
<i class="bi bi-arrow-left"></i> Отмена
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-lg">
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<h1>Заказ {{ order.order_number }}</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{% url 'orders:order-update' order.pk %}" class="btn btn-primary">
|
||||
<a href="{% url 'orders:order-update' order.order_number %}" class="btn btn-primary">
|
||||
<i class="bi bi-pencil"></i> Редактировать
|
||||
</a>
|
||||
<a href="{% url 'orders:order-delete' order.pk %}" class="btn btn-danger">
|
||||
<a href="{% url 'orders:order-delete' order.order_number %}" class="btn btn-danger">
|
||||
<i class="bi bi-trash"></i> Удалить
|
||||
</a>
|
||||
<a href="{% url 'orders:order-list' %}" class="btn btn-secondary">
|
||||
@@ -236,6 +236,64 @@
|
||||
|
||||
<!-- Правая колонка -->
|
||||
<div class="col-md-4">
|
||||
<!-- Кошелёк клиента -->
|
||||
{% if order.customer and order.customer.wallet_balance > 0 and order.amount_due > 0 %}
|
||||
<div class="card mb-3 border-success">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">Кошелёк клиента</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">
|
||||
<strong>Баланс:</strong>
|
||||
<span class="h5 text-success">{{ order.customer.wallet_balance|floatformat:2 }} руб.</span>
|
||||
</p>
|
||||
<p class="text-muted small">Можно использовать для оплаты этого заказа</p>
|
||||
|
||||
<!-- Кнопка "Применить максимум" -->
|
||||
<form method="post" action="{% url 'orders:apply-wallet' order.order_number %}" class="mb-2">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="wallet_amount" value="{% if order.customer.wallet_balance < order.amount_due %}{{ order.customer.wallet_balance|floatformat:2 }}{% else %}{{ order.amount_due|floatformat:2 }}{% endif %}">
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-wallet2"></i> Применить максимум
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Ручной ввод суммы -->
|
||||
<form method="post" action="{% url 'orders:apply-wallet' order.order_number %}">
|
||||
{% csrf_token %}
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="{% if order.customer.wallet_balance < order.amount_due %}{{ order.customer.wallet_balance|floatformat:2 }}{% else %}{{ order.amount_due|floatformat:2 }}{% endif %}"
|
||||
name="wallet_amount"
|
||||
class="form-control"
|
||||
placeholder="Сумма"
|
||||
>
|
||||
<button type="submit" class="btn btn-outline-success">
|
||||
Оплатить
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted">Введите сумму для списания из кошелька</small>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% elif order.customer and order.customer.wallet_balance > 0 %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Кошелёк клиента</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-0">
|
||||
<strong>Баланс:</strong>
|
||||
<span class="h5 text-success">{{ order.customer.wallet_balance|floatformat:2 }} руб.</span>
|
||||
</p>
|
||||
<small class="text-muted">Заказ уже оплачен полностью</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Оплата -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
{% load l10n %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
@@ -11,7 +12,8 @@
|
||||
}
|
||||
|
||||
/* Визуально помечаем удаленные формы */
|
||||
.order-item-form.deleted {
|
||||
.order-item-form.deleted,
|
||||
.payment-form.deleted {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -124,7 +126,15 @@
|
||||
<label for="{{ form.customer.id_for_label }}" class="form-label">
|
||||
Клиент <span class="text-danger">*</span>
|
||||
</label>
|
||||
{% if preselected_customer %}
|
||||
<select name="customer" class="form-select" id="id_customer">
|
||||
<option value="{{ preselected_customer.pk }}" selected data-name="{{ preselected_customer.name }}" data-phone="{{ preselected_customer.phone|default:'' }}" data-email="{{ preselected_customer.email|default:'' }}">
|
||||
{{ preselected_customer.name }}{% if preselected_customer.phone %} ({{ preselected_customer.phone }}){% endif %}
|
||||
</option>
|
||||
</select>
|
||||
{% else %}
|
||||
{{ form.customer }}
|
||||
{% endif %}
|
||||
{% if form.customer.errors %}
|
||||
<div class="text-danger">{{ form.customer.errors }}</div>
|
||||
{% endif %}
|
||||
@@ -227,11 +237,11 @@
|
||||
<label class="form-label">Цена</label>
|
||||
<div class="position-relative">
|
||||
{% if item_form.instance.product %}
|
||||
<input type="number" name="{{ item_form.price.name }}" step="0.01" min="0" class="form-control" id="{{ item_form.price.id_for_label }}" value="{{ item_form.instance.price }}" data-original-price="{{ item_form.instance.product.actual_price }}">
|
||||
<input type="text" name="{{ item_form.prefix }}-price" step="0.01" min="0" class="form-control" id="{{ item_form.price.id_for_label }}" value="{% if item_form.instance.price and item_form.instance.price != 0 %}{{ item_form.instance.price }}{% else %}{{ item_form.instance.product.actual_price }}{% endif %}" data-original-price="{{ item_form.instance.product.actual_price }}">
|
||||
{% elif item_form.instance.product_kit %}
|
||||
<input type="number" name="{{ item_form.price.name }}" step="0.01" min="0" class="form-control" id="{{ item_form.price.id_for_label }}" value="{{ item_form.instance.price }}" data-original-price="{{ item_form.instance.product_kit.actual_price }}">
|
||||
<input type="text" name="{{ item_form.prefix }}-price" step="0.01" min="0" class="form-control" id="{{ item_form.price.id_for_label }}" value="{% if item_form.instance.price and item_form.instance.price != 0 %}{{ item_form.instance.price }}{% else %}{{ item_form.instance.product_kit.actual_price }}{% endif %}" data-original-price="{{ item_form.instance.product_kit.actual_price }}">
|
||||
{% else %}
|
||||
{{ item_form.price }}
|
||||
<input type="text" name="{{ item_form.prefix }}-price" step="0.01" min="0" class="form-control" id="{{ item_form.price.id_for_label }}" value="{{ item_form.instance.price|default:'' }}" data-original-price="0">
|
||||
{% endif %}
|
||||
<span class="custom-price-badge badge bg-warning position-absolute top-0 end-0 mt-1 me-1" style="display: none;">
|
||||
Изменена
|
||||
@@ -302,7 +312,7 @@
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Цена</label>
|
||||
<div class="position-relative">
|
||||
<input type="number" name="items-__prefix__-price" step="0.01" min="0" class="form-control" id="id_items-__prefix__-price">
|
||||
<input type="text" name="items-__prefix__-price" step="0.01" min="0" class="form-control" id="id_items-__prefix__-price">
|
||||
<span class="custom-price-badge badge bg-warning position-absolute top-0 end-0 mt-1 me-1" style="display: none;">
|
||||
Изменена
|
||||
</span>
|
||||
@@ -462,6 +472,7 @@
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="{{ form.address_confirm_with_recipient.id_for_label }}"
|
||||
name="{{ form.address_confirm_with_recipient.name }}"
|
||||
{% if form.address_confirm_with_recipient.value %}checked{% endif %}
|
||||
style="width: 3em; height: 1.5em; cursor: pointer;">
|
||||
<label class="form-check-label" for="{{ form.address_confirm_with_recipient.id_for_label }}"
|
||||
style="font-size: 1.1em; font-weight: 500; cursor: pointer; padding-left: 0.5em;">
|
||||
@@ -483,6 +494,7 @@
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="{{ form.customer_is_recipient.id_for_label }}"
|
||||
name="{{ form.customer_is_recipient.name }}"
|
||||
{% if form.customer_is_recipient.value %}checked{% endif %}
|
||||
style="width: 3em; height: 1.5em; cursor: pointer;">
|
||||
<label class="form-check-label" for="{{ form.customer_is_recipient.id_for_label }}"
|
||||
style="font-size: 1.1em; font-weight: 500; cursor: pointer; padding-left: 0.5em;">
|
||||
@@ -557,19 +569,145 @@
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Оплата и дополнительно -->
|
||||
<!-- Оплата (смешанная оплата) -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Оплата</h5>
|
||||
<div>
|
||||
<span class="badge bg-{% if order.payment_status == 'paid' %}success{% elif order.payment_status == 'partial' %}warning{% else %}danger{% endif %} me-2">
|
||||
{{ order.get_payment_status_display }}
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-success" id="add-payment-btn">
|
||||
<i class="bi bi-plus-circle"></i> Добавить платеж
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
|
||||
<!-- Блок кошелька клиента -->
|
||||
{% if order.customer %}
|
||||
<div class="alert alert-info d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>Кошелёк клиента:</strong>
|
||||
{% if order.customer.wallet_balance > 0 %}
|
||||
<span class="text-success fw-bold">{{ order.customer.wallet_balance|floatformat:2 }} руб.</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0.00 руб.</span>
|
||||
{% endif %}
|
||||
<span class="ms-3">Остаток к оплате: <strong>{{ order.amount_due|floatformat:2 }} руб.</strong></span>
|
||||
</div>
|
||||
{% if order.customer.wallet_balance > 0 and order.amount_due > 0 %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="apply-wallet-max-btn">
|
||||
Учесть максимум
|
||||
</button>
|
||||
<div class="input-group" style="max-width: 280px;">
|
||||
<input type="number" step="0.01" min="0" class="form-control form-control-sm" id="apply-wallet-amount-input" placeholder="Сумма из кошелька">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="apply-wallet-amount-btn">Учесть сумму</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Скрытые поля для formset management -->
|
||||
{{ payment_formset.management_form }}
|
||||
|
||||
<!-- Контейнер для платежей -->
|
||||
<div id="payments-container">
|
||||
{% for payment_form in payment_formset %}
|
||||
<div class="payment-form border rounded p-3 mb-3" data-form-index="{{ forloop.counter0 }}">
|
||||
{{ payment_form.id }}
|
||||
{{ payment_form.DELETE }}
|
||||
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.payment_method.id_for_label }}" class="form-label">Способ оплаты</label>
|
||||
{{ form.payment_method }}
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Способ оплаты</label>
|
||||
{{ payment_form.payment_method }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Сумма</label>
|
||||
{{ payment_form.amount }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Примечания</label>
|
||||
{{ payment_form.notes }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button type="button" class="btn btn-danger btn-sm w-100 remove-payment-btn">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if payment_form.errors %}
|
||||
<div class="alert alert-danger mt-2">{{ payment_form.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Итоговая сумма платежей -->
|
||||
<div id="payments-total-section" class="border-top pt-3 mt-3 mb-3">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<p class="mb-0 text-muted">Внесено платежей:</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<h5 class="mb-0 text-success">
|
||||
<span id="payments-total-value">0.00</span> руб.
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Скрытый шаблон для новых платежей -->
|
||||
<template id="empty-payment-form-template">
|
||||
<div class="payment-form border rounded p-3 mb-3" data-form-index="__prefix__">
|
||||
<input type="hidden" name="payments-__prefix__-id" id="id_payments-__prefix__-id">
|
||||
<input type="checkbox" name="payments-__prefix__-DELETE" id="id_payments-__prefix__-DELETE" style="display: none;">
|
||||
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Способ оплаты</label>
|
||||
<select name="payments-__prefix__-payment_method" class="form-select" id="id_payments-__prefix__-payment_method">
|
||||
<option value="">---------</option>
|
||||
{% for pm in payment_formset.forms.0.fields.payment_method.queryset %}
|
||||
<option value="{{ pm.id }}">{{ pm.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Сумма</label>
|
||||
<input type="number" name="payments-__prefix__-amount" step="0.01" min="0" class="form-control" placeholder="Сумма платежа" id="id_payments-__prefix__-amount">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Примечания</label>
|
||||
<textarea name="payments-__prefix__-notes" class="form-control" rows="1" placeholder="Примечания к платежу (опционально)" id="id_payments-__prefix__-notes"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button type="button" class="btn btn-danger btn-sm w-100 remove-payment-btn">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Скидка -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.discount_amount.id_for_label }}" class="form-label">Скидка</label>
|
||||
@@ -580,6 +718,98 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const walletBalance = parseFloat("{{ order.customer.wallet_balance|default:'0' }}".replace(',', '.')) || 0;
|
||||
const amountDue = parseFloat("{{ order.amount_due|default:'0' }}".replace(',', '.')) || 0;
|
||||
|
||||
function addPaymentRow() {
|
||||
const totalFormsInput = document.querySelector('input[name="payments-TOTAL_FORMS"]');
|
||||
const idx = parseInt(totalFormsInput.value, 10);
|
||||
const tpl = document.getElementById('empty-payment-form-template').innerHTML.replace(/__prefix__/g, idx);
|
||||
const container = document.getElementById('payments-container');
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = tpl.trim();
|
||||
container.appendChild(wrapper.firstElementChild);
|
||||
totalFormsInput.value = String(idx + 1);
|
||||
return container.querySelector(`.payment-form[data-form-index="${idx}"]`);
|
||||
}
|
||||
|
||||
function selectAccountBalance(selectEl) {
|
||||
if (!selectEl) return;
|
||||
const options = Array.from(selectEl.options);
|
||||
const target = options.find(o => o.textContent.trim() === 'С баланса счёта');
|
||||
if (target) {
|
||||
selectEl.value = target.value;
|
||||
selectEl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function applyWallet(amount) {
|
||||
if (!amount || amount <= 0) {
|
||||
alert('Введите сумму больше 0.');
|
||||
return;
|
||||
}
|
||||
if (amount > walletBalance) {
|
||||
alert(`Недостаточно средств в кошельке. Доступно ${walletBalance.toFixed(2)} руб.`);
|
||||
return;
|
||||
}
|
||||
if (amount > amountDue) {
|
||||
alert(`Сумма превышает остаток к оплате (${amountDue.toFixed(2)} руб.).`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Найти существующую форму платежа без метода, иначе добавить новую
|
||||
let formEl = document.querySelector('#payments-container .payment-form:last-child');
|
||||
if (!formEl) {
|
||||
formEl = addPaymentRow();
|
||||
}
|
||||
const sel = formEl.querySelector('select[id^="id_payments-"][id$="-payment_method"]');
|
||||
const amt = formEl.querySelector('input[id^="id_payments-"][id$="-amount"]');
|
||||
|
||||
selectAccountBalance(sel);
|
||||
if (amt) {
|
||||
amt.value = amount.toFixed(2);
|
||||
amt.setAttribute('max', Math.min(walletBalance, amountDue).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
const applyMaxBtn = document.getElementById('apply-wallet-max-btn');
|
||||
const applyAmountBtn = document.getElementById('apply-wallet-amount-btn');
|
||||
const amountInput = document.getElementById('apply-wallet-amount-input');
|
||||
|
||||
if (applyMaxBtn) {
|
||||
applyMaxBtn.addEventListener('click', function() {
|
||||
const maxUsable = Math.min(walletBalance, amountDue);
|
||||
applyWallet(maxUsable);
|
||||
});
|
||||
}
|
||||
if (applyAmountBtn && amountInput) {
|
||||
applyAmountBtn.addEventListener('click', function() {
|
||||
const val = parseFloat((amountInput.value || '0').replace(',', '.')) || 0;
|
||||
applyWallet(val);
|
||||
});
|
||||
}
|
||||
|
||||
// Автозаполнение при выборе "С баланса счёта"
|
||||
document.getElementById('payments-container').addEventListener('change', function(e) {
|
||||
const sel = e.target;
|
||||
if (sel.tagName === 'SELECT') {
|
||||
const label = sel.options[sel.selectedIndex]?.textContent?.trim() || '';
|
||||
if (label === 'С баланса счёта') {
|
||||
const wrap = sel.closest('.payment-form');
|
||||
const amt = wrap.querySelector('input[id^="id_payments-"][id$="-amount"]');
|
||||
if (amt) {
|
||||
const maxUsable = Math.min(walletBalance, amountDue);
|
||||
amt.value = maxUsable.toFixed(2);
|
||||
amt.setAttribute('max', maxUsable.toFixed(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Дополнительно</h5>
|
||||
@@ -613,6 +843,90 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Глобально определяем initOrderItemSelect2, чтобы она была доступна при вызове ниже
|
||||
window.initOrderItemSelect2 = function(element) {
|
||||
console.log('[initOrderItemSelect2] Вызвана для элемента:', element);
|
||||
|
||||
// Проверяем доступность jQuery
|
||||
if (typeof $ === 'undefined') {
|
||||
console.error('[initOrderItemSelect2] jQuery не загружен!');
|
||||
return;
|
||||
}
|
||||
|
||||
const $element = $(element);
|
||||
const formIndex = element.dataset.formIndex;
|
||||
console.log('[initOrderItemSelect2] formIndex:', formIndex);
|
||||
|
||||
// Проверяем, что функция initProductSelect2 доступна
|
||||
if (typeof window.initProductSelect2 !== 'function') {
|
||||
console.error('[initOrderItemSelect2] window.initProductSelect2 не определена. Убедитесь, что select2-product-search.js загружен.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[initOrderItemSelect2] Инициализация Select2 через initProductSelect2...');
|
||||
// Инициализируем Select2 с AJAX поиском
|
||||
window.initProductSelect2(
|
||||
element,
|
||||
'all', // Искать и товары, и комплекты
|
||||
'{% url "products:api-search-products-variants" %}'
|
||||
);
|
||||
|
||||
// Обработка выбора элемента
|
||||
$element.on('select2:select', function(e) {
|
||||
// Проверяем наличие params (может не быть при программном вызове)
|
||||
if (!e.params || !e.params.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = e.params.data;
|
||||
const idParts = data.id.split('_');
|
||||
const type = idParts[0]; // 'product' или 'kit'
|
||||
const id = idParts[1];
|
||||
|
||||
// Найти скрытые поля product и product_kit
|
||||
const form = element.closest('.order-item-form');
|
||||
const productField = form.querySelector('[name$="-product"]');
|
||||
const kitField = form.querySelector('[name$="-product_kit"]');
|
||||
const priceField = form.querySelector('[name$="-price"]');
|
||||
const isCustomPriceField = form.querySelector('[name$="-is_custom_price"]');
|
||||
|
||||
const originalPrice = data.actual_price || data.price || '';
|
||||
|
||||
// Установить значение в правильное поле
|
||||
if (type === 'product') {
|
||||
productField.value = id;
|
||||
kitField.value = '';
|
||||
priceField.value = originalPrice;
|
||||
} else if (type === 'kit') {
|
||||
kitField.value = id;
|
||||
productField.value = '';
|
||||
priceField.value = originalPrice;
|
||||
}
|
||||
|
||||
// Сохраняем оригинальную цену в data-атрибуте
|
||||
priceField.dataset.originalPrice = originalPrice;
|
||||
|
||||
// Сбрасываем флаг кастомной цены
|
||||
isCustomPriceField.value = 'false';
|
||||
|
||||
// Скрываем индикатор
|
||||
const badge = form.querySelector('.custom-price-badge');
|
||||
const priceInfo = form.querySelector('.original-price-info');
|
||||
if (badge) badge.style.display = 'none';
|
||||
if (priceInfo) priceInfo.style.display = 'none';
|
||||
});
|
||||
|
||||
// Очистка при удалении выбора
|
||||
$element.on('select2:clear', function() {
|
||||
const form = element.closest('.order-item-form');
|
||||
form.querySelector('[name$="-product"]').value = '';
|
||||
form.querySelector('[name$="-product_kit"]').value = '';
|
||||
form.querySelector('[name$="-price"]').value = '';
|
||||
});
|
||||
|
||||
console.log('[initOrderItemSelect2] Инициализация завершена успешно');
|
||||
};
|
||||
|
||||
// Ждем пока jQuery загрузится
|
||||
function initCustomerSelect2() {
|
||||
if (typeof $ === 'undefined') {
|
||||
@@ -686,6 +1000,14 @@ function initCustomerSelect2() {
|
||||
console.log('Значение восстановлено:', $customerSelect.val());
|
||||
}
|
||||
|
||||
// Уведомляем draft-creator.js что Select2 готов и есть предзаполненное значение
|
||||
if (currentValue && window.DraftCreator) {
|
||||
console.log('7. Уведомляем DraftCreator о предзаполненном клиенте');
|
||||
setTimeout(function() {
|
||||
window.DraftCreator.triggerDraftCreation();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Слушаем события
|
||||
$customerSelect.on('select2:open', function(e) {
|
||||
console.log('7. Dropdown открыт');
|
||||
@@ -920,79 +1242,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
customerIsRecipientCheckbox.addEventListener('change', toggleRecipientFields);
|
||||
toggleRecipientFields(); // Инициализация при загрузке
|
||||
|
||||
// Инициализация Select2 для поиска товаров/комплектов
|
||||
// ВНИМАНИЕ: Эта функция будет вызвана ПОСЛЕ загрузки select2-product-search.js
|
||||
window.initOrderItemSelect2 = function(element) {
|
||||
const $element = $(element);
|
||||
const formIndex = element.dataset.formIndex;
|
||||
|
||||
// Проверяем, что функция initProductSelect2 доступна
|
||||
if (typeof window.initProductSelect2 !== 'function') {
|
||||
console.error('window.initProductSelect2 is not defined. Make sure select2-product-search.js is loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Инициализируем Select2 с AJAX поиском
|
||||
window.initProductSelect2(
|
||||
element,
|
||||
'all', // Искать и товары, и комплекты
|
||||
'{% url "products:api-search-products-variants" %}'
|
||||
);
|
||||
|
||||
// Обработка выбора элемента
|
||||
$element.on('select2:select', function(e) {
|
||||
// Проверяем наличие params (может не быть при программном вызове)
|
||||
if (!e.params || !e.params.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = e.params.data;
|
||||
const idParts = data.id.split('_');
|
||||
const type = idParts[0]; // 'product' или 'kit'
|
||||
const id = idParts[1];
|
||||
|
||||
// Найти скрытые поля product и product_kit
|
||||
const form = element.closest('.order-item-form');
|
||||
const productField = form.querySelector('[name$="-product"]');
|
||||
const kitField = form.querySelector('[name$="-product_kit"]');
|
||||
const priceField = form.querySelector('[name$="-price"]');
|
||||
const isCustomPriceField = form.querySelector('[name$="-is_custom_price"]');
|
||||
|
||||
const originalPrice = data.actual_price || data.price || '';
|
||||
|
||||
// Установить значение в правильное поле
|
||||
if (type === 'product') {
|
||||
productField.value = id;
|
||||
kitField.value = '';
|
||||
priceField.value = originalPrice;
|
||||
} else if (type === 'kit') {
|
||||
kitField.value = id;
|
||||
productField.value = '';
|
||||
priceField.value = originalPrice;
|
||||
}
|
||||
|
||||
// Сохраняем оригинальную цену в data-атрибуте
|
||||
priceField.dataset.originalPrice = originalPrice;
|
||||
|
||||
// Сбрасываем флаг кастомной цены
|
||||
isCustomPriceField.value = 'false';
|
||||
|
||||
// Скрываем индикатор
|
||||
const badge = form.querySelector('.custom-price-badge');
|
||||
const priceInfo = form.querySelector('.original-price-info');
|
||||
if (badge) badge.style.display = 'none';
|
||||
if (priceInfo) priceInfo.style.display = 'none';
|
||||
});
|
||||
|
||||
// Очистка при удалении выбора
|
||||
$element.on('select2:clear', function() {
|
||||
const form = element.closest('.order-item-form');
|
||||
form.querySelector('[name$="-product"]').value = '';
|
||||
form.querySelector('[name$="-product_kit"]').value = '';
|
||||
form.querySelector('[name$="-price"]').value = '';
|
||||
});
|
||||
};
|
||||
|
||||
// === РАСЧЁТ ИТОГОВОЙ СУММЫ ТОВАРОВ ===
|
||||
function calculateOrderItemsTotal() {
|
||||
// Собираем все видимые (не удалённые) формы товаров
|
||||
@@ -1001,18 +1250,34 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
let total = 0;
|
||||
|
||||
console.log('[TOTAL] Calculating total for', visibleForms.length, 'forms');
|
||||
|
||||
// Для каждого товара: количество × цена
|
||||
visibleForms.forEach(form => {
|
||||
visibleForms.forEach((form, index) => {
|
||||
const quantityField = form.querySelector('[name$="-quantity"]');
|
||||
const priceField = form.querySelector('[name$="-price"]');
|
||||
|
||||
console.log(`[TOTAL] Form ${index}:`, form);
|
||||
console.log(`[TOTAL] Form ${index}: quantityField=${quantityField}, priceField=${priceField}`);
|
||||
const allInputs = form.querySelectorAll('input');
|
||||
console.log(`[TOTAL] Form ${index}: All inputs:`, allInputs);
|
||||
allInputs.forEach((input, i) => {
|
||||
console.log(` Input ${i}: name="${input.name}", id="${input.id}", type="${input.type}"`);
|
||||
});
|
||||
|
||||
if (quantityField && priceField) {
|
||||
const quantity = parseFloat(quantityField.value) || 0;
|
||||
const price = parseFloat(priceField.value) || 0;
|
||||
// Заменяем запятую на точку для корректного парсинга
|
||||
const priceValue = priceField.value.replace(',', '.');
|
||||
const price = parseFloat(priceValue) || 0;
|
||||
console.log(`[TOTAL] Form ${index}: quantity=${quantityField.value} (parsed: ${quantity}), price="${priceField.value}" (parsed: ${price}), subtotal=${quantity * price}`);
|
||||
total += quantity * price;
|
||||
} else {
|
||||
console.log(`[TOTAL] Form ${index}: SKIPPED - missing fields!`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[TOTAL] Final total:', total);
|
||||
return total;
|
||||
}
|
||||
|
||||
@@ -1129,25 +1394,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Функция для удаления формы
|
||||
function removeForm(form) {
|
||||
// Показываем диалог подтверждения
|
||||
if (!confirm('Вы действительно хотите удалить этот товар из заказа?')) {
|
||||
return; // Пользователь отменил удаление
|
||||
}
|
||||
|
||||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||
const idField = form.querySelector('input[name$="-id"]');
|
||||
|
||||
// Проверяем, не последняя ли это форма
|
||||
const visibleForms = Array.from(container.querySelectorAll('.order-item-form'))
|
||||
.filter(f => !f.classList.contains('deleted'));
|
||||
|
||||
if (visibleForms.length <= 1) {
|
||||
alert('Нельзя удалить единственную позицию. Добавьте новую позицию перед удалением этой.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Если форма уже сохранена (есть ID), помечаем на удаление
|
||||
if (idField && idField.value) {
|
||||
deleteCheckbox.checked = true;
|
||||
form.classList.add('deleted');
|
||||
console.log('Form marked for deletion');
|
||||
form.style.display = 'none'; // Скрываем форму визуально
|
||||
console.log('Form marked for deletion, id:', idField.value);
|
||||
// Обновляем итоговую сумму после удаления
|
||||
updateOrderItemsTotal();
|
||||
// Триггерим автосохранение для отправки изменений
|
||||
if (typeof window.orderAutosave !== 'undefined' && window.orderAutosave.scheduleAutosave) {
|
||||
window.orderAutosave.scheduleAutosave();
|
||||
}
|
||||
} else {
|
||||
// Если форма новая, просто удаляем из DOM
|
||||
form.remove();
|
||||
@@ -1176,26 +1442,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Инициализируем итоговую сумму при загрузке страницы
|
||||
updateOrderItemsTotal();
|
||||
|
||||
// Валидация перед отправкой
|
||||
// Валидация перед отправкой (убрана обязательность товаров — можно сохранить пустой заказ)
|
||||
document.getElementById('order-form').addEventListener('submit', function(e) {
|
||||
const visibleForms = Array.from(container.querySelectorAll('.order-item-form'))
|
||||
.filter(f => !f.classList.contains('deleted'));
|
||||
|
||||
// Проверяем, что есть хотя бы одна позиция с товаром
|
||||
let hasItems = false;
|
||||
visibleForms.forEach(form => {
|
||||
const productField = form.querySelector('[name$="-product"]');
|
||||
const kitField = form.querySelector('[name$="-product_kit"]');
|
||||
if (productField.value || kitField.value) {
|
||||
hasItems = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasItems && visibleForms.length > 0) {
|
||||
e.preventDefault();
|
||||
alert('Добавьте хотя бы один товар или комплект в заказ');
|
||||
return false;
|
||||
}
|
||||
// Валидация отключена — заказ можно сохранить без товаров
|
||||
// Товары можно добавить позже
|
||||
});
|
||||
|
||||
// === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
|
||||
@@ -1437,6 +1687,141 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// === УПРАВЛЕНИЕ ПЛАТЕЖАМИ (СМЕШАННАЯ ОПЛАТА) ===
|
||||
|
||||
const paymentsContainer = document.getElementById('payments-container');
|
||||
const addPaymentBtn = document.getElementById('add-payment-btn');
|
||||
const paymentFormTemplate = document.getElementById('empty-payment-form-template');
|
||||
let paymentFormCount = parseInt(document.querySelector('[name="payments-TOTAL_FORMS"]').value);
|
||||
|
||||
// Функция для расчета итоговой суммы платежей
|
||||
function calculatePaymentsTotal() {
|
||||
const visiblePaymentForms = Array.from(document.querySelectorAll('.payment-form'))
|
||||
.filter(form => !form.classList.contains('deleted'));
|
||||
|
||||
let total = 0;
|
||||
|
||||
visiblePaymentForms.forEach((form) => {
|
||||
const amountField = form.querySelector('[name$="-amount"]');
|
||||
if (amountField) {
|
||||
const amount = parseFloat(amountField.value.replace(',', '.')) || 0;
|
||||
total += amount;
|
||||
}
|
||||
});
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function updatePaymentsTotal() {
|
||||
const total = calculatePaymentsTotal();
|
||||
const totalElement = document.getElementById('payments-total-value');
|
||||
|
||||
if (totalElement) {
|
||||
totalElement.textContent = total.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для добавления нового платежа
|
||||
function addNewPayment() {
|
||||
const newPaymentHtml = paymentFormTemplate.content.cloneNode(true);
|
||||
const newPaymentDiv = newPaymentHtml.querySelector('.payment-form');
|
||||
|
||||
// Заменяем __prefix__ на актуальный индекс
|
||||
newPaymentDiv.innerHTML = newPaymentDiv.innerHTML.replace(/__prefix__/g, paymentFormCount);
|
||||
newPaymentDiv.setAttribute('data-form-index', paymentFormCount);
|
||||
|
||||
// Добавляем в контейнер
|
||||
paymentsContainer.appendChild(newPaymentDiv);
|
||||
|
||||
// Обновляем счетчик форм
|
||||
paymentFormCount++;
|
||||
document.querySelector('[name="payments-TOTAL_FORMS"]').value = paymentFormCount;
|
||||
|
||||
// Добавляем обработчик удаления
|
||||
const removeBtn = newPaymentDiv.querySelector('.remove-payment-btn');
|
||||
removeBtn.addEventListener('click', function() {
|
||||
removePayment(newPaymentDiv);
|
||||
});
|
||||
|
||||
// Добавляем обработчики для автоматического пересчета
|
||||
const amountField = newPaymentDiv.querySelector('[name$="-amount"]');
|
||||
if (amountField) {
|
||||
amountField.addEventListener('input', updatePaymentsTotal);
|
||||
}
|
||||
|
||||
// Загружаем payment methods в select
|
||||
loadPaymentMethods(newPaymentDiv.querySelector('select[name$="-payment_method"]'));
|
||||
|
||||
// Обновляем итоговую сумму
|
||||
updatePaymentsTotal();
|
||||
|
||||
return newPaymentDiv;
|
||||
}
|
||||
|
||||
// Функция для удаления платежа
|
||||
function removePayment(form) {
|
||||
if (!confirm('Вы действительно хотите удалить этот платеж?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||
const idField = form.querySelector('input[name$="-id"]');
|
||||
|
||||
// Если форма уже сохранена (есть ID), помечаем на удаление
|
||||
if (idField && idField.value) {
|
||||
deleteCheckbox.checked = true;
|
||||
form.classList.add('deleted');
|
||||
form.style.display = 'none';
|
||||
console.log('Payment form marked for deletion, id:', idField.value);
|
||||
} else {
|
||||
// Если форма новая, просто удаляем из DOM
|
||||
form.remove();
|
||||
console.log('Payment form removed from DOM');
|
||||
}
|
||||
|
||||
// Обновляем итоговую сумму
|
||||
updatePaymentsTotal();
|
||||
}
|
||||
|
||||
// Функция для загрузки активных payment methods
|
||||
function loadPaymentMethods(selectElement) {
|
||||
fetch('/products/api/payment-methods/')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
selectElement.innerHTML = '<option value="">---------</option>';
|
||||
data.forEach(method => {
|
||||
const option = document.createElement('option');
|
||||
option.value = method.id;
|
||||
option.textContent = method.name;
|
||||
selectElement.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading payment methods:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Обработчик кнопки "Добавить платеж"
|
||||
if (addPaymentBtn) {
|
||||
addPaymentBtn.addEventListener('click', addNewPayment);
|
||||
}
|
||||
|
||||
// Добавляем обработчики удаления для существующих платежей
|
||||
paymentsContainer.querySelectorAll('.remove-payment-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const form = this.closest('.payment-form');
|
||||
removePayment(form);
|
||||
});
|
||||
});
|
||||
|
||||
// Добавляем обработчики для автоматического пересчета для существующих форм
|
||||
paymentsContainer.querySelectorAll('[name$="-amount"]').forEach(field => {
|
||||
field.addEventListener('input', updatePaymentsTotal);
|
||||
});
|
||||
|
||||
// Инициализируем итоговую сумму при загрузке страницы
|
||||
updatePaymentsTotal();
|
||||
|
||||
// Закрытие обработчика DOMContentLoaded для управления типом доставки и остальных функций
|
||||
});
|
||||
</script>
|
||||
@@ -1757,13 +2142,42 @@ if (!document.getElementById('notification-styles')) {
|
||||
|
||||
<script>
|
||||
// Инициализируем все существующие Select2 для товаров после загрузки модуля
|
||||
(function() {
|
||||
if (typeof window.initOrderItemSelect2 === 'function') {
|
||||
document.querySelectorAll('.select2-order-item').forEach(window.initOrderItemSelect2);
|
||||
console.log('[Order Items] Select2 initialized for existing items');
|
||||
} else {
|
||||
console.error('[Order Items] window.initOrderItemSelect2 is not defined');
|
||||
(function initExistingOrderItems() {
|
||||
console.log('[Order Items] Начало инициализации существующих элементов');
|
||||
console.log('[Order Items] jQuery доступен?', typeof $ !== 'undefined');
|
||||
console.log('[Order Items] initOrderItemSelect2 доступен?', typeof window.initOrderItemSelect2 === 'function');
|
||||
console.log('[Order Items] initProductSelect2 доступен?', typeof window.initProductSelect2 === 'function');
|
||||
|
||||
// Проверяем все зависимости
|
||||
if (typeof $ === 'undefined') {
|
||||
console.log('[Order Items] Ожидание загрузки jQuery...');
|
||||
setTimeout(initExistingOrderItems, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.initOrderItemSelect2 !== 'function') {
|
||||
console.log('[Order Items] Ожидание инициализации initOrderItemSelect2...');
|
||||
setTimeout(initExistingOrderItems, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.initProductSelect2 !== 'function') {
|
||||
console.log('[Order Items] Ожидание загрузки initProductSelect2 из select2-product-search.js...');
|
||||
setTimeout(initExistingOrderItems, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Все зависимости готовы
|
||||
console.log('[Order Items] Все зависимости готовы, запуск инициализации...');
|
||||
const items = document.querySelectorAll('.select2-order-item');
|
||||
console.log('[Order Items] Найдено элементов для инициализации:', items.length);
|
||||
|
||||
items.forEach((item, index) => {
|
||||
console.log(`[Order Items] Инициализация элемента ${index + 1}/${items.length}`);
|
||||
window.initOrderItemSelect2(item);
|
||||
});
|
||||
|
||||
console.log('[Order Items] Инициализация всех существующих элементов завершена');
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
{% for order in page_obj %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'orders:order-detail' order.pk %}" class="text-decoration-none">
|
||||
<a href="{% url 'orders:order-detail' order.order_number %}" class="text-decoration-none">
|
||||
<strong>{{ order.order_number }}</strong>
|
||||
</a>
|
||||
</td>
|
||||
@@ -130,13 +130,23 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="js-status-container" data-order-number="{{ order.order_number }}">
|
||||
<span class="badge badge-lg js-status-badge" style="{% if order.status %}background-color: {{ order.status.color }}; color: #fff;{% else %}background-color: #6c757d; color: #fff;{% endif %} cursor: pointer; font-size: 0.9rem; padding: 0.5rem 0.75rem;" title="Кликните для изменения">
|
||||
{% if order.status %}
|
||||
<span class="badge" style="background-color: {{ order.status.color }}; color: #fff;">
|
||||
{{ order.status.label|default:order.status.name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Не установлен</span>
|
||||
Не установлен
|
||||
{% endif %}
|
||||
</span>
|
||||
<select class="form-select form-select-sm js-status-select" style="display: none;">
|
||||
<option value="" {% if not order.status_id %}selected{% endif %}>Не установлен</option>
|
||||
{% for s in status_choices %}
|
||||
<option value="{{ s.pk }}" data-color="{{ s.color }}" data-label="{{ s.label|default:s.name }}" {% if order.status_id == s.pk %}selected{% endif %}>
|
||||
{{ s.label|default:s.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td><strong>{{ order.total_amount }} руб.</strong></td>
|
||||
<td>
|
||||
@@ -155,12 +165,12 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'orders:order-detail' order.pk %}"
|
||||
<a href="{% url 'orders:order-detail' order.order_number %}"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
title="Просмотр">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'orders:order-update' order.pk %}"
|
||||
<a href="{% url 'orders:order-update' order.order_number %}"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="Редактировать">
|
||||
<i class="bi bi-pencil"></i>
|
||||
@@ -225,4 +235,79 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'orders/js/date_filter.js' %}"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const csrfToken = '{{ csrf_token }}';
|
||||
|
||||
async function updateStatus(orderNumber, statusId) {
|
||||
const body = new URLSearchParams({ status_id: statusId }).toString();
|
||||
const resp = await fetch(`/orders/api/${orderNumber}/set-status/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body
|
||||
});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.js-status-container').forEach(function(container) {
|
||||
const badge = container.querySelector('.js-status-badge');
|
||||
const select = container.querySelector('.js-status-select');
|
||||
const orderNumber = container.dataset.orderNumber;
|
||||
|
||||
// Click on badge: show select
|
||||
badge.addEventListener('click', function() {
|
||||
badge.style.display = 'none';
|
||||
select.style.display = 'inline-block';
|
||||
select.focus();
|
||||
// Open dropdown programmatically
|
||||
if (select.showPicker) {
|
||||
select.showPicker();
|
||||
} else {
|
||||
// Fallback for browsers without showPicker
|
||||
select.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Change status
|
||||
select.addEventListener('change', async function() {
|
||||
const statusId = select.value || '';
|
||||
const selectedOption = select.options[select.selectedIndex];
|
||||
select.disabled = true;
|
||||
|
||||
try {
|
||||
const result = await updateStatus(orderNumber, statusId);
|
||||
if (result.success) {
|
||||
// Update badge
|
||||
const newColor = selectedOption.dataset.color || '#6c757d';
|
||||
const newLabel = selectedOption.dataset.label || 'Не установлен';
|
||||
badge.style.backgroundColor = newColor;
|
||||
badge.style.color = '#fff';
|
||||
badge.textContent = newLabel;
|
||||
|
||||
// Show badge, hide select
|
||||
select.style.display = 'none';
|
||||
badge.style.display = 'inline-block';
|
||||
} else {
|
||||
alert(result.error || 'Не удалось обновить статус');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка сервера при обновлении статуса');
|
||||
} finally {
|
||||
select.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside or blur: hide select, show badge
|
||||
select.addEventListener('blur', function() {
|
||||
setTimeout(function() {
|
||||
select.style.display = 'none';
|
||||
badge.style.display = 'inline-block';
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,15 +7,21 @@ app_name = 'orders'
|
||||
urlpatterns = [
|
||||
path('', views.order_list, name='order-list'),
|
||||
path('create/', views.order_create, name='order-create'),
|
||||
path('<int:pk>/', views.order_detail, name='order-detail'),
|
||||
path('<int:pk>/edit/', views.order_update, name='order-update'),
|
||||
path('<int:pk>/delete/', views.order_delete, name='order-delete'),
|
||||
path('<int:order_number>/', views.order_detail, name='order-detail'),
|
||||
path('<int:order_number>/edit/', views.order_update, name='order-update'),
|
||||
path('<int:order_number>/delete/', views.order_delete, name='order-delete'),
|
||||
|
||||
# AJAX endpoints
|
||||
path('<int:pk>/autosave/', views.autosave_draft_order, name='order-autosave'),
|
||||
path('<int:order_number>/autosave/', views.autosave_draft_order, name='order-autosave'),
|
||||
path('create-draft/', views.create_draft_from_form, name='order-create-draft'),
|
||||
path('api/customer-address-history/', views.get_customer_address_history, name='api-customer-address-history'),
|
||||
|
||||
# Wallet payment
|
||||
path('<int:order_number>/apply-wallet/', views.apply_wallet_payment, name='apply-wallet'),
|
||||
|
||||
# AJAX status update
|
||||
path('api/<int:order_number>/set-status/', views.set_order_status, name='api-set-order-status'),
|
||||
|
||||
# Order Status Management URLs
|
||||
path('statuses/', views.order_status_list, name='status_list'),
|
||||
path('statuses/create/', views.order_status_create, name='status_create'),
|
||||
|
||||
@@ -7,8 +7,9 @@ from django.views.decorators.http import require_http_methods
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from decimal import Decimal
|
||||
from .models import Order, OrderItem, Address, OrderStatus
|
||||
from .forms import OrderForm, OrderItemFormSet, OrderStatusForm
|
||||
from .forms import OrderForm, OrderItemFormSet, OrderStatusForm, PaymentFormSet
|
||||
from .filters import OrderFilter
|
||||
from .services import DraftOrderService
|
||||
from .services.address_service import AddressService
|
||||
@@ -45,12 +46,12 @@ def order_list(request):
|
||||
return render(request, 'orders/order_list.html', context)
|
||||
|
||||
|
||||
def order_detail(request, pk):
|
||||
def order_detail(request, order_number):
|
||||
"""Детальная информация о заказе"""
|
||||
order = get_object_or_404(
|
||||
Order.objects.select_related('customer', 'delivery_address', 'pickup_warehouse', 'modified_by')
|
||||
.prefetch_related('items__product', 'items__product_kit', 'payments__created_by'),
|
||||
pk=pk
|
||||
order_number=order_number
|
||||
)
|
||||
|
||||
context = {
|
||||
@@ -65,8 +66,9 @@ def order_create(request):
|
||||
if request.method == 'POST':
|
||||
form = OrderForm(request.POST)
|
||||
formset = OrderItemFormSet(request.POST)
|
||||
payment_formset = PaymentFormSet(request.POST)
|
||||
|
||||
if form.is_valid() and formset.is_valid():
|
||||
if form.is_valid() and formset.is_valid() and payment_formset.is_valid():
|
||||
order = form.save(commit=False)
|
||||
|
||||
# Обрабатываем адрес доставки
|
||||
@@ -90,6 +92,10 @@ def order_create(request):
|
||||
formset.instance = order
|
||||
formset.save()
|
||||
|
||||
# Сохраняем платежи
|
||||
payment_formset.instance = order
|
||||
payment_formset.save()
|
||||
|
||||
# Пересчитываем итоговую сумму
|
||||
order.calculate_total()
|
||||
order.save()
|
||||
@@ -98,16 +104,31 @@ def order_create(request):
|
||||
messages.success(request, f'Черновик #{order.order_number} успешно создан!')
|
||||
else:
|
||||
messages.success(request, f'Заказ #{order.order_number} успешно создан!')
|
||||
return redirect('orders:order-detail', pk=order.pk)
|
||||
return redirect('orders:order-detail', order_number=order.order_number)
|
||||
else:
|
||||
messages.error(request, 'Пожалуйста, исправьте ошибки в форме.')
|
||||
else:
|
||||
form = OrderForm()
|
||||
# Предзаполнение клиента из GET параметра
|
||||
initial_data = {}
|
||||
preselected_customer = None
|
||||
customer_id = request.GET.get('customer')
|
||||
if customer_id:
|
||||
try:
|
||||
from customers.models import Customer
|
||||
preselected_customer = Customer.objects.get(pk=customer_id)
|
||||
initial_data['customer'] = preselected_customer.pk
|
||||
except (Customer.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
form = OrderForm(initial=initial_data)
|
||||
formset = OrderItemFormSet()
|
||||
payment_formset = PaymentFormSet()
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'formset': formset,
|
||||
'payment_formset': payment_formset,
|
||||
'preselected_customer': preselected_customer,
|
||||
'title': 'Создание заказа',
|
||||
'button_text': 'Создать заказ',
|
||||
}
|
||||
@@ -115,15 +136,16 @@ def order_create(request):
|
||||
return render(request, 'orders/order_form.html', context)
|
||||
|
||||
|
||||
def order_update(request, pk):
|
||||
def order_update(request, order_number):
|
||||
"""Редактирование заказа"""
|
||||
order = get_object_or_404(Order, pk=pk)
|
||||
order = get_object_or_404(Order, order_number=order_number)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = OrderForm(request.POST, instance=order)
|
||||
formset = OrderItemFormSet(request.POST, instance=order)
|
||||
payment_formset = PaymentFormSet(request.POST, instance=order)
|
||||
|
||||
if form.is_valid() and formset.is_valid():
|
||||
if form.is_valid() and formset.is_valid() and payment_formset.is_valid():
|
||||
order = form.save(commit=False)
|
||||
|
||||
# Если черновик финализируется
|
||||
@@ -131,11 +153,12 @@ def order_update(request, pk):
|
||||
try:
|
||||
order = DraftOrderService.finalize_draft(order.pk, request.user)
|
||||
messages.success(request, f'Черновик #{order.order_number} успешно завершен и переведен в статус "Новый"!')
|
||||
return redirect('orders:order-detail', pk=order.pk)
|
||||
return redirect('orders:order-detail', order_number=order.order_number)
|
||||
except ValidationError as e:
|
||||
messages.error(request, f'Ошибка финализации: {str(e)}')
|
||||
form = OrderForm(instance=order)
|
||||
formset = OrderItemFormSet(instance=order)
|
||||
payment_formset = PaymentFormSet(instance=order)
|
||||
else:
|
||||
# Обрабатываем адрес доставки
|
||||
if order.is_delivery:
|
||||
@@ -166,6 +189,9 @@ def order_update(request, pk):
|
||||
order.save()
|
||||
formset.save()
|
||||
|
||||
# Сохраняем платежи
|
||||
payment_formset.save()
|
||||
|
||||
# Пересчитываем итоговую сумму
|
||||
order.calculate_total()
|
||||
order.save()
|
||||
@@ -174,16 +200,32 @@ def order_update(request, pk):
|
||||
messages.success(request, f'Черновик #{order.order_number} успешно обновлен!')
|
||||
else:
|
||||
messages.success(request, f'Заказ #{order.order_number} успешно обновлен!')
|
||||
return redirect('orders:order-detail', pk=order.pk)
|
||||
return redirect('orders:order-detail', order_number=order.order_number)
|
||||
else:
|
||||
# Логируем ошибки для отладки
|
||||
print("\n=== ОШИБКИ ВАЛИДАЦИИ ФОРМЫ ===")
|
||||
if not form.is_valid():
|
||||
print(f"OrderForm errors: {form.errors}")
|
||||
if not formset.is_valid():
|
||||
print(f"OrderItemFormSet errors: {formset.errors}")
|
||||
print(f"OrderItemFormSet non_form_errors: {formset.non_form_errors()}")
|
||||
for i, item_form in enumerate(formset):
|
||||
if item_form.errors:
|
||||
print(f" Item form {i} errors: {item_form.errors}")
|
||||
if not payment_formset.is_valid():
|
||||
print(f"PaymentFormSet errors: {payment_formset.errors}")
|
||||
print(f"PaymentFormSet non_form_errors: {payment_formset.non_form_errors()}")
|
||||
print("=== КОНЕЦ ОШИБОК ===\n")
|
||||
messages.error(request, 'Пожалуйста, исправьте ошибки в форме.')
|
||||
else:
|
||||
form = OrderForm(instance=order)
|
||||
formset = OrderItemFormSet(instance=order)
|
||||
payment_formset = PaymentFormSet(instance=order)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'formset': formset,
|
||||
'payment_formset': payment_formset,
|
||||
'order': order,
|
||||
'title': f'Редактирование {"черновика" if order.is_draft() else "заказа"} #{order.order_number}',
|
||||
'button_text': 'Сохранить изменения',
|
||||
@@ -193,9 +235,9 @@ def order_update(request, pk):
|
||||
return render(request, 'orders/order_form.html', context)
|
||||
|
||||
|
||||
def order_delete(request, pk):
|
||||
def order_delete(request, order_number):
|
||||
"""Удаление заказа с подтверждением"""
|
||||
order = get_object_or_404(Order, pk=pk)
|
||||
order = get_object_or_404(Order, order_number=order_number)
|
||||
|
||||
if request.method == 'POST':
|
||||
order_number = order.order_number
|
||||
@@ -214,7 +256,7 @@ def order_delete(request, pk):
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@login_required
|
||||
def autosave_draft_order(request, pk):
|
||||
def autosave_draft_order(request, order_number):
|
||||
"""
|
||||
AJAX endpoint для автосохранения черновика заказа.
|
||||
|
||||
@@ -247,32 +289,71 @@ def autosave_draft_order(request, pk):
|
||||
|
||||
# Проверяем существование заказа
|
||||
try:
|
||||
order = Order.objects.get(pk=pk)
|
||||
order = Order.objects.get(order_number=order_number)
|
||||
except Order.DoesNotExist:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Заказ не найден'
|
||||
}, status=404)
|
||||
|
||||
# Используем DraftOrderService для обновления
|
||||
# Обновляем основные поля заказа из DraftOrderService (БЕЗ товаров)
|
||||
# Товары обрабатываем отдельно ниже
|
||||
order_fields_only = {k: v for k, v in data.items() if k not in ['items', 'payments']}
|
||||
order = DraftOrderService.update_draft(
|
||||
order_id=pk,
|
||||
order_id=order.pk,
|
||||
user=request.user,
|
||||
data=data
|
||||
data=order_fields_only
|
||||
)
|
||||
|
||||
# Обрабатываем позиции заказа, если они переданы
|
||||
if 'items' in data:
|
||||
# Удаляем существующие позиции
|
||||
order.items.all().delete()
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
# Создаем новые позиции
|
||||
# Получаем ID товаров, которые нужно удалить
|
||||
deleted_item_ids = data.get('deleted_item_ids', [])
|
||||
if deleted_item_ids:
|
||||
order.items.filter(pk__in=deleted_item_ids).delete()
|
||||
|
||||
# Обрабатываем каждый товар
|
||||
for item_data in data['items']:
|
||||
item_id = item_data.get('id') # ID существующего товара (если есть)
|
||||
product_id = item_data.get('product_id')
|
||||
product_kit_id = item_data.get('product_kit_id')
|
||||
quantity = item_data.get('quantity')
|
||||
price = item_data.get('price')
|
||||
price_raw = item_data.get('price')
|
||||
|
||||
# Преобразуем цену
|
||||
try:
|
||||
price = Decimal(str(price_raw).replace(',', '.')) if price_raw else None
|
||||
except (ValueError, InvalidOperation):
|
||||
price = None
|
||||
|
||||
# Если есть ID - обновляем существующий товар
|
||||
if item_id:
|
||||
try:
|
||||
item = order.items.get(pk=item_id)
|
||||
# Обновляем поля
|
||||
if product_id:
|
||||
from products.models import Product
|
||||
item.product = Product.objects.get(pk=product_id)
|
||||
item.product_kit = None
|
||||
elif product_kit_id:
|
||||
from products.models import ProductKit
|
||||
item.product_kit = ProductKit.objects.get(pk=product_kit_id)
|
||||
item.product = None
|
||||
|
||||
if quantity:
|
||||
item.quantity = quantity
|
||||
if price is not None:
|
||||
item.price = price
|
||||
|
||||
item.save()
|
||||
except OrderItem.DoesNotExist:
|
||||
# Если товар не найден, создаем новый
|
||||
item_id = None
|
||||
|
||||
# Если нет ID - создаем новый товар
|
||||
if not item_id:
|
||||
if product_id:
|
||||
DraftOrderService.add_item_to_draft(
|
||||
order_id=order.pk,
|
||||
@@ -288,6 +369,14 @@ def autosave_draft_order(request, pk):
|
||||
price=price
|
||||
)
|
||||
|
||||
# НЕ ОБРАБАТЫВАЕМ ПЛАТЕЖИ В АВТОСОХРАНЕНИИ
|
||||
# Платежи обрабатываются только при ручном сохранении формы
|
||||
|
||||
# Пересчитываем итоговую сумму заказа и обновляем статус оплаты
|
||||
order.calculate_total()
|
||||
order.update_payment_status()
|
||||
order.save()
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'last_saved': order.last_autosave_at.isoformat() if order.last_autosave_at else None,
|
||||
@@ -390,7 +479,7 @@ def create_draft_from_form(request):
|
||||
'success': True,
|
||||
'order_id': order.pk,
|
||||
'order_number': order.order_number,
|
||||
'redirect_url': f'/orders/{order.pk}/edit/'
|
||||
'redirect_url': f'/orders/{order.order_number}/edit/'
|
||||
})
|
||||
|
||||
except ValidationError as e:
|
||||
@@ -590,3 +679,87 @@ def order_status_delete(request, pk):
|
||||
# === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
|
||||
# УДАЛЕНО: Логика создания временных комплектов перенесена в products.services.kit_service
|
||||
# Используйте API endpoint: products:api-temporary-kit-create
|
||||
|
||||
|
||||
# === КОШЕЛЁК КЛИЕНТА ===
|
||||
|
||||
@login_required
|
||||
def apply_wallet_payment(request, order_number):
|
||||
"""
|
||||
Применение оплаты из кошелька клиента к заказу.
|
||||
Вызывается через POST-запрос с суммой для списания.
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return redirect('orders:order-detail', order_number=order_number)
|
||||
|
||||
order = get_object_or_404(Order, order_number=order_number)
|
||||
|
||||
# Получаем запрашиваемую сумму из формы
|
||||
try:
|
||||
raw_amount = request.POST.get('wallet_amount', '0')
|
||||
amount = Decimal(str(raw_amount).replace(',', '.'))
|
||||
except (ValueError, TypeError, ArithmeticError):
|
||||
messages.error(request, 'Некорректная сумма для списания из кошелька.')
|
||||
return redirect('orders:order-detail', pk=pk)
|
||||
|
||||
# Вызываем сервис для оплаты из кошелька
|
||||
try:
|
||||
from customers.services.wallet_service import WalletService
|
||||
paid_amount = WalletService.pay_with_wallet(order, amount, request.user)
|
||||
|
||||
if paid_amount and paid_amount > 0:
|
||||
messages.success(
|
||||
request,
|
||||
f'Из кошелька клиента списано {paid_amount} руб. для оплаты заказа #{order.order_number}.'
|
||||
)
|
||||
else:
|
||||
messages.warning(
|
||||
request,
|
||||
'Не удалось списать средства из кошелька. Проверьте баланс и сумму заказа.'
|
||||
)
|
||||
except ValueError as e:
|
||||
messages.error(request, str(e))
|
||||
except Exception as e:
|
||||
messages.error(request, f'Ошибка при оплате из кошелька: {str(e)}')
|
||||
|
||||
return redirect('orders:order-detail', order_number=order.order_number)
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@login_required
|
||||
def set_order_status(request, order_number):
|
||||
"""
|
||||
Update order status via AJAX.
|
||||
Accepts POST with 'status_id' (can be empty to clear).
|
||||
Returns JSON with the resulting status info.
|
||||
"""
|
||||
try:
|
||||
order = get_object_or_404(Order, order_number=order_number)
|
||||
status_id = request.POST.get('status_id', '').strip()
|
||||
|
||||
# Allow clearing status if empty
|
||||
if status_id == '':
|
||||
order.status = None
|
||||
order.modified_by = request.user
|
||||
order.save(update_fields=['status', 'modified_by', 'updated_at'])
|
||||
return JsonResponse({'success': True, 'status': None})
|
||||
|
||||
try:
|
||||
status = OrderStatus.objects.get(pk=status_id)
|
||||
except OrderStatus.DoesNotExist:
|
||||
return JsonResponse({'success': False, 'error': 'Status not found'}, status=404)
|
||||
|
||||
order.status = status
|
||||
order.modified_by = request.user
|
||||
order.save(update_fields=['status', 'modified_by', 'updated_at'])
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': {
|
||||
'id': status.pk,
|
||||
'name': status.label or status.name,
|
||||
'color': status.color
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
return JsonResponse({'success': False, 'error': f'Server error: {str(e)}'}, status=500)
|
||||
|
||||
@@ -2,10 +2,116 @@
|
||||
|
||||
{% block title %}{% if object %}Редактировать товар{% else %}Создать товар{% endif %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Адаптивная grid-система для чекбоксов категорий и тегов */
|
||||
/* Нацеливаемся на любой UL внутри .checkbox-grid */
|
||||
#id_categories,
|
||||
#id_tags,
|
||||
.checkbox-grid ul,
|
||||
.checkbox-grid > ul,
|
||||
.checkbox-grid div > ul,
|
||||
.checkbox-grid * ul {
|
||||
display: grid !important;
|
||||
gap: 0.35rem !important;
|
||||
list-style: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
/* 1 столбец на маленьких экранах */
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
/* 2 столбца на средних экранах (≥768px) */
|
||||
@media (min-width: 768px) {
|
||||
#id_categories,
|
||||
#id_tags,
|
||||
.checkbox-grid ul,
|
||||
.checkbox-grid > ul,
|
||||
.checkbox-grid div > ul,
|
||||
.checkbox-grid * ul {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 3 столбца на больших экранах (≥1200px) */
|
||||
@media (min-width: 1200px) {
|
||||
#id_categories,
|
||||
#id_tags,
|
||||
.checkbox-grid ul,
|
||||
.checkbox-grid > ul,
|
||||
.checkbox-grid div > ul,
|
||||
.checkbox-grid * ul {
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 4 столбца на очень больших экранах (≥1600px) */
|
||||
@media (min-width: 1600px) {
|
||||
#id_categories,
|
||||
#id_tags,
|
||||
.checkbox-grid ul,
|
||||
.checkbox-grid > ul,
|
||||
.checkbox-grid div > ul,
|
||||
.checkbox-grid * ul {
|
||||
grid-template-columns: repeat(4, 1fr) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Стилизация элементов списка */
|
||||
#id_categories li,
|
||||
#id_tags li,
|
||||
.checkbox-grid li {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Стилизация чекбоксов */
|
||||
#id_categories label,
|
||||
#id_tags label,
|
||||
.checkbox-grid label {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
margin-bottom: 0 !important;
|
||||
padding: 0.4rem 0.6rem !important;
|
||||
background: white !important;
|
||||
border-radius: 0.375rem !important;
|
||||
transition: all 0.2s ease !important;
|
||||
cursor: pointer !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
#id_categories label:hover,
|
||||
#id_tags label:hover,
|
||||
.checkbox-grid label:hover {
|
||||
background: #e9ecef !important;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
#id_categories input[type="checkbox"],
|
||||
#id_tags input[type="checkbox"],
|
||||
.checkbox-grid input[type="checkbox"] {
|
||||
margin-right: 0.5rem !important;
|
||||
cursor: pointer !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
/* Стиль для выбранных чекбоксов */
|
||||
#id_categories li:has(input[type="checkbox"]:checked) label,
|
||||
#id_tags li:has(input[type="checkbox"]:checked) label,
|
||||
.checkbox-grid li:has(input[type="checkbox"]:checked) label {
|
||||
background: #e7f3ff !important;
|
||||
border-left: 3px solid #0d6efd !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="col-12 col-xl-10">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
@@ -76,7 +182,7 @@
|
||||
<i class="bi bi-plus-circle"></i> Новая
|
||||
</a>
|
||||
</div>
|
||||
<div class="p-3 bg-light rounded">
|
||||
<div class="p-3 bg-light rounded checkbox-grid">
|
||||
{{ form.categories }}
|
||||
</div>
|
||||
{% if form.categories.help_text %}
|
||||
@@ -90,7 +196,7 @@
|
||||
<!-- Теги -->
|
||||
<div class="mb-3">
|
||||
{{ form.tags.label_tag }}
|
||||
<div class="p-3 bg-light rounded">
|
||||
<div class="p-3 bg-light rounded checkbox-grid">
|
||||
{{ form.tags }}
|
||||
</div>
|
||||
{% if form.tags.help_text %}
|
||||
@@ -400,3 +506,78 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Диагностика и принудительное применение grid для чекбоксов
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('=== Checkbox Grid Diagnostic ===');
|
||||
|
||||
// Ищем все UL элементы внутри .checkbox-grid
|
||||
const checkboxGrids = document.querySelectorAll('.checkbox-grid');
|
||||
|
||||
checkboxGrids.forEach((grid, index) => {
|
||||
console.log(`Grid ${index}:`, grid);
|
||||
|
||||
// Ищем все UL внутри этого grid
|
||||
const lists = grid.querySelectorAll('ul');
|
||||
console.log(` Found ${lists.length} UL elements`);
|
||||
|
||||
lists.forEach((ul, ulIndex) => {
|
||||
console.log(` UL ${ulIndex}:`, ul);
|
||||
console.log(` ID: ${ul.id}`);
|
||||
console.log(` Current display: ${window.getComputedStyle(ul).display}`);
|
||||
console.log(` Current grid-template-columns: ${window.getComputedStyle(ul).gridTemplateColumns}`);
|
||||
|
||||
// Принудительно применяем grid стили
|
||||
ul.style.setProperty('display', 'grid', 'important');
|
||||
ul.style.setProperty('list-style', 'none', 'important');
|
||||
ul.style.setProperty('padding', '0', 'important');
|
||||
ul.style.setProperty('margin', '0', 'important');
|
||||
ul.style.setProperty('gap', '0.35rem', 'important');
|
||||
|
||||
// Определяем количество колонок на основе ширины экрана
|
||||
const width = window.innerWidth;
|
||||
let columns = 1;
|
||||
if (width >= 1600) columns = 4;
|
||||
else if (width >= 1200) columns = 3;
|
||||
else if (width >= 768) columns = 2;
|
||||
|
||||
ul.style.setProperty('grid-template-columns', `repeat(${columns}, 1fr)`, 'important');
|
||||
|
||||
console.log(` Applied ${columns} columns`);
|
||||
console.log(` New display: ${window.getComputedStyle(ul).display}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Также проверяем прямые ID
|
||||
['id_categories', 'id_tags'].forEach(id => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
console.log(`Found element with ID: ${id}`, element);
|
||||
console.log(` Tag name: ${element.tagName}`);
|
||||
console.log(` Parent: ${element.parentElement.className}`);
|
||||
} else {
|
||||
console.log(`Element with ID ${id} NOT found`);
|
||||
}
|
||||
});
|
||||
|
||||
// Пересчитываем при изменении размера окна
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', function() {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(function() {
|
||||
const width = window.innerWidth;
|
||||
let columns = 1;
|
||||
if (width >= 1600) columns = 4;
|
||||
else if (width >= 1200) columns = 3;
|
||||
else if (width >= 768) columns = 2;
|
||||
|
||||
document.querySelectorAll('.checkbox-grid ul, #id_categories, #id_tags').forEach(ul => {
|
||||
ul.style.setProperty('grid-template-columns', `repeat(${columns}, 1fr)`, 'important');
|
||||
});
|
||||
}, 250);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-between">
|
||||
<a href="{% url 'orders:order-detail' kit.order.pk %}" class="btn btn-secondary">
|
||||
<a href="{% url 'orders:order-detail' kit.order.order_number %}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Отмена
|
||||
</a>
|
||||
<button type="submit" class="btn btn-success">
|
||||
|
||||
@@ -50,6 +50,7 @@ urlpatterns = [
|
||||
path('api/categories/create/', api_views.create_category_api, name='api-category-create'),
|
||||
path('api/categories/<int:pk>/rename/', api_views.rename_category_api, name='api-category-rename'),
|
||||
path('api/products/<int:pk>/update-price/', api_views.update_product_price_api, name='api-update-product-price'),
|
||||
path('api/payment-methods/', api_views.get_payment_methods, name='api-payment-methods'),
|
||||
|
||||
# Photo processing status API (for AJAX polling)
|
||||
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),
|
||||
|
||||
@@ -73,7 +73,7 @@ def search_products_and_variants(request):
|
||||
numeric_id = int(item_id)
|
||||
|
||||
if item_type == 'product':
|
||||
product = Product.objects.get(id=numeric_id, is_active=True)
|
||||
product = Product.objects.get(id=numeric_id, status='active')
|
||||
return JsonResponse({
|
||||
'results': [{
|
||||
'id': f'product_{product.id}',
|
||||
@@ -89,7 +89,7 @@ def search_products_and_variants(request):
|
||||
elif item_type == 'kit':
|
||||
# Для комплектов: временные комплекты можно получать по ID (для заказов)
|
||||
# но не показываем их в общем поиске
|
||||
kit = ProductKit.objects.get(id=numeric_id, is_active=True)
|
||||
kit = ProductKit.objects.get(id=numeric_id, status='active')
|
||||
return JsonResponse({
|
||||
'results': [{
|
||||
'id': f'kit_{kit.id}',
|
||||
@@ -170,7 +170,7 @@ def search_products_and_variants(request):
|
||||
|
||||
if search_type in ['all', 'kit']:
|
||||
# Показываем последние добавленные активные комплекты (только постоянные)
|
||||
kits = ProductKit.objects.filter(is_active=True, is_temporary=False)\
|
||||
kits = ProductKit.objects.filter(status='active', is_temporary=False)\
|
||||
.order_by('-created_at')[:page_size]\
|
||||
.values('id', 'name', 'sku', 'price', 'sale_price')
|
||||
|
||||
@@ -244,7 +244,7 @@ def search_products_and_variants(request):
|
||||
models.Q(name_lower__contains=query_lower) |
|
||||
models.Q(sku_lower__contains=query_lower) |
|
||||
models.Q(description_lower__contains=query_lower),
|
||||
is_active=True
|
||||
status='active'
|
||||
).annotate(
|
||||
relevance=Case(
|
||||
When(name_lower=query_lower, then=3),
|
||||
@@ -259,7 +259,7 @@ def search_products_and_variants(request):
|
||||
models.Q(name__icontains=query_normalized) |
|
||||
models.Q(sku__icontains=query_normalized) |
|
||||
models.Q(description__icontains=query_normalized),
|
||||
is_active=True
|
||||
status='active'
|
||||
).annotate(
|
||||
relevance=Case(
|
||||
When(name__iexact=query_normalized, then=3),
|
||||
@@ -310,7 +310,7 @@ def search_products_and_variants(request):
|
||||
models.Q(name_lower__contains=query_lower) |
|
||||
models.Q(sku_lower__contains=query_lower) |
|
||||
models.Q(description_lower__contains=query_lower),
|
||||
is_active=True,
|
||||
status='active',
|
||||
is_temporary=False
|
||||
).annotate(
|
||||
relevance=Case(
|
||||
@@ -325,7 +325,7 @@ def search_products_and_variants(request):
|
||||
models.Q(name__icontains=query_normalized) |
|
||||
models.Q(sku__icontains=query_normalized) |
|
||||
models.Q(description__icontains=query_normalized),
|
||||
is_active=True,
|
||||
status='active',
|
||||
is_temporary=False
|
||||
).annotate(
|
||||
relevance=Case(
|
||||
@@ -498,7 +498,7 @@ def validate_kit_cost(request):
|
||||
elif variant_group_id:
|
||||
try:
|
||||
variant_group = ProductVariantGroup.objects.get(id=variant_group_id)
|
||||
product = variant_group.products.filter(is_active=True).first()
|
||||
product = variant_group.products.filter(status='active').first()
|
||||
if variant_group:
|
||||
product_name = f"[Варианты] {variant_group.name}"
|
||||
except ProductVariantGroup.DoesNotExist:
|
||||
@@ -1217,3 +1217,39 @@ def update_product_price_api(request, pk):
|
||||
'success': False,
|
||||
'error': f'Ошибка при обновлении цены: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
|
||||
def get_payment_methods(request):
|
||||
"""
|
||||
API endpoint для получения списка активных способов оплаты.
|
||||
Используется для динамической загрузки payment methods в JavaScript.
|
||||
|
||||
Возвращает JSON:
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Наличные курьеру",
|
||||
"code": "cash_to_courier",
|
||||
"description": "Оплата наличными при получении заказа"
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
try:
|
||||
from orders.models import PaymentMethod
|
||||
|
||||
# Получаем все активные способы оплаты, упорядоченные по полю order и названию
|
||||
payment_methods = PaymentMethod.objects.filter(
|
||||
is_active=True
|
||||
).order_by('order', 'name').values('id', 'name', 'code', 'description')
|
||||
|
||||
# Преобразуем QuerySet в список
|
||||
methods_list = list(payment_methods)
|
||||
|
||||
return JsonResponse(methods_list, safe=False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при загрузке способов оплаты: {str(e)}')
|
||||
return JsonResponse({
|
||||
'error': f'Ошибка при загрузке способов оплаты: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
@@ -310,6 +310,57 @@ class TenantRegistrationAdmin(admin.ModelAdmin):
|
||||
logger.error(f"Ошибка при создании статусов заказов: {e}", exc_info=True)
|
||||
# Не прерываем процесс, т.к. это не критично
|
||||
|
||||
# Создаем системные способы оплаты
|
||||
logger.info(f"Создание системных способов оплаты для тенанта: {client.id}")
|
||||
from orders.models import PaymentMethod
|
||||
|
||||
try:
|
||||
payment_methods = [
|
||||
{
|
||||
'code': 'cash',
|
||||
'name': 'Наличными',
|
||||
'description': 'Оплата наличными деньгами',
|
||||
'is_system': True,
|
||||
'order': 1
|
||||
},
|
||||
{
|
||||
'code': 'card',
|
||||
'name': 'Картой',
|
||||
'description': 'Оплата банковской картой',
|
||||
'is_system': True,
|
||||
'order': 2
|
||||
},
|
||||
{
|
||||
'code': 'online',
|
||||
'name': 'Онлайн',
|
||||
'description': 'Онлайн оплата через платежную систему',
|
||||
'is_system': True,
|
||||
'order': 3
|
||||
},
|
||||
{
|
||||
'code': 'legal_entity',
|
||||
'name': 'Безнал от ЮРЛИЦ',
|
||||
'description': 'Безналичный расчёт от юридических лиц',
|
||||
'is_system': True,
|
||||
'order': 4
|
||||
},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
for method_data in payment_methods:
|
||||
method, created = PaymentMethod.objects.get_or_create(
|
||||
code=method_data['code'],
|
||||
defaults=method_data
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
logger.info(f"Создан способ оплаты: {method.name}")
|
||||
|
||||
logger.info(f"Системные способы оплаты успешно созданы: {created_count} новых")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании способов оплаты: {e}", exc_info=True)
|
||||
# Не прерываем процесс, т.к. это не критично
|
||||
|
||||
# Возвращаемся в public схему
|
||||
connection.set_schema_to_public()
|
||||
|
||||
|
||||
73
myproject/test_wallet_system.py
Normal file
73
myproject/test_wallet_system.py
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Тест системы кошелька клиента
|
||||
"""
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from django_tenants.utils import schema_context
|
||||
from customers.models import Customer, WalletTransaction
|
||||
from orders.models import Order, PaymentMethod
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("ТЕСТ СИСТЕМЫ КОШЕЛЬКА КЛИЕНТА")
|
||||
print("="*60)
|
||||
|
||||
with schema_context('buba'):
|
||||
# 1. Проверяем способ оплаты
|
||||
try:
|
||||
method = PaymentMethod.objects.get(code='account_balance')
|
||||
print(f"\n✓ Способ оплаты найден: {method.name}")
|
||||
print(f" Описание: {method.description}")
|
||||
print(f" Порядок: {method.order}")
|
||||
except PaymentMethod.DoesNotExist:
|
||||
print("\n✗ Способ оплаты 'account_balance' не найден!")
|
||||
|
||||
# 2. Проверяем клиентов
|
||||
customers = Customer.objects.filter(is_system_customer=False)
|
||||
print(f"\n✓ Всего клиентов: {customers.count()}")
|
||||
|
||||
if customers.exists():
|
||||
customer = customers.first()
|
||||
print(f"\n Тестовый клиент: {customer.name}")
|
||||
print(f" Баланс кошелька: {customer.wallet_balance} руб.")
|
||||
print(f" Всего покупок: {customer.total_spent} руб.")
|
||||
|
||||
# Транзакции
|
||||
txn_count = customer.wallet_transactions.count()
|
||||
print(f" Транзакций кошелька: {txn_count}")
|
||||
|
||||
if txn_count > 0:
|
||||
print("\n Последние транзакции:")
|
||||
for txn in customer.wallet_transactions.all()[:5]:
|
||||
print(f" - {txn.created_at.strftime('%d.%m.%Y %H:%M')}: "
|
||||
f"{txn.get_transaction_type_display()} "
|
||||
f"{txn.amount} руб.")
|
||||
|
||||
# 3. Проверяем заказы
|
||||
orders = Order.objects.all()
|
||||
print(f"\n✓ Всего заказов: {orders.count()}")
|
||||
|
||||
if orders.exists():
|
||||
order = orders.first()
|
||||
print(f"\n Тестовый заказ: #{order.order_number}")
|
||||
print(f" Клиент: {order.customer.name}")
|
||||
print(f" Сумма: {order.total_amount} руб.")
|
||||
print(f" Оплачено: {order.amount_paid} руб.")
|
||||
print(f" К оплате: {order.amount_due} руб.")
|
||||
print(f" Статус оплаты: {order.get_payment_status_display()}")
|
||||
|
||||
# Платежи
|
||||
payments = order.payments.all()
|
||||
if payments.exists():
|
||||
print(f"\n Платежи по заказу:")
|
||||
for payment in payments:
|
||||
print(f" - {payment.payment_method.name}: {payment.amount} руб.")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("ТЕСТ ЗАВЕРШЁН")
|
||||
print("="*60 + "\n")
|
||||
771
nested-singing-rainbow.md
Normal file
771
nested-singing-rainbow.md
Normal file
@@ -0,0 +1,771 @@
|
||||
# План реализации системы личного счета клиента
|
||||
|
||||
## Обзор
|
||||
|
||||
Реализация системы личного счета для клиентов цветочного магазина с поддержкой резервирования средств, смешанной оплаты, автоматического пополнения при переплате и полной историей транзакций.
|
||||
|
||||
## Ключевые бизнес-требования
|
||||
|
||||
1. **Баланс счета**: У каждого клиента есть личный счет (может быть положительным или отрицательным)
|
||||
2. **Пополнение**: Вручную администратором или автоматически при переплате заказа
|
||||
3. **Кредитование**: Разрешен отрицательный баланс для доверенных клиентов
|
||||
4. **История операций**: Полный аудит всех операций со счетом
|
||||
5. **Смешанная оплата**: Можно комбинировать с другими способами оплаты
|
||||
6. **Резервирование**: При создании заказа средства резервируются, при завершении списываются
|
||||
7. **Управление**: Только администраторы/менеджеры имеют доступ
|
||||
|
||||
---
|
||||
|
||||
## 1. Изменения в базе данных
|
||||
|
||||
### 1.1 Расширение модели Customer
|
||||
|
||||
**Файл**: `myproject/customers/models.py`
|
||||
|
||||
Добавить поля для управления балансом:
|
||||
|
||||
```python
|
||||
# Поля баланса
|
||||
account_balance = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Баланс счета",
|
||||
help_text="Текущий баланс лицевого счета клиента"
|
||||
)
|
||||
|
||||
available_balance = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Доступный баланс",
|
||||
help_text="Баланс за вычетом зарезервированных средств"
|
||||
)
|
||||
|
||||
reserved_balance = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Зарезервировано",
|
||||
help_text="Сумма, зарезервированная под активные заказы"
|
||||
)
|
||||
|
||||
allow_negative_balance = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Разрешить отрицательный баланс",
|
||||
help_text="Позволяет клиенту уходить в минус"
|
||||
)
|
||||
|
||||
negative_balance_limit = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Лимит кредита",
|
||||
help_text="Максимальная сумма отрицательного баланса (0 = без лимита)"
|
||||
)
|
||||
```
|
||||
|
||||
**Взаимосвязь полей**:
|
||||
- `account_balance` = Общий баланс клиента
|
||||
- `reserved_balance` = Сумма, зарезервированная под заказы
|
||||
- `available_balance` = `account_balance` - `reserved_balance`
|
||||
|
||||
### 1.2 Новая модель AccountTransaction
|
||||
|
||||
**Файл**: `myproject/customers/models.py` (или отдельный файл в models/)
|
||||
|
||||
Модель для хранения истории всех операций со счетом:
|
||||
|
||||
```python
|
||||
class AccountTransaction(models.Model):
|
||||
"""
|
||||
Транзакция по лицевому счету клиента.
|
||||
"""
|
||||
TRANSACTION_TYPE_CHOICES = [
|
||||
('deposit', 'Пополнение вручную'),
|
||||
('auto_deposit', 'Авто-пополнение (переплата)'),
|
||||
('reservation', 'Резервирование'),
|
||||
('reservation_release', 'Снятие резерва'),
|
||||
('charge', 'Списание за заказ'),
|
||||
('refund', 'Возврат средств'),
|
||||
('adjustment', 'Корректировка баланса'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('active', 'Активна'),
|
||||
('completed', 'Завершена'),
|
||||
('cancelled', 'Отменена'),
|
||||
]
|
||||
|
||||
customer = models.ForeignKey(
|
||||
'Customer',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='account_transactions'
|
||||
)
|
||||
|
||||
transaction_type = models.CharField(max_length=30, choices=TRANSACTION_TYPE_CHOICES)
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
|
||||
balance_before = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
balance_after = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
|
||||
order = models.ForeignKey('orders.Order', null=True, blank=True, on_delete=models.PROTECT)
|
||||
payment = models.ForeignKey('orders.Payment', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
related_transaction = models.ForeignKey('self', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
|
||||
description = models.TextField()
|
||||
notes = models.TextField(blank=True)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='completed')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
created_by = models.ForeignKey('accounts.CustomUser', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['customer', '-created_at']),
|
||||
models.Index(fields=['transaction_type']),
|
||||
models.Index(fields=['order']),
|
||||
models.Index(fields=['status']),
|
||||
]
|
||||
```
|
||||
|
||||
### 1.3 Новый способ оплаты
|
||||
|
||||
Добавить в команду `create_payment_methods.py`:
|
||||
|
||||
```python
|
||||
{
|
||||
'code': 'account_balance',
|
||||
'name': 'С баланса счета',
|
||||
'description': 'Списание с личного счета клиента',
|
||||
'is_system': True,
|
||||
'order': 0 # Первый в списке
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Бизнес-логика: AccountBalanceService
|
||||
|
||||
**Новый файл**: `myproject/customers/services/account_balance_service.py`
|
||||
|
||||
Создать сервис с методами:
|
||||
|
||||
### Основные методы:
|
||||
|
||||
1. **`deposit(customer, amount, description, user, notes)`**
|
||||
- Пополнение счета вручную администратором
|
||||
- Увеличивает `account_balance` и `available_balance`
|
||||
- Создает транзакцию типа `deposit`
|
||||
|
||||
2. **`auto_deposit_from_overpayment(order, overpayment_amount, user)`**
|
||||
- Автоматическое пополнение при переплате
|
||||
- Вызывается когда `order.amount_paid > order.total_amount`
|
||||
- Создает транзакцию типа `auto_deposit`
|
||||
|
||||
3. **`reserve_balance(customer, order, amount, user)`**
|
||||
- Резервирование средств при создании заказа с оплатой со счета
|
||||
- Проверяет достаточность средств (с учетом кредита)
|
||||
- Уменьшает `available_balance`, увеличивает `reserved_balance`
|
||||
- Создает транзакцию типа `reservation` со статусом `active`
|
||||
|
||||
4. **`charge_reserved_balance(reservation_transaction, user)`**
|
||||
- Списание зарезервированных средств при завершении заказа
|
||||
- Уменьшает `account_balance` и `reserved_balance`
|
||||
- Обновляет статус резервирования на `completed`
|
||||
- Создает транзакцию типа `charge`
|
||||
|
||||
5. **`release_reservation(reservation_transaction, user)`**
|
||||
- Снятие резервирования при отмене заказа
|
||||
- Увеличивает `available_balance`, уменьшает `reserved_balance`
|
||||
- Обновляет статус резервирования на `cancelled`
|
||||
- Создает транзакцию типа `reservation_release`
|
||||
|
||||
6. **`refund(customer, amount, order, description, user, notes)`**
|
||||
- Возврат средств на счет
|
||||
- Используется при индивидуальных решениях по возвратам
|
||||
- Создает транзакцию типа `refund`
|
||||
|
||||
7. **`adjustment(customer, amount, description, user, notes)`**
|
||||
- Корректировка баланса администратором
|
||||
- Может быть положительной или отрицательной
|
||||
- Требует обязательное описание
|
||||
|
||||
### Ключевые особенности реализации:
|
||||
|
||||
- Все методы используют `@transaction.atomic` для атомарности
|
||||
- `select_for_update()` для блокировки записи клиента при изменении
|
||||
- Проверка лимитов кредита перед резервированием
|
||||
- Запись `balance_before` и `balance_after` для аудита
|
||||
|
||||
---
|
||||
|
||||
## 3. Интеграция с существующей системой платежей
|
||||
|
||||
### 3.1 Модификация Payment.save()
|
||||
|
||||
**Файл**: `myproject/orders/models/payment.py`
|
||||
|
||||
В методе `save()` добавить логику:
|
||||
|
||||
```python
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = self.pk is None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Пересчитываем сумму оплаты
|
||||
self.order.amount_paid = sum(p.amount for p in self.order.payments.all())
|
||||
|
||||
# Обработка оплаты с баланса счета
|
||||
if self.payment_method.code == 'account_balance' and is_new:
|
||||
from customers.services.account_balance_service import AccountBalanceService
|
||||
AccountBalanceService.reserve_balance(
|
||||
customer=self.order.customer,
|
||||
order=self.order,
|
||||
amount=self.amount,
|
||||
user=self.created_by
|
||||
)
|
||||
|
||||
self.order.update_payment_status()
|
||||
|
||||
# Проверка переплаты
|
||||
if self.order.amount_paid > self.order.total_amount:
|
||||
overpayment = self.order.amount_paid - self.order.total_amount
|
||||
from customers.services.account_balance_service import AccountBalanceService
|
||||
AccountBalanceService.auto_deposit_from_overpayment(
|
||||
order=self.order,
|
||||
overpayment_amount=overpayment,
|
||||
user=self.created_by
|
||||
)
|
||||
```
|
||||
|
||||
### 3.2 Обработка изменения статуса заказа
|
||||
|
||||
**Новый файл**: `myproject/orders/signals.py`
|
||||
|
||||
Создать сигналы для автоматической обработки:
|
||||
|
||||
```python
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from .models import Order
|
||||
|
||||
@receiver(post_save, sender=Order)
|
||||
def handle_order_status_change(sender, instance, created, **kwargs):
|
||||
"""Обработка изменения статуса заказа"""
|
||||
if created or not instance.status:
|
||||
return
|
||||
|
||||
from customers.models import AccountTransaction
|
||||
from customers.services.account_balance_service import AccountBalanceService
|
||||
|
||||
# Заказ выполнен успешно - списываем
|
||||
if instance.status.is_positive_end:
|
||||
reservations = AccountTransaction.objects.filter(
|
||||
order=instance,
|
||||
transaction_type='reservation',
|
||||
status='active'
|
||||
)
|
||||
for reservation in reservations:
|
||||
AccountBalanceService.charge_reserved_balance(
|
||||
reservation_transaction=reservation,
|
||||
user=instance.modified_by
|
||||
)
|
||||
|
||||
# Заказ отменен - снимаем резерв
|
||||
elif instance.status.is_negative_end:
|
||||
reservations = AccountTransaction.objects.filter(
|
||||
order=instance,
|
||||
transaction_type='reservation',
|
||||
status='active'
|
||||
)
|
||||
for reservation in reservations:
|
||||
AccountBalanceService.release_reservation(
|
||||
reservation_transaction=reservation,
|
||||
user=instance.modified_by
|
||||
)
|
||||
```
|
||||
|
||||
Подключить сигналы в `myproject/orders/apps.py`:
|
||||
|
||||
```python
|
||||
class OrdersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'orders'
|
||||
|
||||
def ready(self):
|
||||
import orders.signals # noqa
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Административный интерфейс
|
||||
|
||||
### 4.1 Расширение CustomerAdmin
|
||||
|
||||
**Файл**: `myproject/customers/admin.py`
|
||||
|
||||
Изменения:
|
||||
|
||||
1. **Добавить поля баланса в list_display**:
|
||||
```python
|
||||
list_display = (
|
||||
'full_name', 'email', 'phone',
|
||||
'account_balance_colored', # новое
|
||||
'available_balance_display', # новое
|
||||
'reserved_balance_display', # новое
|
||||
'total_spent',
|
||||
'is_system_customer',
|
||||
'created_at'
|
||||
)
|
||||
```
|
||||
|
||||
2. **Добавить фильтр по кредиту**:
|
||||
```python
|
||||
list_filter = (
|
||||
IsSystemCustomerFilter,
|
||||
'allow_negative_balance', # новое
|
||||
'created_at'
|
||||
)
|
||||
```
|
||||
|
||||
3. **Добавить секцию баланса в fieldsets**:
|
||||
```python
|
||||
('Баланс лицевого счета', {
|
||||
'fields': (
|
||||
'account_balance',
|
||||
'available_balance',
|
||||
'reserved_balance',
|
||||
'allow_negative_balance',
|
||||
'negative_balance_limit',
|
||||
),
|
||||
'classes': ('wide',),
|
||||
}),
|
||||
```
|
||||
|
||||
4. **Добавить inline для транзакций**:
|
||||
```python
|
||||
class AccountTransactionInline(admin.TabularInline):
|
||||
model = AccountTransaction
|
||||
extra = 0
|
||||
can_delete = False
|
||||
readonly_fields = [...]
|
||||
|
||||
inlines = [AccountTransactionInline]
|
||||
```
|
||||
|
||||
5. **Добавить actions**:
|
||||
```python
|
||||
actions = [
|
||||
'add_deposit',
|
||||
'add_refund',
|
||||
'add_adjustment',
|
||||
'enable_negative_balance',
|
||||
]
|
||||
```
|
||||
|
||||
### 4.2 Новый AccountTransactionAdmin
|
||||
|
||||
**Файл**: `myproject/customers/admin.py`
|
||||
|
||||
Создать отдельную админку для просмотра всех транзакций:
|
||||
|
||||
```python
|
||||
@admin.register(AccountTransaction)
|
||||
class AccountTransactionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'created_at', 'customer_link', 'transaction_type',
|
||||
'amount_colored', 'balance_after', 'order_link', 'status'
|
||||
]
|
||||
list_filter = ['transaction_type', 'status', 'created_at']
|
||||
search_fields = ['customer__name', 'customer__email', 'description']
|
||||
readonly_fields = [все поля]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False # Только через сервис
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False # Аудит, нельзя удалять
|
||||
```
|
||||
|
||||
### 4.3 Кастомные views для операций
|
||||
|
||||
**Новый файл**: `myproject/customers/admin_views.py`
|
||||
|
||||
Создать views для:
|
||||
- Пополнения баланса (`/admin/customers/deposit/`)
|
||||
- Возврата средств (`/admin/customers/refund/`)
|
||||
- Корректировки (`/admin/customers/adjustment/`)
|
||||
|
||||
**Новый файл**: `myproject/customers/admin_urls.py`
|
||||
|
||||
```python
|
||||
from django.urls import path
|
||||
from . import admin_views
|
||||
|
||||
urlpatterns = [
|
||||
path('deposit/', admin_views.deposit_view, name='customer_deposit'),
|
||||
path('refund/', admin_views.refund_view, name='customer_refund'),
|
||||
path('adjustment/', admin_views.adjustment_view, name='customer_adjustment'),
|
||||
]
|
||||
```
|
||||
|
||||
Подключить в основной `urls.py`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Формы для операций
|
||||
|
||||
**Новый файл**: `myproject/customers/forms.py`
|
||||
|
||||
Создать формы:
|
||||
|
||||
```python
|
||||
class DepositForm(forms.Form):
|
||||
"""Форма пополнения баланса"""
|
||||
customer = forms.ModelChoiceField(queryset=Customer.objects.all())
|
||||
amount = forms.DecimalField(min_value=0.01, max_digits=10, decimal_places=2)
|
||||
description = forms.CharField(widget=forms.Textarea)
|
||||
notes = forms.CharField(widget=forms.Textarea, required=False)
|
||||
|
||||
class RefundForm(forms.Form):
|
||||
"""Форма возврата средств"""
|
||||
customer = forms.ModelChoiceField(queryset=Customer.objects.all())
|
||||
amount = forms.DecimalField(min_value=0.01, max_digits=10, decimal_places=2)
|
||||
order = forms.ModelChoiceField(queryset=Order.objects.all(), required=False)
|
||||
description = forms.CharField(widget=forms.Textarea)
|
||||
notes = forms.CharField(widget=forms.Textarea, required=False)
|
||||
|
||||
class AdjustmentForm(forms.Form):
|
||||
"""Форма корректировки баланса"""
|
||||
customer = forms.ModelChoiceField(queryset=Customer.objects.all())
|
||||
amount = forms.DecimalField(max_digits=10, decimal_places=2) # может быть отрицательным
|
||||
description = forms.CharField(widget=forms.Textarea)
|
||||
notes = forms.CharField(widget=forms.Textarea, required=False)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. UI/UX улучшения
|
||||
|
||||
### 6.1 Отображение баланса в форме заказа
|
||||
|
||||
**Файл**: `myproject/orders/templates/orders/order_form.html`
|
||||
|
||||
Добавить блок с информацией о балансе клиента:
|
||||
|
||||
```html
|
||||
{% if order.customer %}
|
||||
<div class="alert alert-info">
|
||||
<h5>Баланс клиента</h5>
|
||||
<ul>
|
||||
<li>Общий баланс: <strong>{{ order.customer.account_balance }} руб.</strong></li>
|
||||
<li>Доступно: <strong>{{ order.customer.available_balance }} руб.</strong></li>
|
||||
<li>Зарезервировано: <strong>{{ order.customer.reserved_balance }} руб.</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### 6.2 Валидация при выборе оплаты со счета
|
||||
|
||||
**Файл**: `myproject/orders/static/orders/js/payment_validation.js`
|
||||
|
||||
Добавить JS-валидацию:
|
||||
|
||||
```javascript
|
||||
// Проверка достаточности средств при выборе оплаты со счета
|
||||
function validateAccountBalance(paymentMethodCode, amount, availableBalance, allowNegative, creditLimit) {
|
||||
if (paymentMethodCode === 'account_balance') {
|
||||
if (amount > availableBalance && !allowNegative) {
|
||||
alert('Недостаточно средств на счете клиента!');
|
||||
return false;
|
||||
}
|
||||
if (allowNegative && creditLimit > 0) {
|
||||
let potentialBalance = availableBalance - amount;
|
||||
if (Math.abs(potentialBalance) > creditLimit) {
|
||||
alert('Превышен лимит кредита клиента!');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Миграции
|
||||
|
||||
### Последовательность миграций:
|
||||
|
||||
1. **Добавить поля баланса в Customer**:
|
||||
```bash
|
||||
python manage.py makemigrations customers --name add_account_balance_fields
|
||||
```
|
||||
|
||||
2. **Создать модель AccountTransaction**:
|
||||
```bash
|
||||
python manage.py makemigrations customers --name create_account_transaction_model
|
||||
```
|
||||
|
||||
3. **Создать индексы и ограничения**:
|
||||
```bash
|
||||
python manage.py makemigrations customers --name add_balance_constraints
|
||||
```
|
||||
|
||||
4. **Инициализация данных** (data migration):
|
||||
```python
|
||||
def initialize_customer_balances(apps, schema_editor):
|
||||
Customer = apps.get_model('customers', 'Customer')
|
||||
Customer.objects.all().update(
|
||||
account_balance=0,
|
||||
available_balance=0,
|
||||
reserved_balance=0,
|
||||
allow_negative_balance=False,
|
||||
negative_balance_limit=0
|
||||
)
|
||||
```
|
||||
|
||||
5. **Добавить способ оплаты**:
|
||||
```bash
|
||||
python manage.py create_payment_methods
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Обеспечение целостности данных
|
||||
|
||||
### 8.1 Транзакции и блокировки
|
||||
|
||||
- Все операции в `@transaction.atomic`
|
||||
- Использование `select_for_update()` для блокировки записи клиента
|
||||
- Проверка статуса транзакции перед обработкой
|
||||
|
||||
### 8.2 Ограничения БД
|
||||
|
||||
```python
|
||||
# В миграции
|
||||
models.CheckConstraint(
|
||||
check=models.Q(reserved_balance__gte=0),
|
||||
name='reserved_balance_non_negative'
|
||||
)
|
||||
|
||||
models.CheckConstraint(
|
||||
check=models.Q(account_balance__gte=models.F('reserved_balance') * -1),
|
||||
name='available_balance_consistency'
|
||||
)
|
||||
```
|
||||
|
||||
### 8.3 Предотвращение дублирования
|
||||
|
||||
- Проверка `status='active'` перед обработкой резервирования
|
||||
- Связь `related_transaction` для отслеживания цепочки операций
|
||||
- Валидация перед созданием транзакции
|
||||
|
||||
---
|
||||
|
||||
## 9. Типы транзакций: Подробное описание
|
||||
|
||||
### DEPOSIT (Пополнение)
|
||||
- **Когда**: Администратор вручную пополняет счет
|
||||
- **Эффект**: `account_balance ↑`, `available_balance ↑`
|
||||
- **Статус**: `completed`
|
||||
|
||||
### AUTO_DEPOSIT (Авто-пополнение)
|
||||
- **Когда**: `order.amount_paid > order.total_amount`
|
||||
- **Эффект**: `account_balance ↑`, `available_balance ↑`
|
||||
- **Статус**: `completed`
|
||||
|
||||
### RESERVATION (Резервирование)
|
||||
- **Когда**: Создание заказа с оплатой со счета
|
||||
- **Эффект**: `available_balance ↓`, `reserved_balance ↑`
|
||||
- **Статус**: `active` → меняется при charge/release
|
||||
|
||||
### CHARGE (Списание)
|
||||
- **Когда**: Заказ выполнен (`is_positive_end=True`)
|
||||
- **Эффект**: `account_balance ↓`, `reserved_balance ↓`
|
||||
- **Статус**: `completed`
|
||||
|
||||
### RESERVATION_RELEASE (Снятие резерва)
|
||||
- **Когда**: Заказ отменен (`is_negative_end=True`)
|
||||
- **Эффект**: `available_balance ↑`, `reserved_balance ↓`
|
||||
- **Статус**: `completed`
|
||||
|
||||
### REFUND (Возврат)
|
||||
- **Когда**: Администратор принимает решение о возврате
|
||||
- **Эффект**: `account_balance ↑`, `available_balance ↑`
|
||||
- **Статус**: `completed`
|
||||
|
||||
### ADJUSTMENT (Корректировка)
|
||||
- **Когда**: Ручная корректировка администратором
|
||||
- **Эффект**: `account_balance ±`, `available_balance ±`
|
||||
- **Статус**: `completed`
|
||||
|
||||
---
|
||||
|
||||
## 10. Сценарии использования
|
||||
|
||||
### Сценарий 1: Заказ с полной оплатой со счета
|
||||
|
||||
1. Клиент создает заказ на 540 руб.
|
||||
2. На балансе 1000 руб.
|
||||
3. Выбирается способ оплаты "С баланса счета"
|
||||
4. **Создается RESERVATION** на 540 руб.: `available_balance: 1000→460`, `reserved_balance: 0→540`
|
||||
5. При выполнении заказа создается **CHARGE**: `account_balance: 1000→460`, `reserved_balance: 540→0`
|
||||
|
||||
### Сценарий 2: Смешанная оплата
|
||||
|
||||
1. Заказ на 540 руб.
|
||||
2. На балансе 300 руб.
|
||||
3. Создается Payment со счета на 300 руб. → **RESERVATION** 300 руб.
|
||||
4. Создается Payment наличными на 240 руб.
|
||||
5. При выполнении → **CHARGE** 300 руб. со счета
|
||||
|
||||
### Сценарий 3: Переплата с авто-пополнением
|
||||
|
||||
1. Заказ на 540 руб.
|
||||
2. Клиент платит наличными 1000 руб.
|
||||
3. `order.amount_paid = 1000`, `order.total_amount = 540`
|
||||
4. Система создает **AUTO_DEPOSIT** на 460 руб.
|
||||
5. Баланс клиента увеличивается на 460 руб.
|
||||
|
||||
### Сценарий 4: Отмена заказа
|
||||
|
||||
1. Заказ на 540 руб. с резервированием
|
||||
2. `reserved_balance = 540`, `available_balance = 460`
|
||||
3. Заказ меняет статус на "Отменен" (`is_negative_end=True`)
|
||||
4. Сигнал создает **RESERVATION_RELEASE**
|
||||
5. `available_balance: 460→1000`, `reserved_balance: 540→0`
|
||||
|
||||
### Сценарий 5: Кредит доверенного клиента
|
||||
|
||||
1. У клиента баланс 0 руб., но `allow_negative_balance=True`
|
||||
2. Заказ на 540 руб.
|
||||
3. Создается **RESERVATION** на 540 руб.
|
||||
4. `account_balance: 0→0`, `available_balance: 0→-540`, `reserved_balance: 0→540`
|
||||
5. При выполнении **CHARGE**: `account_balance: 0→-540`
|
||||
|
||||
---
|
||||
|
||||
## 11. Тестирование
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**Файл**: `myproject/customers/tests/test_account_balance_service.py`
|
||||
|
||||
Тесты:
|
||||
- `test_deposit_increases_balance`
|
||||
- `test_reserve_decreases_available`
|
||||
- `test_charge_decreases_account_balance`
|
||||
- `test_release_increases_available`
|
||||
- `test_overpayment_creates_auto_deposit`
|
||||
- `test_negative_balance_validation`
|
||||
- `test_credit_limit_enforcement`
|
||||
- `test_concurrent_operations`
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**Файл**: `myproject/orders/tests/test_order_with_account_balance.py`
|
||||
|
||||
Тесты:
|
||||
- `test_order_with_account_payment`
|
||||
- `test_mixed_payment_scenario`
|
||||
- `test_order_completion_charges_balance`
|
||||
- `test_order_cancellation_releases_reservation`
|
||||
- `test_overpayment_auto_deposit`
|
||||
|
||||
---
|
||||
|
||||
## 12. Критические файлы для реализации
|
||||
|
||||
1. **`myproject/customers/models.py`**
|
||||
- Добавить поля баланса в Customer
|
||||
- Создать модель AccountTransaction
|
||||
|
||||
2. **`myproject/customers/services/account_balance_service.py`** (НОВЫЙ)
|
||||
- Все методы управления балансом
|
||||
|
||||
3. **`myproject/orders/models/payment.py`**
|
||||
- Модифицировать `save()` для обработки оплаты со счета
|
||||
|
||||
4. **`myproject/orders/signals.py`** (НОВЫЙ)
|
||||
- Обработка изменения статуса заказа
|
||||
|
||||
5. **`myproject/customers/admin.py`**
|
||||
- Расширить CustomerAdmin
|
||||
- Создать AccountTransactionAdmin
|
||||
|
||||
6. **`myproject/customers/admin_views.py`** (НОВЫЙ)
|
||||
- Views для пополнения/возврата/корректировки
|
||||
|
||||
7. **`myproject/customers/forms.py`** (НОВЫЙ)
|
||||
- Формы для операций с балансом
|
||||
|
||||
8. **`myproject/orders/management/commands/create_payment_methods.py`**
|
||||
- Добавить способ оплаты 'account_balance'
|
||||
|
||||
9. **`myproject/orders/apps.py`**
|
||||
- Подключить сигналы
|
||||
|
||||
---
|
||||
|
||||
## 13. Последовательность реализации
|
||||
|
||||
### Фаза 1: Модели и миграции (основа)
|
||||
1. Добавить поля в Customer
|
||||
2. Создать AccountTransaction
|
||||
3. Создать миграции
|
||||
4. Инициализировать данные
|
||||
|
||||
### Фаза 2: Бизнес-логика (ядро)
|
||||
1. Создать AccountBalanceService со всеми методами
|
||||
2. Покрыть unit-тестами
|
||||
|
||||
### Фаза 3: Интеграция с заказами (связывание)
|
||||
1. Модифицировать Payment.save()
|
||||
2. Создать signals.py
|
||||
3. Добавить способ оплаты
|
||||
4. Покрыть integration-тестами
|
||||
|
||||
### Фаза 4: Административный интерфейс (управление)
|
||||
1. Расширить CustomerAdmin
|
||||
2. Создать AccountTransactionAdmin
|
||||
3. Создать формы и views для операций
|
||||
4. Настроить URLs
|
||||
|
||||
### Фаза 5: UI/UX улучшения (удобство)
|
||||
1. Отображение баланса в форме заказа
|
||||
2. JS-валидация при оплате
|
||||
3. Виджеты истории транзакций
|
||||
|
||||
### Фаза 6: Тестирование и документация (качество)
|
||||
1. Полное покрытие тестами
|
||||
2. Ручное тестирование сценариев
|
||||
3. Документация для администраторов
|
||||
|
||||
---
|
||||
|
||||
## 14. Безопасность и права доступа
|
||||
|
||||
- Только `staff_member_required` для admin views
|
||||
- Транзакции нельзя удалять (`has_delete_permission = False`)
|
||||
- Транзакции нельзя создавать вручную (`has_add_permission = False`)
|
||||
- Все операции требуют `created_by` (аудит)
|
||||
- Mandatory `description` для adjustment
|
||||
|
||||
---
|
||||
|
||||
## Заключение
|
||||
|
||||
Данная архитектура обеспечивает:
|
||||
- ✅ Полную историю операций (аудит)
|
||||
- ✅ Атомарность операций (транзакции БД)
|
||||
- ✅ Защиту от race conditions (блокировки)
|
||||
- ✅ Гибкость (смешанная оплата, кредит)
|
||||
- ✅ Интеграцию с существующей системой
|
||||
- ✅ Простоту управления (admin interface)
|
||||
- ✅ Безопасность (только администраторы)
|
||||
|
||||
Решение готово к production-использованию после прохождения всех фаз тестирования.
|
||||
2
test_simple.py
Normal file
2
test_simple.py
Normal file
@@ -0,0 +1,2 @@
|
||||
print
|
||||
Testing wallet
|
||||
Reference in New Issue
Block a user