1
0
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:
2026-04-09 17:35:14 +08:00
parent 76ea445880
commit 646dbe7f18
27 changed files with 1122 additions and 13 deletions

2
.env
View File

@@ -7,5 +7,5 @@ DB_NAME=flower
DB_USER=flower DB_USER=flower
DB_PASSWORD=flower_pg_zuowei1216 DB_PASSWORD=flower_pg_zuowei1216
PRINTING_SALES_ITEM_SOURCE_STATE_ID=2 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} PRINTING_JOB_STATE_ADVANCED_FOLLOWUP_URL_TEMPLATE=https://app.yuwen.cloud/workstation/production/batch-advance?orderId={order_id}

View File

@@ -170,6 +170,26 @@
- **描述**: 将任务回退到上一个流程状态。 - **描述**: 将任务回退到上一个流程状态。
- **响应**: 成功时返回 `200 OK`,包含 `detail` 和 `data`。失败时返回 `400 Bad Request`。 - **响应**: 成功时返回 `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. 状态查询操作 ### 2.3. 状态查询操作
- **GET** `/api/v1/printing-jobs/{id}/completed-states/` - **GET** `/api/v1/printing-jobs/{id}/completed-states/`

View File

@@ -340,6 +340,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
work_state_display = serializers.SerializerMethodField() work_state_display = serializers.SerializerMethodField()
has_started = serializers.BooleanField(read_only=True) has_started = serializers.BooleanField(read_only=True)
is_completed = 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) progress_percentage = serializers.FloatField(read_only=True)
last_completed_state = serializers.CharField(read_only=True) last_completed_state = serializers.CharField(read_only=True)
business_object_id = serializers.SerializerMethodField() business_object_id = serializers.SerializerMethodField()
@@ -354,7 +355,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
'product_image_url', 'has_started', 'product_image_url', 'has_started',
'quantity', 'unit', 'size', 'pieces', 'description', 'quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state_display', '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', 'business_object_id',
'batch_advance_records', 'batch_advance_records',
'saleitems', 'saleitems',
@@ -362,7 +363,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
] ]
read_only_fields = [ read_only_fields = [
'id', 'created_at', 'updated_at', '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' 'business_object_id', 'merchant_id'
] ]
@@ -432,6 +433,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
status_id = serializers.IntegerField(read_only=True) status_id = serializers.IntegerField(read_only=True)
work_state_display = serializers.SerializerMethodField() work_state_display = serializers.SerializerMethodField()
is_completed = serializers.BooleanField(read_only=True) is_completed = serializers.BooleanField(read_only=True)
is_production_completed = serializers.BooleanField(read_only=True)
has_started = serializers.BooleanField(read_only=True) has_started = serializers.BooleanField(read_only=True)
progress_percentage = serializers.FloatField(read_only=True) progress_percentage = serializers.FloatField(read_only=True)
last_completed_state = serializers.CharField(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', 'id', 'original_id', 'merchant_id', 'printing_order', 'printing_order_id', 'external_order_id', 'product', 'product_name', 'product_code',
'quantity', 'unit', 'size', 'pieces', 'description', 'quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state_display', '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', 'progress_percentage', 'last_completed_state',
'business_object_id', 'business_object_id',
'batch_advance_records', 'batch_advance_records',
@@ -455,7 +457,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
] ]
read_only_fields = [ read_only_fields = [
'id', 'created_at', 'updated_at', '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', 'progress_percentage', 'last_completed_state',
'business_object_id', 'merchant_id' 'business_object_id', 'merchant_id'
] ]

View File

@@ -6,6 +6,7 @@ from django.conf import settings
from django.db import transaction from django.db import transaction
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from printing import models as printing_models 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 models as stateflow_models
from stateflow import services as stateflow_services from stateflow import services as stateflow_services
@@ -55,7 +56,6 @@ class PrintingOrderService:
# domain event: printing order created # domain event: printing order created
# handler will ensure transaction.on_commit for external side effects (e.g. WeCom notify) # handler will ensure transaction.on_commit for external side effects (e.g. WeCom notify)
try: try:
from printing.signals import printing_order_created
printing_order_created.send( printing_order_created.send(
sender=printing_models.PrintingOrder, sender=printing_models.PrintingOrder,
instance=order, instance=order,
@@ -173,6 +173,8 @@ class PrintingJobService:
Returns: Returns:
创建的任务实例 创建的任务实例
""" """
data = dict(data)
data.pop('is_production_completed', None)
printing_order = data.get('printing_order') printing_order = data.get('printing_order')
# 绑定创建人 # 绑定创建人
@@ -218,6 +220,9 @@ class PrintingJobService:
Returns: Returns:
(success, message, updated_job) (success, message, updated_job)
""" """
data = dict(data)
data.pop('is_production_completed', None)
# 更新字段 # 更新字段
for field, value in data.items(): for field, value in data.items():
# 不允许直接修改 business_object # 不允许直接修改 business_object
@@ -228,6 +233,31 @@ class PrintingJobService:
printing_job.save() printing_job.save()
return True, '更新成功', printing_job 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 @staticmethod
def get_job_status(printing_job: printing_models.PrintingJob) -> Dict[str, Any]: def get_job_status(printing_job: printing_models.PrintingJob) -> Dict[str, Any]:
""" """
@@ -243,5 +273,6 @@ class PrintingJobService:
'status': printing_job.status, 'status': printing_job.status,
'status_id': printing_job.status_id, 'status_id': printing_job.status_id,
'is_completed': printing_job.is_completed, 'is_completed': printing_job.is_completed,
'is_production_completed': printing_job.is_production_completed,
'has_started': printing_job.has_started, 'has_started': printing_job.has_started,
} }

View File

@@ -128,6 +128,23 @@ class PrintingJobAPITestCase(TestCase):
self.assertEqual(job.quantity, 100) self.assertEqual(job.quantity, 100)
self.assertEqual(job.unit, '') self.assertEqual(job.unit, '')
self.assertEqual(job.pieces, 10) 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): def test_list_printing_jobs(self):
"""测试获取款式明细列表""" """测试获取款式明细列表"""
@@ -159,6 +176,89 @@ class PrintingJobAPITestCase(TestCase):
self.assertIsInstance(item['batch_advance_records'], list) self.assertIsInstance(item['batch_advance_records'], list)
self.assertEqual(len(item['batch_advance_records']), 0) 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): def test_retrieve_printing_job(self):
"""测试获取款式明细详情""" """测试获取款式明细详情"""
job = printing_models.PrintingJob.objects.create( job = printing_models.PrintingJob.objects.create(
@@ -347,6 +447,28 @@ class PrintingJobAPITestCase(TestCase):
self.assertEqual(job.quantity, 200) self.assertEqual(job.quantity, 200)
self.assertEqual(job.unit, '') self.assertEqual(job.unit, '')
self.assertEqual(job.pieces, 20) 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): def test_cannot_change_printing_order_on_update(self):
"""测试:不允许通过更新接口修改 PrintingJob.printing_order""" """测试:不允许通过更新接口修改 PrintingJob.printing_order"""
@@ -706,12 +828,14 @@ class PrintingJobAPITestCase(TestCase):
self.assertIn('status', response.data) self.assertIn('status', response.data)
self.assertIn('status_id', response.data) self.assertIn('status_id', response.data)
self.assertIn('is_completed', response.data) self.assertIn('is_completed', response.data)
self.assertIn('is_production_completed', response.data)
self.assertIn('has_started', response.data) self.assertIn('has_started', response.data)
self.assertIn('business_object_id', response.data) self.assertIn('business_object_id', response.data)
# 未推进状态,但有 BusinessObject当前是初始状态 # 未推进状态,但有 BusinessObject当前是初始状态
self.assertIn(response.data['status'], ['待印染', '未开始']) # 初始状态或未开始 self.assertIn(response.data['status'], ['待印染', '未开始']) # 初始状态或未开始
self.assertFalse(response.data['is_completed']) self.assertFalse(response.data['is_completed'])
self.assertFalse(response.data['is_production_completed'])
self.assertFalse(response.data['has_started']) self.assertFalse(response.data['has_started'])
def test_job_status_in_list(self): def test_job_status_in_list(self):
@@ -731,6 +855,7 @@ class PrintingJobAPITestCase(TestCase):
# 验证列表中的状态字段 # 验证列表中的状态字段
self.assertIn('status', response.data['results'][0]) self.assertIn('status', response.data['results'][0])
self.assertIn('is_completed', 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): def test_job_completion_status(self):
"""测试任务完成状态判断""" """测试任务完成状态判断"""
@@ -763,8 +888,32 @@ class PrintingJobAPITestCase(TestCase):
# 验证完成状态 # 验证完成状态
self.assertEqual(response.data['status'], '已完成') self.assertEqual(response.data['status'], '已完成')
self.assertTrue(response.data['is_completed']) self.assertTrue(response.data['is_completed'])
self.assertFalse(response.data['is_production_completed'])
self.assertTrue(response.data['has_started']) 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): def test_advance_to_next_state(self):
"""测试推进到下一个状态 API""" """测试推进到下一个状态 API"""
job = printing_models.PrintingJob.objects.create( job = printing_models.PrintingJob.objects.create(

View File

@@ -7,6 +7,7 @@ from django.conf import settings
from basic_info.models import Merchant, MerchantTypeEnum, Employee, Customer, Product from basic_info.models import Merchant, MerchantTypeEnum, Employee, Customer, Product
from stateflow.models import State, Process from stateflow.models import State, Process
from printing.models import PrintingOrder, PrintingJob from printing.models import PrintingOrder, PrintingJob
from printing.signals import printing_job_production_completed
from api_v1.views.printing.services import PrintingOrderService, PrintingJobService from api_v1.views.printing.services import PrintingOrderService, PrintingJobService
@@ -381,6 +382,70 @@ class PrintingJobServiceTest(TestCase):
self.assertEqual(updated_job.quantity, 20) self.assertEqual(updated_job.quantity, 20)
self.assertEqual(updated_job.pieces, 10) 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): def test_get_job_status(self):
"""测试获取任务状态""" """测试获取任务状态"""
job = PrintingJob.objects.create( job = PrintingJob.objects.create(
@@ -397,9 +462,11 @@ class PrintingJobServiceTest(TestCase):
self.assertIn('status', status_info) self.assertIn('status', status_info)
self.assertIn('status_id', status_info) self.assertIn('status_id', status_info)
self.assertIn('is_completed', status_info) self.assertIn('is_completed', status_info)
self.assertIn('is_production_completed', status_info)
self.assertIn('has_started', status_info) self.assertIn('has_started', status_info)
# 未开始状态(现在返回空字符串或第一个状态名称) # 未开始状态(现在返回空字符串或第一个状态名称)
self.assertIn(status_info['status'], ['', self.state1.name]) self.assertIn(status_info['status'], ['', self.state1.name])
self.assertFalse(status_info['is_completed']) self.assertFalse(status_info['is_completed'])
self.assertFalse(status_info['is_production_completed'])
self.assertFalse(status_info['has_started']) self.assertFalse(status_info['has_started'])

View File

@@ -412,10 +412,11 @@ class PrintingJobFilterSet(django_filters.FilterSet):
quantity_max = django_filters.NumberFilter(field_name="quantity", lookup_expr="lte") quantity_max = django_filters.NumberFilter(field_name="quantity", lookup_expr="lte")
pieces_min = django_filters.NumberFilter(field_name="pieces", lookup_expr="gte") pieces_min = django_filters.NumberFilter(field_name="pieces", lookup_expr="gte")
pieces_max = django_filters.NumberFilter(field_name="pieces", lookup_expr="lte") pieces_max = django_filters.NumberFilter(field_name="pieces", lookup_expr="lte")
is_production_completed = django_filters.BooleanFilter()
class Meta: class Meta:
model = models.PrintingJob model = models.PrintingJob
fields = ["printing_order", "product", "external_order_id"] fields = ["printing_order", "product", "external_order_id", "is_production_completed"]
class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
@@ -449,6 +450,7 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
- quantity_max: 最大数量 - quantity_max: 最大数量
- pieces_min: 最小件数 - pieces_min: 最小件数
- pieces_max: 最大件数 - pieces_max: 最大件数
- is_production_completed: 是否已标记完成生产true/false
- search: 全文搜索(产品名称、单位、尺寸、备注) - search: 全文搜索(产品名称、单位、尺寸、备注)
- ordering: 排序字段 - ordering: 排序字段
@@ -537,6 +539,60 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
serializer = self.get_serializer(instance) serializer = self.get_serializer(instance)
return Response(serializer.data) 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): def destroy(self, request, *args, **kwargs):
"""禁用删除操作""" """禁用删除操作"""
return Response( return Response(

View File

@@ -3,8 +3,10 @@ Shipment API 测试
""" """
from decimal import Decimal from decimal import Decimal
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from django.test import override_settings
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.utils import timezone 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_item1.shipment_id, result["id"])
self.assertEqual(self.sales_item2.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): def test_create_shipment_without_sales_items(self):
"""测试创建出货单但不关联销售品""" """测试创建出货单但不关联销售品"""
data = { data = {
@@ -2208,11 +2230,17 @@ class SalesItemCreateAPITestCase(APITestCase):
area="测试地区Sales", area="测试地区Sales",
) )
self.category = basic_models.ProductCategory.objects.create(
merchant=self.merchant,
name="测试分类Sales",
)
# 创建产品 # 创建产品
self.product = basic_models.Product.objects.create( self.product = basic_models.Product.objects.create(
merchant=self.merchant, merchant=self.merchant,
category=self.category,
name="测试产品", name="测试产品",
code="TEST001", human_id="TEST001",
unit=basic_models.ProductUnitEnum.METER, unit=basic_models.ProductUnitEnum.METER,
) )

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View 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

View File

@@ -141,6 +141,7 @@
- 并且这些销售品必须全部来自同一个 `printing_order` - 并且这些销售品必须全部来自同一个 `printing_order`
- 如果存在缺少生产任务、生产任务不存在、或跨生产订单混装,接口会拒绝创建 - 如果存在缺少生产任务、生产任务不存在、或跨生产订单混装,接口会拒绝创建
- 新创建的出货单默认状态为 `草稿(未发布)` - 新创建的出货单默认状态为 `草稿(未发布)`
- 创建成功后会触发 `shipment_created` 领域信号,并通过异步 Celery task 发送企业微信通知
### 接口信息 ### 接口信息

View File

@@ -301,6 +301,10 @@ PRINTING_ORDER_CREATED_WECOM_NOTIFY_ENABLED = env.bool(
"PRINTING_ORDER_CREATED_WECOM_NOTIFY_ENABLED", "PRINTING_ORDER_CREATED_WECOM_NOTIFY_ENABLED",
default=(not TESTING), 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 = ( PRINTING_ORDER_CREATED_WECOM_MARKDOWN_TEMPLATE = (
"### 印染订单创建\n" "### 印染订单创建\n"
"\n" "\n"
@@ -313,6 +317,11 @@ PRINTING_ORDER_CREATED_WECOM_MARKDOWN_TEMPLATE = (
"- **出货日期**`{outgoing_date}`\n" "- **出货日期**`{outgoing_date}`\n"
) )
SHIPMENT_CREATED_WECOM_NOTIFY_ENABLED = env.bool(
"SHIPMENT_CREATED_WECOM_NOTIFY_ENABLED",
default=True,
)
# PreSalesOrder创建时的企业微信通知markdown # PreSalesOrder创建时的企业微信通知markdown
# - 默认:测试环境关闭(避免单测出网/刷屏),非测试环境开启 # - 默认:测试环境关闭(避免单测出网/刷屏),非测试环境开启
PRE_SALES_ORDER_CREATED_WECOM_NOTIFY_ENABLED = env.bool( PRE_SALES_ORDER_CREATED_WECOM_NOTIFY_ENABLED = env.bool(

View File

@@ -14,7 +14,7 @@ class PrintingConfig(AppConfig):
from stateflow.signals import process_completed, state_advanced from stateflow.signals import process_completed, state_advanced
from .models import PrintingJob, PrintingOrder from .models import PrintingJob, PrintingOrder
from . import handlers from . import handlers
from .signals import printing_order_created from .signals import printing_job_production_completed, printing_order_created
# 监听 PrintingJob 流程完成信号 # 监听 PrintingJob 流程完成信号
process_completed.connect( process_completed.connect(
@@ -34,6 +34,11 @@ class PrintingConfig(AppConfig):
sender=PrintingOrder, sender=PrintingOrder,
dispatch_uid="printing.on_printing_order_created", 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( logger.info(
f'[printing.apps] 已注册 process_completed 信号处理器, ' f'[printing.apps] 已注册 process_completed 信号处理器, '
@@ -47,3 +52,7 @@ class PrintingConfig(AppConfig):
f'[printing.apps] 已注册 printing_order_created 信号处理器, ' f'[printing.apps] 已注册 printing_order_created 信号处理器, '
f'sender={PrintingOrder}, handler={handlers.on_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}'
)

View File

@@ -336,3 +336,44 @@ def on_printing_order_created(sender, **kwargs):
# 在事务提交后再发送,避免事务回滚但通知已发出 # 在事务提交后再发送,避免事务回滚但通知已发出
transaction.on_commit(_send_wecom) 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)

View File

@@ -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="是否完成生产",
),
),
]

View File

@@ -466,6 +466,11 @@ class PrintingJob(ModelBase):
default=PrintingJobWorkStateEnum.PRODUCING, default=PrintingJobWorkStateEnum.PRODUCING,
verbose_name='业务进展', verbose_name='业务进展',
) )
is_production_completed = models.BooleanField(
default=False,
verbose_name='是否完成生产',
help_text='独立于流程计算状态的生产完成标记',
)
quantity = models.PositiveIntegerField(verbose_name='数量') quantity = models.PositiveIntegerField(verbose_name='数量')
unit = models.CharField(max_length=50, verbose_name='单位') unit = models.CharField(max_length=50, verbose_name='单位')
size = models.CharField(max_length=100, null=True, blank=True, verbose_name='一段尺寸') size = models.CharField(max_length=100, null=True, blank=True, verbose_name='一段尺寸')

View File

@@ -294,3 +294,118 @@ def render_printing_order_created_markdown(
fabric=str(fabric or "-"), fabric=str(fabric or "-"),
outgoing_date=str(outgoing_date 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,
}

View File

@@ -15,3 +15,9 @@ from django.dispatch import Signal
# - created_by: Django User (may be None) # - created_by: Django User (may be None)
printing_order_created = Signal() 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()

View File

@@ -7,6 +7,7 @@ from django.db import transaction
from django.utils import timezone from django.utils import timezone
from printing.models import PlateOrder, PlateOrderTiiaUploadFailure from printing.models import PlateOrder, PlateOrderTiiaUploadFailure
from printing.services import send_printing_job_production_completed_wecom
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -116,3 +117,18 @@ def upload_yesterday_plate_order_images_to_tencent_tiia(self):
logger.info('[TIIA] 昨日 PlateOrder 图片上传任务完成: %s', payload) logger.info('[TIIA] 昨日 PlateOrder 图片上传任务完成: %s', payload)
return 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

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

View File

@@ -21,3 +21,22 @@ class WeComMarkdownRenderTest(SimpleTestCase):
md = render_process_params_markdown(params={"图片": " https://example.com/a.png "}) md = render_process_params_markdown(params={"图片": " https://example.com/a.png "})
self.assertIn("[https://example.com/a.png](https://example.com/a.png)", md) 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)

View File

@@ -5,3 +5,14 @@ class ShipmentConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'shipment' name = 'shipment'
verbose_name = '出货管理' 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
View 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)

View File

@@ -7,11 +7,14 @@ from __future__ import annotations
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import List 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 import transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, QuerySet, Subquery from django.db.models import Count, Exists, IntegerField, OuterRef, QuerySet, Subquery
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
from api_v1.utils.wecom_webhook import send_wecom_webhook_message
from shipment.models import ( from shipment.models import (
ExternalFinishedProduct, ExternalFinishedProduct,
SalesItem, SalesItem,
@@ -22,6 +25,7 @@ from shipment.models import (
ShipmentDeliveryStatus, ShipmentDeliveryStatus,
ShipmentStatus, ShipmentStatus,
) )
from shipment.signals import shipment_created
def _resolve_user_merchant(user): def _resolve_user_merchant(user):
@@ -33,6 +37,19 @@ def _resolve_user_employee(user):
return getattr(user, "employee", None) 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]: def get_active_sales_items_queryset() -> QuerySet[SalesItem]:
""" """
返回未软删除的销售品查询集。 返回未软删除的销售品查询集。
@@ -716,6 +733,7 @@ def update_shipment(
return shipment return shipment
@transaction.atomic
@transaction.atomic @transaction.atomic
def create_shipment( def create_shipment(
customer_id: int, customer_id: int,
@@ -834,9 +852,119 @@ def create_shipment(
if updated != len(sales_item_ids): if updated != len(sales_item_ids):
raise ValueError("存在不属于当前商户的销售品,无法关联到出货单") 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 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 @transaction.atomic
def create_external_shipment( def create_external_shipment(
customer_id: int, customer_id: int,

11
shipment/signals.py Normal file
View 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
View 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

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