From afd6842af4fdd1881145b3720542ed8bceac8ade Mon Sep 17 00:00:00 2001 From: "Guewen Baconnier @ Camptocamp" Date: Mon, 22 Oct 2012 13:06:14 +0200 Subject: [PATCH] [IMP] renamed module to account_credit_control, wordings, removed scenarios (moved to lp:oerpscenario (lp:c2c-addons/6.1 rev 89.1.1) --- account_credit_control/__init__.py | 28 ++ account_credit_control/__openerp__.py | 74 +++++ account_credit_control/account.py | 221 +++++++++++++ account_credit_control/account_view.xml | 41 +++ account_credit_control/company.py | 28 ++ account_credit_control/company_view.xml | 15 + .../credit_control_demo.xml | 33 ++ account_credit_control/data.xml | 173 ++++++++++ account_credit_control/line.py | 189 +++++++++++ account_credit_control/line_view.xml | 145 ++++++++ account_credit_control/partner.py | 43 +++ account_credit_control/partner_view.xml | 25 ++ account_credit_control/policy.py | 310 ++++++++++++++++++ account_credit_control/policy_view.xml | 118 +++++++ account_credit_control/report/__init__.py | 1 + .../report/credit_control_summary.html.mako | 19 ++ .../report/credit_control_summary.py | 37 +++ account_credit_control/report/report.xml | 12 + account_credit_control/run.py | 150 +++++++++ account_credit_control/run_view.xml | 65 ++++ .../security/ir.model.access.csv | 27 ++ account_credit_control/wizard/__init__.py | 24 ++ .../wizard/credit_control_communication.py | 178 ++++++++++ .../wizard/credit_control_mailer.py | 64 ++++ .../wizard/credit_control_mailer_view.xml | 44 +++ .../wizard/credit_control_marker.py | 83 +++++ .../wizard/credit_control_marker_view.xml | 44 +++ .../wizard/credit_control_printer.py | 74 +++++ .../wizard/credit_control_printer_view.xml | 47 +++ 29 files changed, 2312 insertions(+) create mode 100644 account_credit_control/__init__.py create mode 100644 account_credit_control/__openerp__.py create mode 100644 account_credit_control/account.py create mode 100644 account_credit_control/account_view.xml create mode 100644 account_credit_control/company.py create mode 100644 account_credit_control/company_view.xml create mode 100644 account_credit_control/credit_control_demo.xml create mode 100644 account_credit_control/data.xml create mode 100644 account_credit_control/line.py create mode 100644 account_credit_control/line_view.xml create mode 100644 account_credit_control/partner.py create mode 100644 account_credit_control/partner_view.xml create mode 100644 account_credit_control/policy.py create mode 100644 account_credit_control/policy_view.xml create mode 100644 account_credit_control/report/__init__.py create mode 100644 account_credit_control/report/credit_control_summary.html.mako create mode 100644 account_credit_control/report/credit_control_summary.py create mode 100644 account_credit_control/report/report.xml create mode 100644 account_credit_control/run.py create mode 100644 account_credit_control/run_view.xml create mode 100644 account_credit_control/security/ir.model.access.csv create mode 100644 account_credit_control/wizard/__init__.py create mode 100644 account_credit_control/wizard/credit_control_communication.py create mode 100644 account_credit_control/wizard/credit_control_mailer.py create mode 100644 account_credit_control/wizard/credit_control_mailer_view.xml create mode 100644 account_credit_control/wizard/credit_control_marker.py create mode 100644 account_credit_control/wizard/credit_control_marker_view.xml create mode 100644 account_credit_control/wizard/credit_control_printer.py create mode 100644 account_credit_control/wizard/credit_control_printer_view.xml diff --git a/account_credit_control/__init__.py b/account_credit_control/__init__.py new file mode 100644 index 000000000..40d22fe09 --- /dev/null +++ b/account_credit_control/__init__.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Nicolas Bessi +# 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 . import run +from . import line +from . import account +from . import partner +from . import policy +from . import company +import wizard +import report diff --git a/account_credit_control/__openerp__.py b/account_credit_control/__openerp__.py new file mode 100644 index 000000000..f22c22db9 --- /dev/null +++ b/account_credit_control/__openerp__.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Nicolas Bessi +# 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' : 'Account Credit Control', + 'version' : '0.1', + 'author' : 'Camptocamp', + 'maintainer': 'Camptocamp', + 'category': 'Finance', + 'complexity': "normal", + 'depends' : ['base', 'account', 'email_template', 'report_webkit'], + 'description': """ +Credit Control +============== + +Configuration +------------- + +Configure the policies and policy levels in ``Accounting > Configuration > +Credit Control > Credit Policies``. +You can define as many policy levels as you need. + +Usage +----- + +Menu entries are located in ``Accounting > Periodical Processing > Credit +Control``. + +Create a new "run" in the ``Credit Control Run`` menu with the controlling date. +Then, use the ``Compute credit lines`` button. All the credit control lines will +be generated. You can find them in the ``Credit Control Lines`` menu. + +On each generated line, you have many choices: + * Send a email + * Print a letter + * Change the state (so you can ignore or reopen lines) + """, + 'website': 'http://www.camptocamp.com', + 'init_xml': ["data.xml"], + 'update_xml': ["line_view.xml", + "account_view.xml", + "partner_view.xml", + "policy_view.xml", + "run_view.xml", + "company_view.xml", + "wizard/credit_control_mailer_view.xml", + "wizard/credit_control_marker_view.xml", + "wizard/credit_control_printer_view.xml", + "report/report.xml", + "security/ir.model.access.csv", + ], + 'demo_xml': ["credit_control_demo.xml"], + 'tests': [], + 'installable': True, + 'license': 'AGPL-3', + 'application': True +} + diff --git a/account_credit_control/account.py b/account_credit_control/account.py new file mode 100644 index 000000000..b71928fab --- /dev/null +++ b/account_credit_control/account.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Nicolas Bessi +# 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 datetime import datetime +import operator +from openerp.osv.orm import Model, fields +from openerp.tools.translate import _ +from openerp.addons.account_credit_control import run + +class AccountAccount(Model): + """Add a link to a credit control policy on account account""" + + + def _check_account_type_compatibility(self, cursor, uid, acc_ids, context=None): + """We check that account is of type reconcile""" + if not isinstance(acc_ids, list): + acc_ids = [acc_ids] + for acc in self.browse(cursor, uid, acc_ids, context): + if acc.credit_policy_id and not acc.reconcile: + return False + return True + + _inherit = "account.account" + _description = """Add a link to a credit policy""" + _columns = {'credit_policy_id': fields.many2one('credit.control.policy', + 'Credit control policy', + help=("Define global credit policy" + "order is account partner invoice")), + + 'credit_control_line_ids': fields.one2many('credit.control.line', + 'account_id', + string='Credit Lines', + readonly=True)} + + _constraints = [(_check_account_type_compatibility, + _('You can not set a credit policy on a non reconciliable account'), + ['credit_policy_id'])] + +class AccountInvoice(Model): + """Add a link to a credit control policy on account account""" + + _inherit = "account.invoice" + _description = """Add a link to a credit policy""" + _columns = {'credit_policy_id': fields.many2one('credit.control.policy', + 'Credit control policy', + help=("Define global credit policy" + "order is account partner invoice")), + + 'credit_control_line_ids': fields.one2many('credit.control.line', + 'account_id', + string='Credit Lines', + readonly=True)} + + def action_move_create(self, cursor, uid, ids, context=None): + """We ensure writing of invoice id in move line because + Trigger field may not work without account_voucher addon""" + res = super(AccountInvoice, self).action_move_create(cursor, uid, ids, context=context) + for inv in self.browse(cursor, uid, ids, context=context): + if inv.move_id: + for line in inv.move_id.line_id: + line.write({'invoice_id': inv.id}) + return res + + +class AccountMoveLine(Model): + """Add a function that compute the residual amount using a follow up date + Add relation between move line and invoicex""" + + _inherit = "account.move.line" + # Store fields has strange behavior with voucher module we had to overwrite invoice + + + # def _invoice_id(self, cursor, user, ids, name, arg, context=None): + # #Code taken from OpenERP account addon + # invoice_obj = self.pool.get('account.invoice') + # res = {} + # for line_id in ids: + # res[line_id] = False + # cursor.execute('SELECT l.id, i.id ' \ + # 'FROM account_move_line l, account_invoice i ' \ + # 'WHERE l.move_id = i.move_id ' \ + # 'AND l.id IN %s', + # (tuple(ids),)) + # invoice_ids = [] + # for line_id, invoice_id in cursor.fetchall(): + # res[line_id] = invoice_id + # invoice_ids.append(invoice_id) + # invoice_names = {False: ''} + # for invoice_id, name in invoice_obj.name_get(cursor, user, invoice_ids, context=context): + # invoice_names[invoice_id] = name + # for line_id in res.keys(): + # invoice_id = res[line_id] + # res[line_id] = (invoice_id, invoice_names[invoice_id]) + # return res + + # def _get_invoice(self, cursor, uid, ids, context=None): + # result = set() + # for line in self.pool.get('account.invoice').browse(cursor, uid, ids, context=context): + # if line.move_id: + # ids = [x.id for x in line.move_id.line_id or []] + # return list(result) + + # _columns = {'invoice_id': fields.function(_invoice_id, string='Invoice', + # type='many2one', relation='account.invoice', + # store={'account.invoice': (_get_invoice, ['move_id'], 20)})} + + _columns = {'invoice_id': fields.many2one('account.invoice', 'Invoice')} + + def _get_payment_and_credit_lines(self, moveline_array, lookup_date): + credit_lines = [] + payment_lines = [] + for line in moveline_array: + if self._should_exlude_line(line): + continue + if line.account_id.type == 'receivable' and line.debit: + credit_lines.append(line) + else: + if line.reconcile_partial_id: + payment_lines.append(line) + credit_lines.sort(key=operator.attrgetter('date')) + payment_lines.sort(key=operator.attrgetter('date')) + return (credit_lines, payment_lines) + + def _validate_line_currencies(self, credit_lines): + """Raise an excpetion if there is lines with different currency""" + if len(credit_lines) == 0: + return True + currency = credit_lines[0].currency_id.id + if not all(obj.currency_id.id == currency for obj in credit_lines): + raise Exception('Not all line of move line are in the same currency') + + def _get_value_amount(self, mv_line_br): + if mv_line_br.currency_id: + return mv_line_br.amount_currency + else: + return mv_line_br.debit - mv_line_br.credit + + def _validate_partial(self, credit_lines): + if len(credit_lines) == 0: + return True + else: + line_with_partial = 0 + for line in credit_lines: + if not line.reconcile_partial_id: + line_with_partial += 1 + if line_with_partial and line_with_partial != len(credit_lines): + raise Exception('Can not compute credit line if multiple' + ' lines are not all linked to a partial') + + def _get_applicable_payment_lines(self, credit_line, payment_lines): + applicable_payment = [] + for pay_line in payment_lines: + if datetime.strptime(pay_line.date, "%Y-%m-%d").date() \ + <= datetime.strptime(credit_line.date, "%Y-%m-%d").date(): + applicable_payment.append(pay_line) + return applicable_payment + + def _compute_partial_reconcile_residual(self, move_lines, lookup_date, move_id, memoizer): + """ Compute open amount of multiple credit lines linked to multiple payment lines""" + credit_lines, payment_lines = self._get_payment_and_credit_lines(move_lines, lookup_date, memoizer) + self._validate_line_currencies(credit_lines) + self._validate_line_currencies(payment_lines) + self._validate_partial(credit_lines) + # memoizer structure move_id : {move_line_id: open_amount} + # paymnent line and credit line are sorted by date + rest = 0.0 + for credit_line in credit_lines: + applicable_payment = self._get_applicable_payment_lines(credit_line, payment_lines) + paid_amount = 0.0 + for pay_line in applicable_payment: + paid_amount += self._get_value_amount(pay_line) + balance_amount = self._get_value_amount(credit_lines) - (paid_amount + rest) + memoizer[move_id][credit_line.id] = balance_amount + if balance_amount < 0.0: + rest = balance_amount + else: + rest = 0.0 + return memoizer + + def _compute_fully_open_amount(self, move_lines, lookup_date, move_id, memoizer): + for move_line in move_lines: + memoizer[move_id][move_line.id] = self._get_value_amount(move_line) + return memoizer + + + def _amount_residual_from_date(self, cursor, uid, mv_line_br, lookup_date, context=None): + """ + Code from function _amount_residual of account/account_move_line.py does not take + in account mulitple line payment and reconciliation. We have to rewrite it + Code computes residual amount at lookup date for mv_line_br in entry + """ + memoizer = run.memoizers['credit_line_residuals'] + move_id = mv_line_br.move_id.id + if mv_line_br.move_id.id in memoizer: + pass # get back value + else: + memoizer[move_id] = {} + move_lines = mv_line_br.move_id.line_id + if mv_line_br.reconcile_partial_id: + self._compute_partial_reconcile_residual(move_lines, lookup_date, move_id, memoizer) + else: + self._compute_fully_open_amount(move_lines, lookup_date, move_id, memoizer) + return memoizer[move_id][mv_line_br.id] + diff --git a/account_credit_control/account_view.xml b/account_credit_control/account_view.xml new file mode 100644 index 000000000..fa68c94b2 --- /dev/null +++ b/account_credit_control/account_view.xml @@ -0,0 +1,41 @@ + + + + + account.followup.form.view + account.account + + form + + + + + + + + + + + invoice.followup.form.view + account.invoice + + form + + + + + + + + + + + + diff --git a/account_credit_control/company.py b/account_credit_control/company.py new file mode 100644 index 000000000..632ff95e7 --- /dev/null +++ b/account_credit_control/company.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Nicolas Bessi +# 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, fields + +class ResCompany(Model): + _inherit = "res.company" + + _columns = {"credit_control_tolerance": fields.float('Credit Tolerance')} + + _defaults = {"credit_control_tolerance": 0.1} diff --git a/account_credit_control/company_view.xml b/account_credit_control/company_view.xml new file mode 100644 index 000000000..2adb3f185 --- /dev/null +++ b/account_credit_control/company_view.xml @@ -0,0 +1,15 @@ + + + + credit.control.company.form + res.company + form + + + + + + + + + diff --git a/account_credit_control/credit_control_demo.xml b/account_credit_control/credit_control_demo.xml new file mode 100644 index 000000000..96734fbea --- /dev/null +++ b/account_credit_control/credit_control_demo.xml @@ -0,0 +1,33 @@ + + + + X11002-a + B2B Debtors - (test) + + receivable + + + + + + + X11002-b + B2C Debtors - (test) + + receivable + + + + + + + X11002-c + New Debtors - (test) + + receivable + + + + + + diff --git a/account_credit_control/data.xml b/account_credit_control/data.xml new file mode 100644 index 000000000..30ce5b0ca --- /dev/null +++ b/account_credit_control/data.xml @@ -0,0 +1,173 @@ + + + + + Credit Control demo mail + noreply@localhost + Credit Control Invoice (${object.current_policy_level.level or 'n/a' }) + ${object.get_mail() or ''} + + + + %if mode != 'pdf': + + + %endif +
+ +

Dear ${object.partner_id.name or ''},

+ +
${object.current_policy_level.level.custom_text}
+ + + + + + + + + +%for line in object.credit_lines: + + + + + %if line.invoice_id: + + %else: + + %endif +%endfor +
Summary
date dueAmount dueAmount balanceInvoice number
${line.date_due}${line.amount_due}${line.balance_due}${line.invoice_id.number}n/a
+
+
+ +

If you have any question, do not hesitate to contact us.

+ + +

Thank you for choosing ${object.company_id.name}!

+ + -- more info here -- +

${object.user_id.name} ${object.user_id.user_email and '<%s>'%(object.user_id.user_email) or ''}
+ ${object.company_id.name}
+ % if object.company_id.street: + ${object.company_id.street or ''}
+ + % endif + + % if object.company_id.street2: + ${object.company_id.street2}
+ % endif + % if object.company_id.city or object.company_id.zip: + ${object.company_id.zip or ''} ${object.company_id.city or ''}
+ % endif + % if object.company_id.country_id: + ${object.company_id.state_id and ('%s, ' % object.company_id.state_id.name) or ''} ${object.company_id.country_id.name or ''}
+ % endif + % if object.company_id.phone: + Phone: ${object.company_id.phone}
+ % endif + % if object.company_id.website: + ${object.company_id.website or ''}
+ % endif + ]]> + + + + + No follow + + + + + + 3 time policy + + + + 10 days net + + net_days + + + + mail + Replace this text in level by your message + + + + 30 days end of month + + end_of_month + + + + mail + Replace this text in level by your message + + + + 10 days sommation + + previous_date + + + + manual + Replace this text in level by your message + + + + + 2 time policy + + + + 30 days end of month + + end_of_month + + + + mail + Replace this text in level by your message + + + + 60 days sommation + + previous_date + + + + manual + Replace this text in level by your message + + + + Credit Control Manager + + + + + Credit Control User + + + + + Credit Control Info + + + + + diff --git a/account_credit_control/line.py b/account_credit_control/line.py new file mode 100644 index 000000000..db5486833 --- /dev/null +++ b/account_credit_control/line.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Nicolas Bessi +# 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 logging + +from openerp.osv.orm import Model, fields +import pooler +#from datetime import datetime + +logger = logging.getLogger('credit.line.control') + +class CreditControlLine (Model): + """A credit control line decribe a line of amount due by a customer. + It is linked to all required financial account. + It has various state draft open to be send send. For more information about + usage please read __openerp__.py file""" + + _name = "credit.control.line" + _description = """A credit control line""" + _rec_name = "id" + + _columns = {'date': fields.date('Controlling date', required=True), + # maturity date of related move line we do not use a related field in order to + # allow manual changes + 'date_due': fields.date('Due date', + required=True, + readonly=True, + states={'draft': [('readonly', False)]}), + + 'date_sent': fields.date('Sent date', + readonly=True, + states={'draft': [('readonly', False)]}), + + 'state': fields.selection([('draft', 'Draft'), + ('to_be_sent', 'To be sent'), + ('sent', 'Done'), + ('error', 'Error'), + ('mail_error', 'Mailing Error')], + 'State', required=True, readonly=True), + + 'canal': fields.selection([('manual', 'Manual'), + ('mail', 'Mail')], + 'Canal', required=True, + readonly=True, + states={'draft': [('readonly', False)]}), + + 'invoice_id': fields.many2one('account.invoice', 'Invoice', readonly=True), + 'partner_id': fields.many2one('res.partner', "Partner", required=True), + 'amount_due': fields.float('Due Amount Tax inc.', required=True, readonly=True), + 'balance_due': fields.float('Due balance', required=True, readonly=True), + 'mail_message_id': fields.many2one('mail.message', 'Sent mail', readonly=True), + + 'move_line_id': fields.many2one('account.move.line', 'Move line', + required=True, readonly=True), + + 'account_id': fields.related('move_line_id', 'account_id', type='many2one', + relation='account.account', string='Account', + store=True, readonly=True), + + 'currency_id': fields.related('move_line_id', 'currency_id', type='many2one', + relation='res.currency', string='Currency', + store=True, readonly=True), + + 'company_id': fields.related('move_line_id', 'company_id', type='many2one', + relation='res.company', string='Company', + store=True, readonly=True), + + # we can allow a manual change of policy in draft state + 'policy_level_id':fields.many2one('credit.control.policy.level', + 'Overdue Level', required=True, readonly=True, + states={'draft': [('readonly', False)]}), + + 'policy_id': fields.related('policy_level_id', + 'policy_id', + type='many2one', + relation='credit.control.policy', + string='Policy', + store=True, + readonly=True), + + 'level': fields.related('policy_level_id', + 'level', + type='float', + relation='credit.control.policy', + string='Level', + store=True, + readonly=True),} + + + _defaults = {'state': 'draft'} + + def _update_from_mv_line(self, cursor, uid, ids, mv_line_br, level, + lookup_date, context=None): + """hook function to update line if required""" + context = context or {} + return [] + + def _create_from_mv_line(self, cursor, uid, ids, mv_line_br, + level, lookup_date, context=None): + """Create credit line""" + acc_line_obj = self.pool.get('account.move.line') + context = context or {} + data_dict = {} + data_dict['date'] = lookup_date + data_dict['date_due'] = mv_line_br.date_maturity + data_dict['state'] = 'draft' + data_dict['canal'] = level.canal + data_dict['invoice_id'] = (mv_line_br.invoice_id and mv_line_br.invoice_id.id + or False) + data_dict['partner_id'] = mv_line_br.partner_id.id + data_dict['amount_due'] = (mv_line_br.amount_currency or mv_line_br.debit + or mv_line_br.credit) + data_dict['balance_due'] = acc_line_obj._amount_residual_from_date(cursor, uid, mv_line_br, + lookup_date, context=context) + data_dict['policy_level_id'] = level.id + data_dict['company_id'] = mv_line_br.company_id.id + data_dict['move_line_id'] = mv_line_br.id + return [self.create(cursor, uid, data_dict)] + + + def create_or_update_from_mv_lines(self, cursor, uid, ids, lines, + level_id, lookup_date, errors=None, context=None): + """Create or update line base on levels""" + context = context or {} + currency_obj = self.pool.get('res.currency') + level_obj = self.pool.get('credit.control.policy.level') + ml_obj = self.pool.get('account.move.line') + level = level_obj.browse(cursor, uid, level_id, context) + current_lvl = level.level + credit_line_ids = [] + user = self.pool.get('res.users').browse(cursor, uid, uid) + tolerance_base = user.company_id.credit_control_tolerance + tolerance = {} + currency_ids = currency_obj.search(cursor, uid, []) + + acc_line_obj = self.pool.get('account.move.line') + for c_id in currency_ids: + tmp = currency_obj.compute(cursor, uid, c_id, + user.company_id.currency_id.id, tolerance_base) + tolerance[c_id] = tmp + + existings = self.search(cursor, uid, [('move_line_id', 'in', lines), + ('level', '=', current_lvl)]) + db, pool = pooler.get_db_and_pool(cursor.dbname) + for line in ml_obj.browse(cursor, uid, lines, context): + # we want to create as many line as possible + local_cr = db.cursor() + try: + if line.id in existings: + # does nothing just a hook + credit_line_ids += self._update_from_mv_line(local_cr, uid, ids, + line, level, lookup_date, + context=context) + else: + # as we use memoizer pattern this has almost no cost to get it + # multiple time + open_amount = acc_line_obj._amount_residual_from_date(cursor, uid, line, + lookup_date, context=context) + + if open_amount > tolerance.get(line.currency_id.id, tolerance_base): + credit_line_ids += self._create_from_mv_line(local_cr, uid, ids, + line, level, lookup_date, + context=context) + except Exception, exc: + logger.error(exc) + if errors: + errors.append(unicode(exc)) #obj-c common pattern + local_cr.rollback() + finally: + local_cr.commit() + local_cr.close() + return credit_line_ids diff --git a/account_credit_control/line_view.xml b/account_credit_control/line_view.xml new file mode 100644 index 000000000..8c68366d0 --- /dev/null +++ b/account_credit_control/line_view.xml @@ -0,0 +1,145 @@ + + + + credit.control.line.form + credit.control.line + form + +

