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,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
# Generated by Django 5.1.4 on 2025-10-28 22:47
|
||||||
|
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
# Generated by Django 5.1.4 on 2025-10-28 22:47
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import phonenumber_field.modelfields
|
import phonenumber_field.modelfields
|
||||||
|
|||||||
@@ -1,19 +1,325 @@
|
|||||||
from django.contrib import admin
|
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):
|
class StockAdmin(admin.ModelAdmin):
|
||||||
list_display = ('product', 'quantity_available', 'quantity_reserved', 'updated_at')
|
list_display = ('product', 'warehouse', 'quantity_available', 'quantity_reserved', 'quantity_free', 'updated_at')
|
||||||
list_filter = ('updated_at',)
|
list_filter = ('warehouse', 'updated_at')
|
||||||
search_fields = ('product__name', 'product__sku')
|
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):
|
class StockMovementAdmin(admin.ModelAdmin):
|
||||||
list_display = ('product', 'change', 'reason', 'order', 'created_at')
|
list_display = ('product', 'change', 'reason', 'order', 'created_at')
|
||||||
list_filter = ('reason', 'created_at')
|
list_filter = ('reason', 'created_at')
|
||||||
search_fields = ('product__name', 'order__id')
|
search_fields = ('product__name', 'order__order_number')
|
||||||
date_hierarchy = 'created_at'
|
date_hierarchy = 'created_at'
|
||||||
|
readonly_fields = ('created_at',)
|
||||||
|
|
||||||
admin.site.register(Stock, StockAdmin)
|
|
||||||
admin.site.register(StockMovement, StockMovementAdmin)
|
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ from django.apps import AppConfig
|
|||||||
class InventoryConfig(AppConfig):
|
class InventoryConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'inventory'
|
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
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
@@ -14,19 +14,204 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
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(
|
migrations.CreateModel(
|
||||||
name='Stock',
|
name='Stock',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('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_available', models.DecimalField(decimal_places=3, default=0, editable=False, max_digits=10, verbose_name='Доступное количество')),
|
||||||
('quantity_reserved', models.DecimalField(decimal_places=3, default=0, 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='Дата обновления')),
|
('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={
|
options={
|
||||||
'verbose_name': 'Остаток на складе',
|
'verbose_name': 'Остаток на складе',
|
||||||
'verbose_name_plural': 'Остатки на складе',
|
'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(
|
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')],
|
'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.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from decimal import Decimal
|
||||||
from products.models import Product
|
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):
|
class Stock(models.Model):
|
||||||
"""
|
"""
|
||||||
Остатки по каждому товару.
|
Агрегированные остатки по товарам и складам.
|
||||||
|
Читаемое представление (может быть кешировано или пересчитано из StockBatch).
|
||||||
"""
|
"""
|
||||||
product = models.OneToOneField(Product, on_delete=models.CASCADE,
|
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||||
related_name='stock', verbose_name="Товар")
|
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,
|
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,
|
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="Дата обновления")
|
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Остаток на складе"
|
verbose_name = "Остаток на складе"
|
||||||
verbose_name_plural = "Остатки на складе"
|
verbose_name_plural = "Остатки на складе"
|
||||||
|
unique_together = [['product', 'warehouse']]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['product']),
|
models.Index(fields=['product', 'warehouse']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
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
|
@property
|
||||||
def quantity_free(self):
|
def quantity_free(self):
|
||||||
"""Свободное количество (доступное минус зарезервированное)"""
|
"""Свободное количество (доступное минус зарезервированное)"""
|
||||||
return self.quantity_available - self.quantity_reserved
|
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):
|
class StockMovement(models.Model):
|
||||||
"""
|
"""
|
||||||
Журнал всех складских операций (приход, списание, коррекция).
|
Журнал всех складских операций (приход, списание, коррекция).
|
||||||
|
Используется для аудита.
|
||||||
"""
|
"""
|
||||||
REASON_CHOICES = [
|
REASON_CHOICES = [
|
||||||
('purchase', 'Закупка'),
|
('purchase', 'Закупка'),
|
||||||
@@ -41,7 +423,7 @@ class StockMovement(models.Model):
|
|||||||
('adjustment', 'Корректировка'),
|
('adjustment', 'Корректировка'),
|
||||||
]
|
]
|
||||||
|
|
||||||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||||
related_name='movements', verbose_name="Товар")
|
related_name='movements', verbose_name="Товар")
|
||||||
change = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Изменение")
|
change = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Изменение")
|
||||||
reason = models.CharField(max_length=20, choices=REASON_CHOICES, 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
|
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)
|
||||||
@@ -8,15 +8,19 @@ from django.contrib import admin
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('_nested_admin/', include('nested_admin.urls')), # Для nested admin
|
path('_nested_admin/', include('nested_admin.urls')), # Для nested admin
|
||||||
path('admin/', admin.site.urls), # Админка для владельца магазина (доступна на поддомене)
|
path('admin/', admin.site.urls), # Админка для владельца магазина (доступна на поддомене)
|
||||||
# TODO: Add web interface for shop owners
|
|
||||||
# path('', views.dashboard, name='dashboard'),
|
# Web interface for shop owners
|
||||||
# path('products/', include('products.urls')),
|
path('', views.index, name='index'), # Главная страница
|
||||||
# path('orders/', include('orders.urls')),
|
path('accounts/', include('accounts.urls')), # Управление аккаунтом
|
||||||
# path('customers/', include('customers.urls')),
|
path('products/', include('products.urls')), # Управление товарами
|
||||||
|
path('customers/', include('customers.urls')), # Управление клиентами
|
||||||
|
path('inventory/', include('inventory.urls')), # Управление складом
|
||||||
|
# path('orders/', include('orders.urls')), # TODO: Создать URL-конфиг для заказов
|
||||||
]
|
]
|
||||||
|
|
||||||
# Serve media files during development
|
# Serve media files during development
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ URL configuration for the PUBLIC schema (inventory.by domain).
|
|||||||
This is the main domain where:
|
This is the main domain where:
|
||||||
- Super admin can access admin panel
|
- Super admin can access admin panel
|
||||||
- Tenant registration is available
|
- Tenant registration is available
|
||||||
- Future: Landing page, etc.
|
|
||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
@@ -14,7 +13,7 @@ from django.conf.urls.static import static
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('', include('tenants.urls')), # Подключаем роуты для регистрации тенантов
|
path('', include('tenants.urls')), # Роуты для регистрации тенантов (/, /register/, /register/success/)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Serve media files in development
|
# Serve media files in development
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
# Generated by Django 5.1.4 on 2025-10-28 22:47
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
# Generated by Django 5.1.4 on 2025-10-28 22:47
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
# Generated by Django 5.1.4 on 2025-10-28 22:47
|
||||||
|
|
||||||
import phonenumber_field.modelfields
|
import phonenumber_field.modelfields
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Касса</a>
|
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Касса</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'inventory:inventory-home' %}">Склад</a>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
@@ -249,6 +249,16 @@ class TenantRegistrationAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
logger.info(f"Домен создан: {domain.id}")
|
logger.info(f"Домен создан: {domain.id}")
|
||||||
|
|
||||||
|
# Применяем миграции для нового тенанта
|
||||||
|
logger.info(f"Применение миграций для тенанта: {registration.schema_name}")
|
||||||
|
from django.core.management import call_command
|
||||||
|
try:
|
||||||
|
call_command('migrate_schemas', schema_name=registration.schema_name, verbosity=1)
|
||||||
|
logger.info(f"Миграции успешно применены для тенанта: {registration.schema_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при применении миграций: {str(e)}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
# Создаем триальную подписку на 90 дней
|
# Создаем триальную подписку на 90 дней
|
||||||
logger.info(f"Создание триальной подписки для тенанта: {client.id}")
|
logger.info(f"Создание триальной подписки для тенанта: {client.id}")
|
||||||
subscription = Subscription.create_trial(client)
|
subscription = Subscription.create_trial(client)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
# Generated by Django 5.1.4 on 2025-10-28 22:47
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django_tenants.postgresql_backend.base
|
import django_tenants.postgresql_backend.base
|
||||||
|
import phonenumber_field.modelfields
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -10,6 +13,7 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -18,12 +22,12 @@ class Migration(migrations.Migration):
|
|||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('schema_name', models.CharField(db_index=True, max_length=63, unique=True, validators=[django_tenants.postgresql_backend.base._check_schema_name])),
|
('schema_name', models.CharField(db_index=True, max_length=63, unique=True, validators=[django_tenants.postgresql_backend.base._check_schema_name])),
|
||||||
('name', models.CharField(max_length=200, verbose_name='Название магазина')),
|
('name', models.CharField(db_index=True, max_length=200, verbose_name='Название магазина')),
|
||||||
('owner_email', models.EmailField(help_text='Контактный email владельца магазина', max_length=254, verbose_name='Email владельца')),
|
('owner_email', models.EmailField(help_text='Контактный email владельца магазина. Один email может использоваться для нескольких магазинов (для супер-админа)', max_length=254, verbose_name='Email владельца')),
|
||||||
('owner_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='Имя владельца')),
|
('owner_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='Имя владельца')),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
('is_active', models.BooleanField(default=True, help_text='Активна ли учетная запись магазина', verbose_name='Активен')),
|
('is_active', models.BooleanField(default=True, help_text='Активна ли учетная запись магазина (ручная блокировка админом)', verbose_name='Активен')),
|
||||||
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Телефон')),
|
('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='Телефон')),
|
||||||
('notes', models.TextField(blank=True, help_text='Внутренние заметки администратора', null=True, verbose_name='Заметки')),
|
('notes', models.TextField(blank=True, help_text='Внутренние заметки администратора', null=True, verbose_name='Заметки')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
@@ -45,4 +49,45 @@ class Migration(migrations.Migration):
|
|||||||
'verbose_name_plural': 'Домены',
|
'verbose_name_plural': 'Домены',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Subscription',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('plan', models.CharField(choices=[('trial', 'Триальный (90 дней)'), ('monthly', 'Месячный'), ('quarterly', 'Квартальный (3 месяца)'), ('yearly', 'Годовой')], default='trial', max_length=20, verbose_name='План подписки')),
|
||||||
|
('started_at', models.DateTimeField(verbose_name='Дата начала')),
|
||||||
|
('expires_at', models.DateTimeField(verbose_name='Дата окончания')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Активна ли подписка (может быть отключена вручную)', verbose_name='Активна')),
|
||||||
|
('auto_renew', models.BooleanField(default=False, help_text='Автоматически продлевать подписку', verbose_name='Автопродление')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||||
|
('client', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='tenants.client', verbose_name='Тенант')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Подписка',
|
||||||
|
'verbose_name_plural': 'Подписки',
|
||||||
|
'ordering': ['-expires_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TenantRegistration',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('shop_name', models.CharField(max_length=200, verbose_name='Название магазина')),
|
||||||
|
('schema_name', models.CharField(help_text='Например: myshop (будет доступен как myshop.inventory.by)', max_length=63, unique=True, validators=[django.core.validators.RegexValidator(message='Поддомен должен содержать только латинские буквы в нижнем регистре, цифры и дефис. Длина от 3 до 63 символов. Не может начинаться или заканчиваться дефисом.', regex='^[a-z0-9](?:[a-z0-9\\-]{0,61}[a-z0-9])?$')], verbose_name='Желаемый поддомен')),
|
||||||
|
('owner_email', models.EmailField(max_length=254, verbose_name='Email владельца')),
|
||||||
|
('owner_name', models.CharField(max_length=200, verbose_name='Имя владельца')),
|
||||||
|
('phone', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='Телефон')),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Ожидает проверки'), ('approved', 'Одобрено'), ('rejected', 'Отклонено')], db_index=True, default='pending', max_length=20, verbose_name='Статус')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата подачи заявки')),
|
||||||
|
('processed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата обработки')),
|
||||||
|
('rejection_reason', models.TextField(blank=True, verbose_name='Причина отклонения')),
|
||||||
|
('processed_by', models.ForeignKey(blank=True, help_text='Администратор, который обработал заявку', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Обработал')),
|
||||||
|
('tenant', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tenants.client', verbose_name='Созданный тенант')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Заявка на регистрацию',
|
||||||
|
'verbose_name_plural': 'Заявки на регистрацию',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-10-27 09:45
|
|
||||||
|
|
||||||
import django.core.validators
|
|
||||||
import django.db.models.deletion
|
|
||||||
import phonenumber_field.modelfields
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('tenants', '0001_initial'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='client',
|
|
||||||
name='is_active',
|
|
||||||
field=models.BooleanField(default=True, help_text='Активна ли учетная запись магазина (ручная блокировка админом)', verbose_name='Активен'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='client',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(db_index=True, max_length=200, verbose_name='Название магазина'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='client',
|
|
||||||
name='owner_email',
|
|
||||||
field=models.EmailField(help_text='Контактный email владельца магазина. Один email может использоваться для нескольких магазинов (для супер-админа)', max_length=254, verbose_name='Email владельца'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='client',
|
|
||||||
name='phone',
|
|
||||||
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='Телефон'),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Subscription',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('plan', models.CharField(choices=[('trial', 'Триальный (90 дней)'), ('monthly', 'Месячный'), ('quarterly', 'Квартальный (3 месяца)'), ('yearly', 'Годовой')], default='trial', max_length=20, verbose_name='План подписки')),
|
|
||||||
('started_at', models.DateTimeField(verbose_name='Дата начала')),
|
|
||||||
('expires_at', models.DateTimeField(verbose_name='Дата окончания')),
|
|
||||||
('is_active', models.BooleanField(default=True, help_text='Активна ли подписка (может быть отключена вручную)', verbose_name='Активна')),
|
|
||||||
('auto_renew', models.BooleanField(default=False, help_text='Автоматически продлевать подписку', verbose_name='Автопродление')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
|
||||||
('client', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='tenants.client', verbose_name='Тенант')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Подписка',
|
|
||||||
'verbose_name_plural': 'Подписки',
|
|
||||||
'ordering': ['-expires_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='TenantRegistration',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('shop_name', models.CharField(max_length=200, verbose_name='Название магазина')),
|
|
||||||
('schema_name', models.CharField(help_text='Например: myshop (будет доступен как myshop.inventory.by)', max_length=63, unique=True, validators=[django.core.validators.RegexValidator(message='Поддомен должен содержать только латинские буквы в нижнем регистре, цифры и дефис. Длина от 3 до 63 символов. Не может начинаться или заканчиваться дефисом.', regex='^[a-z0-9](?:[a-z0-9\\-]{0,61}[a-z0-9])?$')], verbose_name='Желаемый поддомен')),
|
|
||||||
('owner_email', models.EmailField(max_length=254, verbose_name='Email владельца')),
|
|
||||||
('owner_name', models.CharField(max_length=200, verbose_name='Имя владельца')),
|
|
||||||
('phone', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='Телефон')),
|
|
||||||
('status', models.CharField(choices=[('pending', 'Ожидает проверки'), ('approved', 'Одобрено'), ('rejected', 'Отклонено')], db_index=True, default='pending', max_length=20, verbose_name='Статус')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата подачи заявки')),
|
|
||||||
('processed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата обработки')),
|
|
||||||
('rejection_reason', models.TextField(blank=True, verbose_name='Причина отклонения')),
|
|
||||||
('processed_by', models.ForeignKey(blank=True, help_text='Администратор, который обработал заявку', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Обработал')),
|
|
||||||
('tenant', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tenants.client', verbose_name='Созданный тенант')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Заявка на регистрацию',
|
|
||||||
'verbose_name_plural': 'Заявки на регистрацию',
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
from django.views.generic import RedirectView
|
||||||
from .views import TenantRegistrationView, RegistrationSuccessView
|
from .views import TenantRegistrationView, RegistrationSuccessView
|
||||||
|
|
||||||
app_name = 'tenants'
|
app_name = 'tenants'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('', RedirectView.as_view(url='register/', permanent=False), name='home'), # Главная страница перенаправляет на регистрацию
|
||||||
path('register/', TenantRegistrationView.as_view(), name='register'),
|
path('register/', TenantRegistrationView.as_view(), name='register'),
|
||||||
path('register/success/', RegistrationSuccessView.as_view(), name='registration_success'),
|
path('register/success/', RegistrationSuccessView.as_view(), name='registration_success'),
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user