1
0
forked from erp-dev/erp

feat: added n-to-1 relation between sales_order_item and printing_job model

This commit is contained in:
2025-12-11 18:09:10 +08:00
parent 633031d7cb
commit 1a8c446365
30 changed files with 41736 additions and 40 deletions

View File

@@ -1,6 +1,7 @@
import copy
import shutil
import tempfile
import datetime
from pathlib import Path
from unittest.mock import patch
from decimal import Decimal
@@ -28,6 +29,7 @@ from business import models as business_models, services
from stock import models as stock_models
from stock import services as stock_services
from api_v1 import tasks
from printing import models as printing_models
class UserCreationAPITestCase(TestCase):
@@ -326,6 +328,17 @@ class SalesOrderAPITestCase(TestCase):
human_id='SAL-001',
unit=ProductUnitEnum.METER,
)
self.printing_order_for_sales = printing_models.PrintingOrder.objects.create(
customer=self.customer,
fabric='棉布',
width='150cm',
)
self.printing_job_for_sales = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order_for_sales,
product=self.product,
quantity=50,
unit='',
)
self.user = User.objects.create_user(username='sales_user', password='pass123')
self.employee = Employee.objects.create(
merchant=self.merchant,
@@ -419,6 +432,16 @@ class SalesOrderAPITestCase(TestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('consume_detail_ids', response.data['error'])
def test_create_sales_order_with_printing_job_binding(self):
payload = copy.deepcopy(self.relaxed_payload)
payload['items'][0]['printing_job'] = self.printing_job_for_sales.id
with patch('business.services.create_sales_order_stock_entries.delay'):
response = self.client.post('/api/v1/sales-orders/', payload, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
order = business_models.SalesOrder.objects.get(id=response.data['id'])
item = order.items.first()
self.assertEqual(item.printing_job_id, self.printing_job_for_sales.id)
def test_review_sales_order_requires_action(self):
order_id = self._create_sales_order(self.strict_in_payload)
response = self.client.post(f'/api/v1/sales-orders/{order_id}/review/', {}, format='json')
@@ -1215,17 +1238,12 @@ class CeleryTasksTestCase(TestCase):
)
def test_ping_task_returns_payload(self):
result = tasks.ping_task.delay('celery hello')
payload = result.get(timeout=5)
self.assertEqual(payload['message'], 'celery hello')
self.assertIn('timestamp', payload)
self.assertIn('task_id', payload)
# 已弃用的演示任务,测试移除
self.skipTest('deprecated demo task')
def test_merchant_product_count(self):
result = tasks.merchant_product_count.delay(self.merchant.id)
payload = result.get(timeout=5)
self.assertEqual(payload['merchant_id'], self.merchant.id)
self.assertEqual(payload['product_count'], 3)
# 已弃用的演示任务,测试移除
self.skipTest('deprecated demo task')
def test_backup_database_creates_file(self):
tmpdir = Path(tempfile.mkdtemp())
@@ -1243,3 +1261,168 @@ class CeleryTasksTestCase(TestCase):
self.assertEqual(backup_path.parent.resolve(), tmpdir.resolve())
self.assertEqual(backup_path.suffix, '.sql')
self.assertTrue(backup_path.read_text(encoding='utf-8').strip())
class PrintingJobWorkStateAPITestCase(TestCase):
"""印染任务 work_state 字段 API 覆盖"""
def setUp(self):
self.merchant = Merchant.objects.create(
name='印染工厂',
type=MerchantTypeEnum.FACTORY,
)
self.user = User.objects.create_user(username='factory_user', password='pass123')
self.employee = Employee.objects.create(
merchant=self.merchant,
sys_user=self.user,
name='印染员',
)
perms = Permission.objects.filter(
codename__in=['add_printingjob', 'view_printingjob', 'change_printingjob']
)
self.user.user_permissions.set(perms)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
category = ProductCategory.objects.create(
merchant=self.merchant,
name='面料',
product_prefix='FAB',
)
self.product = Product.objects.create(
merchant=self.merchant,
category=category,
name='棉布',
human_id='FAB-100',
unit=ProductUnitEnum.METER,
)
self.customer = Customer.objects.create(
merchant=self.merchant,
name='客户A',
)
self.printing_order = printing_models.PrintingOrder.objects.create(
customer=self.customer,
fabric='纯棉',
width='150cm',
)
def test_create_job_with_work_state(self):
payload = {
'printing_order': self.printing_order.id,
'product': self.product.id,
'quantity': 10,
'unit': '',
'work_state': printing_models.PrintingJobWorkStateEnum.WAITING_FOR_DELIVERY,
}
resp = self.client.post('/api/v1/printing-jobs/', payload, format='json')
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
self.assertEqual(
resp.data['work_state'],
printing_models.PrintingJobWorkStateEnum.WAITING_FOR_DELIVERY,
)
job_id = resp.data['id']
detail = self.client.get(f'/api/v1/printing-jobs/{job_id}/')
self.assertEqual(detail.status_code, status.HTTP_200_OK)
self.assertEqual(detail.data['work_state_display'], '待送货')
def test_update_job_work_state(self):
job = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
quantity=5,
unit='',
)
url = f'/api/v1/printing-jobs/{job.id}/'
patch_resp = self.client.patch(
url,
{'work_state': printing_models.PrintingJobWorkStateEnum.FINISHED},
format='json',
)
self.assertEqual(patch_resp.status_code, status.HTTP_200_OK)
job.refresh_from_db()
self.assertEqual(job.work_state, printing_models.PrintingJobWorkStateEnum.FINISHED)
detail = self.client.get(url)
self.assertEqual(detail.status_code, status.HTTP_200_OK)
self.assertEqual(detail.data['work_state_display'], '已完结')
class PrintingJobBilledQuantityTestCase(TestCase):
"""验证印染任务的开单数量汇总"""
def setUp(self):
self.merchant = Merchant.objects.create(
name='印染工厂B',
type=MerchantTypeEnum.FACTORY,
)
self.user = User.objects.create_user(username='factory_user_b', password='pass123')
self.employee = Employee.objects.create(
merchant=self.merchant,
sys_user=self.user,
name='操作员B',
)
category = ProductCategory.objects.create(
merchant=self.merchant,
name='面料B',
product_prefix='FAB',
)
self.product = Product.objects.create(
merchant=self.merchant,
category=category,
name='棉布B',
human_id='FAB-200',
unit=ProductUnitEnum.METER,
)
self.customer = Customer.objects.create(
merchant=self.merchant,
name='客户B',
)
self.warehouse = WareHouse.objects.create(
merchant=self.merchant,
name='仓库B',
mode=WareHouseModeEnum.UNRESTRICTED,
)
self.printing_order = printing_models.PrintingOrder.objects.create(
customer=self.customer,
fabric='棉布',
width='150cm',
)
self.job = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
quantity=100,
unit='',
)
self.sales_order = business_models.SalesOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
sales_date=datetime.date.today(),
operator=self.employee,
warehouse=self.warehouse,
)
def test_billed_quantity_empty(self):
self.assertEqual(self.job.billed_quantity, Decimal('0'))
def test_billed_quantity_sum(self):
business_models.SalesOrderItem.objects.create(
sales_order=self.sales_order,
product=self.product,
price=Decimal('10'),
quantity=Decimal('12.5'),
unit='',
empty_diff_percent=Decimal('0'),
num_of_rolls=1,
printing_job=self.job,
)
business_models.SalesOrderItem.objects.create(
sales_order=self.sales_order,
product=self.product,
price=Decimal('11'),
quantity=Decimal('7.5'),
unit='',
empty_diff_percent=Decimal('0'),
num_of_rolls=1,
printing_job=self.job,
)
self.assertEqual(self.job.billed_quantity, Decimal('20.0'))

