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:
@@ -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',)
|
||||
|
||||
@@ -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
|
||||
|
||||
305
myproject/inventory/forms.py
Normal file
305
myproject/inventory/forms.py
Normal 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
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,38 +1,420 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from decimal import Decimal
|
||||
from products.models import Product
|
||||
|
||||
|
||||
class Warehouse(models.Model):
|
||||
"""
|
||||
Склад (физическое или логическое место хранения).
|
||||
"""
|
||||
name = models.CharField(max_length=200, verbose_name="Название")
|
||||
description = models.TextField(blank=True, null=True, verbose_name="Описание")
|
||||
is_active = models.BooleanField(default=True, verbose_name="Активен")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Склад"
|
||||
verbose_name_plural = "Склады"
|
||||
indexes = [
|
||||
models.Index(fields=['is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class StockBatch(models.Model):
|
||||
"""
|
||||
Партия товара (неделимая единица учета).
|
||||
Ключевая сущность для FIFO.
|
||||
"""
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||
related_name='stock_batches', verbose_name="Товар")
|
||||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||||
related_name='stock_batches', verbose_name="Склад")
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
||||
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Закупочная цена")
|
||||
is_active = models.BooleanField(default=True, verbose_name="Активна")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Партия товара"
|
||||
verbose_name_plural = "Партии товаров"
|
||||
ordering = ['created_at'] # FIFO: старые партии первыми
|
||||
indexes = [
|
||||
models.Index(fields=['product', 'warehouse']),
|
||||
models.Index(fields=['created_at']),
|
||||
models.Index(fields=['is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name} на {self.warehouse.name} - Остаток: {self.quantity} шт @ {self.cost_price} за ед."
|
||||
|
||||
|
||||
class IncomingBatch(models.Model):
|
||||
"""
|
||||
Партия поступления товара (один номер документа = одна партия).
|
||||
Содержит один номер документа и может включать несколько товаров.
|
||||
"""
|
||||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||||
related_name='incoming_batches', verbose_name="Склад")
|
||||
document_number = models.CharField(max_length=100, unique=True, db_index=True,
|
||||
verbose_name="Номер документа")
|
||||
supplier_name = models.CharField(max_length=200, blank=True, null=True,
|
||||
verbose_name="Наименование поставщика")
|
||||
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Партия поступления"
|
||||
verbose_name_plural = "Партии поступлений"
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['document_number']),
|
||||
models.Index(fields=['warehouse']),
|
||||
models.Index(fields=['-created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
total_items = self.items.count()
|
||||
total_qty = self.items.aggregate(models.Sum('quantity'))['quantity__sum'] or 0
|
||||
return f"Партия {self.document_number}: {total_items} товаров, {total_qty} шт"
|
||||
|
||||
|
||||
class Incoming(models.Model):
|
||||
"""
|
||||
Товар в партии поступления. Много товаров = одна партия (IncomingBatch).
|
||||
"""
|
||||
batch = models.ForeignKey(IncomingBatch, on_delete=models.CASCADE,
|
||||
related_name='items', verbose_name="Партия")
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||
related_name='incomings', verbose_name="Товар")
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
||||
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Закупочная цена")
|
||||
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
stock_batch = models.ForeignKey(StockBatch, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='incomings', verbose_name="Складская партия")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Товар в поступлении"
|
||||
verbose_name_plural = "Товары в поступлениях"
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['batch']),
|
||||
models.Index(fields=['product']),
|
||||
models.Index(fields=['-created_at']),
|
||||
]
|
||||
unique_together = [['batch', 'product']] # Один товар максимум один раз в партии
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name}: {self.quantity} шт (партия {self.batch.document_number})"
|
||||
|
||||
|
||||
class Sale(models.Model):
|
||||
"""
|
||||
Продажа товара. Списывает по FIFO.
|
||||
"""
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||
related_name='sales', verbose_name="Товар")
|
||||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||||
related_name='sales', verbose_name="Склад")
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
||||
sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Цена продажи")
|
||||
order = models.ForeignKey('orders.Order', on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='sales', verbose_name="Заказ")
|
||||
document_number = models.CharField(max_length=100, blank=True, null=True,
|
||||
verbose_name="Номер документа")
|
||||
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции")
|
||||
processed = models.BooleanField(default=False, verbose_name="Обработана (FIFO применена)")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Продажа"
|
||||
verbose_name_plural = "Продажи"
|
||||
ordering = ['-date']
|
||||
indexes = [
|
||||
models.Index(fields=['product', 'warehouse']),
|
||||
models.Index(fields=['date']),
|
||||
models.Index(fields=['order']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Продажа {self.product.name}: {self.quantity} шт @ {self.sale_price}"
|
||||
|
||||
|
||||
class SaleBatchAllocation(models.Model):
|
||||
"""
|
||||
Связь между Sale и StockBatch для отслеживания FIFO-списания.
|
||||
(Для аудита: какая партия использована при продаже)
|
||||
"""
|
||||
sale = models.ForeignKey(Sale, on_delete=models.CASCADE,
|
||||
related_name='batch_allocations', verbose_name="Продажа")
|
||||
batch = models.ForeignKey(StockBatch, on_delete=models.CASCADE,
|
||||
related_name='sale_allocations', verbose_name="Партия")
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
||||
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Закупочная цена")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Распределение продажи по партиям"
|
||||
verbose_name_plural = "Распределения продаж по партиям"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.sale} ← {self.batch} ({self.quantity} шт)"
|
||||
|
||||
|
||||
class WriteOff(models.Model):
|
||||
"""
|
||||
Списание товара вручную (брак, порча, недостача).
|
||||
Человек выбирает конкретную партию.
|
||||
"""
|
||||
REASON_CHOICES = [
|
||||
('damage', 'Повреждение'),
|
||||
('spoilage', 'Порча'),
|
||||
('shortage', 'Недостача'),
|
||||
('inventory', 'Инвентаризационная недостача'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
batch = models.ForeignKey(StockBatch, on_delete=models.CASCADE,
|
||||
related_name='writeoffs', verbose_name="Партия")
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
||||
reason = models.CharField(max_length=20, choices=REASON_CHOICES,
|
||||
default='other', verbose_name="Причина")
|
||||
cost_price = models.DecimalField(max_digits=10, decimal_places=2,
|
||||
verbose_name="Закупочная цена", editable=False)
|
||||
document_number = models.CharField(max_length=100, blank=True, null=True,
|
||||
verbose_name="Номер документа")
|
||||
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
|
||||
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Списание"
|
||||
verbose_name_plural = "Списания"
|
||||
ordering = ['-date']
|
||||
indexes = [
|
||||
models.Index(fields=['batch']),
|
||||
models.Index(fields=['date']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Списание {self.batch.product.name}: {self.quantity} шт ({self.get_reason_display()})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Автоматически записываем cost_price из партии
|
||||
if not self.pk: # Только при создании
|
||||
self.cost_price = self.batch.cost_price
|
||||
|
||||
# Проверяем что не списываем больше чем есть
|
||||
if self.quantity > self.batch.quantity:
|
||||
raise ValidationError(
|
||||
f"Невозможно списать {self.quantity} шт из партии, "
|
||||
f"где только {self.batch.quantity} шт. "
|
||||
f"Недостаток: {self.quantity - self.batch.quantity} шт."
|
||||
)
|
||||
|
||||
# Уменьшаем количество в партии при создании списания
|
||||
self.batch.quantity -= self.quantity
|
||||
if self.batch.quantity <= 0:
|
||||
self.batch.is_active = False
|
||||
self.batch.save(update_fields=['quantity', 'is_active', 'updated_at'])
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Transfer(models.Model):
|
||||
"""
|
||||
Перемещение товара между складами. Сохраняет партийность.
|
||||
"""
|
||||
batch = models.ForeignKey(StockBatch, on_delete=models.CASCADE,
|
||||
related_name='transfers', verbose_name="Партия")
|
||||
from_warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||||
related_name='transfers_from', verbose_name="Из склада")
|
||||
to_warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||||
related_name='transfers_to', verbose_name="На склад")
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
||||
document_number = models.CharField(max_length=100, blank=True, null=True,
|
||||
verbose_name="Номер документа")
|
||||
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции")
|
||||
new_batch = models.ForeignKey(StockBatch, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='transfer_sources', verbose_name="Новая партия")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Перемещение"
|
||||
verbose_name_plural = "Перемещения"
|
||||
ordering = ['-date']
|
||||
indexes = [
|
||||
models.Index(fields=['from_warehouse', 'to_warehouse']),
|
||||
models.Index(fields=['date']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Перемещение {self.batch.product.name} ({self.quantity} шт): {self.from_warehouse} → {self.to_warehouse}"
|
||||
|
||||
|
||||
class Inventory(models.Model):
|
||||
"""
|
||||
Инвентаризация (физический пересчет товаров).
|
||||
"""
|
||||
STATUS_CHOICES = [
|
||||
('draft', 'Черновик'),
|
||||
('processing', 'В обработке'),
|
||||
('completed', 'Завершена'),
|
||||
]
|
||||
|
||||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||||
related_name='inventories', verbose_name="Склад")
|
||||
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата инвентаризации")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES,
|
||||
default='draft', verbose_name="Статус")
|
||||
conducted_by = models.CharField(max_length=200, blank=True, null=True,
|
||||
verbose_name="Провел инвентаризацию")
|
||||
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Инвентаризация"
|
||||
verbose_name_plural = "Инвентаризации"
|
||||
ordering = ['-date']
|
||||
|
||||
def __str__(self):
|
||||
return f"Инвентаризация {self.warehouse.name} ({self.date.strftime('%Y-%m-%d')})"
|
||||
|
||||
|
||||
class InventoryLine(models.Model):
|
||||
"""
|
||||
Строка инвентаризации (товар + фактическое количество).
|
||||
"""
|
||||
inventory = models.ForeignKey(Inventory, on_delete=models.CASCADE,
|
||||
related_name='lines', verbose_name="Инвентаризация")
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||
verbose_name="Товар")
|
||||
quantity_system = models.DecimalField(max_digits=10, decimal_places=3,
|
||||
verbose_name="Количество в системе")
|
||||
quantity_fact = models.DecimalField(max_digits=10, decimal_places=3,
|
||||
verbose_name="Фактическое количество")
|
||||
difference = models.DecimalField(max_digits=10, decimal_places=3,
|
||||
default=0, verbose_name="Разница (факт - система)",
|
||||
editable=False)
|
||||
processed = models.BooleanField(default=False,
|
||||
verbose_name="Обработана (создана операция)")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Строка инвентаризации"
|
||||
verbose_name_plural = "Строки инвентаризации"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name}: {self.quantity_system} (сист.) vs {self.quantity_fact} (факт)"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Автоматически рассчитываем разницу
|
||||
self.difference = self.quantity_fact - self.quantity_system
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Reservation(models.Model):
|
||||
"""
|
||||
Резервирование товара для заказа.
|
||||
Отслеживает, какой товар зарезервирован за каким заказом.
|
||||
"""
|
||||
STATUS_CHOICES = [
|
||||
('reserved', 'Зарезервирован'),
|
||||
('released', 'Освобожден'),
|
||||
('converted_to_sale', 'Преобразован в продажу'),
|
||||
]
|
||||
|
||||
order_item = models.ForeignKey('orders.OrderItem', on_delete=models.CASCADE,
|
||||
related_name='reservations', verbose_name="Позиция заказа",
|
||||
null=True, blank=True)
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||
related_name='reservations', verbose_name="Товар")
|
||||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||||
related_name='reservations', verbose_name="Склад")
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES,
|
||||
default='reserved', verbose_name="Статус")
|
||||
reserved_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата резервирования")
|
||||
released_at = models.DateTimeField(null=True, blank=True, verbose_name="Дата освобождения")
|
||||
converted_at = models.DateTimeField(null=True, blank=True,
|
||||
verbose_name="Дата преобразования в продажу")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Резервирование"
|
||||
verbose_name_plural = "Резервирования"
|
||||
ordering = ['-reserved_at']
|
||||
indexes = [
|
||||
models.Index(fields=['product', 'warehouse']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['order_item']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
order_info = f" (заказ {self.order_item.order.order_number})" if self.order_item else ""
|
||||
return f"Резерв {self.product.name}: {self.quantity} шт{order_info} [{self.get_status_display()}]"
|
||||
|
||||
|
||||
class Stock(models.Model):
|
||||
"""
|
||||
Остатки по каждому товару.
|
||||
Агрегированные остатки по товарам и складам.
|
||||
Читаемое представление (может быть кешировано или пересчитано из StockBatch).
|
||||
"""
|
||||
product = models.OneToOneField(Product, on_delete=models.CASCADE,
|
||||
related_name='stock', verbose_name="Товар")
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||
related_name='stocks', verbose_name="Товар")
|
||||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||||
related_name='stocks', verbose_name="Склад")
|
||||
quantity_available = models.DecimalField(max_digits=10, decimal_places=3, default=0,
|
||||
verbose_name="Доступное количество")
|
||||
verbose_name="Доступное количество",
|
||||
editable=False)
|
||||
quantity_reserved = models.DecimalField(max_digits=10, decimal_places=3, default=0,
|
||||
verbose_name="Зарезервированное количество")
|
||||
verbose_name="Зарезервированное количество",
|
||||
editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Остаток на складе"
|
||||
verbose_name_plural = "Остатки на складе"
|
||||
unique_together = [['product', 'warehouse']]
|
||||
indexes = [
|
||||
models.Index(fields=['product']),
|
||||
models.Index(fields=['product', 'warehouse']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name} - {self.quantity_available}"
|
||||
return f"{self.product.name} на {self.warehouse.name}: {self.quantity_available} (зарезерв: {self.quantity_reserved})"
|
||||
|
||||
@property
|
||||
def quantity_free(self):
|
||||
"""Свободное количество (доступное минус зарезервированное)"""
|
||||
return self.quantity_available - self.quantity_reserved
|
||||
|
||||
def refresh_from_batches(self):
|
||||
"""
|
||||
Пересчитать остатки из StockBatch.
|
||||
Можно вызвать для синхронизации после операций.
|
||||
"""
|
||||
total_qty = StockBatch.objects.filter(
|
||||
product=self.product,
|
||||
warehouse=self.warehouse,
|
||||
is_active=True
|
||||
).aggregate(models.Sum('quantity'))['quantity__sum'] or Decimal('0')
|
||||
|
||||
total_reserved = Reservation.objects.filter(
|
||||
product=self.product,
|
||||
warehouse=self.warehouse,
|
||||
status='reserved'
|
||||
).aggregate(models.Sum('quantity'))['quantity__sum'] or Decimal('0')
|
||||
|
||||
self.quantity_available = total_qty
|
||||
self.quantity_reserved = total_reserved
|
||||
self.save()
|
||||
|
||||
|
||||
class StockMovement(models.Model):
|
||||
"""
|
||||
Журнал всех складских операций (приход, списание, коррекция).
|
||||
Используется для аудита.
|
||||
"""
|
||||
REASON_CHOICES = [
|
||||
('purchase', 'Закупка'),
|
||||
@@ -41,7 +423,7 @@ class StockMovement(models.Model):
|
||||
('adjustment', 'Корректировка'),
|
||||
]
|
||||
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||
related_name='movements', verbose_name="Товар")
|
||||
change = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Изменение")
|
||||
reason = models.CharField(max_length=20, choices=REASON_CHOICES, verbose_name="Причина")
|
||||
|
||||
13
myproject/inventory/services/__init__.py
Normal file
13
myproject/inventory/services/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Сервисы для работы со складским учетом.
|
||||
"""
|
||||
|
||||
from .batch_manager import StockBatchManager
|
||||
from .sale_processor import SaleProcessor
|
||||
from .inventory_processor import InventoryProcessor
|
||||
|
||||
__all__ = [
|
||||
'StockBatchManager',
|
||||
'SaleProcessor',
|
||||
'InventoryProcessor',
|
||||
]
|
||||
246
myproject/inventory/services/batch_manager.py
Normal file
246
myproject/inventory/services/batch_manager.py
Normal 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'])
|
||||
286
myproject/inventory/services/inventory_processor.py
Normal file
286
myproject/inventory/services/inventory_processor.py
Normal 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
|
||||
}
|
||||
216
myproject/inventory/services/sale_processor.py
Normal file
216
myproject/inventory/services/sale_processor.py
Normal 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
|
||||
}
|
||||
340
myproject/inventory/signals.py
Normal file
340
myproject/inventory/signals.py
Normal 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()
|
||||
@@ -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 %}
|
||||
96
myproject/inventory/templates/inventory/base_inventory.html
Normal file
96
myproject/inventory/templates/inventory/base_inventory.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
149
myproject/inventory/templates/inventory/home.html
Normal file
149
myproject/inventory/templates/inventory/home.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
138
myproject/inventory/templates/inventory/sale/sale_detail.html
Normal file
138
myproject/inventory/templates/inventory/sale/sale_detail.html
Normal 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 %}
|
||||
127
myproject/inventory/templates/inventory/sale/sale_form.html
Normal file
127
myproject/inventory/templates/inventory/sale/sale_form.html
Normal 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 %}
|
||||
113
myproject/inventory/templates/inventory/sale/sale_list.html
Normal file
113
myproject/inventory/templates/inventory/sale/sale_list.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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'))
|
||||
|
||||
96
myproject/inventory/urls.py
Normal file
96
myproject/inventory/urls.py
Normal 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'),
|
||||
]
|
||||
81
myproject/inventory/utils.py
Normal file
81
myproject/inventory/utils.py
Normal 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
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
10
myproject/inventory/views.py.old
Normal file
10
myproject/inventory/views.py.old
Normal 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')
|
||||
72
myproject/inventory/views/__init__.py
Normal file
72
myproject/inventory/views/__init__.py
Normal 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',
|
||||
]
|
||||
25
myproject/inventory/views/allocation.py
Normal file
25
myproject/inventory/views/allocation.py
Normal 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')
|
||||
83
myproject/inventory/views/batch.py
Normal file
83
myproject/inventory/views/batch.py
Normal 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
|
||||
239
myproject/inventory/views/incoming.py
Normal file
239
myproject/inventory/views/incoming.py
Normal 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
|
||||
102
myproject/inventory/views/inventory_ops.py
Normal file
102
myproject/inventory/views/inventory_ops.py
Normal 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)
|
||||
24
myproject/inventory/views/movements.py
Normal file
24
myproject/inventory/views/movements.py
Normal 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')
|
||||
46
myproject/inventory/views/reservation.py
Normal file
46
myproject/inventory/views/reservation.py
Normal 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)
|
||||
103
myproject/inventory/views/sale.py
Normal file
103
myproject/inventory/views/sale.py
Normal 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)
|
||||
27
myproject/inventory/views/stock.py
Normal file
27
myproject/inventory/views/stock.py
Normal 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'
|
||||
60
myproject/inventory/views/transfer.py
Normal file
60
myproject/inventory/views/transfer.py
Normal 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)
|
||||
66
myproject/inventory/views/warehouse.py
Normal file
66
myproject/inventory/views/warehouse.py
Normal 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)
|
||||
54
myproject/inventory/views/writeoff.py
Normal file
54
myproject/inventory/views/writeoff.py
Normal 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)
|
||||
Reference in New Issue
Block a user