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

@@ -0,0 +1,49 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('basic_info', '0016_merchantsetting_type'),
('business', '0014_alter_balancechangerecord_source_type_and_more'),
]
operations = [
migrations.AddField(
model_name='paymentorder',
name='bank_account',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='payment_orders',
to='basic_info.bankaccount',
verbose_name='银行账户',
),
),
migrations.AddField(
model_name='paymentorder',
name='markup',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='附言'),
),
migrations.AddField(
model_name='receiptorder',
name='bank_account',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='receipt_orders',
to='basic_info.bankaccount',
verbose_name='银行账户',
),
),
migrations.AddField(
model_name='receiptorder',
name='markup',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='附言'),
),
]

View File

@@ -0,0 +1,35 @@
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('business', '0015_payment_receipt_bank_and_markup'),
]
operations = [
migrations.AddField(
model_name='paymentorder',
name='discount_amount',
field=models.DecimalField(
decimal_places=2,
default=Decimal('0'),
max_digits=15,
verbose_name='折扣金额',
),
),
migrations.AddField(
model_name='receiptorder',
name='discount_amount',
field=models.DecimalField(
decimal_places=2,
default=Decimal('0'),
max_digits=15,
verbose_name='折扣金额',
),
),
]

View File

@@ -558,6 +558,21 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
)
payment_date = models.DateField(verbose_name='付款日期')
amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额')
discount_amount = models.DecimalField(
max_digits=15,
decimal_places=2,
default=Decimal('0'),
verbose_name='折扣金额',
)
bank_account = models.ForeignKey(
basic_info_models.BankAccount,
on_delete=models.PROTECT,
related_name='payment_orders',
verbose_name='银行账户',
blank=True,
null=True,
)
markup = models.CharField(max_length=255, blank=True, null=True, verbose_name='附言')
operator = models.ForeignKey(
basic_info_models.Employee,
on_delete=models.PROTECT,
@@ -578,8 +593,13 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
def __str__(self):
return f'付款单 {self.id} - {self.supplier.name}'
@property
def settlement_amount(self) -> Decimal:
discount = self.discount_amount or Decimal('0')
return self.amount + discount
def get_total_amount(self) -> Decimal:
return self.amount
return self.settlement_amount
def get_direction(self) -> int:
return -1
@@ -610,6 +630,21 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
)
receipt_date = models.DateField(verbose_name='收款日期')
amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额')
discount_amount = models.DecimalField(
max_digits=15,
decimal_places=2,
default=Decimal('0'),
verbose_name='折扣金额',
)
bank_account = models.ForeignKey(
basic_info_models.BankAccount,
on_delete=models.PROTECT,
related_name='receipt_orders',
verbose_name='银行账户',
blank=True,
null=True,
)
markup = models.CharField(max_length=255, blank=True, null=True, verbose_name='附言')
operator = models.ForeignKey(
basic_info_models.Employee,
on_delete=models.PROTECT,
@@ -630,8 +665,13 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
def __str__(self):
return f'收款单 {self.id} - {self.customer.name}'
@property
def settlement_amount(self) -> Decimal:
discount = self.discount_amount or Decimal('0')
return self.amount + discount
def get_total_amount(self) -> Decimal:
return self.amount
return self.settlement_amount
def get_direction(self) -> int:
return -1

View File

@@ -1,9 +1,10 @@
from __future__ import annotations
import logging
from collections import OrderedDict
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Tuple
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Any, Dict, Iterable, List, Tuple
from django.contrib.auth import get_user_model
from django.db import transaction
@@ -446,12 +447,19 @@ def create_payment_order(
amount,
operator: basic_info_models.Employee,
remarks: str | None = '',
bank_account: basic_info_models.BankAccount | None = None,
markup: str | None = None,
discount_amount=None,
) -> models.PaymentOrder:
"""
创建付款单(资金流出)。
"""
normalized_date = _normalize_order_date(payment_date)
normalized_amount = _ensure_positive_amount(amount, 'amount')
normalized_discount = _ensure_non_negative_amount(discount_amount, 'discount_amount')
if bank_account and bank_account.merchant_id != merchant.id:
raise ValueError('银行账户不属于当前商户')
with transaction.atomic():
payment_order = models.PaymentOrder.objects.create(
@@ -461,6 +469,9 @@ def create_payment_order(
amount=normalized_amount,
operator=operator,
remarks=remarks,
bank_account=bank_account,
markup=markup or None,
discount_amount=normalized_discount,
)
payment_order.refresh_from_db()
return payment_order
@@ -474,12 +485,19 @@ def create_receipt_order(
amount,
operator: basic_info_models.Employee,
remarks: str | None = '',
bank_account: basic_info_models.BankAccount | None = None,
markup: str | None = None,
discount_amount=None,
) -> models.ReceiptOrder:
"""
创建收款单(资金流入)。
"""
normalized_date = _normalize_order_date(receipt_date)
normalized_amount = _ensure_positive_amount(amount, 'amount')
normalized_discount = _ensure_non_negative_amount(discount_amount, 'discount_amount')
if bank_account and bank_account.merchant_id != merchant.id:
raise ValueError('银行账户不属于当前商户')
with transaction.atomic():
receipt_order = models.ReceiptOrder.objects.create(
@@ -489,6 +507,9 @@ def create_receipt_order(
amount=normalized_amount,
operator=operator,
remarks=remarks,
bank_account=bank_account,
markup=markup or None,
discount_amount=normalized_discount,
)
receipt_order.refresh_from_db()
return receipt_order
@@ -641,7 +662,7 @@ def review_payment_order(
BalanceService.adjust_supplier_balance(
merchant=locked.merchant,
supplier=locked.supplier,
delta=-locked.amount,
delta=-locked.settlement_amount,
source_type=models.BalanceChangeSourceEnum.PAYMENT_ORDER,
source_id=locked.id,
)
@@ -694,7 +715,7 @@ def review_receipt_order(
BalanceService.adjust_customer_balance(
merchant=locked.merchant,
customer=locked.customer,
delta=-locked.amount,
delta=-locked.settlement_amount,
source_type=models.BalanceChangeSourceEnum.RECEIPT_ORDER,
source_id=locked.id,
)
@@ -857,7 +878,7 @@ def _approve_purchase_order(
created_by_id = getattr(reviewed_by, 'id', None)
if _auto_stock_task_enabled(locked_order.merchant):
logger.info('审批通过采购单 %s,触发入库任务', locked_order.id)
create_purchase_order_stock_entries.delay(
create_purchase_order_stock_entries.delay(
purchase_order_id=locked_order.id,
warehouse_id=locked_order.warehouse_id,
items=stock_flow_items,
@@ -1395,6 +1416,15 @@ def _ensure_positive_amount(value, field_name: str) -> Decimal:
return amount
def _ensure_non_negative_amount(value, field_name: str) -> Decimal:
if value in (None, ''):
return Decimal('0')
amount = _to_decimal(value, field_name)
if amount < 0:
raise ValueError(f'{field_name} 不能小于 0')
return amount
def _to_positive_int(value, field_name: str) -> int:
if value is None:
raise ValueError(f'{field_name} 不能为空')
@@ -1405,3 +1435,397 @@ def _to_positive_int(value, field_name: str) -> int:
raise ValueError(f'{field_name} 必须为整数')
return int(decimal_value)
# ==================== Statement Builders ====================
_STATEMENT_TWO_PLACES = Decimal('0.01')
_STATEMENT_ZERO = Decimal('0')
STATEMENT_COUNTERPARTY_CHOICES = (
('customer', '客户'),
('supplier', '供应商'),
)
STATEMENT_ORDER_TYPE_CHOICES = (
('sales_order', '销售单'),
('sales_return_order', '销售退货单'),
('receipt_order', '收款单'),
('purchase_order', '采购单'),
('purchase_return_order', '采购退货单'),
('payment_order', '付款单'),
)
def build_customer_statement(
*,
merchant: basic_info_models.Merchant,
customer: basic_info_models.Customer,
) -> Dict[str, Any]:
"""
根据客户历史单据生成对账记录,供多个 API 复用。
"""
balance = BalanceService.get_customer_balance(merchant=merchant, customer=customer)
builder = _CustomerStatementBuilder(merchant=merchant, current_balance=balance)
records = builder.collect_records(customer)
return builder.build_payload(
counterparty_id=customer.id,
counterparty_name=customer.name,
records=records,
)
def build_supplier_statement(
*,
merchant: basic_info_models.Merchant,
supplier: basic_info_models.Supplier,
) -> Dict[str, Any]:
"""
根据供应商历史单据生成对账记录,供多个 API 复用。
"""
balance = BalanceService.get_supplier_balance(merchant=merchant, supplier=supplier)
builder = _SupplierStatementBuilder(merchant=merchant, current_balance=balance)
records = builder.collect_records(supplier)
return builder.build_payload(
counterparty_id=supplier.id,
counterparty_name=supplier.name,
records=records,
)
def build_statement_summary(payload: Dict[str, Any]) -> Dict[str, str]:
"""
通用汇总函数,配合 StatementResponseSerializer 的 summary 字段。
"""
records = payload.get('records', []) or []
total_positive = sum(
(record.get('positive_amount', _STATEMENT_ZERO) for record in records),
_STATEMENT_ZERO,
)
total_negative = sum(
(record.get('negative_amount', _STATEMENT_ZERO) for record in records),
_STATEMENT_ZERO,
)
return {
'positive_total': _decimal_to_string(total_positive),
'negative_total': _decimal_to_string(total_negative),
}
class _StatementBuilder:
def __init__(
self,
*,
merchant: basic_info_models.Merchant,
current_balance: Decimal,
):
self.merchant = merchant
self._current_balance_value = _normalize_statement_amount(current_balance)
self._current_balance_display = _decimal_to_string(self._current_balance_value)
def collect_records(self, counterparty) -> List[dict]: # pragma: no cover - interface only
raise NotImplementedError
def build_payload(
self,
*,
counterparty_id: int,
counterparty_name: str,
records: List[dict],
) -> Dict[str, Any]:
sorted_records = self._sort_records(records)
processed_records = self._attach_running_totals(sorted_records)
return {
'counterparty': counterparty_id,
'counterparty_name': counterparty_name,
'records': processed_records,
}
def _build_record(
self,
*,
counterparty_id: int,
counterparty_name: str,
source_type: str,
source_label: str,
source_id: int,
occurred_at,
recorded_at,
status: int,
status_label: str,
positive_amount,
negative_amount,
items: List[dict] | None = None,
extra: dict | None = None,
) -> dict:
items = items or []
record = {
'counterparty': counterparty_id,
'counterparty_name': counterparty_name,
'source_type': source_type,
'source_label': source_label,
'source_id': source_id,
'occurred_at': occurred_at,
'recorded_at': recorded_at,
'status': status,
'status_label': status_label,
'positive_amount': _normalize_statement_amount(positive_amount),
'negative_amount': _normalize_statement_amount(negative_amount),
'items': items,
}
if extra:
record['extra'] = extra
return record
def _aggregate_items(self, order_items) -> List[dict]:
aggregated: OrderedDict[tuple, dict] = OrderedDict()
for item in order_items:
product = getattr(item, 'product', None)
product_id = getattr(item, 'product_id', None)
product_name = getattr(product, 'name', '')
unit = getattr(item, 'unit', '')
price = getattr(item, 'price', Decimal('0'))
key = (product_id, product_name, unit, price)
if key not in aggregated:
aggregated[key] = {
'product_id': product_id,
'product_name': product_name,
'quantity': Decimal('0'),
'price': price,
'unit': unit,
}
quantity_value = getattr(item, 'quantity', 0) or 0
aggregated[key]['quantity'] += Decimal(str(quantity_value))
return list(aggregated.values())
def _sort_records(self, records: Iterable[dict]) -> List[dict]:
return sorted(
records,
key=lambda item: (item['occurred_at'], item['recorded_at'], item['source_id']),
reverse=True,
)
def _attach_running_totals(self, records: List[dict]) -> List[dict]:
running_total = _STATEMENT_ZERO
processed: List[dict] = []
for record in records:
record_copy = dict(record)
record_copy['cumulative_amount'] = _decimal_to_string(running_total)
record_copy['current_balance'] = self._current_balance_display
arrears_amount = self._current_balance_value - running_total
record_copy['arrears_amount'] = _decimal_to_string(arrears_amount)
delta = record_copy['positive_amount'] - record_copy['negative_amount']
running_total += delta
processed.append(record_copy)
return processed
class _CustomerStatementBuilder(_StatementBuilder):
def collect_records(self, customer: basic_info_models.Customer) -> List[dict]:
records: List[dict] = []
records.extend(self._build_sales_records(customer))
records.extend(self._build_sales_return_records(customer))
records.extend(self._build_receipt_records(customer))
return records
def _build_sales_records(self, customer: basic_info_models.Customer) -> List[dict]:
qs = (
models.SalesOrder.objects.filter(
merchant=self.merchant,
customer=customer,
status=models.SalesOrderStatusEnum.APPROVED,
)
.select_related('customer')
.prefetch_related('items__product')
)
records = []
for order in qs:
items = self._aggregate_items(order.items.all())
records.append(
self._build_record(
counterparty_id=order.customer_id,
counterparty_name=order.customer.name,
source_type='sales_order',
source_label='销售单',
source_id=order.id,
occurred_at=order.sales_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=order.get_total_amount(),
negative_amount=_STATEMENT_ZERO,
items=items,
)
)
return records
def _build_sales_return_records(self, customer: basic_info_models.Customer) -> List[dict]:
qs = (
models.SalesReturnOrder.objects.filter(
merchant=self.merchant,
customer=customer,
status=models.SalesReturnStatusEnum.APPROVED,
)
.select_related('customer')
.prefetch_related('items__product')
)
records = []
for order in qs:
items = self._aggregate_items(order.items.all())
records.append(
self._build_record(
counterparty_id=order.customer_id,
counterparty_name=order.customer.name,
source_type='sales_return_order',
source_label='销售退货单',
source_id=order.id,
occurred_at=order.return_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=_STATEMENT_ZERO,
negative_amount=order.get_total_amount(),
items=items,
)
)
return records
def _build_receipt_records(self, customer: basic_info_models.Customer) -> List[dict]:
qs = (
models.ReceiptOrder.objects.filter(
merchant=self.merchant,
customer=customer,
status=models.ReceiptOrderStatusEnum.APPROVED,
)
.select_related('customer')
)
records = []
for order in qs:
records.append(
self._build_record(
counterparty_id=order.customer_id,
counterparty_name=order.customer.name,
source_type='receipt_order',
source_label='收款单',
source_id=order.id,
occurred_at=order.receipt_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=_STATEMENT_ZERO,
negative_amount=order.get_total_amount(),
)
)
return records
class _SupplierStatementBuilder(_StatementBuilder):
def collect_records(self, supplier: basic_info_models.Supplier) -> List[dict]:
records: List[dict] = []
records.extend(self._build_purchase_records(supplier))
records.extend(self._build_purchase_return_records(supplier))
records.extend(self._build_payment_records(supplier))
return records
def _build_purchase_records(self, supplier: basic_info_models.Supplier) -> List[dict]:
qs = (
models.PurchaseOrder.objects.filter(
merchant=self.merchant,
supplier=supplier,
status=models.PurchaseOrderStatusEnum.APPROVED,
)
.select_related('supplier')
.prefetch_related('items__product')
)
records = []
for order in qs:
items = self._aggregate_items(order.items.all())
records.append(
self._build_record(
counterparty_id=order.supplier_id,
counterparty_name=order.supplier.name,
source_type='purchase_order',
source_label='采购单',
source_id=order.id,
occurred_at=order.purchase_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=order.get_total_amount(),
negative_amount=_STATEMENT_ZERO,
items=items,
)
)
return records
def _build_purchase_return_records(self, supplier: basic_info_models.Supplier) -> List[dict]:
qs = (
models.PurchaseReturnOrder.objects.filter(
merchant=self.merchant,
supplier=supplier,
status=models.PurchaseReturnStatusEnum.APPROVED,
)
.select_related('supplier')
.prefetch_related('items__product')
)
records = []
for order in qs:
items = self._aggregate_items(order.items.all())
records.append(
self._build_record(
counterparty_id=order.supplier_id,
counterparty_name=order.supplier.name,
source_type='purchase_return_order',
source_label='采购退货单',
source_id=order.id,
occurred_at=order.return_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=_STATEMENT_ZERO,
negative_amount=order.get_total_amount(),
items=items,
)
)
return records
def _build_payment_records(self, supplier: basic_info_models.Supplier) -> List[dict]:
qs = (
models.PaymentOrder.objects.filter(
merchant=self.merchant,
supplier=supplier,
status=models.PaymentOrderStatusEnum.APPROVED,
)
.select_related('supplier')
)
records = []
for order in qs:
records.append(
self._build_record(
counterparty_id=order.supplier_id,
counterparty_name=order.supplier.name,
source_type='payment_order',
source_label='付款单',
source_id=order.id,
occurred_at=order.payment_date,
recorded_at=order.created_at,
status=order.status,
status_label=order.get_status_display(),
positive_amount=_STATEMENT_ZERO,
negative_amount=order.get_total_amount(),
)
)
return records
def _normalize_statement_amount(value) -> Decimal:
if isinstance(value, Decimal):
decimal_value = value
else:
decimal_value = Decimal(str(value))
return decimal_value.quantize(_STATEMENT_TWO_PLACES, rounding=ROUND_HALF_UP)
def _decimal_to_string(value: Decimal) -> str:
normalized = _normalize_statement_amount(value)
return format(normalized, 'f')

View File

@@ -704,6 +704,11 @@ class PaymentReceiptServiceTestCase(TestCase):
mobile='13888888888',
created_by=None,
)
self.bank_account = basic_models.BankAccount.objects.create(
merchant=self.merchant,
name='测试账户',
auto_number='BANK-SVC',
)
def test_create_payment_order_and_approve(self):
order = services.create_payment_order(
@@ -711,10 +716,17 @@ class PaymentReceiptServiceTestCase(TestCase):
supplier=self.supplier,
payment_date=timezone.now().date(),
amount='120.50',
discount_amount='20.50',
operator=self.operator,
remarks='付款测试',
bank_account=self.bank_account,
markup='服务层附言',
)
self.assertEqual(order.status, business_models.PaymentOrderStatusEnum.PENDING)
self.assertEqual(order.bank_account, self.bank_account)
self.assertEqual(order.markup, '服务层附言')
self.assertEqual(order.discount_amount, Decimal('20.50'))
self.assertEqual(order.settlement_amount, Decimal('141.00'))
reviewed = services.review_payment_order(
payment_order=order,
target_status=business_models.PaymentOrderStatusEnum.APPROVED,
@@ -725,14 +737,14 @@ class PaymentReceiptServiceTestCase(TestCase):
merchant=self.merchant,
supplier=self.supplier,
)
self.assertEqual(balance.balance, Decimal('-120.50'))
self.assertEqual(balance.balance, Decimal('-141.00'))
record = business_models.BalanceChangeRecord.objects.get(
merchant=self.merchant,
source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER,
source_id=order.id,
)
self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.DECREASE)
self.assertEqual(record.delta, Decimal('-120.50'))
self.assertEqual(record.delta, Decimal('-141.00'))
self.assertEqual(record.balance_after, balance.balance)
with self.assertRaises(ValueError):
services.review_payment_order(
@@ -747,9 +759,15 @@ class PaymentReceiptServiceTestCase(TestCase):
customer=self.customer,
receipt_date=timezone.now().date(),
amount='80.00',
discount_amount='5',
operator=self.operator,
remarks='收款测试',
bank_account=self.bank_account,
markup='收款附言',
)
self.assertEqual(order.bank_account, self.bank_account)
self.assertEqual(order.discount_amount, Decimal('5'))
self.assertEqual(order.settlement_amount, Decimal('85'))
cancelled = services.review_receipt_order(
receipt_order=order,
target_status=business_models.ReceiptOrderStatusEnum.CANCELLED,
@@ -774,6 +792,68 @@ class PaymentReceiptServiceTestCase(TestCase):
operator=self.operator,
)
def test_payment_discount_can_exceed_amount(self):
order = services.create_payment_order(
merchant=self.merchant,
supplier=self.supplier,
payment_date=timezone.now().date(),
amount='50',
discount_amount='60',
operator=self.operator,
)
self.assertEqual(order.settlement_amount, Decimal('110'))
def test_receipt_discount_can_exceed_amount(self):
order = services.create_receipt_order(
merchant=self.merchant,
customer=self.customer,
receipt_date=timezone.now().date(),
amount='50',
discount_amount='60',
operator=self.operator,
)
self.assertEqual(order.settlement_amount, Decimal('110'))
def test_payment_order_rejects_foreign_bank_account(self):
other_merchant = basic_models.Merchant.objects.create(
name='无关商户',
type=basic_models.MerchantTypeEnum.FACTORY,
)
foreign_bank = basic_models.BankAccount.objects.create(
merchant=other_merchant,
name='其他账户',
auto_number='BANK-FOREIGN',
)
with self.assertRaises(ValueError):
services.create_payment_order(
merchant=self.merchant,
supplier=self.supplier,
payment_date=timezone.now().date(),
amount='10',
operator=self.operator,
bank_account=foreign_bank,
)
def test_receipt_order_rejects_foreign_bank_account(self):
other_merchant = basic_models.Merchant.objects.create(
name='第二商户',
type=basic_models.MerchantTypeEnum.FACTORY,
)
foreign_bank = basic_models.BankAccount.objects.create(
merchant=other_merchant,
name='第二账户',
auto_number='BANK-SECOND',
)
with self.assertRaises(ValueError):
services.create_receipt_order(
merchant=self.merchant,
customer=self.customer,
receipt_date=timezone.now().date(),
amount='10',
operator=self.operator,
bank_account=foreign_bank,
)
def test_receipt_approval_is_idempotent(self):
order = services.create_receipt_order(
merchant=self.merchant,