forked from erp-dev/erp
feat: added wecom notify when printing_job production was completed(new status) and shipment was created
This commit is contained in:
2
.env
2
.env
@@ -7,5 +7,5 @@ DB_NAME=flower
|
||||
DB_USER=flower
|
||||
DB_PASSWORD=flower_pg_zuowei1216
|
||||
PRINTING_SALES_ITEM_SOURCE_STATE_ID=2
|
||||
WECOM_WEBHOOK_KEY=
|
||||
WECOM_WEBHOOK_KEY=cc88bdef-a13f-4d7e-bdb6-ee51b68b8205
|
||||
PRINTING_JOB_STATE_ADVANCED_FOLLOWUP_URL_TEMPLATE=https://app.yuwen.cloud/workstation/production/batch-advance?orderId={order_id}
|
||||
|
||||
@@ -170,6 +170,26 @@
|
||||
- **描述**: 将任务回退到上一个流程状态。
|
||||
- **响应**: 成功时返回 `200 OK`,包含 `detail` 和 `data`。失败时返回 `400 Bad Request`。
|
||||
|
||||
- **POST** `/api/v1/printing-jobs/mark-production-completed/`
|
||||
- **描述**: 显式将指定的 `PrintingJob` 标记为“已完成生产”。
|
||||
- **后续动作**: 成功后会触发 `printing_job_production_completed` 领域信号,并由异步 Celery task 发送企业微信通知。
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"printing_job_id": 123
|
||||
}
|
||||
```
|
||||
- **成功响应**: `200 OK`
|
||||
```json
|
||||
{
|
||||
"message": "OK"
|
||||
}
|
||||
```
|
||||
- **失败响应**:
|
||||
- `400 Bad Request` - 缺少 `printing_job_id`
|
||||
- `403 Forbidden` - 当前用户未关联商户,或试图操作其他商户的 `PrintingJob`
|
||||
- `404 Not Found` - `PrintingJob` 不存在
|
||||
|
||||
### 2.3. 状态查询操作
|
||||
|
||||
- **GET** `/api/v1/printing-jobs/{id}/completed-states/`
|
||||
|
||||
@@ -340,6 +340,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
||||
work_state_display = serializers.SerializerMethodField()
|
||||
has_started = serializers.BooleanField(read_only=True)
|
||||
is_completed = serializers.BooleanField(read_only=True)
|
||||
is_production_completed = serializers.BooleanField(read_only=True)
|
||||
progress_percentage = serializers.FloatField(read_only=True)
|
||||
last_completed_state = serializers.CharField(read_only=True)
|
||||
business_object_id = serializers.SerializerMethodField()
|
||||
@@ -354,7 +355,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
||||
'product_image_url', 'has_started',
|
||||
'quantity', 'unit', 'size', 'pieces', 'description',
|
||||
'work_state', 'work_state_display',
|
||||
'status', 'is_completed', 'progress_percentage', 'last_completed_state',
|
||||
'status', 'is_completed', 'is_production_completed', 'progress_percentage', 'last_completed_state',
|
||||
'business_object_id',
|
||||
'batch_advance_records',
|
||||
'saleitems',
|
||||
@@ -362,7 +363,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = [
|
||||
'id', 'created_at', 'updated_at',
|
||||
'status', 'is_completed', 'progress_percentage', 'last_completed_state',
|
||||
'status', 'is_completed', 'is_production_completed', 'progress_percentage', 'last_completed_state',
|
||||
'business_object_id', 'merchant_id'
|
||||
]
|
||||
|
||||
@@ -432,6 +433,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
||||
status_id = serializers.IntegerField(read_only=True)
|
||||
work_state_display = serializers.SerializerMethodField()
|
||||
is_completed = serializers.BooleanField(read_only=True)
|
||||
is_production_completed = serializers.BooleanField(read_only=True)
|
||||
has_started = serializers.BooleanField(read_only=True)
|
||||
progress_percentage = serializers.FloatField(read_only=True)
|
||||
last_completed_state = serializers.CharField(read_only=True)
|
||||
@@ -446,7 +448,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
||||
'id', 'original_id', 'merchant_id', 'printing_order', 'printing_order_id', 'external_order_id', 'product', 'product_name', 'product_code',
|
||||
'quantity', 'unit', 'size', 'pieces', 'description',
|
||||
'work_state', 'work_state_display',
|
||||
'status', 'status_id', 'is_completed', 'has_started',
|
||||
'status', 'status_id', 'is_completed', 'is_production_completed', 'has_started',
|
||||
'progress_percentage', 'last_completed_state',
|
||||
'business_object_id',
|
||||
'batch_advance_records',
|
||||
@@ -455,7 +457,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = [
|
||||
'id', 'created_at', 'updated_at',
|
||||
'status', 'status_id', 'is_completed', 'has_started',
|
||||
'status', 'status_id', 'is_completed', 'is_production_completed', 'has_started',
|
||||
'progress_percentage', 'last_completed_state',
|
||||
'business_object_id', 'merchant_id'
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from printing import models as printing_models
|
||||
from printing.signals import printing_job_production_completed, printing_order_created
|
||||
from stateflow import models as stateflow_models
|
||||
from stateflow import services as stateflow_services
|
||||
|
||||
@@ -55,7 +56,6 @@ class PrintingOrderService:
|
||||
# domain event: printing order created
|
||||
# handler will ensure transaction.on_commit for external side effects (e.g. WeCom notify)
|
||||
try:
|
||||
from printing.signals import printing_order_created
|
||||
printing_order_created.send(
|
||||
sender=printing_models.PrintingOrder,
|
||||
instance=order,
|
||||
@@ -173,6 +173,8 @@ class PrintingJobService:
|
||||
Returns:
|
||||
创建的任务实例
|
||||
"""
|
||||
data = dict(data)
|
||||
data.pop('is_production_completed', None)
|
||||
printing_order = data.get('printing_order')
|
||||
|
||||
# 绑定创建人
|
||||
@@ -218,6 +220,9 @@ class PrintingJobService:
|
||||
Returns:
|
||||
(success, message, updated_job)
|
||||
"""
|
||||
data = dict(data)
|
||||
data.pop('is_production_completed', None)
|
||||
|
||||
# 更新字段
|
||||
for field, value in data.items():
|
||||
# 不允许直接修改 business_object
|
||||
@@ -227,6 +232,31 @@ class PrintingJobService:
|
||||
|
||||
printing_job.save()
|
||||
return True, '更新成功', printing_job
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def make_printing_job_production_completed(
|
||||
printing_job: printing_models.PrintingJob,
|
||||
triggered_by=None,
|
||||
) -> printing_models.PrintingJob:
|
||||
"""显式标记 PrintingJob 已完成生产,并触发领域信号。"""
|
||||
if not printing_job.is_production_completed:
|
||||
printing_job.is_production_completed = True
|
||||
printing_job.save(update_fields=['is_production_completed', 'updated_at'])
|
||||
|
||||
try:
|
||||
printing_job_production_completed.send(
|
||||
sender=printing_models.PrintingJob,
|
||||
instance=printing_job,
|
||||
triggered_by=triggered_by,
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception(
|
||||
"[api_v1.views.printing.services] 触发 printing_job_production_completed signal 失败(已忽略)"
|
||||
)
|
||||
|
||||
return printing_job
|
||||
|
||||
@staticmethod
|
||||
def get_job_status(printing_job: printing_models.PrintingJob) -> Dict[str, Any]:
|
||||
@@ -243,5 +273,6 @@ class PrintingJobService:
|
||||
'status': printing_job.status,
|
||||
'status_id': printing_job.status_id,
|
||||
'is_completed': printing_job.is_completed,
|
||||
'is_production_completed': printing_job.is_production_completed,
|
||||
'has_started': printing_job.has_started,
|
||||
}
|
||||
|
||||
@@ -128,6 +128,23 @@ class PrintingJobAPITestCase(TestCase):
|
||||
self.assertEqual(job.quantity, 100)
|
||||
self.assertEqual(job.unit, '米')
|
||||
self.assertEqual(job.pieces, 10)
|
||||
self.assertFalse(job.is_production_completed)
|
||||
|
||||
def test_create_printing_job_ignores_is_production_completed(self):
|
||||
"""测试创建接口忽略 is_production_completed"""
|
||||
data = {
|
||||
'printing_order': self.printing_order.id,
|
||||
'product': self.product.id,
|
||||
'quantity': 100,
|
||||
'unit': '米',
|
||||
'is_production_completed': True,
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/printing-jobs/', data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
job = printing_models.PrintingJob.objects.get(id=response.data['id'])
|
||||
self.assertFalse(job.is_production_completed)
|
||||
|
||||
def test_list_printing_jobs(self):
|
||||
"""测试获取款式明细列表"""
|
||||
@@ -158,6 +175,89 @@ class PrintingJobAPITestCase(TestCase):
|
||||
self.assertIn('batch_advance_records', item)
|
||||
self.assertIsInstance(item['batch_advance_records'], list)
|
||||
self.assertEqual(len(item['batch_advance_records']), 0)
|
||||
|
||||
def test_mark_production_completed(self):
|
||||
"""测试显式标记完成生产接口"""
|
||||
job = printing_models.PrintingJob.objects.create(
|
||||
merchant=self.merchant,
|
||||
printing_order=self.printing_order,
|
||||
product=self.product,
|
||||
quantity=100,
|
||||
unit='米',
|
||||
is_production_completed=False,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
'/api/v1/printing-jobs/mark-production-completed/',
|
||||
{'printing_job_id': job.id},
|
||||
format='json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, {'message': 'OK'})
|
||||
|
||||
job.refresh_from_db()
|
||||
self.assertTrue(job.is_production_completed)
|
||||
|
||||
def test_mark_production_completed_requires_printing_job_id(self):
|
||||
"""测试显式标记完成生产接口缺少 printing_job_id"""
|
||||
response = self.client.post(
|
||||
'/api/v1/printing-jobs/mark-production-completed/',
|
||||
{},
|
||||
format='json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('printing_job_id', response.data['detail'])
|
||||
|
||||
def test_mark_production_completed_forbidden_for_other_merchant(self):
|
||||
"""测试不能操作其他商户的 PrintingJob"""
|
||||
other_merchant = basic_models.Merchant.objects.create(
|
||||
name='其他印花厂',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY
|
||||
)
|
||||
job = printing_models.PrintingJob.objects.create(
|
||||
merchant=other_merchant,
|
||||
printing_order=self.printing_order,
|
||||
product=self.product,
|
||||
quantity=100,
|
||||
unit='米',
|
||||
is_production_completed=False,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
'/api/v1/printing-jobs/mark-production-completed/',
|
||||
{'printing_job_id': job.id},
|
||||
format='json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertIn('其他商户', response.data['detail'])
|
||||
|
||||
job.refresh_from_db()
|
||||
self.assertFalse(job.is_production_completed)
|
||||
|
||||
def test_mark_production_completed_requires_employee(self):
|
||||
"""测试显式标记完成生产接口需要 employee"""
|
||||
no_employee_user = User.objects.create_user(
|
||||
username='noemployee',
|
||||
password='testpass123',
|
||||
email='noemployee@example.com'
|
||||
)
|
||||
no_employee_user.user_permissions.add(
|
||||
Permission.objects.get(codename='view_printingjob'),
|
||||
Permission.objects.get(codename='add_printingjob'),
|
||||
Permission.objects.get(codename='change_printingjob'),
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user=no_employee_user)
|
||||
try:
|
||||
response = self.client.post(
|
||||
'/api/v1/printing-jobs/mark-production-completed/',
|
||||
{'printing_job_id': 1},
|
||||
format='json'
|
||||
)
|
||||
finally:
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_retrieve_printing_job(self):
|
||||
"""测试获取款式明细详情"""
|
||||
@@ -347,6 +447,28 @@ class PrintingJobAPITestCase(TestCase):
|
||||
self.assertEqual(job.quantity, 200)
|
||||
self.assertEqual(job.unit, '码')
|
||||
self.assertEqual(job.pieces, 20)
|
||||
self.assertFalse(job.is_production_completed)
|
||||
|
||||
def test_update_printing_job_ignores_is_production_completed(self):
|
||||
"""测试更新接口忽略 is_production_completed"""
|
||||
job = printing_models.PrintingJob.objects.create(
|
||||
printing_order=self.printing_order,
|
||||
product=self.product,
|
||||
quantity=100,
|
||||
unit='米',
|
||||
is_production_completed=False,
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f'/api/v1/printing-jobs/{job.id}/',
|
||||
{'is_production_completed': True, 'quantity': 120},
|
||||
format='json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
job.refresh_from_db()
|
||||
self.assertEqual(job.quantity, 120)
|
||||
self.assertFalse(job.is_production_completed)
|
||||
|
||||
def test_cannot_change_printing_order_on_update(self):
|
||||
"""测试:不允许通过更新接口修改 PrintingJob.printing_order"""
|
||||
@@ -706,12 +828,14 @@ class PrintingJobAPITestCase(TestCase):
|
||||
self.assertIn('status', response.data)
|
||||
self.assertIn('status_id', response.data)
|
||||
self.assertIn('is_completed', response.data)
|
||||
self.assertIn('is_production_completed', response.data)
|
||||
self.assertIn('has_started', response.data)
|
||||
self.assertIn('business_object_id', response.data)
|
||||
|
||||
# 未推进状态,但有 BusinessObject,当前是初始状态
|
||||
self.assertIn(response.data['status'], ['待印染', '未开始']) # 初始状态或未开始
|
||||
self.assertFalse(response.data['is_completed'])
|
||||
self.assertFalse(response.data['is_production_completed'])
|
||||
self.assertFalse(response.data['has_started'])
|
||||
|
||||
def test_job_status_in_list(self):
|
||||
@@ -731,6 +855,7 @@ class PrintingJobAPITestCase(TestCase):
|
||||
# 验证列表中的状态字段
|
||||
self.assertIn('status', response.data['results'][0])
|
||||
self.assertIn('is_completed', response.data['results'][0])
|
||||
self.assertIn('is_production_completed', response.data['results'][0])
|
||||
|
||||
def test_job_completion_status(self):
|
||||
"""测试任务完成状态判断"""
|
||||
@@ -763,7 +888,31 @@ class PrintingJobAPITestCase(TestCase):
|
||||
# 验证完成状态
|
||||
self.assertEqual(response.data['status'], '已完成')
|
||||
self.assertTrue(response.data['is_completed'])
|
||||
self.assertFalse(response.data['is_production_completed'])
|
||||
self.assertTrue(response.data['has_started'])
|
||||
|
||||
def test_list_filter_by_is_production_completed(self):
|
||||
"""测试按 is_production_completed 过滤 PrintingJob 列表"""
|
||||
completed_job = printing_models.PrintingJob.objects.create(
|
||||
printing_order=self.printing_order,
|
||||
product=self.product,
|
||||
quantity=100,
|
||||
unit='米',
|
||||
is_production_completed=True,
|
||||
)
|
||||
printing_models.PrintingJob.objects.create(
|
||||
printing_order=self.printing_order,
|
||||
product=self.product,
|
||||
quantity=80,
|
||||
unit='米',
|
||||
is_production_completed=False,
|
||||
)
|
||||
|
||||
response = self.client.get('/api/v1/printing-jobs/?is_production_completed=true')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data['results']), 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], completed_job.id)
|
||||
self.assertTrue(response.data['results'][0]['is_production_completed'])
|
||||
|
||||
def test_advance_to_next_state(self):
|
||||
"""测试推进到下一个状态 API"""
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.conf import settings
|
||||
from basic_info.models import Merchant, MerchantTypeEnum, Employee, Customer, Product
|
||||
from stateflow.models import State, Process
|
||||
from printing.models import PrintingOrder, PrintingJob
|
||||
from printing.signals import printing_job_production_completed
|
||||
from api_v1.views.printing.services import PrintingOrderService, PrintingJobService
|
||||
|
||||
|
||||
@@ -380,7 +381,71 @@ class PrintingJobServiceTest(TestCase):
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(updated_job.quantity, 20)
|
||||
self.assertEqual(updated_job.pieces, 10)
|
||||
|
||||
|
||||
def test_create_printing_job_ignores_is_production_completed(self):
|
||||
"""测试创建任务时忽略 is_production_completed 输入"""
|
||||
data = {
|
||||
'printing_order': self.order,
|
||||
'product': self.product,
|
||||
'quantity': 10,
|
||||
'unit': '件',
|
||||
'is_production_completed': True,
|
||||
}
|
||||
|
||||
job = PrintingJobService.create_printing_job(data, self.user)
|
||||
|
||||
self.assertFalse(job.is_production_completed)
|
||||
|
||||
def test_update_printing_job_ignores_is_production_completed(self):
|
||||
"""测试更新任务时忽略 is_production_completed 输入"""
|
||||
job = PrintingJob.objects.create(
|
||||
printing_order=self.order,
|
||||
product=self.product,
|
||||
quantity=10,
|
||||
unit='件',
|
||||
is_production_completed=False,
|
||||
)
|
||||
|
||||
success, _, updated_job = PrintingJobService.update_printing_job(
|
||||
job,
|
||||
{'is_production_completed': True, 'quantity': 20},
|
||||
self.user,
|
||||
)
|
||||
|
||||
updated_job.refresh_from_db()
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(updated_job.quantity, 20)
|
||||
self.assertFalse(updated_job.is_production_completed)
|
||||
|
||||
def test_make_printing_job_production_completed_sets_flag_and_sends_signal(self):
|
||||
"""测试显式完成生产会更新字段并触发 signal"""
|
||||
job = PrintingJob.objects.create(
|
||||
printing_order=self.order,
|
||||
product=self.product,
|
||||
quantity=10,
|
||||
unit='件',
|
||||
)
|
||||
|
||||
received = {}
|
||||
uid = 'test.printing_job_production_completed'
|
||||
|
||||
def _receiver(sender, **kwargs):
|
||||
received['sender'] = sender
|
||||
received['instance_id'] = kwargs['instance'].id
|
||||
received['triggered_by'] = kwargs.get('triggered_by')
|
||||
|
||||
printing_job_production_completed.connect(_receiver, dispatch_uid=uid, weak=False)
|
||||
try:
|
||||
updated_job = PrintingJobService.make_printing_job_production_completed(job, self.user)
|
||||
finally:
|
||||
printing_job_production_completed.disconnect(dispatch_uid=uid)
|
||||
|
||||
updated_job.refresh_from_db()
|
||||
self.assertTrue(updated_job.is_production_completed)
|
||||
self.assertEqual(received['sender'], PrintingJob)
|
||||
self.assertEqual(received['instance_id'], job.id)
|
||||
self.assertEqual(received['triggered_by'], self.user)
|
||||
|
||||
def test_get_job_status(self):
|
||||
"""测试获取任务状态"""
|
||||
job = PrintingJob.objects.create(
|
||||
@@ -397,9 +462,11 @@ class PrintingJobServiceTest(TestCase):
|
||||
self.assertIn('status', status_info)
|
||||
self.assertIn('status_id', status_info)
|
||||
self.assertIn('is_completed', status_info)
|
||||
self.assertIn('is_production_completed', status_info)
|
||||
self.assertIn('has_started', status_info)
|
||||
|
||||
# 未开始状态(现在返回空字符串或第一个状态名称)
|
||||
self.assertIn(status_info['status'], ['', self.state1.name])
|
||||
self.assertFalse(status_info['is_completed'])
|
||||
self.assertFalse(status_info['is_production_completed'])
|
||||
self.assertFalse(status_info['has_started'])
|
||||
|
||||
@@ -412,10 +412,11 @@ class PrintingJobFilterSet(django_filters.FilterSet):
|
||||
quantity_max = django_filters.NumberFilter(field_name="quantity", lookup_expr="lte")
|
||||
pieces_min = django_filters.NumberFilter(field_name="pieces", lookup_expr="gte")
|
||||
pieces_max = django_filters.NumberFilter(field_name="pieces", lookup_expr="lte")
|
||||
is_production_completed = django_filters.BooleanFilter()
|
||||
|
||||
class Meta:
|
||||
model = models.PrintingJob
|
||||
fields = ["printing_order", "product", "external_order_id"]
|
||||
fields = ["printing_order", "product", "external_order_id", "is_production_completed"]
|
||||
|
||||
|
||||
class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
|
||||
@@ -449,6 +450,7 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
|
||||
- quantity_max: 最大数量
|
||||
- pieces_min: 最小件数
|
||||
- pieces_max: 最大件数
|
||||
- is_production_completed: 是否已标记完成生产(true/false)
|
||||
- search: 全文搜索(产品名称、单位、尺寸、备注)
|
||||
- ordering: 排序字段
|
||||
|
||||
@@ -537,6 +539,60 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
|
||||
serializer = self.get_serializer(instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="mark-production-completed")
|
||||
def mark_production_completed(self, request):
|
||||
"""
|
||||
显式标记印染任务已完成生产。
|
||||
|
||||
请求体:
|
||||
{
|
||||
"printing_job_id": 123
|
||||
}
|
||||
"""
|
||||
printing_job_id = request.data.get("printing_job_id")
|
||||
if not printing_job_id:
|
||||
return Response(
|
||||
{"detail": "printing_job_id 不能为空"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user = request.user
|
||||
merchant = None
|
||||
if hasattr(user, "employee") and user.employee and user.employee.merchant:
|
||||
merchant = user.employee.merchant
|
||||
|
||||
if merchant is None:
|
||||
return Response(
|
||||
{"detail": "当前用户未关联商户"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
job = models.PrintingJob.objects.get(pk=printing_job_id)
|
||||
except models.PrintingJob.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "PrintingJob 不存在"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
job_merchant = getattr(job, "merchant", None)
|
||||
if job_merchant is None and getattr(job, "printing_order", None):
|
||||
job_merchant = getattr(job.printing_order, "merchant", None)
|
||||
|
||||
if job_merchant and job_merchant.id != merchant.id:
|
||||
return Response(
|
||||
{"detail": "您不能操作其他商户的 PrintingJob"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
from .services import PrintingJobService
|
||||
|
||||
PrintingJobService.make_printing_job_production_completed(
|
||||
job,
|
||||
triggered_by=user,
|
||||
)
|
||||
return Response({"message": "OK"}, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""禁用删除操作"""
|
||||
return Response(
|
||||
|
||||
@@ -3,8 +3,10 @@ Shipment API 测试
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.utils import timezone
|
||||
@@ -1294,6 +1296,26 @@ class ShipmentCreateAPITestCase(TestCase):
|
||||
self.assertEqual(self.sales_item1.shipment_id, result["id"])
|
||||
self.assertEqual(self.sales_item2.shipment_id, result["id"])
|
||||
|
||||
@override_settings(TESTING=False, SHIPMENT_CREATED_WECOM_NOTIFY_ENABLED=True)
|
||||
def test_create_shipment_enqueues_wecom_notification_task(self):
|
||||
"""测试创建出货单后投递企业微信通知任务"""
|
||||
data = {
|
||||
"customer": self.customer.id,
|
||||
"shipment_date": "2026-01-14",
|
||||
"sales_items": [self.sales_item1.id, self.sales_item2.id],
|
||||
}
|
||||
|
||||
with patch("shipment.tasks.notify_shipment_created_wecom.delay") as mock_delay:
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
response = self.client.post("/api/v1/shipment/shipments/", data, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
shipment_id = response.json()["id"]
|
||||
mock_delay.assert_called_once_with(
|
||||
shipment_id=shipment_id,
|
||||
created_by_id=self.user.id,
|
||||
)
|
||||
|
||||
def test_create_shipment_without_sales_items(self):
|
||||
"""测试创建出货单但不关联销售品"""
|
||||
data = {
|
||||
@@ -2208,11 +2230,17 @@ class SalesItemCreateAPITestCase(APITestCase):
|
||||
area="测试地区Sales",
|
||||
)
|
||||
|
||||
self.category = basic_models.ProductCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="测试分类Sales",
|
||||
)
|
||||
|
||||
# 创建产品
|
||||
self.product = basic_models.Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=self.category,
|
||||
name="测试产品",
|
||||
code="TEST001",
|
||||
human_id="TEST001",
|
||||
unit=basic_models.ProductUnitEnum.METER,
|
||||
)
|
||||
|
||||
|
||||
BIN
docs/plant/printing-job-completion-flow.png
Normal file
BIN
docs/plant/printing-job-completion-flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
61
docs/plant/printing-job-completion-flow.puml
Normal file
61
docs/plant/printing-job-completion-flow.puml
Normal file
@@ -0,0 +1,61 @@
|
||||
@startuml
|
||||
title 滚筒完成后驱动生产子单完成的流程
|
||||
|
||||
skinparam shadowing false
|
||||
skinparam activity {
|
||||
BackgroundColor #F8FBFF
|
||||
BorderColor #4C6A92
|
||||
DiamondBackgroundColor #FFF7E6
|
||||
DiamondBorderColor #B7791F
|
||||
StartColor #3B82F6
|
||||
EndColor #64748B
|
||||
BarColor #94A3B8
|
||||
FontName Noto Sans CJK SC
|
||||
}
|
||||
|
||||
start
|
||||
|
||||
:场景开始\n当前节点为“滚筒进行中”;
|
||||
|
||||
if (是否发生“滚筒完成”动作?) then (是)
|
||||
:系统创建销售品\nshipment.SalesItem;
|
||||
note right
|
||||
当前实现中,SalesItem 通过
|
||||
printing_job_id 字段关联 PrintingJob,
|
||||
不是数据库级 ForeignKey。
|
||||
end note
|
||||
|
||||
:写入销售品关联信息\nprinting_job_id + sales_item_id;
|
||||
|
||||
:发送“销售品已被创建”信号;
|
||||
note right
|
||||
该信号计划携带:
|
||||
- printing_job_id
|
||||
- sales_item_id
|
||||
|
||||
当前信号未实现,
|
||||
但销售品创建 API 已存在:
|
||||
POST /shipment/sales-items/
|
||||
end note
|
||||
|
||||
:信号处理器接收事件;
|
||||
:读取对应 printing.PrintingJob;
|
||||
:统计该 job 关联销售品数量之和;
|
||||
:计算完成比例\nsum(sales_items.quantity) / printing_job.quantity;
|
||||
|
||||
if (完成比例 >= 90% ?) then (是)
|
||||
:发送“生产子单(printing_job)已完成”信号;
|
||||
note right
|
||||
完成判定规则:
|
||||
关联销售品数量之和
|
||||
大于或约等于 job.quantity 的 90%
|
||||
end note
|
||||
else (否)
|
||||
:不发送完成信号\n等待后续销售品继续创建;
|
||||
endif
|
||||
else (否)
|
||||
:保持“滚筒进行中”;
|
||||
endif
|
||||
|
||||
stop
|
||||
@enduml
|
||||
@@ -141,6 +141,7 @@
|
||||
- 并且这些销售品必须全部来自同一个 `printing_order`
|
||||
- 如果存在缺少生产任务、生产任务不存在、或跨生产订单混装,接口会拒绝创建
|
||||
- 新创建的出货单默认状态为 `草稿(未发布)`
|
||||
- 创建成功后会触发 `shipment_created` 领域信号,并通过异步 Celery task 发送企业微信通知
|
||||
|
||||
### 接口信息
|
||||
|
||||
|
||||
@@ -301,6 +301,10 @@ PRINTING_ORDER_CREATED_WECOM_NOTIFY_ENABLED = env.bool(
|
||||
"PRINTING_ORDER_CREATED_WECOM_NOTIFY_ENABLED",
|
||||
default=(not TESTING),
|
||||
)
|
||||
PRINTING_JOB_PRODUCTION_COMPLETED_WECOM_NOTIFY_ENABLED = env.bool(
|
||||
"PRINTING_JOB_PRODUCTION_COMPLETED_WECOM_NOTIFY_ENABLED",
|
||||
default=True,
|
||||
)
|
||||
PRINTING_ORDER_CREATED_WECOM_MARKDOWN_TEMPLATE = (
|
||||
"### 印染订单创建\n"
|
||||
"\n"
|
||||
@@ -313,6 +317,11 @@ PRINTING_ORDER_CREATED_WECOM_MARKDOWN_TEMPLATE = (
|
||||
"- **出货日期**:`{outgoing_date}`\n"
|
||||
)
|
||||
|
||||
SHIPMENT_CREATED_WECOM_NOTIFY_ENABLED = env.bool(
|
||||
"SHIPMENT_CREATED_WECOM_NOTIFY_ENABLED",
|
||||
default=True,
|
||||
)
|
||||
|
||||
# PreSalesOrder:创建时的企业微信通知(markdown)
|
||||
# - 默认:测试环境关闭(避免单测出网/刷屏),非测试环境开启
|
||||
PRE_SALES_ORDER_CREATED_WECOM_NOTIFY_ENABLED = env.bool(
|
||||
|
||||
@@ -14,7 +14,7 @@ class PrintingConfig(AppConfig):
|
||||
from stateflow.signals import process_completed, state_advanced
|
||||
from .models import PrintingJob, PrintingOrder
|
||||
from . import handlers
|
||||
from .signals import printing_order_created
|
||||
from .signals import printing_job_production_completed, printing_order_created
|
||||
|
||||
# 监听 PrintingJob 流程完成信号
|
||||
process_completed.connect(
|
||||
@@ -34,6 +34,11 @@ class PrintingConfig(AppConfig):
|
||||
sender=PrintingOrder,
|
||||
dispatch_uid="printing.on_printing_order_created",
|
||||
)
|
||||
printing_job_production_completed.connect(
|
||||
handlers.on_printing_job_production_completed,
|
||||
sender=PrintingJob,
|
||||
dispatch_uid="printing.on_printing_job_production_completed",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[printing.apps] 已注册 process_completed 信号处理器, '
|
||||
@@ -46,4 +51,8 @@ class PrintingConfig(AppConfig):
|
||||
logger.info(
|
||||
f'[printing.apps] 已注册 printing_order_created 信号处理器, '
|
||||
f'sender={PrintingOrder}, handler={handlers.on_printing_order_created}'
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
f'[printing.apps] 已注册 printing_job_production_completed 信号处理器, '
|
||||
f'sender={PrintingJob}, handler={handlers.on_printing_job_production_completed}'
|
||||
)
|
||||
|
||||
@@ -253,8 +253,8 @@ def on_printing_order_created(sender, **kwargs):
|
||||
- 消息字段(中文):
|
||||
- 印染订单ID / human_id
|
||||
- 创建时间
|
||||
- 发送者(优先 created_by.employee.name / created_by.username)
|
||||
- 客户、面料、出货日期(可选字段,缺省展示为 '-')
|
||||
- 发送者(优先 created_by.employee.name / created_by.username)
|
||||
- 客户、面料、出货日期(可选字段,缺省展示为 '-')
|
||||
"""
|
||||
# 测试环境默认关闭,避免单测出网/刷屏
|
||||
if not getattr(settings, "PRINTING_ORDER_CREATED_WECOM_NOTIFY_ENABLED", True):
|
||||
@@ -336,3 +336,44 @@ def on_printing_order_created(sender, **kwargs):
|
||||
|
||||
# 在事务提交后再发送,避免事务回滚但通知已发出
|
||||
transaction.on_commit(_send_wecom)
|
||||
|
||||
|
||||
def on_printing_job_production_completed(sender, **kwargs):
|
||||
"""
|
||||
PrintingJob 被显式标记为完成生产后的处理。
|
||||
|
||||
当前通过 Celery task 异步发送企业微信通知,避免阻塞主流程。
|
||||
"""
|
||||
if not getattr(settings, "PRINTING_JOB_PRODUCTION_COMPLETED_WECOM_NOTIFY_ENABLED", True):
|
||||
return
|
||||
if getattr(settings, "TESTING", False):
|
||||
return
|
||||
|
||||
job = kwargs.get("instance")
|
||||
triggered_by = kwargs.get("triggered_by")
|
||||
if job is None:
|
||||
logger.warning(
|
||||
"[printing.handlers] printing_job_production_completed 信号缺少 instance,跳过通知"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"[printing.handlers] 收到 printing_job_production_completed 信号: sender=%s, job_id=%s",
|
||||
sender,
|
||||
getattr(job, "id", None),
|
||||
)
|
||||
|
||||
def _enqueue_task():
|
||||
try:
|
||||
from printing.tasks import notify_printing_job_production_completed_wecom
|
||||
|
||||
notify_printing_job_production_completed_wecom.delay(
|
||||
printing_job_id=job.id,
|
||||
triggered_by_id=getattr(triggered_by, "id", None),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[printing.handlers] 投递 notify_printing_job_production_completed_wecom task 失败(已忽略,不影响主流程)"
|
||||
)
|
||||
|
||||
transaction.on_commit(_enqueue_task)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("printing", "0036_printing_external_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="printingjob",
|
||||
name="is_production_completed",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="独立于流程计算状态的生产完成标记",
|
||||
verbose_name="是否完成生产",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -466,6 +466,11 @@ class PrintingJob(ModelBase):
|
||||
default=PrintingJobWorkStateEnum.PRODUCING,
|
||||
verbose_name='业务进展',
|
||||
)
|
||||
is_production_completed = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='是否完成生产',
|
||||
help_text='独立于流程计算状态的生产完成标记',
|
||||
)
|
||||
quantity = models.PositiveIntegerField(verbose_name='数量')
|
||||
unit = models.CharField(max_length=50, verbose_name='单位')
|
||||
size = models.CharField(max_length=100, null=True, blank=True, verbose_name='一段尺寸')
|
||||
|
||||
@@ -294,3 +294,118 @@ def render_printing_order_created_markdown(
|
||||
fabric=str(fabric or "-"),
|
||||
outgoing_date=str(outgoing_date or "-"),
|
||||
)
|
||||
|
||||
|
||||
def render_printing_job_production_completed_markdown(
|
||||
*,
|
||||
printing_order_identifier: str,
|
||||
printing_job_id: str,
|
||||
product_name: str,
|
||||
unshipped_sales_items_count: str,
|
||||
unshipped_sales_items_quantity: str,
|
||||
sender_label: str,
|
||||
) -> str:
|
||||
template = getattr(
|
||||
settings,
|
||||
"PRINTING_JOB_PRODUCTION_COMPLETED_WECOM_MARKDOWN_TEMPLATE",
|
||||
(
|
||||
"### 印染任务已完成生产\n"
|
||||
"\n"
|
||||
"- **订单号**:`{printing_order_identifier}`\n"
|
||||
"- **印染任务ID**:`{printing_job_id}`\n"
|
||||
"- **产品名称**:{product_name}\n"
|
||||
"- **未出货销售品条数**:`{unshipped_sales_items_count}`\n"
|
||||
"- **未出货销售品数量**:`{unshipped_sales_items_quantity}`\n"
|
||||
"- **发送者**:{sender}\n"
|
||||
),
|
||||
)
|
||||
return template.format(
|
||||
printing_order_identifier=str(printing_order_identifier or "-"),
|
||||
printing_job_id=str(printing_job_id or "-"),
|
||||
product_name=str(product_name or "-"),
|
||||
unshipped_sales_items_count=str(unshipped_sales_items_count or "0"),
|
||||
unshipped_sales_items_quantity=str(unshipped_sales_items_quantity or "0"),
|
||||
sender=str(sender_label or "系统自动发送"),
|
||||
)
|
||||
|
||||
|
||||
def send_printing_job_production_completed_wecom(
|
||||
*,
|
||||
printing_job_id: int,
|
||||
triggered_by_id: int | None = None,
|
||||
key: str | None = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Sum
|
||||
from printing.models import PrintingJob
|
||||
from shipment.services import get_active_sales_items_queryset
|
||||
|
||||
job = (
|
||||
PrintingJob.objects.select_related("product", "printing_order")
|
||||
.filter(id=int(printing_job_id))
|
||||
.first()
|
||||
)
|
||||
if not job:
|
||||
raise ValueError(f"PrintingJob 不存在:id={printing_job_id}")
|
||||
|
||||
triggered_by = None
|
||||
if triggered_by_id:
|
||||
triggered_by = get_user_model().objects.filter(id=int(triggered_by_id)).first()
|
||||
|
||||
order = getattr(job, "printing_order", None)
|
||||
external_order_id = str(getattr(order, "external_order_id", "") or "").strip()
|
||||
printing_order_identifier = external_order_id or str(getattr(order, "id", "-") or "-")
|
||||
product_name = getattr(getattr(job, "product", None), "name", None) or "-"
|
||||
unshipped_sales_items_qs = get_active_sales_items_queryset().filter(
|
||||
printing_job_id=job.id,
|
||||
shipment__isnull=True,
|
||||
)
|
||||
unshipped_sales_items_count = unshipped_sales_items_qs.count()
|
||||
unshipped_sales_items_quantity = (
|
||||
unshipped_sales_items_qs.aggregate(total=Sum("quantity")).get("total") or 0
|
||||
)
|
||||
sender_label = _user_label(triggered_by)
|
||||
|
||||
msg = render_printing_job_production_completed_markdown(
|
||||
printing_order_identifier=str(printing_order_identifier),
|
||||
printing_job_id=str(job.id),
|
||||
product_name=str(product_name),
|
||||
unshipped_sales_items_count=str(unshipped_sales_items_count),
|
||||
unshipped_sales_items_quantity=str(unshipped_sales_items_quantity),
|
||||
sender_label=str(sender_label),
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"dry_run": True,
|
||||
"message": msg,
|
||||
"printing_job_id": job.id,
|
||||
"printing_order_identifier": str(printing_order_identifier),
|
||||
"product_name": str(product_name),
|
||||
"unshipped_sales_items_count": unshipped_sales_items_count,
|
||||
"unshipped_sales_items_quantity": str(unshipped_sales_items_quantity),
|
||||
"sender_label": str(sender_label),
|
||||
}
|
||||
|
||||
resp = send_wecom_webhook_message(
|
||||
content=msg,
|
||||
msgtype="markdown",
|
||||
key=key,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
return {
|
||||
"dry_run": False,
|
||||
"message": msg,
|
||||
"printing_job_id": job.id,
|
||||
"printing_order_identifier": str(printing_order_identifier),
|
||||
"product_name": str(product_name),
|
||||
"unshipped_sales_items_count": unshipped_sales_items_count,
|
||||
"unshipped_sales_items_quantity": str(unshipped_sales_items_quantity),
|
||||
"sender_label": str(sender_label),
|
||||
"wecom": resp.raw,
|
||||
"ok": resp.ok,
|
||||
"errcode": resp.errcode,
|
||||
"errmsg": resp.errmsg,
|
||||
}
|
||||
|
||||
@@ -15,3 +15,9 @@ from django.dispatch import Signal
|
||||
# - created_by: Django User (may be None)
|
||||
printing_order_created = Signal()
|
||||
|
||||
# Fired when a PrintingJob is explicitly marked as production completed
|
||||
# via business service.
|
||||
# Payload:
|
||||
# - instance: PrintingJob
|
||||
# - triggered_by: Django User (may be None)
|
||||
printing_job_production_completed = Signal()
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from printing.models import PlateOrder, PlateOrderTiiaUploadFailure
|
||||
from printing.services import send_printing_job_production_completed_wecom
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -116,3 +117,18 @@ def upload_yesterday_plate_order_images_to_tencent_tiia(self):
|
||||
logger.info('[TIIA] 昨日 PlateOrder 图片上传任务完成: %s', payload)
|
||||
return payload
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def notify_printing_job_production_completed_wecom(
|
||||
self,
|
||||
*,
|
||||
printing_job_id: int,
|
||||
triggered_by_id: int | None = None,
|
||||
) -> dict:
|
||||
payload = send_printing_job_production_completed_wecom(
|
||||
printing_job_id=printing_job_id,
|
||||
triggered_by_id=triggered_by_id,
|
||||
)
|
||||
payload["task_id"] = self.request.id
|
||||
logger.info("[printing.tasks] 印染任务完成生产企业微信通知发送完成: %s", payload)
|
||||
return payload
|
||||
|
||||
136
printing/test_production_completed_notification.py
Normal file
136
printing/test_production_completed_notification.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.test import TestCase, TransactionTestCase, override_settings
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from printing import handlers
|
||||
from printing import models as printing_models
|
||||
from printing import tasks as printing_tasks
|
||||
from shipment import models as shipment_models
|
||||
|
||||
|
||||
class PrintingJobProductionCompletedWeComServiceTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(username="notify-user", password="pass")
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name="测试印花厂",
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
basic_models.Employee.objects.create(
|
||||
sys_user=self.user,
|
||||
merchant=self.merchant,
|
||||
name="测试员工",
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
self.customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="测试客户",
|
||||
)
|
||||
self.category = basic_models.ProductCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="测试分类",
|
||||
)
|
||||
self.product = basic_models.Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=self.category,
|
||||
name="测试产品",
|
||||
)
|
||||
|
||||
def test_send_printing_job_production_completed_wecom_fallbacks_to_order_id(self):
|
||||
order = printing_models.PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
fabric="棉布",
|
||||
width="150cm",
|
||||
external_order_id="",
|
||||
)
|
||||
job = printing_models.PrintingJob.objects.create(
|
||||
merchant=self.merchant,
|
||||
printing_order=order,
|
||||
product=self.product,
|
||||
quantity=10,
|
||||
unit="米",
|
||||
)
|
||||
shipment_models.SalesItem.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="未出货销售品1",
|
||||
quantity="50.50",
|
||||
unit=shipment_models.UnitChoices.METER,
|
||||
printing_job_id=job.id,
|
||||
created_by=self.user,
|
||||
)
|
||||
shipment_models.SalesItem.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="未出货销售品2",
|
||||
quantity="38.00",
|
||||
unit=shipment_models.UnitChoices.METER,
|
||||
printing_job_id=job.id,
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
from printing.services import send_printing_job_production_completed_wecom
|
||||
|
||||
payload = send_printing_job_production_completed_wecom(
|
||||
printing_job_id=job.id,
|
||||
triggered_by_id=self.user.id,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["printing_order_identifier"], str(order.id))
|
||||
self.assertEqual(payload["product_name"], self.product.name)
|
||||
self.assertEqual(payload["unshipped_sales_items_count"], 2)
|
||||
self.assertEqual(payload["unshipped_sales_items_quantity"], "88.50")
|
||||
self.assertEqual(payload["sender_label"], "测试员工")
|
||||
|
||||
|
||||
@override_settings(
|
||||
CELERY_TASK_ALWAYS_EAGER=True,
|
||||
CELERY_TASK_EAGER_PROPAGATES=True,
|
||||
)
|
||||
class PrintingJobProductionCompletedWeComTaskTestCase(TestCase):
|
||||
def test_task_delegates_to_service(self):
|
||||
with patch(
|
||||
"printing.tasks.send_printing_job_production_completed_wecom"
|
||||
) as mock_sync:
|
||||
mock_sync.return_value = {"printing_job_id": 123, "ok": True}
|
||||
|
||||
async_result = printing_tasks.notify_printing_job_production_completed_wecom.delay(
|
||||
printing_job_id=123,
|
||||
triggered_by_id=456,
|
||||
)
|
||||
payload = async_result.get(timeout=5)
|
||||
|
||||
mock_sync.assert_called_once_with(
|
||||
printing_job_id=123,
|
||||
triggered_by_id=456,
|
||||
)
|
||||
self.assertEqual(payload["printing_job_id"], 123)
|
||||
self.assertIn("task_id", payload)
|
||||
|
||||
|
||||
@override_settings(
|
||||
TESTING=False,
|
||||
PRINTING_JOB_PRODUCTION_COMPLETED_WECOM_NOTIFY_ENABLED=True,
|
||||
)
|
||||
class PrintingJobProductionCompletedHandlerTestCase(TransactionTestCase):
|
||||
def test_handler_enqueues_task_on_commit(self):
|
||||
job = type("Job", (), {"id": 321})()
|
||||
user = type("User", (), {"id": 654})()
|
||||
|
||||
with patch(
|
||||
"printing.tasks.notify_printing_job_production_completed_wecom.delay"
|
||||
) as mock_delay:
|
||||
with transaction.atomic():
|
||||
handlers.on_printing_job_production_completed(
|
||||
sender=printing_models.PrintingJob,
|
||||
instance=job,
|
||||
triggered_by=user,
|
||||
)
|
||||
self.assertFalse(mock_delay.called)
|
||||
|
||||
mock_delay.assert_called_once_with(
|
||||
printing_job_id=321,
|
||||
triggered_by_id=654,
|
||||
)
|
||||
@@ -21,3 +21,22 @@ class WeComMarkdownRenderTest(SimpleTestCase):
|
||||
md = render_process_params_markdown(params={"图片": " https://example.com/a.png "})
|
||||
self.assertIn("[https://example.com/a.png](https://example.com/a.png)", md)
|
||||
|
||||
def test_render_printing_job_production_completed_markdown(self):
|
||||
from printing.services import render_printing_job_production_completed_markdown
|
||||
|
||||
md = render_printing_job_production_completed_markdown(
|
||||
printing_order_identifier="EXT-001",
|
||||
printing_job_id="123",
|
||||
product_name="测试产品",
|
||||
unshipped_sales_items_count="2",
|
||||
unshipped_sales_items_quantity="88.50",
|
||||
sender_label="张三",
|
||||
)
|
||||
|
||||
self.assertIn("印染任务已完成生产", md)
|
||||
self.assertIn("`EXT-001`", md)
|
||||
self.assertIn("`123`", md)
|
||||
self.assertIn("测试产品", md)
|
||||
self.assertIn("`2`", md)
|
||||
self.assertIn("`88.50`", md)
|
||||
self.assertIn("张三", md)
|
||||
|
||||
@@ -5,3 +5,14 @@ class ShipmentConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'shipment'
|
||||
verbose_name = '出货管理'
|
||||
|
||||
def ready(self):
|
||||
from . import handlers
|
||||
from .models import Shipment
|
||||
from .signals import shipment_created
|
||||
|
||||
shipment_created.connect(
|
||||
handlers.on_shipment_created,
|
||||
sender=Shipment,
|
||||
dispatch_uid="shipment.on_shipment_created",
|
||||
)
|
||||
|
||||
46
shipment/handlers.py
Normal file
46
shipment/handlers.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def on_shipment_created(sender, **kwargs):
|
||||
"""
|
||||
Shipment 创建后的处理。
|
||||
|
||||
当前通过 Celery task 异步发送企业微信通知,避免阻塞主流程。
|
||||
"""
|
||||
if not getattr(settings, "SHIPMENT_CREATED_WECOM_NOTIFY_ENABLED", True):
|
||||
return
|
||||
if getattr(settings, "TESTING", False):
|
||||
return
|
||||
|
||||
shipment = kwargs.get("instance")
|
||||
created_by = kwargs.get("created_by")
|
||||
if shipment is None:
|
||||
logger.warning("[shipment.handlers] shipment_created 信号缺少 instance,跳过通知")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"[shipment.handlers] 收到 shipment_created 信号: sender=%s, shipment_id=%s",
|
||||
sender,
|
||||
getattr(shipment, "id", None),
|
||||
)
|
||||
|
||||
def _enqueue_task():
|
||||
try:
|
||||
from shipment.tasks import notify_shipment_created_wecom
|
||||
|
||||
notify_shipment_created_wecom.delay(
|
||||
shipment_id=shipment.id,
|
||||
created_by_id=getattr(created_by, "id", None),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[shipment.handlers] 投递 notify_shipment_created_wecom task 失败(已忽略,不影响主流程)"
|
||||
)
|
||||
|
||||
transaction.on_commit(_enqueue_task)
|
||||
@@ -7,11 +7,14 @@ from __future__ import annotations
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Exists, IntegerField, OuterRef, QuerySet, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
|
||||
from api_v1.utils.wecom_webhook import send_wecom_webhook_message
|
||||
from shipment.models import (
|
||||
ExternalFinishedProduct,
|
||||
SalesItem,
|
||||
@@ -22,6 +25,7 @@ from shipment.models import (
|
||||
ShipmentDeliveryStatus,
|
||||
ShipmentStatus,
|
||||
)
|
||||
from shipment.signals import shipment_created
|
||||
|
||||
|
||||
def _resolve_user_merchant(user):
|
||||
@@ -33,6 +37,19 @@ def _resolve_user_employee(user):
|
||||
return getattr(user, "employee", None)
|
||||
|
||||
|
||||
def _user_label(user) -> str:
|
||||
if not user:
|
||||
return "系统自动发送"
|
||||
emp = getattr(user, "employee", None)
|
||||
name = getattr(emp, "name", None) if emp is not None else None
|
||||
if name:
|
||||
return str(name)
|
||||
username = getattr(user, "username", None)
|
||||
if username:
|
||||
return str(username)
|
||||
return "系统自动发送"
|
||||
|
||||
|
||||
def get_active_sales_items_queryset() -> QuerySet[SalesItem]:
|
||||
"""
|
||||
返回未软删除的销售品查询集。
|
||||
@@ -716,6 +733,7 @@ def update_shipment(
|
||||
return shipment
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@transaction.atomic
|
||||
def create_shipment(
|
||||
customer_id: int,
|
||||
@@ -834,9 +852,119 @@ def create_shipment(
|
||||
if updated != len(sales_item_ids):
|
||||
raise ValueError("存在不属于当前商户的销售品,无法关联到出货单")
|
||||
|
||||
try:
|
||||
shipment_created.send(
|
||||
sender=Shipment,
|
||||
instance=shipment,
|
||||
created_by=created_by,
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception(
|
||||
"[shipment.services] 触发 shipment_created signal 失败(已忽略)"
|
||||
)
|
||||
|
||||
return shipment
|
||||
|
||||
|
||||
def render_shipment_created_markdown(
|
||||
*,
|
||||
shipment_id: str,
|
||||
customer_name: str,
|
||||
shipment_date: str,
|
||||
items_count: str,
|
||||
sender_label: str,
|
||||
) -> str:
|
||||
template = getattr(
|
||||
settings,
|
||||
"SHIPMENT_CREATED_WECOM_MARKDOWN_TEMPLATE",
|
||||
(
|
||||
"### 出货单创建\n"
|
||||
"\n"
|
||||
"- **出货单ID**:`{shipment_id}`\n"
|
||||
"- **客户**:{customer_name}\n"
|
||||
"- **出货日期**:`{shipment_date}`\n"
|
||||
"- **销售品数量**:`{items_count}`\n"
|
||||
"- **发送者**:{sender}\n"
|
||||
),
|
||||
)
|
||||
return template.format(
|
||||
shipment_id=str(shipment_id or "-"),
|
||||
customer_name=str(customer_name or "-"),
|
||||
shipment_date=str(shipment_date or "-"),
|
||||
items_count=str(items_count or "0"),
|
||||
sender=str(sender_label or "系统自动发送"),
|
||||
)
|
||||
|
||||
|
||||
def send_shipment_created_wecom(
|
||||
*,
|
||||
shipment_id: int,
|
||||
created_by_id: int | None = None,
|
||||
key: str | None = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
shipment = (
|
||||
Shipment.objects.select_related("customer")
|
||||
.prefetch_related("items")
|
||||
.filter(id=int(shipment_id))
|
||||
.first()
|
||||
)
|
||||
if not shipment:
|
||||
raise ValueError(f"Shipment 不存在:id={shipment_id}")
|
||||
|
||||
created_by = None
|
||||
if created_by_id:
|
||||
created_by = get_user_model().objects.filter(id=int(created_by_id)).first()
|
||||
|
||||
customer_name = getattr(getattr(shipment, "customer", None), "name", None) or "-"
|
||||
shipment_date_text = (
|
||||
shipment.shipment_date.isoformat() if getattr(shipment, "shipment_date", None) else "-"
|
||||
)
|
||||
items_count = shipment.items.filter(delete_at__isnull=True).count()
|
||||
sender_label = _user_label(created_by)
|
||||
|
||||
msg = render_shipment_created_markdown(
|
||||
shipment_id=str(shipment.id),
|
||||
customer_name=str(customer_name),
|
||||
shipment_date=str(shipment_date_text),
|
||||
items_count=str(items_count),
|
||||
sender_label=str(sender_label),
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"dry_run": True,
|
||||
"message": msg,
|
||||
"shipment_id": shipment.id,
|
||||
"customer_name": str(customer_name),
|
||||
"shipment_date": str(shipment_date_text),
|
||||
"items_count": items_count,
|
||||
"sender_label": str(sender_label),
|
||||
}
|
||||
|
||||
resp = send_wecom_webhook_message(
|
||||
content=msg,
|
||||
msgtype="markdown",
|
||||
key=key,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
return {
|
||||
"dry_run": False,
|
||||
"message": msg,
|
||||
"shipment_id": shipment.id,
|
||||
"customer_name": str(customer_name),
|
||||
"shipment_date": str(shipment_date_text),
|
||||
"items_count": items_count,
|
||||
"sender_label": str(sender_label),
|
||||
"wecom": resp.raw,
|
||||
"ok": resp.ok,
|
||||
"errcode": resp.errcode,
|
||||
"errmsg": resp.errmsg,
|
||||
}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_external_shipment(
|
||||
customer_id: int,
|
||||
|
||||
11
shipment/signals.py
Normal file
11
shipment/signals.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Shipment domain signals.
|
||||
"""
|
||||
|
||||
from django.dispatch import Signal
|
||||
|
||||
# Fired when a Shipment is created via business service.
|
||||
# Payload:
|
||||
# - instance: Shipment
|
||||
# - created_by: Django User (may be None)
|
||||
shipment_created = Signal()
|
||||
24
shipment/tasks.py
Normal file
24
shipment/tasks.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from shipment.services import send_shipment_created_wecom
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def notify_shipment_created_wecom(
|
||||
self,
|
||||
*,
|
||||
shipment_id: int,
|
||||
created_by_id: int | None = None,
|
||||
) -> dict:
|
||||
payload = send_shipment_created_wecom(
|
||||
shipment_id=shipment_id,
|
||||
created_by_id=created_by_id,
|
||||
)
|
||||
payload["task_id"] = self.request.id
|
||||
logger.info("[shipment.tasks] 出货单创建企业微信通知发送完成: %s", payload)
|
||||
return payload
|
||||
98
shipment/test_notification.py
Normal file
98
shipment/test_notification.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.test import TestCase, TransactionTestCase, override_settings
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from shipment import handlers
|
||||
from shipment import models as shipment_models
|
||||
from shipment import tasks as shipment_tasks
|
||||
|
||||
|
||||
class ShipmentCreatedWeComServiceTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(username="shipment-user", password="pass")
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name="测试印花厂",
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
basic_models.Employee.objects.create(
|
||||
sys_user=self.user,
|
||||
merchant=self.merchant,
|
||||
name="出货员工",
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
self.customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="测试客户",
|
||||
)
|
||||
|
||||
def test_send_shipment_created_wecom_dry_run(self):
|
||||
shipment = shipment_models.Shipment.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
shipment_date="2026-01-14",
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
from shipment.services import send_shipment_created_wecom
|
||||
|
||||
payload = send_shipment_created_wecom(
|
||||
shipment_id=shipment.id,
|
||||
created_by_id=self.user.id,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["shipment_id"], shipment.id)
|
||||
self.assertEqual(payload["customer_name"], self.customer.name)
|
||||
self.assertEqual(payload["shipment_date"], "2026-01-14")
|
||||
self.assertEqual(payload["items_count"], 0)
|
||||
self.assertEqual(payload["sender_label"], "出货员工")
|
||||
|
||||
|
||||
@override_settings(
|
||||
CELERY_TASK_ALWAYS_EAGER=True,
|
||||
CELERY_TASK_EAGER_PROPAGATES=True,
|
||||
)
|
||||
class ShipmentCreatedWeComTaskTestCase(TestCase):
|
||||
def test_task_delegates_to_service(self):
|
||||
with patch("shipment.tasks.send_shipment_created_wecom") as mock_sync:
|
||||
mock_sync.return_value = {"shipment_id": 123, "ok": True}
|
||||
|
||||
async_result = shipment_tasks.notify_shipment_created_wecom.delay(
|
||||
shipment_id=123,
|
||||
created_by_id=456,
|
||||
)
|
||||
payload = async_result.get(timeout=5)
|
||||
|
||||
mock_sync.assert_called_once_with(
|
||||
shipment_id=123,
|
||||
created_by_id=456,
|
||||
)
|
||||
self.assertEqual(payload["shipment_id"], 123)
|
||||
self.assertIn("task_id", payload)
|
||||
|
||||
|
||||
@override_settings(
|
||||
TESTING=False,
|
||||
SHIPMENT_CREATED_WECOM_NOTIFY_ENABLED=True,
|
||||
)
|
||||
class ShipmentCreatedHandlerTestCase(TransactionTestCase):
|
||||
def test_handler_enqueues_task_on_commit(self):
|
||||
shipment = type("Shipment", (), {"id": 321})()
|
||||
user = type("User", (), {"id": 654})()
|
||||
|
||||
with patch("shipment.tasks.notify_shipment_created_wecom.delay") as mock_delay:
|
||||
with transaction.atomic():
|
||||
handlers.on_shipment_created(
|
||||
sender=shipment_models.Shipment,
|
||||
instance=shipment,
|
||||
created_by=user,
|
||||
)
|
||||
self.assertFalse(mock_delay.called)
|
||||
|
||||
mock_delay.assert_called_once_with(
|
||||
shipment_id=321,
|
||||
created_by_id=654,
|
||||
)
|
||||
Reference in New Issue
Block a user