forked from erp-dev/erp
feat: added n-to-1 relation between sales_order_item and printing_job model
This commit is contained in:
201
api_v1/tests.py
201
api_v1/tests.py
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
158
api_v2/tests.py
158
api_v2/tests.py
@@ -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')
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
140
api_v2/views/printing.py
Normal 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)
|
||||
@@ -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')
|
||||
|
||||
@@ -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='关联印染任务',
|
||||
|
||||
@@ -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='关联印染任务'),
|
||||
),
|
||||
]
|
||||
@@ -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='各条数数量'),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
30343
data-bak/db-backup-20251209-190000.sql
Normal file
30343
data-bak/db-backup-20251209-190000.sql
Normal file
File diff suppressed because it is too large
Load Diff
10502
data-bak/db-backup-20251210-190002.sql
Normal file
10502
data-bak/db-backup-20251210-190002.sql
Normal file
File diff suppressed because it is too large
Load Diff
80
docs/patch_api_v1_business_sales_order_create.md
Normal file
80
docs/patch_api_v1_business_sales_order_create.md
Normal 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/`
|
||||
- Body(JSON)
|
||||
- `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`。***
|
||||
74
docs/printing_job_customer_query_v2.md
Normal file
74
docs/printing_job_customer_query_v2.md
Normal 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"
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
"""自定义登录视图"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
18
printing/migrations/0022_printingjob_work_state.py
Normal file
18
printing/migrations/0022_printingjob_work_state.py
Normal 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='业务进展'),
|
||||
),
|
||||
]
|
||||
21
printing/migrations/0023_alter_printingjob_work_state.py
Normal file
21
printing/migrations/0023_alter_printingjob_work_state.py
Normal 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='业务进展',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user