[MERGE] sale_timesheet: invoice from project overview
These commit allows users to create sales order, and invoice from the project overview. To do, the overview is now refreshed when naviguating throught the breadcrumb, and supports action buttons on its control panel. Also, some stat buttons are added on the overview (sales order and invoices). The process of invoice creation is the same wizard than the one from the sale order. Thoses actions are only available when there is no ambiguity to determine the SO from the project, or for non billable project (to create the SO). Task #49213 Closes #24280
This commit is contained in:
@@ -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',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="project.plan.ControlButtons">
|
||||
<div>
|
||||
<t t-foreach="buttons" t-as="button">
|
||||
<button type="button" class="btn btn-primary btn-sm o_timesheet_plan_btn_action" t-att-title="button['label']" t-att-data-context="button['context']" t-att-data-action-id="button['action_id']">
|
||||
<t t-esc="button['label']"/>
|
||||
</button>
|
||||
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="project_create_invoice_view_form" model="ir.ui.view">
|
||||
<field name="name">project.create.invoice.view.form</field>
|
||||
<field name="model">project.create.invoice</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Create Sales Order from Project">
|
||||
<group>
|
||||
<field name="project_id" readonly="1"/>
|
||||
<field name="sale_order_id" options="{'no_create_edit': True}" context="{'sale_show_partner_name': True}"/>
|
||||
<field name="amount_to_invoice"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Create Invoice" type="object" name="action_create_invoice" class="oe_highlight"/>
|
||||
<button string="Cancel" special="cancel" type="object" class="btn btn-default oe_inline"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_project_action_multi_create_invoice" model="ir.actions.act_window">
|
||||
<field name="name">Create Invoice</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">project.create.invoice</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="project_create_invoice_view_form"/>
|
||||
<field name="target">new</field>
|
||||
<field name="multi" eval="False"/>
|
||||
<field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman_all_leads'))]"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -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
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="project_create_sale_order_view_form" model="ir.ui.view">
|
||||
<field name="name">project.create.sale.order.wizard.form</field>
|
||||
<field name="model">project.create.sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Create Sales Order from Project">
|
||||
<group>
|
||||
<group>
|
||||
<field name="project_id" readonly="1"/>
|
||||
<field name="product_id" options="{'no_create_edit': True, 'no_create': True}"/>
|
||||
<field name="price_unit" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Create Sales Order" type="object" name="action_create_sale_order" class="oe_highlight"/>
|
||||
<button string="Cancel" special="cancel" type="object" class="btn btn-default oe_inline"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_project_action_multi_create_sale_order" model="ir.actions.act_window">
|
||||
<field name="name">Create a Sales Order</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">project.create.sale.order</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="project_create_sale_order_view_form"/>
|
||||
<field name="target">new</field>
|
||||
<field name="multi" eval="False"/>
|
||||
<field name="binding_model_id" ref="project.model_project_project"/>
|
||||
<field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user