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

@@ -1,5 +1,7 @@
from django.contrib import admin
from django.contrib.admin import action
from api_v1.models import UploadedFile
from .tasks import backup_database
@admin.register(UploadedFile)
@@ -10,7 +12,12 @@ class UploadedFileAdmin(admin.ModelAdmin):
readonly_fields = ['created_at', 'file_size', 'content_type']
date_hierarchy = 'created_at'
ordering = ['-created_at']
actions = ['backup_database']
def get_queryset(self, request):
# 在管理界面显示所有文件,包括已删除的
return super().get_queryset(request).select_related('owner')
@action(description='备份数据库')
def backup_database(self, request, queryset):
backup_database.delay()

11
api_v1/enums.py Normal file
View File

@@ -0,0 +1,11 @@
from django.db import models
class PrintCountObjectType(models.TextChoices):
PRINTING_ORDER = 'printing_order', '印染订单'
PLATE_ORDER = 'plate_order', '开版订单'
@classmethod
def values(cls):
return [choice.value for choice in cls]

View File

@@ -4,6 +4,7 @@ from typing import List, Dict, Any
from stock import models as stock_models
from basic_info import models as basic_info_models
from . import models as api_models
from .enums import PrintCountObjectType
class ProductStockChangeSerializer(serializers.Serializer):
@@ -193,4 +194,25 @@ class FileUploadSerializer(serializers.Serializer):
if value.size > max_size:
raise serializers.ValidationError(f'文件大小不能超过 {max_size // (1024*1024)}MB')
return value
return value
class PrintCountDeltaSerializer(serializers.Serializer):
"""打印次数增量请求"""
object_type = serializers.ChoiceField(choices=PrintCountObjectType.choices)
object_id = serializers.IntegerField(min_value=1)
delta = serializers.CharField(required=False, allow_null=True, allow_blank=True)
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
raw_delta = attrs.get('delta')
try:
delta_value = int(raw_delta)
except (TypeError, ValueError):
delta_value = 1
if delta_value < 1:
delta_value = 1
attrs['delta'] = delta_value
return attrs

View File

