1
0
forked from erp-dev/erp

feat: relaxed in restrict out

This commit is contained in:
2025-11-24 15:57:19 +08:00
parent 113818f73d
commit cd6a2370fc
11 changed files with 840 additions and 5 deletions

View File

@@ -4,6 +4,7 @@ from django.db.models import Sum
from django.utils import timezone
from sse.services import push_simple_message_with_object_id
from basic_info import models as basic_models
from decimal import Decimal, InvalidOperation
import logging
from typing import List, Dict, Any, Tuple
@@ -11,6 +12,26 @@ from typing import List, Dict, Any, Tuple
logger = logging.getLogger(__name__)
def _raise_unimplemented_mode():
raise ValueError('仓库出入库模式为【严进严出】,该模式暂未支持创建出入库记录')
def _ensure_strict_mode(warehouse: basic_models.WareHouse):
if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN:
return
if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT:
_raise_unimplemented_mode()
raise ValueError('仓库出入库模式为【宽进宽出】,请使用宽松模式接口创建出入库记录')
def _ensure_relaxed_mode(warehouse: basic_models.WareHouse):
if warehouse.mode == basic_models.WareHouseModeEnum.UNRESTRICTED:
return
if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT:
_raise_unimplemented_mode()
raise ValueError('仓库出入库模式为【严进宽出】,请使用严谨模式接口创建出入库记录')
def create_stock_change_record_with_details(
*,
merchant: basic_models.Merchant,
@@ -48,6 +69,7 @@ def create_stock_change_record_with_details(
if warehouse.merchant_id != merchant.id:
raise ValueError('仓库不属于当前商户')
_ensure_strict_mode(warehouse)
created_details: List[models.StockChangeDetail] = []
created_count = 0
@@ -96,6 +118,116 @@ def create_stock_change_record_with_details(
return stock_change_record, created_details, created_count
def _to_decimal(value, field_name: str) -> Decimal:
try:
return Decimal(str(value))
except (InvalidOperation, TypeError):
raise ValueError(f'{field_name} 必须是合法的数值')
def _split_quantities(total: Decimal, unit_size: Decimal) -> List[Decimal]:
if total <= 0:
raise ValueError('quantity.value 必须大于 0')
if unit_size <= 0:
raise ValueError('quantity.unit_count 必须大于 0')
quantities: List[Decimal] = []
num_full = int(total // unit_size)
remainder = total % unit_size
if num_full == 0:
quantities.append(total)
else:
quantities.extend([unit_size] * num_full)
if remainder > 0:
quantities.append(remainder)
return quantities
def create_stock_change_record_relaxed(
*,
merchant: basic_models.Merchant,
created_by,
type: int,
warehouse_id: int,
source_type: int,
source_id: int | None = None,
products: List[Dict[str, Any]] | None = None,
) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail], int]:
"""
宽松模式:根据总数量和单条数量自动拆分明细
"""
if not merchant:
raise ValueError('必须提供商户信息')
if not warehouse_id:
raise ValueError('必须提供仓库ID')
if not products:
raise ValueError('产品列表不能为空')
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 != merchant.id:
raise ValueError('仓库不属于当前商户')
_ensure_relaxed_mode(warehouse)
created_details: List[models.StockChangeDetail] = []
with transaction.atomic():
stock_change_record = models.StockChangeRecord.objects.create(
type=type,
warehouse=warehouse,
source_type=source_type,
source_id=source_id,
merchant=merchant,
created_by=created_by,
)
logger.info('创建宽松模式库存变动记录 ID: %s', stock_change_record.id)
for product_data in products:
product_id = product_data.get('product')
quantity_data = product_data.get('quantity') or {}
if not product_id:
raise ValueError('产品ID不能为空')
total_value = quantity_data.get('value')
unit_size = quantity_data.get('unit_count', 1)
total_value = _to_decimal(total_value, 'quantity.value')
unit_size = _to_decimal(unit_size, 'quantity.unit_count')
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} 不属于当前商户')
quantities = _split_quantities(total_value, unit_size)
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)
if warehouse.merchant.auto_complete_stock_change:
make_stock_change_completed(stock_change_record)
logger.info('自动确认宽松模式库存变动记录 ID: %s', stock_change_record.id)
return stock_change_record, created_details, len(created_details)
def find_inventory(product_id: int, warehouse_id: int) -> models.Inventory | None:
"""根据产品ID和仓库ID查找库存记录"""

