+ You can register and track your workings hours by project every
+ day. Every time spent on a project will become a cost and can be re-invoiced to
+ customers if required.
+
diff --git a/addons/hr_timesheet/wizard/__init__.py b/addons/hr_timesheet/wizard/__init__.py
new file mode 100644
index 00000000000..9030e15e6e0
--- /dev/null
+++ b/addons/hr_timesheet/wizard/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import project_task_create_timesheet
diff --git a/addons/hr_timesheet/wizard/project_task_create_timesheet.py b/addons/hr_timesheet/wizard/project_task_create_timesheet.py
new file mode 100644
index 00000000000..dd461c29ea9
--- /dev/null
+++ b/addons/hr_timesheet/wizard/project_task_create_timesheet.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from datetime import datetime
+from odoo.exceptions import UserError
+
+
+class ProjectTaskCreateTimesheet(models.TransientModel):
+ _name = 'project.task.create.timesheet'
+ _description = "Create Timesheet from task"
+
+ _sql_constraints = [('time_positive', 'CHECK(time_spent > 0)', 'The timesheet\'s time must be positive' )]
+
+ @api.model
+ def default_get(self, fields):
+ result = super(ProjectTaskCreateTimesheet, self).default_get(fields)
+ active_model = self._context.get('active_model')
+ if active_model != 'project.task':
+ raise UserError(_("You can only apply this action from a task."))
+
+ active_id = self._context.get('active_id')
+ if 'task_id' in fields and active_id:
+ task_id = self.env['project.task'].browse(active_id)
+ result['task_id'] = active_id
+ result['description'] = task_id.name
+ return result
+
+ time_spent = fields.Float('Time', precision_digits=2)
+ description = fields.Char('Description')
+ task_id = fields.Many2one('project.task', "Task", help="Task for which we are creating a sales order", required=True)
+
+ def save_timesheet(self):
+ values = {
+ 'task_id': self.task_id.id,
+ 'project_id': self.task_id.project_id.id,
+ 'date': datetime.now(),
+ 'name': self.description,
+ 'user_id': self.env.uid,
+ 'unit_amount': self.time_spent,
+ }
+ self.task_id.write({
+ 'timer_start': False,
+ 'timer_pause': False,
+ 'timesheet_timer_last_stop': fields.datetime.now(),
+ })
+ return self.env['account.analytic.line'].create(values)
diff --git a/addons/hr_timesheet/wizard/project_task_create_timesheet_views.xml b/addons/hr_timesheet/wizard/project_task_create_timesheet_views.xml
new file mode 100644
index 00000000000..2b5deaf854a
--- /dev/null
+++ b/addons/hr_timesheet/wizard/project_task_create_timesheet_views.xml
@@ -0,0 +1,28 @@
+
+
+
+
+ project.task.create.timesheet.wizard.form
+ project.task.create.timesheet
+
+
+
+
+
+
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 889bf98dc44..5fce56f0fa1 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -572,7 +572,7 @@ class SaleOrder(models.Model):
action['context'] = context
return action
- def _create_invoices(self, grouped=False, final=False):
+ def _create_invoices(self, grouped=False, final=False, date=None):
"""
Create the invoice associated to the SO.
:param grouped: if True, invoices are grouped by SO id. If False, invoices are grouped by
diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py
index 966b0df76dd..40db3f55277 100644
--- a/addons/sale_timesheet/__manifest__.py
+++ b/addons/sale_timesheet/__manifest__.py
@@ -29,6 +29,8 @@ 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',
+ 'wizard/project_task_create_sale_order_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..e98e479576b 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'].sudo().search(domain)
+ 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
@@ -74,9 +73,7 @@ class AccountMoveLine(models.Model):
:return a normalized domain
"""
return [
- '&',
('so_line', 'in', sale_line_delivery.ids),
- '&',
('timesheet_invoice_id', '=', False),
('project_id', '!=', False)
]
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/project.py b/addons/sale_timesheet/models/project.py
index f2dbb291619..21fa83c0068 100644
--- a/addons/sale_timesheet/models/project.py
+++ b/addons/sale_timesheet/models/project.py
@@ -25,6 +25,7 @@ class Project(models.Model):
sale_line_employee_ids = fields.One2many('project.sale.line.employee.map', 'project_id', "Sale line/Employee map", copy=False,
help="Employee/Sale Order Item Mapping:\n Defines to which sales order item an employee's timesheet entry will be linked."
"By extension, it defines the rate at which an employee's time on the project is billed.")
+ allow_billable = fields.Boolean("Bill from Tasks")
_sql_constraints = [
('sale_order_required_if_sale_line', "CHECK((sale_line_id IS NOT NULL AND sale_order_id IS NOT NULL) OR (sale_line_id IS NULL))", 'The Project should be linked to a Sale Order to select an Sale Order Items.'),
@@ -50,6 +51,15 @@ class Project(models.Model):
if self.billable_type == 'no':
self.sale_line_employee_ids = False
+ @api.onchange('allow_billable')
+ def _onchange_allow_billable(self):
+ """ In order to keep task billable type as 'task_rate' using sale_timesheet usual flow.
+ (see _compute_billable_type method in sale_timesheet)
+ """
+ if self.allow_billable:
+ self.sale_order_id = False
+ self.sale_line_employee_ids = False
+
@api.constrains('sale_line_id', 'billable_type')
def _check_sale_line_type(self):
for project in self:
@@ -134,6 +144,7 @@ class ProjectTask(models.Model):
('no', 'No Billable')
], string="Billable Type", compute='_compute_billable_type', compute_sudo=True, store=True)
is_project_map_empty = fields.Boolean("Is Project map empty", compute='_compute_is_project_map_empty')
+ allow_billable = fields.Boolean(related="project_id.allow_billable")
@api.depends('sale_line_id', 'project_id', 'billable_type')
def _compute_sale_order_id(self):
@@ -228,3 +239,17 @@ class ProjectTask(models.Model):
if partner:
return partner
return super(ProjectTask, self).rating_get_partner_id()
+
+ def action_make_billable(self):
+ return {
+ "name": _("Create Sales Order"),
+ "type": 'ir.actions.act_window',
+ "res_model": 'project.task.create.sale.order',
+ "views": [[False, "form"]],
+ "target": 'new',
+ "context": {
+ 'active_id': self.id,
+ 'active_model': 'project.task',
+ 'form_view_initial_mode': 'edit',
+ },
+ }
diff --git a/addons/sale_timesheet/models/project_overview.py b/addons/sale_timesheet/models/project_overview.py
index 1230ce8c6fa..c5953a556f4 100644
--- a/addons/sale_timesheet/models/project_overview.py
+++ b/addons/sale_timesheet/models/project_overview.py
@@ -102,7 +102,8 @@ class Project(models.Model):
aal_employee_ids = self.env['account.analytic.line'].read_group([('project_id', 'in', self.ids), ('employee_id', '!=', False)], ['employee_id'], ['employee_id'])
employee_ids.extend(list(map(lambda x: x['employee_id'][0], aal_employee_ids)))
- employees = self.env['hr.employee'].sudo().browse(employee_ids)
+ # Retrieve the employees for which the current user can see theirs timesheets
+ employees = self.env['hr.employee'].sudo().browse(employee_ids).filtered_domain(self.env['account.analytic.line']._domain_employee_id())
repartition_domain = [('project_id', 'in', self.ids), ('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type
# repartition data, without timesheet on cancelled so
repartition_data = self.env['account.analytic.line'].read_group(repartition_domain + ['|', ('so_line', '=', False), ('so_line.state', '!=', 'cancel')], ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False)
@@ -146,12 +147,12 @@ class Project(models.Model):
#
# Table grouped by SO / SOL / Employees
#
- timesheet_forecast_table_rows = self._table_get_line_values()
+ timesheet_forecast_table_rows = self._table_get_line_values(employees)
if timesheet_forecast_table_rows:
values['timesheet_forecast_table'] = timesheet_forecast_table_rows
return values
- def _table_get_line_values(self):
+ def _table_get_line_values(self, employees=None):
""" return the header and the rows informations of the table """
if not self:
return False
@@ -193,9 +194,11 @@ class Project(models.Model):
if not is_milestone:
rows_sale_line[sale_line_row_key][-2] = sale_line.product_uom._compute_quantity(sale_line.product_uom_qty, uom_hour, raise_if_failure=False) if sale_line else 0.0
+ rows_sale_line_all_data = {}
+ if not employees:
+ employees = self.env['hr.employee'].sudo().search(self.env['account.analytic.line']._domain_employee_id())
for row_key, row_employee in rows_employee.items():
- sale_line_id = row_key[1]
- sale_order_id = row_key[0]
+ sale_order_id, sale_line_id, employee_id = row_key
# sale line row
sale_line_row_key = (sale_order_id, sale_line_id)
if sale_line_row_key not in rows_sale_line:
@@ -205,35 +208,26 @@ class Project(models.Model):
if not is_milestone:
rows_sale_line[sale_line_row_key][-2] = sale_line.product_uom._compute_quantity(sale_line.product_uom_qty, uom_hour, raise_if_failure=False) if sale_line else 0.0
- for index in range(len(rows_employee[row_key])):
- if index != 0:
- rows_sale_line[sale_line_row_key][index] += rows_employee[row_key][index]
- if not rows_sale_line[sale_line_row_key][0].get('is_milestone'):
- rows_sale_line[sale_line_row_key][-1] = rows_sale_line[sale_line_row_key][-2] - rows_sale_line[sale_line_row_key][5]
- else:
- rows_sale_line[sale_line_row_key][-1] = 0
+ if sale_line_row_key not in rows_sale_line_all_data:
+ rows_sale_line_all_data[sale_line_row_key] = [0] * len(row_employee)
+ for index in range(1, len(row_employee)):
+ if employee_id in employees.ids:
+ rows_sale_line[sale_line_row_key][index] += row_employee[index]
+ rows_sale_line_all_data[sale_line_row_key][index] += row_employee[index]
+ if not rows_sale_line[sale_line_row_key][0].get('is_milestone'):
+ rows_sale_line[sale_line_row_key][-1] = rows_sale_line[sale_line_row_key][-2] - rows_sale_line_all_data[sale_line_row_key][5]
+ else:
+ rows_sale_line[sale_line_row_key][-1] = 0
rows_sale_order = {} # so -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted]
- rows_sale_order_done_sold = {key : dict(sold=0.0, done=0.0) for key in set(map_sol_so.values()) | set([None])} # SO id -> {'sold':0.0, 'done': 0.0}
for row_key, row_sale_line in rows_sale_line.items():
sale_order_id = row_key[0]
# sale order row
if sale_order_id not in rows_sale_order:
rows_sale_order[sale_order_id] = [{'label': map_so_names.get(sale_order_id, _('No Sales Order')), 'canceled': map_so_cancel.get(sale_order_id, False), 'res_id': sale_order_id, 'res_model': 'sale.order', 'type': 'sale_order'}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted
- for index in range(len(rows_sale_line[row_key])):
- if index != 0:
- rows_sale_order[sale_order_id][index] += rows_sale_line[row_key][index]
-
- # do not sum the milestone SO line for sold and done (for remaining computation)
- if not rows_sale_line[row_key][0].get('is_milestone'):
- rows_sale_order_done_sold[sale_order_id]['sold'] += rows_sale_line[row_key][-2]
- rows_sale_order_done_sold[sale_order_id]['done'] += rows_sale_line[row_key][5]
-
- # remaining computation of SO row, as Sold - Done (timesheet total)
- for sale_order_id, done_sold_vals in rows_sale_order_done_sold.items():
- if sale_order_id in rows_sale_order:
- rows_sale_order[sale_order_id][-1] = done_sold_vals['sold'] - done_sold_vals['done']
+ for index in range(1, len(row_sale_line)):
+ rows_sale_order[sale_order_id][index] += row_sale_line[index]
# group rows SO, SOL and their related employee rows.
timesheet_forecast_table_rows = []
@@ -243,7 +237,7 @@ class Project(models.Model):
if sale_order_id == sale_line_row_key[0]:
timesheet_forecast_table_rows.append(sale_line_row)
for employee_row_key, employee_row in rows_employee.items():
- if sale_order_id == employee_row_key[0] and sale_line_row_key[1] == employee_row_key[1]:
+ if sale_order_id == employee_row_key[0] and sale_line_row_key[1] == employee_row_key[1] and employee_row_key[2] in employees.ids:
timesheet_forecast_table_rows.append(employee_row)
# complete table data
diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py
index 62eb114d2db..bf3c405cbd2 100644
--- a/addons/sale_timesheet/models/sale_order.py
+++ b/addons/sale_timesheet/models/sale_order.py
@@ -66,7 +66,8 @@ class SaleOrder(models.Model):
def _compute_timesheet_total_duration(self):
for sale_order in self:
duration_list = []
- for timesheet in sale_order.timesheet_ids:
+ timesheets = sale_order.timesheet_ids if self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') else sale_order.timesheet_ids.filtered(lambda t: t.user_id.id == self.env.uid)
+ for timesheet in timesheets:
timesheet_uom = timesheet.product_uom_id or timesheet.company_id.project_time_mode_id
if timesheet_uom != sale_order.timesheet_encode_uom_id and timesheet_uom.category_id == sale_order.timesheet_encode_uom_id.category_id:
duration_list.append(timesheet_uom._compute_quantity(timesheet.unit_amount, sale_order.timesheet_encode_uom_id))
@@ -74,6 +75,7 @@ class SaleOrder(models.Model):
duration_list.append(timesheet.unit_amount)
sale_order.timesheet_total_duration = sum(duration_list)
+
@api.onchange('project_id')
def _onchange_project_id(self):
""" Set the SO analytic account to the selected project's analytic account """
@@ -148,6 +150,15 @@ 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 +400,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/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml
index ecdd8b480ce..e4adc8aa443 100644
--- a/addons/sale_timesheet/views/project_task_views.xml
+++ b/addons/sale_timesheet/views/project_task_views.xml
@@ -28,6 +28,29 @@
+
+ project.view.form.inherit
+ project.project
+
+
+
+