protocol_payment_installment.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. """分期缴费:应缴日计算(整数月按日历递增,小数月按 30 天/月换算)"""
  2. from __future__ import annotations
  3. import calendar
  4. import json
  5. from decimal import Decimal, ROUND_DOWN
  6. from typing import List, Optional, Sequence, Union
  7. Number = Union[int, float, Decimal, str]
  8. DAYS_PER_MONTH = 30
  9. DEFAULT_INSTALLMENT_ADVANCE_DAYS = 30
  10. def add_days_yyyymmdd(date_str: str, days: int) -> str:
  11. raw = (date_str or "").strip().replace("-", "")
  12. if len(raw) != 8:
  13. return ""
  14. from datetime import date, timedelta
  15. y = int(raw[0:4])
  16. m = int(raw[4:6])
  17. d = int(raw[6:8])
  18. dt = date(y, m, d) + timedelta(days=days)
  19. return f"{dt.year:04d}{dt.month:02d}{dt.day:02d}"
  20. def add_months_yyyymmdd(date_str: str, months: int) -> str:
  21. raw = (date_str or "").strip().replace("-", "")
  22. if len(raw) != 8:
  23. return ""
  24. y = int(raw[0:4])
  25. m = int(raw[4:6])
  26. d = int(raw[6:8])
  27. month_index = m - 1 + months
  28. ny = y + month_index // 12
  29. nm = month_index % 12 + 1
  30. last_day = calendar.monthrange(ny, nm)[1]
  31. nd = min(d, last_day)
  32. return f"{ny:04d}{nm:02d}{nd:02d}"
  33. def add_interval_months_yyyymmdd(date_str: str, total_months: Number) -> str:
  34. """整数部分按日历加月,小数部分按 30 天/月换算为天数。"""
  35. raw = (date_str or "").strip().replace("-", "")
  36. if len(raw) != 8:
  37. return ""
  38. total = Decimal(str(total_months))
  39. if total == 0:
  40. return raw
  41. whole = int(total.to_integral_value(rounding=ROUND_DOWN))
  42. frac = total - Decimal(whole)
  43. result = raw
  44. if whole:
  45. result = add_months_yyyymmdd(result, whole)
  46. if frac:
  47. days = int(round(float(frac) * DAYS_PER_MONTH))
  48. if days:
  49. result = add_days_yyyymmdd(result, days)
  50. return result
  51. def calc_fq_installment_due_date(
  52. start_date: str,
  53. index: int,
  54. interval_months: Number,
  55. advance_days: int = DEFAULT_INSTALLMENT_ADVANCE_DAYS,
  56. ) -> str:
  57. """第 1 期=开始时间;第 2 期起在间隔递增基础上提前 advance_days 天。"""
  58. total_months = Decimal(str(index)) * Decimal(str(interval_months))
  59. base = add_interval_months_yyyymmdd(start_date, total_months)
  60. if index >= 1:
  61. return add_days_yyyymmdd(base, -int(advance_days or 0))
  62. return base
  63. def parse_installment_ratios(raw: Optional[str]) -> List[float]:
  64. if not raw:
  65. return []
  66. try:
  67. data = json.loads(raw)
  68. return [float(x) for x in data] if isinstance(data, list) else []
  69. except (json.JSONDecodeError, TypeError, ValueError):
  70. return []
  71. def is_legacy_installment_ratios(
  72. values: Sequence[Number],
  73. contract_amount: Optional[Decimal] = None,
  74. ) -> bool:
  75. if not values:
  76. return False
  77. nums = [float(v) for v in values]
  78. total = sum(nums)
  79. mx = max(nums)
  80. if contract_amount is not None and abs(total - float(contract_amount)) < 0.02:
  81. return False
  82. return mx <= 100 and abs(total - 100) < 0.05
  83. def resolve_installment_due_amounts(
  84. contract_amount: Optional[Decimal],
  85. stored: Sequence[Number],
  86. count: int,
  87. ) -> List[Decimal]:
  88. if stored and len(stored) == count:
  89. if is_legacy_installment_ratios(stored, contract_amount):
  90. return calc_installment_due_amounts_by_ratios(contract_amount, stored)
  91. return [Decimal(str(x)) for x in stored]
  92. return _calc_installment_due_amounts_inline(contract_amount, count)
  93. def _calc_installment_due_amounts_inline(
  94. contract_amount: Optional[Decimal],
  95. count: int,
  96. ) -> List[Decimal]:
  97. if contract_amount is None or count < 1:
  98. return []
  99. total_cents = int(round(float(contract_amount) * 100))
  100. base_cents = total_cents // count
  101. amounts: List[Decimal] = []
  102. sum_cents = 0
  103. for i in range(count):
  104. if i == count - 1:
  105. cents = total_cents - sum_cents
  106. else:
  107. cents = base_cents
  108. sum_cents += base_cents
  109. amounts.append(Decimal(cents) / Decimal(100))
  110. return amounts
  111. def calc_installment_due_amounts_by_ratios(
  112. contract_amount: Optional[Decimal],
  113. ratios: Sequence[Number],
  114. ) -> List[Decimal]:
  115. """全额分配:前 n-1 期 floor 到分,末位补尾差。"""
  116. if contract_amount is None or not ratios:
  117. return []
  118. count = len(ratios)
  119. total_cents = int(round(float(contract_amount) * 100))
  120. amounts: List[Decimal] = []
  121. sum_cents = 0
  122. for i in range(count):
  123. if i == count - 1:
  124. cents = total_cents - sum_cents
  125. else:
  126. cents = int(total_cents * float(ratios[i]) / 100.0)
  127. sum_cents += cents
  128. amounts.append(Decimal(cents) / Decimal(100))
  129. return amounts
  130. def calc_unpaid_amounts_by_ratios(
  131. remaining: Decimal,
  132. ratios: Sequence[Number],
  133. unpaid_indices: Sequence[int],
  134. ) -> List[Decimal]:
  135. """部分已缴:按未缴期次比例归一化分配 remaining。"""
  136. if remaining <= 0 or not unpaid_indices:
  137. return []
  138. subset = [float(ratios[i]) for i in unpaid_indices]
  139. ratio_sum = sum(subset)
  140. if ratio_sum <= 0:
  141. return []
  142. total_cents = int(round(float(remaining) * 100))
  143. amounts: List[Decimal] = []
  144. sum_cents = 0
  145. for j, _idx in enumerate(unpaid_indices):
  146. if j == len(unpaid_indices) - 1:
  147. cents = total_cents - sum_cents
  148. else:
  149. cents = int(total_cents * subset[j] / ratio_sum)
  150. sum_cents += cents
  151. amounts.append(Decimal(cents) / Decimal(100))
  152. return amounts