[FIX] sale_timesheet: Billable project
taskId 2338803 closes odoo/odoo#58198 X-original-commit: 81b6e13cc6d2065c9274a50719acfeebe0d7cf99 Related: odoo/enterprise#13405 Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com> Signed-off-by: jbm-odoo <jbm-odoo@users.noreply.github.com>
This commit is contained in:
@@ -147,7 +147,7 @@ class SaleOrderLine(models.Model):
|
||||
# changing the ordered quantity should change the planned hours on the
|
||||
# task, whatever the SO state. It will be blocked by the super in case
|
||||
# of a locked sale order.
|
||||
if 'product_uom_qty' in values:
|
||||
if 'product_uom_qty' in values and not self.env.context.get('no_update_planned_hours', False):
|
||||
for line in self:
|
||||
if line.task_id:
|
||||
planned_hours = line._convert_qty_company_hours(line.task_id.company_id)
|
||||
|
||||
@@ -83,7 +83,7 @@ class Project(models.Model):
|
||||
def _compute_warning_employee_rate(self):
|
||||
projects = self.filtered(lambda p: p.allow_billable and p.allow_timesheets and p.bill_type == 'customer_project' and p.pricing_type == 'employee_rate')
|
||||
tasks = projects.task_ids.filtered(lambda t: not t.non_allow_billable)
|
||||
employees = self.env['account.analytic.line'].read_group([('task_id', 'in', tasks.ids)], ['employee_id', 'project_id'], ['employee_id', 'project_id'], lazy=False)
|
||||
employees = self.env['account.analytic.line'].read_group([('task_id', 'in', tasks.ids), ('non_allow_billable', '=', False)], ['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]]
|
||||
@@ -225,7 +225,7 @@ class ProjectTask(models.Model):
|
||||
for task in self:
|
||||
show = True
|
||||
if not task.allow_billable or not task.allow_timesheets or \
|
||||
(task.bill_type != 'customer_task' and not task.timesheet_product_id) or not task.partner_id or \
|
||||
(task.bill_type != 'customer_task' and not task.timesheet_product_id) or (not task.partner_id and task.bill_type != 'customer_task') or \
|
||||
task.sale_order_id or (task.bill_type != 'customer_task' and task.pricing_type != 'employee_rate'):
|
||||
show = False
|
||||
task.display_create_order = show
|
||||
@@ -321,10 +321,12 @@ class ProjectTask(models.Model):
|
||||
)
|
||||
if values['non_allow_billable']:
|
||||
timesheet_ids.write({'so_line': False})
|
||||
self.sale_line_id = False
|
||||
else:
|
||||
# We write project on timesheet lines to call _timesheet_preprocess. This function will set correct the SOL
|
||||
for project in timesheet_ids.project_id:
|
||||
current_timesheet_ids = timesheet_ids.filtered(lambda t: t.project_id == project)
|
||||
current_timesheet_ids.task_id.update({'sale_line_id': project.sale_line_id.id})
|
||||
for employee in current_timesheet_ids.employee_id:
|
||||
current_timesheet_ids.filtered(lambda t: t.employee_id == employee).write({'project_id': project.id})
|
||||
|
||||
|
||||
@@ -239,8 +239,8 @@ class TestProjectBilling(TestCommonSaleTimesheet):
|
||||
self.assertFalse(line1.project_id, "Sale line 1 should be linked to the 'non billable' project")
|
||||
self.assertEqual(line2.project_id, self.project_non_billable, "Sale line 3 should be linked to the 'non billable' project")
|
||||
self.assertEqual(line1.price_unit, 15, "The unit price of SOL 1 should be 15")
|
||||
self.assertEqual(line1.product_uom_qty, 0, "The ordered qty of SOL 1 should be one")
|
||||
self.assertEqual(line2.product_uom_qty, 0, "The ordered qty of SOL 1 should be one")
|
||||
self.assertEqual(line1.product_uom_qty, 3, "The ordered qty of SOL 1 should be 3")
|
||||
self.assertEqual(line2.product_uom_qty, 2, "The ordered qty of SOL 2 should be 2")
|
||||
|
||||
self.assertEqual(self.project_non_billable.sale_line_employee_ids.mapped('sale_line_id'), sale_order.order_line, "The SO lines of the map should be the same of the sales order")
|
||||
self.assertEqual(timesheet1.so_line, line1, "Timesheet1 should be linked to sale line 1, as employee manager create the timesheet")
|
||||
|
||||
@@ -73,11 +73,7 @@
|
||||
<field name="bill_type" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('allow_timesheets', '=', False)], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" widget="radio"/>
|
||||
<field name="partner_id" attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('allow_timesheets', '=', False), ('bill_type', '!=', 'customer_project')], 'required': [('bill_type', '=', 'customer_project')]}"/>
|
||||
<field name="pricing_type" attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('allow_timesheets', '=', False), ('bill_type', '!=', 'customer_project')], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" widget="radio"/>
|
||||
<div class="o_td_label" attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('allow_timesheets', '=', False), '&', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate')]}">
|
||||
<label for="timesheet_product_id" string="Default Service" attrs="{'invisible': [('bill_type', '!=', 'customer_task')]}"/>
|
||||
<label for="timesheet_product_id" string="Service" attrs="{'invisible': [('bill_type', '=', 'customer_task')]}"/>
|
||||
</div>
|
||||
<field name="timesheet_product_id" nolabel="1" attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('allow_timesheets', '=', False), '&', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate')], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet', 'default_service_type': 'timesheet'}"/>
|
||||
<field name="timesheet_product_id" string="Service" attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('allow_timesheets', '=', False), '&', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate')], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet', 'default_service_type': 'timesheet'}"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
@@ -91,9 +87,9 @@
|
||||
<field name="allow_billable"/>
|
||||
<field name="warning_employee_rate"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('o_kanban_card_manage_pane')]" position="after">
|
||||
<xpath expr="//div[hasclass('o_primary')]//span" position="inside">
|
||||
<t t-if="record.warning_employee_rate.raw_value">
|
||||
<i class="float-right mr-5 mt-1 fa fa-exclamation-triangle text-danger" role="img" title="Some of the employees who are recording time on this project are not linked to any Sales Order Item. This means that their time will be considered as non-billable." aria-label="Some of the employees who are recording time on this project are not linked to any Sales Order Item. This means that their time will be considered as non-billable."/>
|
||||
<i class="ml-2 fa fa-exclamation-triangle text-danger small" role="img" title="Some of the employees who are recording time on this project are not linked to any Sales Order Item. This means that their time will be considered as non-billable." aria-label="Some of the employees who are recording time on this project are not linked to any Sales Order Item. This means that their time will be considered as non-billable."/>
|
||||
</t>
|
||||
</xpath>
|
||||
<xpath expr="//a[@name='action_view_account_analytic_line']" position="attributes">
|
||||
@@ -147,7 +143,7 @@
|
||||
<xpath expr="//field[@name='timesheet_ids']/tree" position="inside">
|
||||
<field name="timesheet_invoice_id" invisible="1"/>
|
||||
<field name="so_line" readonly="1" attrs="{'column_invisible': [('parent.has_multi_sol', '=', False), '|', ('parent.is_project_map_empty', '=', True), ('parent.pricing_type', '!=', 'employee_rate')]}" optional="hide"/>
|
||||
<field name="non_allow_billable" attrs="{'column_invisible': ['|', '&', ('parent.timesheet_product_id', '=', False), ('parent.sale_line_id', '=', False), '&', '&', ('parent.bill_type', '=', 'customer_project'), ('parent.pricing_type', '=', 'employee_rate'), ('parent.non_allow_billable', '=', True)]}" optional="hide"/>
|
||||
<field name="non_allow_billable" attrs="{'column_invisible': ['|', '|', ('parent.allow_billable', '=', False), '&', '&', '|', ('parent.bill_type', '!=', 'customer_project'), ('parent.pricing_type', '!=', 'employee_rate'), ('parent.timesheet_product_id', '=', False), ('parent.sale_line_id', '=', False), '&', '&', ('parent.bill_type', '=', 'customer_project'), ('parent.pricing_type', '=', 'employee_rate'), ('parent.non_allow_billable', '=', True)]}" optional="hide"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
@@ -159,7 +155,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='sale_line_id']" position="attributes">
|
||||
<attribute name="attrs">
|
||||
{'invisible': ['|', '|', '&', ('sale_order_id', '=', False), ('bill_type', '!=', 'customer_task'), '&', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate'), ('partner_id', '=', False)]}
|
||||
{'invisible': ['|', '|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), '&', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate'), ('partner_id', '=', False)]}
|
||||
</attribute>
|
||||
<attribute name="placeholder">Leave empty if non-billable</attribute>
|
||||
</xpath>
|
||||
|
||||
@@ -69,7 +69,7 @@ class ProjectCreateSalesOrder(models.TransientModel):
|
||||
tasks = line.project_id.tasks.filtered(lambda t: not t.non_allow_billable)
|
||||
domain = self.env['sale.order.line']._timesheet_compute_delivered_quantity_domain()
|
||||
timesheet = self.env['account.analytic.line'].read_group(domain + [('task_id', 'in', tasks.ids), ('so_line', '=', False), ('timesheet_invoice_id', '=', False)], ['unit_amount'], ['task_id'])
|
||||
unit_amount = round(timesheet[0].get('unit_amount', 0), 2) if timesheet else 0
|
||||
unit_amount = round(sum(t.get('unit_amount', 0) for t in timesheet), 2) if timesheet else 0
|
||||
if not unit_amount:
|
||||
line.info_invoice = False
|
||||
continue
|
||||
@@ -79,10 +79,8 @@ class ProjectCreateSalesOrder(models.TransientModel):
|
||||
label = _("days")
|
||||
if line.link_selection == 'create':
|
||||
line.info_invoice = _("%(amount)s %(label)s will be added to the new Sales Order.", amount=unit_amount, label=label)
|
||||
elif line.sale_order_id:
|
||||
line.info_invoice = _("%(amount)s %(label)s will be added to the selected Sales Order.", amount=unit_amount, label=label)
|
||||
else:
|
||||
line.info_invoice = False
|
||||
line.info_invoice = _("%(amount)s %(label)s will be added to the selected Sales Order.", amount=unit_amount, label=label)
|
||||
|
||||
@api.onchange('partner_id')
|
||||
def _onchange_partner_id(self):
|
||||
@@ -224,6 +222,9 @@ class ProjectCreateSalesOrder(models.TransientModel):
|
||||
self.env['account.analytic.line'].search([('task_id', 'in', task_ids.ids), ('so_line', '=', False)]).write({
|
||||
'so_line': sale_order_line.id
|
||||
})
|
||||
sale_order_line.with_context({'no_update_planned_hours': True}).write({
|
||||
'product_uom_qty': sale_order_line.qty_delivered
|
||||
})
|
||||
|
||||
# link the project to the SO line
|
||||
self.project_id.write({
|
||||
@@ -298,6 +299,9 @@ class ProjectCreateSalesOrder(models.TransientModel):
|
||||
self.env['account.analytic.line'].search([('task_id', 'in', tasks.ids), ('employee_id', '=', map_entry.employee_id.id), ('so_line', '=', False)]).write({
|
||||
'so_line': map_entry.sale_line_id.id
|
||||
})
|
||||
map_entry.sale_line_id.with_context({'no_update_planned_hours': True}).write({
|
||||
'product_uom_qty': map_entry.sale_line_id.qty_delivered
|
||||
})
|
||||
|
||||
return map_entries
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class ProjectTaskCreateSalesOrder(models.TransientModel):
|
||||
link_selection = fields.Selection([('create', 'Create a new sales order'), ('link', 'Link to an existing sales order')], required=True, default='create')
|
||||
|
||||
task_id = fields.Many2one('project.task', "Task", domain=[('sale_line_id', '=', False)], help="Task for which we are creating a sales order", required=True)
|
||||
partner_id = fields.Many2one('res.partner', string="Customer", help="Customer of the sales order")
|
||||
partner_id = fields.Many2one('res.partner', string="Customer", help="Customer of the sales order", required=True)
|
||||
product_id = fields.Many2one('product.product', domain=[('type', '=', 'service'), ('invoice_policy', '=', 'delivery'), ('service_type', '=', 'timesheet')], string="Service", help="Product of the sales order item. Must be a service invoiced based on timesheets on tasks. The existing timesheet will be linked to this product.", required=True)
|
||||
price_unit = fields.Float("Unit Price", help="Unit price of the sales order item.")
|
||||
currency_id = fields.Many2one('res.currency', string="Currency", related='product_id.currency_id', readonly=False)
|
||||
@@ -59,10 +59,8 @@ class ProjectTaskCreateSalesOrder(models.TransientModel):
|
||||
label = _("days")
|
||||
if line.link_selection == 'create' and line.price_unit:
|
||||
line.info_invoice = _("%(amount)s %(label)s will be added to the new Sales Order.", amount=unit_amount, label=label)
|
||||
elif line.sale_line_id:
|
||||
line.info_invoice = _("%(amount)s %(label)s will be added to the selected Sales Order.", amount=unit_amount, label=label)
|
||||
else:
|
||||
line.info_invoice = False
|
||||
line.info_invoice = _("%(amount)s %(label)s will be added to the selected Sales Order.", amount=unit_amount, label=label)
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
@@ -111,8 +109,6 @@ class ProjectTaskCreateSalesOrder(models.TransientModel):
|
||||
if self.task_id.sale_line_id:
|
||||
raise UserError(_("The task is already linked to a sales order item."))
|
||||
|
||||
timesheet_with_so_line = self.env['account.analytic.line'].search([('task_id', '=', self.task_id.id), ('so_line', '!=', False), ('project_id', '!=', False)])
|
||||
|
||||
# create SO
|
||||
sale_order = self.env['sale.order'].create({
|
||||
'partner_id': self.partner_id.id,
|
||||
@@ -131,7 +127,7 @@ class ProjectTaskCreateSalesOrder(models.TransientModel):
|
||||
'price_unit': self.price_unit,
|
||||
'project_id': self.task_id.project_id.id, # prevent to re-create a project on confirmation
|
||||
'task_id': self.task_id.id,
|
||||
'product_uom_qty': self.task_id.total_hours_spent - round(sum(timesheet_with_so_line.mapped('unit_amount')), 2),
|
||||
'product_uom_qty': round(sum(self.task_id.timesheet_ids.filtered(lambda t: not t.non_allow_billable and not t.so_line).mapped('unit_amount')), 2),
|
||||
})
|
||||
|
||||
# link task to SOL
|
||||
|
||||
Reference in New Issue
Block a user