This commit is contained in:
2025-11-04 11:00:05 +03:00
parent 706ee5d8e8
commit b24d5bcdee
13 changed files with 1383 additions and 72 deletions

View File

@@ -3,7 +3,10 @@ from django import forms
from django.core.exceptions import ValidationError
from decimal import Decimal
from .models import Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch
from .models import (
Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch,
TransferBatch, TransferItem
)
from products.models import Product
@@ -82,42 +85,6 @@ class WriteOffForm(forms.ModelForm):
return cleaned_data
class TransferForm(forms.ModelForm):
class Meta:
model = Transfer
fields = ['batch', 'from_warehouse', 'to_warehouse', 'quantity', 'document_number']
widgets = {
'batch': forms.Select(attrs={'class': 'form-control'}),
'from_warehouse': forms.Select(attrs={'class': 'form-control'}),
'to_warehouse': forms.Select(attrs={'class': 'form-control'}),
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
'document_number': forms.TextInput(attrs={'class': 'form-control'}),
}
def clean(self):
cleaned_data = super().clean()
batch = cleaned_data.get('batch')
quantity = cleaned_data.get('quantity')
from_warehouse = cleaned_data.get('from_warehouse')
if batch and quantity:
if quantity > batch.quantity:
raise ValidationError(
f'Невозможно перенести {quantity} шт, доступно {batch.quantity} шт'
)
if quantity <= 0:
raise ValidationError('Количество должно быть больше нуля')
# Проверяем что складской источник совпадает с складом партии
if from_warehouse and batch.warehouse_id != from_warehouse.id:
raise ValidationError(
f'Партия находится на складе "{batch.warehouse.name}", '
f'а вы выбрали "{from_warehouse.name}"'
)
return cleaned_data
class ReservationForm(forms.ModelForm):
class Meta:
model = Reservation
@@ -339,3 +306,103 @@ class IncomingForm(forms.Form):
'Оставьте поле пустым для автогенерации или используйте другой формат.'
)
return document_number
# ============================================================================
# TRANSFER FORMS - Перемещение товаров между складами
# ============================================================================
class TransferHeaderForm(forms.ModelForm):
"""
Форма заголовка документа перемещения товара между складами.
Содержит информацию о складах-источнике и складе-назначении, примечания.
"""
class Meta:
model = TransferBatch
fields = ['from_warehouse', 'to_warehouse', 'notes']
widgets = {
'from_warehouse': forms.Select(attrs={'class': 'form-control'}),
'to_warehouse': forms.Select(attrs={'class': 'form-control'}),
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Примечания к перемещению'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Фильтруем только активные склады
self.fields['from_warehouse'].queryset = Warehouse.objects.filter(is_active=True)
self.fields['to_warehouse'].queryset = Warehouse.objects.filter(is_active=True)
def clean(self):
cleaned_data = super().clean()
from_warehouse = cleaned_data.get('from_warehouse')
to_warehouse = cleaned_data.get('to_warehouse')
if from_warehouse and to_warehouse:
if from_warehouse.id == to_warehouse.id:
raise ValidationError('Склад-источник и склад-назначение должны быть разными')
return cleaned_data
class TransferLineForm(forms.Form):
"""
Форма для одной строки товара при массовом перемещении.
Используется в динамической таблице для ввода нескольких товаров.
"""
product = forms.ModelChoiceField(
queryset=Product.objects.filter(is_active=True).order_by('name'),
widget=forms.Select(attrs={'class': 'form-control'}),
label="Товар",
required=True
)
quantity = forms.DecimalField(
max_digits=10,
decimal_places=3,
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
label="Количество",
required=True
)
def clean_quantity(self):
quantity = self.cleaned_data.get('quantity')
if quantity and quantity <= 0:
raise ValidationError('Количество должно быть больше нуля')
return quantity
class TransferBulkForm(forms.Form):
"""
Комбинированная форма для ввода перемещения товаров.
Содержит header информацию (склад-источник, склад-назначение, примечания) + динамический набор товаров.
"""
from_warehouse = forms.ModelChoiceField(
queryset=Warehouse.objects.filter(is_active=True),
widget=forms.Select(attrs={'class': 'form-control'}),
label="Склад-отгрузки",
required=True
)
to_warehouse = forms.ModelChoiceField(
queryset=Warehouse.objects.filter(is_active=True),
widget=forms.Select(attrs={'class': 'form-control'}),
label="Склад-приемки",
required=True
)
notes = forms.CharField(
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Примечания к перемещению'}),
label="Примечания",
required=False
)
def clean(self):
cleaned_data = super().clean()
from_warehouse = cleaned_data.get('from_warehouse')
to_warehouse = cleaned_data.get('to_warehouse')
if from_warehouse and to_warehouse:
if from_warehouse.id == to_warehouse.id:
raise ValidationError('Склад-источник и склад-назначение должны быть разными')
return cleaned_data

View File

@@ -0,0 +1,84 @@
# Generated by Django 5.0.10 on 2025-11-02 20:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0001_initial'),
('products', '0005_remove_kititem_notes'),
]
operations = [
migrations.CreateModel(
name='DocumentCounter',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('counter_type', models.CharField(choices=[('transfer', 'Перемещение товара')], max_length=20, unique=True, verbose_name='Тип счетчика')),
('current_value', models.IntegerField(default=0, verbose_name='Текущее значение')),
],
options={
'verbose_name': 'Счетчик документов',
'verbose_name_plural': 'Счетчики документов',
},
),
migrations.CreateModel(
name='TransferBatch',
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='Номер документа')),
('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='Дата обновления')),
('from_warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_batches_from', to='inventory.warehouse', verbose_name='Склад-отгрузки')),
('to_warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_batches_to', to='inventory.warehouse', verbose_name='Склад-приемки')),
],
options={
'verbose_name': 'Документ перемещения',
'verbose_name_plural': 'Документы перемещения',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='TransferItem',
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='Количество')),
('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_items', to='inventory.stockbatch', verbose_name='Исходная партия (FIFO)')),
('new_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_items_created', to='inventory.stockbatch', verbose_name='Созданная партия на целевом складе')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_items', to='products.product', verbose_name='Товар')),
('transfer_batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.transferbatch', verbose_name='Документ перемещения')),
],
options={
'verbose_name': 'Строка перемещения',
'verbose_name_plural': 'Строки перемещения',
'ordering': ['id'],
},
),
migrations.AddIndex(
model_name='transferbatch',
index=models.Index(fields=['document_number'], name='inventory_t_documen_143275_idx'),
),
migrations.AddIndex(
model_name='transferbatch',
index=models.Index(fields=['from_warehouse', 'to_warehouse'], name='inventory_t_from_wa_2a41f1_idx'),
),
migrations.AddIndex(
model_name='transferbatch',
index=models.Index(fields=['-created_at'], name='inventory_t_created_b6fd05_idx'),
),
migrations.AddIndex(
model_name='transferitem',
index=models.Index(fields=['transfer_batch'], name='inventory_t_transfe_f7479b_idx'),
),
migrations.AddIndex(
model_name='transferitem',
index=models.Index(fields=['product'], name='inventory_t_product_0e0ec9_idx'),
),
migrations.AlterUniqueTogether(
name='transferitem',
unique_together={('transfer_batch', 'batch')},
),
]

