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/')
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):
def setUp(self):

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,21 @@ from .services import PrintingOrderService, PrintingJobService
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):
return build_public_media_url(url, request=request)
@@ -346,6 +361,8 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
business_object_id = serializers.SerializerMethodField()
batch_advance_records = 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)
class Meta:
@@ -353,10 +370,11 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
fields = [
'id', 'original_id', 'merchant_id', 'printing_order', 'printing_order_id', 'external_order_id', 'product', 'product_name',
'product_image_url', 'has_started',
'quantity', 'unit', 'size', 'pieces', 'description',
'quantity', 'billed_quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state_display',
'status', 'is_completed', 'is_production_completed', 'progress_percentage', 'last_completed_state',
'business_object_id',
'is_sales_order_bound',
'batch_advance_records',
'saleitems',
'created_at', 'updated_at'
@@ -364,8 +382,14 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
read_only_fields = [
'id', 'created_at', 'updated_at',
'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):
"""安全地获取 business_object_id"""
@@ -422,6 +446,9 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
)
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):
"""印染款式明细详情序列化器"""
@@ -440,17 +467,20 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
business_object_id = serializers.SerializerMethodField()
batch_advance_records = 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)
class Meta:
model = models.PrintingJob
fields = [
'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',
'status', 'status_id', 'is_completed', 'is_production_completed', 'has_started',
'progress_percentage', 'last_completed_state',
'business_object_id',
'is_sales_order_bound',
'batch_advance_records',
'saleitems',
'created_at', 'updated_at'
@@ -459,8 +489,14 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
'id', 'created_at', 'updated_at',
'status', 'status_id', 'is_completed', 'is_production_completed', 'has_started',
'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):
"""安全地获取 business_object_id"""
@@ -485,19 +521,23 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
)
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):
"""印染款式明细创建/更新序列化器"""
external_order_id = serializers.CharField(source='printing_order.external_order_id', read_only=True)
batch_advance_records = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.PrintingJob
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',
'batch_advance_records',
]
read_only_fields = ['id']
read_only_fields = ['id', 'external_order_id']
extra_kwargs = {
'size': {'required': False, 'allow_null': True, 'allow_blank': True},
'pieces': {'required': False, 'allow_null': True},

View File

@@ -1,6 +1,8 @@
"""
PrintingJob API 测试
"""
from decimal import Decimal
from django.test import TestCase
from django.conf import settings
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.contenttypes.models import ContentType
from basic_info import models as basic_models
from business import models as business_models
from printing import models as printing_models
from shipment import models as shipment_models
from stateflow import models as stateflow_models
@@ -101,6 +104,9 @@ class PrintingJobAPITestCase(TestCase):
def test_create_printing_job(self):
"""测试创建印染款式明细"""
self.printing_order.external_order_id = 'KD20410611'
self.printing_order.save(update_fields=['external_order_id'])
data = {
'printing_order': self.printing_order.id,
'product': self.product.id,
@@ -113,6 +119,7 @@ class PrintingJobAPITestCase(TestCase):
response = self.client.post('/api/v1/printing-jobs/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['external_order_id'], 'KD20410611')
# 新增字段:批量推进记录(稳定输出 key默认空数组
self.assertIn('batch_advance_records', response.data)
@@ -176,6 +183,128 @@ class PrintingJobAPITestCase(TestCase):
self.assertIsInstance(item['batch_advance_records'], list)
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):
"""测试显式标记完成生产接口"""
job = printing_models.PrintingJob.objects.create(
@@ -283,6 +412,81 @@ class PrintingJobAPITestCase(TestCase):
self.assertIsInstance(response.data['batch_advance_records'], list)
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):
"""测试 PrintingJob 序列化输出包含批量推进记录(有记录时返回明细,无记录返回空)"""
job = printing_models.PrintingJob.objects.create(
@@ -417,6 +621,9 @@ class PrintingJobAPITestCase(TestCase):
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(
printing_order=self.printing_order,
product=self.product,
@@ -442,6 +649,7 @@ class PrintingJobAPITestCase(TestCase):
format='json'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['external_order_id'], 'KD20410611')
job.refresh_from_db()
self.assertEqual(job.quantity, 200)
@@ -502,6 +710,9 @@ class PrintingJobAPITestCase(TestCase):
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(
printing_order=self.printing_order,
product=self.product,
@@ -522,6 +733,7 @@ class PrintingJobAPITestCase(TestCase):
format='json'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['external_order_id'], 'KD20410611')
job.refresh_from_db()
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.parsers import MultiPartParser, FormParser, JSONParser
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.views.decorators.cache import cache_page
from django.views.decorators.http import condition
@@ -486,7 +486,16 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
"""优化查询"""
queryset = super().get_queryset()
if self.action in ["list", "retrieve"]:
from business.models import SalesOrderItem, SalesOrderStatusEnum
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
queryset = queryset.prefetch_related(
Prefetch(

View File

@@ -171,6 +171,7 @@ class PrintingJobByCustomerAPITest(TestCase):
customer=self.customer,
fabric='',
width='150cm',
external_order_id='KD20410611',
)
self.printing_order_other = printing_models.PrintingOrder.objects.create(
customer=self.customer,
@@ -251,6 +252,7 @@ class PrintingJobByCustomerAPITest(TestCase):
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.data), 1)
self.assertEqual(resp.data[0]['id'], self.job_in_range.id)
self.assertEqual(resp.data[0]['external_order_id'], 'KD20410611')
self.assertEqual(resp.data[0]['billed_quantity'], '20.00')
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):
"""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)
business_object_id = serializers.SerializerMethodField()
width = serializers.SerializerMethodField()
@@ -61,6 +62,7 @@ class PrintingJobV2Serializer(serializers.ModelSerializer):
'id',
'original_id',
'printing_order',
'external_order_id',
'product',
'work_state',
'quantity',
@@ -75,7 +77,7 @@ class PrintingJobV2Serializer(serializers.ModelSerializer):
'updated_at',
'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):
return obj.business_object_id

View File

@@ -52,13 +52,14 @@
## 3. 服务层约定
- `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`,不得直接操作库存模型,保持模块边界清晰。
- 红冲/对冲等高级动作应由业务模块提供入口(例如 `PurchaseOrder` 红冲),但最终仍调用库存服务完成实际库存变动。
## 4. 未来演进建议
1. **新增单据**:若未来出现调拨单、退货单或更多资金类单据,优先复用 `OrderDirectionMixin + OrderCounterpartyMixin`(如有明细再叠加 `OrderItemsAggregationMixin`),仅通过 `get_direction()` / `get_counterparty_field_name()` 区别方向与主体,减少重复实现。
2. **审批/状态机**采购与销售如需共享状态流转,可提炼状态机或 service 层 mixin而无需在模型层合并
2. **审批/状态机**作废逻辑已通过 `_cancel_order_impl()` 统一实现,新增单据的作废只需委托该函数并传入对应参数,无需复制逻辑
3. **统计与报表**:财务/库存统计应依赖 `get_signed_total_amount()` / `get_direction()`,确保采购/销售、退货/正向都能通过统一接口处理。
4. **余额审计**:任何会写入 Supplier/CustomerBalance 的流程必须通过 `BalanceService`,以便自动生成 `BalanceChangeRecord`。审批通过后禁止作废,若未来需要冲销,必须新建红冲记录并维护 `offset_to/offset_id` 链路。
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')))
total_amount += amount_value
zk_amount += zk_value
matched_product = _find_local_product_by_external_product_id(
merchant=customer.merchant,
external_product_id=str(record.get('HpID') or '').strip(),
)
product_name = getattr(matched_product, 'name', '') or str(record.get('HpID') or '').strip()
unit = str(record.get('JiJiaDW') or '').strip() or getattr(matched_product, 'get_unit_display', lambda: '')()
external_product_id = str(record.get('HpID') or '').strip()
product_name = str(record.get('HpName') or '').strip() or external_product_id
color = str(record.get('YanSe') or '').strip()
spec = str(record.get('SeHao') or '').strip()
unit = str(record.get('JiJiaDW') or '').strip()
item_payload = {
'product_id': getattr(matched_product, 'id', None),
'product_id': None,
'product_name': product_name,
'quantity': _decimal_to_string(quantity_value),
'price': _decimal_to_string(price_value),
'unit': unit,
'color': '',
'spec': getattr(matched_product, 'spec', '') or '',
'color': color,
'spec': spec,
'quantity_of_rolls': [],
'num_of_rolls': int(rolls_value) if rolls_value == rolls_value.to_integral_value() else 0,
'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)
for note_key in ('BeiZhu', 'BeiZhuC', 'BeiZhuD', 'MeoD'):

View File

@@ -973,6 +973,11 @@ class SalesReturnOrderItem(ModelBase):
verbose_name = '销售退货明细'
verbose_name_plural = '销售退货明细'
def split_quantity_of_rolls(self) -> List[int]:
if self.quantity_of_rolls:
return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()]
return []
def real_quantity(self):
return round(self.quantity * (1 - self.empty_diff_percent / 100), 2)
@@ -984,6 +989,7 @@ class SalesReturnOrderItem(ModelBase):
class ExternalCustomerStatementCategoryEnum(models.TextChoices):
SALE = 'sale', '外部销售单'
SALE_RETURN = 'sale_return', '外部销售退货单'
@@ -1041,11 +1047,7 @@ class ExternalCustomerStatementOrder(ModelBase):
def __str__(self):
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):

View File

@@ -140,6 +140,54 @@ class BalanceService:
return Decimal('0')
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
def get_supplier_balance(
*,
@@ -158,7 +206,29 @@ class BalanceService:
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:
if isinstance(value, date):
return value
if isinstance(value, datetime):
@@ -324,6 +394,7 @@ def create_sales_order(
remarks: str | None = '',
created_by=None,
from_pre_sales_order_id: int | None = None,
kind: int | None = None,
) -> models.SalesOrder:
"""
创建销售订单,后续审批通过后会触发出库任务。
@@ -331,7 +402,10 @@ def create_sales_order(
if not items:
raise ValueError('items 不能为空')
normalized_kind = _normalize_sales_order_kind(kind)
normalized_date = _normalize_order_date(order_date)
sales_items, stock_flow_items = _normalize_order_items(
merchant=merchant,
warehouse=warehouse,
@@ -349,7 +423,9 @@ def create_sales_order(
warehouse=warehouse,
remarks=remarks,
from_pre_sales_order_id=from_pre_sales_order_id,
kind=normalized_kind,
)
bulk_objects = [
models.SalesOrderItem(
sales_order=sales_order,
@@ -385,6 +461,7 @@ def update_sales_order(
operator: basic_info_models.Employee | None = None,
items: List[Dict[str, Any]] | None = None,
remarks: str | None = '',
kind: int | None = None,
) -> models.SalesOrder:
"""
更新销售订单(仅限审批中状态)。
@@ -397,6 +474,9 @@ def update_sales_order(
new_operator = operator or sales_order.operator
new_order_date = _normalize_order_date(order_date or sales_order.sales_date)
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:
raise ValueError('items 需要为非空数组')
@@ -415,10 +495,12 @@ def update_sales_order(
sales_order.warehouse = new_warehouse
sales_order.operator = new_operator
sales_order.remarks = remarks
sales_order.kind = new_kind
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()
bulk_objects = [
models.SalesOrderItem(
@@ -977,16 +1059,13 @@ def review_payment_order(
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
if order.status == models.PaymentOrderStatusEnum.APPROVED:
raise ValueError('已审批的付款单无法作废')
with transaction.atomic():
locked = models.PaymentOrder.objects.select_for_update().get(id=order.id)
locked.status = models.PaymentOrderStatusEnum.CANCELLED
locked.save(update_fields=['status', 'updated_at'])
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
return _cancel_order_impl(
order=order,
order_model_cls=models.PaymentOrder,
approved_status=models.PaymentOrderStatusEnum.APPROVED,
cancelled_status=models.PaymentOrderStatusEnum.CANCELLED,
error_label='付款单',
)
def review_receipt_order(
@@ -1030,16 +1109,13 @@ def review_receipt_order(
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
if order.status == models.ReceiptOrderStatusEnum.APPROVED:
raise ValueError('已审批的收款单无法作废')
with transaction.atomic():
locked = models.ReceiptOrder.objects.select_for_update().get(id=order.id)
locked.status = models.ReceiptOrderStatusEnum.CANCELLED
locked.save(update_fields=['status', 'updated_at'])
locked.refresh_from_db(fields=['status', 'updated_at'])
return locked
return _cancel_order_impl(
order=order,
order_model_cls=models.ReceiptOrder,
approved_status=models.ReceiptOrderStatusEnum.APPROVED,
cancelled_status=models.ReceiptOrderStatusEnum.CANCELLED,
error_label='收款单',
)
def _normalize_order_items(
@@ -1243,23 +1319,52 @@ def _approve_purchase_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():
locked = models.PurchaseOrder.objects.select_related('merchant').select_for_update().get(id=purchase_order.id)
if locked.status == models.PurchaseOrderStatusEnum.APPROVED:
raise ValueError('已审批的采购单无法作废')
if _order_has_stock_records(
locked = order_model_cls.objects.select_for_update().get(id=order.id)
if locked.status == approved_status:
raise ValueError(f'已审批的{error_label}无法作废')
if stock_source_type is not None and _order_has_stock_records(
merchant_id=locked.merchant_id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_type=stock_source_type,
source_id=locked.id,
):
raise ValueError('采购单已生成出入库记录,无法作废')
locked.status = models.PurchaseOrderStatusEnum.CANCELLED
raise ValueError(f'{error_label}已生成出入库记录,无法作废')
locked.status = cancelled_status
locked.save(update_fields=['status', 'updated_at'])
locked.refresh_from_db(fields=['status', 'updated_at'])
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(
sales_order: models.SalesOrder,
reviewed_by,
@@ -1323,22 +1428,14 @@ def _approve_sales_order(
def _cancel_sales_order(sales_order: models.SalesOrder) -> models.SalesOrder:
with transaction.atomic():
locked = models.SalesOrder.objects.select_related('merchant').select_for_update().get(id=sales_order.id)
if locked.status == models.SalesOrderStatusEnum.APPROVED:
raise ValueError('已审批的销售单无法作废')
if _order_has_stock_records(
merchant_id=locked.merchant_id,
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
return _cancel_order_impl(
order=sales_order,
order_model_cls=models.SalesOrder,
approved_status=models.SalesOrderStatusEnum.APPROVED,
cancelled_status=models.SalesOrderStatusEnum.CANCELLED,
error_label='销售单',
stock_source_type=stock_models.StockChangeSourceEnum.SALES,
)
def _approve_purchase_return_order(
@@ -1384,22 +1481,14 @@ def _approve_purchase_return_order(
def _cancel_purchase_return_order(
purchase_return_order: models.PurchaseReturnOrder,
) -> models.PurchaseReturnOrder:
with transaction.atomic():
locked = models.PurchaseReturnOrder.objects.select_related('merchant').select_for_update().get(
id=purchase_return_order.id
)
if locked.status == models.PurchaseReturnStatusEnum.APPROVED:
raise ValueError('已审批的采购退货单无法作废')
if _order_has_stock_records(
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
return _cancel_order_impl(
order=purchase_return_order,
order_model_cls=models.PurchaseReturnOrder,
approved_status=models.PurchaseReturnStatusEnum.APPROVED,
cancelled_status=models.PurchaseReturnStatusEnum.CANCELLED,
error_label='采购退货单',
stock_source_type=stock_models.StockChangeSourceEnum.PURCHASE_RETURN,
)
def _approve_sales_return_order(
@@ -1445,22 +1534,14 @@ def _approve_sales_return_order(
def _cancel_sales_return_order(
sales_return_order: models.SalesReturnOrder,
) -> models.SalesReturnOrder:
with transaction.atomic():
locked = models.SalesReturnOrder.objects.select_related('merchant').select_for_update().get(
id=sales_return_order.id
)
if locked.status == models.SalesReturnStatusEnum.APPROVED:
raise ValueError('已审批的销售退货单无法作废')
if _order_has_stock_records(
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
return _cancel_order_impl(
order=sales_return_order,
order_model_cls=models.SalesReturnOrder,
approved_status=models.SalesReturnStatusEnum.APPROVED,
cancelled_status=models.SalesReturnStatusEnum.CANCELLED,
error_label='销售退货单',
stock_source_type=stock_models.StockChangeSourceEnum.SALES_RETURN,
)
def create_purchase_order_stock_entries_sync(
@@ -1919,10 +2000,9 @@ def build_customer_statement(
"""
根据客户历史单据生成对账记录,供多个 API 复用。
"""
local_balance = BalanceService.get_customer_balance(merchant=merchant, customer=customer)
builder = _CustomerStatementBuilder(merchant=merchant, current_balance=local_balance)
statement_balance = BalanceService.get_customer_statement_balance(merchant=merchant, customer=customer)
builder = _CustomerStatementBuilder(merchant=merchant, current_balance=statement_balance)
records = builder.collect_records(customer)
builder.adjust_current_balance(builder.external_balance_adjustment)
return builder.build_payload(
counterparty_id=customer.id,
counterparty_name=customer.name,

View File

@@ -109,6 +109,9 @@ class ExternalFinanceSyncTestCase(TestCase):
'KdRiQi': '2026-05-14T19:08:23Z',
'JieSunFS': '欠款',
'HpID': 'HP00001',
'HpName': '外部同步品名',
'YanSe': '天蓝',
'SeHao': '150cm',
'JiJiaDW': '',
'JianShu': '1.00',
'ShuLiang': '20.00',
@@ -136,6 +139,9 @@ class ExternalFinanceSyncTestCase(TestCase):
'KdRiQi': '2025-11-25T18:08:23Z',
'JieSunFS': '欠款',
'HpID': 'HP00001',
'HpName': '外部退货品名',
'YanSe': '米白',
'SeHao': '160cm',
'JiJiaDW': '',
'JianShu': '1.00',
'ShuLiang': '5.00',
@@ -166,7 +172,7 @@ class ExternalFinanceSyncTestCase(TestCase):
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_zero_settlement_count'], 0)
self.assertEqual(payload['external_business_created_count'], 2)
@@ -177,7 +183,7 @@ class ExternalFinanceSyncTestCase(TestCase):
orders = list(
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}
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'].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)
self.assertEqual(balance.balance, Decimal('-198381.00'))
self.assertEqual(balance.balance, Decimal('-198871.00'))
external_orders = list(
business_models.ExternalCustomerStatementOrder.objects.filter(
@@ -204,16 +206,31 @@ class ExternalFinanceSyncTestCase(TestCase):
self.assertEqual(len(external_orders), 2)
self.assertEqual(external_orders[0].total_amount, Decimal('640.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)
records_by_type = {record['source_type']: record for record in statement_payload['records']}
source_types = list(records_by_type.keys())
self.assertIn('external_sales_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']['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(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):
purchase_order = business_models.PurchaseOrder.objects.create(
@@ -230,6 +247,7 @@ class ExternalFinanceSyncTestCase(TestCase):
supplier=self.supplier,
return_date='2026-05-02',
warehouse=self.warehouse_relaxed,
operator=self.operator,
purchase_order=purchase_order,
status=business_models.PurchaseReturnStatusEnum.APPROVED,
remarks='退货备注',

View File

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

View File

@@ -75,11 +75,12 @@ python manage.py showmigrations business
当前 `items_payload` 中保留的信息包括:
- `product_id`若能通过 `HpID -> Product.human_id` 匹配到本地产品,则写入本地产品 ID否则`null`
- `product_name`:优先本地产品名,否则回退为外部 `HpID`
- `product_id`外部 statement-only 明细不绑定本地产品,固定`null`
- `product_name`:优先使用外部 `HpName`,否则回退为外部 `HpID`
- `quantity`
- `price`
- `unit`
- `color`:来源于外部 i-sale payload 的 `YanSe` 字段;若源端未下发则为空字符串
- `spec`
- `num_of_rolls`
- `external_sub_id`
@@ -137,8 +138,31 @@ python manage.py showmigrations business
- `RiQi` -> statement `occurred_at`
- `KdRiQi` -> statement `recorded_at`
- `JinE` 聚合为外部业务单金额
- `HpID` 优先匹配本地 `Product.human_id`
- 若未匹配到本地产品,仍保留 `HpID` 作为对账单 item 展示标识,不阻塞同步
- 外部业务单仅作为对账依据保存,不再通过 `HpID` 绑定本地 `Product`,避免外部历史数据误入库存/审批产品体系
### 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 业务依据

View File

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

View File

@@ -517,13 +517,27 @@ class PrintingJob(ModelBase):
@property
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')
@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
def status(self) -> str:
"""