From 6735be9b081ee1b68491136c81d1ed8153dbf2e9 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Wed, 29 Oct 2025 03:26:06 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D0=B8=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D1=83=20=D0=BF=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=20=D1=81=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D1=8F=D0=BC=D0=B8=20?= =?UTF-8?q?(IncomingBatch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Основные изменения: - Создана модель IncomingBatch для группировки товаров по документам - Каждое поступление (Incoming) связано с одной батчем поступления - Автоматическое создание StockBatch для каждого товара в приходе - Реализована система нумерации партий (IN-XXXX-XXXX) с поиском максимума в БД - Обновлены все представления (views) для работы с новой архитектурой - Добавлены детальные страницы просмотра партий поступлений - Обновлены шаблоны для отображения информации о партиях и их товарах - Исправлена логика сигналов для создания StockBatch при приходе товара - Обновлены формы для работы с новой структурой IncomingBatch Архитектура FIFO: - IncomingBatch: одна партия поступления (номер IN-XXXX-XXXX) - Incoming: товар в партии поступления - StockBatch: одна партия товара на складе (создается для каждого товара) Это позволяет системе правильно применять FIFO при продаже товаров. 🤖 Generated with Claude Code Co-Authored-By: Claude --- myproject/accounts/migrations/0001_initial.py | 2 +- .../customers/migrations/0001_initial.py | 2 +- myproject/inventory/admin.py | 324 +++++++++++- myproject/inventory/apps.py | 4 + myproject/inventory/forms.py | 305 +++++++++++ .../inventory/migrations/0001_initial.py | 278 +++++++++- myproject/inventory/models.py | 398 ++++++++++++++- myproject/inventory/services/__init__.py | 13 + myproject/inventory/services/batch_manager.py | 246 +++++++++ .../inventory/services/inventory_processor.py | 286 +++++++++++ .../inventory/services/sale_processor.py | 216 ++++++++ myproject/inventory/signals.py | 340 ++++++++++++ .../inventory/allocation/allocation_list.html | 4 + .../templates/inventory/base_inventory.html | 96 ++++ .../inventory/batch/batch_detail.html | 4 + .../templates/inventory/batch/batch_list.html | 49 ++ .../inventory/templates/inventory/home.html | 149 ++++++ .../incoming/incoming_bulk_form.html | 475 +++++++++++++++++ .../incoming/incoming_confirm_delete.html | 48 ++ .../inventory/incoming/incoming_form.html | 125 +++++ .../inventory/incoming/incoming_list.html | 112 ++++ .../incoming_batch/batch_detail.html | 110 ++++ .../inventory/incoming_batch/batch_list.html | 60 +++ .../inventory/inventory/inventory_detail.html | 111 ++++ .../inventory/inventory/inventory_form.html | 69 +++ .../inventory/inventory_line_bulk_form.html | 58 +++ .../inventory/inventory/inventory_list.html | 96 ++++ .../inventory/movements/movement_list.html | 4 + .../reservation/reservation_form.html | 5 + .../reservation/reservation_list.html | 4 + .../reservation/reservation_update.html | 5 + .../inventory/sale/sale_confirm_delete.html | 55 ++ .../templates/inventory/sale/sale_detail.html | 138 +++++ .../templates/inventory/sale/sale_form.html | 127 +++++ .../templates/inventory/sale/sale_list.html | 113 ++++ .../inventory/stock/stock_detail.html | 4 + .../templates/inventory/stock/stock_list.html | 4 + .../transfer/transfer_confirm_delete.html | 4 + .../inventory/transfer/transfer_form.html | 6 + .../inventory/transfer/transfer_list.html | 4 + .../warehouse/warehouse_confirm_delete.html | 41 ++ .../inventory/warehouse/warehouse_form.html | 86 ++++ .../inventory/warehouse/warehouse_list.html | 98 ++++ .../writeoff/writeoff_confirm_delete.html | 11 + .../inventory/writeoff/writeoff_form.html | 174 +++++++ .../inventory/writeoff/writeoff_list.html | 49 ++ myproject/inventory/tests.py | 483 +++++++++++++++++- myproject/inventory/urls.py | 96 ++++ myproject/inventory/utils.py | 81 +++ myproject/inventory/views.py | 3 - myproject/inventory/views.py.old | 10 + myproject/inventory/views/__init__.py | 72 +++ myproject/inventory/views/allocation.py | 25 + myproject/inventory/views/batch.py | 83 +++ myproject/inventory/views/incoming.py | 239 +++++++++ myproject/inventory/views/inventory_ops.py | 102 ++++ myproject/inventory/views/movements.py | 24 + myproject/inventory/views/reservation.py | 46 ++ myproject/inventory/views/sale.py | 103 ++++ myproject/inventory/views/stock.py | 27 + myproject/inventory/views/transfer.py | 60 +++ myproject/inventory/views/warehouse.py | 66 +++ myproject/inventory/views/writeoff.py | 54 ++ myproject/myproject/urls.py | 14 +- myproject/myproject/urls_public.py | 3 +- myproject/orders/migrations/0001_initial.py | 2 +- myproject/products/migrations/0001_initial.py | 2 +- myproject/shops/migrations/0001_initial.py | 2 +- myproject/templates/navbar.html | 3 + myproject/tenants/admin.py | 10 + myproject/tenants/migrations/0001_initial.py | 55 +- ...nt_is_active_alter_client_name_and_more.py | 79 --- myproject/tenants/urls.py | 2 + 73 files changed, 6536 insertions(+), 122 deletions(-) create mode 100644 myproject/inventory/forms.py create mode 100644 myproject/inventory/services/__init__.py create mode 100644 myproject/inventory/services/batch_manager.py create mode 100644 myproject/inventory/services/inventory_processor.py create mode 100644 myproject/inventory/services/sale_processor.py create mode 100644 myproject/inventory/signals.py create mode 100644 myproject/inventory/templates/inventory/allocation/allocation_list.html create mode 100644 myproject/inventory/templates/inventory/base_inventory.html create mode 100644 myproject/inventory/templates/inventory/batch/batch_detail.html create mode 100644 myproject/inventory/templates/inventory/batch/batch_list.html create mode 100644 myproject/inventory/templates/inventory/home.html create mode 100644 myproject/inventory/templates/inventory/incoming/incoming_bulk_form.html create mode 100644 myproject/inventory/templates/inventory/incoming/incoming_confirm_delete.html create mode 100644 myproject/inventory/templates/inventory/incoming/incoming_form.html create mode 100644 myproject/inventory/templates/inventory/incoming/incoming_list.html create mode 100644 myproject/inventory/templates/inventory/incoming_batch/batch_detail.html create mode 100644 myproject/inventory/templates/inventory/incoming_batch/batch_list.html create mode 100644 myproject/inventory/templates/inventory/inventory/inventory_detail.html create mode 100644 myproject/inventory/templates/inventory/inventory/inventory_form.html create mode 100644 myproject/inventory/templates/inventory/inventory/inventory_line_bulk_form.html create mode 100644 myproject/inventory/templates/inventory/inventory/inventory_list.html create mode 100644 myproject/inventory/templates/inventory/movements/movement_list.html create mode 100644 myproject/inventory/templates/inventory/reservation/reservation_form.html create mode 100644 myproject/inventory/templates/inventory/reservation/reservation_list.html create mode 100644 myproject/inventory/templates/inventory/reservation/reservation_update.html create mode 100644 myproject/inventory/templates/inventory/sale/sale_confirm_delete.html create mode 100644 myproject/inventory/templates/inventory/sale/sale_detail.html create mode 100644 myproject/inventory/templates/inventory/sale/sale_form.html create mode 100644 myproject/inventory/templates/inventory/sale/sale_list.html create mode 100644 myproject/inventory/templates/inventory/stock/stock_detail.html create mode 100644 myproject/inventory/templates/inventory/stock/stock_list.html create mode 100644 myproject/inventory/templates/inventory/transfer/transfer_confirm_delete.html create mode 100644 myproject/inventory/templates/inventory/transfer/transfer_form.html create mode 100644 myproject/inventory/templates/inventory/transfer/transfer_list.html create mode 100644 myproject/inventory/templates/inventory/warehouse/warehouse_confirm_delete.html create mode 100644 myproject/inventory/templates/inventory/warehouse/warehouse_form.html create mode 100644 myproject/inventory/templates/inventory/warehouse/warehouse_list.html create mode 100644 myproject/inventory/templates/inventory/writeoff/writeoff_confirm_delete.html create mode 100644 myproject/inventory/templates/inventory/writeoff/writeoff_form.html create mode 100644 myproject/inventory/templates/inventory/writeoff/writeoff_list.html create mode 100644 myproject/inventory/urls.py create mode 100644 myproject/inventory/utils.py delete mode 100644 myproject/inventory/views.py create mode 100644 myproject/inventory/views.py.old create mode 100644 myproject/inventory/views/__init__.py create mode 100644 myproject/inventory/views/allocation.py create mode 100644 myproject/inventory/views/batch.py create mode 100644 myproject/inventory/views/incoming.py create mode 100644 myproject/inventory/views/inventory_ops.py create mode 100644 myproject/inventory/views/movements.py create mode 100644 myproject/inventory/views/reservation.py create mode 100644 myproject/inventory/views/sale.py create mode 100644 myproject/inventory/views/stock.py create mode 100644 myproject/inventory/views/transfer.py create mode 100644 myproject/inventory/views/warehouse.py create mode 100644 myproject/inventory/views/writeoff.py delete mode 100644 myproject/tenants/migrations/0002_alter_client_is_active_alter_client_name_and_more.py diff --git a/myproject/accounts/migrations/0001_initial.py b/myproject/accounts/migrations/0001_initial.py index 6305286..1c1f343 100644 --- a/myproject/accounts/migrations/0001_initial.py +++ b/myproject/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.4 on 2025-10-26 22:44 +# Generated by Django 5.1.4 on 2025-10-28 22:47 import django.contrib.auth.validators import django.utils.timezone diff --git a/myproject/customers/migrations/0001_initial.py b/myproject/customers/migrations/0001_initial.py index a8efa55..c1c13f8 100644 --- a/myproject/customers/migrations/0001_initial.py +++ b/myproject/customers/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.4 on 2025-10-26 22:44 +# Generated by Django 5.1.4 on 2025-10-28 22:47 import django.db.models.deletion import phonenumber_field.modelfields diff --git a/myproject/inventory/admin.py b/myproject/inventory/admin.py index f8568c8..34e91a1 100644 --- a/myproject/inventory/admin.py +++ b/myproject/inventory/admin.py @@ -1,19 +1,325 @@ from django.contrib import admin -from .models import Stock, StockMovement +from django.utils.html import format_html +from django.urls import reverse +from django.db.models import Sum +from decimal import Decimal + +from inventory.models import ( + Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer, + Inventory, InventoryLine, Reservation, Stock, StockMovement, + SaleBatchAllocation +) +# ===== WAREHOUSE ===== +@admin.register(Warehouse) +class WarehouseAdmin(admin.ModelAdmin): + list_display = ('name', 'is_active', 'created_at') + list_filter = ('is_active', 'created_at') + search_fields = ('name',) + fieldsets = ( + ('Основная информация', { + 'fields': ('name', 'description', 'is_active') + }), + ('Даты', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('created_at', 'updated_at') + + +# ===== STOCK BATCH ===== +@admin.register(StockBatch) +class StockBatchAdmin(admin.ModelAdmin): + list_display = ('product', 'warehouse', 'quantity_display', 'cost_price', 'created_at', 'is_active') + list_filter = ('warehouse', 'is_active', 'created_at') + search_fields = ('product__name', 'product__sku', 'warehouse__name') + date_hierarchy = 'created_at' + fieldsets = ( + ('Партия', { + 'fields': ('product', 'warehouse', 'quantity', 'is_active') + }), + ('Финансы', { + 'fields': ('cost_price',) + }), + ('Даты', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('created_at', 'updated_at') + + def quantity_display(self, obj): + if obj.quantity <= 0: + color = '#ff0000' # красный + elif obj.quantity < 10: + color = '#ff9900' # оранжевый + else: + color = '#008000' # зелёный + + return format_html( + '{}', + color, + f'{obj.quantity} шт' + ) + quantity_display.short_description = 'Количество' + + +# ===== INCOMING BATCH ===== +@admin.register(IncomingBatch) +class IncomingBatchAdmin(admin.ModelAdmin): + list_display = ('document_number', 'warehouse', 'supplier_name', 'items_count', 'created_at') + list_filter = ('warehouse', 'created_at') + search_fields = ('document_number', 'supplier_name') + date_hierarchy = 'created_at' + fieldsets = ( + ('Партия поступления', { + 'fields': ('document_number', 'warehouse', 'supplier_name', 'notes') + }), + ('Даты', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('created_at', 'updated_at') + + def items_count(self, obj): + return obj.items.count() + items_count.short_description = 'Товаров' + + +# ===== INCOMING ===== +@admin.register(Incoming) +class IncomingAdmin(admin.ModelAdmin): + list_display = ('product', 'batch', 'quantity', 'cost_price', 'created_at') + list_filter = ('batch__warehouse', 'created_at', 'product') + search_fields = ('product__name', 'batch__document_number') + date_hierarchy = 'created_at' + fieldsets = ( + ('Товар в партии', { + 'fields': ('batch', 'product', 'quantity', 'cost_price', 'stock_batch') + }), + ('Дата', { + 'fields': ('created_at',), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('created_at', 'stock_batch') + + +# ===== SALE BATCH ALLOCATION (INLINE) ===== +class SaleBatchAllocationInline(admin.TabularInline): + model = SaleBatchAllocation + extra = 0 + readonly_fields = ('batch', 'quantity', 'cost_price') + can_delete = False + fields = ('batch', 'quantity', 'cost_price') + + +# ===== SALE ===== +@admin.register(Sale) +class SaleAdmin(admin.ModelAdmin): + list_display = ('product', 'warehouse', 'quantity', 'sale_price', 'order_display', 'processed_display', 'date') + list_filter = ('warehouse', 'processed', 'date') + search_fields = ('product__name', 'order__order_number') + date_hierarchy = 'date' + fieldsets = ( + ('Продажа', { + 'fields': ('product', 'warehouse', 'quantity', 'sale_price', 'order') + }), + ('Статус', { + 'fields': ('processed',) + }), + ('Документ', { + 'fields': ('document_number',) + }), + ('Дата', { + 'fields': ('date',), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('date',) + inlines = [SaleBatchAllocationInline] + + def order_display(self, obj): + if obj.order: + return f"ORD-{obj.order.order_number}" + return "-" + order_display.short_description = 'Заказ' + + def processed_display(self, obj): + if obj.processed: + return format_html('✓ Обработана') + return format_html('✗ Ожидает') + processed_display.short_description = 'Статус' + + +# ===== WRITE OFF ===== +@admin.register(WriteOff) +class WriteOffAdmin(admin.ModelAdmin): + list_display = ('batch', 'quantity', 'reason_display', 'cost_price', 'date') + list_filter = ('reason', 'date', 'batch__warehouse') + search_fields = ('batch__product__name', 'document_number') + date_hierarchy = 'date' + fieldsets = ( + ('Списание', { + 'fields': ('batch', 'quantity', 'reason', 'cost_price') + }), + ('Документ', { + 'fields': ('document_number', 'notes') + }), + ('Дата', { + 'fields': ('date',), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('date', 'cost_price') + + def reason_display(self, obj): + return obj.get_reason_display() + reason_display.short_description = 'Причина' + + +# ===== TRANSFER ===== +@admin.register(Transfer) +class TransferAdmin(admin.ModelAdmin): + list_display = ('batch', 'from_warehouse', 'to_warehouse', 'quantity', 'date') + list_filter = ('date', 'from_warehouse', 'to_warehouse') + search_fields = ('batch__product__name', 'document_number') + date_hierarchy = 'date' + fieldsets = ( + ('Перемещение', { + 'fields': ('batch', 'from_warehouse', 'to_warehouse', 'quantity', 'new_batch') + }), + ('Документ', { + 'fields': ('document_number',) + }), + ('Дата', { + 'fields': ('date',), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('date', 'new_batch') + + +# ===== INVENTORY LINE (INLINE) ===== +class InventoryLineInline(admin.TabularInline): + model = InventoryLine + extra = 1 + fields = ('product', 'quantity_system', 'quantity_fact', 'difference', 'processed') + readonly_fields = ('difference', 'processed') + + +# ===== INVENTORY ===== +@admin.register(Inventory) +class InventoryAdmin(admin.ModelAdmin): + list_display = ('warehouse', 'status_display', 'date', 'conducted_by') + list_filter = ('status', 'date', 'warehouse') + search_fields = ('warehouse__name', 'conducted_by') + date_hierarchy = 'date' + fieldsets = ( + ('Инвентаризация', { + 'fields': ('warehouse', 'status', 'conducted_by', 'notes') + }), + ('Дата', { + 'fields': ('date',), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('date',) + inlines = [InventoryLineInline] + actions = ['process_inventory'] + + def status_display(self, obj): + colors = { + 'draft': '#ff9900', # оранжевый + 'processing': '#0099ff', # синий + 'completed': '#008000' # зелёный + } + return format_html( + '{}', + colors.get(obj.status, '#000000'), + obj.get_status_display() + ) + status_display.short_description = 'Статус' + + def process_inventory(self, request, queryset): + from inventory.services import InventoryProcessor + + for inventory in queryset: + result = InventoryProcessor.process_inventory(inventory.id) + self.message_user( + request, + f"Инвентаризация {inventory.warehouse.name}: " + f"обработано {result['processed_lines']} строк, " + f"создано {result['writeoffs_created']} списаний и " + f"{result['incomings_created']} приходов" + ) + + process_inventory.short_description = 'Обработать инвентаризацию' + + +# ===== RESERVATION ===== +@admin.register(Reservation) +class ReservationAdmin(admin.ModelAdmin): + list_display = ('product', 'warehouse', 'quantity', 'status_display', 'order_info', 'reserved_at') + list_filter = ('status', 'reserved_at', 'warehouse') + search_fields = ('product__name', 'order_item__order__order_number') + date_hierarchy = 'reserved_at' + fieldsets = ( + ('Резерв', { + 'fields': ('product', 'warehouse', 'quantity', 'status', 'order_item') + }), + ('Даты', { + 'fields': ('reserved_at', 'released_at', 'converted_at') + }), + ) + readonly_fields = ('reserved_at', 'released_at', 'converted_at') + + def status_display(self, obj): + colors = { + 'reserved': '#0099ff', # синий + 'released': '#ff0000', # красный + 'converted_to_sale': '#008000' # зелёный + } + return format_html( + '{}', + colors.get(obj.status, '#000000'), + obj.get_status_display() + ) + status_display.short_description = 'Статус' + + def order_info(self, obj): + if obj.order_item: + return f"ORD-{obj.order_item.order.order_number}" + return "-" + order_info.short_description = 'Заказ' + + +# ===== STOCK ===== +@admin.register(Stock) class StockAdmin(admin.ModelAdmin): - list_display = ('product', 'quantity_available', 'quantity_reserved', 'updated_at') - list_filter = ('updated_at',) - search_fields = ('product__name', 'product__sku') + list_display = ('product', 'warehouse', 'quantity_available', 'quantity_reserved', 'quantity_free', 'updated_at') + list_filter = ('warehouse', 'updated_at') + search_fields = ('product__name', 'product__sku', 'warehouse__name') + fieldsets = ( + ('Остаток', { + 'fields': ('product', 'warehouse', 'quantity_available', 'quantity_reserved') + }), + ('Дата', { + 'fields': ('updated_at',), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('quantity_available', 'quantity_reserved', 'updated_at') +# ===== STOCK MOVEMENT (для аудита) ===== +@admin.register(StockMovement) class StockMovementAdmin(admin.ModelAdmin): list_display = ('product', 'change', 'reason', 'order', 'created_at') list_filter = ('reason', 'created_at') - search_fields = ('product__name', 'order__id') + search_fields = ('product__name', 'order__order_number') date_hierarchy = 'created_at' - - -admin.site.register(Stock, StockAdmin) -admin.site.register(StockMovement, StockMovementAdmin) + readonly_fields = ('created_at',) diff --git a/myproject/inventory/apps.py b/myproject/inventory/apps.py index 905749f..c9fad33 100644 --- a/myproject/inventory/apps.py +++ b/myproject/inventory/apps.py @@ -4,3 +4,7 @@ from django.apps import AppConfig class InventoryConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'inventory' + + def ready(self): + """Регистрируем сигналы при загрузке приложения.""" + import inventory.signals # noqa diff --git a/myproject/inventory/forms.py b/myproject/inventory/forms.py new file mode 100644 index 0000000..72d8d83 --- /dev/null +++ b/myproject/inventory/forms.py @@ -0,0 +1,305 @@ +# -*- coding: utf-8 -*- +from django import forms +from django.core.exceptions import ValidationError +from decimal import Decimal + +from .models import Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch +from products.models import Product + + +class WarehouseForm(forms.ModelForm): + class Meta: + model = Warehouse + fields = ['name', 'description', 'is_active'] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control'}), + 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), + 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + } + + +class SaleForm(forms.ModelForm): + class Meta: + model = Sale + fields = ['product', 'warehouse', 'quantity', 'sale_price', 'order', 'document_number'] + widgets = { + 'product': forms.Select(attrs={'class': 'form-control'}), + 'warehouse': forms.Select(attrs={'class': 'form-control'}), + 'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}), + 'sale_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + 'order': forms.Select(attrs={'class': 'form-control'}), + 'document_number': forms.TextInput(attrs={'class': 'form-control'}), + } + + def clean_quantity(self): + quantity = self.cleaned_data.get('quantity') + if quantity and quantity <= 0: + raise ValidationError('Количество должно быть больше нуля') + return quantity + + def clean_sale_price(self): + sale_price = self.cleaned_data.get('sale_price') + if sale_price and sale_price < 0: + raise ValidationError('Цена не может быть отрицательной') + return sale_price + + +class WriteOffForm(forms.ModelForm): + class Meta: + model = WriteOff + fields = ['batch', 'quantity', 'reason', 'document_number', 'notes'] + widgets = { + 'batch': forms.Select(attrs={'class': 'form-control'}), + 'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}), + 'reason': forms.Select(attrs={'class': 'form-control'}), + 'document_number': forms.TextInput(attrs={'class': 'form-control'}), + 'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Фильтруем партии - показываем только активные + self.fields['batch'].queryset = StockBatch.objects.filter( + is_active=True + ).select_related('product', 'warehouse').order_by('-created_at') + + def clean(self): + cleaned_data = super().clean() + batch = cleaned_data.get('batch') + quantity = cleaned_data.get('quantity') + + if batch and quantity: + if quantity > batch.quantity: + raise ValidationError( + f'Невозможно списать {quantity} шт из партии, ' + f'где только {batch.quantity} шт. ' + f'Недостаток: {quantity - batch.quantity} шт.' + ) + if quantity <= 0: + raise ValidationError('Количество должно быть больше нуля') + + return cleaned_data + + +class TransferForm(forms.ModelForm): + class Meta: + model = Transfer + fields = ['batch', 'from_warehouse', 'to_warehouse', 'quantity', 'document_number'] + widgets = { + 'batch': forms.Select(attrs={'class': 'form-control'}), + 'from_warehouse': forms.Select(attrs={'class': 'form-control'}), + 'to_warehouse': forms.Select(attrs={'class': 'form-control'}), + 'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}), + 'document_number': forms.TextInput(attrs={'class': 'form-control'}), + } + + def clean(self): + cleaned_data = super().clean() + batch = cleaned_data.get('batch') + quantity = cleaned_data.get('quantity') + from_warehouse = cleaned_data.get('from_warehouse') + + if batch and quantity: + if quantity > batch.quantity: + raise ValidationError( + f'Невозможно перенести {quantity} шт, доступно {batch.quantity} шт' + ) + if quantity <= 0: + raise ValidationError('Количество должно быть больше нуля') + + # Проверяем что складской источник совпадает с складом партии + if from_warehouse and batch.warehouse_id != from_warehouse.id: + raise ValidationError( + f'Партия находится на складе "{batch.warehouse.name}", ' + f'а вы выбрали "{from_warehouse.name}"' + ) + + return cleaned_data + + +class ReservationForm(forms.ModelForm): + class Meta: + model = Reservation + fields = ['product', 'warehouse', 'quantity', 'order_item'] + widgets = { + 'product': forms.Select(attrs={'class': 'form-control'}), + 'warehouse': forms.Select(attrs={'class': 'form-control'}), + 'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}), + 'order_item': forms.Select(attrs={'class': 'form-control'}), + } + + def clean_quantity(self): + quantity = self.cleaned_data.get('quantity') + if quantity and quantity <= 0: + raise ValidationError('Количество должно быть больше нуля') + return quantity + + +class InventoryForm(forms.ModelForm): + class Meta: + model = Inventory + fields = ['warehouse', 'conducted_by', 'notes'] + widgets = { + 'warehouse': forms.Select(attrs={'class': 'form-control'}), + 'conducted_by': forms.TextInput(attrs={'class': 'form-control'}), + 'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + } + + +class InventoryLineForm(forms.ModelForm): + class Meta: + model = InventoryLine + fields = ['product', 'quantity_system', 'quantity_fact'] + widgets = { + 'product': forms.Select(attrs={'class': 'form-control'}), + 'quantity_system': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'readonly': True}), + 'quantity_fact': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}), + } + + def clean_quantity_fact(self): + quantity_fact = self.cleaned_data.get('quantity_fact') + if quantity_fact and quantity_fact < 0: + raise ValidationError('Количество не может быть отрицательным') + return quantity_fact + + +# ============================================================================ +# INCOMING FORMS - Ввод товаров (один или много) от одного поставщика +# ============================================================================ + +class IncomingHeaderForm(forms.Form): + """ + Форма для общей информации при приходе товаров. + Используется для ввода информации об источнике поступления (склад, номер документа, поставщик). + """ + warehouse = forms.ModelChoiceField( + queryset=Warehouse.objects.filter(is_active=True), + widget=forms.Select(attrs={'class': 'form-control'}), + label="Склад", + required=True + ) + + document_number = forms.CharField( + max_length=100, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'PO-2024-001 (опционально)'}), + label="Номер документа / ПО", + required=False + ) + + supplier_name = forms.CharField( + max_length=200, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ООО Поставщик'}), + label="Наименование поставщика", + required=False + ) + + notes = forms.CharField( + widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Дополнительная информация'}), + label="Примечания", + required=False + ) + + def clean_document_number(self): + document_number = self.cleaned_data.get('document_number', '') + if document_number: + document_number = document_number.strip() + # Запретить номера, начинающиеся с "IN-" (зарезервировано для системы) + if document_number.upper().startswith('IN-'): + raise ValidationError( + 'Номера, начинающиеся с "IN-", зарезервированы для системы автогенерации. ' + 'Оставьте поле пустым для автогенерации или используйте другой формат.' + ) + return document_number + + +class IncomingLineForm(forms.Form): + """ + Форма для одной строки товара при массовом приходе. + Используется в formset'е для динамического ввода нескольких товаров. + """ + product = forms.ModelChoiceField( + queryset=None, # Будет установлено в __init__ + widget=forms.Select(attrs={'class': 'form-control'}), + label="Товар", + required=True + ) + + quantity = forms.DecimalField( + max_digits=10, + decimal_places=3, + widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}), + label="Количество", + required=True + ) + + cost_price = forms.DecimalField( + max_digits=10, + decimal_places=2, + widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + label="Цена закупки за ед.", + required=True + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Устанавливаем queryset товаров для поля product + self.fields['product'].queryset = Product.objects.filter( + is_active=True + ).order_by('name') + + def clean_quantity(self): + quantity = self.cleaned_data.get('quantity') + if quantity and quantity <= 0: + raise ValidationError('Количество должно быть больше нуля') + return quantity + + def clean_cost_price(self): + cost_price = self.cleaned_data.get('cost_price') + if cost_price and cost_price < 0: + raise ValidationError('Цена не может быть отрицательной') + return cost_price + + +class IncomingForm(forms.Form): + """ + Комбинированная форма для ввода товаров (один или много). + Содержит header информацию (склад, документ, поставщик) + динамический набор товаров. + """ + warehouse = forms.ModelChoiceField( + queryset=Warehouse.objects.filter(is_active=True), + widget=forms.Select(attrs={'class': 'form-control'}), + label="Склад", + required=True + ) + + document_number = forms.CharField( + max_length=100, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'PO-2024-001 (опционально)'}), + label="Номер документа / ПО", + required=False + ) + + supplier_name = forms.CharField( + max_length=200, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ООО Поставщик'}), + label="Наименование поставщика (опционально)", + required=False + ) + + notes = forms.CharField( + widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Дополнительная информация'}), + label="Примечания", + required=False + ) + + def clean_document_number(self): + document_number = self.cleaned_data.get('document_number', '') + if document_number: + document_number = document_number.strip() + # Запретить номера, начинающиеся с "IN-" (зарезервировано для системы) + if document_number.upper().startswith('IN-'): + raise ValidationError( + 'Номера, начинающиеся с "IN-", зарезервированы для системы автогенерации. ' + 'Оставьте поле пустым для автогенерации или используйте другой формат.' + ) + return document_number diff --git a/myproject/inventory/migrations/0001_initial.py b/myproject/inventory/migrations/0001_initial.py index 1478885..d42590b 100644 --- a/myproject/inventory/migrations/0001_initial.py +++ b/myproject/inventory/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.4 on 2025-10-26 22:44 +# Generated by Django 5.1.4 on 2025-10-28 23:32 import django.db.models.deletion from django.db import migrations, models @@ -14,19 +14,204 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='Inventory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата инвентаризации')), + ('status', models.CharField(choices=[('draft', 'Черновик'), ('processing', 'В обработке'), ('completed', 'Завершена')], default='draft', max_length=20, verbose_name='Статус')), + ('conducted_by', models.CharField(blank=True, max_length=200, null=True, verbose_name='Провел инвентаризацию')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')), + ], + options={ + 'verbose_name': 'Инвентаризация', + 'verbose_name_plural': 'Инвентаризации', + 'ordering': ['-date'], + }, + ), + migrations.CreateModel( + name='InventoryLine', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity_system', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество в системе')), + ('quantity_fact', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Фактическое количество')), + ('difference', models.DecimalField(decimal_places=3, default=0, editable=False, max_digits=10, verbose_name='Разница (факт - система)')), + ('processed', models.BooleanField(default=False, verbose_name='Обработана (создана операция)')), + ('inventory', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='inventory.inventory', verbose_name='Инвентаризация')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.product', verbose_name='Товар')), + ], + options={ + 'verbose_name': 'Строка инвентаризации', + 'verbose_name_plural': 'Строки инвентаризации', + }, + ), + migrations.CreateModel( + name='Sale', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')), + ('sale_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Цена продажи')), + ('document_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Номер документа')), + ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата операции')), + ('processed', models.BooleanField(default=False, verbose_name='Обработана (FIFO применена)')), + ('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales', to='orders.order', verbose_name='Заказ')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sales', to='products.product', verbose_name='Товар')), + ], + options={ + 'verbose_name': 'Продажа', + 'verbose_name_plural': 'Продажи', + 'ordering': ['-date'], + }, + ), + migrations.CreateModel( + name='StockBatch', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')), + ('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Закупочная цена')), + ('is_active', models.BooleanField(default=True, verbose_name='Активна')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stock_batches', to='products.product', verbose_name='Товар')), + ], + options={ + 'verbose_name': 'Партия товара', + 'verbose_name_plural': 'Партии товаров', + 'ordering': ['created_at'], + }, + ), + migrations.CreateModel( + name='SaleBatchAllocation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')), + ('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Закупочная цена')), + ('sale', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='batch_allocations', to='inventory.sale', verbose_name='Продажа')), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sale_allocations', to='inventory.stockbatch', verbose_name='Партия')), + ], + options={ + 'verbose_name': 'Распределение продажи по партиям', + 'verbose_name_plural': 'Распределения продаж по партиям', + }, + ), + migrations.CreateModel( + name='Warehouse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название')), + ('description', models.TextField(blank=True, null=True, verbose_name='Описание')), + ('is_active', models.BooleanField(default=True, verbose_name='Активен')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ], + options={ + 'verbose_name': 'Склад', + 'verbose_name_plural': 'Склады', + 'indexes': [models.Index(fields=['is_active'], name='inventory_w_is_acti_3ddeac_idx')], + }, + ), + migrations.AddField( + model_name='stockbatch', + name='warehouse', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stock_batches', to='inventory.warehouse', verbose_name='Склад'), + ), migrations.CreateModel( name='Stock', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('quantity_available', models.DecimalField(decimal_places=3, default=0, max_digits=10, verbose_name='Доступное количество')), - ('quantity_reserved', models.DecimalField(decimal_places=3, default=0, max_digits=10, verbose_name='Зарезервированное количество')), + ('quantity_available', models.DecimalField(decimal_places=3, default=0, editable=False, max_digits=10, verbose_name='Доступное количество')), + ('quantity_reserved', models.DecimalField(decimal_places=3, default=0, editable=False, max_digits=10, verbose_name='Зарезервированное количество')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), - ('product', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='stock', to='products.product', verbose_name='Товар')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stocks', to='products.product', verbose_name='Товар')), + ('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stocks', to='inventory.warehouse', verbose_name='Склад')), ], options={ 'verbose_name': 'Остаток на складе', 'verbose_name_plural': 'Остатки на складе', - 'indexes': [models.Index(fields=['product'], name='inventory_s_product_4c1da7_idx')], + }, + ), + migrations.AddField( + model_name='sale', + name='warehouse', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sales', to='inventory.warehouse', verbose_name='Склад'), + ), + migrations.CreateModel( + name='Reservation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')), + ('status', models.CharField(choices=[('reserved', 'Зарезервирован'), ('released', 'Освобожден'), ('converted_to_sale', 'Преобразован в продажу')], default='reserved', max_length=20, verbose_name='Статус')), + ('reserved_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата резервирования')), + ('released_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата освобождения')), + ('converted_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата преобразования в продажу')), + ('order_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='orders.orderitem', verbose_name='Позиция заказа')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='products.product', verbose_name='Товар')), + ('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.warehouse', verbose_name='Склад')), + ], + options={ + 'verbose_name': 'Резервирование', + 'verbose_name_plural': 'Резервирования', + 'ordering': ['-reserved_at'], + }, + ), + migrations.AddField( + model_name='inventory', + name='warehouse', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventories', to='inventory.warehouse', verbose_name='Склад'), + ), + migrations.CreateModel( + name='IncomingBatch', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')), + ('supplier_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='Наименование поставщика')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incoming_batches', to='inventory.warehouse', verbose_name='Склад')), + ], + options={ + 'verbose_name': 'Партия поступления', + 'verbose_name_plural': 'Партии поступлений', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='WriteOff', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')), + ('reason', models.CharField(choices=[('damage', 'Повреждение'), ('spoilage', 'Порча'), ('shortage', 'Недостача'), ('inventory', 'Инвентаризационная недостача'), ('other', 'Другое')], default='other', max_length=20, verbose_name='Причина')), + ('cost_price', models.DecimalField(decimal_places=2, editable=False, max_digits=10, verbose_name='Закупочная цена')), + ('document_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Номер документа')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')), + ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата операции')), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='writeoffs', to='inventory.stockbatch', verbose_name='Партия')), + ], + options={ + 'verbose_name': 'Списание', + 'verbose_name_plural': 'Списания', + 'ordering': ['-date'], + }, + ), + migrations.CreateModel( + name='Incoming', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')), + ('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Закупочная цена')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incomings', to='products.product', verbose_name='Товар')), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.incomingbatch', verbose_name='Партия')), + ('stock_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incomings', to='inventory.stockbatch', verbose_name='Складская партия')), + ], + options={ + 'verbose_name': 'Товар в поступлении', + 'verbose_name_plural': 'Товары в поступлениях', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['batch'], name='inventory_i_batch_i_c50b63_idx'), models.Index(fields=['product'], name='inventory_i_product_39b00d_idx'), models.Index(fields=['-created_at'], name='inventory_i_created_563ec0_idx')], + 'unique_together': {('batch', 'product')}, }, ), migrations.CreateModel( @@ -45,4 +230,87 @@ class Migration(migrations.Migration): 'indexes': [models.Index(fields=['product'], name='inventory_s_product_cbdc37_idx'), models.Index(fields=['created_at'], name='inventory_s_created_05ebf5_idx')], }, ), + migrations.CreateModel( + name='Transfer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')), + ('document_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Номер документа')), + ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата операции')), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfers', to='inventory.stockbatch', verbose_name='Партия')), + ('new_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_sources', to='inventory.stockbatch', verbose_name='Новая партия')), + ('from_warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfers_from', to='inventory.warehouse', verbose_name='Из склада')), + ('to_warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfers_to', to='inventory.warehouse', verbose_name='На склад')), + ], + options={ + 'verbose_name': 'Перемещение', + 'verbose_name_plural': 'Перемещения', + 'ordering': ['-date'], + 'indexes': [models.Index(fields=['from_warehouse', 'to_warehouse'], name='inventory_t_from_wa_578feb_idx'), models.Index(fields=['date'], name='inventory_t_date_e1402d_idx')], + }, + ), + migrations.AddIndex( + model_name='stockbatch', + index=models.Index(fields=['product', 'warehouse'], name='inventory_s_product_022460_idx'), + ), + migrations.AddIndex( + model_name='stockbatch', + index=models.Index(fields=['created_at'], name='inventory_s_created_10279b_idx'), + ), + migrations.AddIndex( + model_name='stockbatch', + index=models.Index(fields=['is_active'], name='inventory_s_is_acti_0dd559_idx'), + ), + migrations.AddIndex( + model_name='stock', + index=models.Index(fields=['product', 'warehouse'], name='inventory_s_product_112b63_idx'), + ), + migrations.AlterUniqueTogether( + name='stock', + unique_together={('product', 'warehouse')}, + ), + migrations.AddIndex( + model_name='sale', + index=models.Index(fields=['product', 'warehouse'], name='inventory_s_product_084314_idx'), + ), + migrations.AddIndex( + model_name='sale', + index=models.Index(fields=['date'], name='inventory_s_date_8972d4_idx'), + ), + migrations.AddIndex( + model_name='sale', + index=models.Index(fields=['order'], name='inventory_s_order_i_7d13ea_idx'), + ), + migrations.AddIndex( + model_name='reservation', + index=models.Index(fields=['product', 'warehouse'], name='inventory_r_product_fa0d33_idx'), + ), + migrations.AddIndex( + model_name='reservation', + index=models.Index(fields=['status'], name='inventory_r_status_806333_idx'), + ), + migrations.AddIndex( + model_name='reservation', + index=models.Index(fields=['order_item'], name='inventory_r_order_i_ae991f_idx'), + ), + migrations.AddIndex( + model_name='incomingbatch', + index=models.Index(fields=['document_number'], name='inventory_i_documen_679096_idx'), + ), + migrations.AddIndex( + model_name='incomingbatch', + index=models.Index(fields=['warehouse'], name='inventory_i_warehou_cc3a73_idx'), + ), + migrations.AddIndex( + model_name='incomingbatch', + index=models.Index(fields=['-created_at'], name='inventory_i_created_59ee8b_idx'), + ), + migrations.AddIndex( + model_name='writeoff', + index=models.Index(fields=['batch'], name='inventory_w_batch_i_b098ce_idx'), + ), + migrations.AddIndex( + model_name='writeoff', + index=models.Index(fields=['date'], name='inventory_w_date_70c7e3_idx'), + ), ] diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py index 95b4dad..9e41a1d 100644 --- a/myproject/inventory/models.py +++ b/myproject/inventory/models.py @@ -1,38 +1,420 @@ from django.db import models +from django.utils import timezone +from django.core.exceptions import ValidationError +from decimal import Decimal from products.models import Product +class Warehouse(models.Model): + """ + Склад (физическое или логическое место хранения). + """ + name = models.CharField(max_length=200, verbose_name="Название") + description = models.TextField(blank=True, null=True, verbose_name="Описание") + is_active = models.BooleanField(default=True, verbose_name="Активен") + 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=['is_active']), + ] + + def __str__(self): + return self.name + + +class StockBatch(models.Model): + """ + Партия товара (неделимая единица учета). + Ключевая сущность для FIFO. + """ + product = models.ForeignKey(Product, on_delete=models.CASCADE, + related_name='stock_batches', verbose_name="Товар") + warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, + related_name='stock_batches', verbose_name="Склад") + quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество") + cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Закупочная цена") + is_active = models.BooleanField(default=True, verbose_name="Активна") + 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 = "Партии товаров" + ordering = ['created_at'] # FIFO: старые партии первыми + indexes = [ + models.Index(fields=['product', 'warehouse']), + models.Index(fields=['created_at']), + models.Index(fields=['is_active']), + ] + + def __str__(self): + return f"{self.product.name} на {self.warehouse.name} - Остаток: {self.quantity} шт @ {self.cost_price} за ед." + + +class IncomingBatch(models.Model): + """ + Партия поступления товара (один номер документа = одна партия). + Содержит один номер документа и может включать несколько товаров. + """ + warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, + related_name='incoming_batches', verbose_name="Склад") + document_number = models.CharField(max_length=100, unique=True, db_index=True, + verbose_name="Номер документа") + supplier_name = models.CharField(max_length=200, blank=True, null=True, + verbose_name="Наименование поставщика") + notes = models.TextField(blank=True, null=True, verbose_name="Примечания") + 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 = "Партии поступлений" + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['document_number']), + models.Index(fields=['warehouse']), + models.Index(fields=['-created_at']), + ] + + def __str__(self): + total_items = self.items.count() + total_qty = self.items.aggregate(models.Sum('quantity'))['quantity__sum'] or 0 + return f"Партия {self.document_number}: {total_items} товаров, {total_qty} шт" + + +class Incoming(models.Model): + """ + Товар в партии поступления. Много товаров = одна партия (IncomingBatch). + """ + batch = models.ForeignKey(IncomingBatch, on_delete=models.CASCADE, + related_name='items', verbose_name="Партия") + product = models.ForeignKey(Product, on_delete=models.CASCADE, + related_name='incomings', verbose_name="Товар") + quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество") + cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Закупочная цена") + notes = models.TextField(blank=True, null=True, verbose_name="Примечания") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") + stock_batch = models.ForeignKey(StockBatch, on_delete=models.SET_NULL, null=True, blank=True, + related_name='incomings', verbose_name="Складская партия") + + class Meta: + verbose_name = "Товар в поступлении" + verbose_name_plural = "Товары в поступлениях" + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['batch']), + models.Index(fields=['product']), + models.Index(fields=['-created_at']), + ] + unique_together = [['batch', 'product']] # Один товар максимум один раз в партии + + def __str__(self): + return f"{self.product.name}: {self.quantity} шт (партия {self.batch.document_number})" + + +class Sale(models.Model): + """ + Продажа товара. Списывает по FIFO. + """ + product = models.ForeignKey(Product, on_delete=models.CASCADE, + related_name='sales', verbose_name="Товар") + warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, + related_name='sales', verbose_name="Склад") + quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество") + sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Цена продажи") + order = models.ForeignKey('orders.Order', on_delete=models.SET_NULL, null=True, blank=True, + related_name='sales', verbose_name="Заказ") + document_number = models.CharField(max_length=100, blank=True, null=True, + verbose_name="Номер документа") + date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции") + processed = models.BooleanField(default=False, verbose_name="Обработана (FIFO применена)") + + class Meta: + verbose_name = "Продажа" + verbose_name_plural = "Продажи" + ordering = ['-date'] + indexes = [ + models.Index(fields=['product', 'warehouse']), + models.Index(fields=['date']), + models.Index(fields=['order']), + ] + + def __str__(self): + return f"Продажа {self.product.name}: {self.quantity} шт @ {self.sale_price}" + + +class SaleBatchAllocation(models.Model): + """ + Связь между Sale и StockBatch для отслеживания FIFO-списания. + (Для аудита: какая партия использована при продаже) + """ + sale = models.ForeignKey(Sale, on_delete=models.CASCADE, + related_name='batch_allocations', verbose_name="Продажа") + batch = models.ForeignKey(StockBatch, on_delete=models.CASCADE, + related_name='sale_allocations', verbose_name="Партия") + quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество") + cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Закупочная цена") + + class Meta: + verbose_name = "Распределение продажи по партиям" + verbose_name_plural = "Распределения продаж по партиям" + + def __str__(self): + return f"{self.sale} ← {self.batch} ({self.quantity} шт)" + + +class WriteOff(models.Model): + """ + Списание товара вручную (брак, порча, недостача). + Человек выбирает конкретную партию. + """ + REASON_CHOICES = [ + ('damage', 'Повреждение'), + ('spoilage', 'Порча'), + ('shortage', 'Недостача'), + ('inventory', 'Инвентаризационная недостача'), + ('other', 'Другое'), + ] + + batch = models.ForeignKey(StockBatch, on_delete=models.CASCADE, + related_name='writeoffs', verbose_name="Партия") + quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество") + reason = models.CharField(max_length=20, choices=REASON_CHOICES, + default='other', verbose_name="Причина") + cost_price = models.DecimalField(max_digits=10, decimal_places=2, + verbose_name="Закупочная цена", editable=False) + document_number = models.CharField(max_length=100, blank=True, null=True, + verbose_name="Номер документа") + notes = models.TextField(blank=True, null=True, verbose_name="Примечания") + date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции") + + class Meta: + verbose_name = "Списание" + verbose_name_plural = "Списания" + ordering = ['-date'] + indexes = [ + models.Index(fields=['batch']), + models.Index(fields=['date']), + ] + + def __str__(self): + return f"Списание {self.batch.product.name}: {self.quantity} шт ({self.get_reason_display()})" + + def save(self, *args, **kwargs): + # Автоматически записываем cost_price из партии + if not self.pk: # Только при создании + self.cost_price = self.batch.cost_price + + # Проверяем что не списываем больше чем есть + if self.quantity > self.batch.quantity: + raise ValidationError( + f"Невозможно списать {self.quantity} шт из партии, " + f"где только {self.batch.quantity} шт. " + f"Недостаток: {self.quantity - self.batch.quantity} шт." + ) + + # Уменьшаем количество в партии при создании списания + self.batch.quantity -= self.quantity + if self.batch.quantity <= 0: + self.batch.is_active = False + self.batch.save(update_fields=['quantity', 'is_active', 'updated_at']) + + super().save(*args, **kwargs) + + +class Transfer(models.Model): + """ + Перемещение товара между складами. Сохраняет партийность. + """ + batch = models.ForeignKey(StockBatch, on_delete=models.CASCADE, + related_name='transfers', verbose_name="Партия") + from_warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, + related_name='transfers_from', verbose_name="Из склада") + to_warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, + related_name='transfers_to', verbose_name="На склад") + quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество") + document_number = models.CharField(max_length=100, blank=True, null=True, + verbose_name="Номер документа") + date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции") + new_batch = models.ForeignKey(StockBatch, on_delete=models.SET_NULL, null=True, blank=True, + related_name='transfer_sources', verbose_name="Новая партия") + + class Meta: + verbose_name = "Перемещение" + verbose_name_plural = "Перемещения" + ordering = ['-date'] + indexes = [ + models.Index(fields=['from_warehouse', 'to_warehouse']), + models.Index(fields=['date']), + ] + + def __str__(self): + return f"Перемещение {self.batch.product.name} ({self.quantity} шт): {self.from_warehouse} → {self.to_warehouse}" + + +class Inventory(models.Model): + """ + Инвентаризация (физический пересчет товаров). + """ + STATUS_CHOICES = [ + ('draft', 'Черновик'), + ('processing', 'В обработке'), + ('completed', 'Завершена'), + ] + + warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, + related_name='inventories', verbose_name="Склад") + date = models.DateTimeField(auto_now_add=True, verbose_name="Дата инвентаризации") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, + default='draft', verbose_name="Статус") + conducted_by = models.CharField(max_length=200, blank=True, null=True, + verbose_name="Провел инвентаризацию") + notes = models.TextField(blank=True, null=True, verbose_name="Примечания") + + class Meta: + verbose_name = "Инвентаризация" + verbose_name_plural = "Инвентаризации" + ordering = ['-date'] + + def __str__(self): + return f"Инвентаризация {self.warehouse.name} ({self.date.strftime('%Y-%m-%d')})" + + +class InventoryLine(models.Model): + """ + Строка инвентаризации (товар + фактическое количество). + """ + inventory = models.ForeignKey(Inventory, on_delete=models.CASCADE, + related_name='lines', verbose_name="Инвентаризация") + product = models.ForeignKey(Product, on_delete=models.CASCADE, + verbose_name="Товар") + quantity_system = models.DecimalField(max_digits=10, decimal_places=3, + verbose_name="Количество в системе") + quantity_fact = models.DecimalField(max_digits=10, decimal_places=3, + verbose_name="Фактическое количество") + difference = models.DecimalField(max_digits=10, decimal_places=3, + default=0, verbose_name="Разница (факт - система)", + editable=False) + processed = models.BooleanField(default=False, + verbose_name="Обработана (создана операция)") + + class Meta: + verbose_name = "Строка инвентаризации" + verbose_name_plural = "Строки инвентаризации" + + def __str__(self): + return f"{self.product.name}: {self.quantity_system} (сист.) vs {self.quantity_fact} (факт)" + + def save(self, *args, **kwargs): + # Автоматически рассчитываем разницу + self.difference = self.quantity_fact - self.quantity_system + super().save(*args, **kwargs) + + +class Reservation(models.Model): + """ + Резервирование товара для заказа. + Отслеживает, какой товар зарезервирован за каким заказом. + """ + STATUS_CHOICES = [ + ('reserved', 'Зарезервирован'), + ('released', 'Освобожден'), + ('converted_to_sale', 'Преобразован в продажу'), + ] + + order_item = models.ForeignKey('orders.OrderItem', on_delete=models.CASCADE, + related_name='reservations', verbose_name="Позиция заказа", + null=True, blank=True) + product = models.ForeignKey(Product, on_delete=models.CASCADE, + related_name='reservations', verbose_name="Товар") + warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, + related_name='reservations', verbose_name="Склад") + quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, + default='reserved', verbose_name="Статус") + reserved_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата резервирования") + released_at = models.DateTimeField(null=True, blank=True, verbose_name="Дата освобождения") + converted_at = models.DateTimeField(null=True, blank=True, + verbose_name="Дата преобразования в продажу") + + class Meta: + verbose_name = "Резервирование" + verbose_name_plural = "Резервирования" + ordering = ['-reserved_at'] + indexes = [ + models.Index(fields=['product', 'warehouse']), + models.Index(fields=['status']), + models.Index(fields=['order_item']), + ] + + def __str__(self): + order_info = f" (заказ {self.order_item.order.order_number})" if self.order_item else "" + return f"Резерв {self.product.name}: {self.quantity} шт{order_info} [{self.get_status_display()}]" + + class Stock(models.Model): """ - Остатки по каждому товару. + Агрегированные остатки по товарам и складам. + Читаемое представление (может быть кешировано или пересчитано из StockBatch). """ - product = models.OneToOneField(Product, on_delete=models.CASCADE, - related_name='stock', verbose_name="Товар") + product = models.ForeignKey(Product, on_delete=models.CASCADE, + related_name='stocks', verbose_name="Товар") + warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, + related_name='stocks', verbose_name="Склад") quantity_available = models.DecimalField(max_digits=10, decimal_places=3, default=0, - verbose_name="Доступное количество") + verbose_name="Доступное количество", + editable=False) quantity_reserved = models.DecimalField(max_digits=10, decimal_places=3, default=0, - verbose_name="Зарезервированное количество") + verbose_name="Зарезервированное количество", + editable=False) updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления") class Meta: verbose_name = "Остаток на складе" verbose_name_plural = "Остатки на складе" + unique_together = [['product', 'warehouse']] indexes = [ - models.Index(fields=['product']), + models.Index(fields=['product', 'warehouse']), ] def __str__(self): - return f"{self.product.name} - {self.quantity_available}" + return f"{self.product.name} на {self.warehouse.name}: {self.quantity_available} (зарезерв: {self.quantity_reserved})" @property def quantity_free(self): """Свободное количество (доступное минус зарезервированное)""" return self.quantity_available - self.quantity_reserved + def refresh_from_batches(self): + """ + Пересчитать остатки из StockBatch. + Можно вызвать для синхронизации после операций. + """ + total_qty = StockBatch.objects.filter( + product=self.product, + warehouse=self.warehouse, + is_active=True + ).aggregate(models.Sum('quantity'))['quantity__sum'] or Decimal('0') + + total_reserved = Reservation.objects.filter( + product=self.product, + warehouse=self.warehouse, + status='reserved' + ).aggregate(models.Sum('quantity'))['quantity__sum'] or Decimal('0') + + self.quantity_available = total_qty + self.quantity_reserved = total_reserved + self.save() + class StockMovement(models.Model): """ Журнал всех складских операций (приход, списание, коррекция). + Используется для аудита. """ REASON_CHOICES = [ ('purchase', 'Закупка'), @@ -41,7 +423,7 @@ class StockMovement(models.Model): ('adjustment', 'Корректировка'), ] - product = models.ForeignKey(Product, on_delete=models.CASCADE, + product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='movements', verbose_name="Товар") change = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Изменение") reason = models.CharField(max_length=20, choices=REASON_CHOICES, verbose_name="Причина") diff --git a/myproject/inventory/services/__init__.py b/myproject/inventory/services/__init__.py new file mode 100644 index 0000000..2b65052 --- /dev/null +++ b/myproject/inventory/services/__init__.py @@ -0,0 +1,13 @@ +""" +Сервисы для работы со складским учетом. +""" + +from .batch_manager import StockBatchManager +from .sale_processor import SaleProcessor +from .inventory_processor import InventoryProcessor + +__all__ = [ + 'StockBatchManager', + 'SaleProcessor', + 'InventoryProcessor', +] diff --git a/myproject/inventory/services/batch_manager.py b/myproject/inventory/services/batch_manager.py new file mode 100644 index 0000000..9688675 --- /dev/null +++ b/myproject/inventory/services/batch_manager.py @@ -0,0 +1,246 @@ +""" +Менеджер для работы с партиями товаров (StockBatch). +Основной функционал: +- Получение партий для FIFO списания +- Создание новых партий при поступлении +- Списание товара по FIFO при продажах и инвентаризации +""" + +from decimal import Decimal +from django.db import transaction +from django.db.models import Sum, Q + +from inventory.models import StockBatch, Stock, SaleBatchAllocation + + +class StockBatchManager: + """ + Менеджер для работы с партиями товаров. + Реализует логику FIFO для списания товаров. + """ + + @staticmethod + def get_batches_for_fifo(product, warehouse): + """ + Получить все активные партии товара на складе, + отсортированные по created_at (старые первыми для FIFO). + + Args: + product: объект Product + warehouse: объект Warehouse + + Returns: + QuerySet отсортированных партий + """ + return StockBatch.objects.filter( + product=product, + warehouse=warehouse, + is_active=True, + quantity__gt=0 # Только партии с остатком + ).order_by('created_at') # FIFO: старые первыми + + @staticmethod + def create_batch(product, warehouse, quantity, cost_price): + """ + Создать новую партию товара при поступлении. + + Args: + product: объект Product + warehouse: объект Warehouse + quantity: Decimal - количество товара + cost_price: Decimal - закупочная цена + + Returns: + Созданный объект StockBatch + """ + if quantity <= 0: + raise ValueError("Количество должно быть больше нуля") + + batch = StockBatch.objects.create( + product=product, + warehouse=warehouse, + quantity=quantity, + cost_price=cost_price + ) + + # Обновляем кеш остатков + StockBatchManager.refresh_stock_cache(product, warehouse) + + return batch + + @staticmethod + def write_off_by_fifo(product, warehouse, quantity_to_write_off): + """ + Списать товар по FIFO (старые партии первыми). + Возвращает список (batch, written_off_quantity) кортежей. + + Args: + product: объект Product + warehouse: объект Warehouse + quantity_to_write_off: Decimal - сколько списать + + Returns: + list: [(batch, qty_written), ...] - какие партии и сколько списано + + Raises: + ValueError: если недостаточно товара на складе + """ + remaining = quantity_to_write_off + allocations = [] + + # Получаем партии по FIFO + batches = StockBatchManager.get_batches_for_fifo(product, warehouse) + + for batch in batches: + if remaining <= 0: + break + + # Сколько можем списать из этой партии + qty_from_this_batch = min(batch.quantity, remaining) + + # Списываем + batch.quantity -= qty_from_this_batch + batch.save(update_fields=['quantity', 'updated_at']) + + remaining -= qty_from_this_batch + + # Фиксируем распределение + allocations.append((batch, qty_from_this_batch)) + + # Если партия опустошена, деактивируем её + if batch.quantity <= 0: + batch.is_active = False + batch.save(update_fields=['is_active']) + + if remaining > 0: + raise ValueError( + f"Недостаточно товара на складе. " + f"Требуется {quantity_to_write_off}, доступно {quantity_to_write_off - remaining}" + ) + + # Обновляем кеш остатков + StockBatchManager.refresh_stock_cache(product, warehouse) + + return allocations + + @staticmethod + def transfer_batch(batch, to_warehouse, quantity): + """ + Перенести товар из одной партии на другой склад. + Сохраняет cost_price партии. + + Args: + batch: объект StockBatch (источник) + to_warehouse: объект Warehouse (пункт назначения) + quantity: Decimal - сколько перенести + + Returns: + Новый объект StockBatch на целевом складе + """ + if quantity <= 0: + raise ValueError("Количество должно быть больше нуля") + + if quantity > batch.quantity: + raise ValueError( + f"Недостаточно товара в партии. " + f"Требуется {quantity}, доступно {batch.quantity}" + ) + + # Уменьшаем исходную партию + batch.quantity -= quantity + batch.save(update_fields=['quantity', 'updated_at']) + + # Если исходная партия опустошена, деактивируем + if batch.quantity <= 0: + batch.is_active = False + batch.save(update_fields=['is_active']) + + # Создаем новую партию на целевом складе с той же ценой + new_batch = StockBatch.objects.create( + product=batch.product, + warehouse=to_warehouse, + quantity=quantity, + cost_price=batch.cost_price # Сохраняем цену! + ) + + # Обновляем кеш остатков на обоих складах + StockBatchManager.refresh_stock_cache(batch.product, batch.warehouse) + StockBatchManager.refresh_stock_cache(batch.product, to_warehouse) + + return new_batch + + @staticmethod + def refresh_stock_cache(product, warehouse): + """ + Пересчитать кеш остатков для товара на складе. + Обновляет модель Stock с агрегированными данными. + + Args: + product: объект Product + warehouse: объект Warehouse + """ + # Получаем или создаем запись Stock + stock, created = Stock.objects.get_or_create( + product=product, + warehouse=warehouse + ) + + # Обновляем её из батчей + # refresh_from_batches() уже вызывает save() внутри + stock.refresh_from_batches() + + @staticmethod + def get_total_stock(product, warehouse): + """ + Получить общее доступное количество товара на складе. + + Args: + product: объект Product + warehouse: объект Warehouse + + Returns: + Decimal - количество товара + """ + total = StockBatch.objects.filter( + product=product, + warehouse=warehouse, + is_active=True + ).aggregate(total=Sum('quantity'))['total'] or Decimal('0') + + return total + + @staticmethod + def get_batch_details(warehouse, product=None): + """ + Получить подробную информацию о партиях на складе. + Полезно для отчетов. + + Args: + warehouse: объект Warehouse + product: (опционально) объект Product для фильтрации + + Returns: + list: QuerySet партий с деталями + """ + qs = StockBatch.objects.filter(warehouse=warehouse, is_active=True) + + if product: + qs = qs.filter(product=product) + + return qs.select_related('product', 'warehouse').order_by('product', 'created_at') + + @staticmethod + @transaction.atomic + def close_batch(batch): + """ + Закрыть партию (например, при окончании срока годности). + Невозможно списывать из закрытой партии. + + Args: + batch: объект StockBatch + """ + if batch.quantity > 0: + raise ValueError(f"Невозможно закрыть партию с остатком {batch.quantity}") + + batch.is_active = False + batch.save(update_fields=['is_active']) diff --git a/myproject/inventory/services/inventory_processor.py b/myproject/inventory/services/inventory_processor.py new file mode 100644 index 0000000..067309c --- /dev/null +++ b/myproject/inventory/services/inventory_processor.py @@ -0,0 +1,286 @@ +""" +Процессор для обработки инвентаризации. +Основной функционал: +- Обработка расхождений между фактом и системой +- Автоматическое создание WriteOff для недостач (по FIFO) +- Автоматическое создание Incoming для излишков +""" + +from decimal import Decimal +from django.db import transaction +from django.utils import timezone + +from inventory.models import ( + Inventory, InventoryLine, WriteOff, Incoming, + StockBatch, Stock +) +from inventory.services.batch_manager import StockBatchManager + + +class InventoryProcessor: + """ + Обработчик инвентаризации с автоматической коррекцией остатков. + """ + + @staticmethod + @transaction.atomic + def process_inventory(inventory_id): + """ + Обработать инвентаризацию: + - Для недостач (разница < 0): создать WriteOff по FIFO + - Для излишков (разница > 0): создать Incoming с новой партией + - Обновить статус inventory и lines + + Args: + inventory_id: ID объекта Inventory + + Returns: + dict: { + 'inventory': Inventory, + 'processed_lines': int, + 'writeoffs_created': int, + 'incomings_created': int, + 'errors': [...] + } + """ + inventory = Inventory.objects.get(id=inventory_id) + lines = InventoryLine.objects.filter(inventory=inventory, processed=False) + + writeoffs_created = 0 + incomings_created = 0 + errors = [] + + try: + for line in lines: + try: + if line.difference < 0: + # Недостача: списать по FIFO + InventoryProcessor._create_writeoff_for_deficit( + inventory, line + ) + writeoffs_created += 1 + + elif line.difference > 0: + # Излишек: создать новую партию + InventoryProcessor._create_incoming_for_surplus( + inventory, line + ) + incomings_created += 1 + + # Отмечаем строку как обработанную + line.processed = True + line.save(update_fields=['processed']) + + except Exception as e: + errors.append({ + 'line': line, + 'error': str(e) + }) + + # Обновляем статус инвентаризации + inventory.status = 'completed' + inventory.save(update_fields=['status']) + + except Exception as e: + errors.append({ + 'inventory': inventory, + 'error': str(e) + }) + + return { + 'inventory': inventory, + 'processed_lines': lines.count(), + 'writeoffs_created': writeoffs_created, + 'incomings_created': incomings_created, + 'errors': errors + } + + @staticmethod + def _create_writeoff_for_deficit(inventory, line): + """ + Создать операцию WriteOff для недостачи при инвентаризации. + Списывается по FIFO из старейших партий. + + Args: + inventory: объект Inventory + line: объект InventoryLine с negative difference + """ + quantity_to_writeoff = abs(line.difference) + + # Списываем по FIFO + allocations = StockBatchManager.write_off_by_fifo( + line.product, + inventory.warehouse, + quantity_to_writeoff + ) + + # Создаем WriteOff для каждой партии + for batch, qty_allocated in allocations: + WriteOff.objects.create( + batch=batch, + quantity=qty_allocated, + reason='inventory', + cost_price=batch.cost_price, + notes=f'Инвентаризация {inventory.id}, строка {line.id}' + ) + + @staticmethod + def _create_incoming_for_surplus(inventory, line): + """ + Создать операцию Incoming для излишка при инвентаризации. + Новая партия создается с последней известной cost_price товара. + + Args: + inventory: объект Inventory + line: объект InventoryLine с positive difference + """ + quantity_surplus = line.difference + + # Получаем последнюю known cost_price + cost_price = InventoryProcessor._get_last_cost_price( + line.product, + inventory.warehouse + ) + + # Создаем новую партию + batch = StockBatchManager.create_batch( + line.product, + inventory.warehouse, + quantity_surplus, + cost_price + ) + + # Создаем документ Incoming + Incoming.objects.create( + product=line.product, + warehouse=inventory.warehouse, + quantity=quantity_surplus, + cost_price=cost_price, + batch=batch, + notes=f'Инвентаризация {inventory.id}, строка {line.id}' + ) + + @staticmethod + def _get_last_cost_price(product, warehouse): + """ + Получить последнюю известную закупочную цену товара на складе. + Используется для создания новой партии при излишке. + + Порядок поиска: + 1. Последняя активная партия на этом складе + 2. Последняя активная партия на любом складе + 3. cost_price из карточки Product (если есть) + 4. Дефолт 0 (если ничего не найдено) + + Args: + product: объект Product + warehouse: объект Warehouse + + Returns: + Decimal - закупочная цена + """ + from inventory.models import StockBatch + + # Вариант 1: последняя партия на этом складе + last_batch = StockBatch.objects.filter( + product=product, + warehouse=warehouse, + is_active=True + ).order_by('-created_at').first() + + if last_batch: + return last_batch.cost_price + + # Вариант 2: последняя партия на любом складе + last_batch_any = StockBatch.objects.filter( + product=product, + is_active=True + ).order_by('-created_at').first() + + if last_batch_any: + return last_batch_any.cost_price + + # Вариант 3: cost_price из карточки товара + if product.cost_price: + return product.cost_price + + # Вариант 4: ноль (не должно быть) + return Decimal('0') + + @staticmethod + def create_inventory_lines_from_current_stock(inventory): + """ + Автоматически создать InventoryLine для всех товаров на складе. + Используется для удобства: оператор может сразу начать вводить фактические + количества, имея под рукой системные остатки. + + Args: + inventory: объект Inventory + """ + from inventory.models import StockBatch + + # Получаем все товары, которые есть на этом складе + batches = StockBatch.objects.filter( + warehouse=inventory.warehouse, + is_active=True + ).values('product').distinct() + + for batch_dict in batches: + product = batch_dict['product'] + + # Рассчитываем системный остаток + quantity_system = StockBatchManager.get_total_stock(product, inventory.warehouse) + + # Создаем строку инвентаризации (факт будет заполнен оператором) + InventoryLine.objects.get_or_create( + inventory=inventory, + product_id=product, + defaults={ + 'quantity_system': quantity_system, + 'quantity_fact': 0, # Оператор должен заполнить + } + ) + + @staticmethod + def get_inventory_report(inventory): + """ + Получить отчет по инвентаризации. + + Args: + inventory: объект Inventory + + Returns: + dict: { + 'inventory': Inventory, + 'total_lines': int, + 'total_deficit': Decimal, + 'total_surplus': Decimal, + 'lines': [...] + } + """ + lines = InventoryLine.objects.filter(inventory=inventory).select_related('product') + + total_deficit = Decimal('0') + total_surplus = Decimal('0') + lines_data = [] + + for line in lines: + if line.difference < 0: + total_deficit += abs(line.difference) + elif line.difference > 0: + total_surplus += line.difference + + lines_data.append({ + 'line': line, + 'system_value': line.quantity_system * line.product.cost_price, + 'fact_value': line.quantity_fact * line.product.cost_price, + 'value_difference': (line.quantity_fact - line.quantity_system) * line.product.cost_price, + }) + + return { + 'inventory': inventory, + 'total_lines': lines.count(), + 'total_deficit': total_deficit, + 'total_surplus': total_surplus, + 'lines': lines_data + } diff --git a/myproject/inventory/services/sale_processor.py b/myproject/inventory/services/sale_processor.py new file mode 100644 index 0000000..4f0e9f9 --- /dev/null +++ b/myproject/inventory/services/sale_processor.py @@ -0,0 +1,216 @@ +""" +Процессор для обработки продаж. +Основной функционал: +- Создание операции Sale +- FIFO-списание товара из партий +- Фиксирование распределения партий для аудита +""" + +from decimal import Decimal +from django.db import transaction +from django.utils import timezone + +from inventory.models import Sale, SaleBatchAllocation +from inventory.services.batch_manager import StockBatchManager + + +class SaleProcessor: + """ + Обработчик продаж с автоматическим FIFO-списанием. + """ + + @staticmethod + @transaction.atomic + def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None): + """ + Создать операцию продажи и произвести FIFO-списание. + + Процесс: + 1. Создаем запись Sale + 2. Списываем товар по FIFO из партий + 3. Фиксируем распределение в SaleBatchAllocation для аудита + + Args: + product: объект Product + warehouse: объект Warehouse + quantity: Decimal - количество товара + sale_price: Decimal - цена продажи + order: (опционально) объект Order + document_number: (опционально) номер документа + + Returns: + Объект Sale + + Raises: + ValueError: если недостаточно товара или некорректные данные + """ + if quantity <= 0: + raise ValueError("Количество должно быть больше нуля") + + if sale_price < 0: + raise ValueError("Цена продажи не может быть отрицательной") + + # Создаем запись Sale + sale = Sale.objects.create( + product=product, + warehouse=warehouse, + quantity=quantity, + sale_price=sale_price, + order=order, + document_number=document_number, + processed=False + ) + + try: + # Списываем товар по FIFO + allocations = StockBatchManager.write_off_by_fifo(product, warehouse, quantity) + + # Фиксируем распределение для аудита + for batch, qty_allocated in allocations: + SaleBatchAllocation.objects.create( + sale=sale, + batch=batch, + quantity=qty_allocated, + cost_price=batch.cost_price + ) + + # Отмечаем продажу как обработанную + sale.processed = True + sale.save(update_fields=['processed']) + + return sale + + except ValueError as e: + # Если ошибка при списании - удаляем Sale и пробрасываем исключение + sale.delete() + raise + + @staticmethod + def get_sale_cost_analysis(sale): + """ + Получить анализ себестоимости продажи. + Возвращает список партий, использованных при продаже, с расчетом прибыли. + + Args: + sale: объект Sale + + Returns: + dict: { + 'total_quantity': Decimal, + 'total_cost': Decimal, # сумма себестоимости + 'total_revenue': Decimal, # сумма выручки + 'profit': Decimal, + 'profit_margin': Decimal, # процент прибыли + 'allocations': [ # распределение по партиям + { + 'batch': StockBatch, + 'quantity': Decimal, + 'cost_price': Decimal, + 'batch_cost': Decimal, + 'revenue': Decimal, + 'batch_profit': Decimal + }, + ... + ] + } + """ + allocations = SaleBatchAllocation.objects.filter(sale=sale).select_related('batch') + + allocation_details = [] + total_cost = Decimal('0') + total_revenue = sale.quantity * sale.sale_price + + for alloc in allocations: + batch_cost = alloc.quantity * alloc.cost_price + batch_revenue = alloc.quantity * sale.sale_price + batch_profit = batch_revenue - batch_cost + + total_cost += batch_cost + + allocation_details.append({ + 'batch': alloc.batch, + 'quantity': alloc.quantity, + 'cost_price': alloc.cost_price, + 'batch_cost': batch_cost, + 'revenue': batch_revenue, + 'batch_profit': batch_profit + }) + + total_profit = total_revenue - total_cost + profit_margin = (total_profit / total_revenue * 100) if total_revenue > 0 else Decimal('0') + + return { + 'total_quantity': sale.quantity, + 'total_cost': total_cost, + 'total_revenue': total_revenue, + 'profit': total_profit, + 'profit_margin': round(profit_margin, 2), + 'allocations': allocation_details + } + + @staticmethod + def get_sales_report(warehouse, product=None, date_from=None, date_to=None): + """ + Получить отчет по продажам с расчетом прибыли. + + Args: + warehouse: объект Warehouse + product: (опционально) объект Product для фильтрации + date_from: (опционально) начальная дата + date_to: (опционально) конечная дата + + Returns: + dict: { + 'total_sales': int, # количество операций + 'total_quantity': Decimal, + 'total_revenue': Decimal, + 'total_cost': Decimal, + 'total_profit': Decimal, + 'avg_profit_margin': Decimal, + 'sales': [...] # подробная информация по каждой продаже + } + """ + from inventory.models import Sale + + qs = Sale.objects.filter(warehouse=warehouse, processed=True) + + if product: + qs = qs.filter(product=product) + + if date_from: + qs = qs.filter(date__gte=date_from) + + if date_to: + qs = qs.filter(date__lte=date_to) + + sales_list = [] + total_revenue = Decimal('0') + total_cost = Decimal('0') + total_quantity = Decimal('0') + + for sale in qs.select_related('product', 'order'): + analysis = SaleProcessor.get_sale_cost_analysis(sale) + + total_revenue += analysis['total_revenue'] + total_cost += analysis['total_cost'] + total_quantity += analysis['total_quantity'] + + sales_list.append({ + 'sale': sale, + 'analysis': analysis + }) + + total_profit = total_revenue - total_cost + avg_profit_margin = ( + (total_profit / total_revenue * 100) if total_revenue > 0 else Decimal('0') + ) + + return { + 'total_sales': len(sales_list), + 'total_quantity': total_quantity, + 'total_revenue': total_revenue, + 'total_cost': total_cost, + 'total_profit': total_profit, + 'avg_profit_margin': round(avg_profit_margin, 2), + 'sales': sales_list + } diff --git a/myproject/inventory/signals.py b/myproject/inventory/signals.py new file mode 100644 index 0000000..7320555 --- /dev/null +++ b/myproject/inventory/signals.py @@ -0,0 +1,340 @@ +""" +Сигналы для автоматического управления резервами и списаниями. + +Подключаются при создании, изменении и удалении заказов. +""" + +from django.db.models.signals import post_save, pre_delete +from django.dispatch import receiver +from django.utils import timezone +from decimal import Decimal + +from orders.models import Order, OrderItem +from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff +from inventory.services import SaleProcessor +from inventory.services.batch_manager import StockBatchManager +from inventory.services.inventory_processor import InventoryProcessor + + +@receiver(post_save, sender=Order) +def reserve_stock_on_order_create(sender, instance, created, **kwargs): + """ + Сигнал: При создании нового заказа зарезервировать товар. + + Процесс: + 1. Проверяем, новый ли заказ (создан только что) + 2. Для каждого товара в заказе создаем Reservation + 3. Статус резерва = 'reserved' + """ + if not created: + return # Только для новых заказов + + # Определяем склад (пока используем первый активный) + warehouse = Warehouse.objects.filter(is_active=True).first() + + if not warehouse: + # Если нет активных складов, зарезервировать не можем + # Можно логировать ошибку или выбросить исключение + return + + # Для каждого товара в заказе + for item in instance.items.all(): + # Определяем товар (может быть product или product_kit) + product = item.product if item.product else item.product_kit + + if product: + # Создаем резерв + Reservation.objects.create( + order_item=item, + product=product, + warehouse=warehouse, + quantity=Decimal(str(item.quantity)), + status='reserved' + ) + + +@receiver(post_save, sender=Order) +def create_sale_on_order_shipment(sender, instance, created, **kwargs): + """ + Сигнал: Когда заказ переходит в статус 'in_delivery', + создается операция Sale и резервы преобразуются в продажу. + + Процесс: + 1. Проверяем, изменился ли статус на 'in_delivery' + 2. Для каждого товара создаем Sale (автоматический FIFO-список) + 3. Обновляем резерв на 'converted_to_sale' + """ + if created: + return # Только для обновлений + + if instance.status != 'in_delivery': + return # Только для статуса 'in_delivery' + + # Определяем склад + warehouse = Warehouse.objects.filter(is_active=True).first() + + if not warehouse: + return + + # Для каждого товара в заказе + for item in instance.items.all(): + # Определяем товар + product = item.product if item.product else item.product_kit + + if not product: + continue + + try: + # Создаем Sale (с автоматическим FIFO-списанием) + sale = SaleProcessor.create_sale( + product=product, + warehouse=warehouse, + quantity=Decimal(str(item.quantity)), + sale_price=Decimal(str(item.price)), + order=instance, + document_number=instance.order_number + ) + + # Обновляем резерв + reservations = Reservation.objects.filter( + order_item=item, + status='reserved' + ) + + for res in reservations: + res.status = 'converted_to_sale' + res.converted_at = timezone.now() + res.save() + + except ValueError as e: + # Логируем ошибку, но не прерываем процесс + import logging + logger = logging.getLogger(__name__) + logger.error( + f"Ошибка при создании Sale для заказа {instance.order_number}: {e}" + ) + + +@receiver(pre_delete, sender=Order) +def release_stock_on_order_delete(sender, instance, **kwargs): + """ + Сигнал: При удалении/отмене заказа освободить резервы. + + Процесс: + 1. Ищем все резервы для этого заказа + 2. Меняем статус резерва на 'released' + 3. Фиксируем время освобождения + """ + # Находим все резервы для этого заказа + reservations = Reservation.objects.filter( + order_item__order=instance, + status='reserved' + ) + + # Освобождаем каждый резерв + for res in reservations: + res.status = 'released' + res.released_at = timezone.now() + res.save() + + +@receiver(post_save, sender=OrderItem) +def update_reservation_on_item_change(sender, instance, created, **kwargs): + """ + Сигнал: Если изменилось количество товара в позиции заказа, + обновить резерв. + + Процесс: + 1. Если это новая позиция - игнорируем (резерв уже создан через Order) + 2. Если изменилось количество - обновляем резерв или создаем новый + """ + if created: + return # Новые позиции обрабатываются через Order signal + + # Получаем резерв для этой позиции + try: + reservation = Reservation.objects.get( + order_item=instance, + status='reserved' + ) + + # Обновляем количество в резерве + reservation.quantity = Decimal(str(instance.quantity)) + reservation.save() + + except Reservation.DoesNotExist: + # Если резерва нет - создаем новый + # (может быть, если заказ был создан до системы резервов) + warehouse = Warehouse.objects.filter(is_active=True).first() + + if warehouse: + product = instance.product if instance.product else instance.product_kit + + if product: + Reservation.objects.create( + order_item=instance, + product=product, + warehouse=warehouse, + quantity=Decimal(str(instance.quantity)), + status='reserved' + ) + + +@receiver(post_save, sender=Incoming) +def create_stock_batch_on_incoming(sender, instance, created, **kwargs): + """ + Сигнал: При создании товара в приходе (Incoming) автоматически создается StockBatch и обновляется Stock. + + Архитектура: + - IncomingBatch: одна партия поступления (IN-0000-0001) содержит несколько товаров + - Incoming: один товар в партии поступления + - StockBatch: одна партия товара на складе (создается для каждого товара в приходе) + Для FIFO: каждый товар имеет свою partия, чтобы можно было списывать отдельно + + Процесс: + 1. Проверяем, новый ли товар в приходе + 2. Если stock_batch еще не создан - создаем StockBatch для этого товара + 3. Связываем Incoming с созданной StockBatch + 4. Обновляем остатки на складе (Stock) + """ + if not created: + return # Только для новых приходов + + # Если stock_batch уже установлен - не создаем новый + if instance.stock_batch: + return + + # Получаем данные из партии поступления + incoming_batch = instance.batch + warehouse = incoming_batch.warehouse + + # Создаем новую партию товара на складе + # Каждый товар в партии поступления → отдельная StockBatch + stock_batch = StockBatch.objects.create( + product=instance.product, + warehouse=warehouse, + quantity=instance.quantity, + cost_price=instance.cost_price, + is_active=True + ) + + # Связываем Incoming с созданной StockBatch + instance.stock_batch = stock_batch + instance.save(update_fields=['stock_batch']) + + # Обновляем или создаем запись в Stock + from inventory.models import Stock + stock, created_stock = Stock.objects.get_or_create( + product=instance.product, + warehouse=warehouse + ) + # Пересчитываем остаток из всех активных партий + # refresh_from_batches() уже вызывает save(), поэтому не вызываем ещё раз + stock.refresh_from_batches() + + +@receiver(post_save, sender=Sale) +def process_sale_fifo(sender, instance, created, **kwargs): + """ + Сигнал: При создании продажи (Sale) автоматически применяется FIFO-списание. + + Процесс: + 1. Проверяем, новая ли продажа + 2. Если уже обработана - пропускаем + 3. Списываем товар по FIFO из партий + 4. Создаем SaleBatchAllocation для аудита + """ + if not created: + return # Только для новых продаж + + # Если уже обработана - пропускаем + if instance.processed: + return + + try: + # Списываем товар по FIFO + allocations = StockBatchManager.write_off_by_fifo( + instance.product, + instance.warehouse, + instance.quantity + ) + + # Фиксируем распределение для аудита + for batch, qty_allocated in allocations: + SaleBatchAllocation.objects.create( + sale=instance, + batch=batch, + quantity=qty_allocated, + cost_price=batch.cost_price + ) + + # Отмечаем продажу как обработанную (используем update() чтобы избежать повторного срабатывания сигнала) + Sale.objects.filter(pk=instance.pk).update(processed=True) + + # Stock уже обновлен в StockBatchManager.write_off_by_fifo() → refresh_stock_cache() + # Не нужно вызывать ещё раз чтобы избежать race condition + + except ValueError as e: + # Логируем ошибку, но не прерываем процесс + import logging + logger = logging.getLogger(__name__) + logger.error(f"Ошибка при обработке Sale {instance.id}: {str(e)}") + + +@receiver(post_save, sender=Inventory) +def process_inventory_reconciliation(sender, instance, created, **kwargs): + """ + Сигнал: При завершении инвентаризации (status='completed') + автоматически обрабатываются расхождения. + + Процесс: + 1. Проверяем, изменился ли статус на 'completed' + 2. Вызываем InventoryProcessor для обработки дефицитов/излишков + 3. Создаются WriteOff для недостач и Incoming для излишков + """ + if created: + return # Только для обновлений + + # Проверяем, изменился ли статус на 'completed' + if instance.status != 'completed': + return + + try: + # Обрабатываем инвентаризацию + result = InventoryProcessor.process_inventory(instance.id) + + import logging + logger = logging.getLogger(__name__) + logger.info( + f"Inventory {instance.id} processed: " + f"lines={result['processed_lines']}, " + f"writeoffs={result['writeoffs_created']}, " + f"incomings={result['incomings_created']}" + ) + + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Ошибка при обработке Inventory {instance.id}: {str(e)}", exc_info=True) + + +@receiver(post_save, sender=WriteOff) +def update_stock_on_writeoff(sender, instance, created, **kwargs): + """ + Сигнал: При создании или изменении WriteOff (списание) обновляем Stock. + + Процесс: + 1. При создании списания - товар удаляется из StockBatch + 2. Обновляем запись Stock для этого товара + """ + from inventory.models import Stock + + # Получаем или создаем Stock запись + stock, _ = Stock.objects.get_or_create( + product=instance.batch.product, + warehouse=instance.batch.warehouse + ) + + # Пересчитываем остаток из всех активных партий + # refresh_from_batches() уже вызывает save() + stock.refresh_from_batches() diff --git a/myproject/inventory/templates/inventory/allocation/allocation_list.html b/myproject/inventory/templates/inventory/allocation/allocation_list.html new file mode 100644 index 0000000..fe74757 --- /dev/null +++ b/myproject/inventory/templates/inventory/allocation/allocation_list.html @@ -0,0 +1,4 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Распределение продаж{% endblock %} +{% block inventory_content %}

Распределение продаж по партиям (FIFO)

{% if allocations %}{% for a in allocations %}{% endfor %}
ПродажаТоварПартияКол-воЦенаДата
#{{ a.sale.id }}{{ a.sale.product.name }}#{{ a.batch.id }}{{ a.quantity }}{{ a.cost_price }}{{ a.sale.date|date:"d.m.Y" }}
{% else %}
Распределений не найдено.
{% endif %}
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/base_inventory.html b/myproject/inventory/templates/inventory/base_inventory.html new file mode 100644 index 0000000..4b01735 --- /dev/null +++ b/myproject/inventory/templates/inventory/base_inventory.html @@ -0,0 +1,96 @@ +{% extends 'base.html' %} + +{% block title %}{% block inventory_title %}Склад{% endblock %}{% endblock %} + +{% block content %} + + + +{% endblock %} diff --git a/myproject/inventory/templates/inventory/batch/batch_detail.html b/myproject/inventory/templates/inventory/batch/batch_detail.html new file mode 100644 index 0000000..5f6683b --- /dev/null +++ b/myproject/inventory/templates/inventory/batch/batch_detail.html @@ -0,0 +1,4 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Партия товара{% endblock %} +{% block inventory_content %}

Партия #{{ batch.id }}: {{ batch.product.name }}

Товар:{{ batch.product.name }}
Склад:{{ batch.warehouse.name }}
Количество:{{ batch.quantity }} шт
Цена закупки:{{ batch.cost_price }} ₽
Создана:{{ batch.created_at|date:"d.m.Y H:i" }}
Статус:{% if batch.is_active %}Активна{% else %}Неактивна{% endif %}
История операций
История продаж и списаний этой партии.
Вернуться
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/batch/batch_list.html b/myproject/inventory/templates/inventory/batch/batch_list.html new file mode 100644 index 0000000..6178d8a --- /dev/null +++ b/myproject/inventory/templates/inventory/batch/batch_list.html @@ -0,0 +1,49 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Партии товаров{% endblock %} +{% block inventory_content %} +
+
+

Все партии товаров на складе

+
+
+ {% if batches %} +
+ + + + + + + + + + + + + + {% for batch in batches %} + + + + + + + + + + {% endfor %} + +
ID ПартииТоварСкладКол-воЦенаСозданаДействия
+ #{{ batch.pk }} + {{ batch.product.name }}{{ batch.warehouse.name }}{{ batch.quantity }}{{ batch.cost_price }} ₽{{ batch.created_at|date:"d.m.Y" }} + + + +
+
+ {% else %} +
Партий не найдено.
+ {% endif %} +
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/home.html b/myproject/inventory/templates/inventory/home.html new file mode 100644 index 0000000..85bbfee --- /dev/null +++ b/myproject/inventory/templates/inventory/home.html @@ -0,0 +1,149 @@ +{% extends 'base.html' %} + +{% block title %}Склад{% endblock %} + +{% block content %} +
+
+
+

Управление складом

+

Здесь будут инструменты для управления инвентаризацией и складским учетом

+
+
+ +
+ +
+
+
+
+ Управление складами +
+

Создание и управление физическими складами

+ Перейти +
+
+
+ +
+
+
+
+ Приход товара +
+

Регистрация поступления товаров на склад

+ Перейти +
+
+
+ +
+
+
+
+ Реализация товара +
+

Учет проданных товаров с применением FIFO

+ Перейти +
+
+
+ +
+
+
+
+ Инвентаризация +
+

Проверка фактических остатков и корректировка

+ Перейти +
+
+
+ + +
+
+
+
+ Списание товара +
+

Списание брака, порчи, недостач

+ Перейти +
+
+
+ +
+
+
+
+ Перемещение товара +
+

Перемещение между складами с сохранением партийности

+ Перейти +
+
+
+ + +
+
+
+
+ Остатки товаров +
+

Просмотр текущих остатков по складам и товарам

+ Перейти +
+
+
+ +
+
+
+
+ Партии товаров +
+

История партий и их распределение

+ Перейти +
+
+
+ +
+
+
+
+ Журнал операций +
+

Полный журнал всех складских движений

+ Перейти +
+
+
+
+
+ + +{% endblock %} diff --git a/myproject/inventory/templates/inventory/incoming/incoming_bulk_form.html b/myproject/inventory/templates/inventory/incoming/incoming_bulk_form.html new file mode 100644 index 0000000..2ea7a8f --- /dev/null +++ b/myproject/inventory/templates/inventory/incoming/incoming_bulk_form.html @@ -0,0 +1,475 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Массовое поступление товара{% endblock %} +{% block inventory_content %} + +
+
+

Поступление товара от поставщика

+
+
+ + {% if form.non_field_errors %} + + {% endif %} + +
+ {% csrf_token %} + + +
+
+
+ + {{ form.warehouse }} + {% if form.warehouse.errors %} +
{{ form.warehouse.errors.0 }}
+ {% endif %} +
+
+
+
+ + {{ form.document_number }} + {% if form.document_number.errors %} +
{{ form.document_number.errors.0 }}
+ {% endif %} + + Оставьте пустым для автогенерации свободного номера (формат: IN-XXXX-XXXX). Номера, начинающиеся с IN-, зарезервированы для системы. + +
+
+
+ +
+ + {{ form.supplier_name }} + {% if form.supplier_name.errors %} +
{{ form.supplier_name.errors.0 }}
+ {% endif %} +
+ +
+ + {{ form.notes }} + {% if form.notes.errors %} +
{{ form.notes.errors.0 }}
+ {% endif %} +
+ +
+ + +
+
Товары в поступлении
+
+ + + + + + + + + + + + + +
ТоварКол-во (шт)Цена закупкиСуммаДействие
+
+ + +
+ + +
+
+
+
+
+
+
Кол-во позиций:
+
0
+
+
+
Общее количество:
+
0 шт
+
+
+
Сумма поступления:
+
0.00 руб
+
+
+
+
+
+ + + + + +
+ + + Отмена + +
+
+
+
+ + + + + +{% endblock %} diff --git a/myproject/inventory/templates/inventory/incoming/incoming_confirm_delete.html b/myproject/inventory/templates/inventory/incoming/incoming_confirm_delete.html new file mode 100644 index 0000000..8e342ae --- /dev/null +++ b/myproject/inventory/templates/inventory/incoming/incoming_confirm_delete.html @@ -0,0 +1,48 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %}Отмена приходу товара{% endblock %} + +{% block inventory_content %} +
+
+

