diff --git a/account_financial_report_webkit/__openerp__.py b/account_financial_report_webkit/__openerp__.py index 32bfc466..40bfc53b 100644 --- a/account_financial_report_webkit/__openerp__.py +++ b/account_financial_report_webkit/__openerp__.py @@ -30,7 +30,7 @@ This module adds or replaces the following standard OpenERP financial reports: - Partner ledger - Partner balance - Open invoices report - + - Aged Partner Balance Main improvements per report: ----------------------------- @@ -100,6 +100,47 @@ The Partner balance: list of account with balances * Subtotal by account and partner * Alphabetical sorting (same as in partner balance) + +Aged Partner Balance: Summary of aged open amount per partner + +This report is an accounting tool helping in various tasks. +You can credit control or partner balance provisions computation for instance. + +The aged balance report allows you to print balances per partner +like the trial balance but add an extra information : + +* It will split balances into due amounts + (due date not reached à the end date of the report) and overdue amounts + Overdue data are also split by period. +* For each partner following columns will be displayed: + + * Total balance (all figures must match with same date partner balance report). + This column equals the sum of all following columns) + + * Due + * Overdue <= 30 days + * Overdue <= 60 days + * Overdue <= 90 days + * Overdue <= 120 days + * Older + +Hypothesis / Contraints of aged partner balance + +* Overdues columns will be by default be based on 30 days range fix number of days. + This can be changed by changes the RANGES constraint +* All data will be displayed in company currency +* When partial payments, the payment must appear in the same colums than the invoice + (Except if multiple payment terms) +* Data granularity: partner (will not display figures at invoices level) +* The report aggregate data per account with sub-totals +* Initial balance must be calculated the same way that + the partner balance / Ignoring the opening entry + in special period (idem open invoice report) +* Only accounts with internal type payable or receivable are considered + (idem open invoice report) +* If maturity date is null then use move line date + + Limitations: ------------ @@ -126,7 +167,7 @@ an issue in wkhtmltopdf the header and footer are created as text with arguments passed to wkhtmltopdf. The texts are defined inside the report classes. """, - 'version': '1.0.2', + 'version': '1.1.0', 'author': 'Camptocamp', 'license': 'AGPL-3', 'category': 'Finance', @@ -147,6 +188,7 @@ wkhtmltopdf. The texts are defined inside the report classes. 'wizard/trial_balance_wizard_view.xml', 'wizard/partner_balance_wizard_view.xml', 'wizard/open_invoices_wizard_view.xml', + 'wizard/aged_partner_balance_wizard.xml', 'wizard/print_journal_view.xml', 'report_menus.xml', ], @@ -155,7 +197,8 @@ wkhtmltopdf. The texts are defined inside the report classes. 'tests/partner_ledger.yml', 'tests/trial_balance.yml', 'tests/partner_balance.yml', - 'tests/open_invoices.yml',], + 'tests/open_invoices.yml', + 'tests/aged_trial_balance.yml'], #'tests/account_move_line.yml' 'active': False, 'installable': True, diff --git a/account_financial_report_webkit/report/__init__.py b/account_financial_report_webkit/report/__init__.py index f8bc59fb..e597e39d 100644 --- a/account_financial_report_webkit/report/__init__.py +++ b/account_financial_report_webkit/report/__init__.py @@ -9,3 +9,4 @@ from . import trial_balance from . import partner_balance from . import open_invoices from . import print_journal +from . import aged_partner_balance diff --git a/account_financial_report_webkit/report/aged_partner_balance.py b/account_financial_report_webkit/report/aged_partner_balance.py new file mode 100644 index 00000000..ef81d326 --- /dev/null +++ b/account_financial_report_webkit/report/aged_partner_balance.py @@ -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 . +# +############################################################################## +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, +) diff --git a/account_financial_report_webkit/report/common_reports.py b/account_financial_report_webkit/report/common_reports.py index 3a0a2a15..617cc97e 100644 --- a/account_financial_report_webkit/report/common_reports.py +++ b/account_financial_report_webkit/report/common_reports.py @@ -30,7 +30,7 @@ from openerp.addons.account.report.common_report_header import common_report_hea _logger = logging.getLogger('financial.reports.webkit') - +MAX_MONSTER_SLICE = 50000 class CommonReportHeaderWebkit(common_report_header): """Define common helper for financial report""" @@ -433,6 +433,14 @@ class CommonReportHeaderWebkit(common_report_header): raise osv.except_osv(_('No valid filter'), _('Please set a valid time filter')) def _get_move_line_datas(self, move_line_ids, order='per.special DESC, l.date ASC, per.date_start ASC, m.name ASC'): + # Possible bang if move_line_ids is too long + # We can not slice here as we have to do the sort. + # If slice has to be done it means that we have to reorder in python + # after all is finished. That quite crapy... + # We have a defective desing here (mea culpa) that should be fixed + # + # TODO improve that by making a better domain or if not possible + # by using python sort if not move_line_ids: return [] if not isinstance(move_line_ids, list): @@ -441,6 +449,7 @@ class CommonReportHeaderWebkit(common_report_header): SELECT l.id AS id, l.date AS ldate, j.code AS jcode , + j.type AS jtype, l.currency_id, l.account_id, l.amount_currency, @@ -455,7 +464,8 @@ SELECT l.id AS id, l.partner_id AS lpartner_id, p.name AS partner_name, m.name AS move_name, - COALESCE(partialrec.name, fullrec.name, '') AS rec_name, + COALESCE(partialrec.name, fullrec.name, '') AS rec_name, + COALESCE(partialrec.id, fullrec.id, NULL) AS rec_id, m.id AS move_id, c.name AS currency_code, i.id AS invoice_id, diff --git a/account_financial_report_webkit/report/open_invoices.py b/account_financial_report_webkit/report/open_invoices.py index 237f9e4f..cce03dec 100644 --- a/account_financial_report_webkit/report/open_invoices.py +++ b/account_financial_report_webkit/report/open_invoices.py @@ -93,7 +93,6 @@ class PartnersOpenInvoicesWebkit(report_sxw.rml_parse, CommonPartnersReportHeade """Populate a ledger_lines attribute on each browse record that will be used by mako template""" new_ids = data['form']['chart_account_id'] - # Account initial balance memoizer init_balance_memoizer = {} # Reading form diff --git a/account_financial_report_webkit/report/report.xml b/account_financial_report_webkit/report/report.xml index 6fa33a25..41278e3a 100644 --- a/account_financial_report_webkit/report/report.xml +++ b/account_financial_report_webkit/report/report.xml @@ -14,23 +14,16 @@ General Ledger Webkit account_financial_report_webkit/report/templates/account_report_general_ledger.mako account_financial_report_webkit/report/templates/account_report_general_ledger.mako - + + account_report_general_ledger_webkit - - - + + webkit account.account_report_partners_ledger_webkit @@ -44,6 +37,7 @@ account_financial_report_webkit/report/templates/account_report_partners_ledger.mako account_financial_report_webkit/report/templates/account_report_partners_ledger.mako + account_report_partners_ledger_webkit @@ -64,6 +58,7 @@ account_financial_report_webkit/report/templates/account_report_trial_balance.mako account_financial_report_webkit/report/templates/account_report_trial_balance.mako + account_report_trial_balance_webkit @@ -84,6 +79,7 @@ account_financial_report_webkit/report/templates/account_report_partner_balance.mako account_financial_report_webkit/report/templates/account_report_partner_balance.mako + account_report_partner_balance_webkit @@ -104,6 +100,7 @@ account_financial_report_webkit/report/templates/account_report_open_invoices.mako account_financial_report_webkit/report/templates/account_report_open_invoices.mako + account_report_open_invoices_webkit @@ -111,6 +108,31 @@ + + webkit + account.account_aged_trial_balance_webkit + + + + + account.account + ir.actions.report.xml + Aged Partner Balance + account_financial_report_webkit/report/templates/aged_trial_webkit.mako + account_financial_report_webkit/report/templates/aged_trial_webkit.mako + + + + account_aged_trial_balance_webkit + + + + + webkit account.account_report_print_journal_webkit @@ -124,6 +146,7 @@ account_financial_report_webkit/report/templates/account_report_print_journal.mako account_financial_report_webkit/report/templates/account_report_print_journal.mako + account_report_print_journal_webkit diff --git a/account_financial_report_webkit/report/templates/aged_trial_webkit.mako b/account_financial_report_webkit/report/templates/aged_trial_webkit.mako new file mode 100644 index 00000000..d021e252 --- /dev/null +++ b/account_financial_report_webkit/report/templates/aged_trial_webkit.mako @@ -0,0 +1,155 @@ +## -*- coding: utf-8 -*- + + + + + + + <%! + def amount(text): + # replace by a non-breaking hyphen (it will not word-wrap between hyphen and numbers) + return text.replace('-', '‑') + %> + + <%setLang(user.lang)%> + +
+
+
${_('Chart of Account')}
+
${_('Fiscal Year')}
+
+ %if filter_form(data) == 'filter_date': + ${_('Dates Filter')} + %else: + ${_('Periods Filter')} + %endif +
+
${_('Clearance Date')}
+
${_('Accounts Filter')}
+
${_('Target Moves')}
+ +
+
+
${ chart_account.name }
+
${ fiscalyear.name if fiscalyear else '-' }
+
+ ${_('From:')} + %if filter_form(data) == 'filter_date': + ${formatLang(start_date, date=True) if start_date else u'' } + %else: + ${start_period.name if start_period else u''} + %endif + ${_('To:')} + %if filter_form(data) == 'filter_date': + ${ formatLang(stop_date, date=True) if stop_date else u'' } + %else: + ${stop_period.name if stop_period else u'' } + %endif +
+
${ formatLang(date_until, date=True) }
+
+ %if partner_ids: + ${_('Custom Filter')} + %else: + ${ display_partner_account(data) } + %endif +
+
${ display_target_move(data) }
+
+
+ %for acc in objects: + %if acc.aged_lines: + + + + +
+
+
+ ## partner +
${_('Partner')}
+ ## code +
${_('code')}
+ ## balance +
${_('balance')}
+ ## Classifications + %for title in ranges_titles: +
${title}
+ %endfor +
+
+
+ %for partner_name, p_id, p_ref, p_name in acc.partners_order: + %if acc.aged_lines.get(p_id): +
+ <%line = acc.aged_lines[p_id]%> + <%percents = acc.aged_percents%> + <%totals = acc.aged_totals%> +
${partner_name}
+
${p_ref or ''}
+ +
${formatLang(line.get('balance') or 0.0) | amount}
+ %for classif in ranges: +
+ ${formatLang(line['aged_lines'][classif] or 0.0) | amount} +
+ %endfor +
+ %endif + %endfor +
+
${_('Total')}
+
+
${formatLang(totals['balance']) | amount}
+ %for classif in ranges: +
${formatLang(totals[classif]) | amount}
+ %endfor +
+ +
+
${_('Percents')}
+
+
+ %for classif in ranges: +
${formatLang(percents[classif]) | amount}%
+ %endfor +
+
+
+ + %endif + %endfor +
+ + diff --git a/account_financial_report_webkit/report/templates/open_invoices_inclusion.mako.html b/account_financial_report_webkit/report/templates/open_invoices_inclusion.mako.html index c5138e5f..d980e325 100644 --- a/account_financial_report_webkit/report/templates/open_invoices_inclusion.mako.html +++ b/account_financial_report_webkit/report/templates/open_invoices_inclusion.mako.html @@ -9,7 +9,7 @@ %> - + %for partner_name, p_id, p_ref, p_name in account.partners_order: <% total_debit = 0.0 @@ -18,7 +18,7 @@ cumul_balance_curr = 0.0 part_cumul_balance = 0.0 - part_cumul_balance_curr = 0.0 + part_cumul_balance_curr = 0.0 %>