1
0
forked from erp-dev/erp

fix: business cancel service merged

This commit is contained in:
2026-06-11 15:33:31 +08:00
parent 65564f772a
commit 80db7867f4
18 changed files with 574 additions and 125 deletions

View File

@@ -91,15 +91,20 @@
- `unit`: 单位 (模糊查询)
- `quantity_min`/`max`: 数量范围
- `pieces_min`/`max`: 件数范围
- `include_sales_order_bound`: 是否返回 `is_sales_order_bound` 字段,默认 `true`;传 `false`/`0`/`no` 时不返回
- `search`: 全文搜索 (产品名称, 单位, 尺寸, 备注)
- `ordering`: 排序字段。
- **响应**: `PrintingJobListSerializer` 列表。
- **重要变更**: 列表中已**包含** `business_object_id` 字段。
- **字段**: `is_sales_order_bound` 表示该印染任务是否已被销售单明细绑定。
- **GET** `/api/v1/printing-jobs/{id}/`
- **描述**: 获取单个印染任务详情。
- **查询参数**:
- `include_sales_order_bound`: 是否返回 `is_sales_order_bound` 字段,默认 `true`;传 `false`/`0`/`no` 时不返回
- **响应**: `PrintingJobDetailSerializer`
- **重要变更**: `business_object_id` 字段现在会安全地返回 `null` 而不是报错(当 `business_object` 不存在时)。
- **字段**: `is_sales_order_bound` 表示该印染任务是否已被销售单明细绑定。
- **POST** `/api/v1/printing-jobs/`
- **描述**: 创建一个新的印染任务。

View File

