1
0
forked from erp-dev/erp

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

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

View File

@@ -1,155 +1,38 @@
from __future__ import annotations
from collections import OrderedDict
from decimal import Decimal, ROUND_HALF_UP
from typing import Iterable, List
from rest_framework import views
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from basic_info import models as basic_models
from business import models as business_models
from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
from business import services as business_services
from . import serializers as statement_serializers
from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
TWO_PLACES = Decimal('0.01')
ZERO = Decimal('0')
from . import serializers as statement_serializers
class StatementViewBase(StockChangeViewMixin, views.APIView):
"""
提供对账单视图的公共实现:权限校验、序列化上下文及记录构建方法
提供对账单视图的公共实现:权限校验、序列化上下文及响应构造
"""
permission_classes = [IsAuthenticated]
serializer_class = statement_serializers.StatementResponseSerializer
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._current_balance_display: str | None = None
self._current_balance_value: Decimal | None = None
def get_serializer_context(self):
return {
'request': self.request,
'view': self,
'summary_builder': self._build_summary,
'summary_builder': business_services.build_statement_summary,
}
def _build_response(self, counterparty, records: List[dict]):
processed_records = self._attach_running_totals(records)
def _build_response(self, payload: dict):
serializer = self.serializer_class(
instance={
'counterparty': counterparty.id,
'counterparty_name': counterparty.name,
'records': processed_records,
},
instance=payload,
context=self.get_serializer_context(),
)
return Response(serializer.data)
def _build_record(
self,
*,
counterparty_id: int,
counterparty_name: str,
source_type: str,
source_label: str,
source_id: int,
occurred_at,
recorded_at,
status: int,
status_label: str,
positive_amount,
negative_amount,
items: List[dict] | None = None,
extra: dict | None = None,
) -> dict:
items = items or []
record = {
'counterparty': counterparty_id,
'counterparty_name': counterparty_name,
'source_type': source_type,
'source_label': source_label,
'source_id': source_id,
'occurred_at': occurred_at,
'recorded_at': recorded_at,
'status': status,
'status_label': status_label,
'positive_amount': self._normalize_amount(positive_amount),
'negative_amount': self._normalize_amount(negative_amount),
'items': items,
}
if extra:
record['extra'] = extra
return record
def _aggregate_items(self, order_items) -> List[dict]:
"""Merge duplicate order items without changing the response schema."""
aggregated = OrderedDict()
for item in order_items:
product_id = getattr(item, 'product_id', None)
product_name = getattr(getattr(item, 'product', None), 'name', '')
unit = getattr(item, 'unit', '')
price = getattr(item, 'price', Decimal('0'))
key = (product_id, product_name, unit, price)
if key not in aggregated:
aggregated[key] = {
'product_id': product_id,
'product_name': product_name,
'quantity': Decimal('0'),
'price': price,
'unit': unit,
}
aggregated[key]['quantity'] += Decimal(getattr(item, 'quantity', 0))
return list(aggregated.values())
def _sort_records(self, records: Iterable[dict]) -> List[dict]:
return sorted(
records,
key=lambda item: (item['occurred_at'], item['recorded_at'], item['source_id']),
reverse=True,
)
def _normalize_amount(self, value) -> Decimal:
if isinstance(value, Decimal):
decimal_value = value
else:
decimal_value = Decimal(str(value))
return decimal_value.quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
def _decimal_to_string(self, value: Decimal) -> str:
normalized = self._normalize_amount(value)
return format(normalized, 'f')
def _build_summary(self, payload: dict):
records = payload.get('records', [])
total_positive = sum((record.get('positive_amount', ZERO) for record in records), ZERO)
total_negative = sum((record.get('negative_amount', ZERO) for record in records), ZERO)
return {
'positive_total': self._decimal_to_string(total_positive),
'negative_total': self._decimal_to_string(total_negative),
}
def _attach_running_totals(self, records: List[dict]) -> List[dict]:
running_total = ZERO
current_balance_value = self._current_balance_value or ZERO
current_balance_display = self._current_balance_display or self._decimal_to_string(current_balance_value)
processed = []
for record in records:
record_copy = dict(record)
record_copy['cumulative_amount'] = self._decimal_to_string(running_total)
record_copy['current_balance'] = current_balance_display
arrears_amount = current_balance_value - running_total
record_copy['arrears_amount'] = self._decimal_to_string(arrears_amount)
delta = record_copy['positive_amount'] - record_copy['negative_amount']
running_total += delta
processed.append(record_copy)
return processed
class CustomerStatementView(StatementViewBase):
"""
@@ -166,115 +49,8 @@ class CustomerStatementView(StatementViewBase):
except basic_models.Customer.DoesNotExist:
return self.not_found_response('客户不存在')
self._get_customer_balance(merchant, customer)
records = self._collect_customer_records(merchant, customer)
return self._build_response(customer, records)
def _collect_customer_records(self, merchant, customer) -> List[dict]:
records: List[dict] = []
records.extend(self._build_sales_records(merchant, customer))
records.extend(self._build_sales_return_records(merchant, customer))
records.extend(self._build_receipt_records(merchant, customer))
return self._sort_records(records)
def _build_sales_records(self, merchant, customer) -> List[dict]:
qs = (
business_models.SalesOrder.objects.filter(
merchant=merchant,
customer=customer,
status=business_models.SalesOrderStatusEnum.APPROVED,
)
.select_related('customer')
.prefetch_related('items__product')
)
records = []
for order in qs:
items = self._aggregate_items(order.items.all())
records.append(
self._build_record(
counterparty_id=order.customer_id,
counterparty_name=order.customer.name,
source_type='sales_order',
source_label='销售单',
source_id=order.id,
occurred_at=order.sales_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=order.get_total_amount(),
negative_amount=ZERO,
items=items,
)
)
return records
def _build_sales_return_records(self, merchant, customer) -> List[dict]:
qs = (
business_models.SalesReturnOrder.objects.filter(
merchant=merchant,
customer=customer,
status=business_models.SalesReturnStatusEnum.APPROVED,
)
.select_related('customer')
.prefetch_related('items__product')
)
records = []
for order in qs:
items = self._aggregate_items(order.items.all())
records.append(
self._build_record(
counterparty_id=order.customer_id,
counterparty_name=order.customer.name,
source_type='sales_return_order',
source_label='销售退货单',
source_id=order.id,
occurred_at=order.return_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=ZERO,
negative_amount=order.get_total_amount(),
items=items,
)
)
return records
def _build_receipt_records(self, merchant, customer) -> List[dict]:
qs = (
business_models.ReceiptOrder.objects.filter(
merchant=merchant,
customer=customer,
status=business_models.ReceiptOrderStatusEnum.APPROVED,
)
.select_related('customer')
)
records = []
for order in qs:
records.append(
self._build_record(
counterparty_id=order.customer_id,
counterparty_name=order.customer.name,
source_type='receipt_order',
source_label='收款单',
source_id=order.id,
occurred_at=order.receipt_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=ZERO,
negative_amount=order.get_total_amount(),
)
)
return records
def _get_customer_balance(self, merchant, customer):
balance = business_services.BalanceService.get_customer_balance(
merchant=merchant,
customer=customer,
)
self._current_balance_value = balance
self._current_balance_display = self._decimal_to_string(balance)
return self._current_balance_display
payload = business_services.build_customer_statement(merchant=merchant, customer=customer)
return self._build_response(payload)
class SupplierStatementView(StatementViewBase):
@@ -292,114 +68,56 @@ class SupplierStatementView(StatementViewBase):
except basic_models.Supplier.DoesNotExist:
return self.not_found_response('供应商不存在')
self._get_supplier_balance(merchant, supplier)
records = self._collect_supplier_records(merchant, supplier)
return self._build_response(supplier, records)
def _collect_supplier_records(self, merchant, supplier) -> List[dict]:
records: List[dict] = []
records.extend(self._build_purchase_records(merchant, supplier))
records.extend(self._build_purchase_return_records(merchant, supplier))
records.extend(self._build_payment_records(merchant, supplier))
return self._sort_records(records)
def _build_purchase_records(self, merchant, supplier) -> List[dict]:
qs = (
business_models.PurchaseOrder.objects.filter(
merchant=merchant,
supplier=supplier,
status=business_models.PurchaseOrderStatusEnum.APPROVED,
)
.select_related('supplier')
.prefetch_related('items__product')
)
records = []
for order in qs:
items = self._aggregate_items(order.items.all())
records.append(
self._build_record(
counterparty_id=order.supplier_id,
counterparty_name=order.supplier.name,
source_type='purchase_order',
source_label='采购单',
source_id=order.id,
occurred_at=order.purchase_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=order.get_total_amount(),
negative_amount=ZERO,
items=items,
)
)
return records
def _build_purchase_return_records(self, merchant, supplier) -> List[dict]:
qs = (
business_models.PurchaseReturnOrder.objects.filter(
merchant=merchant,
supplier=supplier,
status=business_models.PurchaseReturnStatusEnum.APPROVED,
)
.select_related('supplier')
.prefetch_related('items__product')
)
records = []
for order in qs:
items = self._aggregate_items(order.items.all())
records.append(
self._build_record(
counterparty_id=order.supplier_id,
counterparty_name=order.supplier.name,
source_type='purchase_return_order',
source_label='采购退货单',
source_id=order.id,
occurred_at=order.return_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=ZERO,
negative_amount=order.get_total_amount(),
items=items,
)
)
return records
def _build_payment_records(self, merchant, supplier) -> List[dict]:
qs = (
business_models.PaymentOrder.objects.filter(
merchant=merchant,
supplier=supplier,
status=business_models.PaymentOrderStatusEnum.APPROVED,
)
.select_related('supplier')
)
records = []
for order in qs:
records.append(
self._build_record(
counterparty_id=order.supplier_id,
counterparty_name=order.supplier.name,
source_type='payment_order',
source_label='付款单',
source_id=order.id,
occurred_at=order.payment_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=ZERO,
negative_amount=order.get_total_amount(),
)
)
return records
def _get_supplier_balance(self, merchant, supplier):
balance = business_services.BalanceService.get_supplier_balance(
merchant=merchant,
supplier=supplier,
)
self._current_balance_value = balance
self._current_balance_display = self._decimal_to_string(balance)
return self._current_balance_display
payload = business_services.build_supplier_statement(merchant=merchant, supplier=supplier)
return self._build_response(payload)
class StatementRecordView(StatementViewBase):
"""
根据业务主体与单据信息查询单条对账记录,返回与列表接口一致的 schema。
"""
def get(self, request):
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
serializer = statement_serializers.StatementRecordQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
params = serializer.validated_data
merchant = request.user.employee.merchant
counterparty_type = params['counterparty_type']
counterparty_id = params['counterparty_id']
order_type = params['order_type']
order_id = params['order_id']
if counterparty_type == 'customer':
try:
counterparty = basic_models.Customer.objects.get(id=counterparty_id, merchant=merchant)
except basic_models.Customer.DoesNotExist:
return self.not_found_response('客户不存在')
payload = business_services.build_customer_statement(merchant=merchant, customer=counterparty)
else:
try:
counterparty = basic_models.Supplier.objects.get(id=counterparty_id, merchant=merchant)
except basic_models.Supplier.DoesNotExist:
return self.not_found_response('供应商不存在')
payload = business_services.build_supplier_statement(merchant=merchant, supplier=counterparty)
matched_records = [
record
for record in payload.get('records', [])
if record['source_type'] == order_type and record['source_id'] == order_id
]
if not matched_records:
return self.not_found_response('未找到匹配的对账记录')
single_payload = {
'counterparty': payload['counterparty'],
'counterparty_name': payload['counterparty_name'],
'records': matched_records,
}
return self._build_response(single_payload)