View File

@@ -454,3 +454,153 @@ class StockMovement(models.Model):
def __str__(self):
return f"{self.product.name}: {self.change} ({self.reason})"
class DocumentCounter(models.Model):
"""
Счетчик номеров документов для различных операций.
Используется для генерации уникальных номеров документов.
"""
COUNTER_TYPE_CHOICES = [
('transfer', 'Перемещение товара'),
]
counter_type = models.CharField(
max_length=20,
choices=COUNTER_TYPE_CHOICES,
unique=True,
verbose_name="Тип счетчика"
)
current_value = models.IntegerField(
default=0,
verbose_name="Текущее значение"
)
class Meta:
verbose_name = "Счетчик документов"
verbose_name_plural = "Счетчики документов"
def __str__(self):
return f"Счетчик {self.get_counter_type_display()}: {self.current_value}"
@classmethod
def get_next_value(cls, counter_type):
"""
Получить следующее значение для счетчика.
Thread-safe операция с использованием select_for_update.
"""
from django.db import transaction
with transaction.atomic():
obj, _ = cls.objects.select_for_update().get_or_create(
counter_type=counter_type
)
obj.current_value += 1
obj.save(update_fields=['current_value'])
return obj.current_value
class TransferBatch(models.Model):
"""
Документ перемещения товара между складами.
Один номер документа = одна операция перемещения множественных товаров.
"""
from_warehouse = models.ForeignKey(
Warehouse,
on_delete=models.CASCADE,
related_name='transfer_batches_from',
verbose_name="Склад-отгрузки"
)
to_warehouse = models.ForeignKey(
Warehouse,
on_delete=models.CASCADE,
related_name='transfer_batches_to',
verbose_name="Склад-приемки"
)
document_number = models.CharField(
max_length=100,
unique=True,
db_index=True,
verbose_name="Номер документа"
)
notes = models.TextField(
blank=True,
null=True,
verbose_name="Примечания"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="Дата обновления"
)
class Meta:
verbose_name = "Документ перемещения"
verbose_name_plural = "Документы перемещения"
ordering = ['-created_at']
indexes = [
models.Index(fields=['document_number']),
models.Index(fields=['from_warehouse', 'to_warehouse']),
models.Index(fields=['-created_at']),
]
def __str__(self):
total_items = self.items.count()
total_qty = self.items.aggregate(
models.Sum('quantity')
)['quantity__sum'] or Decimal('0')
return f"Перемещение {self.document_number}: {total_items} товаров, {total_qty} шт ({self.from_warehouse}{self.to_warehouse})"
class TransferItem(models.Model):
"""
Строка документа перемещения (товар в перемещении).
Связь между документом и товарами.
"""
transfer_batch = models.ForeignKey(
TransferBatch,
on_delete=models.CASCADE,
related_name='items',
verbose_name="Документ перемещения"
)
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
related_name='transfer_items',
verbose_name="Товар"
)
batch = models.ForeignKey(
StockBatch,
on_delete=models.CASCADE,
related_name='transfer_items',
verbose_name="Исходная партия (FIFO)"
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=3,
verbose_name="Количество"
)
new_batch = models.ForeignKey(
StockBatch,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transfer_items_created',
verbose_name="Созданная партия на целевом складе"
)
class Meta:
verbose_name = "Строка перемещения"
verbose_name_plural = "Строки перемещения"
unique_together = [['transfer_batch', 'batch']]
ordering = ['id']
indexes = [
models.Index(fields=['transfer_batch']),
models.Index(fields=['product']),
]
def __str__(self):
return f"{self.product.name}: {self.quantity} шт (партия {self.batch.id})"

