1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3 4import logging 5import math 6import re 7import time 8import traceback 9 10from odoo import api, fields, models, tools, _ 11 12_logger = logging.getLogger(__name__) 13 14try: 15 from num2words import num2words 16except ImportError: 17 _logger.warning("The num2words python library is not installed, amount-to-text features won't be fully available.") 18 num2words = None 19 20CURRENCY_DISPLAY_PATTERN = re.compile(r'(\w+)\s*(?:\((.*)\))?') 21 22 23class Currency(models.Model): 24 _name = "res.currency" 25 _description = "Currency" 26 _order = 'active desc, name' 27 28 # Note: 'code' column was removed as of v6.0, the 'name' should now hold the ISO code. 29 name = fields.Char(string='Currency', size=3, required=True, help="Currency Code (ISO 4217)") 30 symbol = fields.Char(help="Currency sign, to be used when printing amounts.", required=True) 31 rate = fields.Float(compute='_compute_current_rate', string='Current Rate', digits=0, 32 help='The rate of the currency to the currency of rate 1.') 33 rate_ids = fields.One2many('res.currency.rate', 'currency_id', string='Rates') 34 rounding = fields.Float(string='Rounding Factor', digits=(12, 6), default=0.01) 35 decimal_places = fields.Integer(compute='_compute_decimal_places', store=True) 36 active = fields.Boolean(default=True) 37 position = fields.Selection([('after', 'After Amount'), ('before', 'Before Amount')], default='after', 38 string='Symbol Position', help="Determines where the currency symbol should be placed after or before the amount.") 39 date = fields.Date(compute='_compute_date') 40 currency_unit_label = fields.Char(string="Currency Unit", help="Currency Unit Name") 41 currency_subunit_label = fields.Char(string="Currency Subunit", help="Currency Subunit Name") 42 43 _sql_constraints = [ 44 ('unique_name', 'unique (name)', 'The currency code must be unique!'), 45 ('rounding_gt_zero', 'CHECK (rounding>0)', 'The rounding factor must be greater than 0!') 46 ] 47 48 def _get_rates(self, company, date): 49 if not self.ids: 50 return {} 51 self.env['res.currency.rate'].flush(['rate', 'currency_id', 'company_id', 'name']) 52 query = """SELECT c.id, 53 COALESCE((SELECT r.rate FROM res_currency_rate r 54 WHERE r.currency_id = c.id AND r.name <= %s 55 AND (r.company_id IS NULL OR r.company_id = %s) 56 ORDER BY r.company_id, r.name DESC 57 LIMIT 1), 1.0) AS rate 58 FROM res_currency c 59 WHERE c.id IN %s""" 60 self._cr.execute(query, (date, company.id, tuple(self.ids))) 61 currency_rates = dict(self._cr.fetchall()) 62 return currency_rates 63 64 @api.depends('rate_ids.rate') 65 def _compute_current_rate(self): 66 date = self._context.get('date') or fields.Date.today() 67 company = self.env['res.company'].browse(self._context.get('company_id')) or self.env.company 68 # the subquery selects the last rate before 'date' for the given currency/company 69 currency_rates = self._get_rates(company, date) 70 for currency in self: 71 currency.rate = currency_rates.get(currency.id) or 1.0 72 73 @api.depends('rounding') 74 def _compute_decimal_places(self): 75 for currency in self: 76 if 0 < currency.rounding < 1: 77 currency.decimal_places = int(math.ceil(math.log10(1/currency.rounding))) 78 else: 79 currency.decimal_places = 0 80 81 @api.depends('rate_ids.name') 82 def _compute_date(self): 83 for currency in self: 84 currency.date = currency.rate_ids[:1].name 85 86 @api.model 87 def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): 88 results = super(Currency, self)._name_search(name, args, operator=operator, limit=limit, name_get_uid=name_get_uid) 89 if not results: 90 name_match = CURRENCY_DISPLAY_PATTERN.match(name) 91 if name_match: 92 results = super(Currency, self)._name_search(name_match.group(1), args, operator=operator, limit=limit, name_get_uid=name_get_uid) 93 return results 94 95 def name_get(self): 96 return [(currency.id, tools.ustr(currency.name)) for currency in self] 97 98 def amount_to_text(self, amount): 99 self.ensure_one() 100 def _num2words(number, lang): 101 try: 102 return num2words(number, lang=lang).title() 103 except NotImplementedError: 104 return num2words(number, lang='en').title() 105 106 if num2words is None: 107 logging.getLogger(__name__).warning("The library 'num2words' is missing, cannot render textual amounts.") 108 return "" 109 110 formatted = "%.{0}f".format(self.decimal_places) % amount 111 parts = formatted.partition('.') 112 integer_value = int(parts[0]) 113 fractional_value = int(parts[2] or 0) 114 115 lang = tools.get_lang(self.env) 116 amount_words = tools.ustr('{amt_value} {amt_word}').format( 117 amt_value=_num2words(integer_value, lang=lang.iso_code), 118 amt_word=self.currency_unit_label, 119 ) 120 if not self.is_zero(amount - integer_value): 121 amount_words += ' ' + _('and') + tools.ustr(' {amt_value} {amt_word}').format( 122 amt_value=_num2words(fractional_value, lang=lang.iso_code), 123 amt_word=self.currency_subunit_label, 124 ) 125 return amount_words 126 127 def round(self, amount): 128 """Return ``amount`` rounded according to ``self``'s rounding rules. 129 130 :param float amount: the amount to round 131 :return: rounded float 132 """ 133 self.ensure_one() 134 return tools.float_round(amount, precision_rounding=self.rounding) 135 136 def compare_amounts(self, amount1, amount2): 137 """Compare ``amount1`` and ``amount2`` after rounding them according to the 138 given currency's precision.. 139 An amount is considered lower/greater than another amount if their rounded 140 value is different. This is not the same as having a non-zero difference! 141 142 For example 1.432 and 1.431 are equal at 2 digits precision, 143 so this method would return 0. 144 However 0.006 and 0.002 are considered different (returns 1) because 145 they respectively round to 0.01 and 0.0, even though 146 0.006-0.002 = 0.004 which would be considered zero at 2 digits precision. 147 148 :param float amount1: first amount to compare 149 :param float amount2: second amount to compare 150 :return: (resp.) -1, 0 or 1, if ``amount1`` is (resp.) lower than, 151 equal to, or greater than ``amount2``, according to 152 ``currency``'s rounding. 153 154 With the new API, call it like: ``currency.compare_amounts(amount1, amount2)``. 155 """ 156 self.ensure_one() 157 return tools.float_compare(amount1, amount2, precision_rounding=self.rounding) 158 159 def is_zero(self, amount): 160 """Returns true if ``amount`` is small enough to be treated as 161 zero according to current currency's rounding rules. 162 Warning: ``is_zero(amount1-amount2)`` is not always equivalent to 163 ``compare_amounts(amount1,amount2) == 0``, as the former will round after 164 computing the difference, while the latter will round before, giving 165 different results for e.g. 0.006 and 0.002 at 2 digits precision. 166 167 :param float amount: amount to compare with currency's zero 168 169 With the new API, call it like: ``currency.is_zero(amount)``. 170 """ 171 self.ensure_one() 172 return tools.float_is_zero(amount, precision_rounding=self.rounding) 173 174 @api.model 175 def _get_conversion_rate(self, from_currency, to_currency, company, date): 176 currency_rates = (from_currency + to_currency)._get_rates(company, date) 177 res = currency_rates.get(to_currency.id) / currency_rates.get(from_currency.id) 178 return res 179 180 def _convert(self, from_amount, to_currency, company, date, round=True): 181 """Returns the converted amount of ``from_amount``` from the currency 182 ``self`` to the currency ``to_currency`` for the given ``date`` and 183 company. 184 185 :param company: The company from which we retrieve the convertion rate 186 :param date: The nearest date from which we retriev the conversion rate. 187 :param round: Round the result or not 188 """ 189 self, to_currency = self or to_currency, to_currency or self 190 assert self, "convert amount from unknown currency" 191 assert to_currency, "convert amount to unknown currency" 192 assert company, "convert amount from unknown company" 193 assert date, "convert amount from unknown date" 194 # apply conversion rate 195 if self == to_currency: 196 to_amount = from_amount 197 else: 198 to_amount = from_amount * self._get_conversion_rate(self, to_currency, company, date) 199 # apply rounding 200 return to_currency.round(to_amount) if round else to_amount 201 202 @api.model 203 def _compute(self, from_currency, to_currency, from_amount, round=True): 204 _logger.warning('The `_compute` method is deprecated. Use `_convert` instead') 205 date = self._context.get('date') or fields.Date.today() 206 company = self.env['res.company'].browse(self._context.get('company_id')) or self.env.company 207 return from_currency._convert(from_amount, to_currency, company, date) 208 209 def compute(self, from_amount, to_currency, round=True): 210 _logger.warning('The `compute` method is deprecated. Use `_convert` instead') 211 date = self._context.get('date') or fields.Date.today() 212 company = self.env['res.company'].browse(self._context.get('company_id')) or self.env.company 213 return self._convert(from_amount, to_currency, company, date) 214 215 def _select_companies_rates(self): 216 return """ 217 SELECT 218 r.currency_id, 219 COALESCE(r.company_id, c.id) as company_id, 220 r.rate, 221 r.name AS date_start, 222 (SELECT name FROM res_currency_rate r2 223 WHERE r2.name > r.name AND 224 r2.currency_id = r.currency_id AND 225 (r2.company_id is null or r2.company_id = c.id) 226 ORDER BY r2.name ASC 227 LIMIT 1) AS date_end 228 FROM res_currency_rate r 229 JOIN res_company c ON (r.company_id is null or r.company_id = c.id) 230 """ 231 232 233class CurrencyRate(models.Model): 234 _name = "res.currency.rate" 235 _description = "Currency Rate" 236 _order = "name desc" 237 238 name = fields.Date(string='Date', required=True, index=True, 239 default=lambda self: fields.Date.today()) 240 rate = fields.Float(digits=0, default=1.0, help='The rate of the currency to the currency of rate 1') 241 currency_id = fields.Many2one('res.currency', string='Currency', readonly=True, required=True, ondelete="cascade") 242 company_id = fields.Many2one('res.company', string='Company', 243 default=lambda self: self.env.company) 244 245 _sql_constraints = [ 246 ('unique_name_per_day', 'unique (name,currency_id,company_id)', 'Only one currency rate per day allowed!'), 247 ('currency_rate_check', 'CHECK (rate>0)', 'The currency rate must be strictly positive.'), 248 ] 249 250 @api.model 251 def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): 252 if operator in ['=', '!=']: 253 try: 254 date_format = '%Y-%m-%d' 255 if self._context.get('lang'): 256 lang_id = self.env['res.lang']._search([('code', '=', self._context['lang'])], access_rights_uid=name_get_uid) 257 if lang_id: 258 date_format = self.browse(lang_id).date_format 259 name = time.strftime('%Y-%m-%d', time.strptime(name, date_format)) 260 except ValueError: 261 try: 262 args.append(('rate', operator, float(name))) 263 except ValueError: 264 return [] 265 name = '' 266 operator = 'ilike' 267 return super(CurrencyRate, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) 268