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:
@@ -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
|
||||
|
||||
|
||||
|
||||
215
api_v1/tests.py
215
api_v1/tests.py
@@ -10,6 +10,7 @@ from django.contrib.auth.models import User, Permission
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from basic_info.models import (
|
||||
BankAccount,
|
||||
Customer,
|
||||
Employee,
|
||||
EmployeeStatusEnum,
|
||||
@@ -770,6 +771,11 @@ class PaymentOrderAPITestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.merchant = Merchant.objects.create(name='付款商户', type=MerchantTypeEnum.FACTORY)
|
||||
self.supplier = Supplier.objects.create(merchant=self.merchant, name='付款供应商')
|
||||
self.bank_account = BankAccount.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='主账户',
|
||||
auto_number='BANK001',
|
||||
)
|
||||
self.user = User.objects.create_user(username='pay_user', password='pass123')
|
||||
self.employee = Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
@@ -781,9 +787,12 @@ class PaymentOrderAPITestCase(TestCase):
|
||||
self.client.force_authenticate(user=self.user)
|
||||
self.payload = {
|
||||
'supplier': self.supplier.id,
|
||||
'bank_account': self.bank_account.id,
|
||||
'payment_date': '2025-11-26',
|
||||
'amount': '120.5',
|
||||
'discount_amount': '5.50',
|
||||
'remarks': '付款备注',
|
||||
'markup': '附言',
|
||||
}
|
||||
|
||||
def test_create_payment_order_success(self):
|
||||
@@ -802,6 +811,8 @@ class PaymentOrderAPITestCase(TestCase):
|
||||
response = self.client.post(f'/api/v1/payment-orders/{order_id}/review/', {'action': 'approve'}, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['status'], business_models.PaymentOrderStatusEnum.APPROVED)
|
||||
self.assertEqual(response.data['discount_amount'], '5.50')
|
||||
self.assertEqual(response.data['settlement_amount'], '126.00')
|
||||
response_cancel = self.client.post(
|
||||
f'/api/v1/payment-orders/{order_id}/review/',
|
||||
{'action': 'cancel'},
|
||||
@@ -822,6 +833,35 @@ class PaymentOrderAPITestCase(TestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('amount 必须大于 0', response.data['error'])
|
||||
|
||||
def test_payment_order_invalid_bank_account(self):
|
||||
other_merchant = Merchant.objects.create(name='其他商户', type=MerchantTypeEnum.FACTORY)
|
||||
other_bank = BankAccount.objects.create(merchant=other_merchant, name='其他账户', auto_number='BANK999')
|
||||
payload = {**self.payload, 'bank_account': other_bank.id}
|
||||
response = self.client.post('/api/v1/payment-orders/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('银行账户不存在', response.data['error'])
|
||||
|
||||
def test_payment_order_stores_bank_account_and_markup(self):
|
||||
response = self.client.post('/api/v1/payment-orders/', self.payload, format='json')
|
||||
order = business_models.PaymentOrder.objects.get(id=response.data['id'])
|
||||
self.assertEqual(order.bank_account_id, self.bank_account.id)
|
||||
self.assertEqual(order.markup, '附言')
|
||||
self.assertEqual(str(order.discount_amount), '5.50')
|
||||
self.assertEqual(str(order.settlement_amount), '126.00')
|
||||
|
||||
def test_payment_order_discount_can_exceed_amount(self):
|
||||
payload = {**self.payload, 'discount_amount': '200.00'}
|
||||
response = self.client.post('/api/v1/payment-orders/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
order = business_models.PaymentOrder.objects.get(id=response.data['id'])
|
||||
self.assertEqual(str(order.discount_amount), '200.00')
|
||||
self.assertEqual(str(order.settlement_amount), '320.50')
|
||||
|
||||
def test_payment_order_discount_cannot_be_negative(self):
|
||||
payload = {**self.payload, 'discount_amount': '-1'}
|
||||
response = self.client.post('/api/v1/payment-orders/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('discount_amount 不能小于 0', response.data['error'])
|
||||
|
||||
class ReceiptOrderAPITestCase(TestCase):
|
||||
def setUp(self):
|
||||
@@ -832,6 +872,11 @@ class ReceiptOrderAPITestCase(TestCase):
|
||||
mobile='13812345678',
|
||||
created_by=None,
|
||||
)
|
||||
self.bank_account = BankAccount.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='收款账户',
|
||||
auto_number='BANK100',
|
||||
)
|
||||
self.user = User.objects.create_user(username='receipt_user', password='pass123')
|
||||
self.employee = Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
@@ -843,9 +888,12 @@ class ReceiptOrderAPITestCase(TestCase):
|
||||
self.client.force_authenticate(user=self.user)
|
||||
self.payload = {
|
||||
'customer': self.customer.id,
|
||||
'bank_account': self.bank_account.id,
|
||||
'receipt_date': '2025-11-26',
|
||||
'amount': '88.00',
|
||||
'discount_amount': '3.00',
|
||||
'remarks': '收款备注',
|
||||
'markup': '收款附言',
|
||||
}
|
||||
|
||||
def test_create_receipt_order_success(self):
|
||||
@@ -868,6 +916,8 @@ class ReceiptOrderAPITestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['status'], business_models.ReceiptOrderStatusEnum.CANCELLED)
|
||||
self.assertEqual(response.data['discount_amount'], '3.00')
|
||||
self.assertEqual(response.data['settlement_amount'], '91.00')
|
||||
|
||||
def test_receipt_order_requires_amount(self):
|
||||
payload = {**self.payload}
|
||||
@@ -882,6 +932,34 @@ class ReceiptOrderAPITestCase(TestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('amount 必须大于 0', response.data['error'])
|
||||
|
||||
def test_receipt_order_stores_bank_account_and_markup(self):
|
||||
response = self.client.post('/api/v1/receipt-orders/', self.payload, format='json')
|
||||
order = business_models.ReceiptOrder.objects.get(id=response.data['id'])
|
||||
self.assertEqual(order.bank_account_id, self.bank_account.id)
|
||||
self.assertEqual(order.markup, '收款附言')
|
||||
self.assertEqual(str(order.discount_amount), '3.00')
|
||||
self.assertEqual(str(order.settlement_amount), '91.00')
|
||||
|
||||
def test_receipt_order_discount_can_exceed_amount(self):
|
||||
payload = {**self.payload, 'discount_amount': '150.00'}
|
||||
response = self.client.post('/api/v1/receipt-orders/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
order = business_models.ReceiptOrder.objects.get(id=response.data['id'])
|
||||
self.assertEqual(str(order.settlement_amount), '238.00')
|
||||
|
||||
def test_receipt_order_discount_cannot_be_negative(self):
|
||||
payload = {**self.payload, 'discount_amount': '-1'}
|
||||
response = self.client.post('/api/v1/receipt-orders/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('discount_amount 不能小于 0', response.data['error'])
|
||||
|
||||
def test_receipt_order_invalid_bank_account(self):
|
||||
other_merchant = Merchant.objects.create(name='其他收款商户', type=MerchantTypeEnum.FACTORY)
|
||||
other_bank = BankAccount.objects.create(merchant=other_merchant, name='其他收款账户', auto_number='BANK888')
|
||||
payload = {**self.payload, 'bank_account': other_bank.id}
|
||||
response = self.client.post('/api/v1/receipt-orders/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('银行账户不存在', response.data['error'])
|
||||
|
||||
class CustomerBalanceAPITestCase(TestCase):
|
||||
def setUp(self):
|
||||
@@ -976,6 +1054,143 @@ class SupplierBalanceAPITestCase(TestCase):
|
||||
response = self.client.get(f'/api/v1/suppliers/{self.supplier.id}/balance/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['supplier'], self.supplier.id)
|
||||
|
||||
|
||||
class StatementRecordAPITestCase(TestCase):
|
||||
"""对账单单条记录查询 API 测试"""
|
||||
|
||||
def setUp(self):
|
||||
self.merchant = Merchant.objects.create(name='对账商户', type=MerchantTypeEnum.FACTORY)
|
||||
self.customer = Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='对账客户',
|
||||
mobile='13800000000',
|
||||
created_by=None,
|
||||
)
|
||||
self.supplier = Supplier.objects.create(merchant=self.merchant, name='对账供应商')
|
||||
category = ProductCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='对账品类',
|
||||
product_prefix='STM',
|
||||
)
|
||||
self.product = Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='对账产品',
|
||||
human_id='STM-001',
|
||||
unit=ProductUnitEnum.METER,
|
||||
)
|
||||
self.warehouse = WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='对账仓库',
|
||||
mode=WareHouseModeEnum.UNRESTRICTED,
|
||||
)
|
||||
self.user = User.objects.create_user(username='statement_user', password='pass123')
|
||||
self.employee = Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='财务',
|
||||
status=EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.sales_order = business_models.SalesOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
sales_date='2025-11-20',
|
||||
operator=self.employee,
|
||||
warehouse=self.warehouse,
|
||||
status=business_models.SalesOrderStatusEnum.APPROVED,
|
||||
)
|
||||
business_models.SalesOrderItem.objects.create(
|
||||
sales_order=self.sales_order,
|
||||
product=self.product,
|
||||
price=Decimal('12.50'),
|
||||
quantity=Decimal('5'),
|
||||
unit='米',
|
||||
empty_diff_percent=Decimal('0'),
|
||||
)
|
||||
|
||||
self.purchase_order = business_models.PurchaseOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
supplier=self.supplier,
|
||||
purchase_date='2025-11-21',
|
||||
operator=self.employee,
|
||||
warehouse=self.warehouse,
|
||||
status=business_models.PurchaseOrderStatusEnum.APPROVED,
|
||||
)
|
||||
business_models.PurchaseOrderItem.objects.create(
|
||||
purchase_order=self.purchase_order,
|
||||
product=self.product,
|
||||
price=Decimal('8.30'),
|
||||
quantity=Decimal('10'),
|
||||
unit='米',
|
||||
empty_diff_percent=Decimal('0'),
|
||||
)
|
||||
|
||||
def test_get_customer_sales_order_record(self):
|
||||
response = self.client.get(
|
||||
'/api/v1/statements/record/',
|
||||
{
|
||||
'counterparty_type': 'customer',
|
||||
'counterparty_id': self.customer.id,
|
||||
'order_type': 'sales_order',
|
||||
'order_id': self.sales_order.id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['counterparty'], self.customer.id)
|
||||
self.assertEqual(response.data['counterparty_name'], self.customer.name)
|
||||
self.assertEqual(len(response.data['records']), 1)
|
||||
record = response.data['records'][0]
|
||||
self.assertEqual(record['source_type'], 'sales_order')
|
||||
self.assertEqual(record['source_id'], self.sales_order.id)
|
||||
self.assertEqual(record['counterparty'], self.customer.id)
|
||||
|
||||
def test_get_supplier_purchase_order_record(self):
|
||||
response = self.client.get(
|
||||
'/api/v1/statements/record/',
|
||||
{
|
||||
'counterparty_type': 'supplier',
|
||||
'counterparty_id': self.supplier.id,
|
||||
'order_type': 'purchase_order',
|
||||
'order_id': self.purchase_order.id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
record = response.data['records'][0]
|
||||
self.assertEqual(record['source_type'], 'purchase_order')
|
||||
self.assertEqual(record['source_id'], self.purchase_order.id)
|
||||
self.assertEqual(record['counterparty'], self.supplier.id)
|
||||
|
||||
def test_statement_record_not_found(self):
|
||||
response = self.client.get(
|
||||
'/api/v1/statements/record/',
|
||||
{
|
||||
'counterparty_type': 'customer',
|
||||
'counterparty_id': self.customer.id,
|
||||
'order_type': 'sales_order',
|
||||
'order_id': 9999,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assertIn('未找到匹配的对账记录', response.data['error'])
|
||||
|
||||
def test_invalid_order_type_returns_400(self):
|
||||
response = self.client.get(
|
||||
'/api/v1/statements/record/',
|
||||
{
|
||||
'counterparty_type': 'customer',
|
||||
'counterparty_id': self.customer.id,
|
||||
'order_type': 'unknown',
|
||||
'order_id': self.sales_order.id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('order_type', response.data)
|
||||
|
||||
|
||||
@override_settings(
|
||||
CELERY_TASK_ALWAYS_EAGER=True,
|
||||
CELERY_TASK_EAGER_PROPAGATES=True,
|
||||
|
||||
@@ -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
5
api_v1/utils/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Utilities for api_v1 app.
|
||||
"""
|
||||
|
||||
|
||||
67
api_v1/utils/media.py
Normal file
67
api_v1/utils/media.py
Normal 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']
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
49
business/migrations/0015_payment_receipt_bank_and_markup.py
Normal file
49
business/migrations/0015_payment_receipt_bank_and_markup.py
Normal 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='附言'),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
35
business/migrations/0016_payment_receipt_discount.py
Normal file
35
business/migrations/0016_payment_receipt_discount.py
Normal 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='折扣金额',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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` 中包含匹配记录。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`)与提示信息。
|
||||
|
||||
|
||||
@@ -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` 会基于该数组重新计算。
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user