Подтверждение отмены

+
+ +
+
+ + Внимание! Вы собираетесь отменить приход товара. +
+ +

+ Это действие удалит запись о приходе товара и может повлиять на остатки на складе. +

+ +
+
Информация о приходе:
+
    +
  • Товар: {{ incoming.product.name }}
  • +
  • Склад: {{ incoming.warehouse.name }}
  • +
  • Количество: {{ incoming.quantity }} шт
  • +
  • Цена закупки: {{ incoming.cost_price }} ₽
  • + {% if incoming.document_number %} +
  • Номер документа: {{ incoming.document_number }}
  • + {% endif %} +
+
+ +
+ {% csrf_token %} + +
+ + + Вернуться + +
+
+
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/incoming/incoming_form.html b/myproject/inventory/templates/inventory/incoming/incoming_form.html new file mode 100644 index 0000000..0a1890c --- /dev/null +++ b/myproject/inventory/templates/inventory/incoming/incoming_form.html @@ -0,0 +1,125 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %} + {% if form.instance.pk %} + Редактирование приходу товара + {% else %} + Новый приход товара + {% endif %} +{% endblock %} + +{% block inventory_content %} +
+
+

+ {% if form.instance.pk %} + Редактирование приходу + {% else %} + Регистрация нового поступления + {% endif %} +

