1
0
forked from erp-dev/erp
Files
erpnew/stock/models.py
2026-06-12 13:57:21 +08:00

521 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.db import models
from django.core.exceptions import ValidationError
from flower.common import ModelBase
from basic_info import models as basic_info_models
from functools import reduce
import decimal
# ==================== 枚举类定义 ====================
class StockFreezeStatusEnum(models.IntegerChoices):
"""库存冻结状态类型"""
FROZEN = 1, '冻结中'
CANCELLED = 2, '已取消'
COMPLETED = 3, '已成单'
class StockChangeSourceEnum(models.IntegerChoices):
"""库存变动来源类型"""
PURCHASE = 1, '采购'
SALES_RETURN = 2, '销退'
TRANSPORT_IN = 3, '调入'
RECHECK_ADD = 4, '盘盈'
COMBINE = 5, '合并'
SALES = 6, '销售'
PURCHASE_RETURN = 7, '采购退货'
TRANSPORT_OUT = 8, '调出'
RECHECK_REMOVE = 9, '盘亏'
EXPLODE = 10, '拆卷'
OFFSET = 11, '红冲'
@classmethod
def incoming_values(cls) -> set[int]:
"""返回被视为入库的来源类型"""
return {
cls.PURCHASE,
cls.SALES_RETURN,
cls.TRANSPORT_IN,
cls.RECHECK_ADD,
cls.COMBINE,
cls.OFFSET,
}
@classmethod
def outgoing_values(cls) -> set[int]:
"""返回被视为出库的来源类型"""
return {
cls.SALES,
cls.PURCHASE_RETURN,
cls.TRANSPORT_OUT,
cls.RECHECK_REMOVE,
cls.EXPLODE,
cls.OFFSET,
}
class StockChangeTypeEnum(models.IntegerChoices):
"""库存变动类型"""
ADD = 1, '入库'
REMOVE = 2, '出库'
# ==================== 模型定义 ====================
class StockChangeRecord(ModelBase):
"""库存变动记录模型"""
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
basic_info_models.Merchant,
on_delete=models.PROTECT,
related_name='stock_change_records',
verbose_name='所属商户'
)
type = models.IntegerField(choices=StockChangeTypeEnum.choices, verbose_name='变动类型')
warehouse = models.ForeignKey(
basic_info_models.WareHouse,
on_delete=models.PROTECT,
related_name='stock_change_records',
verbose_name='仓库',
)
source_type = models.IntegerField(
choices=StockChangeSourceEnum.choices,
verbose_name='变动来源',
)
created_by = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
null=True,
blank=True,
verbose_name='操作人',
)
source_id = models.BigIntegerField(null=True, blank=True, verbose_name='来源单据ID')
is_finished = models.BooleanField(default=False, verbose_name='完成出入库')
finished_at = models.DateTimeField(null=True, blank=True, verbose_name='完成时间')
red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID')
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
def __str__(self):
return f'出入库记录 {self.id} - 仓库:{self.warehouse.name}'
class Meta:
verbose_name = '出入库记录'
verbose_name_plural = '出入库记录'
def clean(self):
if self.type == StockChangeTypeEnum.ADD:
if self.source_type not in StockChangeSourceEnum.incoming_values():
raise ValidationError('入库记录的来源类型无效')
elif self.type == StockChangeTypeEnum.REMOVE:
if self.source_type not in StockChangeSourceEnum.outgoing_values():
raise ValidationError('出库记录的来源类型无效')
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
@property
def is_incoming(self) -> bool:
"""判断是否为入库记录"""
return self.type == StockChangeTypeEnum.ADD
@property
def is_outgoing(self) -> bool:
"""判断是否为出库记录"""
return self.type == StockChangeTypeEnum.REMOVE
@property
def num_of_rolls(self) -> int:
"""获取匹数"""
self.details.count()
return self.details.count()
@property
def is_offset(self) -> bool:
"""是否为红冲记录(用于冲抵其他记录)"""
return self.stock_snapshots.filter(offset_to__isnull=False).exists()
@property
def is_reversed(self) -> bool:
"""是否已被红冲"""
return self.stock_snapshots.filter(cancelled=True).exists()
@property
def cancelled(self) -> bool:
"""兼容字段:是否已被红冲"""
return self.is_reversed
@property
def delta_num_of_rolls(self) -> int:
"""计算库存变动的匹数增减"""
# 入库为正,出库为负
if self.type == StockChangeTypeEnum.ADD:
return self.num_of_rolls
elif self.type == StockChangeTypeEnum.REMOVE:
return self.num_of_rolls * -1
else:
raise ValueError("未知的库存变动类型")
class StockChangeDetail(ModelBase):
"""库存变动明细模型"""
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
basic_info_models.Merchant,
on_delete=models.PROTECT,
related_name='stock_change_details',
verbose_name='所属商户'
)
product = models.ForeignKey(
basic_info_models.Product,
on_delete=models.PROTECT,
related_name='stock_change_details',
verbose_name='产品',
)
unit = models.IntegerField(
verbose_name='单位',
choices=basic_info_models.ProductUnitEnum.choices,
)
stock_change_record = models.ForeignKey(
StockChangeRecord,
on_delete=models.CASCADE,
related_name='details',
verbose_name='库存变动记录',
)
quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量')
is_consumed = models.BooleanField(default=False, verbose_name='是否已消耗')
consume_with = models.OneToOneField(
'self',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='consumed_by_detail',
verbose_name='所消耗的入库明细',
)
def __str__(self):
return f'库存变动明细 {self.id} - 记录ID: {self.stock_change_record.id}'
class Meta:
verbose_name = '库存变动明细'
verbose_name_plural = '库存变动明细'
class Inventory(ModelBase):
"""库存模型"""
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
basic_info_models.Merchant,
on_delete=models.PROTECT,
related_name='inventories',
verbose_name='所属商户'
)
product = models.ForeignKey(
basic_info_models.Product,
on_delete=models.PROTECT,
related_name='inventories',
verbose_name='产品',
)
warehouse = models.ForeignKey(
basic_info_models.WareHouse,
on_delete=models.PROTECT,
related_name='inventories',
verbose_name='仓库',
)
quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='库存数量')
num_of_rolls = models.IntegerField(default=1, verbose_name='匹数')
# minimum_quantity = models.IntegerField(null=True, blank=True, verbose_name='最低库存量')
spec = models.CharField(max_length=100, blank=True, null=True, verbose_name='规格')
description = models.TextField(blank=True, null=True, verbose_name='备注描述')
def __str__(self):
return f'库存 {self.product.name} - 仓库: {self.warehouse.name}'
class Meta:
verbose_name = '库存'
verbose_name_plural = '库存'
unique_together = ('product', 'warehouse')
class StockSnapshot(ModelBase):
"""库存变动快照模型"""
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
basic_info_models.Merchant,
on_delete=models.PROTECT,
related_name='stock_snapshots',
verbose_name='所属商户'
)
product = models.ForeignKey(
basic_info_models.Product,
on_delete=models.PROTECT,
related_name='stock_snapshots',
verbose_name='产品',
)
warehouse = models.ForeignKey(
basic_info_models.WareHouse,
on_delete=models.PROTECT,
related_name='stock_snapshots',
verbose_name='仓库',
)
delta = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='库存变动数量')
quantity_before = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='变动前库存数量')
quantity_after = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='变动后库存数量')
stock_change_record = models.ForeignKey(
StockChangeRecord,
on_delete=models.PROTECT,
related_name='stock_snapshots',
verbose_name='关联的库存变动记录',
)
unit = models.IntegerField(
verbose_name='单位',
choices=basic_info_models.ProductUnitEnum.choices,
)
num_of_rolls = models.IntegerField(default=1, verbose_name='匹数')
# offset_to表示该快照被冲抵到的目标快照ID
# 当offset_to不为空时cancelled不可为True 及 offset_id只能为空
# 因为该记录是冲抵记录无法再取消,也不可能冲抵的同时被冲抵
offset_to = models.BigIntegerField(
null=True,
blank=True,
verbose_name='冲抵目标ID',
)
offset_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='冲抵时间',
)
# 当cancelled为True时offset_id不可为空表示该快照已被冲抵
cancelled = models.BooleanField(default=False, verbose_name='已取消')
cancelled_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='取消时间',
)
offset_id = models.BigIntegerField(
null=True,
blank=True,
verbose_name='冲抵记录ID',
)
def __str__(self):
return f'库存快照 {self.id} - 产品: {self.product.name}'
class Meta:
verbose_name = '库存快照'
verbose_name_plural = '库存快照'
class StockFreeze(ModelBase):
"""库存冻结模型"""
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
basic_info_models.Merchant,
on_delete=models.PROTECT,
related_name='stock_freezes',
verbose_name='所属商户'
)
product = models.ForeignKey(
basic_info_models.Product,
on_delete=models.PROTECT,
related_name='stock_freezes',
verbose_name='产品',
)
warehouse = models.ForeignKey(
basic_info_models.WareHouse,
on_delete=models.PROTECT,
related_name='stock_freezes',
verbose_name='仓库',
)
stock_detail = models.ForeignKey(
StockChangeDetail,
on_delete=models.PROTECT,
related_name='stock_freezes',
verbose_name='关联的库存变动明细',
)
quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='冻结数量')
unit = models.IntegerField(
verbose_name='单位',
choices=basic_info_models.ProductUnitEnum.choices,
)
status = models.IntegerField(
choices=StockFreezeStatusEnum.choices,
default=StockFreezeStatusEnum.FROZEN,
verbose_name='冻结状态',
)
frozen_by = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
verbose_name='冻结操作人',
)
frozen_with = models.BigIntegerField(null=True, blank=True, verbose_name='冻结关联ID')
cancelled_by = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='cancelled_stock_freezes',
verbose_name='取消操作人',
)
completed_by = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='completed_stock_freezes',
verbose_name='成单操作人',
)
completed_at = models.DateTimeField(null=True, blank=True, verbose_name='成单时间')
completed_with = models.BigIntegerField(null=True, blank=True, verbose_name='成单关联ID')
cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name='取消时间')
reason = models.TextField(blank=True, null=True, verbose_name='冻结原因')
def __str__(self):
return f'库存冻结 {self.id} - 产品: {self.product.name}'
class Meta:
verbose_name = '库存冻结'
verbose_name_plural = '库存冻结'
class TransferOrderStatusEnum(models.IntegerChoices):
DRAFT = 1, '待完成'
COMPLETED = 2, '已完成'
CANCELLED = 3, '已取消'
class TransferOrder(ModelBase):
"""跨仓调拨单"""
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
basic_info_models.Merchant,
on_delete=models.PROTECT,
related_name='transfer_orders',
verbose_name='所属商户',
)
from_warehouse = models.ForeignKey(
basic_info_models.WareHouse,
on_delete=models.PROTECT,
related_name='outgoing_transfer_orders',
verbose_name='调出仓',
)
to_warehouse = models.ForeignKey(
basic_info_models.WareHouse,
on_delete=models.PROTECT,
related_name='incoming_transfer_orders',
verbose_name='调入仓',
)
mode = models.IntegerField(
choices=basic_info_models.WareHouseModeEnum.choices,
verbose_name='调拨模式',
)
operator = models.ForeignKey(
basic_info_models.Employee,
on_delete=models.PROTECT,
related_name='transfer_orders',
verbose_name='经办人',
)
created_by = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='created_transfer_orders',
verbose_name='创建人',
)
transfer_date = models.DateField(verbose_name='调拨日期')
status = models.IntegerField(
choices=TransferOrderStatusEnum.choices,
default=TransferOrderStatusEnum.DRAFT,
verbose_name='状态',
)
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
request_id = models.CharField(max_length=64, blank=True, null=True, verbose_name='请求ID')
outgoing_record = models.ForeignKey(
'stock.StockChangeRecord',
on_delete=models.PROTECT,
related_name='transfer_out_orders',
null=True,
blank=True,
verbose_name='调出出入库记录',
)
incoming_record = models.ForeignKey(
'stock.StockChangeRecord',
on_delete=models.PROTECT,
related_name='transfer_in_orders',
null=True,
blank=True,
verbose_name='调入出入库记录',
)
class Meta:
verbose_name = '调拨单'
verbose_name_plural = '调拨单'
constraints = [
models.UniqueConstraint(
fields=['merchant', 'request_id'],
condition=models.Q(request_id__isnull=False),
name='unique_transfer_request_per_merchant',
),
models.CheckConstraint(
check=~models.Q(from_warehouse=models.F('to_warehouse')),
name='transfer_different_warehouses',
),
]
def clean(self):
super().clean()
if self.from_warehouse_id and self.to_warehouse_id:
if self.from_warehouse_id == self.to_warehouse_id:
raise ValidationError('调拨的出入库仓库不能相同')
if self.from_warehouse.merchant_id != self.merchant_id or self.to_warehouse.merchant_id != self.merchant_id:
raise ValidationError('调拨仓库必须属于当前商户')
if self.from_warehouse.mode != self.to_warehouse.mode:
raise ValidationError('调拨要求出入库仓库的模式一致')
if self.mode and self.mode != self.from_warehouse.mode:
raise ValidationError('调拨模式与仓库模式不一致')
def __str__(self):
return f'调拨单 {self.id} - {self.from_warehouse.name} -> {self.to_warehouse.name}'
class TransferOrderItem(ModelBase):
id = models.BigAutoField(primary_key=True)
transfer_order = models.ForeignKey(
TransferOrder,
on_delete=models.CASCADE,
related_name='items',
verbose_name='调拨单',
)
product = models.ForeignKey(
basic_info_models.Product,
on_delete=models.PROTECT,
related_name='transfer_order_items',
verbose_name='产品',
)
unit = models.IntegerField(
choices=basic_info_models.ProductUnitEnum.choices,
verbose_name='单位',
)
total_quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量合计')
num_of_rolls = models.PositiveIntegerField(default=1, verbose_name='匹数')
class Meta:
verbose_name = '调拨单明细'
verbose_name_plural = '调拨单明细'
def __str__(self):
return f'调拨明细 {self.id} - 产品: {self.product.name}'