1
0
forked from erp-dev/erp

feat: purchase_order approve and calcel

This commit is contained in:
2025-11-28 18:25:06 +08:00
parent 0c62398120
commit 4b3cd8eb75
14 changed files with 1019 additions and 43 deletions

View File

@@ -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):

View 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='数量'),
),
]

View 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='规格'),
),
]

View File

@@ -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,

View File

@@ -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))

View File

@@ -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,
)