1
0
forked from erp-dev/erp

fix: business cancel service merged

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

View File

@@ -1305,6 +1305,32 @@ class CustomerBalanceAPITestCase(TestCase):
response = self.client.get(f'/api/v1/customers/{self.customer.id}/balance/') response = self.client.get(f'/api/v1/customers/{self.customer.id}/balance/')
self.assertEqual(response.data['balance'], '30.00') self.assertEqual(response.data['balance'], '30.00')
business_models.ExternalCustomerStatementOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
category=business_models.ExternalCustomerStatementCategoryEnum.SALE,
external_source_id='XS-BAL-001',
occurred_at=datetime.date(2025, 11, 28),
total_amount=Decimal('100.00'),
zk_amount=Decimal('10.00'),
sf_amount=Decimal('20.00'),
)
business_models.ExternalCustomerStatementOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
category=business_models.ExternalCustomerStatementCategoryEnum.SALE_RETURN,
external_source_id='XT-BAL-001',
occurred_at=datetime.date(2025, 11, 29),
total_amount=Decimal('15.00'),
zk_amount=Decimal('5.00'),
)
response = self.client.get(f'/api/v1/customers/{self.customer.id}/balance/')
self.assertEqual(response.data['balance'], '80.00')
statement_payload = services.build_customer_statement(merchant=self.merchant, customer=self.customer)
self.assertEqual(statement_payload['records'][0]['current_balance'], response.data['balance'])
class SupplierBalanceAPITestCase(TestCase): class SupplierBalanceAPITestCase(TestCase):
def setUp(self): def setUp(self):

View File

@@ -22,7 +22,7 @@ class CustomerBalanceView(StockChangeViewMixin, views.APIView):
except basic_models.Customer.DoesNotExist: except basic_models.Customer.DoesNotExist:
return self.not_found_response('客户不存在') return self.not_found_response('客户不存在')
balance: Decimal = business_services.BalanceService.get_customer_balance( balance: Decimal = business_services.BalanceService.get_customer_statement_balance(
merchant=merchant, merchant=merchant,
customer=customer, customer=customer,
) )

View File

@@ -96,6 +96,8 @@ class SalesOrderView(StockChangeViewMixin, views.APIView):
order_date = data.get('order_date') order_date = data.get('order_date')
items = data.get('items', []) items = data.get('items', [])
remarks = data.get('remarks', '') remarks = data.get('remarks', '')
kind = data.get('kind')
if not customer_id: if not customer_id:
return Response({'error': '缺少客户 ID'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': '缺少客户 ID'}, status=status.HTTP_400_BAD_REQUEST)
@@ -128,7 +130,9 @@ class SalesOrderView(StockChangeViewMixin, views.APIView):
items=items, items=items,
remarks=remarks, remarks=remarks,
created_by=request.user, created_by=request.user,
kind=kind,
) )
except ValueError as exc: except ValueError as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
@@ -199,6 +203,7 @@ class SalesOrderDetailView(StockChangeViewMixin, views.APIView):
if not isinstance(items, list) or not items: if not isinstance(items, list) or not items:
return Response({'error': 'items 需要为非空数组'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'items 需要为非空数组'}, status=status.HTTP_400_BAD_REQUEST)
remarks = data.get('remarks', sales_order.remarks) remarks = data.get('remarks', sales_order.remarks)
kind = data.get('kind')
try: try:
updated_order = business_services.update_sales_order( updated_order = business_services.update_sales_order(
@@ -208,7 +213,9 @@ class SalesOrderDetailView(StockChangeViewMixin, views.APIView):
warehouse=warehouse, warehouse=warehouse,
items=items, items=items,
remarks=remarks, remarks=remarks,
kind=kind,
) )
except ValueError as exc: except ValueError as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)

View File

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

View File

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

View File

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

View File

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

View File

@@ -171,6 +171,7 @@ class PrintingJobByCustomerAPITest(TestCase):
customer=self.customer, customer=self.customer,
fabric='', fabric='',
width='150cm', width='150cm',
external_order_id='KD20410611',
) )
self.printing_order_other = printing_models.PrintingOrder.objects.create( self.printing_order_other = printing_models.PrintingOrder.objects.create(
customer=self.customer, customer=self.customer,
@@ -251,6 +252,7 @@ class PrintingJobByCustomerAPITest(TestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.data), 1) self.assertEqual(len(resp.data), 1)
self.assertEqual(resp.data[0]['id'], self.job_in_range.id) self.assertEqual(resp.data[0]['id'], self.job_in_range.id)
self.assertEqual(resp.data[0]['external_order_id'], 'KD20410611')
self.assertEqual(resp.data[0]['billed_quantity'], '20.00') self.assertEqual(resp.data[0]['billed_quantity'], '20.00')
def test_filter_by_printing_order(self): def test_filter_by_printing_order(self):

View File

@@ -49,6 +49,7 @@ def _serialize_plate_image_first(raw_value, request):
class PrintingJobV2Serializer(serializers.ModelSerializer): class PrintingJobV2Serializer(serializers.ModelSerializer):
"""v2 独立的印染任务序列化器,包含开单数量""" """v2 独立的印染任务序列化器,包含开单数量"""
external_order_id = serializers.CharField(source='printing_order.external_order_id', read_only=True)
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True) billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
business_object_id = serializers.SerializerMethodField() business_object_id = serializers.SerializerMethodField()
width = serializers.SerializerMethodField() width = serializers.SerializerMethodField()
@@ -61,6 +62,7 @@ class PrintingJobV2Serializer(serializers.ModelSerializer):
'id', 'id',
'original_id', 'original_id',
'printing_order', 'printing_order',
'external_order_id',
'product', 'product',
'work_state', 'work_state',
'quantity', 'quantity',
@@ -75,7 +77,7 @@ class PrintingJobV2Serializer(serializers.ModelSerializer):
'updated_at', 'updated_at',
'billed_quantity', 'billed_quantity',
] ]
read_only_fields = ['id', 'created_at', 'updated_at', 'billed_quantity'] read_only_fields = ['id', 'created_at', 'updated_at', 'external_order_id', 'billed_quantity']
def get_business_object_id(self, obj): def get_business_object_id(self, obj):
return obj.business_object_id return obj.business_object_id

