forked from erp-dev/erp
feat: purchase_order approve and calcel
This commit is contained in:
@@ -5,11 +5,23 @@ from . import models
|
||||
|
||||
@admin.register(models.PurchaseOrder)
|
||||
class PurchaseOrderAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'supplier', 'purchase_date', 'operator', 'created_at')
|
||||
list_display = ('id', 'supplier', 'purchase_date', 'operator', 'status', '_total_amount', '_diff_quantity', '_total_quantity', 'created_at')
|
||||
search_fields = ('supplier__name',)
|
||||
list_filter = ('operator', 'warehouse', 'created_at')
|
||||
list_filter = ('operator', 'warehouse', 'status', 'created_at')
|
||||
ordering = ('-created_at',)
|
||||
|
||||
@admin.display(description='总价')
|
||||
def _total_amount(self, obj: models.PurchaseOrder):
|
||||
return obj.get_total_amount()
|
||||
|
||||
@admin.display(description='差异数量')
|
||||
def _diff_quantity(self, obj: models.PurchaseOrder):
|
||||
return obj.get_total_diff_quantity()
|
||||
|
||||
@admin.display(description='总数量')
|
||||
def _total_quantity(self, obj: models.PurchaseOrder):
|
||||
return obj.get_total_quantity()
|
||||
|
||||
|
||||
@admin.register(models.PurchaseOrderItem)
|
||||
class PurchaseOrderItemAdmin(admin.ModelAdmin):
|
||||
|
||||
18
business/migrations/0008_alter_purchaseorderitem_quantity.py
Normal file
18
business/migrations/0008_alter_purchaseorderitem_quantity.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-28 09:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('business', '0007_alter_purchaseorderitem_batch_number'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderitem',
|
||||
name='quantity',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=10, verbose_name='数量'),
|
||||
),
|
||||
]
|
||||
18
business/migrations/0009_purchaseorderitem_spec.py
Normal file
18
business/migrations/0009_purchaseorderitem_spec.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-28 09:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('business', '0008_alter_purchaseorderitem_quantity'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchaseorderitem',
|
||||
name='spec',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='规格'),
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,4 @@
|
||||
from decimal import Decimal
|
||||
from django.db import models
|
||||
from flower.common import ModelBase
|
||||
from basic_info import models as basic_info_models
|
||||
@@ -64,6 +65,15 @@ class PurchaseOrder(ModelBase):
|
||||
verbose_name = '采购单'
|
||||
verbose_name_plural = '采购单'
|
||||
|
||||
def get_total_amount(self) -> Decimal:
|
||||
return sum(item.total_amount() for item in self.items.all())
|
||||
|
||||
def get_total_quantity(self) -> Decimal:
|
||||
return sum(item.quantity for item in self.items.all())
|
||||
|
||||
def get_total_diff_quantity(self) -> Decimal:
|
||||
return sum(item.diff_quantity() for item in self.items.all())
|
||||
|
||||
|
||||
class PurchaseOrderItem(ModelBase):
|
||||
"""采购单明细模型"""
|
||||
@@ -83,8 +93,9 @@ class PurchaseOrderItem(ModelBase):
|
||||
)
|
||||
price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='单价')
|
||||
color = models.CharField(max_length=50, null=True, blank=True, verbose_name='颜色')
|
||||
quantity = models.PositiveIntegerField(verbose_name='数量')
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量')
|
||||
unit = models.CharField(max_length=50, verbose_name='单位')
|
||||
spec = models.CharField(max_length=100, null=True, blank=True, verbose_name='规格')
|
||||
empty_diff_percent = models.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=2,
|
||||
|
||||
@@ -84,6 +84,7 @@ def create_purchase_order(
|
||||
color=item_data.get('color'),
|
||||
quantity=item_data['quantity'],
|
||||
unit=item_data['unit'],
|
||||
spec=item_data.get('spec'),
|
||||
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'],
|
||||
@@ -94,18 +95,42 @@ def create_purchase_order(
|
||||
]
|
||||
models.PurchaseOrderItem.objects.bulk_create(bulk_objects)
|
||||
|
||||
created_by_id = getattr(created_by, 'id', None)
|
||||
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,
|
||||
)
|
||||
purchase_order.refresh_from_db()
|
||||
return purchase_order
|
||||
|
||||
|
||||
def review_purchase_order(
|
||||
*,
|
||||
purchase_order: models.PurchaseOrder | None = None,
|
||||
purchase_order_id: int | None = None,
|
||||
target_status: models.PurchaseOrderStatusEnum,
|
||||
reviewed_by=None,
|
||||
) -> models.PurchaseOrder:
|
||||
"""
|
||||
审批或作废采购单。
|
||||
|
||||
当目标状态为 APPROVED 且开启自动入库任务时,将触发入库 Celery 任务;
|
||||
当目标状态为 CANCELLED 时,会在更新状态前确认未生成任何出入库单。
|
||||
"""
|
||||
order = _resolve_purchase_order_instance(purchase_order, purchase_order_id)
|
||||
|
||||
if target_status not in {
|
||||
models.PurchaseOrderStatusEnum.APPROVED,
|
||||
models.PurchaseOrderStatusEnum.CANCELLED,
|
||||
}:
|
||||
raise ValueError('target_status 只能是 APPROVED 或 CANCELLED')
|
||||
|
||||
if order.status == target_status:
|
||||
return order
|
||||
|
||||
if target_status == models.PurchaseOrderStatusEnum.APPROVED:
|
||||
if order.status == models.PurchaseOrderStatusEnum.CANCELLED:
|
||||
raise ValueError('作废状态的采购单无法再次审批')
|
||||
return _approve_purchase_order(order, reviewed_by)
|
||||
|
||||
return _cancel_purchase_order(order)
|
||||
|
||||
|
||||
def _normalize_purchase_items(
|
||||
*,
|
||||
merchant: basic_info_models.Merchant,
|
||||
@@ -136,6 +161,7 @@ def _normalize_purchase_items(
|
||||
color = raw_item.get('color')
|
||||
batch_number = raw_item.get('batch_number')
|
||||
remarks = raw_item.get('remarks')
|
||||
spec = raw_item.get('spec')
|
||||
unit = raw_item.get('unit') or product.get_unit_display() or '米'
|
||||
|
||||
if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED:
|
||||
@@ -176,11 +202,48 @@ def _normalize_purchase_items(
|
||||
'num_of_rolls': num_of_rolls,
|
||||
'batch_number': batch_number,
|
||||
'remarks': remarks,
|
||||
'spec': spec,
|
||||
})
|
||||
|
||||
return purchase_items, stock_flow_items
|
||||
|
||||
|
||||
def _approve_purchase_order(
|
||||
purchase_order: models.PurchaseOrder,
|
||||
reviewed_by,
|
||||
) -> models.PurchaseOrder:
|
||||
stock_flow_items = _build_stock_flow_items_from_order(purchase_order)
|
||||
|
||||
with transaction.atomic():
|
||||
purchase_order.status = models.PurchaseOrderStatusEnum.APPROVED
|
||||
purchase_order.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
created_by_id = getattr(reviewed_by, 'id', None)
|
||||
if _auto_stock_task_enabled(purchase_order.merchant):
|
||||
logger.info('审批通过采购单 %s,触发入库任务', purchase_order.id)
|
||||
create_purchase_order_stock_entries.delay(
|
||||
purchase_order_id=purchase_order.id,
|
||||
warehouse_id=purchase_order.warehouse_id,
|
||||
items=stock_flow_items,
|
||||
created_by_id=created_by_id,
|
||||
)
|
||||
|
||||
purchase_order.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return purchase_order
|
||||
|
||||
|
||||
def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.PurchaseOrder:
|
||||
if _purchase_order_has_stock_records(purchase_order):
|
||||
raise ValueError('采购单已生成出入库记录,无法作废')
|
||||
|
||||
with transaction.atomic():
|
||||
purchase_order.status = models.PurchaseOrderStatusEnum.CANCELLED
|
||||
purchase_order.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
purchase_order.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return purchase_order
|
||||
|
||||
|
||||
def create_purchase_order_stock_entries_sync(
|
||||
*,
|
||||
purchase_order_id: int,
|
||||
@@ -221,6 +284,74 @@ def create_purchase_order_stock_entries_sync(
|
||||
return payload
|
||||
|
||||
|
||||
def _build_stock_flow_items_from_order(purchase_order: models.PurchaseOrder) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
根据采购单明细还原 StockFlowService 所需的 items 结构。
|
||||
"""
|
||||
warehouse_mode = purchase_order.warehouse.mode
|
||||
items_payload: List[Dict[str, Any]] = []
|
||||
|
||||
order_items = purchase_order.items.all()
|
||||
if not order_items:
|
||||
raise ValueError('采购单没有任何明细,无法生成入库记录')
|
||||
|
||||
if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED:
|
||||
for item in order_items:
|
||||
items_payload.append({
|
||||
'product_id': item.product_id,
|
||||
'value': str(item.quantity),
|
||||
'num_of_rolls': item.num_of_rolls,
|
||||
})
|
||||
return items_payload
|
||||
|
||||
# 严进 / 严进严出模式
|
||||
for item in order_items:
|
||||
raw_numbers = (item.quantity_of_rolls or '').split(',')
|
||||
normalized = [value.strip() for value in raw_numbers if value.strip()]
|
||||
if not normalized:
|
||||
raise ValueError('严进仓采购单缺少 numbers 数据,无法生成入库记录')
|
||||
items_payload.append({
|
||||
'product_id': item.product_id,
|
||||
'quantities': normalized,
|
||||
})
|
||||
return items_payload
|
||||
|
||||
|
||||
def _auto_stock_task_enabled(merchant: basic_info_models.Merchant) -> bool:
|
||||
try:
|
||||
setting = MerchantSettingService.get_setting(
|
||||
merchant,
|
||||
basic_info_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS,
|
||||
)
|
||||
except basic_info_models.MerchantSetting.DoesNotExist:
|
||||
logger.warning('商户 %s 未配置 auto_create_stock_change_tasks,默认关闭', merchant.id)
|
||||
return False
|
||||
return setting.value is True
|
||||
|
||||
|
||||
def _purchase_order_has_stock_records(purchase_order: models.PurchaseOrder) -> bool:
|
||||
return stock_models.StockChangeRecord.objects.filter(
|
||||
merchant_id=purchase_order.merchant_id,
|
||||
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=purchase_order.id,
|
||||
).exists()
|
||||
|
||||
|
||||
def _resolve_purchase_order_instance(
|
||||
purchase_order: models.PurchaseOrder | None,
|
||||
purchase_order_id: int | None,
|
||||
) -> models.PurchaseOrder:
|
||||
if purchase_order is None and purchase_order_id is None:
|
||||
raise ValueError('必须提供 purchase_order 或 purchase_order_id')
|
||||
|
||||
if purchase_order is not None:
|
||||
purchase_order_id = purchase_order.id
|
||||
|
||||
return models.PurchaseOrder.objects.select_related('merchant', 'warehouse').prefetch_related('items').get(
|
||||
id=purchase_order_id
|
||||
)
|
||||
|
||||
|
||||
def _to_decimal(value, field_name: str) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
|
||||
@@ -15,6 +15,12 @@ def create_basic_fixtures():
|
||||
name='测试商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
basic_models.MerchantSetting.objects.create(
|
||||
merchant=merchant,
|
||||
key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS,
|
||||
type=basic_models.MerchantSettingTypeEnum.BOOL,
|
||||
val_bool=True,
|
||||
)
|
||||
supplier = basic_models.Supplier.objects.create(
|
||||
merchant=merchant,
|
||||
name='测试供应商',
|
||||
@@ -68,7 +74,7 @@ class PurchaseOrderServiceTestCase(TestCase):
|
||||
{'product_id': self.product.id, 'quantity': 120, 'num_of_rolls': 3, 'price': '10.0', 'unit': '米'}
|
||||
]
|
||||
|
||||
def test_create_purchase_order_triggers_task_strict(self):
|
||||
def test_create_purchase_order_does_not_trigger_task_immediately(self):
|
||||
with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay:
|
||||
purchase_order = services.create_purchase_order(
|
||||
merchant=self.merchant,
|
||||
@@ -82,11 +88,31 @@ class PurchaseOrderServiceTestCase(TestCase):
|
||||
)
|
||||
|
||||
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.status, business_models.PurchaseOrderStatusEnum.PENDING)
|
||||
self.assertEqual(purchase_order.items.count(), 1)
|
||||
item = purchase_order.items.first()
|
||||
self.assertEqual(item.quantity_of_rolls, '10,5')
|
||||
mock_delay.assert_not_called()
|
||||
|
||||
def test_review_purchase_order_approval_triggers_task(self):
|
||||
purchase_order = services.create_purchase_order(
|
||||
merchant=self.merchant,
|
||||
supplier=self.supplier,
|
||||
order_date=timezone.now().date(),
|
||||
warehouse=self.warehouse_strict,
|
||||
operator=self.operator,
|
||||
items=self.strict_items,
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay:
|
||||
reviewed = services.review_purchase_order(
|
||||
purchase_order=purchase_order,
|
||||
target_status=business_models.PurchaseOrderStatusEnum.APPROVED,
|
||||
reviewed_by=self.user,
|
||||
)
|
||||
|
||||
self.assertEqual(reviewed.status, business_models.PurchaseOrderStatusEnum.APPROVED)
|
||||
mock_delay.assert_called_once_with(
|
||||
purchase_order_id=purchase_order.id,
|
||||
warehouse_id=self.warehouse_strict.id,
|
||||
@@ -105,23 +131,72 @@ class PurchaseOrderServiceTestCase(TestCase):
|
||||
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,
|
||||
def test_review_purchase_order_cancel_without_stock(self):
|
||||
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.assertIsNone(purchase_order.items.first().quantity_of_rolls)
|
||||
|
||||
cancelled = services.review_purchase_order(
|
||||
purchase_order=purchase_order,
|
||||
target_status=business_models.PurchaseOrderStatusEnum.CANCELLED,
|
||||
reviewed_by=self.user,
|
||||
)
|
||||
self.assertEqual(cancelled.status, business_models.PurchaseOrderStatusEnum.CANCELLED)
|
||||
|
||||
def test_review_purchase_order_cancel_blocked_after_stock_created(self):
|
||||
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,
|
||||
)
|
||||
stock_models.StockChangeRecord.objects.create(
|
||||
merchant=self.merchant,
|
||||
type=stock_models.StockChangeTypeEnum.ADD,
|
||||
warehouse=self.warehouse_relaxed,
|
||||
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=purchase_order.id,
|
||||
)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
services.review_purchase_order(
|
||||
purchase_order=purchase_order,
|
||||
target_status=business_models.PurchaseOrderStatusEnum.CANCELLED,
|
||||
reviewed_by=self.user,
|
||||
)
|
||||
|
||||
def test_review_purchase_order_relaxed_mode_approval_payload(self):
|
||||
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,
|
||||
)
|
||||
|
||||
with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay:
|
||||
services.review_purchase_order(
|
||||
purchase_order=purchase_order,
|
||||
target_status=business_models.PurchaseOrderStatusEnum.APPROVED,
|
||||
reviewed_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}],
|
||||
items=[{'product_id': self.product.id, 'value': '120.00', 'num_of_rolls': 3}],
|
||||
created_by_id=self.user.id,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user