Унификация генерации номеров документов и оптимизация кода

- Унифицирован формат номеров документов: 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:
2025-12-21 00:51:08 +03:00
parent 78dc9e9801
commit 375ec5366a
14 changed files with 1873 additions and 147 deletions

View File

@@ -7,7 +7,8 @@ from decimal import Decimal
from inventory.models import ( from inventory.models import (
Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer, Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer,
Inventory, InventoryLine, Reservation, Stock, StockMovement, 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') list_filter = ('reason', 'document__status', 'created_at')
search_fields = ('product__name', 'document__document_number') search_fields = ('product__name', 'document__document_number')
raw_id_fields = ('product', 'document', 'reservation') 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 = 'Сумма'

View File

@@ -5,7 +5,8 @@ from decimal import Decimal
from .models import ( from .models import (
Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch, 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 from products.models import Product
@@ -557,3 +558,94 @@ class WriteOffDocumentItemForm(forms.ModelForm):
return cleaned_data 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

View File

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

View File

@@ -761,6 +761,7 @@ class DocumentCounter(models.Model):
COUNTER_TYPE_CHOICES = [ COUNTER_TYPE_CHOICES = [
('transfer', 'Перемещение товара'), ('transfer', 'Перемещение товара'),
('writeoff', 'Списание товара'), ('writeoff', 'Списание товара'),
('incoming', 'Поступление товара'),
] ]
counter_type = models.CharField( counter_type = models.CharField(
@@ -1100,3 +1101,205 @@ class WriteOffDocumentItem(models.Model):
def total_cost(self): def total_cost(self):
"""Себестоимость позиции (средневзвешенная из cost_price товара)""" """Себестоимость позиции (средневзвешенная из cost_price товара)"""
return self.quantity * (self.product.cost_price or Decimal('0')) 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

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

View File

@@ -125,6 +125,24 @@
</a> </a>
</div> </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"> <div class="col-md-6 col-lg-4">
<a href="{% url 'inventory:transfer-list' %}" class="card shadow-sm h-100 text-decoration-none"> <a href="{% url 'inventory:transfer-list' %}" class="card shadow-sm h-100 text-decoration-none">

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,12 @@ from .views.writeoff_document import (
WriteOffDocumentAddItemView, WriteOffDocumentUpdateItemView, WriteOffDocumentRemoveItemView, WriteOffDocumentAddItemView, WriteOffDocumentUpdateItemView, WriteOffDocumentRemoveItemView,
WriteOffDocumentConfirmView, WriteOffDocumentCancelView WriteOffDocumentConfirmView, WriteOffDocumentCancelView
) )
# Incoming Document views
from .views.incoming_document import (
IncomingDocumentListView, IncomingDocumentCreateView, IncomingDocumentDetailView,
IncomingDocumentAddItemView, IncomingDocumentUpdateItemView, IncomingDocumentRemoveItemView,
IncomingDocumentConfirmView, IncomingDocumentCancelView
)
# Debug views # Debug views
from .views.debug_views import debug_inventory_page from .views.debug_views import debug_inventory_page
from . import views 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>/confirm/', WriteOffDocumentConfirmView.as_view(), name='writeoff-document-confirm'),
path('writeoff-documents/<int:pk>/cancel/', WriteOffDocumentCancelView.as_view(), name='writeoff-document-cancel'), 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 ==================== # ==================== TRANSFER ====================
path('transfers/', TransferListView.as_view(), name='transfer-list'), path('transfers/', TransferListView.as_view(), name='transfer-list'),
path('transfers/create/', TransferBulkCreateView.as_view(), name='transfer-create'), # Новая форма массового перемещения path('transfers/create/', TransferBulkCreateView.as_view(), name='transfer-create'), # Новая форма массового перемещения

View File

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

View File

@@ -32,73 +32,117 @@ def generate_transfer_document_number():
return f"MOVE-{next_number:06d}" 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(): def generate_incoming_document_number():
""" """
Генерирует номер документа поступления вида 'IN-XXXX-XXXX'. Генерирует уникальный номер документа поступления.
Алгоритм: Формат: IN-XXXXXX (6 цифр) - унифицирован с WO-000001 и MOVE-000001.
1. Ищет максимальный номер в БД с префиксом 'IN-' Thread-safe через DocumentCounter.
2. Извлекает числовое значение из последней части (IN-XXXX-XXXX)
3. Увеличивает на 1 и форматирует в 'IN-XXXX-XXXX'
Преимущества: При первом использовании автоматически инициализирует DocumentCounter
- Работает без SEQUENCE (не требует миграций) максимальным номером из существующих документов (IncomingBatch и IncomingDocument).
- Гарантирует уникальность через unique constraint в модели
- Простая логика, легко отладить
- Работает с любым тенантом (django-tenants совместимо)
Возвращает: Returns:
str: Номер вида 'IN-0000-0001', 'IN-0000-0002', итд str: Сгенерированный номер документа (например, IN-000001)
""" """
from inventory.models import IncomingBatch # Инициализируем счетчик, если нужно (только если он равен 0)
import logging _initialize_incoming_counter_if_needed()
import os
# Настройка логирования # Используем стандартный метод, как и другие функции
LOG_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs', 'incoming_sequence.log') next_number = DocumentCounter.get_next_value('incoming')
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) return f"IN-{next_number:06d}"
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

View File

@@ -27,6 +27,16 @@ from .inventory_ops import (
InventoryLineCreateBulkView InventoryLineCreateBulkView
) )
from .writeoff import WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView 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 .transfer import TransferListView, TransferBulkCreateView, TransferDetailView, TransferDeleteView, GetProductStockView
from .reservation import ReservationListView from .reservation import ReservationListView
from .stock import StockListView, StockDetailView from .stock import StockListView, StockDetailView
@@ -57,6 +67,14 @@ __all__ = [
'InventoryListView', 'InventoryCreateView', 'InventoryDetailView', 'InventoryLineCreateBulkView', 'InventoryListView', 'InventoryCreateView', 'InventoryDetailView', 'InventoryLineCreateBulkView',
# WriteOff # WriteOff
'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView', 'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView',
# WriteOffDocument
'WriteOffDocumentListView', 'WriteOffDocumentCreateView', 'WriteOffDocumentDetailView',
'WriteOffDocumentAddItemView', 'WriteOffDocumentUpdateItemView', 'WriteOffDocumentRemoveItemView',
'WriteOffDocumentConfirmView', 'WriteOffDocumentCancelView',
# IncomingDocument
'IncomingDocumentListView', 'IncomingDocumentCreateView', 'IncomingDocumentDetailView',
'IncomingDocumentAddItemView', 'IncomingDocumentUpdateItemView', 'IncomingDocumentRemoveItemView',
'IncomingDocumentConfirmView', 'IncomingDocumentCancelView',
# Transfer # Transfer
'TransferListView', 'TransferBulkCreateView', 'TransferDetailView', 'TransferDeleteView', 'GetProductStockView', 'TransferListView', 'TransferBulkCreateView', 'TransferDetailView', 'TransferDeleteView', 'GetProductStockView',
# Reservation # Reservation

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