From d8f2b1b87e8e3b8fc6ac63ec2d43e7bef0049ada Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Tue, 8 May 2018 13:07:58 +0200 Subject: [PATCH 1/5] [IMP] sale_timesheet: wizard to create SO from project This commit provides a wizard to create a SO from a non billable project. It sets sale_line_id on the project, and the project on the SO line to simulate the project was created by the SOL. The created SO contains one line with a selected service product, with ordered qty set as the total of planned hours of all task in projects. The wizard also links all non billable active tasks of the project to the SO line (and its timesheet entries). --- addons/sale_timesheet/__manifest__.py | 1 + addons/sale_timesheet/wizard/__init__.py | 1 + .../wizard/project_create_sale_order.py | 91 +++++++++++++++++++ .../project_create_sale_order_views.xml | 39 ++++++++ 4 files changed, 132 insertions(+) create mode 100644 addons/sale_timesheet/wizard/project_create_sale_order.py create mode 100644 addons/sale_timesheet/wizard/project_create_sale_order_views.xml diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index ba22b0818f2..fae72deab02 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -28,6 +28,7 @@ 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', ], 'demo': [ 'data/sale_service_demo.xml', diff --git a/addons/sale_timesheet/wizard/__init__.py b/addons/sale_timesheet/wizard/__init__.py index dc6b755ec0a..08132642389 100644 --- a/addons/sale_timesheet/wizard/__init__.py +++ b/addons/sale_timesheet/wizard/__init__.py @@ -2,3 +2,4 @@ # 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 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 + + + + + +
From 9f3040ee1f7af3500e2eeb966ef4815cd0e762a5 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Thu, 19 Apr 2018 11:45:11 +0200 Subject: [PATCH 2/5] [IMP] sale_timesheet: control button on plan project This commit makes the project overview handle action button in its control panel. When calling server to get overview HTML code, we also get a list of actions to display in the control panel. Those are rendered client side. Since when an action is done, we usually don't want the button to be display anymore, reloading the overview is required. The overview is now refreshed when navigating with the breadcrumb, so each time the overview is displayed, an RPC call to the server is done (`do_show` method override). Task #49213 --- addons/sale_timesheet/__manifest__.py | 3 ++ addons/sale_timesheet/controllers/main.py | 13 ++++++++ .../static/src/js/timesheet_plan.js | 31 +++++++++++++++++-- .../static/src/xml/timesheet_plan.xml | 15 +++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 addons/sale_timesheet/static/src/xml/timesheet_plan.xml diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index fae72deab02..420fb7fbf66 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -34,4 +34,7 @@ have real delivered quantities in sales orders. '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..912ca6a5313 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): @@ -307,6 +309,17 @@ class SaleTimesheetController(http.Controller): # Actions: Stat buttons, ... # -------------------------------------------------- + def _plan_prepare_actions(self, projects): + actions = [] + if len(projects) == 1 and not projects.sale_line_id and not projects.tasks.mapped('sale_line_id') and request.env.user.has_group('sales_team.group_sale_salesman'): + 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}), + }) + return actions + def _plan_get_stat_button(self, projects): stat_buttons = [] stat_buttons.append({ 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 @@ + + + + +
+ + + + +
+
+ +
From 55ca3dd2b11d1da3774790f37cf5350164ce2190 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Thu, 1 Mar 2018 13:59:46 +0100 Subject: [PATCH 3/5] [IMP] sale_timesheet: invoice from plan project We want to invoice from the project overview. To do so, a button can help people: it is only visible when an order linked to task in that project has something to invoice Any sale order line, even the ones with no service product will be invoiced. However, the user will have to choose which SO (involved in the project) to invoice. Then the "invoice order" wizard takes over. --- addons/sale_timesheet/__manifest__.py | 1 + addons/sale_timesheet/controllers/main.py | 33 ++++++++--- addons/sale_timesheet/wizard/__init__.py | 1 + .../wizard/project_create_invoice.py | 56 +++++++++++++++++++ .../wizard/project_create_invoice_views.xml | 34 +++++++++++ 5 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 addons/sale_timesheet/wizard/project_create_invoice.py create mode 100644 addons/sale_timesheet/wizard/project_create_invoice_views.xml diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index 420fb7fbf66..c3c44ab8e48 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_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', diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py index 912ca6a5313..3e93500914b 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -311,13 +311,32 @@ class SaleTimesheetController(http.Controller): def _plan_prepare_actions(self, projects): actions = [] - if len(projects) == 1 and not projects.sale_line_id and not projects.tasks.mapped('sale_line_id') and request.env.user.has_group('sales_team.group_sale_salesman'): - 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}), - }) + 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): diff --git a/addons/sale_timesheet/wizard/__init__.py b/addons/sale_timesheet/wizard/__init__.py index 08132642389..0a4df69a6c2 100644 --- a/addons/sale_timesheet/wizard/__init__.py +++ b/addons/sale_timesheet/wizard/__init__.py @@ -3,3 +3,4 @@ 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 + + + + +
From 6b1aec5bb736a10ef3a290ec75467af6498bc346 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Fri, 20 Apr 2018 14:38:43 +0200 Subject: [PATCH 4/5] [IMP] sale_timesheet: SO/INV stat button on project overview Now that the overview is refreshed when it is displayed and that we can create SO/INV from it, it is usefull for the users to have a stat button displaying those new documents directly from the overview. This commit use the already existing mecanism to display Sales Order and Invoices stata buttons. Thoses 2 stat buttons are only visible for salesperson seeing all document, to avoid access errors. Task #49213 --- addons/sale_timesheet/controllers/main.py | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py index 3e93500914b..c1b0fc9e79c 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -354,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") @@ -388,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 From 72e3ebc4f9f0019125cebf8070dfc42b4942614b Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Mon, 30 Apr 2018 14:22:32 +0200 Subject: [PATCH 5/5] [IMP] sale_timesheet: overview access On sales order form view, we display a "project overview" stat button. It redirects to the overview of the only billable project of the SO. To avoid access error, and because this dahsboard is usefull only for project manager, only those ones will have access to this overview. So, stat button (Sales order) and label on project kanban are aonly visible for manager. --- addons/sale_timesheet/controllers/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py index c1b0fc9e79c..8d983164445 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -302,7 +302,7 @@ 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) # --------------------------------------------------