View File

@@ -244,3 +244,66 @@ class StockBatchManager:
batch.is_active = False
batch.save(update_fields=['is_active'])
@staticmethod
@transaction.atomic
def transfer_product_by_fifo(product, from_warehouse, to_warehouse, quantity):
"""
Переместить товар с одного склада на другой по FIFO логике.
Старые партии перемещаются первыми.
Args:
product: объект Product
from_warehouse: объект Warehouse (источник)
to_warehouse: объект Warehouse (назначение)
quantity: Decimal - количество товара для перемещения
Returns:
list: [(source_batch, qty_transferred, new_batch), ...]
список кортежей с исходной партией, количеством и созданной партией
Raises:
ValueError: если недостаточно товара на складе-источнике
"""
# Получаем партии по FIFO (старые первыми)
allocations = StockBatchManager.get_batches_for_fifo(product, from_warehouse)
result = []
remaining = quantity
for batch in allocations:
if remaining <= 0:
break
# Определяем сколько перемещаем из этой партии
qty_to_transfer = min(batch.quantity, remaining)
# Уменьшаем исходную партию
batch.quantity -= qty_to_transfer
if batch.quantity <= 0:
batch.is_active = False
batch.save(update_fields=['quantity', 'is_active', 'updated_at'])
# Создаем новую партию на целевом складе с СОХРАНЕНИЕМ cost_price
new_batch = StockBatch.objects.create(
product=product,
warehouse=to_warehouse,
quantity=qty_to_transfer,
cost_price=batch.cost_price # ВАЖНО: сохраняем цену!
)
result.append((batch, qty_to_transfer, new_batch))
remaining -= qty_to_transfer
# Проверяем что было достаточно товара
if remaining > 0:
raise ValueError(
f"Недостаточно товара '{product.name}' на складе '{from_warehouse.name}'. "
f"Не хватает {remaining} шт из запрашиваемых {quantity} шт"
)
# Обновляем кеш остатков на обоих складах
StockBatchManager.refresh_stock_cache(product, from_warehouse)
StockBatchManager.refresh_stock_cache(product, to_warehouse)
return result

View File

