1
0
forked from erp-dev/erp

feat: add discount_amount to payment_order and receipt_order, and change total_amount logic

This commit is contained in:
2025-12-04 09:36:59 +08:00
parent 0b8146d972
commit d8eb66821e
19 changed files with 1144 additions and 398 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -82,6 +82,7 @@ urlpatterns = [
path('suppliers/<int:supplier_id>/balance/', balance_views.SupplierBalanceView.as_view(), name='supplier_balance'),
path('customers/<int:customer_id>/statements/', statement_views.CustomerStatementView.as_view(), name='customer_statements'),
path('suppliers/<int:supplier_id>/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'),

5
api_v1/utils/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""
Utilities for api_v1 app.
"""

67
api_v1/utils/media.py Normal file
View File

@@ -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']

View File

@@ -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)

View File

@@ -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)

View File

@@ -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')

View File

@@ -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)

View File

@@ -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):

View File

@@ -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='附言'),
),
]

View File

@@ -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='折扣金额',
),
),
]

View File

@@ -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

View File

@@ -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')

View File

@@ -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,

View File

@@ -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/<id>/statements/` | GET | 指定客户的销售 / 销退 / 收款对账单 |
| `/suppliers/<id>/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` 中包含匹配记录。
---

View File

@@ -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`)与提示信息。

View File

@@ -22,8 +22,18 @@
|-----|------|------|
| `/customers/<id>/statements/` | GET | 指定客户的销售、销退、收款对账单 |
| `/suppliers/<id>/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` 会基于该数组重新计算。
### 字段说明
| 字段 | 说明 |

View File

@@ -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 = {