feat: Реализовать систему поступления товаров с партиями (IncomingBatch)

Основные изменения:
- Создана модель IncomingBatch для группировки товаров по документам
- Каждое поступление (Incoming) связано с одной батчем поступления
- Автоматическое создание StockBatch для каждого товара в приходе
- Реализована система нумерации партий (IN-XXXX-XXXX) с поиском максимума в БД
- Обновлены все представления (views) для работы с новой архитектурой
- Добавлены детальные страницы просмотра партий поступлений
- Обновлены шаблоны для отображения информации о партиях и их товарах
- Исправлена логика сигналов для создания StockBatch при приходе товара
- Обновлены формы для работы с новой структурой IncomingBatch

Архитектура FIFO:
- IncomingBatch: одна партия поступления (номер IN-XXXX-XXXX)
- Incoming: товар в партии поступления
- StockBatch: одна партия товара на складе (создается для каждого товара)

Это позволяет системе правильно применять FIFO при продаже товаров.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-29 03:26:06 +03:00
parent 097d4ea304
commit 6735be9b08
73 changed files with 6536 additions and 122 deletions

View File

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

View File

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

View File

@@ -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(
'<span style="color: {}; font-weight: bold;">{}</span>',
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('<span style="color: green;">✓ Обработана</span>')
return format_html('<span style="color: red;">✗ Ожидает</span>')
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(
'<span style="color: {}; font-weight: bold;">{}</span>',
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(
'<span style="color: {}; font-weight: bold;">{}</span>',
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',)

View File

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

View File

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

View File

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

View File

@@ -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', 'Закупка'),

View File

@@ -0,0 +1,13 @@
"""
Сервисы для работы со складским учетом.
"""
from .batch_manager import StockBatchManager
from .sale_processor import SaleProcessor
from .inventory_processor import InventoryProcessor
__all__ = [
'StockBatchManager',
'SaleProcessor',
'InventoryProcessor',
]

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Распределение продаж{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Распределение продаж по партиям (FIFO)</h4></div><div class="card-body">{% if allocations %}<table class="table table-hover table-sm"><thead><tr><th>Продажа</th><th>Товар</th><th>Партия</th><th>Кол-во</th><th>Цена</th><th>Дата</th></tr></thead><tbody>{% for a in allocations %}<tr><td>#{{ a.sale.id }}</td><td>{{ a.sale.product.name }}</td><td>#{{ a.batch.id }}</td><td>{{ a.quantity }}</td><td>{{ a.cost_price }}</td><td>{{ a.sale.date|date:"d.m.Y" }}</td></tr>{% endfor %}</tbody></table>{% else %}<div class="alert alert-info">Распределений не найдено.</div>{% endif %}</div></div>
{% endblock %}

View File

@@ -0,0 +1,96 @@
{% extends 'base.html' %}
{% block title %}{% block inventory_title %}Склад{% endblock %}{% endblock %}
{% block content %}
<div class="container-fluid mt-4">
<div class="row">
<!-- Боковая панель навигации -->
<div class="col-md-3 mb-4">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">Управление складом</h5>
</div>
<div class="list-group list-group-flush">
<a href="{% url 'inventory:inventory-home' %}" class="list-group-item list-group-item-action">
<i class="bi bi-house-door"></i> Главная
</a>
<a href="{% url 'inventory:warehouse-list' %}" class="list-group-item list-group-item-action">
<i class="bi bi-building"></i> Склады
</a>
<a href="{% url 'inventory:incoming-list' %}" class="list-group-item list-group-item-action">
<i class="bi bi-arrow-down-square"></i> Приходы
</a>
<a href="{% url 'inventory:incoming-create' %}" class="list-group-item list-group-item-action">
<i class="bi bi-file-earmark-plus"></i> Поступление товара
</a>
<a href="{% url 'inventory:incoming-batch-list' %}" class="list-group-item list-group-item-action">
<i class="bi bi-diagram-3-fill"></i> Партии поступлений
</a>
<a href="{% url 'inventory:sale-list' %}" class="list-group-item list-group-item-action">
<i class="bi bi-arrow-up-square"></i> Продажи
</a>
<a href="{% url 'inventory:inventory-list' %}" class="list-group-item list-group-item-action">
<i class="bi bi-clipboard-check"></i> Инвентаризация
</a>
<a href="{% url 'inventory:writeoff-list' %}" class="list-group-item list-group-item-action">
<i class="bi bi-x-circle"></i> Списания
</a>
<a href="{% url 'inventory:transfer-list' %}" class="list-group-item list-group-item-action">
<i class="bi bi-arrow-left-right"></i> Перемещения
</a>
</div>
</div>
<!-- Справочная информация -->
<div class="card mt-3">
<div class="card-header bg-info text-white">
<h5 class="mb-0">Справочная информация</h5>
</div>
<div class="list-group list-group-flush">
<a href="{% url 'inventory:stock-list' %}" class="list-group-item list-group-item-action">
<i class="bi bi-box-seam"></i> Остатки
</a>
<a href="{% url 'inventory:batch-list' %}" class="list-group-item list-group-item-action">
<i class="bi bi-diagram-3"></i> Партии
</a>
<a href="{% url 'inventory:movement-list' %}" class="list-group-item list-group-item-action">
<i class="bi bi-journal-check"></i> Журнал
</a>
</div>
</div>
</div>
<!-- Основной контент -->
<div class="col-md-9">
{% block inventory_content %}{% endblock %}
</div>
</div>
</div>
<style>
.list-group-item {
border-left: 4px solid transparent;
transition: all 0.2s;
}
.list-group-item:hover {
border-left-color: #007bff;
background-color: #f8f9fa;
}
.list-group-item.active {
border-left-color: #007bff;
background-color: #e7f1ff;
}
.card {
border: none;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.card-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
}
</style>
{% endblock %}

View File

@@ -0,0 +1,4 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Партия товара{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Партия #{{ batch.id }}: {{ batch.product.name }}</h4></div><div class="card-body"><table class="table table-borderless"><tr><th>Товар:</th><td>{{ batch.product.name }}</td></tr><tr><th>Склад:</th><td>{{ batch.warehouse.name }}</td></tr><tr><th>Количество:</th><td><strong>{{ batch.quantity }} шт</strong></td></tr><tr><th>Цена закупки:</th><td>{{ batch.cost_price }} ₽</td></tr><tr><th>Создана:</th><td>{{ batch.created_at|date:"d.m.Y H:i" }}</td></tr><tr><th>Статус:</th><td>{% if batch.is_active %}<span class="badge bg-success">Активна</span>{% else %}<span class="badge bg-secondary">Неактивна</span>{% endif %}</td></tr></table><h5 class="mt-4">История операций</h5><div class="alert alert-info">История продаж и списаний этой партии.</div><a href="{% url 'inventory:batch-list' %}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Вернуться</a></div></div>
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Партии товаров{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header">
<h4 class="mb-0">Все партии товаров на складе</h4>
</div>
<div class="card-body">
{% if batches %}
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead>
<tr>
<th>ID Партии</th>
<th>Товар</th>
<th>Склад</th>
<th>Кол-во</th>
<th>Цена</th>
<th>Создана</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for batch in batches %}
<tr>
<td>
<strong>#{{ batch.pk }}</strong>
</td>
<td>{{ batch.product.name }}</td>
<td>{{ batch.warehouse.name }}</td>
<td>{{ batch.quantity }}</td>
<td>{{ batch.cost_price }} ₽</td>
<td>{{ batch.created_at|date:"d.m.Y" }}</td>
<td>
<a href="{% url 'inventory:batch-detail' batch.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр партии">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">Партий не найдено.</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,149 @@
{% extends 'base.html' %}
{% block title %}Склад{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row mb-4">
<div class="col-12">
<h1 class="display-5">Управление складом</h1>
<p class="lead text-muted">Здесь будут инструменты для управления инвентаризацией и складским учетом</p>
</div>
</div>
<div class="row">
<!-- Основные операции -->
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-building"></i> Управление складами
</h5>
<p class="card-text text-muted">Создание и управление физическими складами</p>
<a href="{% url 'inventory:warehouse-list' %}" class="btn btn-outline-primary">Перейти</a>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-arrow-down-square"></i> Приход товара
</h5>
<p class="card-text text-muted">Регистрация поступления товаров на склад</p>
<a href="{% url 'inventory:incoming-list' %}" class="btn btn-outline-primary">Перейти</a>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-arrow-up-square"></i> Реализация товара
</h5>
<p class="card-text text-muted">Учет проданных товаров с применением FIFO</p>
<a href="{% url 'inventory:sale-list' %}" class="btn btn-outline-primary">Перейти</a>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-clipboard-check"></i> Инвентаризация
</h5>
<p class="card-text text-muted">Проверка фактических остатков и корректировка</p>
<a href="{% url 'inventory:inventory-list' %}" class="btn btn-outline-primary">Перейти</a>
</div>
</div>
</div>
<!-- Дополнительные операции -->
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-x-circle"></i> Списание товара
</h5>
<p class="card-text text-muted">Списание брака, порчи, недостач</p>
<a href="{% url 'inventory:writeoff-list' %}" class="btn btn-outline-secondary">Перейти</a>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-arrow-left-right"></i> Перемещение товара
</h5>
<p class="card-text text-muted">Перемещение между складами с сохранением партийности</p>
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary">Перейти</a>
</div>
</div>
</div>
<!-- Справочная информация -->
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-box-seam"></i> Остатки товаров
</h5>
<p class="card-text text-muted">Просмотр текущих остатков по складам и товарам</p>
<a href="{% url 'inventory:stock-list' %}" class="btn btn-outline-info">Перейти</a>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-diagram-3"></i> Партии товаров
</h5>
<p class="card-text text-muted">История партий и их распределение</p>
<a href="{% url 'inventory:batch-list' %}" class="btn btn-outline-info">Перейти</a>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-journal-check"></i> Журнал операций
</h5>
<p class="card-text text-muted">Полный журнал всех складских движений</p>
<a href="{% url 'inventory:movement-list' %}" class="btn btn-outline-info">Перейти</a>
</div>
</div>
</div>
</div>
</div>
<style>
.card {
border: none;
border-radius: 8px;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.card-title {
font-weight: 600;
margin-bottom: 1rem;
}
.card-body {
padding: 1.5rem;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,475 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Массовое поступление товара{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header">
<h4 class="mb-0">Поступление товара от поставщика</h4>
</div>
<div class="card-body">
<!-- Ошибки общей формы -->
{% if form.non_field_errors %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong>❌ Ошибка:</strong>
{% for error in form.non_field_errors %}
<div>{{ error }}</div>
{% endfor %}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<form method="post" novalidate id="bulkIncomingForm">
{% csrf_token %}
<!-- ============== HEADER ИНФОРМАЦИЯ ============== -->
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">{{ form.warehouse.label }} <span class="text-danger">*</span></label>
{{ form.warehouse }}
{% if form.warehouse.errors %}
<div class="text-danger small">{{ form.warehouse.errors.0 }}</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">{{ form.document_number.label }}</label>
{{ form.document_number }}
{% if form.document_number.errors %}
<div class="text-danger small">{{ form.document_number.errors.0 }}</div>
{% endif %}
<small class="text-muted d-block mt-1">
Оставьте пустым для автогенерации свободного номера (формат: IN-XXXX-XXXX). Номера, начинающиеся с IN-, зарезервированы для системы.
</small>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ form.supplier_name.label }}</label>
{{ form.supplier_name }}
{% if form.supplier_name.errors %}
<div class="text-danger small">{{ form.supplier_name.errors.0 }}</div>
{% endif %}
</div>
<div class="mb-3">
<label class="form-label">{{ form.notes.label }}</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="text-danger small">{{ form.notes.errors.0 }}</div>
{% endif %}
</div>
<hr>
<!-- ============== ТАБЛИЦА ТОВАРОВ ============== -->
<div class="mb-3">
<h5>Товары в поступлении</h5>
<div class="table-responsive">
<table class="table table-sm table-bordered" id="productsTable">
<thead class="table-light">
<tr>
<th style="width: 45%;">Товар</th>
<th style="width: 15%;">Кол-во (шт)</th>
<th style="width: 15%;">Цена закупки</th>
<th style="width: 15%;">Сумма</th>
<th style="width: 10%;">Действие</th>
</tr>
</thead>
<tbody id="productsBody">
<!-- Строки будут добавлены через JavaScript -->
</tbody>
</table>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="addRowBtn">
<i class="bi bi-plus-circle"></i> Добавить товар
</button>
</div>
<!-- ============== ИТОГО ============== -->
<div class="row mb-3">
<div class="col-md-6"></div>
<div class="col-md-6">
<div class="card bg-light">
<div class="card-body">
<div class="row mb-2">
<div class="col-6"><strong>Кол-во позиций:</strong></div>
<div class="col-6 text-end"><span id="totalItems">0</span></div>
</div>
<div class="row mb-2">
<div class="col-6"><strong>Общее количество:</strong></div>
<div class="col-6 text-end"><span id="totalQuantity">0</span> шт</div>
</div>
<div class="row">
<div class="col-6"><strong>Сумма поступления:</strong></div>
<div class="col-6 text-end text-primary"><strong><span id="totalSum">0.00</span> руб</strong></div>
</div>
</div>
</div>
</div>
</div>
<!-- Hidden input для JSON данных товаров -->
<input type="hidden" id="productsJson" name="products_json" value="[]">
<!-- ============== КНОПКИ ============== -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="bi bi-check-circle"></i> Создать поступление
</button>
<a href="{% url 'inventory:incoming-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отмена
</a>
</div>
</form>
</div>
</div>
<style>
select, textarea, input {
width: 100%;
}
.table-responsive {
border-radius: 4px;
border: 1px solid #dee2e6;
}
.btn-remove-row {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
input[readonly] {
background-color: #e9ecef;
}
.row-error {
background-color: #fff5f5;
}
.error-message {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('bulkIncomingForm');
const productsBody = document.getElementById('productsBody');
const addRowBtn = document.getElementById('addRowBtn');
const productsJsonInput = document.getElementById('productsJson');
const submitBtn = document.getElementById('submitBtn');
// Список всех доступных товаров (преобразуем QuerySet в JSON)
const products = [
{% for product in products %}
{ id: {{ product.id }}, name: "{{ product.name }}" },
{% endfor %}
];
const productOptions = products.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
let rowCounter = 0;
// Добавление новой строки товара
addRowBtn.addEventListener('click', function(e) {
e.preventDefault();
addProductRow();
});
function addProductRow() {
rowCounter++;
const rowId = `row-${rowCounter}`;
const row = document.createElement('tr');
row.id = rowId;
row.innerHTML = `
<td>
<select class="form-control form-control-sm product-select" data-row-id="${rowId}">
<option value="">Выберите товар...</option>
${productOptions}
</select>
<div class="error-message" style="display:none;"></div>
</td>
<td>
<input type="number" class="form-control form-control-sm quantity-input"
data-row-id="${rowId}" step="0.001" placeholder="0" min="0">
<div class="error-message" style="display:none;"></div>
</td>
<td>
<input type="number" class="form-control form-control-sm price-input"
data-row-id="${rowId}" step="0.01" placeholder="0.00" min="0">
<div class="error-message" style="display:none;"></div>
</td>
<td>
<input type="text" class="form-control form-control-sm sum-display"
data-row-id="${rowId}" readonly style="text-align:right;">
</td>
<td>
<button type="button" class="btn btn-sm btn-danger btn-remove-row" data-row-id="${rowId}">
<i class="bi bi-trash"></i>
</button>
</td>
`;
productsBody.appendChild(row);
// Добавляем event listeners для новой строки
const quantityInput = row.querySelector('.quantity-input');
const priceInput = row.querySelector('.price-input');
const removeBtn = row.querySelector('.btn-remove-row');
quantityInput.addEventListener('input', updateTotals);
priceInput.addEventListener('input', updateTotals);
removeBtn.addEventListener('click', function(e) {
e.preventDefault();
row.remove();
updateTotals();
});
updateTotals();
}
function updateTotals() {
let totalItems = 0;
let totalQuantity = 0;
let totalSum = 0;
const productsData = [];
productsBody.querySelectorAll('tr').forEach(row => {
const productSelect = row.querySelector('.product-select');
const quantityInput = row.querySelector('.quantity-input');
const priceInput = row.querySelector('.price-input');
const sumDisplay = row.querySelector('.sum-display');
const productId = productSelect.value;
const quantity = parseFloat(quantityInput.value) || 0;
const price = parseFloat(priceInput.value) || 0;
const sum = quantity * price;
// Обновляем дисплей суммы
sumDisplay.value = sum.toFixed(2);
// Только считаем если данные заполнены
if (productId && quantity > 0 && price >= 0) {
totalItems++;
totalQuantity += quantity;
totalSum += sum;
productsData.push({
product_id: parseInt(productId),
quantity: quantity,
cost_price: price
});
}
});
// Обновляем итоги
document.getElementById('totalItems').textContent = totalItems;
document.getElementById('totalQuantity').textContent = totalQuantity.toFixed(3);
document.getElementById('totalSum').textContent = totalSum.toFixed(2);
// Обновляем JSON данные
productsJsonInput.value = JSON.stringify(productsData);
// Отключаем кнопку отправки если нет товаров
submitBtn.disabled = totalItems === 0;
}
// Добавляем первую пустую строку
addProductRow();
// Восстанавливаем товары из JSON если была ошибка (сохранение данных при ошибке)
const savedProductsJson = '{{ products_json|escapejs }}';
if (savedProductsJson && savedProductsJson.trim() !== '[]' && savedProductsJson.trim() !== '') {
try {
const savedProducts = JSON.parse(savedProductsJson);
if (savedProducts && savedProducts.length > 0) {
// Удаляем пустую первую строку
productsBody.innerHTML = '';
rowCounter = 0;
// Добавляем восстановленные товары
savedProducts.forEach(item => {
const product = products.find(p => p.id === item.product_id);
if (product) {
addProductRow();
const lastRow = productsBody.querySelector('tr:last-child');
lastRow.querySelector('.product-select').value = item.product_id;
lastRow.querySelector('.quantity-input').value = item.quantity;
lastRow.querySelector('.price-input').value = item.cost_price;
}
});
// Обновляем итоги
updateTotals();
// Очищаем поле номера документа для автогенерации
const documentNumberInput = document.querySelector('[name="document_number"]');
if (documentNumberInput) {
documentNumberInput.value = '';
}
}
} catch (e) {
console.error('Ошибка восстановления товаров:', e);
}
}
// Получаем элемент поля номера документа
const documentNumberInput = document.querySelector('[name="document_number"]');
// Валидация номера документа (запретить номера, начинающиеся с "IN-" только для заполненного поля)
documentNumberInput.addEventListener('change', function() {
const value = this.value.trim().toUpperCase();
const container = this.closest('.mb-3');
let errorDiv = container.querySelector('.document-number-error');
// Проверяем IN-* ТОЛЬКО если поле НЕ пусто
if (value && value.startsWith('IN-')) {
// Показать ошибку
this.classList.add('is-invalid');
if (!errorDiv) {
errorDiv = document.createElement('div');
errorDiv.className = 'document-number-error text-danger small mt-2';
container.appendChild(errorDiv);
}
errorDiv.textContent = 'Номера, начинающиеся с "IN-", зарезервированы для системы. Если хотите автогенерацию, оставьте поле пустым.';
} else {
// Очистить ошибку (пусто или другой формат - это ОК)
this.classList.remove('is-invalid');
if (errorDiv) {
errorDiv.remove();
}
}
});
// Валидация перед отправкой
form.addEventListener('submit', function(e) {
// Проверка номера документа - запретить IN-* только если поле ЗАПОЛНЕНО
const docNumberValue = documentNumberInput.value.trim().toUpperCase();
const docNumberContainer = documentNumberInput.closest('.mb-3');
if (docNumberValue && docNumberValue.startsWith('IN-')) {
e.preventDefault();
documentNumberInput.classList.add('is-invalid');
let errorDiv = docNumberContainer.querySelector('.document-number-error');
if (!errorDiv) {
errorDiv = document.createElement('div');
errorDiv.className = 'document-number-error text-danger small mt-2';
docNumberContainer.appendChild(errorDiv);
}
errorDiv.textContent = 'Номера, начинающиеся с "IN-", зарезервированы для системы. Оставьте пустым для автогенерации.';
alert('Номера, начинающиеся с "IN-", зарезервированы для системы. Оставьте пустым для автогенерации.');
documentNumberInput.focus();
return false;
}
// Проверка склада
const warehouseSelect = document.querySelector('[name="warehouse"]');
const warehouseContainer = warehouseSelect.closest('.mb-3');
if (!warehouseSelect.value) {
e.preventDefault();
// Добавляем класс ошибки если его нет
if (!warehouseSelect.classList.contains('is-invalid')) {
warehouseSelect.classList.add('is-invalid');
warehouseContainer.classList.add('has-validation');
}
// Создаём или обновляем сообщение об ошибке
let errorDiv = warehouseContainer.querySelector('.warehouse-error');
if (!errorDiv) {
errorDiv = document.createElement('div');
errorDiv.className = 'warehouse-error text-danger small mt-2';
warehouseContainer.appendChild(errorDiv);
}
errorDiv.textContent = 'Пожалуйста, выберите склад перед отправкой.';
alert('Пожалуйста, выберите склад перед отправкой.');
warehouseSelect.focus();
return false;
} else {
// Очищаем ошибку если склад выбран
warehouseSelect.classList.remove('is-invalid');
const errorDiv = warehouseContainer.querySelector('.warehouse-error');
if (errorDiv) {
errorDiv.remove();
}
}
const productsData = JSON.parse(productsJsonInput.value);
if (productsData.length === 0) {
e.preventDefault();
alert('Пожалуйста, добавьте хотя бы один товар.');
return false;
}
// Проверяем что все товары корректно заполнены
let hasErrors = false;
productsBody.querySelectorAll('tr').forEach(row => {
const productSelect = row.querySelector('.product-select');
const quantityInput = row.querySelector('.quantity-input');
const priceInput = row.querySelector('.price-input');
const productError = row.querySelector('td:nth-child(1) .error-message');
const quantityError = row.querySelector('td:nth-child(2) .error-message');
const priceError = row.querySelector('td:nth-child(3) .error-message');
let rowHasError = false;
if (!productSelect.value) {
productError.textContent = 'Выберите товар';
productError.style.display = 'block';
hasErrors = true;
rowHasError = true;
} else {
productError.style.display = 'none';
}
const quantity = parseFloat(quantityInput.value) || 0;
if (quantity <= 0) {
quantityError.textContent = 'Количество должно быть > 0';
quantityError.style.display = 'block';
hasErrors = true;
rowHasError = true;
} else {
quantityError.style.display = 'none';
}
const price = parseFloat(priceInput.value) || 0;
if (price < 0) {
priceError.textContent = 'Цена не может быть отрицательной';
priceError.style.display = 'block';
hasErrors = true;
rowHasError = true;
} else {
priceError.style.display = 'none';
}
if (rowHasError) {
row.classList.add('row-error');
} else {
row.classList.remove('row-error');
}
});
if (hasErrors) {
e.preventDefault();
alert('Пожалуйста, исправьте ошибки в форме.');
return false;
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Отмена приходу товара{% endblock %}
{% block inventory_content %}
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">Подтверждение отмены</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
<strong>Внимание!</strong> Вы собираетесь отменить приход товара.
</div>
<p class="text-muted">
Это действие удалит запись о приходе товара и может повлиять на остатки на складе.
</p>
<div class="alert alert-info">
<h5>Информация о приходе:</h5>
<ul class="mb-0">
<li><strong>Товар:</strong> {{ incoming.product.name }}</li>
<li><strong>Склад:</strong> {{ incoming.warehouse.name }}</li>
<li><strong>Количество:</strong> {{ incoming.quantity }} шт</li>
<li><strong>Цена закупки:</strong> {{ incoming.cost_price }} ₽</li>
{% if incoming.document_number %}
<li><strong>Номер документа:</strong> {{ incoming.document_number }}</li>
{% endif %}
</ul>
</div>
<form method="post" class="mt-4">
{% csrf_token %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Подтвердить отмену
</button>
<a href="{% url 'inventory:incoming-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Вернуться
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,125 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}
{% if form.instance.pk %}
Редактирование приходу товара
{% else %}
Новый приход товара
{% endif %}
{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header">
<h4 class="mb-0">
{% if form.instance.pk %}
Редактирование приходу
{% else %}
Регистрация нового поступления
{% endif %}
</h4>
</div>
<div class="card-body">
<form method="post" class="form">
{% csrf_token %}
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.product.id_for_label }}" class="form-label">
{{ form.product.label }} <span class="text-danger">*</span>
</label>
{{ form.product }}
{% if form.product.errors %}
<div class="invalid-feedback d-block">
{% for error in form.product.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.warehouse.id_for_label }}" class="form-label">
{{ form.warehouse.label }} <span class="text-danger">*</span>
</label>
{{ form.warehouse }}
{% if form.warehouse.errors %}
<div class="invalid-feedback d-block">
{% for error in form.warehouse.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.quantity.id_for_label }}" class="form-label">
{{ form.quantity.label }} <span class="text-danger">*</span>
</label>
{{ form.quantity }}
{% if form.quantity.errors %}
<div class="invalid-feedback d-block">
{% for error in form.quantity.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.cost_price.id_for_label }}" class="form-label">
{{ form.cost_price.label }} <span class="text-danger">*</span>
</label>
{{ form.cost_price }}
{% if form.cost_price.errors %}
<div class="invalid-feedback d-block">
{% for error in form.cost_price.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="mb-3">
<label for="{{ form.document_number.id_for_label }}" class="form-label">
{{ form.document_number.label }}
</label>
{{ form.document_number }}
{% if form.document_number.errors %}
<div class="invalid-feedback d-block">
{% for error in form.document_number.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.notes.id_for_label }}" class="form-label">
{{ form.notes.label }}
</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="invalid-feedback d-block">
{% for error in form.notes.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i>
{% if form.instance.pk %}
Сохранить
{% else %}
Создать
{% endif %}
</button>
<a href="{% url 'inventory:incoming-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отменить
</a>
</div>
</form>
</div>
</div>
<style>
select, textarea, input {
width: 100%;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,112 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}История приходов товара{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Приходы товара</h4>
<a href="{% url 'inventory:incoming-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Новый приход
</a>
</div>
<div class="card-body">
{% if incomings %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Товар</th>
<th>Склад</th>
<th>Количество</th>
<th>Цена закупки</th>
<th>Номер документа</th>
<th>Партия</th>
<th>Дата</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
{% for incoming in incomings %}
<tr>
<td><strong>{{ incoming.product.name }}</strong></td>
<td>{{ incoming.batch.warehouse.name }}</td>
<td>{{ incoming.quantity }} шт</td>
<td>{{ incoming.cost_price }} ₽</td>
<td>
{% if incoming.batch.document_number %}
<code>{{ incoming.batch.document_number }}</code>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{% if incoming.stock_batch %}
<a href="{% url 'inventory:batch-detail' incoming.stock_batch.pk %}" title="Перейти к партии на складе">
<strong>#{{ incoming.stock_batch.pk }}</strong>
</a>
{% else %}
<span class="badge bg-warning">Не назначена</span>
{% endif %}
</td>
<td>{{ incoming.created_at|date:"d.m.Y H:i" }}</td>
<td class="text-end">
<a href="{% url 'inventory:incoming-update' incoming.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'inventory:incoming-delete' incoming.pk %}" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if is_paginated %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Предыдущая</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Приходов не найдено.
<a href="{% url 'inventory:incoming-create' %}" class="alert-link">Зарегистрировать новый приход</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Партия {{ batch.document_number }}{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h4 class="mb-0">Партия: <strong>{{ batch.document_number }}</strong></h4>
<a href="{% url 'inventory:incoming-batch-list' %}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Назад
</a>
</div>
</div>
<div class="card-body">
<div class="row mb-4">
<div class="col-md-6">
<h5>Основная информация</h5>
<table class="table table-sm table-borderless">
<tr>
<th>Номер:</th>
<td><strong>{{ batch.document_number }}</strong></td>
</tr>
<tr>
<th>Склад:</th>
<td>{{ batch.warehouse.name }}</td>
</tr>
<tr>
<th>Поставщик:</th>
<td>{{ batch.supplier_name|default:"—" }}</td>
</tr>
<tr>
<th>Создана:</th>
<td>{{ batch.created_at|date:"d.m.Y H:i" }}</td>
</tr>
</table>
</div>
<div class="col-md-6">
<h5>Статистика</h5>
<table class="table table-sm table-borderless">
<tr>
<th>Товаров:</th>
<td><span class="badge bg-info">{{ items.count }}</span></td>
</tr>
<tr>
<th>Общее количество:</th>
<td>
{% with total=items.all|length %}
<strong>{{ total }} шт</strong>
{% endwith %}
</td>
</tr>
</table>
</div>
</div>
<h5 class="mt-4 mb-3">Товары в партии</h5>
{% if items %}
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead>
<tr>
<th>Товар</th>
<th>Количество</th>
<th>Цена</th>
<th>Сумма</th>
<th>StockBatch ID</th>
<th>Дата</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.product.name }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.cost_price }} ₽</td>
<td>
{% widthratio item.quantity 1 item.cost_price as total_price %}
<strong>{{ total_price|floatformat:2 }} ₽</strong>
</td>
<td>
{% if item.stock_batch %}
<strong>#{{ item.stock_batch.pk }}</strong>
{% else %}
<span class="badge bg-warning">Не назначена</span>
{% endif %}
</td>
<td>{{ item.created_at|date:"d.m.Y" }}</td>
<td>
{% if item.stock_batch %}
<a href="{% url 'inventory:batch-detail' item.stock_batch.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр партии на складе">
<i class="bi bi-arrow-right"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">В этой партии нет товаров.</div>
{% endif %}
{% if batch.notes %}
<h5 class="mt-4 mb-3">Примечания</h5>
<p>{{ batch.notes }}</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Партии поступлений{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h4 class="mb-0">Партии поступлений товара</h4>
<a href="{% url 'inventory:incoming-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Новое поступление
</a>
</div>
</div>
<div class="card-body">
{% if batches %}
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead>
<tr>
<th>Номер документа</th>
<th>Склад</th>
<th>Поставщик</th>
<th>Товары</th>
<th>Кол-во</th>
<th>Дата создания</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for batch in batches %}
<tr>
<td><strong>{{ batch.document_number }}</strong></td>
<td>{{ batch.warehouse.name }}</td>
<td>{{ batch.supplier_name|default:"—" }}</td>
<td>
<small>
{% for item in batch.items.all %}
{{ item.product.name }}{% if not forloop.last %}<br>{% endif %}
{% endfor %}
</small>
</td>
<td>
<span class="badge bg-info">{{ batch.items_count }}</span>
</td>
<td>{{ batch.created_at|date:"d.m.Y H:i" }}</td>
<td>
<a href="{% url 'inventory:incoming-batch-detail' batch.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр партии">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">Партий поступлений не найдено.</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,111 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Детали инвентаризации{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Инвентаризация: {{ inventory.warehouse.name }}</h4>
<a href="{% url 'inventory:inventory-list' %}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Вернуться
</a>
</div>
<div class="card-body">
<div class="row mb-4">
<div class="col-md-6">
<h5>Информация</h5>
<table class="table table-borderless">
<tr>
<th>Склад:</th>
<td><strong>{{ inventory.warehouse.name }}</strong></td>
</tr>
<tr>
<th>Статус:</th>
<td>
{% if inventory.status == 'draft' %}
<span class="badge bg-secondary">Черновик</span>
{% elif inventory.status == 'processing' %}
<span class="badge bg-warning">В обработке</span>
{% else %}
<span class="badge bg-success">Завершена</span>
{% endif %}
</td>
</tr>
<tr>
<th>Дата:</th>
<td>{{ inventory.date|date:"d.m.Y H:i" }}</td>
</tr>
{% if inventory.conducted_by %}
<tr>
<th>Провёл:</th>
<td>{{ inventory.conducted_by }}</td>
</tr>
{% endif %}
</table>
</div>
</div>
<hr>
<h5>Строки инвентаризации</h5>
{% if lines %}
<div class="table-responsive">
<table class="table table-sm">
<thead class="table-light">
<tr>
<th>Товар</th>
<th>В системе</th>
<th>По факту</th>
<th>Разница</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for line in lines %}
<tr>
<td>{{ line.product.name }}</td>
<td>{{ line.quantity_system }}</td>
<td>{{ line.quantity_fact }}</td>
<td>
{% if line.difference > 0 %}
<span class="badge bg-success">+{{ line.difference }}</span>
{% elif line.difference < 0 %}
<span class="badge bg-danger">{{ line.difference }}</span>
{% else %}
<span class="badge bg-secondary">0</span>
{% endif %}
</td>
<td>
{% if line.processed %}
<span class="badge bg-success">Обработана</span>
{% else %}
<span class="badge bg-warning">Не обработана</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Строк инвентаризации не добавлено.
<a href="{% url 'inventory:inventory-lines-add' inventory.pk %}" class="alert-link">Добавить строки</a>
</div>
{% endif %}
<div class="d-flex gap-2 mt-4">
{% if inventory.status != 'completed' %}
<a href="{% url 'inventory:inventory-lines-add' inventory.pk %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Добавить строки
</a>
{% endif %}
<a href="{% url 'inventory:inventory-list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Вернуться к списку
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Новая инвентаризация{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header">
<h4 class="mb-0">Начало новой инвентаризации</h4>
</div>
<div class="card-body">
<form method="post" class="form">
{% csrf_token %}
<div class="mb-3">
<label for="{{ form.warehouse.id_for_label }}" class="form-label">
{{ form.warehouse.label }} <span class="text-danger">*</span>
</label>
{{ form.warehouse }}
{% if form.warehouse.errors %}
<div class="invalid-feedback d-block">
{% for error in form.warehouse.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.conducted_by.id_for_label }}" class="form-label">
{{ form.conducted_by.label }}
</label>
{{ form.conducted_by }}
{% if form.conducted_by.errors %}
<div class="invalid-feedback d-block">
{% for error in form.conducted_by.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
<small class="text-muted">Кто проводит инвентаризацию?</small>
</div>
<div class="mb-3">
<label for="{{ form.notes.id_for_label }}" class="form-label">
{{ form.notes.label }}
</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="invalid-feedback d-block">
{% for error in form.notes.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Начать инвентаризацию
</button>
<a href="{% url 'inventory:inventory-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отменить
</a>
</div>
</form>
</div>
</div>
<style>
select, textarea, input {
width: 100%;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Внесение результатов инвентаризации{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header">
<h4 class="mb-0">Внесение результатов инвентаризации</h4>
</div>
<div class="card-body">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
<strong>Инвентаризация:</strong> {{ inventory.warehouse.name }} ({{ inventory.date|date:"d.m.Y" }})
</div>
<form method="post" class="form">
{% csrf_token %}
<table class="table">
<thead>
<tr>
<th>Товар</th>
<th>Кол-во в системе</th>
<th>Кол-во по факту</th>
</tr>
</thead>
<tbody>
{% for line in lines %}
<tr>
<td>{{ line.product.name }}</td>
<td>{{ line.quantity_system }}</td>
<td>
<input type="number" step="0.001" class="form-control" name="quantity_{{ line.id }}" value="{{ line.quantity_fact }}">
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center text-muted">
Нет товаров для инвентаризации
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Сохранить результаты
</button>
<a href="{% url 'inventory:inventory-detail' inventory.pk %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отменить
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,96 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}История инвентаризаций{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Инвентаризации</h4>
<a href="{% url 'inventory:inventory-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Новая инвентаризация
</a>
</div>
<div class="card-body">
{% if inventories %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Склад</th>
<th>Статус</th>
<th>Провёл</th>
<th>Дата</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
{% for inventory in inventories %}
<tr>
<td><strong>{{ inventory.warehouse.name }}</strong></td>
<td>
{% if inventory.status == 'draft' %}
<span class="badge bg-secondary">Черновик</span>
{% elif inventory.status == 'processing' %}
<span class="badge bg-warning">В обработке</span>
{% else %}
<span class="badge bg-success">Завершена</span>
{% endif %}
</td>
<td>{{ inventory.conducted_by|default:"—" }}</td>
<td>{{ inventory.date|date:"d.m.Y H:i" }}</td>
<td class="text-end">
<a href="{% url 'inventory:inventory-detail' inventory.pk %}" class="btn btn-sm btn-outline-info">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if is_paginated %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Предыдущая</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Инвентаризаций не найдено.
<a href="{% url 'inventory:inventory-create' %}" class="alert-link">Начать новую инвентаризацию</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,4 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Журнал операций{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Журнал всех складских операций</h4></div><div class="card-body">{% if movements %}<table class="table table-hover table-sm"><thead><tr><th>Товар</th><th>Изменение</th><th>Причина</th><th>Дата</th></tr></thead><tbody>{% for m in movements %}<tr><td>{{ m.product.name }}</td><td>{% if m.change > 0 %}<span class="badge bg-success">+{{ m.change }}</span>{% else %}<span class="badge bg-danger">{{ m.change }}</span>{% endif %}</td><td>{{ m.get_reason_display }}</td><td>{{ m.created_at|date:"d.m.Y H:i" }}</td></tr>{% endfor %}</tbody></table>{% else %}<div class="alert alert-info">Операций не найдено.</div>{% endif %}</div></div>
{% endblock %}

View File

@@ -0,0 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Новое резервирование{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Резервирование товара</h4></div><div class="card-body"><form method="post">{% csrf_token %}<div class="mb-3"><label class="form-label">{{ form.product.label }} *</label>{{ form.product }}</div><div class="mb-3"><label class="form-label">{{ form.warehouse.label }} *</label>{{ form.warehouse }}</div><div class="mb-3"><label class="form-label">{{ form.quantity.label }} *</label>{{ form.quantity }}</div><div class="mb-3"><label class="form-label">{{ form.order_item.label }}</label>{{ form.order_item }}</div><div class="d-flex gap-2"><button type="submit" class="btn btn-primary">Сохранить</button><a href="{% url 'inventory:reservation-list' %}" class="btn btn-secondary">Отмена</a></div></form></div></div>
<style>select,input{width:100%;}</style>
{% endblock %}

View File

@@ -0,0 +1,4 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Резервирования{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Активные резервирования <a href="{% url 'inventory:reservation-create' %}" class="btn btn-primary btn-sm float-end"><i class="bi bi-plus-circle"></i> Новое</a></h4></div><div class="card-body">{% if reservations %}<table class="table table-hover table-sm"><thead><tr><th>Товар</th><th>Кол-во</th><th>Склад</th><th>Зарезервировано</th><th class="text-end">Действия</th></tr></thead><tbody>{% for r in reservations %}<tr><td>{{ r.product.name }}</td><td>{{ r.quantity }}</td><td>{{ r.warehouse.name }}</td><td>{{ r.reserved_at|date:"d.m.Y" }}</td><td class="text-end"><a href="{% url 'inventory:reservation-update' r.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a></td></tr>{% endfor %}</tbody></table>{% else %}<div class="alert alert-info">Резервирований не найдено.</div>{% endif %}</div></div>
{% endblock %}

View File

@@ -0,0 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Изменение резервирования{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Изменение статуса резервирования</h4></div><div class="card-body"><form method="post">{% csrf_token %}<div class="mb-3"><label class="form-label">{{ form.status.label }}</label>{{ form.status }}</div><div class="d-flex gap-2"><button type="submit" class="btn btn-primary">Сохранить</button><a href="{% url 'inventory:reservation-list' %}" class="btn btn-secondary">Отмена</a></div></form></div></div>
<style>select{width:100%;}</style>
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Отмена продажи{% endblock %}
{% block inventory_content %}
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">Подтверждение отмены продажи</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
<strong>Внимание!</strong> Вы собираетесь отменить продажу.
</div>
<p class="text-muted">
Это действие удалит запись о продаже и может повлиять на остатки товара на складе.
</p>
<div class="alert alert-info">
<h5>Информация о продаже:</h5>
<ul class="mb-0">
<li><strong>Товар:</strong> {{ sale.product.name }}</li>
<li><strong>Склад:</strong> {{ sale.warehouse.name }}</li>
<li><strong>Количество:</strong> {{ sale.quantity }} шт</li>
<li><strong>Цена продажи:</strong> {{ sale.sale_price }} ₽</li>
<li><strong>Статус:</strong>
{% if sale.processed %}
Обработана
{% else %}
Ожидает обработки
{% endif %}
</li>
{% if sale.document_number %}
<li><strong>Номер документа:</strong> {{ sale.document_number }}</li>
{% endif %}
</ul>
</div>
<form method="post" class="mt-4">
{% csrf_token %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Подтвердить отмену
</button>
<a href="{% url 'inventory:sale-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Вернуться
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,138 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Детали продажи{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Продажа: {{ sale.product.name }}</h4>
<a href="{% url 'inventory:sale-list' %}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Вернуться
</a>
</div>
<div class="card-body">
<div class="row mb-4">
<div class="col-md-6">
<h5>Информация о продаже</h5>
<table class="table table-borderless">
<tr>
<th>Товар:</th>
<td><strong>{{ sale.product.name }}</strong></td>
</tr>
<tr>
<th>Склад:</th>
<td>{{ sale.warehouse.name }}</td>
</tr>
<tr>
<th>Количество:</th>
<td><strong>{{ sale.quantity }} шт</strong></td>
</tr>
<tr>
<th>Цена продажи:</th>
<td><strong>{{ sale.sale_price }} ₽</strong></td>
</tr>
<tr>
<th>Сумма:</th>
<td><strong>{{ sale.quantity|add:0|multiply:sale.sale_price }} ₽</strong></td>
</tr>
</table>
</div>
<div class="col-md-6">
<h5>Дополнительная информация</h5>
<table class="table table-borderless">
<tr>
<th>Статус:</th>
<td>
{% if sale.processed %}
<span class="badge bg-success">Обработана (FIFO применена)</span>
{% else %}
<span class="badge bg-warning">Ожидает обработки</span>
{% endif %}
</td>
</tr>
<tr>
<th>Дата продажи:</th>
<td>{{ sale.date|date:"d.m.Y H:i" }}</td>
</tr>
{% if sale.order %}
<tr>
<th>Связанный заказ:</th>
<td><code>{{ sale.order.order_number }}</code></td>
</tr>
{% endif %}
{% if sale.document_number %}
<tr>
<th>Номер документа:</th>
<td><code>{{ sale.document_number }}</code></td>
</tr>
{% endif %}
</table>
</div>
</div>
<hr>
<h5 class="mt-4">Распределение по партиям (FIFO)</h5>
<p class="text-muted">Какие партии товара использовались в этой продаже:</p>
{% if allocations %}
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="table-light">
<tr>
<th>Партия</th>
<th>Дата создания</th>
<th>Количество использовано</th>
<th>Закупочная цена</th>
<th>Сумма закупки</th>
</tr>
</thead>
<tbody>
{% for allocation in allocations %}
<tr>
<td>
<code>Партия #{{ allocation.batch.id }}</code>
</td>
<td>{{ allocation.batch.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ allocation.quantity }} шт</td>
<td>{{ allocation.cost_price }} ₽</td>
<td><strong>{{ allocation.quantity|add:0|multiply:allocation.cost_price }} ₽</strong></td>
</tr>
{% endfor %}
</tbody>
<tfoot class="table-light">
<tr>
<th colspan="2">Итого:</th>
<th>{{ sale.quantity }} шт</th>
<th colspan="2">
<strong>
{% comment %} Сумма всех закупочных цен {% endcomment %}
Средняя стоимость
</strong>
</th>
</tr>
</tfoot>
</table>
</div>
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Распределение по партиям ещё не выполнено.
</div>
{% endif %}
<div class="d-flex gap-2 mt-4">
<a href="{% url 'inventory:sale-update' sale.pk %}" class="btn btn-primary">
<i class="bi bi-pencil"></i> Редактировать
</a>
<a href="{% url 'inventory:sale-delete' sale.pk %}" class="btn btn-danger">
<i class="bi bi-trash"></i> Удалить
</a>
<a href="{% url 'inventory:sale-list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Вернуться к списку
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,127 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}
{% if form.instance.pk %}
Редактирование продажи
{% else %}
Новая продажа
{% endif %}
{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header">
<h4 class="mb-0">
{% if form.instance.pk %}
Редактирование продажи
{% else %}
Регистрация новой продажи
{% endif %}
</h4>
</div>
<div class="card-body">
<form method="post" class="form">
{% csrf_token %}
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.product.id_for_label }}" class="form-label">
{{ form.product.label }} <span class="text-danger">*</span>
</label>
{{ form.product }}
{% if form.product.errors %}
<div class="invalid-feedback d-block">
{% for error in form.product.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.warehouse.id_for_label }}" class="form-label">
{{ form.warehouse.label }} <span class="text-danger">*</span>
</label>
{{ form.warehouse }}
{% if form.warehouse.errors %}
<div class="invalid-feedback d-block">
{% for error in form.warehouse.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.quantity.id_for_label }}" class="form-label">
{{ form.quantity.label }} <span class="text-danger">*</span>
</label>
{{ form.quantity }}
{% if form.quantity.errors %}
<div class="invalid-feedback d-block">
{% for error in form.quantity.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.sale_price.id_for_label }}" class="form-label">
{{ form.sale_price.label }} <span class="text-danger">*</span>
</label>
{{ form.sale_price }}
{% if form.sale_price.errors %}
<div class="invalid-feedback d-block">
{% for error in form.sale_price.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.order.id_for_label }}" class="form-label">
{{ form.order.label }}
</label>
{{ form.order }}
{% if form.order.errors %}
<div class="invalid-feedback d-block">
{% for error in form.order.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.document_number.id_for_label }}" class="form-label">
{{ form.document_number.label }}
</label>
{{ form.document_number }}
{% if form.document_number.errors %}
<div class="invalid-feedback d-block">
{% for error in form.document_number.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i>
{% if form.instance.pk %}
Сохранить
{% else %}
Создать
{% endif %}
</button>
<a href="{% url 'inventory:sale-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отменить
</a>
</div>
</form>
</div>
</div>
<style>
select, textarea, input {
width: 100%;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,113 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}История продаж{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Продажи товара (FIFO)</h4>
<a href="{% url 'inventory:sale-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Новая продажа
</a>
</div>
<div class="card-body">
{% if sales %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Товар</th>
<th>Склад</th>
<th>Количество</th>
<th>Цена продажи</th>
<th>Заказ</th>
<th>Статус</th>
<th>Дата</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
{% for sale in sales %}
<tr>
<td><strong>{{ sale.product.name }}</strong></td>
<td>{{ sale.warehouse.name }}</td>
<td>{{ sale.quantity }} шт</td>
<td>{{ sale.sale_price }} ₽</td>
<td>
{% if sale.order %}
<code>{{ sale.order.order_number }}</code>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{% if sale.processed %}
<span class="badge bg-success">Обработана</span>
{% else %}
<span class="badge bg-warning">Ожидает</span>
{% endif %}
</td>
<td>{{ sale.date|date:"d.m.Y H:i" }}</td>
<td class="text-end">
<a href="{% url 'inventory:sale-detail' sale.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр деталей">
<i class="bi bi-eye"></i>
</a>
<a href="{% url 'inventory:sale-update' sale.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'inventory:sale-delete' sale.pk %}" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if is_paginated %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Предыдущая</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Продаж не найдено.
<a href="{% url 'inventory:sale-create' %}" class="alert-link">Зарегистрировать новую продажу</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,4 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Остатки товара{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">{{ stock.product.name }} на {{ stock.warehouse.name }}</h4></div><div class="card-body"><table class="table table-borderless"><tr><th>Товар:</th><td><strong>{{ stock.product.name }}</strong></td></tr><tr><th>Склад:</th><td>{{ stock.warehouse.name }}</td></tr><tr><th>Доступно:</th><td><strong>{{ stock.quantity_available }} шт</strong></td></tr><tr><th>Зарезервировано:</th><td>{{ stock.quantity_reserved }} шт</td></tr><tr><th>Свободно:</th><td><strong>{{ stock.quantity_free }} шт</strong></td></tr><tr><th>Последнее обновление:</th><td>{{ stock.updated_at|date:"d.m.Y H:i" }}</td></tr></table><a href="{% url 'inventory:stock-list' %}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Вернуться</a></div></div>
{% endblock %}

View File

@@ -0,0 +1,4 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Остатки товаров{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Остатки на складах</h4></div><div class="card-body">{% if stocks %}<table class="table table-hover table-sm"><thead><tr><th>Товар</th><th>Склад</th><th>Доступно</th><th>Зарезервировано</th><th>Свободно</th><th>Последний обновления</th></tr></thead><tbody>{% for stock in stocks %}<tr><td><a href="{% url 'inventory:stock-detail' stock.pk %}">{{ stock.product.name }}</a></td><td>{{ stock.warehouse.name }}</td><td>{{ stock.quantity_available }}</td><td>{{ stock.quantity_reserved }}</td><td><strong>{{ stock.quantity_free }}</strong></td><td>{{ stock.updated_at|date:"d.m.Y H:i" }}</td></tr>{% endfor %}</tbody></table>{% else %}<div class="alert alert-info">Остатки не найдены.</div>{% endif %}</div></div>
{% endblock %}

View File

@@ -0,0 +1,4 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Отмена перемещения{% endblock %}
{% block inventory_content %}<div class="card border-danger"><div class="card-header bg-danger text-white"><h4 class="mb-0">Подтверждение</h4></div><div class="card-body"><div class="alert alert-warning"><i class="bi bi-exclamation-triangle"></i> Отменить перемещение товара?</div><form method="post">{% csrf_token %}<div class="d-flex gap-2"><button type="submit" class="btn btn-danger">Подтвердить</button><a href="{% url 'inventory:transfer-list' %}" class="btn btn-secondary">Отмена</a></div></form></div></div>
{% endblock %}

View File

@@ -0,0 +1,6 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Перемещение товара{% endblock %}
{% block inventory_content %}
<div class="card"><div class="card-header"><h4 class="mb-0">Перемещение товара</h4></div><div class="card-body"><form method="post">{% csrf_token %}<div class="mb-3"><label class="form-label">{{ form.batch.label }} *</label>{{ form.batch }}</div><div class="mb-3"><label class="form-label">{{ form.from_warehouse.label }} *</label>{{ form.from_warehouse }}</div><div class="mb-3"><label class="form-label">{{ form.to_warehouse.label }} *</label>{{ form.to_warehouse }}</div><div class="mb-3"><label class="form-label">{{ form.quantity.label }} *</label>{{ form.quantity }}</div><div class="mb-3"><label class="form-label">{{ form.document_number.label }}</label>{{ form.document_number }}</div><div class="d-flex gap-2"><button type="submit" class="btn btn-primary">Сохранить</button><a href="{% url 'inventory:transfer-list' %}" class="btn btn-secondary">Отмена</a></div></form></div></div>
<style>select,textarea,input{width:100%;}</style>
{% endblock %}

View File

@@ -0,0 +1,4 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Перемещение товаров{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Перемещение товаров между складами <a href="{% url 'inventory:transfer-create' %}" class="btn btn-primary btn-sm float-end"><i class="bi bi-plus-circle"></i> Новое</a></h4></div><div class="card-body">{% if transfers %}<div class="table-responsive"><table class="table table-hover table-sm"><thead><tr><th>Товар</th><th>Из</th><th>В</th><th>Кол-во</th><th>Дата</th><th class="text-end">Действия</th></tr></thead><tbody>{% for t in transfers %}<tr><td>{{ t.batch.product.name }}</td><td>{{ t.from_warehouse.name }}</td><td>{{ t.to_warehouse.name }}</td><td>{{ t.quantity }}</td><td>{{ t.date|date:"d.m.Y" }}</td><td class="text-end"><a href="{% url 'inventory:transfer-update' t.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a><a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></a></td></tr>{% endfor %}</tbody></table></div>{% else %}<div class="alert alert-info">Перемещений не найдено.</div>{% endif %}</div></div>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Удаление склада{% endblock %}
{% block inventory_content %}
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">Подтверждение удаления</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
<strong>Внимание!</strong> Вы собираетесь удалить (деактивировать) склад.
</div>
<p class="text-muted">
Этот склад будет деактивирован и скрыт из основного списка.
</p>
<h5>Склад: <strong>{{ warehouse.name }}</strong></h5>
{% if warehouse.description %}
<p class="text-muted">{{ warehouse.description }}</p>
{% endif %}
<form method="post" class="mt-4">
{% csrf_token %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Подтвердить удаление
</button>
<a href="{% url 'inventory:warehouse-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отменить
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,86 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}
{% if form.instance.pk %}
Редактирование склада
{% else %}
Создание нового склада
{% endif %}
{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header">
<h4 class="mb-0">
{% if form.instance.pk %}
Редактирование: {{ form.instance.name }}
{% else %}
Создание нового склада
{% endif %}
</h4>
</div>
<div class="card-body">
<form method="post" class="form">
{% csrf_token %}
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">
{{ form.name.label }}
</label>
<input type="text" class="form-control {% if form.name.errors %}is-invalid{% endif %}"
id="{{ form.name.id_for_label }}" name="{{ form.name.html_name }}"
value="{{ form.name.value|default:'' }}" required>
{% if form.name.errors %}
<div class="invalid-feedback">
{% for error in form.name.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.description.id_for_label }}" class="form-label">
{{ form.description.label }}
</label>
<textarea class="form-control {% if form.description.errors %}is-invalid{% endif %}"
id="{{ form.description.id_for_label }}" name="{{ form.description.html_name }}"
rows="4">{{ form.description.value|default:'' }}</textarea>
{% if form.description.errors %}
<div class="invalid-feedback">
{% for error in form.description.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input"
id="{{ form.is_active.id_for_label }}" name="{{ form.is_active.html_name }}"
{% if form.is_active.value %}checked{% endif %}>
<label class="form-check-label" for="{{ form.is_active.id_for_label }}">
{{ form.is_active.label }}
</label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i>
{% if form.instance.pk %}
Сохранить
{% else %}
Создать
{% endif %}
</button>
<a href="{% url 'inventory:warehouse-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отменить
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,98 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Управление складами{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Список складов</h4>
<a href="{% url 'inventory:warehouse-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Новый склад
</a>
</div>
<div class="card-body">
{% if warehouses %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Название</th>
<th>Описание</th>
<th>Статус</th>
<th>Дата создания</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
{% for warehouse in warehouses %}
<tr>
<td><strong>{{ warehouse.name }}</strong></td>
<td>{{ warehouse.description|truncatewords:10 }}</td>
<td>
{% if warehouse.is_active %}
<span class="badge bg-success">Активен</span>
{% else %}
<span class="badge bg-secondary">Неактивен</span>
{% endif %}
</td>
<td>{{ warehouse.created_at|date:"d.m.Y H:i" }}</td>
<td class="text-end">
<a href="{% url 'inventory:warehouse-update' warehouse.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'inventory:warehouse-delete' warehouse.pk %}" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if is_paginated %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Предыдущая</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Складов не найдено.
<a href="{% url 'inventory:warehouse-create' %}" class="alert-link">Создать новый</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Отмена списания{% endblock %}
{% block inventory_content %}
<div class="card border-danger">
<div class="card-header bg-danger text-white"><h4 class="mb-0">Подтверждение отмены</h4></div>
<div class="card-body">
<div class="alert alert-warning"><i class="bi bi-exclamation-triangle"></i> <strong>Внимание!</strong> Вы собираетесь отменить списание товара.</div>
<form method="post"><{% csrf_token %}<div class="d-flex gap-2"><button type="submit" class="btn btn-danger"><i class="bi bi-trash"></i> Подтвердить</button><a href="{% url 'inventory:writeoff-list' %}" class="btn btn-secondary"><i class="bi bi-x-circle"></i> Отмена</a></div></form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,174 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}{% if form.instance.pk %}Редактирование списания{% else %}Новое списание{% endif %}{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header"><h4 class="mb-0">{% if form.instance.pk %}Редактирование{% else %}Создание{% endif %} списания</h4></div>
<div class="card-body">
<!-- Ошибки формы -->
{% if form.non_field_errors %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong><i class="bi bi-exclamation-triangle"></i> Ошибка:</strong>
{% for error in form.non_field_errors %}
<div>{{ error }}</div>
{% endfor %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<form method="post" novalidate>
{% csrf_token %}
<!-- Поле Партия -->
<div class="mb-3">
<label class="form-label">{{ form.batch.label }} <span class="text-danger">*</span></label>
{{ form.batch }}
{% if form.batch.errors %}
<div class="invalid-feedback d-block">{{ form.batch.errors.0 }}</div>
{% endif %}
<!-- Информация об остатке партии -->
<div id="batch-info" class="mt-2 p-2 bg-light border rounded" style="display:none;">
<small class="text-muted">
Остаток в партии: <strong id="batch-quantity">0</strong> шт
</small>
</div>
</div>
<!-- Поле Количество -->
<div class="mb-3">
<label class="form-label">{{ form.quantity.label }} <span class="text-danger">*</span></label>
{{ form.quantity }}
{% if form.quantity.errors %}
<div class="invalid-feedback d-block">{{ form.quantity.errors.0 }}</div>
{% endif %}
<small class="text-muted d-block mt-1">
Введите количество товара для списания
</small>
<!-- Предупреждение о превышении остатка -->
<div id="quantity-warning" class="alert alert-warning mt-2" style="display:none;">
<i class="bi bi-exclamation-circle"></i>
<strong>Внимание!</strong> Вы пытаетесь списать <strong id="warning-qty">0</strong> шт,
а в партии только <strong id="warning-batch">0</strong> шт.
Недостаток: <strong id="warning-shortage" class="text-danger">0</strong> шт.
</div>
</div>
<!-- Поле Причина -->
<div class="mb-3">
<label class="form-label">{{ form.reason.label }}</label>
{{ form.reason }}
{% if form.reason.errors %}
<div class="invalid-feedback d-block">{{ form.reason.errors.0 }}</div>
{% endif %}
</div>
<!-- Поле Номер документа -->
<div class="mb-3">
<label class="form-label">{{ form.document_number.label }}</label>
{{ form.document_number }}
{% if form.document_number.errors %}
<div class="invalid-feedback d-block">{{ form.document_number.errors.0 }}</div>
{% endif %}
</div>
<!-- Поле Примечания -->
<div class="mb-3">
<label class="form-label">{{ form.notes.label }}</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="invalid-feedback d-block">{{ form.notes.errors.0 }}</div>
{% endif %}
</div>
<!-- Кнопки действия -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" id="submit-btn"><i class="bi bi-check-circle"></i> Сохранить</button>
<a href="{% url 'inventory:writeoff-list' %}" class="btn btn-secondary"><i class="bi bi-x-circle"></i> Отмена</a>
</div>
</form>
</div>
</div>
<style>
select, textarea, input {
width: 100%;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875em;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const batchSelect = document.querySelector('#id_batch');
const quantityInput = document.querySelector('#id_quantity');
const batchInfo = document.getElementById('batch-info');
const batchQuantitySpan = document.getElementById('batch-quantity');
const quantityWarning = document.getElementById('quantity-warning');
const warningQty = document.getElementById('warning-qty');
const warningBatch = document.getElementById('warning-batch');
const warningShortage = document.getElementById('warning-shortage');
// Функция для получения остатка партии
function getBatchQuantity() {
if (!batchSelect.value) {
batchInfo.style.display = 'none';
quantityWarning.style.display = 'none';
return null;
}
// Получаем текст option и парсим остаток
const selectedOption = batchSelect.options[batchSelect.selectedIndex];
const optionText = selectedOption.text;
// Пытаемся найти количество в скобках (формат: "Product - Остаток: X шт")
const match = optionText.match(/Остаток:\s*(\d+(?:[.,]\d+)?)/);
if (match) {
const qty = parseFloat(match[1].replace(',', '.'));
return qty;
}
return null;
}
// Функция для обновления информации и предупреждений
function updateBatchInfo() {
const batchQty = getBatchQuantity();
if (batchQty !== null) {
batchQuantitySpan.textContent = batchQty;
batchInfo.style.display = 'block';
} else {
batchInfo.style.display = 'none';
quantityWarning.style.display = 'none';
}
}
// Функция для проверки количества
function checkQuantity() {
const batchQty = getBatchQuantity();
const qty = parseFloat(quantityInput.value) || 0;
if (batchQty !== null && qty > 0) {
if (qty > batchQty) {
warningQty.textContent = qty;
warningBatch.textContent = batchQty;
warningShortage.textContent = (qty - batchQty).toFixed(3);
quantityWarning.style.display = 'block';
} else {
quantityWarning.style.display = 'none';
}
} else {
quantityWarning.style.display = 'none';
}
}
// События
batchSelect.addEventListener('change', updateBatchInfo);
quantityInput.addEventListener('input', checkQuantity);
// Инициализация
updateBatchInfo();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}История списаний{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Списания товара</h4>
<a href="{% url 'inventory:writeoff-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Новое списание
</a>
</div>
<div class="card-body">
{% if writeoffs %}
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead class="table-light">
<tr>
<th>Товар</th>
<th>Количество</th>
<th>Причина</th>
<th>Дата</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
{% for writeoff in writeoffs %}
<tr>
<td><strong>{{ writeoff.batch.product.name }}</strong></td>
<td>{{ writeoff.quantity }} шт</td>
<td><span class="badge bg-warning">{{ writeoff.get_reason_display }}</span></td>
<td>{{ writeoff.date|date:"d.m.Y H:i" }}</td>
<td class="text-end">
<a href="{% url 'inventory:writeoff-update' writeoff.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'inventory:writeoff-delete' writeoff.pk %}" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">Списаний не найдено.</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

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

View File

@@ -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/<int:pk>/edit/', WarehouseUpdateView.as_view(), name='warehouse-update'),
path('warehouses/<int:pk>/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/<int:pk>/edit/', IncomingUpdateView.as_view(), name='incoming-update'),
path('incoming/<int:pk>/delete/', IncomingDeleteView.as_view(), name='incoming-delete'),
# ==================== INCOMING BATCH ====================
path('incoming-batches/', IncomingBatchListView.as_view(), name='incoming-batch-list'),
path('incoming-batches/<int:pk>/', 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/<int:pk>/', SaleDetailView.as_view(), name='sale-detail'),
path('sales/<int:pk>/edit/', SaleUpdateView.as_view(), name='sale-update'),
path('sales/<int:pk>/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/<int:pk>/', InventoryDetailView.as_view(), name='inventory-detail'),
path('inventory-ops/<int:pk>/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/<int:pk>/edit/', WriteOffUpdateView.as_view(), name='writeoff-update'),
path('writeoffs/<int:pk>/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/<int:pk>/edit/', TransferUpdateView.as_view(), name='transfer-update'),
path('transfers/<int:pk>/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/<int:pk>/update-status/', ReservationUpdateView.as_view(), name='reservation-update'),
# ==================== STOCK (READ ONLY) ====================
path('stock/', StockListView.as_view(), name='stock-list'),
path('stock/<int:pk>/', StockDetailView.as_view(), name='stock-detail'),
# ==================== BATCH (READ ONLY) ====================
path('batches/', StockBatchListView.as_view(), name='batch-list'),
path('batches/<int:pk>/', 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'),
]

View File

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

View File

@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,9 @@
<li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Касса</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'inventory:inventory-home' %}">Склад</a>
</li>
{% endif %}
</ul>

View File

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

View File

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

View File

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

View File

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