diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml
index 51f9132d833..ec40bacd847 100644
--- a/addons/project/views/project_views.xml
+++ b/addons/project/views/project_views.xml
@@ -663,29 +663,28 @@
-
-
-
-
-
-
-
-
-
+
-
+
+
+
+
+
+
+
+
diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py
index 66b942cb3e9..b948b19ddee 100644
--- a/addons/sale_project/models/project.py
+++ b/addons/sale_project/models/project.py
@@ -15,7 +15,7 @@ class Project(models.Model):
domain="[('is_expense', '=', False), ('order_id', '=', sale_order_id), ('state', 'in', ['sale', 'done']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
help="Sales order item to which the project is linked. Link the timesheet entry to the sales order item defined on the project. "
"Only applies on tasks without sale order item defined, and if the employee is not in the 'Employee/Sales Order Item Mapping' of the project.")
- sale_order_id = fields.Many2one('sale.order', 'Sales Order', domain="[('partner_id', '=', partner_id)]", readonly=True, copy=False, help="Sales order to which the project is linked.")
+ sale_order_id = fields.Many2one('sale.order', 'Sales Order', domain="[('partner_id', '=', partner_id)]", copy=False, help="Sales order to which the project is linked.")
_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 a sale order item.'),
diff --git a/addons/sale_project/views/project_task_views.xml b/addons/sale_project/views/project_task_views.xml
index 4d2a91325e2..0858361c18a 100644
--- a/addons/sale_project/views/project_task_views.xml
+++ b/addons/sale_project/views/project_task_views.xml
@@ -27,7 +27,7 @@
groups="sales_team.group_sale_salesman"/>
-
+
diff --git a/addons/sale_timesheet/i18n/sale_timesheet.pot b/addons/sale_timesheet/i18n/sale_timesheet.pot
index 4f6e31de6d2..9f4fa0712ef 100644
--- a/addons/sale_timesheet/i18n/sale_timesheet.pot
+++ b/addons/sale_timesheet/i18n/sale_timesheet.pot
@@ -431,7 +431,7 @@ msgstr ""
#. module: sale_timesheet
#: model:ir.model.fields,field_description:sale_timesheet.field_project_project__bill_type
#: model:ir.model.fields,field_description:sale_timesheet.field_project_task__bill_type
-msgid "Customer Type"
+msgid "Invoice Tasks to"
msgstr ""
#. module: sale_timesheet
@@ -557,7 +557,7 @@ msgstr ""
#. module: sale_timesheet
#: model:ir.model.fields.selection,name:sale_timesheet.selection__project_project__pricing_type__fixed_rate
-msgid "Fixed rate"
+msgid "Project rate"
msgstr ""
#. module: sale_timesheet
@@ -663,7 +663,7 @@ msgstr ""
#. module: sale_timesheet
#: model:ir.model.fields.selection,name:sale_timesheet.selection__project_project__bill_type__customer_project
-msgid "Invoice all tasks to a single customer"
+msgid "A unique customer"
msgstr ""
#. module: sale_timesheet
@@ -673,7 +673,7 @@ msgstr ""
#. module: sale_timesheet
#: model:ir.model.fields.selection,name:sale_timesheet.selection__project_project__bill_type__customer_task
-msgid "Invoice tasks separately to different customers"
+msgid "Different customers"
msgstr ""
#. module: sale_timesheet
@@ -920,7 +920,7 @@ msgstr ""
#. module: sale_timesheet
#: model:ir.model.fields.selection,name:sale_timesheet.selection__product_template__service_policy__ordered_timesheet
-msgid "Ordered quantities"
+msgid "Prepaid"
msgstr ""
#. module: sale_timesheet
diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py
index 977404fa88c..e946c30d18d 100644
--- a/addons/sale_timesheet/models/account.py
+++ b/addons/sale_timesheet/models/account.py
@@ -123,6 +123,8 @@ class AccountAnalyticLine(models.Model):
map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', task.project_id.id), ('employee_id', '=', employee.id)])
if map_entry:
return map_entry.sale_line_id
+ if task.sale_line_id or project.sale_line_id:
+ return task.sale_line_id or project.sale_line_id
return self.env['sale.order.line']
def _timesheet_get_portal_domain(self):
@@ -139,7 +141,7 @@ class AccountAnalyticLine(models.Model):
'|',
'&',
('timesheet_invoice_id', 'in', invoice_ids.ids),
- #TODO Question to Reviewer: non_billable was part of domain in _timesheet_get_portal_domain so I kept it here, does this make sense?
+ # TODO : Master: Check if non_billable should be removed ?
('timesheet_invoice_type', 'in', ['billable_time', 'non_billable']),
'&',
('timesheet_invoice_type', '=', 'billable_fixed'),
diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py
index f042b2d3426..04b02b34ddd 100644
--- a/addons/sale_timesheet/models/product.py
+++ b/addons/sale_timesheet/models/product.py
@@ -11,7 +11,7 @@ class ProductTemplate(models.Model):
_inherit = 'product.template'
service_policy = fields.Selection([
- ('ordered_timesheet', 'Ordered quantities'),
+ ('ordered_timesheet', 'Prepaid'),
('delivered_timesheet', 'Timesheets on tasks'),
('delivered_manual', 'Milestones (manually set quantities on order)')
], string="Service Invoicing Policy", compute='_compute_service_policy', inverse='_inverse_service_policy')
diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py
index 92bf39ea41c..e56ab1bef18 100644
--- a/addons/sale_timesheet/models/project.py
+++ b/addons/sale_timesheet/models/project.py
@@ -25,12 +25,12 @@ class Project(models.Model):
return self.env.ref('sale_timesheet.time_product', False)
bill_type = fields.Selection([
- ('customer_task', 'Invoice tasks separately to different customers'),
- ('customer_project', 'Invoice all tasks to a single customer')
- ], string="Customer Type", default="customer_task",
+ ('customer_task', 'Different customers'),
+ ('customer_project', 'A unique customer')
+ ], string="Invoice Tasks to", default="customer_task",
help='When billing tasks individually, a Sales Order will be created from each task. It is perfect if you would like to bill different services to different customers at different rates. \n When billing the whole project, a Sales Order will be created from the project instead. This option is better if you would like to bill all the tasks of a given project to a specific customer either at a fixed rate, or at an employee rate.')
pricing_type = fields.Selection([
- ('fixed_rate', 'Fixed rate'),
+ ('fixed_rate', 'Project rate'),
('employee_rate', 'Employee rate')
], string="Pricing", default="fixed_rate",
help='The fixed rate is perfect if you bill a service at a fixed rate per hour or day worked regardless of the employee who performed it. The employee rate is preferable if your employees deliver the same service at a different rate. For instance, junior and senior consultants would deliver the same service (= consultancy), but at a different rate because of their level of seniority.')
@@ -40,7 +40,7 @@ class Project(models.Model):
allow_billable = fields.Boolean("Billable", help="Invoice your time and material from tasks.")
display_create_order = fields.Boolean(compute='_compute_display_create_order')
timesheet_product_id = fields.Many2one(
- 'product.product', string='Timesheet Product',
+ 'product.product', string='Timesheet Product',
domain="""[
('type', '=', 'service'),
('invoice_policy', '=', 'delivery'),
@@ -83,7 +83,7 @@ class Project(models.Model):
def _compute_warning_employee_rate(self):
projects = self.filtered(lambda p: p.allow_billable and p.allow_timesheets and p.bill_type == 'customer_project' and p.pricing_type == 'employee_rate')
tasks = projects.task_ids.filtered(lambda t: not t.non_allow_billable)
- employees = self.env['account.analytic.line'].read_group([('task_id', 'in', tasks.ids), ('non_allow_billable', '=', False)], ['employee_id', 'project_id'], ['employee_id', 'project_id'], lazy=False)
+ employees = self.env['account.analytic.line'].read_group([('task_id', 'in', tasks.ids), ('non_allow_billable', '=', False)], ['employee_id', 'project_id'], ['employee_id', 'project_id'], ['employee_id', 'project_id'], lazy=False)
dict_project_employee = defaultdict(list)
for line in employees:
dict_project_employee[line['project_id'][0]] += [line['employee_id'][0]]
@@ -92,7 +92,6 @@ class Project(models.Model):
(self - projects).warning_employee_rate = False
-
@api.constrains('sale_line_id', 'pricing_type')
def _check_sale_line_type(self):
for project in self:
@@ -119,6 +118,19 @@ class Project(models.Model):
})
return res
+ def _get_not_billed_timesheets(self):
+ return self.mapped('timesheet_ids').filtered(
+ lambda t: not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel')
+
+ def _update_timesheets_sale_line_id(self):
+ for project in self.filtered(lambda p: p.allow_billable and p.allow_timesheets):
+ timesheet_ids = project._get_not_billed_timesheets()
+ if not timesheet_ids:
+ continue
+ for employee_id in project.sale_line_employee_ids.filtered(lambda l: l.project_id == project).employee_id:
+ sale_line_id = project.sale_line_employee_ids.filtered(lambda l: l.project_id == project and l.employee_id == employee_id).sale_line_id
+ timesheet_ids.filtered(lambda t: t.employee_id == employee_id).so_line = sale_line_id
+
def action_view_timesheet(self):
self.ensure_one()
if self.allow_timesheets:
@@ -217,6 +229,7 @@ class ProjectTask(models.Model):
'|', ('company_id', '=', False), ('company_id', '=', company_id)]""",
help='Select a Service product with which you would like to bill your time spent on this task.')
+ # TODO: [XBO] remove me in master
non_allow_billable = fields.Boolean("Non-Billable", help="Your timesheets linked to this task will not be billed.")
@api.depends(
@@ -232,29 +245,13 @@ class ProjectTask(models.Model):
@api.onchange('sale_line_id')
def _onchange_sale_line_id(self):
- if self._get_timesheet() and self.allow_timesheets:
- if self.sale_line_id:
- if self.sale_line_id.product_id.service_policy == 'delivered_timesheet' and self._origin.sale_line_id.product_id.service_policy == 'delivered_timesheet':
- message = _("All timesheet hours that are not yet invoiced will be assigned to the selected Sales Order Item on save. Discard to avoid the change.")
- else:
- message = _("All timesheet hours will be assigned to the selected Sales Order Item on save. Discard to avoid the change.")
- else:
- message = _("All timesheet hours that are not yet invoiced will be removed from the selected Sales Order Item on save. Discard to avoid the change.")
-
- return {'warning': {
- 'title': _("Warning"),
- 'message': message
- }}
+ # TODO: remove me in master
+ return
@api.onchange('project_id')
def _onchange_project_id(self):
- if self._origin.allow_timesheets and self._get_timesheet():
- message = _("All timesheet hours that are not yet invoiced will be assigned to the selected Project on save. Discard to avoid the change.")
-
- return {'warning': {
- 'title': _("Warning"),
- 'message': message
- }}
+ # TODO: remove me in master
+ return
@api.depends('analytic_account_id.active')
def _compute_analytic_account_active(self):
diff --git a/addons/sale_timesheet/models/project_sale_line_employee_map.py b/addons/sale_timesheet/models/project_sale_line_employee_map.py
index 68d940bc62e..76a702d8b9f 100644
--- a/addons/sale_timesheet/models/project_sale_line_employee_map.py
+++ b/addons/sale_timesheet/models/project_sale_line_employee_map.py
@@ -19,7 +19,7 @@ class ProjectProductEmployeeMap(models.Model):
('invoice_policy', '=', 'delivery'),
('service_type', '=', 'timesheet'),
'|', ('company_id', '=', False), ('company_id', '=', company_id)]""")
- price_unit = fields.Float("Unit Price", compute='_compute_price_unit', store=True, readonly=False)
+ price_unit = fields.Float("Unit Price", compute='_compute_price_unit', store=True, readonly=True)
currency_id = fields.Many2one('res.currency', string="Currency", compute='_compute_price_unit', store=True, readonly=False)
_sql_constraints = [
@@ -49,10 +49,13 @@ class ProjectProductEmployeeMap(models.Model):
@api.model
def create(self, values):
res = super(ProjectProductEmployeeMap, self).create(values)
- for project_id in res.filtered(lambda l: l.sale_line_id).project_id:
- if project_id.allow_timesheets and project_id.allow_billable and project_id.task_ids._get_timesheet():
- timesheet_ids = project_id.task_ids._get_timesheet()
- for employee_id in res.filtered(lambda l: l.project_id == project_id).employee_id:
- sale_line_id = res.filtered(lambda l: l.project_id == project_id and l.employee_id == employee_id).sale_line_id
- timesheet_ids.filtered(lambda t: t.employee_id == employee_id).so_line = sale_line_id
+ res._update_project_timesheet()
return res
+
+ def write(self, values):
+ res = super(ProjectProductEmployeeMap, self).write(values)
+ self._update_project_timesheet()
+ return res
+
+ def _update_project_timesheet(self):
+ self.filtered(lambda l: l.sale_line_id).project_id._update_timesheets_sale_line_id()
diff --git a/addons/sale_timesheet/static/src/js/sale_project_kanban_controller.js b/addons/sale_timesheet/static/src/js/sale_project_kanban_controller.js
index 17181808487..2c252c6f95e 100644
--- a/addons/sale_timesheet/static/src/js/sale_project_kanban_controller.js
+++ b/addons/sale_timesheet/static/src/js/sale_project_kanban_controller.js
@@ -7,19 +7,9 @@ var session = require('web.session');
var QWeb = core.qweb;
-var SaleProjectKanbanController = ProjectKanbanController.include({
- events: _.extend({}, ProjectKanbanController.prototype.events, {
- 'click .o_create_sale_order': '_onCreateSaleOrder'
- }),
+// YTI TODO : Master remove file
- /**
- * @override
- */
- willStart: async function () {
- const _super = this._super.bind(this);
- await this._showCreateSOButton();
- return _super(...arguments);
- },
+var SaleProjectKanbanController = ProjectKanbanController.include({
//--------------------------------------------------------------------------
// Private
@@ -53,39 +43,6 @@ var SaleProjectKanbanController = ProjectKanbanController.include({
}
},
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- * @param {jQuery} [$node]
- */
- renderButtons: function ($node) {
- this._super.apply(this, arguments);
- this.$saleButtons = $(QWeb.render('SaleProjectKanbanView.buttons'));
- var state = this.model.get(this.handle, {raw: true});
- var createHidden = this.is_action_enabled('group_create') && state.isGroupedByM2ONoColumn || !this.showCreateSaleOrder;
- this.$saleButtons.toggleClass('o_hidden', createHidden);
- this.$saleButtons.appendTo(this.$buttons);
-
- },
-
- /**
- * @override
- */
- updateButtons: async function () {
- if (!this.$buttons) {
- return;
- }
- this._super.apply(this, arguments);
-
- await this._showCreateSOButton();
- var state = this.model.get(this.handle, {raw: true});
- var createHidden = this.is_action_enabled('group_create') && state.isGroupedByM2ONoColumn || !this.showCreateSaleOrder;
- this.$buttons.find('.o_create_sale_order').toggleClass('o_hidden', createHidden);
- },
-
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml
index df07c452f88..b75a28ed748 100644
--- a/addons/sale_timesheet/views/project_task_views.xml
+++ b/addons/sale_timesheet/views/project_task_views.xml
@@ -20,25 +20,23 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
@@ -81,11 +79,11 @@
-
+
-
-
+
+
@@ -110,7 +108,7 @@
-
+
@@ -133,13 +131,13 @@
-
-
+
+
-
+
@@ -153,7 +151,6 @@
{'invisible': ['|', '|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), '&', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate'), ('partner_id', '=', False)]}
- Leave empty if non-billable
diff --git a/addons/sale_timesheet/wizard/project_create_sale_order.py b/addons/sale_timesheet/wizard/project_create_sale_order.py
index a9f20e45ecc..8c3271128c5 100644
--- a/addons/sale_timesheet/wizard/project_create_sale_order.py
+++ b/addons/sale_timesheet/wizard/project_create_sale_order.py
@@ -191,7 +191,9 @@ class ProjectCreateSalesOrder(models.TransientModel):
self._make_billable_at_employee_rate(sale_order)
def _make_billable_at_project_rate(self, sale_order):
+ self.ensure_one()
task_left = self.project_id.tasks.filtered(lambda task: not task.sale_line_id)
+ ticket_timesheet_ids = self.env.context.get('ticket_timesheet_ids', [])
for wizard_line in self.line_ids:
task_ids = self.project_id.tasks.filtered(lambda task: not task.sale_line_id and task.timesheet_product_id == wizard_line.product_id)
task_left -= task_ids
@@ -211,6 +213,17 @@ class ProjectCreateSalesOrder(models.TransientModel):
'product_uom_qty': 0.0,
})
+ if ticket_timesheet_ids and not self.project_id.sale_line_id and not task_ids:
+ # With pricing = "project rate" in project. When the user wants to create a sale order from a ticket in helpdesk
+ # The project cannot contain any tasks. Thus, we need to give the first sale_order_line created to link
+ # the timesheet to this first sale order line.
+ # link the project to the SO line
+ self.project_id.write({
+ 'sale_order_id': sale_order.id,
+ 'sale_line_id': sale_order_line.id,
+ 'partner_id': self.partner_id.id,
+ })
+
# link the tasks to the SO line
task_ids.write({
'sale_line_id': sale_order_line.id,
@@ -219,19 +232,29 @@ class ProjectCreateSalesOrder(models.TransientModel):
})
# assign SOL to timesheets
- self.env['account.analytic.line'].search([('task_id', 'in', task_ids.ids), ('so_line', '=', False)]).write({
+ search_domain = [('task_id', 'in', task_ids.ids), ('so_line', '=', False)]
+ if ticket_timesheet_ids:
+ search_domain = [('id', 'in', ticket_timesheet_ids), ('so_line', '=', False)]
+
+ self.env['account.analytic.line'].search(search_domain).write({
'so_line': sale_order_line.id
})
sale_order_line.with_context({'no_update_planned_hours': True}).write({
'product_uom_qty': sale_order_line.qty_delivered
})
- # link the project to the SO line
- self.project_id.write({
- 'sale_order_id': sale_order.id,
- 'sale_line_id': sale_order_line.id, # we take the last sale_order_line created
- 'partner_id': self.partner_id.id,
- })
+ if ticket_timesheet_ids and self.project_id.sale_line_id and not self.project_id.tasks and len(self.line_ids) > 1:
+ # Then, we need to give to the project the last sale order line created
+ self.project_id.write({
+ 'sale_line_id': sale_order_line.id
+ })
+ else: # Otherwise, we are in the normal behaviour
+ # link the project to the SO line
+ self.project_id.write({
+ 'sale_order_id': sale_order.id,
+ 'sale_line_id': sale_order_line.id, # we take the last sale_order_line created
+ 'partner_id': self.partner_id.id,
+ })
if task_left:
task_left.sale_line_id = False
@@ -296,7 +319,13 @@ class ProjectCreateSalesOrder(models.TransientModel):
tasks = self.project_id.tasks.filtered(lambda t: not t.non_allow_billable)
# assign SOL to timesheets
for map_entry in map_entries:
- self.env['account.analytic.line'].search([('task_id', 'in', tasks.ids), ('employee_id', '=', map_entry.employee_id.id), ('so_line', '=', False)]).write({
+ search_domain = [('employee_id', '=', map_entry.employee_id.id), ('so_line', '=', False)]
+ ticket_timesheet_ids = self.env.context.get('ticket_timesheet_ids', [])
+ if ticket_timesheet_ids:
+ search_domain.append(('id', 'in', ticket_timesheet_ids))
+ else:
+ search_domain.append(('task_id', 'in', tasks.ids))
+ self.env['account.analytic.line'].search(search_domain).write({
'so_line': map_entry.sale_line_id.id
})
map_entry.sale_line_id.with_context({'no_update_planned_hours': True}).write({
diff --git a/addons/sale_timesheet/wizard/project_create_sale_order_views.xml b/addons/sale_timesheet/wizard/project_create_sale_order_views.xml
index 73ebfcd8974..4ca4db51664 100644
--- a/addons/sale_timesheet/wizard/project_create_sale_order_views.xml
+++ b/addons/sale_timesheet/wizard/project_create_sale_order_views.xml
@@ -6,9 +6,7 @@
project.create.sale.order