+
+ +
+
+ {% csrf_token %} + +
+
+ + {{ form.product }} + {% if form.product.errors %} +
+ {% for error in form.product.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.warehouse }} + {% if form.warehouse.errors %} +
+ {% for error in form.warehouse.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+
+ +
+
+ + {{ form.quantity }} + {% if form.quantity.errors %} +
+ {% for error in form.quantity.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.cost_price }} + {% if form.cost_price.errors %} +
+ {% for error in form.cost_price.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+
+ +
+ + {{ form.document_number }} + {% if form.document_number.errors %} +
+ {% for error in form.document_number.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.notes }} + {% if form.notes.errors %} +
+ {% for error in form.notes.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ + + Отменить + +
+
+
+
+ + +{% endblock %} diff --git a/myproject/inventory/templates/inventory/incoming/incoming_list.html b/myproject/inventory/templates/inventory/incoming/incoming_list.html new file mode 100644 index 0000000..9e268c3 --- /dev/null +++ b/myproject/inventory/templates/inventory/incoming/incoming_list.html @@ -0,0 +1,112 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %}История приходов товара{% endblock %} + +{% block inventory_content %} +
+
+

Приходы товара

+ + Новый приход + +
+ +
+ {% if incomings %} +
+ + + + + + + + + + + + + + + {% for incoming in incomings %} + + + + + + + + + + + {% endfor %} + +
ТоварСкладКоличествоЦена закупкиНомер документаПартияДатаДействия
{{ incoming.product.name }}{{ incoming.batch.warehouse.name }}{{ incoming.quantity }} шт{{ incoming.cost_price }} ₽ + {% if incoming.batch.document_number %} + {{ incoming.batch.document_number }} + {% else %} + + {% endif %} + + {% if incoming.stock_batch %} + + #{{ incoming.stock_batch.pk }} + + {% else %} + Не назначена + {% endif %} + {{ incoming.created_at|date:"d.m.Y H:i" }} + + + + + + +
+
+ + + {% if is_paginated %} + + {% endif %} + {% else %} +
+ Приходов не найдено. + Зарегистрировать новый приход +
+ {% endif %} +
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/incoming_batch/batch_detail.html b/myproject/inventory/templates/inventory/incoming_batch/batch_detail.html new file mode 100644 index 0000000..f0b8a9b --- /dev/null +++ b/myproject/inventory/templates/inventory/incoming_batch/batch_detail.html @@ -0,0 +1,110 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Партия {{ batch.document_number }}{% endblock %} +{% block inventory_content %} +
+
+
+