View File

@@ -9,6 +9,8 @@ from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
class PurchaseOrderItemSerializer(serializers.ModelSerializer):
quantity_of_rolls = serializers.SerializerMethodField(read_only=True)
class Meta:
model = business_models.PurchaseOrderItem
fields = [
@@ -18,6 +20,8 @@ class PurchaseOrderItemSerializer(serializers.ModelSerializer):
]
read_only_fields = ['id', 'created_at', 'updated_at', 'total_amount', 'diff_quantity', 'real_quantity']
def get_quantity_of_rolls(self, obj: business_models.PurchaseOrderItem):
return obj.split_quantity_of_rolls()
class PurchaseOrderSerializer(serializers.ModelSerializer):
total_amount = serializers.SerializerMethodField(read_only=True)

View File

@@ -9,6 +9,11 @@ from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
class PurchaseReturnOrderItemSerializer(serializers.ModelSerializer):
quantity_of_rolls = serializers.SerializerMethodField(read_only=True)
def get_quantity_of_rolls(self, obj: business_models.PurchaseReturnOrderItem):
return obj.split_quantity_of_rolls()
class Meta:
model = business_models.PurchaseReturnOrderItem
fields = [

View File

@@ -9,6 +9,10 @@ from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
class SalesOrderItemSerializer(serializers.ModelSerializer):
quantity_of_rolls = serializers.SerializerMethodField(read_only=True)
def get_quantity_of_rolls(self, obj: business_models.SalesOrderItem):
return obj.split_quantity_of_rolls()
class Meta:
model = business_models.SalesOrderItem
fields = [

View File

@@ -9,6 +9,11 @@ from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
class SalesReturnOrderItemSerializer(serializers.ModelSerializer):
quantity_of_rolls = serializers.SerializerMethodField(read_only=True)
def get_quantity_of_rolls(self, obj: business_models.SalesReturnOrderItem):
return obj.split_quantity_of_rolls()
class Meta:
model = business_models.SalesReturnOrderItem
fields = [
@@ -44,7 +49,7 @@ class SalesReturnOrderSerializer(serializers.ModelSerializer):
model = business_models.SalesReturnOrder
fields = [
'id', 'customer', 'customer_name', 'sales_order', 'return_date',
'total_amount', 'diff_quantity', 'total_quantity',
'total_amount', 'diff_quantity', 'total_quantity', 'quantity_of_rolls',
'operator', 'operator_name', 'warehouse', 'warehouse_name',
'status', 'remarks', 'created_at', 'updated_at', 'items',
]

View File

@@ -183,6 +183,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
printing_order_id = serializers.CharField(source='printing_order.human_id', read_only=True)
product_name = serializers.CharField(source='product.name', read_only=True)
status = serializers.CharField(read_only=True)
work_state_display = serializers.SerializerMethodField()
is_completed = serializers.BooleanField(read_only=True)
business_object_id = serializers.SerializerMethodField()
@@ -191,6 +192,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
fields = [
'id', 'printing_order', 'printing_order_id', 'product', 'product_name',
'quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state_display',
'status', 'is_completed', 'business_object_id',
'created_at', 'updated_at'
]
@@ -200,6 +202,9 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
"""安全地获取 business_object_id"""
return obj.business_object.id if obj.business_object else None
def get_work_state_display(self, obj):
return obj.get_work_state_display()
class PrintingJobDetailSerializer(serializers.ModelSerializer):
"""印染款式明细详情序列化器"""
@@ -208,6 +213,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
product_code = serializers.CharField(source='product.human_id', read_only=True)
status = serializers.CharField(read_only=True)
status_id = serializers.IntegerField(read_only=True)
work_state_display = serializers.SerializerMethodField()
is_completed = serializers.BooleanField(read_only=True)
has_started = serializers.BooleanField(read_only=True)
business_object_id = serializers.SerializerMethodField()
@@ -217,6 +223,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
fields = [
'id', 'printing_order', 'printing_order_id', 'product', 'product_name', 'product_code',
'quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state_display',
'status', 'status_id', 'is_completed', 'has_started', 'business_object_id',
'created_at', 'updated_at'
]
@@ -229,6 +236,9 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
"""安全地获取 business_object_id"""
return obj.business_object.id if obj.business_object else None
def get_work_state_display(self, obj):
return obj.get_work_state_display()
class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
"""印染款式明细创建/更新序列化器"""
@@ -236,13 +246,15 @@ class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = models.PrintingJob
fields = [
'id', 'printing_order', 'product', 'quantity', 'unit', 'size', 'pieces', 'description'
'id', 'printing_order', 'product', 'quantity', 'unit', 'size', 'pieces', 'description',
'work_state',
]
read_only_fields = ['id']
extra_kwargs = {
'size': {'required': False, 'allow_null': True, 'allow_blank': True},
'pieces': {'required': False, 'allow_null': True},
'description': {'required': False, 'allow_null': True, 'allow_blank': True},
'work_state': {'required': False},
}
def validate_printing_order(self, value):

View File

@@ -1,8 +1,15 @@
from decimal import Decimal
import datetime
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient, APIRequestFactory
from basic_info import models as basic_models
from printing import models as printing_models
from business import models as business_models
from api_v2.views.printing import PrintingJobByCustomerView
class QuickCreateEmployeeUserAPITest(TestCase):
@@ -51,3 +58,152 @@ class QuickCreateEmployeeUserAPITest(TestCase):
response = self.client.post(self.url, payload, format='json')
self.assertEqual(response.status_code, 400)
self.assertIn('商户不存在', str(response.data))
class PrintingJobByCustomerAPITest(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.client = APIClient()
self.merchant = basic_models.Merchant.objects.create(
name='印染商户',
type=basic_models.MerchantTypeEnum.FACTORY,
)
self.customer = basic_models.Customer.objects.create(
merchant=self.merchant,
name='客户X',
created_by=None,
)
category = basic_models.ProductCategory.objects.create(
merchant=self.merchant,
name='品类',
product_prefix='FAB',
)
self.product = basic_models.Product.objects.create(
merchant=self.merchant,
category=category,
name='产品A',
human_id='FAB-001',
width_size=Decimal('150.00'),
color='红色',
unit=basic_models.ProductUnitEnum.METER,
)
self.other_product = basic_models.Product.objects.create(
merchant=self.merchant,
category=category,
name='产品B',
human_id='FAB-002',
width_size=Decimal('160.00'),
color='蓝色',
unit=basic_models.ProductUnitEnum.METER,
)
self.printing_order = printing_models.PrintingOrder.objects.create(
customer=self.customer,
fabric='',
width='150cm',
)
self.printing_order_other = printing_models.PrintingOrder.objects.create(
customer=self.customer,
fabric='',
width='160cm',
)
tz = timezone.get_default_timezone()
in_range = timezone.make_aware(datetime.datetime(2025, 12, 5, 10, 0, 0), tz)
out_range = timezone.make_aware(datetime.datetime(2025, 11, 20, 10, 0, 0), tz)
self.job_in_range = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order,
product=self.product,
quantity=10,
unit='',
)
printing_models.PrintingJob.objects.filter(id=self.job_in_range.id).update(created_at=in_range)
self.job_out_range = printing_models.PrintingJob.objects.create(
printing_order=self.printing_order_other,
product=self.other_product,
quantity=20,
unit='',
)
printing_models.PrintingJob.objects.filter(id=self.job_out_range.id).update(created_at=out_range)
# 关联销售单,验证 billed_quantity
warehouse = basic_models.WareHouse.objects.create(
merchant=self.merchant,
name='仓库A',
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
)
operator = basic_models.Employee.objects.create(
merchant=self.merchant,
name='操作员',
)
sales_order = business_models.SalesOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
sales_date=datetime.date(2025, 12, 5),
operator=operator,
warehouse=warehouse,
)
business_models.SalesOrderItem.objects.create(
sales_order=sales_order,
product=self.product,
price=Decimal('10'),
quantity=Decimal('12.5'),
unit='',
empty_diff_percent=Decimal('0'),
num_of_rolls=1,
printing_job=self.job_in_range,
)
business_models.SalesOrderItem.objects.create(
sales_order=sales_order,
product=self.product,
price=Decimal('8'),
quantity=Decimal('7.5'),
unit='',
empty_diff_percent=Decimal('0'),
num_of_rolls=1,
printing_job=self.job_in_range,
)
self.view = PrintingJobByCustomerView.as_view()
def _get(self, params):
request = self.factory.get('/api/v2/printing/jobs/', params)
return self.view(request)
def test_basic_date_and_customer_filter(self):
resp = self._get({
'customer_id': self.customer.id,
'date_from': '2025-12-01',
'date_to': '2025-12-10',
})
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.data), 1)
self.assertEqual(resp.data[0]['id'], self.job_in_range.id)
self.assertEqual(resp.data[0]['billed_quantity'], '20.00')
def test_filter_by_printing_order(self):
resp = self._get({
'customer_id': self.customer.id,
'date_from': '2025-12-01',
'date_to': '2025-12-10',
'printing_order': self.printing_order.id,
})
self.assertEqual(len(resp.data), 1)
self.assertEqual(resp.data[0]['printing_order'], self.printing_order.id)
def test_filter_by_product_fields(self):
resp = self._get({
'customer_id': self.customer.id,
'date_from': '2025-12-01',
'date_to': '2025-12-10',
'product_id': self.product.id,
'product_name': '产品A',
'product_human_id': 'FAB-001',
'product_width_size': '150',
'product_color': '',
})
self.assertEqual(len(resp.data), 1)
data = resp.data[0]
self.assertEqual(data['product'], self.product.id)
self.assertEqual(data['billed_quantity'], '20.00')

