feat(products): add support for product sales units
Add new models UnitOfMeasure and ProductSalesUnit to enable selling products in different units (e.g., bunches, kg). Update Product model with base_unit field and methods for unit conversions and availability. Extend Sale, Reservation, and OrderItem models with sales_unit fields and snapshots. Modify SaleProcessor to handle quantity conversions. Include admin interfaces for managing units. Add corresponding database migrations.
This commit is contained in:
40
myproject/inventory/migrations/0003_add_sales_unit_fields.py
Normal file
40
myproject/inventory/migrations/0003_add_sales_unit_fields.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.0.10 on 2026-01-01 21:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('inventory', '0002_initial'),
|
||||
('products', '0006_populate_unit_of_measure'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='reservation',
|
||||
name='quantity_base',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Количество в единицах хранения товара', max_digits=10, null=True, verbose_name='Количество в базовых единицах'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reservation',
|
||||
name='sales_unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reservations', to='products.productsalesunit', verbose_name='Единица продажи'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sale',
|
||||
name='quantity_base',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Количество в единицах хранения товара (для списания со склада)', max_digits=10, null=True, verbose_name='Количество в базовых единицах'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sale',
|
||||
name='sales_unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales', to='products.productsalesunit', verbose_name='Единица продажи'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sale',
|
||||
name='unit_name_snapshot',
|
||||
field=models.CharField(blank=True, default='', help_text='Название единицы продажи на момент продажи', max_length=100, verbose_name='Название единицы (snapshot)'),
|
||||
),
|
||||
]
|
||||
@@ -122,6 +122,31 @@ class Sale(models.Model):
|
||||
date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции")
|
||||
processed = models.BooleanField(default=False, verbose_name="Обработана (FIFO применена)")
|
||||
|
||||
# === ПОЛЯ ДЛЯ ЕДИНИЦ ПРОДАЖИ ===
|
||||
sales_unit = models.ForeignKey(
|
||||
'products.ProductSalesUnit',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='sales',
|
||||
verbose_name="Единица продажи"
|
||||
)
|
||||
quantity_base = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=6,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Количество в базовых единицах",
|
||||
help_text="Количество в единицах хранения товара (для списания со склада)"
|
||||
)
|
||||
unit_name_snapshot = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name="Название единицы (snapshot)",
|
||||
help_text="Название единицы продажи на момент продажи"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Продажа"
|
||||
verbose_name_plural = "Продажи"
|
||||
@@ -133,7 +158,8 @@ class Sale(models.Model):
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Продажа {self.product.name}: {self.quantity} шт @ {self.sale_price}"
|
||||
unit_info = f" ({self.unit_name_snapshot})" if self.unit_name_snapshot else ""
|
||||
return f"Продажа {self.product.name}: {self.quantity}{unit_info} @ {self.sale_price}"
|
||||
|
||||
|
||||
class SaleBatchAllocation(models.Model):
|
||||
@@ -469,6 +495,24 @@ class Reservation(models.Model):
|
||||
help_text="Резерв для входного товара трансформации (черновик)"
|
||||
)
|
||||
|
||||
# === ПОЛЯ ДЛЯ ЕДИНИЦ ПРОДАЖИ ===
|
||||
sales_unit = models.ForeignKey(
|
||||
'products.ProductSalesUnit',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='reservations',
|
||||
verbose_name="Единица продажи"
|
||||
)
|
||||
quantity_base = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=6,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Количество в базовых единицах",
|
||||
help_text="Количество в единицах хранения товара"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Резервирование"
|
||||
verbose_name_plural = "Резервирования"
|
||||
|
||||
@@ -55,22 +55,25 @@ class SaleProcessor:
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None):
|
||||
def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None, sales_unit=None):
|
||||
"""
|
||||
Создать операцию продажи и произвести FIFO-списание.
|
||||
|
||||
Процесс:
|
||||
1. Создаем запись Sale
|
||||
2. Списываем товар по FIFO из партий
|
||||
3. Фиксируем распределение в SaleBatchAllocation для аудита
|
||||
2. Конвертируем количество в базовые единицы (если указана sales_unit)
|
||||
3. Списываем товар по FIFO из партий
|
||||
4. Фиксируем распределение в SaleBatchAllocation для аудита
|
||||
|
||||
Args:
|
||||
product: объект Product
|
||||
warehouse: объект Warehouse
|
||||
quantity: Decimal - количество товара
|
||||
quantity: Decimal - количество товара (в единицах продажи, если указана sales_unit)
|
||||
sale_price: Decimal - цена продажи
|
||||
order: (опционально) объект Order
|
||||
document_number: (опционально) номер документа
|
||||
sales_unit: (опционально) объект ProductSalesUnit - единица продажи.
|
||||
Если указана, quantity конвертируется в базовые единицы товара.
|
||||
|
||||
Returns:
|
||||
Объект Sale
|
||||
@@ -84,25 +87,36 @@ class SaleProcessor:
|
||||
if sale_price < 0:
|
||||
raise ValueError("Цена продажи не может быть отрицательной")
|
||||
|
||||
# Конвертируем количество в базовые единицы, если указана единица продажи
|
||||
if sales_unit:
|
||||
quantity_base = sales_unit.convert_to_base(quantity)
|
||||
unit_name_snapshot = sales_unit.name
|
||||
else:
|
||||
quantity_base = quantity
|
||||
unit_name_snapshot = ''
|
||||
|
||||
# Создаем запись Sale
|
||||
# ВАЖНО: Устанавливаем processed=True сразу, чтобы сигнал process_sale_fifo не сработал
|
||||
# (списание делаем вручную ниже, чтобы избежать двойного списания)
|
||||
sale = Sale.objects.create(
|
||||
product=product,
|
||||
warehouse=warehouse,
|
||||
quantity=quantity,
|
||||
quantity=quantity, # В единицах продажи (для истории/отчётов)
|
||||
quantity_base=quantity_base, # В базовых единицах (для списания)
|
||||
sale_price=sale_price,
|
||||
order=order,
|
||||
document_number=document_number,
|
||||
processed=True # Сразу отмечаем как обработанную
|
||||
processed=True, # Сразу отмечаем как обработанную
|
||||
sales_unit=sales_unit,
|
||||
unit_name_snapshot=unit_name_snapshot
|
||||
)
|
||||
|
||||
try:
|
||||
# Списываем товар по FIFO
|
||||
# Списываем товар по FIFO в БАЗОВЫХ единицах
|
||||
# exclude_order позволяет не считать резервы этого заказа как занятые
|
||||
# (резервы переводятся в converted_to_sale ПОСЛЕ создания Sale)
|
||||
allocations = StockBatchManager.write_off_by_fifo(
|
||||
product, warehouse, quantity, exclude_order=order
|
||||
product, warehouse, quantity_base, exclude_order=order
|
||||
)
|
||||
|
||||
# Фиксируем распределение для аудита
|
||||
|
||||
Reference in New Issue
Block a user