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:
49
business/migrations/0015_payment_receipt_bank_and_markup.py
Normal file
49
business/migrations/0015_payment_receipt_bank_and_markup.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('basic_info', '0016_merchantsetting_type'),
|
||||
('business', '0014_alter_balancechangerecord_source_type_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='paymentorder',
|
||||
name='bank_account',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='payment_orders',
|
||||
to='basic_info.bankaccount',
|
||||
verbose_name='银行账户',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paymentorder',
|
||||
name='markup',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='附言'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='receiptorder',
|
||||
name='bank_account',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='receipt_orders',
|
||||
to='basic_info.bankaccount',
|
||||
verbose_name='银行账户',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='receiptorder',
|
||||
name='markup',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='附言'),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
35
business/migrations/0016_payment_receipt_discount.py
Normal file
35
business/migrations/0016_payment_receipt_discount.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('business', '0015_payment_receipt_bank_and_markup'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='paymentorder',
|
||||
name='discount_amount',
|
||||
field=models.DecimalField(
|
||||
decimal_places=2,
|
||||
default=Decimal('0'),
|
||||
max_digits=15,
|
||||
verbose_name='折扣金额',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='receiptorder',
|
||||
name='discount_amount',
|
||||
field=models.DecimalField(
|
||||
decimal_places=2,
|
||||
default=Decimal('0'),
|
||||
max_digits=15,
|
||||
verbose_name='折扣金额',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -558,6 +558,21 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
|
||||
)
|
||||
payment_date = models.DateField(verbose_name='付款日期')
|
||||
amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额')
|
||||
discount_amount = models.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=2,
|
||||
default=Decimal('0'),
|
||||
verbose_name='折扣金额',
|
||||
)
|
||||
bank_account = models.ForeignKey(
|
||||
basic_info_models.BankAccount,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='payment_orders',
|
||||
verbose_name='银行账户',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
markup = models.CharField(max_length=255, blank=True, null=True, verbose_name='附言')
|
||||
operator = models.ForeignKey(
|
||||
basic_info_models.Employee,
|
||||
on_delete=models.PROTECT,
|
||||
@@ -578,8 +593,13 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
|
||||
def __str__(self):
|
||||
return f'付款单 {self.id} - {self.supplier.name}'
|
||||
|
||||
@property
|
||||
def settlement_amount(self) -> Decimal:
|
||||
discount = self.discount_amount or Decimal('0')
|
||||
return self.amount + discount
|
||||
|
||||
def get_total_amount(self) -> Decimal:
|
||||
return self.amount
|
||||
return self.settlement_amount
|
||||
|
||||
def get_direction(self) -> int:
|
||||
return -1
|
||||
@@ -610,6 +630,21 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
|
||||
)
|
||||
receipt_date = models.DateField(verbose_name='收款日期')
|
||||
amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额')
|
||||
discount_amount = models.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=2,
|
||||
default=Decimal('0'),
|
||||
verbose_name='折扣金额',
|
||||
)
|
||||
bank_account = models.ForeignKey(
|
||||
basic_info_models.BankAccount,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='receipt_orders',
|
||||
verbose_name='银行账户',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
markup = models.CharField(max_length=255, blank=True, null=True, verbose_name='附言')
|
||||
operator = models.ForeignKey(
|
||||
basic_info_models.Employee,
|
||||
on_delete=models.PROTECT,
|
||||
@@ -630,8 +665,13 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
|
||||
def __str__(self):
|
||||
return f'收款单 {self.id} - {self.customer.name}'
|
||||
|
||||
@property
|
||||
def settlement_amount(self) -> Decimal:
|
||||
discount = self.discount_amount or Decimal('0')
|
||||
return self.amount + discount
|
||||
|
||||
def get_total_amount(self) -> Decimal:
|
||||
return self.amount
|
||||
return self.settlement_amount
|
||||
|
||||
def get_direction(self) -> int:
|
||||
return -1
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
@@ -446,12 +447,19 @@ def create_payment_order(
|
||||
amount,
|
||||
operator: basic_info_models.Employee,
|
||||
remarks: str | None = '',
|
||||
bank_account: basic_info_models.BankAccount | None = None,
|
||||
markup: str | None = None,
|
||||
discount_amount=None,
|
||||
) -> models.PaymentOrder:
|
||||
"""
|
||||
创建付款单(资金流出)。
|
||||
"""
|
||||
normalized_date = _normalize_order_date(payment_date)
|
||||
normalized_amount = _ensure_positive_amount(amount, 'amount')
|
||||
normalized_discount = _ensure_non_negative_amount(discount_amount, 'discount_amount')
|
||||
|
||||
if bank_account and bank_account.merchant_id != merchant.id:
|
||||
raise ValueError('银行账户不属于当前商户')
|
||||
|
||||
with transaction.atomic():
|
||||
payment_order = models.PaymentOrder.objects.create(
|
||||
@@ -461,6 +469,9 @@ def create_payment_order(
|
||||
amount=normalized_amount,
|
||||
operator=operator,
|
||||
remarks=remarks,
|
||||
bank_account=bank_account,
|
||||
markup=markup or None,
|
||||
discount_amount=normalized_discount,
|
||||
)
|
||||
payment_order.refresh_from_db()
|
||||
return payment_order
|
||||
@@ -474,12 +485,19 @@ def create_receipt_order(
|
||||
amount,
|
||||
operator: basic_info_models.Employee,
|
||||
remarks: str | None = '',
|
||||
bank_account: basic_info_models.BankAccount | None = None,
|
||||
markup: str | None = None,
|
||||
discount_amount=None,
|
||||
) -> models.ReceiptOrder:
|
||||
"""
|
||||
创建收款单(资金流入)。
|
||||
"""
|
||||
normalized_date = _normalize_order_date(receipt_date)
|
||||
normalized_amount = _ensure_positive_amount(amount, 'amount')
|
||||
normalized_discount = _ensure_non_negative_amount(discount_amount, 'discount_amount')
|
||||
|
||||
if bank_account and bank_account.merchant_id != merchant.id:
|
||||
raise ValueError('银行账户不属于当前商户')
|
||||
|
||||
with transaction.atomic():
|
||||
receipt_order = models.ReceiptOrder.objects.create(
|
||||
@@ -489,6 +507,9 @@ def create_receipt_order(
|
||||
amount=normalized_amount,
|
||||
operator=operator,
|
||||
remarks=remarks,
|
||||
bank_account=bank_account,
|
||||
markup=markup or None,
|
||||
discount_amount=normalized_discount,
|
||||
)
|
||||
receipt_order.refresh_from_db()
|
||||
return receipt_order
|
||||
@@ -641,7 +662,7 @@ def review_payment_order(
|
||||
BalanceService.adjust_supplier_balance(
|
||||
merchant=locked.merchant,
|
||||
supplier=locked.supplier,
|
||||
delta=-locked.amount,
|
||||
delta=-locked.settlement_amount,
|
||||
source_type=models.BalanceChangeSourceEnum.PAYMENT_ORDER,
|
||||
source_id=locked.id,
|
||||
)
|
||||
@@ -694,7 +715,7 @@ def review_receipt_order(
|
||||
BalanceService.adjust_customer_balance(
|
||||
merchant=locked.merchant,
|
||||
customer=locked.customer,
|
||||
delta=-locked.amount,
|
||||
delta=-locked.settlement_amount,
|
||||
source_type=models.BalanceChangeSourceEnum.RECEIPT_ORDER,
|
||||
source_id=locked.id,
|
||||
)
|
||||
@@ -857,7 +878,7 @@ def _approve_purchase_order(
|
||||
created_by_id = getattr(reviewed_by, 'id', None)
|
||||
if _auto_stock_task_enabled(locked_order.merchant):
|
||||
logger.info('审批通过采购单 %s,触发入库任务', locked_order.id)
|
||||
create_purchase_order_stock_entries.delay(
|
||||
create_purchase_order_stock_entries.delay(
|
||||
purchase_order_id=locked_order.id,
|
||||
warehouse_id=locked_order.warehouse_id,
|
||||
items=stock_flow_items,
|
||||
@@ -1395,6 +1416,15 @@ def _ensure_positive_amount(value, field_name: str) -> Decimal:
|
||||
return amount
|
||||
|
||||
|
||||
def _ensure_non_negative_amount(value, field_name: str) -> Decimal:
|
||||
if value in (None, ''):
|
||||
return Decimal('0')
|
||||
amount = _to_decimal(value, field_name)
|
||||
if amount < 0:
|
||||
raise ValueError(f'{field_name} 不能小于 0')
|
||||
return amount
|
||||
|
||||
|
||||
def _to_positive_int(value, field_name: str) -> int:
|
||||
if value is None:
|
||||
raise ValueError(f'{field_name} 不能为空')
|
||||
@@ -1405,3 +1435,397 @@ def _to_positive_int(value, field_name: str) -> int:
|
||||
raise ValueError(f'{field_name} 必须为整数')
|
||||
return int(decimal_value)
|
||||
|
||||
|
||||
# ==================== Statement Builders ====================
|
||||
|
||||
_STATEMENT_TWO_PLACES = Decimal('0.01')
|
||||
_STATEMENT_ZERO = Decimal('0')
|
||||
|
||||
STATEMENT_COUNTERPARTY_CHOICES = (
|
||||
('customer', '客户'),
|
||||
('supplier', '供应商'),
|
||||
)
|
||||
|
||||
STATEMENT_ORDER_TYPE_CHOICES = (
|
||||
('sales_order', '销售单'),
|
||||
('sales_return_order', '销售退货单'),
|
||||
('receipt_order', '收款单'),
|
||||
('purchase_order', '采购单'),
|
||||
('purchase_return_order', '采购退货单'),
|
||||
('payment_order', '付款单'),
|
||||
)
|
||||
|
||||
|
||||
def build_customer_statement(
|
||||
*,
|
||||
merchant: basic_info_models.Merchant,
|
||||
customer: basic_info_models.Customer,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
根据客户历史单据生成对账记录,供多个 API 复用。
|
||||
"""
|
||||
balance = BalanceService.get_customer_balance(merchant=merchant, customer=customer)
|
||||
builder = _CustomerStatementBuilder(merchant=merchant, current_balance=balance)
|
||||
records = builder.collect_records(customer)
|
||||
return builder.build_payload(
|
||||
counterparty_id=customer.id,
|
||||
counterparty_name=customer.name,
|
||||
records=records,
|
||||
)
|
||||
|
||||
|
||||
def build_supplier_statement(
|
||||
*,
|
||||
merchant: basic_info_models.Merchant,
|
||||
supplier: basic_info_models.Supplier,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
根据供应商历史单据生成对账记录,供多个 API 复用。
|
||||
"""
|
||||
balance = BalanceService.get_supplier_balance(merchant=merchant, supplier=supplier)
|
||||
builder = _SupplierStatementBuilder(merchant=merchant, current_balance=balance)
|
||||
records = builder.collect_records(supplier)
|
||||
return builder.build_payload(
|
||||
counterparty_id=supplier.id,
|
||||
counterparty_name=supplier.name,
|
||||
records=records,
|
||||
)
|
||||
|
||||
|
||||
def build_statement_summary(payload: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""
|
||||
通用汇总函数,配合 StatementResponseSerializer 的 summary 字段。
|
||||
"""
|
||||
records = payload.get('records', []) or []
|
||||
total_positive = sum(
|
||||
(record.get('positive_amount', _STATEMENT_ZERO) for record in records),
|
||||
_STATEMENT_ZERO,
|
||||
)
|
||||
total_negative = sum(
|
||||
(record.get('negative_amount', _STATEMENT_ZERO) for record in records),
|
||||
_STATEMENT_ZERO,
|
||||
)
|
||||
return {
|
||||
'positive_total': _decimal_to_string(total_positive),
|
||||
'negative_total': _decimal_to_string(total_negative),
|
||||
}
|
||||
|
||||
|
||||
class _StatementBuilder:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
merchant: basic_info_models.Merchant,
|
||||
current_balance: Decimal,
|
||||
):
|
||||
self.merchant = merchant
|
||||
self._current_balance_value = _normalize_statement_amount(current_balance)
|
||||
self._current_balance_display = _decimal_to_string(self._current_balance_value)
|
||||
|
||||
def collect_records(self, counterparty) -> List[dict]: # pragma: no cover - interface only
|
||||
raise NotImplementedError
|
||||
|
||||
def build_payload(
|
||||
self,
|
||||
*,
|
||||
counterparty_id: int,
|
||||
counterparty_name: str,
|
||||
records: List[dict],
|
||||
) -> Dict[str, Any]:
|
||||
sorted_records = self._sort_records(records)
|
||||
processed_records = self._attach_running_totals(sorted_records)
|
||||
return {
|
||||
'counterparty': counterparty_id,
|
||||
'counterparty_name': counterparty_name,
|
||||
'records': processed_records,
|
||||
}
|
||||
|
||||
def _build_record(
|
||||
self,
|
||||
*,
|
||||
counterparty_id: int,
|
||||
counterparty_name: str,
|
||||
source_type: str,
|
||||
source_label: str,
|
||||
source_id: int,
|
||||
occurred_at,
|
||||
recorded_at,
|
||||
status: int,
|
||||
status_label: str,
|
||||
positive_amount,
|
||||
negative_amount,
|
||||
items: List[dict] | None = None,
|
||||
extra: dict | None = None,
|
||||
) -> dict:
|
||||
items = items or []
|
||||
record = {
|
||||
'counterparty': counterparty_id,
|
||||
'counterparty_name': counterparty_name,
|
||||
'source_type': source_type,
|
||||
'source_label': source_label,
|
||||
'source_id': source_id,
|
||||
'occurred_at': occurred_at,
|
||||
'recorded_at': recorded_at,
|
||||
'status': status,
|
||||
'status_label': status_label,
|
||||
'positive_amount': _normalize_statement_amount(positive_amount),
|
||||
'negative_amount': _normalize_statement_amount(negative_amount),
|
||||
'items': items,
|
||||
}
|
||||
if extra:
|
||||
record['extra'] = extra
|
||||
return record
|
||||
|
||||
def _aggregate_items(self, order_items) -> List[dict]:
|
||||
aggregated: OrderedDict[tuple, dict] = OrderedDict()
|
||||
for item in order_items:
|
||||
product = getattr(item, 'product', None)
|
||||
product_id = getattr(item, 'product_id', None)
|
||||
product_name = getattr(product, 'name', '')
|
||||
unit = getattr(item, 'unit', '')
|
||||
price = getattr(item, 'price', Decimal('0'))
|
||||
key = (product_id, product_name, unit, price)
|
||||
if key not in aggregated:
|
||||
aggregated[key] = {
|
||||
'product_id': product_id,
|
||||
'product_name': product_name,
|
||||
'quantity': Decimal('0'),
|
||||
'price': price,
|
||||
'unit': unit,
|
||||
}
|
||||
quantity_value = getattr(item, 'quantity', 0) or 0
|
||||
aggregated[key]['quantity'] += Decimal(str(quantity_value))
|
||||
return list(aggregated.values())
|
||||
|
||||
def _sort_records(self, records: Iterable[dict]) -> List[dict]:
|
||||
return sorted(
|
||||
records,
|
||||
key=lambda item: (item['occurred_at'], item['recorded_at'], item['source_id']),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def _attach_running_totals(self, records: List[dict]) -> List[dict]:
|
||||
running_total = _STATEMENT_ZERO
|
||||
processed: List[dict] = []
|
||||
for record in records:
|
||||
record_copy = dict(record)
|
||||
record_copy['cumulative_amount'] = _decimal_to_string(running_total)
|
||||
record_copy['current_balance'] = self._current_balance_display
|
||||
arrears_amount = self._current_balance_value - running_total
|
||||
record_copy['arrears_amount'] = _decimal_to_string(arrears_amount)
|
||||
delta = record_copy['positive_amount'] - record_copy['negative_amount']
|
||||
running_total += delta
|
||||
processed.append(record_copy)
|
||||
return processed
|
||||
|
||||
|
||||
class _CustomerStatementBuilder(_StatementBuilder):
|
||||
def collect_records(self, customer: basic_info_models.Customer) -> List[dict]:
|
||||
records: List[dict] = []
|
||||
records.extend(self._build_sales_records(customer))
|
||||
records.extend(self._build_sales_return_records(customer))
|
||||
records.extend(self._build_receipt_records(customer))
|
||||
return records
|
||||
|
||||
def _build_sales_records(self, customer: basic_info_models.Customer) -> List[dict]:
|
||||
qs = (
|
||||
models.SalesOrder.objects.filter(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
status=models.SalesOrderStatusEnum.APPROVED,
|
||||
)
|
||||
.select_related('customer')
|
||||
.prefetch_related('items__product')
|
||||
)
|
||||
records = []
|
||||
for order in qs:
|
||||
items = self._aggregate_items(order.items.all())
|
||||
records.append(
|
||||
self._build_record(
|
||||
counterparty_id=order.customer_id,
|
||||
counterparty_name=order.customer.name,
|
||||
source_type='sales_order',
|
||||
source_label='销售单',
|
||||
source_id=order.id,
|
||||
occurred_at=order.sales_date,
|
||||
recorded_at=order.created_at,
|
||||
status=order.status,
|
||||
status_label=order.get_status_display(),
|
||||
positive_amount=order.get_total_amount(),
|
||||
negative_amount=_STATEMENT_ZERO,
|
||||
items=items,
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
def _build_sales_return_records(self, customer: basic_info_models.Customer) -> List[dict]:
|
||||
qs = (
|
||||
models.SalesReturnOrder.objects.filter(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
status=models.SalesReturnStatusEnum.APPROVED,
|
||||
)
|
||||
.select_related('customer')
|
||||
.prefetch_related('items__product')
|
||||
)
|
||||
records = []
|
||||
for order in qs:
|
||||
items = self._aggregate_items(order.items.all())
|
||||
records.append(
|
||||
self._build_record(
|
||||
counterparty_id=order.customer_id,
|
||||
counterparty_name=order.customer.name,
|
||||
source_type='sales_return_order',
|
||||
source_label='销售退货单',
|
||||
source_id=order.id,
|
||||
occurred_at=order.return_date,
|
||||
recorded_at=order.created_at,
|
||||
status=order.status,
|
||||
status_label=order.get_status_display(),
|
||||
positive_amount=_STATEMENT_ZERO,
|
||||
negative_amount=order.get_total_amount(),
|
||||
items=items,
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
def _build_receipt_records(self, customer: basic_info_models.Customer) -> List[dict]:
|
||||
qs = (
|
||||
models.ReceiptOrder.objects.filter(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
status=models.ReceiptOrderStatusEnum.APPROVED,
|
||||
)
|
||||
.select_related('customer')
|
||||
)
|
||||
records = []
|
||||
for order in qs:
|
||||
records.append(
|
||||
self._build_record(
|
||||
counterparty_id=order.customer_id,
|
||||
counterparty_name=order.customer.name,
|
||||
source_type='receipt_order',
|
||||
source_label='收款单',
|
||||
source_id=order.id,
|
||||
occurred_at=order.receipt_date,
|
||||
recorded_at=order.created_at,
|
||||
status=order.status,
|
||||
status_label=order.get_status_display(),
|
||||
positive_amount=_STATEMENT_ZERO,
|
||||
negative_amount=order.get_total_amount(),
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
class _SupplierStatementBuilder(_StatementBuilder):
|
||||
def collect_records(self, supplier: basic_info_models.Supplier) -> List[dict]:
|
||||
records: List[dict] = []
|
||||
records.extend(self._build_purchase_records(supplier))
|
||||
records.extend(self._build_purchase_return_records(supplier))
|
||||
records.extend(self._build_payment_records(supplier))
|
||||
return records
|
||||
|
||||
def _build_purchase_records(self, supplier: basic_info_models.Supplier) -> List[dict]:
|
||||
qs = (
|
||||
models.PurchaseOrder.objects.filter(
|
||||
merchant=self.merchant,
|
||||
supplier=supplier,
|
||||
status=models.PurchaseOrderStatusEnum.APPROVED,
|
||||
)
|
||||
.select_related('supplier')
|
||||
.prefetch_related('items__product')
|
||||
)
|
||||
records = []
|
||||
for order in qs:
|
||||
items = self._aggregate_items(order.items.all())
|
||||
records.append(
|
||||
self._build_record(
|
||||
counterparty_id=order.supplier_id,
|
||||
counterparty_name=order.supplier.name,
|
||||
source_type='purchase_order',
|
||||
source_label='采购单',
|
||||
source_id=order.id,
|
||||
occurred_at=order.purchase_date,
|
||||
recorded_at=order.created_at,
|
||||
status=order.status,
|
||||
status_label=order.get_status_display(),
|
||||
positive_amount=order.get_total_amount(),
|
||||
negative_amount=_STATEMENT_ZERO,
|
||||
items=items,
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
def _build_purchase_return_records(self, supplier: basic_info_models.Supplier) -> List[dict]:
|
||||
qs = (
|
||||
models.PurchaseReturnOrder.objects.filter(
|
||||
merchant=self.merchant,
|
||||
supplier=supplier,
|
||||
status=models.PurchaseReturnStatusEnum.APPROVED,
|
||||
)
|
||||
.select_related('supplier')
|
||||
.prefetch_related('items__product')
|
||||
)
|
||||
records = []
|
||||
for order in qs:
|
||||
items = self._aggregate_items(order.items.all())
|
||||
records.append(
|
||||
self._build_record(
|
||||
counterparty_id=order.supplier_id,
|
||||
counterparty_name=order.supplier.name,
|
||||
source_type='purchase_return_order',
|
||||
source_label='采购退货单',
|
||||
source_id=order.id,
|
||||
occurred_at=order.return_date,
|
||||
recorded_at=order.created_at,
|
||||
status=order.status,
|
||||
status_label=order.get_status_display(),
|
||||
positive_amount=_STATEMENT_ZERO,
|
||||
negative_amount=order.get_total_amount(),
|
||||
items=items,
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
def _build_payment_records(self, supplier: basic_info_models.Supplier) -> List[dict]:
|
||||
qs = (
|
||||
models.PaymentOrder.objects.filter(
|
||||
merchant=self.merchant,
|
||||
supplier=supplier,
|
||||
status=models.PaymentOrderStatusEnum.APPROVED,
|
||||
)
|
||||
.select_related('supplier')
|
||||
)
|
||||
records = []
|
||||
for order in qs:
|
||||
records.append(
|
||||
self._build_record(
|
||||
counterparty_id=order.supplier_id,
|
||||
counterparty_name=order.supplier.name,
|
||||
source_type='payment_order',
|
||||
source_label='付款单',
|
||||
source_id=order.id,
|
||||
occurred_at=order.payment_date,
|
||||
recorded_at=order.created_at,
|
||||
status=order.status,
|
||||
status_label=order.get_status_display(),
|
||||
positive_amount=_STATEMENT_ZERO,
|
||||
negative_amount=order.get_total_amount(),
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def _normalize_statement_amount(value) -> Decimal:
|
||||
if isinstance(value, Decimal):
|
||||
decimal_value = value
|
||||
else:
|
||||
decimal_value = Decimal(str(value))
|
||||
return decimal_value.quantize(_STATEMENT_TWO_PLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _decimal_to_string(value: Decimal) -> str:
|
||||
normalized = _normalize_statement_amount(value)
|
||||
return format(normalized, 'f')
|
||||
|
||||
|
||||
@@ -704,6 +704,11 @@ class PaymentReceiptServiceTestCase(TestCase):
|
||||
mobile='13888888888',
|
||||
created_by=None,
|
||||
)
|
||||
self.bank_account = basic_models.BankAccount.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试账户',
|
||||
auto_number='BANK-SVC',
|
||||
)
|
||||
|
||||
def test_create_payment_order_and_approve(self):
|
||||
order = services.create_payment_order(
|
||||
@@ -711,10 +716,17 @@ class PaymentReceiptServiceTestCase(TestCase):
|
||||
supplier=self.supplier,
|
||||
payment_date=timezone.now().date(),
|
||||
amount='120.50',
|
||||
discount_amount='20.50',
|
||||
operator=self.operator,
|
||||
remarks='付款测试',
|
||||
bank_account=self.bank_account,
|
||||
markup='服务层附言',
|
||||
)
|
||||
self.assertEqual(order.status, business_models.PaymentOrderStatusEnum.PENDING)
|
||||
self.assertEqual(order.bank_account, self.bank_account)
|
||||
self.assertEqual(order.markup, '服务层附言')
|
||||
self.assertEqual(order.discount_amount, Decimal('20.50'))
|
||||
self.assertEqual(order.settlement_amount, Decimal('141.00'))
|
||||
reviewed = services.review_payment_order(
|
||||
payment_order=order,
|
||||
target_status=business_models.PaymentOrderStatusEnum.APPROVED,
|
||||
@@ -725,14 +737,14 @@ class PaymentReceiptServiceTestCase(TestCase):
|
||||
merchant=self.merchant,
|
||||
supplier=self.supplier,
|
||||
)
|
||||
self.assertEqual(balance.balance, Decimal('-120.50'))
|
||||
self.assertEqual(balance.balance, Decimal('-141.00'))
|
||||
record = business_models.BalanceChangeRecord.objects.get(
|
||||
merchant=self.merchant,
|
||||
source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER,
|
||||
source_id=order.id,
|
||||
)
|
||||
self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.DECREASE)
|
||||
self.assertEqual(record.delta, Decimal('-120.50'))
|
||||
self.assertEqual(record.delta, Decimal('-141.00'))
|
||||
self.assertEqual(record.balance_after, balance.balance)
|
||||
with self.assertRaises(ValueError):
|
||||
services.review_payment_order(
|
||||
@@ -747,9 +759,15 @@ class PaymentReceiptServiceTestCase(TestCase):
|
||||
customer=self.customer,
|
||||
receipt_date=timezone.now().date(),
|
||||
amount='80.00',
|
||||
discount_amount='5',
|
||||
operator=self.operator,
|
||||
remarks='收款测试',
|
||||
bank_account=self.bank_account,
|
||||
markup='收款附言',
|
||||
)
|
||||
self.assertEqual(order.bank_account, self.bank_account)
|
||||
self.assertEqual(order.discount_amount, Decimal('5'))
|
||||
self.assertEqual(order.settlement_amount, Decimal('85'))
|
||||
cancelled = services.review_receipt_order(
|
||||
receipt_order=order,
|
||||
target_status=business_models.ReceiptOrderStatusEnum.CANCELLED,
|
||||
@@ -774,6 +792,68 @@ class PaymentReceiptServiceTestCase(TestCase):
|
||||
operator=self.operator,
|
||||
)
|
||||
|
||||
def test_payment_discount_can_exceed_amount(self):
|
||||
order = services.create_payment_order(
|
||||
merchant=self.merchant,
|
||||
supplier=self.supplier,
|
||||
payment_date=timezone.now().date(),
|
||||
amount='50',
|
||||
discount_amount='60',
|
||||
operator=self.operator,
|
||||
)
|
||||
self.assertEqual(order.settlement_amount, Decimal('110'))
|
||||
|
||||
def test_receipt_discount_can_exceed_amount(self):
|
||||
order = services.create_receipt_order(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
receipt_date=timezone.now().date(),
|
||||
amount='50',
|
||||
discount_amount='60',
|
||||
operator=self.operator,
|
||||
)
|
||||
self.assertEqual(order.settlement_amount, Decimal('110'))
|
||||
|
||||
def test_payment_order_rejects_foreign_bank_account(self):
|
||||
other_merchant = basic_models.Merchant.objects.create(
|
||||
name='无关商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
foreign_bank = basic_models.BankAccount.objects.create(
|
||||
merchant=other_merchant,
|
||||
name='其他账户',
|
||||
auto_number='BANK-FOREIGN',
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
services.create_payment_order(
|
||||
merchant=self.merchant,
|
||||
supplier=self.supplier,
|
||||
payment_date=timezone.now().date(),
|
||||
amount='10',
|
||||
operator=self.operator,
|
||||
bank_account=foreign_bank,
|
||||
)
|
||||
|
||||
def test_receipt_order_rejects_foreign_bank_account(self):
|
||||
other_merchant = basic_models.Merchant.objects.create(
|
||||
name='第二商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
foreign_bank = basic_models.BankAccount.objects.create(
|
||||
merchant=other_merchant,
|
||||
name='第二账户',
|
||||
auto_number='BANK-SECOND',
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
services.create_receipt_order(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
receipt_date=timezone.now().date(),
|
||||
amount='10',
|
||||
operator=self.operator,
|
||||
bank_account=foreign_bank,
|
||||
)
|
||||
|
||||
def test_receipt_approval_is_idempotent(self):
|
||||
order = services.create_receipt_order(
|
||||
merchant=self.merchant,
|
||||
|
||||
Reference in New Issue
Block a user