| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225 |
- """分期缴费:应缴日计算(整数月按日历递增,小数月按 30 天/月换算)"""
- from __future__ import annotations
- import calendar
- import json
- from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP
- from typing import List, Optional, Sequence, Union
- Number = Union[int, float, Decimal, str]
- DAYS_PER_MONTH = 30
- DEFAULT_INSTALLMENT_ADVANCE_DAYS = 30
- def add_days_yyyymmdd(date_str: str, days: int) -> str:
- raw = (date_str or "").strip().replace("-", "")
- if len(raw) != 8:
- return ""
- from datetime import date, timedelta
- y = int(raw[0:4])
- m = int(raw[4:6])
- d = int(raw[6:8])
- dt = date(y, m, d) + timedelta(days=days)
- return f"{dt.year:04d}{dt.month:02d}{dt.day:02d}"
- def add_months_yyyymmdd(date_str: str, months: int) -> str:
- raw = (date_str or "").strip().replace("-", "")
- if len(raw) != 8:
- return ""
- y = int(raw[0:4])
- m = int(raw[4:6])
- d = int(raw[6:8])
- month_index = m - 1 + months
- ny = y + month_index // 12
- nm = month_index % 12 + 1
- last_day = calendar.monthrange(ny, nm)[1]
- nd = min(d, last_day)
- return f"{ny:04d}{nm:02d}{nd:02d}"
- def add_interval_months_yyyymmdd(date_str: str, total_months: Number) -> str:
- """整数部分按日历加月,小数部分按 30 天/月换算为天数。"""
- raw = (date_str or "").strip().replace("-", "")
- if len(raw) != 8:
- return ""
- total = Decimal(str(total_months))
- if total == 0:
- return raw
- whole = int(total.to_integral_value(rounding=ROUND_DOWN))
- frac = total - Decimal(whole)
- result = raw
- if whole:
- result = add_months_yyyymmdd(result, whole)
- if frac:
- days = int(
- (frac * Decimal(DAYS_PER_MONTH)).to_integral_value(
- rounding=ROUND_HALF_UP
- )
- )
- if days:
- result = add_days_yyyymmdd(result, days)
- return result
- def calc_fq_installment_due_date(
- start_date: str,
- index: int,
- interval_months: Number,
- advance_days: int = DEFAULT_INSTALLMENT_ADVANCE_DAYS,
- ) -> str:
- """第 1 期=开始时间;第 2 期起在间隔递增基础上提前 advance_days 天。"""
- total_months = Decimal(str(index)) * Decimal(str(interval_months))
- base = add_interval_months_yyyymmdd(start_date, total_months)
- if index >= 1:
- return add_days_yyyymmdd(base, -int(advance_days or 0))
- return base
- def parse_installment_ratios(raw: Optional[str]) -> List[Decimal]:
- if not raw:
- return []
- try:
- data = json.loads(raw)
- return [Decimal(str(x)) for x in data] if isinstance(data, list) else []
- except (json.JSONDecodeError, TypeError, ValueError):
- return []
- def is_legacy_installment_ratios(
- values: Sequence[Number],
- contract_amount: Optional[Decimal] = None,
- ) -> bool:
- if not values:
- return False
- nums = [Decimal(str(v)) for v in values]
- total = sum(nums)
- mx = max(nums)
- if contract_amount is not None and abs(total - contract_amount) < Decimal('0.02'):
- return False
- return mx <= Decimal('100') and abs(total - Decimal('100')) < Decimal('0.05')
- def _money_to_cents(value: Decimal) -> int:
- return int(
- (Decimal(str(value)) * Decimal('100')).to_integral_value(
- rounding=ROUND_HALF_UP
- )
- )
- def resolve_installment_due_amounts(
- contract_amount: Optional[Decimal],
- stored: Sequence[Number],
- count: int,
- ) -> List[Decimal]:
- if stored and len(stored) == count:
- if is_legacy_installment_ratios(stored, contract_amount):
- return calc_installment_due_amounts_by_ratios(contract_amount, stored)
- return [Decimal(str(x)) for x in stored]
- return _calc_installment_due_amounts_inline(contract_amount, count)
- def _calc_installment_due_amounts_inline(
- contract_amount: Optional[Decimal],
- count: int,
- ) -> List[Decimal]:
- if contract_amount is None or count < 1:
- return []
- total_cents = _money_to_cents(contract_amount)
- base_cents = total_cents // count
- amounts: List[Decimal] = []
- sum_cents = 0
- for i in range(count):
- if i == count - 1:
- cents = total_cents - sum_cents
- else:
- cents = base_cents
- sum_cents += base_cents
- amounts.append(Decimal(cents) / Decimal(100))
- return amounts
- def calc_installment_due_amounts_by_ratios(
- contract_amount: Optional[Decimal],
- ratios: Sequence[Number],
- ) -> List[Decimal]:
- """全额分配:前 n-1 期 floor 到分,末位补尾差。"""
- if contract_amount is None or not ratios:
- return []
- count = len(ratios)
- total_cents = _money_to_cents(contract_amount)
- amounts: List[Decimal] = []
- sum_cents = 0
- for i in range(count):
- if i == count - 1:
- cents = total_cents - sum_cents
- else:
- ratio = Decimal(str(ratios[i]))
- cents = int(
- (Decimal(total_cents) * ratio / Decimal('100')).to_integral_value(
- rounding=ROUND_DOWN
- )
- )
- sum_cents += cents
- amounts.append(Decimal(cents) / Decimal(100))
- return amounts
- def calc_unpaid_amounts_by_ratios(
- remaining: Decimal,
- ratios: Sequence[Number],
- unpaid_indices: Sequence[int],
- ) -> List[Decimal]:
- """部分已缴:按未缴期次比例归一化分配 remaining。"""
- if remaining <= 0 or not unpaid_indices:
- return []
- subset = [Decimal(str(ratios[i])) for i in unpaid_indices]
- ratio_sum = sum(subset)
- if ratio_sum <= 0:
- return []
- total_cents = _money_to_cents(remaining)
- amounts: List[Decimal] = []
- sum_cents = 0
- for j, _idx in enumerate(unpaid_indices):
- if j == len(unpaid_indices) - 1:
- cents = total_cents - sum_cents
- else:
- cents = int(
- (Decimal(total_cents) * subset[j] / ratio_sum).to_integral_value(
- rounding=ROUND_DOWN
- )
- )
- sum_cents += cents
- amounts.append(Decimal(cents) / Decimal(100))
- return amounts
- def resolve_locked_remaining_due_amounts(
- contract_amount: Decimal,
- locked_due_amount: Decimal,
- stored: Sequence[Number],
- unpaid_indices: Sequence[int],
- ) -> List[Decimal]:
- """按剩余合同金额分配未缴期次;有期次权重时按权重,无则均摊。"""
- contract = Decimal(str(contract_amount))
- locked = Decimal(str(locked_due_amount))
- remaining = contract - locked
- if remaining < 0:
- raise ValueError("合同金额不能小于已缴或锁定明细金额")
- indices = [int(index) for index in unpaid_indices]
- if not indices:
- return []
- values = [Decimal(str(value)) for value in stored]
- if (
- values
- and max(indices) < len(values)
- and all(values[index] > 0 for index in indices)
- ):
- weighted = calc_unpaid_amounts_by_ratios(remaining, values, indices)
- if weighted:
- return weighted
- return _calc_installment_due_amounts_inline(remaining, len(indices))
|