mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-27 11:32:04 +02:00
19.0 vanilla
This commit is contained in:
parent
79f83631d5
commit
73afc09215
6267 changed files with 1534193 additions and 1130106 deletions
|
|
@ -1,11 +1,16 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import account
|
||||
from . import account_move_line
|
||||
from . import account_move
|
||||
from . import product
|
||||
from . import project
|
||||
from . import project_update
|
||||
from . import sale_order
|
||||
from . import res_config_settings
|
||||
from . import hr_employee
|
||||
from . import hr_timesheet
|
||||
from . import product_product
|
||||
from . import product_template
|
||||
from . import project_project
|
||||
from . import project_sale_line_employee_map
|
||||
from . import project_task
|
||||
from . import res_config_settings
|
||||
from . import sale_order_line
|
||||
from . import sale_order
|
||||
from . import account_move_reversal
|
||||
|
|
|
|||
|
|
@ -1,30 +1,31 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.osv import expression
|
||||
from odoo.fields import Domain
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
timesheet_ids = fields.One2many('account.analytic.line', 'timesheet_invoice_id', string='Timesheets', readonly=True, copy=False)
|
||||
timesheet_count = fields.Integer("Number of timesheets", compute='_compute_timesheet_count')
|
||||
timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id')
|
||||
timesheet_total_duration = fields.Integer("Timesheet Total Duration", compute='_compute_timesheet_total_duration', help="Total recorded duration, expressed in the encoding UoM, and rounded to the unit")
|
||||
timesheet_ids = fields.One2many('account.analytic.line', 'timesheet_invoice_id', string='Timesheets', readonly=True, copy=False, export_string_translation=False)
|
||||
timesheet_count = fields.Integer("Number of timesheets", compute='_compute_timesheet_count', compute_sudo=True, export_string_translation=False)
|
||||
timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id', export_string_translation=False)
|
||||
timesheet_total_duration = fields.Integer("Timesheet Total Duration",
|
||||
compute='_compute_timesheet_total_duration', compute_sudo=True,
|
||||
help="Total recorded duration, expressed in the encoding UoM, and rounded to the unit")
|
||||
|
||||
@api.depends('timesheet_ids', 'company_id.timesheet_encode_uom_id')
|
||||
def _compute_timesheet_total_duration(self):
|
||||
if not self.user_has_groups('hr_timesheet.group_hr_timesheet_user'):
|
||||
if not self.env.user.has_group('hr_timesheet.group_hr_timesheet_user'):
|
||||
self.timesheet_total_duration = 0
|
||||
return
|
||||
group_data = self.env['account.analytic.line']._read_group([
|
||||
('timesheet_invoice_id', 'in', self.ids)
|
||||
], ['timesheet_invoice_id', 'unit_amount'], ['timesheet_invoice_id'])
|
||||
], ['timesheet_invoice_id'], ['unit_amount:sum'])
|
||||
timesheet_unit_amount_dict = defaultdict(float)
|
||||
timesheet_unit_amount_dict.update({data['timesheet_invoice_id'][0]: data['unit_amount'] for data in group_data})
|
||||
timesheet_unit_amount_dict.update({timesheet_invoice.id: amount for timesheet_invoice, amount in group_data})
|
||||
for invoice in self:
|
||||
total_time = invoice.company_id.project_time_mode_id._compute_quantity(
|
||||
timesheet_unit_amount_dict[invoice.id],
|
||||
|
|
@ -35,8 +36,8 @@ class AccountMove(models.Model):
|
|||
|
||||
@api.depends('timesheet_ids')
|
||||
def _compute_timesheet_count(self):
|
||||
timesheet_data = self.env['account.analytic.line']._read_group([('timesheet_invoice_id', 'in', self.ids)], ['timesheet_invoice_id'], ['timesheet_invoice_id'])
|
||||
mapped_data = dict([(t['timesheet_invoice_id'][0], t['timesheet_invoice_id_count']) for t in timesheet_data])
|
||||
timesheet_data = self.env['account.analytic.line']._read_group([('timesheet_invoice_id', 'in', self.ids)], ['timesheet_invoice_id'], ['__count'])
|
||||
mapped_data = {timesheet_invoice.id: count for timesheet_invoice, count in timesheet_data}
|
||||
for invoice in self:
|
||||
invoice.timesheet_count = mapped_data.get(invoice.id, 0)
|
||||
|
||||
|
|
@ -48,7 +49,7 @@ class AccountMove(models.Model):
|
|||
'domain': [('project_id', '!=', False)],
|
||||
'res_model': 'account.analytic.line',
|
||||
'view_id': False,
|
||||
'view_mode': 'tree,form',
|
||||
'view_mode': 'list,form',
|
||||
'help': _("""
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Record timesheets
|
||||
|
|
@ -76,62 +77,29 @@ class AccountMove(models.Model):
|
|||
"""
|
||||
for line in self.filtered(lambda i: i.move_type == 'out_invoice' and i.state == 'draft').invoice_line_ids:
|
||||
sale_line_delivery = line.sale_line_ids.filtered(lambda sol: sol.product_id.invoice_policy == 'delivery' and sol.product_id.service_type == 'timesheet')
|
||||
if not start_date and not end_date:
|
||||
start_date, end_date = self._get_range_dates(sale_line_delivery.order_id)
|
||||
if sale_line_delivery:
|
||||
domain = line._timesheet_domain_get_invoiced_lines(sale_line_delivery)
|
||||
domain = Domain(line._timesheet_domain_get_invoiced_lines(sale_line_delivery))
|
||||
if start_date:
|
||||
domain = expression.AND([domain, [('date', '>=', start_date)]])
|
||||
domain &= Domain('date', '>=', start_date)
|
||||
if end_date:
|
||||
domain = expression.AND([domain, [('date', '<=', end_date)]])
|
||||
domain &= Domain('date', '<=', end_date)
|
||||
timesheets = self.env['account.analytic.line'].sudo().search(domain)
|
||||
timesheets.write({'timesheet_invoice_id': line.move_id.id})
|
||||
|
||||
def _get_range_dates(self, order):
|
||||
# A method that can be overridden
|
||||
# to set the start and end dates according to order values
|
||||
return None, None
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
@api.model
|
||||
def _timesheet_domain_get_invoiced_lines(self, sale_line_delivery):
|
||||
""" Get the domain for the timesheet to link to the created invoice
|
||||
:param sale_line_delivery: recordset of sale.order.line to invoice
|
||||
:return a normalized domain
|
||||
"""
|
||||
return [
|
||||
('so_line', 'in', sale_line_delivery.ids),
|
||||
def action_post(self):
|
||||
result = super().action_post()
|
||||
credit_notes = self.filtered(lambda move: move.move_type == 'out_refund' and move.reversed_entry_id)
|
||||
timesheets_sudo = self.env['account.analytic.line'].sudo().search([
|
||||
('timesheet_invoice_id', 'in', credit_notes.reversed_entry_id.ids),
|
||||
('so_line', 'in', credit_notes.invoice_line_ids.sale_line_ids.ids),
|
||||
('project_id', '!=', False),
|
||||
'|', '|',
|
||||
('timesheet_invoice_id', '=', False),
|
||||
'&',
|
||||
('timesheet_invoice_id.state', '=', 'cancel'),
|
||||
('timesheet_invoice_id.payment_state', '!=', 'invoicing_legacy'),
|
||||
('timesheet_invoice_id.payment_state', '=', 'reversed')
|
||||
]
|
||||
|
||||
def unlink(self):
|
||||
move_line_read_group = self.env['account.move.line'].search_read([
|
||||
('move_id.move_type', '=', 'out_invoice'),
|
||||
('move_id.state', '=', 'draft'),
|
||||
('sale_line_ids.product_id.invoice_policy', '=', 'delivery'),
|
||||
('sale_line_ids.product_id.service_type', '=', 'timesheet'),
|
||||
('id', 'in', self.ids)],
|
||||
['move_id', 'sale_line_ids'])
|
||||
|
||||
sale_line_ids_per_move = defaultdict(lambda: self.env['sale.order.line'])
|
||||
for move_line in move_line_read_group:
|
||||
sale_line_ids_per_move[move_line['move_id'][0]] += self.env['sale.order.line'].browse(move_line['sale_line_ids'])
|
||||
|
||||
timesheet_read_group = self.sudo().env['account.analytic.line']._read_group([
|
||||
('timesheet_invoice_id.move_type', '=', 'out_invoice'),
|
||||
('timesheet_invoice_id.state', '=', 'draft'),
|
||||
('timesheet_invoice_id', 'in', self.move_id.ids)],
|
||||
['timesheet_invoice_id', 'so_line', 'ids:array_agg(id)'],
|
||||
['timesheet_invoice_id', 'so_line'],
|
||||
lazy=False)
|
||||
|
||||
timesheet_ids = []
|
||||
for timesheet in timesheet_read_group:
|
||||
move_id = timesheet['timesheet_invoice_id'][0]
|
||||
if timesheet['so_line'] and timesheet['so_line'][0] in sale_line_ids_per_move[move_id].ids:
|
||||
timesheet_ids += timesheet['ids']
|
||||
|
||||
self.sudo().env['account.analytic.line'].browse(timesheet_ids).write({'timesheet_invoice_id': False})
|
||||
return super().unlink()
|
||||
])
|
||||
timesheets_sudo.write({'timesheet_invoice_id': False})
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
@api.model
|
||||
def _timesheet_domain_get_invoiced_lines(self, sale_line_delivery):
|
||||
""" Get the domain for the timesheet to link to the created invoice
|
||||
:param sale_line_delivery: recordset of sale.order.line to invoice
|
||||
:return a normalized domain
|
||||
"""
|
||||
return [
|
||||
('so_line', 'in', sale_line_delivery.ids),
|
||||
('project_id', '!=', False),
|
||||
'|', '|',
|
||||
('timesheet_invoice_id', '=', False),
|
||||
'&',
|
||||
('timesheet_invoice_id.state', '=', 'cancel'),
|
||||
('timesheet_invoice_id.payment_state', '!=', 'invoicing_legacy'),
|
||||
('timesheet_invoice_id.payment_state', '=', 'reversed')
|
||||
]
|
||||
|
||||
def unlink(self):
|
||||
move_line_read_group = self.env['account.move.line'].search_read([
|
||||
('move_id.move_type', '=', 'out_invoice'),
|
||||
('move_id.state', '=', 'draft'),
|
||||
('sale_line_ids.product_id.invoice_policy', '=', 'delivery'),
|
||||
('sale_line_ids.product_id.service_type', '=', 'timesheet'),
|
||||
('id', 'in', self.ids)],
|
||||
['move_id', 'sale_line_ids'])
|
||||
|
||||
sale_line_ids_per_move = defaultdict(lambda: self.env['sale.order.line'])
|
||||
for move_line in move_line_read_group:
|
||||
sale_line_ids_per_move[move_line['move_id'][0]] += self.env['sale.order.line'].browse(move_line['sale_line_ids'])
|
||||
|
||||
timesheet_read_group = self.sudo().env['account.analytic.line']._read_group([
|
||||
('timesheet_invoice_id.move_type', '=', 'out_invoice'),
|
||||
('timesheet_invoice_id.state', '=', 'draft'),
|
||||
('timesheet_invoice_id', 'in', self.move_id.ids)],
|
||||
['timesheet_invoice_id', 'so_line'],
|
||||
['id:array_agg'])
|
||||
|
||||
timesheet_ids = []
|
||||
for timesheet_invoice, so_line, ids in timesheet_read_group:
|
||||
if so_line.id in sale_line_ids_per_move[timesheet_invoice.id].ids:
|
||||
timesheet_ids += ids
|
||||
|
||||
self.sudo().env['account.analytic.line'].browse(timesheet_ids).write({'timesheet_invoice_id': False})
|
||||
return super().unlink()
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
from odoo import models
|
||||
|
||||
|
||||
class AccountMoveReversal(models.TransientModel):
|
||||
_inherit = 'account.move.reversal'
|
||||
|
||||
def reverse_moves(self, is_modify=False):
|
||||
if is_modify:
|
||||
moves = self.move_ids.filtered(lambda m: m.move_type == 'out_invoice')
|
||||
timesheets_sudo = self.env['account.analytic.line'].sudo().search([
|
||||
('timesheet_invoice_id', 'in', moves.ids),
|
||||
])
|
||||
if timesheets_sudo:
|
||||
timesheets_sudo.write({'timesheet_invoice_id': False})
|
||||
return super().reverse_moves(is_modify)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
result = super().default_get(fields)
|
||||
project_company_id = self.env.context.get('create_project_employee_mapping', False)
|
||||
if project_company_id:
|
||||
result['company_id'] = project_company_id
|
||||
return result
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.osv import expression
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools.misc import unquote
|
||||
|
||||
TIMESHEET_INVOICE_TYPES = [
|
||||
|
|
@ -12,31 +11,23 @@ TIMESHEET_INVOICE_TYPES = [
|
|||
('billable_fixed', 'Billed at a Fixed price'),
|
||||
('billable_milestones', 'Billed on Milestones'),
|
||||
('billable_manual', 'Billed Manually'),
|
||||
('non_billable', 'Non Billable Tasks'),
|
||||
('non_billable', 'Non-Billable'),
|
||||
('timesheet_revenues', 'Timesheet Revenues'),
|
||||
('service_revenues', 'Service Revenues'),
|
||||
('other_revenues', 'Other revenues'),
|
||||
('other_costs', 'Other costs'),
|
||||
]
|
||||
|
||||
|
||||
class AccountAnalyticLine(models.Model):
|
||||
_inherit = 'account.analytic.line'
|
||||
|
||||
def _default_sale_line_domain(self):
|
||||
domain = super(AccountAnalyticLine, self)._default_sale_line_domain()
|
||||
return expression.OR([domain, [('qty_delivered_method', '=', 'timesheet')]])
|
||||
|
||||
def _get_sellable_line_domain(self, domain):
|
||||
return self.env['project.task']._get_sellable_line_domain(domain)
|
||||
|
||||
def _domain_so_line(self):
|
||||
domain = expression.AND([
|
||||
domain = Domain.AND([
|
||||
self.env['sale.order.line']._sellable_lines_domain(),
|
||||
self.env['sale.order.line']._domain_sale_line_service(),
|
||||
[
|
||||
('is_service', '=', True),
|
||||
('is_expense', '=', False),
|
||||
('state', 'in', ['sale', 'done']),
|
||||
('order_partner_id', 'child_of', unquote('commercial_partner_id')),
|
||||
('order_partner_id.commercial_partner_id', '=', unquote('commercial_partner_id')),
|
||||
],
|
||||
])
|
||||
return str(domain)
|
||||
|
|
@ -46,26 +37,30 @@ class AccountAnalyticLine(models.Model):
|
|||
commercial_partner_id = fields.Many2one('res.partner', compute="_compute_commercial_partner")
|
||||
timesheet_invoice_id = fields.Many2one('account.move', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet", index='btree_not_null')
|
||||
so_line = fields.Many2one(compute="_compute_so_line", store=True, readonly=False,
|
||||
domain=_domain_so_line,
|
||||
domain=_domain_so_line, falsy_value_label="Non-billable",
|
||||
help="Sales order item to which the time spent will be added in order to be invoiced to your customer. Remove the sales order item for the timesheet entry to be non-billable.")
|
||||
# we needed to store it only in order to be able to groupby in the portal
|
||||
order_id = fields.Many2one(related='so_line.order_id', store=True, readonly=True, index=True)
|
||||
is_so_line_edited = fields.Boolean("Is Sales Order Item Manually Edited")
|
||||
allow_billable = fields.Boolean(related="project_id.allow_billable")
|
||||
sale_order_state = fields.Selection(related='order_id.state')
|
||||
|
||||
@api.depends('project_id.commercial_partner_id', 'task_id.commercial_partner_id')
|
||||
@api.depends('project_id.partner_id.commercial_partner_id', 'task_id.partner_id.commercial_partner_id')
|
||||
def _compute_commercial_partner(self):
|
||||
for timesheet in self:
|
||||
timesheet.commercial_partner_id = timesheet.task_id.commercial_partner_id or timesheet.project_id.commercial_partner_id
|
||||
timesheet.commercial_partner_id = timesheet.task_id.sudo().partner_id.commercial_partner_id or timesheet.project_id.sudo().partner_id.commercial_partner_id
|
||||
|
||||
@api.depends('so_line.product_id', 'project_id', 'amount')
|
||||
@api.depends('so_line.product_id', 'project_id.billing_type', 'amount')
|
||||
def _compute_timesheet_invoice_type(self):
|
||||
for timesheet in self:
|
||||
if timesheet.project_id: # AAL will be set to False
|
||||
invoice_type = 'non_billable' if not timesheet.so_line else False
|
||||
if timesheet.so_line and timesheet.so_line.product_id.type == 'service':
|
||||
invoice_type = False
|
||||
if not timesheet.so_line:
|
||||
invoice_type = 'non_billable' if timesheet.project_id.billing_type != 'manually' else 'billable_manual'
|
||||
elif timesheet.so_line.product_id.type == 'service':
|
||||
if timesheet.so_line.product_id.invoice_policy == 'delivery':
|
||||
if timesheet.so_line.product_id.service_type == 'timesheet':
|
||||
invoice_type = 'timesheet_revenues' if timesheet.amount > 0 else 'billable_time'
|
||||
invoice_type = 'timesheet_revenues' if timesheet.amount > 0 and timesheet.unit_amount > 0 else 'billable_time'
|
||||
else:
|
||||
service_type = timesheet.so_line.product_id.service_type
|
||||
invoice_type = f'billable_{service_type}' if service_type in ['milestones', 'manual'] else 'billable_fixed'
|
||||
|
|
@ -73,7 +68,7 @@ class AccountAnalyticLine(models.Model):
|
|||
invoice_type = 'billable_fixed'
|
||||
timesheet.timesheet_invoice_type = invoice_type
|
||||
else:
|
||||
if timesheet.amount >= 0:
|
||||
if timesheet.amount >= 0 and timesheet.unit_amount >= 0:
|
||||
if timesheet.so_line and timesheet.so_line.product_id.type == 'service':
|
||||
timesheet.timesheet_invoice_type = 'service_revenues'
|
||||
else:
|
||||
|
|
@ -94,6 +89,9 @@ class AccountAnalyticLine(models.Model):
|
|||
def _compute_project_id(self):
|
||||
super(AccountAnalyticLine, self.filtered(lambda t: t._is_not_billed()))._compute_project_id()
|
||||
|
||||
def _is_readonly(self):
|
||||
return super()._is_readonly() or not self._is_not_billed()
|
||||
|
||||
def _is_not_billed(self):
|
||||
self.ensure_one()
|
||||
return not self.timesheet_invoice_id or (self.timesheet_invoice_id.state == 'cancel' and self.timesheet_invoice_id.payment_state != 'invoicing_legacy')
|
||||
|
|
@ -101,16 +99,12 @@ class AccountAnalyticLine(models.Model):
|
|||
def _check_timesheet_can_be_billed(self):
|
||||
return self.so_line in self.project_id.mapped('sale_line_employee_ids.sale_line_id') | self.task_id.sale_line_id | self.project_id.sale_line_id
|
||||
|
||||
def write(self, values):
|
||||
# prevent to update invoiced timesheets if one line is of type delivery
|
||||
self._check_can_write(values)
|
||||
result = super(AccountAnalyticLine, self).write(values)
|
||||
return result
|
||||
|
||||
def _check_can_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 t: t.timesheet_invoice_id and t.timesheet_invoice_id.state != 'cancel'):
|
||||
if any(field_name in values for field_name in ['unit_amount', 'employee_id', 'project_id', 'task_id', 'so_line', 'date']):
|
||||
raise UserError(_('You cannot modify timesheets that are already invoiced.'))
|
||||
return super()._check_can_write(values)
|
||||
|
||||
def _timesheet_determine_sale_line(self):
|
||||
""" Deduce the SO line associated to the timesheet line:
|
||||
|
|
@ -133,8 +127,8 @@ class AccountAnalyticLine(models.Model):
|
|||
else: # then pricing_type = 'employee_rate'
|
||||
map_entry = self.project_id.sale_line_employee_ids.filtered(
|
||||
lambda map_entry:
|
||||
map_entry.employee_id == self.employee_id
|
||||
and map_entry.sale_line_id.order_partner_id.commercial_partner_id == self.task_id.commercial_partner_id
|
||||
map_entry.employee_id == (self.employee_id or self.env.user.employee_id)
|
||||
and map_entry.sale_line_id.order_partner_id.commercial_partner_id == self.task_id.partner_id.commercial_partner_id
|
||||
)
|
||||
if map_entry:
|
||||
return map_entry.sale_line_id
|
||||
|
|
@ -146,8 +140,8 @@ class AccountAnalyticLine(models.Model):
|
|||
since in ordered quantity, the timesheet quantity is not invoiced,
|
||||
thus there is no meaning of showing invoice with ordered quantity.
|
||||
"""
|
||||
domain = super(AccountAnalyticLine, self)._timesheet_get_portal_domain()
|
||||
return expression.AND([domain, [('timesheet_invoice_type', 'in', ['billable_time', 'non_billable', 'billable_fixed'])]])
|
||||
domain = super()._timesheet_get_portal_domain()
|
||||
return Domain.AND([domain, [('timesheet_invoice_type', 'in', ['billable_time', 'non_billable', 'billable_fixed', 'billable_manual', 'billable_milestones'])]])
|
||||
|
||||
@api.model
|
||||
def _timesheet_get_sale_domain(self, order_lines_ids, invoice_ids):
|
||||
|
|
@ -168,7 +162,7 @@ class AccountAnalyticLine(models.Model):
|
|||
]
|
||||
|
||||
def _get_timesheets_to_merge(self):
|
||||
res = super(AccountAnalyticLine, self)._get_timesheets_to_merge()
|
||||
res = super()._get_timesheets_to_merge()
|
||||
return res.filtered(lambda l: not l.timesheet_invoice_id or l.timesheet_invoice_id.state != 'posted')
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
|
|
@ -187,9 +181,67 @@ class AccountAnalyticLine(models.Model):
|
|||
return mapping_entry.cost
|
||||
return super()._hourly_cost()
|
||||
|
||||
def action_sale_order_from_timesheet(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Sales Order'),
|
||||
'res_model': 'sale.order',
|
||||
'views': [[False, 'form']],
|
||||
'context': {'create': False, 'show_sale': True},
|
||||
'res_id': self.order_id.id,
|
||||
}
|
||||
|
||||
def action_invoice_from_timesheet(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Invoice'),
|
||||
'res_model': 'account.move',
|
||||
'views': [[False, 'form']],
|
||||
'context': {'create': False},
|
||||
'res_id': self.timesheet_invoice_id.id,
|
||||
}
|
||||
|
||||
def _timesheet_convert_sol_uom(self, sol, to_unit):
|
||||
to_uom = self.env.ref(to_unit)
|
||||
return round(sol.product_uom._compute_quantity(sol.product_uom_qty, to_uom, raise_if_failure=False), 2)
|
||||
return round(sol.product_uom_id._compute_quantity(sol.product_uom_qty, to_uom, raise_if_failure=False), 2)
|
||||
|
||||
def _is_updatable_timesheet(self):
|
||||
return super()._is_updatable_timesheet and self._is_not_billed()
|
||||
|
||||
def _timesheet_preprocess_get_accounts(self, vals):
|
||||
so_line = self.env['sale.order.line'].browse(vals.get('so_line'))
|
||||
if not (so_line and (distribution := so_line.sudo().analytic_distribution)):
|
||||
return super()._timesheet_preprocess_get_accounts(vals)
|
||||
|
||||
company = self.env['res.company'].browse(vals.get('company_id'))
|
||||
accounts = self.env['account.analytic.account'].browse([
|
||||
int(account_id) for account_id in next(iter(distribution)).split(',')
|
||||
]).exists()
|
||||
|
||||
if not accounts:
|
||||
return super()._timesheet_preprocess_get_accounts(vals)
|
||||
|
||||
plan_column_names = {account.root_plan_id._column_name() for account in accounts}
|
||||
mandatory_plans = [plan for plan in self._get_mandatory_plans(company, business_domain='timesheet') if plan['column_name'] != 'account_id']
|
||||
missing_plan_names = [plan['name'] for plan in mandatory_plans if plan['column_name'] not in plan_column_names]
|
||||
if missing_plan_names:
|
||||
raise ValidationError(_(
|
||||
"'%(missing_plan_names)s' analytic plan(s) required on the analytic distribution of the sale order item '%(so_line_name)s' linked to the timesheet.",
|
||||
missing_plan_names=missing_plan_names,
|
||||
so_line_name=so_line.name,
|
||||
))
|
||||
|
||||
account_id_per_fname = dict.fromkeys(self._get_plan_fnames(), False)
|
||||
for account in accounts:
|
||||
account_id_per_fname[account.root_plan_id._column_name()] = account.id
|
||||
return account_id_per_fname
|
||||
|
||||
def _timesheet_postprocess(self, values):
|
||||
if values.get('so_line'):
|
||||
for timesheet in self.sudo():
|
||||
# If no account_id was found in the SOL's distribution, we fallback on the project's account_id
|
||||
if not timesheet.account_id:
|
||||
timesheet.account_id = timesheet.project_id.account_id
|
||||
return super()._timesheet_postprocess(values)
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import threading
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = 'product.template'
|
||||
|
||||
def _selection_service_policy(self):
|
||||
service_policies = super()._selection_service_policy()
|
||||
service_policies.insert(1, ('delivered_timesheet', _('Based on Timesheets')))
|
||||
return service_policies
|
||||
|
||||
service_type = fields.Selection(selection_add=[
|
||||
('timesheet', 'Timesheets on project (one fare per SO/Project)'),
|
||||
], ondelete={'timesheet': 'set manual'})
|
||||
# override domain
|
||||
project_id = fields.Many2one(domain="[('company_id', '=', current_company_id), ('allow_billable', '=', True), ('pricing_type', '=', 'task_rate'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet', True])]")
|
||||
project_template_id = fields.Many2one(domain="[('company_id', '=', current_company_id), ('allow_billable', '=', True), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet', True])]")
|
||||
service_upsell_threshold = fields.Float('Threshold', default=1, help="Percentage of time delivered compared to the prepaid amount that must be reached for the upselling opportunity activity to be triggered.")
|
||||
service_upsell_threshold_ratio = fields.Char(compute='_compute_service_upsell_threshold_ratio')
|
||||
|
||||
@api.depends('uom_id', 'company_id')
|
||||
def _compute_service_upsell_threshold_ratio(self):
|
||||
product_uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
uom_unit = self.env.ref('uom.product_uom_unit')
|
||||
company_uom = self.env.company.timesheet_encode_uom_id
|
||||
for record in self:
|
||||
if not record.uom_id or record.uom_id != uom_unit or\
|
||||
product_uom_hour.factor == record.uom_id.factor or\
|
||||
record.uom_id.category_id not in [product_uom_hour.category_id, uom_unit.category_id]:
|
||||
record.service_upsell_threshold_ratio = False
|
||||
continue
|
||||
else:
|
||||
timesheet_encode_uom = record.company_id.timesheet_encode_uom_id or company_uom
|
||||
record.service_upsell_threshold_ratio = f'(1 {record.uom_id.name} = {timesheet_encode_uom.factor / product_uom_hour.factor:.2f} {timesheet_encode_uom.name})'
|
||||
|
||||
def _compute_visible_expense_policy(self):
|
||||
visibility = self.user_has_groups('project.group_project_user')
|
||||
for product_template in self:
|
||||
if not product_template.visible_expense_policy:
|
||||
product_template.visible_expense_policy = visibility
|
||||
return super(ProductTemplate, self)._compute_visible_expense_policy()
|
||||
|
||||
@api.depends('service_tracking', 'service_policy', 'type')
|
||||
def _compute_product_tooltip(self):
|
||||
super()._compute_product_tooltip()
|
||||
for record in self.filtered(lambda record: record.type == 'service'):
|
||||
if record.service_policy == 'delivered_timesheet':
|
||||
if record.service_tracking == 'no':
|
||||
record.product_tooltip = _(
|
||||
"Invoice based on timesheets (delivered quantity) on projects or tasks "
|
||||
"you'll create later on."
|
||||
)
|
||||
elif record.service_tracking == 'task_global_project':
|
||||
record.product_tooltip = _(
|
||||
"Invoice based on timesheets (delivered quantity), and create a task in "
|
||||
"an existing project to track the time spent."
|
||||
)
|
||||
elif record.service_tracking == 'task_in_project':
|
||||
record.product_tooltip = _(
|
||||
"Invoice based on timesheets (delivered quantity), and create a project "
|
||||
"for the order with a task for each sales order line to track the time "
|
||||
"spent."
|
||||
)
|
||||
elif record.service_tracking == 'project_only':
|
||||
record.product_tooltip = _(
|
||||
"Invoice based on timesheets (delivered quantity), and create an empty "
|
||||
"project for the order to track the time spent."
|
||||
)
|
||||
|
||||
def _get_service_to_general_map(self):
|
||||
return {
|
||||
**super()._get_service_to_general_map(),
|
||||
'delivered_timesheet': ('delivery', 'timesheet'),
|
||||
'ordered_prepaid': ('order', 'timesheet'),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_onchange_service_policy_updates(self, service_tracking, service_policy, project_id, project_template_id):
|
||||
vals = {}
|
||||
if service_tracking != 'no' and service_policy == 'delivered_timesheet':
|
||||
if project_id and not project_id.allow_timesheets:
|
||||
vals['project_id'] = False
|
||||
elif project_template_id and not project_template_id.allow_timesheets:
|
||||
vals['project_template_id'] = False
|
||||
return vals
|
||||
|
||||
@api.onchange('service_policy')
|
||||
def _onchange_service_policy(self):
|
||||
self._inverse_service_policy()
|
||||
vals = self._get_onchange_service_policy_updates(self.service_tracking,
|
||||
self.service_policy,
|
||||
self.project_id,
|
||||
self.project_template_id)
|
||||
if vals:
|
||||
self.update(vals)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_master_data(self):
|
||||
time_product = self.env.ref('sale_timesheet.time_product')
|
||||
if time_product.product_tmpl_id in self:
|
||||
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be archived nor deleted.', time_product.name))
|
||||
|
||||
def write(self, vals):
|
||||
# timesheet product can't be archived or linked to a company
|
||||
test_mode = getattr(threading.current_thread(), 'testing', False) or self.env.registry.in_test_mode()
|
||||
if not test_mode and 'active' in vals and not vals['active']:
|
||||
time_product = self.env.ref('sale_timesheet.time_product')
|
||||
if time_product.product_tmpl_id in self:
|
||||
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be archived nor deleted.', time_product.name))
|
||||
# TODO: avoid duplicate code by joining both conditions in master
|
||||
if not test_mode and 'company_id' in vals and vals['company_id']:
|
||||
time_product = self.env.ref('sale_timesheet.time_product')
|
||||
if time_product.product_tmpl_id in self:
|
||||
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be linked to a company.', time_product.name))
|
||||
return super(ProductTemplate, self).write(vals)
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = 'product.product'
|
||||
|
||||
def _is_delivered_timesheet(self):
|
||||
""" Check if the product is a delivered timesheet """
|
||||
self.ensure_one()
|
||||
return self.type == 'service' and self.service_policy == 'delivered_timesheet'
|
||||
|
||||
@api.onchange('service_policy')
|
||||
def _onchange_service_policy(self):
|
||||
self._inverse_service_policy()
|
||||
vals = self.product_tmpl_id._get_onchange_service_policy_updates(self.service_tracking,
|
||||
self.service_policy,
|
||||
self.project_id,
|
||||
self.project_template_id)
|
||||
if vals:
|
||||
self.update(vals)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_master_data(self):
|
||||
time_product = self.env.ref('sale_timesheet.time_product')
|
||||
if time_product in self:
|
||||
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be archived nor deleted.') % time_product.name)
|
||||
|
||||
def write(self, vals):
|
||||
# timesheet product can't be archived
|
||||
test_mode = getattr(threading.current_thread(), 'testing', False) or self.env.registry.in_test_mode()
|
||||
if not test_mode and 'active' in vals and not vals['active']:
|
||||
time_product = self.env.ref('sale_timesheet.time_product')
|
||||
if time_product in self:
|
||||
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be archived nor deleted.') % time_product.name)
|
||||
return super(ProductProduct, self).write(vals)
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import threading
|
||||
|
||||
from odoo import api, models, tools, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = 'product.product'
|
||||
|
||||
def _is_delivered_timesheet(self):
|
||||
""" Check if the product is a delivered timesheet """
|
||||
self.ensure_one()
|
||||
return self.type == 'service' and self.service_policy == 'delivered_timesheet'
|
||||
|
||||
@api.onchange('type', 'service_type', 'service_policy')
|
||||
def _onchange_service_fields(self):
|
||||
for record in self:
|
||||
if record.type == 'service' and record.service_type == 'timesheet' and \
|
||||
not (record._origin.service_policy and record.service_policy == record._origin.service_policy):
|
||||
record.uom_id = self.env.ref('uom.product_uom_hour')
|
||||
elif record._origin.uom_id:
|
||||
record.uom_id = record._origin.uom_id
|
||||
else:
|
||||
record.uom_id = self.product_tmpl_id.default_get(['uom_id']).get('uom_id')
|
||||
|
||||
@api.onchange('service_policy')
|
||||
def _onchange_service_policy(self):
|
||||
self._inverse_service_policy()
|
||||
vals = self.product_tmpl_id._get_onchange_service_policy_updates(self.service_tracking,
|
||||
self.service_policy,
|
||||
self.project_id,
|
||||
self.project_template_id)
|
||||
if vals:
|
||||
self.update(vals)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_master_data(self):
|
||||
time_product = self.env.ref('sale_timesheet.time_product')
|
||||
if time_product in self:
|
||||
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be archived, deleted nor linked to a company.', time_product.name))
|
||||
|
||||
def write(self, vals):
|
||||
# timesheet product can't be deleted, archived or linked to a company
|
||||
if ('active' in vals and not vals['active']) or ('company_id' in vals and vals['company_id']):
|
||||
time_product = self.env.ref('sale_timesheet.time_product')
|
||||
if time_product in self:
|
||||
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be archived, deleted nor linked to a company.', time_product.name))
|
||||
return super().write(vals)
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import threading
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = 'product.template'
|
||||
|
||||
def _selection_service_policy(self):
|
||||
service_policies = super()._selection_service_policy()
|
||||
service_policies.insert(1, ('delivered_timesheet', _('Based on Timesheets')))
|
||||
return service_policies
|
||||
|
||||
service_type = fields.Selection(selection_add=[
|
||||
('timesheet', 'Timesheets on project (one fare per SO/Project)'),
|
||||
], ondelete={'timesheet': 'set manual'})
|
||||
# override domain
|
||||
project_id = fields.Many2one(domain="['|', ('company_id', '=', False), '&', ('company_id', '=?', company_id), ('company_id', '=', current_company_id), ('allow_billable', '=', True), ('pricing_type', '=', 'task_rate'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet', True]), ('is_template', '=', False)]")
|
||||
project_template_id = fields.Many2one(domain="['|', ('company_id', '=', False), '&', ('company_id', '=?', company_id), ('company_id', '=', current_company_id), ('allow_billable', '=', True), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet', True]), ('is_template', '=', True)]")
|
||||
service_upsell_threshold = fields.Float('Threshold', default=1, help="Percentage of time delivered compared to the prepaid amount that must be reached for the upselling opportunity activity to be triggered.")
|
||||
service_upsell_threshold_ratio = fields.Char(compute='_compute_service_upsell_threshold_ratio', export_string_translation=False)
|
||||
|
||||
@api.depends('uom_id', 'company_id')
|
||||
def _compute_service_upsell_threshold_ratio(self):
|
||||
product_uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
uom_unit = self.env.ref('uom.product_uom_unit')
|
||||
company_uom = self.env.company.timesheet_encode_uom_id
|
||||
for record in self:
|
||||
if not record.uom_id or record.uom_id != uom_unit or\
|
||||
product_uom_hour.factor == record.uom_id.factor:
|
||||
record.service_upsell_threshold_ratio = False
|
||||
continue
|
||||
else:
|
||||
timesheet_encode_uom = record.company_id.timesheet_encode_uom_id or company_uom
|
||||
record.service_upsell_threshold_ratio = f'(1 {record.uom_id.name} = {timesheet_encode_uom.factor / product_uom_hour.factor:.2f} {timesheet_encode_uom.name})'
|
||||
|
||||
def _compute_visible_expense_policy(self):
|
||||
visibility = self.env.user.has_group('project.group_project_user')
|
||||
for product_template in self:
|
||||
if not product_template.visible_expense_policy:
|
||||
product_template.visible_expense_policy = visibility
|
||||
return super()._compute_visible_expense_policy()
|
||||
|
||||
def _prepare_invoicing_tooltip(self):
|
||||
if self.service_policy == 'delivered_timesheet':
|
||||
return _("Invoice based on timesheets (delivered quantity).")
|
||||
return super()._prepare_invoicing_tooltip()
|
||||
|
||||
@api.onchange('type', 'service_type', 'service_policy')
|
||||
def _onchange_service_fields(self):
|
||||
for record in self:
|
||||
if record.type == 'service' and record.service_type == 'timesheet' and \
|
||||
not (record._origin.service_policy and record.service_policy == record._origin.service_policy):
|
||||
record.uom_id = self.env.ref('uom.product_uom_hour')
|
||||
elif record._origin.uom_id:
|
||||
record.uom_id = record._origin.uom_id
|
||||
else:
|
||||
record.uom_id = self.default_get(['uom_id']).get('uom_id')
|
||||
|
||||
def _get_service_to_general_map(self):
|
||||
return {
|
||||
**super()._get_service_to_general_map(),
|
||||
'delivered_timesheet': ('delivery', 'timesheet'),
|
||||
'ordered_prepaid': ('order', 'timesheet'),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_onchange_service_policy_updates(self, service_tracking, service_policy, project_id, project_template_id):
|
||||
vals = {}
|
||||
if service_tracking != 'no' and service_policy == 'delivered_timesheet':
|
||||
if project_id and not project_id.allow_timesheets:
|
||||
vals['project_id'] = False
|
||||
elif project_template_id and not project_template_id.allow_timesheets:
|
||||
vals['project_template_id'] = False
|
||||
return vals
|
||||
|
||||
@api.onchange('service_policy')
|
||||
def _onchange_service_policy(self):
|
||||
self._inverse_service_policy()
|
||||
vals = self._get_onchange_service_policy_updates(self.service_tracking,
|
||||
self.service_policy,
|
||||
self.project_id,
|
||||
self.project_template_id)
|
||||
if vals:
|
||||
self.update(vals)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_master_data(self):
|
||||
time_product = self.env.ref('sale_timesheet.time_product')
|
||||
if time_product.product_tmpl_id in self:
|
||||
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be archived, deleted nor linked to a company.', time_product.name))
|
||||
|
||||
def write(self, vals):
|
||||
# timesheet product can't be deleted, archived or linked to a company
|
||||
if ('active' in vals and not vals['active']) or ('company_id' in vals and vals['company_id']):
|
||||
time_product = self.env.ref('sale_timesheet.time_product')
|
||||
if time_product.product_tmpl_id in self:
|
||||
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be archived, deleted nor linked to a company.', time_product.name))
|
||||
return super().write(vals)
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import ast
|
||||
import json
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models, _, _lt
|
||||
from odoo.osv import expression
|
||||
from odoo import api, fields, models
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools import SQL
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
from odoo.tools.translate import _
|
||||
|
||||
# YTI PLEASE SPLIT ME
|
||||
class Project(models.Model):
|
||||
|
||||
class ProjectProject(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
""" Pre-fill timesheet product as "Time" data product when creating new project allowing billable tasks by default. """
|
||||
result = super(Project, self).default_get(fields)
|
||||
result = super().default_get(fields)
|
||||
if 'timesheet_product_id' in fields and result.get('allow_billable') and result.get('allow_timesheets') and not result.get('timesheet_product_id'):
|
||||
default_product = self.env.ref('sale_timesheet.time_product', False)
|
||||
if default_product:
|
||||
|
|
@ -34,28 +34,41 @@ class Project(models.Model):
|
|||
compute='_compute_pricing_type',
|
||||
search='_search_pricing_type',
|
||||
help='The task rate is perfect if you would like to bill different services to different customers at different rates. The fixed rate is perfect if you bill a service at a fixed rate per hour or day worked regardless of the employee who performed it. The employee rate is preferable if your employees deliver the same service at a different rate. For instance, junior and senior consultants would deliver the same service (= consultancy), but at a different rate because of their level of seniority.')
|
||||
sale_line_employee_ids = fields.One2many('project.sale.line.employee.map', 'project_id', "Sale line/Employee map", copy=False,
|
||||
sale_line_employee_ids = fields.One2many(
|
||||
'project.sale.line.employee.map',
|
||||
'project_id',
|
||||
'Sale line/Employee map',
|
||||
copy=False,
|
||||
export_string_translation=False,
|
||||
help="Sales order item that will be selected by default on the timesheets of the corresponding employee. It bypasses the sales order item defined on the project and the task, and can be modified on each timesheet entry if necessary. In other words, it defines the rate at which an employee's time is billed based on their expertise, skills or experience, for instance.\n"
|
||||
"If you would like to bill the same service at a different rate, you need to create two separate sales order items as each sales order item can only have a single unit price at a time.\n"
|
||||
"You can also define the hourly company cost of your employees for their timesheets on this project specifically. It will bypass the timesheet cost set on the employee.")
|
||||
billable_percentage = fields.Integer(
|
||||
compute='_compute_billable_percentage', groups='hr_timesheet.group_hr_timesheet_approver',
|
||||
help="% of timesheets that are billable compared to the total number of timesheets linked to the AA of the project, rounded to the unit.")
|
||||
display_create_order = fields.Boolean(compute='_compute_display_create_order')
|
||||
timesheet_product_id = fields.Many2one(
|
||||
'product.product', string='Timesheet Product',
|
||||
domain="""[
|
||||
('detailed_type', '=', 'service'),
|
||||
('type', '=', 'service'),
|
||||
('invoice_policy', '=', 'delivery'),
|
||||
('service_type', '=', 'timesheet'),
|
||||
'|', ('company_id', '=', False), ('company_id', '=', company_id)]""",
|
||||
]""",
|
||||
help='Service that will be used by default when invoicing the time spent on a task. It can be modified on each task individually by selecting a specific sales order item.',
|
||||
check_company=True,
|
||||
compute="_compute_timesheet_product_id", store=True, readonly=False,
|
||||
default=_default_timesheet_product_id)
|
||||
warning_employee_rate = fields.Boolean(compute='_compute_warning_employee_rate', compute_sudo=True)
|
||||
warning_employee_rate = fields.Boolean(compute='_compute_warning_employee_rate', compute_sudo=True, export_string_translation=False)
|
||||
partner_id = fields.Many2one(
|
||||
compute='_compute_partner_id', store=True, readonly=False)
|
||||
allocated_hours = fields.Float(compute='_compute_allocated_hours', store=True, readonly=False)
|
||||
allocated_hours = fields.Float()
|
||||
billing_type = fields.Selection(
|
||||
compute="_compute_billing_type",
|
||||
selection=[
|
||||
('not_billable', 'not billable'),
|
||||
('manually', 'billed manually'),
|
||||
],
|
||||
default='not_billable',
|
||||
required=True,
|
||||
readonly=False,
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_view(self, view_id=None, view_type='form', **options):
|
||||
|
|
@ -80,65 +93,23 @@ class Project(models.Model):
|
|||
def _search_pricing_type(self, operator, value):
|
||||
""" Search method for pricing_type field.
|
||||
|
||||
This method returns a domain based on the operator and the value given in parameter:
|
||||
- operator = '=':
|
||||
- value = 'task_rate': [('sale_line_employee_ids', '=', False), ('sale_line_id', '=', False), ('allow_billable', '=', True)]
|
||||
- value = 'fixed_rate': [('sale_line_employee_ids', '=', False), ('sale_line_id', '!=', False), ('allow_billable', '=', True)]
|
||||
- value = 'employee_rate': [('sale_line_employee_ids', '!=', False), ('allow_billable', '=', True)]
|
||||
- value is False: [('allow_billable', '=', False)]
|
||||
- operator = '!=':
|
||||
- value = 'task_rate': ['|', '|', ('sale_line_employee_ids', '!=', False), ('sale_line_id', '!=', False), ('allow_billable', '=', False)]
|
||||
- value = 'fixed_rate': ['|', '|', ('sale_line_employee_ids', '!=', False), ('sale_line_id', '=', False), ('allow_billable', '=', False)]
|
||||
- value = 'employee_rate': ['|', ('sale_line_employee_ids', '=', False), ('allow_billable', '=', False)]
|
||||
- value is False: [('allow_billable', '!=', False)]
|
||||
|
||||
:param operator: the supported operator is either '=' or '!='.
|
||||
:param value: the value than the field should be is among these values into the following tuple: (False, 'task_rate', 'fixed_rate', 'employee_rate').
|
||||
|
||||
:returns: the domain to find the expected projects.
|
||||
"""
|
||||
if operator not in ('=', '!='):
|
||||
raise UserError(_('Operation not supported'))
|
||||
if not ((isinstance(value, bool) and value is False) or (isinstance(value, str) and value in ('task_rate', 'fixed_rate', 'employee_rate'))):
|
||||
raise UserError(_('Value does not exist in the pricing type'))
|
||||
if value is False:
|
||||
return [('allow_billable', operator, value)]
|
||||
|
||||
sol_cond = ('sale_line_id', '!=', False)
|
||||
mapping_cond = ('sale_line_employee_ids', '!=', False)
|
||||
if value == 'task_rate':
|
||||
domain = [expression.NOT_OPERATOR, sol_cond, expression.NOT_OPERATOR, mapping_cond]
|
||||
elif value == 'fixed_rate':
|
||||
domain = [sol_cond, expression.NOT_OPERATOR, mapping_cond]
|
||||
else: # value == 'employee_rate'
|
||||
domain = [mapping_cond]
|
||||
|
||||
domain = expression.AND([domain, [('allow_billable', '=', True)]])
|
||||
domain = expression.normalize_domain(domain)
|
||||
if operator != '=':
|
||||
domain.insert(0, expression.NOT_OPERATOR)
|
||||
domain = expression.distribute_not(domain)
|
||||
return domain
|
||||
|
||||
@api.depends('analytic_account_id', 'timesheet_ids')
|
||||
def _compute_billable_percentage(self):
|
||||
timesheets_read_group = self.env['account.analytic.line']._read_group([('project_id', 'in', self.ids)], ['project_id', 'so_line', 'unit_amount'], ['project_id', 'so_line'], lazy=False)
|
||||
timesheets_by_project = defaultdict(list)
|
||||
for res in timesheets_read_group:
|
||||
timesheets_by_project[res['project_id'][0]].append((res['unit_amount'], bool(res['so_line'])))
|
||||
for project in self:
|
||||
timesheet_total = timesheet_billable = 0.0
|
||||
for unit_amount, is_billable_timesheet in timesheets_by_project[project.id]:
|
||||
timesheet_total += unit_amount
|
||||
if is_billable_timesheet:
|
||||
timesheet_billable += unit_amount
|
||||
billable_percentage = timesheet_billable / timesheet_total * 100 if timesheet_total > 0 else 0
|
||||
project.billable_percentage = round(billable_percentage)
|
||||
|
||||
@api.depends('partner_id', 'pricing_type')
|
||||
def _compute_display_create_order(self):
|
||||
for project in self:
|
||||
project.display_create_order = project.partner_id and project.pricing_type == 'task_rate'
|
||||
if operator != 'in':
|
||||
return NotImplemented
|
||||
domains = []
|
||||
if 'task_rate' in value:
|
||||
domains.append([('sale_line_employee_ids', '=', False), ('sale_line_id', '=', False), ('allow_billable', '=', True)])
|
||||
if 'fixed_rate' in value:
|
||||
domains.append([('sale_line_employee_ids', '=', False), ('sale_line_id', '!=', False), ('allow_billable', '=', True)])
|
||||
if 'employee_rate' in value:
|
||||
domains.append([('sale_line_employee_ids', '!=', False), ('allow_billable', '=', True)])
|
||||
if False in value:
|
||||
domains.append([('allow_billable', '=', False)])
|
||||
return Domain.OR(domains)
|
||||
|
||||
@api.depends('allow_timesheets', 'allow_billable')
|
||||
def _compute_timesheet_product_id(self):
|
||||
|
|
@ -152,48 +123,54 @@ class Project(models.Model):
|
|||
@api.depends('pricing_type', 'allow_timesheets', 'allow_billable', 'sale_line_employee_ids', 'sale_line_employee_ids.employee_id')
|
||||
def _compute_warning_employee_rate(self):
|
||||
projects = self.filtered(lambda p: p.allow_billable and p.allow_timesheets and p.pricing_type == 'employee_rate')
|
||||
employees = self.env['account.analytic.line']._read_group([('task_id', 'in', projects.task_ids.ids)], ['employee_id', 'project_id'], ['employee_id', 'project_id'], lazy=False)
|
||||
dict_project_employee = defaultdict(list)
|
||||
for line in employees:
|
||||
dict_project_employee[line['project_id'][0]] += [line['employee_id'][0]] if line['employee_id'] else []
|
||||
employees = self.env['account.analytic.line']._read_group(
|
||||
[('task_id', 'in', projects.task_ids.ids), ('employee_id', '!=', False)],
|
||||
['project_id'],
|
||||
['employee_id:array_agg'],
|
||||
)
|
||||
dict_project_employee = {project.id: employee_ids for project, employee_ids in employees}
|
||||
for project in projects:
|
||||
project.warning_employee_rate = any(x not in project.sale_line_employee_ids.employee_id.ids for x in dict_project_employee[project.id])
|
||||
project.warning_employee_rate = any(
|
||||
x not in project.sale_line_employee_ids.employee_id.ids
|
||||
for x in dict_project_employee.get(project.id, ())
|
||||
)
|
||||
|
||||
(self - projects).warning_employee_rate = False
|
||||
|
||||
@api.depends('sale_line_employee_ids.sale_line_id', 'sale_line_id')
|
||||
def _compute_partner_id(self):
|
||||
for project in self:
|
||||
billable_projects = self.filtered('allow_billable')
|
||||
for project in billable_projects:
|
||||
if project.partner_id:
|
||||
continue
|
||||
if project.allow_billable and project.allow_timesheets and project.pricing_type != 'task_rate':
|
||||
sol = project.sale_line_id or project.sale_line_employee_ids.sale_line_id[:1]
|
||||
project.partner_id = sol.order_partner_id
|
||||
super(ProjectProject, self - billable_projects)._compute_partner_id()
|
||||
|
||||
@api.depends('partner_id')
|
||||
def _compute_sale_line_id(self):
|
||||
super()._compute_sale_line_id()
|
||||
for project in self.filtered(lambda p: not p.sale_line_id and p.partner_id and p.pricing_type == 'employee_rate'):
|
||||
# Give a SOL by default either the last SOL with service product and remaining_hours > 0
|
||||
sol = self.env['sale.order.line'].search([
|
||||
('is_service', '=', True),
|
||||
('order_partner_id', 'child_of', project.partner_id.commercial_partner_id.id),
|
||||
('is_expense', '=', False),
|
||||
('state', 'in', ['sale', 'done']),
|
||||
('remaining_hours', '>', 0)
|
||||
], limit=1)
|
||||
SaleOrderLine = self.env['sale.order.line']
|
||||
sol = SaleOrderLine.search(Domain.AND([
|
||||
SaleOrderLine._domain_sale_line_service(),
|
||||
[('order_partner_id', 'child_of', project.partner_id.commercial_partner_id.id), ('remaining_hours', '>', 0)],
|
||||
]), limit=1)
|
||||
project.sale_line_id = sol or project.sale_line_employee_ids.sale_line_id[:1] # get the first SOL containing in the employee mappings if no sol found in the search
|
||||
|
||||
@api.depends('sale_line_id.product_uom_qty', 'sale_line_id.product_uom')
|
||||
def _compute_allocated_hours(self):
|
||||
# TODO: remove in master
|
||||
return
|
||||
|
||||
@api.depends('sale_line_employee_ids.sale_line_id', 'allow_billable')
|
||||
def _compute_sale_order_count(self):
|
||||
billable_projects = self.filtered('allow_billable')
|
||||
super(Project, billable_projects)._compute_sale_order_count()
|
||||
(self - billable_projects).sale_order_count = 0
|
||||
super(ProjectProject, billable_projects)._compute_sale_order_count()
|
||||
non_billable_projects = self - billable_projects
|
||||
non_billable_projects.sale_order_line_count = 0
|
||||
non_billable_projects.sale_order_count = 0
|
||||
|
||||
@api.depends('allow_billable', 'allow_timesheets')
|
||||
def _compute_billing_type(self):
|
||||
self.filtered(lambda project: (not project.allow_billable or not project.allow_timesheets) and project.billing_type == 'manually').billing_type = 'not_billable'
|
||||
|
||||
@api.constrains('sale_line_id')
|
||||
def _check_sale_line_type(self):
|
||||
|
|
@ -203,9 +180,9 @@ class Project(models.Model):
|
|||
if project.sale_line_id.is_expense:
|
||||
raise ValidationError(_("You cannot link a billable project to a sales order item that comes from an expense or a vendor bill."))
|
||||
|
||||
def write(self, values):
|
||||
res = super(Project, self).write(values)
|
||||
if 'allow_billable' in values and not values.get('allow_billable'):
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if 'allow_billable' in vals and not vals.get('allow_billable'):
|
||||
self.task_ids._get_timesheet().write({
|
||||
'so_line': False,
|
||||
})
|
||||
|
|
@ -228,7 +205,7 @@ class Project(models.Model):
|
|||
'domain': [('project_id', '!=', False)],
|
||||
'res_model': 'account.analytic.line',
|
||||
'view_id': False,
|
||||
'view_mode': 'tree,form',
|
||||
'view_mode': 'list,form',
|
||||
'help': _("""
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Record timesheets
|
||||
|
|
@ -245,26 +222,11 @@ class Project(models.Model):
|
|||
}
|
||||
}
|
||||
|
||||
def action_make_billable(self):
|
||||
return {
|
||||
"name": _("Create Sales Order"),
|
||||
"type": 'ir.actions.act_window',
|
||||
"res_model": 'project.create.sale.order',
|
||||
"views": [[False, "form"]],
|
||||
"target": 'new',
|
||||
"context": {
|
||||
'active_id': self.id,
|
||||
'active_model': 'project.project',
|
||||
'default_product_id': self.timesheet_product_id.id,
|
||||
},
|
||||
}
|
||||
|
||||
def action_billable_time_button(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.timesheet_action_from_sales_order_item")
|
||||
action.update({
|
||||
'context': {
|
||||
'grid_range': 'week',
|
||||
'search_default_groupby_timesheet_invoice_type': True,
|
||||
'default_project_id': self.id,
|
||||
},
|
||||
|
|
@ -277,11 +239,11 @@ class Project(models.Model):
|
|||
if section_name in ['billable_fixed', 'billable_time', 'billable_milestones', 'billable_manual', 'non_billable']:
|
||||
action = self.action_billable_time_button()
|
||||
if domain:
|
||||
action['domain'] = expression.AND([[('project_id', '=', self.id)], domain])
|
||||
action['domain'] = Domain.AND([[('project_id', '=', self.id)], domain])
|
||||
action['context'].update(search_default_groupby_timesheet_invoice_type=False, **self.env.context)
|
||||
graph_view = False
|
||||
if section_name == 'billable_time':
|
||||
graph_view = self.env.ref('hr_timesheet.view_hr_timesheet_line_graph_all').id
|
||||
graph_view = self.env.ref('sale_timesheet.view_hr_timesheet_line_graph_invoice_employee').id
|
||||
action['views'] = [
|
||||
(view_id, view_type) if view_type != 'graph' else (graph_view or view_id, view_type)
|
||||
for view_id, view_type in action['views']
|
||||
|
|
@ -298,22 +260,41 @@ class Project(models.Model):
|
|||
return action
|
||||
return super().action_profitability_items(section_name, domain, res_id)
|
||||
|
||||
def action_project_timesheets(self):
|
||||
action = super().action_project_timesheets()
|
||||
if not self.allow_billable:
|
||||
context = action['context'].replace('active_id', str(self.id))
|
||||
action['context'] = {
|
||||
**ast.literal_eval(context),
|
||||
'hide_so_line': True,
|
||||
}
|
||||
return action
|
||||
|
||||
# ----------------------------
|
||||
# Project Updates
|
||||
# ----------------------------
|
||||
|
||||
def get_panel_data(self):
|
||||
panel_data = super(Project, self).get_panel_data()
|
||||
panel_data = super().get_panel_data()
|
||||
return {
|
||||
**panel_data,
|
||||
'analytic_account_id': self.analytic_account_id.id,
|
||||
'account_id': self.account_id.id,
|
||||
}
|
||||
|
||||
def _get_foldable_section(self):
|
||||
foldable_section = super()._get_foldable_section()
|
||||
return foldable_section + [
|
||||
'billable_fixed',
|
||||
'billable_milestones',
|
||||
'billable_time',
|
||||
'billable_manual',
|
||||
]
|
||||
|
||||
def _get_sale_order_items_query(self, domain_per_model=None):
|
||||
if domain_per_model is None:
|
||||
domain_per_model = {'project.task': [('allow_billable', '=', True)]}
|
||||
else:
|
||||
domain_per_model['project.task'] = expression.AND([
|
||||
domain_per_model['project.task'] = Domain.AND([
|
||||
domain_per_model.get('project.task', []),
|
||||
[('allow_billable', '=', True)],
|
||||
])
|
||||
|
|
@ -322,13 +303,12 @@ class Project(models.Model):
|
|||
Timesheet = self.env['account.analytic.line']
|
||||
timesheet_domain = [('project_id', 'in', self.ids), ('so_line', '!=', False), ('project_id.allow_billable', '=', True)]
|
||||
if Timesheet._name in domain_per_model:
|
||||
timesheet_domain = expression.AND([
|
||||
timesheet_domain = Domain.AND([
|
||||
domain_per_model.get(Timesheet._name, []),
|
||||
timesheet_domain,
|
||||
])
|
||||
timesheet_query = Timesheet._where_calc(timesheet_domain)
|
||||
Timesheet._apply_ir_rules(timesheet_query, 'read')
|
||||
timesheet_query_str, timesheet_params = timesheet_query.select(
|
||||
timesheet_query = Timesheet._search(timesheet_domain)
|
||||
timesheet_sql = timesheet_query.select(
|
||||
f'{Timesheet._table}.project_id AS id',
|
||||
f'{Timesheet._table}.so_line AS sale_line_id',
|
||||
)
|
||||
|
|
@ -336,35 +316,61 @@ class Project(models.Model):
|
|||
EmployeeMapping = self.env['project.sale.line.employee.map']
|
||||
employee_mapping_domain = [('project_id', 'in', self.ids), ('project_id.allow_billable', '=', True), ('sale_line_id', '!=', False)]
|
||||
if EmployeeMapping._name in domain_per_model:
|
||||
employee_mapping_domain = expression.AND([
|
||||
employee_mapping_domain = Domain.AND([
|
||||
domain_per_model[EmployeeMapping._name],
|
||||
employee_mapping_domain,
|
||||
])
|
||||
employee_mapping_query = EmployeeMapping._where_calc(employee_mapping_domain)
|
||||
EmployeeMapping._apply_ir_rules(employee_mapping_query, 'read')
|
||||
employee_mapping_query_str, employee_mapping_params = employee_mapping_query.select(
|
||||
employee_mapping_query = EmployeeMapping._search(employee_mapping_domain)
|
||||
employee_mapping_sql = employee_mapping_query.select(
|
||||
f'{EmployeeMapping._table}.project_id AS id',
|
||||
f'{EmployeeMapping._table}.sale_line_id',
|
||||
)
|
||||
|
||||
query._tables['project_sale_order_item'] = ' UNION '.join([
|
||||
query._tables['project_sale_order_item'] = SQL('(%s)', SQL(' UNION ').join([
|
||||
query._tables['project_sale_order_item'],
|
||||
timesheet_query_str,
|
||||
employee_mapping_query_str,
|
||||
])
|
||||
query._where_params += timesheet_params + employee_mapping_params
|
||||
timesheet_sql,
|
||||
employee_mapping_sql,
|
||||
]))
|
||||
return query
|
||||
|
||||
def _get_domain_from_section_id(self, section_id):
|
||||
section_domains = {
|
||||
'materials': [
|
||||
('product_id.type', '!=', 'service')
|
||||
],
|
||||
'billable_fixed': [
|
||||
('product_id.type', '=', 'service'),
|
||||
('product_id.invoice_policy', '=', 'order')
|
||||
],
|
||||
'billable_milestones': [
|
||||
('product_id.type', '=', 'service'),
|
||||
('product_id.invoice_policy', '=', 'delivery'),
|
||||
('product_id.service_type', '=', 'milestones'),
|
||||
],
|
||||
'billable_time': [
|
||||
('product_id.type', '=', 'service'),
|
||||
('product_id.invoice_policy', '=', 'delivery'),
|
||||
('product_id.service_type', '=', 'timesheet'),
|
||||
],
|
||||
'billable_manual': [
|
||||
('product_id.type', '=', 'service'),
|
||||
('product_id.invoice_policy', '=', 'delivery'),
|
||||
('product_id.service_type', '=', 'manual'),
|
||||
],
|
||||
}
|
||||
|
||||
return self._get_sale_items_domain(section_domains.get(section_id, []))
|
||||
|
||||
def _get_profitability_labels(self):
|
||||
return {
|
||||
**super()._get_profitability_labels(),
|
||||
'billable_fixed': _lt('Timesheets (Fixed Price)'),
|
||||
'billable_time': _lt('Timesheets (Billed on Timesheets)'),
|
||||
'billable_milestones': _lt('Timesheets (Billed on Milestones)'),
|
||||
'billable_manual': _lt('Timesheets (Billed Manually)'),
|
||||
'non_billable': _lt('Timesheets (Non Billable)'),
|
||||
'timesheet_revenues': _lt('Timesheets revenues'),
|
||||
'other_costs': _lt('Materials'),
|
||||
'billable_fixed': self.env._('Timesheets (Fixed Price)'),
|
||||
'billable_time': self.env._('Timesheets (Billed on Timesheets)'),
|
||||
'billable_milestones': self.env._('Timesheets (Billed on Milestones)'),
|
||||
'billable_manual': self.env._('Timesheets (Billed Manually)'),
|
||||
'non_billable': self.env._('Timesheets (Non-Billable)'),
|
||||
'timesheet_revenues': self.env._('Timesheets revenues'),
|
||||
'other_costs': self.env._('Materials'),
|
||||
}
|
||||
|
||||
def _get_profitability_sequence_per_invoice_type(self):
|
||||
|
|
@ -381,7 +387,7 @@ class Project(models.Model):
|
|||
|
||||
def _get_profitability_aal_domain(self):
|
||||
domain = ['|', ('project_id', 'in', self.ids), ('so_line', 'in', self._fetch_sale_order_item_ids())]
|
||||
return expression.AND([
|
||||
return Domain.AND([
|
||||
super()._get_profitability_aal_domain(),
|
||||
domain,
|
||||
])
|
||||
|
|
@ -403,17 +409,20 @@ class Project(models.Model):
|
|||
return profitability_items
|
||||
aa_line_read_group = self.env['account.analytic.line'].sudo()._read_group(
|
||||
self.sudo()._get_profitability_aal_domain(),
|
||||
['timesheet_invoice_type', 'timesheet_invoice_id', 'unit_amount', 'amount', 'ids:array_agg(id)'],
|
||||
['timesheet_invoice_type', 'timesheet_invoice_id'],
|
||||
lazy=False)
|
||||
can_see_timesheets = with_action and len(self) == 1 and self.user_has_groups('hr_timesheet.group_hr_timesheet_approver')
|
||||
['timesheet_invoice_type', 'timesheet_invoice_id', 'currency_id', 'category'],
|
||||
['amount:sum', 'id:array_agg'],
|
||||
)
|
||||
can_see_timesheets = with_action and len(self) == 1 and self.env.user.has_group('hr_timesheet.group_hr_timesheet_approver')
|
||||
revenues_dict = {}
|
||||
costs_dict = {}
|
||||
total_revenues = {'invoiced': 0.0, 'to_invoice': 0.0}
|
||||
total_costs = {'billed': 0.0, 'to_bill': 0.0}
|
||||
for res in aa_line_read_group:
|
||||
amount = res['amount']
|
||||
invoice_type = res['timesheet_invoice_type']
|
||||
convert_company = self.company_id or self.env.company
|
||||
for timesheet_invoice_type, _dummy, currency, category, amount, ids in aa_line_read_group:
|
||||
if category == 'vendor_bill':
|
||||
continue # This is done to prevent expense duplication with product re-invoice policies
|
||||
amount = currency._convert(amount, self.currency_id, convert_company)
|
||||
invoice_type = timesheet_invoice_type
|
||||
cost = costs_dict.setdefault(invoice_type, {'billed': 0.0, 'to_bill': 0.0})
|
||||
revenue = revenues_dict.setdefault(invoice_type, {'invoiced': 0.0, 'to_invoice': 0.0})
|
||||
if amount < 0: # cost
|
||||
|
|
@ -423,9 +432,8 @@ class Project(models.Model):
|
|||
revenue['invoiced'] += amount
|
||||
total_revenues['invoiced'] += amount
|
||||
if can_see_timesheets and invoice_type not in ['other_costs', 'other_revenues']:
|
||||
cost.setdefault('record_ids', []).extend(res['ids'])
|
||||
revenue.setdefault('record_ids', []).extend(res['ids'])
|
||||
|
||||
cost.setdefault('record_ids', []).extend(ids)
|
||||
revenue.setdefault('record_ids', []).extend(ids)
|
||||
action_name = None
|
||||
if can_see_timesheets:
|
||||
action_name = 'action_profitability_items'
|
||||
|
|
@ -449,7 +457,6 @@ class Project(models.Model):
|
|||
if record_ids:
|
||||
if invoice_type not in ['other_costs', 'other_revenues'] and can_see_timesheets: # action to see the timesheets
|
||||
action = get_timesheets_action(invoice_type, record_ids)
|
||||
action['context'] = json.dumps({'search_default_groupby_invoice': 1 if not cost and invoice_type == 'billable_time' else 0})
|
||||
data['action'] = action
|
||||
profitability_data.append(data)
|
||||
return profitability_data
|
||||
|
|
@ -457,7 +464,7 @@ class Project(models.Model):
|
|||
def merge_profitability_data(a, b):
|
||||
return {
|
||||
'data': a['data'] + b['data'],
|
||||
'total': {key: a['total'][key] + b['total'][key] for key in a['total'].keys() if key in b['total']}
|
||||
'total': {key: a['total'][key] + b['total'][key] for key in a['total'] if key in b['total']}
|
||||
}
|
||||
|
||||
for revenue in profitability_items['revenues']['data']:
|
||||
|
|
@ -468,7 +475,6 @@ class Project(models.Model):
|
|||
record_ids = aal_revenue.get('record_ids', [])
|
||||
if can_see_timesheets and record_ids:
|
||||
action = get_timesheets_action(revenue_id, record_ids)
|
||||
action['context'] = json.dumps({'search_default_groupby_invoice': 1 if revenue_id == 'billable_time' else 0})
|
||||
revenue['action'] = action
|
||||
|
||||
for cost in profitability_items['costs']['data']:
|
||||
|
|
@ -490,6 +496,13 @@ class Project(models.Model):
|
|||
)
|
||||
return profitability_items
|
||||
|
||||
def _get_domain_aal_with_no_move_line(self):
|
||||
# we add the tuple 'project_id = False' in the domain to remove the timesheets from the search.
|
||||
return Domain.AND([
|
||||
super()._get_domain_aal_with_no_move_line(),
|
||||
[('project_id', '=', False)]
|
||||
])
|
||||
|
||||
def _get_service_policy_to_invoice_type(self):
|
||||
return {
|
||||
**super()._get_service_policy_to_invoice_type(),
|
||||
|
|
@ -505,113 +518,15 @@ class Project(models.Model):
|
|||
with_action
|
||||
)
|
||||
|
||||
|
||||
class ProjectTask(models.Model):
|
||||
_inherit = "project.task"
|
||||
|
||||
def _get_default_partner_id(self, project, parent):
|
||||
res = super()._get_default_partner_id(project, parent)
|
||||
if not res and project:
|
||||
# project in sudo if the current user is a portal user.
|
||||
related_project = project if not self.user_has_groups('!base.group_user,base.group_portal') else project.sudo()
|
||||
if related_project.pricing_type == 'employee_rate':
|
||||
return related_project.sale_line_employee_ids.sale_line_id.order_partner_id[:1]
|
||||
def _get_project_to_template_warnings(self):
|
||||
res = super()._get_project_to_template_warnings()
|
||||
timesheet_linked_count = self.env['account.analytic.line'].search_count([('project_id', '=', self.id)], limit=1)
|
||||
if timesheet_linked_count:
|
||||
res.append(self.env._("This project is current linked to timesheet."))
|
||||
return res
|
||||
|
||||
sale_order_id = fields.Many2one(domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id', 'child_of', commercial_partner_id), ('partner_id', 'parent_of', partner_id)]")
|
||||
so_analytic_account_id = fields.Many2one(related='sale_order_id.analytic_account_id', string='Sale Order Analytic Account')
|
||||
pricing_type = fields.Selection(related="project_id.pricing_type")
|
||||
is_project_map_empty = fields.Boolean("Is Project map empty", compute='_compute_is_project_map_empty')
|
||||
has_multi_sol = fields.Boolean(compute='_compute_has_multi_sol', compute_sudo=True)
|
||||
allow_billable = fields.Boolean(related="project_id.allow_billable")
|
||||
timesheet_product_id = fields.Many2one(related="project_id.timesheet_product_id")
|
||||
remaining_hours_so = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours_so', search='_search_remaining_hours_so', compute_sudo=True)
|
||||
remaining_hours_available = fields.Boolean(related="sale_line_id.remaining_hours_available")
|
||||
|
||||
@property
|
||||
def SELF_READABLE_FIELDS(self):
|
||||
return super().SELF_READABLE_FIELDS | {
|
||||
'allow_billable',
|
||||
'remaining_hours_available',
|
||||
'remaining_hours_so',
|
||||
}
|
||||
|
||||
@api.depends('sale_line_id', 'timesheet_ids', 'timesheet_ids.unit_amount')
|
||||
def _compute_remaining_hours_so(self):
|
||||
# TODO This is not yet perfectly working as timesheet.so_line stick to its old value although changed
|
||||
# in the task From View.
|
||||
timesheets = self.timesheet_ids.filtered(lambda t: t.task_id.sale_line_id in (t.so_line, t._origin.so_line) and t.so_line.remaining_hours_available)
|
||||
|
||||
mapped_remaining_hours = {task._origin.id: task.sale_line_id and task.sale_line_id.remaining_hours or 0.0 for task in self}
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
for timesheet in timesheets:
|
||||
delta = 0
|
||||
if timesheet._origin.so_line == timesheet.task_id.sale_line_id:
|
||||
delta += timesheet._origin.unit_amount
|
||||
if timesheet.so_line == timesheet.task_id.sale_line_id:
|
||||
delta -= timesheet.unit_amount
|
||||
if delta:
|
||||
mapped_remaining_hours[timesheet.task_id._origin.id] += timesheet.product_uom_id._compute_quantity(delta, uom_hour)
|
||||
|
||||
for task in self:
|
||||
task.remaining_hours_so = mapped_remaining_hours[task._origin.id]
|
||||
|
||||
@api.model
|
||||
def _search_remaining_hours_so(self, operator, value):
|
||||
return [('sale_line_id.remaining_hours', operator, value)]
|
||||
|
||||
@api.depends('so_analytic_account_id.active')
|
||||
def _compute_analytic_account_active(self):
|
||||
super()._compute_analytic_account_active()
|
||||
for task in self:
|
||||
task.analytic_account_active = task.analytic_account_active or task.so_analytic_account_id.active
|
||||
|
||||
@api.depends('allow_billable')
|
||||
def _compute_sale_order_id(self):
|
||||
billable_tasks = self.filtered('allow_billable')
|
||||
super(ProjectTask, billable_tasks)._compute_sale_order_id()
|
||||
(self - billable_tasks).sale_order_id = False
|
||||
|
||||
@api.depends('commercial_partner_id', 'sale_line_id.order_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'allow_billable')
|
||||
def _compute_sale_line(self):
|
||||
billable_tasks = self.filtered('allow_billable')
|
||||
(self - billable_tasks).update({'sale_line_id': False})
|
||||
super(ProjectTask, billable_tasks)._compute_sale_line()
|
||||
for task in billable_tasks:
|
||||
if not task.sale_line_id:
|
||||
task.sale_line_id = task._get_last_sol_of_customer()
|
||||
|
||||
@api.depends('project_id.sale_line_employee_ids')
|
||||
def _compute_is_project_map_empty(self):
|
||||
for task in self:
|
||||
task.is_project_map_empty = not bool(task.sudo().project_id.sale_line_employee_ids)
|
||||
|
||||
@api.depends('timesheet_ids')
|
||||
def _compute_has_multi_sol(self):
|
||||
for task in self:
|
||||
task.has_multi_sol = task.timesheet_ids and task.timesheet_ids.so_line != task.sale_line_id
|
||||
|
||||
def _get_last_sol_of_customer(self):
|
||||
# Get the last SOL made for the customer in the current task where we need to compute
|
||||
self.ensure_one()
|
||||
if not self.commercial_partner_id or not self.allow_billable:
|
||||
return False
|
||||
domain = [('company_id', '=', self.company_id.id), ('is_service', '=', True), ('order_partner_id', 'child_of', self.commercial_partner_id.id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('remaining_hours', '>', 0)]
|
||||
if self.project_id.pricing_type != 'task_rate' and self.project_sale_order_id and self.commercial_partner_id == self.project_id.partner_id.commercial_partner_id:
|
||||
domain.append(('order_id', '=?', self.project_sale_order_id.id))
|
||||
return self.env['sale.order.line'].search(domain, limit=1)
|
||||
|
||||
def _get_timesheet(self):
|
||||
# return not invoiced timesheet and timesheet without so_line or so_line linked to task
|
||||
timesheet_ids = super(ProjectTask, self)._get_timesheet()
|
||||
return timesheet_ids.filtered(lambda t: t._is_not_billed())
|
||||
|
||||
def _get_action_view_so_ids(self):
|
||||
return list(set((self.sale_order_id + self.timesheet_ids.so_line.order_id).ids))
|
||||
|
||||
class ProjectTaskRecurrence(models.Model):
|
||||
_inherit = 'project.task.recurrence'
|
||||
|
||||
@api.model
|
||||
def _get_recurring_fields(self):
|
||||
return ['so_analytic_account_id'] + super(ProjectTaskRecurrence, self)._get_recurring_fields()
|
||||
def _get_template_default_context_whitelist(self):
|
||||
return [
|
||||
*super()._get_template_default_context_whitelist(),
|
||||
"allow_timesheets",
|
||||
]
|
||||
|
|
@ -1,47 +1,56 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.osv import expression
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools.misc import unquote
|
||||
|
||||
|
||||
class ProjectProductEmployeeMap(models.Model):
|
||||
class ProjectSaleLineEmployeeMap(models.Model):
|
||||
_name = 'project.sale.line.employee.map'
|
||||
_description = 'Project Sales line, employee mapping'
|
||||
|
||||
def _domain_sale_line_id(self):
|
||||
domain = expression.AND([
|
||||
domain = Domain.AND([
|
||||
self.env['sale.order.line']._sellable_lines_domain(),
|
||||
self.env['sale.order.line']._domain_sale_line_service(),
|
||||
[
|
||||
('is_service', '=', True),
|
||||
('is_expense', '=', False),
|
||||
('state', 'in', ['sale', 'done']),
|
||||
('order_partner_id', '=?', unquote('partner_id')),
|
||||
'|', ('company_id', '=', False), ('company_id', '=', unquote('company_id')),
|
||||
],
|
||||
])
|
||||
return domain
|
||||
|
||||
project_id = fields.Many2one('project.project', "Project", required=True)
|
||||
employee_id = fields.Many2one('hr.employee', "Employee", required=True)
|
||||
project_id = fields.Many2one('project.project', "Project", domain=[('is_template', '=', False)], required=True, index=True)
|
||||
employee_id = fields.Many2one('hr.employee', "Employee", required=True, domain="[('id', 'not in', existing_employee_ids)]")
|
||||
existing_employee_ids = fields.Many2many('hr.employee', compute="_compute_existing_employee_ids", export_string_translation=False, compute_sudo=True)
|
||||
sale_line_id = fields.Many2one(
|
||||
'sale.order.line', "Sales Order Item",
|
||||
compute="_compute_sale_line_id", store=True, readonly=False,
|
||||
domain=lambda self: str(self._domain_sale_line_id()))
|
||||
company_id = fields.Many2one('res.company', string='Company', related='project_id.company_id')
|
||||
partner_id = fields.Many2one(related='project_id.partner_id')
|
||||
domain=lambda self: str(self._domain_sale_line_id())
|
||||
)
|
||||
sale_order_id = fields.Many2one(related="project_id.sale_order_id", export_string_translation=False)
|
||||
company_id = fields.Many2one('res.company', string='Company', related='project_id.company_id', export_string_translation=False)
|
||||
partner_id = fields.Many2one(related='project_id.partner_id', export_string_translation=False)
|
||||
price_unit = fields.Float("Unit Price", compute='_compute_price_unit', store=True, readonly=True)
|
||||
currency_id = fields.Many2one('res.currency', string="Currency", compute='_compute_currency_id', store=True, readonly=False)
|
||||
cost = fields.Monetary(currency_field='cost_currency_id', compute='_compute_cost', store=True, readonly=False,
|
||||
help="This cost overrides the employee's default employee hourly wage in employee's HR Settings")
|
||||
display_cost = fields.Monetary(currency_field='cost_currency_id', compute="_compute_display_cost", inverse="_inverse_display_cost", string="Hourly Cost")
|
||||
cost_currency_id = fields.Many2one('res.currency', string="Cost Currency", related='employee_id.currency_id', readonly=True)
|
||||
is_cost_changed = fields.Boolean('Is Cost Manually Changed', compute='_compute_is_cost_changed', store=True)
|
||||
display_cost = fields.Monetary(currency_field='cost_currency_id', compute="_compute_display_cost", inverse="_inverse_display_cost", string="Hourly Cost", groups="project.group_project_manager,hr.group_hr_user")
|
||||
cost_currency_id = fields.Many2one('res.currency', string="Cost Currency", related='employee_id.currency_id', readonly=True, export_string_translation=False)
|
||||
is_cost_changed = fields.Boolean('Is Cost Manually Changed', compute='_compute_is_cost_changed', store=True, export_string_translation=False)
|
||||
|
||||
_sql_constraints = [
|
||||
('uniqueness_employee', 'UNIQUE(project_id,employee_id)', 'An employee cannot be selected more than once in the mapping. Please remove duplicate(s) and try again.'),
|
||||
]
|
||||
_uniqueness_employee = models.Constraint(
|
||||
'UNIQUE(project_id,employee_id)',
|
||||
'An employee cannot be selected more than once in the mapping. Please remove duplicate(s) and try again.',
|
||||
)
|
||||
|
||||
@api.depends('employee_id', 'project_id.sale_line_employee_ids.employee_id')
|
||||
def _compute_existing_employee_ids(self):
|
||||
project = self.project_id
|
||||
if len(project) == 1:
|
||||
self.existing_employee_ids = project.sale_line_employee_ids.employee_id
|
||||
return
|
||||
for map_entry in self:
|
||||
map_entry.existing_employee_ids = map_entry.project_id.sale_line_employee_ids.employee_id
|
||||
|
||||
@api.depends('partner_id')
|
||||
def _compute_sale_line_id(self):
|
||||
|
|
@ -80,12 +89,12 @@ class ProjectProductEmployeeMap(models.Model):
|
|||
|
||||
read_group_data = self.env['resource.calendar']._read_group(
|
||||
[('id', 'in', self.employee_id.resource_calendar_id.ids)],
|
||||
['ids:array_agg(id)', 'hours_per_day'],
|
||||
['hours_per_day'],
|
||||
['id:array_agg'],
|
||||
)
|
||||
for res in read_group_data:
|
||||
for calendar_id in res.get('ids', []):
|
||||
resource_calendar_per_hours[calendar_id] = res.get('hours_per_day')
|
||||
for hours_per_day, ids in read_group_data:
|
||||
for calendar_id in ids:
|
||||
resource_calendar_per_hours[calendar_id] = hours_per_day
|
||||
|
||||
return resource_calendar_per_hours
|
||||
|
||||
|
|
@ -122,8 +131,8 @@ class ProjectProductEmployeeMap(models.Model):
|
|||
maps._update_project_timesheet()
|
||||
return maps
|
||||
|
||||
def write(self, values):
|
||||
res = super(ProjectProductEmployeeMap, self).write(values)
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
self._update_project_timesheet()
|
||||
return res
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.fields import Domain
|
||||
|
||||
|
||||
class ProjectTask(models.Model):
|
||||
_inherit = "project.task"
|
||||
|
||||
def _get_default_partner_id(self, project=None, parent=None):
|
||||
res = super()._get_default_partner_id(project, parent)
|
||||
if not res and project:
|
||||
# project in sudo if the current user is a portal user.
|
||||
related_project = project
|
||||
if self.env.user._is_portal() and not self.env.user._is_internal():
|
||||
related_project = related_project.sudo()
|
||||
if related_project.pricing_type == 'employee_rate':
|
||||
return related_project.sale_line_employee_ids.sale_line_id.order_partner_id[:1]
|
||||
return res
|
||||
|
||||
sale_order_id = fields.Many2one(domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id.commercial_partner_id.id', 'parent_of', partner_id), ('partner_id', 'parent_of', partner_id)]")
|
||||
pricing_type = fields.Selection(related="project_id.pricing_type")
|
||||
is_project_map_empty = fields.Boolean("Is Project map empty", compute='_compute_is_project_map_empty')
|
||||
has_multi_sol = fields.Boolean(compute='_compute_has_multi_sol', compute_sudo=True)
|
||||
timesheet_product_id = fields.Many2one(related="project_id.timesheet_product_id")
|
||||
remaining_hours_so = fields.Float('Time Remaining on SO', compute='_compute_remaining_hours_so', search='_search_remaining_hours_so', compute_sudo=True)
|
||||
remaining_hours_available = fields.Boolean(related="sale_line_id.remaining_hours_available")
|
||||
last_sol_of_customer = fields.Many2one('sale.order.line', compute='_compute_last_sol_of_customer')
|
||||
|
||||
@property
|
||||
def TASK_PORTAL_READABLE_FIELDS(self):
|
||||
return super().TASK_PORTAL_READABLE_FIELDS | {
|
||||
'remaining_hours_available',
|
||||
'remaining_hours_so',
|
||||
}
|
||||
|
||||
@api.depends('sale_line_id', 'timesheet_ids', 'timesheet_ids.unit_amount')
|
||||
def _compute_remaining_hours_so(self):
|
||||
# TODO This is not yet perfectly working as timesheet.so_line stick to its old value although changed
|
||||
# in the task From View.
|
||||
timesheets = self.timesheet_ids.filtered(lambda t: t.task_id.sale_line_id in (t.so_line, t._origin.so_line) and t.so_line.remaining_hours_available)
|
||||
|
||||
mapped_remaining_hours = {task._origin.id: task.sale_line_id and task.sale_line_id.remaining_hours or 0.0 for task in self}
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
for timesheet in timesheets:
|
||||
delta = 0
|
||||
if timesheet._origin.so_line == timesheet.task_id.sale_line_id:
|
||||
delta += timesheet._origin.unit_amount
|
||||
if timesheet.so_line == timesheet.task_id.sale_line_id:
|
||||
delta -= timesheet.unit_amount
|
||||
if delta:
|
||||
mapped_remaining_hours[timesheet.task_id._origin.id] += timesheet.product_uom_id._compute_quantity(delta, uom_hour)
|
||||
|
||||
for task in self:
|
||||
task.remaining_hours_so = mapped_remaining_hours[task._origin.id]
|
||||
|
||||
@api.model
|
||||
def _search_remaining_hours_so(self, operator, value):
|
||||
return [('sale_line_id.remaining_hours', operator, value)]
|
||||
|
||||
def _compute_last_sol_of_customer(self):
|
||||
sol_per_domain = dict()
|
||||
for task in self:
|
||||
domain = tuple(task._get_last_sol_of_customer_domain())
|
||||
if not domain:
|
||||
task.last_sol_of_customer = False
|
||||
continue
|
||||
if domain not in sol_per_domain:
|
||||
sol_per_domain[domain] = self.env['sale.order.line'].search(domain, limit=1)
|
||||
task.last_sol_of_customer = sol_per_domain[domain]
|
||||
|
||||
def _inverse_partner_id(self):
|
||||
super()._inverse_partner_id()
|
||||
for task in self:
|
||||
if task.allow_billable and not task.sale_line_id:
|
||||
task.sale_line_id = task.sudo().last_sol_of_customer
|
||||
|
||||
@api.depends('sale_line_id.order_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'allow_billable')
|
||||
def _compute_sale_line(self):
|
||||
super()._compute_sale_line()
|
||||
for task in self:
|
||||
if task.allow_billable and not task.sale_line_id:
|
||||
task.sale_line_id = task.last_sol_of_customer
|
||||
|
||||
@api.depends('project_id.sale_line_employee_ids')
|
||||
def _compute_is_project_map_empty(self):
|
||||
for task in self:
|
||||
task.is_project_map_empty = not bool(task.sudo().project_id.sale_line_employee_ids)
|
||||
|
||||
@api.depends('timesheet_ids')
|
||||
def _compute_has_multi_sol(self):
|
||||
for task in self:
|
||||
task.has_multi_sol = task.timesheet_ids and task.timesheet_ids.so_line != task.sale_line_id
|
||||
|
||||
def _get_last_sol_of_customer_domain(self):
|
||||
# Get the domain of the last SOL made for the customer in the current task where we need to compute
|
||||
self.ensure_one()
|
||||
if not self.partner_id.commercial_partner_id or not self.allow_billable:
|
||||
return []
|
||||
SaleOrderLine = self.env['sale.order.line']
|
||||
domain = Domain.AND([
|
||||
SaleOrderLine._domain_sale_line_service(),
|
||||
[
|
||||
('company_id', '=?', self.company_id.id),
|
||||
('order_partner_id', 'child_of', self.partner_id.commercial_partner_id.id),
|
||||
('remaining_hours', '>', 0),
|
||||
],
|
||||
])
|
||||
if self.project_id.pricing_type != 'task_rate' and self.project_sale_order_id and self.partner_id.commercial_partner_id == self.project_id.partner_id.commercial_partner_id:
|
||||
domain &= Domain('order_id', '=', self.project_sale_order_id.id)
|
||||
return domain
|
||||
|
||||
def _get_timesheet(self):
|
||||
# return not invoiced timesheet and timesheet without so_line or so_line linked to task
|
||||
timesheet_ids = super()._get_timesheet()
|
||||
return timesheet_ids.filtered(lambda t: t._is_not_billed())
|
||||
|
||||
def _get_action_view_so_ids(self):
|
||||
return list(set((self.sale_order_id + self.timesheet_ids.so_line.order_id).ids))
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
from odoo.tools import float_utils, format_amount, formatLang
|
||||
from odoo.tools.misc import format_duration
|
||||
|
||||
|
||||
class ProjectUpdate(models.Model):
|
||||
_inherit = 'project.update'
|
||||
|
||||
@api.model
|
||||
def _get_template_values(self, project):
|
||||
template_values = super(ProjectUpdate, self)._get_template_values(project)
|
||||
services = self._get_services_values(project)
|
||||
profitability = self._get_profitability_values(project)
|
||||
show_sold = template_values['project'].allow_billable and len(services.get('data', [])) > 0
|
||||
return {
|
||||
**template_values,
|
||||
'show_sold': show_sold,
|
||||
'show_profitability': bool(profitability),
|
||||
'show_activities': template_values['show_activities'] or show_sold or bool(profitability),
|
||||
'services': services,
|
||||
'profitability': profitability,
|
||||
'format_value': lambda value, is_hour: str(round(value, 2)) if not is_hour else format_duration(value),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_services_values(self, project):
|
||||
if not project.allow_billable:
|
||||
return {}
|
||||
|
||||
services = []
|
||||
total_sold, total_effective, total_remaining = 0, 0, 0
|
||||
sols = self.env['sale.order.line'].search(
|
||||
project._get_sale_items_domain([
|
||||
('is_downpayment', '=', False),
|
||||
]),
|
||||
)
|
||||
name_by_sol = dict(sols.with_context(with_price_unit=True).name_get())
|
||||
product_uom_unit = self.env.ref('uom.product_uom_unit')
|
||||
product_uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
company_uom = self.env.company.timesheet_encode_uom_id
|
||||
for sol in sols:
|
||||
#We only want to consider hours and days for this calculation
|
||||
is_unit = sol.product_uom == product_uom_unit
|
||||
if sol.product_uom.category_id == company_uom.category_id or is_unit:
|
||||
product_uom_qty = sol.product_uom._compute_quantity(sol.product_uom_qty, company_uom, raise_if_failure=False)
|
||||
qty_delivered = sol.product_uom._compute_quantity(sol.qty_delivered, company_uom, raise_if_failure=False)
|
||||
unit = sol.product_uom if is_unit else company_uom
|
||||
services.append({
|
||||
'name': name_by_sol[sol.id],
|
||||
'sold_value': product_uom_qty,
|
||||
'effective_value': qty_delivered,
|
||||
'remaining_value': product_uom_qty - qty_delivered,
|
||||
'unit': unit.name,
|
||||
'is_unit': is_unit,
|
||||
'is_hour': unit == product_uom_hour,
|
||||
'sol': sol,
|
||||
})
|
||||
if sol.product_uom.category_id == company_uom.category_id:
|
||||
total_sold += product_uom_qty
|
||||
total_effective += qty_delivered
|
||||
total_remaining = total_sold - total_effective
|
||||
|
||||
return {
|
||||
'data': services,
|
||||
'total_sold': total_sold,
|
||||
'total_effective': total_effective,
|
||||
'total_remaining': total_remaining,
|
||||
'company_unit_name': company_uom.name,
|
||||
'is_hour': company_uom == product_uom_hour,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_profitability_values(self, project):
|
||||
costs_revenues = project.analytic_account_id and project.allow_billable
|
||||
if not (self.user_has_groups('project.group_project_manager') and costs_revenues):
|
||||
return {}
|
||||
profitability_items = project._get_profitability_items(False)
|
||||
costs = sum(profitability_items['costs']['total'].values())
|
||||
revenues = sum(profitability_items['revenues']['total'].values())
|
||||
margin = revenues + costs
|
||||
return {
|
||||
'analytic_account_id': project.analytic_account_id,
|
||||
'costs': costs,
|
||||
'costs_formatted': format_amount(self.env, -costs, project.currency_id),
|
||||
'revenues': revenues,
|
||||
'revenues_formatted': format_amount(self.env, revenues, project.currency_id),
|
||||
'margin': margin,
|
||||
'margin_formatted': format_amount(self.env, margin, project.currency_id),
|
||||
'margin_percentage': formatLang(self.env,
|
||||
not float_utils.float_is_zero(costs, precision_digits=2) and (margin / -costs) * 100 or 0.0,
|
||||
digits=0),
|
||||
}
|
||||
|
|
@ -1,54 +1,44 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import math
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import float_compare, format_duration
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools import float_compare
|
||||
|
||||
|
||||
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', groups="hr_timesheet.group_hr_timesheet_user")
|
||||
timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_count', groups="hr_timesheet.group_hr_timesheet_user", export_string_translation=False)
|
||||
timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id', export_string_translation=False)
|
||||
timesheet_total_duration = fields.Integer("Timesheet Total Duration", compute='_compute_timesheet_total_duration',
|
||||
help="Total recorded duration, expressed in the encoding UoM, and rounded to the unit", compute_sudo=True,
|
||||
groups="hr_timesheet.group_hr_timesheet_user", export_string_translation=False)
|
||||
show_hours_recorded_button = fields.Boolean(compute="_compute_show_hours_recorded_button", groups="hr_timesheet.group_hr_timesheet_user", export_string_translation=False)
|
||||
|
||||
# override domain
|
||||
project_id = fields.Many2one(domain="[('pricing_type', '!=', 'employee_rate'), ('analytic_account_id', '!=', False), ('company_id', '=', company_id)]")
|
||||
timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id')
|
||||
timesheet_total_duration = fields.Integer("Timesheet Total Duration", compute='_compute_timesheet_total_duration', help="Total recorded duration, expressed in the encoding UoM, and rounded to the unit")
|
||||
|
||||
def _compute_timesheet_ids(self):
|
||||
timesheet_groups = self.env['account.analytic.line'].sudo().read_group(
|
||||
[('so_line', 'in', self.mapped('order_line').ids), ('project_id', '!=', False)],
|
||||
['so_line', 'ids:array_agg(id)'],
|
||||
['so_line'])
|
||||
timesheets_per_sol = {group['so_line'][0]: (group['ids'], group['so_line_count']) for group in timesheet_groups}
|
||||
def _compute_timesheet_count(self):
|
||||
timesheets_per_so = {
|
||||
order.id: count
|
||||
for order, count in self.env['account.analytic.line']._read_group(
|
||||
[('order_id', 'in', self.ids), ('project_id', '!=', False)],
|
||||
['order_id'],
|
||||
['__count'],
|
||||
)
|
||||
}
|
||||
|
||||
for order in self:
|
||||
timesheet_ids = []
|
||||
timesheet_count = 0
|
||||
for sale_line_id in order.order_line.filtered('is_service').ids:
|
||||
list_timesheet_ids, count = timesheets_per_sol.get(sale_line_id, ([], 0))
|
||||
timesheet_ids.extend(list_timesheet_ids)
|
||||
timesheet_count += count
|
||||
order.timesheet_count = timesheets_per_so.get(order.id, 0)
|
||||
|
||||
order.update({
|
||||
'timesheet_ids': self.env['account.analytic.line'].browse(timesheet_ids),
|
||||
'timesheet_count': timesheet_count,
|
||||
})
|
||||
|
||||
@api.depends('company_id.project_time_mode_id', 'timesheet_ids', 'company_id.timesheet_encode_uom_id')
|
||||
@api.depends('company_id.project_time_mode_id', 'company_id.timesheet_encode_uom_id', 'order_line.timesheet_ids')
|
||||
def _compute_timesheet_total_duration(self):
|
||||
if not self.user_has_groups('hr_timesheet.group_hr_timesheet_user'):
|
||||
self.update({'timesheet_total_duration': 0})
|
||||
return
|
||||
group_data = self.env['account.analytic.line'].sudo()._read_group([
|
||||
group_data = self.env['account.analytic.line']._read_group([
|
||||
('order_id', 'in', self.ids), ('project_id', '!=', False)
|
||||
], ['order_id', 'unit_amount'], ['order_id'])
|
||||
], ['order_id'], ['unit_amount:sum'])
|
||||
timesheet_unit_amount_dict = defaultdict(float)
|
||||
timesheet_unit_amount_dict.update({data['order_id'][0]: data['unit_amount'] for data in group_data})
|
||||
timesheet_unit_amount_dict.update({order.id: unit_amount for order, unit_amount in group_data})
|
||||
for sale_order in self:
|
||||
total_time = sale_order.company_id.project_time_mode_id._compute_quantity(
|
||||
timesheet_unit_amount_dict[sale_order.id],
|
||||
|
|
@ -77,6 +67,31 @@ class SaleOrder(models.Model):
|
|||
upsellable_lines.write({'has_displayed_warning_upsell': True})
|
||||
super(SaleOrder, self - upsellable_orders)._compute_field_value(field)
|
||||
|
||||
def _compute_show_hours_recorded_button(self):
|
||||
show_button_ids = self._get_order_with_valid_service_product()
|
||||
for order in self:
|
||||
order.show_hours_recorded_button = order.timesheet_count or order.project_count and order.id in show_button_ids
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
created_records = super().create(vals_list)
|
||||
if self.env.context.get('create_for_employee_mapping'):
|
||||
if not next((sol for sol in created_records.order_line if sol.is_service), False):
|
||||
raise UserError(_('The Sales Order must contain at least one service product.'))
|
||||
created_records.with_context(disable_project_task_generation=True).action_confirm()
|
||||
return created_records
|
||||
|
||||
def _get_order_with_valid_service_product(self):
|
||||
SaleOrderLine = self.env['sale.order.line']
|
||||
return SaleOrderLine._read_group(Domain.AND([
|
||||
SaleOrderLine._domain_sale_line_service(),
|
||||
[
|
||||
('order_id', 'in', self.ids),
|
||||
'|', ('product_id.service_type', 'not in', ['milestones', 'manual']),
|
||||
('product_id.invoice_policy', '!=', 'delivery'),
|
||||
]
|
||||
]), aggregates=['order_id:array_agg'])[0][0]
|
||||
|
||||
def _get_prepaid_service_lines_to_upsell(self):
|
||||
""" Retrieve all sols which need to display an upsell activity warning in the SO
|
||||
|
||||
|
|
@ -85,9 +100,10 @@ class SaleOrder(models.Model):
|
|||
- service_policy="ordered_prepaid",
|
||||
"""
|
||||
self.ensure_one()
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit')
|
||||
return self.order_line.filtered(lambda sol:
|
||||
sol.is_service
|
||||
and sol.invoice_status != "invoiced"
|
||||
and not sol.has_displayed_warning_upsell # we don't want to display many times the warning each time we timesheet on the SOL
|
||||
and sol.product_id.service_policy == 'ordered_prepaid'
|
||||
and float_compare(
|
||||
|
|
@ -99,222 +115,51 @@ class SaleOrder(models.Model):
|
|||
|
||||
def action_view_timesheet(self):
|
||||
self.ensure_one()
|
||||
if not self.order_line:
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.timesheet_action_from_sales_order")
|
||||
action['context'] = {
|
||||
'search_default_billable_timesheet': True
|
||||
default_sale_line = next((sale_line for sale_line in self.order_line if sale_line.is_service and sale_line.product_id.service_policy in ['ordered_prepaid', 'delivered_timesheet']), self.env['sale.order.line'])
|
||||
context = {
|
||||
'search_default_billable_timesheet': True,
|
||||
'default_is_so_line_edited': True,
|
||||
'default_so_line': default_sale_line.id,
|
||||
} # erase default filters
|
||||
if self.order_line:
|
||||
tasks = self.order_line.task_id._filter_access_rules_python('write')
|
||||
if tasks:
|
||||
action['context']['default_task_id'] = tasks[0].id
|
||||
else:
|
||||
projects = self.order_line.project_id._filter_access_rules_python('write')
|
||||
if projects:
|
||||
action['context']['default_project_id'] = projects[0].id
|
||||
if self.timesheet_count > 0:
|
||||
action['domain'] = [('so_line', 'in', self.order_line.ids), ('project_id', '!=', False)]
|
||||
|
||||
tasks = self.order_line.task_id._filtered_access('write')
|
||||
if tasks:
|
||||
context['default_task_id'] = tasks[0].id
|
||||
else:
|
||||
action = {'type': 'ir.actions.act_window_close'}
|
||||
projects = self.order_line.project_id._filtered_access('write')
|
||||
if projects:
|
||||
context['default_project_id'] = projects[0].id
|
||||
elif self.project_ids:
|
||||
context['default_project_id'] = self.project_ids[0].id
|
||||
action.update({
|
||||
'context': context,
|
||||
'domain': [('so_line', 'in', self.order_line.ids), ('project_id', '!=', False)],
|
||||
'help': _("""
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No activities found. Let's start a new one!
|
||||
</p><p>
|
||||
Track your working hours by projects every day and invoice this time to your customers.
|
||||
</p>
|
||||
""")
|
||||
})
|
||||
|
||||
return action
|
||||
|
||||
def _reset_has_displayed_warning_upsell_order_lines(self):
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit')
|
||||
for line in self.order_line:
|
||||
if line.has_displayed_warning_upsell and line.product_uom_id and float_compare(line.qty_delivered, line.product_uom_qty, precision_digits=precision) == 0:
|
||||
line.has_displayed_warning_upsell = False
|
||||
|
||||
def _create_invoices(self, grouped=False, final=False, date=None):
|
||||
"""Link timesheets to the created invoices. Date interval is injected in the
|
||||
context in sale_make_invoice_advance_inv wizard.
|
||||
"""
|
||||
moves = super()._create_invoices(grouped=grouped, final=final, date=date)
|
||||
moves._link_timesheets_to_invoice(self.env.context.get("timesheet_start_date"), self.env.context.get("timesheet_end_date"))
|
||||
self._reset_has_displayed_warning_upsell_order_lines()
|
||||
return moves
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = "sale.order.line"
|
||||
|
||||
qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')])
|
||||
analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense)
|
||||
remaining_hours_available = fields.Boolean(compute='_compute_remaining_hours_available', compute_sudo=True)
|
||||
remaining_hours = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours', compute_sudo=True, store=True)
|
||||
has_displayed_warning_upsell = fields.Boolean('Has Displayed Warning Upsell', copy=False)
|
||||
timesheet_ids = fields.One2many('account.analytic.line', 'so_line', domain=[('project_id', '!=', False)], string='Timesheets')
|
||||
|
||||
def name_get(self):
|
||||
res = super(SaleOrderLine, self).name_get()
|
||||
with_remaining_hours = self.env.context.get('with_remaining_hours')
|
||||
if with_remaining_hours:
|
||||
names = dict(res)
|
||||
result = []
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
uom_day = self.env.ref('uom.product_uom_day')
|
||||
for line in self:
|
||||
name = names.get(line.id)
|
||||
if line.remaining_hours_available:
|
||||
company = self.env.company
|
||||
encoding_uom = company.timesheet_encode_uom_id
|
||||
remaining_time = ''
|
||||
if encoding_uom == uom_hour:
|
||||
remaining_time = f' ({format_duration(line.remaining_hours)})'
|
||||
elif encoding_uom == uom_day:
|
||||
remaining_days = company.project_time_mode_id._compute_quantity(line.remaining_hours, encoding_uom, round=False)
|
||||
remaining_time = ' ({qty:.02f} {unit})'.format(
|
||||
qty=remaining_days,
|
||||
unit=_('days') if abs(remaining_days) > 1 else _('day')
|
||||
)
|
||||
name = '{name}{remaining_time}'.format(
|
||||
name=name,
|
||||
remaining_time=remaining_time
|
||||
)
|
||||
result.append((line.id, name))
|
||||
return result
|
||||
return res
|
||||
|
||||
@api.depends('product_id.service_policy')
|
||||
def _compute_remaining_hours_available(self):
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
for line in self:
|
||||
is_ordered_prepaid = line.product_id.service_policy == 'ordered_prepaid'
|
||||
is_time_product = line.product_uom.category_id == uom_hour.category_id
|
||||
line.remaining_hours_available = is_ordered_prepaid and is_time_product
|
||||
|
||||
@api.depends('qty_delivered', 'product_uom_qty', 'analytic_line_ids')
|
||||
def _compute_remaining_hours(self):
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
for line in self:
|
||||
remaining_hours = None
|
||||
if line.remaining_hours_available:
|
||||
qty_left = line.product_uom_qty - line.qty_delivered
|
||||
remaining_hours = line.product_uom._compute_quantity(qty_left, uom_hour)
|
||||
line.remaining_hours = remaining_hours
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_qty_delivered_method(self):
|
||||
""" Sale Timesheet module compute delivered qty for product [('type', 'in', ['service']), ('service_type', '=', 'timesheet')] """
|
||||
super(SaleOrderLine, self)._compute_qty_delivered_method()
|
||||
for line in self:
|
||||
if not line.is_expense and line.product_id.type == 'service' and line.product_id.service_type == 'timesheet':
|
||||
line.qty_delivered_method = 'timesheet'
|
||||
|
||||
@api.depends('analytic_line_ids.project_id', 'project_id.pricing_type')
|
||||
def _compute_qty_delivered(self):
|
||||
super(SaleOrderLine, self)._compute_qty_delivered()
|
||||
|
||||
lines_by_timesheet = self.filtered(lambda sol: sol.qty_delivered_method == 'timesheet')
|
||||
domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
|
||||
mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
|
||||
for line in lines_by_timesheet:
|
||||
line.qty_delivered = mapping.get(line.id or line._origin.id, 0.0)
|
||||
|
||||
def _timesheet_compute_delivered_quantity_domain(self):
|
||||
""" Hook for validated timesheet in addionnal module """
|
||||
domain = [('project_id', '!=', False)]
|
||||
if self._context.get('accrual_entry_date'):
|
||||
domain += [('date', '<=', self._context['accrual_entry_date'])]
|
||||
return domain
|
||||
|
||||
###########################################
|
||||
# Service : Project and task generation
|
||||
###########################################
|
||||
|
||||
def _convert_qty_company_hours(self, dest_company):
|
||||
company_time_uom_id = dest_company.project_time_mode_id
|
||||
planned_hours = 0.0
|
||||
product_uom = self.product_uom
|
||||
if product_uom == self.env.ref('uom.product_uom_unit'):
|
||||
product_uom = self.env.ref('uom.product_uom_hour')
|
||||
if product_uom.category_id == company_time_uom_id.category_id:
|
||||
if product_uom != company_time_uom_id:
|
||||
planned_hours = product_uom._compute_quantity(self.product_uom_qty, company_time_uom_id)
|
||||
else:
|
||||
planned_hours = self.product_uom_qty
|
||||
return planned_hours
|
||||
|
||||
def _timesheet_create_project(self):
|
||||
project = super()._timesheet_create_project()
|
||||
project_uom = self.company_id.project_time_mode_id
|
||||
uom_unit = self.env.ref('uom.product_uom_unit')
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
|
||||
# dict of inverse factors for each relevant UoM found in SO
|
||||
factor_inv_per_id = {
|
||||
uom.id: uom.factor_inv
|
||||
for uom in self.order_id.order_line.product_uom
|
||||
if uom.category_id == project_uom.category_id
|
||||
}
|
||||
# if sold as units, assume hours for time allocation
|
||||
factor_inv_per_id[uom_unit.id] = uom_hour.factor_inv
|
||||
|
||||
allocated_hours = 0.0
|
||||
# method only called once per project, so also allocate hours for
|
||||
# all lines in SO that will share the same project
|
||||
for line in self.order_id.order_line:
|
||||
if line.is_service \
|
||||
and line.product_id.service_tracking in ['task_in_project', 'project_only'] \
|
||||
and line.product_id.project_template_id == self.product_id.project_template_id \
|
||||
and line.product_uom.id in factor_inv_per_id:
|
||||
uom_factor = project_uom.factor * factor_inv_per_id[line.product_uom.id]
|
||||
allocated_hours += line.product_uom_qty * uom_factor
|
||||
|
||||
project.write({
|
||||
'allocated_hours': allocated_hours,
|
||||
'allow_timesheets': True,
|
||||
})
|
||||
return project
|
||||
|
||||
def _timesheet_create_project_prepare_values(self):
|
||||
"""Generate project values"""
|
||||
values = super()._timesheet_create_project_prepare_values()
|
||||
values['allow_billable'] = True
|
||||
return values
|
||||
|
||||
def _recompute_qty_to_invoice(self, start_date, end_date):
|
||||
""" Recompute the qty_to_invoice field for product containing timesheets
|
||||
|
||||
Search the existed timesheets between the given period in parameter.
|
||||
Retrieve the unit_amount of this timesheet and then recompute
|
||||
the qty_to_invoice for each current product.
|
||||
|
||||
:param start_date: the start date of the period
|
||||
:param end_date: the end date of the period
|
||||
"""
|
||||
lines_by_timesheet = self.filtered(lambda sol: sol.product_id and sol.product_id._is_delivered_timesheet())
|
||||
domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
|
||||
refund_account_moves = self.order_id.invoice_ids.filtered(lambda am: am.state == 'posted' and am.move_type == 'out_refund').reversed_entry_id
|
||||
timesheet_domain = [
|
||||
'|',
|
||||
('timesheet_invoice_id', '=', False),
|
||||
('timesheet_invoice_id.state', '=', 'cancel')]
|
||||
if refund_account_moves:
|
||||
credited_timesheet_domain = [('timesheet_invoice_id.state', '=', 'posted'), ('timesheet_invoice_id', 'in', refund_account_moves.ids)]
|
||||
timesheet_domain = expression.OR([timesheet_domain, credited_timesheet_domain])
|
||||
domain = expression.AND([domain, timesheet_domain])
|
||||
if start_date:
|
||||
domain = expression.AND([domain, [('date', '>=', start_date)]])
|
||||
if end_date:
|
||||
domain = expression.AND([domain, [('date', '<=', end_date)]])
|
||||
mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
|
||||
|
||||
for line in lines_by_timesheet:
|
||||
qty_to_invoice = mapping.get(line.id, 0.0)
|
||||
if qty_to_invoice:
|
||||
line.qty_to_invoice = qty_to_invoice
|
||||
else:
|
||||
prev_inv_status = line.invoice_status
|
||||
line.qty_to_invoice = qty_to_invoice
|
||||
line.invoice_status = prev_inv_status
|
||||
|
||||
def _get_action_per_item(self):
|
||||
""" Get action per Sales Order Item
|
||||
|
||||
When the Sales Order Item contains a service product then the action will be View Timesheets.
|
||||
|
||||
:returns: Dict containing id of SOL as key and the action as value
|
||||
"""
|
||||
action_per_sol = super()._get_action_per_item()
|
||||
timesheet_action = self.env.ref('sale_timesheet.timesheet_action_from_sales_order_item').id
|
||||
timesheet_ids_per_sol = {}
|
||||
if self.user_has_groups('hr_timesheet.group_hr_timesheet_user'):
|
||||
timesheet_read_group = self.env['account.analytic.line']._read_group([('so_line', 'in', self.ids), ('project_id', '!=', False)], ['so_line', 'ids:array_agg(id)'], ['so_line'])
|
||||
timesheet_ids_per_sol = {res['so_line'][0]: res['ids'] for res in timesheet_read_group}
|
||||
for sol in self:
|
||||
timesheet_ids = timesheet_ids_per_sol.get(sol.id, [])
|
||||
if sol.is_service and len(timesheet_ids) > 0:
|
||||
action_per_sol[sol.id] = timesheet_action, timesheet_ids[0] if len(timesheet_ids) == 1 else False
|
||||
return action_per_sol
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools import format_duration
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = "sale.order.line"
|
||||
|
||||
qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')])
|
||||
analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense)
|
||||
remaining_hours_available = fields.Boolean(compute='_compute_remaining_hours_available', compute_sudo=True)
|
||||
remaining_hours = fields.Float('Time Remaining on SO', compute='_compute_remaining_hours', compute_sudo=True, store=True)
|
||||
has_displayed_warning_upsell = fields.Boolean('Has Displayed Warning Upsell', copy=False, export_string_translation=False)
|
||||
timesheet_ids = fields.One2many('account.analytic.line', 'so_line', domain=[('project_id', '!=', False)], string='Timesheets', export_string_translation=False)
|
||||
|
||||
@api.depends('remaining_hours_available', 'remaining_hours')
|
||||
@api.depends_context('with_remaining_hours', 'company')
|
||||
def _compute_display_name(self):
|
||||
super()._compute_display_name()
|
||||
with_remaining_hours = self.env.context.get('with_remaining_hours') and not self.env.context.get('skip_remaining_hours', False)
|
||||
if with_remaining_hours and any(line.remaining_hours_available for line in self):
|
||||
company = self.env.company
|
||||
encoding_uom = company.timesheet_encode_uom_id
|
||||
is_hour = is_day = False
|
||||
unit_label = ''
|
||||
if encoding_uom == self.env.ref('uom.product_uom_hour'):
|
||||
is_hour = True
|
||||
unit_label = _('remaining')
|
||||
elif encoding_uom == self.env.ref('uom.product_uom_day'):
|
||||
is_day = True
|
||||
unit_label = _('days remaining')
|
||||
for line in self:
|
||||
if line.remaining_hours_available:
|
||||
remaining_time = ''
|
||||
if is_hour:
|
||||
remaining_time = f' ({format_duration(line.remaining_hours)} {unit_label})'
|
||||
elif is_day:
|
||||
remaining_days = company.project_time_mode_id._compute_quantity(line.remaining_hours, encoding_uom, round=False)
|
||||
remaining_time = f' ({remaining_days:.02f} {unit_label})'
|
||||
name = f'{line.display_name}{remaining_time}'
|
||||
line.display_name = name
|
||||
|
||||
@api.depends('product_id.service_policy')
|
||||
def _compute_remaining_hours_available(self):
|
||||
for line in self:
|
||||
is_ordered_prepaid = line.product_id.service_policy == 'ordered_prepaid'
|
||||
is_time_product = line.product_uom_id and line.product_uom_id._has_common_reference(self.env.ref('uom.product_uom_hour'))
|
||||
line.remaining_hours_available = is_ordered_prepaid and is_time_product
|
||||
|
||||
@api.depends('qty_delivered', 'product_uom_qty', 'analytic_line_ids')
|
||||
def _compute_remaining_hours(self):
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
for line in self:
|
||||
remaining_hours = None
|
||||
if line.remaining_hours_available:
|
||||
qty_left = line.product_uom_qty - line.qty_delivered
|
||||
remaining_hours = line.product_uom_id._compute_quantity(qty_left, uom_hour, round=False)
|
||||
line.remaining_hours = remaining_hours
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_qty_delivered_method(self):
|
||||
""" Sale Timesheet module compute delivered qty for product [('type', 'in', ['service']), ('service_type', '=', 'timesheet')] """
|
||||
super()._compute_qty_delivered_method()
|
||||
for line in self:
|
||||
if not line.is_expense and line.product_id.type == 'service' and line.product_id.service_type == 'timesheet':
|
||||
line.qty_delivered_method = 'timesheet'
|
||||
|
||||
@api.depends('analytic_line_ids.project_id', 'project_id.pricing_type')
|
||||
def _compute_qty_delivered(self):
|
||||
super()._compute_qty_delivered()
|
||||
|
||||
def _prepare_qty_delivered(self):
|
||||
delivered_qties = super()._prepare_qty_delivered()
|
||||
lines_by_timesheet = self.filtered(lambda sol: sol.qty_delivered_method == 'timesheet')
|
||||
domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
|
||||
mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
|
||||
for line in lines_by_timesheet:
|
||||
delivered_qties[line] = mapping.get(line.id or line._origin.id, 0.0)
|
||||
return delivered_qties
|
||||
|
||||
def _timesheet_compute_delivered_quantity_domain(self):
|
||||
""" Hook for validated timesheet in addionnal module """
|
||||
domain = [('project_id', '!=', False)]
|
||||
if self.env.context.get('accrual_entry_date'):
|
||||
domain += [('date', '<=', self.env.context['accrual_entry_date'])]
|
||||
return domain
|
||||
|
||||
###########################################
|
||||
# Service : Project and task generation
|
||||
###########################################
|
||||
|
||||
def _convert_qty_company_hours(self, dest_company):
|
||||
company_time_uom_id = dest_company.project_time_mode_id
|
||||
allocated_hours = 0.0
|
||||
product_uom = self.product_uom_id
|
||||
if product_uom == self.env.ref('uom.product_uom_unit'):
|
||||
product_uom = self.env.ref('uom.product_uom_hour')
|
||||
if product_uom != company_time_uom_id and product_uom._has_common_reference(company_time_uom_id):
|
||||
allocated_hours = product_uom._compute_quantity(self.product_uom_qty, company_time_uom_id, rounding_method='HALF-UP')
|
||||
else:
|
||||
allocated_hours = self.product_uom_qty
|
||||
return allocated_hours
|
||||
|
||||
def _timesheet_create_project(self):
|
||||
project = super()._timesheet_create_project()
|
||||
# we can skip all the allocated hours calculation if allocated hours is already set on the template project
|
||||
if self.product_id.project_template_id.allocated_hours:
|
||||
project.write({
|
||||
'allocated_hours': self.product_id.project_template_id.allocated_hours,
|
||||
'allow_timesheets': True,
|
||||
})
|
||||
return project
|
||||
project_uom = self.company_id.project_time_mode_id
|
||||
uom_unit = self.env.ref('uom.product_uom_unit')
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
|
||||
# dict of inverse factors for each relevant UoM found in SO
|
||||
factor_per_id = {
|
||||
uom.id: uom.factor
|
||||
for uom in self.order_id.order_line.product_uom_id
|
||||
}
|
||||
# if sold as units, assume hours for time allocation
|
||||
factor_per_id[uom_unit.id] = uom_hour.factor
|
||||
|
||||
allocated_hours = 0.0
|
||||
# method only called once per project, so also allocate hours for
|
||||
# all lines in SO that will share the same project
|
||||
for line in self.order_id.order_line:
|
||||
if line.is_service \
|
||||
and line.product_id.service_tracking in ['task_in_project', 'project_only'] \
|
||||
and line.product_id.project_template_id == self.product_id.project_template_id \
|
||||
and line.product_uom_id.id in factor_per_id:
|
||||
uom_factor = factor_per_id[line.product_uom_id.id] / project_uom.factor
|
||||
allocated_hours += line.product_uom_qty * uom_factor
|
||||
|
||||
project.write({
|
||||
'allocated_hours': allocated_hours,
|
||||
'allow_timesheets': True,
|
||||
})
|
||||
return project
|
||||
|
||||
def _timesheet_create_project_prepare_values(self):
|
||||
"""Generate project values"""
|
||||
values = super()._timesheet_create_project_prepare_values()
|
||||
values['allow_billable'] = True
|
||||
return values
|
||||
|
||||
def _recompute_qty_to_invoice(self, start_date, end_date):
|
||||
""" Recompute the qty_to_invoice field for product containing timesheets
|
||||
|
||||
Search the existed timesheets between the given period in parameter.
|
||||
Retrieve the unit_amount of this timesheet and then recompute
|
||||
the qty_to_invoice for each current product.
|
||||
|
||||
:param start_date: the start date of the period
|
||||
:param end_date: the end date of the period
|
||||
"""
|
||||
lines_by_timesheet = self.filtered(lambda sol: sol.product_id and sol.product_id._is_delivered_timesheet())
|
||||
domain = Domain(lines_by_timesheet._timesheet_compute_delivered_quantity_domain())
|
||||
refund_account_moves = self.order_id.invoice_ids.filtered(lambda am: am.state == 'posted' and am.move_type == 'out_refund').reversed_entry_id
|
||||
timesheet_domain = Domain('timesheet_invoice_id', '=', False) | Domain('timesheet_invoice_id.state', '=', 'cancel')
|
||||
if refund_account_moves:
|
||||
credited_timesheet_domain = Domain('timesheet_invoice_id.state', '=', 'posted') & Domain('timesheet_invoice_id', 'in', refund_account_moves.ids)
|
||||
timesheet_domain |= credited_timesheet_domain
|
||||
domain &= timesheet_domain
|
||||
if start_date:
|
||||
domain &= Domain('date', '>=', start_date)
|
||||
if end_date:
|
||||
domain &= Domain('date', '<=', end_date)
|
||||
mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
|
||||
|
||||
for line in lines_by_timesheet:
|
||||
qty_to_invoice = mapping.get(line.id, 0.0)
|
||||
if qty_to_invoice:
|
||||
line.qty_to_invoice = qty_to_invoice
|
||||
else:
|
||||
prev_inv_status = line.invoice_status
|
||||
line.qty_to_invoice = qty_to_invoice
|
||||
line.invoice_status = prev_inv_status
|
||||
|
||||
def _get_action_per_item(self):
|
||||
""" Get action per Sales Order Item
|
||||
|
||||
When the Sales Order Item contains a service product then the action will be View Timesheets.
|
||||
|
||||
:returns: Dict containing id of SOL as key and the action as value
|
||||
"""
|
||||
action_per_sol = super()._get_action_per_item()
|
||||
timesheet_action = self.env.ref('sale_timesheet.timesheet_action_from_sales_order_item').id
|
||||
timesheet_ids_per_sol = {}
|
||||
if self.env.user.has_group('hr_timesheet.group_hr_timesheet_user'):
|
||||
timesheet_read_group = self.env['account.analytic.line']._read_group([('so_line', 'in', self.ids), ('project_id', '!=', False)], ['so_line'], ['id:array_agg'])
|
||||
timesheet_ids_per_sol = {so_line.id: ids for so_line, ids in timesheet_read_group}
|
||||
for sol in self:
|
||||
timesheet_ids = timesheet_ids_per_sol.get(sol.id, [])
|
||||
if sol.is_service and len(timesheet_ids) > 0:
|
||||
action_per_sol[sol.id] = timesheet_action, timesheet_ids[0] if len(timesheet_ids) == 1 else False
|
||||
return action_per_sol
|
||||
|
||||
@api.model
|
||||
def _get_product_service_policy(self):
|
||||
return super()._get_product_service_policy() + ['delivered_timesheet']
|
||||
Loading…
Add table
Add a link
Reference in a new issue