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:
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user