1
0
forked from erp-dev/erp
This commit is contained in:
2026-07-02 18:26:09 +08:00
parent 5170700234
commit 4538e51ad5
14 changed files with 471 additions and 26 deletions

59
business/amounts.py Normal file
View 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

View File

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

View File

@@ -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

View File

@@ -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

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

View File

@@ -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(