Партия: {{ batch.document_number }}

+ + Назад + +
+
+
+
+
+
Основная информация
+ + + + + + + + + + + + + + + + + +
Номер:{{ batch.document_number }}
Склад:{{ batch.warehouse.name }}
Поставщик:{{ batch.supplier_name|default:"—" }}
Создана:{{ batch.created_at|date:"d.m.Y H:i" }}
+
+
+
Статистика
+ + + + + + + + + +
Товаров:{{ items.count }}
Общее количество: + {% with total=items.all|length %} + {{ total }} шт + {% endwith %} +
+
+
+ +
Товары в партии
+ {% if items %} +
+ + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + {% endfor %} + +
ТоварКоличествоЦенаСуммаStockBatch IDДатаДействие
{{ item.product.name }}{{ item.quantity }}{{ item.cost_price }} ₽ + {% widthratio item.quantity 1 item.cost_price as total_price %} + {{ total_price|floatformat:2 }} ₽ + + {% if item.stock_batch %} + #{{ item.stock_batch.pk }} + {% else %} + Не назначена + {% endif %} + {{ item.created_at|date:"d.m.Y" }} + {% if item.stock_batch %} + + + + {% endif %} +
+
+ {% else %} +
В этой партии нет товаров.
+ {% endif %} + + {% if batch.notes %} +
Примечания
+

