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 %}
{% if allocations %}
| Продажа | Товар | Партия | Кол-во | Цена | Дата |
{% for a in allocations %}| #{{ a.sale.id }} | {{ a.sale.product.name }} | #{{ a.batch.id }} | {{ a.quantity }} | {{ a.cost_price }} | {{ a.sale.date|date:"d.m.Y" }} |
{% endfor %}
{% 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 %}
+
+
+
+
+
+
+
+ {% block inventory_content %}{% endblock %}
+
+
+
+
+
+{% 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.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 %}
+
+
+
+
+ | ID Партии |
+ Товар |
+ Склад |
+ Кол-во |
+ Цена |
+ Создана |
+ Действия |
+
+
+
+ {% for batch in batches %}
+
+ |
+ #{{ batch.pk }}
+ |
+ {{ batch.product.name }} |
+ {{ batch.warehouse.name }} |
+ {{ batch.quantity }} |
+ {{ batch.cost_price }} ₽ |
+ {{ batch.created_at|date:"d.m.Y" }} |
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+ {% 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 %}
+
+
❌ Ошибка:
+ {% for error in form.non_field_errors %}
+
{{ error }}
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+{% 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 %}
+
+
+
+
+
+
+{% 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 %}
+
+
+
+{% 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 %}
+
+ | {{ 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" }} |
+
+
+
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% 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.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 %}
+
+
+
+
+ | Товар |
+ Количество |
+ Цена |
+ Сумма |
+ StockBatch ID |
+ Дата |
+ Действие |
+
+
+
+ {% for item in items %}
+
+ | {{ 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 %}
+ |
+
+ {% endfor %}
+
+
+
+ {% 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 %}
+
+ | {{ 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" }} |
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+ {% 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.status == 'draft' %}
+ Черновик
+ {% elif inventory.status == 'processing' %}
+ В обработке
+ {% else %}
+ Завершена
+ {% endif %}
+ |
+
+
+ | Дата: |
+ {{ inventory.date|date:"d.m.Y H:i" }} |
+
+ {% if inventory.conducted_by %}
+
+ | Провёл: |
+ {{ inventory.conducted_by }} |
+
+ {% endif %}
+
+
+
+
+
+
+
Строки инвентаризации
+
+ {% if lines %}
+
+
+
+
+ | Товар |
+ В системе |
+ По факту |
+ Разница |
+ Статус |
+
+
+
+ {% for line in lines %}
+
+ | {{ 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 %}
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+ {% 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 %}
+
+
+
+{% 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" }})
+
+
+
+
+
+{% 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 %}
+
+ | {{ 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" }} |
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+ {% 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 %}| {{ 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" }} |
{% endfor %}
{% 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 %}
+
+{% 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 %}| {{ r.product.name }} | {{ r.quantity }} | {{ r.warehouse.name }} | {{ r.reserved_at|date:"d.m.Y" }} | |
{% endfor %}
{% 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 %}
+
+{% 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 %}
+
+
+
+
+
+
+{% 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.warehouse.name }} |
+
+
+ | Количество: |
+ {{ sale.quantity }} шт |
+
+
+ | Цена продажи: |
+ {{ sale.sale_price }} ₽ |
+
+
+ | Сумма: |
+ {{ sale.quantity|add:0|multiply:sale.sale_price }} ₽ |
+
+
+
+
+
+
Дополнительная информация
+
+
+ | Статус: |
+
+ {% if sale.processed %}
+ Обработана (FIFO применена)
+ {% else %}
+ Ожидает обработки
+ {% endif %}
+ |
+
+
+ | Дата продажи: |
+ {{ sale.date|date:"d.m.Y H:i" }} |
+
+ {% if sale.order %}
+
+ | Связанный заказ: |
+ {{ sale.order.order_number }} |
+
+ {% endif %}
+ {% if sale.document_number %}
+
+ | Номер документа: |
+ {{ sale.document_number }} |
+
+ {% endif %}
+
+
+
+
+
+
+
Распределение по партиям (FIFO)
+
Какие партии товара использовались в этой продаже:
+
+ {% if allocations %}
+
+
+
+
+ | Партия |
+ Дата создания |
+ Количество использовано |
+ Закупочная цена |
+ Сумма закупки |
+
+
+
+ {% for allocation in allocations %}
+
+
+ Партия #{{ 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 }} ₽ |
+
+ {% endfor %}
+
+
+
+ | Итого: |
+ {{ 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 %}
+
+
+
+{% 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 %}
+
+
+
+
+ {% if sales %}
+
+
+
+
+ | Товар |
+ Склад |
+ Количество |
+ Цена продажи |
+ Заказ |
+ Статус |
+ Дата |
+ Действия |
+
+
+
+ {% for sale in sales %}
+
+ | {{ 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" }} |
+
+
+
+
+
+
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% 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.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 %}| {{ stock.product.name }} | {{ stock.warehouse.name }} | {{ stock.quantity_available }} | {{ stock.quantity_reserved }} | {{ stock.quantity_free }} | {{ stock.updated_at|date:"d.m.Y H:i" }} |
{% endfor %}
{% 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 %} Отменить перемещение товара?
+{% 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 %}
+
+
+{% 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 %}| {{ t.batch.product.name }} | {{ t.from_warehouse.name }} | {{ t.to_warehouse.name }} | {{ t.quantity }} | {{ t.date|date:"d.m.Y" }} | |
{% endfor %}
{% 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 %}
+
+
+
+
+{% 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 %}
+
+{% 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 %}
+
+ | {{ warehouse.name }} |
+ {{ warehouse.description|truncatewords:10 }} |
+
+ {% if warehouse.is_active %}
+ Активен
+ {% else %}
+ Неактивен
+ {% endif %}
+ |
+ {{ warehouse.created_at|date:"d.m.Y H:i" }} |
+
+
+
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% 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 %}
+
+
+
+
Внимание! Вы собираетесь отменить списание товара.
+
+
+
+{% 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.non_field_errors %}
+
+
Ошибка:
+ {% for error in form.non_field_errors %}
+
{{ error }}
+ {% endfor %}
+
+
+ {% 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 %}
+
+ | {{ writeoff.batch.product.name }} |
+ {{ writeoff.quantity }} шт |
+ {{ writeoff.get_reason_display }} |
+ {{ writeoff.date|date:"d.m.Y H:i" }} |
+
+
+
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+ {% 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'),
]