diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 0b3119aae31..761a53ee998 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -25,6 +25,7 @@ class AccountBankStatement(models.Model): date = fields.Date( compute='_compute_date_index', store=True, + index=True, ) # The internal index of the first line of a statement, it is used for sorting the statements diff --git a/addons/account/models/account_journal.py b/addons/account/models/account_journal.py index 2aeeaff36a5..d4b87105dce 100644 --- a/addons/account/models/account_journal.py +++ b/addons/account/models/account_journal.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- from odoo import api, Command, fields, models, _ -from odoo.osv import expression from odoo.exceptions import UserError, ValidationError from odoo.addons.base.models.res_bank import sanitize_account_number from odoo.tools import remove_accents @@ -812,6 +811,7 @@ class AccountJournal(models.Model): # REPORTING METHODS # ------------------------------------------------------------------------- + # TODO move to `account_reports` in master (simple read_group) def _get_journal_bank_account_balance(self, domain=None): ''' Get the bank balance of the current journal by filtering the journal items using the journal's accounts. @@ -872,6 +872,7 @@ class AccountJournal(models.Model): account_ids.add(line.payment_account_id.id or self.company_id.account_journal_payment_credit_account_id.id) return self.env['account.account'].browse(account_ids) + # TODO remove in master def _get_journal_outstanding_payments_account_balance(self, domain=None, date=None): ''' Get the outstanding payments balance of the current journal by filtering the journal items using the journal's accounts. @@ -938,6 +939,7 @@ class AccountJournal(models.Model): total_balance += balance return total_balance, nb_lines + # TODO move to `account_reports` in master def _get_last_bank_statement(self, domain=None): ''' Retrieve the last bank statement created using this journal. :param domain: An additional domain to be applied on the account.bank.statement model. diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index 7ba5a422527..253a6c1a21e 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -1,81 +1,102 @@ -import json -from datetime import datetime, timedelta - +import ast from babel.dates import format_datetime, format_date +from collections import defaultdict +from datetime import datetime, timedelta +import json +import random + from odoo import models, api, _, fields from odoo.exceptions import UserError from odoo.osv import expression from odoo.release import version from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF from odoo.tools.misc import formatLang, format_date as odoo_format_date, get_lang -import random -import ast + +def group_by_journal(vals_list): + res = defaultdict(list) + for vals in vals_list: + res[vals['journal_id']].append(vals) + return res class account_journal(models.Model): _inherit = "account.journal" - def _kanban_dashboard(self): - for journal in self: - journal.kanban_dashboard = json.dumps(journal.get_journal_dashboard_datas()) - - def _kanban_dashboard_graph(self): - for journal in self: - if (journal.type in ['sale', 'purchase']): - journal.kanban_dashboard_graph = json.dumps(journal.get_bar_graph_datas()) - elif (journal.type in ['cash', 'bank']): - journal.kanban_dashboard_graph = json.dumps(journal.get_line_graph_datas()) - else: - journal.kanban_dashboard_graph = False - - def _get_json_activity_data(self): - for journal in self: - activities = [] - # search activity on move on the journal - sql_query = ''' - SELECT act.id, - act.res_id, - act.res_model, - act.summary, - act_type.name as act_type_name, - act_type.category as activity_category, - act.date_deadline, - m.date, - m.ref, - CASE WHEN act.date_deadline < CURRENT_DATE THEN 'late' ELSE 'future' END as status - FROM account_move m - LEFT JOIN mail_activity act ON act.res_id = m.id - LEFT JOIN mail_activity_type act_type ON act.activity_type_id = act_type.id - WHERE act.res_model = 'account.move' - AND m.journal_id = %s - AND (act_type.category != 'tax_report' OR (act_type.category = 'tax_report' AND act.date_deadline <= CURRENT_DATE)) - ''' - self.env.cr.execute(sql_query, (journal.id,)) - for activity in self.env.cr.dictfetchall(): - act = { - 'id': activity.get('id'), - 'res_id': activity.get('res_id'), - 'res_model': activity.get('res_model'), - 'status': activity.get('status'), - 'name': (activity.get('summary') or activity.get('act_type_name')), - 'activity_category': activity.get('activity_category'), - 'date': odoo_format_date(self.env, activity.get('date_deadline')) - } - if activity.get('activity_category') == 'tax_report' and activity.get('res_model') == 'account.move': - act['name'] = activity.get('ref') - - activities.append(act) - journal.json_activity_data = json.dumps({'activities': activities}) - kanban_dashboard = fields.Text(compute='_kanban_dashboard') kanban_dashboard_graph = fields.Text(compute='_kanban_dashboard_graph') json_activity_data = fields.Text(compute='_get_json_activity_data') show_on_dashboard = fields.Boolean(string='Show journal on dashboard', help="Whether this journal should be displayed on the dashboard or not", default=True) color = fields.Integer("Color Index", default=0) + current_statement_balance = fields.Monetary(compute='_compute_current_statement_balance') # technical field used to avoid computing the value multiple times + has_statement_lines = fields.Boolean(compute='_compute_current_statement_balance') # technical field used to avoid computing the value multiple times entries_count = fields.Integer(compute='_compute_entries_count') has_sequence_holes = fields.Boolean(compute='_compute_has_sequence_holes') + def _compute_current_statement_balance(self): + query_result = self._get_journal_dashboard_bank_running_balance() + for journal in self: + journal.has_statement_lines, journal.current_statement_balance = query_result.get(journal.id) + + def _kanban_dashboard(self): + dashboard_data = self._get_journal_dashboard_data_batched() + for journal in self: + journal.kanban_dashboard = json.dumps(dashboard_data[journal.id]) + + @api.depends('current_statement_balance') + def _kanban_dashboard_graph(self): + bank_cash_journals = self.filtered(lambda journal: journal.type in ('bank', 'cash')) + bank_cash_graph_datas = bank_cash_journals._get_bank_cash_graph_data() + for journal in bank_cash_journals: + journal.kanban_dashboard_graph = json.dumps(bank_cash_graph_datas[journal.id]) + + sale_purchase_journals = self.filtered(lambda journal: journal.type in ('sale', 'purchase')) + sale_purchase_graph_datas = sale_purchase_journals._get_sale_purchase_graph_data() + for journal in sale_purchase_journals: + journal.kanban_dashboard_graph = json.dumps(sale_purchase_graph_datas[journal.id]) + + (self - bank_cash_journals - sale_purchase_journals).kanban_dashboard_graph = False + + def _get_json_activity_data(self): + today = fields.Date.context_today(self) + activities = defaultdict(list) + # search activity on move on the journal + sql_query = """ + SELECT activity.id, + activity.res_id, + activity.res_model, + activity.summary, + CASE WHEN activity.date_deadline < %(today)s THEN 'late' ELSE 'future' END as status, + act_type.name as act_type_name, + act_type.category as activity_category, + activity.date_deadline, + move.date, + move.ref, + move.journal_id + FROM account_move move + JOIN mail_activity activity ON activity.res_id = move.id AND activity.res_model = 'account.move' + LEFT JOIN mail_activity_type act_type ON activity.activity_type_id = act_type.id + WHERE move.journal_id = ANY(%(ids)s) + AND (act_type.category != 'tax_report' OR (act_type.category = 'tax_report' AND activity.date_deadline <= %(today)s)) + """ + self.env.cr.execute(sql_query, {'ids': self.ids, 'today': today}) + for activity in self.env.cr.dictfetchall(): + act = { + 'id': activity['id'], + 'res_id': activity['res_id'], + 'res_model': activity['res_model'], + 'status': activity['status'], + 'name': activity['summary'] or activity['act_type_name'], + 'activity_category': activity['activity_category'], + 'date': odoo_format_date(self.env, activity['date_deadline']) + } + if activity['activity_category'] == 'tax_report' and activity['res_model'] == 'account.move': + act['name'] = activity['ref'] + + activities[activity['journal_id']].append(act) + for journal in self: + journal.json_activity_data = json.dumps({'activities': activities[journal.id]}) + def _query_has_sequence_holes(self): self.env.cr.execute(""" SELECT move.journal_id, @@ -84,7 +105,7 @@ class account_journal(models.Model): JOIN res_company company ON company.id = move.company_id WHERE move.journal_id = ANY(%(journal_ids)s) AND move.state = 'posted' - AND (company.fiscalyear_lock_date IS NULL OR move.date >= company.fiscalyear_lock_date) + AND (company.fiscalyear_lock_date IS NULL OR move.date >= company.fiscalyear_lock_date) GROUP BY move.journal_id, move.sequence_prefix HAVING COUNT(*) != MAX(move.sequence_number) - MIN(move.sequence_number) + 1 """, { @@ -117,324 +138,457 @@ class account_journal(models.Model): elif self.type == 'bank': return ['', _('Bank: Balance')] - # Below method is used to get data of bank and cash statemens + # TODO remove in master def get_line_graph_datas(self): - """Computes the data used to display the graph for bank and cash journals in the accounting dashboard""" - currency = self.currency_id or self.company_id.currency_id + self.ensure_one() + return self._get_bank_cash_graph_data()[self.id] - def build_graph_data(date, amount): + def _get_bank_cash_graph_data(self): + """Computes the data used to display the graph for bank and cash journals in the accounting dashboard""" + def build_graph_data(date, amount, currency): #display date in locale format name = format_date(date, 'd LLLL Y', locale=locale) short_name = format_date(date, 'd MMM', locale=locale) - return {'x':short_name,'y': amount, 'name':name} + return {'x': short_name, 'y': currency.round(amount), 'name': name} - self.ensure_one() - data = [] today = datetime.today() last_month = today + timedelta(days=-30) locale = get_lang(self.env).code - #starting point of the graph is the last statement - last_stmt = self._get_last_bank_statement(domain=[('move_id.state', '=', 'posted')]) + query = """ + SELECT move.journal_id, + move.date, + SUM(st_line.amount) AS amount + FROM account_bank_statement_line st_line + JOIN account_move move ON move.id = st_line.move_id + WHERE move.journal_id = ANY(%s) + AND move.date > %s + AND move.date <= %s + GROUP BY move.date, move.journal_id + ORDER BY move.date DESC + """ + self.env.cr.execute(query, (self.ids, last_month, today)) + query_result = group_by_journal(self.env.cr.dictfetchall()) - last_balance = last_stmt and last_stmt.balance_end_real or 0 - data.append(build_graph_data(today, last_balance)) + result = {} + for journal in self: + graph_title, graph_key = journal._graph_title_and_key() + currency = journal.currency_id or journal.company_id.currency_id + journal_result = query_result[journal.id] - #then we subtract the total amount of bank statement lines per day to get the previous points - #(graph is drawn backward) - date = today - amount = last_balance - query = ''' - SELECT move.date, sum(st_line.amount) as amount - FROM account_bank_statement_line st_line - JOIN account_move move ON move.id = st_line.move_id - WHERE move.journal_id = %s - AND move.date > %s - AND move.date <= %s - GROUP BY move.date - ORDER BY move.date desc - ''' - self.env.cr.execute(query, (self.id, last_month, today)) - query_result = self.env.cr.dictfetchall() - for val in query_result: - date = val['date'] - if date != today.strftime(DF): # make sure the last point in the graph is today - data[:0] = [build_graph_data(date, amount)] - amount = currency.round(amount - val['amount']) + color = '#875A7B' if 'e' in version else '#7c7bad' + is_sample_data = not journal.has_statement_lines - # make sure the graph starts 1 month ago - if date.strftime(DF) != last_month.strftime(DF): - data[:0] = [build_graph_data(last_month, amount)] - - [graph_title, graph_key] = self._graph_title_and_key() - color = '#875A7B' if 'e' in version else '#7c7bad' - - is_sample_data = not last_stmt and len(query_result) == 0 - if is_sample_data: data = [] - for i in range(30, 0, -5): - current_date = today + timedelta(days=-i) - data.append(build_graph_data(current_date, random.randint(-5, 15))) + if is_sample_data: + for i in range(30, 0, -5): + current_date = today + timedelta(days=-i) + data.append(build_graph_data(current_date, random.randint(-5, 15), currency)) + else: + last_balance = journal.current_statement_balance + data.append(build_graph_data(today, last_balance, currency)) + date = today + amount = last_balance + #then we subtract the total amount of bank statement lines per day to get the previous points + #(graph is drawn backward) + for val in journal_result: + date = val['date'] + if date != today.strftime(DF): # make sure the last point in the graph is today + data[:0] = [build_graph_data(date, amount, currency)] + amount -= val['amount'] - return [{'values': data, 'title': graph_title, 'key': graph_key, 'area': True, 'color': color, 'is_sample_data': is_sample_data}] + # make sure the graph starts 1 month ago + if date.strftime(DF) != last_month.strftime(DF): + data[:0] = [build_graph_data(last_month, amount, currency)] + result[journal.id] = [{'values': data, 'title': graph_title, 'key': graph_key, 'area': True, 'color': color, 'is_sample_data': is_sample_data}] + return result + + # TODO remove in master def get_bar_graph_datas(self): - data = [] + self.ensure_one() + return self._get_sale_purchase_graph_data()[self.id] + + def _get_sale_purchase_graph_data(self): today = fields.Date.today() - data.append({'label': _('Due'), 'value':0.0, 'type': 'past'}) day_of_week = int(format_datetime(today, 'e', locale=get_lang(self.env).code)) first_day_of_week = today + timedelta(days=-day_of_week+1) - for i in range(-1,4): - if i==0: - label = _('This Week') - elif i==3: - label = _('Not Due') - else: - start_week = first_day_of_week + timedelta(days=i*7) - end_week = start_week + timedelta(days=6) - if start_week.month == end_week.month: - label = str(start_week.day) + '-' + str(end_week.day) + ' ' + format_date(end_week, 'MMM', locale=get_lang(self.env).code) - else: - label = format_date(start_week, 'd MMM', locale=get_lang(self.env).code) + '-' + format_date(end_week, 'd MMM', locale=get_lang(self.env).code) - data.append({'label':label,'value':0.0, 'type': 'past' if i<0 else 'future'}) + format_month = lambda d: format_date(d, 'MMM', locale=get_lang(self.env).code) - # Build SQL query to find amount aggregated by week - (select_sql_clause, query_args) = self._get_bar_graph_select_query() - query = '' - start_date = (first_day_of_week + timedelta(days=-7)) - weeks = [] - for i in range(0,6): - if i == 0: - query += "("+select_sql_clause+" and invoice_date_due < '"+start_date.strftime(DF)+"')" - weeks.append((start_date.min, start_date)) - elif i == 5: - query += " UNION ALL ("+select_sql_clause+" and invoice_date_due >= '"+start_date.strftime(DF)+"')" - weeks.append((start_date, start_date.max)) - else: - next_date = start_date + timedelta(days=7) - query += " UNION ALL ("+select_sql_clause+" and invoice_date_due >= '"+start_date.strftime(DF)+"' and invoice_date_due < '"+next_date.strftime(DF)+"')" - weeks.append((start_date, next_date)) - start_date = next_date - # Ensure results returned by postgres match the order of data list - self.env.cr.execute(query, query_args) - query_results = self.env.cr.dictfetchall() - is_sample_data = True - for index in range(0, len(query_results)): - if query_results[index].get('aggr_date') != None: - is_sample_data = False - aggr_date = query_results[index]['aggr_date'] - week_index = next(i for i in range(0, len(weeks)) if weeks[i][0] <= aggr_date < weeks[i][1]) - data[week_index]['value'] = query_results[index].get('total') - - [graph_title, graph_key] = self._graph_title_and_key() - - if is_sample_data: - for index in range(0, len(query_results)): - data[index]['type'] = 'o_sample_data' - # we use unrealistic values for the sample data - data[index]['value'] = random.randint(0, 20) - graph_key = _('Sample data') - - return [{'values': data, 'title': graph_title, 'key': graph_key, 'is_sample_data': is_sample_data}] - - def _get_bar_graph_select_query(self): - """ - Returns a tuple containing the base SELECT SQL query used to gather - the bar graph's data as its first element, and the arguments dictionary - for it as its second. - """ - sign = '' if self.type == 'sale' else '-' - return (''' - SELECT - ''' + sign + ''' + SUM(move.amount_residual_signed) AS total, - MIN(invoice_date_due) AS aggr_date - FROM account_move move - WHERE move.journal_id = %(journal_id)s - AND move.state = 'posted' - AND move.payment_state in ('not_paid', 'partial') - AND move.move_type IN %(invoice_types)s - ''', { + self.env.cr.execute(""" + SELECT move.journal_id, + COALESCE(SUM(move.amount_residual_signed) FILTER (WHERE invoice_date_due < %(start_week1)s), 0) AS total_before, + COALESCE(SUM(move.amount_residual_signed) FILTER (WHERE invoice_date_due >= %(start_week1)s AND invoice_date_due < %(start_week2)s), 0) AS total_week1, + COALESCE(SUM(move.amount_residual_signed) FILTER (WHERE invoice_date_due >= %(start_week2)s AND invoice_date_due < %(start_week3)s), 0) AS total_week2, + COALESCE(SUM(move.amount_residual_signed) FILTER (WHERE invoice_date_due >= %(start_week3)s AND invoice_date_due < %(start_week4)s), 0) AS total_week3, + COALESCE(SUM(move.amount_residual_signed) FILTER (WHERE invoice_date_due >= %(start_week4)s AND invoice_date_due < %(start_week5)s), 0) AS total_week4, + COALESCE(SUM(move.amount_residual_signed) FILTER (WHERE invoice_date_due >= %(start_week5)s), 0) AS total_after + FROM account_move move + WHERE move.journal_id = ANY(%(journal_ids)s) + AND move.state = 'posted' + AND move.payment_state in ('not_paid', 'partial') + AND move.move_type IN %(invoice_types)s + GROUP BY move.journal_id + """, { 'invoice_types': tuple(self.env['account.move'].get_invoice_types(True)), - 'journal_id': self.id + 'journal_ids': self.ids, + 'start_week1': first_day_of_week + timedelta(days=-7), + 'start_week2': first_day_of_week + timedelta(days=0), + 'start_week3': first_day_of_week + timedelta(days=7), + 'start_week4': first_day_of_week + timedelta(days=14), + 'start_week5': first_day_of_week + timedelta(days=21), }) + query_results = {r['journal_id']: r for r in self.env.cr.dictfetchall()} + result = {} + for journal in self: + currency = journal.currency_id or journal.company_id.currency_id + graph_title, graph_key = journal._graph_title_and_key() + sign = 1 if journal.type == 'sale' else -1 + journal_data = query_results.get(journal.id) + data = [] + data.append({'label': _('Due'), 'type': 'past'}) + for i in range(-1, 3): + if i == 0: + label = _('This Week') + else: + start_week = first_day_of_week + timedelta(days=i*7) + end_week = start_week + timedelta(days=6) + if start_week.month == end_week.month: + label = f"{start_week.day} - {end_week.day} {format_month(end_week)}" + else: + label = f"{start_week.day} {format_month(start_week)} - {end_week.day} {format_month(end_week)}" + data.append({'label': label, 'type': 'past' if i < 0 else 'future'}) + data.append({'label': _('Not Due'), 'type': 'future'}) - def get_journal_dashboard_datas(self): - currency = self.currency_id or self.company_id.currency_id - number_to_reconcile = number_to_check = last_balance = 0 - has_at_least_one_statement = False - bank_account_balance = nb_lines_bank_account_balance = 0 - outstanding_pay_account_balance = nb_lines_outstanding_pay_account_balance = 0 - title = '' - number_draft = number_waiting = number_late = to_check_balance = 0 - sum_draft = sum_waiting = sum_late = 0.0 - if self.type in ('bank', 'cash'): - last_statement = self._get_last_bank_statement( - domain=[('move_id.state', '=', 'posted')]) - last_balance = last_statement.balance_end - has_at_least_one_statement = bool(last_statement) - bank_account_balance, nb_lines_bank_account_balance = self._get_journal_bank_account_balance( - domain=[('parent_state', '=', 'posted')]) - outstanding_pay_account_balance, nb_lines_outstanding_pay_account_balance = self._get_journal_outstanding_payments_account_balance( - domain=[('parent_state', '=', 'posted')]) - - if self.default_account_id: - self._cr.execute(''' - SELECT COUNT(st_line.id) - FROM account_bank_statement_line st_line - JOIN account_move st_line_move ON st_line_move.id = st_line.move_id - JOIN account_move_line aml ON aml.move_id = st_line_move.id - WHERE st_line_move.journal_id IN %s - AND NOT st_line.is_reconciled - AND st_line_move.to_check IS NOT TRUE - AND st_line_move.state = 'posted' - AND aml.account_id = %s - ''', [tuple(self.ids), self.default_account_id.id]) - number_to_reconcile = self.env.cr.fetchone()[0] + is_sample_data = not journal_data + if not is_sample_data: + data[0]['value'] = currency.round(sign * journal_data['total_before']) + data[1]['value'] = currency.round(sign * journal_data['total_week1']) + data[2]['value'] = currency.round(sign * journal_data['total_week2']) + data[3]['value'] = currency.round(sign * journal_data['total_week3']) + data[4]['value'] = currency.round(sign * journal_data['total_week4']) + data[5]['value'] = currency.round(sign * journal_data['total_after']) else: - number_to_reconcile = 0 + for index in range(6): + data[index]['type'] = 'o_sample_data' + # we use unrealistic values for the sample data + data[index]['value'] = random.randint(0, 20) + graph_key = _('Sample data') - to_check_ids = self.to_check_ids() - number_to_check = len(to_check_ids) - to_check_balance = sum([r.amount for r in to_check_ids]) - #TODO need to check if all invoices are in the same currency than the journal!!!! - elif self.type in ['sale', 'purchase']: - title = _('Bills to pay') if self.type == 'purchase' else _('Invoices owed to you') - self.env['account.move'].flush_model() + result[journal.id] = [{'values': data, 'title': graph_title, 'key': graph_key, 'is_sample_data': is_sample_data}] + return result - (query, query_args) = self._get_open_bills_to_pay_query() - self.env.cr.execute(query, query_args) - query_results_to_pay = self.env.cr.dictfetchall() + # TODO remove in master + def get_journal_dashboard_datas(self): + return self._get_journal_dashboard_data_batched()[self.id] - (query, query_args) = self._get_draft_bills_query() - self.env.cr.execute(query, query_args) - query_results_drafts = self.env.cr.dictfetchall() + def _get_journal_dashboard_data_batched(self): + self.env['account.move'].flush_model() + self.env['account.move.line'].flush_model() + dashboard_data = {} # container that will be filled by functions below + for journal in self: + dashboard_data[journal.id] = { + 'currency_id': journal.currency_id.id or journal.company_id.currency_id.id, + 'company_count': len(self.env.companies), + } + self._fill_bank_cash_dashboard_data(dashboard_data) + self._fill_sale_purchase_dashboard_data(dashboard_data) + self._fill_general_dashboard_data(dashboard_data) + return dashboard_data - (query, query_args) = self._get_late_bills_query() - self.env.cr.execute(query, query_args) - late_query_results = self.env.cr.dictfetchall() + def _fill_dashboard_data_count(self, dashboard_data, model, name, domain): + """Populate the dashboard data with the result of a count. - curr_cache = {} - (number_waiting, sum_waiting) = self._count_results_and_sum_amounts(query_results_to_pay, currency, curr_cache=curr_cache) - (number_draft, sum_draft) = self._count_results_and_sum_amounts(query_results_drafts, currency, curr_cache=curr_cache) - (number_late, sum_late) = self._count_results_and_sum_amounts(late_query_results, currency, curr_cache=curr_cache) - read = self.env['account.move'].read_group([('journal_id', '=', self.id), ('to_check', '=', True)], ['amount_total_signed'], 'journal_id', lazy=False) - if read: - number_to_check = read[0]['__count'] - to_check_balance = read[0]['amount_total_signed'] - elif self.type == 'general': - read = self.env['account.move'].read_group([('journal_id', '=', self.id), ('to_check', '=', True)], ['amount_total_signed'], 'journal_id', lazy=False) - if read: - number_to_check = read[0]['__count'] - to_check_balance = read[0]['amount_total_signed'] + :param dashboard_data: a mapping between a journal ids and the data needed to display their + dashboard kanban card. + :type dashboard_data: dict[int, dict] + :param model: the model on which to perform the count + :type model: str + :param name: the name of the variable to inject in the dashboard's data + :type name: str + :param domain: the domain of records to count + :type domain: list[tuple] + """ + res = { + r['journal_id'][0]: r['journal_id_count'] + for r in self.env[model]._read_group( + domain=[('journal_id', 'in', self.ids)] + domain, + fields=['journal_id'], + groupby=['journal_id'], + ) + } + for journal in self: + dashboard_data[journal.id][name] = res.get(journal.id, 0) - is_sample_data = self.kanban_dashboard_graph and any(data.get('is_sample_data', False) for data in json.loads(self.kanban_dashboard_graph)) + def _fill_bank_cash_dashboard_data(self, dashboard_data): + """Populate all bank and cash journal's data dict with relevant information for the kanban card.""" + bank_cash_journals = self.filtered(lambda journal: journal.type in ('bank', 'cash')) + if not bank_cash_journals: + return - return { - 'number_to_check': number_to_check, - 'to_check_balance': formatLang(self.env, to_check_balance, currency_obj=currency), - 'number_to_reconcile': number_to_reconcile, - 'account_balance': formatLang(self.env, currency.round(bank_account_balance), currency_obj=currency), - 'has_at_least_one_statement': has_at_least_one_statement, - 'nb_lines_bank_account_balance': nb_lines_bank_account_balance, - 'outstanding_pay_account_balance': formatLang(self.env, currency.round(outstanding_pay_account_balance), currency_obj=currency), - 'nb_lines_outstanding_pay_account_balance': nb_lines_outstanding_pay_account_balance, - 'last_balance': formatLang(self.env, currency.round(last_balance) + 0.0, currency_obj=currency), - 'number_draft': number_draft, - 'number_waiting': number_waiting, - 'number_late': number_late, - 'sum_draft': formatLang(self.env, currency.round(sum_draft) + 0.0, currency_obj=currency), - 'sum_waiting': formatLang(self.env, currency.round(sum_waiting) + 0.0, currency_obj=currency), - 'sum_late': formatLang(self.env, currency.round(sum_late) + 0.0, currency_obj=currency), - 'currency_id': currency.id, - 'bank_statements_source': self.bank_statements_source, - 'title': title, - 'is_sample_data': is_sample_data, - 'company_count': len(self.env.companies) + # Number to reconcile + self._cr.execute(""" + SELECT st_line_move.journal_id, + COUNT(st_line.id) + FROM account_bank_statement_line st_line + JOIN account_move st_line_move ON st_line_move.id = st_line.move_id + WHERE st_line_move.journal_id IN %s + AND NOT st_line.is_reconciled + AND st_line_move.to_check IS NOT TRUE + AND st_line_move.state = 'posted' + GROUP BY st_line_move.journal_id + """, [tuple(bank_cash_journals.ids)]) + number_to_reconcile = { + journal_id: count + for journal_id, count in self.env.cr.fetchall() } + # Last statement + self.env.cr.execute(""" + SELECT journal.id, statement.id + FROM account_journal journal + LEFT JOIN LATERAL ( + SELECT id + FROM account_bank_statement + WHERE journal_id = journal.id + ORDER BY date DESC + LIMIT 1 + ) statement ON TRUE + WHERE journal.id = ANY(%s) + """, [self.ids]) + last_statements = {journal_id: statement_id for journal_id, statement_id in self.env.cr.fetchall()} + self.env['account.bank.statement'].browse(i for i in last_statements.values() if i).mapped('balance_end_real') # prefetch + + outstanding_pay_account_balances = bank_cash_journals._get_journal_dashboard_outstanding_payments() + + # To check + to_check = { + res['journal_id'][0]: (res['amount'], res['journal_id_count']) + for res in self.env['account.bank.statement.line'].read_group( + domain=[ + ('journal_id', 'in', bank_cash_journals.ids), + ('move_id.to_check', '=', True), + ('move_id.state', '=', 'posted'), + ], + fields=['amount'], + groupby=['journal_id'], + ) + } + + for journal in bank_cash_journals: + last_statement = self.env['account.bank.statement'].browse(last_statements.get(journal.id)) + currency = journal.currency_id or journal.company_id.currency_id + has_outstanding, outstanding_pay_account_balance = outstanding_pay_account_balances[journal.id] + to_check_balance, number_to_check = to_check.get(journal.id, (0, 0)) + + dashboard_data[journal.id].update({ + 'number_to_check': number_to_check, + 'to_check_balance': currency.format(to_check_balance), + 'number_to_reconcile': number_to_reconcile.get(journal.id, 0), + 'account_balance': currency.format(journal.current_statement_balance), + 'has_at_least_one_statement': bool(last_statement), + 'nb_lines_bank_account_balance': bool(last_statement), + 'outstanding_pay_account_balance': currency.format(outstanding_pay_account_balance), + 'nb_lines_outstanding_pay_account_balance': has_outstanding, + 'last_balance': currency.format(last_statement.balance_end_real), + 'bank_statements_source': journal.bank_statements_source, + 'is_sample_data': journal.has_statement_lines, + }) + + def _fill_sale_purchase_dashboard_data(self, dashboard_data): + """Populate all sale and purchase journal's data dict with relevant information for the kanban card.""" + sale_purchase_journals = self.filtered(lambda journal: journal.type in ('sale', 'purchase')) + if not sale_purchase_journals: + return + field_list = [ + "account_move.journal_id", + "(CASE WHEN account_move.move_type IN ('out_refund', 'in_refund') THEN -1 ELSE 1 END) * account_move.amount_residual AS amount_total", + "account_move.amount_residual_signed AS amount_total_company", + "account_move.currency_id AS currency", + "account_move.move_type", + "account_move.invoice_date", + "account_move.company_id", + ] + query, params = sale_purchase_journals._get_open_bills_to_pay_query().select(*field_list) + self.env.cr.execute(query, params) + query_results_to_pay = group_by_journal(self.env.cr.dictfetchall()) + + query, params = sale_purchase_journals._get_draft_bills_query().select(*field_list) + self.env.cr.execute(query, params) + query_results_drafts = group_by_journal(self.env.cr.dictfetchall()) + + query, params = sale_purchase_journals._get_late_bills_query().select(*field_list) + self.env.cr.execute(query, params) + late_query_results = group_by_journal(self.env.cr.dictfetchall()) + + to_check_vals = { + vals['journal_id']: vals + for vals in self.env['account.move'].read_group( + domain=[('journal_id', 'in', sale_purchase_journals.ids), ('to_check', '=', True)], + fields=['amount_total_signed'], + groupby='journal_id', + ) + } + + curr_cache = {} + sale_purchase_journals._fill_dashboard_data_count(dashboard_data, 'account.move', 'entries_count', []) + for journal in sale_purchase_journals: + currency = journal.currency_id or journal.company_id.currency_id + (number_waiting, sum_waiting) = self._count_results_and_sum_amounts(query_results_to_pay[journal.id], currency, curr_cache=curr_cache) + (number_draft, sum_draft) = self._count_results_and_sum_amounts(query_results_drafts[journal.id], currency, curr_cache=curr_cache) + (number_late, sum_late) = self._count_results_and_sum_amounts(late_query_results[journal.id], currency, curr_cache=curr_cache) + to_check = to_check_vals.get(journal.id, {}) + dashboard_data[journal.id].update({ + 'number_to_check': to_check.get('__count', 0), + 'to_check_balance': to_check.get('amount_total_signed', 0), + 'title': _('Bills to pay') if journal.type == 'purchase' else _('Invoices owed to you'), + 'number_draft': number_draft, + 'number_waiting': number_waiting, + 'number_late': number_late, + 'sum_draft': currency.format(sum_draft), + 'sum_waiting': currency.format(sum_waiting), + 'sum_late': currency.format(sum_late), + 'has_sequence_holes': journal.has_sequence_holes, + 'is_sample_data': dashboard_data[journal.id]['entries_count'], + }) + + def _fill_general_dashboard_data(self, dashboard_data): + """Populate all miscelaneous journal's data dict with relevant information for the kanban card.""" + general_journals = self.filtered(lambda journal: journal.type == 'general') + if not general_journals: + return + to_check_vals = { + vals['journal_id']: vals + for vals in self.env['account.move'].read_group( + domain=[('journal_id', 'in', general_journals.ids), ('to_check', '=', True)], + fields=['amount_total_signed'], + groupby='journal_id', + lazy=False, + ) + } + for journal in general_journals: + vals = to_check_vals.get('journal_id', {}) + dashboard_data[journal.id].update({ + 'number_to_check': vals.get('__count', 0), + 'to_check_balance': vals.get('amount_total_signed', 0), + }) + def _get_open_bills_to_pay_query(self): - """ - Returns a tuple containing the SQL query used to gather the open bills - data as its first element, and the arguments dictionary to use to run - it as its second. - """ - return (''' - SELECT - (CASE WHEN move.move_type IN ('out_refund', 'in_refund') THEN -1 ELSE 1 END) * move.amount_residual AS amount_total, - move.currency_id AS currency, - move.move_type, - move.invoice_date, - move.company_id - FROM account_move move - WHERE move.journal_id = %(journal_id)s - AND move.state = 'posted' - AND move.payment_state in ('not_paid', 'partial') - AND move.move_type IN ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt'); - ''', {'journal_id': self.id}) + return self.env['account.move']._where_calc([ + ('journal_id', 'in', self.ids), + ('state', '=', 'posted'), + ('payment_state', 'in', ('not_paid', 'partial')), + ('move_type', 'in', self.env['account.move'].get_invoice_types(include_receipts=True)), + ]) def _get_draft_bills_query(self): - """ - Returns a tuple containing as its first element the SQL query used to - gather the bills in draft state data, and the arguments - dictionary to use to run it as its second. - """ - return (''' - SELECT - (CASE WHEN move.move_type IN ('out_refund', 'in_refund') THEN -1 ELSE 1 END) * move.amount_total AS amount_total, - move.currency_id AS currency, - move.move_type, - move.invoice_date, - move.company_id - FROM account_move move - WHERE move.journal_id = %(journal_id)s - AND move.state = 'draft' - AND move.payment_state in ('not_paid', 'partial') - AND move.move_type IN ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt'); - ''', {'journal_id': self.id}) + return self.env['account.move']._where_calc([ + ('journal_id', 'in', self.ids), + ('state', '=', 'draft'), + ('payment_state', 'in', ('not_paid', 'partial')), + ('move_type', 'in', self.env['account.move'].get_invoice_types(include_receipts=True)), + ]) def _get_late_bills_query(self): - return """ - SELECT - (CASE WHEN move_type IN ('out_refund', 'in_refund') THEN -1 ELSE 1 END) * amount_residual AS amount_total, - currency_id AS currency, - move_type, - invoice_date, - company_id - FROM account_move move - WHERE journal_id = %(journal_id)s - AND invoice_date_due < %(today)s - AND state = 'posted' - AND payment_state in ('not_paid', 'partial') - AND move_type IN ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt'); - """, {'journal_id': self.id, 'today': fields.Date.context_today(self)} + return self.env['account.move']._where_calc([ + ('journal_id', 'in', self.ids), + ('invoice_date_due', '<', fields.Date.context_today(self)), + ('state', '=', 'posted'), + ('payment_state', 'in', ('not_paid', 'partial')), + ('move_type', 'in', self.env['account.move'].get_invoice_types(include_receipts=True)), + ]) def _count_results_and_sum_amounts(self, results_dict, target_currency, curr_cache=None): """ Loops on a query result to count the total number of invoices and sum their amount_total field (expressed in the given target currency). amount_total must be signed! """ - rslt_count = 0 - rslt_sum = 0.0 # Create a cache with currency rates to avoid unnecessary SQL requests. Do not copy # curr_cache on purpose, so the dictionary is modified and can be re-used for subsequent # calls of the method. curr_cache = {} if curr_cache is None else curr_cache + total_amount = 0 for result in results_dict: - cur = self.env['res.currency'].browse(result.get('currency')) + document_currency = self.env['res.currency'].browse(result.get('currency')) company = self.env['res.company'].browse(result.get('company_id')) or self.env.company - rslt_count += 1 date = result.get('invoice_date') or fields.Date.context_today(self) - amount = result.get('amount_total', 0) or 0 - if cur != target_currency: - key = (cur, target_currency, company, date) - # Using setdefault will call _get_conversion_rate, so we explicitly check the - # existence of the key in the cache instead. + if document_currency == target_currency: + total_amount += result.get('amount_total') or 0 + elif company.currency_id == target_currency: + total_amount += result.get('amount_total_company') or 0 + else: + key = (document_currency, target_currency, company, date) if key not in curr_cache: curr_cache[key] = self.env['res.currency']._get_conversion_rate(*key) - amount *= curr_cache[key] - rslt_sum += target_currency.round(amount) - return (rslt_count, rslt_sum) + total_amount += (result.get('amount_total') or 0) * curr_cache[key] + return (len(results_dict), target_currency.round(total_amount)) + + def _get_journal_dashboard_bank_running_balance(self): + # In order to not recompute everything from the start, we take the last + # bank statement and only sum starting from there. + self._cr.execute(""" + SELECT journal.id AS journal_id, + statement.id AS statement_id, + COALESCE(statement.balance_end_real, 0) AS balance_end_real, + without_statement.amount AS unlinked_amount, + without_statement.count AS unlinked_count + FROM account_journal journal + LEFT JOIN LATERAL ( -- select latest statement based on the date + SELECT id, + first_line_index, + balance_end_real + FROM account_bank_statement + WHERE journal_id = journal.id + ORDER BY date DESC + LIMIT 1 + ) statement ON TRUE + LEFT JOIN LATERAL ( -- sum all the lines not linked to a statement with a higher index than the last line of the statement + SELECT COALESCE(SUM(stl.amount), 0.0) AS amount, + COUNT(*) + FROM account_bank_statement_line stl + JOIN account_move move ON move.id = stl.move_id + WHERE stl.statement_id IS NULL + AND move.state != 'cancel' + AND move.journal_id = journal.id + AND stl.internal_index >= statement.first_line_index + ) without_statement ON TRUE + WHERE journal.id = ANY(%s) + """, [(self.ids)]) + query_res = {res['journal_id']: res for res in self.env.cr.dictfetchall()} + result = {} + for journal in self: + journal_vals = query_res[journal.id] + result[journal.id] = ( + bool(journal_vals['statement_id'] or journal_vals['unlinked_count']), + journal_vals['balance_end_real'] + journal_vals['unlinked_amount'], + ) + return result + + def _get_journal_dashboard_outstanding_payments(self): + self.env.cr.execute(""" + SELECT move.journal_id AS journal_id, + move.company_id AS company_id, + move.currency_id AS currency, + SUM(CASE + WHEN payment.payment_type = 'outbound' THEN -payment.amount + ELSE payment.amount + END) AS amount_total, + SUM(amount_company_currency_signed) AS amount_total_company + FROM account_payment payment + JOIN account_move move ON move.payment_id = payment.id + WHERE payment.is_matched IS NOT TRUE + AND move.state = 'posted' + AND move.journal_id = ANY(%s) + GROUP BY move.company_id, move.journal_id, move.currency_id + """, [self.ids]) + query_result = group_by_journal(self.env.cr.dictfetchall()) + result = {} + curr_cache = {} + for journal in self: + currency = journal.currency_id or journal.company_id.currency_id + result[journal.id] = self._count_results_and_sum_amounts(query_result[journal.id], currency, curr_cache) + return result def _get_move_action_context(self): ctx = self._context.copy() diff --git a/addons/account/tests/test_account_journal_dashboard.py b/addons/account/tests/test_account_journal_dashboard.py index c64b7899865..138389ac51b 100644 --- a/addons/account/tests/test_account_journal_dashboard.py +++ b/addons/account/tests/test_account_journal_dashboard.py @@ -55,7 +55,7 @@ class TestAccountJournalDashboard(AccountTestInvoicingCommon): dashboard_data = journal.get_journal_dashboard_datas() self.assertEqual(dashboard_data['number_draft'], 1) - self.assertIn('-13.30', dashboard_data['sum_draft']) + self.assertIn('-\N{ZERO WIDTH NO-BREAK SPACE}13.30', dashboard_data['sum_draft']) self.assertEqual(dashboard_data['number_waiting'], 1) self.assertIn('81.72', dashboard_data['sum_waiting']) diff --git a/addons/account/views/account_journal_dashboard_view.xml b/addons/account/views/account_journal_dashboard_view.xml index c6014deea8a..9c5a21aa945 100644 --- a/addons/account/views/account_journal_dashboard_view.xml +++ b/addons/account/views/account_journal_dashboard_view.xml @@ -53,8 +53,7 @@ - - + Gaps in the sequence @@ -298,8 +297,7 @@ - - + diff --git a/addons/account_check_printing/models/account_journal.py b/addons/account_check_printing/models/account_journal.py index fb4e04906b4..aa5a0797409 100644 --- a/addons/account_check_printing/models/account_journal.py +++ b/addons/account_check_printing/models/account_journal.py @@ -74,17 +74,14 @@ class AccountJournal(models.Model): 'company_id': journal.company_id.id, }) - def get_journal_dashboard_datas(self): - domain_checks_to_print = [ - ('journal_id', '=', self.id), + def _get_journal_dashboard_data_batched(self): + dashboard_data = super()._get_journal_dashboard_data_batched() + self._fill_dashboard_data_count(dashboard_data, 'account.payment', 'num_checks_to_print', [ ('payment_method_line_id.code', '=', 'check_printing'), ('state', '=', 'posted'), ('is_move_sent','=', False), - ] - return dict( - super(AccountJournal, self).get_journal_dashboard_datas(), - num_checks_to_print=self.env['account.payment'].search_count(domain_checks_to_print), - ) + ]) + return dashboard_data def action_checks_to_print(self): payment_method_line = self.outbound_payment_method_line_ids.filtered(lambda l: l.code == 'check_printing')