[MODULE] +v1.1.0 from https://www.odoo.com/apps/7.0/account_financial_report_webkit/
[burette/account_financial_report_webkit.git] / report / aged_partner_balance.py
diff --git a/report/aged_partner_balance.py b/report/aged_partner_balance.py
new file mode 100644 (file)
index 0000000..ef81d32
--- /dev/null
@@ -0,0 +1,403 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+#    Author: Nicolas Bessi
+#    Copyright 2014 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 <http://www.gnu.org/licenses/>.
+#
+##############################################################################
+from __future__ import division
+from datetime import datetime
+
+from openerp import pooler
+from openerp.tools import DEFAULT_SERVER_DATE_FORMAT
+from openerp.tools.translate import _
+from .open_invoices import PartnersOpenInvoicesWebkit
+from .webkit_parser_header_fix import HeaderFooterTextWebKitParser
+
+
+def make_ranges(top, offset):
+    """Return sorted days ranges
+
+    :param top: maximum overdue day
+    :param offset: offset for ranges
+
+    :returns: list of sorted ranges tuples in days
+              eg. [(-100000, 0), (0, offset), (offset, n*offset), ... (top, 100000)]
+    """
+    ranges = [(n, min(n + offset, top)) for n in xrange(0, top, offset)]
+    ranges.insert(0, (-100000000000, 0))
+    ranges.append((top, 100000000000))
+    return ranges
+
+#list of overdue ranges
+RANGES = make_ranges(120, 30)
+
+
+def make_ranges_titles():
+    """Generates title to be used by mako"""
+    titles = [_('Due')]
+    titles += [_(u'Overdue ≤ %s d.') % x[1] for x in RANGES[1:-1]]
+    titles.append(_('Older'))
+    return titles
+
+#list of overdue ranges title
+RANGES_TITLES = make_ranges_titles()
+#list of payable journal types
+REC_PAY_TYPE = ('purchase', 'sale')
+#list of refund payable type
+REFUND_TYPE = ('purchase_refund', 'sale_refund')
+INV_TYPE = REC_PAY_TYPE + REFUND_TYPE
+
+
+class AccountAgedTrialBalanceWebkit(PartnersOpenInvoicesWebkit):
+    """Compute Aged Partner Balance based on result of Open Invoices"""
+
+    def __init__(self, cursor, uid, name, context=None):
+        """Constructor, refer to :class:`openerp.report.report_sxw.rml_parse`"""
+        super(AccountAgedTrialBalanceWebkit, self).__init__(cursor, uid, name,
+                                                            context=context)
+        self.pool = pooler.get_pool(self.cr.dbname)
+        self.cursor = self.cr
+        company = self.pool.get('res.users').browse(self.cr, uid, uid,
+                                                    context=context).company_id
+
+        header_report_name = ' - '.join((_('Aged Partner Balance'),
+                                         company.currency_id.name))
+
+        footer_date_time = self.formatLang(str(datetime.today()),
+                                           date_time=True)
+
+        self.localcontext.update({
+            'cr': cursor,
+            'uid': uid,
+            'company': company,
+            'ranges': self._get_ranges(),
+            'ranges_titles': self._get_ranges_titles(),
+            'report_name': _('Aged Partner Balance'),
+            'additional_args': [
+                ('--header-font-name', 'Helvetica'),
+                ('--footer-font-name', 'Helvetica'),
+                ('--header-font-size', '10'),
+                ('--footer-font-size', '6'),
+                ('--header-left', header_report_name),
+                ('--header-spacing', '2'),
+                ('--footer-left', footer_date_time),
+                ('--footer-right', ' '.join((_('Page'), '[page]', _('of'), '[topage]'))),
+                ('--footer-line',),
+            ],
+        })
+
+    def _get_ranges(self):
+        """:returns: :cons:`RANGES`"""
+        return RANGES
+
+    def _get_ranges_titles(self):
+        """:returns: :cons: `RANGES_TITLES`"""
+        return RANGES_TITLES
+
+    def set_context(self, objects, data, ids, report_type=None):
+        """Populate aged_lines, aged_balance, aged_percents attributes
+
+        on each account browse record that will be used by mako template
+        The browse record are store in :attr:`objects`
+
+        The computation are based on the ledger_lines attribute set on account
+        contained by :attr:`objects`
+
+        :attr:`objects` values were previously set by parent class
+        :class: `.open_invoices.PartnersOpenInvoicesWebkit`
+
+        :returns: parent :class:`.open_invoices.PartnersOpenInvoicesWebkit`
+                  call to set_context
+
+        """
+        res = super(AccountAgedTrialBalanceWebkit, self).set_context(
+            objects,
+            data,
+            ids,
+            report_type=report_type
+        )
+
+        for acc in self.objects:
+            acc.aged_lines = {}
+            acc.agged_totals = {}
+            acc.agged_percents = {}
+            for part_id, partner_lines in acc.ledger_lines.items():
+                aged_lines = self.compute_aged_lines(part_id,
+                                                     partner_lines,
+                                                     data)
+                if aged_lines:
+                    acc.aged_lines[part_id] = aged_lines
+            acc.aged_totals = totals = self.compute_totals(acc.aged_lines.values())
+            acc.aged_percents = self.compute_percents(totals)
+        #Free some memory
+        del(acc.ledger_lines)
+        return res
+
+    def compute_aged_lines(self, partner_id, ledger_lines, data):
+        """Add property aged_lines to accounts browse records
+
+        contained in :attr:`objects` for a given partner
+
+        :param: partner_id: current partner
+        :param ledger_lines: generated by parent
+                 :class:`.open_invoices.PartnersOpenInvoicesWebkit`
+
+        :returns: dict of computed aged lines
+                  eg {'balance': 1000.0,
+                       'aged_lines': {(90, 120): 0.0, ...}
+
+        """
+        lines_to_age = self.filter_lines(partner_id, ledger_lines)
+        res = {}
+        end_date = self._get_end_date(data)
+        aged_lines = dict.fromkeys(RANGES, 0.0)
+        reconcile_lookup = self.get_reconcile_count_lookup(lines_to_age)
+        res['aged_lines'] = aged_lines
+        for line in lines_to_age:
+            compute_method = self.get_compute_method(reconcile_lookup,
+                                                     partner_id,
+                                                     line)
+            delay = compute_method(line, end_date, ledger_lines)
+            classification = self.classify_line(partner_id, delay)
+            aged_lines[classification] += line['debit'] - line['credit']
+        self.compute_balance(res, aged_lines)
+        return res
+
+    def _get_end_date(self, data):
+        """Retrieve end date to be used to compute delay.
+
+        :param data: data dict send to report contains form dict
+
+        :returns: end date to be used to compute overdue delay
+
+        """
+        end_date = None
+        date_to = data['form']['date_to']
+        period_to_id = data['form']['period_to']
+        fiscal_to_id = data['form']['fiscalyear_id']
+        if date_to:
+            end_date = date_to
+        elif period_to_id:
+            period_to = self.pool['account.period'].browse(self.cr,
+                                                           self.uid,
+                                                           period_to_id)
+            end_date = period_to.date_stop
+        elif fiscal_to_id:
+            fiscal_to = self.pool['account.fiscalyear'].browse(self.cr,
+                                                               self.uid,
+                                                               fiscal_to_id)
+            end_date = fiscal_to.date_stop
+        else:
+            raise ValueError('End date and end period not available')
+        return end_date
+
+    def _compute_delay_from_key(self, key, line, end_date):
+        """Compute overdue delay delta in days for line using attribute in key
+
+        delta = end_date - date of key
+
+        :param line: current ledger line
+        :param key: date key to be used to compute delta
+        :param end_date: end_date computed for wizard data
+
+        :returns: delta in days
+        """
+        from_date = datetime.strptime(line[key], DEFAULT_SERVER_DATE_FORMAT)
+        end_date = datetime.strptime(end_date, DEFAULT_SERVER_DATE_FORMAT)
+        delta = end_date - from_date
+        return delta.days
+
+    def compute_delay_from_maturity(self, line, end_date, ledger_lines):
+        """Compute overdue delay delta in days for line using attribute in key
+
+        delta = end_date - maturity date
+
+        :param line: current ledger line
+        :param end_date: end_date computed for wizard data
+        :param ledger_lines: generated by parent
+                 :class:`.open_invoices.PartnersOpenInvoicesWebkit`
+
+        :returns: delta in days
+        """
+        return self._compute_delay_from_key('date_maturity',
+                                            line,
+                                            end_date)
+
+    def compute_delay_from_date(self, line, end_date, ledger_lines):
+        """Compute overdue delay delta in days for line using attribute in key
+
+        delta = end_date - date
+
+        :param line: current ledger line
+        :param end_date: end_date computed for wizard data
+        :param ledger_lines: generated by parent
+                 :class:`.open_invoices.PartnersOpenInvoicesWebkit`
+
+        :returns: delta in days
+        """
+        return self._compute_delay_from_key('ldate',
+                                            line,
+                                            end_date)
+
+    def compute_delay_from_partial_rec(self, line, end_date, ledger_lines):
+        """Compute overdue delay delta in days for the case where move line
+
+        is related to a partial reconcile with more than one reconcile line
+
+        :param line: current ledger line
+        :param end_date: end_date computed for wizard data
+        :param ledger_lines: generated by parent
+                 :class:`.open_invoices.PartnersOpenInvoicesWebkit`
+
+        :returns: delta in days
+        """
+        sale_lines = [x for x in ledger_lines if x['jtype'] in REC_PAY_TYPE and
+                      line['rec_id'] == x['rec_id']]
+        refund_lines = [x for x in ledger_lines if x['jtype'] in REFUND_TYPE and
+                        line['rec_id'] == x['rec_id']]
+        if len(sale_lines) == 1:
+            reference_line = sale_lines[0]
+        elif len(refund_lines) == 1:
+            reference_line = refund_lines[0]
+        else:
+            reference_line = line
+        key = 'date_maturity' if reference_line.get('date_maturity') else 'ldate'
+        return self._compute_delay_from_key(key,
+                                            reference_line,
+                                            end_date)
+
+    def get_compute_method(self, reconcile_lookup, partner_id, line):
+        """Get the function that should compute the delay for a given line
+
+        :param reconcile_lookup: dict of reconcile group by id and count
+                                 {rec_id: count of line related to reconcile}
+        :param partner_id: current partner_id
+        :param line: current ledger line generated by parent
+                     :class:`.open_invoices.PartnersOpenInvoicesWebkit`
+
+        :returns: function bounded to :class:`.AccountAgedTrialBalanceWebkit`
+
+        """
+        if reconcile_lookup.get(line['rec_id'], 0.0) > 1:
+            return self.compute_delay_from_partial_rec
+        elif line['jtype'] in INV_TYPE and line.get('date_maturity'):
+            return self.compute_delay_from_maturity
+        else:
+            return self.compute_delay_from_date
+
+    def line_is_valid(self, partner_id, line):
+        """Predicate hook that allows to filter line to be treated
+
+        :param partner_id: current partner_id
+        :param line: current ledger line generated by parent
+                     :class:`.open_invoices.PartnersOpenInvoicesWebkit`
+
+        :returns: boolean True if line is allowed
+        """
+        return True
+
+    def filter_lines(self, partner_id, lines):
+        """Filter ledger lines that have to be treated
+
+        :param partner_id: current partner_id
+        :param lines: ledger_lines related to current partner
+                      and generated by parent
+                      :class:`.open_invoices.PartnersOpenInvoicesWebkit`
+
+        :returns: list of allowed lines
+
+        """
+        return [x for x in lines if self.line_is_valid(partner_id, x)]
+
+    def classify_line(self, partner_id, overdue_days):
+        """Return the overdue range for a given delay
+
+        We loop from smaller range to higher
+        This should be the most effective solution as generaly
+        customer tend to have one or two month of delay
+
+        :param overdue_days: delay in days
+        :param partner_id: current partner_id
+
+        :returns: the correct range in :const:`RANGES`
+
+        """
+        for drange in RANGES:
+            if overdue_days <= drange[1]:
+                return drange
+        return drange
+
+    def compute_balance(self, res, aged_lines):
+        """Compute the total balance of aged line
+        for given account"""
+        res['balance'] = sum(aged_lines.values())
+
+    def compute_totals(self, aged_lines):
+        """Compute the totals for an account
+
+        :param aged_lines: dict of aged line taken from the
+                           property added to account record
+
+        :returns: dict of total {'balance':1000.00, (30, 60): 3000,...}
+
+        """
+        totals = {}
+        totals['balance'] = sum(x.get('balance', 0.0) for
+                                x in aged_lines)
+        aged_ranges = [x.get('aged_lines', {}) for x in aged_lines]
+        for drange in RANGES:
+            totals[drange] = sum(x.get(drange, 0.0) for x in aged_ranges)
+        return totals
+
+    def compute_percents(self, totals):
+        percents = {}
+        base = totals['balance'] or 1.0
+        for drange in RANGES:
+            percents[drange] = (totals[drange] / base) * 100.0
+        return percents
+
+    def get_reconcile_count_lookup(self, lines):
+        """Compute an lookup dict
+
+        It contains has partial reconcile id as key and the count of lines
+        related to the reconcile id
+
+        :param: a list of ledger lines generated by parent
+                :class:`.open_invoices.PartnersOpenInvoicesWebkit`
+
+        :retuns: lookup dict {ṛec_id: count}
+
+        """
+        # possible bang if l_ids is really long.
+        # We have the same weakness in common_report ...
+        # but it seems not really possible for a partner
+        # So I'll keep that option.
+        l_ids = tuple(x['id'] for x in lines)
+        sql = ("SELECT reconcile_partial_id, COUNT(*) FROM account_move_line"
+               "   WHERE reconcile_partial_id IS NOT NULL"
+               "   AND id in %s"
+               "   GROUP BY reconcile_partial_id")
+        self.cr.execute(sql, (l_ids,))
+        res = self.cr.fetchall()
+        return dict((x[0], x[1]) for x in res)
+
+HeaderFooterTextWebKitParser(
+    'report.account.account_aged_trial_balance_webkit',
+    'account.account',
+    'addons/account_financial_report_webkit/report/templates/aged_trial_webkit.mako',
+    parser=AccountAgedTrialBalanceWebkit,
+)