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 + + + + + +