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,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')