protocol_payment_installment.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. """分期缴费:应缴日计算(整数月按日历递增,小数月按 30 天/月换算)"""
  2. from __future__ import annotations
  3. import calendar
  4. import json
  5. from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP
  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(
  48. (frac * Decimal(DAYS_PER_MONTH)).to_integral_value(
  49. rounding=ROUND_HALF_UP
  50. )
  51. )
  52. if days:
  53. result = add_days_yyyymmdd(result, days)
  54. return result
  55. def calc_fq_installment_due_date(
  56. start_date: str,
  57. index: int,
  58. interval_months: Number,
  59. advance_days: int = DEFAULT_INSTALLMENT_ADVANCE_DAYS,
  60. ) -> str:
  61. """第 1 期=开始时间;第 2 期起在间隔递增基础上提前 advance_days 天。"""
  62. total_months = Decimal(str(index)) * Decimal(str(interval_months))
  63. base = add_interval_months_yyyymmdd(start_date, total_months)
  64. if index >= 1:
  65. return add_days_yyyymmdd(base, -int(advance_days or 0))
  66. return base
  67. def parse_installment_ratios(raw: Optional[str]) -> List[Decimal]:
  68. if not raw:
  69. return []
  70. try:
  71. data = json.loads(raw)
  72. return [Decimal(str(x)) for x in data] if isinstance(data, list) else []
  73. except (json.JSONDecodeError, TypeError, ValueError):
  74. return []
  75. def is_legacy_installment_ratios(
  76. values: Sequence[Number],
  77. contract_amount: Optional[Decimal] = None,
  78. ) -> bool:
  79. if not values:
  80. return False
  81. nums = [Decimal(str(v)) for v in values]
  82. total = sum(nums)
  83. mx = max(nums)
  84. if contract_amount is not None and abs(total - contract_amount) < Decimal('0.02'):
  85. return False
  86. return mx <= Decimal('100') and abs(total - Decimal('100')) < Decimal('0.05')
  87. def _money_to_cents(value: Decimal) -> int:
  88. return int(
  89. (Decimal(str(value)) * Decimal('100')).to_integral_value(
  90. rounding=ROUND_HALF_UP
  91. )
  92. )
  93. def resolve_installment_due_amounts(
  94. contract_amount: Optional[Decimal],
  95. stored: Sequence[Number],
  96. count: int,
  97. ) -> List[Decimal]:
  98. if stored and len(stored) == count:
  99. if is_legacy_installment_ratios(stored, contract_amount):
  100. return calc_installment_due_amounts_by_ratios(contract_amount, stored)
  101. return [Decimal(str(x)) for x in stored]
  102. return _calc_installment_due_amounts_inline(contract_amount, count)
  103. def _calc_installment_due_amounts_inline(
  104. contract_amount: Optional[Decimal],
  105. count: int,
  106. ) -> List[Decimal]:
  107. if contract_amount is None or count < 1:
  108. return []
  109. total_cents = _money_to_cents(contract_amount)
  110. base_cents = total_cents // count
  111. amounts: List[Decimal] = []
  112. sum_cents = 0
  113. for i in range(count):
  114. if i == count - 1:
  115. cents = total_cents - sum_cents
  116. else:
  117. cents = base_cents
  118. sum_cents += base_cents
  119. amounts.append(Decimal(cents) / Decimal(100))
  120. return amounts
  121. def calc_installment_due_amounts_by_ratios(
  122. contract_amount: Optional[Decimal],
  123. ratios: Sequence[Number],
  124. ) -> List[Decimal]:
  125. """全额分配:前 n-1 期 floor 到分,末位补尾差。"""
  126. if contract_amount is None or not ratios:
  127. return []
  128. count = len(ratios)
  129. total_cents = _money_to_cents(contract_amount)
  130. amounts: List[Decimal] = []
  131. sum_cents = 0
  132. for i in range(count):
  133. if i == count - 1:
  134. cents = total_cents - sum_cents
  135. else:
  136. ratio = Decimal(str(ratios[i]))
  137. cents = int(
  138. (Decimal(total_cents) * ratio / Decimal('100')).to_integral_value(
  139. rounding=ROUND_DOWN
  140. )
  141. )
  142. sum_cents += cents
  143. amounts.append(Decimal(cents) / Decimal(100))
  144. return amounts
  145. def calc_unpaid_amounts_by_ratios(
  146. remaining: Decimal,
  147. ratios: Sequence[Number],
  148. unpaid_indices: Sequence[int],
  149. ) -> List[Decimal]:
  150. """部分已缴:按未缴期次比例归一化分配 remaining。"""
  151. if remaining <= 0 or not unpaid_indices:
  152. return []
  153. subset = [Decimal(str(ratios[i])) for i in unpaid_indices]
  154. ratio_sum = sum(subset)
  155. if ratio_sum <= 0:
  156. return []
  157. total_cents = _money_to_cents(remaining)
  158. amounts: List[Decimal] = []
  159. sum_cents = 0
  160. for j, _idx in enumerate(unpaid_indices):
  161. if j == len(unpaid_indices) - 1:
  162. cents = total_cents - sum_cents
  163. else:
  164. cents = int(
  165. (Decimal(total_cents) * subset[j] / ratio_sum).to_integral_value(
  166. rounding=ROUND_DOWN
  167. )
  168. )
  169. sum_cents += cents
  170. amounts.append(Decimal(cents) / Decimal(100))
  171. return amounts
  172. def resolve_locked_remaining_due_amounts(
  173. contract_amount: Decimal,
  174. locked_due_amount: Decimal,
  175. stored: Sequence[Number],
  176. unpaid_indices: Sequence[int],
  177. ) -> List[Decimal]:
  178. """按剩余合同金额分配未缴期次;有期次权重时按权重,无则均摊。"""
  179. contract = Decimal(str(contract_amount))
  180. locked = Decimal(str(locked_due_amount))
  181. remaining = contract - locked
  182. if remaining < 0:
  183. raise ValueError("合同金额不能小于已缴或锁定明细金额")
  184. indices = [int(index) for index in unpaid_indices]
  185. if not indices:
  186. return []
  187. values = [Decimal(str(value)) for value in stored]
  188. if (
  189. values
  190. and max(indices) < len(values)
  191. and all(values[index] > 0 for index in indices)
  192. ):
  193. weighted = calc_unpaid_amounts_by_ratios(remaining, values, indices)
  194. if weighted:
  195. return weighted
  196. return _calc_installment_due_amounts_inline(remaining, len(indices))