diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py index 062ddf4..e56219c 100755 --- a/modules/purchase_trade/fee.py +++ b/modules/purchase_trade/fee.py @@ -19,6 +19,7 @@ import logging from collections import defaultdict from trytond.exceptions import UserWarning, UserError from trytond.modules.account.exceptions import PeriodNotFoundError +from trytond.modules.purchase_trade.finance_tools import InterestCalculator logger = logging.getLogger(__name__) @@ -56,6 +57,7 @@ class Fee(ModelSQL,ModelView): ('lumpsum', 'Lump sum'), ('perqt', 'Per qt'), ('pprice', '% price'), + ('rate', '% Mth'), ('pcost', '% cost price'), ('ppack', 'Per packing'), ], 'Mode', required=True) @@ -93,6 +95,13 @@ class Fee(ModelSQL,ModelView): ('brut', 'Gross'), ], string='W. type') + fee_date = fields.Date("Date") + + @classmethod + def default_fee_date(cls): + Date = Pool().get('ir.date') + return Date.today() + @classmethod def default_qt_state(cls): LotQtType = Pool().get('lot.qt.type') @@ -295,6 +304,7 @@ class Fee(ModelSQL,ModelView): return Decimal(lqts[0].lot_quantity) def get_amount(self,name=None): + Date = Pool().get('ir.date') sign = Decimal(1) if self.price: # if self.p_r: @@ -304,6 +314,37 @@ class Fee(ModelSQL,ModelView): return self.price * sign elif self.mode == 'ppack': return round(self.price * self.quantity,2) + elif self.mode == 'rate': + #take period with estimated trigger date + if self.line: + if self.line.purchase.payment_term: + est_date = self.line.purchase.payment_term.get_date() + beg_date = self.fee_date if self.fee_date else Date.today() + factor = InterestCalculator.calculate( + start_date=beg_date, + end_date=est_date, + rate=self.price, + rate_type='annual', + convention='ACT/360', + compounding='simple' + ) + + return round(factor * self.line.unit_price * (self.quantity if self.quantity else 0) * sign,2) + if self.sale_line: + if self.sale_line.sale.payment_term: + est_date = self.sale_line.sale.payment_term.get_date() + beg_date = self.fee_date if self.fee_date else Date.today() + factor = InterestCalculator.calculate( + start_date=beg_date, + end_date=est_date, + rate=self.price, + rate_type='annual', + convention='ACT/360', + compounding='simple' + ) + + return round(factor * self.sale_line.unit_price * (self.quantity if self.quantity else 0) * sign,2) + elif self.mode == 'perqt': if self.shipment_in: StockMove = Pool().get('stock.move') diff --git a/modules/purchase_trade/finance_tools.py b/modules/purchase_trade/finance_tools.py new file mode 100644 index 0000000..ffbcc19 --- /dev/null +++ b/modules/purchase_trade/finance_tools.py @@ -0,0 +1,141 @@ +from decimal import Decimal, getcontext +from datetime import date +from calendar import isleap + +getcontext().prec = 28 + + +class DayCount: + + @staticmethod + def year_fraction(start_date, end_date, convention): + if end_date <= start_date: + return Decimal('0') + + if convention == 'ACT/360': + return Decimal((end_date - start_date).days) / Decimal(360) + + elif convention in ('ACT/365', 'ACT/365F'): + return Decimal((end_date - start_date).days) / Decimal(365) + + elif convention == 'ACT/ACT_ISDA': + return DayCount._act_act_isda(start_date, end_date) + + elif convention == '30/360_US': + return DayCount._30_360_us(start_date, end_date) + + elif convention == '30E/360': + return DayCount._30e_360(start_date, end_date) + + elif convention == '30E/360_ISDA': + return DayCount._30e_360_isda(start_date, end_date) + + else: + raise ValueError(f"Unsupported convention {convention}") + + # ---------- IMPLEMENTATIONS ---------- + + @staticmethod + def _act_act_isda(start_date, end_date): + total = Decimal('0') + current = start_date + + while current < end_date: + year_end = date(current.year, 12, 31) + period_end = min(year_end.replace(day=31) + + (date(current.year + 1, 1, 1) - year_end), + end_date) + + days_in_period = (period_end - current).days + days_in_year = 366 if isleap(current.year) else 365 + + total += Decimal(days_in_period) / Decimal(days_in_year) + current = period_end + + return total + + @staticmethod + def _30_360_us(d1, d2): + d1_day = 30 if d1.day == 31 else d1.day + if d1.day in (30, 31) and d2.day == 31: + d2_day = 30 + else: + d2_day = d2.day + + days = ((d2.year - d1.year) * 360 + + (d2.month - d1.month) * 30 + + (d2_day - d1_day)) + + return Decimal(days) / Decimal(360) + + @staticmethod + def _30e_360(d1, d2): + d1_day = min(d1.day, 30) + d2_day = min(d2.day, 30) + + days = ((d2.year - d1.year) * 360 + + (d2.month - d1.month) * 30 + + (d2_day - d1_day)) + + return Decimal(days) / Decimal(360) + + @staticmethod + def _30e_360_isda(d1, d2): + d1_day = 30 if d1.day == 31 else d1.day + d2_day = 30 if d2.day == 31 else d2.day + + days = ((d2.year - d1.year) * 360 + + (d2.month - d1.month) * 30 + + (d2_day - d1_day)) + + return Decimal(days) / Decimal(360) + + +class InterestCalculator: + + @staticmethod + def calculate( + start_date, + end_date, + rate, + rate_type='annual', # 'annual' or 'monthly' + convention='ACT/360', + compounding='simple', # simple, annual, monthly, continuous + ): + """ + Retourne le facteur d'intérêt (pas le montant). + """ + + if not start_date or not end_date: + return Decimal('0') + + if end_date <= start_date: + return Decimal('0') + + rate = Decimal(str(rate)) + + # Conversion en taux annuel si besoin + if rate_type == 'monthly': + annual_rate = rate * Decimal(12) + else: + annual_rate = rate + + yf = DayCount.year_fraction(start_date, end_date, convention) + + if compounding == 'simple': + return annual_rate * yf + + elif compounding == 'annual': + return (Decimal(1) + annual_rate) ** yf - Decimal(1) + + elif compounding == 'monthly': + monthly_rate = annual_rate / Decimal(12) + months = yf * Decimal(12) + return (Decimal(1) + monthly_rate) ** months - Decimal(1) + + elif compounding == 'continuous': + from math import exp + return Decimal(exp(float(annual_rate * yf))) - Decimal(1) + + else: + raise ValueError("Unsupported compounding mode")