diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py
index ba22b0818f2..c3c44ab8e48 100644
--- a/addons/sale_timesheet/__manifest__.py
+++ b/addons/sale_timesheet/__manifest__.py
@@ -28,9 +28,14 @@ have real delivered quantities in sales orders.
'report/project_profitability_report_analysis_views.xml',
'data/sale_timesheet_filters.xml',
'wizard/project_task_assign_so_line_views.xml',
+ 'wizard/project_create_sale_order_views.xml',
+ 'wizard/project_create_invoice_views.xml',
],
'demo': [
'data/sale_service_demo.xml',
],
'auto_install': True,
+ 'qweb': [
+ 'static/src/xml/timesheet_plan.xml',
+ ],
}
diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py
index 6ed6d0a8ae2..8d983164445 100644
--- a/addons/sale_timesheet/controllers/main.py
+++ b/addons/sale_timesheet/controllers/main.py
@@ -3,6 +3,7 @@
from ast import literal_eval
import babel
from dateutil.relativedelta import relativedelta
+import json
from odoo import http, fields, _
from odoo.http import request
@@ -24,6 +25,7 @@ class SaleTimesheetController(http.Controller):
return {
'html_content': view.render(values),
'project_ids': projects.ids,
+ 'actions': self._plan_prepare_actions(projects),
}
def _plan_prepare_values(self, projects):
@@ -300,13 +302,43 @@ class SaleTimesheetController(http.Controller):
def _table_get_empty_so_lines(self, projects):
""" get the Sale Order Lines having no timesheet but having generated a task or a project """
- so_lines = projects.mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service)
+ so_lines = projects.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service)
return set(so_lines.ids), set(so_lines.mapped('order_id').ids)
# --------------------------------------------------
# Actions: Stat buttons, ...
# --------------------------------------------------
+ def _plan_prepare_actions(self, projects):
+ actions = []
+ if len(projects) == 1:
+ if request.env.user.has_group('sales_team.group_sale_salesman'):
+ if not projects.sale_line_id and not projects.tasks.mapped('sale_line_id'):
+ actions.append({
+ 'label': _("Create a Sales Order"),
+ 'type': 'action',
+ 'action_id': 'sale_timesheet.project_project_action_multi_create_sale_order',
+ 'context': json.dumps({'active_id': projects.id, 'active_model': 'project.project'}),
+ })
+ if request.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
+ sale_orders = projects.tasks.mapped('sale_line_id.order_id').filtered(lambda so: so.invoice_status == 'to invoice')
+ if sale_orders:
+ if len(sale_orders) == 1:
+ actions.append({
+ 'label': _("Create Invoice"),
+ 'type': 'action',
+ 'action_id': 'sale.action_view_sale_advance_payment_inv',
+ 'context': json.dumps({'active_ids': sale_orders.ids, 'active_model': 'project.project'}),
+ })
+ else:
+ actions.append({
+ 'label': _("Create Invoice"),
+ 'type': 'action',
+ 'action_id': 'sale_timesheet.project_project_action_multi_create_invoice',
+ 'context': json.dumps({'active_id': projects.id, 'active_model': 'project.project'}),
+ })
+ return actions
+
def _plan_get_stat_button(self, projects):
stat_buttons = []
stat_buttons.append({
@@ -322,6 +354,25 @@ class SaleTimesheetController(http.Controller):
'domain': [('project_id', 'in', projects.ids)],
'icon': 'fa fa-tasks',
})
+ if request.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
+ sale_orders = projects.mapped('sale_line_id.order_id') | projects.mapped('tasks.sale_order_id')
+ if sale_orders:
+ stat_buttons.append({
+ 'name': _('Sales Orders'),
+ 'count': len(sale_orders),
+ 'res_model': 'sale.order',
+ 'domain': [('id', 'in', sale_orders.ids)],
+ 'icon': 'fa fa-dollar',
+ })
+ invoices = sale_orders.mapped('invoice_ids').filtered(lambda inv: inv.type == 'out_invoice')
+ if invoices:
+ stat_buttons.append({
+ 'name': _('Invoices'),
+ 'count': len(invoices),
+ 'res_model': 'account.invoice',
+ 'domain': [('id', 'in', invoices.ids), ('type', '=', 'out_invoice')],
+ 'icon': 'fa fa-pencil-square-o',
+ })
return stat_buttons
@http.route('/timesheet/plan/action', type='json', auth="user")
@@ -356,4 +407,10 @@ class SaleTimesheetController(http.Controller):
tasks = request.env['project.task'].sudo().search(literal_eval(domain))
if len(tasks.mapped('project_id')) == 1:
action['context']['default_project_id'] = tasks.mapped('project_id')[0].id
+ elif res_model == 'sale.order':
+ action = request.env.ref('sale.action_orders').read()[0]
+ action['domain'] = domain
+ elif res_model == 'account.invoice':
+ action = request.env.ref('account.action_invoice_tree1').read()[0]
+ action['domain'] = domain
return action
diff --git a/addons/sale_timesheet/static/src/js/timesheet_plan.js b/addons/sale_timesheet/static/src/js/timesheet_plan.js
index d657b421479..6596bd4ea88 100644
--- a/addons/sale_timesheet/static/src/js/timesheet_plan.js
+++ b/addons/sale_timesheet/static/src/js/timesheet_plan.js
@@ -9,6 +9,7 @@ var pyeval = require('web.pyeval');
var SearchView = require('web.SearchView');
var _t = core._t;
+var QWeb = core.qweb;
var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
events: {
@@ -76,7 +77,6 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
return $.when(def1, def2).then(function(){
self.searchview.do_search();
- self._updateControlPanel();
});
},
@@ -89,7 +89,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
*/
do_show: function () {
this._super.apply(this, arguments);
- this._updateControlPanel();
+ this.searchview.do_search();
this.action_manager.do_push_state({
action: this.action.id,
active_id: this.action.context.active_id,
@@ -109,6 +109,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
_refreshPlan: function (dom) {
this.$el.html(dom);
},
+
/**
* Call controller to get the html content
*
@@ -123,13 +124,22 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
params: {domain: domain},
}).then(function(result){
self._refreshPlan(result.html_content);
+ self._updateControlPanel(result.actions);
self.project_ids = result.project_ids;
});
},
/**
* @private
*/
- _updateControlPanel: function () {
+ _updateControlPanel: function (buttons) {
+ // set actions button
+ if (this.$buttons) {
+ this.$buttons.off().destroy();
+ }
+ var buttons = buttons || [];
+ this.$buttons = $(QWeb.render("project.plan.ControlButtons", {'buttons': buttons}));
+ this.$buttons.on('click', '.o_timesheet_plan_btn_action', this._onClickControlButton.bind(this));
+ // refresh control panel
this.update_control_panel({
cp_content: {
$buttons: this.$buttons,
@@ -194,6 +204,21 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
additional_context: context
});
},
+ /**
+ * Call the action of the action button from control panel, based on the data attribute on the button DOM
+ *
+ * @param {MouseEvent} event
+ * @private
+ */
+ _onClickControlButton: function (ev) {
+ var $target = $(ev.target);
+ var action_id = $target.data('action-id');
+ var context = $target.data('context');
+
+ return this.do_action(action_id, {
+ 'additional_context': context,
+ });
+ },
/**
* @private
* @param {MouseEvent} event
diff --git a/addons/sale_timesheet/static/src/xml/timesheet_plan.xml b/addons/sale_timesheet/static/src/xml/timesheet_plan.xml
new file mode 100644
index 00000000000..b11d055c9e0
--- /dev/null
+++ b/addons/sale_timesheet/static/src/xml/timesheet_plan.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/sale_timesheet/wizard/__init__.py b/addons/sale_timesheet/wizard/__init__.py
index dc6b755ec0a..0a4df69a6c2 100644
--- a/addons/sale_timesheet/wizard/__init__.py
+++ b/addons/sale_timesheet/wizard/__init__.py
@@ -2,3 +2,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import project_task_assign_so_line
+from . import project_create_sale_order
+from . import project_create_invoice
diff --git a/addons/sale_timesheet/wizard/project_create_invoice.py b/addons/sale_timesheet/wizard/project_create_invoice.py
new file mode 100644
index 00000000000..db56e5f8fcb
--- /dev/null
+++ b/addons/sale_timesheet/wizard/project_create_invoice.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+
+class ProjectCreateInvoice(models.TransientModel):
+ _name = 'project.create.invoice'
+ _description = "Create a Invoice from project"
+
+ @api.model
+ def default_get(self, fields):
+ result = super(ProjectCreateInvoice, self).default_get(fields)
+
+ active_model = self._context.get('active_model')
+ if active_model != 'project.project':
+ raise UserError(_('You can only apply this action from a project.'))
+
+ active_id = self._context.get('active_id')
+ if 'project_id' in fields and active_id:
+ result['project_id'] = active_id
+ return result
+
+ project_id = fields.Many2one('project.project', "Project", help="Project to make billable", required=True)
+ sale_order_id = fields.Many2one('sale.order', string="Choose the Sales Order to invoice", required=True)
+ amount_to_invoice = fields.Monetary("Amount to invoice", compute='_compute_amount_to_invoice', currency_field='currency_id', help="Total amount to invoice on the sales order, including all items (services, stockables, expenses, ...)")
+ currency_id = fields.Many2one(related='sale_order_id.currency_id', readonly=True)
+
+ @api.onchange('project_id')
+ def _onchange_project_id(self):
+ sale_orders = self.project_id.tasks.mapped('sale_line_id.order_id').filtered(lambda so: so.invoice_status == 'to invoice')
+ return {
+ 'domain': {'sale_order_id': [('id', 'in', sale_orders.ids)]},
+ }
+
+ @api.multi
+ @api.depends('sale_order_id')
+ def _compute_amount_to_invoice(self):
+ for wizard in self:
+ amount_untaxed = 0.0
+ amount_tax = 0.0
+ for line in wizard.sale_order_id.order_line.filtered(lambda sol: sol.invoice_status == 'to invoice'):
+ amount_untaxed += line.price_reduce * line.qty_to_invoice
+ amount_tax += line.price_tax
+ wizard.amount_to_invoice = amount_untaxed + amount_tax
+
+ @api.multi
+ def action_create_invoice(self):
+ if not self.sale_order_id and self.sale_order_id.invoice_status != 'to invoice':
+ raise UserError(_("The selected Sales Order should contain something to invoice."))
+ action = self.env.ref('sale.action_view_sale_advance_payment_inv').read()[0]
+ action['context'] = {
+ 'active_ids': self.sale_order_id.ids
+ }
+ return action
diff --git a/addons/sale_timesheet/wizard/project_create_invoice_views.xml b/addons/sale_timesheet/wizard/project_create_invoice_views.xml
new file mode 100644
index 00000000000..3eb5a23a671
--- /dev/null
+++ b/addons/sale_timesheet/wizard/project_create_invoice_views.xml
@@ -0,0 +1,34 @@
+
+
+
+
+ project.create.invoice.view.form
+ project.create.invoice
+
+
+
+
+
+
+ Create Invoice
+ ir.actions.act_window
+ project.create.invoice
+ form
+ form
+
+ new
+
+
+
+
+
diff --git a/addons/sale_timesheet/wizard/project_create_sale_order.py b/addons/sale_timesheet/wizard/project_create_sale_order.py
new file mode 100644
index 00000000000..0c964bf4560
--- /dev/null
+++ b/addons/sale_timesheet/wizard/project_create_sale_order.py
@@ -0,0 +1,91 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+
+class ProjectCreateSalesOrder(models.TransientModel):
+ _name = 'project.create.sale.order'
+ _description = "Create a SO from project"
+
+ @api.model
+ def default_get(self, fields):
+ result = super(ProjectCreateSalesOrder, self).default_get(fields)
+
+ active_model = self._context.get('active_model')
+ if active_model != 'project.project':
+ raise UserError(_('You can only apply this action from a project.'))
+
+ active_id = self._context.get('active_id')
+ if 'project_id' in fields and active_id:
+ result['project_id'] = active_id
+ result['partner_id'] = self.env['project.project'].browse(active_id).partner_id.id
+ return result
+
+ project_id = fields.Many2one('project.project', "Project", domain=[('sale_line_id', '=', False)], help="Project to make billable", required=True)
+ product_id = fields.Many2one('product.product', domain=[('type', '=', 'service'), ('invoice_policy', '=', 'delivery')], required=True)
+ partner_id = fields.Many2one('res.partner', string="Customer", domain=[('customer', '=', True)], required=True)
+ price_unit = fields.Float("Price", help="Price unit of the selected product for the generated Sales Order.")
+ currency_id = fields.Many2one('res.currency', string="Currency", related='product_id.currency_id')
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ if self.product_id:
+ self.price_unit = self.product_id.lst_price
+
+ @api.multi
+ def action_create_sale_order(self):
+ # if project linked to SO line or at least on tasks with SO line, then we consider project as billable.
+ if self.project_id.sale_line_id or self.project_id.tasks.mapped('sale_line_id'):
+ raise UserError(_("The project is already linked to a sales order item."))
+
+ # create SO
+ sale_order = self.env['sale.order'].create({
+ 'partner_id': self.partner_id.id,
+ 'analytic_account_id': self.project_id.analytic_account_id.id,
+ 'client_order_ref': self.project_id.name,
+ })
+ sale_order.onchange_partner_id()
+ sale_order.onchange_partner_shipping_id()
+
+ planned_hours = sum(self.project_id.tasks.mapped('planned_hours'))
+
+ # trying to simulate the SO line created a task, according to the product configuration
+ # To avoid, generating a task when confirming the SO
+ task_id = False
+ if self.product_id.service_tracking in ['task_new_project', 'task_global_project']:
+ task_id = self.env['project.task'].search([('project_id', '=', self.project_id.id)], order='create_date DESC', limit=1).id
+
+ # create SO line
+ sale_order_line = self.env['sale.order.line'].create({
+ 'order_id': sale_order.id,
+ 'product_id': self.product_id.id,
+ 'price_unit': self.price_unit,
+ 'project_id': self.project_id.id, # prevent to re-create a project on confirmation
+ 'task_id': task_id,
+ 'product_uom_qty': sale_order.company_id.project_time_mode_id._compute_quantity(planned_hours, self.product_id.uom_id, raise_if_failure=False),
+ })
+
+ # confirm SO
+ sale_order.action_confirm()
+
+ # link project and tasks to SO line
+ self.project_id.write({
+ 'sale_line_id': sale_order_line.id,
+ })
+ self.project_id.tasks.filtered(lambda t: not t.sale_line_id).write({
+ 'sale_line_id': sale_order_line.id,
+ 'partner_id': sale_order.partner_id.id,
+ 'email_from': sale_order.partner_id.email,
+ })
+
+ view_form_id = self.env.ref('sale.view_order_form').id
+ action = self.env.ref('sale.action_orders').read()[0]
+ action.update({
+ 'views': [(view_form_id, 'form')],
+ 'view_mode': 'form',
+ 'name': sale_order.name,
+ 'res_id': sale_order.id,
+ })
+ return action
diff --git a/addons/sale_timesheet/wizard/project_create_sale_order_views.xml b/addons/sale_timesheet/wizard/project_create_sale_order_views.xml
new file mode 100644
index 00000000000..306f9e335f2
--- /dev/null
+++ b/addons/sale_timesheet/wizard/project_create_sale_order_views.xml
@@ -0,0 +1,39 @@
+
+
+
+
+ project.create.sale.order.wizard.form
+ project.create.sale.order
+
+
+
+
+
+
+ Create a Sales Order
+ ir.actions.act_window
+ project.create.sale.order
+ form
+ form
+
+ new
+
+
+
+
+
+