forked from erp-dev/erp
fix: business cancel service merged
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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/`
|
||||||
- **描述**: 创建一个新的印染任务。
|
- **描述**: 创建一个新的印染任务。
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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、如何影响下游模块,保持设计透明。
|
||||||
|
|||||||
@@ -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'):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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='退货备注',
|
||||||
|
|||||||
@@ -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 审批
|
||||||
|
|
||||||
与采购单一致,但方向为出库:
|
与采购单一致,但方向为出库:
|
||||||
|
|||||||
@@ -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 业务依据
|
||||||
|
|
||||||
|
|||||||
@@ -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黄宇', '英豪'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user