{{ batch.notes }}

+ {% endif %} +
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/incoming_batch/batch_list.html b/myproject/inventory/templates/inventory/incoming_batch/batch_list.html new file mode 100644 index 0000000..e9b9097 --- /dev/null +++ b/myproject/inventory/templates/inventory/incoming_batch/batch_list.html @@ -0,0 +1,60 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Партии поступлений{% endblock %} +{% block inventory_content %} +
+
+
+

Партии поступлений товара

+ + Новое поступление + +
+
+
+ {% if batches %} +
+ + + + + + + + + + + + + + {% for batch in batches %} + + + + + + + + + + {% endfor %} + +
Номер документаСкладПоставщикТоварыКол-воДата созданияДействия
{{ batch.document_number }}{{ batch.warehouse.name }}{{ batch.supplier_name|default:"—" }} + + {% for item in batch.items.all %} + {{ item.product.name }}{% if not forloop.last %}
{% endif %} + {% endfor %} +
+
+ {{ batch.items_count }} + {{ batch.created_at|date:"d.m.Y H:i" }} + + + +
+
+ {% else %} +
Партий поступлений не найдено.
+ {% endif %} +
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/inventory/inventory_detail.html b/myproject/inventory/templates/inventory/inventory/inventory_detail.html new file mode 100644 index 0000000..ef47719 --- /dev/null +++ b/myproject/inventory/templates/inventory/inventory/inventory_detail.html @@ -0,0 +1,111 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %}Детали инвентаризации{% endblock %} + +{% block inventory_content %} +
+
+

Инвентаризация: {{ inventory.warehouse.name }}

+ + Вернуться + +
+ +
+
+
+
Информация
+ + + + + + + + + + + + + + {% if inventory.conducted_by %} + + + + + {% endif %} +
Склад:{{ inventory.warehouse.name }}
Статус: + {% if inventory.status == 'draft' %} + Черновик + {% elif inventory.status == 'processing' %} + В обработке + {% else %} + Завершена + {% endif %} +
Дата:{{ inventory.date|date:"d.m.Y H:i" }}
Провёл:{{ inventory.conducted_by }}
+
+
+ +
+ +
Строки инвентаризации
+ + {% if lines %} +
+ + + + + + + + + + + + {% for line in lines %} + + + + + + + + {% endfor %} + +
ТоварВ системеПо фактуРазницаСтатус
{{ line.product.name }}{{ line.quantity_system }}{{ line.quantity_fact }} + {% if line.difference > 0 %} + +{{ line.difference }} + {% elif line.difference < 0 %} + {{ line.difference }} + {% else %} + 0 + {% endif %} + + {% if line.processed %} + Обработана + {% else %} + Не обработана + {% endif %} +
+
+ {% else %} +
+ Строк инвентаризации не добавлено. + Добавить строки +
+ {% endif %} + +
+ {% if inventory.status != 'completed' %} + + Добавить строки + + {% endif %} + + Вернуться к списку + +
+
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/inventory/inventory_form.html b/myproject/inventory/templates/inventory/inventory/inventory_form.html new file mode 100644 index 0000000..1fff69c --- /dev/null +++ b/myproject/inventory/templates/inventory/inventory/inventory_form.html @@ -0,0 +1,69 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %}Новая инвентаризация{% endblock %} + +{% block inventory_content %} +
+
+

Начало новой инвентаризации

+
+ +
+
+ {% csrf_token %} + +
+ + {{ form.warehouse }} + {% if form.warehouse.errors %} +
+ {% for error in form.warehouse.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.conducted_by }} + {% if form.conducted_by.errors %} +
+ {% for error in form.conducted_by.errors %}{{ error }}{% endfor %} +
+ {% endif %} + Кто проводит инвентаризацию? +
+ +
+ + {{ form.notes }} + {% if form.notes.errors %} +
+ {% for error in form.notes.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ + + Отменить + +
+
+
+
+ + +{% endblock %} diff --git a/myproject/inventory/templates/inventory/inventory/inventory_line_bulk_form.html b/myproject/inventory/templates/inventory/inventory/inventory_line_bulk_form.html new file mode 100644 index 0000000..514de81 --- /dev/null +++ b/myproject/inventory/templates/inventory/inventory/inventory_line_bulk_form.html @@ -0,0 +1,58 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %}Внесение результатов инвентаризации{% endblock %} + +{% block inventory_content %} +
+
+

Внесение результатов инвентаризации

+
+ +
+
+ + Инвентаризация: {{ inventory.warehouse.name }} ({{ inventory.date|date:"d.m.Y" }}) +
+ +
+ {% csrf_token %} + + + + + + + + + + + {% for line in lines %} + + + + + + {% empty %} + + + + {% endfor %} + +
ТоварКол-во в системеКол-во по факту
{{ line.product.name }}{{ line.quantity_system }} + +
+ Нет товаров для инвентаризации +
+ +
+ + + Отменить + +
+
+
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/inventory/inventory_list.html b/myproject/inventory/templates/inventory/inventory/inventory_list.html new file mode 100644 index 0000000..aeca0eb --- /dev/null +++ b/myproject/inventory/templates/inventory/inventory/inventory_list.html @@ -0,0 +1,96 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %}История инвентаризаций{% endblock %} + +{% block inventory_content %} +
+
+

Инвентаризации

+ + Новая инвентаризация + +
+ +
+ {% if inventories %} +
+ + + + + + + + + + + + {% for inventory in inventories %} + + + + + + + + {% endfor %} + +
СкладСтатусПровёлДатаДействия
{{ inventory.warehouse.name }} + {% if inventory.status == 'draft' %} + Черновик + {% elif inventory.status == 'processing' %} + В обработке + {% else %} + Завершена + {% endif %} + {{ inventory.conducted_by|default:"—" }}{{ inventory.date|date:"d.m.Y H:i" }} + + + +
+
+ + {% if is_paginated %} + + {% endif %} + {% else %} +
+ Инвентаризаций не найдено. + Начать новую инвентаризацию +
+ {% endif %} +
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/movements/movement_list.html b/myproject/inventory/templates/inventory/movements/movement_list.html new file mode 100644 index 0000000..178f065 --- /dev/null +++ b/myproject/inventory/templates/inventory/movements/movement_list.html @@ -0,0 +1,4 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Журнал операций{% endblock %} +{% block inventory_content %}

Журнал всех складских операций

{% if movements %}{% for m in movements %}{% endfor %}
ТоварИзменениеПричинаДата
{{ m.product.name }}{% if m.change > 0 %}+{{ m.change }}{% else %}{{ m.change }}{% endif %}{{ m.get_reason_display }}{{ m.created_at|date:"d.m.Y H:i" }}
{% else %}
Операций не найдено.
{% endif %}
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/reservation/reservation_form.html b/myproject/inventory/templates/inventory/reservation/reservation_form.html new file mode 100644 index 0000000..0984509 --- /dev/null +++ b/myproject/inventory/templates/inventory/reservation/reservation_form.html @@ -0,0 +1,5 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Новое резервирование{% endblock %} +{% block inventory_content %}

Резервирование товара

{% csrf_token %}
{{ form.product }}
{{ form.warehouse }}
{{ form.quantity }}
{{ form.order_item }}
Отмена
+ +{% endblock %} diff --git a/myproject/inventory/templates/inventory/reservation/reservation_list.html b/myproject/inventory/templates/inventory/reservation/reservation_list.html new file mode 100644 index 0000000..c8ea865 --- /dev/null +++ b/myproject/inventory/templates/inventory/reservation/reservation_list.html @@ -0,0 +1,4 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Резервирования{% endblock %} +{% block inventory_content %}

Активные резервирования Новое

{% if reservations %}{% for r in reservations %}{% endfor %}
ТоварКол-воСкладЗарезервированоДействия
{{ r.product.name }}{{ r.quantity }}{{ r.warehouse.name }}{{ r.reserved_at|date:"d.m.Y" }}
{% else %}
Резервирований не найдено.
{% endif %}
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/reservation/reservation_update.html b/myproject/inventory/templates/inventory/reservation/reservation_update.html new file mode 100644 index 0000000..fef6528 --- /dev/null +++ b/myproject/inventory/templates/inventory/reservation/reservation_update.html @@ -0,0 +1,5 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Изменение резервирования{% endblock %} +{% block inventory_content %}

Изменение статуса резервирования

{% csrf_token %}
{{ form.status }}
Отмена
+ +{% endblock %} diff --git a/myproject/inventory/templates/inventory/sale/sale_confirm_delete.html b/myproject/inventory/templates/inventory/sale/sale_confirm_delete.html new file mode 100644 index 0000000..ada7a6d --- /dev/null +++ b/myproject/inventory/templates/inventory/sale/sale_confirm_delete.html @@ -0,0 +1,55 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %}Отмена продажи{% endblock %} + +{% block inventory_content %} +
+
+

Подтверждение отмены продажи

+
+ +
+
+ + Внимание! Вы собираетесь отменить продажу. +
+ +

+ Это действие удалит запись о продаже и может повлиять на остатки товара на складе. +

+ +
+
Информация о продаже:
+
    +
  • Товар: {{ sale.product.name }}
  • +
  • Склад: {{ sale.warehouse.name }}
  • +
  • Количество: {{ sale.quantity }} шт
  • +
  • Цена продажи: {{ sale.sale_price }} ₽
  • +
  • Статус: + {% if sale.processed %} + Обработана + {% else %} + Ожидает обработки + {% endif %} +
  • + {% if sale.document_number %} +
  • Номер документа: {{ sale.document_number }}
  • + {% endif %} +
+
+ +
+ {% csrf_token %} + +
+ + + Вернуться + +
+
+
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/sale/sale_detail.html b/myproject/inventory/templates/inventory/sale/sale_detail.html new file mode 100644 index 0000000..d064907 --- /dev/null +++ b/myproject/inventory/templates/inventory/sale/sale_detail.html @@ -0,0 +1,138 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %}Детали продажи{% endblock %} + +{% block inventory_content %} +
+
+

Продажа: {{ sale.product.name }}

+ + Вернуться + +
+ +
+
+
+
Информация о продаже
+ + + + + + + + + + + + + + + + + + + + + +
Товар:{{ sale.product.name }}
Склад:{{ sale.warehouse.name }}
Количество:{{ sale.quantity }} шт
Цена продажи:{{ sale.sale_price }} ₽
Сумма:{{ sale.quantity|add:0|multiply:sale.sale_price }} ₽
+
+ +
+
Дополнительная информация
+ + + + + + + + + + {% if sale.order %} + + + + + {% endif %} + {% if sale.document_number %} + + + + + {% endif %} +
Статус: + {% if sale.processed %} + Обработана (FIFO применена) + {% else %} + Ожидает обработки + {% endif %} +
Дата продажи:{{ sale.date|date:"d.m.Y H:i" }}
Связанный заказ:{{ sale.order.order_number }}
Номер документа:{{ sale.document_number }}
+
+
+ +
+ +
Распределение по партиям (FIFO)
+

Какие партии товара использовались в этой продаже:

+ + {% if allocations %} +
+ + + + + + + + + + + + {% for allocation in allocations %} + + + + + + + + {% endfor %} + + + + + + + + +
ПартияДата созданияКоличество использованоЗакупочная ценаСумма закупки
+ Партия #{{ allocation.batch.id }} + {{ allocation.batch.created_at|date:"d.m.Y H:i" }}{{ allocation.quantity }} шт{{ allocation.cost_price }} ₽{{ allocation.quantity|add:0|multiply:allocation.cost_price }} ₽
Итого:{{ sale.quantity }} шт + + {% comment %} Сумма всех закупочных цен {% endcomment %} + Средняя стоимость + +
+
+ {% else %} +
+ Распределение по партиям ещё не выполнено. +
+ {% endif %} + + +
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/sale/sale_form.html b/myproject/inventory/templates/inventory/sale/sale_form.html new file mode 100644 index 0000000..ef1a18a --- /dev/null +++ b/myproject/inventory/templates/inventory/sale/sale_form.html @@ -0,0 +1,127 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %} + {% if form.instance.pk %} + Редактирование продажи + {% else %} + Новая продажа + {% endif %} +{% endblock %} + +{% block inventory_content %} +
+
+

+ {% if form.instance.pk %} + Редактирование продажи + {% else %} + Регистрация новой продажи + {% endif %} +

+
+ +
+
+ {% csrf_token %} + +
+
+ + {{ form.product }} + {% if form.product.errors %} +
+ {% for error in form.product.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.warehouse }} + {% if form.warehouse.errors %} +
+ {% for error in form.warehouse.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+
+ +
+
+ + {{ form.quantity }} + {% if form.quantity.errors %} +
+ {% for error in form.quantity.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.sale_price }} + {% if form.sale_price.errors %} +
+ {% for error in form.sale_price.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+
+ +
+
+ + {{ form.order }} + {% if form.order.errors %} +
+ {% for error in form.order.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.document_number }} + {% if form.document_number.errors %} +
+ {% for error in form.document_number.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+
+ +
+ + + Отменить + +
+
+
+
+ + +{% endblock %} diff --git a/myproject/inventory/templates/inventory/sale/sale_list.html b/myproject/inventory/templates/inventory/sale/sale_list.html new file mode 100644 index 0000000..e360b48 --- /dev/null +++ b/myproject/inventory/templates/inventory/sale/sale_list.html @@ -0,0 +1,113 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %}История продаж{% endblock %} + +{% block inventory_content %} +
+
+

Продажи товара (FIFO)

+ + Новая продажа + +
+ +
+ {% if sales %} +
+ + + + + + + + + + + + + + + {% for sale in sales %} + + + + + + + + + + + {% endfor %} + +
ТоварСкладКоличествоЦена продажиЗаказСтатусДатаДействия
{{ sale.product.name }}{{ sale.warehouse.name }}{{ sale.quantity }} шт{{ sale.sale_price }} ₽ + {% if sale.order %} + {{ sale.order.order_number }} + {% else %} + + {% endif %} + + {% if sale.processed %} + Обработана + {% else %} + Ожидает + {% endif %} + {{ sale.date|date:"d.m.Y H:i" }} + + + + + + + + + +
+
+ + + {% if is_paginated %} + + {% endif %} + {% else %} +
+ Продаж не найдено. + Зарегистрировать новую продажу +
+ {% endif %} +
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/stock/stock_detail.html b/myproject/inventory/templates/inventory/stock/stock_detail.html new file mode 100644 index 0000000..137c562 --- /dev/null +++ b/myproject/inventory/templates/inventory/stock/stock_detail.html @@ -0,0 +1,4 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Остатки товара{% endblock %} +{% block inventory_content %}

{{ stock.product.name }} на {{ stock.warehouse.name }}

Товар:{{ stock.product.name }}
Склад:{{ stock.warehouse.name }}
Доступно:{{ stock.quantity_available }} шт
Зарезервировано:{{ stock.quantity_reserved }} шт
Свободно:{{ stock.quantity_free }} шт
Последнее обновление:{{ stock.updated_at|date:"d.m.Y H:i" }}
Вернуться
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/stock/stock_list.html b/myproject/inventory/templates/inventory/stock/stock_list.html new file mode 100644 index 0000000..8940b48 --- /dev/null +++ b/myproject/inventory/templates/inventory/stock/stock_list.html @@ -0,0 +1,4 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Остатки товаров{% endblock %} +{% block inventory_content %}

Остатки на складах

{% if stocks %}{% for stock in stocks %}{% endfor %}
ТоварСкладДоступноЗарезервированоСвободноПоследний обновления
{{ stock.product.name }}{{ stock.warehouse.name }}{{ stock.quantity_available }}{{ stock.quantity_reserved }}{{ stock.quantity_free }}{{ stock.updated_at|date:"d.m.Y H:i" }}
{% else %}
Остатки не найдены.
{% endif %}
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/transfer/transfer_confirm_delete.html b/myproject/inventory/templates/inventory/transfer/transfer_confirm_delete.html new file mode 100644 index 0000000..8318384 --- /dev/null +++ b/myproject/inventory/templates/inventory/transfer/transfer_confirm_delete.html @@ -0,0 +1,4 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Отмена перемещения{% endblock %} +{% block inventory_content %}

Подтверждение

Отменить перемещение товара?
{% csrf_token %}
Отмена
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/transfer/transfer_form.html b/myproject/inventory/templates/inventory/transfer/transfer_form.html new file mode 100644 index 0000000..d7a6805 --- /dev/null +++ b/myproject/inventory/templates/inventory/transfer/transfer_form.html @@ -0,0 +1,6 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Перемещение товара{% endblock %} +{% block inventory_content %} +

Перемещение товара

{% csrf_token %}
{{ form.batch }}
{{ form.from_warehouse }}
{{ form.to_warehouse }}
{{ form.quantity }}
{{ form.document_number }}
Отмена
+ +{% endblock %} diff --git a/myproject/inventory/templates/inventory/transfer/transfer_list.html b/myproject/inventory/templates/inventory/transfer/transfer_list.html new file mode 100644 index 0000000..52ff16c --- /dev/null +++ b/myproject/inventory/templates/inventory/transfer/transfer_list.html @@ -0,0 +1,4 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Перемещение товаров{% endblock %} +{% block inventory_content %}

Перемещение товаров между складами Новое

{% if transfers %}
{% for t in transfers %}{% endfor %}
ТоварИзВКол-воДатаДействия
{{ t.batch.product.name }}{{ t.from_warehouse.name }}{{ t.to_warehouse.name }}{{ t.quantity }}{{ t.date|date:"d.m.Y" }}
{% else %}
Перемещений не найдено.
{% endif %}
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/warehouse/warehouse_confirm_delete.html b/myproject/inventory/templates/inventory/warehouse/warehouse_confirm_delete.html new file mode 100644 index 0000000..e1e7553 --- /dev/null +++ b/myproject/inventory/templates/inventory/warehouse/warehouse_confirm_delete.html @@ -0,0 +1,41 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %}Удаление склада{% endblock %} + +{% block inventory_content %} +
+
+

Подтверждение удаления

+
+ +
+
+ + Внимание! Вы собираетесь удалить (деактивировать) склад. +
+ +

+ Этот склад будет деактивирован и скрыт из основного списка. +

+ +
Склад: {{ warehouse.name }}
+ + {% if warehouse.description %} +

{{ warehouse.description }}

+ {% endif %} + +
+ {% csrf_token %} + +
+ + + Отменить + +
+
+
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/warehouse/warehouse_form.html b/myproject/inventory/templates/inventory/warehouse/warehouse_form.html new file mode 100644 index 0000000..4156114 --- /dev/null +++ b/myproject/inventory/templates/inventory/warehouse/warehouse_form.html @@ -0,0 +1,86 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %} + {% if form.instance.pk %} + Редактирование склада + {% else %} + Создание нового склада + {% endif %} +{% endblock %} + +{% block inventory_content %} +
+
+

+ {% if form.instance.pk %} + Редактирование: {{ form.instance.name }} + {% else %} + Создание нового склада + {% endif %} +

