diff --git a/account_advanced_reconcile/__init__.py b/account_advanced_reconcile/__init__.py new file mode 100644 index 00000000..1c643cae --- /dev/null +++ b/account_advanced_reconcile/__init__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Guewen Baconnier +# Copyright 2012 Camptocamp SA +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +import easy_reconcile +import base_advanced_reconciliation +import advanced_reconciliation diff --git a/account_advanced_reconcile/__openerp__.py b/account_advanced_reconcile/__openerp__.py new file mode 100644 index 00000000..726fe5e2 --- /dev/null +++ b/account_advanced_reconcile/__openerp__.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Guewen Baconnier +# Copyright 2012 Camptocamp SA +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +{'name': "Advanced Reconcile", + 'version': '1.0', + 'author': 'Camptocamp', + 'maintainer': 'Camptocamp', + 'category': 'Finance', + 'complexity': 'normal', + 'depends': ['account_easy_reconcile'], + 'description': """ +Advanced reconciliation methods for the module account_easy_reconcile. + +It implements a basis to created advanced reconciliation methods in a few lines +of code. + +Typically, such a method can be: + - Reconcile entries if the partner and the ref are equal + - Reconcile entries if the partner is equal and the ref is the same than ref + or name + - Reconcile entries if the partner is equal and the ref match with a pattern + +A method is already implemented in this module, it matches on entries: + * Partner + * Ref on credit move lines should be case insensitive equals to the ref or + the name of the debit move line + +The base class to find the reconciliations is built to be as efficient as +possible. + +Reconciliations with multiple credit / debit lines is possible. +Partial reconciliation are generated. +You can choose a write-off amount as well. + +So basically, if you have an invoice with 3 payments (one per month), the first +month, it will partial reconcile the debit move line with the first payment, the second +month, it will partial reconcile the debit move line with 2 first payments, +the third month, it will make the full reconciliation. + +This module is perfectly adapted for E-Commerce business where a big volume of +move lines and so, reconciliations, is involved and payments often come from +many offices. + """, + 'website': 'http://www.camptocamp.com', + 'init_xml': [], + 'update_xml': [], + 'demo_xml': [], + 'test': [], + 'images': [], + 'installable': True, + 'auto_install': False, + 'license': 'AGPL-3', + 'application': True, +} diff --git a/account_advanced_reconcile/advanced_reconciliation.py b/account_advanced_reconcile/advanced_reconciliation.py new file mode 100644 index 00000000..dfdb8883 --- /dev/null +++ b/account_advanced_reconcile/advanced_reconciliation.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Guewen Baconnier +# Copyright 2012 Camptocamp SA +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +from openerp.osv.orm import TransientModel + + +class easy_reconcile_advanced_ref(TransientModel): + + _name = 'easy.reconcile.advanced.ref' + _inherit = 'easy.reconcile.advanced' + _auto = True # False when inherited from AbstractModel + + def _skip_line(self, cr, uid, rec, move_line, context=None): + """ + When True is returned on some conditions, the credit move line + will be skipped for reconciliation. Can be inherited to + skip on some conditions. ie: ref or partner_id is empty. + """ + return not (move_line.get('ref') and move_line.get('partner_id')) + + def _matchers(self, cr, uid, rec, move_line, context=None): + """ + Return the values used as matchers to found the opposite lines + + All the matcher keys in the dict must have their equivalent in + the `_opposite_matchers`. + + The values of each matcher key will be searched in the + one returned by the `_opposite_matchers` + + Must be inherited to implement the matchers for one method + + As instance, it can returns: + return ('ref', move_line['rec']) + + or + return (('partner_id', move_line['partner_id']), + ('ref', "prefix_%s" % move_line['rec'])) + + All the matchers have to be found in the opposite lines + to consider them as "opposite" + + The matchers will be evaluated in the same order than declared + vs the the opposite matchers, so you can gain performance by + declaring first the partners with the less computation. + + All matchers should match with their opposite to be considered + as "matching". + So with the previous example, partner_id and ref have to be + equals on the opposite line matchers. + + :return: tuple of tuples (key, value) where the keys are + the matchers keys + (must be the same than `_opposite_matchers` returns, + and their values to match in the opposite lines. + A matching key can have multiples values. + """ + return (('partner_id', move_line['partner_id']), + ('ref', move_line['ref'].lower().strip())) + + def _opposite_matchers(self, cr, uid, rec, move_line, context=None): + """ + Return the values of the opposite line used as matchers + so the line is matched + + Must be inherited to implement the matchers for one method + It can be inherited to apply some formatting of fields + (strip(), lower() and so on) + + This method is the counterpart of the `_matchers()` method. + + Each matcher have to yield its value respecting the orders + of the `_matchers()`. + + When a matcher does not correspond, the next matchers won't + be evaluated so the ones which need the less computation + have to be executed first. + + If the `_matchers()` returns: + (('partner_id', move_line['partner_id']), + ('ref', move_line['ref'])) + + Here, you should yield : + yield ('partner_id', move_line['partner_id']) + yield ('ref', move_line['ref']) + + Note that a matcher can contain multiple values, as instance, + if for a move line, you want to search from its `ref` in the + `ref` or `name` fields of the opposite move lines, you have to + yield ('partner_id', move_line['partner_id']) + yield ('ref', (move_line['ref'], move_line['name']) + + An OR is used between the values for the same key. + An AND is used between the differents keys. + + :param dict move_line: values of the move_line + :yield: matchers as tuple ('matcher key', value(s)) + """ + yield ('partner_id', move_line['partner_id']) + yield ('ref', (move_line['ref'].lower().strip(), + move_line['name'].lower().strip())) + diff --git a/account_advanced_reconcile/base_advanced_reconciliation.py b/account_advanced_reconcile/base_advanced_reconciliation.py new file mode 100644 index 00000000..299ffab2 --- /dev/null +++ b/account_advanced_reconcile/base_advanced_reconciliation.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Guewen Baconnier +# Copyright 2012 Camptocamp SA +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +from itertools import groupby, product +from operator import itemgetter +from openerp.osv.orm import Model, AbstractModel, TransientModel +from openerp.osv import fields + + +class easy_reconcile_advanced(AbstractModel): + + _name = 'easy.reconcile.advanced' + _inherit = 'easy.reconcile.base' + + def _query_debit(self, cr, uid, rec, context=None): + """Select all move (debit>0) as candidate. Optional choice on invoice + will filter with an inner join on the related moves. + """ + select = self._select(rec) + sql_from = self._from(rec) + where, params = self._where(rec) + where += " AND account_move_line.debit > 0 " + + where2, params2 = self._get_filter(cr, uid, rec, context=context) + + query = ' '.join((select, sql_from, where, where2)) + + cr.execute(query, params + params2) + return cr.dictfetchall() + + def _query_credit(self, cr, uid, rec, context=None): + """Select all move (credit>0) as candidate. Optional choice on invoice + will filter with an inner join on the related moves. + """ + select = self._select(rec) + sql_from = self._from(rec) + where, params = self._where(rec) + where += " AND account_move_line.credit > 0 " + + where2, params2 = self._get_filter(cr, uid, rec, context=context) + + query = ' '.join((select, sql_from, where, where2)) + + cr.execute(query, params + params2) + return cr.dictfetchall() + + def _matchers(self, cr, uid, rec, move_line, context=None): + """ + Return the values used as matchers to found the opposite lines + + All the matcher keys in the dict must have their equivalent in + the `_opposite_matchers`. + + The values of each matcher key will be searched in the + one returned by the `_opposite_matchers` + + Must be inherited to implement the matchers for one method + + As instance, it can returns: + return ('ref', move_line['rec']) + + or + return (('partner_id', move_line['partner_id']), + ('ref', "prefix_%s" % move_line['rec'])) + + All the matchers have to be found in the opposite lines + to consider them as "opposite" + + The matchers will be evaluated in the same order than declared + vs the the opposite matchers, so you can gain performance by + declaring first the partners with the less computation. + + All matchers should match with their opposite to be considered + as "matching". + So with the previous example, partner_id and ref have to be + equals on the opposite line matchers. + + :return: tuple of tuples (key, value) where the keys are + the matchers keys + (must be the same than `_opposite_matchers` returns, + and their values to match in the opposite lines. + A matching key can have multiples values. + """ + raise NotImplementedError + + def _opposite_matchers(self, cr, uid, rec, move_line, context=None): + """ + Return the values of the opposite line used as matchers + so the line is matched + + Must be inherited to implement the matchers for one method + It can be inherited to apply some formatting of fields + (strip(), lower() and so on) + + This method is the counterpart of the `_matchers()` method. + + Each matcher have to yield its value respecting the orders + of the `_matchers()`. + + When a matcher does not correspond, the next matchers won't + be evaluated so the ones which need the less computation + have to be executed first. + + If the `_matchers()` returns: + (('partner_id', move_line['partner_id']), + ('ref', move_line['ref'])) + + Here, you should yield : + yield ('partner_id', move_line['partner_id']) + yield ('ref', move_line['ref']) + + Note that a matcher can contain multiple values, as instance, + if for a move line, you want to search from its `ref` in the + `ref` or `name` fields of the opposite move lines, you have to + yield ('partner_id', move_line['partner_id']) + yield ('ref', (move_line['ref'], move_line['name']) + + An OR is used between the values for the same key. + An AND is used between the differents keys. + + :param dict move_line: values of the move_line + :yield: matchers as tuple ('matcher key', value(s)) + """ + raise NotImplementedError + + @staticmethod + def _compare_values(key, value, opposite_value): + """Can be inherited to modify the equality condition + specifically according to the matcher key (maybe using + a like operator instead of equality on 'ref' as instance) + """ + # consider that empty vals are not valid matchers + # it can still be inherited for some special cases + # where it would be allowed + if not (value and opposite_value): + return False + + if value == opposite_value: + return True + return False + + @staticmethod + def _compare_matcher_values(key, values, opposite_values): + """ Compare every values from a matcher vs an opposite matcher + and return True if it matches + """ + for value, ovalue in product(values, opposite_values): + # we do not need to compare all values, if one matches + # we are done + if easy_reconcile_advanced._compare_values(key, value, ovalue): + return True + return False + + @staticmethod + def _compare_matchers(matcher, opposite_matcher): + """ + Prepare and check the matchers to compare + """ + mkey, mvalue = matcher + omkey, omvalue = opposite_matcher + assert mkey == omkey, "A matcher %s is compared with a matcher %s, " \ + " the _matchers and _opposite_matchers are probably wrong" % \ + (mkey, omkey) + if not isinstance(mvalue, (list, tuple)): + mvalue = mvalue, + if not isinstance(omvalue, (list, tuple)): + omvalue = omvalue, + return easy_reconcile_advanced._compare_matcher_values(mkey, mvalue, omvalue) + + def _compare_opposite(self, cr, uid, rec, move_line, opposite_move_line, + matchers, context=None): + opp_matchers = self._opposite_matchers(cr, uid, rec, opposite_move_line, + context=context) + for matcher in matchers: + try: + opp_matcher = opp_matchers.next() + except StopIteration: + # if you fall here, you probably missed to put a `yield` + # in `_opposite_matchers()` + raise ValueError("Missing _opposite_matcher: %s" % matcher[0]) + + if not self._compare_matchers(matcher, opp_matcher): + # if any of the matcher fails, the opposite line + # is not a valid counterpart + # directly returns so the next yield of _opposite_matchers + # are not evaluated + return False + + return True + + def _search_opposites(self, cr, uid, rec, move_line, opposite_move_lines, context=None): + """ + Search the opposite move lines for a move line + + :param dict move_line: the move line for which we search opposites + :param list opposite_move_lines: list of dict of move lines values, the move + lines we want to search for + :return: list of matching lines + """ + matchers = self._matchers(cr, uid, rec, move_line, context=context) + return [op for op in opposite_move_lines if \ + self._compare_opposite(cr, uid, rec, move_line, op, matchers, context=context)] + + def _action_rec(self, cr, uid, rec, context=None): + credit_lines = self._query_credit(cr, uid, rec, context=context) + debit_lines = self._query_debit(cr, uid, rec, context=context) + return self._rec_auto_lines_advanced( + cr, uid, rec, credit_lines, debit_lines, context=context) + + def _skip_line(self, cr, uid, rec, move_line, context=None): + """ + When True is returned on some conditions, the credit move line + will be skipped for reconciliation. Can be inherited to + skip on some conditions. ie: ref or partner_id is empty. + """ + return False + + def _rec_auto_lines_advanced(self, cr, uid, rec, credit_lines, debit_lines, context=None): + if context is None: + context = {} + + reconciled_ids = [] + partial_reconciled_ids = [] + reconcile_groups = [] + + for credit_line in credit_lines: + if self._skip_line(cr, uid, rec, credit_line, context=context): + continue + + opposite_lines = self._search_opposites( + cr, uid, rec, credit_line, debit_lines, context=context) + + if not opposite_lines: + continue + + opposite_ids = [l['id'] for l in opposite_lines] + line_ids = opposite_ids + [credit_line['id']] + for group in reconcile_groups: + if any([lid in group for lid in opposite_ids]): + group.update(line_ids) + break + else: + reconcile_groups.append(set(line_ids)) + + lines_by_id = dict([(l['id'], l) for l in credit_lines + debit_lines]) + for reconcile_group_ids in reconcile_groups: + group_lines = [lines_by_id[lid] for lid in reconcile_group_ids] + reconciled, partial = self._reconcile_lines( + cr, uid, rec, group_lines, allow_partial=True, context=context) + if reconciled and partial: + reconciled_ids += reconcile_group_ids + elif partial: + partial_reconciled_ids += reconcile_group_ids + + return reconciled_ids, partial_reconciled_ids + diff --git a/account_advanced_reconcile/easy_reconcile.py b/account_advanced_reconcile/easy_reconcile.py new file mode 100644 index 00000000..8204a8c1 --- /dev/null +++ b/account_advanced_reconcile/easy_reconcile.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Guewen Baconnier +# Copyright 2012 Camptocamp SA +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +from openerp.osv.orm import Model + + +class account_easy_reconcile_method(Model): + + _inherit = 'account.easy.reconcile.method' + + def _get_all_rec_method(self, cr, uid, context=None): + methods = super(account_easy_reconcile_method, self).\ + _get_all_rec_method(cr, uid, context=context) + methods += [ + ('easy.reconcile.advanced.ref', + 'Advanced method, payment ref matches with ref or name'), + ] + return methods +