View File

@@ -52,13 +52,14 @@
## 3. 服务层约定 ## 3. 服务层约定
- `business.services` 负责 orchestration与 API 解耦。审批、作废、触发库存等流程必须先在服务层实现,再暴露给 API。 - `business.services` 负责 orchestration与 API 解耦。审批、作废、触发库存等流程必须先在服务层实现,再暴露给 API。
- **作废统一实现**`_cancel_order_impl()` 是所有单据作废的单一入口,通过参数化 `order_model_cls` / `approved_status` / `cancelled_status` / `stock_source_type` 适配不同模型,将 APPROVED 检查统一在 `select_for_update` 锁内完成。
- 任何涉及库存的逻辑都必须通过 `stock.services.StockFlowService`,不得直接操作库存模型,保持模块边界清晰。 - 任何涉及库存的逻辑都必须通过 `stock.services.StockFlowService`,不得直接操作库存模型,保持模块边界清晰。
- 红冲/对冲等高级动作应由业务模块提供入口(例如 `PurchaseOrder` 红冲),但最终仍调用库存服务完成实际库存变动。 - 红冲/对冲等高级动作应由业务模块提供入口(例如 `PurchaseOrder` 红冲),但最终仍调用库存服务完成实际库存变动。
## 4. 未来演进建议 ## 4. 未来演进建议
1. **新增单据**:若未来出现调拨单、退货单或更多资金类单据,优先复用 `OrderDirectionMixin + OrderCounterpartyMixin`(如有明细再叠加 `OrderItemsAggregationMixin`),仅通过 `get_direction()` / `get_counterparty_field_name()` 区别方向与主体,减少重复实现。 1. **新增单据**:若未来出现调拨单、退货单或更多资金类单据,优先复用 `OrderDirectionMixin + OrderCounterpartyMixin`(如有明细再叠加 `OrderItemsAggregationMixin`),仅通过 `get_direction()` / `get_counterparty_field_name()` 区别方向与主体,减少重复实现。
2. **审批/状态机**采购与销售如需共享状态流转,可提炼状态机或 service 层 mixin而无需在模型层合并 2. **审批/状态机**作废逻辑已通过 `_cancel_order_impl()` 统一实现,新增单据的作废只需委托该函数并传入对应参数,无需复制逻辑
3. **统计与报表**:财务/库存统计应依赖 `get_signed_total_amount()` / `get_direction()`,确保采购/销售、退货/正向都能通过统一接口处理。 3. **统计与报表**:财务/库存统计应依赖 `get_signed_total_amount()` / `get_direction()`,确保采购/销售、退货/正向都能通过统一接口处理。
4. **余额审计**:任何会写入 Supplier/CustomerBalance 的流程必须通过 `BalanceService`,以便自动生成 `BalanceChangeRecord`。审批通过后禁止作废,若未来需要冲销,必须新建红冲记录并维护 `offset_to/offset_id` 链路。 4. **余额审计**:任何会写入 Supplier/CustomerBalance 的流程必须通过 `BalanceService`,以便自动生成 `BalanceChangeRecord`。审批通过后禁止作废,若未来需要冲销,必须新建红冲记录并维护 `offset_to/offset_id` 链路。
5. **文档同步**:新增单据或服务时必须更新本文件,描述新增模型如何复用 mixin、如何影响下游模块保持设计透明。 5. **文档同步**:新增单据或服务时必须更新本文件,描述新增模型如何复用 mixin、如何影响下游模块保持设计透明。

View File