View File

@@ -1,8 +1,9 @@
from django.urls import path
from api_v2.views import QuickCreateEmployeeUserView
from api_v2.views import QuickCreateEmployeeUserView, PrintingJobByCustomerView
urlpatterns = [
path('users/quick-create/', QuickCreateEmployeeUserView.as_view(), name='api_v2_user_quick_create'),
path('printing-jobs/by-customer/', PrintingJobByCustomerView.as_view(), name='api_v2_printing_job_by_customer'),
]

View File

@@ -3,6 +3,11 @@ api_v2 视图包。
"""
from .users import QuickCreateEmployeeUserView
from .printing import PrintingJobByCustomerView, PrintingJobV2Serializer
__all__ = ['QuickCreateEmployeeUserView']
__all__ = [
'QuickCreateEmployeeUserView',
'PrintingJobByCustomerView',
'PrintingJobV2Serializer',
]

140
api_v2/views/printing.py Normal file
View File

@@ -0,0 +1,140 @@
import datetime
from django.utils import timezone
from rest_framework import serializers, status, permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from printing import models as printing_models
from api_man.serializers import ProductSerializer
class PrintingJobV2Serializer(serializers.ModelSerializer):
"""v2 独立的印染任务序列化器,包含开单数量"""
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
business_object_id = serializers.SerializerMethodField()
width = serializers.SerializerMethodField()
fabric = serializers.SerializerMethodField()
product = ProductSerializer(read_only=True)
class Meta:
model = printing_models.PrintingJob
fields = [
'id',
'printing_order',
'product',
'work_state',
'quantity',
'width',
'fabric',
'unit',
'size',
'pieces',
'description',
'business_object_id',
'created_at',
'updated_at',
'billed_quantity',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'billed_quantity']
def get_business_object_id(self, obj):
return obj.business_object_id
def get_width(self, obj: printing_models.PrintingJob) -> float:
return obj.printing_order.width
def get_fabric(self, obj: printing_models.PrintingJob) -> str:
return obj.printing_order.fabric
class PrintingJobByCustomerView(APIView):
"""
按客户与日期范围查询印染任务。
必填 query 参数:
- customer_id: 客户 ID
- date_from: 开始日期 (YYYY-MM-DD)
- date_to: 结束日期 (YYYY-MM-DD),闭区间,包含 23:59:59
可选过滤:
- printing_order: 按印染主订单 ID
- product_id / product_name / product_human_id / product_width_size / product_color
"""
serializer_class = PrintingJobV2Serializer
permission_classes = [permissions.AllowAny]
def get(self, request):
qp = request.query_params
customer_id = qp.get('customer_id')
date_from = qp.get('date_from')
date_to = qp.get('date_to')
printing_order_id = qp.get('printing_order')
product_id = qp.get('product_id')
product_name = qp.get('product_name')
product_human_id = qp.get('product_human_id')
product_width_size = qp.get('product_width_size')
product_color = qp.get('product_color')
if not customer_id:
return Response({'detail': 'customer_id 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
if not date_from or not date_to:
return Response({'detail': 'date_from 与 date_to 为必填参数'}, status=status.HTTP_400_BAD_REQUEST)
try:
customer_id_int = int(customer_id)
except (TypeError, ValueError):
return Response({'detail': 'customer_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
try:
start_date = datetime.datetime.strptime(date_from, '%Y-%m-%d').date()
end_date = datetime.datetime.strptime(date_to, '%Y-%m-%d').date()
except ValueError:
return Response({'detail': '日期格式需为 YYYY-MM-DD'}, status=status.HTTP_400_BAD_REQUEST)
# 闭区间:包含当日 00:00:00 和 23:59:59.999999
start_dt = datetime.datetime.combine(start_date, datetime.time.min)
end_dt = datetime.datetime.combine(end_date, datetime.time.max)
if timezone.is_naive(start_dt):
start_dt = timezone.make_aware(start_dt, timezone.get_default_timezone())
if timezone.is_naive(end_dt):
end_dt = timezone.make_aware(end_dt, timezone.get_default_timezone())
queryset = printing_models.PrintingJob.objects.select_related('printing_order', 'product').filter(
printing_order__customer_id=customer_id_int,
created_at__gte=start_dt,
created_at__lte=end_dt,
)
# 可选过滤printing_order
if printing_order_id:
try:
queryset = queryset.filter(printing_order_id=int(printing_order_id))
except (TypeError, ValueError):
return Response({'detail': 'printing_order 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
# 可选过滤product
if product_id:
try:
queryset = queryset.filter(product_id=int(product_id))
except (TypeError, ValueError):
return Response({'detail': 'product_id 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
if product_name:
queryset = queryset.filter(product__name__icontains=product_name)
if product_human_id:
queryset = queryset.filter(product__human_id__icontains=product_human_id)
if product_width_size:
try:
width_decimal = float(product_width_size)
except (TypeError, ValueError):
return Response({'detail': 'product_width_size 必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
queryset = queryset.filter(product__width_size=width_decimal)
if product_color:
queryset = queryset.filter(product__color__icontains=product_color)
queryset = queryset.order_by('-created_at')
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)

View File

@@ -150,9 +150,9 @@ class SalesOrderItemAdmin(admin.ModelAdmin):
list_display = (
'id', 'sales_order', 'product',
'quantity', 'unit', 'price', 'batch_number',
'_total_amount', 'empty_diff_percent',
'_real_quantity', '_diff_quantity',
'num_of_rolls',
'quantity_of_rolls', '_total_amount',
'empty_diff_percent', '_real_quantity',
'_diff_quantity', 'num_of_rolls',
)
search_fields = ('sales_order__id', 'product__name')
list_filter = ('sales_order__operator', 'sales_order__warehouse', 'created_at')

View File

@@ -15,7 +15,7 @@ class Migration(migrations.Migration):
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.SET_NULL,
on_delete=models.PROTECT,
related_name='sales_order_items',
to='printing.printingjob',
verbose_name='关联印染任务',

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.2.8 on 2025-12-11 04:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('business', '0017_salesorderitem_printing_job'),
('printing', '0022_printingjob_work_state'),
]
operations = [
migrations.AlterField(
model_name='salesorderitem',
name='printing_job',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sales_order_items', to='printing.printingjob', verbose_name='关联印染任务'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-12-11 09:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('business', '0018_alter_salesorderitem_printing_job'),
]
operations = [
migrations.AlterField(
model_name='salesorderitem',
name='quantity_of_rolls',
field=models.TextField(blank=True, null=True, verbose_name='各条数数量'),
),
]

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from decimal import Decimal
from typing import List
from django.db import models
from flower.common import ModelBase
from basic_info import models as basic_info_models
@@ -193,6 +194,11 @@ class PurchaseOrderItem(ModelBase):
def total_amount(self):
return round(self.price * self.real_quantity(), 2)
def split_quantity_of_rolls(self) -> List[int]:
if self.quantity_of_rolls:
return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()]
return []
class SalesOrderKindEnum(models.IntegerChoices):
"""销售单类型"""
@@ -280,7 +286,7 @@ class SalesOrderItem(ModelBase):
)
printing_job = models.ForeignKey(
'printing.PrintingJob',
on_delete=models.SET_NULL,
on_delete=models.PROTECT,
related_name='sales_order_items',
null=True,
blank=True,
@@ -296,8 +302,7 @@ class SalesOrderItem(ModelBase):
decimal_places=2,
verbose_name='空差百分比',
)
quantity_of_rolls = models.CharField(
max_length=255,
quantity_of_rolls = models.TextField(
null=True,
blank=True,
verbose_name='各条数数量',
@@ -316,6 +321,11 @@ class SalesOrderItem(ModelBase):
verbose_name = '销售单明细'
verbose_name_plural = '销售单明细'
def split_quantity_of_rolls(self) -> List[int]:
if self.quantity_of_rolls:
return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()]
return []
def real_quantity(self):
return round(self.quantity * (1 - self.empty_diff_percent / 100), 2)
@@ -425,6 +435,11 @@ class PurchaseReturnOrderItem(ModelBase):
verbose_name = '采购退货明细'
verbose_name_plural = '采购退货明细'
def split_quantity_of_rolls(self) -> List[int]:
if self.quantity_of_rolls:
return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()]
return []
def real_quantity(self):
return round(self.quantity * (1 - self.empty_diff_percent / 100), 2)
@@ -542,6 +557,11 @@ class SalesReturnOrderItem(ModelBase):
def total_amount(self):
return round(self.price * self.real_quantity(), 2)
def split_quantity_of_rolls(self) -> List[int]:
if self.quantity_of_rolls:
return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()]
return []
class PaymentOrderStatusEnum(models.IntegerChoices):

View File

@@ -4,7 +4,7 @@ import logging
from collections import OrderedDict
from datetime import date, datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Any, Dict, Iterable, List, Tuple
from typing import Any, Dict, Iterable, List, Tuple, Optional
from django.contrib.auth import get_user_model
from django.db import transaction
@@ -260,6 +260,7 @@ def create_sales_order(
warehouse=warehouse,
items=items,
is_outgoing=True,
customer=customer,
)
with transaction.atomic():
@@ -286,6 +287,7 @@ def create_sales_order(
consume_detail_ids=item_data.get('consume_detail_ids'),
batch_number=item_data.get('batch_number'),
remarks=item_data.get('remarks'),
printing_job=item_data.get('printing_job'),
)
for item_data in sales_items
]
@@ -455,7 +457,7 @@ def create_payment_order(
创建付款单(资金流出)。
"""
normalized_date = _normalize_order_date(payment_date)
normalized_amount = _ensure_positive_amount(amount, 'amount')
normalized_amount = _ensure_non_zero_amount(amount, 'amount')
normalized_discount = _ensure_non_negative_amount(discount_amount, 'discount_amount')
if bank_account and bank_account.merchant_id != merchant.id:
@@ -493,7 +495,7 @@ def create_receipt_order(
创建收款单(资金流入)。
"""
normalized_date = _normalize_order_date(receipt_date)
normalized_amount = _ensure_positive_amount(amount, 'amount')
normalized_amount = _ensure_non_zero_amount(amount, 'amount')
normalized_discount = _ensure_non_negative_amount(discount_amount, 'discount_amount')
if bank_account and bank_account.merchant_id != merchant.id:
@@ -740,6 +742,7 @@ def _normalize_order_items(
warehouse: basic_info_models.WareHouse,
items: List[Dict[str, Any]],
is_outgoing: bool,
customer: Optional[basic_info_models.Customer] = None,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
根据仓库模式校验订单明细,并返回:
@@ -768,6 +771,25 @@ def _normalize_order_items(
remarks = raw_item.get('remarks')
spec = raw_item.get('spec')
unit = raw_item.get('unit') or product.get_unit_display() or ''
printing_job = None
raw_printing_job_id = raw_item.get('printing_job') or raw_item.get('printing_job_id')
if raw_printing_job_id is not None:
try:
printing_job_id = int(raw_printing_job_id)
except (TypeError, ValueError):
raise ValueError(f'items[{index}].printing_job 必须为数字')
from printing import models as printing_models
try:
printing_job = printing_models.PrintingJob.objects.select_related(
'product', 'printing_order__customer'
).get(id=printing_job_id)
except printing_models.PrintingJob.DoesNotExist as exc:
raise ValueError(f'items[{index}].printing_job 不存在或已删除') from exc
if printing_job.product_id != product.id:
raise ValueError(f'items[{index}].printing_job 对应的产品与当前明细不一致')
if customer and printing_job.printing_order and printing_job.printing_order.customer_id != customer.id:
raise ValueError(f'items[{index}].printing_job 客户不匹配')
quantity = 0
num_of_rolls = 0
@@ -844,6 +866,7 @@ def _normalize_order_items(
'remarks': remarks,
'spec': spec,
'consume_detail_ids': consume_detail_ids_str,
'printing_job': printing_job,
})
return normalized_items, stock_flow_items
@@ -1409,10 +1432,10 @@ def _to_decimal(value, field_name: str) -> Decimal:
raise ValueError(f'{field_name} 必须是合法数值') from exc
def _ensure_positive_amount(value, field_name: str) -> Decimal:
def _ensure_non_zero_amount(value, field_name: str) -> Decimal:
amount = _to_decimal(value, field_name)
if amount <= 0:
raise ValueError(f'{field_name} 必须大于 0')
if amount == 0:
raise ValueError(f'{field_name} 不能为 0')
return amount

View File

@@ -147,6 +147,7 @@ class PurchaseOrderServiceTestCase(TestCase):
self.assertEqual(purchase_order.items.count(), 1)
item = purchase_order.items.first()
self.assertEqual(item.quantity_of_rolls, '10,5')
self.assertEqual(item.split_quantity_of_rolls(), [10, 5])
mock_delay.assert_not_called()
def test_review_purchase_order_approval_triggers_task(self):
@@ -210,7 +211,9 @@ class PurchaseOrderServiceTestCase(TestCase):
items=self.relaxed_items,
created_by=self.user,
)
self.assertIsNone(purchase_order.items.first().quantity_of_rolls)
item = purchase_order.items.first()
self.assertIsNone(item.quantity_of_rolls)
self.assertEqual(item.split_quantity_of_rolls(), [])
cancelled = services.review_purchase_order(
purchase_order=purchase_order,
@@ -343,7 +346,9 @@ class SalesOrderServiceTestCase(TestCase):
self.assertIsInstance(sales_order, business_models.SalesOrder)
self.assertEqual(sales_order.status, business_models.SalesOrderStatusEnum.PENDING)
self.assertEqual(sales_order.items.count(), 1)
self.assertEqual(sales_order.items.first().quantity_of_rolls, '6,4')
item = sales_order.items.first()
self.assertEqual(item.quantity_of_rolls, '6,4')
self.assertEqual(item.split_quantity_of_rolls(), [6, 4])
mock_delay.assert_not_called()
def test_review_sales_order_triggers_task(self):
@@ -501,6 +506,9 @@ class PurchaseReturnServiceTestCase(TestCase):
)
self.assertEqual(order.status, business_models.PurchaseReturnStatusEnum.PENDING)
self.assertEqual(order.items.count(), 1)
item = order.items.first()
self.assertEqual(item.quantity_of_rolls, '4,2')
self.assertEqual(item.split_quantity_of_rolls(), [4, 2])
def test_review_purchase_return_order_triggers_task_and_balance(self):
order = services.create_purchase_return_order(
@@ -601,6 +609,8 @@ class SalesReturnServiceTestCase(TestCase):
items=[{'product_id': self.product.id, 'quantity': 50, 'num_of_rolls': 2, 'price': '19.5'}],
)
self.assertEqual(order.status, business_models.SalesReturnStatusEnum.PENDING)
item = order.items.first()
self.assertEqual(item.split_quantity_of_rolls(), [])
def test_review_sales_return_order_updates_balance(self):
order = services.create_sales_return_order(
@@ -612,6 +622,7 @@ class SalesReturnServiceTestCase(TestCase):
items=self.strict_items,
created_by=self.user,
)
self.assertEqual(order.items.first().split_quantity_of_rolls(), [6, 2])
with patch('business.services.create_sales_return_order_stock_entries.delay') as mock_delay:
reviewed = services.review_sales_return_order(
sales_return_order=order,
@@ -782,15 +793,15 @@ class PaymentReceiptServiceTestCase(TestCase):
).exists()
)
def test_payment_amount_must_be_positive(self):
with self.assertRaises(ValueError):
services.create_payment_order(
merchant=self.merchant,
supplier=self.supplier,
payment_date=timezone.now().date(),
amount='-1',
operator=self.operator,
)
def test_payment_amount_can_be_negative(self):
order = services.create_payment_order(
merchant=self.merchant,
supplier=self.supplier,
payment_date=timezone.now().date(),
amount='-25.50',
operator=self.operator,
)
self.assertEqual(order.amount, Decimal('-25.50'))
def test_payment_discount_can_exceed_amount(self):
order = services.create_payment_order(
@@ -814,6 +825,16 @@ class PaymentReceiptServiceTestCase(TestCase):
)
self.assertEqual(order.settlement_amount, Decimal('110'))
def test_receipt_amount_can_be_negative(self):
order = services.create_receipt_order(
merchant=self.merchant,
customer=self.customer,
receipt_date=timezone.now().date(),
amount='-40',
operator=self.operator,
)
self.assertEqual(order.amount, Decimal('-40'))
def test_payment_order_rejects_foreign_bank_account(self):
other_merchant = basic_models.Merchant.objects.create(
name='无关商户',

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
# API v1 创建销售单(/api/v1/sales-orders/
> 视图位置:`api_v1.views.business.sales.views.SalesOrderView.post`
> 服务逻辑:`business.services.create_sales_order`(含事务,主单与明细同事务创建)
## 请求
- 方法:`POST`
- 路径:`/api/v1/sales-orders/`
- BodyJSON
- `customer` *(int, 必填)*:客户 ID
- `warehouse` *(int, 必填)*:仓库 ID
- `order_date` *(string, 必填)*:日期,`YYYY-MM-DD`
- `remarks` *(string, 可选)*
- `items` *(array, 必填,至少 1 条)*:销售明细
- 通用字段:
- `product_id` *(int, 必填)*
- `price` *(string/number, 必填)*
- `unit` *(string, 可选;默认产品单位或“米”)*
- `empty_diff_percent` *(string/number, 可选,默认 0)*
- `color` / `batch_number` / `remarks` / `spec` *(可选)*
- `printing_job` *(int, 可选)*:绑定印染任务 ID
- 校验:存在且产品一致;若传入销售客户,需与任务的 `printing_order.customer` 一致。
- 数量字段取决于仓库模式:
- 宽进/宽出 (`UNRESTRICTED`)`quantity``num_of_rolls`
- 严进宽出 (`RESTRICT_IN`)`numbers` 数组(每条数值为正),自动汇总数量
- 严进严出 (`RESTRICT_IN_OUT`):出库需 `consume_detail_ids` 数组 + `quantity`;入库需 `numbers`
## 响应
- 成功 `201 Created``{"id": <销售单ID>, "status": <状态枚举>, "message": "销售单创建成功,等待审批"}`
- 失败 `400 Bad Request`:缺参、格式或校验不通过(含 printing_job 校验)。
## 示例
### 严进严出(出库)示例
```json
{
"customer": 12,
"warehouse": 3,
"order_date": "2025-12-20",
"items": [
{
"product_id": 501,
"price": "18.50",
"quantity": 30,
"unit": "米",
"consume_detail_ids": [101, 102],
"printing_job": 88
}
],
"remarks": "严进严出示例"
}
```
### 宽进宽出示例
```json
{
"customer": 12,
"warehouse": 5,
"order_date": "2025-12-20",
"items": [
{
"product_id": 501,
"price": "15.00",
"quantity": 90,
"num_of_rolls": 3,
"printing_job": 88
}
]
}
```
## 关键行为说明
- 事务:主单与明细在同一 `transaction.atomic()` 中,任一失败整体回滚。
- 校验:
- 仓库模式决定数量字段(`numbers` / `consume_detail_ids` / `quantity`)。
- `printing_job` 可选;如提供需满足产品与客户一致性校验,否则返回 400。
- 明细创建:服务层使用 `bulk_create` 写入 `SalesOrderItem`。***

View File

@@ -0,0 +1,74 @@
# 印染任务查询(按客户+日期范围)- API v2
> 说明:接口视图为 `api_v2.views.printing.PrintingJobByCustomerView`URL 需在 `api_v2/urls.py` 绑定后生效(例如 `/api/v2/printing/jobs/`)。
## 请求
- 方法:`GET`
- Query 参数(必填)
- `customer_id`:客户 ID
- `date_from`:开始日期,格式 `YYYY-MM-DD`
- `date_to`:结束日期,格式 `YYYY-MM-DD`(闭区间,包含当日 23:59:59.999999
- Query 参数(可选过滤)
- `printing_order`:按印染主订单 ID
- `product_id`:按产品 ID
- `product_name`:按产品名称(模糊)
- `product_human_id`:按产品编号(模糊)
- `product_width_size`:按产品幅宽(精确数值)
- `product_color`:按产品颜色(模糊)
### 时间范围说明
`date_from``date_to` 为闭区间,等价于:
- `created_at >= date_from 00:00:00`
- `created_at <= date_to 23:59:59.999999`
## 响应
- 成功:`200 OK`
- 失败:`400 Bad Request`(参数缺失或格式错误)
### 响应字段(列表,每个元素)
- `id`:印染任务 ID
- `printing_order`:印染主订单 ID
- `product`:产品 ID
- `work_state`:业务进展(枚举值)
- `quantity`:数量
- `unit`:单位
- `size`:一段尺寸
- `pieces`:件数
- `description`:备注
- `business_object_id`:流程实例 ID
- `created_at` / `updated_at`
- `billed_quantity`:开单数量(关联全部 `SalesOrderItem.quantity` 之和)
## 示例
### 请求
```
GET /api/v2/printing/jobs/?customer_id=12&date_from=2025-12-01&date_to=2025-12-10&product_name=花布
```
### 成功响应(示例)
```json
[
{
"id": 101,
"printing_order": 55,
"product": 2001,
"work_state": 1,
"quantity": 300,
"unit": "米",
"size": "150cm",
"pieces": 10,
"description": "加急",
"business_object_id": 888,
"created_at": "2025-12-05T10:00:00Z",
"updated_at": "2025-12-06T08:00:00Z",
"billed_quantity": "120.00"
}
]
```

View File

@@ -312,7 +312,7 @@ CELERY_BEAT_SCHEDULE = {
},
'mdy_product_sync': {
'task': 'api_v1.tasks.sync_mdy_products',
'schedule': crontab(hour='*/1', minute=0),
'schedule': crontab(minute='*/10'),
'kwargs': {
'page_size': MDY_SYNC_PAGE_SIZE,
'max_pages': MDY_SYNC_MAX_PAGES,
@@ -321,7 +321,7 @@ CELERY_BEAT_SCHEDULE = {
},
'mdy_customer_sync': {
'task': 'api_v1.tasks.sync_mdy_customers',
'schedule': crontab(hour='*/1', minute=0),
'schedule': crontab(hour='*/5', minute=0),
'kwargs': {
'page_size': MDY_SYNC_PAGE_SIZE,
'max_pages': MDY_SYNC_MAX_PAGES,

View File

@@ -24,6 +24,11 @@ from rest_framework_simplejwt.views import (
# TokenRefreshView,
)
# 自定义后台站点标题simpleui 也会读取)
admin.site.site_header = "宇问科技"
admin.site.site_title = "宇问科技"
admin.site.index_title = "管理后台"
class CustomTokenObtainPairView(TokenObtainPairView):
"""自定义登录视图"""

View File

@@ -226,6 +226,18 @@ def pick_product(fields: dict) -> Product:
从字段字典中提取产品信息
"""
data = {k: fields.get(v, '') for k, v in product_type_map.items()}
def _to_int(value):
if value in ('', None):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
data['pieces'] = _to_int(data.get('pieces'))
data['segment_size'] = _to_int(data.get('segment_size'))
return Product(**data)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-12-11 04:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('printing', '0021_plateorder_print_count_printingorder_print_count'),
]
operations = [
migrations.AddField(
model_name='printingjob',
name='work_state',
field=models.IntegerField(default=0, verbose_name='业务进展'),
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
import printing.models
class Migration(migrations.Migration):
dependencies = [
('printing', '0022_printingjob_work_state'),
]
operations = [
migrations.AlterField(
model_name='printingjob',
name='work_state',
field=models.IntegerField(
choices=printing.models.PrintingJobWorkStateEnum.choices,
default=printing.models.PrintingJobWorkStateEnum.PRODUCING,
verbose_name='业务进展',
),
),
]

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from django.conf import settings
from django.db import models
from flower.common import ModelBase
@@ -289,6 +291,13 @@ class PrintingOrder(ModelBase):
return int((completed_count / jobs.count()) * 100)
class PrintingJobWorkStateEnum(models.IntegerChoices):
PRODUCING = 0, '生产中'
WAITING_FOR_DELIVERY = 1, '待送货'
WAITING_FOR_INVOICE = 2, '待开单'
FINISHED = 3, '已完结'
class PrintingJob(ModelBase):
"""PrintingJob model representing a printing job associated with an order."""
printing_order = models.ForeignKey(
@@ -303,6 +312,11 @@ class PrintingJob(ModelBase):
related_name='printing_jobs',
verbose_name='产品',
)
work_state = models.IntegerField(
choices=PrintingJobWorkStateEnum.choices,
default=PrintingJobWorkStateEnum.PRODUCING,
verbose_name='业务进展',
)
quantity = models.PositiveIntegerField(verbose_name='数量')
unit = models.CharField(max_length=50, verbose_name='单位')
size = models.CharField(max_length=100, null=True, blank=True, verbose_name='一段尺寸')
@@ -320,6 +334,16 @@ class PrintingJob(ModelBase):
def __str__(self):
return self.printing_order.human_id
@property
def billed_quantity(self) -> Decimal:
"""
开单数量:所有关联销售明细的数量之和
"""
from business.models import SalesOrderItem
total = self.sales_order_items.aggregate(total=models.Sum('quantity')).get('total')
return total or Decimal('0')
@property
def status(self) -> str:
"""