+ + + + + + + + + + + + + + + + + + + + + + + Credit Control Lines + credit.control.lines + search + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + credit.control.line.tree + credit.control.line + tree + + + + + + + + + + + + + + + + + + + + + + + + + Credit Control Lines + ir.actions.act_window + credit.control.line + + form + tree,form + + + + + + + + + + diff --git a/account_credit_control/partner.py b/account_credit_control/partner.py new file mode 100644 index 000000000..5938a3891 --- /dev/null +++ b/account_credit_control/partner.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Nicolas Bessi +# 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, fields + + +class ResPartner(Model): + """Add a settings on the credit control policy to use on the partners, + and links to the credit control lines.""" + + _inherit = "res.partner" + _columns = { + 'credit_policy_id': fields.many2one('credit.control.policy', + 'Credit Control Policy', + help=("The Credit Control policy" + "used for this user. This " + "setting can be forced on the " + "invoice. If nothing is defined, " + "it will use the account " + "setting.")), + 'credit_control_line_ids': fields.one2many('credit.control.line', + 'invoice_id', + string='Credit Lines', + readonly=True) + } + diff --git a/account_credit_control/partner_view.xml b/account_credit_control/partner_view.xml new file mode 100644 index 000000000..5ac108447 --- /dev/null +++ b/account_credit_control/partner_view.xml @@ -0,0 +1,25 @@ + + + + partner.credit_control.form.view + res.partner + + form + + + + + + + + + + + diff --git a/account_credit_control/policy.py b/account_credit_control/policy.py new file mode 100644 index 000000000..2c10dd2f4 --- /dev/null +++ b/account_credit_control/policy.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Nicolas Bessi +# 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, fields +from openerp.tools.translate import _ + +class CreditControlPolicy(Model): + """Define a policy of reminder""" + + _name = "credit.control.policy" + _description = """Define a reminder policy""" + _columns = {'name': fields.char('Name', required=True, size=128), + + 'level_ids' : fields.one2many('credit.control.policy.level', + 'policy_id', + 'Policy Levels'), + + 'do_nothing' : fields.boolean('Do nothing', + help=('For policies who should not ' + 'generate lines or are obsolete')), + + 'company_id' : fields.many2one('res.company', 'Company') + } + + + def _get_account_related_lines(self, cursor, uid, policy_id, lookup_date, lines, context=None): + """ We get all the lines related to accounts with given credit policy. + We try not to use direct SQL in order to respect security rules. + As we define the first set it is important, The date is used to do a prefilter. + !!!We take the asumption that only receivable lines have a maturity date + and account must be reconcillable""" + context = context or {} + move_l_obj = self.pool.get('account.move.line') + account_obj = self.pool.get('account.account') + acc_ids = account_obj.search(cursor, uid, [('credit_policy_id', '=', policy_id)]) + if not acc_ids: + return lines + move_ids = move_l_obj.search(cursor, uid, [('account_id', 'in', acc_ids), + ('date_maturity', '<=', lookup_date), + ('reconcile_id', '=', False), + ('partner_id', '!=', False)]) + + lines += move_ids + return lines + + + def _get_sum_reduce_range(self, cursor, uid, policy_id, lookup_date, lines, model, + move_relation_field, context=None): + """ We get all the lines related to the model with given credit policy. + We also reduce from the global set (lines) the move line to be excluded. + We try not to use direct SQL in order to respect security rules. + As we define the first set it is important. + The policy relation field MUST be named credit_policy_id + and the model must have a relation + with account move line. + !!! We take the asumption that only receivable lines have a maturity date + and account must be reconcillable""" + # MARK possible place for a good optimisation + context = context or {} + my_obj = self.pool.get(model) + move_l_obj = self.pool.get('account.move.line') + add_obj_ids = my_obj.search(cursor, uid, [('credit_policy_id', '=', policy_id)]) + if add_obj_ids: + add_lines = move_l_obj.search(cursor, uid, [(move_relation_field, 'in', add_obj_ids), + ('date_maturity', '<=', lookup_date), + ('partner_id', '!=', False), + ('reconcile_id', '=', False)]) + lines = list(set(lines + add_lines)) + # we get all the lines that must be excluded at partner_level + # from the global set (even the one included at account level) + neg_obj_ids = my_obj.search(cursor, uid, [('credit_policy_id', '!=', policy_id), + ('credit_policy_id', '!=', False)]) + if neg_obj_ids: + # should we add ('id', 'in', lines) in domain ? it may give a veeery long SQL... + neg_lines = move_l_obj.search(cursor, uid, [(move_relation_field, 'in', neg_obj_ids), + ('date_maturity', '<=', lookup_date), + ('partner_id', '!=', False), + ('reconcile_id', '=', False)]) + if neg_lines: + lines = list(set(lines) - set(neg_lines)) + return lines + + + def _get_partner_related_lines(self, cursor, uid, policy_id, lookup_date, lines, context=None): + return self._get_sum_reduce_range(cursor, uid, policy_id, lookup_date, lines, + 'res.partner', 'partner_id', context=context) + + + def _get_invoice_related_lines(self, cursor, uid, policy_id, lookup_date, lines, context=None): + return self._get_sum_reduce_range(cursor, uid, policy_id, lookup_date, lines, + 'account.invoice', 'invoice', context=context) + + + def _get_moves_line_to_process(self, cursor, uid, policy_id, lookup_date, context=None): + """Retrive all the move line to be procces for current policy. + This function is planned to be use only on one id. + Priority of inclustion, exlusion is account, partner, invoice""" + context = context or {} + lines = [] + if isinstance(policy_id, list): + policy_id = policy_id[0] + # order of call MUST be respected priority is account, partner, invoice + lines = self._get_account_related_lines(cursor, uid, policy_id, + lookup_date, lines, context=context) + lines = self._get_partner_related_lines(cursor, uid, policy_id, + lookup_date, lines, context=context) + lines = self._get_invoice_related_lines(cursor, uid, policy_id, + lookup_date, lines, context=context) + return lines + + def _check_lines_policies(self, cursor, uid, policy_id, lines, context=None): + """ Check if there is credit line related to same move line but + related to an other policy""" + context = context or {} + if not lines: + return [] + if isinstance(policy_id, list): + policy_id = policy_id[0] + cursor.execute("SELECT move_line_id FROM credit_control_line" + " WHERE policy_id != %s and move_line_id in %s", + (policy_id, tuple(lines))) + res = cursor.fetchall() + if res: + return [x[0] for x in res] + else: + return [] + + + +class CreditControlPolicyLevel(Model): + """Define a policy level. A level allows to determine if + a move line is due and the level of overdue of the line""" + + _name = "credit.control.policy.level" + _order = 'level' + _description = """A credit control policy level""" + _columns = {'policy_id': fields.many2one('credit.control.policy', + 'Related Policy', required=True), + 'name': fields.char('Name', size=128, required=True), + 'level': fields.float('level', required=True), + + 'computation_mode': fields.selection([('net_days', 'Due date'), + ('end_of_month', 'Due Date: end of Month'), + ('previous_date', 'Previous reminder')], + 'Compute mode', + required=True), + + 'delay_days': fields.integer('Delay in day', required='True'), + 'mail_template_id': fields.many2one('email.template', 'Mail template', + required=True), + 'canal': fields.selection([('manual', 'Manual'), + ('mail', 'Mail')], + 'Canal', required=True), + 'custom_text': fields.text('Custom message', required=True, translate=True), + } + + + def _check_level_mode(self, cursor, uid, rids, context=None): + """We check that the smallest level is not based + on a level using previous_date mode""" + if not isinstance(rids, list): + rids = [rids] + for level in self.browse(cursor, uid, rids, context): + smallest_level_id = self.search(cursor, uid, [('policy_id', '=', level.policy_id.id)], + order='level asc', limit=1, context=context) + smallest_level = self.browse(cursor, uid, smallest_level_id[0], context) + if smallest_level.computation_mode == 'previous_date': + return False + return True + + + + _sql_constraint = [('unique level', + 'UNIQUE (policy_id, level)', + 'Level must be unique per policy')] + + _constraints = [(_check_level_mode, + 'The smallest level can not be of type Previous reminder', + ['level'])] + + def _previous_level(self, cursor, uid, policy_level, context=None): + """ For one policy level, returns the id of the previous level + + If there is no previous level, it returns None, it means that's the + first policy level + + :param browse_record policy_level: policy level + :return: previous level id or None if there is no previous level + """ + previous_level_ids = self.search( + cursor, + uid, + [('policy_id', '=', policy_level.policy_id.id), + ('level', '<', policy_level.level)], + order='level desc', + limit=1, + context=context) + return previous_level_ids[0] if previous_level_ids else None + + # ----- time related functions --------- + + def _net_days_get_boundary(self): + return " (mv_line.date_maturity + %(delay)s)::date <= date(%(lookup_date)s)" + + def _end_of_month_get_boundary(self): + return ("(date_trunc('MONTH', (mv_line.date_maturity + %(delay)s))+INTERVAL '1 MONTH - 1 day')::date" + "<= date(%(lookup_date)s)") + + def _previous_date_get_boundary(self): + return "(cr_line.date + %(delay)s)::date <= date(%(lookup_date)s)" + + def _get_sql_date_boundary_for_computation_mode(self, cursor, uid, level, lookup_date, context=None): + """Return a where clauses statement for the given + lookup date and computation mode of the level""" + fname = "_%s_get_boundary" % (level.computation_mode,) + if hasattr(self, fname): + fnc = getattr(self, fname) + return fnc() + else: + raise NotImplementedError(_('Can not get function for computation mode: ' + '%s is not implemented') % (fname,)) + + # ----------------------------------------- + + def _get_first_level_lines(self, cursor, uid, level, lookup_date, lines, context=None): + if not lines: + return [] + """Retrieve all the line that are linked to a frist level. + We use Raw SQL for perf. Security rule where applied in + policy object when line where retrieved""" + sql = ("SELECT DISTINCT mv_line.id\n" + " FROM account_move_line mv_line\n" + " WHERE mv_line.id in %(line_ids)s\n" + " AND NOT EXISTS (SELECT cr_line.id from credit_control_line cr_line\n" + " WHERE cr_line.move_line_id = mv_line.id)") + sql += " AND" + self._get_sql_date_boundary_for_computation_mode( + cursor, uid, level, lookup_date, context) + data_dict = {'lookup_date': lookup_date, 'line_ids': tuple(lines), + 'delay': level.delay_days} + + cursor.execute(sql, data_dict) + res = cursor.fetchall() + if not res: + return [] + return [x[0] for x in res] + + + def _get_other_level_lines(self, cursor, uid, level, lookup_date, lines, context=None): + # We filter line that have a level smaller than current one + # TODO if code fits need refactor _get_first_level_lines and _get_other_level_lines + # Code is not DRY + if not lines: + return [] + sql = ("SELECT mv_line.id\n" + " FROM account_move_line mv_line\n" + " JOIN credit_control_line cr_line\n" + " ON (mv_line.id = cr_line.move_line_id)\n" + " WHERE cr_line.id = (SELECT credit_control_line.id FROM credit_control_line\n" + " WHERE credit_control_line.move_line_id = mv_line.id\n" + " ORDER BY credit_control_line.level desc limit 1)\n" + " AND cr_line.level = %(level)s\n" + " AND mv_line.id in %(line_ids)s\n") + sql += " AND " + self._get_sql_date_boundary_for_computation_mode( + cursor, uid, level, lookup_date, context) + previous_level_id = self._previous_level( + cursor, uid, level, context=context) + previous_level = self.browse( + cursor, uid, previous_level_id, context=context) + data_dict = {'lookup_date': lookup_date, 'line_ids': tuple(lines), + 'delay': level.delay_days, 'level': previous_level.level} + + cursor.execute(sql, data_dict) + res = cursor.fetchall() + if not res: + return [] + return [x[0] for x in res] + + def get_level_lines(self, cursor, uid, level_id, lookup_date, lines, context=None): + """get all move lines in entry lines that match the current level""" + assert not (isinstance(level_id, list) and len(level_id) > 1), "level_id: only one id expected" + if isinstance(level_id, list): + level_id = level_id[0] + matching_lines = [] + level = self.browse(cursor, uid, level_id, context=context) + if self._previous_level(cursor, uid, level, context=context) is None: + matching_lines += self._get_first_level_lines( + cursor, uid, level, lookup_date, lines, context=context) + else: + matching_lines += self._get_other_level_lines( + cursor, uid, level, lookup_date, lines, context=context) + + return matching_lines + diff --git a/account_credit_control/policy_view.xml b/account_credit_control/policy_view.xml new file mode 100644 index 000000000..6113f0439 --- /dev/null +++ b/account_credit_control/policy_view.xml @@ -0,0 +1,118 @@ + + + + + credit.control.policy.form + credit.control.policy + form + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + credit.control.policy.tree + credit.control.policy + tree + + + + + + + + + + + + Credit Policies + ir.actions.act_window + credit.control.policy + + form + tree,form + + + + + + + credit.mangement.policy.level.form + credit.control.policy.level + form + +
+ + + + + + + + + + + + + + +
+
+ + + credit.control.policy.level.tree + credit.control.policy.level + tree + + + + + + + + + + + +
+
diff --git a/account_credit_control/report/__init__.py b/account_credit_control/report/__init__.py new file mode 100644 index 000000000..b7e5edadc --- /dev/null +++ b/account_credit_control/report/__init__.py @@ -0,0 +1 @@ +from . import credit_control_summary diff --git a/account_credit_control/report/credit_control_summary.html.mako b/account_credit_control/report/credit_control_summary.html.mako new file mode 100644 index 000000000..250bccc29 --- /dev/null +++ b/account_credit_control/report/credit_control_summary.html.mako @@ -0,0 +1,19 @@ + + + + + + %for comm in objects : + ${setLang(comm.partner_id.lang)} + <% + current_uri = '%s_policy_template' % (comm.partner_id.lang) + if not context.lookup.has_template(current_uri): + context.lookup.put_string(current_uri, comm.current_policy_level.mail_template_id.body_html) + %> + <%include file="${current_uri}" args="object=comm,mode='pdf'"/> +
+ %endfor + + diff --git a/account_credit_control/report/credit_control_summary.py b/account_credit_control/report/credit_control_summary.py new file mode 100644 index 000000000..156857f92 --- /dev/null +++ b/account_credit_control/report/credit_control_summary.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Nicolas Bessi +# 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 time + +from openerp.report import report_sxw + +class CreditSummaryReport(report_sxw.rml_parse): + def __init__(self, cr, uid, name, context): + super(CreditSummaryReport, self).__init__(cr, uid, name, context=context) + self.localcontext.update({ + 'time': time, + 'cr':cr, + 'uid': uid, + }) + +report_sxw.report_sxw('report.credit_control_summary', + 'credit.control.communication', + 'addons/account_credit_control/report/credit_control_summary.html.mako', + parser=CreditSummaryReport) diff --git a/account_credit_control/report/report.xml b/account_credit_control/report/report.xml new file mode 100644 index 000000000..04e84f277 --- /dev/null +++ b/account_credit_control/report/report.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/account_credit_control/run.py b/account_credit_control/run.py new file mode 100644 index 000000000..80e58798e --- /dev/null +++ b/account_credit_control/run.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Author: Nicolas Bessi +# 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 sys +import traceback +import logging + +from openerp.osv.orm import Model, fields +from openerp.tools.translate import _ +from openerp.osv.osv import except_osv + +logger = logging.getLogger('Credit Control run') + +memoizers = {} + + +class CreditControlRun(Model): + """Credit Control run generate all credit control lines and reject""" + + _name = "credit.control.run" + _rec_name = 'date' + _description = """Credit control line generator""" + _columns = {'date': fields.date('Lookup date', required=True), + 'policy_ids': fields.many2many('credit.control.policy', + rel="credit_run_policy_rel", + string='Policies', + readonly=True, + help="If nothing set all policies will be used", + + states={'draft': [('readonly', False)]}), + + 'report': fields.text('Report', readonly=True), + + 'state': fields.selection([('draft', 'Draft'), + ('running', 'Running'), + ('done', 'Done'), + ('error', 'Error')], + string='State', + required=True, + readonly=True), + + 'manual_ids': fields.many2many('account.move.line', + rel="credit_runreject_rel", + string='Line to be handled manually', + readonly=True), + } + + _defaults = {'state': 'draft'} + + def check_run_date(self, cursor, uid, ids, lookup_date, context=None): + """Ensure that there is no credit line in the future using lookup_date""" + line_obj = self.pool.get('credit.control.line') + lines = line_obj.search(cursor, uid, [('date', '>', lookup_date)], + order='date DESC', limit=1) + if lines: + line = line_obj.browse(cursor, uid, lines[0]) + raise except_osv(_('A run was already executed in a greater date'), + _('Run date should be >= %s') % (line.date)) + + + def _generate_credit_lines(self, cursor, uid, run_id, context=None): + """ Generate credit line. Function can be a little dryer but + it does almost noting, initalise variable maange error and call + real know how method""" + memoizers['credit_line_residuals'] = {} + cr_line_obj = self.pool.get('credit.control.line') + if isinstance(run_id, list): + run_id = run_id[0] + run = self.browse(cursor, uid, run_id, context=context) + errors = [] + manualy_managed_lines = [] #line who changed policy + credit_line_ids = [] # generated lines + run.check_run_date(run.date, context=context) + policy_ids = run.policy_ids + if not policy_ids: + policy_obj = self.pool.get('credit.control.policy') + policy_ids_ids = policy_obj.search(cursor, uid, []) + policy_ids = policy_obj.browse(cursor, uid, policy_ids_ids) + for policy in policy_ids: + if policy.do_nothing: + continue + try: + lines = policy._get_moves_line_to_process(run.date, context=context) + tmp_manual = policy._check_lines_policies(lines, context=context) + lines = list(set(lines) - set(tmp_manual)) + manualy_managed_lines += tmp_manual + if not lines: + continue + # policy levels are sorted by level so iteration is in the correct order + for level in policy.level_ids: + level_lines = level.get_level_lines(run.date, lines) + #only this write action own a separate cursor + credit_line_ids += cr_line_obj.create_or_update_from_mv_lines( + cursor, uid, [], level_lines, level.id, run.date, errors=errors, context=context) + + lines = list(set(lines) - set(level_lines)) + except except_osv, exc: + cursor.rollback() + error_type, error_value, trbk = sys.exc_info() + st = "Error: %s\nDescription: %s\nTraceback:" % (error_type.__name__, error_value) + st += ''.join(traceback.format_tb(trbk, 30)) + logger.error(st) + self.write(cursor, uid, [run.id], {'report':st, 'state': 'error'}) + return False + vals = {'report': u"Number of generated lines : %s \n" % (len(credit_line_ids),), + 'state': 'done', + 'manual_ids': [(6, 0, manualy_managed_lines)]} + if errors: + vals['report'] += u"Following line generation errors appends:" + vals['report'] += u"----\n".join(errors) + vals['state'] = 'done' + run.write(vals) + # lines will correspond to line that where not treated + return lines + + + + def generate_credit_lines(self, cursor, uid, run_id, context=None): + """Generate credit control lines""" + context = context or {} + # we do a little magical tips in order to ensure non concurrent run + # of the function generate_credit_lines + try: + cursor.execute('SELECT id FROM credit_control_run' + ' LIMIT 1 FOR UPDATE NOWAIT' ) + except Exception, exc: + cursor.rollback() + raise except_osv(_('A credit control run is already running' + ' in background please try later'), + str(exc)) + # in case of exception openerp will do a rollback for us and free the lock + return self._generate_credit_lines(cursor, uid, run_id, context) + diff --git a/account_credit_control/run_view.xml b/account_credit_control/run_view.xml new file mode 100644 index 000000000..cdd49b401 --- /dev/null +++ b/account_credit_control/run_view.xml @@ -0,0 +1,65 @@ + + + + + credit.control.run.tree + credit.control.run + tree + + + + + + + + + + credit.control.run.form + credit.control.run + form + +
+ + + + + + + + + + + + + +