+
+ +
+
+ {% csrf_token %} + +
+ + + {% if form.name.errors %} +
+ {% for error in form.name.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ + + {% if form.description.errors %} +
+ {% for error in form.description.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+
+ + +
+
+ +
+ + + Отменить + +
+
+
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/warehouse/warehouse_list.html b/myproject/inventory/templates/inventory/warehouse/warehouse_list.html new file mode 100644 index 0000000..3c648c3 --- /dev/null +++ b/myproject/inventory/templates/inventory/warehouse/warehouse_list.html @@ -0,0 +1,98 @@ +{% extends 'inventory/base_inventory.html' %} + +{% block inventory_title %}Управление складами{% endblock %} + +{% block inventory_content %} +
+
+

Список складов

+ + Новый склад + +
+ +
+ {% if warehouses %} +
+ + + + + + + + + + + + {% for warehouse in warehouses %} + + + + + + + + {% endfor %} + +
НазваниеОписаниеСтатусДата созданияДействия
{{ warehouse.name }}{{ warehouse.description|truncatewords:10 }} + {% if warehouse.is_active %} + Активен + {% else %} + Неактивен + {% endif %} + {{ warehouse.created_at|date:"d.m.Y H:i" }} + + + + + + +
+
+ + + {% if is_paginated %} + + {% endif %} + {% else %} +
+ Складов не найдено. + Создать новый +
+ {% endif %} +
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/writeoff/writeoff_confirm_delete.html b/myproject/inventory/templates/inventory/writeoff/writeoff_confirm_delete.html new file mode 100644 index 0000000..7150633 --- /dev/null +++ b/myproject/inventory/templates/inventory/writeoff/writeoff_confirm_delete.html @@ -0,0 +1,11 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}Отмена списания{% endblock %} +{% block inventory_content %} +
+

Подтверждение отмены

+
+
Внимание! Вы собираетесь отменить списание товара.
+
<{% csrf_token %}
Отмена
+
+
+{% endblock %} diff --git a/myproject/inventory/templates/inventory/writeoff/writeoff_form.html b/myproject/inventory/templates/inventory/writeoff/writeoff_form.html new file mode 100644 index 0000000..d0f2411 --- /dev/null +++ b/myproject/inventory/templates/inventory/writeoff/writeoff_form.html @@ -0,0 +1,174 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}{% if form.instance.pk %}Редактирование списания{% else %}Новое списание{% endif %}{% endblock %} +{% block inventory_content %} +
+

{% if form.instance.pk %}Редактирование{% else %}Создание{% endif %} списания

+
+ + {% if form.non_field_errors %} + + {% endif %} + +
+ {% csrf_token %} + + +
+ + {{ form.batch }} + {% if form.batch.errors %} +
{{ form.batch.errors.0 }}
+ {% endif %} + + +
+ + +
+ + {{ form.quantity }} + {% if form.quantity.errors %} +
{{ form.quantity.errors.0 }}
+ {% endif %} + + Введите количество товара для списания + + + +
+ + +
+ + {{ form.reason }} + {% if form.reason.errors %} +
{{ form.reason.errors.0 }}
+ {% endif %} +
+ + +
+ + {{ form.document_number }} + {% if form.document_number.errors %} +
{{ form.document_number.errors.0 }}
+ {% endif %} +
+ + +
+ + {{ form.notes }} + {% if form.notes.errors %} +
{{ form.notes.errors.0 }}
+ {% endif %} +
+ + +
+ + Отмена +
+
+
+
+ + + + +{% endblock %} diff --git a/myproject/inventory/templates/inventory/writeoff/writeoff_list.html b/myproject/inventory/templates/inventory/writeoff/writeoff_list.html new file mode 100644 index 0000000..05b7a04 --- /dev/null +++ b/myproject/inventory/templates/inventory/writeoff/writeoff_list.html @@ -0,0 +1,49 @@ +{% extends 'inventory/base_inventory.html' %} +{% block inventory_title %}История списаний{% endblock %} +{% block inventory_content %} +
+
+

Списания товара

