1
0
forked from erp-dev/erp

feat: added print_count api, added merchant setting model, added manual mode setting for purchase order

This commit is contained in:
2025-11-28 15:01:11 +08:00
parent 53f2a24b20
commit 0c62398120
36 changed files with 1634 additions and 478 deletions

View File

@@ -1,21 +1,25 @@
from __future__ import annotations
from typing import Any, Dict, List
from django.db import transaction
import logging
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Dict, List
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Tuple
from django.contrib.auth import get_user_model
from django.db import transaction
from basic_info import models as basic_info_models
from stock import models as stock_models
from stock.services import StockFlowService
from basic_info.services import MerchantSettingService
from . import models
from .tasks import create_purchase_order_stock_entries
logger = logging.getLogger(__name__)
def _normalize_order_date(value) -> date:
if isinstance(value, date):
return value
@@ -34,8 +38,8 @@ def create_purchase_order(
merchant: basic_info_models.Merchant,
supplier: basic_info_models.Supplier,
order_date,
total_amount,
warehouse_id: int,
warehouse: basic_info_models.WareHouse,
operator: basic_info_models.Employee,
items: List[Dict[str, Any]],
remarks: str | None = '',
created_by=None,
@@ -47,40 +51,190 @@ def create_purchase_order(
merchant: 采购单所属商户
supplier: 供应商
order_date: 订单日期 (date)
total_amount: 总金额
warehouse_id: 入库仓库ID
items: 产品明细,格式示例:
[
{'product_id': 1, 'quantities': ['10.00', '5.00']},
{'product_id': 2, 'quantities': ['3.50']},
]
该结构会被传递给 StockFlowService需满足其模式要求。
warehouse: 入库仓库实例
operator: 经办人
items: 产品明细,字段会根据仓库模式校验
remarks: 备注
created_by: 创建者用户(可选,用于 stock 记录中的 created_by
"""
if not items:
raise ValueError('items 不能为空')
if not warehouse_id:
raise ValueError('warehouse_id 不能为空')
normalized_date = _normalize_order_date(order_date)
total_amount = Decimal(str(total_amount))
purchase_items, stock_flow_items = _normalize_purchase_items(
merchant=merchant,
warehouse=warehouse,
items=items,
)
with transaction.atomic():
purchase_order = models.PurchaseOrder.objects.create(
merchant=merchant,
supplier=supplier,
order_date=normalized_date,
total_amount=total_amount,
purchase_date=normalized_date,
operator=operator,
warehouse=warehouse,
remarks=remarks,
)
bulk_objects = [
models.PurchaseOrderItem(
purchase_order=purchase_order,
product=item_data['product'],
price=item_data['price'],
color=item_data.get('color'),
quantity=item_data['quantity'],
unit=item_data['unit'],
empty_diff_percent=item_data['empty_diff_percent'],
quantity_of_rolls=item_data.get('quantity_of_rolls'),
num_of_rolls=item_data['num_of_rolls'],
batch_number=item_data.get('batch_number'),
remarks=item_data.get('remarks'),
)
for item_data in purchase_items
]
models.PurchaseOrderItem.objects.bulk_create(bulk_objects)
created_by_id = getattr(created_by, 'id', None)
create_purchase_order_stock_entries.delay(
purchase_order_id=purchase_order.id,
warehouse_id=warehouse_id,
items=items,
created_by_id=created_by_id,
)
if MerchantSettingService.get_setting(merchant, basic_info_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS).value is True:
logger.info('自动创建出入库记录任务已开启,创建入库记录任务')
create_purchase_order_stock_entries.delay(
purchase_order_id=purchase_order.id,
warehouse_id=warehouse.id,
items=stock_flow_items,
created_by_id=created_by_id,
)
return purchase_order
def _normalize_purchase_items(
*,
merchant: basic_info_models.Merchant,
warehouse: basic_info_models.WareHouse,
items: List[Dict[str, Any]],
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
根据仓库模式校验采购明细,并返回
- purchase_items: 用于创建 PurchaseOrderItem
- stock_flow_items: 传递给 StockFlowService 的 items 结构
"""
purchase_items: List[Dict[str, Any]] = []
stock_flow_items: List[Dict[str, Any]] = []
warehouse_mode = warehouse.mode
for index, raw_item in enumerate(items):
product_id = raw_item.get('product_id')
if not product_id:
raise ValueError(f'items[{index}].product_id 不能为空')
try:
product = basic_info_models.Product.objects.get(id=product_id, merchant=merchant)
except basic_info_models.Product.DoesNotExist as exc:
raise ValueError(f'产品 {product_id} 不存在或不属于当前商户') from exc
price = _to_decimal(raw_item.get('price', '0'), f'items[{index}].price')
empty_diff_percent = _to_decimal(raw_item.get('empty_diff_percent', '0'), f'items[{index}].empty_diff_percent')
color = raw_item.get('color')
batch_number = raw_item.get('batch_number')
remarks = raw_item.get('remarks')
unit = raw_item.get('unit') or product.get_unit_display() or ''
if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED:
if 'numbers' in raw_item and raw_item['numbers']:
raise ValueError(f'仓库为宽进模式items[{index}] 不应提供 numbers')
quantity = _to_positive_int(raw_item.get('quantity'), f'items[{index}].quantity')
num_of_rolls = _to_positive_int(raw_item.get('num_of_rolls'), f'items[{index}].num_of_rolls')
quantity_of_rolls = None
stock_flow_items.append({
'product_id': product.id,
'value': str(quantity),
'num_of_rolls': num_of_rolls,
})
else:
numbers = raw_item.get('numbers')
if not numbers or not isinstance(numbers, list):
raise ValueError(f'仓库为严进模式items[{index}] 需要提供 numbers 数组')
normalized_numbers = [
str(_to_positive_int(value, f'items[{index}].numbers[{pos}]'))
for pos, value in enumerate(numbers)
]
num_of_rolls = len(normalized_numbers)
quantity = sum(int(val) for val in normalized_numbers)
quantity_of_rolls = ','.join(normalized_numbers)
stock_flow_items.append({
'product_id': product.id,
'quantities': normalized_numbers,
})
purchase_items.append({
'product': product,
'price': price,
'color': color,
'quantity': quantity,
'unit': unit,
'empty_diff_percent': empty_diff_percent,
'quantity_of_rolls': quantity_of_rolls,
'num_of_rolls': num_of_rolls,
'batch_number': batch_number,
'remarks': remarks,
})
return purchase_items, stock_flow_items
def create_purchase_order_stock_entries_sync(
*,
purchase_order_id: int,
warehouse_id: int,
items: List[Dict[str, Any]],
created_by_id: int | None = None,
) -> Dict[str, Any]:
"""
根据采购单生成入库记录。
"""
try:
purchase_order = models.PurchaseOrder.objects.select_related('merchant').get(id=purchase_order_id)
except models.PurchaseOrder.DoesNotExist:
logger.error('PurchaseOrder %s 不存在,无法创建入库单', purchase_order_id)
return {'error': 'purchase_order_not_found', 'purchase_order_id': purchase_order_id}
merchant = purchase_order.merchant
created_by = None
if created_by_id:
UserModel = get_user_model()
created_by = UserModel.objects.filter(id=created_by_id).first()
service = StockFlowService(merchant=merchant, created_by=created_by)
record, details, created_count = service.stock_in(
warehouse_id=warehouse_id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=purchase_order.id,
items=items,
)
payload = {
'purchase_order_id': purchase_order.id,
'stock_change_record_id': getattr(record, 'id', None),
'created_details_count': created_count,
}
logger.info('采购单 %s 入库任务完成: %s', purchase_order.id, payload)
return payload
def _to_decimal(value, field_name: str) -> Decimal:
try:
return Decimal(str(value))
except (InvalidOperation, TypeError) as exc:
raise ValueError(f'{field_name} 必须是合法数值') from exc
def _to_positive_int(value, field_name: str) -> int:
if value is None:
raise ValueError(f'{field_name} 不能为空')
decimal_value = _to_decimal(value, field_name)
if decimal_value <= 0:
raise ValueError(f'{field_name} 必须大于 0')
if decimal_value != decimal_value.to_integral_value():
raise ValueError(f'{field_name} 必须为整数')
return int(decimal_value)

View File

@@ -1,15 +1,8 @@
import logging
from typing import Any, Dict, List
from celery import shared_task
from django.contrib.auth import get_user_model
from business import models as business_models
from stock import models as stock_models
from stock.services import StockFlowService
logger = logging.getLogger(__name__)
from business import services as business_services
@shared_task(bind=True)
@@ -20,37 +13,13 @@ def create_purchase_order_stock_entries(
warehouse_id: int,
items: List[Dict[str, Any]],
created_by_id: int | None = None,
):
"""
为采购单创建入库记录。仅支持入库StockFlowService.stock_in
"""
try:
purchase_order = business_models.PurchaseOrder.objects.select_related('merchant').get(id=purchase_order_id)
except business_models.PurchaseOrder.DoesNotExist:
logger.error('PurchaseOrder %s 不存在,无法创建入库单', purchase_order_id)
return {'error': 'purchase_order_not_found', 'purchase_order_id': purchase_order_id}
merchant = purchase_order.merchant
created_by = None
if created_by_id:
UserModel = get_user_model()
created_by = UserModel.objects.filter(id=created_by_id).first()
service = StockFlowService(merchant=merchant, created_by=created_by)
record, details, created_count = service.stock_in(
) -> Dict[str, Any]:
payload = business_services.create_purchase_order_stock_entries_sync(
purchase_order_id=purchase_order_id,
warehouse_id=warehouse_id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=purchase_order.id,
items=items,
created_by_id=created_by_id,
)
payload = {
'task_id': self.request.id,
'purchase_order_id': purchase_order.id,
'stock_change_record_id': getattr(record, 'id', None),
'created_details_count': created_count,
}
logger.info('采购单 %s 入库任务完成: %s', purchase_order.id, payload)
payload['task_id'] = self.request.id
return payload

View File

@@ -1,4 +1,3 @@
from decimal import Decimal
from typing import List, Dict, Any
from django.contrib.auth import get_user_model
@@ -20,11 +19,16 @@ def create_basic_fixtures():
merchant=merchant,
name='测试供应商',
)
warehouse = basic_models.WareHouse.objects.create(
warehouse_strict = basic_models.WareHouse.objects.create(
merchant=merchant,
name='',
name='严进',
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
)
warehouse_relaxed = basic_models.WareHouse.objects.create(
merchant=merchant,
name='宽进仓',
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
)
category = basic_models.ProductCategory.objects.create(
merchant=merchant,
name='面料',
@@ -37,36 +41,56 @@ def create_basic_fixtures():
human_id='FAB-001',
unit=basic_models.ProductUnitEnum.METER,
)
return merchant, supplier, warehouse, product
operator = basic_models.Employee.objects.create(
merchant=merchant,
name='经办人',
status=basic_models.EmployeeStatusEnum.ACTIVE,
)
return merchant, supplier, warehouse_strict, warehouse_relaxed, product, operator
class PurchaseOrderServiceTestCase(TestCase):
def setUp(self):
self.merchant, self.supplier, self.warehouse, self.product = create_basic_fixtures()
(
self.merchant,
self.supplier,
self.warehouse_strict,
self.warehouse_relaxed,
self.product,
self.operator,
) = create_basic_fixtures()
User = get_user_model()
self.user = User.objects.create_user(username='creator', password='pass123')
self.items: List[Dict[str, Any]] = [
{'product_id': self.product.id, 'quantities': ['10.00', '5.00']},
self.strict_items: List[Dict[str, Any]] = [
{'product_id': self.product.id, 'numbers': [10, 5], 'price': '12.5', 'unit': ''}
]
self.relaxed_items: List[Dict[str, Any]] = [
{'product_id': self.product.id, 'quantity': 120, 'num_of_rolls': 3, 'price': '10.0', 'unit': ''}
]
def test_create_purchase_order_triggers_task(self):
def test_create_purchase_order_triggers_task_strict(self):
with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay:
purchase_order = services.create_purchase_order(
merchant=self.merchant,
supplier=self.supplier,
order_date=timezone.now().date(),
total_amount=Decimal('100.00'),
warehouse_id=self.warehouse.id,
items=self.items,
warehouse=self.warehouse_strict,
operator=self.operator,
items=self.strict_items,
remarks='自动化测试',
created_by=self.user,
)
self.assertIsInstance(purchase_order, business_models.PurchaseOrder)
self.assertEqual(purchase_order.warehouse, self.warehouse_strict)
self.assertEqual(purchase_order.operator, self.operator)
self.assertEqual(purchase_order.items.count(), 1)
item = purchase_order.items.first()
self.assertEqual(item.quantity_of_rolls, '10,5')
mock_delay.assert_called_once_with(
purchase_order_id=purchase_order.id,
warehouse_id=self.warehouse.id,
items=self.items,
warehouse_id=self.warehouse_strict.id,
items=[{'product_id': self.product.id, 'quantities': ['10', '5']}],
created_by_id=self.user.id,
)
@@ -76,11 +100,75 @@ class PurchaseOrderServiceTestCase(TestCase):
merchant=self.merchant,
supplier=self.supplier,
order_date=timezone.now().date(),
total_amount=Decimal('50.00'),
warehouse_id=self.warehouse.id,
warehouse=self.warehouse_strict,
operator=self.operator,
items=[],
)
def test_create_purchase_order_relaxed_items(self):
with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay:
purchase_order = services.create_purchase_order(
merchant=self.merchant,
supplier=self.supplier,
order_date=timezone.now().date(),
warehouse=self.warehouse_relaxed,
operator=self.operator,
items=self.relaxed_items,
created_by=self.user,
)
self.assertEqual(purchase_order.items.first().quantity_of_rolls, None)
mock_delay.assert_called_once_with(
purchase_order_id=purchase_order.id,
warehouse_id=self.warehouse_relaxed.id,
items=[{'product_id': self.product.id, 'value': '120', 'num_of_rolls': 3}],
created_by_id=self.user.id,
)
class PurchaseOrderStockServiceTestCase(TestCase):
def setUp(self):
(
self.merchant,
self.supplier,
self.warehouse_strict,
self.warehouse_relaxed,
self.product,
self.operator,
) = create_basic_fixtures()
User = get_user_model()
self.user = User.objects.create_user(username='svc-user', password='pass123')
self.purchase_order = business_models.PurchaseOrder.objects.create(
merchant=self.merchant,
supplier=self.supplier,
purchase_date=timezone.now().date(),
operator=self.operator,
warehouse=self.warehouse_strict,
)
self.items = [{'product_id': self.product.id, 'quantities': ['8']}]
def test_service_calls_stock_flow_service(self):
with patch('business.services.StockFlowService') as mock_flow_cls:
mock_instance = mock_flow_cls.return_value
mock_instance.stock_in.return_value = (MagicMock(id=321), [], 2)
payload = services.create_purchase_order_stock_entries_sync(
purchase_order_id=self.purchase_order.id,
warehouse_id=self.warehouse_strict.id,
items=self.items,
created_by_id=self.user.id,
)
mock_flow_cls.assert_called_once_with(merchant=self.merchant, created_by=self.user)
mock_instance.stock_in.assert_called_once_with(
warehouse_id=self.warehouse_strict.id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
items=[{'product_id': self.product.id, 'quantities': ['8']}],
)
self.assertEqual(payload['purchase_order_id'], self.purchase_order.id)
self.assertEqual(payload['stock_change_record_id'], 321)
@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,
@@ -88,39 +176,30 @@ class PurchaseOrderServiceTestCase(TestCase):
)
class PurchaseOrderStockTaskTestCase(TestCase):
def setUp(self):
self.merchant, self.supplier, self.warehouse, self.product = create_basic_fixtures()
User = get_user_model()
self.user = User.objects.create_user(username='task-user', password='pass123')
self.purchase_order = business_models.PurchaseOrder.objects.create(
merchant=self.merchant,
supplier=self.supplier,
order_date=timezone.now().date(),
total_amount=Decimal('100.00'),
)
self.items = [{'product_id': self.product.id, 'quantities': ['8.00']}]
self.purchase_order_id = 123
self.warehouse_id = 456
self.items = [{'product_id': 1, 'quantities': ['5']}]
def test_task_calls_stock_flow_service(self):
with patch('business.tasks.StockFlowService') as mock_flow_cls:
mock_instance = mock_flow_cls.return_value
mock_instance.stock_in.return_value = (MagicMock(id=321), [], 2)
def test_task_delegates_to_service(self):
with patch('business.tasks.business_services.create_purchase_order_stock_entries_sync') as mock_sync:
mock_sync.return_value = {'purchase_order_id': self.purchase_order_id}
async_result = tasks.create_purchase_order_stock_entries.delay(
purchase_order_id=self.purchase_order.id,
warehouse_id=self.warehouse.id,
purchase_order_id=self.purchase_order_id,
warehouse_id=self.warehouse_id,
items=self.items,
created_by_id=self.user.id,
created_by_id=999,
)
payload = async_result.get(timeout=5)
mock_flow_cls.assert_called_once_with(merchant=self.merchant, created_by=self.user)
mock_instance.stock_in.assert_called_once_with(
warehouse_id=self.warehouse.id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
mock_sync.assert_called_once_with(
purchase_order_id=self.purchase_order_id,
warehouse_id=self.warehouse_id,
items=self.items,
created_by_id=999,
)
self.assertEqual(payload['purchase_order_id'], self.purchase_order.id)
self.assertEqual(payload['stock_change_record_id'], 321)
self.assertEqual(payload['purchase_order_id'], self.purchase_order_id)
self.assertIn('task_id', payload)
from django.test import TestCase
# Create your tests here.