summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjbm-odoo <jbm@odoo.com>2020-09-15 14:44:03 +0000
committerjbm-odoo <jbm@odoo.com>2020-09-18 14:21:46 +0000
commit81b6e13cc6d2065c9274a50719acfeebe0d7cf99 (patch)
tree1429e90ac2dd9d7320808ca9770160e23937bd19
parent255eb91dd5e9fb39a7625064d1ba8e0b492bfb85 (diff)
[FIX] sale_timesheet: Billable project
taskId 2338803 closes odoo/odoo#57761 Related: odoo/enterprise#13241 Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
-rw-r--r--addons/sale_project/models/sale_order.py2
-rw-r--r--addons/sale_timesheet/models/project.py6
-rw-r--r--addons/sale_timesheet/tests/test_project_billing.py4
-rw-r--r--addons/sale_timesheet/views/project_task_views.xml14
-rw-r--r--addons/sale_timesheet/wizard/project_create_sale_order.py12
-rw-r--r--addons/sale_timesheet/wizard/project_task_create_sale_order.py10
6 files changed, 23 insertions, 25 deletions
diff --git a/addons/sale_project/models/sale_order.py b/addons/sale_project/models/sale_order.py
index ffeeae5ffa4..3a2a2866f92 100644
--- a/addons/sale_project/models/sale_order.py
+++ b/addons/sale_project/models/sale_order.py
@@ -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)
diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py
index 229594df6e1..92bf39ea41c 100644
--- a/addons/sale_timesheet/models/project.py
+++ b/addons/sale_timesheet/models/project.py
@@ -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})
diff --git a/addons/sale_timesheet/tests/test_project_billing.py b/addons/sale_timesheet/tests/test_project_billing.py
index 9570595ca30..50ab8abbdac 100644
--- a/addons/sale_timesheet/tests/test_project_billing.py
+++ b/addons/sale_timesheet/tests/test_project_billing.py
@@ -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")
diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml
index ed0df2ab2f3..d59c3fb37b1 100644
--- a/addons/sale_timesheet/views/project_task_views.xml
+++ b/addons/sale_timesheet/views/project_task_views.xml
@@ -73,11 +73,7 @@
<field name="bill_type" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('allow_timesheets', '=', False)], 'required': ['&amp;', ('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': ['&amp;', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" widget="radio"/>
- <div class="o_td_label" attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('allow_timesheets', '=', False), '&amp;', ('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), '&amp;', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate')], 'required': ['&amp;', ('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), '&amp;', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate')], 'required': ['&amp;', ('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': ['|', '&amp;', ('parent.timesheet_product_id', '=', False), ('parent.sale_line_id', '=', False), '&amp;', '&amp;', ('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), '&amp;', '&amp;', '|', ('parent.bill_type', '!=', 'customer_project'), ('parent.pricing_type', '!=', 'employee_rate'), ('parent.timesheet_product_id', '=', False), ('parent.sale_line_id', '=', False), '&amp;', '&amp;', ('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': ['|', '|', '&amp;', ('sale_order_id', '=', False), ('bill_type', '!=', 'customer_task'), '&amp;', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate'), ('partner_id', '=', False)]}
+ {'invisible': ['|', '|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), '&amp;', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate'), ('partner_id', '=', False)]}
</attribute>
<attribute name="placeholder">Leave empty if non-billable</attribute>
</xpath>
diff --git a/addons/sale_timesheet/wizard/project_create_sale_order.py b/addons/sale_timesheet/wizard/project_create_sale_order.py
index 6a5ce482428..a9f20e45ecc 100644
--- a/addons/sale_timesheet/wizard/project_create_sale_order.py
+++ b/addons/sale_timesheet/wizard/project_create_sale_order.py
@@ -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
diff --git a/addons/sale_timesheet/wizard/project_task_create_sale_order.py b/addons/sale_timesheet/wizard/project_task_create_sale_order.py
index cc9d9f6dcc8..02dbda23ab7 100644
--- a/addons/sale_timesheet/wizard/project_task_create_sale_order.py
+++ b/addons/sale_timesheet/wizard/project_task_create_sale_order.py
@@ -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