From d8eb66821ea7877860bd9be7e548c6b206f81879 Mon Sep 17 00:00:00 2001 From: colaftc Date: Thu, 4 Dec 2025 09:36:59 +0800 Subject: [PATCH] feat: add discount_amount to payment_order and receipt_order, and change total_amount logic --- api_v1/serializers.py | 9 +- api_v1/tests.py | 215 +++++++++ api_v1/urls.py | 1 + api_v1/utils/__init__.py | 5 + api_v1/utils/media.py | 67 +++ api_v1/views/business/payment/views.py | 57 ++- api_v1/views/business/receipt/views.py | 57 ++- .../views/business/statements/serializers.py | 16 + api_v1/views/business/statements/views.py | 400 +++------------- api_v1/views/printing/serializers.py | 15 +- .../0015_payment_receipt_bank_and_markup.py | 49 ++ .../0016_payment_receipt_discount.py | 35 ++ business/models.py | 44 +- business/services.py | 434 +++++++++++++++++- business/tests.py | 84 +++- docs/business_api_reference.md | 27 +- docs/payment_receipt_workflow.md | 7 +- docs/statements.md | 18 +- flower/settings.py | 2 +- 19 files changed, 1144 insertions(+), 398 deletions(-) create mode 100644 api_v1/utils/__init__.py create mode 100644 api_v1/utils/media.py create mode 100644 business/migrations/0015_payment_receipt_bank_and_markup.py create mode 100644 business/migrations/0016_payment_receipt_discount.py diff --git a/api_v1/serializers.py b/api_v1/serializers.py index 5189c2f..64aa625 100644 --- a/api_v1/serializers.py +++ b/api_v1/serializers.py @@ -3,6 +3,7 @@ from decimal import Decimal from typing import List, Dict, Any from stock import models as stock_models from basic_info import models as basic_info_models +from api_v1.utils.media import build_public_media_url from . import models as api_models from .enums import PrintCountObjectType @@ -254,9 +255,11 @@ class UploadedFileSerializer(serializers.ModelSerializer): """获取文件完整URL""" if obj.path: request = self.context.get('request') - if request: - return request.build_absolute_uri(obj.path.url) - return obj.path.url + try: + raw_value = obj.path.url + except ValueError: + raw_value = obj.path.name + return build_public_media_url(raw_value, request=request) return None diff --git a/api_v1/tests.py b/api_v1/tests.py index 1209a39..07bc764 100644 --- a/api_v1/tests.py +++ b/api_v1/tests.py @@ -10,6 +10,7 @@ from django.contrib.auth.models import User, Permission from rest_framework.test import APIClient from rest_framework import status from basic_info.models import ( + BankAccount, Customer, Employee, EmployeeStatusEnum, @@ -770,6 +771,11 @@ class PaymentOrderAPITestCase(TestCase): def setUp(self): self.merchant = Merchant.objects.create(name='付款商户', type=MerchantTypeEnum.FACTORY) self.supplier = Supplier.objects.create(merchant=self.merchant, name='付款供应商') + self.bank_account = BankAccount.objects.create( + merchant=self.merchant, + name='主账户', + auto_number='BANK001', + ) self.user = User.objects.create_user(username='pay_user', password='pass123') self.employee = Employee.objects.create( merchant=self.merchant, @@ -781,9 +787,12 @@ class PaymentOrderAPITestCase(TestCase): self.client.force_authenticate(user=self.user) self.payload = { 'supplier': self.supplier.id, + 'bank_account': self.bank_account.id, 'payment_date': '2025-11-26', 'amount': '120.5', + 'discount_amount': '5.50', 'remarks': '付款备注', + 'markup': '附言', } def test_create_payment_order_success(self): @@ -802,6 +811,8 @@ class PaymentOrderAPITestCase(TestCase): response = self.client.post(f'/api/v1/payment-orders/{order_id}/review/', {'action': 'approve'}, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['status'], business_models.PaymentOrderStatusEnum.APPROVED) + self.assertEqual(response.data['discount_amount'], '5.50') + self.assertEqual(response.data['settlement_amount'], '126.00') response_cancel = self.client.post( f'/api/v1/payment-orders/{order_id}/review/', {'action': 'cancel'}, @@ -822,6 +833,35 @@ class PaymentOrderAPITestCase(TestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('amount 必须大于 0', response.data['error']) + def test_payment_order_invalid_bank_account(self): + other_merchant = Merchant.objects.create(name='其他商户', type=MerchantTypeEnum.FACTORY) + other_bank = BankAccount.objects.create(merchant=other_merchant, name='其他账户', auto_number='BANK999') + payload = {**self.payload, 'bank_account': other_bank.id} + response = self.client.post('/api/v1/payment-orders/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('银行账户不存在', response.data['error']) + + def test_payment_order_stores_bank_account_and_markup(self): + response = self.client.post('/api/v1/payment-orders/', self.payload, format='json') + order = business_models.PaymentOrder.objects.get(id=response.data['id']) + self.assertEqual(order.bank_account_id, self.bank_account.id) + self.assertEqual(order.markup, '附言') + self.assertEqual(str(order.discount_amount), '5.50') + self.assertEqual(str(order.settlement_amount), '126.00') + + def test_payment_order_discount_can_exceed_amount(self): + payload = {**self.payload, 'discount_amount': '200.00'} + response = self.client.post('/api/v1/payment-orders/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + order = business_models.PaymentOrder.objects.get(id=response.data['id']) + self.assertEqual(str(order.discount_amount), '200.00') + self.assertEqual(str(order.settlement_amount), '320.50') + + def test_payment_order_discount_cannot_be_negative(self): + payload = {**self.payload, 'discount_amount': '-1'} + response = self.client.post('/api/v1/payment-orders/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('discount_amount 不能小于 0', response.data['error']) class ReceiptOrderAPITestCase(TestCase): def setUp(self): @@ -832,6 +872,11 @@ class ReceiptOrderAPITestCase(TestCase): mobile='13812345678', created_by=None, ) + self.bank_account = BankAccount.objects.create( + merchant=self.merchant, + name='收款账户', + auto_number='BANK100', + ) self.user = User.objects.create_user(username='receipt_user', password='pass123') self.employee = Employee.objects.create( merchant=self.merchant, @@ -843,9 +888,12 @@ class ReceiptOrderAPITestCase(TestCase): self.client.force_authenticate(user=self.user) self.payload = { 'customer': self.customer.id, + 'bank_account': self.bank_account.id, 'receipt_date': '2025-11-26', 'amount': '88.00', + 'discount_amount': '3.00', 'remarks': '收款备注', + 'markup': '收款附言', } def test_create_receipt_order_success(self): @@ -868,6 +916,8 @@ class ReceiptOrderAPITestCase(TestCase): ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['status'], business_models.ReceiptOrderStatusEnum.CANCELLED) + self.assertEqual(response.data['discount_amount'], '3.00') + self.assertEqual(response.data['settlement_amount'], '91.00') def test_receipt_order_requires_amount(self): payload = {**self.payload} @@ -882,6 +932,34 @@ class ReceiptOrderAPITestCase(TestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('amount 必须大于 0', response.data['error']) + def test_receipt_order_stores_bank_account_and_markup(self): + response = self.client.post('/api/v1/receipt-orders/', self.payload, format='json') + order = business_models.ReceiptOrder.objects.get(id=response.data['id']) + self.assertEqual(order.bank_account_id, self.bank_account.id) + self.assertEqual(order.markup, '收款附言') + self.assertEqual(str(order.discount_amount), '3.00') + self.assertEqual(str(order.settlement_amount), '91.00') + + def test_receipt_order_discount_can_exceed_amount(self): + payload = {**self.payload, 'discount_amount': '150.00'} + response = self.client.post('/api/v1/receipt-orders/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + order = business_models.ReceiptOrder.objects.get(id=response.data['id']) + self.assertEqual(str(order.settlement_amount), '238.00') + + def test_receipt_order_discount_cannot_be_negative(self): + payload = {**self.payload, 'discount_amount': '-1'} + response = self.client.post('/api/v1/receipt-orders/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('discount_amount 不能小于 0', response.data['error']) + + def test_receipt_order_invalid_bank_account(self): + other_merchant = Merchant.objects.create(name='其他收款商户', type=MerchantTypeEnum.FACTORY) + other_bank = BankAccount.objects.create(merchant=other_merchant, name='其他收款账户', auto_number='BANK888') + payload = {**self.payload, 'bank_account': other_bank.id} + response = self.client.post('/api/v1/receipt-orders/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('银行账户不存在', response.data['error']) class CustomerBalanceAPITestCase(TestCase): def setUp(self): @@ -976,6 +1054,143 @@ class SupplierBalanceAPITestCase(TestCase): response = self.client.get(f'/api/v1/suppliers/{self.supplier.id}/balance/') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['supplier'], self.supplier.id) + + +class StatementRecordAPITestCase(TestCase): + """对账单单条记录查询 API 测试""" + + def setUp(self): + self.merchant = Merchant.objects.create(name='对账商户', type=MerchantTypeEnum.FACTORY) + self.customer = Customer.objects.create( + merchant=self.merchant, + name='对账客户', + mobile='13800000000', + created_by=None, + ) + self.supplier = Supplier.objects.create(merchant=self.merchant, name='对账供应商') + category = ProductCategory.objects.create( + merchant=self.merchant, + name='对账品类', + product_prefix='STM', + ) + self.product = Product.objects.create( + merchant=self.merchant, + category=category, + name='对账产品', + human_id='STM-001', + unit=ProductUnitEnum.METER, + ) + self.warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='对账仓库', + mode=WareHouseModeEnum.UNRESTRICTED, + ) + self.user = User.objects.create_user(username='statement_user', password='pass123') + self.employee = Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='财务', + status=EmployeeStatusEnum.ACTIVE, + ) + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + self.sales_order = business_models.SalesOrder.objects.create( + merchant=self.merchant, + customer=self.customer, + sales_date='2025-11-20', + operator=self.employee, + warehouse=self.warehouse, + status=business_models.SalesOrderStatusEnum.APPROVED, + ) + business_models.SalesOrderItem.objects.create( + sales_order=self.sales_order, + product=self.product, + price=Decimal('12.50'), + quantity=Decimal('5'), + unit='米', + empty_diff_percent=Decimal('0'), + ) + + self.purchase_order = business_models.PurchaseOrder.objects.create( + merchant=self.merchant, + supplier=self.supplier, + purchase_date='2025-11-21', + operator=self.employee, + warehouse=self.warehouse, + status=business_models.PurchaseOrderStatusEnum.APPROVED, + ) + business_models.PurchaseOrderItem.objects.create( + purchase_order=self.purchase_order, + product=self.product, + price=Decimal('8.30'), + quantity=Decimal('10'), + unit='米', + empty_diff_percent=Decimal('0'), + ) + + def test_get_customer_sales_order_record(self): + response = self.client.get( + '/api/v1/statements/record/', + { + 'counterparty_type': 'customer', + 'counterparty_id': self.customer.id, + 'order_type': 'sales_order', + 'order_id': self.sales_order.id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['counterparty'], self.customer.id) + self.assertEqual(response.data['counterparty_name'], self.customer.name) + self.assertEqual(len(response.data['records']), 1) + record = response.data['records'][0] + self.assertEqual(record['source_type'], 'sales_order') + self.assertEqual(record['source_id'], self.sales_order.id) + self.assertEqual(record['counterparty'], self.customer.id) + + def test_get_supplier_purchase_order_record(self): + response = self.client.get( + '/api/v1/statements/record/', + { + 'counterparty_type': 'supplier', + 'counterparty_id': self.supplier.id, + 'order_type': 'purchase_order', + 'order_id': self.purchase_order.id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + record = response.data['records'][0] + self.assertEqual(record['source_type'], 'purchase_order') + self.assertEqual(record['source_id'], self.purchase_order.id) + self.assertEqual(record['counterparty'], self.supplier.id) + + def test_statement_record_not_found(self): + response = self.client.get( + '/api/v1/statements/record/', + { + 'counterparty_type': 'customer', + 'counterparty_id': self.customer.id, + 'order_type': 'sales_order', + 'order_id': 9999, + }, + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn('未找到匹配的对账记录', response.data['error']) + + def test_invalid_order_type_returns_400(self): + response = self.client.get( + '/api/v1/statements/record/', + { + 'counterparty_type': 'customer', + 'counterparty_id': self.customer.id, + 'order_type': 'unknown', + 'order_id': self.sales_order.id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('order_type', response.data) + + @override_settings( CELERY_TASK_ALWAYS_EAGER=True, CELERY_TASK_EAGER_PROPAGATES=True, diff --git a/api_v1/urls.py b/api_v1/urls.py index d198130..5037ca5 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -82,6 +82,7 @@ urlpatterns = [ path('suppliers//balance/', balance_views.SupplierBalanceView.as_view(), name='supplier_balance'), path('customers//statements/', statement_views.CustomerStatementView.as_view(), name='customer_statements'), path('suppliers//statements/', statement_views.SupplierStatementView.as_view(), name='supplier_statements'), + path('statements/record/', statement_views.StatementRecordView.as_view(), name='statement_record'), path('health/', healthy.HealthCheckView.as_view(), name='health_check'), path('print-count/delta/', print_count.adjust_print_count, name='print_count_delta'), diff --git a/api_v1/utils/__init__.py b/api_v1/utils/__init__.py new file mode 100644 index 0000000..e1aed2d --- /dev/null +++ b/api_v1/utils/__init__.py @@ -0,0 +1,5 @@ +""" +Utilities for api_v1 app. +""" + + diff --git a/api_v1/utils/media.py b/api_v1/utils/media.py new file mode 100644 index 0000000..dd70401 --- /dev/null +++ b/api_v1/utils/media.py @@ -0,0 +1,67 @@ +"""Helper utilities for building public media URLs.""" +from __future__ import annotations + +from urllib.parse import urlparse, urlunparse + +from django.conf import settings + + +def _get_storage_base_url() -> str | None: + """ + Return the base URL (scheme + domain) for public media files based on Qiniu settings. + + The returned URL never ends with a slash. + """ + domain = getattr(settings, 'QINIU_BUCKET_DOMAIN', '') or '' + domain = domain.strip().rstrip('/') + if not domain: + return None + + if domain.startswith('http://') or domain.startswith('https://'): + base = domain + else: + scheme = 'https' if getattr(settings, 'QINIU_SECURE_URL', False) else 'http' + base = f'{scheme}://{domain}' + return base.rstrip('/') + + +def build_public_media_url(value: str | None, *, request=None) -> str | None: + """ + Normalize any stored media reference (absolute URL or relative path) to the currently + configured bucket domain. Falls back to request host or the original value when the + bucket domain is unavailable. + """ + if value is None: + return None + value = str(value).strip() + if not value: + return None + + base = _get_storage_base_url() + parsed = urlparse(value) + if parsed.scheme and parsed.netloc: + if not base: + return value + base_parts = urlparse(base) + return urlunparse( + ( + base_parts.scheme or parsed.scheme or 'http', + base_parts.netloc or parsed.netloc, + parsed.path or '', + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + + path = value if value.startswith('/') else f'/{value}' + if base: + return f'{base}{path}' + if request is not None: + return request.build_absolute_uri(path) + return path + + +__all__ = ['build_public_media_url'] + + diff --git a/api_v1/views/business/payment/views.py b/api_v1/views/business/payment/views.py index 8772f18..5606730 100644 --- a/api_v1/views/business/payment/views.py +++ b/api_v1/views/business/payment/views.py @@ -11,15 +11,30 @@ from api_v1.views.stock_change_views.mixins import StockChangeViewMixin class PaymentOrderSerializer(serializers.ModelSerializer): supplier_name = serializers.CharField(source='supplier.name', read_only=True) operator_name = serializers.CharField(source='operator.name', read_only=True) + bank_account_name = serializers.CharField(source='bank_account.name', read_only=True) + settlement_amount = serializers.SerializerMethodField() class Meta: model = business_models.PaymentOrder fields = [ - 'id', 'supplier', 'supplier_name', 'payment_date', - 'amount', 'operator', 'operator_name', 'status', - 'remarks', 'created_at', 'updated_at', + 'id', 'supplier', 'supplier_name', 'bank_account', 'bank_account_name', + 'payment_date', 'amount', 'discount_amount', 'settlement_amount', + 'operator', 'operator_name', 'status', + 'remarks', 'markup', 'created_at', 'updated_at', ] - read_only_fields = ['id', 'supplier_name', 'operator_name', 'status', 'created_at', 'updated_at'] + read_only_fields = [ + 'id', + 'supplier_name', + 'operator_name', + 'bank_account_name', + 'settlement_amount', + 'status', + 'created_at', + 'updated_at', + ] + + def get_settlement_amount(self, obj): + return str(obj.settlement_amount) class PaymentOrderPagination(pagination.LimitOffsetPagination): @@ -35,9 +50,11 @@ class PaymentOrderView(StockChangeViewMixin, views.APIView): if not self.check_employee_permission(request): return self.permission_error_response('无权限访问') merchant = request.user.employee.merchant - queryset = business_models.PaymentOrder.objects.filter(merchant=merchant).select_related( - 'supplier', 'operator' - ).order_by('-created_at') + queryset = ( + business_models.PaymentOrder.objects.filter(merchant=merchant) + .select_related('supplier', 'operator', 'bank_account') + .order_by('-created_at') + ) paginator = self.pagination_class() page = paginator.paginate_queryset(queryset, request, view=self) serializer = PaymentOrderSerializer(page, many=True) @@ -51,6 +68,9 @@ class PaymentOrderView(StockChangeViewMixin, views.APIView): supplier_id = data.get('supplier') payment_date = data.get('payment_date') amount = data.get('amount') + discount_amount = data.get('discount_amount') + bank_account_id = data.get('bank_account') + markup = data.get('markup') remarks = data.get('remarks', '') if not supplier_id: @@ -66,6 +86,12 @@ class PaymentOrderView(StockChangeViewMixin, views.APIView): return Response({'error': f'供应商 {supplier_id} 不存在'}, status=status.HTTP_400_BAD_REQUEST) operator = request.user.employee + bank_account = None + if bank_account_id not in (None, '', 0): + try: + bank_account = basic_models.BankAccount.objects.get(id=bank_account_id, merchant=merchant) + except basic_models.BankAccount.DoesNotExist: + return Response({'error': '银行账户不存在'}, status=status.HTTP_400_BAD_REQUEST) try: payment_order = business_services.create_payment_order( @@ -73,8 +99,11 @@ class PaymentOrderView(StockChangeViewMixin, views.APIView): supplier=supplier, payment_date=payment_date, amount=amount, + discount_amount=discount_amount, operator=operator, remarks=remarks, + bank_account=bank_account, + markup=markup, ) except ValueError as exc: return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) @@ -106,9 +135,10 @@ class PaymentOrderReviewView(StockChangeViewMixin, views.APIView): merchant = request.user.employee.merchant try: - payment_order = business_models.PaymentOrder.objects.select_related( - 'supplier', 'operator' - ).get(id=pk, merchant=merchant) + payment_order = ( + business_models.PaymentOrder.objects.select_related('supplier', 'operator', 'bank_account') + .get(id=pk, merchant=merchant) + ) except business_models.PaymentOrder.DoesNotExist: return self.not_found_response('付款单不存在') @@ -126,8 +156,9 @@ class PaymentOrderReviewView(StockChangeViewMixin, views.APIView): except ValueError as exc: return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) - refreshed = business_models.PaymentOrder.objects.select_related( - 'supplier', 'operator' - ).get(id=payment_order.id) + refreshed = ( + business_models.PaymentOrder.objects.select_related('supplier', 'operator', 'bank_account') + .get(id=payment_order.id) + ) return Response(PaymentOrderSerializer(refreshed).data, status=status.HTTP_200_OK) diff --git a/api_v1/views/business/receipt/views.py b/api_v1/views/business/receipt/views.py index d9c527b..a713244 100644 --- a/api_v1/views/business/receipt/views.py +++ b/api_v1/views/business/receipt/views.py @@ -11,15 +11,30 @@ from api_v1.views.stock_change_views.mixins import StockChangeViewMixin class ReceiptOrderSerializer(serializers.ModelSerializer): customer_name = serializers.CharField(source='customer.name', read_only=True) operator_name = serializers.CharField(source='operator.name', read_only=True) + bank_account_name = serializers.CharField(source='bank_account.name', read_only=True) + settlement_amount = serializers.SerializerMethodField() class Meta: model = business_models.ReceiptOrder fields = [ - 'id', 'customer', 'customer_name', 'receipt_date', - 'amount', 'operator', 'operator_name', 'status', - 'remarks', 'created_at', 'updated_at', + 'id', 'customer', 'customer_name', 'bank_account', 'bank_account_name', + 'receipt_date', 'amount', 'discount_amount', 'settlement_amount', + 'operator', 'operator_name', 'status', + 'remarks', 'markup', 'created_at', 'updated_at', ] - read_only_fields = ['id', 'customer_name', 'operator_name', 'status', 'created_at', 'updated_at'] + read_only_fields = [ + 'id', + 'customer_name', + 'operator_name', + 'bank_account_name', + 'settlement_amount', + 'status', + 'created_at', + 'updated_at', + ] + + def get_settlement_amount(self, obj): + return str(obj.settlement_amount) class ReceiptOrderPagination(pagination.LimitOffsetPagination): @@ -35,9 +50,11 @@ class ReceiptOrderView(StockChangeViewMixin, views.APIView): if not self.check_employee_permission(request): return self.permission_error_response('无权限访问') merchant = request.user.employee.merchant - queryset = business_models.ReceiptOrder.objects.filter(merchant=merchant).select_related( - 'customer', 'operator' - ).order_by('-created_at') + queryset = ( + business_models.ReceiptOrder.objects.filter(merchant=merchant) + .select_related('customer', 'operator', 'bank_account') + .order_by('-created_at') + ) paginator = self.pagination_class() page = paginator.paginate_queryset(queryset, request, view=self) serializer = ReceiptOrderSerializer(page, many=True) @@ -51,6 +68,9 @@ class ReceiptOrderView(StockChangeViewMixin, views.APIView): customer_id = data.get('customer') receipt_date = data.get('receipt_date') amount = data.get('amount') + discount_amount = data.get('discount_amount') + bank_account_id = data.get('bank_account') + markup = data.get('markup') remarks = data.get('remarks', '') if not customer_id: @@ -66,6 +86,12 @@ class ReceiptOrderView(StockChangeViewMixin, views.APIView): return Response({'error': f'客户 {customer_id} 不存在'}, status=status.HTTP_400_BAD_REQUEST) operator = request.user.employee + bank_account = None + if bank_account_id not in (None, '', 0): + try: + bank_account = basic_models.BankAccount.objects.get(id=bank_account_id, merchant=merchant) + except basic_models.BankAccount.DoesNotExist: + return Response({'error': '银行账户不存在'}, status=status.HTTP_400_BAD_REQUEST) try: receipt_order = business_services.create_receipt_order( @@ -73,8 +99,11 @@ class ReceiptOrderView(StockChangeViewMixin, views.APIView): customer=customer, receipt_date=receipt_date, amount=amount, + discount_amount=discount_amount, operator=operator, remarks=remarks, + bank_account=bank_account, + markup=markup, ) except ValueError as exc: return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) @@ -106,9 +135,10 @@ class ReceiptOrderReviewView(StockChangeViewMixin, views.APIView): merchant = request.user.employee.merchant try: - receipt_order = business_models.ReceiptOrder.objects.select_related( - 'customer', 'operator' - ).get(id=pk, merchant=merchant) + receipt_order = ( + business_models.ReceiptOrder.objects.select_related('customer', 'operator', 'bank_account') + .get(id=pk, merchant=merchant) + ) except business_models.ReceiptOrder.DoesNotExist: return self.not_found_response('收款单不存在') @@ -126,8 +156,9 @@ class ReceiptOrderReviewView(StockChangeViewMixin, views.APIView): except ValueError as exc: return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) - refreshed = business_models.ReceiptOrder.objects.select_related( - 'customer', 'operator' - ).get(id=receipt_order.id) + refreshed = ( + business_models.ReceiptOrder.objects.select_related('customer', 'operator', 'bank_account') + .get(id=receipt_order.id) + ) return Response(ReceiptOrderSerializer(refreshed).data, status=status.HTTP_200_OK) diff --git a/api_v1/views/business/statements/serializers.py b/api_v1/views/business/statements/serializers.py index a9d4c96..0029d7d 100644 --- a/api_v1/views/business/statements/serializers.py +++ b/api_v1/views/business/statements/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from business import services as business_services + class StatementRecordSerializer(serializers.Serializer): source_type = serializers.CharField() @@ -39,3 +41,17 @@ class StatementResponseSerializer(serializers.Serializer): return data +class StatementRecordQuerySerializer(serializers.Serializer): + """单条对账记录查询的参数序列化器""" + + counterparty_type = serializers.ChoiceField( + choices=business_services.STATEMENT_COUNTERPARTY_CHOICES, + help_text='客户/供应商类型(customer/supplier)', + ) + counterparty_id = serializers.IntegerField(min_value=1, help_text='客户或供应商 ID') + order_type = serializers.ChoiceField( + choices=business_services.STATEMENT_ORDER_TYPE_CHOICES, + help_text='来源单据类型(如 sales_order)', + ) + order_id = serializers.IntegerField(min_value=1, help_text='来源单据 ID') + diff --git a/api_v1/views/business/statements/views.py b/api_v1/views/business/statements/views.py index b5427ab..4c8bce6 100644 --- a/api_v1/views/business/statements/views.py +++ b/api_v1/views/business/statements/views.py @@ -1,155 +1,38 @@ from __future__ import annotations -from collections import OrderedDict -from decimal import Decimal, ROUND_HALF_UP -from typing import Iterable, List - from rest_framework import views from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from basic_info import models as basic_models -from business import models as business_models -from api_v1.views.stock_change_views.mixins import StockChangeViewMixin from business import services as business_services -from . import serializers as statement_serializers +from api_v1.views.stock_change_views.mixins import StockChangeViewMixin -TWO_PLACES = Decimal('0.01') -ZERO = Decimal('0') +from . import serializers as statement_serializers class StatementViewBase(StockChangeViewMixin, views.APIView): """ - 提供对账单视图的公共实现:权限校验、序列化上下文及记录构建方法。 + 提供对账单视图的公共实现:权限校验、序列化上下文及响应构造。 """ permission_classes = [IsAuthenticated] serializer_class = statement_serializers.StatementResponseSerializer - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._current_balance_display: str | None = None - self._current_balance_value: Decimal | None = None - def get_serializer_context(self): return { 'request': self.request, 'view': self, - 'summary_builder': self._build_summary, + 'summary_builder': business_services.build_statement_summary, } - def _build_response(self, counterparty, records: List[dict]): - processed_records = self._attach_running_totals(records) + def _build_response(self, payload: dict): serializer = self.serializer_class( - instance={ - 'counterparty': counterparty.id, - 'counterparty_name': counterparty.name, - 'records': processed_records, - }, + instance=payload, context=self.get_serializer_context(), ) return Response(serializer.data) - def _build_record( - self, - *, - counterparty_id: int, - counterparty_name: str, - source_type: str, - source_label: str, - source_id: int, - occurred_at, - recorded_at, - status: int, - status_label: str, - positive_amount, - negative_amount, - items: List[dict] | None = None, - extra: dict | None = None, - ) -> dict: - items = items or [] - record = { - 'counterparty': counterparty_id, - 'counterparty_name': counterparty_name, - 'source_type': source_type, - 'source_label': source_label, - 'source_id': source_id, - 'occurred_at': occurred_at, - 'recorded_at': recorded_at, - 'status': status, - 'status_label': status_label, - 'positive_amount': self._normalize_amount(positive_amount), - 'negative_amount': self._normalize_amount(negative_amount), - 'items': items, - } - if extra: - record['extra'] = extra - return record - - def _aggregate_items(self, order_items) -> List[dict]: - """Merge duplicate order items without changing the response schema.""" - - aggregated = OrderedDict() - for item in order_items: - product_id = getattr(item, 'product_id', None) - product_name = getattr(getattr(item, 'product', None), 'name', '') - unit = getattr(item, 'unit', '') - price = getattr(item, 'price', Decimal('0')) - key = (product_id, product_name, unit, price) - if key not in aggregated: - aggregated[key] = { - 'product_id': product_id, - 'product_name': product_name, - 'quantity': Decimal('0'), - 'price': price, - 'unit': unit, - } - aggregated[key]['quantity'] += Decimal(getattr(item, 'quantity', 0)) - return list(aggregated.values()) - - def _sort_records(self, records: Iterable[dict]) -> List[dict]: - return sorted( - records, - key=lambda item: (item['occurred_at'], item['recorded_at'], item['source_id']), - reverse=True, - ) - - def _normalize_amount(self, value) -> Decimal: - if isinstance(value, Decimal): - decimal_value = value - else: - decimal_value = Decimal(str(value)) - return decimal_value.quantize(TWO_PLACES, rounding=ROUND_HALF_UP) - - def _decimal_to_string(self, value: Decimal) -> str: - normalized = self._normalize_amount(value) - return format(normalized, 'f') - - def _build_summary(self, payload: dict): - records = payload.get('records', []) - total_positive = sum((record.get('positive_amount', ZERO) for record in records), ZERO) - total_negative = sum((record.get('negative_amount', ZERO) for record in records), ZERO) - return { - 'positive_total': self._decimal_to_string(total_positive), - 'negative_total': self._decimal_to_string(total_negative), - } - - def _attach_running_totals(self, records: List[dict]) -> List[dict]: - running_total = ZERO - current_balance_value = self._current_balance_value or ZERO - current_balance_display = self._current_balance_display or self._decimal_to_string(current_balance_value) - processed = [] - for record in records: - record_copy = dict(record) - record_copy['cumulative_amount'] = self._decimal_to_string(running_total) - record_copy['current_balance'] = current_balance_display - arrears_amount = current_balance_value - running_total - record_copy['arrears_amount'] = self._decimal_to_string(arrears_amount) - delta = record_copy['positive_amount'] - record_copy['negative_amount'] - running_total += delta - processed.append(record_copy) - return processed - class CustomerStatementView(StatementViewBase): """ @@ -166,115 +49,8 @@ class CustomerStatementView(StatementViewBase): except basic_models.Customer.DoesNotExist: return self.not_found_response('客户不存在') - self._get_customer_balance(merchant, customer) - records = self._collect_customer_records(merchant, customer) - return self._build_response(customer, records) - - def _collect_customer_records(self, merchant, customer) -> List[dict]: - records: List[dict] = [] - records.extend(self._build_sales_records(merchant, customer)) - records.extend(self._build_sales_return_records(merchant, customer)) - records.extend(self._build_receipt_records(merchant, customer)) - return self._sort_records(records) - - def _build_sales_records(self, merchant, customer) -> List[dict]: - qs = ( - business_models.SalesOrder.objects.filter( - merchant=merchant, - customer=customer, - status=business_models.SalesOrderStatusEnum.APPROVED, - ) - .select_related('customer') - .prefetch_related('items__product') - ) - records = [] - for order in qs: - items = self._aggregate_items(order.items.all()) - records.append( - self._build_record( - counterparty_id=order.customer_id, - counterparty_name=order.customer.name, - source_type='sales_order', - source_label='销售单', - source_id=order.id, - occurred_at=order.sales_date, - recorded_at=order.created_at, - status=order.status, - status_label=order.get_status_display(), - positive_amount=order.get_total_amount(), - negative_amount=ZERO, - items=items, - ) - ) - return records - - def _build_sales_return_records(self, merchant, customer) -> List[dict]: - qs = ( - business_models.SalesReturnOrder.objects.filter( - merchant=merchant, - customer=customer, - status=business_models.SalesReturnStatusEnum.APPROVED, - ) - .select_related('customer') - .prefetch_related('items__product') - ) - records = [] - for order in qs: - items = self._aggregate_items(order.items.all()) - records.append( - self._build_record( - counterparty_id=order.customer_id, - counterparty_name=order.customer.name, - source_type='sales_return_order', - source_label='销售退货单', - source_id=order.id, - occurred_at=order.return_date, - recorded_at=order.created_at, - status=order.status, - status_label=order.get_status_display(), - positive_amount=ZERO, - negative_amount=order.get_total_amount(), - items=items, - ) - ) - return records - - def _build_receipt_records(self, merchant, customer) -> List[dict]: - qs = ( - business_models.ReceiptOrder.objects.filter( - merchant=merchant, - customer=customer, - status=business_models.ReceiptOrderStatusEnum.APPROVED, - ) - .select_related('customer') - ) - records = [] - for order in qs: - records.append( - self._build_record( - counterparty_id=order.customer_id, - counterparty_name=order.customer.name, - source_type='receipt_order', - source_label='收款单', - source_id=order.id, - occurred_at=order.receipt_date, - recorded_at=order.created_at, - status=order.status, - status_label=order.get_status_display(), - positive_amount=ZERO, - negative_amount=order.get_total_amount(), - ) - ) - return records - - def _get_customer_balance(self, merchant, customer): - balance = business_services.BalanceService.get_customer_balance( - merchant=merchant, - customer=customer, - ) - self._current_balance_value = balance - self._current_balance_display = self._decimal_to_string(balance) - return self._current_balance_display + payload = business_services.build_customer_statement(merchant=merchant, customer=customer) + return self._build_response(payload) class SupplierStatementView(StatementViewBase): @@ -292,114 +68,56 @@ class SupplierStatementView(StatementViewBase): except basic_models.Supplier.DoesNotExist: return self.not_found_response('供应商不存在') - self._get_supplier_balance(merchant, supplier) - records = self._collect_supplier_records(merchant, supplier) - return self._build_response(supplier, records) - - def _collect_supplier_records(self, merchant, supplier) -> List[dict]: - records: List[dict] = [] - records.extend(self._build_purchase_records(merchant, supplier)) - records.extend(self._build_purchase_return_records(merchant, supplier)) - records.extend(self._build_payment_records(merchant, supplier)) - return self._sort_records(records) - - def _build_purchase_records(self, merchant, supplier) -> List[dict]: - qs = ( - business_models.PurchaseOrder.objects.filter( - merchant=merchant, - supplier=supplier, - status=business_models.PurchaseOrderStatusEnum.APPROVED, - ) - .select_related('supplier') - .prefetch_related('items__product') - ) - records = [] - for order in qs: - items = self._aggregate_items(order.items.all()) - records.append( - self._build_record( - counterparty_id=order.supplier_id, - counterparty_name=order.supplier.name, - source_type='purchase_order', - source_label='采购单', - source_id=order.id, - occurred_at=order.purchase_date, - recorded_at=order.created_at, - status=order.status, - status_label=order.get_status_display(), - positive_amount=order.get_total_amount(), - negative_amount=ZERO, - items=items, - ) - ) - return records - - def _build_purchase_return_records(self, merchant, supplier) -> List[dict]: - qs = ( - business_models.PurchaseReturnOrder.objects.filter( - merchant=merchant, - supplier=supplier, - status=business_models.PurchaseReturnStatusEnum.APPROVED, - ) - .select_related('supplier') - .prefetch_related('items__product') - ) - records = [] - for order in qs: - items = self._aggregate_items(order.items.all()) - records.append( - self._build_record( - counterparty_id=order.supplier_id, - counterparty_name=order.supplier.name, - source_type='purchase_return_order', - source_label='采购退货单', - source_id=order.id, - occurred_at=order.return_date, - recorded_at=order.created_at, - status=order.status, - status_label=order.get_status_display(), - positive_amount=ZERO, - negative_amount=order.get_total_amount(), - items=items, - ) - ) - return records - - def _build_payment_records(self, merchant, supplier) -> List[dict]: - qs = ( - business_models.PaymentOrder.objects.filter( - merchant=merchant, - supplier=supplier, - status=business_models.PaymentOrderStatusEnum.APPROVED, - ) - .select_related('supplier') - ) - records = [] - for order in qs: - records.append( - self._build_record( - counterparty_id=order.supplier_id, - counterparty_name=order.supplier.name, - source_type='payment_order', - source_label='付款单', - source_id=order.id, - occurred_at=order.payment_date, - recorded_at=order.created_at, - status=order.status, - status_label=order.get_status_display(), - positive_amount=ZERO, - negative_amount=order.get_total_amount(), - ) - ) - return records - - def _get_supplier_balance(self, merchant, supplier): - balance = business_services.BalanceService.get_supplier_balance( - merchant=merchant, - supplier=supplier, - ) - self._current_balance_value = balance - self._current_balance_display = self._decimal_to_string(balance) - return self._current_balance_display + payload = business_services.build_supplier_statement(merchant=merchant, supplier=supplier) + return self._build_response(payload) + + +class StatementRecordView(StatementViewBase): + """ + 根据业务主体与单据信息查询单条对账记录,返回与列表接口一致的 schema。 + """ + + def get(self, request): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + serializer = statement_serializers.StatementRecordQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + params = serializer.validated_data + + merchant = request.user.employee.merchant + counterparty_type = params['counterparty_type'] + counterparty_id = params['counterparty_id'] + order_type = params['order_type'] + order_id = params['order_id'] + + if counterparty_type == 'customer': + try: + counterparty = basic_models.Customer.objects.get(id=counterparty_id, merchant=merchant) + except basic_models.Customer.DoesNotExist: + return self.not_found_response('客户不存在') + payload = business_services.build_customer_statement(merchant=merchant, customer=counterparty) + else: + try: + counterparty = basic_models.Supplier.objects.get(id=counterparty_id, merchant=merchant) + except basic_models.Supplier.DoesNotExist: + return self.not_found_response('供应商不存在') + payload = business_services.build_supplier_statement(merchant=merchant, supplier=counterparty) + + matched_records = [ + record + for record in payload.get('records', []) + if record['source_type'] == order_type and record['source_id'] == order_id + ] + + if not matched_records: + return self.not_found_response('未找到匹配的对账记录') + + single_payload = { + 'counterparty': payload['counterparty'], + 'counterparty_name': payload['counterparty_name'], + 'records': matched_records, + } + return self._build_response(single_payload) diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index 5c30514..2082da9 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -4,25 +4,14 @@ Printing API 序列化器 from rest_framework import serializers from api_v1.models import UploadedFile +from api_v1.utils.media import build_public_media_url from printing import models from .services import PrintingOrderService, PrintingJobService from basic_info.models import Customer, Employee -from django.conf import settings def _build_absolute_media_url(url: str | None, request): - if not url: - return None - if isinstance(url, str) and url.startswith(('http://', 'https://')): - return url - if not isinstance(url, str): - return None - if request: - if url.startswith('/'): - return request.build_absolute_uri(url) - media_prefix = (settings.MEDIA_URL or '/media/').rstrip('/') - return request.build_absolute_uri(f'{media_prefix}/{url.lstrip("/")}') - return url + return build_public_media_url(url, request=request) def _serialize_plate_images(raw_value, request): diff --git a/business/migrations/0015_payment_receipt_bank_and_markup.py b/business/migrations/0015_payment_receipt_bank_and_markup.py new file mode 100644 index 0000000..9fc02c6 --- /dev/null +++ b/business/migrations/0015_payment_receipt_bank_and_markup.py @@ -0,0 +1,49 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0016_merchantsetting_type'), + ('business', '0014_alter_balancechangerecord_source_type_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='paymentorder', + name='bank_account', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='payment_orders', + to='basic_info.bankaccount', + verbose_name='银行账户', + ), + ), + migrations.AddField( + model_name='paymentorder', + name='markup', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='附言'), + ), + migrations.AddField( + model_name='receiptorder', + name='bank_account', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='receipt_orders', + to='basic_info.bankaccount', + verbose_name='银行账户', + ), + ), + migrations.AddField( + model_name='receiptorder', + name='markup', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='附言'), + ), + ] + + diff --git a/business/migrations/0016_payment_receipt_discount.py b/business/migrations/0016_payment_receipt_discount.py new file mode 100644 index 0000000..092eb0a --- /dev/null +++ b/business/migrations/0016_payment_receipt_discount.py @@ -0,0 +1,35 @@ +from decimal import Decimal + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('business', '0015_payment_receipt_bank_and_markup'), + ] + + operations = [ + migrations.AddField( + model_name='paymentorder', + name='discount_amount', + field=models.DecimalField( + decimal_places=2, + default=Decimal('0'), + max_digits=15, + verbose_name='折扣金额', + ), + ), + migrations.AddField( + model_name='receiptorder', + name='discount_amount', + field=models.DecimalField( + decimal_places=2, + default=Decimal('0'), + max_digits=15, + verbose_name='折扣金额', + ), + ), + ] + + diff --git a/business/models.py b/business/models.py index 18c10e8..8f1d6fd 100644 --- a/business/models.py +++ b/business/models.py @@ -558,6 +558,21 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): ) payment_date = models.DateField(verbose_name='付款日期') amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额') + discount_amount = models.DecimalField( + max_digits=15, + decimal_places=2, + default=Decimal('0'), + verbose_name='折扣金额', + ) + bank_account = models.ForeignKey( + basic_info_models.BankAccount, + on_delete=models.PROTECT, + related_name='payment_orders', + verbose_name='银行账户', + blank=True, + null=True, + ) + markup = models.CharField(max_length=255, blank=True, null=True, verbose_name='附言') operator = models.ForeignKey( basic_info_models.Employee, on_delete=models.PROTECT, @@ -578,8 +593,13 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): def __str__(self): return f'付款单 {self.id} - {self.supplier.name}' + @property + def settlement_amount(self) -> Decimal: + discount = self.discount_amount or Decimal('0') + return self.amount + discount + def get_total_amount(self) -> Decimal: - return self.amount + return self.settlement_amount def get_direction(self) -> int: return -1 @@ -610,6 +630,21 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): ) receipt_date = models.DateField(verbose_name='收款日期') amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额') + discount_amount = models.DecimalField( + max_digits=15, + decimal_places=2, + default=Decimal('0'), + verbose_name='折扣金额', + ) + bank_account = models.ForeignKey( + basic_info_models.BankAccount, + on_delete=models.PROTECT, + related_name='receipt_orders', + verbose_name='银行账户', + blank=True, + null=True, + ) + markup = models.CharField(max_length=255, blank=True, null=True, verbose_name='附言') operator = models.ForeignKey( basic_info_models.Employee, on_delete=models.PROTECT, @@ -630,8 +665,13 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): def __str__(self): return f'收款单 {self.id} - {self.customer.name}' + @property + def settlement_amount(self) -> Decimal: + discount = self.discount_amount or Decimal('0') + return self.amount + discount + def get_total_amount(self) -> Decimal: - return self.amount + return self.settlement_amount def get_direction(self) -> int: return -1 diff --git a/business/services.py b/business/services.py index 795dd9a..08f48df 100644 --- a/business/services.py +++ b/business/services.py @@ -1,9 +1,10 @@ from __future__ import annotations import logging +from collections import OrderedDict from datetime import date, datetime -from decimal import Decimal, InvalidOperation -from typing import Any, Dict, List, Tuple +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from typing import Any, Dict, Iterable, List, Tuple from django.contrib.auth import get_user_model from django.db import transaction @@ -446,12 +447,19 @@ def create_payment_order( amount, operator: basic_info_models.Employee, remarks: str | None = '', + bank_account: basic_info_models.BankAccount | None = None, + markup: str | None = None, + discount_amount=None, ) -> models.PaymentOrder: """ 创建付款单(资金流出)。 """ normalized_date = _normalize_order_date(payment_date) normalized_amount = _ensure_positive_amount(amount, 'amount') + normalized_discount = _ensure_non_negative_amount(discount_amount, 'discount_amount') + + if bank_account and bank_account.merchant_id != merchant.id: + raise ValueError('银行账户不属于当前商户') with transaction.atomic(): payment_order = models.PaymentOrder.objects.create( @@ -461,6 +469,9 @@ def create_payment_order( amount=normalized_amount, operator=operator, remarks=remarks, + bank_account=bank_account, + markup=markup or None, + discount_amount=normalized_discount, ) payment_order.refresh_from_db() return payment_order @@ -474,12 +485,19 @@ def create_receipt_order( amount, operator: basic_info_models.Employee, remarks: str | None = '', + bank_account: basic_info_models.BankAccount | None = None, + markup: str | None = None, + discount_amount=None, ) -> models.ReceiptOrder: """ 创建收款单(资金流入)。 """ normalized_date = _normalize_order_date(receipt_date) normalized_amount = _ensure_positive_amount(amount, 'amount') + normalized_discount = _ensure_non_negative_amount(discount_amount, 'discount_amount') + + if bank_account and bank_account.merchant_id != merchant.id: + raise ValueError('银行账户不属于当前商户') with transaction.atomic(): receipt_order = models.ReceiptOrder.objects.create( @@ -489,6 +507,9 @@ def create_receipt_order( amount=normalized_amount, operator=operator, remarks=remarks, + bank_account=bank_account, + markup=markup or None, + discount_amount=normalized_discount, ) receipt_order.refresh_from_db() return receipt_order @@ -641,7 +662,7 @@ def review_payment_order( BalanceService.adjust_supplier_balance( merchant=locked.merchant, supplier=locked.supplier, - delta=-locked.amount, + delta=-locked.settlement_amount, source_type=models.BalanceChangeSourceEnum.PAYMENT_ORDER, source_id=locked.id, ) @@ -694,7 +715,7 @@ def review_receipt_order( BalanceService.adjust_customer_balance( merchant=locked.merchant, customer=locked.customer, - delta=-locked.amount, + delta=-locked.settlement_amount, source_type=models.BalanceChangeSourceEnum.RECEIPT_ORDER, source_id=locked.id, ) @@ -857,7 +878,7 @@ def _approve_purchase_order( created_by_id = getattr(reviewed_by, 'id', None) if _auto_stock_task_enabled(locked_order.merchant): logger.info('审批通过采购单 %s,触发入库任务', locked_order.id) - create_purchase_order_stock_entries.delay( + create_purchase_order_stock_entries.delay( purchase_order_id=locked_order.id, warehouse_id=locked_order.warehouse_id, items=stock_flow_items, @@ -1395,6 +1416,15 @@ def _ensure_positive_amount(value, field_name: str) -> Decimal: return amount +def _ensure_non_negative_amount(value, field_name: str) -> Decimal: + if value in (None, ''): + return Decimal('0') + amount = _to_decimal(value, field_name) + if amount < 0: + raise ValueError(f'{field_name} 不能小于 0') + return amount + + def _to_positive_int(value, field_name: str) -> int: if value is None: raise ValueError(f'{field_name} 不能为空') @@ -1405,3 +1435,397 @@ def _to_positive_int(value, field_name: str) -> int: raise ValueError(f'{field_name} 必须为整数') return int(decimal_value) + +# ==================== Statement Builders ==================== + +_STATEMENT_TWO_PLACES = Decimal('0.01') +_STATEMENT_ZERO = Decimal('0') + +STATEMENT_COUNTERPARTY_CHOICES = ( + ('customer', '客户'), + ('supplier', '供应商'), +) + +STATEMENT_ORDER_TYPE_CHOICES = ( + ('sales_order', '销售单'), + ('sales_return_order', '销售退货单'), + ('receipt_order', '收款单'), + ('purchase_order', '采购单'), + ('purchase_return_order', '采购退货单'), + ('payment_order', '付款单'), +) + + +def build_customer_statement( + *, + merchant: basic_info_models.Merchant, + customer: basic_info_models.Customer, +) -> Dict[str, Any]: + """ + 根据客户历史单据生成对账记录,供多个 API 复用。 + """ + balance = BalanceService.get_customer_balance(merchant=merchant, customer=customer) + builder = _CustomerStatementBuilder(merchant=merchant, current_balance=balance) + records = builder.collect_records(customer) + return builder.build_payload( + counterparty_id=customer.id, + counterparty_name=customer.name, + records=records, + ) + + +def build_supplier_statement( + *, + merchant: basic_info_models.Merchant, + supplier: basic_info_models.Supplier, +) -> Dict[str, Any]: + """ + 根据供应商历史单据生成对账记录,供多个 API 复用。 + """ + balance = BalanceService.get_supplier_balance(merchant=merchant, supplier=supplier) + builder = _SupplierStatementBuilder(merchant=merchant, current_balance=balance) + records = builder.collect_records(supplier) + return builder.build_payload( + counterparty_id=supplier.id, + counterparty_name=supplier.name, + records=records, + ) + + +def build_statement_summary(payload: Dict[str, Any]) -> Dict[str, str]: + """ + 通用汇总函数,配合 StatementResponseSerializer 的 summary 字段。 + """ + records = payload.get('records', []) or [] + total_positive = sum( + (record.get('positive_amount', _STATEMENT_ZERO) for record in records), + _STATEMENT_ZERO, + ) + total_negative = sum( + (record.get('negative_amount', _STATEMENT_ZERO) for record in records), + _STATEMENT_ZERO, + ) + return { + 'positive_total': _decimal_to_string(total_positive), + 'negative_total': _decimal_to_string(total_negative), + } + + +class _StatementBuilder: + def __init__( + self, + *, + merchant: basic_info_models.Merchant, + current_balance: Decimal, + ): + self.merchant = merchant + self._current_balance_value = _normalize_statement_amount(current_balance) + self._current_balance_display = _decimal_to_string(self._current_balance_value) + + def collect_records(self, counterparty) -> List[dict]: # pragma: no cover - interface only + raise NotImplementedError + + def build_payload( + self, + *, + counterparty_id: int, + counterparty_name: str, + records: List[dict], + ) -> Dict[str, Any]: + sorted_records = self._sort_records(records) + processed_records = self._attach_running_totals(sorted_records) + return { + 'counterparty': counterparty_id, + 'counterparty_name': counterparty_name, + 'records': processed_records, + } + + def _build_record( + self, + *, + counterparty_id: int, + counterparty_name: str, + source_type: str, + source_label: str, + source_id: int, + occurred_at, + recorded_at, + status: int, + status_label: str, + positive_amount, + negative_amount, + items: List[dict] | None = None, + extra: dict | None = None, + ) -> dict: + items = items or [] + record = { + 'counterparty': counterparty_id, + 'counterparty_name': counterparty_name, + 'source_type': source_type, + 'source_label': source_label, + 'source_id': source_id, + 'occurred_at': occurred_at, + 'recorded_at': recorded_at, + 'status': status, + 'status_label': status_label, + 'positive_amount': _normalize_statement_amount(positive_amount), + 'negative_amount': _normalize_statement_amount(negative_amount), + 'items': items, + } + if extra: + record['extra'] = extra + return record + + def _aggregate_items(self, order_items) -> List[dict]: + aggregated: OrderedDict[tuple, dict] = OrderedDict() + for item in order_items: + product = getattr(item, 'product', None) + product_id = getattr(item, 'product_id', None) + product_name = getattr(product, 'name', '') + unit = getattr(item, 'unit', '') + price = getattr(item, 'price', Decimal('0')) + key = (product_id, product_name, unit, price) + if key not in aggregated: + aggregated[key] = { + 'product_id': product_id, + 'product_name': product_name, + 'quantity': Decimal('0'), + 'price': price, + 'unit': unit, + } + quantity_value = getattr(item, 'quantity', 0) or 0 + aggregated[key]['quantity'] += Decimal(str(quantity_value)) + return list(aggregated.values()) + + def _sort_records(self, records: Iterable[dict]) -> List[dict]: + return sorted( + records, + key=lambda item: (item['occurred_at'], item['recorded_at'], item['source_id']), + reverse=True, + ) + + def _attach_running_totals(self, records: List[dict]) -> List[dict]: + running_total = _STATEMENT_ZERO + processed: List[dict] = [] + for record in records: + record_copy = dict(record) + record_copy['cumulative_amount'] = _decimal_to_string(running_total) + record_copy['current_balance'] = self._current_balance_display + arrears_amount = self._current_balance_value - running_total + record_copy['arrears_amount'] = _decimal_to_string(arrears_amount) + delta = record_copy['positive_amount'] - record_copy['negative_amount'] + running_total += delta + processed.append(record_copy) + return processed + + +class _CustomerStatementBuilder(_StatementBuilder): + def collect_records(self, customer: basic_info_models.Customer) -> List[dict]: + records: List[dict] = [] + records.extend(self._build_sales_records(customer)) + records.extend(self._build_sales_return_records(customer)) + records.extend(self._build_receipt_records(customer)) + return records + + def _build_sales_records(self, customer: basic_info_models.Customer) -> List[dict]: + qs = ( + models.SalesOrder.objects.filter( + merchant=self.merchant, + customer=customer, + status=models.SalesOrderStatusEnum.APPROVED, + ) + .select_related('customer') + .prefetch_related('items__product') + ) + records = [] + for order in qs: + items = self._aggregate_items(order.items.all()) + records.append( + self._build_record( + counterparty_id=order.customer_id, + counterparty_name=order.customer.name, + source_type='sales_order', + source_label='销售单', + source_id=order.id, + occurred_at=order.sales_date, + recorded_at=order.created_at, + status=order.status, + status_label=order.get_status_display(), + positive_amount=order.get_total_amount(), + negative_amount=_STATEMENT_ZERO, + items=items, + ) + ) + return records + + def _build_sales_return_records(self, customer: basic_info_models.Customer) -> List[dict]: + qs = ( + models.SalesReturnOrder.objects.filter( + merchant=self.merchant, + customer=customer, + status=models.SalesReturnStatusEnum.APPROVED, + ) + .select_related('customer') + .prefetch_related('items__product') + ) + records = [] + for order in qs: + items = self._aggregate_items(order.items.all()) + records.append( + self._build_record( + counterparty_id=order.customer_id, + counterparty_name=order.customer.name, + source_type='sales_return_order', + source_label='销售退货单', + source_id=order.id, + occurred_at=order.return_date, + recorded_at=order.created_at, + status=order.status, + status_label=order.get_status_display(), + positive_amount=_STATEMENT_ZERO, + negative_amount=order.get_total_amount(), + items=items, + ) + ) + return records + + def _build_receipt_records(self, customer: basic_info_models.Customer) -> List[dict]: + qs = ( + models.ReceiptOrder.objects.filter( + merchant=self.merchant, + customer=customer, + status=models.ReceiptOrderStatusEnum.APPROVED, + ) + .select_related('customer') + ) + records = [] + for order in qs: + records.append( + self._build_record( + counterparty_id=order.customer_id, + counterparty_name=order.customer.name, + source_type='receipt_order', + source_label='收款单', + source_id=order.id, + occurred_at=order.receipt_date, + recorded_at=order.created_at, + status=order.status, + status_label=order.get_status_display(), + positive_amount=_STATEMENT_ZERO, + negative_amount=order.get_total_amount(), + ) + ) + return records + + +class _SupplierStatementBuilder(_StatementBuilder): + def collect_records(self, supplier: basic_info_models.Supplier) -> List[dict]: + records: List[dict] = [] + records.extend(self._build_purchase_records(supplier)) + records.extend(self._build_purchase_return_records(supplier)) + records.extend(self._build_payment_records(supplier)) + return records + + def _build_purchase_records(self, supplier: basic_info_models.Supplier) -> List[dict]: + qs = ( + models.PurchaseOrder.objects.filter( + merchant=self.merchant, + supplier=supplier, + status=models.PurchaseOrderStatusEnum.APPROVED, + ) + .select_related('supplier') + .prefetch_related('items__product') + ) + records = [] + for order in qs: + items = self._aggregate_items(order.items.all()) + records.append( + self._build_record( + counterparty_id=order.supplier_id, + counterparty_name=order.supplier.name, + source_type='purchase_order', + source_label='采购单', + source_id=order.id, + occurred_at=order.purchase_date, + recorded_at=order.created_at, + status=order.status, + status_label=order.get_status_display(), + positive_amount=order.get_total_amount(), + negative_amount=_STATEMENT_ZERO, + items=items, + ) + ) + return records + + def _build_purchase_return_records(self, supplier: basic_info_models.Supplier) -> List[dict]: + qs = ( + models.PurchaseReturnOrder.objects.filter( + merchant=self.merchant, + supplier=supplier, + status=models.PurchaseReturnStatusEnum.APPROVED, + ) + .select_related('supplier') + .prefetch_related('items__product') + ) + records = [] + for order in qs: + items = self._aggregate_items(order.items.all()) + records.append( + self._build_record( + counterparty_id=order.supplier_id, + counterparty_name=order.supplier.name, + source_type='purchase_return_order', + source_label='采购退货单', + source_id=order.id, + occurred_at=order.return_date, + recorded_at=order.created_at, + status=order.status, + status_label=order.get_status_display(), + positive_amount=_STATEMENT_ZERO, + negative_amount=order.get_total_amount(), + items=items, + ) + ) + return records + + def _build_payment_records(self, supplier: basic_info_models.Supplier) -> List[dict]: + qs = ( + models.PaymentOrder.objects.filter( + merchant=self.merchant, + supplier=supplier, + status=models.PaymentOrderStatusEnum.APPROVED, + ) + .select_related('supplier') + ) + records = [] + for order in qs: + records.append( + self._build_record( + counterparty_id=order.supplier_id, + counterparty_name=order.supplier.name, + source_type='payment_order', + source_label='付款单', + source_id=order.id, + occurred_at=order.payment_date, + recorded_at=order.created_at, + status=order.status, + status_label=order.get_status_display(), + positive_amount=_STATEMENT_ZERO, + negative_amount=order.get_total_amount(), + ) + ) + return records + + +def _normalize_statement_amount(value) -> Decimal: + if isinstance(value, Decimal): + decimal_value = value + else: + decimal_value = Decimal(str(value)) + return decimal_value.quantize(_STATEMENT_TWO_PLACES, rounding=ROUND_HALF_UP) + + +def _decimal_to_string(value: Decimal) -> str: + normalized = _normalize_statement_amount(value) + return format(normalized, 'f') + diff --git a/business/tests.py b/business/tests.py index a3ac606..acda9b4 100644 --- a/business/tests.py +++ b/business/tests.py @@ -704,6 +704,11 @@ class PaymentReceiptServiceTestCase(TestCase): mobile='13888888888', created_by=None, ) + self.bank_account = basic_models.BankAccount.objects.create( + merchant=self.merchant, + name='测试账户', + auto_number='BANK-SVC', + ) def test_create_payment_order_and_approve(self): order = services.create_payment_order( @@ -711,10 +716,17 @@ class PaymentReceiptServiceTestCase(TestCase): supplier=self.supplier, payment_date=timezone.now().date(), amount='120.50', + discount_amount='20.50', operator=self.operator, remarks='付款测试', + bank_account=self.bank_account, + markup='服务层附言', ) self.assertEqual(order.status, business_models.PaymentOrderStatusEnum.PENDING) + self.assertEqual(order.bank_account, self.bank_account) + self.assertEqual(order.markup, '服务层附言') + self.assertEqual(order.discount_amount, Decimal('20.50')) + self.assertEqual(order.settlement_amount, Decimal('141.00')) reviewed = services.review_payment_order( payment_order=order, target_status=business_models.PaymentOrderStatusEnum.APPROVED, @@ -725,14 +737,14 @@ class PaymentReceiptServiceTestCase(TestCase): merchant=self.merchant, supplier=self.supplier, ) - self.assertEqual(balance.balance, Decimal('-120.50')) + self.assertEqual(balance.balance, Decimal('-141.00')) record = business_models.BalanceChangeRecord.objects.get( merchant=self.merchant, source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER, source_id=order.id, ) self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.DECREASE) - self.assertEqual(record.delta, Decimal('-120.50')) + self.assertEqual(record.delta, Decimal('-141.00')) self.assertEqual(record.balance_after, balance.balance) with self.assertRaises(ValueError): services.review_payment_order( @@ -747,9 +759,15 @@ class PaymentReceiptServiceTestCase(TestCase): customer=self.customer, receipt_date=timezone.now().date(), amount='80.00', + discount_amount='5', operator=self.operator, remarks='收款测试', + bank_account=self.bank_account, + markup='收款附言', ) + self.assertEqual(order.bank_account, self.bank_account) + self.assertEqual(order.discount_amount, Decimal('5')) + self.assertEqual(order.settlement_amount, Decimal('85')) cancelled = services.review_receipt_order( receipt_order=order, target_status=business_models.ReceiptOrderStatusEnum.CANCELLED, @@ -774,6 +792,68 @@ class PaymentReceiptServiceTestCase(TestCase): operator=self.operator, ) + def test_payment_discount_can_exceed_amount(self): + order = services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='50', + discount_amount='60', + operator=self.operator, + ) + self.assertEqual(order.settlement_amount, Decimal('110')) + + def test_receipt_discount_can_exceed_amount(self): + order = services.create_receipt_order( + merchant=self.merchant, + customer=self.customer, + receipt_date=timezone.now().date(), + amount='50', + discount_amount='60', + operator=self.operator, + ) + self.assertEqual(order.settlement_amount, Decimal('110')) + + def test_payment_order_rejects_foreign_bank_account(self): + other_merchant = basic_models.Merchant.objects.create( + name='无关商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + foreign_bank = basic_models.BankAccount.objects.create( + merchant=other_merchant, + name='其他账户', + auto_number='BANK-FOREIGN', + ) + with self.assertRaises(ValueError): + services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='10', + operator=self.operator, + bank_account=foreign_bank, + ) + + def test_receipt_order_rejects_foreign_bank_account(self): + other_merchant = basic_models.Merchant.objects.create( + name='第二商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + foreign_bank = basic_models.BankAccount.objects.create( + merchant=other_merchant, + name='第二账户', + auto_number='BANK-SECOND', + ) + with self.assertRaises(ValueError): + services.create_receipt_order( + merchant=self.merchant, + customer=self.customer, + receipt_date=timezone.now().date(), + amount='10', + operator=self.operator, + bank_account=foreign_bank, + ) + def test_receipt_approval_is_idempotent(self): order = services.create_receipt_order( merchant=self.merchant, diff --git a/docs/business_api_reference.md b/docs/business_api_reference.md index c50be7b..d4b8e6f 100644 --- a/docs/business_api_reference.md +++ b/docs/business_api_reference.md @@ -159,13 +159,22 @@ ```json { "supplier": 12, + "bank_account": 3, "payment_date": "2025-11-30", "amount": "5000.00", - "remarks": "" + "discount_amount": "120.00", + "remarks": "", + "markup": "审批备注" } ``` -`amount` 必须大于 0。返回 201 + 创建的记录。 +字段说明: + +- `bank_account`:可选,引用 `basic_info.BankAccount`,用于记录具体的付款账户。 +- `markup`:可选字符串,用于记录票据附言;与 `remarks`(内部备注)区分。 +- `discount_amount`:可选,默认 0,允许大于 `amount`(表示折扣大于实付);必须 ≥ 0。 +- `settlement_amount = amount + discount_amount`,所有余额、对账及汇总均基于结算金额。 +- `amount` 必须大于 0。返回 201 + 创建的记录,响应中会包含 `discount_amount` 与 `settlement_amount`。 ### 4.2 审批逻辑 @@ -187,12 +196,22 @@ ```json { "customer": 6, + "bank_account": 3, "receipt_date": "2025-11-30", "amount": "3200.00", - "remarks": "" + "discount_amount": "50.00", + "remarks": "", + "markup": "回单附言" } ``` +字段说明: + +- `bank_account`:可选,引用到账银行账户。 +- `discount_amount`:可选,默认 0,可大于 `amount`,但必须 ≥ 0。 +- `markup`:可选,记录回单附言,默认留空。 +- `settlement_amount` 为响应只读字段(`amount + discount_amount`),对账及余额只会计算结算金额。 + ### 5.2 审批 - `approve`:状态改为 `APPROVED`,客户余额 **减少** 对应金额(冲减欠款)。若已取消则拒绝再次审批。 @@ -261,12 +280,14 @@ |-----|------|------| | `/customers//statements/` | GET | 指定客户的销售 / 销退 / 收款对账单 | | `/suppliers//statements/` | GET | 指定供应商的采购 / 采退 / 付款对账单 | +| `/statements/record/` | GET | 通过主体与单据参数获取单条对账记录 | 关键特性: - 固定按 `occurred_at -> recorded_at -> source_id` 倒序输出,不提供排序参数。 - `positive_amount` / `negative_amount` 统一表示余额增减;`cumulative_amount`、`current_balance`、`arrears_amount` 均冗余在每条记录中,前端可直接使用。 - 余额快照来自 `BalanceService`,每次请求只查询一次,保证与审批事务一致。 +- 单条查询接口需提供 `counterparty_type`、`counterparty_id`、`order_type`、`order_id` 四个 Query 参数,返回 schema 与列表一致,仅 `records` 中包含匹配记录。 --- diff --git a/docs/payment_receipt_workflow.md b/docs/payment_receipt_workflow.md index 4144fa2..5e94b38 100644 --- a/docs/payment_receipt_workflow.md +++ b/docs/payment_receipt_workflow.md @@ -13,7 +13,12 @@ | 对方主体 | `supplier` (供应商 ID) | `customer` (客户 ID) | | 日期 | `payment_date` | `receipt_date` | | 金额 | `amount`,必须 > 0 | `amount`,必须 > 0 | -| 其他 | `remarks`(可选) | `remarks`(可选) | +| 银行账户 | `bank_account`(可选,引用 `BankAccount`) | `bank_account`(可选) | +| 附言 | `markup`(可选,记录票据附言) | `markup`(可选) | +| 备注 | `remarks`(可选,内部备注) | `remarks`(可选) | +| 折扣 | `discount_amount`(可选,≥0,可大于 `amount`) | 同左 | + +> 结算金额 = `amount + discount_amount`,所有余额调整与对账单统计均以结算金额为准,响应中通过只读字段 `settlement_amount` 展示。 创建成功返回 `201`,包含新建单据 ID、状态(默认 `PENDING`)与提示信息。 diff --git a/docs/statements.md b/docs/statements.md index 98df417..a00ee26 100644 --- a/docs/statements.md +++ b/docs/statements.md @@ -22,8 +22,18 @@ |-----|------|------| | `/customers//statements/` | GET | 指定客户的销售、销退、收款对账单 | | `/suppliers//statements/` | GET | 指定供应商的采购、采退、付款对账单 | +| `/statements/record/` | GET | 通过主体 + 单据信息查询单条对账记录 | -请求无需额外参数;分页暂不开放(按时间倒序返回全部记录)。 +请求无需额外参数;分页暂不开放(按时间倒序返回全部记录)。单条记录查询则必须提供如下 Query 参数: + +| 参数 | 说明 | +|------|------| +| `counterparty_type` | 业务主体类型,`customer` 或 `supplier` | +| `counterparty_id` | 对应客户或供应商 ID | +| `order_type` | 单据类型,取值与 `source_type` 一致,例如 `sales_order`、`purchase_order` 等 | +| `order_id` | 单据 ID | + +> ⚠️ 由于底层仍需构建完整对账单后再筛选匹配记录,在大体量数据下应谨慎调用此接口。 --- @@ -74,6 +84,12 @@ } ``` +### 3.1 单条记录查询示例 + +`GET /api/v1/statements/record/?counterparty_type=customer&counterparty_id=6&order_type=sales_order&order_id=1024` + +返回结构与对账单列表保持一致,仅 `records` 数组只包含匹配的那一条记录,`summary` 会基于该数组重新计算。 + ### 字段说明 | 字段 | 说明 | diff --git a/flower/settings.py b/flower/settings.py index 0ba4a72..7d9c8a6 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -218,7 +218,7 @@ USE_TZ = True QINIU_ACCESS_KEY = 'IySZff0zEN7jHeDuxj5PrQNMv0qjKoVqBRCuFMzi' QINIU_SECRET_KEY = 'Mph5XkjUQO3VHFPT1JulYHY442uMOya2bXjbSZp8' QINIU_BUCKET_NAME = 'yunwenerp' -QINIU_BUCKET_DOMAIN = 't5510mjho.hn-bkt.clouddn.com' +QINIU_BUCKET_DOMAIN = 'image.yuwen.cloud' QINIU_SECURE_URL = False STORAGES = {