@@ -0,0 +1,91 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework import status
from rest_framework.test import APIClient
from basic_info.models import Merchant, MerchantTypeEnum, Customer
from printing import models as printing_models
class PrintCountDeltaAPITestCase(TestCase):
"""打印次数增量接口测试"""
url = '/api/v1/print-count/delta/'
def setUp(self):
self.client = APIClient()
self.user = get_user_model().objects.create_user(username='tester', password='test123')
self.client.force_authenticate(self.user)
self.merchant = Merchant.objects.create(name='测试商户', type=MerchantTypeEnum.FACTORY)
self.customer = Customer.objects.create(
merchant=self.merchant,
name='客户甲',
mobile='13900000000',
created_by=None,
)
self.printing_order = printing_models.PrintingOrder.objects.create(
customer=self.customer,
fabric='棉布',
width='150cm',
)
self.plate_order = printing_models.PlateOrder.objects.create(
customer=self.customer,
design_code='DES-001',
)
def test_increment_printing_order_with_delta(self):
response = self.client.post(
self.url,
{
'object_type': 'printing_order',
'object_id': self.printing_order.id,
'delta': 3,
},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.printing_order.refresh_from_db()
self.assertEqual(self.printing_order.print_count, 3)
self.assertEqual(response.data['print_count'], 3)
self.assertEqual(response.data['delta'], 3)
def test_delta_defaults_to_one_when_invalid(self):
response = self.client.post(
self.url,
{
'object_type': 'plate_order',
'object_id': self.plate_order.id,
'delta': 'invalid',
},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.plate_order.refresh_from_db()
self.assertEqual(self.plate_order.print_count, 1)
self.assertEqual(response.data['delta'], 1)
def test_invalid_object_type_returns_400(self):
response = self.client.post(
self.url,
{
'object_type': 'unknown_type',
'object_id': self.printing_order.id,
},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('object_type', response.data)
def test_not_found_returns_404(self):
response = self.client.post(
self.url,
{
'object_type': 'printing_order',
'object_id': 999999,
},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

View File

@@ -139,11 +139,16 @@ class PurchaseOrderAPITestCase(TestCase):
def setUp(self):
self.merchant = Merchant.objects.create(name='PO商户', type=MerchantTypeEnum.FACTORY)
self.supplier = Supplier.objects.create(merchant=self.merchant, name='供应商A')
self.warehouse = WareHouse.objects.create(
self.warehouse_strict = WareHouse.objects.create(
merchant=self.merchant,
name='',
name='严进',
mode=WareHouseModeEnum.RESTRICT_IN,
)
self.warehouse_relaxed = WareHouse.objects.create(
merchant=self.merchant,
name='宽进仓',
mode=WareHouseModeEnum.UNRESTRICTED,
)
category = ProductCategory.objects.create(
merchant=self.merchant,
name='品类',
@@ -164,39 +169,66 @@ class PurchaseOrderAPITestCase(TestCase):
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.payload = {
self.strict_payload = {
'supplier': self.supplier.id,
'warehouse': self.warehouse.id,
'warehouse': self.warehouse_strict.id,
'order_date': '2025-11-26',
'total_amount': '1500.00',
'items': [
{
'product_id': self.product.id,
'quantities': ['10.0', '5.0'],
'numbers': [10, 5],
'price': '12.5',
'unit': '',
}
],
'remarks': '接口测试',
}
self.relaxed_payload = {
'supplier': self.supplier.id,
'warehouse': self.warehouse_relaxed.id,
'order_date': '2025-11-26',
'items': [
{
'product_id': self.product.id,
'quantity': 120,
'num_of_rolls': 3,
'price': '10.5',
}
],
}
def test_create_purchase_order_success(self):
def test_create_purchase_order_success_strict(self):
with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay:
response = self.client.post('/api/v1/purchase-orders/', self.payload, format='json')
response = self.client.post('/api/v1/purchase-orders/', self.strict_payload, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertIn('id', response.data)
mock_delay.assert_called_once()
def test_create_purchase_order_invalid_supplier(self):
payload = {**self.payload, 'supplier': 999}
payload = {**self.strict_payload, 'supplier': 999}
response = self.client.post('/api/v1/purchase-orders/', payload, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('不存在', response.data['error'])
def test_create_purchase_order_unauthenticated(self):
self.client.force_authenticate(user=None)
response = self.client.post('/api/v1/purchase-orders/', self.payload, format='json')
response = self.client.post('/api/v1/purchase-orders/', self.strict_payload, format='json')
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_create_purchase_order_relaxed_mode(self):
with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay:
response = self.client.post('/api/v1/purchase-orders/', self.relaxed_payload, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
mock_delay.assert_called_once()
def test_mode_mismatch_raises(self):
payload = {**self.relaxed_payload}
payload['warehouse'] = self.warehouse_strict.id # 严进仓却传宽进参数
response = self.client.post('/api/v1/purchase-orders/', payload, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('严进模式', response.data['error'])
@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,

View File

@@ -9,6 +9,7 @@ from .views import (
product_image,
stateflow,
users,
print_count,
)
from .views.stock_change_views.snapshot import StockSnapshotListView
from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet
@@ -57,6 +58,7 @@ urlpatterns = [
path('inventory/', inventory.InventoryAPIView.as_view(), name='inventory'),
path('purchase-orders/', purchase_order.PurchaseOrderView.as_view(), name='purchase_orders'),
path('health/', healthy.HealthCheckView.as_view(), name='health_check'),
path('print-count/delta/', print_count.adjust_print_count, name='print_count_delta'),
# 产品图片上传 API
path('products/<int:product_id>/image/', product_image.ProductImageUploadView.as_view(), name='product_image_upload'),

View File

@@ -0,0 +1,57 @@
from django.db import transaction
from django.db.models import F
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from api_v1.serializers import PrintCountDeltaSerializer
from api_v1.enums import PrintCountObjectType
from printing import models as printing_models
class PrintCountDeltaView(APIView):
"""通用打印次数递增接口"""
permission_classes = [permissions.IsAuthenticated]
OBJECT_MODEL_MAP = {
PrintCountObjectType.PRINTING_ORDER: printing_models.PrintingOrder,
PrintCountObjectType.PLATE_ORDER: printing_models.PlateOrder,
}
def post(self, request, *args, **kwargs):
serializer = PrintCountDeltaSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
model = self.OBJECT_MODEL_MAP.get(data['object_type'])
if model is None:
return Response(
{'detail': '不支持的对象类型'},
status=status.HTTP_400_BAD_REQUEST,
)
with transaction.atomic():
updated = model.objects.filter(id=data['object_id']).update(
print_count=F('print_count') + data['delta']
)
if not updated:
return Response(
{'detail': '指定对象不存在'},
status=status.HTTP_404_NOT_FOUND,
)
fresh_value = model.objects.only('print_count').get(id=data['object_id']).print_count
return Response(
{
'object_type': data['object_type'],
'object_id': data['object_id'],
'delta': data['delta'],
'print_count': fresh_value,
},
status=status.HTTP_200_OK,
)
adjust_print_count = PrintCountDeltaView.as_view()

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: 全文搜索(设计编号、款式名称、客户名称、面料)

View File

@@ -1,18 +1,61 @@
from decimal import Decimal, InvalidOperation
from rest_framework import status, views
from rest_framework import status, views, serializers, pagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from basic_info import models as basic_models
from business import services as business_services
from business import models as business_models
from .stock_change_views.mixins import StockChangeViewMixin
class PurchaseOrderItemSerializer(serializers.ModelSerializer):
class Meta:
model = business_models.PurchaseOrderItem
fields = [
'id', 'product', 'price', 'color', 'quantity', 'unit',
'empty_diff_percent', 'quantity_of_rolls', 'num_of_rolls',
'batch_number', 'remarks', 'created_at', 'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at']
class PurchaseOrderSerializer(serializers.ModelSerializer):
supplier_name = serializers.CharField(source='supplier.name', read_only=True)
operator_name = serializers.CharField(source='operator.name', read_only=True)
warehouse_name = serializers.CharField(source='warehouse.name', read_only=True)
items = PurchaseOrderItemSerializer(many=True, read_only=True)
class Meta:
model = business_models.PurchaseOrder
fields = [
'id', 'supplier', 'supplier_name', 'purchase_date', 'kind',
'operator', 'operator_name', 'warehouse', 'warehouse_name',
'status', 'remarks', 'created_at', 'updated_at', 'items',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'items', 'supplier_name', 'operator_name', 'warehouse_name']
class PurchaseOrderPagination(pagination.LimitOffsetPagination):
default_limit = 20
max_limit = 100
class PurchaseOrderView(StockChangeViewMixin, views.APIView):
"""创建采购订单并触发入库任务"""
"""采购订单查询与创建"""
permission_classes = [IsAuthenticated]
pagination_class = PurchaseOrderPagination
def get(self, request):
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
merchant = request.user.employee.merchant
queryset = business_models.PurchaseOrder.objects.filter(merchant=merchant).prefetch_related('items', 'supplier', 'operator', 'warehouse')
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self)
serializer = PurchaseOrderSerializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
def post(self, request):
if not self.check_employee_permission(request):
@@ -22,9 +65,8 @@ class PurchaseOrderView(StockChangeViewMixin, views.APIView):
data = request.data or {}
supplier_id = data.get('supplier')
warehouse_id = data.get('warehouse')
warehouse_id = data.get('warehouse_id') or data.get('warehouse')
order_date = data.get('order_date')
total_amount = data.get('total_amount')
items = data.get('items', [])
remarks = data.get('remarks', '')
@@ -32,6 +74,10 @@ class PurchaseOrderView(StockChangeViewMixin, views.APIView):
return Response({'error': '缺少供应商 ID'}, status=status.HTTP_400_BAD_REQUEST)
if not warehouse_id:
return Response({'error': '缺少仓库 ID'}, status=status.HTTP_400_BAD_REQUEST)
if not order_date:
return Response({'error': '缺少 order_date'}, status=status.HTTP_400_BAD_REQUEST)
if not isinstance(items, list) or not items:
return Response({'error': 'items 需要为非空数组'}, status=status.HTTP_400_BAD_REQUEST)
try:
supplier = basic_models.Supplier.objects.get(id=supplier_id, merchant=merchant)
@@ -39,22 +85,19 @@ class PurchaseOrderView(StockChangeViewMixin, views.APIView):
return Response({'error': f'供应商 {supplier_id} 不存在'}, status=status.HTTP_400_BAD_REQUEST)
try:
basic_models.WareHouse.objects.get(id=warehouse_id, merchant=merchant)
warehouse = basic_models.WareHouse.objects.get(id=warehouse_id, merchant=merchant)
except basic_models.WareHouse.DoesNotExist:
return Response({'error': f'仓库 {warehouse_id} 不存在'}, status=status.HTTP_400_BAD_REQUEST)
try:
total_amount_decimal = Decimal(str(total_amount))
except (InvalidOperation, TypeError):
return Response({'error': 'total_amount 必须为合法数值'}, status=status.HTTP_400_BAD_REQUEST)
operator = request.user.employee
try:
purchase_order = business_services.create_purchase_order(
merchant=merchant,
supplier=supplier,
order_date=order_date,
total_amount=total_amount_decimal,
warehouse_id=warehouse_id,
warehouse=warehouse,
operator=operator,
items=items,
remarks=remarks,
created_by=request.user,