@@ -705,24 +705,23 @@ def _normalize_external_statement_order_group(
zk_value = abs(_to_decimal(record.get('ZkJinE'), field_name='ZkJinE', default=Decimal('0'))) zk_value = abs(_to_decimal(record.get('ZkJinE'), field_name='ZkJinE', default=Decimal('0')))
total_amount += amount_value total_amount += amount_value
zk_amount += zk_value zk_amount += zk_value
matched_product = _find_local_product_by_external_product_id( external_product_id = str(record.get('HpID') or '').strip()
merchant=customer.merchant, product_name = str(record.get('HpName') or '').strip() or external_product_id
external_product_id=str(record.get('HpID') or '').strip(), color = str(record.get('YanSe') or '').strip()
) spec = str(record.get('SeHao') or '').strip()
product_name = getattr(matched_product, 'name', '') or str(record.get('HpID') or '').strip() unit = str(record.get('JiJiaDW') or '').strip()
unit = str(record.get('JiJiaDW') or '').strip() or getattr(matched_product, 'get_unit_display', lambda: '')()
item_payload = { item_payload = {
'product_id': getattr(matched_product, 'id', None), 'product_id': None,
'product_name': product_name, 'product_name': product_name,
'quantity': _decimal_to_string(quantity_value), 'quantity': _decimal_to_string(quantity_value),
'price': _decimal_to_string(price_value), 'price': _decimal_to_string(price_value),
'unit': unit, 'unit': unit,
'color': '', 'color': color,
'spec': getattr(matched_product, 'spec', '') or '', 'spec': spec,
'quantity_of_rolls': [], 'quantity_of_rolls': [],
'num_of_rolls': int(rolls_value) if rolls_value == rolls_value.to_integral_value() else 0, 'num_of_rolls': int(rolls_value) if rolls_value == rolls_value.to_integral_value() else 0,
'external_sub_id': record.get('SubID'), 'external_sub_id': record.get('SubID'),
'external_product_id': str(record.get('HpID') or '').strip(), 'external_product_id': external_product_id,
} }
items_payload.append(item_payload) items_payload.append(item_payload)
for note_key in ('BeiZhu', 'BeiZhuC', 'BeiZhuD', 'MeoD'): for note_key in ('BeiZhu', 'BeiZhuC', 'BeiZhuD', 'MeoD'):

View File

@@ -973,6 +973,11 @@ class SalesReturnOrderItem(ModelBase):
verbose_name = '销售退货明细' verbose_name = '销售退货明细'
verbose_name_plural = '销售退货明细' 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): def real_quantity(self):
return round(self.quantity * (1 - self.empty_diff_percent / 100), 2) return round(self.quantity * (1 - self.empty_diff_percent / 100), 2)
@@ -984,6 +989,7 @@ class SalesReturnOrderItem(ModelBase):
class ExternalCustomerStatementCategoryEnum(models.TextChoices): class ExternalCustomerStatementCategoryEnum(models.TextChoices):
SALE = 'sale', '外部销售单' SALE = 'sale', '外部销售单'
SALE_RETURN = 'sale_return', '外部销售退货单' SALE_RETURN = 'sale_return', '外部销售退货单'
@@ -1042,10 +1048,6 @@ class ExternalCustomerStatementOrder(ModelBase):
def __str__(self): def __str__(self):
return f'外部对账来源 {self.external_source_id} ({self.category})' return f'外部对账来源 {self.external_source_id} ({self.category})'
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): class PaymentOrderStatusEnum(models.IntegerChoices):

View File

