Compare commits

..

10 Commits

Author SHA1 Message Date
f911a57640 Before simplifying order creation and editing 2025-11-28 23:11:34 +03:00
94ddb0424b Добавлены методы-обёртки для работы с кошельком в модель Customer 2025-11-27 21:24:33 +03:00
82ed5a409e Добавлена функциональность редактирования заказов с обновлением резервов товаров 2025-11-27 21:13:42 +03:00
da5d4001b5 feat: Add adaptive multi-column layout for categories and tags checkboxes
Implemented responsive grid system for product form checkboxes to improve UX
when dealing with multiple categories and tags.

Changes:
- Added CSS Grid layout with adaptive columns (1-4 based on screen width)
- Mobile (< 768px): 1 column
- Tablet (≥ 768px): 2 columns
- Desktop (≥ 1200px): 3 columns
- Wide screens (≥ 1600px): 4 columns
- Compact spacing (0.35rem gap) for better density
- Enhanced checkbox styling with hover effects and selected state
- JavaScript fallback for forced grid application and responsive resizing
- Improved form container width (col-12 col-xl-10) for better space usage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 01:11:01 +03:00
c62cdb0298 feat: Add customer prefill from URL parameter in order creation
- Modified order_create view to read customer from GET parameter
- Pass preselected_customer to template context
- Template renders select with preselected option for Select2
- Fixed draft creation timing with callback after Select2 initialization
- Auto-create draft when customer is preselected from URL
- Graceful handling if customer not found or invalid ID
2025-11-27 00:17:02 +03:00
5ead7fdd2e Реализация системы кошелька клиента для переплат
- Добавлено поле wallet_balance в модель Customer
- Создана модель WalletTransaction для истории операций
- Реализован сервис WalletService с методами:
  * add_overpayment - автоматическое зачисление переплаты
  * pay_with_wallet - оплата заказа из кошелька
  * adjust_balance - ручная корректировка баланса
- Интеграция с Payment.save() для автоматической обработки переплат
- UI для оплаты из кошелька в деталях заказа
- Отображение баланса и долга на странице клиента
- Админка с inline транзакций и запретом ручного создания
- Добавлен способ оплаты account_balance
- Миграция 0004 для customers приложения
2025-11-26 14:47:11 +03:00
0653ec0545 Рефакторинг моделей заказов и добавление методов оплаты 2025-11-26 13:38:02 +03:00
08e8409a66 Fix: Restore checkbox values on page reload for recipient fields
Adds proper initialization for checkbox fields on page load:
- address_confirm_with_recipient (Уточнить адрес у получателя)
- customer_is_recipient (Покупатель является получателем)

These fields are already tracked by autosave.js and properly saved
by backend, but were not displaying saved values after page reload.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 06:34:44 +03:00
3b4785e2ad Fix: Implement proper deletion of order items with confirmation dialog
Fixes deletion functionality for order items across frontend and backend:
- Remove restriction preventing deletion of last item
- Add confirmation dialog before deletion
- Properly track and send deleted item IDs to backend via autosave
- Update backend to handle item deletion by ID instead of index
- Fix visual feedback: deleted items are hidden immediately
- Auto-recalculate total sum after deletion

Technical changes:
- order_form.html: Add confirmation dialog, trigger autosave on delete
- autosave.js: Collect deleted item IDs, send to backend
- draft_service.py: Process deleted_item_ids, update items by ID

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 06:27:27 +03:00
5df182e030 Fix: Auto-fill product prices when empty or zero in autosave
Исправлена проблема с пропаданием цен товаров при автосохранении и
перезагрузке страницы.

ПРОБЛЕМА:
- При автосохранении пустые поля цен отправлялись как '0'
- Backend сохранял 0 в базу данных
- При перезагрузке страницы поля цен оставались пустыми
- Итоговая сумма товаров показывала 0.00 руб.

РЕШЕНИЕ 1 - Backend (draft_service.py):
- Изменена логика обработки цен в update_draft()
- Если цена пустая или равна 0, используется actual_price из каталога
- Добавлена корректная обработка price_raw перед конвертацией в Decimal
- Улучшена логика определения is_custom_price

Логика обработки цены:
1. Получаем price_raw из items_data
2. Если price_raw пустой или 0 → используем original_price из каталога
3. Если price_raw заполнен → используем его и сравниваем с original_price
4. is_custom_price = True только если разница больше 0.01

РЕШЕНИЕ 2 - Frontend (order_form.html):
- Добавлена fallback логика в шаблоне для отображения цены
- Если item_form.instance.price пустой/None/0 → показываем actual_price
- Используется inline условие {% if %} для проверки наличия цены
- Отдельная логика для product и product_kit

Теперь работает корректно:
 При выборе товара цена автоматически заполняется из каталога
 Автосохранение сохраняет правильную цену (из каталога или изменённую)
 При перезагрузке страницы цены отображаются корректно
 Итоговая сумма товаров рассчитывается правильно
 Бейдж "Изменена" показывается только для реально изменённых цен

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 20:15:50 +03:00
38 changed files with 4306 additions and 1093 deletions

View 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'")

View File

@@ -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

View File

@@ -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')],
},
),
]

View File

@@ -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}"

View File

@@ -0,0 +1,3 @@
"""
Сервисы для работы с клиентами.
"""

View 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

View File

@@ -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 %}

View File

@@ -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)

View File

@@ -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>

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -1 +0,0 @@
# Management commands for orders app

View File

@@ -1 +0,0 @@
# Management commands

View File

@@ -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} новых способов оплаты.')
)

View File

@@ -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'),
),
]

View File

@@ -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()

View 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',
]

View 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

View 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 "Время не указано"

View 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

View 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

View 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()

View File

@@ -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()

View File

@@ -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
};
})();

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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'),

View File

@@ -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)

View File

@@ -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 %}

View File

@@ -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">

View File

@@ -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'),

View File

@@ -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)

View File

@@ -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()

View 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
View 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
View File

@@ -0,0 +1,2 @@
print
Testing wallet