forked from erp-dev/erp
beta
This commit is contained in:
59
business/amounts.py
Normal file
59
business/amounts.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
AMOUNT_MODE_HAOBUYE_INTEGER = 'haobuye_integer_round_half_up'
|
||||
AMOUNT_MODE_STANDARD_DECIMAL_2 = 'standard_decimal_2'
|
||||
AMOUNT_MODES = {
|
||||
AMOUNT_MODE_HAOBUYE_INTEGER,
|
||||
AMOUNT_MODE_STANDARD_DECIMAL_2,
|
||||
}
|
||||
INTEGER_QUANT = Decimal('1')
|
||||
DECIMAL_2_QUANT = Decimal('0.01')
|
||||
|
||||
|
||||
def to_decimal(value, *, field_name: str = 'value') -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (InvalidOperation, TypeError, ValueError) as exc:
|
||||
raise ValueError(f'{field_name} 必须是数字') from exc
|
||||
|
||||
|
||||
def normalize_money_amount(value, *, field_name: str = 'amount') -> Decimal:
|
||||
decimal_value = to_decimal(value, field_name=field_name)
|
||||
return decimal_value.quantize(_money_quant(), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def calculate_line_amount(*, quantity, price, standard_quantity=None) -> Decimal:
|
||||
raw_quantity = quantity
|
||||
if _amount_mode() == AMOUNT_MODE_STANDARD_DECIMAL_2 and standard_quantity is not None:
|
||||
raw_quantity = standard_quantity
|
||||
|
||||
quantity_value = to_decimal(
|
||||
raw_quantity,
|
||||
field_name='quantity',
|
||||
)
|
||||
price_value = to_decimal(price, field_name='price')
|
||||
return (quantity_value * price_value).quantize(_money_quant(), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def calculate_settlement_amount(*, amount, discount_amount=Decimal('0')) -> Decimal:
|
||||
return normalize_money_amount(
|
||||
normalize_money_amount(amount, field_name='amount')
|
||||
+ normalize_money_amount(discount_amount or Decimal('0'), field_name='discount_amount'),
|
||||
field_name='settlement_amount',
|
||||
)
|
||||
|
||||
|
||||
def _amount_mode() -> str:
|
||||
mode = getattr(settings, 'BUSINESS_AMOUNT_MODE', AMOUNT_MODE_HAOBUYE_INTEGER)
|
||||
if mode not in AMOUNT_MODES:
|
||||
raise ValueError(f'BUSINESS_AMOUNT_MODE 不合法: {mode}')
|
||||
return mode
|
||||
|
||||
|
||||
def _money_quant() -> Decimal:
|
||||
if _amount_mode() == AMOUNT_MODE_STANDARD_DECIMAL_2:
|
||||
return DECIMAL_2_QUANT
|
||||
return INTEGER_QUANT
|
||||
@@ -14,6 +14,7 @@ from django.db import transaction
|
||||
from basic_info import models as basic_models
|
||||
from . import models as business_models
|
||||
from . import services as business_services
|
||||
from .amounts import calculate_settlement_amount, normalize_money_amount
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -524,10 +525,19 @@ def _normalize_external_receipt_record(*, record: dict[str, Any], record_kind: s
|
||||
markup = str(record.get('JieSunFS') or '').strip() or None
|
||||
receipt_date = _parse_external_date(record.get('RiQi') or record.get('KdRiQi'))
|
||||
if record_kind == 'receipt':
|
||||
amount = _to_decimal(record.get('FkJinE'), field_name='FkJinE')
|
||||
discount_amount = _to_decimal(record.get('ZkJinE'), field_name='ZkJinE', default=Decimal('0'))
|
||||
amount = normalize_money_amount(
|
||||
_to_decimal(record.get('FkJinE'), field_name='FkJinE'),
|
||||
field_name='FkJinE',
|
||||
)
|
||||
discount_amount = normalize_money_amount(
|
||||
_to_decimal(record.get('ZkJinE'), field_name='ZkJinE', default=Decimal('0')),
|
||||
field_name='ZkJinE',
|
||||
)
|
||||
elif record_kind == 'refund':
|
||||
amount = _to_decimal(record.get('YfJinE'), field_name='YfJinE')
|
||||
amount = normalize_money_amount(
|
||||
_to_decimal(record.get('YfJinE'), field_name='YfJinE'),
|
||||
field_name='YfJinE',
|
||||
)
|
||||
discount_amount = Decimal('0')
|
||||
else:
|
||||
raise ExternalFinanceSyncError(f'不支持的 record_kind: {record_kind}')
|
||||
@@ -537,7 +547,10 @@ def _normalize_external_receipt_record(*, record: dict[str, Any], record_kind: s
|
||||
'receipt_date': receipt_date,
|
||||
'amount': amount,
|
||||
'discount_amount': discount_amount,
|
||||
'settlement_amount': amount + discount_amount,
|
||||
'settlement_amount': calculate_settlement_amount(
|
||||
amount=amount,
|
||||
discount_amount=discount_amount,
|
||||
),
|
||||
'customer_name': customer_name,
|
||||
'markup': markup,
|
||||
'remarks': _build_external_remarks(record=record, record_kind=record_kind),
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from flower.common import ModelBase
|
||||
from basic_info import models as basic_info_models
|
||||
from .amounts import calculate_line_amount, calculate_settlement_amount
|
||||
|
||||
|
||||
def build_business_human_id(prefix: str, created_at, object_id) -> str:
|
||||
@@ -222,7 +223,11 @@ class PurchaseOrderItem(ModelBase):
|
||||
return round(self.quantity * (self.empty_diff_percent / 100), 2)
|
||||
|
||||
def total_amount(self):
|
||||
return round(self.price * self.real_quantity(), 2)
|
||||
return calculate_line_amount(
|
||||
quantity=self.quantity,
|
||||
price=self.price,
|
||||
standard_quantity=self.real_quantity(),
|
||||
)
|
||||
|
||||
def split_quantity_of_rolls(self) -> List[int]:
|
||||
if self.quantity_of_rolls:
|
||||
@@ -393,7 +398,11 @@ class SalesOrderItem(ModelBase):
|
||||
return round(self.quantity * (self.empty_diff_percent / 100), 2)
|
||||
|
||||
def total_amount(self):
|
||||
return round(self.price * self.real_quantity(), 2)
|
||||
return calculate_line_amount(
|
||||
quantity=self.quantity,
|
||||
price=self.price,
|
||||
standard_quantity=self.real_quantity(),
|
||||
)
|
||||
|
||||
|
||||
class PreSalesOrder(ModelBase):
|
||||
@@ -878,7 +887,11 @@ class PurchaseReturnOrderItem(ModelBase):
|
||||
return round(self.quantity * (self.empty_diff_percent / 100), 2)
|
||||
|
||||
def total_amount(self):
|
||||
return round(self.price * self.real_quantity(), 2)
|
||||
return calculate_line_amount(
|
||||
quantity=self.quantity,
|
||||
price=self.price,
|
||||
standard_quantity=self.real_quantity(),
|
||||
)
|
||||
|
||||
|
||||
class SalesReturnStatusEnum(models.IntegerChoices):
|
||||
@@ -1008,7 +1021,11 @@ class SalesReturnOrderItem(ModelBase):
|
||||
return round(self.quantity * (self.empty_diff_percent / 100), 2)
|
||||
|
||||
def total_amount(self):
|
||||
return round(self.price * self.real_quantity(), 2)
|
||||
return calculate_line_amount(
|
||||
quantity=self.quantity,
|
||||
price=self.price,
|
||||
standard_quantity=self.real_quantity(),
|
||||
)
|
||||
|
||||
|
||||
class ExternalCustomerStatementCategoryEnum(models.TextChoices):
|
||||
@@ -1151,8 +1168,10 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
|
||||
|
||||
@property
|
||||
def settlement_amount(self) -> Decimal:
|
||||
discount = self.discount_amount or Decimal('0')
|
||||
return self.amount + discount
|
||||
return calculate_settlement_amount(
|
||||
amount=self.amount,
|
||||
discount_amount=self.discount_amount or Decimal('0'),
|
||||
)
|
||||
|
||||
def get_total_amount(self) -> Decimal:
|
||||
return self.settlement_amount
|
||||
@@ -1242,8 +1261,10 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
|
||||
|
||||
@property
|
||||
def settlement_amount(self) -> Decimal:
|
||||
discount = self.discount_amount or Decimal('0')
|
||||
return self.amount + discount
|
||||
return calculate_settlement_amount(
|
||||
amount=self.amount,
|
||||
discount_amount=self.discount_amount or Decimal('0'),
|
||||
)
|
||||
|
||||
def get_total_amount(self) -> Decimal:
|
||||
return self.settlement_amount
|
||||
|
||||
@@ -16,6 +16,7 @@ from stock import models as stock_models
|
||||
from stock.services import StockFlowService
|
||||
from basic_info.services import MerchantSettingService
|
||||
|
||||
from .amounts import normalize_money_amount
|
||||
from . import models
|
||||
from .tasks import (
|
||||
create_purchase_order_stock_entries,
|
||||
@@ -2272,7 +2273,7 @@ def _to_decimal(value, field_name: str) -> Decimal:
|
||||
|
||||
|
||||
def _ensure_non_zero_amount(value, field_name: str) -> Decimal:
|
||||
amount = _to_decimal(value, field_name)
|
||||
amount = normalize_money_amount(_to_decimal(value, field_name), field_name=field_name)
|
||||
# 金额类字段要求非零(允许负数用于处理退款场景)
|
||||
# 付款单负金额 = 供应商退款,收款单负金额 = 退款给客户
|
||||
if amount == 0:
|
||||
@@ -2288,7 +2289,7 @@ def _ensure_order_pending(order, pending_status, entity_name: str):
|
||||
def _ensure_non_negative_amount(value, field_name: str) -> Decimal:
|
||||
if value in (None, ''):
|
||||
return Decimal('0')
|
||||
amount = _to_decimal(value, field_name)
|
||||
amount = normalize_money_amount(_to_decimal(value, field_name), field_name=field_name)
|
||||
if amount < 0:
|
||||
raise ValueError(f'{field_name} 不能小于 0')
|
||||
return amount
|
||||
|
||||
101
business/tests/test_amounts.py
Normal file
101
business/tests/test_amounts.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from business import services
|
||||
from business.amounts import calculate_line_amount, normalize_money_amount
|
||||
|
||||
from .fixtures import create_basic_fixtures
|
||||
|
||||
|
||||
class BusinessAmountRulesTestCase(TestCase):
|
||||
def setUp(self):
|
||||
(
|
||||
self.merchant,
|
||||
self.supplier,
|
||||
self.warehouse_strict,
|
||||
self.warehouse_relaxed,
|
||||
self.product,
|
||||
self.operator,
|
||||
) = create_basic_fixtures()
|
||||
|
||||
def test_line_amount_rounds_half_up_to_integer(self):
|
||||
self.assertEqual(calculate_line_amount(quantity='1', price='1.49'), Decimal('1'))
|
||||
self.assertEqual(calculate_line_amount(quantity='1', price='1.50'), Decimal('2'))
|
||||
self.assertEqual(calculate_line_amount(quantity='1.5', price='1.5'), Decimal('2'))
|
||||
self.assertEqual(calculate_line_amount(quantity='1', price='2.5'), Decimal('3'))
|
||||
|
||||
def test_order_total_sums_rounded_line_amounts(self):
|
||||
order = services.create_purchase_order(
|
||||
merchant=self.merchant,
|
||||
supplier=self.supplier,
|
||||
order_date=timezone.now().date(),
|
||||
warehouse=self.warehouse_relaxed,
|
||||
operator=self.operator,
|
||||
items=[
|
||||
{'product_id': self.product.id, 'quantity': '1', 'num_of_rolls': 1, 'price': '1.5', 'unit': '米'},
|
||||
{'product_id': self.product.id, 'quantity': '1', 'num_of_rolls': 1, 'price': '1.5', 'unit': '米'},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual([item.total_amount() for item in order.items.order_by('id')], [Decimal('2'), Decimal('2')])
|
||||
self.assertEqual(order.get_total_amount(), Decimal('4'))
|
||||
|
||||
def test_payment_and_receipt_amounts_are_normalized_on_create(self):
|
||||
payment = services.create_payment_order(
|
||||
merchant=self.merchant,
|
||||
supplier=self.supplier,
|
||||
payment_date=timezone.now().date(),
|
||||
amount='120.50',
|
||||
discount_amount='20.49',
|
||||
operator=self.operator,
|
||||
)
|
||||
self.assertEqual(payment.amount, Decimal('121'))
|
||||
self.assertEqual(payment.discount_amount, Decimal('20'))
|
||||
self.assertEqual(payment.settlement_amount, Decimal('141'))
|
||||
|
||||
customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='金额测试客户',
|
||||
created_by=None,
|
||||
)
|
||||
receipt = services.create_receipt_order(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
receipt_date=timezone.now().date(),
|
||||
amount='80.49',
|
||||
discount_amount='5.50',
|
||||
operator=self.operator,
|
||||
)
|
||||
self.assertEqual(receipt.amount, Decimal('80'))
|
||||
self.assertEqual(receipt.discount_amount, Decimal('6'))
|
||||
self.assertEqual(receipt.settlement_amount, Decimal('86'))
|
||||
|
||||
def test_normalize_money_amount_rounds_negative_half_away_from_zero(self):
|
||||
self.assertEqual(normalize_money_amount('-25.50'), Decimal('-26'))
|
||||
|
||||
@override_settings(BUSINESS_AMOUNT_MODE='standard_decimal_2')
|
||||
def test_standard_decimal_mode_preserves_original_precision(self):
|
||||
self.assertEqual(
|
||||
calculate_line_amount(quantity='10', standard_quantity='9.50', price='12.345'),
|
||||
Decimal('117.28'),
|
||||
)
|
||||
|
||||
payment = services.create_payment_order(
|
||||
merchant=self.merchant,
|
||||
supplier=self.supplier,
|
||||
payment_date=timezone.now().date(),
|
||||
amount='120.50',
|
||||
discount_amount='20.49',
|
||||
operator=self.operator,
|
||||
)
|
||||
self.assertEqual(payment.amount, Decimal('120.50'))
|
||||
self.assertEqual(payment.discount_amount, Decimal('20.49'))
|
||||
self.assertEqual(payment.settlement_amount, Decimal('140.99'))
|
||||
|
||||
@override_settings(BUSINESS_AMOUNT_MODE='unknown_mode')
|
||||
def test_invalid_amount_mode_fails_fast(self):
|
||||
with self.assertRaisesMessage(ValueError, 'BUSINESS_AMOUNT_MODE 不合法'):
|
||||
calculate_line_amount(quantity='1', price='1')
|
||||
@@ -48,8 +48,9 @@ class PaymentReceiptServiceTestCase(TestCase):
|
||||
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'))
|
||||
self.assertEqual(order.amount, Decimal('121'))
|
||||
self.assertEqual(order.discount_amount, Decimal('21'))
|
||||
self.assertEqual(order.settlement_amount, Decimal('142'))
|
||||
reviewed = services.review_payment_order(
|
||||
payment_order=order,
|
||||
target_status=business_models.PaymentOrderStatusEnum.APPROVED,
|
||||
@@ -60,14 +61,14 @@ class PaymentReceiptServiceTestCase(TestCase):
|
||||
merchant=self.merchant,
|
||||
supplier=self.supplier,
|
||||
)
|
||||
self.assertEqual(balance.balance, Decimal('-141.00'))
|
||||
self.assertEqual(balance.balance, Decimal('-142'))
|
||||
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('-141.00'))
|
||||
self.assertEqual(record.delta, Decimal('-142'))
|
||||
self.assertEqual(record.balance_after, balance.balance)
|
||||
with self.assertRaises(ValueError):
|
||||
services.review_payment_order(
|
||||
@@ -113,7 +114,7 @@ class PaymentReceiptServiceTestCase(TestCase):
|
||||
amount='-25.50',
|
||||
operator=self.operator,
|
||||
)
|
||||
self.assertEqual(order.amount, Decimal('-25.50'))
|
||||
self.assertEqual(order.amount, Decimal('-26'))
|
||||
|
||||
def test_payment_discount_can_exceed_amount(self):
|
||||
order = services.create_payment_order(
|
||||
|
||||
Reference in New Issue
Block a user