- Validated leaves create timesheets automatically.
+
+
Leaves
+
+
+
+
-
-
+
+
+
+ Validated leaves create timesheets automatically.
+
+
diff --git a/addons/project/static/src/js/tour.js b/addons/project/static/src/js/tour.js
index 3e844c309b0..24a3e7f0c19 100644
--- a/addons/project/static/src/js/tour.js
+++ b/addons/project/static/src/js/tour.js
@@ -31,7 +31,7 @@ tour.register('project_tour', {
content: _t('Click on the card to
go to your project and start organizing tasks.'),
position: 'right',
run: function (actions) {
- actions.auto(this.$anchor.find(".o_project_kanban_box:first > a"));
+ actions.auto(this.$anchor.find(".o_project_kanban_box:first"));
},
}, {
trigger: ".o_kanban_project_tasks .o_column_quick_create",
diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml
index 4ecc31ae062..b714cd28c9d 100644
--- a/addons/project/views/project_views.xml
+++ b/addons/project/views/project_views.xml
@@ -327,15 +327,15 @@
diff --git a/addons/project_timesheet_holidays/__init__.py b/addons/project_timesheet_holidays/__init__.py
index dc5e6b693d1..cb09489a1e7 100644
--- a/addons/project_timesheet_holidays/__init__.py
+++ b/addons/project_timesheet_holidays/__init__.py
@@ -2,3 +2,19 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
+
+
+def post_init(cr, registry):
+ """ Set the timesheet project and task on existing leave type. Do it in post_init to
+ be sure the internal project/task of res.company are set. (Since timesheet_generate field
+ is true by default, those 2 fields are required on the leave type).
+ """
+ from odoo import api, SUPERUSER_ID
+
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ for leave_type in env['hr.holidays.status'].search([('timesheet_generate', '=', True), ('timesheet_project_id', '=', False)]):
+ company = leave_type.company_id or env.user.company_id
+ leave_type.write({
+ 'timesheet_project_id': company.leave_timesheet_project_id.id,
+ 'timesheet_task_id': company.leave_timesheet_task_id.id,
+ })
diff --git a/addons/project_timesheet_holidays/__manifest__.py b/addons/project_timesheet_holidays/__manifest__.py
index 39f549e5b0c..d98cef28b51 100644
--- a/addons/project_timesheet_holidays/__manifest__.py
+++ b/addons/project_timesheet_holidays/__manifest__.py
@@ -17,8 +17,11 @@ on leaves. Project and task can be configured company-wide.
'data': [
'views/res_config_settings_views.xml',
'views/hr_holidays_views.xml',
+ 'security/ir.model.access.csv',
+
],
'demo': [],
'installable': True,
'auto_install': True,
+ 'post_init_hook': 'post_init',
}
diff --git a/addons/project_timesheet_holidays/models/hr_holidays.py b/addons/project_timesheet_holidays/models/hr_holidays.py
index a2d2457f573..d71ece9631b 100644
--- a/addons/project_timesheet_holidays/models/hr_holidays.py
+++ b/addons/project_timesheet_holidays/models/hr_holidays.py
@@ -1,13 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import api, fields, models
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
class HolidaysType(models.Model):
_inherit = "hr.holidays.status"
- timesheet_generate = fields.Boolean('Generate Timesheet', help="If checked, when validating a leave, timesheet will be generated in the Vacation Project of the company.")
+ timesheet_generate = fields.Boolean('Generate Timesheet', default=True, help="If checked, when validating a leave, timesheet will be generated in the Vacation Project of the company.")
timesheet_project_id = fields.Many2one('project.project', string="Internal Project", help="The project will contain the timesheet generated when a leave is validated.")
timesheet_task_id = fields.Many2one('project.task', string="Internal Task for timesheet", domain="[('project_id', '=', timesheet_project_id)]")
@@ -21,6 +22,13 @@ class HolidaysType(models.Model):
self.timesheet_project_id = False
self.timesheet_task_id = False
+ @api.constrains('timesheet_generate')
+ def _check_timesheet_generate(self):
+ for holiday_status in self:
+ if holiday_status.timesheet_generate:
+ if not holiday_status.timesheet_project_id or not holiday_status.timesheet_task_id:
+ raise ValidationError(_('For the leaves to generate timesheet, the internal project and task are requried.'))
+
class Holidays(models.Model):
_inherit = "hr.holidays"
diff --git a/addons/project_timesheet_holidays/security/ir.model.access.csv b/addons/project_timesheet_holidays/security/ir.model.access.csv
new file mode 100644
index 00000000000..03c142ccb21
--- /dev/null
+++ b/addons/project_timesheet_holidays/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_account_analytic_account_leaves_manager,account.analytic.account,analytic.model_account_analytic_account,hr_holidays.group_hr_holidays_manager,1,0,0,0
diff --git a/addons/sale/models/__init__.py b/addons/sale/models/__init__.py
index e5c5e1ecb51..5f6e7ba2857 100644
--- a/addons/sale/models/__init__.py
+++ b/addons/sale/models/__init__.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import analytic
from . import account_invoice
from . import product_pricelist
from . import product_product
@@ -8,7 +9,6 @@ from . import product_template
from . import res_company
from . import res_partner
from . import sale
-from . import sale_analytic
from . import res_config_settings
from . import sale_layout
from . import sales_team
diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py
new file mode 100644
index 00000000000..cde205d5a98
--- /dev/null
+++ b/addons/sale/models/analytic.py
@@ -0,0 +1,132 @@
+# -*- 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 AccountAnalyticLine(models.Model):
+ _inherit = "account.analytic.line"
+
+ so_line = fields.Many2one('sale.order.line', string='Sales Order Line')
+
+ @api.model
+ def create(self, values):
+ result = super(AccountAnalyticLine, self).create(values)
+ result._sale_postprocess(values)
+ return result
+
+ @api.multi
+ def write(self, values):
+ # get current so lines for which update qty wil be required
+ sale_order_lines = self.env['sale.order.line']
+ if 'so_line' in values:
+ sale_order_lines = self.sudo().mapped('so_line')
+ result = super(AccountAnalyticLine, self).write(values)
+ self._sale_postprocess(values, additional_so_lines=sale_order_lines)
+ return result
+
+ @api.multi
+ def unlink(self):
+ sale_order_lines = self.sudo().mapped('so_line')
+ res = super(AccountAnalyticLine, self).unlink()
+ sale_order_lines._analytic_compute_delivered_quantity()
+ return res
+
+ @api.model
+ def _sale_get_fields_delivered_qty(self):
+ """ Returns a list with the field impacting the delivered quantity on SO line. """
+ return ['so_line', 'unit_amount', 'product_uom_id']
+
+ @api.multi
+ def _sale_postprocess(self, values, additional_so_lines=None):
+ if 'so_line' not in values: # allow to force a False value for so_line
+ self.with_context(sale_analytic_norecompute=True)._sale_determine_order_line()
+
+ if any(field_name in values for field_name in self._sale_get_fields_delivered_qty()):
+ if not self._context.get('sale_analytic_norecompute'):
+ so_lines = self.sudo().filtered(lambda aal: aal.so_line).mapped('so_line')
+ if additional_so_lines:
+ so_lines |= additional_so_lines
+ so_lines.sudo()._analytic_compute_delivered_quantity()
+
+ # NOTE JEM: thoses method are used in vendor bills to reinvoice at cost (see test `test_cost_invoicing`)
+ # some cleaning are still necessary
+
+ @api.multi
+ def _sale_get_invoice_price(self, order):
+ self.ensure_one()
+ if self.product_id.expense_policy == 'sales_price':
+ return self.product_id.with_context(
+ partner=order.partner_id.id,
+ date_order=order.date_order,
+ pricelist=order.pricelist_id.id,
+ uom=self.product_uom_id.id
+ ).price
+ if self.unit_amount == 0.0:
+ return 0.0
+
+ # Prevent unnecessary currency conversion that could be impacted by exchange rate
+ # fluctuations
+ if self.currency_id and self.amount_currency and self.currency_id == order.currency_id:
+ return abs(self.amount_currency / self.unit_amount)
+
+ price_unit = abs(self.amount / self.unit_amount)
+ currency_id = self.company_id.currency_id
+ if currency_id and currency_id != order.currency_id:
+ price_unit = currency_id.compute(price_unit, order.currency_id)
+ return price_unit
+
+ @api.multi
+ def _sale_prepare_sale_order_line_values(self, order, price):
+ self.ensure_one()
+ last_so_line = self.env['sale.order.line'].search([('order_id', '=', order.id)], order='sequence desc', limit=1)
+ last_sequence = last_so_line.sequence + 1 if last_so_line else 100
+
+ fpos = order.fiscal_position_id or order.partner_id.property_account_position_id
+ taxes = fpos.map_tax(self.product_id.taxes_id, self.product_id, order.partner_id)
+
+ return {
+ 'order_id': order.id,
+ 'name': self.name,
+ 'sequence': last_sequence,
+ 'price_unit': price,
+ 'tax_id': [x.id for x in taxes],
+ 'discount': 0.0,
+ 'product_id': self.product_id.id,
+ 'product_uom': self.product_uom_id.id,
+ 'product_uom_qty': 0.0,
+ 'qty_delivered': self.unit_amount,
+ }
+
+ @api.multi
+ def _sale_determine_order_line(self):
+ """ Automatically set the SO line on the analytic line, for the expense/vendor bills flow. It retrives
+ an existing line, or create a new one (upselling expenses).
+ """
+ for analytic_line in self.sudo().filtered(lambda aal: not aal.so_line and aal.account_id and aal.product_id and aal.product_id.expense_policy != 'no'):
+ # determine SO : first SO open linked to AA
+ sale_order = self.env['sale.order'].search([('analytic_account_id', '=', analytic_line.account_id.id), ('state', '=', 'sale')], limit=1)
+ if not sale_order:
+ sale_order = self.env['sale.order'].search([('analytic_account_id', '=', analytic_line.account_id.id)], limit=1)
+ if not sale_order:
+ continue
+
+ price = analytic_line._sale_get_invoice_price(sale_order)
+ so_line = self.env['sale.order.line'].search([
+ ('order_id', '=', sale_order.id),
+ ('price_unit', '=', price),
+ ('product_id', '=', self.product_id.id)
+ ], limit=1)
+
+ if not so_line:
+ # generate a new SO line
+ if sale_order.state != 'sale':
+ raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % sale_order.name)
+ so_line_values = analytic_line._sale_prepare_sale_order_line_values(sale_order, price)
+ if so_line_values:
+ so_line = self.env['sale.order.line'].create(so_line_values)
+ so_line._compute_tax_id()
+
+ if so_line: # if so line found or created, then update AAL (this will trigger the recomputation of qty delivered on SO line)
+ analytic_line.write({'so_line': so_line.id})
diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py
index 4a7a58f05e6..7cdfc9817cf 100644
--- a/addons/sale/models/product_template.py
+++ b/addons/sale/models/product_template.py
@@ -8,11 +8,11 @@ from odoo.addons.base.res.res_partner import WARNING_MESSAGE, WARNING_HELP
class ProductTemplate(models.Model):
_inherit = 'product.template'
- track_service = fields.Selection([('manual', 'Manually set quantities on order')], string='Track Service',
+ service_type = fields.Selection([('manual', 'Manually set quantities on order')], string='Track Service',
help="Manually set quantities on order: Invoice based on the manually entered quantity, without creating an analytic account.\n"
"Timesheets on contract: Invoice based on the tracked hours on the related timesheet.\n"
"Create a task and track hours: Create a task on the sales order validation and track the work hours.",
- default='manual')
+ default='manual', oldname='track_service')
sale_line_warn = fields.Selection(WARNING_MESSAGE, 'Sales Order Line', help=WARNING_HELP, required=True, default="no-message")
sale_line_warn_msg = fields.Text('Message for Sales Order Line')
expense_policy = fields.Selection(
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 4d6ba4d193b..cc5d909db85 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -110,9 +110,6 @@ class SaleOrder(models.Model):
for order in self:
order.order_line._compute_tax_id()
- def _inverse_project_id(self):
- self.project_id = self.related_project_id
-
name = fields.Char(string='Order Reference', required=True, copy=False, readonly=True, states={'draft': [('readonly', False)]}, index=True, default=lambda self: _('New'))
origin = fields.Char(string='Source Document', help="Reference of the document that generated this sales order request.")
client_order_ref = fields.Char(string='Customer Reference', copy=False)
@@ -138,8 +135,7 @@ class SaleOrder(models.Model):
pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Pricelist for current sales order.")
currency_id = fields.Many2one("res.currency", related='pricelist_id.currency_id', string="Currency", readonly=True, required=True)
- project_id = fields.Many2one('account.analytic.account', 'Analytic Account', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="The analytic account related to a sales order.", copy=False)
- related_project_id = fields.Many2one('account.analytic.account', inverse='_inverse_project_id', related='project_id', string='Analytic Account', help="The analytic account related to a sales order.")
+ analytic_account_id = fields.Many2one('account.analytic.account', 'Analytic Account', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="The analytic account related to a sales order.", copy=False, oldname='project_id')
order_line = fields.One2many('sale.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True, auto_join=True)
@@ -513,7 +509,7 @@ class SaleOrder(models.Model):
'company_id': order.company_id.id,
'partner_id': order.partner_id.id
})
- order.project_id = analytic
+ order.analytic_account_id = analytic
@api.multi
def order_lines_layouted(self):
@@ -663,7 +659,7 @@ class SaleOrderLine(models.Model):
@api.depends('product_id.invoice_policy', 'order_id.state')
def _compute_qty_delivered_updateable(self):
for line in self:
- line.qty_delivered_updateable = (line.order_id.state == 'sale') and (line.product_id.track_service == 'manual') and (line.product_id.expense_policy == 'no')
+ line.qty_delivered_updateable = (line.order_id.state == 'sale') and (line.product_id.service_type == 'manual') and (line.product_id.expense_policy == 'no')
@api.depends('invoice_lines',
'invoice_lines.price_total',
@@ -895,7 +891,7 @@ class SaleOrderLine(models.Model):
'product_id': self.product_id.id or False,
'layout_category_id': self.layout_category_id and self.layout_category_id.id or False,
'invoice_line_tax_ids': [(6, 0, self.tax_id.ids)],
- 'account_analytic_id': self.order_id.project_id.id,
+ 'account_analytic_id': self.order_id.analytic_account_id.id,
'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)],
}
return res
@@ -1087,3 +1083,51 @@ class SaleOrderLine(models.Model):
discount = (new_list_price - price) / new_list_price * 100
if discount > 0:
self.discount = discount
+
+ ###########################
+ # Analytic Methods
+ ###########################
+
+ @api.multi
+ def _analytic_compute_delivered_quantity_domain(self):
+ """ Return the domain of the analytic lines to use to recompute the delivered quantity
+ on SO lines. This method is a hook: since analytic line are used for timesheet,
+ expense, ... each use case should provide its part of the domain.
+ """
+ return [('so_line', 'in', self.ids), ('amount', '<=', 0.0)]
+
+ @api.multi
+ def _analytic_compute_delivered_quantity(self):
+ """ Compute and write the delivered quantity of current SO lines, based on their related
+ analytic lines.
+ """
+ # avoid recomputation if no SO lines concerned
+ if not self:
+ return False
+
+ # group anaytic lines by product uom and so line
+ domain = self._analytic_compute_delivered_quantity_domain()
+ data = self.env['account.analytic.line'].read_group(
+ domain,
+ ['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False
+ )
+
+ # convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines
+ value_to_write = {}
+ for item in data:
+ if not item['product_uom_id']:
+ continue
+ so_line = self.browse(item['so_line'][0])
+ value_to_write.setdefault(so_line, 0.0)
+ uom = self.env['product.uom'].browse(item['product_uom_id'][0])
+ if so_line.product_uom.category_id == uom.category_id:
+ qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom)
+ else:
+ qty = item['unit_amount']
+ value_to_write[so_line] += qty
+
+ # write the delivered quantity
+ for so_line, qty in value_to_write.items():
+ so_line.write({'qty_delivered': qty})
+
+ return True
diff --git a/addons/sale/models/sale_analytic.py b/addons/sale/models/sale_analytic.py
deleted file mode 100644
index 96f2f896f44..00000000000
--- a/addons/sale/models/sale_analytic.py
+++ /dev/null
@@ -1,147 +0,0 @@
-# -*- 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 SaleOrderLine(models.Model):
- _inherit = "sale.order.line"
-
- @api.multi
- def _compute_analytic(self, domain=None):
- lines = {}
- force_so_lines = self.env.context.get("force_so_lines")
- if not domain:
- if not self.ids and not force_so_lines:
- return True
- # To filter on analyic lines linked to an expense
- expense_type_id = self.env.ref('account.data_account_type_expenses', raise_if_not_found=False)
- expense_type_id = expense_type_id and expense_type_id.id
- domain = [('so_line', 'in', self.ids), ('amount', '<=', 0.0)]
-
- data = self.env['account.analytic.line'].read_group(
- domain,
- ['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False
- )
- # If the unlinked analytic line was the last one on the SO line, the qty was not updated.
- if force_so_lines:
- for line in force_so_lines:
- lines.setdefault(line, 0.0)
- for d in data:
- if not d['product_uom_id']:
- continue
- line = self.browse(d['so_line'][0])
- lines.setdefault(line, 0.0)
- uom = self.env['product.uom'].browse(d['product_uom_id'][0])
- if line.product_uom.category_id == uom.category_id:
- qty = uom._compute_quantity(d['unit_amount'], line.product_uom)
- else:
- qty = d['unit_amount']
- lines[line] += qty
-
- for line, qty in lines.items():
- line.qty_delivered = qty
- return True
-
-
-class AccountAnalyticLine(models.Model):
- _inherit = "account.analytic.line"
- so_line = fields.Many2one('sale.order.line', string='Sales Order Line')
-
- def _get_invoice_price(self, order):
- if self.product_id.expense_policy == 'sales_price':
- return self.product_id.with_context(
- partner=order.partner_id.id,
- date_order=order.date_order,
- pricelist=order.pricelist_id.id,
- uom=self.product_uom_id.id
- ).price
- if self.unit_amount == 0.0:
- return 0.0
-
- # Prevent unnecessary currency conversion that could be impacted by exchange rate
- # fluctuations
- if self.currency_id and self.amount_currency and self.currency_id == order.currency_id:
- return abs(self.amount_currency / self.unit_amount)
-
- price_unit = abs(self.amount / self.unit_amount)
- currency_id = self.company_id.currency_id
- if currency_id and currency_id != order.currency_id:
- price_unit = currency_id.compute(price_unit, order.currency_id)
- return price_unit
-
- def _get_sale_order_line_vals(self, order, price):
-
- last_so_line = self.env['sale.order.line'].search([('order_id', '=', order.id)], order='sequence desc', limit=1)
- last_sequence = last_so_line.sequence + 1 if last_so_line else 100
-
- fpos = order.fiscal_position_id or order.partner_id.property_account_position_id
- taxes = fpos.map_tax(self.product_id.taxes_id, self.product_id, order.partner_id)
-
- return {
- 'order_id': order.id,
- 'name': self.name,
- 'sequence': last_sequence,
- 'price_unit': price,
- 'tax_id': [x.id for x in taxes],
- 'discount': 0.0,
- 'product_id': self.product_id.id,
- 'product_uom': self.product_uom_id.id,
- 'product_uom_qty': 0.0,
- 'qty_delivered': self.unit_amount,
- }
-
- def _get_sale_order_line(self, vals=None):
- result = dict(vals or {})
- so_line = result.get('so_line', False) or self.so_line
- if not so_line and self.account_id and self.product_id and (self.product_id.expense_policy != 'no'):
- order_in_sale = self.env['sale.order'].search([('project_id', '=', self.account_id.id), ('state', '=', 'sale')], limit=1)
- order = order_in_sale or self.env['sale.order'].search([('project_id', '=', self.account_id.id)], limit=1)
- if not order:
- return result
- price = self._get_invoice_price(order)
- so_lines = self.env['sale.order.line'].search([
- ('order_id', '=', order.id),
- ('price_unit', '=', price),
- ('product_id', '=', self.product_id.id)])
-
- if so_lines:
- result.update({'so_line': so_lines[0].id})
- else:
- if order.state != 'sale':
- raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % order.name)
- order_line_vals = self._get_sale_order_line_vals(order, price)
- if order_line_vals:
- so_line = self.env['sale.order.line'].create(order_line_vals)
- so_line._compute_tax_id()
- result.update({'so_line': so_line.id})
- return result
-
- @api.multi
- def write(self, values):
- if self._context.get('create', False):
- return super(AccountAnalyticLine, self).write(values)
-
- lines = super(AccountAnalyticLine, self).write(values)
- for line in self:
- res = line.sudo()._get_sale_order_line(vals=values)
- super(AccountAnalyticLine, line).write(res)
-
- self.mapped('so_line').sudo()._compute_analytic()
- return lines
-
- @api.model
- def create(self, values):
- line = super(AccountAnalyticLine, self).create(values)
- res = line.sudo()._get_sale_order_line(vals=values)
- line.with_context(create=True).write(res)
- line.mapped('so_line').sudo()._compute_analytic()
- return line
-
- @api.multi
- def unlink(self):
- so_lines = self.sudo().mapped('so_line')
- res = super(AccountAnalyticLine, self).unlink()
- so_lines.with_context(force_so_lines=so_lines)._compute_analytic()
- return res
diff --git a/addons/sale/report/report_all_channels_sales.py b/addons/sale/report/report_all_channels_sales.py
index ed0219e2e0d..5e1f9eca28b 100644
--- a/addons/sale/report/report_all_channels_sales.py
+++ b/addons/sale/report/report_all_channels_sales.py
@@ -42,7 +42,7 @@ class PosSaleReport(models.Model):
rp.country_id AS country_id,
sol.price_subtotal / COALESCE (cr.rate, 1.0) AS price_subtotal,
(sol.product_uom_qty / u.factor * u2.factor) as product_qty,
- so.project_id AS analytic_account_id,
+ so.analytic_account_id AS analytic_account_id,
so.team_id AS team_id
FROM sale_order_line sol
diff --git a/addons/sale/report/sale_report.py b/addons/sale/report/sale_report.py
index 3e9905ec85b..42c6953f4dc 100644
--- a/addons/sale/report/sale_report.py
+++ b/addons/sale/report/sale_report.py
@@ -71,7 +71,7 @@ class SaleReport(models.Model):
extract(epoch from avg(date_trunc('day',s.date_order)-date_trunc('day',s.create_date)))/(24*60*60)::decimal(16,2) as delay,
t.categ_id as categ_id,
s.pricelist_id as pricelist_id,
- s.project_id as analytic_account_id,
+ s.analytic_account_id as analytic_account_id,
s.team_id as team_id,
p.product_tmpl_id,
partner.country_id as country_id,
@@ -112,7 +112,7 @@ class SaleReport(models.Model):
s.state,
s.company_id,
s.pricelist_id,
- s.project_id,
+ s.analytic_account_id,
s.team_id,
p.product_tmpl_id,
partner.country_id,
diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py
index 2b6c082b150..e0892afed10 100644
--- a/addons/sale/tests/test_sale_order.py
+++ b/addons/sale/tests/test_sale_order.py
@@ -127,7 +127,7 @@ class TestSaleOrder(TestSale):
'name': '',
'type': 'in_invoice',
'partner_id': inv_partner.id,
- 'invoice_line_ids': [(0, 0, {'name': serv_cost.name, 'product_id': serv_cost.id, 'quantity': 2, 'uom_id': serv_cost.uom_id.id, 'price_unit': serv_cost.standard_price, 'account_analytic_id': so.project_id.id, 'account_id': account_income.id})],
+ 'invoice_line_ids': [(0, 0, {'name': serv_cost.name, 'product_id': serv_cost.id, 'quantity': 2, 'uom_id': serv_cost.uom_id.id, 'price_unit': serv_cost.standard_price, 'account_analytic_id': so.analytic_account_id.id, 'account_id': account_income.id})],
'account_id': account_payable.id,
'journal_id': journal.id,
'currency_id': company.currency_id.id,
diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml
index 24e97c177a2..cded496290b 100644
--- a/addons/sale/views/sale_views.xml
+++ b/addons/sale/views/sale_views.xml
@@ -383,11 +383,7 @@
-
-
+
@@ -433,7 +429,7 @@
-
+
@@ -700,7 +696,7 @@
-
+
diff --git a/addons/sale/wizard/sale_make_invoice_advance.py b/addons/sale/wizard/sale_make_invoice_advance.py
index 01cf2225a95..838f038d9bc 100644
--- a/addons/sale/wizard/sale_make_invoice_advance.py
+++ b/addons/sale/wizard/sale_make_invoice_advance.py
@@ -107,7 +107,7 @@ class SaleAdvancePaymentInv(models.TransientModel):
'product_id': self.product_id.id,
'sale_line_ids': [(6, 0, [so_line.id])],
'invoice_line_tax_ids': [(6, 0, tax_ids)],
- 'account_analytic_id': order.project_id.id or False,
+ 'account_analytic_id': order.analytic_account_id.id or False,
})],
'currency_id': order.pricelist_id.currency_id.id,
'payment_term_id': order.payment_term_id.id,
diff --git a/addons/sale_expense/tests/test_sale_expense.py b/addons/sale_expense/tests/test_sale_expense.py
index 91a094bf901..1fd00aa2a68 100644
--- a/addons/sale_expense/tests/test_sale_expense.py
+++ b/addons/sale_expense/tests/test_sale_expense.py
@@ -40,7 +40,7 @@ class TestSaleExpense(TestSale):
exp = self.env['hr.expense'].create({
'name': 'Air Travel',
'product_id': prod_exp_1.id,
- 'analytic_account_id': so.project_id.id,
+ 'analytic_account_id': so.analytic_account_id.id,
'unit_amount': 621.54,
'employee_id': employee.id,
'sheet_id': sheet.id
@@ -67,7 +67,7 @@ class TestSaleExpense(TestSale):
exp = self.env['hr.expense'].create({
'name': 'Car Travel',
'product_id': prod_exp_2.id,
- 'analytic_account_id': so.project_id.id,
+ 'analytic_account_id': so.analytic_account_id.id,
'product_uom_id': self.env.ref('product.product_uom_km').id,
'unit_amount': 0.15,
'quantity': 100,
diff --git a/addons/sale_expense/views/product_view.xml b/addons/sale_expense/views/product_view.xml
index 90539c2fade..2f27621a522 100644
--- a/addons/sale_expense/views/product_view.xml
+++ b/addons/sale_expense/views/product_view.xml
@@ -5,7 +5,7 @@
product.template
-
+
diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py
index 8bc94cdffcf..e65f7580af7 100644
--- a/addons/sale_timesheet/__manifest__.py
+++ b/addons/sale_timesheet/__manifest__.py
@@ -16,7 +16,6 @@ have real delivered quantities in sales orders.
'website': 'https://www.odoo.com/page/warehouse',
'depends': ['sale_management', 'hr_timesheet'],
'data': [
- 'data/sale_timesheet_data.xml',
'views/account_invoice_views.xml',
'views/sale_order_views.xml',
'views/product_views.xml',
diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py
index 975b79523a9..bd9654e16e9 100644
--- a/addons/sale_timesheet/controllers/main.py
+++ b/addons/sale_timesheet/controllers/main.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import http
+from odoo import http, _
from odoo.http import request
from odoo.tools import float_round
@@ -12,19 +12,30 @@ class SaleTimesheetController(http.Controller):
@http.route('/timesheet/plan', type='json', auth="user")
def plan(self, domain):
values = self._prepare_plan_values(domain)
- view = request.env['ir.model.data'].get_object('sale_timesheet', 'timesheet_plan')
+ view = request.env.ref('sale_timesheet.timesheet_plan')
return {
'html_content': view.render(values)
}
def _prepare_plan_values(self, domain):
+
+ def float_to_time(hour_number):
+ hours, minutes = divmod(hour_number * 60, 60)
+ return _('%sh%s') % (hours, '%02d' % minutes)
+
+ timesheet_lines = request.env['account.analytic.line'].search(domain)
+
values = {
'currency': request.env.user.company_id.currency_id,
- 'timesheet_lines': request.env['account.analytic.line'].search(domain),
+ 'timesheet_lines': timesheet_lines,
'domain': domain,
+ 'float_to_time': float_to_time,
}
hour_rounding = request.env.ref('product.product_uom_hour').rounding
- billable_types = ['non_billable', 'billable_time', 'billable_fixed']
+ billable_types = ['non_billable', 'non_billable_project', 'billable_time', 'billable_fixed']
+
+ # -- Stat Buttons
+ values['stat_buttons'] = self._plan_get_stat_button(timesheet_lines)
# -- Dashboard (per billable type)
dashboard_values = {
@@ -69,11 +80,13 @@ class SaleTimesheetController(http.Controller):
repartition_employee.setdefault(employee_id, dict(
employee_id=data['employee_id'][0],
employee_name=data['employee_id'][1],
+ non_billable_project=0.0,
non_billable=0.0,
billable_time=0.0,
billable_fixed=0.0,
- total=0.0
+ total=0.0,
))[data['timesheet_invoice_type']] = float_round(data.get('unit_amount', 0.0), precision_rounding=hour_rounding)
+ repartition_employee[employee_id]['__domain_'+data['timesheet_invoice_type']] = data['__domain']
# compute total
for employee_id, vals in repartition_employee.items():
@@ -84,3 +97,52 @@ class SaleTimesheetController(http.Controller):
values['repartition_employee'] = repartition_employee
return values
+
+ def _plan_get_stat_button(self, timesheet_lines):
+ stat_buttons = []
+ stat_buttons.append({
+ 'name': _('Timesheets'),
+ 'res_model': 'account.analytic.line',
+ 'domain': [('id', 'in', timesheet_lines.ids)],
+ 'icon': 'fa fa-calendar',
+ })
+ stat_project_ids = timesheet_lines.mapped('project_id').ids
+ stat_task_domain = [('project_id', 'in', stat_project_ids), '|', ('stage_id', '=', False), ('stage_id.fold', '=', False)]
+ stat_buttons.append({
+ 'name': _('Tasks'),
+ 'count': request.env['project.task'].search_count(stat_task_domain),
+ 'res_model': 'project.task',
+ 'domain': stat_task_domain,
+ 'icon': 'fa fa-tasks',
+ })
+ return stat_buttons
+
+ @http.route('/timesheet/plan/action', type='json', auth="user")
+ def plan_stat_button(self, domain, res_model='account.analytic.line'):
+ action = {
+ 'type': 'ir.actions.act_window',
+ 'view_id': False,
+ 'view_mode': 'tree,form',
+ 'view_type': 'list',
+ 'domain': domain,
+ }
+ if res_model == 'account.analytic.line':
+ ts_view_tree_id = request.env.ref('hr_timesheet.hr_timesheet_line_tree').id
+ ts_view_form_id = request.env.ref('hr_timesheet.hr_timesheet_line_form').id
+ action = {
+ 'name': _('Timesheets'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': res_model,
+ 'view_mode': 'tree,form',
+ 'view_type': 'tree',
+ 'views': [[ts_view_tree_id, 'list'], [ts_view_form_id, 'form']],
+ 'domain': domain,
+ }
+ elif res_model == 'project.task':
+ action = request.env.ref('project.action_view_task').read()[0]
+ action.update({
+ 'name': _('Tasks'),
+ 'domain': domain,
+ 'context': request.env.context, # erase original context to avoid default filter
+ })
+ return action
diff --git a/addons/sale_timesheet/data/sale_service_demo.xml b/addons/sale_timesheet/data/sale_service_demo.xml
index cfddb602bc8..96aadd83975 100644
--- a/addons/sale_timesheet/data/sale_service_demo.xml
+++ b/addons/sale_timesheet/data/sale_service_demo.xml
@@ -2,11 +2,13 @@
- timesheet
+ timesheet
+ project_only
Support Contract (on timesheet)
- timesheet
+ timesheet
+ project_only
@@ -27,7 +29,8 @@
- task
+ timesheet
+ task_global_project
diff --git a/addons/sale_timesheet/data/sale_timesheet_data.xml b/addons/sale_timesheet/data/sale_timesheet_data.xml
deleted file mode 100644
index f0d5148a754..00000000000
--- a/addons/sale_timesheet/data/sale_timesheet_data.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
- ir.model.fields
- sale_timesheet
- project_time_mode_id_duplicate_xmlid
- True
-
-
-
-
diff --git a/addons/sale_timesheet/models/__init__.py b/addons/sale_timesheet/models/__init__.py
index cd36b996ae6..d22baec4d49 100644
--- a/addons/sale_timesheet/models/__init__.py
+++ b/addons/sale_timesheet/models/__init__.py
@@ -6,5 +6,4 @@ from . import account_invoice
from . import hr
from . import product
from . import project
-from . import res_company
from . import sale_order
diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py
index 0f30f231aa1..21be7fe5408 100644
--- a/addons/sale_timesheet/models/account.py
+++ b/addons/sale_timesheet/models/account.py
@@ -11,78 +11,62 @@ class AccountAnalyticLine(models.Model):
timesheet_invoice_type = fields.Selection([
('billable_time', 'Billable Time'),
('billable_fixed', 'Billable Fixed'),
- ('non_billable', 'Non Billable')], string="Billable Type", readonly=True, copy=False)
+ ('non_billable', 'Non Billable'),
+ ('non_billable_project', 'No task found')], string="Billable Type", readonly=True, copy=False)
timesheet_invoice_id = fields.Many2one('account.invoice', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet")
timesheet_revenue = fields.Monetary("Revenue", default=0.0, readonly=True, currency_field='company_currency_id', copy=False)
@api.model
def create(self, values):
- if values.get('task_id'):
- task = self.env['project.task'].browse(values['task_id'])
- values['so_line'] = task.sale_line_id.id or values.get('so_line', False)
- values.update(self._get_timesheet_values(values))
- values.update(self._get_timesheet_billing_values(values))
- return super(AccountAnalyticLine, self).create(values)
+ result = super(AccountAnalyticLine, self).create(values)
+ # applied only for timesheet
+ if result.project_id:
+ result._timesheet_postprocess(values)
+ return result
@api.multi
def write(self, values):
# prevent to update invoiced timesheets if one line is of type delivery
if self.sudo().filtered(lambda aal: aal.so_line.product_id.invoice_policy == "delivery") and self.filtered(lambda timesheet: timesheet.timesheet_invoice_id):
if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'task_id', 'timesheet_revenue', 'so_line', 'amount', 'date']]):
- raise UserError(_('You can not modify already invoiced timesheets.'))
-
- so_lines = self.mapped('so_line')
- if values.get('task_id'):
- task = self.env['project.task'].browse(values['task_id'])
- values['so_line'] = task.sale_line_id.id or values.get('so_line', False)
- for line in self:
- values.update(line._get_timesheet_values(values))
- values.update(line._get_timesheet_billing_values(values))
- super(AccountAnalyticLine, line).write(values)
-
- # Update delivered quantity on SO lines which are not linked to the analytic lines anymore
- so_lines -= self.mapped('so_line')
- if so_lines:
- so_lines.with_context(force_so_lines=so_lines).sudo()._compute_analytic()
- return True
-
- def _get_timesheet_values(self, values):
- result = {}
- values = values if values is not None else {}
- if values.get('project_id') or self.project_id:
- if values.get('amount'):
- return {}
- unit_amount = values.get('unit_amount', 0.0) or self.unit_amount
-
- # find the employee
- employee_id = values.get('employee_id') or self.employee_id.id or (self.env.user.employee_ids and self.env.user.employee_ids[0].id or False)
- employee = self.env['hr.employee'].browse(employee_id) if employee_id else self.env['hr.employee']
- if not employee:
- user_id = values.get('user_id') or self.user_id.id or self._default_user()
- employee = self.env['hr.employee'].search([('user_id', '=', user_id)], limit=1)
-
- # find the analytic account
- account_id = values.get('account_id') or self.account_id.id
- if not account_id and values.get('project_id'):
- project = self.env['project.project'].browse(values['project_id'])
- account_id = project.analytic_account_id.id
- analytic_account = self.env['account.analytic.account'].browse(account_id) if account_id else self.env['account.analytic.account']
-
- # convert employee cost into timesheet (analytic account) currency
- uom = employee.company_id.project_time_mode_id
- cost = employee.timesheet_cost or 0.0
- amount = -unit_amount * cost
- amount_converted = employee.currency_id.compute(amount, analytic_account.currency_id)
-
- result.update({
- 'amount': amount_converted,
- 'product_uom_id': uom.id,
- 'account_id': account_id,
- })
+ raise UserError(_('You can not modify already invoiced timesheets (linked to a Sales order items invoiced on Time and material).'))
+ result = super(AccountAnalyticLine, self).write(values)
+ # applied only for timesheet
+ self.filtered(lambda t: t.project_id)._timesheet_postprocess(values)
return result
- def _get_timesheet_billing_values(self, values):
- """
+ @api.model
+ def _timesheet_preprocess(self, values):
+ values = super(AccountAnalyticLine, self)._timesheet_preprocess(values)
+ # task implies so line
+ if 'task_id' in values:
+ task = self.env['project.task'].sudo().browse(values['task_id'])
+ values['so_line'] = task.sale_line_id.id or values.get('so_line', False)
+ return values
+
+ @api.multi
+ def _timesheet_postprocess(self, values):
+ sudo_self = self.sudo() # this creates only one env for all operation that required sudo()
+ # (re)compute the amount (depending on unit_amount, employee_id for the cost, and account_id for currency)
+ if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'account_id']]):
+ for timesheet in sudo_self:
+ uom = timesheet.employee_id.company_id.project_time_mode_id
+ cost = timesheet.employee_id.timesheet_cost or 0.0
+ amount = -timesheet.unit_amount * cost
+ amount_converted = timesheet.employee_id.currency_id.compute(amount, timesheet.account_id.currency_id)
+ timesheet.write({
+ 'amount': amount_converted,
+ 'product_uom_id': uom.id,
+ })
+ # (re)compute the theorical revenue
+ if any([field_name in values for field_name in ['so_line', 'unit_amount', 'account_id']]):
+ sudo_self._timesheet_compute_theorical_revenue()
+ return values
+
+ @api.multi
+ def _timesheet_compute_theorical_revenue(self):
+ """ This method set the theorical revenue on the current timesheet lines.
+
If invoice on delivered quantity:
timesheet hours * (SO Line Price) * (1- discount),
elif invoice on ordered quantities & create task:
@@ -93,76 +77,49 @@ class AccountAnalyticLine(models.Model):
else:
0
"""
- result = {}
- if self.env.context.get('create'): # avoid bad loop
- return result
+ for timesheet in self:
+ # find the timesheet UoM
+ timesheet_uom = timesheet.product_uom_id
+ if not timesheet_uom: # fallback on default company timesheet UoM
+ timesheet_uom = self.env.user.company_id.project_time_mode_id
- # find the timesheet UoM
- timesheet_uom = self.product_uom_id
- if values.get('product_uom_id'):
- timesheet_uom = self.env['product.uom'].browse(values['product_uom_id'])
- if not timesheet_uom: # fallback on default company timesheet UoM
- timesheet_uom = self.env.user.company_id.project_time_mode_id
+ # default values
+ unit_amount = timesheet.unit_amount
+ so_line = timesheet.so_line
+ values = {
+ 'timesheet_revenue': 0.0,
+ 'timesheet_invoice_type': 'non_billable_project' if not timesheet.task_id else 'non_billable',
+ }
+ # set the revenue and billable type according to the product and the SO line
+ if timesheet.task_id and so_line.product_id.type == 'service' and so_line.product_id.service_type == 'timesheet':
+ # find the analytic account to convert revenue into its currency
+ analytic_account = timesheet.account_id
+ # convert the unit of mesure into hours
+ sale_price_hour = so_line.product_uom._compute_price(so_line.price_unit, timesheet_uom)
+ sale_price = so_line.currency_id.compute(sale_price_hour, analytic_account.currency_id) # amount from SO should be convert into analytic account currency
- unit_amount = values.get('unit_amount', 0.0) or self.unit_amount
- billable_type = 'non_billable'
- revenue = 0.0
-
- # set the revenue and billable type according to the product and the SO line
- so_line_id = values.get('so_line') or self.so_line.id
- so_line = self.env['sale.order.line'].browse(so_line_id).sudo() if so_line_id else self.env['sale.order.line'].browse().sudo()
- if so_line.product_id.type == 'service':
- # find the analytic account to convert revenue into its currency
- account_id = values.get('account_id') or self.account_id.id
- analytic_account = self.env['account.analytic.account'].browse(account_id)
- # convert the unit of mesure into hours
- sale_price_hour = so_line.product_uom._compute_price(so_line.price_unit, timesheet_uom)
- sale_price = so_line.currency_id.compute(sale_price_hour, analytic_account.currency_id) # amount from SO should be convert into analytic account currency
- # calculate the revenue on the timesheet
- if so_line.product_id.invoice_policy == 'delivery':
- revenue = analytic_account.currency_id.round(unit_amount * sale_price * (1-(so_line.discount/100)))
- billable_type = 'billable_time'
- elif so_line.product_id.invoice_policy == 'order' and so_line.product_id.track_service == 'task':
- quantity_hour = unit_amount
- if so_line.product_uom.category_id == timesheet_uom.category_id:
+ # calculate the revenue on the timesheet
+ if so_line.product_id.invoice_policy == 'delivery':
+ values['timesheet_revenue'] = analytic_account.currency_id.round(unit_amount * sale_price * (1-(so_line.discount/100)))
+ values['timesheet_invoice_type'] = 'billable_time'
+ elif so_line.product_id.invoice_policy == 'order':
quantity_hour = so_line.product_uom._compute_quantity(so_line.product_uom_qty, timesheet_uom)
- # compute the total revenue the SO since we are in fixed price
- total_revenue_so = analytic_account.currency_id.round(quantity_hour * sale_price * (1-(so_line.discount/100)))
- # compute the total revenue already existing (without the current timesheet line)
- domain = [('so_line', '=', so_line.id)]
- if self.ids:
- domain += [('id', 'not in', self.ids)]
- analytic_lines = self.sudo().search(domain)
- total_revenue_invoiced = sum(analytic_lines.mapped('timesheet_revenue'))
- # compute (new) revenue of current timesheet line
- revenue = min(
- analytic_account.currency_id.round(unit_amount * so_line.currency_id.compute(so_line.price_unit, analytic_account.currency_id) * (1-(so_line.discount/100))),
- total_revenue_so - total_revenue_invoiced
- )
- billable_type = 'billable_fixed'
- result['timesheet_revenue'] = revenue
- result['timesheet_invoice_type'] = billable_type
- return result
-
- def _get_sale_order_line(self, vals=None):
- result = dict(vals or {})
- if self.project_id:
- if result.get('so_line'):
- sol = self.env['sale.order.line'].browse([result['so_line']])
- else:
- sol = self.so_line
- if not sol:
- sol = self.env['sale.order.line'].search([
- ('order_id.project_id', '=', self.account_id.id),
- ('state', 'in', ('sale', 'done')),
- ('product_id.track_service', '=', 'timesheet'),
- ('product_id.type', '=', 'service')],
- limit=1)
- if sol:
- result.update({
- 'so_line': sol.id,
- 'product_id': sol.product_id.id,
- })
- result.update(self._get_timesheet_values(result))
-
- return super(AccountAnalyticLine, self)._get_sale_order_line(vals=result)
+ # compute the total revenue the SO since we are in fixed price
+ total_revenue_so = analytic_account.currency_id.round(quantity_hour * sale_price * (1-(so_line.discount/100)))
+ # compute the total revenue already existing (without the current timesheet line)
+ domain = [('so_line', '=', so_line.id)]
+ if timesheet.ids:
+ domain += [('id', 'not in', timesheet.ids)]
+ analytic_lines = timesheet.search(domain)
+ total_revenue_invoiced = sum(analytic_lines.mapped('timesheet_revenue'))
+ # compute (new) revenue of current timesheet line
+ values['timesheet_revenue'] = min(
+ analytic_account.currency_id.round(unit_amount * so_line.currency_id.compute(so_line.price_unit, analytic_account.currency_id) * (1-so_line.discount)),
+ total_revenue_so - total_revenue_invoiced
+ )
+ values['timesheet_invoice_type'] = 'billable_fixed'
+ # if the so line is already invoiced, and the delivered qty is still smaller than the ordered, then link the timesheet to the invoice
+ if so_line.invoice_status == 'invoiced':
+ values['timesheet_invoice_id'] = so_line.invoice_lines[0].invoice_id.id
+ timesheet.write(values)
+ return True
diff --git a/addons/sale_timesheet/models/account_invoice.py b/addons/sale_timesheet/models/account_invoice.py
index 27dcd59243c..c4eb3d46c38 100644
--- a/addons/sale_timesheet/models/account_invoice.py
+++ b/addons/sale_timesheet/models/account_invoice.py
@@ -59,7 +59,7 @@ class AccountInvoice(models.Model):
('project_id', '!=', False),
('timesheet_invoice_id', '=', False),
('timesheet_invoice_type', 'in', ['billable_time', 'billable_fixed'])
- ]).with_context(create=True) # context key required to avoid loop
+ ])
# NOTE JEM : changing quantity (or unit price) of invoice line does not impact the revenue calculation. (FP specs)
if uninvoiced_timesheet_lines:
diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py
index b3035565c9b..af71631227f 100644
--- a/addons/sale_timesheet/models/product.py
+++ b/addons/sale_timesheet/models/product.py
@@ -7,17 +7,43 @@ from odoo import api, fields, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
- track_service = fields.Selection(selection_add=[
+ service_policy = fields.Selection([
+ ('ordered_timesheet', 'Ordered quantities'),
+ ('delivered_timesheet', 'Timesheets on tasks'),
+ ('delivered_manual', 'Milestones (manually set quantities on order)')
+ ], string="Invoice based on", compute='_compute_service_policy', inverse='_inverse_service_policy')
+ service_type = fields.Selection(selection_add=[
('timesheet', 'Timesheets on project (one fare per SO/Project)'),
- ('task', 'Create a task per order line to track hours')])
+ ])
+ service_tracking = fields.Selection([
+ ('no', 'Don\'t create task'),
+ ('task_global_project', 'Create a task in an existing project'),
+ ('task_new_project', 'Create a task in a new project'),
+ ('project_only', 'Create a new project but no task'),
+ ], string="Service Tracking", default="no", help="On Sales order confirmation, this product can generate project and/or task. From thoses, you can track the service you are selling.")
project_id = fields.Many2one(
- 'project.project', 'Project', company_dependent=True,
- help='Create a task under this project on sales order validation. This setting must be set for each company.')
+ 'project.project', 'Project', company_dependent=True, domain=[('sale_line_id', '=', False)],
+ help='Select a non billable project on which tasks can be created. This setting must be set for each company.')
- @api.onchange('type')
- def _onchange_type(self):
- if self.type == 'service':
- self.track_service = 'timesheet'
- else:
- self.track_service = 'manual'
+ @api.depends('invoice_policy', 'service_type')
+ def _compute_service_policy(self):
+ for product in self:
+ policy = 'ordered_timesheet'
+ if product.invoice_policy == 'delivery':
+ policy = 'delivered_manual' if product.service_type == 'manual' else 'delivered_timesheet'
+ product.service_policy = policy
+ def _inverse_service_policy(self):
+ for product in self:
+ policy = product.service_policy
+ if product.service_policy == 'ordered_timesheet':
+ product.invoice_policy = 'order'
+ product.service_type = 'timesheet'
+ else:
+ product.invoice_policy = 'delivery'
+ product.service_type = 'manual' if policy == 'delivered_manual' else 'timesheet'
+
+ @api.onchange('service_tracking')
+ def _onchange_service_tracking(self):
+ if self.service_tracking != 'task_global_project':
+ self.project_id = False
diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py
index 214cab4cf39..f15b6479636 100644
--- a/addons/sale_timesheet/models/project.py
+++ b/addons/sale_timesheet/models/project.py
@@ -8,6 +8,8 @@ from odoo.exceptions import ValidationError
class Project(models.Model):
_inherit = 'project.project'
+ sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Line', readonly=True, help="Sale order line from which the project has been created. Used for tracability.")
+
@api.multi
def action_view_timesheet(self):
self.ensure_one()
@@ -40,7 +42,7 @@ class Project(models.Model):
@api.multi
def action_view_timesheet_plan(self):
return {
- 'name': _('Overview of %s') % self.name,
+ 'name': _('Overview'),
'type': 'ir.actions.client',
'tag': 'timesheet.plan',
'context': {
@@ -54,7 +56,17 @@ class Project(models.Model):
class ProjectTask(models.Model):
_inherit = "project.task"
- sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Line')
+ sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', domain=[('is_service', '=', True)])
+
+ @api.multi
+ def write(self, values):
+ result = super(ProjectTask, self).write(values)
+ # reassign SO line on related timesheet lines
+ if 'sale_line_id' in values:
+ self.sudo().mapped('timesheet_ids').write({
+ 'so_line': values['sale_line_id']
+ })
+ return result
@api.multi
def unlink(self):
diff --git a/addons/sale_timesheet/models/res_company.py b/addons/sale_timesheet/models/res_company.py
deleted file mode 100644
index 19d5ce45ea0..00000000000
--- a/addons/sale_timesheet/models/res_company.py
+++ /dev/null
@@ -1,14 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from odoo import models, api, fields
-
-
-class ResCompany(models.Model):
- _inherit = 'res.company'
-
- @api.model
- def _get_default_uom_id(self):
- return self.env.ref("product.product_uom_hour", raise_if_not_found=False)
-
- project_time_mode_id = fields.Many2one('product.uom', 'Timesheet UoM', default=_get_default_uom_id)
diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py
index d8a79438898..cddb6f6abd9 100644
--- a/addons/sale_timesheet/models/sale_order.py
+++ b/addons/sale_timesheet/models/sale_order.py
@@ -4,24 +4,27 @@
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
+from odoo.osv import expression
+from odoo.tools.safe_eval import safe_eval
class SaleOrder(models.Model):
_inherit = 'sale.order'
timesheet_ids = fields.Many2many('account.analytic.line', compute='_compute_timesheet_ids', string='Timesheet activities associated to this sale')
- timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_ids')
+ timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_ids', groups="hr_timesheet.group_hr_timesheet_user")
tasks_ids = fields.Many2many('project.task', compute='_compute_tasks_ids', string='Tasks associated to this sale')
- tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids')
+ tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids', groups="project.group_project_user")
project_project_id = fields.Many2one('project.project', compute='_compute_project_project_id', string='Project associated to this sale')
+ project_ids = fields.Many2many('project.project', compute="_compute_project_ids", string='Projects', copy=False, groups="project.group_project_user", help="Projects used in this sales order.")
@api.multi
- @api.depends('project_id.line_ids')
+ @api.depends('analytic_account_id.line_ids')
def _compute_timesheet_ids(self):
for order in self:
- if order.project_id:
+ if order.analytic_account_id:
order.timesheet_ids = self.env['account.analytic.line'].search(
[('so_line', 'in', order.order_line.ids),
('amount', '<=', 0.0),
@@ -38,35 +41,25 @@ class SaleOrder(models.Model):
order.tasks_count = len(order.tasks_ids)
@api.multi
- @api.depends('project_id.project_ids')
+ @api.depends('analytic_account_id.project_ids')
def _compute_project_project_id(self):
for order in self:
- order.project_project_id = self.env['project.project'].search([('analytic_account_id', '=', order.project_id.id)])
+ order.project_project_id = self.env['project.project'].search([('analytic_account_id', '=', order.analytic_account_id.id)])
@api.multi
- @api.constrains('order_line')
- def _check_multi_timesheet(self):
+ @api.depends('order_line.product_id', 'project_project_id')
+ def _compute_project_ids(self):
for order in self:
- count = 0
- for line in order.order_line:
- if line.product_id.track_service == 'timesheet':
- count += 1
- if count > 1:
- raise ValidationError(_("You can use only one product on timesheet within the same sales order. You should split your order to include only one contract based on time and material."))
- return {}
+ projects = order.order_line.mapped('product_id.project_id')
+ if order.project_project_id:
+ projects |= order.project_project_id
+ order.project_ids = projects
@api.multi
def action_confirm(self):
+ """ On SO confirmation, some lines should generate a task or a project. """
result = super(SaleOrder, self).action_confirm()
- for order in self:
- if not order.project_project_id:
- for line in order.order_line:
- if line.product_id.track_service == 'timesheet':
- if not order.project_id:
- order._create_analytic_account(prefix=line.product_id.default_code or None)
- order.project_id.project_create({'name': order.project_id.name})
- break
- order.order_line.filtered(lambda line: line._is_task())._create_task()
+ self.order_line._timesheet_service_generation()
return result
@api.multi
@@ -95,15 +88,25 @@ class SaleOrder(models.Model):
return result
@api.multi
- def action_view_project_project(self):
+ def action_view_project_ids(self):
self.ensure_one()
- action = self.env.ref('project.open_view_project_all').read()[0]
- form_view_id = self.env.ref('project.edit_project').id
-
- action['views'] = [(form_view_id, 'form')]
- action['res_id'] = self.project_project_id.id
- action.pop('target', None)
-
+ if len(self.project_ids) == 1:
+ if self.env.user.has_group("hr_timesheet.group_hr_timesheet_user"):
+ action = self.project_ids.action_view_timesheet_plan()
+ else:
+ action = self.env.ref("project.act_project_project_2_project_task_all").read()[0]
+ action['context'] = safe_eval(action.get('context', '{}'), {'active_id': self.project_ids.id, 'active_ids': self.project_ids.ids})
+ else:
+ view_form_id = self.env.ref('project.edit_project').id
+ view_kanban_id = self.env.ref('project.view_project_kanban').id
+ action = {
+ 'type': 'ir.actions.act_window',
+ 'domain': [('id', 'in', self.project_ids.ids)],
+ 'views': [(view_kanban_id, 'kanban'), (view_form_id, 'form')],
+ 'view_mode': 'kanban,form',
+ 'name': _('Projects'),
+ 'res_model': 'project.project',
+ }
return action
@api.multi
@@ -132,24 +135,41 @@ class SaleOrder(models.Model):
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
- task_id = fields.Many2one('project.task', 'Task')
+ task_id = fields.Many2one('project.task', 'Task', help="Task generated by the sales order item")
+ is_service = fields.Boolean("Is service", compute='_compute_is_service', store=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.")
+
+ @api.multi
+ @api.depends('product_id.type')
+ def _compute_is_service(self):
+ for so_line in self:
+ so_line.is_service = so_line.product_id.type == 'service'
@api.model
def create(self, values):
line = super(SaleOrderLine, self).create(values)
- if line.state == 'sale' and not line.order_id.project_id and line.product_id.track_service in ['timesheet', 'task']:
- line.order_id._create_analytic_account()
- line._create_task()
+ if line.state == 'sale':
+ line._timesheet_service_generation()
return line
+ ###########################################
+ ### Analytic : auto recompute delivered quantity
+ ###########################################
+
+ def _timesheet_compute_delivered_quantity_domain(self):
+ # TODO JEM: avoid increment delivered for all AAL or just timesheet ?
+ # see nim commit https://github.com/odoo/odoo/commit/21fbb9776a5fbd1838b189f1f7cf8c5d40663e14
+ so_line_ids = self.filtered(lambda sol: sol.product_id.service_type != 'manual').ids
+ return ['&', ('so_line', 'in', so_line_ids), ('project_id', '!=', False)]
+
@api.multi
- def _compute_analytic(self, domain=None):
- if not domain and self.ids:
- # To filter on analyic lines linked to an expense
- expense_type_id = self.env.ref('account.data_account_type_expenses', raise_if_not_found=False)
- expense_type_id = expense_type_id and expense_type_id.id
- domain = [('so_line', 'in', self.ids), '|', ('amount', '<=', 0.0), ('project_id', '!=', False)]
- return super(SaleOrderLine, self)._compute_analytic(domain=domain)
+ def _analytic_compute_delivered_quantity_domain(self):
+ domain = super(SaleOrderLine, self)._analytic_compute_delivered_quantity_domain()
+ timesheet_domain = self._timesheet_compute_delivered_quantity_domain()
+ return expression.OR([domain, timesheet_domain])
+
+ ###########################################
+ ## Service : Project and task generation
+ ###########################################
def _convert_qty_company_hours(self):
company_time_uom_id = self.env.user.company_id.project_time_mode_id
@@ -159,27 +179,35 @@ class SaleOrderLine(models.Model):
planned_hours = self.product_uom_qty
return planned_hours
- def _get_project(self):
+ def _timesheet_find_project(self):
+ self.ensure_one()
Project = self.env['project.project']
project = self.product_id.with_context(force_company=self.company_id.id).project_id
if not project:
# find the project corresponding to the analytic account of the sales order
- account = self.order_id.project_id
+ account = self.order_id.analytic_account_id
if not account:
- self.order_id._create_analytic_account()
- account = self.order_id.project_id
+ self.order_id._create_analytic_account(prefix=self.product_id.default_code or None)
+ account = self.order_id.analytic_account_id
project = Project.search([('analytic_account_id', '=', account.id)], limit=1)
if not project:
- project_id = account.project_create({'name': account.name, 'use_tasks': True})
- project = Project.browse(project_id)
+ project_name = '%s (%s)' % (account.name, self.sale_line_id.order_partner_id.ref) if self.sale_line_id.order_partner_id.ref else account.name
+ project_id = account.sudo().project_create({
+ 'name': project_name,
+ 'allow_timesheets': self.product_id.service_type == 'timesheet',
+ })
+ project = Project.sudo().browse(project_id)
+ # set the SO line origin if product should create project
+ if not project.sale_line_id and self.product_id.service_tracking in ['task_new_project', 'project_only']:
+ project.write({'sale_line_id': self.id})
return project
- def _prepare_service_task_values(self):
+ def _timesheet_create_task_prepare_values(self):
self.ensure_one()
- project = self._get_project()
+ project = self._timesheet_find_project()
planned_hours = self._convert_qty_company_hours()
return {
- 'name': '%s:%s' % (self.order_id.name or '', self.product_id.name),
+ 'name': '%s:%s' % (self.order_id.name or '', self.name.split('\n')[0] or self.product_id.name),
'planned_hours': planned_hours,
'remaining_hours': planned_hours,
'partner_id': self.order_id.partner_id.id,
@@ -189,12 +217,63 @@ class SaleOrderLine(models.Model):
'company_id': self.company_id.id,
}
- def _create_task(self):
- for line in self:
- task_values = line._prepare_service_task_values()
- task = self.env['project.task'].create(task_values)
- self.write({'task_id': task.id})
+ @api.multi
+ def _timesheet_create_task(self):
+ """ Generate task for the given so line, and link it.
- def _is_task(self):
- self.ensure_one()
- return self.product_id.type == 'service' and self.product_id.track_service == 'task'
+ :return a mapping with the so line id and its linked task
+ :rtype dict
+ """
+ result = {}
+ for so_line in self:
+ # create task
+ values = so_line._timesheet_create_task_prepare_values()
+ task = self.env['project.task'].sudo().create(values)
+ so_line.write({'task_id': task.id})
+ # post message on SO
+ msg_body = _("Task Created (%s): %s ") % (so_line.product_id.name, task.id, task.name)
+ so_line.order_id.message_post(body=msg_body)
+ # post message on task
+ task_msg = _("This task has been created from: %s (%s)") % (so_line.order_id.id, so_line.order_id.name, so_line.product_id.name)
+ task.message_post(body=task_msg)
+
+ result[so_line.id] = task
+ return result
+
+ @api.multi
+ def _timesheet_find_task(self):
+ """ Find the task generated by the so lines. If no task linked, it will be
+ created automatically.
+
+ :return a mapping with the so line id and its linked task
+ :rtype dict
+ """
+ # one search for all so lines
+ tasks = self.env['project.task'].search([('sale_line_id', 'in', self.ids)])
+ task_sol_mapping = {task.sale_line_id.id: task for task in tasks}
+
+ result = {}
+ for so_line in self:
+ # If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a new task.
+ task = task_sol_mapping.get(so_line.id)
+ # If not found, create one task for the so line
+ if not task:
+ task = so_line._timesheet_create_task()[so_line.id]
+ result[so_line.id] = task
+ return result
+
+ @api.multi
+ def _timesheet_service_generation(self):
+ """ For service lines, create the task or the project. If already exists, it simply links
+ the existing one to the line.
+ """
+ for so_line in self.filtered(lambda sol: sol.is_service):
+ # create task
+ if so_line.product_id.service_tracking == 'task_global_project':
+ so_line._timesheet_find_task()
+ # create project
+ if so_line.product_id.service_tracking == 'project_only':
+ so_line._timesheet_find_project()
+ # create project and task
+ if so_line.product_id.service_tracking == 'task_new_project':
+ so_line._timesheet_find_task()
diff --git a/addons/sale_timesheet/static/src/js/timesheet_plan.js b/addons/sale_timesheet/static/src/js/timesheet_plan.js
index 0ee353240c2..e146fc4b365 100644
--- a/addons/sale_timesheet/static/src/js/timesheet_plan.js
+++ b/addons/sale_timesheet/static/src/js/timesheet_plan.js
@@ -18,6 +18,8 @@ var PlanAction = Widget.extend(ControlPanelMixin, {
events: {
"click a[type='action']": "_onClickAction",
"click .o_timesheet_plan_redirect": '_onRedirect',
+ "click .oe_stat_button": "_onClickStatButton",
+ "click .o_timesheet_plan_sale_timesheet_people_time .progress-bar": '_onClickEmployeeProgressbar',
},
init: function(parent, action, options) {
this._super.apply(this, arguments);
@@ -186,6 +188,31 @@ var PlanAction = Widget.extend(ControlPanelMixin, {
res_id: $target.data('oe-id'),
});
},
+ _onClickStatButton: function(event){
+ var self = this;
+ var data = $(event.currentTarget).data();
+ return this._rpc({
+ route:"/timesheet/plan/action",
+ params: {
+ domain: data['domain'],
+ res_model: data['resModel'],
+ },
+ }).then(function(action){
+ self.do_action(action);
+ });
+ },
+ _onClickEmployeeProgressbar: function(event){
+ var domain = $(event.currentTarget).data('domain');
+ this.do_action({
+ name: 'Timesheets',
+ type: 'ir.actions.act_window',
+ res_model: 'account.analytic.line',
+ views: [[false, 'list'], [false, 'form']],
+ view_type: 'list',
+ view_mode: 'form',
+ domain: domain,
+ });
+ },
_onSearch: function (search_event) {
var session = this.getSession();
// group by are disabled, so we don't take care of them
diff --git a/addons/sale_timesheet/static/src/less/sale_timesheet.less b/addons/sale_timesheet/static/src/less/sale_timesheet.less
index b420a53a2a2..c7bc4864566 100644
--- a/addons/sale_timesheet/static/src/less/sale_timesheet.less
+++ b/addons/sale_timesheet/static/src/less/sale_timesheet.less
@@ -6,6 +6,12 @@
cursor: pointer;
}
+ .o_timesheet_plan_stat_buttons {
+ text-align: right;
+ float: none;
+ width: auto;
+ }
+
.o_timesheet_plan_sale_timesheet_dashboard {
.table {
@@ -30,20 +36,31 @@
.o_progress_billable_time {
background-color: #f0ad4e;
color: white;
+ cursor: pointer;
}
.o_progress_billable_fixed {
background-color: #5bc0de;
color: white;
+ cursor: pointer;
+ }
+ .o_progress_non_billable_project {
+ background-color: @brand-primary;
+ color: white;
+ cursor: pointer;
}
.o_progress_non_billable {
background-color: gray;
color: white;
+ cursor: pointer;
}
>.o_timesheet_plan_badge {
margin-bottom: 10px;
> .badge {
border: none;
+ a {
+ color: white;
+ }
}
}
diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py
index 9b3932ef5fb..c18361894ff 100644
--- a/addons/sale_timesheet/tests/__init__.py
+++ b/addons/sale_timesheet/tests/__init__.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import common
from . import test_sale_timesheet
from . import test_sale_service
from . import test_timesheet_revenue
diff --git a/addons/sale_timesheet/tests/common.py b/addons/sale_timesheet/tests/common.py
new file mode 100644
index 00000000000..e60291c61bb
--- /dev/null
+++ b/addons/sale_timesheet/tests/common.py
@@ -0,0 +1,265 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import common
+
+
+class CommonTest(common.SavepointCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super(CommonTest, cls).setUpClass()
+
+ # Create accounts
+ cls.account_debit = cls.env['account.account'].create({
+ 'code': 'X1012',
+ 'name': 'Debtors - (test)',
+ 'reconcile': True,
+ 'user_type_id': cls.env.ref('account.data_account_type_receivable').id,
+ })
+ cls.account_credit = cls.env['account.account'].create({
+ 'code': 'X1111',
+ 'name': 'Creditors - (test)',
+ 'reconcile': True,
+ 'user_type_id': cls.env.ref('account.data_account_type_payable').id,
+ })
+ cls.account_sale = cls.env['account.account'].create({
+ 'code': 'X2020',
+ 'name': 'Product Sales - (test)',
+ 'reconcile': True,
+ 'user_type_id': cls.env.ref('account.data_account_type_revenue').id,
+ })
+
+ # Create project
+ cls.project_global = cls.env['project.project'].create({
+ 'name': 'Project for selling timesheets',
+ 'allow_timesheets': True,
+ })
+
+ # Create service products
+
+ # -- ordered quantities (ordered, timesheet)
+ cls.product_order_timesheet1 = cls.env['product.product'].create({
+ 'name': "Service Ordered, create no task",
+ 'standard_price': 11,
+ 'list_price': 13,
+ 'type': 'service',
+ 'invoice_policy': 'order',
+ 'uom_id': cls.env.ref('product.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('product.product_uom_hour').id,
+ 'default_code': 'SERV-ORDERED1',
+ 'service_type': 'timesheet',
+ 'service_tracking': 'no',
+ 'project_id': False,
+ 'taxes_id': False,
+ 'property_account_income_id': cls.account_sale.id,
+ })
+ cls.product_order_timesheet2 = cls.env['product.product'].create({
+ 'name': "Service Ordered, create task in global project",
+ 'standard_price': 30,
+ 'list_price': 90,
+ 'type': 'service',
+ 'invoice_policy': 'order',
+ 'uom_id': cls.env.ref('product.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('product.product_uom_hour').id,
+ 'default_code': 'SERV-ORDERED2',
+ 'service_type': 'timesheet',
+ 'service_tracking': 'task_global_project',
+ 'project_id': cls.project_global.id,
+ 'taxes_id': False,
+ 'property_account_income_id': cls.account_sale.id,
+ })
+ cls.product_order_timesheet3 = cls.env['product.product'].create({
+ 'name': "Service Ordered, create task in new project",
+ 'standard_price': 10,
+ 'list_price': 20,
+ 'type': 'service',
+ 'invoice_policy': 'order',
+ 'uom_id': cls.env.ref('product.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('product.product_uom_hour').id,
+ 'default_code': 'SERV-ORDERED3',
+ 'service_type': 'timesheet',
+ 'service_tracking': 'task_new_project',
+ 'project_id': False, # will create a project
+ 'taxes_id': False,
+ 'property_account_income_id': cls.account_sale.id,
+ })
+ cls.product_order_timesheet4 = cls.env['product.product'].create({
+ 'name': "Service Ordered, create project only",
+ 'standard_price': 15,
+ 'list_price': 30,
+ 'type': 'service',
+ 'invoice_policy': 'order',
+ 'uom_id': cls.env.ref('product.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('product.product_uom_hour').id,
+ 'default_code': 'SERV-ORDERED4',
+ 'service_type': 'timesheet',
+ 'service_tracking': 'project_only',
+ 'project_id': False,
+ 'taxes_id': False,
+ 'property_account_income_id': cls.account_sale.id,
+ })
+
+ # -- timesheet on tasks (delivered, timesheet)
+ cls.product_delivery_timesheet1 = cls.env['product.product'].create({
+ 'name': "Service delivered, create no task",
+ 'standard_price': 11,
+ 'list_price': 13,
+ 'type': 'service',
+ 'invoice_policy': 'delivery',
+ 'uom_id': cls.env.ref('product.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('product.product_uom_hour').id,
+ 'default_code': 'SERV-DELI1',
+ 'service_type': 'timesheet',
+ 'service_tracking': 'no',
+ 'project_id': False,
+ 'taxes_id': False,
+ 'property_account_income_id': cls.account_sale.id,
+ })
+ cls.product_delivery_timesheet2 = cls.env['product.product'].create({
+ 'name': "Service delivered, create task in global project",
+ 'standard_price': 30,
+ 'list_price': 90,
+ 'type': 'service',
+ 'invoice_policy': 'delivery',
+ 'uom_id': cls.env.ref('product.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('product.product_uom_hour').id,
+ 'default_code': 'SERV-DELI2',
+ 'service_type': 'timesheet',
+ 'service_tracking': 'task_global_project',
+ 'project_id': cls.project_global.id,
+ 'taxes_id': False,
+ 'property_account_income_id': cls.account_sale.id,
+ })
+ cls.product_delivery_timesheet3 = cls.env['product.product'].create({
+ 'name': "Service delivered, create task in new project",
+ 'standard_price': 10,
+ 'list_price': 20,
+ 'type': 'service',
+ 'invoice_policy': 'delivery',
+ 'uom_id': cls.env.ref('product.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('product.product_uom_hour').id,
+ 'default_code': 'SERV-DELI3',
+ 'service_type': 'timesheet',
+ 'service_tracking': 'task_new_project',
+ 'project_id': False, # will create a project
+ 'taxes_id': False,
+ 'property_account_income_id': cls.account_sale.id,
+ })
+ cls.product_delivery_timesheet4 = cls.env['product.product'].create({
+ 'name': "Service delivered, create project only",
+ 'standard_price': 15,
+ 'list_price': 30,
+ 'type': 'service',
+ 'invoice_policy': 'delivery',
+ 'uom_id': cls.env.ref('product.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('product.product_uom_hour').id,
+ 'default_code': 'SERV-DELI4',
+ 'service_type': 'timesheet',
+ 'service_tracking': 'project_only',
+ 'project_id': False,
+ 'taxes_id': False,
+ 'property_account_income_id': cls.account_sale.id,
+ })
+
+ # -- milestons (delivered, manual)
+ cls.product_delivery_manual1 = cls.env['product.product'].create({
+ 'name': "Service delivered, create no task",
+ 'standard_price': 11,
+ 'list_price': 13,
+ 'type': 'service',
+ 'invoice_policy': 'delivery',
+ 'uom_id': cls.env.ref('product.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('product.product_uom_hour').id,
+ 'default_code': 'SERV-DELI1',
+ 'service_type': 'manual',
+ 'service_tracking': 'no',
+ 'project_id': False,
+ 'taxes_id': False,
+ 'property_account_income_id': cls.account_sale.id,
+ })
+ cls.product_delivery_manual2 = cls.env['product.product'].create({
+ 'name': "Service delivered, create task in global project",
+ 'standard_price': 30,
+ 'list_price': 90,
+ 'type': 'service',
+ 'invoice_policy': 'delivery',
+ 'uom_id': cls.env.ref('product.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('product.product_uom_hour').id,
+ 'default_code': 'SERV-DELI2',
+ 'service_type': 'manual',
+ 'service_tracking': 'task_global_project',
+ 'project_id': cls.project_global.id,
+ 'taxes_id': False,
+ 'property_account_income_id': cls.account_sale.id,
+ })
+ cls.product_delivery_manual3 = cls.env['product.product'].create({
+ 'name': "Service delivered, create task in new project",
+ 'standard_price': 10,
+ 'list_price': 20,
+ 'type': 'service',
+ 'invoice_policy': 'delivery',
+ 'uom_id': cls.env.ref('product.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('product.product_uom_hour').id,
+ 'default_code': 'SERV-DELI3',
+ 'service_type': 'manual',
+ 'service_tracking': 'task_new_project',
+ 'project_id': False, # will create a project
+ 'taxes_id': False,
+ 'property_account_income_id': cls.account_sale.id,
+ })
+ cls.product_delivery_manual4 = cls.env['product.product'].create({
+ 'name': "Service delivered, create project only",
+ 'standard_price': 15,
+ 'list_price': 30,
+ 'type': 'service',
+ 'invoice_policy': 'delivery',
+ 'uom_id': cls.env.ref('product.product_uom_hour').id,
+ 'uom_po_id': cls.env.ref('product.product_uom_hour').id,
+ 'default_code': 'SERV-DELI4',
+ 'service_type': 'manual',
+ 'service_tracking': 'project_only',
+ 'project_id': False,
+ 'taxes_id': False,
+ 'property_account_income_id': cls.account_sale.id,
+ })
+
+ # Create pricelists
+ cls.pricelist_usd = cls.env['product.pricelist'].create({
+ 'name': 'USD pricelist',
+ 'active': True,
+ 'currency_id': cls.env.ref('base.USD').id,
+ 'company_id': cls.env.user.company_id.id,
+ })
+ cls.pricelist_eur = cls.env['product.pricelist'].create({
+ 'name': 'EUR pricelist',
+ 'active': True,
+ 'currency_id': cls.env.ref('base.EUR').id,
+ 'company_id': cls.env.user.company_id.id,
+ })
+
+ # Create partners
+ cls.partner_usd = cls.env['res.partner'].create({
+ 'name': 'Cool Partner in USD',
+ 'email': 'partner.usd@test.com',
+ 'property_product_pricelist': cls.pricelist_usd.id,
+ 'property_account_payable_id': cls.account_credit.id,
+ 'property_account_receivable_id': cls.account_debit.id,
+ })
+ cls.partner_eur = cls.env['res.partner'].create({
+ 'name': 'Cool partner in EUR',
+ 'email': 'partner.eur@test.com',
+ 'property_product_pricelist': cls.pricelist_eur.id,
+ 'property_account_payable_id': cls.account_credit.id,
+ 'property_account_receivable_id': cls.account_debit.id,
+ })
+
+ # Create employees
+ cls.employee_user = cls.env['hr.employee'].create({
+ 'name': 'Employee User',
+ 'timesheet_cost': 15,
+ })
+ cls.employee_manager = cls.env['hr.employee'].create({
+ 'name': 'Employee Manager',
+ 'timesheet_cost': 45,
+ })
diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py
index 755a4946636..388a3830122 100644
--- a/addons/sale_timesheet/tests/test_sale_service.py
+++ b/addons/sale_timesheet/tests/test_sale_service.py
@@ -1,43 +1,52 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo.addons.sale.tests.test_sale_common import TestSale
+
+from odoo.addons.sale_timesheet.tests.common import CommonTest
+from odoo.exceptions import UserError
-class TestSaleService(TestSale):
+class TestSaleService(CommonTest):
+ """ This test suite provide checks for miscellaneous small things. """
def test_sale_service(self):
- """ Test task creation when confirming a so with the corresponding product """
- prod_task = self.env.ref('product.product_product_1')
- so_vals = {
- 'partner_id': self.partner.id,
- 'partner_invoice_id': self.partner.id,
- 'partner_shipping_id': self.partner.id,
- 'order_line': [(0, 0, {'name': prod_task.name, 'product_id': prod_task.id, 'product_uom_qty': 50, 'product_uom': prod_task.uom_id.id, 'price_unit': prod_task.list_price})],
- 'pricelist_id': self.env.ref('product.list0').id,
+ """ Test task creation when confirming a sale_order with the corresponding product """
+ sale_order_vals = {
+ 'partner_id': self.partner_usd.id,
+ 'partner_invoice_id': self.partner_usd.id,
+ 'partner_shipping_id': self.partner_usd.id,
+ 'order_line': [(0, 0, {
+ 'name': self.product_delivery_timesheet2.name,
+ 'product_id': self.product_delivery_timesheet2.id,
+ 'product_uom_qty': 50,
+ 'product_uom': self.product_delivery_timesheet2.uom_id.id,
+ 'price_unit': self.product_delivery_timesheet2.list_price
+ }),
+ ],
+ 'pricelist_id': self.pricelist_usd.id,
}
- so = self.env['sale.order'].create(so_vals)
- so.action_confirm()
- self.assertEqual(so.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation')
+ sale_order = self.env['sale.order'].create(sale_order_vals)
+ sale_order.action_confirm()
+ self.assertEqual(sale_order.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation')
# check task creation
- project = self.env.ref('sale_timesheet.project_GAP')
- task = project.task_ids.filtered(lambda t: t.name == '%s:%s' % (so.name, prod_task.name))
+ project = self.project_global
+ task = project.task_ids.filtered(lambda t: t.name == '%s:%s' % (sale_order.name, self.product_delivery_timesheet2.name))
self.assertTrue(task, 'Sale Service: task is not created')
- self.assertEqual(task.partner_id, so.partner_id, 'Sale Service: customer should be the same on task and on SO')
+ self.assertEqual(task.partner_id, sale_order.partner_id, 'Sale Service: customer should be the same on task and on SO')
# register timesheet on task
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': project.id,
'task_id': task.id,
'unit_amount': 50,
- 'user_id': self.manager.id,
+ 'employee_id': self.employee_manager.id,
})
- self.assertEqual(so.invoice_status, 'to invoice', 'Sale Service: there should be something to invoice after registering timesheets')
- so.action_invoice_create()
- line = so.order_line
+ self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Service: there should be sale_ordermething to invoice after registering timesheets')
+ sale_order.action_invoice_create()
+ line = sale_order.order_line
self.assertTrue(line.product_uom_qty == line.qty_delivered == line.qty_invoiced, 'Sale Service: line should be invoiced completely')
- self.assertEqual(so.invoice_status, 'invoiced', 'Sale Service: SO should be invoiced')
- self.assertEqual(so.tasks_count, 1, "A task should have been created on SO confirmation.")
+ self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Service: SO should be invoiced')
+ self.assertEqual(sale_order.tasks_count, 1, "A task should have been created on SO confirmation.")
# Add a line on the confirmed SO, and it should generate a new task directly
product_service_task = self.env['product.product'].create({
@@ -49,7 +58,8 @@ class TestSaleService(TestSale):
'uom_id': self.env.ref('product.product_uom_hour').id,
'uom_po_id': self.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-DELI',
- 'track_service': 'task',
+ 'service_type': 'timesheet',
+ 'service_tracking': 'task_global_project',
'project_id': project.id
})
@@ -59,7 +69,105 @@ class TestSaleService(TestSale):
'product_uom_qty': 10,
'product_uom': product_service_task.uom_id.id,
'price_unit': product_service_task.list_price,
- 'order_id': so.id,
+ 'order_id': sale_order.id,
})
- self.assertEqual(so.tasks_count, 2, "Adding a new service line on a confirmer SO should create a new task.")
+ self.assertEqual(sale_order.tasks_count, 2, "Adding a new service line on a confirmer SO should create a new task.")
+
+ def test_timesheet_uom(self):
+ """ Test timesheet invoicing and uom conversion """
+ # create SO and confirm it
+ uom_days = self.env.ref('product.product_uom_day')
+ sale_order = self.env['sale.order'].create({
+ 'partner_id': self.partner_usd.id,
+ 'partner_invoice_id': self.partner_usd.id,
+ 'partner_shipping_id': self.partner_usd.id,
+ 'order_line': [
+ (0, 0, {
+ 'name': self.product_delivery_timesheet3.name,
+ 'product_id': self.product_delivery_timesheet3.id,
+ 'product_uom_qty': 5,
+ 'product_uom': uom_days.id,
+ 'price_unit': self.product_delivery_timesheet3.list_price
+ })
+ ],
+ 'pricelist_id': self.pricelist_usd.id,
+ })
+ sale_order.action_confirm()
+ task = self.env['project.task'].search([('sale_line_id', '=', sale_order.order_line.id)])
+
+ # let's log some timesheets
+ self.env['account.analytic.line'].create({
+ 'name': 'Test Line',
+ 'project_id': sale_order.project_project_id.id,
+ 'task_id': task.id,
+ 'unit_amount': 16,
+ 'employee_id': self.employee_manager.id,
+ })
+ self.assertEqual(sale_order.order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong')
+
+ self.env['account.analytic.line'].create({
+ 'name': 'Test Line',
+ 'project_id': sale_order.project_project_id.id,
+ 'task_id': task.id,
+ 'unit_amount': 24,
+ 'employee_id': self.employee_user.id,
+ })
+ sale_order.action_invoice_create()
+ self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
+
+ def test_task_so_line_assignation(self):
+ # create SO and confirm it
+ sale_order = self.env['sale.order'].create({
+ 'partner_id': self.partner_usd.id,
+ 'partner_invoice_id': self.partner_usd.id,
+ 'partner_shipping_id': self.partner_usd.id,
+ 'pricelist_id': self.pricelist_usd.id,
+ })
+ so_line_deliver_global_project = self.env['sale.order.line'].create({
+ 'name': self.product_delivery_timesheet2.name,
+ 'product_id': self.product_delivery_timesheet2.id,
+ 'product_uom_qty': 10,
+ 'product_uom': self.product_delivery_timesheet2.uom_id.id,
+ 'price_unit': self.product_delivery_timesheet2.list_price,
+ 'order_id': sale_order.id,
+ })
+ so_line_deliver_global_project.product_id_change()
+ sale_order.action_confirm()
+ task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)])
+
+ # let's log some timesheets (on the project created by so_line_ordered_project_only)
+ timesheets = self.env['account.analytic.line']
+ timesheets |= self.env['account.analytic.line'].create({
+ 'name': 'Test Line',
+ 'project_id': task_serv2.project_id.id,
+ 'task_id': task_serv2.id,
+ 'unit_amount': 4,
+ 'employee_id': self.employee_user.id,
+ })
+ timesheets |= self.env['account.analytic.line'].create({
+ 'name': 'Test Line',
+ 'project_id': task_serv2.project_id.id,
+ 'task_id': task_serv2.id,
+ 'unit_amount': 1,
+ 'employee_id': self.employee_manager.id,
+ })
+ self.assertTrue(all([billing_type == 'billable_time' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "All timesheets linked to the task should be on 'billable time'")
+ self.assertEqual(so_line_deliver_global_project.qty_to_invoice, 5, "Quantity to invoice should have been increased when logging timesheet on delivered quantities task")
+
+ # make task non billable
+ task_serv2.write({'sale_line_id': False})
+ self.assertTrue(all([billing_type == 'non_billable' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "Timesheet to a non billable task should be non billable too")
+
+ # make task billable again
+ task_serv2.write({'sale_line_id': so_line_deliver_global_project.id})
+ self.assertTrue(all([billing_type == 'billable_time' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "Timesheet to a billable time task should be billable")
+
+ # invoice SO, and validate invoice
+ invoice_id = sale_order.action_invoice_create()[0]
+ invoice = self.env['account.invoice'].browse(invoice_id)
+ invoice.action_invoice_open()
+
+ # try to update timesheets, catch error 'You cannot modify invoiced timesheet'
+ with self.assertRaises(UserError):
+ task_serv2.write({'sale_line_id': False})
diff --git a/addons/sale_timesheet/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py
index d75991411cb..bb8cf479a1d 100644
--- a/addons/sale_timesheet/tests/test_sale_timesheet.py
+++ b/addons/sale_timesheet/tests/test_sale_timesheet.py
@@ -1,140 +1,271 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo.tools import float_is_zero
+from odoo.tools import float_is_zero, float_compare
-from odoo.addons.sale.tests.test_sale_common import TestSale
+from odoo.addons.sale_timesheet.tests.common import CommonTest
-class TestSaleTimesheet(TestSale):
-
- def setUp(self):
- super(TestSaleTimesheet, self).setUp()
- # link hr employee to res.users of sale tests
- self.employee_user = self.env['hr.employee'].create({
- 'name': 'Employee User',
- 'user_id': self.user.id,
- })
- self.employee_manager = self.env['hr.employee'].create({
- 'name': 'Employee Manager',
- 'user_id': self.manager.id,
- })
+class TestSaleTimesheet(CommonTest):
+ """ This test suite provide tests for the 3 main flows of selling services:
+ - Selling services based on ordered quantities
+ - Selling timesheet based on delivered quantities
+ - Selling milestones, based on manual delivered quantities
+ For that, we check the task/project created, the invoiced amounts, the delivered
+ quantities changes, ...
+ """
def test_timesheet_order(self):
- """ Test timesheet invoicing with 'invoice on order' timetracked products """
- # intial so
- prod_ts = self.env.ref('product.service_order_01')
- so_vals = {
- 'partner_id': self.partner.id,
- 'partner_invoice_id': self.partner.id,
- 'partner_shipping_id': self.partner.id,
- 'order_line': [(0, 0, {'name': prod_ts.name, 'product_id': prod_ts.id, 'product_uom_qty': 50, 'product_uom': prod_ts.uom_id.id, 'price_unit': prod_ts.list_price})],
- 'pricelist_id': self.env.ref('product.list0').id,
- }
- so = self.env['sale.order'].create(so_vals)
- so.action_confirm()
- so.action_invoice_create()
+ """ Test timesheet invoicing with 'invoice on order' timetracked products
+ 1. create SO with 2 ordered product and confirm
+ 2. create invoice
+ 3. log timesheet
+ 4. add new SO line (ordered service)
+ 5. create new invoice
+ """
+ # create SO and confirm it
+ sale_order = self.env['sale.order'].create({
+ 'partner_id': self.partner_usd.id,
+ 'partner_invoice_id': self.partner_usd.id,
+ 'partner_shipping_id': self.partner_usd.id,
+ 'pricelist_id': self.pricelist_usd.id,
+ })
+ so_line_ordered_project_only = self.env['sale.order.line'].create({
+ 'name': self.product_order_timesheet4.name,
+ 'product_id': self.product_order_timesheet4.id,
+ 'product_uom_qty': 10,
+ 'product_uom': self.product_order_timesheet4.uom_id.id,
+ 'price_unit': self.product_order_timesheet4.list_price,
+ 'order_id': sale_order.id,
+ })
+ so_line_ordered_global_project = self.env['sale.order.line'].create({
+ 'name': self.product_order_timesheet2.name,
+ 'product_id': self.product_order_timesheet2.id,
+ 'product_uom_qty': 50,
+ 'product_uom': self.product_order_timesheet2.uom_id.id,
+ 'price_unit': self.product_order_timesheet2.list_price,
+ 'order_id': sale_order.id,
+ })
+ so_line_ordered_project_only.product_id_change()
+ so_line_ordered_global_project.product_id_change()
+ sale_order.action_confirm()
+ task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_global_project.id)])
- # let's log some timesheets
+ self.assertEqual(sale_order.tasks_count, 1, "One task should have been created on SO confirmation")
+ self.assertTrue(sale_order.project_project_id, "A project should have been created by the SO, when confirmed.")
+
+ # create invoice
+ sale_order.action_invoice_create()
+
+ # let's log some timesheets (on the project created by so_line_ordered_project_only)
self.env['account.analytic.line'].create({
'name': 'Test Line',
- 'project_id': so.project_project_id.id,
+ 'project_id': sale_order.project_project_id.id,
+ 'task_id': task_serv2.id,
'unit_amount': 10.5,
'employee_id': self.employee_user.id,
})
- self.assertEqual(so.order_line.qty_delivered, 10.5, 'Sale Timesheet: timesheet does not increase delivered quantity on so line')
- self.assertEqual(so.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
+ self.assertEqual(so_line_ordered_global_project.qty_delivered, 10.5, 'Timesheet directly on project does not increase delivered quantity on so line')
+ self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
self.env['account.analytic.line'].create({
'name': 'Test Line',
- 'project_id': so.project_project_id.id,
+ 'project_id': sale_order.project_project_id.id,
+ 'task_id': task_serv2.id,
'unit_amount': 39.5,
'employee_id': self.employee_user.id,
})
- self.assertEqual(so.order_line.qty_delivered, 50, 'Sale Timesheet: timesheet does not increase delivered quantity on so line')
- self.assertEqual(so.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
+ self.assertEqual(so_line_ordered_global_project.qty_delivered, 50, 'Sale Timesheet: timesheet does not increase delivered quantity on so line')
+ self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
self.env['account.analytic.line'].create({
'name': 'Test Line',
- 'project_id': so.project_project_id.id,
+ 'project_id': sale_order.project_project_id.id,
'unit_amount': 10,
'employee_id': self.employee_user.id,
})
- self.assertEqual(so.order_line.qty_delivered, 60, 'Sale Timesheet: timesheet does not increase delivered quantity on so line')
- self.assertEqual(so.invoice_status, 'upselling', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
+ self.assertEqual(so_line_ordered_project_only.qty_delivered, 0.0, 'Timesheet directly on project does not increase delivered quantity on so line')
+
+ # log timesheet on task in global project (higher than the initial ordrered qty)
+ self.env['account.analytic.line'].create({
+ 'name': 'Test Line',
+ 'project_id': sale_order.project_project_id.id,
+ 'task_id': task_serv2.id,
+ 'unit_amount': 5,
+ 'employee_id': self.employee_user.id,
+ })
+ self.assertEqual(sale_order.invoice_status, 'upselling', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
+
+ # add so line with produdct "create task in new project". (project will be the one from SO)
+ so_line_ordered_task_new_project = self.env['sale.order.line'].create({
+ 'name': self.product_order_timesheet3.name,
+ 'product_id': self.product_order_timesheet3.id,
+ 'product_uom_qty': 3,
+ 'product_uom': self.product_order_timesheet3.uom_id.id,
+ 'price_unit': self.product_order_timesheet3.list_price,
+ 'order_id': sale_order.id,
+ })
+ task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_task_new_project.id)])
+ self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: Adding a new service line (so line) should put the SO in "to invocie" state.')
+ self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation")
+
+ # create invoice
+ invoice_id = sale_order.action_invoice_create()
+ invoice = self.env['account.invoice'].browse(invoice_id)
+ self.assertEqual(len(sale_order.invoice_ids), 2, "A second invoice should have been created from the SO")
+ self.assertTrue(float_is_zero(invoice.amount_total - so_line_ordered_task_new_project.price_unit * 3, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong')
+ self.assertEqual(sale_order.project_project_id, task_serv3.project_id, "When creating task in new project, the task should be in SO project (if already exists), otherwise it created one.")
def test_timesheet_delivery(self):
- """ Test timesheet invoicing with 'invoice on delivery' timetracked products """
- inv_obj = self.env['account.invoice']
- # intial so
- prod_ts = self.env.ref('product.product_product_2')
- so_vals = {
- 'partner_id': self.partner.id,
- 'partner_invoice_id': self.partner.id,
- 'partner_shipping_id': self.partner.id,
- 'order_line': [(0, 0, {'name': prod_ts.name, 'product_id': prod_ts.id, 'product_uom_qty': 50, 'product_uom': prod_ts.uom_id.id, 'price_unit': prod_ts.list_price})],
- 'pricelist_id': self.env.ref('product.list0').id,
- }
- so = self.env['sale.order'].create(so_vals)
- so.action_confirm()
- self.assertEqual(so.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation')
+ """ Test timesheet invoicing with 'invoice on delivery' timetracked products
+ 1. Create SO and confirm it
+ 2. log timesheet
+ 3. create invoice
+ 4. log other timesheet
+ 5. create a second invoice
+ 6. add new SO line (delivered service)
+ """
+ # create SO and confirm it
+ sale_order = self.env['sale.order'].create({
+ 'partner_id': self.partner_usd.id,
+ 'partner_invoice_id': self.partner_usd.id,
+ 'partner_shipping_id': self.partner_usd.id,
+ 'pricelist_id': self.pricelist_usd.id,
+ })
+ so_line_deliver_global_project = self.env['sale.order.line'].create({
+ 'name': self.product_delivery_timesheet2.name,
+ 'product_id': self.product_delivery_timesheet2.id,
+ 'product_uom_qty': 50,
+ 'product_uom': self.product_delivery_timesheet2.uom_id.id,
+ 'price_unit': self.product_delivery_timesheet2.list_price,
+ 'order_id': sale_order.id,
+ })
+ so_line_deliver_task_project = self.env['sale.order.line'].create({
+ 'name': self.product_delivery_timesheet3.name,
+ 'product_id': self.product_delivery_timesheet3.id,
+ 'product_uom_qty': 20,
+ 'product_uom': self.product_delivery_timesheet3.uom_id.id,
+ 'price_unit': self.product_delivery_timesheet3.list_price,
+ 'order_id': sale_order.id,
+ })
+ so_line_deliver_global_project.product_id_change()
+ so_line_deliver_task_project.product_id_change()
+
+ # confirm SO
+ sale_order.action_confirm()
+ task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)])
+ task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)])
+
+ self.assertEqual(task_serv2.project_id, self.project_global, "Sale Timesheet: task should be created in global project")
+ self.assertTrue(task_serv2, "Sale Timesheet: on SO confirmation, a task should have been created in global project")
+ self.assertTrue(task_serv3, "Sale Timesheet: on SO confirmation, a task should have been created in a new project")
+ self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation')
+ self.assertEqual(sale_order.analytic_account_id, task_serv3.project_id.analytic_account_id, "SO should have create a project")
+ self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation")
+
# let's log some timesheets
self.env['account.analytic.line'].create({
'name': 'Test Line',
- 'project_id': so.project_project_id.id,
+ 'project_id': task_serv2.project_id.id, # global project
+ 'task_id': task_serv2.id,
'unit_amount': 10.5,
'employee_id': self.employee_manager.id,
})
- self.assertEqual(so.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so in "to invoice" status when logged')
- inv_id = so.action_invoice_create()
- inv = inv_obj.browse(inv_id)
- self.assertTrue(float_is_zero(inv.amount_total - so.order_line.price_unit * 10.5, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong')
+ self.assertEqual(so_line_deliver_global_project.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged')
+ self.assertEqual(so_line_deliver_task_project.invoice_status, 'no', 'Sale Timesheet: so line invoice status should not change when no timesheet linked to the line')
+ self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so in "to invoice" status when logged')
+ # invoice SO
+ invoice_id = sale_order.action_invoice_create()
+ invoice = self.env['account.invoice'].browse(invoice_id)
+ self.assertTrue(float_is_zero(invoice.amount_total - so_line_deliver_global_project.price_unit * 10.5, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong')
+
+ # log some timesheets again
self.env['account.analytic.line'].create({
'name': 'Test Line',
- 'project_id': so.project_project_id.id,
+ 'project_id': task_serv2.project_id.id, # global project
+ 'task_id': task_serv2.id,
'unit_amount': 39.5,
'employee_id': self.employee_user.id,
})
- self.assertEqual(so.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
- so.action_invoice_create()
- self.assertEqual(so.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should be invoiced completely by now')
+ self.assertEqual(so_line_deliver_global_project.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged')
+ self.assertEqual(so_line_deliver_task_project.invoice_status, 'no', 'Sale Timesheet: so line invoice status should not change when no timesheet linked to the line')
+ self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
+ # create a second invoice
+ sale_order.action_invoice_create()
+ self.assertEqual(so_line_deliver_global_project.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged')
+ self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" timesheets should be invoiced completely by now')
+
+ # add a line on SO
+ so_line_deliver_only_project = self.env['sale.order.line'].create({
+ 'name': self.product_delivery_timesheet4.name,
+ 'product_id': self.product_delivery_timesheet4.id,
+ 'product_uom_qty': 5,
+ 'product_uom': self.product_delivery_timesheet4.uom_id.id,
+ 'price_unit': self.product_delivery_timesheet4.list_price,
+ 'order_id': sale_order.id,
+ })
+ self.assertEqual(sale_order.project_project_id, task_serv3.project_id, "SO should not have create a second project, since so line 3 already create one project for the SO")
+
+ # let's log some timesheets on the project
self.env['account.analytic.line'].create({
'name': 'Test Line',
- 'project_id': so.project_project_id.id,
- 'unit_amount': 10,
+ 'project_id': sale_order.project_project_id.id, # global project
+ 'unit_amount': 7,
'employee_id': self.employee_user.id,
})
- self.assertEqual(so.invoice_status, 'to invoice', 'Sale Timesheet: supplementary timesheets do not change the status of the SO')
+ # import pdb;pdb.set_trace()
+ self.assertTrue(float_is_zero(so_line_deliver_only_project.qty_delivered, precision_digits=2), "Timesheeting on project should not incremented the delivered quantity on the SO line")
+ self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" timesheets should be invoiced completely by now')
- def test_timesheet_uom(self):
- """ Test timesheet invoicing and uom conversion """
- # intial so
- prod_ts = self.env.ref('product.product_product_2')
- uom_days = self.env.ref('product.product_uom_day')
- so_vals = {
- 'partner_id': self.partner.id,
- 'partner_invoice_id': self.partner.id,
- 'partner_shipping_id': self.partner.id,
- 'order_line': [(0, 0, {'name': prod_ts.name, 'product_id': prod_ts.id, 'product_uom_qty': 5, 'product_uom': uom_days.id, 'price_unit': prod_ts.list_price})],
- 'pricelist_id': self.env.ref('product.list0').id,
- }
- so = self.env['sale.order'].create(so_vals)
- so.action_confirm()
- # let's log some timesheets
+ def test_timesheet_manual(self):
+ """ Test timesheet invoicing with 'invoice on delivery' timetracked products
+ """
+ # create SO and confirm it
+ sale_order = self.env['sale.order'].create({
+ 'partner_id': self.partner_usd.id,
+ 'partner_invoice_id': self.partner_usd.id,
+ 'partner_shipping_id': self.partner_usd.id,
+ 'pricelist_id': self.pricelist_usd.id,
+ })
+ so_line_manual_global_project = self.env['sale.order.line'].create({
+ 'name': self.product_delivery_manual2.name,
+ 'product_id': self.product_delivery_manual2.id,
+ 'product_uom_qty': 50,
+ 'product_uom': self.product_delivery_manual2.uom_id.id,
+ 'price_unit': self.product_delivery_manual2.list_price,
+ 'order_id': sale_order.id,
+ })
+ so_line_manual_only_project = self.env['sale.order.line'].create({
+ 'name': self.product_delivery_manual4.name,
+ 'product_id': self.product_delivery_manual4.id,
+ 'product_uom_qty': 20,
+ 'product_uom': self.product_delivery_manual4.uom_id.id,
+ 'price_unit': self.product_delivery_manual4.list_price,
+ 'order_id': sale_order.id,
+ })
+
+ # confirm SO
+ sale_order.action_confirm()
+ self.assertTrue(sale_order.project_project_id, "Sales Order should have create a project")
+ self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: manually product should not need to be invoiced on so confirmation')
+
+ # let's log some timesheets (on task and project)
self.env['account.analytic.line'].create({
'name': 'Test Line',
- 'project_id': so.project_project_id.id,
- 'unit_amount': 16,
+ 'project_id': self.project_global.id, # global project
+ 'task_id': so_line_manual_global_project.task_id.id,
+ 'unit_amount': 6,
'employee_id': self.employee_manager.id,
})
- self.assertEqual(so.order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong')
self.env['account.analytic.line'].create({
'name': 'Test Line',
- 'project_id': so.project_project_id.id,
- 'unit_amount': 24,
- 'employee_id': self.employee_user.id,
+ 'project_id': sale_order.project_project_id.id, # global project
+ 'unit_amount': 3,
+ 'employee_id': self.employee_manager.id,
})
- so.action_invoice_create()
- self.assertEqual(so.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
+
+ self.assertEqual(so_line_manual_global_project.qty_to_invoice, 0.0, "Manual service should not be affected by timesheet on their created task.")
+ self.assertEqual(so_line_manual_only_project.qty_to_invoice, 0.0, "Manual service should not be affected by timesheet on their created project.")
+ self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation')
diff --git a/addons/sale_timesheet/tests/test_timesheet_revenue.py b/addons/sale_timesheet/tests/test_timesheet_revenue.py
index 19dd8a6f3c8..79c14e68c16 100644
--- a/addons/sale_timesheet/tests/test_timesheet_revenue.py
+++ b/addons/sale_timesheet/tests/test_timesheet_revenue.py
@@ -33,8 +33,9 @@ class TestSaleTimesheet(TestSale):
'uom_id': self.env.ref('product.product_uom_hour').id,
'uom_po_id': self.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-DELI',
- 'track_service': 'task',
- 'project_id': self.project.id
+ 'service_type': 'timesheet',
+ 'service_tracking': 'task_global_project',
+ 'project_id': self.project.id,
})
self.product_order = self.env['product.product'].create({
@@ -46,8 +47,9 @@ class TestSaleTimesheet(TestSale):
'uom_id': self.env.ref('product.product_uom_hour').id,
'uom_po_id': self.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-ORDER',
- 'track_service': 'task',
- 'project_id': self.project.id
+ 'service_type': 'timesheet',
+ 'service_tracking': 'task_global_project',
+ 'project_id': self.project.id,
})
# pricelists
@@ -63,7 +65,6 @@ class TestSaleTimesheet(TestSale):
'currency_id': self.env.ref('base.EUR').id,
'company_id': self.env.user.company_id.id,
})
-
# partners
self.partner_usd = self.env['res.partner'].create({
'name': 'Cool Partner in USD',
diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml
index 14fc5fd6195..b01ee3de6e2 100644
--- a/addons/sale_timesheet/views/hr_timesheet_templates.xml
+++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml
@@ -15,25 +15,40 @@
-
Overview
+
+
-
+
Hours
- h
+
Time and material
-
+
Rates
%
Time and material
-
+
Profitability
@@ -45,7 +60,7 @@
- h
+
Fixed
@@ -61,13 +76,13 @@
- h
+
- Non Billable
+ No task found
- %
+ %
- Non Billable
+ No task found
@@ -75,9 +90,21 @@
Cost
+
+
+
+
+ Non Billable
+
+ %
+
+ Non Billable
+
+
+
- h
+
Total
@@ -102,11 +129,20 @@
-
+
@@ -116,15 +152,17 @@
- hours
+
diff --git a/addons/sale_timesheet/views/hr_timesheet_views.xml b/addons/sale_timesheet/views/hr_timesheet_views.xml
index 70f3be7ac24..a9beb7585b3 100644
--- a/addons/sale_timesheet/views/hr_timesheet_views.xml
+++ b/addons/sale_timesheet/views/hr_timesheet_views.xml
@@ -17,11 +17,23 @@
+
+ account.analytic.line.pivot.revenue
+ account.analytic.line
+
+
+
+
+
+
+
+
+
- Billing Rate Analysis
+ By Billing Rate
account.analytic.line
pivot,graph
[('project_id', '!=', False)]
@@ -44,9 +56,48 @@
+ name="By Billing Rate"
+ sequence="40"/>
+
+
+ Costs and Revenues
+ account.analytic.line
+
+ [('project_id', '!=', False)]
+ {
+ 'search_default_groupby_project':1,
+ 'search_default_groupby_employee':2,
+ }
+
+
+
+
+ pivot
+
+
+
+
+
+
+ tree
+
+
+
+
+
+
+ form
+
+
+
+
+