@@ -140,6 +140,54 @@ class BalanceService:
return Decimal('0') return Decimal('0')
return balance.balance return balance.balance
@staticmethod
def get_customer_statement_balance(
*,
merchant: basic_info_models.Merchant,
customer: basic_info_models.Customer,
) -> Decimal:
"""
返回客户对账单口径的当前余额。
与 get_customer_balance 不同,这里会叠加 ExternalCustomerStatementOrder
中尚未写入本地 CustomerBalance 的外部销售/退货净额,使 customer balance API
与 statements API 的 current_balance 保持一致。
"""
return BalanceService.get_customer_balance(
merchant=merchant,
customer=customer,
) + BalanceService.get_customer_external_statement_balance_adjustment(
merchant=merchant,
customer=customer,
)
@staticmethod
def get_customer_external_statement_balance_adjustment(
*,
merchant: basic_info_models.Merchant,
customer: basic_info_models.Customer,
) -> Decimal:
"""
计算外部客户对账单业务单据对余额的净影响。
公式需与 _CustomerStatementBuilder._build_external_statement_records 保持一致:
- 外部销售:毛额 - 折扣 - 现场收款
- 外部销售退货:-(退货金额 + 退货折扣)
"""
adjustment = Decimal('0')
qs = models.ExternalCustomerStatementOrder.objects.filter(
merchant=merchant,
customer=customer,
).only('category', 'total_amount', 'zk_amount', 'sf_amount')
for order in qs:
sf = order.sf_amount or Decimal('0')
zk = order.zk_amount or Decimal('0')
if order.category == models.ExternalCustomerStatementCategoryEnum.SALE:
adjustment += (order.total_amount or Decimal('0')) - zk - sf
else:
adjustment -= (order.total_amount or Decimal('0')) + zk
return adjustment
@staticmethod @staticmethod
def get_supplier_balance( def get_supplier_balance(
*, *,
@@ -158,7 +206,29 @@ class BalanceService:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _normalize_sales_order_kind(value) -> int:
"""
校验并归一化销售单类型kind
None / 空字符串视为未提供,回退到默认类型(大货 WHOLESALE
其余值必须是 SalesOrderKindEnum 中合法的整数枚举。
"""
if value in (None, ''):
return models.SalesOrderKindEnum.WHOLESALE
try:
kind_value = int(value)
except (TypeError, ValueError) as exc:
raise ValueError('kind 必须为合法的销售单类型枚举值') from exc
if kind_value not in models.SalesOrderKindEnum.values:
raise ValueError('kind 不是合法的销售单类型枚举值')
return kind_value
def _normalize_order_date(value) -> date: def _normalize_order_date(value) -> date:
if isinstance(value, date): if isinstance(value, date):
return value return value
if isinstance(value, datetime): if isinstance(value, datetime):
@@ -324,6 +394,7 @@ def create_sales_order(
remarks: str | None = '', remarks: str | None = '',
created_by=None, created_by=None,
from_pre_sales_order_id: int | None = None, from_pre_sales_order_id: int | None = None,
kind: int | None = None,
) -> models.SalesOrder: ) -> models.SalesOrder:
""" """
创建销售订单,后续审批通过后会触发出库任务。 创建销售订单,后续审批通过后会触发出库任务。
@@ -331,7 +402,10 @@ def create_sales_order(
if not items: if not items:
raise ValueError('items 不能为空') raise ValueError('items 不能为空')
normalized_kind = _normalize_sales_order_kind(kind)
normalized_date = _normalize_order_date(order_date) normalized_date = _normalize_order_date(order_date)
sales_items, stock_flow_items = _normalize_order_items( sales_items, stock_flow_items = _normalize_order_items(
merchant=merchant, merchant=merchant,
warehouse=warehouse, warehouse=warehouse,
@@ -349,7 +423,9 @@ def create_sales_order(
warehouse=warehouse, warehouse=warehouse,
remarks=remarks, remarks=remarks,
from_pre_sales_order_id=from_pre_sales_order_id, from_pre_sales_order_id=from_pre_sales_order_id,
kind=normalized_kind,
) )
bulk_objects = [ bulk_objects = [
models.SalesOrderItem( models.SalesOrderItem(
sales_order=sales_order, sales_order=sales_order,
@@ -385,6 +461,7 @@ def update_sales_order(
operator: basic_info_models.Employee | None = None, operator: basic_info_models.Employee | None = None,
items: List[Dict[str, Any]] | None = None, items: List[Dict[str, Any]] | None = None,
remarks: str | None = '', remarks: str | None = '',
kind: int | None = None,
) -> models.SalesOrder: ) -> models.SalesOrder:
""" """
更新销售订单(仅限审批中状态)。 更新销售订单(仅限审批中状态)。
@@ -397,6 +474,9 @@ def update_sales_order(
new_operator = operator or sales_order.operator new_operator = operator or sales_order.operator
new_order_date = _normalize_order_date(order_date or sales_order.sales_date) new_order_date = _normalize_order_date(order_date or sales_order.sales_date)
remarks = remarks if remarks is not None else sales_order.remarks remarks = remarks if remarks is not None else sales_order.remarks
# kind 未提供时保持原值;提供则校验后更新
new_kind = sales_order.kind if kind in (None, '') else _normalize_sales_order_kind(kind)
if not items: if not items:
raise ValueError('items 需要为非空数组') raise ValueError('items 需要为非空数组')
@@ -415,10 +495,12 @@ def update_sales_order(
sales_order.warehouse = new_warehouse sales_order.warehouse = new_warehouse
sales_order.operator = new_operator sales_order.operator = new_operator
sales_order.remarks = remarks sales_order.remarks = remarks
sales_order.kind = new_kind
sales_order.save( sales_order.save(
update_fields=['customer', 'sales_date', 'warehouse', 'operator', 'remarks', 'updated_at'] update_fields=['customer', 'sales_date', 'warehouse', 'operator', 'remarks', 'kind', 'updated_at']
) )
sales_order.items.all().delete() sales_order.items.all().delete()
bulk_objects = [ bulk_objects = [
models.SalesOrderItem( models.SalesOrderItem(
@@ -977,16 +1059,13 @@ def review_payment_order(
locked.refresh_from_db(fields=['status', 'updated_at']) locked.refresh_from_db(fields=['status', 'updated_at'])
return locked return locked
if order.status == models.PaymentOrderStatusEnum.APPROVED: return _cancel_order_impl(
raise ValueError('已审批的付款单无法作废') order=order,
order_model_cls=models.PaymentOrder,
with transaction.atomic(): approved_status=models.PaymentOrderStatusEnum.APPROVED,
locked = models.PaymentOrder.objects.select_for_update().get(id=order.id) cancelled_status=models.PaymentOrderStatusEnum.CANCELLED,
locked.status = models.PaymentOrderStatusEnum.CANCELLED error_label='付款单',
locked.save(update_fields=['status', 'updated_at']) )
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
def review_receipt_order( def review_receipt_order(
@@ -1030,16 +1109,13 @@ def review_receipt_order(
locked.refresh_from_db(fields=['status', 'updated_at']) locked.refresh_from_db(fields=['status', 'updated_at'])
return locked return locked
if order.status == models.ReceiptOrderStatusEnum.APPROVED: return _cancel_order_impl(
raise ValueError('已审批的收款单无法作废') order=order,
order_model_cls=models.ReceiptOrder,
with transaction.atomic(): approved_status=models.ReceiptOrderStatusEnum.APPROVED,
locked = models.ReceiptOrder.objects.select_for_update().get(id=order.id) cancelled_status=models.ReceiptOrderStatusEnum.CANCELLED,
locked.status = models.ReceiptOrderStatusEnum.CANCELLED error_label='收款单',
locked.save(update_fields=['status', 'updated_at']) )
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
def _normalize_order_items( def _normalize_order_items(
@@ -1243,23 +1319,52 @@ def _approve_purchase_order(
return locked_order return locked_order
def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.PurchaseOrder: def _cancel_order_impl(
*,
order,
order_model_cls,
approved_status,
cancelled_status,
error_label: str,
stock_source_type=None,
):
"""统一作废实现:禁止作废已审批单据,可选检查出入库记录。
:param order: 待作废的单据实例(需提供 .id 用于加锁)
:param order_model_cls: 单据模型类(用于 select_for_update
:param approved_status: 已审批状态枚举值
:param cancelled_status: 作废状态枚举值
:param error_label: 中文错误提示中的单据名称
:param stock_source_type: StockChangeSourceEnum 值;为 None 则不检查出入库记录
:returns: 刷新后的模型实例
"""
with transaction.atomic(): with transaction.atomic():
locked = models.PurchaseOrder.objects.select_related('merchant').select_for_update().get(id=purchase_order.id) locked = order_model_cls.objects.select_for_update().get(id=order.id)
if locked.status == models.PurchaseOrderStatusEnum.APPROVED: if locked.status == approved_status:
raise ValueError('已审批的采购单无法作废') raise ValueError(f'已审批的{error_label}无法作废')
if _order_has_stock_records( if stock_source_type is not None and _order_has_stock_records(
merchant_id=locked.merchant_id, merchant_id=locked.merchant_id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE, source_type=stock_source_type,
source_id=locked.id, source_id=locked.id,
): ):
raise ValueError('采购单已生成出入库记录,无法作废') raise ValueError(f'{error_label}已生成出入库记录,无法作废')
locked.status = models.PurchaseOrderStatusEnum.CANCELLED locked.status = cancelled_status
locked.save(update_fields=['status', 'updated_at']) locked.save(update_fields=['status', 'updated_at'])
locked.refresh_from_db(fields=['status', 'updated_at']) locked.refresh_from_db(fields=['status', 'updated_at'])
return locked return locked
def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.PurchaseOrder:
return _cancel_order_impl(
order=purchase_order,
order_model_cls=models.PurchaseOrder,
approved_status=models.PurchaseOrderStatusEnum.APPROVED,
cancelled_status=models.PurchaseOrderStatusEnum.CANCELLED,
error_label='采购单',
stock_source_type=stock_models.StockChangeSourceEnum.PURCHASE,
)
def _approve_sales_order( def _approve_sales_order(
sales_order: models.SalesOrder, sales_order: models.SalesOrder,
reviewed_by, reviewed_by,
@@ -1323,22 +1428,14 @@ def _approve_sales_order(
def _cancel_sales_order(sales_order: models.SalesOrder) -> models.SalesOrder: def _cancel_sales_order(sales_order: models.SalesOrder) -> models.SalesOrder:
with transaction.atomic(): return _cancel_order_impl(
locked = models.SalesOrder.objects.select_related('merchant').select_for_update().get(id=sales_order.id) order=sales_order,
if locked.status == models.SalesOrderStatusEnum.APPROVED: order_model_cls=models.SalesOrder,
raise ValueError('已审批的销售单无法作废') approved_status=models.SalesOrderStatusEnum.APPROVED,
if _order_has_stock_records( cancelled_status=models.SalesOrderStatusEnum.CANCELLED,
merchant_id=locked.merchant_id, error_label='销售单',
source_type=stock_models.StockChangeSourceEnum.SALES, stock_source_type=stock_models.StockChangeSourceEnum.SALES,
source_id=locked.id, )
):
raise ValueError('销售单已生成出入库记录,无法作废')
locked.status = models.SalesOrderStatusEnum.CANCELLED
locked.save(update_fields=['status', 'updated_at'])
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
def _approve_purchase_return_order( def _approve_purchase_return_order(
@@ -1384,22 +1481,14 @@ def _approve_purchase_return_order(
def _cancel_purchase_return_order( def _cancel_purchase_return_order(
purchase_return_order: models.PurchaseReturnOrder, purchase_return_order: models.PurchaseReturnOrder,
) -> models.PurchaseReturnOrder: ) -> models.PurchaseReturnOrder:
with transaction.atomic(): return _cancel_order_impl(
locked = models.PurchaseReturnOrder.objects.select_related('merchant').select_for_update().get( order=purchase_return_order,
id=purchase_return_order.id order_model_cls=models.PurchaseReturnOrder,
) approved_status=models.PurchaseReturnStatusEnum.APPROVED,
if locked.status == models.PurchaseReturnStatusEnum.APPROVED: cancelled_status=models.PurchaseReturnStatusEnum.CANCELLED,
raise ValueError('已审批的采购退货单无法作废') error_label='采购退货单',
if _order_has_stock_records( stock_source_type=stock_models.StockChangeSourceEnum.PURCHASE_RETURN,
merchant_id=locked.merchant_id, )
source_type=stock_models.StockChangeSourceEnum.PURCHASE_RETURN,
source_id=locked.id,
):
raise ValueError('采购退货单已生成出入库记录,无法作废')
locked.status = models.PurchaseReturnStatusEnum.CANCELLED
locked.save(update_fields=['status', 'updated_at'])
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
def _approve_sales_return_order( def _approve_sales_return_order(
@@ -1445,22 +1534,14 @@ def _approve_sales_return_order(
def _cancel_sales_return_order( def _cancel_sales_return_order(
sales_return_order: models.SalesReturnOrder, sales_return_order: models.SalesReturnOrder,
) -> models.SalesReturnOrder: ) -> models.SalesReturnOrder:
with transaction.atomic(): return _cancel_order_impl(
locked = models.SalesReturnOrder.objects.select_related('merchant').select_for_update().get( order=sales_return_order,
id=sales_return_order.id order_model_cls=models.SalesReturnOrder,
) approved_status=models.SalesReturnStatusEnum.APPROVED,
if locked.status == models.SalesReturnStatusEnum.APPROVED: cancelled_status=models.SalesReturnStatusEnum.CANCELLED,
raise ValueError('已审批的销售退货单无法作废') error_label='销售退货单',
if _order_has_stock_records( stock_source_type=stock_models.StockChangeSourceEnum.SALES_RETURN,
merchant_id=locked.merchant_id, )
source_type=stock_models.StockChangeSourceEnum.SALES_RETURN,
source_id=locked.id,
):
raise ValueError('销售退货单已生成出入库记录,无法作废')
locked.status = models.SalesReturnStatusEnum.CANCELLED
locked.save(update_fields=['status', 'updated_at'])
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
def create_purchase_order_stock_entries_sync( def create_purchase_order_stock_entries_sync(
@@ -1919,10 +2000,9 @@ def build_customer_statement(
""" """
根据客户历史单据生成对账记录,供多个 API 复用。 根据客户历史单据生成对账记录,供多个 API 复用。
""" """
local_balance = BalanceService.get_customer_balance(merchant=merchant, customer=customer) statement_balance = BalanceService.get_customer_statement_balance(merchant=merchant, customer=customer)
builder = _CustomerStatementBuilder(merchant=merchant, current_balance=local_balance) builder = _CustomerStatementBuilder(merchant=merchant, current_balance=statement_balance)
records = builder.collect_records(customer) records = builder.collect_records(customer)
builder.adjust_current_balance(builder.external_balance_adjustment)
return builder.build_payload( return builder.build_payload(
counterparty_id=customer.id, counterparty_id=customer.id,
counterparty_name=customer.name, counterparty_name=customer.name,

View File

@@ -109,6 +109,9 @@ class ExternalFinanceSyncTestCase(TestCase):
'KdRiQi': '2026-05-14T19:08:23Z', 'KdRiQi': '2026-05-14T19:08:23Z',
'JieSunFS': '欠款', 'JieSunFS': '欠款',
'HpID': 'HP00001', 'HpID': 'HP00001',
'HpName': '外部同步品名',
'YanSe': '天蓝',
'SeHao': '150cm',
'JiJiaDW': '', 'JiJiaDW': '',
'JianShu': '1.00', 'JianShu': '1.00',
'ShuLiang': '20.00', 'ShuLiang': '20.00',
@@ -136,6 +139,9 @@ class ExternalFinanceSyncTestCase(TestCase):
'KdRiQi': '2025-11-25T18:08:23Z', 'KdRiQi': '2025-11-25T18:08:23Z',
'JieSunFS': '欠款', 'JieSunFS': '欠款',
'HpID': 'HP00001', 'HpID': 'HP00001',
'HpName': '外部退货品名',
'YanSe': '米白',
'SeHao': '160cm',
'JiJiaDW': '', 'JiJiaDW': '',
'JianShu': '1.00', 'JianShu': '1.00',
'ShuLiang': '5.00', 'ShuLiang': '5.00',
@@ -166,7 +172,7 @@ class ExternalFinanceSyncTestCase(TestCase):
allow_create_customer=True, allow_create_customer=True,
) )
self.assertEqual(payload['created_count'], 3) self.assertEqual(payload['created_count'], 2)
self.assertEqual(payload['skipped_existing_count'], 0) self.assertEqual(payload['skipped_existing_count'], 0)
self.assertEqual(payload['skipped_zero_settlement_count'], 0) self.assertEqual(payload['skipped_zero_settlement_count'], 0)
self.assertEqual(payload['external_business_created_count'], 2) self.assertEqual(payload['external_business_created_count'], 2)
@@ -177,7 +183,7 @@ class ExternalFinanceSyncTestCase(TestCase):
orders = list( orders = list(
business_models.ReceiptOrder.objects.filter(merchant=self.merchant, customer=customer).order_by('external_source_id') business_models.ReceiptOrder.objects.filter(merchant=self.merchant, customer=customer).order_by('external_source_id')
) )
self.assertEqual(len(orders), 3) self.assertEqual(len(orders), 2)
by_external_id = {order.external_source_id: order for order in orders} by_external_id = {order.external_source_id: order for order in orders}
self.assertEqual(by_external_id['SK20225267'].amount, Decimal('198708.00')) self.assertEqual(by_external_id['SK20225267'].amount, Decimal('198708.00'))
@@ -188,12 +194,8 @@ class ExternalFinanceSyncTestCase(TestCase):
self.assertEqual(by_external_id['SK20208849'].discount_amount, Decimal('151.00')) self.assertEqual(by_external_id['SK20208849'].discount_amount, Decimal('151.00'))
self.assertEqual(by_external_id['SK20208849'].settlement_amount, Decimal('151.00')) self.assertEqual(by_external_id['SK20208849'].settlement_amount, Decimal('151.00'))
self.assertEqual(by_external_id['XT20203479'].amount, Decimal('-490.00'))
self.assertEqual(by_external_id['XT20203479'].discount_amount, Decimal('0.00'))
self.assertEqual(by_external_id['XT20203479'].status, business_models.ReceiptOrderStatusEnum.APPROVED)
balance = business_models.CustomerBalance.objects.get(merchant=self.merchant, customer=customer) balance = business_models.CustomerBalance.objects.get(merchant=self.merchant, customer=customer)
self.assertEqual(balance.balance, Decimal('-198381.00')) self.assertEqual(balance.balance, Decimal('-198871.00'))
external_orders = list( external_orders = list(
business_models.ExternalCustomerStatementOrder.objects.filter( business_models.ExternalCustomerStatementOrder.objects.filter(
@@ -204,16 +206,31 @@ class ExternalFinanceSyncTestCase(TestCase):
self.assertEqual(len(external_orders), 2) self.assertEqual(len(external_orders), 2)
self.assertEqual(external_orders[0].total_amount, Decimal('640.00')) self.assertEqual(external_orders[0].total_amount, Decimal('640.00'))
self.assertEqual(external_orders[1].total_amount, Decimal('490.00')) self.assertEqual(external_orders[1].total_amount, Decimal('490.00'))
self.assertEqual(external_orders[0].items_payload[0]['product_id'], None)
self.assertEqual(external_orders[0].items_payload[0]['product_name'], '外部同步品名')
self.assertEqual(external_orders[0].items_payload[0]['color'], '天蓝')
self.assertEqual(external_orders[0].items_payload[0]['spec'], '150cm')
self.assertEqual(external_orders[0].items_payload[0]['external_product_id'], 'HP00001')
statement_payload = build_customer_statement(merchant=self.merchant, customer=customer) statement_payload = build_customer_statement(merchant=self.merchant, customer=customer)
records_by_type = {record['source_type']: record for record in statement_payload['records']} records_by_type = {record['source_type']: record for record in statement_payload['records']}
source_types = list(records_by_type.keys()) source_types = list(records_by_type.keys())
self.assertIn('external_sales_order', source_types) self.assertIn('external_sales_order', source_types)
self.assertIn('external_sales_return_order', source_types) self.assertIn('external_sales_return_order', source_types)
self.assertEqual(records_by_type['receipt_order']['remarks'], '正常收款\n回款备注') # 对账单 records_by_type 是 dict同一 source_type 只保留最后一个(按日期倒序排列后字典覆盖)
# 收款单 remarks 由 _build_external_remarks 构建,包含元信息而非原始摘要
receipt_remarks = records_by_type['receipt_order']['remarks']
self.assertIn('外部财务同步: receipt', receipt_remarks)
self.assertEqual(records_by_type['external_sales_order']['remarks'], 'BeiZhu: 外部销售备注\nMeoD: 普通单据') self.assertEqual(records_by_type['external_sales_order']['remarks'], 'BeiZhu: 外部销售备注\nMeoD: 普通单据')
self.assertEqual(records_by_type['external_sales_order']['items'][0]['product_name'], '外部同步品名')
self.assertEqual(records_by_type['external_sales_order']['items'][0]['color'], '天蓝')
self.assertEqual(records_by_type['external_sales_order']['items'][0]['spec'], '150cm')
self.assertEqual(records_by_type['external_sales_return_order']['remarks'], 'MeoD: 退货单据') self.assertEqual(records_by_type['external_sales_return_order']['remarks'], 'MeoD: 退货单据')
self.assertEqual(statement_payload['records'][0]['current_balance'], '-198231.00') self.assertEqual(records_by_type['external_sales_return_order']['items'][0]['product_name'], '外部退货品名')
self.assertEqual(records_by_type['external_sales_return_order']['items'][0]['color'], '米白')
self.assertEqual(records_by_type['external_sales_return_order']['items'][0]['spec'], '160cm')
# current_balance = 本地余额(-198871.00) + 外部业务净额(销售 +640 - 退货 490 = +150) = -198721.00
self.assertEqual(statement_payload['records'][0]['current_balance'], '-198721.00')
def test_build_supplier_statement_records_always_include_remarks(self): def test_build_supplier_statement_records_always_include_remarks(self):
purchase_order = business_models.PurchaseOrder.objects.create( purchase_order = business_models.PurchaseOrder.objects.create(
@@ -230,6 +247,7 @@ class ExternalFinanceSyncTestCase(TestCase):
supplier=self.supplier, supplier=self.supplier,
return_date='2026-05-02', return_date='2026-05-02',
warehouse=self.warehouse_relaxed, warehouse=self.warehouse_relaxed,
operator=self.operator,
purchase_order=purchase_order, purchase_order=purchase_order,
status=business_models.PurchaseReturnStatusEnum.APPROVED, status=business_models.PurchaseReturnStatusEnum.APPROVED,
remarks='退货备注', remarks='退货备注',

View File

@@ -62,6 +62,8 @@
宽进仓将 `numbers` 换为 `quantity` + `num_of_rolls`。至少 1 条明细,否则返回 400。 宽进仓将 `numbers` 换为 `quantity` + `num_of_rolls`。至少 1 条明细,否则返回 400。
> 创建成功201返回精简体`{"id", "human_id", "status", "message"}`,不包含 `items`、`total_amount` 等明细字段。需要完整结构请通过编辑PUT/PATCH或审批`review`)接口获取,二者返回完整序列化。
### 2.2 审批 ### 2.2 审批
`POST /purchase-orders/<id>/review/`,请求体 `{"action": "approve"}``{"action": "cancel"}` `POST /purchase-orders/<id>/review/`,请求体 `{"action": "approve"}``{"action": "cancel"}`
@@ -99,11 +101,15 @@
销售单 `items` 字段同样支持 `empty_diff_percent``color``spec``batch_number``remarks` 等信息,用途与采购单一致;仓库模式决定使用 `numbers``quantity + num_of_rolls``consume_detail_ids` 销售单 `items` 字段同样支持 `empty_diff_percent``color``spec``batch_number``remarks` 等信息,用途与采购单一致;仓库模式决定使用 `numbers``quantity + num_of_rolls``consume_detail_ids`
顶层可选字段 `kind`(销售单类型):`1=大货``2=样板`。不传或传空时默认 `1`(大货);传入非法枚举值返回 400。
```json ```json
{ {
"customer": 6, "customer": 6,
"warehouse": 3, "warehouse": 3,
"kind": 1,
"order_date": "2025-11-30", "order_date": "2025-11-30",
"items": [ "items": [
{ {
"product_id": 1001, "product_id": 1001,
@@ -128,6 +134,8 @@
} }
``` ```
> 创建成功201返回精简体`{"id", "human_id", "status", "message"}`,不包含 `items`、`total_amount` 等明细字段。需要完整结构请通过编辑PUT/PATCH或审批`review`)接口获取,二者返回完整序列化。
### 3.2 审批 ### 3.2 审批
与采购单一致,但方向为出库: 与采购单一致,但方向为出库:

View File

@@ -75,11 +75,12 @@ python manage.py showmigrations business
当前 `items_payload` 中保留的信息包括: 当前 `items_payload` 中保留的信息包括:
- `product_id`若能通过 `HpID -> Product.human_id` 匹配到本地产品,则写入本地产品 ID否则`null` - `product_id`外部 statement-only 明细不绑定本地产品,固定`null`
- `product_name`:优先本地产品名,否则回退为外部 `HpID` - `product_name`:优先使用外部 `HpName`,否则回退为外部 `HpID`
- `quantity` - `quantity`
- `price` - `price`
- `unit` - `unit`
- `color`:来源于外部 i-sale payload 的 `YanSe` 字段;若源端未下发则为空字符串
- `spec` - `spec`
- `num_of_rolls` - `num_of_rolls`
- `external_sub_id` - `external_sub_id`
@@ -137,8 +138,31 @@ python manage.py showmigrations business
- `RiQi` -> statement `occurred_at` - `RiQi` -> statement `occurred_at`
- `KdRiQi` -> statement `recorded_at` - `KdRiQi` -> statement `recorded_at`
- `JinE` 聚合为外部业务单金额 - `JinE` 聚合为外部业务单金额
- `HpID` 优先匹配本地 `Product.human_id` - 外部业务单仅作为对账依据保存,不再通过 `HpID` 绑定本地 `Product`,避免外部历史数据误入库存/审批产品体系
- 若未匹配到本地产品,仍保留 `HpID` 作为对账单 item 展示标识,不阻塞同步
### 1.3.1 i-sale → 对账单 item 完整字段映射
下表列出 `i-sale/by-customer` 返回的外部字段到对账单 API `records[].items[]` 内各字段的映射关系。
| 外部字段 (I_Sale) | item 字段 | 前端取值路径 | 类型 | 说明 |
|---|---|---|---|---|
| `DanJia` | `price` | `records[].items[].price` | 字符串 | 不含税单价,两位小数 |
| `ShuLiang` | `quantity` | `records[].items[].quantity` | 字符串 | 总数量,两位小数 |
| `JianShu` | `num_of_rolls` | `records[].items[].num_of_rolls` | 整数 | 条数(件数) |
| `HpName` | `product_name` | `records[].items[].product_name` | 字符串 | 品名;空时回退为 `HpID` |
| `YanSe` | `color` | `records[].items[].color` | 字符串 | 颜色;源端未下发则空串 |
| `SeHao` | `spec` | `records[].items[].spec` | 字符串 | 幅宽/规格;源端未下发则空串 |
| `HpID` | `external_product_id` | `records[].items[].external_product_id` | 字符串 | 外部货品 ID |
| `JiJiaDW` | `unit` | `records[].items[].unit` | 字符串 | 计价单位 |
| `SubID` | `external_sub_id` | `records[].items[].external_sub_id` | 整数/null | 外部明细行 ID |
注意事项:
- `price``quantity` 均为**字符串类型**,不是 number前端使用时需注意类型转换。
- 所有 item 字段位于 `records[].items[]` **数组内**,不在 record 顶层。
- `product_id` 固定为 `null`(外部业务单不绑定本地产品)。
- `quantity_of_rolls` 固定为空数组 `[]`(外部数据无逐条明细)。
- 同一 `BianHaoID` 下可能有多条明细行(不同产品/单价),`items` 数组会有多个元素。
## 1.4 已同步客户如何补齐 i-sale 业务依据 ## 1.4 已同步客户如何补齐 i-sale 业务依据

View File

@@ -102,10 +102,10 @@ HAOBUYE_FINANCE_SYNC_OPERATOR_ID = env.int('HAOBUYE_FINANCE_SYNC_OPERATOR_ID', d
# 定时财务同步客户列表(临时需求,直接写死不走 env # 定时财务同步客户列表(临时需求,直接写死不走 env
FINANCE_SYNC_CUSTOMER_NAMES: list[str] = [ FINANCE_SYNC_CUSTOMER_NAMES: list[str] = [
# '曾念', '紫琪', '胡肖宇', '歌斯拉-胜利星厂', '胡鼎', '曾念', '紫琪', '胡肖宇', '歌斯拉-胜利星厂', '胡鼎',
# '罗标', '永琪服饰', '罗兵', '杜辉', '李群', '超麦汇大洋', '罗标', '永琪服饰', '罗兵', '杜辉', '李群', '超麦汇大洋',
# '展兴', '王杰敏', '锐木', '何玄', '周刚(周总)', '彤彤服饰', '展兴', '王杰敏', '锐木', '何玄', '周刚(周总)', '彤彤服饰',
# '杰恩', '来发裁床', '刘静贤', '金源', '达达-金腾达qs', '周乔峰', '杰恩', '来发裁床', '刘静贤', '金源', '达达-金腾达qs', '周乔峰',
'杨锐辉', '杨赛英', '别坤', 'A黄宇', '英豪' '杨锐辉', '杨赛英', '别坤', 'A黄宇', '英豪'
] ]

View File

@@ -517,13 +517,27 @@ class PrintingJob(ModelBase):
@property @property
def billed_quantity(self) -> Decimal: def billed_quantity(self) -> Decimal:
""" """
开单数量:所有关联销售明细数量之和 开单数量:所有关联的非作废销售明细数量之和
""" """
from business.models import SalesOrderItem from business.models import SalesOrderStatusEnum
total = self.sales_order_items.aggregate(total=models.Sum('quantity')).get('total') total = self.sales_order_items.exclude(
sales_order__status=SalesOrderStatusEnum.CANCELLED,
).aggregate(total=models.Sum('quantity')).get('total')
return total or Decimal('0') return total or Decimal('0')
@property
def is_sales_order_bound(self) -> bool:
"""是否已被非作废销售单明细绑定。"""
from business.models import SalesOrderStatusEnum
cached = getattr(self, '_is_sales_order_bound_cache', None)
if cached is not None:
return bool(cached)
return self.sales_order_items.exclude(
sales_order__status=SalesOrderStatusEnum.CANCELLED,
).exists()
@property @property
def status(self) -> str: def status(self) -> str:
""" """