account_reconcile_model.py 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028
  1. # -*- coding: utf-8 -*-
  2. from odoo import api, fields, models, Command, tools, _
  3. from odoo.tools import float_compare, float_is_zero
  4. from odoo.osv.expression import get_unaccent_wrapper
  5. from odoo.exceptions import UserError, ValidationError
  6. import re
  7. from math import copysign
  8. from collections import defaultdict
  9. from dateutil.relativedelta import relativedelta
  10. class AccountReconcileModelPartnerMapping(models.Model):
  11. _name = 'account.reconcile.model.partner.mapping'
  12. _description = 'Partner mapping for reconciliation models'
  13. model_id = fields.Many2one(comodel_name='account.reconcile.model', readonly=True, required=True, ondelete='cascade')
  14. partner_id = fields.Many2one(comodel_name='res.partner', string="Partner", required=True, ondelete='cascade')
  15. payment_ref_regex = fields.Char(string="Find Text in Label")
  16. narration_regex = fields.Char(string="Find Text in Notes")
  17. @api.constrains('narration_regex', 'payment_ref_regex')
  18. def validate_regex(self):
  19. for record in self:
  20. if not (record.narration_regex or record.payment_ref_regex):
  21. raise ValidationError(_("Please set at least one of the match texts to create a partner mapping."))
  22. try:
  23. if record.payment_ref_regex:
  24. current_regex = record.payment_ref_regex
  25. re.compile(record.payment_ref_regex)
  26. if record.narration_regex:
  27. current_regex = record.narration_regex
  28. re.compile(record.narration_regex)
  29. except re.error:
  30. raise ValidationError(_("The following regular expression is invalid to create a partner mapping: %s") % current_regex)
  31. class AccountReconcileModelLine(models.Model):
  32. _name = 'account.reconcile.model.line'
  33. _inherit = 'analytic.mixin'
  34. _description = 'Rules for the reconciliation model'
  35. _order = 'sequence, id'
  36. _check_company_auto = True
  37. model_id = fields.Many2one('account.reconcile.model', readonly=True, ondelete='cascade')
  38. allow_payment_tolerance = fields.Boolean(related='model_id.allow_payment_tolerance')
  39. payment_tolerance_param = fields.Float(related='model_id.payment_tolerance_param')
  40. rule_type = fields.Selection(related='model_id.rule_type')
  41. company_id = fields.Many2one(related='model_id.company_id', store=True)
  42. sequence = fields.Integer(required=True, default=10)
  43. account_id = fields.Many2one('account.account', string='Account', ondelete='cascade',
  44. domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('is_off_balance', '=', False)]",
  45. required=True, check_company=True)
  46. # This field is ignored in a bank statement reconciliation.
  47. journal_id = fields.Many2one('account.journal', string='Journal', ondelete='cascade',
  48. domain="[('type', '=', 'general'), ('company_id', '=', company_id)]", check_company=True)
  49. label = fields.Char(string='Journal Item Label')
  50. amount_type = fields.Selection([
  51. ('fixed', 'Fixed'),
  52. ('percentage', 'Percentage of balance'),
  53. ('percentage_st_line', 'Percentage of statement line'),
  54. ('regex', 'From label'),
  55. ], required=True, default='percentage')
  56. # used to show the force tax included button'
  57. show_force_tax_included = fields.Boolean(compute='_compute_show_force_tax_included')
  58. force_tax_included = fields.Boolean(string='Tax Included in Price', help='Force the tax to be managed as a price included tax.')
  59. # technical shortcut to parse the amount to a float
  60. amount = fields.Float(string="Float Amount", compute='_compute_float_amount', store=True)
  61. amount_string = fields.Char(string="Amount", default='100', required=True, help="""Value for the amount of the writeoff line
  62. * Percentage: Percentage of the balance, between 0 and 100.
  63. * Fixed: The fixed value of the writeoff. The amount will count as a debit if it is negative, as a credit if it is positive.
  64. * From Label: There is no need for regex delimiter, only the regex is needed. For instance if you want to extract the amount from\nR:9672938 10/07 AX 9415126318 T:5L:NA BRT: 3358,07 C:\nYou could enter\nBRT: ([\d,]+)""")
  65. tax_ids = fields.Many2many('account.tax', string='Taxes', ondelete='restrict', check_company=True)
  66. @api.onchange('tax_ids')
  67. def _onchange_tax_ids(self):
  68. # Multiple taxes with force_tax_included results in wrong computation, so we
  69. # only allow to set the force_tax_included field if we have one tax selected
  70. if len(self.tax_ids) != 1:
  71. self.force_tax_included = False
  72. @api.depends('tax_ids')
  73. def _compute_show_force_tax_included(self):
  74. for record in self:
  75. record.show_force_tax_included = False if len(record.tax_ids) != 1 else True
  76. @api.onchange('amount_type')
  77. def _onchange_amount_type(self):
  78. self.amount_string = ''
  79. if self.amount_type in ('percentage', 'percentage_st_line'):
  80. self.amount_string = '100'
  81. elif self.amount_type == 'regex':
  82. self.amount_string = '([\d,]+)'
  83. @api.depends('amount_string')
  84. def _compute_float_amount(self):
  85. for record in self:
  86. try:
  87. record.amount = float(record.amount_string)
  88. except ValueError:
  89. record.amount = 0
  90. @api.constrains('amount_string')
  91. def _validate_amount(self):
  92. for record in self:
  93. if record.amount_type == 'fixed' and record.amount == 0:
  94. raise UserError(_("The amount is not a number"))
  95. if record.amount_type == 'percentage_st_line' and record.amount == 0:
  96. raise UserError(_("Balance percentage can't be 0"))
  97. if record.amount_type == 'percentage' and record.amount == 0:
  98. raise UserError(_("Statement line percentage can't be 0"))
  99. if record.amount_type == 'regex':
  100. try:
  101. re.compile(record.amount_string)
  102. except re.error:
  103. raise UserError(_('The regex is not valid'))
  104. def _prepare_aml_vals(self, partner):
  105. """ Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the
  106. given reconcile model line.
  107. :param partner: The partner to be linked to the journal item.
  108. :return: A python dictionary.
  109. """
  110. self.ensure_one()
  111. taxes = self.tax_ids
  112. if taxes and partner:
  113. fiscal_position = self.env['account.fiscal.position']._get_fiscal_position(partner)
  114. if fiscal_position:
  115. taxes = fiscal_position.map_tax(taxes)
  116. return {
  117. 'name': self.label,
  118. 'account_id': self.account_id.id,
  119. 'partner_id': partner.id,
  120. 'analytic_distribution': self.analytic_distribution,
  121. 'tax_ids': [Command.set(taxes.ids)],
  122. 'reconcile_model_id': self.model_id.id,
  123. }
  124. def _apply_in_manual_widget(self, residual_amount_currency, partner, currency):
  125. """ Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the
  126. given reconcile model line used by the manual reconciliation widget.
  127. Note: 'journal_id' is added to the returned dictionary even if it is a related readonly field.
  128. It's a hack for the manual reconciliation widget. Indeed, a single journal entry will be created for each
  129. journal.
  130. :param residual_amount_currency: The current balance expressed in the account's currency.
  131. :param partner: The partner to be linked to the journal item.
  132. :param currency: The currency set on the account in the manual reconciliation widget.
  133. :return: A python dictionary.
  134. """
  135. self.ensure_one()
  136. if self.amount_type == 'percentage':
  137. amount_currency = currency.round(residual_amount_currency * (self.amount / 100.0))
  138. elif self.amount_type == 'fixed':
  139. sign = 1 if residual_amount_currency > 0.0 else -1
  140. amount_currency = currency.round(self.amount * sign)
  141. else:
  142. raise UserError(_("This reconciliation model can't be used in the manual reconciliation widget because its "
  143. "configuration is not adapted"))
  144. return {
  145. **self._prepare_aml_vals(partner),
  146. 'currency_id': currency.id,
  147. 'amount_currency': amount_currency,
  148. 'journal_id': self.journal_id.id,
  149. }
  150. def _apply_in_bank_widget(self, residual_amount_currency, partner, st_line):
  151. """ Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the
  152. given reconcile model line used by the bank reconciliation widget.
  153. :param residual_amount_currency: The current balance expressed in the statement line's currency.
  154. :param partner: The partner to be linked to the journal item.
  155. :param st_line: The statement line mounted inside the bank reconciliation widget.
  156. :return: A python dictionary.
  157. """
  158. self.ensure_one()
  159. currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
  160. amount_currency = None
  161. if self.amount_type == 'percentage_st_line':
  162. amount_currency = currency.round(residual_amount_currency * (self.amount / 100.0))
  163. elif self.amount_type == 'regex':
  164. match = re.search(self.amount_string, st_line.payment_ref)
  165. if match:
  166. sign = 1 if residual_amount_currency > 0.0 else -1
  167. decimal_separator = self.model_id.decimal_separator
  168. try:
  169. extracted_match_group = re.sub(r'[^\d' + decimal_separator + ']', '', match.group(1))
  170. extracted_balance = float(extracted_match_group.replace(decimal_separator, '.'))
  171. amount_currency = copysign(extracted_balance * sign, residual_amount_currency)
  172. except ValueError:
  173. amount_currency = 0.0
  174. else:
  175. amount_currency = 0.0
  176. if amount_currency is None:
  177. aml_vals = self._apply_in_manual_widget(residual_amount_currency, partner, currency)
  178. else:
  179. aml_vals = {
  180. **self._prepare_aml_vals(partner),
  181. 'currency_id': currency.id,
  182. 'amount_currency': amount_currency,
  183. }
  184. if not aml_vals['name']:
  185. aml_vals['name'] = st_line.payment_ref
  186. return aml_vals
  187. class AccountReconcileModel(models.Model):
  188. _name = 'account.reconcile.model'
  189. _description = 'Preset to create journal entries during a invoices and payments matching'
  190. _inherit = ['mail.thread']
  191. _order = 'sequence, id'
  192. _check_company_auto = True
  193. _sql_constraints = [('name_unique', 'unique(name, company_id)', 'A reconciliation model already bears this name.')]
  194. # Base fields.
  195. active = fields.Boolean(default=True)
  196. name = fields.Char(string='Name', required=True)
  197. sequence = fields.Integer(required=True, default=10)
  198. company_id = fields.Many2one(
  199. comodel_name='res.company',
  200. string='Company', required=True, readonly=True,
  201. default=lambda self: self.env.company)
  202. rule_type = fields.Selection(selection=[
  203. ('writeoff_button', 'Button to generate counterpart entry'),
  204. ('writeoff_suggestion', 'Rule to suggest counterpart entry'),
  205. ('invoice_matching', 'Rule to match invoices/bills'),
  206. ], string='Type', default='writeoff_button', required=True, tracking=True)
  207. auto_reconcile = fields.Boolean(string='Auto-validate', tracking=True,
  208. help='Validate the statement line automatically (reconciliation based on your rule).')
  209. to_check = fields.Boolean(string='To Check', default=False, help='This matching rule is used when the user is not certain of all the information of the counterpart.')
  210. matching_order = fields.Selection(
  211. selection=[
  212. ('old_first', 'Oldest first'),
  213. ('new_first', 'Newest first'),
  214. ],
  215. required=True,
  216. default='old_first',
  217. tracking=True,
  218. )
  219. # ===== Conditions =====
  220. match_text_location_label = fields.Boolean(
  221. default=True,
  222. help="Search in the Statement's Label to find the Invoice/Payment's reference",
  223. tracking=True,
  224. )
  225. match_text_location_note = fields.Boolean(
  226. default=False,
  227. help="Search in the Statement's Note to find the Invoice/Payment's reference",
  228. tracking=True,
  229. )
  230. match_text_location_reference = fields.Boolean(
  231. default=False,
  232. help="Search in the Statement's Reference to find the Invoice/Payment's reference",
  233. tracking=True,
  234. )
  235. match_journal_ids = fields.Many2many('account.journal', string='Journals Availability',
  236. domain="[('type', 'in', ('bank', 'cash')), ('company_id', '=', company_id)]",
  237. check_company=True,
  238. help='The reconciliation model will only be available from the selected journals.')
  239. match_nature = fields.Selection(selection=[
  240. ('amount_received', 'Received'),
  241. ('amount_paid', 'Paid'),
  242. ('both', 'Paid/Received')
  243. ], string='Amount Type', required=True, default='both', tracking=True,
  244. help='''The reconciliation model will only be applied to the selected transaction type:
  245. * Amount Received: Only applied when receiving an amount.
  246. * Amount Paid: Only applied when paying an amount.
  247. * Amount Paid/Received: Applied in both cases.''')
  248. match_amount = fields.Selection(selection=[
  249. ('lower', 'Is Lower Than'),
  250. ('greater', 'Is Greater Than'),
  251. ('between', 'Is Between'),
  252. ], string='Amount Condition', tracking=True,
  253. help='The reconciliation model will only be applied when the amount being lower than, greater than or between specified amount(s).')
  254. match_amount_min = fields.Float(string='Amount Min Parameter', tracking=True)
  255. match_amount_max = fields.Float(string='Amount Max Parameter', tracking=True)
  256. match_label = fields.Selection(selection=[
  257. ('contains', 'Contains'),
  258. ('not_contains', 'Not Contains'),
  259. ('match_regex', 'Match Regex'),
  260. ], string='Label', tracking=True, help='''The reconciliation model will only be applied when the label:
  261. * Contains: The proposition label must contains this string (case insensitive).
  262. * Not Contains: Negation of "Contains".
  263. * Match Regex: Define your own regular expression.''')
  264. match_label_param = fields.Char(string='Label Parameter', tracking=True)
  265. match_note = fields.Selection(selection=[
  266. ('contains', 'Contains'),
  267. ('not_contains', 'Not Contains'),
  268. ('match_regex', 'Match Regex'),
  269. ], string='Note', tracking=True, help='''The reconciliation model will only be applied when the note:
  270. * Contains: The proposition note must contains this string (case insensitive).
  271. * Not Contains: Negation of "Contains".
  272. * Match Regex: Define your own regular expression.''')
  273. match_note_param = fields.Char(string='Note Parameter', tracking=True)
  274. match_transaction_type = fields.Selection(selection=[
  275. ('contains', 'Contains'),
  276. ('not_contains', 'Not Contains'),
  277. ('match_regex', 'Match Regex'),
  278. ], string='Transaction Type', tracking=True, help='''The reconciliation model will only be applied when the transaction type:
  279. * Contains: The proposition transaction type must contains this string (case insensitive).
  280. * Not Contains: Negation of "Contains".
  281. * Match Regex: Define your own regular expression.''')
  282. match_transaction_type_param = fields.Char(string='Transaction Type Parameter', tracking=True)
  283. match_same_currency = fields.Boolean(string='Same Currency', default=True, tracking=True,
  284. help='Restrict to propositions having the same currency as the statement line.')
  285. allow_payment_tolerance = fields.Boolean(
  286. string="Payment Tolerance",
  287. default=True,
  288. tracking=True,
  289. help="Difference accepted in case of underpayment.",
  290. )
  291. payment_tolerance_param = fields.Float(
  292. string="Gap",
  293. compute='_compute_payment_tolerance_param',
  294. readonly=False,
  295. store=True,
  296. tracking=True,
  297. help="The sum of total residual amount propositions matches the statement line amount under this amount/percentage.",
  298. )
  299. payment_tolerance_type = fields.Selection(
  300. selection=[('percentage', "in percentage"), ('fixed_amount', "in amount")],
  301. default='percentage',
  302. required=True,
  303. tracking=True,
  304. help="The sum of total residual amount propositions and the statement line amount allowed gap type.",
  305. )
  306. match_partner = fields.Boolean(string='Partner is Set', tracking=True,
  307. help='The reconciliation model will only be applied when a customer/vendor is set.')
  308. match_partner_ids = fields.Many2many('res.partner', string='Matching partners',
  309. help='The reconciliation model will only be applied to the selected customers/vendors.')
  310. match_partner_category_ids = fields.Many2many('res.partner.category', string='Matching categories',
  311. help='The reconciliation model will only be applied to the selected customer/vendor categories.')
  312. line_ids = fields.One2many('account.reconcile.model.line', 'model_id', copy=True)
  313. partner_mapping_line_ids = fields.One2many(string="Partner Mapping Lines",
  314. comodel_name='account.reconcile.model.partner.mapping',
  315. inverse_name='model_id',
  316. help="The mapping uses regular expressions.\n"
  317. "- To Match the text at the beginning of the line (in label or notes), simply fill in your text.\n"
  318. "- To Match the text anywhere (in label or notes), put your text between .*\n"
  319. " e.g: .*N°48748 abc123.*")
  320. past_months_limit = fields.Integer(
  321. string="Search Months Limit",
  322. default=18,
  323. tracking=True,
  324. help="Number of months in the past to consider entries from when applying this model.",
  325. )
  326. decimal_separator = fields.Char(
  327. default=lambda self: self.env['res.lang']._lang_get(self.env.user.lang).decimal_point,
  328. tracking=True,
  329. help="Every character that is nor a digit nor this separator will be removed from the matching string",
  330. )
  331. # used to decide if we should show the decimal separator for the regex matching field
  332. show_decimal_separator = fields.Boolean(compute='_compute_show_decimal_separator')
  333. number_entries = fields.Integer(string='Number of entries related to this model', compute='_compute_number_entries')
  334. def action_reconcile_stat(self):
  335. self.ensure_one()
  336. action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
  337. self._cr.execute('''
  338. SELECT ARRAY_AGG(DISTINCT move_id)
  339. FROM account_move_line
  340. WHERE reconcile_model_id = %s
  341. ''', [self.id])
  342. action.update({
  343. 'context': {},
  344. 'domain': [('id', 'in', self._cr.fetchone()[0])],
  345. 'help': """<p class="o_view_nocontent_empty_folder">{}</p>""".format(_('This reconciliation model has created no entry so far')),
  346. })
  347. return action
  348. def _compute_number_entries(self):
  349. data = self.env['account.move.line']._read_group([('reconcile_model_id', 'in', self.ids)], ['reconcile_model_id'], 'reconcile_model_id')
  350. mapped_data = dict([(d['reconcile_model_id'][0], d['reconcile_model_id_count']) for d in data])
  351. for model in self:
  352. model.number_entries = mapped_data.get(model.id, 0)
  353. @api.depends('line_ids.amount_type')
  354. def _compute_show_decimal_separator(self):
  355. for record in self:
  356. record.show_decimal_separator = any(l.amount_type == 'regex' for l in record.line_ids)
  357. @api.depends('payment_tolerance_param', 'payment_tolerance_type')
  358. def _compute_payment_tolerance_param(self):
  359. for record in self:
  360. if record.payment_tolerance_type == 'percentage':
  361. record.payment_tolerance_param = min(100.0, max(0.0, record.payment_tolerance_param))
  362. else:
  363. record.payment_tolerance_param = max(0.0, record.payment_tolerance_param)
  364. @api.constrains('allow_payment_tolerance', 'payment_tolerance_param', 'payment_tolerance_type')
  365. def _check_payment_tolerance_param(self):
  366. for record in self:
  367. if record.allow_payment_tolerance:
  368. if record.payment_tolerance_type == 'percentage' and not 0 <= record.payment_tolerance_param <= 100:
  369. raise ValidationError(_("A payment tolerance defined as a percentage should always be between 0 and 100"))
  370. elif record.payment_tolerance_type == 'fixed_amount' and record.payment_tolerance_param < 0:
  371. raise ValidationError(_("A payment tolerance defined as an amount should always be higher than 0"))
  372. @api.returns('self', lambda value: value.id)
  373. def copy(self, default=None):
  374. default = default or {}
  375. if default.get('name'):
  376. return super(AccountReconcileModel, self).copy(default)
  377. name = _("%s (copy)", self.name)
  378. while self.env['account.reconcile.model'].search([('name', '=', name)], limit=1):
  379. name = _("%s (copy)", name)
  380. default['name'] = name
  381. return super(AccountReconcileModel, self).copy(default)
  382. ####################################################
  383. # RECONCILIATION PROCESS
  384. ####################################################
  385. def _apply_lines_for_bank_widget(self, residual_amount_currency, partner, st_line):
  386. """ Apply the reconciliation model lines to the statement line passed as parameter.
  387. :param residual_amount_currency: The open balance of the statement line in the bank reconciliation widget
  388. expressed in the statement line currency.
  389. :param partner: The partner set on the wizard.
  390. :param st_line: The statement line processed by the bank reconciliation widget.
  391. :return: A list of python dictionaries (one per reconcile model line) representing
  392. the journal items to be created by the current reconcile model.
  393. """
  394. self.ensure_one()
  395. currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
  396. if currency.is_zero(residual_amount_currency):
  397. return []
  398. vals_list = []
  399. for line in self.line_ids:
  400. vals = line._apply_in_bank_widget(residual_amount_currency, partner, st_line)
  401. amount_currency = vals['amount_currency']
  402. if currency.is_zero(amount_currency):
  403. continue
  404. vals_list.append(vals)
  405. residual_amount_currency -= amount_currency
  406. return vals_list
  407. def _get_taxes_move_lines_dict(self, tax, base_line_dict):
  408. ''' Get move.lines dict (to be passed to the create()) corresponding to a tax.
  409. :param tax: An account.tax record.
  410. :param base_line_dict: A dict representing the move.line containing the base amount.
  411. :return: A list of dict representing move.lines to be created corresponding to the tax.
  412. '''
  413. self.ensure_one()
  414. balance = base_line_dict['balance']
  415. tax_type = tax.type_tax_use
  416. is_refund = (tax_type == 'sale' and balance < 0) or (tax_type == 'purchase' and balance > 0)
  417. res = tax.compute_all(balance, is_refund=is_refund)
  418. new_aml_dicts = []
  419. for tax_res in res['taxes']:
  420. tax = self.env['account.tax'].browse(tax_res['id'])
  421. balance = tax_res['amount']
  422. name = ' '.join([x for x in [base_line_dict.get('name', ''), tax_res['name']] if x])
  423. new_aml_dicts.append({
  424. 'account_id': tax_res['account_id'] or base_line_dict['account_id'],
  425. 'journal_id': base_line_dict.get('journal_id', False),
  426. 'name': name,
  427. 'partner_id': base_line_dict.get('partner_id'),
  428. 'balance': balance,
  429. 'debit': balance > 0 and balance or 0,
  430. 'credit': balance < 0 and -balance or 0,
  431. 'analytic_distribution': tax.analytic and base_line_dict['analytic_distribution'],
  432. 'tax_repartition_line_id': tax_res['tax_repartition_line_id'],
  433. 'tax_ids': [(6, 0, tax_res['tax_ids'])],
  434. 'tax_tag_ids': [(6, 0, tax_res['tag_ids'])],
  435. 'group_tax_id': tax_res['group'].id if tax_res['group'] else False,
  436. 'currency_id': False,
  437. 'reconcile_model_id': self.id,
  438. })
  439. # Handle price included taxes.
  440. base_balance = tax_res['base']
  441. base_line_dict.update({
  442. 'balance': base_balance,
  443. 'debit': base_balance > 0 and base_balance or 0,
  444. 'credit': base_balance < 0 and -base_balance or 0,
  445. })
  446. base_line_dict['tax_tag_ids'] = [(6, 0, res['base_tags'])]
  447. return new_aml_dicts
  448. def _get_write_off_move_lines_dict(self, residual_balance, partner_id):
  449. ''' Get move.lines dict corresponding to the reconciliation model's write-off lines.
  450. :param residual_balance: The residual balance of the account on the manual reconciliation widget.
  451. :return: A list of dict representing move.lines to be created corresponding to the write-off lines.
  452. '''
  453. self.ensure_one()
  454. if self.rule_type == 'invoice_matching' and (not self.allow_payment_tolerance or self.payment_tolerance_param == 0):
  455. return []
  456. currency = self.company_id.currency_id
  457. lines_vals_list = []
  458. for line in self.line_ids:
  459. if line.amount_type == 'percentage':
  460. balance = currency.round(residual_balance * (line.amount / 100.0))
  461. elif line.amount_type == 'fixed':
  462. balance = currency.round(line.amount * (1 if residual_balance > 0.0 else -1))
  463. if currency.is_zero(balance):
  464. continue
  465. writeoff_line = {
  466. 'name': line.label,
  467. 'balance': balance,
  468. 'debit': balance > 0 and balance or 0,
  469. 'credit': balance < 0 and -balance or 0,
  470. 'account_id': line.account_id.id,
  471. 'currency_id': currency.id,
  472. 'analytic_distribution': line.analytic_distribution,
  473. 'reconcile_model_id': self.id,
  474. 'journal_id': line.journal_id.id,
  475. 'tax_ids': [],
  476. }
  477. lines_vals_list.append(writeoff_line)
  478. residual_balance -= balance
  479. if line.tax_ids:
  480. taxes = line.tax_ids
  481. detected_fiscal_position = self.env['account.fiscal.position']._get_fiscal_position(self.env['res.partner'].browse(partner_id))
  482. if detected_fiscal_position:
  483. taxes = detected_fiscal_position.map_tax(taxes)
  484. writeoff_line['tax_ids'] += [Command.set(taxes.ids)]
  485. # Multiple taxes with force_tax_included results in wrong computation, so we
  486. # only allow to set the force_tax_included field if we have one tax selected
  487. if line.force_tax_included:
  488. taxes = taxes[0].with_context(force_price_include=True)
  489. tax_vals_list = self._get_taxes_move_lines_dict(taxes, writeoff_line)
  490. lines_vals_list += tax_vals_list
  491. if not line.force_tax_included:
  492. for tax_line in tax_vals_list:
  493. residual_balance -= tax_line['balance']
  494. return lines_vals_list
  495. ####################################################
  496. # RECONCILIATION CRITERIA
  497. ####################################################
  498. def _apply_rules(self, st_line, partner):
  499. ''' Apply criteria to get candidates for all reconciliation models.
  500. This function is called in enterprise by the reconciliation widget to match
  501. the statement line with the available candidates (using the reconciliation models).
  502. :param st_line: The statement line to match.
  503. :param partner: The partner to consider.
  504. :return: A dict mapping each statement line id with:
  505. * aml_ids: A list of account.move.line ids.
  506. * model: An account.reconcile.model record (optional).
  507. * status: 'reconciled' if the lines has been already reconciled, 'write_off' if the write-off
  508. must be applied on the statement line.
  509. * auto_reconcile: A flag indicating if the match is enough significant to auto reconcile the candidates.
  510. '''
  511. available_models = self.filtered(lambda m: m.rule_type != 'writeoff_button').sorted()
  512. for rec_model in available_models:
  513. if not rec_model._is_applicable_for(st_line, partner):
  514. continue
  515. if rec_model.rule_type == 'invoice_matching':
  516. rules_map = rec_model._get_invoice_matching_rules_map()
  517. for rule_index in sorted(rules_map.keys()):
  518. for rule_method in rules_map[rule_index]:
  519. candidate_vals = rule_method(st_line, partner)
  520. if not candidate_vals:
  521. continue
  522. if candidate_vals.get('amls'):
  523. res = rec_model._get_invoice_matching_amls_result(st_line, partner, candidate_vals)
  524. if res:
  525. return {
  526. **res,
  527. 'model': rec_model,
  528. }
  529. else:
  530. return {
  531. **candidate_vals,
  532. 'model': rec_model,
  533. }
  534. elif rec_model.rule_type == 'writeoff_suggestion':
  535. return {
  536. 'model': rec_model,
  537. 'status': 'write_off',
  538. 'auto_reconcile': rec_model.auto_reconcile,
  539. }
  540. return {}
  541. def _is_applicable_for(self, st_line, partner):
  542. """ Returns true iff this reconciliation model can be used to search for matches
  543. for the provided statement line and partner.
  544. """
  545. self.ensure_one()
  546. # Filter on journals, amount nature, amount and partners
  547. # All the conditions defined in this block are non-match conditions.
  548. if ((self.match_journal_ids and st_line.move_id.journal_id not in self.match_journal_ids)
  549. or (self.match_nature == 'amount_received' and st_line.amount < 0)
  550. or (self.match_nature == 'amount_paid' and st_line.amount > 0)
  551. or (self.match_amount == 'lower' and abs(st_line.amount) >= self.match_amount_max)
  552. or (self.match_amount == 'greater' and abs(st_line.amount) <= self.match_amount_min)
  553. or (self.match_amount == 'between' and (abs(st_line.amount) > self.match_amount_max or abs(st_line.amount) < self.match_amount_min))
  554. or (self.match_partner and not partner)
  555. or (self.match_partner and self.match_partner_ids and partner not in self.match_partner_ids)
  556. or (self.match_partner and self.match_partner_category_ids and not (partner.category_id & self.match_partner_category_ids))
  557. ):
  558. return False
  559. # Filter on label, note and transaction_type
  560. for record, rule_field, record_field in [(st_line, 'label', 'payment_ref'), (st_line.move_id, 'note', 'narration'), (st_line, 'transaction_type', 'transaction_type')]:
  561. rule_term = (self['match_' + rule_field + '_param'] or '').lower()
  562. record_term = (record[record_field] or '').lower()
  563. # This defines non-match conditions
  564. if ((self['match_' + rule_field] == 'contains' and rule_term not in record_term)
  565. or (self['match_' + rule_field] == 'not_contains' and rule_term in record_term)
  566. or (self['match_' + rule_field] == 'match_regex' and not re.match(rule_term, record_term))
  567. ):
  568. return False
  569. return True
  570. def _get_invoice_matching_amls_domain(self, st_line, partner):
  571. aml_domain = st_line._get_default_amls_matching_domain()
  572. if st_line.amount > 0.0:
  573. aml_domain.append(('balance', '>', 0.0))
  574. else:
  575. aml_domain.append(('balance', '<', 0.0))
  576. currency = st_line.foreign_currency_id or st_line.currency_id
  577. if self.match_same_currency:
  578. aml_domain.append(('currency_id', '=', currency.id))
  579. if partner:
  580. aml_domain.append(('partner_id', '=', partner.id))
  581. if self.past_months_limit:
  582. date_limit = fields.Date.context_today(self) - relativedelta(months=self.past_months_limit)
  583. aml_domain.append(('date', '>=', fields.Date.to_string(date_limit)))
  584. return aml_domain
  585. def _get_st_line_text_values_for_matching(self, st_line):
  586. """ Collect the strings that could be used on the statement line to perform some matching.
  587. :param st_line: The current statement line.
  588. :return: A list of strings.
  589. """
  590. self.ensure_one()
  591. allowed_fields = []
  592. if self.match_text_location_label:
  593. allowed_fields.append('payment_ref')
  594. if self.match_text_location_note:
  595. allowed_fields.append('narration')
  596. if self.match_text_location_reference:
  597. allowed_fields.append('ref')
  598. return st_line._get_st_line_strings_for_matching(allowed_fields=allowed_fields)
  599. def _get_invoice_matching_st_line_tokens(self, st_line):
  600. """ Parse the textual information from the statement line passed as parameter
  601. in order to extract from it the meaningful information in order to perform the matching.
  602. :param st_line: A statement line.
  603. :return: A tuple of list of tokens, each one being a string.
  604. The first element is a list of tokens you may match on numerical information.
  605. The second element is a list of tokens you may match exactly.
  606. """
  607. st_line_text_values = self._get_st_line_text_values_for_matching(st_line)
  608. significant_token_size = 4
  609. numerical_tokens = []
  610. exact_tokens = []
  611. text_tokens = []
  612. for text_value in st_line_text_values:
  613. tokens = [
  614. ''.join(x for x in token if re.match(r'[0-9a-zA-Z\s]', x))
  615. for token in (text_value or '').split()
  616. ]
  617. # Numerical tokens
  618. for token in tokens:
  619. # The token is too short to be significant.
  620. if len(token) < significant_token_size:
  621. continue
  622. text_tokens.append(token)
  623. formatted_token = ''.join(x for x in token if x.isdecimal())
  624. # The token is too short after formatting to be significant.
  625. if len(formatted_token) < significant_token_size:
  626. continue
  627. numerical_tokens.append(formatted_token)
  628. # Exact tokens.
  629. if len(tokens) == 1:
  630. exact_tokens.append(tokens[0])
  631. return numerical_tokens, exact_tokens, text_tokens
  632. def _get_invoice_matching_amls_candidates(self, st_line, partner):
  633. """ Returns the match candidates for the 'invoice_matching' rule, with respect to the provided parameters.
  634. :param st_line: A statement line.
  635. :param partner: The partner associated to the statement line.
  636. """
  637. assert self.rule_type == 'invoice_matching'
  638. self.env['account.move'].flush_model()
  639. self.env['account.move.line'].flush_model()
  640. if self.matching_order == 'new_first':
  641. order_by = 'sub.date_maturity DESC, sub.date DESC, sub.id DESC'
  642. else:
  643. order_by = 'sub.date_maturity ASC, sub.date ASC, sub.id ASC'
  644. aml_domain = self._get_invoice_matching_amls_domain(st_line, partner)
  645. query = self.env['account.move.line']._where_calc(aml_domain)
  646. tables, where_clause, where_params = query.get_sql()
  647. sub_queries = []
  648. all_params = []
  649. numerical_tokens, exact_tokens, _text_tokens = self._get_invoice_matching_st_line_tokens(st_line)
  650. if numerical_tokens:
  651. for table_alias, field in (
  652. ('account_move_line', 'name'),
  653. ('account_move_line__move_id', 'name'),
  654. ('account_move_line__move_id', 'ref'),
  655. ):
  656. sub_queries.append(rf'''
  657. SELECT
  658. account_move_line.id,
  659. account_move_line.date,
  660. account_move_line.date_maturity,
  661. UNNEST(
  662. REGEXP_SPLIT_TO_ARRAY(
  663. SUBSTRING(
  664. REGEXP_REPLACE({table_alias}.{field}, '[^0-9\s]', '', 'g'),
  665. '\S(?:.*\S)*'
  666. ),
  667. '\s+'
  668. )
  669. ) AS token
  670. FROM {tables}
  671. JOIN account_move account_move_line__move_id ON account_move_line__move_id.id = account_move_line.move_id
  672. WHERE {where_clause} AND {table_alias}.{field} IS NOT NULL
  673. ''')
  674. all_params += where_params
  675. if exact_tokens:
  676. for table_alias, field in (
  677. ('account_move_line', 'name'),
  678. ('account_move_line__move_id', 'name'),
  679. ('account_move_line__move_id', 'ref'),
  680. ):
  681. sub_queries.append(rf'''
  682. SELECT
  683. account_move_line.id,
  684. account_move_line.date,
  685. account_move_line.date_maturity,
  686. {table_alias}.{field} AS token
  687. FROM {tables}
  688. JOIN account_move account_move_line__move_id ON account_move_line__move_id.id = account_move_line.move_id
  689. WHERE {where_clause} AND COALESCE({table_alias}.{field}, '') != ''
  690. ''')
  691. all_params += where_params
  692. if sub_queries:
  693. self._cr.execute(
  694. '''
  695. SELECT
  696. sub.id,
  697. COUNT(*) AS nb_match
  698. FROM (''' + ' UNION ALL '.join(sub_queries) + ''') AS sub
  699. WHERE sub.token IN %s
  700. GROUP BY sub.date_maturity, sub.date, sub.id
  701. HAVING COUNT(*) > 0
  702. ORDER BY nb_match DESC, ''' + order_by + '''
  703. ''',
  704. all_params + [tuple(numerical_tokens + exact_tokens)],
  705. )
  706. candidate_ids = [r[0] for r in self._cr.fetchall()]
  707. if candidate_ids:
  708. return {
  709. 'allow_auto_reconcile': True,
  710. 'amls': self.env['account.move.line'].browse(candidate_ids),
  711. }
  712. # Search without any matching based on textual information.
  713. if partner:
  714. if self.matching_order == 'new_first':
  715. order = 'date_maturity DESC, date DESC, id DESC'
  716. else:
  717. order = 'date_maturity ASC, date ASC, id ASC'
  718. amls = self.env['account.move.line'].search(aml_domain, order=order)
  719. if amls:
  720. return {
  721. 'allow_auto_reconcile': False,
  722. 'amls': amls,
  723. }
  724. def _get_invoice_matching_rules_map(self):
  725. """ Get a mapping <priority_order, rule> that could be overridden in others modules.
  726. :return: a mapping <priority_order, rule> where:
  727. * priority_order: Defines in which order the rules will be evaluated, the lowest comes first.
  728. This is extremely important since the algorithm stops when a rule returns some candidates.
  729. * rule: Method taking <st_line, partner> as parameters and returning the candidates journal items found.
  730. """
  731. rules_map = defaultdict(list)
  732. rules_map[10].append(self._get_invoice_matching_amls_candidates)
  733. return rules_map
  734. def _get_partner_from_mapping(self, st_line):
  735. """Find partner with mapping defined on model.
  736. For invoice matching rules, matches the statement line against each
  737. regex defined in partner mapping, and returns the partner corresponding
  738. to the first one matching.
  739. :param st_line (Model<account.bank.statement.line>):
  740. The statement line that needs a partner to be found
  741. :return Model<res.partner>:
  742. The partner found from the mapping. Can be empty an empty recordset
  743. if there was nothing found from the mapping or if the function is
  744. not applicable.
  745. """
  746. self.ensure_one()
  747. if self.rule_type not in ('invoice_matching', 'writeoff_suggestion'):
  748. return self.env['res.partner']
  749. for partner_mapping in self.partner_mapping_line_ids:
  750. match_payment_ref = True
  751. if partner_mapping.payment_ref_regex:
  752. match_payment_ref = re.match(partner_mapping.payment_ref_regex, st_line.payment_ref) if st_line.payment_ref else False
  753. match_narration = True
  754. if partner_mapping.narration_regex:
  755. match_narration = re.match(
  756. partner_mapping.narration_regex,
  757. tools.html2plaintext(st_line.narration or '').rstrip(),
  758. flags=re.DOTALL, # Ignore '/n' set by online sync.
  759. )
  760. if match_payment_ref and match_narration:
  761. return partner_mapping.partner_id
  762. return self.env['res.partner']
  763. def _get_invoice_matching_amls_result(self, st_line, partner, candidate_vals):
  764. def _create_result_dict(amls_values_list, status):
  765. if 'rejected' in status:
  766. return
  767. result = {'amls': self.env['account.move.line']}
  768. for aml_values in amls_values_list:
  769. result['amls'] |= aml_values['aml']
  770. if 'allow_write_off' in status and self.line_ids:
  771. result['status'] = 'write_off'
  772. if 'allow_auto_reconcile' in status and candidate_vals['allow_auto_reconcile'] and self.auto_reconcile:
  773. result['auto_reconcile'] = True
  774. return result
  775. st_line_currency = st_line.foreign_currency_id or st_line.currency_id
  776. st_line_amount = st_line._prepare_move_line_default_vals()[1]['amount_currency']
  777. sign = 1 if st_line_amount > 0.0 else -1
  778. amls = candidate_vals['amls']
  779. amls_values_list = []
  780. amls_with_epd_values_list = []
  781. same_currency_mode = amls.currency_id == st_line_currency
  782. for aml in amls:
  783. aml_values = {
  784. 'aml': aml,
  785. 'amount_residual': aml.amount_residual,
  786. 'amount_residual_currency': aml.amount_residual_currency,
  787. }
  788. amls_values_list.append(aml_values)
  789. # Manage the early payment discount.
  790. if same_currency_mode \
  791. and aml.move_id.move_type in ('out_invoice', 'out_receipt', 'in_invoice', 'in_receipt') \
  792. and not aml.matched_debit_ids \
  793. and not aml.matched_credit_ids \
  794. and aml.discount_date \
  795. and st_line.date <= aml.discount_date:
  796. rate = abs(aml.amount_currency) / abs(aml.balance) if aml.balance else 1.0
  797. amls_with_epd_values_list.append({
  798. **aml_values,
  799. 'amount_residual': st_line.company_currency_id.round(aml.discount_amount_currency / rate),
  800. 'amount_residual_currency': aml.discount_amount_currency,
  801. })
  802. else:
  803. amls_with_epd_values_list.append(aml_values)
  804. def match_batch_amls(amls_values_list):
  805. if not same_currency_mode:
  806. return None, []
  807. kepts_amls_values_list = []
  808. sum_amount_residual_currency = 0.0
  809. for aml_values in amls_values_list:
  810. if st_line_currency.compare_amounts(st_line_amount, -aml_values['amount_residual_currency']) == 0:
  811. # Special case: the amounts are the same, submit the line directly.
  812. return 'perfect', [aml_values]
  813. if st_line_currency.compare_amounts(sign * (st_line_amount + sum_amount_residual_currency), 0.0) > 0:
  814. # Here, we still have room for other candidates ; so we add the current one to the list we keep.
  815. # Then, we continue iterating, even if there is no room anymore, just in case one of the following candidates
  816. # is an exact match, which would then be preferred on the current candidates.
  817. kepts_amls_values_list.append(aml_values)
  818. sum_amount_residual_currency += aml_values['amount_residual_currency']
  819. if st_line_currency.is_zero(sign * (st_line_amount + sum_amount_residual_currency)):
  820. return 'perfect', kepts_amls_values_list
  821. elif kepts_amls_values_list:
  822. return 'partial', kepts_amls_values_list
  823. else:
  824. return None, []
  825. # Try to match a batch with the early payment feature. Only a perfect match is allowed.
  826. match_type, kepts_amls_values_list = match_batch_amls(amls_with_epd_values_list)
  827. if match_type != 'perfect':
  828. kepts_amls_values_list = []
  829. # Try to match the amls having the same currency as the statement line.
  830. if not kepts_amls_values_list:
  831. _match_type, kepts_amls_values_list = match_batch_amls(amls_values_list)
  832. # Try to match the whole candidates.
  833. if not kepts_amls_values_list:
  834. kepts_amls_values_list = amls_values_list
  835. # Try to match the amls having the same currency as the statement line.
  836. if kepts_amls_values_list:
  837. status = self._check_rule_propositions(st_line, kepts_amls_values_list)
  838. result = _create_result_dict(kepts_amls_values_list, status)
  839. if result:
  840. return result
  841. def _check_rule_propositions(self, st_line, amls_values_list):
  842. """ Check restrictions that can't be handled for each move.line separately.
  843. Note: Only used by models having a type equals to 'invoice_matching'.
  844. :param st_line: The statement line.
  845. :param amls_values_list: The candidates account.move.line as a list of dict:
  846. * aml: The record.
  847. * amount_residual: The amount residual to consider.
  848. * amount_residual_currency: The amount residual in foreign currency to consider.
  849. :return: A string representing what to do with the candidates:
  850. * rejected: Reject candidates.
  851. * allow_write_off: Allow to generate the write-off from the reconcile model lines if specified.
  852. * allow_auto_reconcile: Allow to automatically reconcile entries if 'auto_validate' is enabled.
  853. """
  854. self.ensure_one()
  855. if not self.allow_payment_tolerance:
  856. return {'allow_write_off', 'allow_auto_reconcile'}
  857. st_line_currency = st_line.foreign_currency_id or st_line.currency_id
  858. st_line_amount_curr = st_line._prepare_move_line_default_vals()[1]['amount_currency']
  859. amls_amount_curr = sum(
  860. st_line._prepare_counterpart_amounts_using_st_line_rate(
  861. aml_values['aml'].currency_id,
  862. aml_values['amount_residual'],
  863. aml_values['amount_residual_currency'],
  864. )['amount_currency']
  865. for aml_values in amls_values_list
  866. )
  867. sign = 1 if st_line_amount_curr > 0.0 else -1
  868. amount_curr_after_rec = sign * (amls_amount_curr + st_line_amount_curr)
  869. # The statement line will be fully reconciled.
  870. if st_line_currency.is_zero(amount_curr_after_rec):
  871. return {'allow_auto_reconcile'}
  872. # The payment amount is higher than the sum of invoices.
  873. # In that case, don't check the tolerance and don't try to generate any write-off.
  874. if amount_curr_after_rec > 0.0:
  875. return {'allow_auto_reconcile'}
  876. # No tolerance, reject the candidates.
  877. if self.payment_tolerance_param == 0:
  878. return {'rejected'}
  879. # If the tolerance is expressed as a fixed amount, check the residual payment amount doesn't exceed the
  880. # tolerance.
  881. if self.payment_tolerance_type == 'fixed_amount' and -amount_curr_after_rec <= self.payment_tolerance_param:
  882. return {'allow_write_off', 'allow_auto_reconcile'}
  883. # The tolerance is expressed as a percentage between 0 and 100.0.
  884. reconciled_percentage_left = (abs(amount_curr_after_rec / amls_amount_curr)) * 100.0
  885. if self.payment_tolerance_type == 'percentage' and reconciled_percentage_left <= self.payment_tolerance_param:
  886. return {'allow_write_off', 'allow_auto_reconcile'}
  887. return {'rejected'}