1
0
forked from erp-dev/erp

feat: added print_count api, added merchant setting model, added manual mode setting for purchase order

This commit is contained in:
2025-11-28 15:01:11 +08:00
parent 53f2a24b20
commit 0c62398120
36 changed files with 1634 additions and 478 deletions

View File

@@ -199,7 +199,7 @@
- `plate_type`, `urgency_level`, `fabric`, `style_name`: 模糊查询
- `is_invalid`, `is_ordered`, `is_mark_frame`: 布尔过滤
- `plate_date_from`/`to`: 开版日期范围
- `required_completion_date_from`/`to`: 要求完成日期范围
- `required_completion_date_from`/`to`: 要求完成时间范围ISO8601含具体时间
- `created_date_from`/`to`: 创建日期范围
- `search`: 全文搜索 (设计编号, 款式名称, 客户名称, 面料)
- `ordering`: 排序字段。

View File

@@ -2,9 +2,99 @@
Printing API 序列化器
"""
from rest_framework import serializers
from api_v1.models import UploadedFile
from printing import models
from .services import PrintingOrderService, PrintingJobService
from basic_info.models import Customer, Employee
from django.conf import settings
def _build_absolute_media_url(url: str | None, request):
if not url:
return None
if isinstance(url, str) and url.startswith(('http://', 'https://')):
return url
if not isinstance(url, str):
return None
if request:
if url.startswith('/'):
return request.build_absolute_uri(url)
media_prefix = (settings.MEDIA_URL or '/media/').rstrip('/')
return request.build_absolute_uri(f'{media_prefix}/{url.lstrip("/")}')
return url
def _serialize_plate_images(raw_value, request):
if not raw_value:
return []
serialized = []
if isinstance(raw_value, list):
iterable = raw_value
elif isinstance(raw_value, dict):
iterable = [raw_value]
elif isinstance(raw_value, str):
iterable = [{'url': raw_value}]
else:
iterable = []
for entry in iterable:
if isinstance(entry, str):
data = {'url': entry}
elif isinstance(entry, dict):
data = dict(entry)
else:
continue
url = data.get('url') or data.get('path')
data['url'] = _build_absolute_media_url(url, request)
if 'path' not in data and isinstance(url, str):
data['path'] = url
serialized.append(data)
return serialized
def _build_plate_image_payload(items, request_user):
if items is None:
return None
if not items:
return []
file_ids = [item['file_id'] for item in items if item.get('file_id') is not None]
if not file_ids:
return []
queryset = UploadedFile.objects.filter(id__in=file_ids, is_deleted=False)
if request_user and request_user.is_authenticated:
queryset = queryset.filter(owner=request_user)
files_map = {file.id: file for file in queryset}
missing = [str(fid) for fid in file_ids if fid not in files_map]
if missing:
raise serializers.ValidationError({'plate_image': f'以下文件不存在或已删除: {", ".join(missing)}'})
payload = []
for item in items:
file = files_map[item['file_id']]
name = item.get('name') or file.original_filename or file.path.name
payload.append({
'file_id': file.id,
'name': name,
'path': file.path.name,
'url': file.file_url,
'size': file.file_size,
'content_type': file.content_type,
'uploaded_at': file.created_at.isoformat(),
})
return payload
class PlateImageInputSerializer(serializers.Serializer):
file_id = serializers.IntegerField(min_value=1, help_text='上传文件的 ID')
name = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
help_text='可选的图片名称,默认使用文件原始名称'
)
class PrintingOrderListSerializer(serializers.ModelSerializer):
@@ -20,10 +110,10 @@ class PrintingOrderListSerializer(serializers.ModelSerializer):
'id', 'human_id', 'customer', 'customer_name', 'customer_phone',
'fabric', 'width', 'is_urgent', 'area', 'address', 'curve',
'is_fabric_received', 'outgoing_date', 'is_invalid', 'new_curve',
'process', 'process_name', 'progress', 'position',
'process', 'process_name', 'progress', 'position', 'print_count',
'created_at', 'updated_at',
]
read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress']
read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count']
class PrintingOrderDetailSerializer(serializers.ModelSerializer):
@@ -42,10 +132,10 @@ class PrintingOrderDetailSerializer(serializers.ModelSerializer):
'is_fabric_received', 'craft', 'description', 'outgoing_date',
'curve', 'new_curve', 'position',
'printing_warn', 'rolling_warn', 'production_warn',
'is_invalid', 'process', 'process_name', 'progress',
'is_invalid', 'process', 'process_name', 'progress', 'print_count',
'created_at', 'updated_at'
]
read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress']
read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count']
class PrintingOrderCreateUpdateSerializer(serializers.ModelSerializer):
@@ -214,6 +304,7 @@ class PlateOrderDesignCodeMixin:
def to_representation(self, instance):
data = super().to_representation(instance)
data['design_code'] = self._normalize_design_code(data.get('design_code'), instance.id)
data['plate_image'] = _serialize_plate_images(getattr(instance, 'plate_image', None), self.context.get('request'))
return data
@@ -244,7 +335,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
'sample_rating', 'difficulty_rating',
'sample_meter', 'required_sample_meters',
'required_completion_date', 'completion_date',
'approval_result', 'is_ordered', 'customer_feedback',
'approval_result', 'is_ordered', 'customer_feedback', 'print_count',
'process', 'process_name',
'status', 'status_id', 'is_completed', 'has_started',
'progress_percentage', 'business_object_id',
@@ -252,17 +343,12 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
]
read_only_fields = [
'id', 'status', 'progress_percentage',
'created_at', 'updated_at'
'created_at', 'updated_at', 'print_count'
]
def get_plate_image_url(self, obj):
"""获取图片完整URL"""
if obj.plate_image:
request = self.context.get("request")
if request:
return request.build_absolute_uri(obj.plate_image.url)
return obj.plate_image.url
return None
images = _serialize_plate_images(getattr(obj, 'plate_image', None), self.context.get('request'))
return [entry.get('url') for entry in images if entry.get('url')]
def get_process_name(self, obj) ->str | None:
@@ -308,7 +394,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
'sample_rating', 'difficulty_rating',
'sample_meter', 'required_sample_meters',
'required_completion_date', 'completion_date',
'approval_result', 'is_ordered', 'customer_feedback',
'approval_result', 'is_ordered', 'customer_feedback', 'print_count',
'process', 'process_name',
'status', 'status_id', 'is_completed', 'has_started',
'progress_percentage', 'business_object_id',
@@ -317,7 +403,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
read_only_fields = [
'id', 'status', 'status_id', 'is_completed', 'has_started',
'progress_percentage', 'business_object_id',
'created_at', 'updated_at'
'created_at', 'updated_at', 'print_count'
]
def get_business_object_id(self, obj):
@@ -325,13 +411,8 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
return obj.business_object.id if obj.business_object else None
def get_plate_image_url(self, obj):
"""获取图片完整URL"""
if obj.plate_image:
request = self.context.get("request")
if request:
return request.build_absolute_uri(obj.plate_image.url)
return obj.plate_image.url
return None
images = _serialize_plate_images(getattr(obj, 'plate_image', None), self.context.get('request'))
return [entry.get('url') for entry in images if entry.get('url')]
def get_process_name(self, obj):
"""获取流程名称"""
@@ -347,7 +428,13 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer):
"""开版订单创建/更新序列化器"""
plate_image = PlateImageInputSerializer(
many=True,
required=False,
allow_null=True,
help_text="开版图片列表,需提供已上传的 file_id可选 name 字段"
)
class Meta:
model = models.PlateOrder
fields = [
@@ -365,7 +452,10 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer):
"process"
]
read_only_fields = ["id"]
def validate_plate_image(self, value):
return value or []
def validate_customer(self, value):
"""验证客户是否存在"""
if not value:
@@ -415,18 +505,18 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer):
return attrs
def create(self, validated_data):
"""创建开版订单"""
user = self.context["request"].user
# 创建 PlateOrdersave 方法会自动创建 BusinessObject
plate_order = models.PlateOrder.objects.create(**validated_data)
return plate_order
plate_images = validated_data.pop('plate_image', None)
if plate_images is not None:
validated_data['plate_image'] = _build_plate_image_payload(plate_images, self._request_user)
return super().create(validated_data)
def update(self, instance, validated_data):
"""更新开版订单"""
# 直接更新字段save 方法会自动处理 BusinessObject 的创建
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
plate_images = validated_data.pop('plate_image', None)
if plate_images is not None:
validated_data['plate_image'] = _build_plate_image_payload(plate_images, self._request_user)
return super().update(instance, validated_data)
@property
def _request_user(self):
request = self.context.get('request')
return getattr(request, 'user', None)

View File

@@ -1,6 +1,7 @@
"""
PlateOrder API 测试
"""
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework import status
@@ -9,6 +10,7 @@ from django.contrib.auth.models import Permission
from basic_info import models as basic_models
from printing import models as printing_models
from stateflow import models as stateflow_models
from api_v1 import models as api_models
User = get_user_model()
@@ -95,6 +97,18 @@ class PlateOrderAPITestCase(TestCase):
stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state1, order=0)
stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state2, order=1)
stateflow_models.ProcessNode.objects.create(process=self.process, state=self.state3, order=2)
self.upload_file_primary = self._create_uploaded_file('plate-main.jpg')
self.upload_file_secondary = self._create_uploaded_file('plate-secondary.jpg')
def _create_uploaded_file(self, filename):
file = SimpleUploadedFile(filename, b'test-image-content', content_type='image/jpeg')
return api_models.UploadedFile.objects.create(
owner=self.user,
path=file,
original_filename=filename,
file_size=file.size,
content_type='image/jpeg',
)
def test_create_plate_order(self):
"""测试创建开版订单"""
@@ -109,6 +123,9 @@ class PlateOrderAPITestCase(TestCase):
'salesperson': self.salesperson.id,
'merchandiser': self.merchandiser.id,
'image_name': 'sample.png',
'plate_image': [
{'file_id': self.upload_file_primary.id, 'name': '主图'}
],
}
response = self.client.post('/api/v1/plate-orders/', data, format='json')
@@ -116,6 +133,8 @@ class PlateOrderAPITestCase(TestCase):
self.assertEqual(response.data['design_code'], 'DESIGN001')
self.assertEqual(response.data['style_name'], '测试款式')
self.assertEqual(response.data['image_name'], 'sample.png')
self.assertEqual(len(response.data['plate_image']), 1)
self.assertEqual(response.data['plate_image'][0]['file_id'], self.upload_file_primary.id)
# 验证数据库中创建了记录
self.assertTrue(printing_models.PlateOrder.objects.filter(design_code='DESIGN001').exists())
@@ -145,11 +164,22 @@ class PlateOrderAPITestCase(TestCase):
response = self.client.post('/api/v1/plate-orders/', data, format='json')
# CharField 不会验证内容,所以应该成功
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_create_plate_order_with_invalid_plate_image_file(self):
data = {
'customer': self.customer.id,
'design_code': 'DESIGN_IMG',
'plate_type': '圆网',
'plate_image': [{'file_id': 999}],
}
response = self.client.post('/api/v1/plate-orders/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('plate_image', response.data)
def test_list_plate_orders(self):
"""测试获取开版订单列表"""
# 创建测试数据
printing_models.PlateOrder.objects.create(
order_one = printing_models.PlateOrder.objects.create(
customer=self.customer,
design_code='DESIGN001',
plate_type='圆网',
@@ -158,7 +188,17 @@ class PlateOrderAPITestCase(TestCase):
salesperson=self.salesperson,
image_name='first.png',
)
printing_models.PlateOrder.objects.create(
order_one.plate_image = [
{
'file_id': self.upload_file_primary.id,
'name': 'A 面',
'url': self.upload_file_primary.file_url,
'path': self.upload_file_primary.path.name,
}
]
order_one.save(update_fields=['plate_image'])
order_two = printing_models.PlateOrder.objects.create(
customer=self.customer,
design_code='DESIGN002',
plate_type='平网',
@@ -167,6 +207,15 @@ class PlateOrderAPITestCase(TestCase):
merchandiser=self.merchandiser,
image_name='second.png',
)
order_two.plate_image = [
{
'file_id': self.upload_file_secondary.id,
'name': 'B 面',
'url': self.upload_file_secondary.file_url,
'path': self.upload_file_secondary.path.name,
}
]
order_two.save(update_fields=['plate_image'])
response = self.client.get('/api/v1/plate-orders/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -174,11 +223,13 @@ class PlateOrderAPITestCase(TestCase):
if isinstance(response.data, dict):
self.assertEqual(response.data['count'], 2)
collection = response.data.get('results') or response.data.get('data') or []
if collection:
self.assertIn('image_name', collection[0])
else:
self.assertEqual(len(response.data), 2)
self.assertIn('image_name', response.data[0])
collection = response.data
self.assertEqual(len(collection), 2)
if collection:
self.assertIn('plate_image', collection[0])
self.assertIsInstance(collection[0]['plate_image'], list)
def test_retrieve_plate_order(self):
"""测试获取单个开版订单详情"""
@@ -193,6 +244,15 @@ class PlateOrderAPITestCase(TestCase):
merchandiser=self.merchandiser,
image_name='detail.png',
)
plate_order.plate_image = [
{
'file_id': self.upload_file_primary.id,
'name': '详情图',
'url': self.upload_file_primary.file_url,
'path': self.upload_file_primary.path.name,
}
]
plate_order.save(update_fields=['plate_image'])
response = self.client.get(f'/api/v1/plate-orders/{plate_order.id}/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -202,6 +262,8 @@ class PlateOrderAPITestCase(TestCase):
self.assertIn('customer_name', response.data)
self.assertIn('salesperson_name', response.data)
self.assertEqual(response.data['image_name'], 'detail.png')
self.assertIsInstance(response.data['plate_image'], list)
self.assertEqual(response.data['plate_image'][0]['file_id'], self.upload_file_primary.id)
def test_design_code_fallback_in_detail(self):
"""design_code 为空时返回主键ID"""

View File

@@ -1,196 +0,0 @@
"""
测试 PlateOrder 文件上传功能
"""
from django.test import TestCase
from django.core.files.uploadedfile import SimpleUploadedFile
from rest_framework.test import APIClient
from rest_framework import status
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from basic_info import models as basic_models
from printing import models as printing_models
from stateflow import models as stateflow_models
User = get_user_model()
class PlateOrderFileUploadTestCase(TestCase):
"""测试 PlateOrder 文件上传PATCH 请求)"""
def setUp(self):
self.client = APIClient()
# 创建商户
self.merchant = basic_models.Merchant.objects.create(
name='测试商户',
type=basic_models.MerchantTypeEnum.FACTORY
)
# 创建用户
self.user = User.objects.create_user(
username='testuser',
password='testpass123',
email='test@example.com'
)
# 创建员工并关联商户
self.employee = basic_models.Employee.objects.create(
sys_user=self.user,
merchant=self.merchant,
name='测试员工',
mobile='13800138000',
status=basic_models.EmployeeStatusEnum.ACTIVE
)
# 创建客户
self.customer = basic_models.Customer.objects.create(
merchant=self.merchant,
name='测试客户',
mobile='13900139000',
area='测试地区'
)
# 认证用户
self.client.force_authenticate(user=self.user)
# 给用户添加所有 PlateOrder 权限
perms = Permission.objects.filter(
content_type__app_label='printing',
content_type__model='plateorder'
)
self.user.user_permissions.add(*perms)
def test_patch_with_file_upload(self):
"""测试 PATCH 请求上传文件(问题场景)"""
# 创建开版订单
plate_order = printing_models.PlateOrder.objects.create(
customer=self.customer,
design_code='DESIGN001',
plate_type='圆网',
style_name='测试款式',
fabric='棉布',
)
# 创建一个测试图片文件
image_content = b'fake image content for testing'
image_file = SimpleUploadedFile(
"test_plate_image.jpg",
image_content,
content_type="image/jpeg"
)
# PATCH 请求,同时更新文字字段和上传文件
data = {
'urgency_level': '加急',
'plate_image': image_file,
'is_mark_frame': True,
}
response = self.client.patch(
f'/api/v1/plate-orders/{plate_order.id}/',
data,
format='multipart' # 重要:使用 multipart 格式
)
# 验证响应
self.assertEqual(response.status_code, status.HTTP_200_OK,
f"Expected 200, got {response.status_code}: {response.data}")
self.assertEqual(response.data['urgency_level'], '加急')
self.assertEqual(response.data['is_mark_frame'], True)
# 验证文件已上传
plate_order.refresh_from_db()
self.assertTrue(plate_order.plate_image)
self.assertIn('test_plate_image', plate_order.plate_image.name)
def test_patch_without_file(self):
"""测试 PATCH 请求不上传文件(正常场景)"""
plate_order = printing_models.PlateOrder.objects.create(
customer=self.customer,
design_code='DESIGN002',
plate_type='圆网',
style_name='测试款式2',
)
# 纯 JSON 数据
data = {
'urgency_level': '特急',
'is_mark_frame': False,
}
response = self.client.patch(
f'/api/v1/plate-orders/{plate_order.id}/',
data,
format='json'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['urgency_level'], '特急')
self.assertEqual(response.data['is_mark_frame'], False)
def test_patch_only_file(self):
"""测试 PATCH 请求仅上传文件"""
plate_order = printing_models.PlateOrder.objects.create(
customer=self.customer,
design_code='DESIGN003',
plate_type='平网',
style_name='测试款式3',
urgency_level='正常',
)
# 创建测试文件
file_content = b'another fake image'
image_file = SimpleUploadedFile(
"plate_design.png",
file_content,
content_type="image/png"
)
data = {
'plate_image': image_file,
}
response = self.client.patch(
f'/api/v1/plate-orders/{plate_order.id}/',
data,
format='multipart'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# 验证其他字段未被改变
plate_order.refresh_from_db()
self.assertEqual(plate_order.urgency_level, '正常')
self.assertEqual(plate_order.design_code, 'DESIGN003')
self.assertTrue(plate_order.plate_image)
def test_create_with_file(self):
"""测试 POST 创建时上传文件"""
file_content = b'initial image'
image_file = SimpleUploadedFile(
"initial_plate.jpg",
file_content,
content_type="image/jpeg"
)
data = {
'customer': self.customer.id,
'design_code': 'DESIGN_NEW',
'plate_type': '圆网',
'style_name': '新款式',
'plate_image': image_file,
}
response = self.client.post(
'/api/v1/plate-orders/',
data,
format='multipart'
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertIn('plate_image', response.data)
# 验证数据库
plate_order = printing_models.PlateOrder.objects.get(design_code='DESIGN_NEW')
self.assertTrue(plate_order.plate_image)

View File

@@ -10,6 +10,7 @@ from rest_framework.permissions import DjangoModelPermissions
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as django_filters
from django_filters import IsoDateTimeFilter
from printing import models
from basic_info.models import MerchantTypeEnum
@@ -526,8 +527,8 @@ class PlateOrderFilterSet(django_filters.FilterSet):
style_name = django_filters.CharFilter(lookup_expr='icontains')
plate_date_from = django_filters.DateFilter(field_name='plate_date', lookup_expr='gte')
plate_date_to = django_filters.DateFilter(field_name='plate_date', lookup_expr='lte')
required_completion_date_from = django_filters.DateFilter(field_name='required_completion_date', lookup_expr='gte')
required_completion_date_to = django_filters.DateFilter(field_name='required_completion_date', lookup_expr='lte')
required_completion_date_from = IsoDateTimeFilter(field_name='required_completion_date', lookup_expr='gte')
required_completion_date_to = IsoDateTimeFilter(field_name='required_completion_date', lookup_expr='lte')
created_date_from = django_filters.DateFilter(field_name='created_at', lookup_expr='gte')
created_date_to = django_filters.DateFilter(field_name='created_at', lookup_expr='lte')
@@ -574,8 +575,8 @@ class PlateOrderViewSet(viewsets.ModelViewSet):
- style_name: 款式名称(模糊查询)
- plate_date_from: 开版日期起始
- plate_date_to: 开版日期结束
- required_completion_date_from: 要求完成日期起始
- required_completion_date_to: 要求完成日期结束
- required_completion_date_from: 要求完成时间起始(含时分秒)
- required_completion_date_to: 要求完成时间结束
- created_date_from: 创建日期起始
- created_date_to: 创建日期结束
- search: 全文搜索(设计编号、款式名称、客户名称、面料)