[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:
jem-odoo
2018-06-01 15:47:16 +02:00
9 changed files with 328 additions and 4 deletions
+5
View File
@@ -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',
],
}
+58 -1
View File
@@ -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
View File
@@ -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>