Унификация генерации номеров документов и оптимизация кода
- Унифицирован формат номеров документов: IN-XXXXXX (6 цифр), как WO-XXXXXX и MOVE-XXXXXX - Убрано дублирование функции _extract_number_from_document_number - Оптимизирована инициализация счетчика incoming: быстрая проверка перед полной инициализацией - Удален неиспользуемый файл utils.py (функциональность перенесена в document_generator.py) - Все функции генерации номеров используют единый подход через DocumentCounter.get_next_value()
This commit is contained in:
@@ -7,7 +7,8 @@ from decimal import Decimal
|
||||
from inventory.models import (
|
||||
Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer,
|
||||
Inventory, InventoryLine, Reservation, Stock, StockMovement,
|
||||
SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem
|
||||
SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem,
|
||||
IncomingDocument, IncomingDocumentItem
|
||||
)
|
||||
|
||||
|
||||
@@ -428,3 +429,77 @@ class WriteOffDocumentItemAdmin(admin.ModelAdmin):
|
||||
list_filter = ('reason', 'document__status', 'created_at')
|
||||
search_fields = ('product__name', 'document__document_number')
|
||||
raw_id_fields = ('product', 'document', 'reservation')
|
||||
|
||||
|
||||
# ===== INCOMING DOCUMENT (документы поступления) =====
|
||||
class IncomingDocumentItemInline(admin.TabularInline):
|
||||
model = IncomingDocumentItem
|
||||
extra = 0
|
||||
fields = ('product', 'quantity', 'cost_price', 'notes')
|
||||
raw_id_fields = ('product',)
|
||||
|
||||
|
||||
@admin.register(IncomingDocument)
|
||||
class IncomingDocumentAdmin(admin.ModelAdmin):
|
||||
list_display = ('document_number', 'warehouse', 'status_display', 'receipt_type_display', 'date', 'items_count', 'total_quantity_display', 'created_by', 'created_at')
|
||||
list_filter = ('status', 'warehouse', 'receipt_type', 'date', 'created_at')
|
||||
search_fields = ('document_number', 'warehouse__name', 'supplier_name')
|
||||
date_hierarchy = 'date'
|
||||
readonly_fields = ('document_number', 'created_at', 'updated_at', 'confirmed_at', 'confirmed_by')
|
||||
inlines = [IncomingDocumentItemInline]
|
||||
|
||||
fieldsets = (
|
||||
('Документ', {
|
||||
'fields': ('document_number', 'warehouse', 'status', 'date', 'receipt_type', 'supplier_name', 'notes')
|
||||
}),
|
||||
('Аудит', {
|
||||
'fields': ('created_by', 'created_at', 'confirmed_by', 'confirmed_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def status_display(self, obj):
|
||||
colors = {
|
||||
'draft': '#ff9900',
|
||||
'confirmed': '#008000',
|
||||
'cancelled': '#ff0000',
|
||||
}
|
||||
return format_html(
|
||||
'<span style="color: {}; font-weight: bold;">{}</span>',
|
||||
colors.get(obj.status, '#000000'),
|
||||
obj.get_status_display()
|
||||
)
|
||||
status_display.short_description = 'Статус'
|
||||
|
||||
def receipt_type_display(self, obj):
|
||||
colors = {
|
||||
'supplier': '#0d6efd',
|
||||
'inventory': '#0dcaf0',
|
||||
'adjustment': '#198754',
|
||||
}
|
||||
return format_html(
|
||||
'<span style="color: {}; font-weight: bold;">{}</span>',
|
||||
colors.get(obj.receipt_type, '#6c757d'),
|
||||
obj.get_receipt_type_display()
|
||||
)
|
||||
receipt_type_display.short_description = 'Тип поступления'
|
||||
|
||||
def items_count(self, obj):
|
||||
return obj.items.count()
|
||||
items_count.short_description = 'Позиций'
|
||||
|
||||
def total_quantity_display(self, obj):
|
||||
return f"{obj.total_quantity} шт"
|
||||
total_quantity_display.short_description = 'Всего'
|
||||
|
||||
|
||||
@admin.register(IncomingDocumentItem)
|
||||
class IncomingDocumentItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('document', 'product', 'quantity', 'cost_price', 'total_cost_display', 'created_at')
|
||||
list_filter = ('document__status', 'document__receipt_type', 'created_at')
|
||||
search_fields = ('product__name', 'document__document_number')
|
||||
raw_id_fields = ('product', 'document')
|
||||
|
||||
def total_cost_display(self, obj):
|
||||
return f"{obj.total_cost:.2f}"
|
||||
total_cost_display.short_description = 'Сумма'
|
||||
|
||||
@@ -5,7 +5,8 @@ from decimal import Decimal
|
||||
|
||||
from .models import (
|
||||
Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch,
|
||||
TransferBatch, TransferItem, Showcase, WriteOffDocument, WriteOffDocumentItem, Stock
|
||||
TransferBatch, TransferItem, Showcase, WriteOffDocument, WriteOffDocumentItem, Stock,
|
||||
IncomingDocument, IncomingDocumentItem
|
||||
)
|
||||
from products.models import Product
|
||||
|
||||
@@ -557,3 +558,94 @@ class WriteOffDocumentItemForm(forms.ModelForm):
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class IncomingDocumentForm(forms.ModelForm):
|
||||
"""
|
||||
Форма создания/редактирования документа поступления.
|
||||
"""
|
||||
class Meta:
|
||||
model = IncomingDocument
|
||||
fields = ['warehouse', 'date', 'receipt_type', 'supplier_name', 'notes']
|
||||
widgets = {
|
||||
'warehouse': forms.Select(attrs={'class': 'form-control'}),
|
||||
'date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||
'receipt_type': forms.Select(attrs={'class': 'form-control'}),
|
||||
'supplier_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Наименование поставщика'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Примечания к документу'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['warehouse'].queryset = Warehouse.objects.filter(is_active=True)
|
||||
|
||||
# Устанавливаем дату по умолчанию - сегодня
|
||||
if not self.initial.get('date'):
|
||||
from django.utils import timezone
|
||||
self.initial['date'] = timezone.now().date()
|
||||
|
||||
# Если есть склад по умолчанию - предвыбираем его
|
||||
if not self.initial.get('warehouse'):
|
||||
default_warehouse = Warehouse.objects.filter(
|
||||
is_active=True,
|
||||
is_default=True
|
||||
).first()
|
||||
if default_warehouse:
|
||||
self.initial['warehouse'] = default_warehouse.id
|
||||
|
||||
# Устанавливаем тип поступления по умолчанию
|
||||
if not self.initial.get('receipt_type'):
|
||||
self.initial['receipt_type'] = 'supplier'
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
receipt_type = cleaned_data.get('receipt_type')
|
||||
supplier_name = cleaned_data.get('supplier_name')
|
||||
|
||||
# Для типа 'supplier' supplier_name обязателен
|
||||
if receipt_type == 'supplier' and not supplier_name:
|
||||
raise ValidationError({
|
||||
'supplier_name': 'Для типа "Поступление от поставщика" необходимо указать наименование поставщика'
|
||||
})
|
||||
|
||||
# Для других типов supplier_name не нужен
|
||||
if receipt_type != 'supplier' and supplier_name:
|
||||
cleaned_data['supplier_name'] = None
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class IncomingDocumentItemForm(forms.ModelForm):
|
||||
"""
|
||||
Форма добавления/редактирования позиции в документ поступления.
|
||||
"""
|
||||
class Meta:
|
||||
model = IncomingDocumentItem
|
||||
fields = ['product', 'quantity', 'cost_price', 'notes']
|
||||
widgets = {
|
||||
'product': forms.Select(attrs={'class': 'form-control'}),
|
||||
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'min': '0.001'}),
|
||||
'cost_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 2, 'placeholder': 'Примечания'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, document=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.document = document
|
||||
|
||||
# Для поступлений можно выбрать любой активный товар (не нужно проверять наличие на складе)
|
||||
self.fields['product'].queryset = Product.objects.filter(
|
||||
status='active'
|
||||
).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 is not None and cost_price < 0:
|
||||
raise ValidationError('Закупочная цена не может быть отрицательной')
|
||||
return cost_price
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Generated by Django 5.0.10 on 2025-12-20 21:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('inventory', '0013_add_receipt_type_to_incomingbatch'),
|
||||
('products', '0010_alter_product_cost_price'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='documentcounter',
|
||||
name='counter_type',
|
||||
field=models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара'), ('incoming', 'Поступление товара')], max_length=20, unique=True, verbose_name='Тип счетчика'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IncomingDocument',
|
||||
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='Номер документа')),
|
||||
('status', models.CharField(choices=[('draft', 'Черновик'), ('confirmed', 'Проведён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')),
|
||||
('date', models.DateField(help_text='Дата, к которой относится поступление', verbose_name='Дата документа')),
|
||||
('receipt_type', models.CharField(choices=[('supplier', 'Поступление от поставщика'), ('inventory', 'Оприходование при инвентаризации'), ('adjustment', 'Оприходование без инвентаризации')], db_index=True, default='supplier', max_length=20, verbose_name='Тип поступления')),
|
||||
('supplier_name', models.CharField(blank=True, help_text="Заполняется для типа 'Поступление от поставщика'", max_length=200, null=True, verbose_name='Наименование поставщика')),
|
||||
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
|
||||
('confirmed_at', models.DateTimeField(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='Обновлён')),
|
||||
('confirmed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_incoming_documents', to=settings.AUTH_USER_MODEL, verbose_name='Провёл')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_incoming_documents', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
|
||||
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_documents', to='inventory.warehouse', verbose_name='Склад')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Документ поступления',
|
||||
'verbose_name_plural': 'Документы поступления',
|
||||
'ordering': ['-date', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IncomingDocumentItem',
|
||||
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='Создан')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.incomingdocument', verbose_name='Документ')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_document_items', to='products.product', verbose_name='Товар')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Позиция документа поступления',
|
||||
'verbose_name_plural': 'Позиции документа поступления',
|
||||
'ordering': ['id'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incomingdocument',
|
||||
index=models.Index(fields=['document_number'], name='inventory_i_documen_5b89ad_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incomingdocument',
|
||||
index=models.Index(fields=['warehouse', 'status'], name='inventory_i_warehou_8f141d_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incomingdocument',
|
||||
index=models.Index(fields=['date'], name='inventory_i_date_8ace9b_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incomingdocument',
|
||||
index=models.Index(fields=['receipt_type'], name='inventory_i_receipt_92f322_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incomingdocument',
|
||||
index=models.Index(fields=['-created_at'], name='inventory_i_created_174930_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incomingdocumentitem',
|
||||
index=models.Index(fields=['document'], name='inventory_i_documen_96d470_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incomingdocumentitem',
|
||||
index=models.Index(fields=['product'], name='inventory_i_product_932432_idx'),
|
||||
),
|
||||
]
|
||||
@@ -761,6 +761,7 @@ class DocumentCounter(models.Model):
|
||||
COUNTER_TYPE_CHOICES = [
|
||||
('transfer', 'Перемещение товара'),
|
||||
('writeoff', 'Списание товара'),
|
||||
('incoming', 'Поступление товара'),
|
||||
]
|
||||
|
||||
counter_type = models.CharField(
|
||||
@@ -1100,3 +1101,205 @@ class WriteOffDocumentItem(models.Model):
|
||||
def total_cost(self):
|
||||
"""Себестоимость позиции (средневзвешенная из cost_price товара)"""
|
||||
return self.quantity * (self.product.cost_price or Decimal('0'))
|
||||
|
||||
|
||||
class IncomingDocument(models.Model):
|
||||
"""
|
||||
Документ поступления товара на склад.
|
||||
|
||||
Сценарий использования:
|
||||
1. Создается черновик (draft)
|
||||
2. В течение дня добавляются товары (IncomingDocumentItem)
|
||||
3. В конце смены документ проводится (confirmed) → создаются IncomingBatch и Incoming
|
||||
4. Сигнал автоматически создает StockBatch и обновляет Stock
|
||||
"""
|
||||
STATUS_CHOICES = [
|
||||
('draft', 'Черновик'),
|
||||
('confirmed', 'Проведён'),
|
||||
('cancelled', 'Отменён'),
|
||||
]
|
||||
|
||||
document_number = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
verbose_name="Номер документа"
|
||||
)
|
||||
|
||||
warehouse = models.ForeignKey(
|
||||
Warehouse,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='incoming_documents',
|
||||
verbose_name="Склад"
|
||||
)
|
||||
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='draft',
|
||||
db_index=True,
|
||||
verbose_name="Статус"
|
||||
)
|
||||
|
||||
date = models.DateField(
|
||||
verbose_name="Дата документа",
|
||||
help_text="Дата, к которой относится поступление"
|
||||
)
|
||||
|
||||
receipt_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=IncomingBatch.RECEIPT_TYPE_CHOICES,
|
||||
default='supplier',
|
||||
db_index=True,
|
||||
verbose_name="Тип поступления"
|
||||
)
|
||||
|
||||
supplier_name = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Наименование поставщика",
|
||||
help_text="Заполняется для типа 'Поступление от поставщика'"
|
||||
)
|
||||
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Примечания"
|
||||
)
|
||||
|
||||
# Аудит
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_incoming_documents',
|
||||
verbose_name="Создал"
|
||||
)
|
||||
|
||||
confirmed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='confirmed_incoming_documents',
|
||||
verbose_name="Провёл"
|
||||
)
|
||||
|
||||
confirmed_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=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 = ['-date', '-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['document_number']),
|
||||
models.Index(fields=['warehouse', 'status']),
|
||||
models.Index(fields=['date']),
|
||||
models.Index(fields=['receipt_type']),
|
||||
models.Index(fields=['-created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.document_number} ({self.get_status_display()})"
|
||||
|
||||
@property
|
||||
def total_quantity(self):
|
||||
"""Общее количество товаров в документе"""
|
||||
return self.items.aggregate(
|
||||
total=models.Sum('quantity')
|
||||
)['total'] or Decimal('0')
|
||||
|
||||
@property
|
||||
def total_cost(self):
|
||||
"""Общая себестоимость поступления"""
|
||||
return sum(item.total_cost for item in self.items.select_related('product'))
|
||||
|
||||
@property
|
||||
def can_edit(self):
|
||||
"""Можно ли редактировать документ"""
|
||||
return self.status == 'draft'
|
||||
|
||||
@property
|
||||
def can_confirm(self):
|
||||
"""Можно ли провести документ"""
|
||||
return self.status == 'draft' and self.items.exists()
|
||||
|
||||
@property
|
||||
def can_cancel(self):
|
||||
"""Можно ли отменить документ"""
|
||||
return self.status == 'draft'
|
||||
|
||||
|
||||
class IncomingDocumentItem(models.Model):
|
||||
"""
|
||||
Строка документа поступления.
|
||||
|
||||
При создании:
|
||||
- Товар добавляется в черновик документа
|
||||
- Резервирование НЕ создается (товар еще не поступил)
|
||||
|
||||
При проведении документа:
|
||||
1. Создается IncomingBatch с номером документа
|
||||
2. Создается Incoming запись для каждого товара
|
||||
3. Сигнал create_stock_batch_on_incoming автоматически создает StockBatch
|
||||
"""
|
||||
document = models.ForeignKey(
|
||||
IncomingDocument,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name="Документ"
|
||||
)
|
||||
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='incoming_document_items',
|
||||
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="Создан")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлён")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Позиция документа поступления"
|
||||
verbose_name_plural = "Позиции документа поступления"
|
||||
ordering = ['id']
|
||||
indexes = [
|
||||
models.Index(fields=['document']),
|
||||
models.Index(fields=['product']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name}: {self.quantity} шт @ {self.cost_price}"
|
||||
|
||||
@property
|
||||
def total_cost(self):
|
||||
"""Себестоимость позиции (quantity * cost_price)"""
|
||||
return self.quantity * self.cost_price
|
||||
|
||||
293
myproject/inventory/services/incoming_document_service.py
Normal file
293
myproject/inventory/services/incoming_document_service.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Сервис для работы с документами поступления (IncomingDocument).
|
||||
|
||||
Обеспечивает:
|
||||
- Создание документов с автонумерацией
|
||||
- Добавление позиций в черновик
|
||||
- Проведение документов (создание IncomingBatch и Incoming)
|
||||
- Отмену документов
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from inventory.models import (
|
||||
IncomingDocument, IncomingDocumentItem, IncomingBatch, Incoming
|
||||
)
|
||||
from inventory.utils.document_generator import generate_incoming_document_number
|
||||
|
||||
|
||||
class IncomingDocumentService:
|
||||
"""
|
||||
Сервис для работы с документами поступления.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def create_document(cls, warehouse, date, receipt_type='supplier', supplier_name=None, notes=None, created_by=None):
|
||||
"""
|
||||
Создать новый документ поступления (черновик).
|
||||
|
||||
Args:
|
||||
warehouse: объект Warehouse
|
||||
date: дата документа (date)
|
||||
receipt_type: тип поступления ('supplier', 'inventory', 'adjustment')
|
||||
supplier_name: наименование поставщика (str, опционально, для типа 'supplier')
|
||||
notes: примечания (str, опционально)
|
||||
created_by: пользователь (User, опционально)
|
||||
|
||||
Returns:
|
||||
IncomingDocument
|
||||
"""
|
||||
document = IncomingDocument.objects.create(
|
||||
document_number=generate_incoming_document_number(),
|
||||
warehouse=warehouse,
|
||||
status='draft',
|
||||
date=date,
|
||||
receipt_type=receipt_type,
|
||||
supplier_name=supplier_name if receipt_type == 'supplier' else None,
|
||||
notes=notes,
|
||||
created_by=created_by
|
||||
)
|
||||
return document
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def add_item(cls, document, product, quantity, cost_price, notes=None):
|
||||
"""
|
||||
Добавить позицию в документ поступления.
|
||||
|
||||
Args:
|
||||
document: IncomingDocument
|
||||
product: Product
|
||||
quantity: Decimal - количество товара
|
||||
cost_price: Decimal - закупочная цена
|
||||
notes: str - примечания
|
||||
|
||||
Returns:
|
||||
IncomingDocumentItem
|
||||
|
||||
Raises:
|
||||
ValidationError: если документ не черновик
|
||||
"""
|
||||
if document.status != 'draft':
|
||||
raise ValidationError(
|
||||
"Нельзя добавлять позиции в проведённый или отменённый документ"
|
||||
)
|
||||
|
||||
quantity = Decimal(str(quantity))
|
||||
if quantity <= 0:
|
||||
raise ValidationError("Количество должно быть больше нуля")
|
||||
|
||||
cost_price = Decimal(str(cost_price))
|
||||
if cost_price < 0:
|
||||
raise ValidationError("Закупочная цена не может быть отрицательной")
|
||||
|
||||
# Создаем позицию документа
|
||||
item = IncomingDocumentItem.objects.create(
|
||||
document=document,
|
||||
product=product,
|
||||
quantity=quantity,
|
||||
cost_price=cost_price,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def update_item(cls, item, quantity=None, cost_price=None, notes=None):
|
||||
"""
|
||||
Обновить позицию документа.
|
||||
|
||||
Args:
|
||||
item: IncomingDocumentItem
|
||||
quantity: новое количество (опционально)
|
||||
cost_price: новая закупочная цена (опционально)
|
||||
notes: новые примечания (опционально)
|
||||
|
||||
Returns:
|
||||
IncomingDocumentItem
|
||||
|
||||
Raises:
|
||||
ValidationError: если документ не черновик
|
||||
"""
|
||||
if item.document.status != 'draft':
|
||||
raise ValidationError(
|
||||
"Нельзя редактировать позиции проведённого или отменённого документа"
|
||||
)
|
||||
|
||||
if quantity is not None:
|
||||
quantity = Decimal(str(quantity))
|
||||
if quantity <= 0:
|
||||
raise ValidationError("Количество должно быть больше нуля")
|
||||
item.quantity = quantity
|
||||
|
||||
if cost_price is not None:
|
||||
cost_price = Decimal(str(cost_price))
|
||||
if cost_price < 0:
|
||||
raise ValidationError("Закупочная цена не может быть отрицательной")
|
||||
item.cost_price = cost_price
|
||||
|
||||
if notes is not None:
|
||||
item.notes = notes
|
||||
|
||||
item.save()
|
||||
return item
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def remove_item(cls, item):
|
||||
"""
|
||||
Удалить позицию из документа.
|
||||
|
||||
Args:
|
||||
item: IncomingDocumentItem
|
||||
|
||||
Raises:
|
||||
ValidationError: если документ не черновик
|
||||
"""
|
||||
if item.document.status != 'draft':
|
||||
raise ValidationError(
|
||||
"Нельзя удалять позиции из проведённого или отменённого документа"
|
||||
)
|
||||
|
||||
# Удаляем позицию
|
||||
item.delete()
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def confirm_document(cls, document, confirmed_by=None):
|
||||
"""
|
||||
Провести документ поступления.
|
||||
|
||||
Процесс:
|
||||
1. Проверяем что документ - черновик и имеет позиции
|
||||
2. Создаем IncomingBatch с номером документа
|
||||
3. Для каждой позиции создаем Incoming запись
|
||||
4. Сигнал create_stock_batch_on_incoming автоматически создаст StockBatch
|
||||
5. Меняем статус документа на 'confirmed'
|
||||
|
||||
Args:
|
||||
document: IncomingDocument
|
||||
confirmed_by: User - кто проводит документ
|
||||
|
||||
Returns:
|
||||
dict: результат проведения
|
||||
|
||||
Raises:
|
||||
ValidationError: если документ нельзя провести
|
||||
"""
|
||||
if document.status != 'draft':
|
||||
raise ValidationError(
|
||||
f"Документ уже проведён или отменён (статус: {document.get_status_display()})"
|
||||
)
|
||||
|
||||
if not document.items.exists():
|
||||
raise ValidationError("Нельзя провести пустой документ")
|
||||
|
||||
# Создаем IncomingBatch
|
||||
incoming_batch = IncomingBatch.objects.create(
|
||||
warehouse=document.warehouse,
|
||||
document_number=document.document_number,
|
||||
receipt_type=document.receipt_type,
|
||||
supplier_name=document.supplier_name if document.receipt_type == 'supplier' else '',
|
||||
notes=document.notes
|
||||
)
|
||||
|
||||
# Создаем Incoming записи для каждого товара
|
||||
incomings_created = []
|
||||
total_cost = Decimal('0')
|
||||
|
||||
for item in document.items.select_related('product'):
|
||||
incoming = Incoming.objects.create(
|
||||
batch=incoming_batch,
|
||||
product=item.product,
|
||||
quantity=item.quantity,
|
||||
cost_price=item.cost_price,
|
||||
notes=item.notes
|
||||
)
|
||||
incomings_created.append(incoming)
|
||||
total_cost += item.total_cost
|
||||
|
||||
# Обновляем статус документа
|
||||
document.status = 'confirmed'
|
||||
document.confirmed_by = confirmed_by
|
||||
document.confirmed_at = timezone.now()
|
||||
document.save(update_fields=['status', 'confirmed_by', 'confirmed_at', 'updated_at'])
|
||||
|
||||
return {
|
||||
'document': document,
|
||||
'incoming_batch': incoming_batch,
|
||||
'incomings_created': len(incomings_created),
|
||||
'total_quantity': document.total_quantity,
|
||||
'total_cost': total_cost
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def cancel_document(cls, document):
|
||||
"""
|
||||
Отменить документ поступления (черновик).
|
||||
|
||||
Args:
|
||||
document: IncomingDocument
|
||||
|
||||
Returns:
|
||||
IncomingDocument
|
||||
|
||||
Raises:
|
||||
ValidationError: если документ уже проведён
|
||||
"""
|
||||
if document.status == 'confirmed':
|
||||
raise ValidationError(
|
||||
"Нельзя отменить проведённый документ. "
|
||||
"Создайте новый документ для корректировки."
|
||||
)
|
||||
|
||||
if document.status == 'cancelled':
|
||||
raise ValidationError("Документ уже отменён")
|
||||
|
||||
# Обновляем статус документа
|
||||
document.status = 'cancelled'
|
||||
document.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
return document
|
||||
|
||||
@staticmethod
|
||||
def get_draft_documents(warehouse=None):
|
||||
"""
|
||||
Получить все черновики документов поступления.
|
||||
|
||||
Args:
|
||||
warehouse: фильтр по складу (опционально)
|
||||
|
||||
Returns:
|
||||
QuerySet[IncomingDocument]
|
||||
"""
|
||||
qs = IncomingDocument.objects.filter(status='draft')
|
||||
if warehouse:
|
||||
qs = qs.filter(warehouse=warehouse)
|
||||
return qs.select_related('warehouse', 'created_by').prefetch_related('items')
|
||||
|
||||
@staticmethod
|
||||
def get_today_drafts(warehouse):
|
||||
"""
|
||||
Получить черновики за сегодня для склада.
|
||||
|
||||
Args:
|
||||
warehouse: Warehouse
|
||||
|
||||
Returns:
|
||||
QuerySet[IncomingDocument]
|
||||
"""
|
||||
today = timezone.now().date()
|
||||
|
||||
return IncomingDocument.objects.filter(
|
||||
warehouse=warehouse,
|
||||
status='draft',
|
||||
date=today
|
||||
).select_related('warehouse', 'created_by')
|
||||
|
||||
@@ -125,6 +125,24 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Документы поступления -->
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<a href="{% url 'inventory:incoming-document-list' %}" class="card shadow-sm h-100 text-decoration-none">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle bg-success bg-opacity-10 p-3 me-3">
|
||||
<i class="bi bi-file-earmark-plus text-success" style="font-size: 1.5rem;"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-0 text-dark">Документы поступления</h6>
|
||||
<small class="text-muted">Коллективное поступление</small>
|
||||
</div>
|
||||
<i class="bi bi-chevron-right text-muted"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Перемещения -->
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<a href="{% url 'inventory:transfer-list' %}" class="card shadow-sm h-100 text-decoration-none">
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
{% load inventory_filters %}
|
||||
|
||||
{% block title %}Документ поступления {{ document.document_number }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- CSS для компонента поиска -->
|
||||
<link rel="stylesheet" href="{% static 'products/css/product-search-picker.css' %}">
|
||||
|
||||
<div class="container-fluid px-4 py-3">
|
||||
<!-- Breadcrumbs -->
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="breadcrumb breadcrumb-sm mb-0">
|
||||
<li class="breadcrumb-item"><a href="{% url 'inventory:incoming-document-list' %}">Документы поступления</a></li>
|
||||
<li class="breadcrumb-item active">{{ document.document_number }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Messages -->
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Основной контент - одна колонка -->
|
||||
<div class="col-12">
|
||||
<!-- Информация о документе -->
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-light py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-file-earmark-plus me-2"></i>{{ document.document_number }}
|
||||
{% if document.status == 'draft' %}
|
||||
<span class="badge bg-warning text-dark ms-2">Черновик</span>
|
||||
{% elif document.status == 'confirmed' %}
|
||||
<span class="badge bg-success ms-2">Проведён</span>
|
||||
{% elif document.status == 'cancelled' %}
|
||||
<span class="badge bg-secondary ms-2">Отменён</span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
{% if document.can_edit %}
|
||||
<div class="btn-group">
|
||||
<form method="post" action="{% url 'inventory:incoming-document-confirm' document.pk %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-success btn-sm" {% if not document.can_confirm %}disabled{% endif %}>
|
||||
<i class="bi bi-check-lg me-1"></i>Провести
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'inventory:incoming-document-cancel' document.pk %}" class="d-inline ms-2">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Отменить документ?')">
|
||||
<i class="bi bi-x-lg me-1"></i>Отменить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Склад</p>
|
||||
<p class="fw-semibold">{{ document.warehouse.name }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Дата документа</p>
|
||||
<p class="fw-semibold">{{ document.date|date:"d.m.Y" }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Тип поступления</p>
|
||||
<p class="fw-semibold">{{ document.get_receipt_type_display }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Создан</p>
|
||||
<p class="fw-semibold">{{ document.created_at|date:"d.m.Y H:i" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if document.supplier_name %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted small mb-1">Поставщик</p>
|
||||
<p class="fw-semibold">{{ document.supplier_name }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if document.notes %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted small mb-1">Примечания</p>
|
||||
<p>{{ document.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if document.confirmed_at %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p class="text-muted small mb-1">Проведён</p>
|
||||
<p class="fw-semibold">{{ document.confirmed_at|date:"d.m.Y H:i" }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p class="text-muted small mb-1">Провёл</p>
|
||||
<p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.username }}{% else %}-{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Добавление позиции -->
|
||||
{% if document.can_edit %}
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-light py-3">
|
||||
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить позицию в документ</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Компонент поиска товаров -->
|
||||
<div class="mb-3">
|
||||
{% include 'products/components/product_search_picker.html' with container_id='incoming-document-picker' title='Найти товар для поступления' warehouse_id=document.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %}
|
||||
</div>
|
||||
|
||||
<!-- Информация о выбранном товаре -->
|
||||
<div id="selected-product-info" class="alert alert-info mb-3" style="display: none;">
|
||||
<div class="d-flex align-items-center">
|
||||
<img id="selected-product-photo" src="" alt="" class="rounded me-2" style="width: 50px; height: 50px; object-fit: cover; display: none;">
|
||||
<div class="flex-grow-1">
|
||||
<strong id="selected-product-name" class="d-block"></strong>
|
||||
<small class="text-muted" id="selected-product-sku"></small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-selected-product" title="Очистить">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма добавления позиции -->
|
||||
<form method="post" action="{% url 'inventory:incoming-document-add-item' document.pk %}" id="add-item-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="id_product" class="form-label">Товар <span class="text-danger">*</span></label>
|
||||
{{ item_form.product }}
|
||||
{% if item_form.product.errors %}
|
||||
<div class="text-danger small">{{ item_form.product.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="id_quantity" class="form-label">Количество <span class="text-danger">*</span></label>
|
||||
{{ item_form.quantity }}
|
||||
{% if item_form.quantity.errors %}
|
||||
<div class="text-danger small">{{ item_form.quantity.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="id_cost_price" class="form-label">Закупочная цена <span class="text-danger">*</span></label>
|
||||
{{ item_form.cost_price }}
|
||||
{% if item_form.cost_price.errors %}
|
||||
<div class="text-danger small">{{ item_form.cost_price.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<label for="id_notes" class="form-label">Примечания</label>
|
||||
{{ item_form.notes }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle me-1"></i>Добавить в документ
|
||||
</button>
|
||||
<small class="text-muted ms-3">
|
||||
<i class="bi bi-info-circle"></i> Используйте поиск выше для быстрого выбора товара
|
||||
</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Таблица позиций -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light py-3">
|
||||
<h6 class="mb-0"><i class="bi bi-table me-2"></i>Позиции документа</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col" class="px-3 py-2">Товар</th>
|
||||
<th scope="col" class="px-3 py-2 text-end" style="width: 120px;">Количество</th>
|
||||
<th scope="col" class="px-3 py-2 text-end" style="width: 120px;">Закупочная цена</th>
|
||||
<th scope="col" class="px-3 py-2 text-end" style="width: 120px;">Сумма</th>
|
||||
<th scope="col" class="px-3 py-2">Примечания</th>
|
||||
{% if document.can_edit %}
|
||||
<th scope="col" class="px-3 py-2" style="width: 100px;"></th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in document.items.all %}
|
||||
<tr id="item-row-{{ item.id }}" data-item-id="{{ item.id }}">
|
||||
<td class="px-3 py-2">
|
||||
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-end" style="width: 120px;">
|
||||
<span class="item-quantity-display">{{ item.quantity|smart_quantity }}</span>
|
||||
{% if document.can_edit %}
|
||||
<input type="number" class="form-control form-control-sm item-quantity-input"
|
||||
value="{{ item.quantity|stringformat:'g' }}" step="0.001" min="0.001"
|
||||
style="display: none; width: 100px; text-align: right; margin-left: auto;">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-end" style="width: 120px;">
|
||||
<span class="item-cost-price-display">{{ item.cost_price|floatformat:2 }}</span>
|
||||
{% if document.can_edit %}
|
||||
<input type="number" class="form-control form-control-sm item-cost-price-input"
|
||||
value="{{ item.cost_price|stringformat:'g' }}" step="0.01" min="0"
|
||||
style="display: none; width: 100px; text-align: right; margin-left: auto;">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-end" style="width: 120px;">
|
||||
<strong>{{ item.total_cost|floatformat:2 }}</strong>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="item-notes-display">{% if item.notes %}{{ item.notes|truncatechars:50 }}{% else %}-{% endif %}</span>
|
||||
{% if document.can_edit %}
|
||||
<input type="text" class="form-control form-control-sm item-notes-input"
|
||||
value="{{ item.notes }}" placeholder="Примечания"
|
||||
style="display: none;">
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if document.can_edit %}
|
||||
<td class="px-3 py-2 text-end" style="width: 100px;">
|
||||
<div class="btn-group btn-group-sm item-action-buttons">
|
||||
<button type="button" class="btn btn-outline-primary btn-edit-item" title="Редактировать">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
onclick="if(confirm('Удалить позицию?')) document.getElementById('delete-form-{{ item.id }}').submit();"
|
||||
title="Удалить">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm item-edit-buttons" style="display: none;">
|
||||
<button type="button" class="btn btn-success btn-save-item" title="Сохранить">
|
||||
<i class="bi bi-check-lg"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-cancel-edit" title="Отменить">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form id="delete-form-{{ item.id }}" method="post"
|
||||
action="{% url 'inventory:incoming-document-remove-item' document.pk item.pk %}"
|
||||
style="display: none;">
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="{% if document.can_edit %}6{% else %}5{% endif %}" class="px-3 py-4 text-center text-muted">
|
||||
<i class="bi bi-inbox fs-3 d-block mb-2"></i>
|
||||
Позиций пока нет
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% if document.items.exists %}
|
||||
<tfoot class="table-light">
|
||||
<tr>
|
||||
<td class="px-3 py-2 fw-semibold">Итого:</td>
|
||||
<td class="px-3 py-2 fw-semibold text-end">{{ document.total_quantity|smart_quantity }}</td>
|
||||
<td colspan="2" class="px-3 py-2 fw-semibold text-end">{{ document.total_cost|floatformat:2 }}</td>
|
||||
<td colspan="{% if document.can_edit %}2{% else %}1{% endif %}"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JS для компонента поиска -->
|
||||
<script src="{% static 'products/js/product-search-picker.js' %}?v=3"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Элементы формы
|
||||
const productSelect = document.querySelector('#id_product');
|
||||
const quantityInput = document.querySelector('#id_quantity');
|
||||
const costPriceInput = document.querySelector('#id_cost_price');
|
||||
|
||||
// Элементы отображения выбранного товара
|
||||
const selectedProductInfo = document.getElementById('selected-product-info');
|
||||
const selectedProductName = document.getElementById('selected-product-name');
|
||||
const selectedProductSku = document.getElementById('selected-product-sku');
|
||||
const selectedProductPhoto = document.getElementById('selected-product-photo');
|
||||
const clearSelectedBtn = document.getElementById('clear-selected-product');
|
||||
|
||||
// Инициализация компонента поиска товаров
|
||||
const picker = ProductSearchPicker.init('#incoming-document-picker', {
|
||||
onAddSelected: function(product, instance) {
|
||||
if (product) {
|
||||
selectProduct(product);
|
||||
instance.clearSelection();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Функция выбора товара
|
||||
function selectProduct(product) {
|
||||
const productId = String(product.id).replace('product_', '');
|
||||
const productName = product.text || product.name || '';
|
||||
|
||||
// Показываем информацию о выбранном товаре
|
||||
selectedProductName.textContent = productName;
|
||||
selectedProductSku.textContent = product.sku || '';
|
||||
|
||||
if (product.photo_url) {
|
||||
selectedProductPhoto.src = product.photo_url;
|
||||
selectedProductPhoto.style.display = 'block';
|
||||
} else {
|
||||
selectedProductPhoto.style.display = 'none';
|
||||
}
|
||||
|
||||
selectedProductInfo.style.display = 'block';
|
||||
|
||||
// Устанавливаем значение в select формы
|
||||
if (productSelect) {
|
||||
productSelect.value = productId;
|
||||
// Триггерим change для Select2, если он используется
|
||||
const event = new Event('change', { bubbles: true });
|
||||
productSelect.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// Фокус в поле количества
|
||||
if (quantityInput) {
|
||||
quantityInput.focus();
|
||||
quantityInput.select();
|
||||
}
|
||||
}
|
||||
|
||||
// Функция очистки выбора товара
|
||||
function clearSelectedProduct() {
|
||||
selectedProductInfo.style.display = 'none';
|
||||
selectedProductName.textContent = '';
|
||||
selectedProductSku.textContent = '';
|
||||
selectedProductPhoto.style.display = 'none';
|
||||
|
||||
if (productSelect) {
|
||||
productSelect.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Очистка выбора товара
|
||||
if (clearSelectedBtn) {
|
||||
clearSelectedBtn.addEventListener('click', clearSelectedProduct);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Inline редактирование позиций в таблице
|
||||
// ============================================
|
||||
|
||||
// Хранилище оригинальных значений при редактировании
|
||||
const originalValues = {};
|
||||
|
||||
// Обработчики для кнопок редактирования
|
||||
document.querySelectorAll('.btn-edit-item').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('tr');
|
||||
const itemId = row.dataset.itemId;
|
||||
|
||||
// Сохраняем оригинальные значения
|
||||
originalValues[itemId] = {
|
||||
quantity: row.querySelector('.item-quantity-input').value,
|
||||
cost_price: row.querySelector('.item-cost-price-input').value,
|
||||
notes: row.querySelector('.item-notes-input').value
|
||||
};
|
||||
|
||||
// Переключаем в режим редактирования
|
||||
toggleEditMode(row, true);
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчики для кнопок сохранения
|
||||
document.querySelectorAll('.btn-save-item').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('tr');
|
||||
const itemId = row.dataset.itemId;
|
||||
saveItemChanges(itemId, row);
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчики для кнопок отмены
|
||||
document.querySelectorAll('.btn-cancel-edit').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('tr');
|
||||
const itemId = row.dataset.itemId;
|
||||
|
||||
// Восстанавливаем оригинальные значения
|
||||
if (originalValues[itemId]) {
|
||||
row.querySelector('.item-quantity-input').value = originalValues[itemId].quantity;
|
||||
row.querySelector('.item-cost-price-input').value = originalValues[itemId].cost_price;
|
||||
row.querySelector('.item-notes-input').value = originalValues[itemId].notes;
|
||||
}
|
||||
|
||||
// Выходим из режима редактирования
|
||||
toggleEditMode(row, false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Переключение режима редактирования строки
|
||||
*/
|
||||
function toggleEditMode(row, isEditing) {
|
||||
// Переключаем видимость полей отображения/ввода
|
||||
row.querySelectorAll('.item-quantity-display, .item-cost-price-display, .item-notes-display').forEach(el => {
|
||||
el.style.display = isEditing ? 'none' : '';
|
||||
});
|
||||
row.querySelectorAll('.item-quantity-input, .item-cost-price-input, .item-notes-input').forEach(el => {
|
||||
el.style.display = isEditing ? '' : 'none';
|
||||
});
|
||||
|
||||
// Переключаем видимость кнопок
|
||||
row.querySelector('.item-action-buttons').style.display = isEditing ? 'none' : '';
|
||||
row.querySelector('.item-edit-buttons').style.display = isEditing ? '' : 'none';
|
||||
|
||||
// Фокус на поле количества при входе в режим редактирования
|
||||
if (isEditing) {
|
||||
const qtyInput = row.querySelector('.item-quantity-input');
|
||||
if (qtyInput) {
|
||||
qtyInput.focus();
|
||||
qtyInput.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение изменений позиции
|
||||
*/
|
||||
function saveItemChanges(itemId, row) {
|
||||
const quantity = row.querySelector('.item-quantity-input').value;
|
||||
const costPrice = row.querySelector('.item-cost-price-input').value;
|
||||
const notes = row.querySelector('.item-notes-input').value;
|
||||
|
||||
// Валидация
|
||||
if (!quantity || parseFloat(quantity) <= 0) {
|
||||
alert('Количество должно быть больше нуля');
|
||||
return;
|
||||
}
|
||||
if (!costPrice || parseFloat(costPrice) < 0) {
|
||||
alert('Закупочная цена не может быть отрицательной');
|
||||
return;
|
||||
}
|
||||
|
||||
// Отправляем на сервер
|
||||
const formData = new FormData();
|
||||
formData.append('quantity', quantity);
|
||||
formData.append('cost_price', costPrice);
|
||||
formData.append('notes', notes);
|
||||
formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);
|
||||
|
||||
// Блокируем кнопки во время сохранения
|
||||
const saveBtn = row.querySelector('.btn-save-item');
|
||||
const cancelBtn = row.querySelector('.btn-cancel-edit');
|
||||
saveBtn.disabled = true;
|
||||
cancelBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
||||
|
||||
fetch(`/inventory/incoming-documents/{{ document.pk }}/update-item/${itemId}/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Обновляем отображение
|
||||
let formattedQty = parseFloat(quantity);
|
||||
if (formattedQty === Math.floor(formattedQty)) {
|
||||
formattedQty = Math.floor(formattedQty).toString();
|
||||
} else {
|
||||
formattedQty = formattedQty.toString().replace('.', ',');
|
||||
}
|
||||
row.querySelector('.item-quantity-display').textContent = formattedQty;
|
||||
row.querySelector('.item-cost-price-display').textContent = parseFloat(costPrice).toFixed(2);
|
||||
row.querySelector('.item-notes-display').textContent = notes || '-';
|
||||
|
||||
// Пересчитываем сумму
|
||||
const totalCost = (parseFloat(quantity) * parseFloat(costPrice)).toFixed(2);
|
||||
row.querySelector('td:nth-child(4) strong').textContent = totalCost;
|
||||
|
||||
// Выходим из режима редактирования
|
||||
toggleEditMode(row, false);
|
||||
} else {
|
||||
alert('Ошибка: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Произошла ошибка при сохранении');
|
||||
})
|
||||
.finally(() => {
|
||||
saveBtn.disabled = false;
|
||||
cancelBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="bi bi-check-lg"></i>';
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Создать документ поступления{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4 py-3">
|
||||
<!-- Breadcrumbs -->
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="breadcrumb breadcrumb-sm mb-0">
|
||||
<li class="breadcrumb-item"><a href="{% url 'inventory:incoming-document-list' %}">Документы поступления</a></li>
|
||||
<li class="breadcrumb-item active">Создать</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light py-3">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-file-earmark-plus me-2"></i>Новый документ поступления
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_warehouse" class="form-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 class="mb-3">
|
||||
<label for="id_date" class="form-label">Дата документа <span class="text-danger">*</span></label>
|
||||
{{ form.date }}
|
||||
{% if form.date.errors %}
|
||||
<div class="text-danger small">{{ form.date.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_receipt_type" class="form-label">Тип поступления <span class="text-danger">*</span></label>
|
||||
{{ form.receipt_type }}
|
||||
{% if form.receipt_type.errors %}
|
||||
<div class="text-danger small">{{ form.receipt_type.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="supplier-name-group">
|
||||
<label for="id_supplier_name" class="form-label">Наименование поставщика</label>
|
||||
{{ form.supplier_name }}
|
||||
{% if form.supplier_name.errors %}
|
||||
<div class="text-danger small">{{ form.supplier_name.errors.0 }}</div>
|
||||
{% endif %}
|
||||
<small class="form-text text-muted">Заполняется для типа "Поступление от поставщика"</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_notes" class="form-label">Примечания</label>
|
||||
{{ form.notes }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-1"></i>Создать
|
||||
</button>
|
||||
<a href="{% url 'inventory:incoming-document-list' %}" class="btn btn-outline-secondary">
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const receiptTypeSelect = document.getElementById('id_receipt_type');
|
||||
const supplierNameGroup = document.getElementById('supplier-name-group');
|
||||
const supplierNameInput = document.getElementById('id_supplier_name');
|
||||
|
||||
function toggleSupplierName() {
|
||||
if (receiptTypeSelect.value === 'supplier') {
|
||||
supplierNameGroup.style.display = 'block';
|
||||
supplierNameInput.required = true;
|
||||
} else {
|
||||
supplierNameGroup.style.display = 'none';
|
||||
supplierNameInput.required = false;
|
||||
supplierNameInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
receiptTypeSelect.addEventListener('change', toggleSupplierName);
|
||||
toggleSupplierName(); // Инициализация при загрузке
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Документы поступления{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4 py-3">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-file-earmark-plus me-2"></i>Документы поступления
|
||||
</h4>
|
||||
<a href="{% url 'inventory:incoming-document-create' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>Создать документ
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col" class="px-3 py-2">Номер</th>
|
||||
<th scope="col" class="px-3 py-2">Дата</th>
|
||||
<th scope="col" class="px-3 py-2">Склад</th>
|
||||
<th scope="col" class="px-3 py-2">Тип</th>
|
||||
<th scope="col" class="px-3 py-2">Статус</th>
|
||||
<th scope="col" class="px-3 py-2 text-end">Позиций</th>
|
||||
<th scope="col" class="px-3 py-2 text-end">Количество</th>
|
||||
<th scope="col" class="px-3 py-2">Создал</th>
|
||||
<th scope="col" class="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for doc in documents %}
|
||||
<tr>
|
||||
<td class="px-3 py-2">
|
||||
<a href="{% url 'inventory:incoming-document-detail' doc.pk %}" class="fw-semibold text-decoration-none">
|
||||
{{ doc.document_number }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-3 py-2">{{ doc.date|date:"d.m.Y" }}</td>
|
||||
<td class="px-3 py-2">{{ doc.warehouse.name }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="badge bg-info">{{ doc.get_receipt_type_display }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
{% if doc.status == 'draft' %}
|
||||
<span class="badge bg-warning text-dark">Черновик</span>
|
||||
{% elif doc.status == 'confirmed' %}
|
||||
<span class="badge bg-success">Проведён</span>
|
||||
{% elif doc.status == 'cancelled' %}
|
||||
<span class="badge bg-secondary">Отменён</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-end">{{ doc.items.count }}</td>
|
||||
<td class="px-3 py-2 text-end">{{ doc.total_quantity }}</td>
|
||||
<td class="px-3 py-2">
|
||||
{% if doc.created_by %}{{ doc.created_by.username }}{% else %}-{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-end">
|
||||
<a href="{% url 'inventory:incoming-document-detail' doc.pk %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="9" class="px-3 py-4 text-center text-muted">
|
||||
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
|
||||
Документов поступления пока нет
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Назад</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">{{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
|
||||
</li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Вперёд</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -34,6 +34,12 @@ from .views.writeoff_document import (
|
||||
WriteOffDocumentAddItemView, WriteOffDocumentUpdateItemView, WriteOffDocumentRemoveItemView,
|
||||
WriteOffDocumentConfirmView, WriteOffDocumentCancelView
|
||||
)
|
||||
# Incoming Document views
|
||||
from .views.incoming_document import (
|
||||
IncomingDocumentListView, IncomingDocumentCreateView, IncomingDocumentDetailView,
|
||||
IncomingDocumentAddItemView, IncomingDocumentUpdateItemView, IncomingDocumentRemoveItemView,
|
||||
IncomingDocumentConfirmView, IncomingDocumentCancelView
|
||||
)
|
||||
# Debug views
|
||||
from .views.debug_views import debug_inventory_page
|
||||
from . import views
|
||||
@@ -91,6 +97,16 @@ urlpatterns = [
|
||||
path('writeoff-documents/<int:pk>/confirm/', WriteOffDocumentConfirmView.as_view(), name='writeoff-document-confirm'),
|
||||
path('writeoff-documents/<int:pk>/cancel/', WriteOffDocumentCancelView.as_view(), name='writeoff-document-cancel'),
|
||||
|
||||
# ==================== INCOMING DOCUMENT (документы поступления) ====================
|
||||
path('incoming-documents/', IncomingDocumentListView.as_view(), name='incoming-document-list'),
|
||||
path('incoming-documents/create/', IncomingDocumentCreateView.as_view(), name='incoming-document-create'),
|
||||
path('incoming-documents/<int:pk>/', IncomingDocumentDetailView.as_view(), name='incoming-document-detail'),
|
||||
path('incoming-documents/<int:pk>/add-item/', IncomingDocumentAddItemView.as_view(), name='incoming-document-add-item'),
|
||||
path('incoming-documents/<int:pk>/update-item/<int:item_pk>/', IncomingDocumentUpdateItemView.as_view(), name='incoming-document-update-item'),
|
||||
path('incoming-documents/<int:pk>/remove-item/<int:item_pk>/', IncomingDocumentRemoveItemView.as_view(), name='incoming-document-remove-item'),
|
||||
path('incoming-documents/<int:pk>/confirm/', IncomingDocumentConfirmView.as_view(), name='incoming-document-confirm'),
|
||||
path('incoming-documents/<int:pk>/cancel/', IncomingDocumentCancelView.as_view(), name='incoming-document-cancel'),
|
||||
|
||||
# ==================== TRANSFER ====================
|
||||
path('transfers/', TransferListView.as_view(), name='transfer-list'),
|
||||
path('transfers/create/', TransferBulkCreateView.as_view(), name='transfer-create'), # Новая форма массового перемещения
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# -*- 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
|
||||
@@ -32,73 +32,117 @@ def generate_transfer_document_number():
|
||||
return f"MOVE-{next_number:06d}"
|
||||
|
||||
|
||||
def _extract_number_from_document_number(doc_number):
|
||||
"""
|
||||
Извлекает числовое значение из номера документа.
|
||||
Поддерживает оба формата: IN-0000-0003 и IN-000002.
|
||||
|
||||
Для старого формата IN-XXXX-YYYY извлекается YYYY (последние 4 цифры).
|
||||
Для нового формата IN-XXXXXX извлекается XXXXXX (6 цифр).
|
||||
|
||||
Args:
|
||||
doc_number: строка номера документа (например, 'IN-0000-0003' или 'IN-000002')
|
||||
|
||||
Returns:
|
||||
int: числовое значение или 0 если не удалось распарсить
|
||||
"""
|
||||
try:
|
||||
# Убираем префикс 'IN-'
|
||||
if not doc_number.startswith('IN-'):
|
||||
return 0
|
||||
|
||||
parts = doc_number[3:].split('-') # ['0000', '0003'] или ['000002']
|
||||
|
||||
if len(parts) == 2:
|
||||
# Старый формат: IN-0000-0003
|
||||
# Берем последнюю часть (0003) и конвертируем в число
|
||||
return int(parts[1])
|
||||
elif len(parts) == 1:
|
||||
# Новый формат: IN-000002
|
||||
return int(parts[0])
|
||||
else:
|
||||
return 0
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
|
||||
def _initialize_incoming_counter_if_needed():
|
||||
"""
|
||||
Инициализирует DocumentCounter для 'incoming' максимальным номером
|
||||
из существующих документов, если счетчик еще не инициализирован.
|
||||
|
||||
Вызывается только если счетчик равен 0 (не инициализирован).
|
||||
Thread-safe через select_for_update.
|
||||
"""
|
||||
from inventory.models import IncomingBatch, IncomingDocument
|
||||
from django.db import transaction
|
||||
|
||||
# Быстрая проверка без блокировки - если счетчик существует и > 0, выходим
|
||||
if DocumentCounter.objects.filter(
|
||||
counter_type='incoming',
|
||||
current_value__gt=0
|
||||
).exists():
|
||||
return
|
||||
|
||||
# Только если счетчик не инициализирован - делаем полную проверку с блокировкой
|
||||
with transaction.atomic():
|
||||
counter = DocumentCounter.objects.select_for_update().filter(
|
||||
counter_type='incoming'
|
||||
).first()
|
||||
|
||||
# Двойная проверка: возможно другой поток уже инициализировал
|
||||
if counter and counter.current_value > 0:
|
||||
return
|
||||
|
||||
# Собираем все номера документов
|
||||
all_numbers = []
|
||||
|
||||
# Номера из IncomingBatch
|
||||
batch_numbers = IncomingBatch.objects.filter(
|
||||
document_number__startswith='IN-'
|
||||
).values_list('document_number', flat=True)
|
||||
all_numbers.extend(batch_numbers)
|
||||
|
||||
# Номера из IncomingDocument
|
||||
doc_numbers = IncomingDocument.objects.filter(
|
||||
document_number__startswith='IN-'
|
||||
).values_list('document_number', flat=True)
|
||||
all_numbers.extend(doc_numbers)
|
||||
|
||||
if all_numbers:
|
||||
# Извлекаем максимальный номер из всех форматов
|
||||
max_number = max(_extract_number_from_document_number(num) for num in all_numbers)
|
||||
else:
|
||||
# Нет существующих документов - начинаем с 0
|
||||
max_number = 0
|
||||
|
||||
# Создаем или обновляем счетчик
|
||||
if not counter:
|
||||
DocumentCounter.objects.create(
|
||||
counter_type='incoming',
|
||||
current_value=max_number
|
||||
)
|
||||
elif counter.current_value == 0:
|
||||
counter.current_value = max_number
|
||||
counter.save(update_fields=['current_value'])
|
||||
|
||||
|
||||
def generate_incoming_document_number():
|
||||
"""
|
||||
Генерирует номер документа поступления вида 'IN-XXXX-XXXX'.
|
||||
Генерирует уникальный номер документа поступления.
|
||||
|
||||
Алгоритм:
|
||||
1. Ищет максимальный номер в БД с префиксом 'IN-'
|
||||
2. Извлекает числовое значение из последней части (IN-XXXX-XXXX)
|
||||
3. Увеличивает на 1 и форматирует в 'IN-XXXX-XXXX'
|
||||
Формат: IN-XXXXXX (6 цифр) - унифицирован с WO-000001 и MOVE-000001.
|
||||
Thread-safe через DocumentCounter.
|
||||
|
||||
Преимущества:
|
||||
- Работает без SEQUENCE (не требует миграций)
|
||||
- Гарантирует уникальность через unique constraint в модели
|
||||
- Простая логика, легко отладить
|
||||
- Работает с любым тенантом (django-tenants совместимо)
|
||||
При первом использовании автоматически инициализирует DocumentCounter
|
||||
максимальным номером из существующих документов (IncomingBatch и IncomingDocument).
|
||||
|
||||
Возвращает:
|
||||
str: Номер вида 'IN-0000-0001', 'IN-0000-0002', итд
|
||||
Returns:
|
||||
str: Сгенерированный номер документа (например, IN-000001)
|
||||
"""
|
||||
from inventory.models import IncomingBatch
|
||||
import logging
|
||||
import os
|
||||
# Инициализируем счетчик, если нужно (только если он равен 0)
|
||||
_initialize_incoming_counter_if_needed()
|
||||
|
||||
# Настройка логирования
|
||||
LOG_FILE = os.path.join(os.path.dirname(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)
|
||||
|
||||
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
|
||||
# Используем стандартный метод, как и другие функции
|
||||
next_number = DocumentCounter.get_next_value('incoming')
|
||||
return f"IN-{next_number:06d}"
|
||||
|
||||
@@ -27,6 +27,16 @@ from .inventory_ops import (
|
||||
InventoryLineCreateBulkView
|
||||
)
|
||||
from .writeoff import WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView
|
||||
from .writeoff_document import (
|
||||
WriteOffDocumentListView, WriteOffDocumentCreateView, WriteOffDocumentDetailView,
|
||||
WriteOffDocumentAddItemView, WriteOffDocumentUpdateItemView, WriteOffDocumentRemoveItemView,
|
||||
WriteOffDocumentConfirmView, WriteOffDocumentCancelView
|
||||
)
|
||||
from .incoming_document import (
|
||||
IncomingDocumentListView, IncomingDocumentCreateView, IncomingDocumentDetailView,
|
||||
IncomingDocumentAddItemView, IncomingDocumentUpdateItemView, IncomingDocumentRemoveItemView,
|
||||
IncomingDocumentConfirmView, IncomingDocumentCancelView
|
||||
)
|
||||
from .transfer import TransferListView, TransferBulkCreateView, TransferDetailView, TransferDeleteView, GetProductStockView
|
||||
from .reservation import ReservationListView
|
||||
from .stock import StockListView, StockDetailView
|
||||
@@ -57,6 +67,14 @@ __all__ = [
|
||||
'InventoryListView', 'InventoryCreateView', 'InventoryDetailView', 'InventoryLineCreateBulkView',
|
||||
# WriteOff
|
||||
'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView',
|
||||
# WriteOffDocument
|
||||
'WriteOffDocumentListView', 'WriteOffDocumentCreateView', 'WriteOffDocumentDetailView',
|
||||
'WriteOffDocumentAddItemView', 'WriteOffDocumentUpdateItemView', 'WriteOffDocumentRemoveItemView',
|
||||
'WriteOffDocumentConfirmView', 'WriteOffDocumentCancelView',
|
||||
# IncomingDocument
|
||||
'IncomingDocumentListView', 'IncomingDocumentCreateView', 'IncomingDocumentDetailView',
|
||||
'IncomingDocumentAddItemView', 'IncomingDocumentUpdateItemView', 'IncomingDocumentRemoveItemView',
|
||||
'IncomingDocumentConfirmView', 'IncomingDocumentCancelView',
|
||||
# Transfer
|
||||
'TransferListView', 'TransferBulkCreateView', 'TransferDetailView', 'TransferDeleteView', 'GetProductStockView',
|
||||
# Reservation
|
||||
|
||||
217
myproject/inventory/views/incoming_document.py
Normal file
217
myproject/inventory/views/incoming_document.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Views для работы с документами поступления (IncomingDocument).
|
||||
"""
|
||||
|
||||
from django.views.generic import ListView, CreateView, DetailView, View
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.urls import reverse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.contrib import messages
|
||||
from django.http import JsonResponse
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from inventory.models import IncomingDocument, IncomingDocumentItem
|
||||
from inventory.forms import IncomingDocumentForm, IncomingDocumentItemForm
|
||||
from inventory.services.incoming_document_service import IncomingDocumentService
|
||||
|
||||
|
||||
class IncomingDocumentListView(LoginRequiredMixin, ListView):
|
||||
"""Список документов поступления"""
|
||||
model = IncomingDocument
|
||||
template_name = 'inventory/incoming_document/incoming_document_list.html'
|
||||
context_object_name = 'documents'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
return IncomingDocument.objects.select_related(
|
||||
'warehouse', 'created_by', 'confirmed_by'
|
||||
).prefetch_related('items').order_by('-date', '-created_at')
|
||||
|
||||
|
||||
class IncomingDocumentCreateView(LoginRequiredMixin, CreateView):
|
||||
"""Создание документа поступления"""
|
||||
model = IncomingDocument
|
||||
form_class = IncomingDocumentForm
|
||||
template_name = 'inventory/incoming_document/incoming_document_form.html'
|
||||
|
||||
def form_valid(self, form):
|
||||
document = IncomingDocumentService.create_document(
|
||||
warehouse=form.cleaned_data['warehouse'],
|
||||
date=form.cleaned_data['date'],
|
||||
receipt_type=form.cleaned_data['receipt_type'],
|
||||
supplier_name=form.cleaned_data.get('supplier_name'),
|
||||
notes=form.cleaned_data.get('notes'),
|
||||
created_by=self.request.user
|
||||
)
|
||||
messages.success(self.request, f'Документ {document.document_number} создан')
|
||||
return redirect('inventory:incoming-document-detail', pk=document.pk)
|
||||
|
||||
|
||||
class IncomingDocumentDetailView(LoginRequiredMixin, DetailView):
|
||||
"""Детальный просмотр документа поступления"""
|
||||
model = IncomingDocument
|
||||
template_name = 'inventory/incoming_document/incoming_document_detail.html'
|
||||
context_object_name = 'document'
|
||||
|
||||
def get_queryset(self):
|
||||
return IncomingDocument.objects.select_related(
|
||||
'warehouse', 'created_by', 'confirmed_by'
|
||||
).prefetch_related('items__product')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['item_form'] = IncomingDocumentItemForm(document=self.object)
|
||||
|
||||
# Добавляем категории и теги для компонента поиска товаров
|
||||
from products.models import ProductCategory, ProductTag
|
||||
context['categories'] = ProductCategory.objects.filter(is_active=True).order_by('name')
|
||||
context['tags'] = ProductTag.objects.filter(is_active=True).order_by('name')
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class IncomingDocumentAddItemView(LoginRequiredMixin, View):
|
||||
"""Добавление позиции в документ поступления"""
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, pk):
|
||||
document = get_object_or_404(IncomingDocument, pk=pk)
|
||||
form = IncomingDocumentItemForm(request.POST, document=document)
|
||||
|
||||
if form.is_valid():
|
||||
try:
|
||||
item = IncomingDocumentService.add_item(
|
||||
document=document,
|
||||
product=form.cleaned_data['product'],
|
||||
quantity=form.cleaned_data['quantity'],
|
||||
cost_price=form.cleaned_data['cost_price'],
|
||||
notes=form.cleaned_data.get('notes')
|
||||
)
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'item_id': item.id,
|
||||
'message': f'Добавлено: {item.product.name}'
|
||||
})
|
||||
|
||||
messages.success(request, f'Добавлено: {item.product.name}')
|
||||
|
||||
except ValidationError as e:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
||||
messages.error(request, str(e))
|
||||
else:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
errors = '; '.join([f"{k}: {v[0]}" for k, v in form.errors.items()])
|
||||
return JsonResponse({'success': False, 'error': errors}, status=400)
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
messages.error(request, f'{field}: {error}')
|
||||
|
||||
return redirect('inventory:incoming-document-detail', pk=pk)
|
||||
|
||||
|
||||
class IncomingDocumentUpdateItemView(LoginRequiredMixin, View):
|
||||
"""Обновление позиции документа поступления"""
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, pk, item_pk):
|
||||
document = get_object_or_404(IncomingDocument, pk=pk)
|
||||
item = get_object_or_404(IncomingDocumentItem, pk=item_pk, document=document)
|
||||
|
||||
try:
|
||||
quantity = request.POST.get('quantity')
|
||||
cost_price = request.POST.get('cost_price')
|
||||
notes = request.POST.get('notes')
|
||||
|
||||
IncomingDocumentService.update_item(
|
||||
item,
|
||||
quantity=quantity if quantity else None,
|
||||
cost_price=cost_price if cost_price else None,
|
||||
notes=notes if notes else None
|
||||
)
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': f'Обновлено: {item.product.name}'
|
||||
})
|
||||
|
||||
messages.success(request, f'Обновлено: {item.product.name}')
|
||||
|
||||
except ValidationError as e:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
||||
messages.error(request, str(e))
|
||||
|
||||
return redirect('inventory:incoming-document-detail', pk=pk)
|
||||
|
||||
|
||||
class IncomingDocumentRemoveItemView(LoginRequiredMixin, View):
|
||||
"""Удаление позиции из документа поступления"""
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, pk, item_pk):
|
||||
document = get_object_or_404(IncomingDocument, pk=pk)
|
||||
item = get_object_or_404(IncomingDocumentItem, pk=item_pk, document=document)
|
||||
|
||||
try:
|
||||
product_name = item.product.name
|
||||
IncomingDocumentService.remove_item(item)
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': f'Удалено: {product_name}'
|
||||
})
|
||||
|
||||
messages.success(request, f'Удалено: {product_name}')
|
||||
|
||||
except ValidationError as e:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
||||
messages.error(request, str(e))
|
||||
|
||||
return redirect('inventory:incoming-document-detail', pk=pk)
|
||||
|
||||
|
||||
class IncomingDocumentConfirmView(LoginRequiredMixin, View):
|
||||
"""Проведение документа поступления"""
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, pk):
|
||||
document = get_object_or_404(IncomingDocument, pk=pk)
|
||||
|
||||
try:
|
||||
result = IncomingDocumentService.confirm_document(
|
||||
document,
|
||||
confirmed_by=request.user
|
||||
)
|
||||
messages.success(
|
||||
request,
|
||||
f'Документ {document.document_number} проведён. '
|
||||
f'Оприходовано {result["total_quantity"]} шт на сумму {result["total_cost"]:.2f}'
|
||||
)
|
||||
except ValidationError as e:
|
||||
messages.error(request, str(e))
|
||||
|
||||
return redirect('inventory:incoming-document-detail', pk=pk)
|
||||
|
||||
|
||||
class IncomingDocumentCancelView(LoginRequiredMixin, View):
|
||||
"""Отмена документа поступления"""
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, pk):
|
||||
document = get_object_or_404(IncomingDocument, pk=pk)
|
||||
|
||||
try:
|
||||
IncomingDocumentService.cancel_document(document)
|
||||
messages.success(request, f'Документ {document.document_number} отменён')
|
||||
except ValidationError as e:
|
||||
messages.error(request, str(e))
|
||||
|
||||
return redirect('inventory:incoming-document-list')
|
||||
|
||||
Reference in New Issue
Block a user