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:
2026-01-02 02:09:44 +03:00
parent ca308ae2a2
commit 5b68f14bb4
11 changed files with 764 additions and 15 deletions

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

View File

@@ -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 = "Резервирования"

View File

@@ -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
)
# Фиксируем распределение для аудита