@@ -0,0 +1,462 @@
{% extends 'base.html' %}
{% block title %}Создать перемещение товара{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-3" style="max-width: 1400px;">
<!-- Breadcrumbs -->
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb breadcrumb-sm mb-0">
<li class="breadcrumb-item"><a href="{% url 'inventory:transfer-list' %}">Перемещения</a></li>
<li class="breadcrumb-item active">Новое перемещение</li>
</ol>
</nav>
<!-- Errors -->
<div id="errorContainer"></div>
<form method="post" id="transferForm">
{% csrf_token %}
<div class="row g-3">
<!-- ЛЕВАЯ КОЛОНКА: Основная информация -->
<div class="col-lg-8">
<!-- Склад-отгрузки и Склад-приемки -->
<div class="row g-2 mb-3">
<div class="col-md-6">
<label for="{{ form.from_warehouse.id_for_label }}" class="form-label small text-muted mb-1">
<i class="bi bi-box-seam-out me-1"></i>{{ form.from_warehouse.label }}
</label>
{{ form.from_warehouse }}
{% if form.from_warehouse.errors %}
<div class="text-danger small mt-1">{{ form.from_warehouse.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.to_warehouse.id_for_label }}" class="form-label small text-muted mb-1">
<i class="bi bi-box-seam-in me-1"></i>{{ form.to_warehouse.label }}
</label>
{{ form.to_warehouse }}
{% if form.to_warehouse.errors %}
<div class="text-danger small mt-1">{{ form.to_warehouse.errors }}</div>
{% endif %}
</div>
</div>
<!-- Примечания -->
<div class="mb-3">
<label for="{{ form.notes.id_for_label }}" class="form-label small text-muted mb-1">
<i class="bi bi-chat-dots me-1"></i>{{ form.notes.label }}
</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="text-danger small mt-1">{{ form.notes.errors }}</div>
{% endif %}
</div>
<!-- ТОВАРЫ -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-light py-3 d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-bag-check me-1"></i>Товары для перемещения</h6>
<button type="button" class="btn btn-sm btn-success" id="addProductBtn">
<i class="bi bi-plus-lg me-1"></i>Добавить товар
</button>
</div>
<div class="card-body p-0">
<div id="productsTableContainer">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" id="productsTable">
<thead>
<tr class="border-bottom">
<th scope="col" class="px-3 py-2">Товар</th>
<th scope="col" class="px-3 py-2" style="width: 150px;">Доступно</th>
<th scope="col" class="px-3 py-2" style="text-align: right; width: 150px;">Кол-во</th>
<th scope="col" class="px-3 py-2 text-center" style="width: 50px;">Действие</th>
</tr>
</thead>
<tbody id="productsBody">
<!-- JavaScript добавляет строки сюда -->
</tbody>
</table>
</div>
<div id="emptyState" class="text-center py-4 text-muted">
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
<p class="mt-2 mb-0">Товары не добавлены. Нажмите "Добавить товар" для начала</p>
</div>
</div>
</div>
</div>
<!-- Итоги -->
<div class="row mt-3 g-2">
<div class="col-md-4">
<div class="card border-0 bg-light">
<div class="card-body p-3">
<p class="text-muted small mb-1">Позиций</p>
<p class="h5 mb-0" id="totalItems">0</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 bg-light">
<div class="card-body p-3">
<p class="text-muted small mb-1">Всего товара</p>
<p class="h5 mb-0"><span id="totalQuantity">0</span> шт</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 bg-light">
<div class="card-body p-3">
<p class="text-muted small mb-1">Статус</p>
<p class="h5 mb-0">
<span id="formStatus" class="badge bg-secondary">Готово</span>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- ПРАВАЯ КОЛОНКА: Справочная информация -->
<div class="col-lg-4">
<!-- Справка -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-body">
<h6 class="text-muted mb-3"><i class="bi bi-info-circle me-1"></i>Информация</h6>
<div class="alert alert-info alert-sm" style="font-size: 0.875rem;">
<p class="mb-1"><strong>Логика FIFO:</strong></p>
<p class="mb-0">Товар перемещается из старых партий первыми. Стоимость партий сохраняется.</p>
</div>
</div>
</div>
<!-- Логирование -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-light py-2">
<h6 class="mb-0 text-muted"><i class="bi bi-journal-text me-1"></i>Логирование</h6>
</div>
<div class="card-body p-3" style="max-height: 300px; overflow-y: auto;">
<div id="logContainer">
<p class="text-muted small mb-0">Логи операций появятся здесь...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Sticky Footer -->
<div class="sticky-bottom bg-white border-top mt-4 p-3 d-flex justify-content-between align-items-center shadow-sm">
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary">
Отмена
</a>
<button type="submit" class="btn btn-success px-4" id="submitBtn">
<i class="bi bi-check-circle me-1"></i>Создать перемещение
</button>
</div>
<!-- Скрытое поле для JSON товаров -->
<input type="hidden" name="products_json" id="products_json" value="[]">
</form>
</div>
<style>
.breadcrumb-sm {
font-size: 0.875rem;
padding: 0.5rem 0;
}
.table-hover tbody tr:hover {
background-color: #f8f9fa;
}
.alert-sm {
padding: 0.5rem 0.75rem;
margin-bottom: 0;
}
.sticky-bottom {
position: sticky;
bottom: 0;
z-index: 1020;
}
#productsTable input[type="number"] {
width: 100%;
}
.product-row-delete {
animation: fadeOut 0.3s;
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.log-entry {
padding: 0.5rem 0;
border-bottom: 1px solid #e9ecef;
font-size: 0.875rem;
}
.log-entry:last-child {
border-bottom: none;
}
.log-entry.info {
color: #0c5460;
background-color: #d1ecf1;
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
}
.log-entry.error {
color: #721c24;
background-color: #f8d7da;
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
}
.log-entry.success {
color: #155724;
background-color: #d4edda;
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('transferForm');
const productsBody = document.getElementById('productsBody');
const productsJson = document.getElementById('products_json');
const emptyState = document.getElementById('emptyState');
const addProductBtn = document.getElementById('addProductBtn');
const totalItemsEl = document.getElementById('totalItems');
const totalQuantityEl = document.getElementById('totalQuantity');
const logContainer = document.getElementById('logContainer');
const fromWarehouseSelect = document.querySelector('[name="from_warehouse"]');
const toWarehouseSelect = document.querySelector('[name="to_warehouse"]');
let products = [];
let rowCounter = 0;
// Логирование
function log(message, type = 'info') {
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
logContainer.appendChild(entry);
logContainer.scrollTop = logContainer.scrollHeight;
}
// Добавить новую строку товара
function addProductRow() {
const rowId = rowCounter++;
const html = `
<tr class="product-row" data-row-id="${rowId}">
<td class="px-3 py-2">
<select class="form-control form-control-sm product-select" name="product_${rowId}" required>
<option value="">-- Выберите товар --</option>
{% for product in products %}
<option value="{{ product.id }}">{{ product.name }} ({{ product.sku }})</option>
{% empty %}
<option value="">Товары не найдены. Создайте товар в системе.</option>
{% endfor %}
</select>
</td>
<td class="px-3 py-2">
<small class="text-muted available-qty" data-row-id="${rowId}">--</small>
</td>
<td class="px-3 py-2">
<input type="number" class="form-control form-control-sm quantity-input"
name="quantity_${rowId}" value="1" step="0.001" min="0" required>
</td>
<td class="px-3 py-2 text-center">
<button type="button" class="btn btn-sm btn-link text-danger p-0 delete-row-btn"
data-row-id="${rowId}">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`;
const tr = document.createElement('tr');
tr.innerHTML = html.match(/<tr[^>]*>[\s\S]*?<\/tr>/)[0].replace(html.match(/<tr[^>]*>/)[0], '').slice(0, -5);
// Простой способ: добавляем HTML напрямую
const row = productsBody.insertAdjacentHTML('beforeend', html);
updateUI();
log(`Добавлена новая строка товара (#${rowId})`, 'info');
}
// Функция для получения доступного количества товара
function fetchProductStock(productId, warehouseId, rowId) {
const availableQtyEl = document.querySelector(`.available-qty[data-row-id="${rowId}"]`);
if (!availableQtyEl) {
console.warn(`Element not found for rowId: ${rowId}`);
return;
}
if (!productId || !warehouseId) {
availableQtyEl.textContent = '--';
return;
}
const url = `/inventory/api/product-stock/?product_id=${productId}&warehouse_id=${warehouseId}`;
console.log(`Fetching stock from: ${url}`);
fetch(url)
.then(response => {
console.log('Response status:', response.status);
return response.json();
})
.then(data => {
console.log('Response data:', data);
if (data.success) {
availableQtyEl.textContent = `${data.quantity} шт`;
} else {
availableQtyEl.textContent = `Ошибка: ${data.error || 'неизвестная ошибка'}`;
console.error('API error:', data.error);
}
})
.catch(error => {
console.error('Fetch error:', error);
availableQtyEl.textContent = '--';
});
}
// Удалить строку товара
productsBody.addEventListener('click', function(e) {
if (e.target.closest('.delete-row-btn')) {
const rowId = e.target.closest('.delete-row-btn').dataset.rowId;
const row = document.querySelector(`[data-row-id="${rowId}"]`);
if (row) {
row.classList.add('product-row-delete');
setTimeout(() => {
row.remove();
updateUI();
log(`Удалена строка товара (#${rowId})`, 'info');
}, 300);
}
}
});
// Обновить JSON и UI
function updateUI() {
products = [];
let totalQty = 0;
productsBody.querySelectorAll('tr.product-row').forEach(row => {
const rowId = row.dataset.rowId;
const productSelect = row.querySelector('.product-select');
const quantityInput = row.querySelector('.quantity-input');
if (productSelect.value && quantityInput.value) {
const productId = parseInt(productSelect.value);
const quantity = parseFloat(quantityInput.value);
if (quantity > 0) {
products.push({
product_id: productId,
quantity: quantity
});
totalQty += quantity;
}
}
});
productsJson.value = JSON.stringify(products);
totalItemsEl.textContent = products.length;
totalQuantityEl.textContent = totalQty.toFixed(3);
// Показываем/скрываем пустое состояние
if (products.length === 0) {
emptyState.style.display = 'block';
} else {
emptyState.style.display = 'none';
}
}
// Обработчики событий
addProductBtn.addEventListener('click', function(e) {
e.preventDefault();
addProductRow();
});
// Когда выбор склада отгрузки изменяется, обновляем доступные количества для всех товаров
fromWarehouseSelect.addEventListener('change', function() {
const warehouseId = this.value;
productsBody.querySelectorAll('.product-row').forEach(row => {
const rowId = row.dataset.rowId;
const productSelect = row.querySelector('.product-select');
const productId = productSelect.value;
if (productId && warehouseId) {
fetchProductStock(productId, warehouseId, rowId);
}
});
});
// Обновляем JSON при изменении значений
productsBody.addEventListener('change', function(e) {
updateUI();
// Если изменилось поле выбора товара, загружаем доступное количество
if (e.target.classList.contains('product-select')) {
const row = e.target.closest('.product-row');
const rowId = row.dataset.rowId;
const productId = e.target.value;
const warehouseId = fromWarehouseSelect.value;
console.log('Product selected:', {productId, warehouseId, rowId});
log(`Выбран товар ID ${productId}`, 'info');
fetchProductStock(productId, warehouseId, rowId);
}
});
productsBody.addEventListener('input', updateUI);
// Валидация формы перед отправкой
form.addEventListener('submit', function(e) {
e.preventDefault();
// Проверяем что товары есть
if (products.length === 0) {
log('Ошибка: необходимо добавить хотя бы один товар', 'error');
const errorDiv = document.getElementById('errorContainer');
errorDiv.innerHTML = '<div class="alert alert-danger alert-dismissible fade show">Необходимо добавить хотя бы один товар для перемещения.<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>';
return;
}
// Проверяем выбор складов
if (!fromWarehouseSelect.value || !toWarehouseSelect.value) {
log('Ошибка: необходимо выбрать оба склада', 'error');
const errorDiv = document.getElementById('errorContainer');
errorDiv.innerHTML = '<div class="alert alert-danger alert-dismissible fade show">Необходимо выбрать склад-отгрузки и склад-приемки.<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>';
return;
}
if (fromWarehouseSelect.value === toWarehouseSelect.value) {
log('Ошибка: склады не должны быть одинаковыми', 'error');
const errorDiv = document.getElementById('errorContainer');
errorDiv.innerHTML = '<div class="alert alert-danger alert-dismissible fade show">Склад-отгрузки и склад-приемки должны быть разными.<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>';
return;
}
log(`Отправка документа с ${products.length} товарами`, 'success');
form.submit();
});
// Инициальное состояние
updateUI();
log('Форма инициализирована', 'info');
});
</script>
{% endblock %}

