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