View File

@@ -5,7 +5,7 @@ from decimal import Decimal
from unittest.mock import patch, MagicMock
import logging
from basic_info.models import Product, WareHouse, Supplier, ProductUnitEnum, ProductCategory, Merchant, MerchantTypeEnum
from basic_info.models import Product, WareHouse, Supplier, ProductUnitEnum, ProductCategory, Merchant, MerchantTypeEnum, WareHouseModeEnum
from . import models, services
@@ -437,6 +437,191 @@ class CreateStockChangeRecordWithDetailsTestCase(StockServicesTestCase):
source_id=self.purchase_order.id,
products=products,
)
def test_strict_mode_requires_restrict_in(self):
"""严谨模式仅允许在严进宽出仓库中使用"""
self.warehouse_main.mode = WareHouseModeEnum.UNRESTRICTED
self.warehouse_main.save()
with self.assertRaises(ValueError) as ctx:
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('10.00')]}],
)
self.assertIn('宽进宽出', str(ctx.exception))
def test_strict_mode_not_implemented_for_restrict_in_out(self):
"""严进严出模式暂不支持"""
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
self.warehouse_main.save()
with self.assertRaises(ValueError) as ctx:
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('10.00')]}],
)
self.assertIn('严进严出', str(ctx.exception))
class CreateStockChangeRecordRelaxedTestCase(StockServicesTestCase):
"""测试宽松模式的库存变动创建"""
def setUp(self):
super().setUp()
self.warehouse_main.mode = WareHouseModeEnum.UNRESTRICTED
self.warehouse_main.save()
def test_relaxed_even_split(self):
products = [
{
'product': self.product_fabric_a.id,
'quantity': {'value': Decimal('100.00'), 'unit_count': Decimal('25.00')},
}
]
record, details, created_count = services.create_stock_change_record_relaxed(
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=products,
)
self.assertEqual(created_count, 4)
self.assertEqual(record.details.count(), 4)
self.assertListEqual(
[detail.quantity for detail in details],
[Decimal('25.00')] * 4,
)
def test_relaxed_with_remainder(self):
products = [
{
'product': self.product_fabric_a.id,
'quantity': {'value': Decimal('105.00'), 'unit_count': Decimal('30.00')},
}
]
_, details, created_count = services.create_stock_change_record_relaxed(
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=products,
)
self.assertEqual(created_count, 4)
self.assertListEqual(
[detail.quantity for detail in details],
[Decimal('30.00'), Decimal('30.00'), Decimal('30.00'), Decimal('15.00')],
)
def test_relaxed_invalid_unit_count(self):
products = [
{
'product': self.product_fabric_a.id,
'quantity': {'value': Decimal('50.00'), 'unit_count': Decimal('0')},
}
]
with self.assertRaises(ValueError):
services.create_stock_change_record_relaxed(
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=products,
)
def test_relaxed_auto_complete_triggers_completion(self):
self.merchant.auto_complete_stock_change = True
self.merchant.save()
products = [
{
'product': self.product_fabric_a.id,
'quantity': {'value': Decimal('10.00'), 'unit_count': Decimal('2.00')},
}
]
with patch('stock.services.make_stock_change_completed') as mock_complete:
services.create_stock_change_record_relaxed(
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=products,
)
mock_complete.assert_called_once()
def test_relaxed_mode_requires_unrestricted(self):
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN
self.warehouse_main.save()
products = [
{
'product': self.product_fabric_a.id,
'quantity': {'value': Decimal('10.00'), 'unit_count': Decimal('2.00')},
}
]
with self.assertRaises(ValueError) as ctx:
services.create_stock_change_record_relaxed(
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=products,
)
self.assertIn('严进宽出', str(ctx.exception))
def test_relaxed_mode_not_implemented_for_restrict_in_out(self):
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
self.warehouse_main.save()
products = [
{
'product': self.product_fabric_a.id,
'quantity': {'value': Decimal('10.00'), 'unit_count': Decimal('2.00')},
}
]
with self.assertRaises(ValueError) as ctx:
services.create_stock_change_record_relaxed(
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=products,
)
self.assertIn('严进严出', str(ctx.exception))
def test_complete_outbound_record(self):
"""测试完成出库记录"""