View File

@@ -0,0 +1,155 @@
{% extends 'base.html' %}
{% block title %}Документ перемещения {{ transfer_batch.document_number }}{% 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:transfer-list' %}">Перемещения</a></li>
<li class="breadcrumb-item active">{{ transfer_batch.document_number }}</li>
</ol>
</nav>
<div class="row g-3">
<!-- Основная информация о документе -->
<div class="col-lg-8">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-light py-3">
<h5 class="mb-0">
<i class="bi bi-arrow-left-right me-2"></i>{{ transfer_batch.document_number }}
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<p class="text-muted small mb-1">Склад-отгрузки</p>
<p class="fw-semibold">{{ transfer_batch.from_warehouse.name }}</p>
</div>
<div class="col-md-6">
<p class="text-muted small mb-1">Склад-приемки</p>
<p class="fw-semibold">{{ transfer_batch.to_warehouse.name }}</p>
</div>
</div>
{% if transfer_batch.notes %}
<div class="mb-3">
<p class="text-muted small mb-1">Примечания</p>
<p>{{ transfer_batch.notes }}</p>
</div>
{% endif %}
<div class="row">
<div class="col-md-6">
<p class="text-muted small mb-1">Дата создания</p>
<p class="fw-semibold">{{ transfer_batch.created_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">{{ transfer_batch.updated_at|date:"d.m.Y H:i" }}</p>
</div>
</div>
</div>
</div>
<!-- Таблица товаров -->
<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-sm table-hover mb-0">
<thead>
<tr class="border-bottom">
<th scope="col" class="px-3 py-2">Товар</th>
<th scope="col" class="px-3 py-2" style="text-align: right;">Количество</th>
<th scope="col" class="px-3 py-2" style="text-align: right;">Цена партии</th>
<th scope="col" class="px-3 py-2">Исходная партия</th>
<th scope="col" class="px-3 py-2">Новая партия</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<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" style="text-align: right;">{{ item.quantity }}</td>
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.</td>
<td class="px-3 py-2">
<span class="badge bg-secondary">{{ item.batch.id }}</span>
</td>
<td class="px-3 py-2">
{% if item.new_batch %}
<span class="badge bg-success">{{ item.new_batch.id }}</span>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="px-3 py-2 text-muted text-center">
Товаров не найдено
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Сводка справа -->
<div class="col-lg-4">
<!-- Статистика -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-body">
<h6 class="text-muted mb-3"><i class="bi bi-info-circle me-1"></i>Статистика</h6>
<div class="mb-3 p-2 rounded" style="background: #f8f9fa;">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted small">Позиций:</span>
<span class="fw-semibold">{{ total_items }}</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Всего товара:</span>
<span class="fw-semibold">{{ total_qty }} шт</span>
</div>
</div>
</div>
</div>
<!-- Действия -->
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-3"><i class="bi bi-gear me-1"></i>Действия</h6>
<div class="d-grid gap-2">
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку
</a>
<a href="{% url 'inventory:transfer-delete' transfer_batch.id %}" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash me-1"></i>Удалить
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.breadcrumb-sm {
font-size: 0.875rem;
padding: 0.5rem 0;
}
.table-hover tbody tr:hover {
background-color: #f8f9fa;
}
</style>
{% endblock %}

View File

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

View File

@@ -14,7 +14,7 @@ from .views import (
# WriteOff
WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView,
# Transfer
TransferListView, TransferCreateView, TransferUpdateView, TransferDeleteView,
TransferListView, TransferBulkCreateView, TransferDetailView, TransferDeleteView, GetProductStockView,
# Reservation
ReservationListView, ReservationCreateView, ReservationUpdateView,
# Stock
@@ -72,9 +72,10 @@ urlpatterns = [
# ==================== TRANSFER ====================
path('transfers/', TransferListView.as_view(), name='transfer-list'),
path('transfers/create/', TransferCreateView.as_view(), name='transfer-create'),
path('transfers/<int:pk>/edit/', TransferUpdateView.as_view(), name='transfer-update'),
path('transfers/create/', TransferBulkCreateView.as_view(), name='transfer-create'), # Новая форма массового перемещения
path('transfers/<int:pk>/', TransferDetailView.as_view(), name='transfer-detail'), # Деталь документа
path('transfers/<int:pk>/delete/', TransferDeleteView.as_view(), name='transfer-delete'),
path('api/product-stock/', GetProductStockView.as_view(), name='api-product-stock'), # API для получения количества товара
# ==================== RESERVATION ====================
path('reservations/', ReservationListView.as_view(), name='reservation-list'),

View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
"""
Утилиты для модуля inventory.
Доступны функции для генерации номеров документов и другие вспомогательные функции.
"""
from .document_generator import generate_transfer_document_number, generate_incoming_document_number
__all__ = [
'generate_transfer_document_number',
'generate_incoming_document_number',
]

View File

@@ -0,0 +1,90 @@
"""
Генератор номеров документов для различных операций в inventory.
"""
from inventory.models import DocumentCounter
def generate_transfer_document_number():
"""
Генерирует уникальный номер документа перемещения.
Формат: MOVE-XXXXXX (6 цифр)
Returns:
str: Сгенерированный номер документа (например, MOVE-000001)
"""
next_number = DocumentCounter.get_next_value('transfer')
return f"MOVE-{next_number:06d}"
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
import os
# Настройка логирования
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

View File

@@ -27,7 +27,7 @@ from .inventory_ops import (
InventoryLineCreateBulkView
)
from .writeoff import WriteOffListView, WriteOffCreateView, WriteOffUpdateView, WriteOffDeleteView
from .transfer import TransferListView, TransferCreateView, TransferUpdateView, TransferDeleteView
from .transfer import TransferListView, TransferBulkCreateView, TransferDetailView, TransferDeleteView, GetProductStockView
from .reservation import ReservationListView, ReservationCreateView, ReservationUpdateView
from .stock import StockListView, StockDetailView
from .allocation import SaleBatchAllocationListView
@@ -58,7 +58,7 @@ __all__ = [
# WriteOff
'WriteOffListView', 'WriteOffCreateView', 'WriteOffUpdateView', 'WriteOffDeleteView',
# Transfer
'TransferListView', 'TransferCreateView', 'TransferUpdateView', 'TransferDeleteView',
'TransferListView', 'TransferBulkCreateView', 'TransferDetailView', 'TransferDeleteView', 'GetProductStockView',
# Reservation
'ReservationListView', 'ReservationCreateView', 'ReservationUpdateView',
# Stock

View File

@@ -11,7 +11,7 @@ from django.utils.decorators import method_decorator
from django.db import IntegrityError, transaction
from ..models import Incoming, IncomingBatch, Warehouse
from ..forms import IncomingForm, IncomingLineForm
from ..utils import generate_incoming_document_number
from inventory.utils import generate_incoming_document_number
from products.models import Product
file_logger = logging.getLogger('incoming_sequence_file')

View File

@@ -3,58 +3,235 @@
Transfer (Перемещение товара между складами) views
GROUP 2: MEDIUM PRIORITY
"""
from django.views.generic import ListView, CreateView, UpdateView, DeleteView
import json
from decimal import Decimal
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView
from django.views import View
from django.urls import reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from ..models import Transfer
from ..forms import TransferForm
from django.http import JsonResponse
from django.db import transaction
from django.shortcuts import redirect, render
from ..models import TransferBatch, TransferItem, Stock
from ..forms import TransferBulkForm
from inventory.utils.document_generator import generate_transfer_document_number
from inventory.services.batch_manager import StockBatchManager
from products.models import Product
class TransferListView(LoginRequiredMixin, ListView):
model = Transfer
"""
View для просмотра списка документов перемещений товаров.
"""
model = TransferBatch
template_name = 'inventory/transfer/transfer_list.html'
context_object_name = 'transfers'
paginate_by = 20
def get_queryset(self):
return Transfer.objects.select_related(
'batch', 'batch__product',
return TransferBatch.objects.select_related(
'from_warehouse', 'to_warehouse'
).order_by('-date')
).order_by('-created_at')
class TransferCreateView(LoginRequiredMixin, CreateView):
model = Transfer
form_class = TransferForm
template_name = 'inventory/transfer/transfer_form.html'
success_url = reverse_lazy('inventory:transfer-list')
# ============================================================================
# VIEWS ДЛЯ ПЕРЕМЕЩЕНИЯ ТОВАРОВ (TransferBatch + TransferItem)
# ============================================================================
def form_valid(self, form):
messages.success(
self.request,
f'Перемещение товара "{form.instance.batch.product.name}" успешно зарегистрировано.'
class TransferBulkCreateView(LoginRequiredMixin, View):
"""
View для создания документа перемещения товаров между складами с FIFO логикой.
Один документ может содержать несколько товаров.
"""
template_name = 'inventory/transfer/transfer_bulk_form.html'
def get(self, request):
from products.models import Product
form = TransferBulkForm()
products = Product.objects.filter(is_active=True).values('id', 'name', 'sku').order_by('name')
return render(request, self.template_name, {
'form': form,
'products': products
})
def post(self, request):
form = TransferBulkForm(request.POST)
if not form.is_valid():
messages.error(request, 'Ошибка при заполнении формы')
return render(request, self.template_name, {'form': form}, status=400)
# Получаем данные из формы
from_warehouse = form.cleaned_data.get('from_warehouse')
to_warehouse = form.cleaned_data.get('to_warehouse')
notes = form.cleaned_data.get('notes', '')
# Парсим JSON с товарами
products_json = request.POST.get('products_json', '[]')
try:
products_data = json.loads(products_json)
except json.JSONDecodeError:
messages.error(request, 'Ошибка при парсинге товаров. Пожалуйста, проверьте данные.')
return render(request, self.template_name, {'form': form}, status=400)
# Проверяем что товары есть
if not products_data:
messages.error(request, 'Необходимо добавить хотя бы один товар для перемещения.')
return render(request, self.template_name, {'form': form}, status=400)
# Валидируем товары
products = []
for item in products_data:
product_id = item.get('product_id')
quantity = Decimal(str(item.get('quantity', 0)))
if quantity <= 0:
messages.error(request, f'Количество товара должно быть больше нуля')
return render(request, self.template_name, {'form': form}, status=400)
try:
product = Product.objects.get(id=product_id, is_active=True)
products.append((product, quantity))
except Product.DoesNotExist:
messages.error(request, f'Товар с ID {product_id} не найден')
return render(request, self.template_name, {'form': form}, status=400)
# Начинаем транзакцию
try:
with transaction.atomic():
# 1. Создаем документ TransferBatch
transfer_batch = TransferBatch.objects.create(
from_warehouse=from_warehouse,
to_warehouse=to_warehouse,
document_number=generate_transfer_document_number(),
notes=notes
)
# 2. Для каждого товара выполняем FIFO перемещение
for product, quantity in products:
try:
# Получаем список распределений по FIFO
transfers = StockBatchManager.transfer_product_by_fifo(
product=product,
from_warehouse=from_warehouse,
to_warehouse=to_warehouse,
quantity=quantity
)
# Создаем TransferItem для каждого использованного batch
for source_batch, qty_transferred, new_batch in transfers:
TransferItem.objects.create(
transfer_batch=transfer_batch,
product=product,
batch=source_batch,
quantity=qty_transferred,
new_batch=new_batch
)
except ValueError as e:
messages.error(request, f'Ошибка при перемещении товара "{product.name}": {str(e)}')
raise # Откатываем транзакцию
# 3. Успешно создали документ
messages.success(
request,
f'Документ перемещения {transfer_batch.document_number} успешно создан. '
f'Перемещено {len(products)} видов товаров.'
)
return redirect('inventory:transfer-detail', pk=transfer_batch.id)
except Exception as e:
messages.error(request, f'Ошибка при создании документа перемещения: {str(e)}')
return render(request, self.template_name, {'form': form}, status=400)
class TransferDetailView(LoginRequiredMixin, DetailView):
"""
View для просмотра деталей документа перемещения.
"""
model = TransferBatch
template_name = 'inventory/transfer/transfer_detail.html'
context_object_name = 'transfer_batch'
def get_queryset(self):
return TransferBatch.objects.select_related(
'from_warehouse', 'to_warehouse'
).prefetch_related(
'items__product', 'items__batch', 'items__new_batch'
)
return super().form_valid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
transfer_batch = self.object
class TransferUpdateView(LoginRequiredMixin, UpdateView):
model = Transfer
form_class = TransferForm
template_name = 'inventory/transfer/transfer_form.html'
success_url = reverse_lazy('inventory:transfer-list')
# Собираем статистику по документу
items = transfer_batch.items.all()
total_items = items.count()
total_qty = sum(Decimal(str(item.quantity)) for item in items)
def form_valid(self, form):
messages.success(self.request, f'Перемещение товара обновлено.')
return super().form_valid(form)
context['total_items'] = total_items
context['total_qty'] = total_qty
context['items'] = items
return context
class TransferDeleteView(LoginRequiredMixin, DeleteView):
model = Transfer
"""
View для удаления документа перемещения.
"""
model = TransferBatch
template_name = 'inventory/transfer/transfer_confirm_delete.html'
success_url = reverse_lazy('inventory:transfer-list')
def form_valid(self, form):
transfer = self.get_object()
messages.success(self.request, f'Перемещение товара отменено.')
transfer_batch = self.get_object()
messages.success(self.request, f'Документ перемещения {transfer_batch.document_number} удалён.')
return super().form_valid(form)
class GetProductStockView(LoginRequiredMixin, View):
"""
API endpoint для получения доступного количества товара на конкретном складе.
GET параметры: product_id, warehouse_id
Возвращает JSON: {"quantity": "100.000", "warehouse_name": "Основной склад"}
"""
def get(self, request):
product_id = request.GET.get('product_id')
warehouse_id = request.GET.get('warehouse_id')
if not product_id or not warehouse_id:
return JsonResponse({
'error': 'Missing required parameters: product_id, warehouse_id'
}, status=400)
try:
product_id = int(product_id)
warehouse_id = int(warehouse_id)
except ValueError:
return JsonResponse({
'error': 'Invalid parameter values'
}, status=400)
try:
stock = Stock.objects.get(product_id=product_id, warehouse_id=warehouse_id)
return JsonResponse({
'quantity': str(stock.quantity_available),
'warehouse_name': stock.warehouse.name,
'success': True
})
except Stock.DoesNotExist:
return JsonResponse({
'quantity': '0.000',
'warehouse_name': '',
'success': True
})
except Exception as e:
import traceback
traceback.print_exc()
return JsonResponse({
'error': str(e),
'success': False
}, status=500)