diff --git a/api_v1/tests.py b/api_v1/tests.py index 67a4f2c..063a35b 100644 --- a/api_v1/tests.py +++ b/api_v1/tests.py @@ -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): diff --git a/api_v1/views/business/balance/views.py b/api_v1/views/business/balance/views.py index 930f15b..7dfec70 100644 --- a/api_v1/views/business/balance/views.py +++ b/api_v1/views/business/balance/views.py @@ -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, ) diff --git a/api_v1/views/business/sales/views.py b/api_v1/views/business/sales/views.py index 38d50cc..baa28bd 100644 --- a/api_v1/views/business/sales/views.py +++ b/api_v1/views/business/sales/views.py @@ -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) diff --git a/api_v1/views/printing/API.md b/api_v1/views/printing/API.md index f429355..2cbf3c9 100644 --- a/api_v1/views/printing/API.md +++ b/api_v1/views/printing/API.md @@ -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/` - **描述**: 创建一个新的印染任务。 diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index cdaa9ac..0db5576 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -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}, diff --git a/api_v1/views/printing/test_printing_job_api.py b/api_v1/views/printing/test_printing_job_api.py index 6e5e8dd..64baae9 100644 --- a/api_v1/views/printing/test_printing_job_api.py +++ b/api_v1/views/printing/test_printing_job_api.py @@ -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) diff --git a/api_v1/views/printing/views.py b/api_v1/views/printing/views.py index 9af7ec5..d86ef3c 100644 --- a/api_v1/views/printing/views.py +++ b/api_v1/views/printing/views.py @@ -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( diff --git a/api_v2/tests.py b/api_v2/tests.py index db682c4..47fb305 100644 --- a/api_v2/tests.py +++ b/api_v2/tests.py @@ -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): diff --git a/api_v2/views/printing.py b/api_v2/views/printing.py index 4ec18ac..0fe3f3f 100644 --- a/api_v2/views/printing.py +++ b/api_v2/views/printing.py @@ -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 diff --git a/business/ARCHITECTURE.md b/business/ARCHITECTURE.md index c4ddb92..a29758b 100644 --- a/business/ARCHITECTURE.md +++ b/business/ARCHITECTURE.md @@ -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、如何影响下游模块,保持设计透明。 diff --git a/business/external_finance_sync.py b/business/external_finance_sync.py index e56e101..7f99c9b 100644 --- a/business/external_finance_sync.py +++ b/business/external_finance_sync.py @@ -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'): diff --git a/business/models.py b/business/models.py index dcbc9a0..9cf3589 100644 --- a/business/models.py +++ b/business/models.py @@ -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): diff --git a/business/services.py b/business/services.py index 9656f86..3b4828a 100644 --- a/business/services.py +++ b/business/services.py @@ -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, diff --git a/business/tests/test_external_finance_sync.py b/business/tests/test_external_finance_sync.py index 649ce7b..9275202 100644 --- a/business/tests/test_external_finance_sync.py +++ b/business/tests/test_external_finance_sync.py @@ -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='退货备注', diff --git a/docs/business_api_reference.md b/docs/business_api_reference.md index dc60958..cdcc85c 100644 --- a/docs/business_api_reference.md +++ b/docs/business_api_reference.md @@ -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//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 审批 与采购单一致,但方向为出库: diff --git a/docs/external_finance_sync.md b/docs/external_finance_sync.md index 8bd32f6..86c6144 100644 --- a/docs/external_finance_sync.md +++ b/docs/external_finance_sync.md @@ -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 业务依据 diff --git a/flower/settings.py b/flower/settings.py index 2bc76ab..38c95f6 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -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黄宇', '英豪' ] diff --git a/printing/models.py b/printing/models.py index 43ef1c1..a37bea8 100644 --- a/printing/models.py +++ b/printing/models.py @@ -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: """