@@ -13,6 +13,21 @@ from .services import PrintingOrderService, PrintingJobService
from basic_info.models import Customer, Employee
def _include_sales_order_bound(context) -> bool:
request = context.get('request') if context else None
if not request:
return True
raw_value = request.query_params.get('include_sales_order_bound')
return str(raw_value).lower() not in {'0', 'false', 'no'}
def _get_is_sales_order_bound(obj) -> bool:
annotated_value = getattr(obj, 'annotated_is_sales_order_bound', None)
if annotated_value is not None:
return bool(annotated_value)
return bool(obj.is_sales_order_bound)
def _build_absolute_media_url(url: str | None, request):
return build_public_media_url(url, request=request)
@@ -346,6 +361,8 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
business_object_id = serializers.SerializerMethodField()
batch_advance_records = serializers.SerializerMethodField()
saleitems = serializers.SerializerMethodField()
is_sales_order_bound = serializers.SerializerMethodField()
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
class Meta:
@@ -353,10 +370,11 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
fields = [
'id', 'original_id', 'merchant_id', 'printing_order', 'printing_order_id', 'external_order_id', 'product', 'product_name',
'product_image_url', 'has_started',
'quantity', 'unit', 'size', 'pieces', 'description',
'quantity', 'billed_quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state_display',
'status', 'is_completed', 'is_production_completed', 'progress_percentage', 'last_completed_state',
'business_object_id',
'is_sales_order_bound',
'batch_advance_records',
'saleitems',
'created_at', 'updated_at'
@@ -364,8 +382,14 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
read_only_fields = [
'id', 'created_at', 'updated_at',
'status', 'is_completed', 'is_production_completed', 'progress_percentage', 'last_completed_state',
'business_object_id', 'merchant_id'
'business_object_id', 'is_sales_order_bound', 'billed_quantity', 'merchant_id'
]
def to_representation(self, instance):
data = super().to_representation(instance)
if not _include_sales_order_bound(self.context):
data.pop('is_sales_order_bound', None)
return data
def get_business_object_id(self, obj):
"""安全地获取 business_object_id"""
@@ -422,6 +446,9 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
)
return SalesItemSerializer(items, many=True).data
def get_is_sales_order_bound(self, obj):
return _get_is_sales_order_bound(obj)
class PrintingJobDetailSerializer(serializers.ModelSerializer):
"""印染款式明细详情序列化器"""
@@ -440,17 +467,20 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
business_object_id = serializers.SerializerMethodField()
batch_advance_records = serializers.SerializerMethodField()
saleitems = serializers.SerializerMethodField()
is_sales_order_bound = serializers.SerializerMethodField()
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
class Meta:
model = models.PrintingJob
fields = [
'id', 'original_id', 'merchant_id', 'printing_order', 'printing_order_id', 'external_order_id', 'product', 'product_name', 'product_code',
'quantity', 'unit', 'size', 'pieces', 'description',
'quantity', 'billed_quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state_display',
'status', 'status_id', 'is_completed', 'is_production_completed', 'has_started',
'progress_percentage', 'last_completed_state',
'business_object_id',
'is_sales_order_bound',
'batch_advance_records',
'saleitems',
'created_at', 'updated_at'
@@ -459,8 +489,14 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
'id', 'created_at', 'updated_at',
'status', 'status_id', 'is_completed', 'is_production_completed', 'has_started',
'progress_percentage', 'last_completed_state',
'business_object_id', 'merchant_id'
'business_object_id', 'is_sales_order_bound', 'billed_quantity', 'merchant_id'
]
def to_representation(self, instance):
data = super().to_representation(instance)
if not _include_sales_order_bound(self.context):
data.pop('is_sales_order_bound', None)
return data
def get_business_object_id(self, obj):
"""安全地获取 business_object_id"""
@@ -485,19 +521,23 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
)
return SalesItemSerializer(items, many=True).data
def get_is_sales_order_bound(self, obj):
return _get_is_sales_order_bound(obj)
class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
"""印染款式明细创建/更新序列化器"""
external_order_id = serializers.CharField(source='printing_order.external_order_id', read_only=True)
batch_advance_records = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.PrintingJob
fields = [
'id', 'original_id', 'printing_order', 'product', 'quantity', 'unit', 'size', 'pieces', 'description',
'id', 'original_id', 'printing_order', 'external_order_id', 'product', 'quantity', 'unit', 'size', 'pieces', 'description',
'work_state',
'batch_advance_records',
]
read_only_fields = ['id']
read_only_fields = ['id', 'external_order_id']
extra_kwargs = {
'size': {'required': False, 'allow_null': True, 'allow_blank': True},
'pieces': {'required': False, 'allow_null': True},

View File

@@ -1,6 +1,8 @@
"""
PrintingJob API 测试
"""
from decimal import Decimal
from django.test import TestCase
from django.conf import settings
from rest_framework.test import APIClient
@@ -9,6 +11,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from basic_info import models as basic_models
from business import models as business_models
from printing import models as printing_models
from shipment import models as shipment_models
from stateflow import models as stateflow_models
@@ -101,6 +104,9 @@ class PrintingJobAPITestCase(TestCase):
def test_create_printing_job(self):
"""测试创建印染款式明细"""
self.printing_order.external_order_id = 'KD20410611'
self.printing_order.save(update_fields=['external_order_id'])
data = {
'printing_order': self.printing_order.id,
'product': self.product.id,
@@ -113,6 +119,7 @@ class PrintingJobAPITestCase(TestCase):
response = self.client.post('/api/v1/printing-jobs/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['external_order_id'], 'KD20410611')
# 新增字段:批量推进记录(稳定输出 key默认空数组
self.assertIn('batch_advance_records', response.data)
@@ -176,6 +183,128 @@ class PrintingJobAPITestCase(TestCase):
self.assertIsInstance(item['batch_advance_records'], list)
self.assertEqual(len(item['batch_advance_records']), 0)
def test_list_printing_jobs_includes_is_sales_order_bound(self):
"""测试列表返回是否已绑定销售单字段,并支持关闭该字段"""
unbound_job = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
quantity=100,
unit='',
)
bound_job = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
quantity=200,
unit='',
)
warehouse = basic_models.WareHouse.objects.create(
merchant=self.merchant,
name='测试仓库',
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
)
sales_order = business_models.SalesOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
sales_date='2026-06-10',
operator=self.employee,
warehouse=warehouse,
status=business_models.SalesOrderStatusEnum.APPROVED,
)
business_models.SalesOrderItem.objects.create(
sales_order=sales_order,
product=self.product,
printing_job=bound_job,
price='10.00',
quantity='1.00',
unit='',
empty_diff_percent='0.00',
)
cancelled_order = business_models.SalesOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
sales_date='2026-06-10',
operator=self.employee,
warehouse=warehouse,
status=business_models.SalesOrderStatusEnum.CANCELLED,
)
cancelled_only_job = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
quantity=300,
unit='',
)
business_models.SalesOrderItem.objects.create(
sales_order=cancelled_order,
product=self.product,
printing_job=cancelled_only_job,
price='10.00',
quantity='1.00',
unit='',
empty_diff_percent='0.00',
)
response = self.client.get('/api/v1/printing-jobs/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
bound_by_id = {item['id']: item['is_sales_order_bound'] for item in response.data['results']}
billed_by_id = {item['id']: item['billed_quantity'] for item in response.data['results']}
self.assertFalse(bound_by_id[unbound_job.id])
self.assertTrue(bound_by_id[bound_job.id])
self.assertFalse(bound_by_id[cancelled_only_job.id])
self.assertEqual(billed_by_id[unbound_job.id], '0.00')
self.assertEqual(billed_by_id[bound_job.id], '1.00')
self.assertEqual(billed_by_id[cancelled_only_job.id], '0.00')
response = self.client.get('/api/v1/printing-jobs/?include_sales_order_bound=false')
self.assertEqual(response.status_code, status.HTTP_200_OK)
for item in response.data['results']:
self.assertNotIn('is_sales_order_bound', item)
def test_billed_quantity_ignores_cancelled_sales_order_items(self):
"""测试开单数量忽略已作废销售单明细"""
job = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
quantity=100,
unit='',
)
warehouse = basic_models.WareHouse.objects.create(
merchant=self.merchant,
name='开单数量测试仓库',
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
)
approved_order = business_models.SalesOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
sales_date='2026-06-10',
operator=self.employee,
warehouse=warehouse,
status=business_models.SalesOrderStatusEnum.APPROVED,
)
cancelled_order = business_models.SalesOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
sales_date='2026-06-10',
operator=self.employee,
warehouse=warehouse,
status=business_models.SalesOrderStatusEnum.CANCELLED,
)
for order, quantity in [(approved_order, '7.50'), (cancelled_order, '20.00')]:
business_models.SalesOrderItem.objects.create(
sales_order=order,
product=self.product,
printing_job=job,
price='10.00',
quantity=quantity,
unit='',
empty_diff_percent='0.00',
)
self.assertEqual(job.billed_quantity, Decimal('7.50'))
response = self.client.get(f'/api/v1/printing-jobs/{job.id}/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['billed_quantity'], '7.50')
def test_mark_production_completed(self):
"""测试显式标记完成生产接口"""
job = printing_models.PrintingJob.objects.create(
@@ -283,6 +412,81 @@ class PrintingJobAPITestCase(TestCase):
self.assertIsInstance(response.data['batch_advance_records'], list)
self.assertEqual(len(response.data['batch_advance_records']), 0)
def test_retrieve_printing_job_includes_is_sales_order_bound(self):
"""测试详情返回是否已绑定销售单字段"""
job = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
quantity=100,
unit='',
)
warehouse = basic_models.WareHouse.objects.create(
merchant=self.merchant,
name='详情测试仓库',
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
)
sales_order = business_models.SalesOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
sales_date='2026-06-10',
operator=self.employee,
warehouse=warehouse,
status=business_models.SalesOrderStatusEnum.APPROVED,
)
business_models.SalesOrderItem.objects.create(
sales_order=sales_order,
product=self.product,
printing_job=job,
price='10.00',
quantity='1.00',
unit='',
empty_diff_percent='0.00',
)
response = self.client.get(f'/api/v1/printing-jobs/{job.id}/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data['is_sales_order_bound'])
self.assertEqual(response.data['billed_quantity'], '1.00')
response = self.client.get(f'/api/v1/printing-jobs/{job.id}/?include_sales_order_bound=false')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertNotIn('is_sales_order_bound', response.data)
def test_retrieve_printing_job_ignores_cancelled_sales_order_bound(self):
"""测试详情判断销售单绑定时忽略已作废销售单"""
job = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
quantity=100,
unit='',
)
warehouse = basic_models.WareHouse.objects.create(
merchant=self.merchant,
name='详情作废测试仓库',
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
)
cancelled_order = business_models.SalesOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
sales_date='2026-06-10',
operator=self.employee,
warehouse=warehouse,
status=business_models.SalesOrderStatusEnum.CANCELLED,
)
business_models.SalesOrderItem.objects.create(
sales_order=cancelled_order,
product=self.product,
printing_job=job,
price='10.00',
quantity='1.00',
unit='',
empty_diff_percent='0.00',
)
response = self.client.get(f'/api/v1/printing-jobs/{job.id}/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(response.data['is_sales_order_bound'])
def test_batch_advance_records_in_list_and_detail(self):
"""测试 PrintingJob 序列化输出包含批量推进记录(有记录时返回明细,无记录返回空)"""
job = printing_models.PrintingJob.objects.create(
@@ -417,6 +621,9 @@ class PrintingJobAPITestCase(TestCase):
def test_update_printing_job(self):
"""测试更新款式明细"""
self.printing_order.external_order_id = 'KD20410611'
self.printing_order.save(update_fields=['external_order_id'])
job = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
@@ -442,6 +649,7 @@ class PrintingJobAPITestCase(TestCase):
format='json'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['external_order_id'], 'KD20410611')
job.refresh_from_db()
self.assertEqual(job.quantity, 200)
@@ -502,6 +710,9 @@ class PrintingJobAPITestCase(TestCase):
def test_partial_update_printing_job(self):
"""测试部分更新款式明细"""
self.printing_order.external_order_id = 'KD20410611'
self.printing_order.save(update_fields=['external_order_id'])
job = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
@@ -522,6 +733,7 @@ class PrintingJobAPITestCase(TestCase):
format='json'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['external_order_id'], 'KD20410611')
job.refresh_from_db()
self.assertEqual(job.quantity, 150)

View File

@@ -10,7 +10,7 @@ from rest_framework.permissions import BasePermission
from rest_framework.permissions import DjangoModelPermissions
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from django.core.exceptions import ValidationError
from django.db.models import CharField, Prefetch
from django.db.models import CharField, Exists, OuterRef, Prefetch
from django.db.models.functions import Cast, Coalesce
from django.views.decorators.cache import cache_page
from django.views.decorators.http import condition
@@ -486,7 +486,16 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
"""优化查询"""
queryset = super().get_queryset()
if self.action in ["list", "retrieve"]:
from business.models import SalesOrderItem, SalesOrderStatusEnum
queryset = queryset.select_related("printing_order", "product")
queryset = queryset.annotate(
annotated_is_sales_order_bound=Exists(
SalesOrderItem.objects.filter(
printing_job_id=OuterRef("pk"),
).exclude(sales_order__status=SalesOrderStatusEnum.CANCELLED)
)
)
# 预取批量推进记录(避免 serializer 产生 N+1
queryset = queryset.prefetch_related(
Prefetch(