+ + Новое списание + +
+
+ {% if writeoffs %} +
+ + + + + + + + + + + + {% for writeoff in writeoffs %} + + + + + + + + {% endfor %} + +
ТоварКоличествоПричинаДатаДействия
{{ writeoff.batch.product.name }}{{ writeoff.quantity }} шт{{ writeoff.get_reason_display }}{{ writeoff.date|date:"d.m.Y H:i" }} + + + + + + +
+
+ {% else %} +
Списаний не найдено.
+ {% endif %} +
+
+{% endblock %} diff --git a/myproject/inventory/tests.py b/myproject/inventory/tests.py index 7ce503c..0cc11fc 100644 --- a/myproject/inventory/tests.py +++ b/myproject/inventory/tests.py @@ -1,3 +1,484 @@ +""" +Тесты для складского учета с FIFO логикой. +""" + +from decimal import Decimal from django.test import TestCase -# Create your tests here. +from products.models import Product +from inventory.models import Warehouse, StockBatch, Incoming, Sale, WriteOff, Transfer, Inventory, InventoryLine, Reservation, Stock +from inventory.services import StockBatchManager, SaleProcessor, InventoryProcessor +from orders.models import Order, OrderItem +from customers.models import Customer + + +class WarehouseModelTest(TestCase): + """Тесты модели Warehouse.""" + + def setUp(self): + self.warehouse = Warehouse.objects.create( + name='Основной склад', + description='Главный склад компании' + ) + + def test_warehouse_creation(self): + """Тест создания склада.""" + self.assertEqual(self.warehouse.name, 'Основной склад') + self.assertTrue(self.warehouse.is_active) + self.assertIsNotNone(self.warehouse.created_at) + + def test_warehouse_str(self): + """Тест строкового представления склада.""" + self.assertEqual(str(self.warehouse), 'Основной склад') + + +class StockBatchManagerFIFOTest(TestCase): + """Тесты FIFO логики для партий товаров.""" + + def setUp(self): + """Подготовка тестовых данных.""" + # Создаем склад + self.warehouse = Warehouse.objects.create(name='Склад 1') + + # Создаем товар + self.product = Product.objects.create( + name='Роза красная', + cost_price=Decimal('10.00'), + sale_price=Decimal('30.00') + ) + + def test_create_batch(self): + """Тест создания новой партии.""" + batch = StockBatchManager.create_batch( + product=self.product, + warehouse=self.warehouse, + quantity=Decimal('100'), + cost_price=Decimal('10.00') + ) + + self.assertEqual(batch.quantity, Decimal('100')) + self.assertEqual(batch.cost_price, Decimal('10.00')) + self.assertTrue(batch.is_active) + + def test_fifo_write_off_single_batch(self): + """Тест FIFO списания из одной партии.""" + # Создаем партию + batch = StockBatchManager.create_batch( + product=self.product, + warehouse=self.warehouse, + quantity=Decimal('100'), + cost_price=Decimal('10.00') + ) + + # Списываем 50 шт + allocations = StockBatchManager.write_off_by_fifo( + product=self.product, + warehouse=self.warehouse, + quantity_to_write_off=Decimal('50') + ) + + # Проверяем результат + self.assertEqual(len(allocations), 1) + self.assertEqual(allocations[0][1], Decimal('50')) # qty_written + + # Проверяем остаток в партии + batch.refresh_from_db() + self.assertEqual(batch.quantity, Decimal('50')) + self.assertTrue(batch.is_active) + + def test_fifo_write_off_multiple_batches(self): + """Тест FIFO списания из нескольких партий (старые первыми).""" + # Создаем 3 партии в разные моменты + batch1 = StockBatchManager.create_batch( + product=self.product, + warehouse=self.warehouse, + quantity=Decimal('30'), + cost_price=Decimal('10.00') # Старейшая + ) + + batch2 = StockBatchManager.create_batch( + product=self.product, + warehouse=self.warehouse, + quantity=Decimal('40'), + cost_price=Decimal('12.00') + ) + + batch3 = StockBatchManager.create_batch( + product=self.product, + warehouse=self.warehouse, + quantity=Decimal('50'), + cost_price=Decimal('15.00') # Новейшая + ) + + # Списываем 100 шт (должно быть: вся batch1, вся batch2, 30 из batch3) + allocations = StockBatchManager.write_off_by_fifo( + product=self.product, + warehouse=self.warehouse, + quantity_to_write_off=Decimal('100') + ) + + # Проверяем FIFO порядок + self.assertEqual(len(allocations), 3) + self.assertEqual(allocations[0][0].id, batch1.id) # Первая списана batch1 + self.assertEqual(allocations[0][1], Decimal('30')) # Всё из batch1 + + self.assertEqual(allocations[1][0].id, batch2.id) # Вторая списана batch2 + self.assertEqual(allocations[1][1], Decimal('40')) # Всё из batch2 + + self.assertEqual(allocations[2][0].id, batch3.id) # Третья batch3 + self.assertEqual(allocations[2][1], Decimal('30')) # 30 из batch3 + + # Проверяем остатки + batch1.refresh_from_db() + batch2.refresh_from_db() + batch3.refresh_from_db() + + self.assertEqual(batch1.quantity, Decimal('0')) + self.assertFalse(batch1.is_active) # Деактивирована + + self.assertEqual(batch2.quantity, Decimal('0')) + self.assertFalse(batch2.is_active) + + self.assertEqual(batch3.quantity, Decimal('20')) + self.assertTrue(batch3.is_active) + + def test_insufficient_stock_error(self): + """Тест ошибки при недостаточном товаре на складе.""" + batch = StockBatchManager.create_batch( + product=self.product, + warehouse=self.warehouse, + quantity=Decimal('50'), + cost_price=Decimal('10.00') + ) + + # Пытаемся списать больше, чем есть + with self.assertRaises(ValueError) as context: + StockBatchManager.write_off_by_fifo( + product=self.product, + warehouse=self.warehouse, + quantity_to_write_off=Decimal('100') + ) + + self.assertIn('Недостаточно товара', str(context.exception)) + + def test_transfer_batch(self): + """Тест перемещения товара между складами с сохранением цены.""" + warehouse2 = Warehouse.objects.create(name='Склад 2') + + # Создаем партию на первом складе + batch1 = StockBatchManager.create_batch( + product=self.product, + warehouse=self.warehouse, + quantity=Decimal('100'), + cost_price=Decimal('10.00') + ) + + # Переносим 40 шт на второй склад + new_batch = StockBatchManager.transfer_batch( + batch=batch1, + to_warehouse=warehouse2, + quantity=Decimal('40') + ) + + # Проверяем результаты + batch1.refresh_from_db() + self.assertEqual(batch1.quantity, Decimal('60')) + + self.assertEqual(new_batch.warehouse, warehouse2) + self.assertEqual(new_batch.quantity, Decimal('40')) + self.assertEqual(new_batch.cost_price, Decimal('10.00')) # Цена сохранена! + + +class SaleProcessorTest(TestCase): + """Тесты обработки продаж с FIFO списанием.""" + + def setUp(self): + self.warehouse = Warehouse.objects.create(name='Склад 1') + self.product = Product.objects.create( + name='Гвоздика', + cost_price=Decimal('5.00'), + sale_price=Decimal('20.00') + ) + + def test_create_sale_with_fifo(self): + """Тест создания продажи с FIFO списанием.""" + # Создаем партии + batch1 = StockBatchManager.create_batch( + self.product, self.warehouse, + Decimal('30'), Decimal('5.00') + ) + batch2 = StockBatchManager.create_batch( + self.product, self.warehouse, + Decimal('50'), Decimal('6.00') + ) + + # Создаем продажу 40 шт + sale = SaleProcessor.create_sale( + product=self.product, + warehouse=self.warehouse, + quantity=Decimal('40'), + sale_price=Decimal('20.00') + ) + + # Проверяем Sale + self.assertTrue(sale.processed) + self.assertEqual(sale.quantity, Decimal('40')) + + # Проверяем FIFO распределение + allocations = list(sale.batch_allocations.all()) + self.assertEqual(len(allocations), 2) + self.assertEqual(allocations[0].quantity, Decimal('30')) # Всё из batch1 + self.assertEqual(allocations[1].quantity, Decimal('10')) # 10 из batch2 + + def test_sale_cost_analysis(self): + """Тест анализа себестоимости продажи.""" + # Создаем партии с разными ценами + batch1 = StockBatchManager.create_batch( + self.product, self.warehouse, + Decimal('30'), Decimal('5.00') + ) + batch2 = StockBatchManager.create_batch( + self.product, self.warehouse, + Decimal('50'), Decimal('10.00') + ) + + # Создаем продажу + sale = SaleProcessor.create_sale( + product=self.product, + warehouse=self.warehouse, + quantity=Decimal('40'), + sale_price=Decimal('25.00') + ) + + # Анализируем прибыль + analysis = SaleProcessor.get_sale_cost_analysis(sale) + + # Проверяем финансы + # batch1: 30 * 5 = 150 себестоимость, 30 * 25 = 750 выручка + # batch2: 10 * 10 = 100 себестоимость, 10 * 25 = 250 выручка + # Итого: 250 себестоимость, 1000 выручка, 750 прибыль + + self.assertEqual(analysis['total_cost'], Decimal('250')) + self.assertEqual(analysis['total_revenue'], Decimal('1000')) + self.assertEqual(analysis['total_profit'], Decimal('750')) + self.assertEqual(analysis['profit_margin'], Decimal('75.00')) # 750/1000*100 + + +class InventoryProcessorTest(TestCase): + """Тесты обработки инвентаризации.""" + + def setUp(self): + self.warehouse = Warehouse.objects.create(name='Склад 1') + self.product = Product.objects.create( + name='Тюльпан', + cost_price=Decimal('8.00'), + sale_price=Decimal('25.00') + ) + + def test_process_inventory_deficit(self): + """Тест обработки недостачи при инвентаризации.""" + # Создаем партию + batch = StockBatchManager.create_batch( + self.product, self.warehouse, + Decimal('100'), Decimal('8.00') + ) + + # Создаем инвентаризацию + inventory = Inventory.objects.create( + warehouse=self.warehouse, + status='draft' + ) + + # Строка: в системе 100, по факту 85 (недостача 15) + line = InventoryLine.objects.create( + inventory=inventory, + product=self.product, + quantity_system=Decimal('100'), + quantity_fact=Decimal('85') + ) + + # Обрабатываем инвентаризацию + result = InventoryProcessor.process_inventory(inventory.id) + + # Проверяем результат + self.assertEqual(result['processed_lines'], 1) + self.assertEqual(result['writeoffs_created'], 1) + self.assertEqual(result['incomings_created'], 0) + + # Проверяем, что создалось списание + writeoffs = WriteOff.objects.filter(batch=batch) + self.assertEqual(writeoffs.count(), 1) + self.assertEqual(writeoffs.first().quantity, Decimal('15')) + + # Проверяем остаток в партии + batch.refresh_from_db() + self.assertEqual(batch.quantity, Decimal('85')) + + def test_process_inventory_surplus(self): + """Тест обработки излишка при инвентаризации.""" + # Создаем партию + batch = StockBatchManager.create_batch( + self.product, self.warehouse, + Decimal('100'), Decimal('8.00') + ) + + # Создаем инвентаризацию + inventory = Inventory.objects.create( + warehouse=self.warehouse, + status='draft' + ) + + # Строка: в системе 100, по факту 120 (излишек 20) + line = InventoryLine.objects.create( + inventory=inventory, + product=self.product, + quantity_system=Decimal('100'), + quantity_fact=Decimal('120') + ) + + # Обрабатываем инвентаризацию + result = InventoryProcessor.process_inventory(inventory.id) + + # Проверяем результат + self.assertEqual(result['processed_lines'], 1) + self.assertEqual(result['writeoffs_created'], 0) + self.assertEqual(result['incomings_created'], 1) + + # Проверяем, что создалось приходование + incomings = Incoming.objects.filter(product=self.product) + self.assertEqual(incomings.count(), 1) + self.assertEqual(incomings.first().quantity, Decimal('20')) + + +class ReservationSignalsTest(TestCase): + """Тесты автоматического резервирования через сигналы.""" + + def setUp(self): + self.warehouse = Warehouse.objects.create(name='Склад 1') + + self.product = Product.objects.create( + name='Нарцисс', + cost_price=Decimal('6.00'), + sale_price=Decimal('18.00') + ) + + self.customer = Customer.objects.create( + name='Иван Иванов', + phone='+375291234567' + ) + + def test_reservation_on_order_create(self): + """Тест создания резервирования при создании заказа.""" + # Создаем заказ + order = Order.objects.create( + customer=self.customer, + order_number='ORD-20250101-0001', + delivery_type='courier' + ) + + # Добавляем товар в заказ + item = OrderItem.objects.create( + order=order, + product=self.product, + quantity=5, + price=Decimal('18.00') + ) + + # Проверяем, что резерв создан + reservations = Reservation.objects.filter(order_item=item) + self.assertEqual(reservations.count(), 1) + + res = reservations.first() + self.assertEqual(res.quantity, Decimal('5')) + self.assertEqual(res.status, 'reserved') + + def test_release_reservation_on_order_delete(self): + """Тест освобождения резервирования при удалении заказа.""" + # Создаем заказ с товаром + order = Order.objects.create( + customer=self.customer, + order_number='ORD-20250101-0002', + delivery_type='courier' + ) + + item = OrderItem.objects.create( + order=order, + product=self.product, + quantity=10, + price=Decimal('18.00') + ) + + # Проверяем, что резерв создан + res = Reservation.objects.get(order_item=item) + self.assertEqual(res.status, 'reserved') + + # Удаляем заказ + order.delete() + + # Проверяем, что резерв освобожден + res.refresh_from_db() + self.assertEqual(res.status, 'released') + self.assertIsNotNone(res.released_at) + + +class StockCacheTest(TestCase): + """Тесты кеширования остатков в модели Stock.""" + + def setUp(self): + self.warehouse = Warehouse.objects.create(name='Склад 1') + self.product = Product.objects.create( + name='Лилия', + cost_price=Decimal('12.00'), + sale_price=Decimal('40.00') + ) + + def test_stock_refresh_from_batches(self): + """Тест пересчета остатков из партий.""" + # Создаем партии + batch1 = StockBatchManager.create_batch( + self.product, self.warehouse, + Decimal('50'), Decimal('12.00') + ) + batch2 = StockBatchManager.create_batch( + self.product, self.warehouse, + Decimal('75'), Decimal('13.00') + ) + + # Получаем или создаем Stock + stock, created = Stock.objects.get_or_create( + product=self.product, + warehouse=self.warehouse + ) + + # Обновляем из батчей + stock.refresh_from_batches() + + # Проверяем результат + self.assertEqual(stock.quantity_available, Decimal('125')) + + def test_stock_quantity_free(self): + """Тест расчета свободного количества.""" + batch = StockBatchManager.create_batch( + self.product, self.warehouse, + Decimal('100'), Decimal('12.00') + ) + + # Создаем резерв + Reservation.objects.create( + product=self.product, + warehouse=self.warehouse, + quantity=Decimal('30'), + status='reserved' + ) + + # Получаем Stock и обновляем + stock, created = Stock.objects.get_or_create( + product=self.product, + warehouse=self.warehouse + ) + stock.refresh_from_batches() + + # Проверяем: доступно 100, зарезервировано 30, свободно 70 + self.assertEqual(stock.quantity_available, Decimal('100')) + self.assertEqual(stock.quantity_reserved, Decimal('30')) + self.assertEqual(stock.quantity_free, Decimal('70')) diff --git a/myproject/inventory/urls.py b/myproject/inventory/urls.py new file mode 100644 index 0000000..2d4cc39 --- /dev/null +++ b/myproject/inventory/urls.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +from django.urls import path +from .views import ( + # Warehouse + WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, + # Incoming + IncomingListView, IncomingCreateView, IncomingUpdateView, IncomingDeleteView, + # IncomingBatch + IncomingBatchListView, IncomingBatchDetailView, + # Sale + SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView, + # Inventory + InventoryListView, InventoryCreateView, InventoryDetailView, InventoryLineCreateBulkView, + # WriteOff + WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView, + # Transfer + TransferListView, TransferCreateView, TransferUpdateView, TransferDeleteView, + # Reservation + ReservationListView, ReservationCreateView, ReservationUpdateView, + # Stock + StockListView, StockDetailView, + # StockBatch + StockBatchListView, StockBatchDetailView, + # SaleBatchAllocation + SaleBatchAllocationListView, + # StockMovement + StockMovementListView, +) +from . import views + +app_name = 'inventory' + +urlpatterns = [ + # Главная страница складского модуля + path('', views.inventory_home, name='inventory-home'), + + # ==================== WAREHOUSE ==================== + path('warehouses/', WarehouseListView.as_view(), name='warehouse-list'), + path('warehouses/create/', WarehouseCreateView.as_view(), name='warehouse-create'), + path('warehouses//edit/', WarehouseUpdateView.as_view(), name='warehouse-update'), + path('warehouses//delete/', WarehouseDeleteView.as_view(), name='warehouse-delete'), + + # ==================== INCOMING ==================== + path('incoming/', IncomingListView.as_view(), name='incoming-list'), + path('incoming/create/', IncomingCreateView.as_view(), name='incoming-create'), + path('incoming//edit/', IncomingUpdateView.as_view(), name='incoming-update'), + path('incoming//delete/', IncomingDeleteView.as_view(), name='incoming-delete'), + + # ==================== INCOMING BATCH ==================== + path('incoming-batches/', IncomingBatchListView.as_view(), name='incoming-batch-list'), + path('incoming-batches//', IncomingBatchDetailView.as_view(), name='incoming-batch-detail'), + + # ==================== SALE ==================== + path('sales/', SaleListView.as_view(), name='sale-list'), + path('sales/create/', SaleCreateView.as_view(), name='sale-create'), + path('sales//', SaleDetailView.as_view(), name='sale-detail'), + path('sales//edit/', SaleUpdateView.as_view(), name='sale-update'), + path('sales//delete/', SaleDeleteView.as_view(), name='sale-delete'), + + # ==================== INVENTORY ==================== + path('inventory-ops/', InventoryListView.as_view(), name='inventory-list'), + path('inventory-ops/create/', InventoryCreateView.as_view(), name='inventory-create'), + path('inventory-ops//', InventoryDetailView.as_view(), name='inventory-detail'), + path('inventory-ops//lines/add/', InventoryLineCreateBulkView.as_view(), name='inventory-lines-add'), + + # ==================== WRITEOFF ==================== + path('writeoffs/', WriteOffListView.as_view(), name='writeoff-list'), + path('writeoffs/create/', WriteOffCreateView.as_view(), name='writeoff-create'), + path('writeoffs//edit/', WriteOffUpdateView.as_view(), name='writeoff-update'), + path('writeoffs//delete/', WriteOffDeleteView.as_view(), name='writeoff-delete'), + + # ==================== TRANSFER ==================== + path('transfers/', TransferListView.as_view(), name='transfer-list'), + path('transfers/create/', TransferCreateView.as_view(), name='transfer-create'), + path('transfers//edit/', TransferUpdateView.as_view(), name='transfer-update'), + path('transfers//delete/', TransferDeleteView.as_view(), name='transfer-delete'), + + # ==================== RESERVATION ==================== + path('reservations/', ReservationListView.as_view(), name='reservation-list'), + path('reservations/create/', ReservationCreateView.as_view(), name='reservation-create'), + path('reservations//update-status/', ReservationUpdateView.as_view(), name='reservation-update'), + + # ==================== STOCK (READ ONLY) ==================== + path('stock/', StockListView.as_view(), name='stock-list'), + path('stock//', StockDetailView.as_view(), name='stock-detail'), + + # ==================== BATCH (READ ONLY) ==================== + path('batches/', StockBatchListView.as_view(), name='batch-list'), + path('batches//', StockBatchDetailView.as_view(), name='batch-detail'), + + # ==================== ALLOCATION (READ ONLY) ==================== + path('allocations/', SaleBatchAllocationListView.as_view(), name='allocation-list'), + + # ==================== MOVEMENT (READ ONLY) ==================== + path('movements/', StockMovementListView.as_view(), name='movement-list'), +] diff --git a/myproject/inventory/utils.py b/myproject/inventory/utils.py new file mode 100644 index 0000000..2826c1f --- /dev/null +++ b/myproject/inventory/utils.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +""" +Утилиты для модуля inventory (склад, приходы, продажи, партии). +""" +import re +import os +import logging +from datetime import datetime + + +# Настройка логирования в файл +LOG_FILE = os.path.join(os.path.dirname(__file__), 'logs', 'incoming_sequence.log') +os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + +# Создаём файловый логгер +file_logger = logging.getLogger('incoming_sequence_file') +if not file_logger.handlers: + handler = logging.FileHandler(LOG_FILE, encoding='utf-8') + formatter = logging.Formatter( + '%(asctime)s | %(levelname)s | %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + handler.setFormatter(formatter) + file_logger.addHandler(handler) + file_logger.setLevel(logging.DEBUG) + + +def generate_incoming_document_number(): + """ + Генерирует номер документа поступления вида 'IN-XXXX-XXXX'. + + Алгоритм: + 1. Ищет максимальный номер в БД с префиксом 'IN-' + 2. Извлекает числовое значение из последней части (IN-XXXX-XXXX) + 3. Увеличивает на 1 и форматирует в 'IN-XXXX-XXXX' + + Преимущества: + - Работает без SEQUENCE (не требует миграций) + - Гарантирует уникальность через unique constraint в модели + - Простая логика, легко отладить + - Работает с любым тенантом (django-tenants совместимо) + + Возвращает: + str: Номер вида 'IN-0000-0001', 'IN-0000-0002', итд + """ + from inventory.models import IncomingBatch + import logging + + logger = logging.getLogger('inventory.incoming') + + try: + # Найти все номера с префиксом IN- + existing_batches = IncomingBatch.objects.filter( + document_number__startswith='IN-' + ).values_list('document_number', flat=True).order_by('document_number') + + if not existing_batches: + # Если нет номеров - начинаем с 1 + next_num = 1 + file_logger.info(f"✓ No existing batches found, starting from 1") + else: + # Берем последний номер, извлекаем цифру и увеличиваем + last_number = existing_batches.last() # 'IN-0000-0005' + # Извлекаем последние 4 цифры + last_digits = int(last_number.split('-')[-1]) # 5 + next_num = last_digits + 1 + file_logger.info(f"✓ Last number was {last_number}, next: {next_num}") + + # Форматируем в IN-XXXX-XXXX + combined_str = f"{next_num:08d}" # Гарантируем 8 цифр + first_part = combined_str[:4] # '0000' или '0001' + second_part = combined_str[4:] # '0001' или '0002' + + result = f"IN-{first_part}-{second_part}" + file_logger.info(f"✓ Generated: {result}") + + return result + + except Exception as e: + file_logger.error(f"✗ Error generating number: {str(e)}") + raise diff --git a/myproject/inventory/views.py b/myproject/inventory/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/myproject/inventory/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/myproject/inventory/views.py.old b/myproject/inventory/views.py.old new file mode 100644 index 0000000..35d9c22 --- /dev/null +++ b/myproject/inventory/views.py.old @@ -0,0 +1,10 @@ +from django.shortcuts import render +from django.contrib.auth.decorators import login_required + + +@login_required +def inventory_home(request): + """ + Главная страница Склада для управления инвентаризацией + """ + return render(request, 'inventory/home.html') diff --git a/myproject/inventory/views/__init__.py b/myproject/inventory/views/__init__.py new file mode 100644 index 0000000..1ea99bc --- /dev/null +++ b/myproject/inventory/views/__init__.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +""" +Inventory Views Package + +Организация views по модулям: +- warehouse.py: Управление складами +- incoming.py: Управление приходами товара +- sale.py: Управление продажами +- inventory_ops.py: Инвентаризация и её строки +- writeoff.py: Списания товара +- transfer.py: Перемещения между складами +- reservation.py: Резервирования товара +- stock.py: Справочник остатков (view-only) +- batch.py: Справочник партий товара (view-only) +- allocation.py: Распределение продаж по партиям (view-only) +- movements.py: Журнал складских операций (view-only) +""" +from django.shortcuts import render +from django.contrib.auth.decorators import login_required + +from .warehouse import WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView +from .incoming import IncomingListView, IncomingCreateView, IncomingUpdateView, IncomingDeleteView +from .batch import IncomingBatchListView, IncomingBatchDetailView, StockBatchListView, StockBatchDetailView +from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView +from .inventory_ops import ( + InventoryListView, InventoryCreateView, InventoryDetailView, + InventoryLineCreateBulkView +) +from .writeoff import WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView +from .transfer import TransferListView, TransferCreateView, TransferUpdateView, TransferDeleteView +from .reservation import ReservationListView, ReservationCreateView, ReservationUpdateView +from .stock import StockListView, StockDetailView +from .allocation import SaleBatchAllocationListView +from .movements import StockMovementListView + + +@login_required +def inventory_home(request): + """ + Главная страница Склада для управления инвентаризацией + """ + return render(request, 'inventory/home.html') + + +__all__ = [ + # Home + 'inventory_home', + # Warehouse + 'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', + # Incoming + 'IncomingListView', 'IncomingCreateView', 'IncomingUpdateView', 'IncomingDeleteView', + # IncomingBatch + 'IncomingBatchListView', 'IncomingBatchDetailView', + # Sale + 'SaleListView', 'SaleCreateView', 'SaleUpdateView', 'SaleDeleteView', 'SaleDetailView', + # Inventory + 'InventoryListView', 'InventoryCreateView', 'InventoryDetailView', 'InventoryLineCreateBulkView', + # WriteOff + 'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView', + # Transfer + 'TransferListView', 'TransferCreateView', 'TransferUpdateView', 'TransferDeleteView', + # Reservation + 'ReservationListView', 'ReservationCreateView', 'ReservationUpdateView', + # Stock + 'StockListView', 'StockDetailView', + # StockBatch + 'StockBatchListView', 'StockBatchDetailView', + # Allocation + 'SaleBatchAllocationListView', + # Movement + 'StockMovementListView', +] diff --git a/myproject/inventory/views/allocation.py b/myproject/inventory/views/allocation.py new file mode 100644 index 0000000..286398e --- /dev/null +++ b/myproject/inventory/views/allocation.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +""" +SaleBatchAllocation (Распределение продаж по партиям) views - READ ONLY +GROUP 3: LOW PRIORITY - Аудит и трассировка FIFO +""" +from django.views.generic import ListView +from django.contrib.auth.mixins import LoginRequiredMixin +from ..models import SaleBatchAllocation + + +class SaleBatchAllocationListView(LoginRequiredMixin, ListView): + """ + Полный список всех распределений продаж по партиям. + Используется для аудита и понимания как применялся FIFO. + """ + model = SaleBatchAllocation + template_name = 'inventory/allocation/allocation_list.html' + context_object_name = 'allocations' + paginate_by = 30 + + def get_queryset(self): + return SaleBatchAllocation.objects.select_related( + 'sale', 'sale__product', + 'batch', 'batch__product' + ).order_by('-sale__date') diff --git a/myproject/inventory/views/batch.py b/myproject/inventory/views/batch.py new file mode 100644 index 0000000..ef49b33 --- /dev/null +++ b/myproject/inventory/views/batch.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +""" +Batch views - READ ONLY +- IncomingBatch (Партии поступлений) +- StockBatch (Партии товара на складе) +""" +from django.views.generic import ListView, DetailView +from django.contrib.auth.mixins import LoginRequiredMixin +from ..models import IncomingBatch, Incoming, StockBatch, SaleBatchAllocation, WriteOff + + +class IncomingBatchListView(LoginRequiredMixin, ListView): + """Список всех партий поступлений товара""" + model = IncomingBatch + template_name = 'inventory/incoming_batch/batch_list.html' + context_object_name = 'batches' + paginate_by = 30 + + def get_queryset(self): + return IncomingBatch.objects.all().select_related('warehouse').order_by('-created_at') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Добавляем количество товаров в каждую партию + for batch in context['batches']: + batch.items_count = batch.items.count() + batch.total_quantity = sum(item.quantity for item in batch.items.all()) + return context + + +class IncomingBatchDetailView(LoginRequiredMixin, DetailView): + """Детальная информация по партии поступления""" + model = IncomingBatch + template_name = 'inventory/incoming_batch/batch_detail.html' + context_object_name = 'batch' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + batch = self.get_object() + + # Товары в этой партии + context['items'] = batch.items.all().select_related('product', 'stock_batch') + + return context + + +class StockBatchListView(LoginRequiredMixin, ListView): + """Список всех партий товара на складах""" + model = StockBatch + template_name = 'inventory/batch/batch_list.html' + context_object_name = 'batches' + paginate_by = 30 + + def get_queryset(self): + return StockBatch.objects.filter( + is_active=True + ).select_related('product', 'warehouse').order_by('-created_at') + + +class StockBatchDetailView(LoginRequiredMixin, DetailView): + """ + Детальная информация по партии товара. + Показывает историю операций с данной партией (продажи, списания, перемещения). + """ + model = StockBatch + template_name = 'inventory/batch/batch_detail.html' + context_object_name = 'batch' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + batch = self.get_object() + + # История продаж из этой партии + context['sales'] = SaleBatchAllocation.objects.filter( + batch=batch + ).select_related('sale', 'sale__product') + + # История списаний из этой партии + context['writeoffs'] = WriteOff.objects.filter( + batch=batch + ).order_by('-date') + + return context diff --git a/myproject/inventory/views/incoming.py b/myproject/inventory/views/incoming.py new file mode 100644 index 0000000..576a3db --- /dev/null +++ b/myproject/inventory/views/incoming.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- +import logging +from django.shortcuts import render, redirect +from django.views.generic import ListView, CreateView, UpdateView, DeleteView, View +from django.urls import reverse_lazy +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib import messages +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from django.utils.decorators import method_decorator +from django.db import IntegrityError, transaction +from ..models import Incoming, IncomingBatch, Warehouse +from ..forms import IncomingForm, IncomingLineForm +from ..utils import generate_incoming_document_number +from products.models import Product + +file_logger = logging.getLogger('incoming_sequence_file') + + +class IncomingListView(LoginRequiredMixin, ListView): + """ + Список всех приходов товара (истории поступлений) + """ + model = Incoming + template_name = 'inventory/incoming/incoming_list.html' + context_object_name = 'incomings' + paginate_by = 20 + + def get_queryset(self): + queryset = Incoming.objects.select_related('product', 'batch', 'batch__warehouse').order_by('-created_at') + + # Фильтры (если переданы) + product_id = self.request.GET.get('product') + warehouse_id = self.request.GET.get('warehouse') + + if product_id: + queryset = queryset.filter(product_id=product_id) + if warehouse_id: + queryset = queryset.filter(batch__warehouse_id=warehouse_id) + + return queryset + + +class IncomingUpdateView(LoginRequiredMixin, UpdateView): + """ + Редактирование поступления (только если ещё не обработано). + Обработанные приходы редактировать нельзя. + """ + model = Incoming + form_class = IncomingForm + template_name = 'inventory/incoming/incoming_form.html' + success_url = reverse_lazy('inventory:incoming-list') + + def form_valid(self, form): + # При редактировании можем оставить номер пустым - модель генерирует при сохранении + # Но это только если объект ещё не имеет номера (новый) + messages.success(self.request, f'Приход товара обновлён.') + return super().form_valid(form) + + +class IncomingDeleteView(LoginRequiredMixin, DeleteView): + """ + Отмена/удаление поступления товара. + """ + model = Incoming + template_name = 'inventory/incoming/incoming_confirm_delete.html' + success_url = reverse_lazy('inventory:incoming-list') + + def form_valid(self, form): + incoming = self.get_object() + messages.success( + self.request, + f'Приход товара "{incoming.product.name}" отменён.' + ) + return super().form_valid(form) + + +class IncomingCreateView(LoginRequiredMixin, View): + """ + Создание поступлений товара на склад. + Позволяет добавить один или несколько товаров в одной форме + с одинаковым номером документа и складом. + + По умолчанию показывается одна пустая строка товара, + но пользователь может добавить неограниченное количество товаров. + """ + template_name = 'inventory/incoming/incoming_bulk_form.html' + + def get(self, request): + """Отображение формы ввода товаров.""" + form = IncomingForm() + # Django-tenants автоматически фильтрует по текущей схеме + products = Product.objects.filter(is_active=True).order_by('name') + + # Генерируем номер документа автоматически + generated_document_number = generate_incoming_document_number() + + context = { + 'form': form, + 'products': products, + 'generated_document_number': generated_document_number, + } + return render(request, self.template_name, context) + + def post(self, request): + """Обработка формы ввода товаров.""" + form = IncomingForm(request.POST) + + if not form.is_valid(): + # Django-tenants автоматически фильтрует по текущей схеме + products = Product.objects.filter(is_active=True).order_by('name') + context = { + 'form': form, + 'products': products, + 'errors': form.errors, + } + return render(request, self.template_name, context) + + # Получаем данные header + warehouse = form.cleaned_data['warehouse'] + # Оставляем пустой document_number как есть - модель будет генерировать при сохранении + document_number = form.cleaned_data.get('document_number', '').strip() or None + + supplier_name = form.cleaned_data.get('supplier_name', '') + header_notes = form.cleaned_data.get('notes', '') + + # Получаем данные товаров из POST + products_data = self._parse_products_from_post(request.POST) + + if not products_data: + messages.error(request, 'Пожалуйста, добавьте хотя бы один товар.') + # Django-tenants автоматически фильтрует по текущей схеме + products = Product.objects.filter(is_active=True).order_by('name') + context = { + 'form': form, + 'products': products, + } + return render(request, self.template_name, context) + + # Генерируем номер партии один раз (если не указан) + if not document_number: + document_number = generate_incoming_document_number() + + file_logger.info(f"--- POST started | batch_doc_number={document_number} | items_count={len(products_data)}") + + try: + # Используем транзакцию для атомарности: либо все товары, либо ничего + with transaction.atomic(): + # 1. Создаем партию (содержит номер документа и метаданные) + batch = IncomingBatch.objects.create( + warehouse=warehouse, + document_number=document_number, + supplier_name=supplier_name, + notes=header_notes + ) + file_logger.info(f" ✓ Created batch: {document_number}") + + # 2. Создаем товары в этой партии + created_count = 0 + for product_data in products_data: + incoming = Incoming.objects.create( + batch=batch, + product_id=product_data['product_id'], + quantity=product_data['quantity'], + cost_price=product_data['cost_price'], + ) + created_count += 1 + file_logger.info(f" ✓ Item: {incoming.product.name} ({incoming.quantity} шт)") + + file_logger.info(f"✓ SUCCESS: Batch {document_number} with {created_count} items") + messages.success( + request, + f'✓ Успешно создана партия "{document_number}" с {created_count} товарами.' + ) + return redirect('inventory:incoming-list') + + except IntegrityError as e: + # Ошибка дублирования номера (обычно при вводе вручную существующего номера) + file_logger.error(f"✗ IntegrityError: {str(e)} | doc_number={document_number}") + if 'document_number' in str(e): + error_msg = ( + f'❌ Номер документа "{document_number}" уже существует в системе. ' + f'Пожалуйста, оставьте поле пустым для автогенерации свободного номера. ' + f'Данные, которые вы вводили, сохранены ниже.' + ) + messages.error(request, error_msg) + else: + messages.error(request, f'Ошибка при создании партии: {str(e)}') + + # Восстанавливаем данные на форме + products = Product.objects.filter(is_active=True).order_by('name') + context = { + 'form': form, + 'products': products, + 'products_json': request.POST.get('products_json', '[]'), + } + return render(request, self.template_name, context) + + except Exception as e: + messages.error( + request, + f'❌ Ошибка при создании приходов: {str(e)}' + ) + # Django-tenants автоматически фильтрует по текущей схеме + products = Product.objects.filter(is_active=True).order_by('name') + context = { + 'form': form, + 'products': products, + 'products_json': request.POST.get('products_json', '[]'), # Сохраняем товары + } + return render(request, self.template_name, context) + + def _parse_products_from_post(self, post_data): + """ + Парсит данные товаров из POST данных. + Ожидается formato: + product_ids: [1, 2, 3] + quantities: [100, 50, 30] + cost_prices: [50, 30, 20] + """ + products_data = [] + + # Получаем JSON данные из hidden input (если используется) + import json + + products_json = post_data.get('products_json', '[]') + try: + products_list = json.loads(products_json) + for item in products_list: + if item.get('product_id') and item.get('quantity') and item.get('cost_price'): + products_data.append({ + 'product_id': int(item['product_id']), + 'quantity': float(item['quantity']), + 'cost_price': float(item['cost_price']), + }) + except (json.JSONDecodeError, ValueError): + pass + + return products_data diff --git a/myproject/inventory/views/inventory_ops.py b/myproject/inventory/views/inventory_ops.py new file mode 100644 index 0000000..f092ec1 --- /dev/null +++ b/myproject/inventory/views/inventory_ops.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +from django.shortcuts import render, redirect +from django.views.generic import ListView, CreateView, DetailView, View, FormView +from django.urls import reverse_lazy +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib import messages +from django.http import HttpResponseRedirect +from ..models import Inventory, InventoryLine +from ..forms import InventoryForm, InventoryLineForm + + +class InventoryListView(LoginRequiredMixin, ListView): + """ + Список всех инвентаризаций по складам + """ + model = Inventory + template_name = 'inventory/inventory/inventory_list.html' + context_object_name = 'inventories' + paginate_by = 20 + + def get_queryset(self): + queryset = Inventory.objects.select_related('warehouse').order_by('-date') + + # Фильтры (если переданы) + warehouse_id = self.request.GET.get('warehouse') + status = self.request.GET.get('status') + + if warehouse_id: + queryset = queryset.filter(warehouse_id=warehouse_id) + if status: + queryset = queryset.filter(status=status) + + return queryset + + +class InventoryCreateView(LoginRequiredMixin, CreateView): + """ + Начало новой инвентаризации по конкретному складу. + Переводит инвентаризацию в статус 'processing'. + """ + model = Inventory + form_class = InventoryForm + template_name = 'inventory/inventory/inventory_form.html' + success_url = reverse_lazy('inventory:inventory-list') + + def form_valid(self, form): + form.instance.status = 'processing' + messages.success( + self.request, + f'Инвентаризация склада "{form.instance.warehouse.name}" начата.' + ) + return super().form_valid(form) + + +class InventoryDetailView(LoginRequiredMixin, DetailView): + """ + Детальный просмотр инвентаризации с её строками. + Позволяет добавлять строки и заполнять фактические количества. + """ + model = Inventory + template_name = 'inventory/inventory/inventory_detail.html' + context_object_name = 'inventory' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Получаем все строки этой инвентаризации + context['lines'] = InventoryLine.objects.filter( + inventory=self.object + ).select_related('product') + return context + + +class InventoryLineCreateBulkView(LoginRequiredMixin, View): + """ + Форма для массового внесения результатов инвентаризации. + Позволяет заполнить результаты пересчета для всех товаров на складе. + """ + template_name = 'inventory/inventory/inventory_line_bulk_form.html' + + def get_context_data(self, inventory_id, **kwargs): + inventory = Inventory.objects.get(pk=inventory_id) + return { + 'inventory': inventory, + 'products': inventory.warehouse.stock_batches.values_list( + 'product', flat=True + ).distinct() + } + + def get(self, request, pk): + inventory = Inventory.objects.get(pk=pk) + context = { + 'inventory': inventory, + 'lines': InventoryLine.objects.filter(inventory=inventory).select_related('product') + } + return render(request, self.template_name, context) + + def post(self, request, pk): + inventory = Inventory.objects.get(pk=pk) + # Здесь будет логика обработки массового ввода данных + # TODO: Реализовать обработку формы с множественными строками + messages.success(request, 'Результаты инвентаризации добавлены.') + return redirect('inventory:inventory-detail', pk=pk) diff --git a/myproject/inventory/views/movements.py b/myproject/inventory/views/movements.py new file mode 100644 index 0000000..0a10037 --- /dev/null +++ b/myproject/inventory/views/movements.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +""" +StockMovement (Журнал всех складских операций) views - READ ONLY +GROUP 3: LOW PRIORITY - Аудит логирование +""" +from django.views.generic import ListView +from django.contrib.auth.mixins import LoginRequiredMixin +from ..models import StockMovement + + +class StockMovementListView(LoginRequiredMixin, ListView): + """ + Полный журнал всех складских операций (приход, продажа, списание, корректировка). + Используется для аудита и контроля. + """ + model = StockMovement + template_name = 'inventory/movements/movement_list.html' + context_object_name = 'movements' + paginate_by = 50 + + def get_queryset(self): + return StockMovement.objects.select_related( + 'product', 'order' + ).order_by('-created_at') diff --git a/myproject/inventory/views/reservation.py b/myproject/inventory/views/reservation.py new file mode 100644 index 0000000..d7e5066 --- /dev/null +++ b/myproject/inventory/views/reservation.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +""" +Reservation (Резервирование товара) views +GROUP 2: MEDIUM PRIORITY +""" +from django.views.generic import ListView, CreateView, UpdateView +from django.urls import reverse_lazy +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib import messages +from ..models import Reservation +from ..forms import ReservationForm + + +class ReservationListView(LoginRequiredMixin, ListView): + model = Reservation + template_name = 'inventory/reservation/reservation_list.html' + context_object_name = 'reservations' + paginate_by = 20 + + def get_queryset(self): + return Reservation.objects.filter( + status='reserved' + ).select_related('product', 'warehouse', 'order_item').order_by('-reserved_at') + + +class ReservationCreateView(LoginRequiredMixin, CreateView): + model = Reservation + form_class = ReservationForm + template_name = 'inventory/reservation/reservation_form.html' + success_url = reverse_lazy('inventory:reservation-list') + + def form_valid(self, form): + form.instance.status = 'reserved' + messages.success(self.request, f'Товар успешно зарезервирован.') + return super().form_valid(form) + + +class ReservationUpdateView(LoginRequiredMixin, UpdateView): + model = Reservation + fields = ['status'] + template_name = 'inventory/reservation/reservation_update.html' + success_url = reverse_lazy('inventory:reservation-list') + + def form_valid(self, form): + messages.success(self.request, f'Статус резервирования обновлен.') + return super().form_valid(form) diff --git a/myproject/inventory/views/sale.py b/myproject/inventory/views/sale.py new file mode 100644 index 0000000..069f5c5 --- /dev/null +++ b/myproject/inventory/views/sale.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +from django.shortcuts import render +from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView +from django.urls import reverse_lazy +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib import messages +from ..models import Sale, SaleBatchAllocation +from ..forms import SaleForm + + +class SaleListView(LoginRequiredMixin, ListView): + """ + Список всех продаж товара (истории реализации) + """ + model = Sale + template_name = 'inventory/sale/sale_list.html' + context_object_name = 'sales' + paginate_by = 20 + + def get_queryset(self): + queryset = Sale.objects.select_related('product', 'warehouse', 'order').order_by('-date') + + # Фильтры (если переданы) + product_id = self.request.GET.get('product') + warehouse_id = self.request.GET.get('warehouse') + processed = self.request.GET.get('processed') + + if product_id: + queryset = queryset.filter(product_id=product_id) + if warehouse_id: + queryset = queryset.filter(warehouse_id=warehouse_id) + if processed: + queryset = queryset.filter(processed=processed == 'true') + + return queryset + + +class SaleCreateView(LoginRequiredMixin, CreateView): + """ + Регистрация новой продажи товара. + После сохранения автоматически применяется FIFO (через сигнал). + """ + model = Sale + form_class = SaleForm + template_name = 'inventory/sale/sale_form.html' + success_url = reverse_lazy('inventory:sale-list') + + def form_valid(self, form): + messages.success( + self.request, + f'Продажа товара "{form.instance.product.name}" ({form.instance.quantity} шт) успешно зарегистрирована.' + ) + return super().form_valid(form) + + +class SaleDetailView(LoginRequiredMixin, DetailView): + """ + Просмотр деталей продажи с распределением по партиям. + Показывает SaleBatchAllocation для данной продажи. + """ + model = Sale + template_name = 'inventory/sale/sale_detail.html' + context_object_name = 'sale' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Получаем все распределения этой продажи по партиям + context['allocations'] = SaleBatchAllocation.objects.filter( + sale=self.object + ).select_related('batch', 'batch__product') + return context + + +class SaleUpdateView(LoginRequiredMixin, UpdateView): + """ + Редактирование продажи (только если ещё не обработана). + Обработанные продажи редактировать нельзя. + """ + model = Sale + form_class = SaleForm + template_name = 'inventory/sale/sale_form.html' + success_url = reverse_lazy('inventory:sale-list') + + def form_valid(self, form): + messages.success(self.request, f'Продажа товара обновлена.') + return super().form_valid(form) + + +class SaleDeleteView(LoginRequiredMixin, DeleteView): + """ + Отмена/удаление продажи товара. + """ + model = Sale + template_name = 'inventory/sale/sale_confirm_delete.html' + success_url = reverse_lazy('inventory:sale-list') + + def form_valid(self, form): + sale = self.get_object() + messages.success( + self.request, + f'Продажа товара "{sale.product.name}" отменена.' + ) + return super().form_valid(form) diff --git a/myproject/inventory/views/stock.py b/myproject/inventory/views/stock.py new file mode 100644 index 0000000..f498f6c --- /dev/null +++ b/myproject/inventory/views/stock.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +""" +Stock (Остатки товаров) views - READ ONLY +GROUP 3: LOW PRIORITY - Справочник состояния +""" +from django.views.generic import ListView, DetailView +from django.contrib.auth.mixins import LoginRequiredMixin +from ..models import Stock + + +class StockListView(LoginRequiredMixin, ListView): + """Список всех остатков товаров на всех складах""" + model = Stock + template_name = 'inventory/stock/stock_list.html' + context_object_name = 'stocks' + paginate_by = 30 + + def get_queryset(self): + # Показываем все остатки, включая нулевые (для полной видимости) + return Stock.objects.select_related('product', 'warehouse').order_by('warehouse', 'product') + + +class StockDetailView(LoginRequiredMixin, DetailView): + """Детальная информация по остаткам конкретного товара""" + model = Stock + template_name = 'inventory/stock/stock_detail.html' + context_object_name = 'stock' diff --git a/myproject/inventory/views/transfer.py b/myproject/inventory/views/transfer.py new file mode 100644 index 0000000..a9558cb --- /dev/null +++ b/myproject/inventory/views/transfer.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +""" +Transfer (Перемещение товара между складами) views +GROUP 2: MEDIUM PRIORITY +""" +from django.views.generic import ListView, CreateView, UpdateView, DeleteView +from django.urls import reverse_lazy +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib import messages +from ..models import Transfer +from ..forms import TransferForm + + +class TransferListView(LoginRequiredMixin, ListView): + model = Transfer + template_name = 'inventory/transfer/transfer_list.html' + context_object_name = 'transfers' + paginate_by = 20 + + def get_queryset(self): + return Transfer.objects.select_related( + 'batch', 'batch__product', + 'from_warehouse', 'to_warehouse' + ).order_by('-date') + + +class TransferCreateView(LoginRequiredMixin, CreateView): + model = Transfer + form_class = TransferForm + template_name = 'inventory/transfer/transfer_form.html' + success_url = reverse_lazy('inventory:transfer-list') + + def form_valid(self, form): + messages.success( + self.request, + f'Перемещение товара "{form.instance.batch.product.name}" успешно зарегистрировано.' + ) + return super().form_valid(form) + + +class TransferUpdateView(LoginRequiredMixin, UpdateView): + model = Transfer + form_class = TransferForm + template_name = 'inventory/transfer/transfer_form.html' + success_url = reverse_lazy('inventory:transfer-list') + + def form_valid(self, form): + messages.success(self.request, f'Перемещение товара обновлено.') + return super().form_valid(form) + + +class TransferDeleteView(LoginRequiredMixin, DeleteView): + model = Transfer + template_name = 'inventory/transfer/transfer_confirm_delete.html' + success_url = reverse_lazy('inventory:transfer-list') + + def form_valid(self, form): + transfer = self.get_object() + messages.success(self.request, f'Перемещение товара отменено.') + return super().form_valid(form) diff --git a/myproject/inventory/views/warehouse.py b/myproject/inventory/views/warehouse.py new file mode 100644 index 0000000..5d1dce3 --- /dev/null +++ b/myproject/inventory/views/warehouse.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +from django.shortcuts import render +from django.views.generic import ListView, CreateView, UpdateView, DeleteView +from django.urls import reverse_lazy +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib import messages +from ..models import Warehouse +from ..forms import WarehouseForm + + +class WarehouseListView(LoginRequiredMixin, ListView): + """ + Список всех складов тенанта + """ + model = Warehouse + template_name = 'inventory/warehouse/warehouse_list.html' + context_object_name = 'warehouses' + paginate_by = 20 + + def get_queryset(self): + return Warehouse.objects.filter(is_active=True).order_by('name') + + +class WarehouseCreateView(LoginRequiredMixin, CreateView): + """ + Создание нового склада + """ + model = Warehouse + form_class = WarehouseForm + template_name = 'inventory/warehouse/warehouse_form.html' + success_url = reverse_lazy('inventory:warehouse-list') + + def form_valid(self, form): + messages.success(self.request, f'Склад "{form.instance.name}" успешно создан.') + return super().form_valid(form) + + +class WarehouseUpdateView(LoginRequiredMixin, UpdateView): + """ + Редактирование склада + """ + model = Warehouse + form_class = WarehouseForm + template_name = 'inventory/warehouse/warehouse_form.html' + success_url = reverse_lazy('inventory:warehouse-list') + + def form_valid(self, form): + messages.success(self.request, f'Склад "{form.instance.name}" успешно обновлён.') + return super().form_valid(form) + + +class WarehouseDeleteView(LoginRequiredMixin, DeleteView): + """ + Удаление склада (мягкое удаление - деактивация) + """ + model = Warehouse + template_name = 'inventory/warehouse/warehouse_confirm_delete.html' + success_url = reverse_lazy('inventory:warehouse-list') + + def form_valid(self, form): + # Мягкое удаление - просто деактивируем + warehouse = self.get_object() + warehouse.is_active = False + warehouse.save() + messages.success(self.request, f'Склад "{warehouse.name}" деактивирован.') + return super().form_valid(form) diff --git a/myproject/inventory/views/writeoff.py b/myproject/inventory/views/writeoff.py new file mode 100644 index 0000000..d08e6ee --- /dev/null +++ b/myproject/inventory/views/writeoff.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +""" +WriteOff (Списание товара) views +GROUP 2: MEDIUM PRIORITY +""" +from django.views.generic import ListView, CreateView, UpdateView, DeleteView +from django.urls import reverse_lazy +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib import messages +from ..models import WriteOff +from ..forms import WriteOffForm + + +class WriteOffListView(LoginRequiredMixin, ListView): + model = WriteOff + template_name = 'inventory/writeoff/writeoff_list.html' + context_object_name = 'writeoffs' + paginate_by = 20 + + def get_queryset(self): + return WriteOff.objects.select_related('batch', 'batch__product').order_by('-date') + + +class WriteOffCreateView(LoginRequiredMixin, CreateView): + model = WriteOff + form_class = WriteOffForm + template_name = 'inventory/writeoff/writeoff_form.html' + success_url = reverse_lazy('inventory:writeoff-list') + + def form_valid(self, form): + messages.success(self.request, f'Списание товара успешно создано.') + return super().form_valid(form) + + +class WriteOffUpdateView(LoginRequiredMixin, UpdateView): + model = WriteOff + form_class = WriteOffForm + template_name = 'inventory/writeoff/writeoff_form.html' + success_url = reverse_lazy('inventory:writeoff-list') + + def form_valid(self, form): + messages.success(self.request, f'Списание товара обновлено.') + return super().form_valid(form) + + +class WriteOffDeleteView(LoginRequiredMixin, DeleteView): + model = WriteOff + template_name = 'inventory/writeoff/writeoff_confirm_delete.html' + success_url = reverse_lazy('inventory:writeoff-list') + + def form_valid(self, form): + writeoff = self.get_object() + messages.success(self.request, f'Списание товара отменено.') + return super().form_valid(form) diff --git a/myproject/myproject/urls.py b/myproject/myproject/urls.py index 67a38d8..2fc1096 100644 --- a/myproject/myproject/urls.py +++ b/myproject/myproject/urls.py @@ -8,15 +8,19 @@ from django.contrib import admin from django.urls import path, include from django.conf import settings from django.conf.urls.static import static +from . import views urlpatterns = [ path('_nested_admin/', include('nested_admin.urls')), # Для nested admin path('admin/', admin.site.urls), # Админка для владельца магазина (доступна на поддомене) - # TODO: Add web interface for shop owners - # path('', views.dashboard, name='dashboard'), - # path('products/', include('products.urls')), - # path('orders/', include('orders.urls')), - # path('customers/', include('customers.urls')), + + # Web interface for shop owners + path('', views.index, name='index'), # Главная страница + path('accounts/', include('accounts.urls')), # Управление аккаунтом + path('products/', include('products.urls')), # Управление товарами + path('customers/', include('customers.urls')), # Управление клиентами + path('inventory/', include('inventory.urls')), # Управление складом + # path('orders/', include('orders.urls')), # TODO: Создать URL-конфиг для заказов ] # Serve media files during development diff --git a/myproject/myproject/urls_public.py b/myproject/myproject/urls_public.py index fe07b16..b80015b 100644 --- a/myproject/myproject/urls_public.py +++ b/myproject/myproject/urls_public.py @@ -5,7 +5,6 @@ URL configuration for the PUBLIC schema (inventory.by domain). This is the main domain where: - Super admin can access admin panel - Tenant registration is available -- Future: Landing page, etc. """ from django.contrib import admin from django.urls import path, include @@ -14,7 +13,7 @@ from django.conf.urls.static import static urlpatterns = [ path('admin/', admin.site.urls), - path('', include('tenants.urls')), # Подключаем роуты для регистрации тенантов + path('', include('tenants.urls')), # Роуты для регистрации тенантов (/, /register/, /register/success/) ] # Serve media files in development diff --git a/myproject/orders/migrations/0001_initial.py b/myproject/orders/migrations/0001_initial.py index f15f503..4efd291 100644 --- a/myproject/orders/migrations/0001_initial.py +++ b/myproject/orders/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.4 on 2025-10-26 22:44 +# Generated by Django 5.1.4 on 2025-10-28 22:47 import django.db.models.deletion from django.db import migrations, models diff --git a/myproject/products/migrations/0001_initial.py b/myproject/products/migrations/0001_initial.py index 8d1cfe3..0aa83db 100644 --- a/myproject/products/migrations/0001_initial.py +++ b/myproject/products/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.4 on 2025-10-26 22:44 +# Generated by Django 5.1.4 on 2025-10-28 22:47 import django.db.models.deletion from django.conf import settings diff --git a/myproject/shops/migrations/0001_initial.py b/myproject/shops/migrations/0001_initial.py index 40931ab..9517e00 100644 --- a/myproject/shops/migrations/0001_initial.py +++ b/myproject/shops/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.4 on 2025-10-26 22:44 +# Generated by Django 5.1.4 on 2025-10-28 22:47 import phonenumber_field.modelfields from django.db import migrations, models diff --git a/myproject/templates/navbar.html b/myproject/templates/navbar.html index a5e45a1..c25d5f7 100644 --- a/myproject/templates/navbar.html +++ b/myproject/templates/navbar.html @@ -24,6 +24,9 @@ + {% endif %} diff --git a/myproject/tenants/admin.py b/myproject/tenants/admin.py index 9d9af43..4ee0ca6 100644 --- a/myproject/tenants/admin.py +++ b/myproject/tenants/admin.py @@ -249,6 +249,16 @@ class TenantRegistrationAdmin(admin.ModelAdmin): ) logger.info(f"Домен создан: {domain.id}") + # Применяем миграции для нового тенанта + logger.info(f"Применение миграций для тенанта: {registration.schema_name}") + from django.core.management import call_command + try: + call_command('migrate_schemas', schema_name=registration.schema_name, verbosity=1) + logger.info(f"Миграции успешно применены для тенанта: {registration.schema_name}") + except Exception as e: + logger.error(f"Ошибка при применении миграций: {str(e)}", exc_info=True) + raise + # Создаем триальную подписку на 90 дней logger.info(f"Создание триальной подписки для тенанта: {client.id}") subscription = Subscription.create_trial(client) diff --git a/myproject/tenants/migrations/0001_initial.py b/myproject/tenants/migrations/0001_initial.py index 965c6f4..de11293 100644 --- a/myproject/tenants/migrations/0001_initial.py +++ b/myproject/tenants/migrations/0001_initial.py @@ -1,7 +1,10 @@ -# Generated by Django 5.1.4 on 2025-10-26 22:44 +# Generated by Django 5.1.4 on 2025-10-28 22:47 +import django.core.validators import django.db.models.deletion import django_tenants.postgresql_backend.base +import phonenumber_field.modelfields +from django.conf import settings from django.db import migrations, models @@ -10,6 +13,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -18,12 +22,12 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('schema_name', models.CharField(db_index=True, max_length=63, unique=True, validators=[django_tenants.postgresql_backend.base._check_schema_name])), - ('name', models.CharField(max_length=200, verbose_name='Название магазина')), - ('owner_email', models.EmailField(help_text='Контактный email владельца магазина', max_length=254, verbose_name='Email владельца')), + ('name', models.CharField(db_index=True, max_length=200, verbose_name='Название магазина')), + ('owner_email', models.EmailField(help_text='Контактный email владельца магазина. Один email может использоваться для нескольких магазинов (для супер-админа)', max_length=254, verbose_name='Email владельца')), ('owner_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='Имя владельца')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('is_active', models.BooleanField(default=True, help_text='Активна ли учетная запись магазина', verbose_name='Активен')), - ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Телефон')), + ('is_active', models.BooleanField(default=True, help_text='Активна ли учетная запись магазина (ручная блокировка админом)', verbose_name='Активен')), + ('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='Телефон')), ('notes', models.TextField(blank=True, help_text='Внутренние заметки администратора', null=True, verbose_name='Заметки')), ], options={ @@ -45,4 +49,45 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Домены', }, ), + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('plan', models.CharField(choices=[('trial', 'Триальный (90 дней)'), ('monthly', 'Месячный'), ('quarterly', 'Квартальный (3 месяца)'), ('yearly', 'Годовой')], default='trial', max_length=20, verbose_name='План подписки')), + ('started_at', models.DateTimeField(verbose_name='Дата начала')), + ('expires_at', models.DateTimeField(verbose_name='Дата окончания')), + ('is_active', models.BooleanField(default=True, help_text='Активна ли подписка (может быть отключена вручную)', verbose_name='Активна')), + ('auto_renew', models.BooleanField(default=False, help_text='Автоматически продлевать подписку', verbose_name='Автопродление')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('client', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='tenants.client', verbose_name='Тенант')), + ], + options={ + 'verbose_name': 'Подписка', + 'verbose_name_plural': 'Подписки', + 'ordering': ['-expires_at'], + }, + ), + migrations.CreateModel( + name='TenantRegistration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('shop_name', models.CharField(max_length=200, verbose_name='Название магазина')), + ('schema_name', models.CharField(help_text='Например: myshop (будет доступен как myshop.inventory.by)', max_length=63, unique=True, validators=[django.core.validators.RegexValidator(message='Поддомен должен содержать только латинские буквы в нижнем регистре, цифры и дефис. Длина от 3 до 63 символов. Не может начинаться или заканчиваться дефисом.', regex='^[a-z0-9](?:[a-z0-9\\-]{0,61}[a-z0-9])?$')], verbose_name='Желаемый поддомен')), + ('owner_email', models.EmailField(max_length=254, verbose_name='Email владельца')), + ('owner_name', models.CharField(max_length=200, verbose_name='Имя владельца')), + ('phone', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='Телефон')), + ('status', models.CharField(choices=[('pending', 'Ожидает проверки'), ('approved', 'Одобрено'), ('rejected', 'Отклонено')], db_index=True, default='pending', max_length=20, verbose_name='Статус')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата подачи заявки')), + ('processed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата обработки')), + ('rejection_reason', models.TextField(blank=True, verbose_name='Причина отклонения')), + ('processed_by', models.ForeignKey(blank=True, help_text='Администратор, который обработал заявку', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Обработал')), + ('tenant', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tenants.client', verbose_name='Созданный тенант')), + ], + options={ + 'verbose_name': 'Заявка на регистрацию', + 'verbose_name_plural': 'Заявки на регистрацию', + 'ordering': ['-created_at'], + }, + ), ] diff --git a/myproject/tenants/migrations/0002_alter_client_is_active_alter_client_name_and_more.py b/myproject/tenants/migrations/0002_alter_client_is_active_alter_client_name_and_more.py deleted file mode 100644 index 57d5eaf..0000000 --- a/myproject/tenants/migrations/0002_alter_client_is_active_alter_client_name_and_more.py +++ /dev/null @@ -1,79 +0,0 @@ -# Generated by Django 5.1.4 on 2025-10-27 09:45 - -import django.core.validators -import django.db.models.deletion -import phonenumber_field.modelfields -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tenants', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name='client', - name='is_active', - field=models.BooleanField(default=True, help_text='Активна ли учетная запись магазина (ручная блокировка админом)', verbose_name='Активен'), - ), - migrations.AlterField( - model_name='client', - name='name', - field=models.CharField(db_index=True, max_length=200, verbose_name='Название магазина'), - ), - migrations.AlterField( - model_name='client', - name='owner_email', - field=models.EmailField(help_text='Контактный email владельца магазина. Один email может использоваться для нескольких магазинов (для супер-админа)', max_length=254, verbose_name='Email владельца'), - ), - migrations.AlterField( - model_name='client', - name='phone', - field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='Телефон'), - ), - migrations.CreateModel( - name='Subscription', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('plan', models.CharField(choices=[('trial', 'Триальный (90 дней)'), ('monthly', 'Месячный'), ('quarterly', 'Квартальный (3 месяца)'), ('yearly', 'Годовой')], default='trial', max_length=20, verbose_name='План подписки')), - ('started_at', models.DateTimeField(verbose_name='Дата начала')), - ('expires_at', models.DateTimeField(verbose_name='Дата окончания')), - ('is_active', models.BooleanField(default=True, help_text='Активна ли подписка (может быть отключена вручную)', verbose_name='Активна')), - ('auto_renew', models.BooleanField(default=False, help_text='Автоматически продлевать подписку', verbose_name='Автопродление')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), - ('client', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='tenants.client', verbose_name='Тенант')), - ], - options={ - 'verbose_name': 'Подписка', - 'verbose_name_plural': 'Подписки', - 'ordering': ['-expires_at'], - }, - ), - migrations.CreateModel( - name='TenantRegistration', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('shop_name', models.CharField(max_length=200, verbose_name='Название магазина')), - ('schema_name', models.CharField(help_text='Например: myshop (будет доступен как myshop.inventory.by)', max_length=63, unique=True, validators=[django.core.validators.RegexValidator(message='Поддомен должен содержать только латинские буквы в нижнем регистре, цифры и дефис. Длина от 3 до 63 символов. Не может начинаться или заканчиваться дефисом.', regex='^[a-z0-9](?:[a-z0-9\\-]{0,61}[a-z0-9])?$')], verbose_name='Желаемый поддомен')), - ('owner_email', models.EmailField(max_length=254, verbose_name='Email владельца')), - ('owner_name', models.CharField(max_length=200, verbose_name='Имя владельца')), - ('phone', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='Телефон')), - ('status', models.CharField(choices=[('pending', 'Ожидает проверки'), ('approved', 'Одобрено'), ('rejected', 'Отклонено')], db_index=True, default='pending', max_length=20, verbose_name='Статус')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата подачи заявки')), - ('processed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата обработки')), - ('rejection_reason', models.TextField(blank=True, verbose_name='Причина отклонения')), - ('processed_by', models.ForeignKey(blank=True, help_text='Администратор, который обработал заявку', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Обработал')), - ('tenant', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tenants.client', verbose_name='Созданный тенант')), - ], - options={ - 'verbose_name': 'Заявка на регистрацию', - 'verbose_name_plural': 'Заявки на регистрацию', - 'ordering': ['-created_at'], - }, - ), - ] diff --git a/myproject/tenants/urls.py b/myproject/tenants/urls.py index 95037eb..70b8295 100644 --- a/myproject/tenants/urls.py +++ b/myproject/tenants/urls.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- from django.urls import path +from django.views.generic import RedirectView from .views import TenantRegistrationView, RegistrationSuccessView app_name = 'tenants' urlpatterns = [ + path('', RedirectView.as_view(url='register/', permanent=False), name='home'), # Главная страница перенаправляет на регистрацию path('register/', TenantRegistrationView.as_view(), name='register'), path('register/success/', RegistrationSuccessView.as_view(), name='registration_success'), ]