1
0
forked from erp-dev/erp

feat: big version, added tasks for backup_database and stock change, added health check api, approve sse (support channel via merchant)

This commit is contained in:
2025-11-26 21:49:42 +08:00
parent 6bf0465d05
commit a9c75a13fa
26 changed files with 7158 additions and 216 deletions

86
business/services.py Normal file
View File

@@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Any, Dict, List
from django.db import transaction
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Dict, List
from django.db import transaction
from basic_info import models as basic_info_models
from . import models
from .tasks import create_purchase_order_stock_entries
def _normalize_order_date(value) -> date:
if isinstance(value, date):
return value
if isinstance(value, datetime):
return value.date()
if isinstance(value, str):
try:
return date.fromisoformat(value)
except ValueError as exc:
raise ValueError('order_date 格式不正确,应为 YYYY-MM-DD') from exc
raise ValueError('order_date 格式不正确')
def create_purchase_order(
*,
merchant: basic_info_models.Merchant,
supplier: basic_info_models.Supplier,
order_date,
total_amount,
warehouse_id: int,
items: List[Dict[str, Any]],
remarks: str | None = '',
created_by=None,
) -> models.PurchaseOrder:
"""
创建采购订单并触发异步创建入库单任务。
Args:
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需满足其模式要求。
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))
with transaction.atomic():
purchase_order = models.PurchaseOrder.objects.create(
merchant=merchant,
supplier=supplier,
order_date=normalized_date,
total_amount=total_amount,
remarks=remarks,
)
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,
)
return purchase_order

56
business/tasks.py Normal file
View File

@@ -0,0 +1,56 @@
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__)
@shared_task(bind=True)
def create_purchase_order_stock_entries(
self,
*,
purchase_order_id: int,
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(
warehouse_id=warehouse_id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=purchase_order.id,
items=items,
)
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)
return payload

View File

@@ -1,3 +1,126 @@
from decimal import Decimal
from typing import List, Dict, Any
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.utils import timezone
from unittest.mock import patch, MagicMock
from basic_info import models as basic_models
from stock import models as stock_models
from business import models as business_models, services, tasks
def create_basic_fixtures():
merchant = basic_models.Merchant.objects.create(
name='测试商户',
type=basic_models.MerchantTypeEnum.FACTORY,
)
supplier = basic_models.Supplier.objects.create(
merchant=merchant,
name='测试供应商',
)
warehouse = basic_models.WareHouse.objects.create(
merchant=merchant,
name='主仓',
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
)
category = basic_models.ProductCategory.objects.create(
merchant=merchant,
name='面料',
product_prefix='FAB',
)
product = basic_models.Product.objects.create(
merchant=merchant,
category=category,
name='测试面料',
human_id='FAB-001',
unit=basic_models.ProductUnitEnum.METER,
)
return merchant, supplier, warehouse, product
class PurchaseOrderServiceTestCase(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='creator', password='pass123')
self.items: List[Dict[str, Any]] = [
{'product_id': self.product.id, 'quantities': ['10.00', '5.00']},
]
def test_create_purchase_order_triggers_task(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,
remarks='自动化测试',
created_by=self.user,
)
self.assertIsInstance(purchase_order, business_models.PurchaseOrder)
mock_delay.assert_called_once_with(
purchase_order_id=purchase_order.id,
warehouse_id=self.warehouse.id,
items=self.items,
created_by_id=self.user.id,
)
def test_create_purchase_order_without_items_raises(self):
with self.assertRaises(ValueError):
services.create_purchase_order(
merchant=self.merchant,
supplier=self.supplier,
order_date=timezone.now().date(),
total_amount=Decimal('50.00'),
warehouse_id=self.warehouse.id,
items=[],
)
@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPAGATES=True,
)
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']}]
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)
async_result = tasks.create_purchase_order_stock_entries.delay(
purchase_order_id=self.purchase_order.id,
warehouse_id=self.warehouse.id,
items=self.items,
created_by_id=self.user.id,
)
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,
items=self.items,
)
self.assertEqual(payload['purchase_order_id'], self.purchase_order.id)
self.assertEqual(payload['stock_change_record_id'], 321)
from django.test import TestCase
# Create your tests here.

View File

@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.