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 typing import List, Dict, Any
|
||||||
from stock import models as stock_models
|
from stock import models as stock_models
|
||||||
from basic_info import models as basic_info_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 . import models as api_models
|
||||||
from .enums import PrintCountObjectType
|
from .enums import PrintCountObjectType
|
||||||
|
|
||||||
@@ -254,9 +255,11 @@ class UploadedFileSerializer(serializers.ModelSerializer):
|
|||||||
"""获取文件完整URL"""
|
"""获取文件完整URL"""
|
||||||
if obj.path:
|
if obj.path:
|
||||||
request = self.context.get('request')
|
request = self.context.get('request')
|
||||||
if request:
|
try:
|
||||||
return request.build_absolute_uri(obj.path.url)
|
raw_value = obj.path.url
|
||||||
return obj.path.url
|
except ValueError:
|
||||||
|
raw_value = obj.path.name
|
||||||
|
return build_public_media_url(raw_value, request=request)
|
||||||
return None
|
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.test import APIClient
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from basic_info.models import (
|
from basic_info.models import (
|
||||||
|
BankAccount,
|
||||||
Customer,
|
Customer,
|
||||||
Employee,
|
Employee,
|
||||||
EmployeeStatusEnum,
|
EmployeeStatusEnum,
|
||||||
@@ -770,6 +771,11 @@ class PaymentOrderAPITestCase(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.merchant = Merchant.objects.create(name='付款商户', type=MerchantTypeEnum.FACTORY)
|
self.merchant = Merchant.objects.create(name='付款商户', type=MerchantTypeEnum.FACTORY)
|
||||||
self.supplier = Supplier.objects.create(merchant=self.merchant, name='付款供应商')
|
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.user = User.objects.create_user(username='pay_user', password='pass123')
|
||||||
self.employee = Employee.objects.create(
|
self.employee = Employee.objects.create(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
@@ -781,9 +787,12 @@ class PaymentOrderAPITestCase(TestCase):
|
|||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
self.payload = {
|
self.payload = {
|
||||||
'supplier': self.supplier.id,
|
'supplier': self.supplier.id,
|
||||||
|
'bank_account': self.bank_account.id,
|
||||||
'payment_date': '2025-11-26',
|
'payment_date': '2025-11-26',
|
||||||
'amount': '120.5',
|
'amount': '120.5',
|
||||||
|
'discount_amount': '5.50',
|
||||||
'remarks': '付款备注',
|
'remarks': '付款备注',
|
||||||
|
'markup': '附言',
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_create_payment_order_success(self):
|
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')
|
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.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data['status'], business_models.PaymentOrderStatusEnum.APPROVED)
|
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(
|
response_cancel = self.client.post(
|
||||||
f'/api/v1/payment-orders/{order_id}/review/',
|
f'/api/v1/payment-orders/{order_id}/review/',
|
||||||
{'action': 'cancel'},
|
{'action': 'cancel'},
|
||||||
@@ -822,6 +833,35 @@ class PaymentOrderAPITestCase(TestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn('amount 必须大于 0', response.data['error'])
|
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):
|
class ReceiptOrderAPITestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -832,6 +872,11 @@ class ReceiptOrderAPITestCase(TestCase):
|
|||||||
mobile='13812345678',
|
mobile='13812345678',
|
||||||
created_by=None,
|
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.user = User.objects.create_user(username='receipt_user', password='pass123')
|
||||||
self.employee = Employee.objects.create(
|
self.employee = Employee.objects.create(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
@@ -843,9 +888,12 @@ class ReceiptOrderAPITestCase(TestCase):
|
|||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
self.payload = {
|
self.payload = {
|
||||||
'customer': self.customer.id,
|
'customer': self.customer.id,
|
||||||
|
'bank_account': self.bank_account.id,
|
||||||
'receipt_date': '2025-11-26',
|
'receipt_date': '2025-11-26',
|
||||||
'amount': '88.00',
|
'amount': '88.00',
|
||||||
|
'discount_amount': '3.00',
|
||||||
'remarks': '收款备注',
|
'remarks': '收款备注',
|
||||||
|
'markup': '收款附言',
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_create_receipt_order_success(self):
|
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.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data['status'], business_models.ReceiptOrderStatusEnum.CANCELLED)
|
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):
|
def test_receipt_order_requires_amount(self):
|
||||||
payload = {**self.payload}
|
payload = {**self.payload}
|
||||||
@@ -882,6 +932,34 @@ class ReceiptOrderAPITestCase(TestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn('amount 必须大于 0', response.data['error'])
|
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):
|
class CustomerBalanceAPITestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -976,6 +1054,143 @@ class SupplierBalanceAPITestCase(TestCase):
|
|||||||
response = self.client.get(f'/api/v1/suppliers/{self.supplier.id}/balance/')
|
response = self.client.get(f'/api/v1/suppliers/{self.supplier.id}/balance/')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data['supplier'], self.supplier.id)
|
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(
|
@override_settings(
|
||||||
CELERY_TASK_ALWAYS_EAGER=True,
|
CELERY_TASK_ALWAYS_EAGER=True,
|
||||||
CELERY_TASK_EAGER_PROPAGATES=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('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('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('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('health/', healthy.HealthCheckView.as_view(), name='health_check'),
|
||||||
path('print-count/delta/', print_count.adjust_print_count, name='print_count_delta'),
|
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):
|
class PaymentOrderSerializer(serializers.ModelSerializer):
|
||||||
supplier_name = serializers.CharField(source='supplier.name', read_only=True)
|
supplier_name = serializers.CharField(source='supplier.name', read_only=True)
|
||||||
operator_name = serializers.CharField(source='operator.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:
|
class Meta:
|
||||||
model = business_models.PaymentOrder
|
model = business_models.PaymentOrder
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'supplier', 'supplier_name', 'payment_date',
|
'id', 'supplier', 'supplier_name', 'bank_account', 'bank_account_name',
|
||||||
'amount', 'operator', 'operator_name', 'status',
|
'payment_date', 'amount', 'discount_amount', 'settlement_amount',
|
||||||
'remarks', 'created_at', 'updated_at',
|
'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):
|
class PaymentOrderPagination(pagination.LimitOffsetPagination):
|
||||||
@@ -35,9 +50,11 @@ class PaymentOrderView(StockChangeViewMixin, views.APIView):
|
|||||||
if not self.check_employee_permission(request):
|
if not self.check_employee_permission(request):
|
||||||
return self.permission_error_response('无权限访问')
|
return self.permission_error_response('无权限访问')
|
||||||
merchant = request.user.employee.merchant
|
merchant = request.user.employee.merchant
|
||||||
queryset = business_models.PaymentOrder.objects.filter(merchant=merchant).select_related(
|
queryset = (
|
||||||
'supplier', 'operator'
|
business_models.PaymentOrder.objects.filter(merchant=merchant)
|
||||||
).order_by('-created_at')
|
.select_related('supplier', 'operator', 'bank_account')
|
||||||
|
.order_by('-created_at')
|
||||||
|
)
|
||||||
paginator = self.pagination_class()
|
paginator = self.pagination_class()
|
||||||
page = paginator.paginate_queryset(queryset, request, view=self)
|
page = paginator.paginate_queryset(queryset, request, view=self)
|
||||||
serializer = PaymentOrderSerializer(page, many=True)
|
serializer = PaymentOrderSerializer(page, many=True)
|
||||||
@@ -51,6 +68,9 @@ class PaymentOrderView(StockChangeViewMixin, views.APIView):
|
|||||||
supplier_id = data.get('supplier')
|
supplier_id = data.get('supplier')
|
||||||
payment_date = data.get('payment_date')
|
payment_date = data.get('payment_date')
|
||||||
amount = data.get('amount')
|
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', '')
|
remarks = data.get('remarks', '')
|
||||||
|
|
||||||
if not supplier_id:
|
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)
|
return Response({'error': f'供应商 {supplier_id} 不存在'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
operator = request.user.employee
|
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:
|
try:
|
||||||
payment_order = business_services.create_payment_order(
|
payment_order = business_services.create_payment_order(
|
||||||
@@ -73,8 +99,11 @@ class PaymentOrderView(StockChangeViewMixin, views.APIView):
|
|||||||
supplier=supplier,
|
supplier=supplier,
|
||||||
payment_date=payment_date,
|
payment_date=payment_date,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
|
discount_amount=discount_amount,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
remarks=remarks,
|
remarks=remarks,
|
||||||
|
bank_account=bank_account,
|
||||||
|
markup=markup,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
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
|
merchant = request.user.employee.merchant
|
||||||
try:
|
try:
|
||||||
payment_order = business_models.PaymentOrder.objects.select_related(
|
payment_order = (
|
||||||
'supplier', 'operator'
|
business_models.PaymentOrder.objects.select_related('supplier', 'operator', 'bank_account')
|
||||||
).get(id=pk, merchant=merchant)
|
.get(id=pk, merchant=merchant)
|
||||||
|
)
|
||||||
except business_models.PaymentOrder.DoesNotExist:
|
except business_models.PaymentOrder.DoesNotExist:
|
||||||
return self.not_found_response('付款单不存在')
|
return self.not_found_response('付款单不存在')
|
||||||
|
|
||||||
@@ -126,8 +156,9 @@ class PaymentOrderReviewView(StockChangeViewMixin, views.APIView):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
refreshed = business_models.PaymentOrder.objects.select_related(
|
refreshed = (
|
||||||
'supplier', 'operator'
|
business_models.PaymentOrder.objects.select_related('supplier', 'operator', 'bank_account')
|
||||||
).get(id=payment_order.id)
|
.get(id=payment_order.id)
|
||||||
|
)
|
||||||
return Response(PaymentOrderSerializer(refreshed).data, status=status.HTTP_200_OK)
|
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):
|
class ReceiptOrderSerializer(serializers.ModelSerializer):
|
||||||
customer_name = serializers.CharField(source='customer.name', read_only=True)
|
customer_name = serializers.CharField(source='customer.name', read_only=True)
|
||||||
operator_name = serializers.CharField(source='operator.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:
|
class Meta:
|
||||||
model = business_models.ReceiptOrder
|
model = business_models.ReceiptOrder
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'customer', 'customer_name', 'receipt_date',
|
'id', 'customer', 'customer_name', 'bank_account', 'bank_account_name',
|
||||||
'amount', 'operator', 'operator_name', 'status',
|
'receipt_date', 'amount', 'discount_amount', 'settlement_amount',
|
||||||
'remarks', 'created_at', 'updated_at',
|
'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):
|
class ReceiptOrderPagination(pagination.LimitOffsetPagination):
|
||||||
@@ -35,9 +50,11 @@ class ReceiptOrderView(StockChangeViewMixin, views.APIView):
|
|||||||
if not self.check_employee_permission(request):
|
if not self.check_employee_permission(request):
|
||||||
return self.permission_error_response('无权限访问')
|
return self.permission_error_response('无权限访问')
|
||||||
merchant = request.user.employee.merchant
|
merchant = request.user.employee.merchant
|
||||||
queryset = business_models.ReceiptOrder.objects.filter(merchant=merchant).select_related(
|
queryset = (
|
||||||
'customer', 'operator'
|
business_models.ReceiptOrder.objects.filter(merchant=merchant)
|
||||||
).order_by('-created_at')
|
.select_related('customer', 'operator', 'bank_account')
|
||||||
|
.order_by('-created_at')
|
||||||
|
)
|
||||||
paginator = self.pagination_class()
|
paginator = self.pagination_class()
|
||||||
page = paginator.paginate_queryset(queryset, request, view=self)
|
page = paginator.paginate_queryset(queryset, request, view=self)
|
||||||
serializer = ReceiptOrderSerializer(page, many=True)
|
serializer = ReceiptOrderSerializer(page, many=True)
|
||||||
@@ -51,6 +68,9 @@ class ReceiptOrderView(StockChangeViewMixin, views.APIView):
|
|||||||
customer_id = data.get('customer')
|
customer_id = data.get('customer')
|
||||||
receipt_date = data.get('receipt_date')
|
receipt_date = data.get('receipt_date')
|
||||||
amount = data.get('amount')
|
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', '')
|
remarks = data.get('remarks', '')
|
||||||
|
|
||||||
if not customer_id:
|
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)
|
return Response({'error': f'客户 {customer_id} 不存在'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
operator = request.user.employee
|
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:
|
try:
|
||||||
receipt_order = business_services.create_receipt_order(
|
receipt_order = business_services.create_receipt_order(
|
||||||
@@ -73,8 +99,11 @@ class ReceiptOrderView(StockChangeViewMixin, views.APIView):
|
|||||||
customer=customer,
|
customer=customer,
|
||||||
receipt_date=receipt_date,
|
receipt_date=receipt_date,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
|
discount_amount=discount_amount,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
remarks=remarks,
|
remarks=remarks,
|
||||||
|
bank_account=bank_account,
|
||||||
|
markup=markup,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
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
|
merchant = request.user.employee.merchant
|
||||||
try:
|
try:
|
||||||
receipt_order = business_models.ReceiptOrder.objects.select_related(
|
receipt_order = (
|
||||||
'customer', 'operator'
|
business_models.ReceiptOrder.objects.select_related('customer', 'operator', 'bank_account')
|
||||||
).get(id=pk, merchant=merchant)
|
.get(id=pk, merchant=merchant)
|
||||||
|
)
|
||||||
except business_models.ReceiptOrder.DoesNotExist:
|
except business_models.ReceiptOrder.DoesNotExist:
|
||||||
return self.not_found_response('收款单不存在')
|
return self.not_found_response('收款单不存在')
|
||||||
|
|
||||||
@@ -126,8 +156,9 @@ class ReceiptOrderReviewView(StockChangeViewMixin, views.APIView):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
refreshed = business_models.ReceiptOrder.objects.select_related(
|
refreshed = (
|
||||||
'customer', 'operator'
|
business_models.ReceiptOrder.objects.select_related('customer', 'operator', 'bank_account')
|
||||||
).get(id=receipt_order.id)
|
.get(id=receipt_order.id)
|
||||||
|
)
|
||||||
return Response(ReceiptOrderSerializer(refreshed).data, status=status.HTTP_200_OK)
|
return Response(ReceiptOrderSerializer(refreshed).data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from business import services as business_services
|
||||||
|
|
||||||
|
|
||||||
class StatementRecordSerializer(serializers.Serializer):
|
class StatementRecordSerializer(serializers.Serializer):
|
||||||
source_type = serializers.CharField()
|
source_type = serializers.CharField()
|
||||||
@@ -39,3 +41,17 @@ class StatementResponseSerializer(serializers.Serializer):
|
|||||||
return data
|
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 __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 import views
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from basic_info import models as basic_models
|
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 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')
|
from . import serializers as statement_serializers
|
||||||
ZERO = Decimal('0')
|
|
||||||
|
|
||||||
|
|
||||||
class StatementViewBase(StockChangeViewMixin, views.APIView):
|
class StatementViewBase(StockChangeViewMixin, views.APIView):
|
||||||
"""
|
"""
|
||||||
提供对账单视图的公共实现:权限校验、序列化上下文及记录构建方法。
|
提供对账单视图的公共实现:权限校验、序列化上下文及响应构造。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
serializer_class = statement_serializers.StatementResponseSerializer
|
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):
|
def get_serializer_context(self):
|
||||||
return {
|
return {
|
||||||
'request': self.request,
|
'request': self.request,
|
||||||
'view': self,
|
'view': self,
|
||||||
'summary_builder': self._build_summary,
|
'summary_builder': business_services.build_statement_summary,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_response(self, counterparty, records: List[dict]):
|
def _build_response(self, payload: dict):
|
||||||
processed_records = self._attach_running_totals(records)
|
|
||||||
serializer = self.serializer_class(
|
serializer = self.serializer_class(
|
||||||
instance={
|
instance=payload,
|
||||||
'counterparty': counterparty.id,
|
|
||||||
'counterparty_name': counterparty.name,
|
|
||||||
'records': processed_records,
|
|
||||||
},
|
|
||||||
context=self.get_serializer_context(),
|
context=self.get_serializer_context(),
|
||||||
)
|
)
|
||||||
return Response(serializer.data)
|
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):
|
class CustomerStatementView(StatementViewBase):
|
||||||
"""
|
"""
|
||||||
@@ -166,115 +49,8 @@ class CustomerStatementView(StatementViewBase):
|
|||||||
except basic_models.Customer.DoesNotExist:
|
except basic_models.Customer.DoesNotExist:
|
||||||
return self.not_found_response('客户不存在')
|
return self.not_found_response('客户不存在')
|
||||||
|
|
||||||
self._get_customer_balance(merchant, customer)
|
payload = business_services.build_customer_statement(merchant=merchant, customer=customer)
|
||||||
records = self._collect_customer_records(merchant, customer)
|
return self._build_response(payload)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class SupplierStatementView(StatementViewBase):
|
class SupplierStatementView(StatementViewBase):
|
||||||
@@ -292,114 +68,56 @@ class SupplierStatementView(StatementViewBase):
|
|||||||
except basic_models.Supplier.DoesNotExist:
|
except basic_models.Supplier.DoesNotExist:
|
||||||
return self.not_found_response('供应商不存在')
|
return self.not_found_response('供应商不存在')
|
||||||
|
|
||||||
self._get_supplier_balance(merchant, supplier)
|
payload = business_services.build_supplier_statement(merchant=merchant, supplier=supplier)
|
||||||
records = self._collect_supplier_records(merchant, supplier)
|
return self._build_response(payload)
|
||||||
return self._build_response(supplier, records)
|
|
||||||
|
|
||||||
def _collect_supplier_records(self, merchant, supplier) -> List[dict]:
|
class StatementRecordView(StatementViewBase):
|
||||||
records: List[dict] = []
|
"""
|
||||||
records.extend(self._build_purchase_records(merchant, supplier))
|
根据业务主体与单据信息查询单条对账记录,返回与列表接口一致的 schema。
|
||||||
records.extend(self._build_purchase_return_records(merchant, supplier))
|
"""
|
||||||
records.extend(self._build_payment_records(merchant, supplier))
|
|
||||||
return self._sort_records(records)
|
def get(self, request):
|
||||||
|
if not self.check_employee_permission(request):
|
||||||
def _build_purchase_records(self, merchant, supplier) -> List[dict]:
|
return self.permission_error_response('无权限访问')
|
||||||
qs = (
|
|
||||||
business_models.PurchaseOrder.objects.filter(
|
serializer = statement_serializers.StatementRecordQuerySerializer(data=request.query_params)
|
||||||
merchant=merchant,
|
serializer.is_valid(raise_exception=True)
|
||||||
supplier=supplier,
|
params = serializer.validated_data
|
||||||
status=business_models.PurchaseOrderStatusEnum.APPROVED,
|
|
||||||
)
|
merchant = request.user.employee.merchant
|
||||||
.select_related('supplier')
|
counterparty_type = params['counterparty_type']
|
||||||
.prefetch_related('items__product')
|
counterparty_id = params['counterparty_id']
|
||||||
)
|
order_type = params['order_type']
|
||||||
records = []
|
order_id = params['order_id']
|
||||||
for order in qs:
|
|
||||||
items = self._aggregate_items(order.items.all())
|
if counterparty_type == 'customer':
|
||||||
records.append(
|
try:
|
||||||
self._build_record(
|
counterparty = basic_models.Customer.objects.get(id=counterparty_id, merchant=merchant)
|
||||||
counterparty_id=order.supplier_id,
|
except basic_models.Customer.DoesNotExist:
|
||||||
counterparty_name=order.supplier.name,
|
return self.not_found_response('客户不存在')
|
||||||
source_type='purchase_order',
|
payload = business_services.build_customer_statement(merchant=merchant, customer=counterparty)
|
||||||
source_label='采购单',
|
else:
|
||||||
source_id=order.id,
|
try:
|
||||||
occurred_at=order.purchase_date,
|
counterparty = basic_models.Supplier.objects.get(id=counterparty_id, merchant=merchant)
|
||||||
recorded_at=order.created_at,
|
except basic_models.Supplier.DoesNotExist:
|
||||||
status=order.status,
|
return self.not_found_response('供应商不存在')
|
||||||
status_label=order.get_status_display(),
|
payload = business_services.build_supplier_statement(merchant=merchant, supplier=counterparty)
|
||||||
positive_amount=order.get_total_amount(),
|
|
||||||
negative_amount=ZERO,
|
matched_records = [
|
||||||
items=items,
|
record
|
||||||
)
|
for record in payload.get('records', [])
|
||||||
)
|
if record['source_type'] == order_type and record['source_id'] == order_id
|
||||||
return records
|
]
|
||||||
|
|
||||||
def _build_purchase_return_records(self, merchant, supplier) -> List[dict]:
|
if not matched_records:
|
||||||
qs = (
|
return self.not_found_response('未找到匹配的对账记录')
|
||||||
business_models.PurchaseReturnOrder.objects.filter(
|
|
||||||
merchant=merchant,
|
single_payload = {
|
||||||
supplier=supplier,
|
'counterparty': payload['counterparty'],
|
||||||
status=business_models.PurchaseReturnStatusEnum.APPROVED,
|
'counterparty_name': payload['counterparty_name'],
|
||||||
)
|
'records': matched_records,
|
||||||
.select_related('supplier')
|
}
|
||||||
.prefetch_related('items__product')
|
return self._build_response(single_payload)
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,25 +4,14 @@ Printing API 序列化器
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from api_v1.models import UploadedFile
|
from api_v1.models import UploadedFile
|
||||||
|
from api_v1.utils.media import build_public_media_url
|
||||||
from printing import models
|
from printing import models
|
||||||
from .services import PrintingOrderService, PrintingJobService
|
from .services import PrintingOrderService, PrintingJobService
|
||||||
from basic_info.models import Customer, Employee
|
from basic_info.models import Customer, Employee
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
|
|
||||||
def _build_absolute_media_url(url: str | None, request):
|
def _build_absolute_media_url(url: str | None, request):
|
||||||
if not url:
|
return build_public_media_url(url, request=request)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_plate_images(raw_value, 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='付款日期')
|
payment_date = models.DateField(verbose_name='付款日期')
|
||||||
amount = models.DecimalField(max_digits=15, decimal_places=2, 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(
|
operator = models.ForeignKey(
|
||||||
basic_info_models.Employee,
|
basic_info_models.Employee,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
@@ -578,8 +593,13 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'付款单 {self.id} - {self.supplier.name}'
|
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:
|
def get_total_amount(self) -> Decimal:
|
||||||
return self.amount
|
return self.settlement_amount
|
||||||
|
|
||||||
def get_direction(self) -> int:
|
def get_direction(self) -> int:
|
||||||
return -1
|
return -1
|
||||||
@@ -610,6 +630,21 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
|
|||||||
)
|
)
|
||||||
receipt_date = models.DateField(verbose_name='收款日期')
|
receipt_date = models.DateField(verbose_name='收款日期')
|
||||||
amount = models.DecimalField(max_digits=15, decimal_places=2, 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(
|
operator = models.ForeignKey(
|
||||||
basic_info_models.Employee,
|
basic_info_models.Employee,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
@@ -630,8 +665,13 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'收款单 {self.id} - {self.customer.name}'
|
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:
|
def get_total_amount(self) -> Decimal:
|
||||||
return self.amount
|
return self.settlement_amount
|
||||||
|
|
||||||
def get_direction(self) -> int:
|
def get_direction(self) -> int:
|
||||||
return -1
|
return -1
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections import OrderedDict
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
@@ -446,12 +447,19 @@ def create_payment_order(
|
|||||||
amount,
|
amount,
|
||||||
operator: basic_info_models.Employee,
|
operator: basic_info_models.Employee,
|
||||||
remarks: str | None = '',
|
remarks: str | None = '',
|
||||||
|
bank_account: basic_info_models.BankAccount | None = None,
|
||||||
|
markup: str | None = None,
|
||||||
|
discount_amount=None,
|
||||||
) -> models.PaymentOrder:
|
) -> models.PaymentOrder:
|
||||||
"""
|
"""
|
||||||
创建付款单(资金流出)。
|
创建付款单(资金流出)。
|
||||||
"""
|
"""
|
||||||
normalized_date = _normalize_order_date(payment_date)
|
normalized_date = _normalize_order_date(payment_date)
|
||||||
normalized_amount = _ensure_positive_amount(amount, 'amount')
|
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():
|
with transaction.atomic():
|
||||||
payment_order = models.PaymentOrder.objects.create(
|
payment_order = models.PaymentOrder.objects.create(
|
||||||
@@ -461,6 +469,9 @@ def create_payment_order(
|
|||||||
amount=normalized_amount,
|
amount=normalized_amount,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
remarks=remarks,
|
remarks=remarks,
|
||||||
|
bank_account=bank_account,
|
||||||
|
markup=markup or None,
|
||||||
|
discount_amount=normalized_discount,
|
||||||
)
|
)
|
||||||
payment_order.refresh_from_db()
|
payment_order.refresh_from_db()
|
||||||
return payment_order
|
return payment_order
|
||||||
@@ -474,12 +485,19 @@ def create_receipt_order(
|
|||||||
amount,
|
amount,
|
||||||
operator: basic_info_models.Employee,
|
operator: basic_info_models.Employee,
|
||||||
remarks: str | None = '',
|
remarks: str | None = '',
|
||||||
|
bank_account: basic_info_models.BankAccount | None = None,
|
||||||
|
markup: str | None = None,
|
||||||
|
discount_amount=None,
|
||||||
) -> models.ReceiptOrder:
|
) -> models.ReceiptOrder:
|
||||||
"""
|
"""
|
||||||
创建收款单(资金流入)。
|
创建收款单(资金流入)。
|
||||||
"""
|
"""
|
||||||
normalized_date = _normalize_order_date(receipt_date)
|
normalized_date = _normalize_order_date(receipt_date)
|
||||||
normalized_amount = _ensure_positive_amount(amount, 'amount')
|
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():
|
with transaction.atomic():
|
||||||
receipt_order = models.ReceiptOrder.objects.create(
|
receipt_order = models.ReceiptOrder.objects.create(
|
||||||
@@ -489,6 +507,9 @@ def create_receipt_order(
|
|||||||
amount=normalized_amount,
|
amount=normalized_amount,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
remarks=remarks,
|
remarks=remarks,
|
||||||
|
bank_account=bank_account,
|
||||||
|
markup=markup or None,
|
||||||
|
discount_amount=normalized_discount,
|
||||||
)
|
)
|
||||||
receipt_order.refresh_from_db()
|
receipt_order.refresh_from_db()
|
||||||
return receipt_order
|
return receipt_order
|
||||||
@@ -641,7 +662,7 @@ def review_payment_order(
|
|||||||
BalanceService.adjust_supplier_balance(
|
BalanceService.adjust_supplier_balance(
|
||||||
merchant=locked.merchant,
|
merchant=locked.merchant,
|
||||||
supplier=locked.supplier,
|
supplier=locked.supplier,
|
||||||
delta=-locked.amount,
|
delta=-locked.settlement_amount,
|
||||||
source_type=models.BalanceChangeSourceEnum.PAYMENT_ORDER,
|
source_type=models.BalanceChangeSourceEnum.PAYMENT_ORDER,
|
||||||
source_id=locked.id,
|
source_id=locked.id,
|
||||||
)
|
)
|
||||||
@@ -694,7 +715,7 @@ def review_receipt_order(
|
|||||||
BalanceService.adjust_customer_balance(
|
BalanceService.adjust_customer_balance(
|
||||||
merchant=locked.merchant,
|
merchant=locked.merchant,
|
||||||
customer=locked.customer,
|
customer=locked.customer,
|
||||||
delta=-locked.amount,
|
delta=-locked.settlement_amount,
|
||||||
source_type=models.BalanceChangeSourceEnum.RECEIPT_ORDER,
|
source_type=models.BalanceChangeSourceEnum.RECEIPT_ORDER,
|
||||||
source_id=locked.id,
|
source_id=locked.id,
|
||||||
)
|
)
|
||||||
@@ -857,7 +878,7 @@ def _approve_purchase_order(
|
|||||||
created_by_id = getattr(reviewed_by, 'id', None)
|
created_by_id = getattr(reviewed_by, 'id', None)
|
||||||
if _auto_stock_task_enabled(locked_order.merchant):
|
if _auto_stock_task_enabled(locked_order.merchant):
|
||||||
logger.info('审批通过采购单 %s,触发入库任务', locked_order.id)
|
logger.info('审批通过采购单 %s,触发入库任务', locked_order.id)
|
||||||
create_purchase_order_stock_entries.delay(
|
create_purchase_order_stock_entries.delay(
|
||||||
purchase_order_id=locked_order.id,
|
purchase_order_id=locked_order.id,
|
||||||
warehouse_id=locked_order.warehouse_id,
|
warehouse_id=locked_order.warehouse_id,
|
||||||
items=stock_flow_items,
|
items=stock_flow_items,
|
||||||
@@ -1395,6 +1416,15 @@ def _ensure_positive_amount(value, field_name: str) -> Decimal:
|
|||||||
return amount
|
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:
|
def _to_positive_int(value, field_name: str) -> int:
|
||||||
if value is None:
|
if value is None:
|
||||||
raise ValueError(f'{field_name} 不能为空')
|
raise ValueError(f'{field_name} 不能为空')
|
||||||
@@ -1405,3 +1435,397 @@ def _to_positive_int(value, field_name: str) -> int:
|
|||||||
raise ValueError(f'{field_name} 必须为整数')
|
raise ValueError(f'{field_name} 必须为整数')
|
||||||
return int(decimal_value)
|
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',
|
mobile='13888888888',
|
||||||
created_by=None,
|
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):
|
def test_create_payment_order_and_approve(self):
|
||||||
order = services.create_payment_order(
|
order = services.create_payment_order(
|
||||||
@@ -711,10 +716,17 @@ class PaymentReceiptServiceTestCase(TestCase):
|
|||||||
supplier=self.supplier,
|
supplier=self.supplier,
|
||||||
payment_date=timezone.now().date(),
|
payment_date=timezone.now().date(),
|
||||||
amount='120.50',
|
amount='120.50',
|
||||||
|
discount_amount='20.50',
|
||||||
operator=self.operator,
|
operator=self.operator,
|
||||||
remarks='付款测试',
|
remarks='付款测试',
|
||||||
|
bank_account=self.bank_account,
|
||||||
|
markup='服务层附言',
|
||||||
)
|
)
|
||||||
self.assertEqual(order.status, business_models.PaymentOrderStatusEnum.PENDING)
|
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(
|
reviewed = services.review_payment_order(
|
||||||
payment_order=order,
|
payment_order=order,
|
||||||
target_status=business_models.PaymentOrderStatusEnum.APPROVED,
|
target_status=business_models.PaymentOrderStatusEnum.APPROVED,
|
||||||
@@ -725,14 +737,14 @@ class PaymentReceiptServiceTestCase(TestCase):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
supplier=self.supplier,
|
supplier=self.supplier,
|
||||||
)
|
)
|
||||||
self.assertEqual(balance.balance, Decimal('-120.50'))
|
self.assertEqual(balance.balance, Decimal('-141.00'))
|
||||||
record = business_models.BalanceChangeRecord.objects.get(
|
record = business_models.BalanceChangeRecord.objects.get(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER,
|
source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER,
|
||||||
source_id=order.id,
|
source_id=order.id,
|
||||||
)
|
)
|
||||||
self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.DECREASE)
|
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)
|
self.assertEqual(record.balance_after, balance.balance)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
services.review_payment_order(
|
services.review_payment_order(
|
||||||
@@ -747,9 +759,15 @@ class PaymentReceiptServiceTestCase(TestCase):
|
|||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
receipt_date=timezone.now().date(),
|
receipt_date=timezone.now().date(),
|
||||||
amount='80.00',
|
amount='80.00',
|
||||||
|
discount_amount='5',
|
||||||
operator=self.operator,
|
operator=self.operator,
|
||||||
remarks='收款测试',
|
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(
|
cancelled = services.review_receipt_order(
|
||||||
receipt_order=order,
|
receipt_order=order,
|
||||||
target_status=business_models.ReceiptOrderStatusEnum.CANCELLED,
|
target_status=business_models.ReceiptOrderStatusEnum.CANCELLED,
|
||||||
@@ -774,6 +792,68 @@ class PaymentReceiptServiceTestCase(TestCase):
|
|||||||
operator=self.operator,
|
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):
|
def test_receipt_approval_is_idempotent(self):
|
||||||
order = services.create_receipt_order(
|
order = services.create_receipt_order(
|
||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
|
|||||||
@@ -159,13 +159,22 @@
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"supplier": 12,
|
"supplier": 12,
|
||||||
|
"bank_account": 3,
|
||||||
"payment_date": "2025-11-30",
|
"payment_date": "2025-11-30",
|
||||||
"amount": "5000.00",
|
"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 审批逻辑
|
### 4.2 审批逻辑
|
||||||
|
|
||||||
@@ -187,12 +196,22 @@
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"customer": 6,
|
"customer": 6,
|
||||||
|
"bank_account": 3,
|
||||||
"receipt_date": "2025-11-30",
|
"receipt_date": "2025-11-30",
|
||||||
"amount": "3200.00",
|
"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 审批
|
### 5.2 审批
|
||||||
|
|
||||||
- `approve`:状态改为 `APPROVED`,客户余额 **减少** 对应金额(冲减欠款)。若已取消则拒绝再次审批。
|
- `approve`:状态改为 `APPROVED`,客户余额 **减少** 对应金额(冲减欠款)。若已取消则拒绝再次审批。
|
||||||
@@ -261,12 +280,14 @@
|
|||||||
|-----|------|------|
|
|-----|------|------|
|
||||||
| `/customers/<id>/statements/` | GET | 指定客户的销售 / 销退 / 收款对账单 |
|
| `/customers/<id>/statements/` | GET | 指定客户的销售 / 销退 / 收款对账单 |
|
||||||
| `/suppliers/<id>/statements/` | GET | 指定供应商的采购 / 采退 / 付款对账单 |
|
| `/suppliers/<id>/statements/` | GET | 指定供应商的采购 / 采退 / 付款对账单 |
|
||||||
|
| `/statements/record/` | GET | 通过主体与单据参数获取单条对账记录 |
|
||||||
|
|
||||||
关键特性:
|
关键特性:
|
||||||
|
|
||||||
- 固定按 `occurred_at -> recorded_at -> source_id` 倒序输出,不提供排序参数。
|
- 固定按 `occurred_at -> recorded_at -> source_id` 倒序输出,不提供排序参数。
|
||||||
- `positive_amount` / `negative_amount` 统一表示余额增减;`cumulative_amount`、`current_balance`、`arrears_amount` 均冗余在每条记录中,前端可直接使用。
|
- `positive_amount` / `negative_amount` 统一表示余额增减;`cumulative_amount`、`current_balance`、`arrears_amount` 均冗余在每条记录中,前端可直接使用。
|
||||||
- 余额快照来自 `BalanceService`,每次请求只查询一次,保证与审批事务一致。
|
- 余额快照来自 `BalanceService`,每次请求只查询一次,保证与审批事务一致。
|
||||||
|
- 单条查询接口需提供 `counterparty_type`、`counterparty_id`、`order_type`、`order_id` 四个 Query 参数,返回 schema 与列表一致,仅 `records` 中包含匹配记录。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,12 @@
|
|||||||
| 对方主体 | `supplier` (供应商 ID) | `customer` (客户 ID) |
|
| 对方主体 | `supplier` (供应商 ID) | `customer` (客户 ID) |
|
||||||
| 日期 | `payment_date` | `receipt_date` |
|
| 日期 | `payment_date` | `receipt_date` |
|
||||||
| 金额 | `amount`,必须 > 0 | `amount`,必须 > 0 |
|
| 金额 | `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`)与提示信息。
|
创建成功返回 `201`,包含新建单据 ID、状态(默认 `PENDING`)与提示信息。
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,18 @@
|
|||||||
|-----|------|------|
|
|-----|------|------|
|
||||||
| `/customers/<id>/statements/` | GET | 指定客户的销售、销退、收款对账单 |
|
| `/customers/<id>/statements/` | GET | 指定客户的销售、销退、收款对账单 |
|
||||||
| `/suppliers/<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_ACCESS_KEY = 'IySZff0zEN7jHeDuxj5PrQNMv0qjKoVqBRCuFMzi'
|
||||||
QINIU_SECRET_KEY = 'Mph5XkjUQO3VHFPT1JulYHY442uMOya2bXjbSZp8'
|
QINIU_SECRET_KEY = 'Mph5XkjUQO3VHFPT1JulYHY442uMOya2bXjbSZp8'
|
||||||
QINIU_BUCKET_NAME = 'yunwenerp'
|
QINIU_BUCKET_NAME = 'yunwenerp'
|
||||||
QINIU_BUCKET_DOMAIN = 't5510mjho.hn-bkt.clouddn.com'
|
QINIU_BUCKET_DOMAIN = 'image.yuwen.cloud'
|
||||||
QINIU_SECURE_URL = False
|
QINIU_SECURE_URL = False
|
||||||
|
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
|
|||||||
Reference in New Issue
Block a user