1
0
forked from erp-dev/erp

feat: running on docker fully, and added rabbitmq/worker container

This commit is contained in:
2025-11-25 15:56:13 +08:00
parent cd6a2370fc
commit d90917e764
34 changed files with 2474 additions and 74 deletions

View File

@@ -12,14 +12,6 @@ class StockAdminBase(admin.ModelAdmin):
result = list(result) + ['created_at']
return result
@admin.register(models.PurchaseOrder)
class PurchaseOrderAdmin(admin.ModelAdmin):
list_display = ('id', 'supplier', 'order_date', 'total_amount')
search_fields = ('supplier__name',)
list_filter = ('order_date',)
ordering = ('-order_date',)
@admin.register(models.Inventory)
class InventoryAdmin(StockAdminBase):
list_display = (

View File

@@ -0,0 +1,22 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0004_alter_stockchangerecord_source_type'),
]
operations = [
migrations.AddField(
model_name='stockchangedetail',
name='is_consumed',
field=models.BooleanField(default=False, verbose_name='是否已消耗'),
),
migrations.AddField(
model_name='stockchangedetail',
name='consume_with',
field=models.OneToOneField(blank=True, null=True, on_delete=models.PROTECT, related_name='consumed_by_detail', to='stock.stockchangedetail', verbose_name='所消耗的入库明细'),
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('stock', '0005_stockchangedetail_consume_fields'),
('business', '0001_initial'),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.DeleteModel(
name='PurchaseOrder',
),
],
),
]

View File

@@ -62,29 +62,6 @@ class StockChangeTypeEnum(models.IntegerChoices):
# ==================== 模型定义 ====================
class PurchaseOrder(ModelBase):
"""采购单模型"""
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
basic_info_models.Merchant,
on_delete=models.PROTECT,
related_name='purchase_orders',
verbose_name='所属商户'
)
supplier = models.ForeignKey(basic_info_models.Supplier, on_delete=models.PROTECT, related_name='purchase_orders', verbose_name='供应商')
order_date = models.DateField(verbose_name='订单日期')
total_amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='总金额')
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
def __str__(self):
return f'采购订单 {self.id} - {self.supplier.name}'
class Meta:
verbose_name = '采购单'
verbose_name_plural = '采购单'
class StockChangeRecord(ModelBase):
"""库存变动记录模型"""
@@ -193,9 +170,14 @@ class StockChangeDetail(ModelBase):
verbose_name='库存变动记录',
)
quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量')
unit = models.IntegerField(
verbose_name='单位',
choices=basic_info_models.ProductUnitEnum.choices,
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):

View File

