forked from erp-dev/erp
521 lines
17 KiB
Python
521 lines
17 KiB
Python
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}'
|