diff --git a/.env b/.env index bd0a9c3..ade33ed 100644 --- a/.env +++ b/.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} diff --git a/api_v1/views/printing/API.md b/api_v1/views/printing/API.md index 3192046..f429355 100644 --- a/api_v1/views/printing/API.md +++ b/api_v1/views/printing/API.md @@ -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/` diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index caacd38..cdaa9ac 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -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' ] diff --git a/api_v1/views/printing/services.py b/api_v1/views/printing/services.py index 161c093..5fe708a 100644 --- a/api_v1/views/printing/services.py +++ b/api_v1/views/printing/services.py @@ -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, } diff --git a/api_v1/views/printing/test_printing_job_api.py b/api_v1/views/printing/test_printing_job_api.py index 36f2407..6e5e8dd 100644 --- a/api_v1/views/printing/test_printing_job_api.py +++ b/api_v1/views/printing/test_printing_job_api.py @@ -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""" diff --git a/api_v1/views/printing/test_services.py b/api_v1/views/printing/test_services.py index 53ddd23..b30bbaa 100644 --- a/api_v1/views/printing/test_services.py +++ b/api_v1/views/printing/test_services.py @@ -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']) diff --git a/api_v1/views/printing/views.py b/api_v1/views/printing/views.py index 3ff0f44..9af7ec5 100644 --- a/api_v1/views/printing/views.py +++ b/api_v1/views/printing/views.py @@ -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( diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index fff7202..e44e9cc 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -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, ) diff --git a/docs/plant/printing-job-completion-flow.png b/docs/plant/printing-job-completion-flow.png new file mode 100644 index 0000000..451ac77 Binary files /dev/null and b/docs/plant/printing-job-completion-flow.png differ diff --git a/docs/plant/printing-job-completion-flow.puml b/docs/plant/printing-job-completion-flow.puml new file mode 100644 index 0000000..261d17c --- /dev/null +++ b/docs/plant/printing-job-completion-flow.puml @@ -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 diff --git a/docs/shipment_api.md b/docs/shipment_api.md index 0e4a044..4fddf44 100644 --- a/docs/shipment_api.md +++ b/docs/shipment_api.md @@ -141,6 +141,7 @@ - 并且这些销售品必须全部来自同一个 `printing_order` - 如果存在缺少生产任务、生产任务不存在、或跨生产订单混装,接口会拒绝创建 - 新创建的出货单默认状态为 `草稿(未发布)` +- 创建成功后会触发 `shipment_created` 领域信号,并通过异步 Celery task 发送企业微信通知 ### 接口信息 diff --git a/flower/settings.py b/flower/settings.py index 6efeb24..d93f958 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -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( diff --git a/printing/apps.py b/printing/apps.py index 1a5ef6f..0d05927 100644 --- a/printing/apps.py +++ b/printing/apps.py @@ -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}' - ) \ No newline at end of file + ) + logger.info( + f'[printing.apps] 已注册 printing_job_production_completed 信号处理器, ' + f'sender={PrintingJob}, handler={handlers.on_printing_job_production_completed}' + ) diff --git a/printing/handlers.py b/printing/handlers.py index 726f4b1..6122dd9 100644 --- a/printing/handlers.py +++ b/printing/handlers.py @@ -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) diff --git a/printing/migrations/0037_printingjob_is_production_completed.py b/printing/migrations/0037_printingjob_is_production_completed.py new file mode 100644 index 0000000..25b44bc --- /dev/null +++ b/printing/migrations/0037_printingjob_is_production_completed.py @@ -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="是否完成生产", + ), + ), + ] diff --git a/printing/models.py b/printing/models.py index d9d4f15..2b7b734 100644 --- a/printing/models.py +++ b/printing/models.py @@ -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='一段尺寸') diff --git a/printing/services.py b/printing/services.py index 28cfb34..0395e90 100644 --- a/printing/services.py +++ b/printing/services.py @@ -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, + } diff --git a/printing/signals.py b/printing/signals.py index 731da0f..1a58898 100644 --- a/printing/signals.py +++ b/printing/signals.py @@ -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() diff --git a/printing/tasks.py b/printing/tasks.py index 9ccda43..e29e40f 100644 --- a/printing/tasks.py +++ b/printing/tasks.py @@ -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 diff --git a/printing/test_production_completed_notification.py b/printing/test_production_completed_notification.py new file mode 100644 index 0000000..7b0bdfc --- /dev/null +++ b/printing/test_production_completed_notification.py @@ -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, + ) diff --git a/printing/test_wecom_markdown.py b/printing/test_wecom_markdown.py index 8311a2d..60d4681 100644 --- a/printing/test_wecom_markdown.py +++ b/printing/test_wecom_markdown.py @@ -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) diff --git a/shipment/apps.py b/shipment/apps.py index defbb6e..f9a94ec 100644 --- a/shipment/apps.py +++ b/shipment/apps.py @@ -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", + ) diff --git a/shipment/handlers.py b/shipment/handlers.py new file mode 100644 index 0000000..d518e82 --- /dev/null +++ b/shipment/handlers.py @@ -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) diff --git a/shipment/services.py b/shipment/services.py index a5fceef..f245748 100644 --- a/shipment/services.py +++ b/shipment/services.py @@ -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, diff --git a/shipment/signals.py b/shipment/signals.py new file mode 100644 index 0000000..eb937e0 --- /dev/null +++ b/shipment/signals.py @@ -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() diff --git a/shipment/tasks.py b/shipment/tasks.py new file mode 100644 index 0000000..f1d6447 --- /dev/null +++ b/shipment/tasks.py @@ -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 diff --git a/shipment/test_notification.py b/shipment/test_notification.py new file mode 100644 index 0000000..dc85ee4 --- /dev/null +++ b/shipment/test_notification.py @@ -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, + )