@@ -13,13 +13,15 @@ logger = logging.getLogger(__name__)
def _raise_unimplemented_mode():
raise ValueError('仓库出入库模式为【严进严出】,该模式暂未支持创建出入库记录')
raise ValueError('仓库出入库模式为【严进严出】,该模式暂未支持当前操作')
def _ensure_strict_mode(warehouse: basic_models.WareHouse):
def _ensure_strict_mode(warehouse: basic_models.WareHouse, *, allow_restrict_in_out: bool = False):
if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN:
return
if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT:
if allow_restrict_in_out:
return
_raise_unimplemented_mode()
raise ValueError('仓库出入库模式为【宽进宽出】,请使用宽松模式接口创建出入库记录')
@@ -32,6 +34,105 @@ def _ensure_relaxed_mode(warehouse: basic_models.WareHouse):
raise ValueError('仓库出入库模式为【严进宽出】,请使用严谨模式接口创建出入库记录')
def _create_details_from_quantities(
*,
stock_change_record: models.StockChangeRecord,
merchant: basic_models.Merchant,
products: List[Dict[str, Any]],
) -> Tuple[List[models.StockChangeDetail], int]:
created_details: List[models.StockChangeDetail] = []
created_count = 0
for product_data in products:
product_id = product_data.get('product')
quantities = product_data.get('quantity') or []
if not product_id or not quantities:
raise ValueError('产品数据不完整,缺少 product 或 quantity')
try:
product = basic_models.Product.objects.get(id=product_id)
except basic_models.Product.DoesNotExist:
raise ValueError(f'产品ID {product_id} 不存在')
if product.merchant_id != merchant.id:
raise ValueError(f'产品ID {product_id} 不属于当前商户')
for quantity in quantities:
detail = models.StockChangeDetail.objects.create(
stock_change_record=stock_change_record,
product=product,
quantity=quantity,
merchant=merchant,
unit=product.unit,
)
created_details.append(detail)
created_count += 1
return created_details, created_count
def _create_consumption_details(
*,
stock_change_record: models.StockChangeRecord,
merchant: basic_models.Merchant,
products: List[Dict[str, Any]],
) -> List[models.StockChangeDetail]:
created_details: List[models.StockChangeDetail] = []
for product_data in products:
product_id = product_data.get('product')
consume_ids = product_data.get('consume_with') or []
if not product_id or not consume_ids:
raise ValueError('严进严出出库必须提供 consume_with 列表')
try:
product = basic_models.Product.objects.get(id=product_id)
except basic_models.Product.DoesNotExist:
raise ValueError(f'产品ID {product_id} 不存在')
if product.merchant_id != merchant.id:
raise ValueError(f'产品ID {product_id} 不属于当前商户')
inbound_details = models.StockChangeDetail.objects.select_related('stock_change_record').filter(
id__in=consume_ids
)
if inbound_details.count() != len(consume_ids):
raise ValueError('部分入库明细不存在')
inbound_map = {detail.id: detail for detail in inbound_details}
for detail_id in consume_ids:
inbound_detail = inbound_map.get(detail_id)
if inbound_detail is None:
raise ValueError('部分入库明细不存在')
if inbound_detail.stock_change_record.warehouse_id != stock_change_record.warehouse_id:
raise ValueError('入库明细与当前仓库不一致')
if inbound_detail.stock_change_record.merchant_id != merchant.id:
raise ValueError('入库明细与当前商户不一致')
if not inbound_detail.stock_change_record.is_incoming:
raise ValueError('只能引用入库明细进行出库')
if inbound_detail.is_consumed:
raise ValueError(f'入库明细 {inbound_detail.id} 已被消耗')
if inbound_detail.product_id != product_id:
raise ValueError('入库明细与当前产品不匹配')
detail = models.StockChangeDetail.objects.create(
stock_change_record=stock_change_record,
product=product,
quantity=inbound_detail.quantity,
merchant=merchant,
unit=inbound_detail.unit,
consume_with=inbound_detail,
)
inbound_detail.is_consumed = True
inbound_detail.save(update_fields=['is_consumed'])
created_details.append(detail)
return created_details
def create_stock_change_record_with_details(
*,
merchant: basic_models.Merchant,
@@ -69,7 +170,17 @@ def create_stock_change_record_with_details(
if warehouse.merchant_id != merchant.id:
raise ValueError('仓库不属于当前商户')
_ensure_strict_mode(warehouse)
allow_restrict_in_out = (
warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT
and type == models.StockChangeTypeEnum.ADD
)
is_strict_outgoing = (
warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT
and type == models.StockChangeTypeEnum.REMOVE
)
if not is_strict_outgoing:
_ensure_strict_mode(warehouse, allow_restrict_in_out=allow_restrict_in_out)
created_details: List[models.StockChangeDetail] = []
created_count = 0
@@ -85,31 +196,19 @@ def create_stock_change_record_with_details(
)
logger.info('创建新库存变动记录 ID: %s', stock_change_record.id)
for product_data in products:
product_id = product_data.get('product')
quantities = product_data.get('quantity', [])
if not product_id or not quantities:
raise ValueError('产品数据不完整,缺少 product 或 quantity')
try:
product = basic_models.Product.objects.get(id=product_id)
except basic_models.Product.DoesNotExist:
raise ValueError(f'产品ID {product_id} 不存在')
if product.merchant_id != merchant.id:
raise ValueError(f'产品ID {product_id} 不属于当前商户')
for quantity in quantities:
detail = models.StockChangeDetail.objects.create(
stock_change_record=stock_change_record,
product=product,
quantity=quantity,
merchant=merchant,
unit=product.unit,
)
created_details.append(detail)
created_count += 1
if is_strict_outgoing:
created_details = _create_consumption_details(
stock_change_record=stock_change_record,
merchant=merchant,
products=products,
)
created_count = len(created_details)
else:
created_details, created_count = _create_details_from_quantities(
stock_change_record=stock_change_record,
merchant=merchant,
products=products,
)
if warehouse.merchant.auto_complete_stock_change:
make_stock_change_completed(stock_change_record)
@@ -228,6 +327,160 @@ def create_stock_change_record_relaxed(
return stock_change_record, created_details, len(created_details)
class StockFlowService:
"""
统一出入库服务,屏蔽仓库模式差异。
items 结构支持以下字段:
- product_id: 产品ID必填
- quantities: List[Decimal | str],用于严谨/严进严出入库
- value: Decimal | str总数量用于宽进宽出
- num_of_rolls: Decimal | int每条数量用于宽进宽出
- consume_detail_ids: List[int]严进严出出库消耗的入库明细ID
"""
def __init__(self, *, merchant: basic_models.Merchant, created_by):
if merchant is None:
raise ValueError('StockFlowService 初始化必须提供商户')
self.merchant = merchant
self.created_by = created_by
def stock_in(
self,
*,
warehouse_id: int,
source_type: int,
source_id: int | None = None,
items: List[Dict[str, Any]],
):
warehouse = self._get_and_validate_warehouse(warehouse_id)
products = self._build_products_payload(warehouse, is_incoming=True, items=items)
return self._create_record(
warehouse=warehouse,
record_type=models.StockChangeTypeEnum.ADD,
source_type=source_type,
source_id=source_id,
products=products,
)
def stock_out(
self,
*,
warehouse_id: int,
source_type: int,
source_id: int | None = None,
items: List[Dict[str, Any]],
):
warehouse = self._get_and_validate_warehouse(warehouse_id)
products = self._build_products_payload(warehouse, is_incoming=False, items=items)
return self._create_record(
warehouse=warehouse,
record_type=models.StockChangeTypeEnum.REMOVE,
source_type=source_type,
source_id=source_id,
products=products,
)
def _get_and_validate_warehouse(self, warehouse_id: int) -> basic_models.WareHouse:
if not warehouse_id:
raise ValueError('必须提供仓库ID')
try:
warehouse = basic_models.WareHouse.objects.get(id=warehouse_id)
except basic_models.WareHouse.DoesNotExist:
raise ValueError(f'仓库ID {warehouse_id} 不存在')
if warehouse.merchant_id != self.merchant.id:
raise ValueError('仓库不属于当前商户')
return warehouse
def _create_record(
self,
*,
warehouse: basic_models.WareHouse,
record_type: int,
source_type: int,
source_id: int | None,
products: List[Dict[str, Any]],
):
common_kwargs = dict(
merchant=self.merchant,
created_by=self.created_by,
type=record_type,
warehouse_id=warehouse.id,
source_type=source_type,
source_id=source_id,
products=products,
)
if warehouse.mode == basic_models.WareHouseModeEnum.UNRESTRICTED:
return create_stock_change_record_relaxed(**common_kwargs)
# 严谨或严进严出模式统一由严谨服务处理
return create_stock_change_record_with_details(**common_kwargs)
def _build_products_payload(
self,
warehouse: basic_models.WareHouse,
*,
is_incoming: bool,
items: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
if not items:
raise ValueError('产品明细不能为空')
mode = warehouse.mode
if mode == basic_models.WareHouseModeEnum.UNRESTRICTED:
return self._build_relaxed_items(items)
if mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT and not is_incoming:
return self._build_restrict_out_items(items)
# 严谨模式 + 严进严出入库复用数量列表
return self._build_strict_items(items)
@staticmethod
def _build_strict_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
payload: List[Dict[str, Any]] = []
for item in items:
product_id = item.get('product_id')
quantities = item.get('quantities') or []
if not product_id or not quantities:
raise ValueError('严谨模式需要提供 product_id 与 quantities 列表')
payload.append({'product': product_id, 'quantity': quantities})
return payload
@staticmethod
def _build_relaxed_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
payload: List[Dict[str, Any]] = []
for item in items:
product_id = item.get('product_id')
total_value = item.get('value')
unit_size = item.get('num_of_rolls', 1)
if not product_id or total_value is None:
raise ValueError('宽进宽出需要提供 product_id 与 value')
payload.append({
'product': product_id,
'quantity': {
'value': total_value,
'unit_count': unit_size,
}
})
return payload
@staticmethod
def _build_restrict_out_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
payload: List[Dict[str, Any]] = []
for item in items:
product_id = item.get('product_id')
consume_ids = item.get('consume_detail_ids') or []
if not product_id or not consume_ids:
raise ValueError('严进严出出库需要提供 product_id 与 consume_detail_ids')
payload.append({
'product': product_id,
'consume_with': consume_ids,
})
return payload
def find_inventory(product_id: int, warehouse_id: int) -> models.Inventory | None:
"""根据产品ID和仓库ID查找库存记录"""

View File

@@ -6,6 +6,7 @@ from unittest.mock import patch, MagicMock
import logging
from basic_info.models import Product, WareHouse, Supplier, ProductUnitEnum, ProductCategory, Merchant, MerchantTypeEnum, WareHouseModeEnum
from business.models import PurchaseOrder
from . import models, services
@@ -83,7 +84,7 @@ class StockServicesTestCase(TestCase):
def setUp(self):
"""每个测试方法执行前的设置"""
# 创建采购单
self.purchase_order = models.PurchaseOrder.objects.create(
self.purchase_order = PurchaseOrder.objects.create(
merchant=self.merchant,
supplier=self.supplier,
order_date=timezone.now().date(),
@@ -456,23 +457,129 @@ class CreateStockChangeRecordWithDetailsTestCase(StockServicesTestCase):
self.assertIn('宽进宽出', str(ctx.exception))
def test_strict_mode_not_implemented_for_restrict_in_out(self):
"""严进严出模式暂不支持"""
def test_restrict_in_out_incoming_behaves_like_strict(self):
"""严进严出模式的入库沿用严谨逻辑"""
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
self.warehouse_main.save()
with self.assertRaises(ValueError) as ctx:
record, details, created_count = services.create_stock_change_record_with_details(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.ADD,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
products=[{'product': self.product_fabric_a.id, 'quantity': [Decimal('5.00'), Decimal('6.00')]}],
)
self.assertEqual(created_count, 2)
self.assertEqual(record.details.count(), 2)
def test_restrict_in_out_outgoing_consumes_detail(self):
"""严进严出模式的出库需要消耗入库明细"""
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
self.warehouse_main.save()
products = [{
'product': self.product_fabric_a.id,
'consume_with': [self.stock_detail_in.id],
}]
record, details, created_count = services.create_stock_change_record_with_details(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.REMOVE,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.SALES,
source_id=123,
products=products,
)
self.assertEqual(created_count, 1)
detail = details[0]
self.stock_detail_in.refresh_from_db()
self.assertTrue(self.stock_detail_in.is_consumed)
self.assertEqual(detail.consume_with_id, self.stock_detail_in.id)
self.assertEqual(detail.quantity, self.stock_detail_in.quantity)
def test_restrict_in_out_outgoing_requires_consume_ids(self):
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
self.warehouse_main.save()
with self.assertRaises(ValueError):
services.create_stock_change_record_with_details(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.ADD,
type=models.StockChangeTypeEnum.REMOVE,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
products=[{'product': self.product_fabric_a.id, 'quantity': [Decimal('10.00')]}],
source_type=models.StockChangeSourceEnum.SALES,
source_id=123,
products=[{'product': self.product_fabric_a.id}],
)
self.assertIn('严进严出', str(ctx.exception))
def test_restrict_in_out_outgoing_rejects_consumed_detail(self):
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
self.warehouse_main.save()
products = [{
'product': self.product_fabric_a.id,
'consume_with': [self.stock_detail_in.id],
}]
services.create_stock_change_record_with_details(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.REMOVE,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.SALES,
source_id=123,
products=products,
)
with self.assertRaises(ValueError):
services.create_stock_change_record_with_details(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.REMOVE,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.SALES,
source_id=124,
products=products,
)
def test_restrict_in_out_outgoing_rejects_other_warehouse_detail(self):
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
self.warehouse_main.save()
other_record = models.StockChangeRecord.objects.create(
merchant=self.merchant,
type=models.StockChangeTypeEnum.ADD,
source_type=models.StockChangeSourceEnum.PURCHASE,
warehouse=self.warehouse_backup,
)
other_detail = models.StockChangeDetail.objects.create(
merchant=self.merchant,
stock_change_record=other_record,
product=self.product_fabric_a,
quantity=Decimal('8.00'),
unit=self.unit_meter,
)
products = [{
'product': self.product_fabric_a.id,
'consume_with': [other_detail.id],
}]
with self.assertRaises(ValueError):
services.create_stock_change_record_with_details(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.REMOVE,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.SALES,
source_id=125,
products=products,
)
class CreateStockChangeRecordRelaxedTestCase(StockServicesTestCase):
@@ -748,5 +855,82 @@ class StockServicesIntegrationTestCase(StockServicesTestCase):
self.assertEqual(out_snapshot.quantity_after, 50)
class StockFlowServiceTestCase(TestCase):
"""StockFlowService 统一出入库服务测试"""
def setUp(self):
self.merchant = Merchant.objects.create(
name='统一服务商户',
type=MerchantTypeEnum.FACTORY,
)
self.category = ProductCategory.objects.create(
merchant=self.merchant,
name='面料',
product_prefix='FAB',
)
self.product = Product.objects.create(
merchant=self.merchant,
category=self.category,
name='测试面料',
human_id='FAB-0001',
unit=ProductUnitEnum.METER,
)
self.strict_warehouse = WareHouse.objects.create(
merchant=self.merchant,
name='严谨仓',
mode=WareHouseModeEnum.RESTRICT_IN,
)
self.relaxed_warehouse = WareHouse.objects.create(
merchant=self.merchant,
name='宽松仓',
mode=WareHouseModeEnum.UNRESTRICTED,
)
self.restrict_in_out = WareHouse.objects.create(
merchant=self.merchant,
name='严进严出仓',
mode=WareHouseModeEnum.RESTRICT_IN_OUT,
)
self.service = services.StockFlowService(merchant=self.merchant, created_by=None)
def test_stock_in_strict_mode_uses_quantities(self):
record, details, count = self.service.stock_in(
warehouse_id=self.strict_warehouse.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=10,
items=[{'product_id': self.product.id, 'quantities': ['5', '3']}],
)
self.assertEqual(count, 2)
self.assertEqual(record.warehouse_id, self.strict_warehouse.id)
self.assertEqual(len(details), 2)
def test_stock_in_relaxed_mode_splits_value(self):
_, details, count = self.service.stock_in(
warehouse_id=self.relaxed_warehouse.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=11,
items=[{'product_id': self.product.id, 'value': '9', 'num_of_rolls': 4}],
)
self.assertEqual(count, 3) # 4 + 4 + 1
self.assertEqual(len(details), 3)
def test_stock_out_restrict_mode_consumes_details(self):
_, inbound_details, _ = self.service.stock_in(
warehouse_id=self.restrict_in_out.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=12,
items=[{'product_id': self.product.id, 'quantities': ['6']}],
)
detail_id = inbound_details[0].id
_, outbound_details, _ = self.service.stock_out(
warehouse_id=self.restrict_in_out.id,
source_type=models.StockChangeSourceEnum.SALES,
source_id=13,
items=[{'product_id': self.product.id, 'consume_detail_ids': [detail_id]}],
)
inbound_details[0].refresh_from_db()
self.assertTrue(inbound_details[0].is_consumed)
self.assertEqual(outbound_details[0].consume_with_id, detail_id)
# 禁用测试期间的日志输出
logging.disable(logging.CRITICAL)