From 46d70e30ef9da4427aed5c74e16cd9541f1c4ebc Mon Sep 17 00:00:00 2001 From: "Xavier Bol (xbo)" Date: Mon, 18 Nov 2019 16:03:28 +0100 Subject: [PATCH] [IMP] sale_timesheet: improve invoicing sale order with timesheets TaskID: 2088586 Add an 'Invoice timesheets up to mm/dd/yyyy' field on the Invoice Creation modal. This field is only visible when we want to create an invoice for a Service whose Service Policy is set on 'Timesheets on tasks'. This field allows to invoice the timesheets of a service up to the date set on this field. Before this commit, when we create the account.moves.line records, we link timesheets of a service to an invoice. Now, we need to check which timesheets are invoiced, because with the adding of the date for the creation of invoices, we cannot invoice the timesheets after to this date. Then, the process is done at the end of '_create_invoices' in 'sale.order' model when the invoices are created. Override an ir.rule in accout module, because this rule accept all account.analytic.line in its domain and we don't want to accept all timesheets when the user has the access right : "See own timesheets". Therefore, we need to override this rule to apply the others rules in this model for the timesheets. --- addons/sale_timesheet/__manifest__.py | 1 + .../sale_timesheet/data/sale_service_demo.xml | 4 +- addons/sale_timesheet/models/account_move.py | 41 +++--- addons/sale_timesheet/models/product.py | 5 + addons/sale_timesheet/models/sale_order.py | 33 +++++ .../security/sale_timesheet_security.xml | 9 ++ .../tests/test_sale_timesheet.py | 139 ++++++++++++++++++ addons/sale_timesheet/wizard/__init__.py | 1 + .../wizard/sale_make_invoice_advance.py | 51 +++++++ .../sale_make_invoice_advance_views.xml | 21 +++ 10 files changed, 282 insertions(+), 23 deletions(-) create mode 100644 addons/sale_timesheet/wizard/sale_make_invoice_advance.py create mode 100644 addons/sale_timesheet/wizard/sale_make_invoice_advance_views.xml diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index 966b0df76dd..46c4abe03eb 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -29,6 +29,7 @@ have real delivered quantities in sales orders. 'data/sale_timesheet_filters.xml', 'wizard/project_create_sale_order_views.xml', 'wizard/project_create_invoice_views.xml', + 'wizard/sale_make_invoice_advance_views.xml', ], 'demo': [ 'data/sale_service_demo.xml', diff --git a/addons/sale_timesheet/data/sale_service_demo.xml b/addons/sale_timesheet/data/sale_service_demo.xml index acd7c6bb0fd..5f18503a0ff 100644 --- a/addons/sale_timesheet/data/sale_service_demo.xml +++ b/addons/sale_timesheet/data/sale_service_demo.xml @@ -477,10 +477,10 @@ - + - + diff --git a/addons/sale_timesheet/models/account_move.py b/addons/sale_timesheet/models/account_move.py index 7e316a523e4..69d8ee4277f 100644 --- a/addons/sale_timesheet/models/account_move.py +++ b/addons/sale_timesheet/models/account_move.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ -from odoo.tools.float_utils import float_round, float_is_zero +from odoo.osv import expression class AccountMove(models.Model): @@ -43,30 +43,29 @@ class AccountMove(models.Model): } } + def _link_timesheets_to_invoice(self, date=None): + """ Search timesheets and link this timesheets to the invoice + + When we create an invoice from a sale order, we need to + link the timesheets in this sale order to the invoice. + Then, we can know which timesheets are invoiced in the sale order. + + :param date: All timesheets on task before or equals this date + in the sale order are invoiced. + """ + for line in self.filtered(lambda i: i.type == 'out_invoice' and i.state == 'draft').invoice_line_ids: + sale_line_delivery = line.sale_line_ids.filtered(lambda sol: sol.product_id.invoice_policy == 'delivery' and sol.product_id.service_type == 'timesheet') + if sale_line_delivery: + domain = line._timesheet_domain_get_invoiced_lines(sale_line_delivery) + if date: + domain = expression.AND([domain, [('date', '<=', date)]]) + timesheets = self.env['account.analytic.line'].search(domain).sudo() + timesheets.write({'timesheet_invoice_id': line.move_id.id}) + class AccountMoveLine(models.Model): _inherit = 'account.move.line' - @api.model_create_multi - def create(self, vals_list): - # OVERRIDE - # Link the timesheet from the SO lines to the corresponding draft invoice. - # NOTE: Only the timesheets linked to an Sale Line with a product invoiced on delivered quantity - # are concerned, since in ordered quantity, the timesheet quantity is not invoiced, but is simply - # to compute the delivered one (for reporting). - lines = super(AccountMoveLine, self).create(vals_list) - lines_to_process = lines.filtered(lambda line: line.move_id.type == 'out_invoice' - and line.move_id.state == 'draft') - for line in lines_to_process: - sale_line_delivery = line.sale_line_ids.filtered(lambda sol: sol.product_id.invoice_policy == 'delivery' and sol.product_id.service_type == 'timesheet') - if sale_line_delivery: - domain = self._timesheet_domain_get_invoiced_lines(sale_line_delivery) - timesheets = self.env['account.analytic.line'].search(domain).sudo() - timesheets.write({ - 'timesheet_invoice_id': line.move_id.id, - }) - return lines - @api.model def _timesheet_domain_get_invoiced_lines(self, sale_line_delivery): """ Get the domain for the timesheet to link to the created invoice diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index ebc2df96c00..cf595f138f3 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -113,3 +113,8 @@ class ProductProduct(models.Model): self.project_template_id = False elif self.service_tracking in ['task_in_project', 'project_only']: self.project_id = False + + def _is_delivered_timesheet(self): + """ Check if the product is a delivered timesheet """ + self.ensure_one() + return self.type == 'service' and self.service_policy == 'delivered_timesheet' diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 62eb114d2db..3d17e2dd27f 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -148,6 +148,19 @@ class SaleOrder(models.Model): action = {'type': 'ir.actions.act_window_close'} return action + def _create_invoices(self, grouped=False, final=False, date=None): + """ Override the _create_invoice method in sale.order model in sale module + + Add new parameter in this method, to invoice sale.order with a date. This date is used in sale_make_invoice_advance_inv into this module. + + :param date + + :return {account.move}: the invoices created + """ + moves = super(SaleOrder, self)._create_invoices(grouped, final) + moves._link_timesheets_to_invoice(date) + return moves + class SaleOrderLine(models.Model): _inherit = "sale.order.line" @@ -389,3 +402,23 @@ class SaleOrderLine(models.Model): project = map_so_project[so_line.order_id.id] if not so_line.task_id: so_line._timesheet_create_task(project=project) + + def _recompute_qty_to_invoice(self, date): + """ Recompute the qty_to_invoice field for product containing timesheets + + Search the existed timesheets up the given date in parameter. + Retrieve the unit_amount of this timesheet and then recompute + the qty_to_invoice for each current product. + + :param date: date to search timesheets before this date. + """ + lines_by_timesheet = self.filtered(lambda sol: sol.product_id._is_delivered_timesheet()) + domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain() + domain = expression.AND([domain, [ + ('date', '<=', date), + ('timesheet_invoice_id', '=', False)]]) + mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain) + + for line in lines_by_timesheet: + if line.product_id._is_delivered_timesheet(): + line.qty_to_invoice = mapping.get(line.id, 0.0) diff --git a/addons/sale_timesheet/security/sale_timesheet_security.xml b/addons/sale_timesheet/security/sale_timesheet_security.xml index 3f108758e60..78afdebb627 100644 --- a/addons/sale_timesheet/security/sale_timesheet_security.xml +++ b/addons/sale_timesheet/security/sale_timesheet_security.xml @@ -14,5 +14,14 @@ + + + [('project_id', '=', False)] + diff --git a/addons/sale_timesheet/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py index da88d793561..c958f8e1715 100644 --- a/addons/sale_timesheet/tests/test_sale_timesheet.py +++ b/addons/sale_timesheet/tests/test_sale_timesheet.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import date, timedelta + from odoo.tools import float_is_zero from odoo.exceptions import UserError @@ -21,6 +23,8 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): # set up cls.setUpEmployees() cls.setUpServiceProducts() + cls.setUpAdditionalAccounts() + cls.setUpAccountJournal() def test_timesheet_order(self): """ Test timesheet invoicing with 'invoice on order' timetracked products @@ -379,3 +383,138 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice, even after invoice validation") self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice, even after invoice validation") + + def test_timesheet_invoice(self): + """ Test to create invoices for the sale order with timesheets + + 1) create sale order + 2) try to create an invoice for the timesheets 10 days before + 3) create invoice for the timesheets 6 days before + 4) create invoice for the timesheets 4 days before + 5) create invoice for the timesheets from today + """ + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_customer_usd.id, + 'partner_invoice_id': self.partner_customer_usd.id, + 'partner_shipping_id': self.partner_customer_usd.id, + 'pricelist_id': self.pricelist_usd.id, + }) + so_line_deliver_global_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet2.name, + 'product_id': self.product_delivery_timesheet2.id, + 'product_uom_qty': 50, + 'product_uom': self.product_delivery_timesheet2.uom_id.id, + 'price_unit': self.product_delivery_timesheet2.list_price, + 'order_id': sale_order.id, + }) + so_line_deliver_task_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet3.name, + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 20, + 'product_uom': self.product_delivery_timesheet3.uom_id.id, + 'price_unit': self.product_delivery_timesheet3.list_price, + 'order_id': sale_order.id, + }) + so_line_deliver_global_project.product_id_change() + so_line_deliver_task_project.product_id_change() + + # confirm SO + sale_order.action_confirm() + task_serv1 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)]) + task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) + project_serv2 = self.env['project.project'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) + + timesheet1 = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv1.project_id.id, + 'task_id': task_serv1.id, + 'unit_amount': 10, + 'employee_id': self.employee_manager.id, + 'date': date.today() - timedelta(days=6) + }) + + timesheet2 = self.env['account.analytic.line'].create({ + 'name': 'Test Line 2', + 'project_id': task_serv1.project_id.id, + 'task_id': task_serv1.id, + 'unit_amount': 20, + 'employee_id': self.employee_manager.id, + 'date': date.today() - timedelta(days=1) + }) + + timesheet3 = self.env['account.analytic.line'].create({ + 'name': 'Test Line 3', + 'project_id': task_serv1.project_id.id, + 'task_id': task_serv1.id, + 'unit_amount': 10, + 'employee_id': self.employee_manager.id, + 'date': date.today() - timedelta(days=5) + }) + + timesheet4 = self.env['account.analytic.line'].create({ + 'name': 'Test Line 4', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 30, + 'employee_id': self.employee_manager.id + }) + self.assertEqual(so_line_deliver_global_project.invoice_status, 'to invoice') + self.assertEqual(so_line_deliver_task_project.invoice_status, 'to invoice') + self.assertEqual(sale_order.invoice_status, 'to invoice') + + # Context for sale.advance.payment.inv wizard + self.context = { + 'active_model': 'sale.order', + 'active_ids': [sale_order.id], + 'active_id': sale_order.id, + 'default_journal_id': self.journal_sale.id + } + + # invoice SO + wizard = self.env['sale.advance.payment.inv'].with_context(self.context).create({ + 'advance_payment_method': 'delivered', + 'date_invoice_timesheet': date.today() - timedelta(days=10) + }) + + self.assertTrue(wizard.invoicing_timesheet_enabled, 'The "date_invoice_timesheet" field should be visible in the wizard because a product in sale order has service_policy to "Timesheet on Task"') + + with self.assertRaises(UserError): + wizard.create_invoices() + + self.assertFalse(sale_order.invoice_ids, 'Normally, no invoice will be created created because the timesheet logged is after the date defined in date_invoice_timesheet field') + + wizard.write({ + 'date_invoice_timesheet': date.today() - timedelta(days=6) + }) + wizard.create_invoices() + + self.assertTrue(sale_order.invoice_ids, 'One invoice should be created because the timesheet logged is before or equal to the date defined in wizard') + + invoice = sale_order.invoice_ids[0] + self.assertEqual(so_line_deliver_global_project.qty_invoiced, timesheet1.unit_amount) + + # validate invoice + invoice.post() + + wizard.write({ + 'date_invoice_timesheet': date.today() - timedelta(days=4) + }) + wizard.create_invoices() + + self.assertEqual(len(sale_order.invoice_ids), 2) + invoice2 = sale_order.invoice_ids[-1] + + self.assertEqual(so_line_deliver_global_project.qty_invoiced, timesheet1.unit_amount + timesheet3.unit_amount, "The last invoice done should have the quantity of the timesheet 3, because the date this timesheet is the only one before the 'date_invoice_timesheet' field in the wizard.") + + wizard.write({ + 'date_invoice_timesheet': date.today() + }) + + wizard.create_invoices() + + self.assertEqual(len(sale_order.invoice_ids), 3) + invoice3 = sale_order.invoice_ids[-1] + + # Check if all timesheets have been invoiced + self.assertEqual(so_line_deliver_global_project.qty_invoiced, timesheet1.unit_amount + timesheet2.unit_amount + timesheet3.unit_amount) + self.assertEqual(so_line_deliver_task_project.qty_invoiced, + timesheet4.unit_amount) diff --git a/addons/sale_timesheet/wizard/__init__.py b/addons/sale_timesheet/wizard/__init__.py index ca68115ebf6..a2800ce6ba3 100644 --- a/addons/sale_timesheet/wizard/__init__.py +++ b/addons/sale_timesheet/wizard/__init__.py @@ -3,3 +3,4 @@ from . import project_create_sale_order from . import project_create_invoice +from . import sale_make_invoice_advance diff --git a/addons/sale_timesheet/wizard/sale_make_invoice_advance.py b/addons/sale_timesheet/wizard/sale_make_invoice_advance.py new file mode 100644 index 00000000000..ab4e866dd21 --- /dev/null +++ b/addons/sale_timesheet/wizard/sale_make_invoice_advance.py @@ -0,0 +1,51 @@ +from odoo import api, fields, models + + +class SaleAdvancePaymentInv(models.TransientModel): + _inherit = "sale.advance.payment.inv" + + @api.model + def _default_invoicing_timesheet_enabled(self): + product_ids = None + if self._context.get('active_id', False): + sale_order = self.env['sale.order'].browse(self._context.get('active_id')) + product_ids = sale_order.order_line.filtered(lambda sol: sol.invoice_status == 'to invoice').mapped('product_id') + elif self._context.get('active_ids', []): + sale_orders = self.env['sale.order'].browse(self._context.get('active_ids')) + order_lines = sale_orders.mapped('order_line').filtered(lambda sol: sol.invoice_status == 'to invoice') + product_ids = order_lines.mapped('product_id') + + if product_ids: + return any(product._is_delivered_timesheet() for product in product_ids) + return False + + date_invoice_timesheet = fields.Date( + string='Invoice Timesheets Up To This Date', + default=fields.Date.today, + required=False, + help="Only timesheets not yet invoiced (and validated, if applicable) up to this date included will be invoiced. If no date is indicated, all timesheets not yet invoiced (and validated, if applicable) will be invoiced without distinction.") + invoicing_timesheet_enabled = fields.Boolean(default=_default_invoicing_timesheet_enabled) + + def create_invoices(self): + """ Override method from sale/wizard/sale_make_invoice_advance.py + + When the user want to invoice the timesheets to the SO + up to a specific date then we need to recompute the + qty_to_invoice for each product_id in sale.order.line, + before creating the invoice. + """ + sale_orders = self.env['sale.order'].browse( + self._context.get('active_ids', []) + ) + + if self.advance_payment_method == 'delivered' and self.invoicing_timesheet_enabled: + if self.date_invoice_timesheet: + sale_orders.mapped('order_line')._recompute_qty_to_invoice(self.date_invoice_timesheet) + + sale_orders._create_invoices(final=self.deduct_down_payments, date=self.date_invoice_timesheet) + + if self._context.get('open_invoices', False): + return sale_orders.action_view_invoice() + return {'type': 'ir.actions.act_window_close'} + + return super(SaleAdvancePaymentInv, self).create_invoices() diff --git a/addons/sale_timesheet/wizard/sale_make_invoice_advance_views.xml b/addons/sale_timesheet/wizard/sale_make_invoice_advance_views.xml new file mode 100644 index 00000000000..8a0a1708f9e --- /dev/null +++ b/addons/sale_timesheet/wizard/sale_make_invoice_advance_views.xml @@ -0,0 +1,21 @@ + + + + sale_timesheet.sale.advance.payment.inv.view.form + sale.advance.payment.inv + + + + + + + + +