1
0
forked from erp-dev/erp

feat: stock module

This commit is contained in:
2025-11-04 18:04:27 +08:00
parent 9fde654e2f
commit d88bf367f8
27 changed files with 2238 additions and 561 deletions

View File

@@ -1,9 +1,13 @@
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
import decimal
class PurchaseOrder(ModelBase):
"""采购单模型"""
id = models.BigAutoField(primary_key=True)
supplier = models.ForeignKey(basic_info_models.Supplier, on_delete=models.PROTECT, related_name='purchase_orders', verbose_name='供应商')
order_date = models.DateField(verbose_name='订单日期')
@@ -18,7 +22,114 @@ class PurchaseOrder(ModelBase):
verbose_name_plural = '采购单'
class StockChangeSourceEnum(models.IntegerChoices):
"""库存变动来源类型"""
PURCHASE = 1, '采购'
SALES_RETURN = 2, '销退'
TRANSPORT_IN = 3, '调入'
RECHECK_ADD = 4, '盘盈'
SALES = 5, '销售'
PURCHASE_RETURN = 6, '采购退货'
TRANSPORT_OUT = 7, '调出'
RECHECK_REMOVE = 8, '盘亏'
class StockChangeTypeEnum(models.IntegerChoices):
"""库存变动类型"""
ADD = 1, '入库'
REMOVE = 2, '出库'
class StockChangeRecord(ModelBase):
"""库存变动记录模型"""
id = models.BigAutoField(primary_key=True)
type = models.IntegerField(choices=StockChangeTypeEnum.choices, verbose_name='变动类型')
product = models.ForeignKey(
basic_info_models.Product,
on_delete=models.PROTECT,
related_name='stock_change_records',
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='变动来源',
)
unit = models.IntegerField(
verbose_name='单位',
choices=basic_info_models.ProductUnitEnum.choices,
)
num_of_rolls = models.IntegerField(
default=1,
verbose_name='匹数',
)
created_by = models.ForeignKey(
'auth.User',
on_delete=models.PROTECT,
null=True,
blank=True,
verbose_name='操作人',
)
quantity = models.DecimalField(max_digits=10, decimal_places=2, 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='完成时间')
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
def __str__(self):
return f'出入库记录 {self.id} - 仓库:{self.warehouse.name} - 产品: {self.product.name}'
class Meta:
verbose_name = '出入库记录'
verbose_name_plural = '出入库记录'
def clean(self):
if self.type == StockChangeTypeEnum.ADD:
if self.source_type > 4:
raise ValidationError('入库记录的来源类型无效')
elif self.type == StockChangeTypeEnum.REMOVE:
if self.source_type < 5:
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 delta(self) -> decimal.Decimal:
"""计算库存变动的数量增减"""
# 入库为正,出库为负
if self.type == StockChangeTypeEnum.ADD:
return self.quantity
elif self.type == StockChangeTypeEnum.REMOVE:
return -self.quantity
else:
raise ValueError("未知的库存变动类型")
class Inventory(ModelBase):
"""库存模型"""
id = models.BigAutoField(primary_key=True)
product = models.ForeignKey(
basic_info_models.Product,
@@ -32,8 +143,8 @@ class Inventory(ModelBase):
related_name='inventories',
verbose_name='仓库',
)
quantity = models.IntegerField(verbose_name='库存数量')
num_of_rolls = models.IntegerField(null=True, blank=True, 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='备注描述')
@@ -44,3 +155,70 @@ class Inventory(ModelBase):
class Meta:
verbose_name = '库存'
verbose_name_plural = '库存'
unique_together = ('product', 'warehouse')
class StockSnapshot(ModelBase):
"""库存变动快照模型"""
id = models.BigAutoField(primary_key=True)
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.snapshot_date} - 产品: {self.product.name}'
class Meta:
verbose_name = '库存快照'
verbose_name_plural = '库存快照'