735 lines
40 KiB
Python
735 lines
40 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from ast import literal_eval
|
|
from collections import defaultdict
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
from odoo.fields import Domain
|
|
from odoo.tools.misc import unquote
|
|
|
|
|
|
class ProjectTask(models.Model):
|
|
_inherit = "project.task"
|
|
|
|
def _domain_sale_line_id(self):
|
|
domain = Domain.AND([
|
|
self.env['sale.order.line']._sellable_lines_domain(),
|
|
self.env['sale.order.line']._domain_sale_line_service(),
|
|
[
|
|
'|',
|
|
'|',
|
|
('order_partner_id', 'child_of', unquote('partner_id if partner_id else []')),
|
|
('order_id.partner_shipping_id', 'child_of', unquote('partner_id if partner_id else []')),
|
|
'|',
|
|
('order_partner_id', '=?', unquote('partner_id')),
|
|
('order_id.partner_shipping_id', '=?', unquote('partner_id')),
|
|
],
|
|
])
|
|
return domain
|
|
|
|
allow_material = fields.Boolean(related='project_id.allow_material', export_string_translation=False)
|
|
allow_quotations = fields.Boolean(related='project_id.allow_quotations', export_string_translation=False)
|
|
quotation_count = fields.Integer(compute='_compute_quotation_count')
|
|
material_line_product_count = fields.Integer(compute='_compute_material_line_totals', export_string_translation=False)
|
|
material_line_total_price = fields.Float(compute='_compute_material_line_totals')
|
|
currency_id = fields.Many2one('res.currency', compute='_compute_currency_id', compute_sudo=True)
|
|
display_create_invoice_primary = fields.Boolean(compute='_compute_display_create_invoice_buttons', export_string_translation=False)
|
|
display_create_invoice_secondary = fields.Boolean(compute='_compute_display_create_invoice_buttons', export_string_translation=False)
|
|
invoice_status = fields.Selection(related='sale_order_id.invoice_status', export_string_translation=False)
|
|
warning_message = fields.Char('Warning Message', compute='_compute_warning_message', export_string_translation=False)
|
|
invoice_count = fields.Integer("Number of invoices", related='sale_order_id.invoice_count')
|
|
pricelist_id = fields.Many2one('product.pricelist', compute="_compute_pricelist_id", export_string_translation=False)
|
|
under_warranty = fields.Boolean('Under Warranty',
|
|
copy=False,
|
|
help='If ticked, the time and materials used for this task will not be billed to the customer. '
|
|
'However, the inventory of consumed materials will still be updated.')
|
|
|
|
# Project Sharing fields
|
|
portal_quotation_count = fields.Integer(compute='_compute_portal_quotation_count')
|
|
portal_invoice_count = fields.Integer('Invoice Count', compute='_compute_portal_invoice_count')
|
|
|
|
@api.depends(
|
|
'allow_material', 'timer_start', 'worksheet_signature',
|
|
'display_satisfied_conditions_count', 'display_enabled_conditions_count',
|
|
)
|
|
def _compute_display_sign_report_buttons(self):
|
|
for task in self:
|
|
sign_p, sign_s = True, True
|
|
if (
|
|
not task.allow_material
|
|
or task.sudo().timer_start
|
|
or task.worksheet_signature
|
|
or not task.display_satisfied_conditions_count
|
|
):
|
|
sign_p, sign_s = False, False
|
|
else:
|
|
if task.display_enabled_conditions_count == task.display_satisfied_conditions_count:
|
|
sign_s = False
|
|
else:
|
|
sign_p = False
|
|
task.update({
|
|
'display_sign_report_primary': sign_p,
|
|
'display_sign_report_secondary': sign_s,
|
|
})
|
|
|
|
@api.depends(
|
|
'allow_material', 'timer_start', 'display_satisfied_conditions_count',
|
|
'display_enabled_conditions_count', 'fsm_is_sent',
|
|
)
|
|
def _compute_display_send_report_buttons(self):
|
|
for task in self:
|
|
send_p, send_s = True, True
|
|
if (
|
|
not task.allow_material
|
|
or task.sudo().timer_start
|
|
or not task.display_satisfied_conditions_count
|
|
or task.fsm_is_sent
|
|
):
|
|
send_p, send_s = False, False
|
|
else:
|
|
if task.display_enabled_conditions_count == task.display_satisfied_conditions_count:
|
|
send_s = False
|
|
else:
|
|
send_p = False
|
|
task.update({
|
|
'display_send_report_primary': send_p,
|
|
'display_send_report_secondary': send_s,
|
|
})
|
|
|
|
def _is_fsm_report_available(self):
|
|
self.ensure_one()
|
|
return super()._is_fsm_report_available() or self.material_line_product_count
|
|
|
|
@property
|
|
def TASK_PORTAL_READABLE_FIELDS(self):
|
|
return super().TASK_PORTAL_READABLE_FIELDS | {'allow_material',
|
|
'allow_quotations',
|
|
'portal_quotation_count',
|
|
'material_line_product_count',
|
|
'material_line_total_price',
|
|
'currency_id',
|
|
'portal_invoice_count',
|
|
'warning_message'}
|
|
|
|
@api.depends('sale_order_id.pricelist_id', 'partner_id.property_product_pricelist')
|
|
def _compute_pricelist_id(self):
|
|
pricelist_active = self.env.user.has_group('product.group_product_pricelist')
|
|
for task in self:
|
|
task.pricelist_id = pricelist_active and \
|
|
(task.sale_order_id.sudo().pricelist_id or task.partner_id.property_product_pricelist)
|
|
|
|
@api.depends('pricelist_id', 'company_id')
|
|
def _compute_currency_id(self):
|
|
for task in self:
|
|
task.currency_id = task.pricelist_id.currency_id or task.company_id.currency_id
|
|
|
|
@api.depends('allow_material', 'material_line_product_count')
|
|
def _compute_display_conditions_count(self):
|
|
super()._compute_display_conditions_count()
|
|
for task in self:
|
|
enabled = task.display_enabled_conditions_count
|
|
satisfied = task.display_satisfied_conditions_count
|
|
enabled += task.allow_material
|
|
satisfied += 1 if task.allow_material and task.material_line_product_count else 0
|
|
task.update({
|
|
'display_enabled_conditions_count': enabled,
|
|
'display_satisfied_conditions_count': satisfied
|
|
})
|
|
|
|
def _compute_quotation_count(self):
|
|
quotation_data = self.sudo().env['sale.order']._read_group([('task_id', 'in', self.ids)], ['task_id'], ['__count'])
|
|
mapped_data = {task.id: count for task, count in quotation_data}
|
|
for task in self:
|
|
task.quotation_count = mapped_data.get(task.id, 0)
|
|
|
|
def _compute_portal_quotation_count(self):
|
|
domain = Domain('task_id', 'in', self.ids)
|
|
if self.env.user._is_portal():
|
|
domain &= Domain('state', '!=', 'draft')
|
|
quotation_data = self.env['sale.order']._read_group(domain, ['task_id'], ['__count'])
|
|
mapped_data = {task.id: count for task, count in quotation_data}
|
|
for task in self:
|
|
task.portal_quotation_count = mapped_data.get(task.id, 0)
|
|
|
|
def _has_no_billable_products(self):
|
|
return all(line.price_unit == 0 for line in self.sale_order_id.order_line)
|
|
|
|
@api.depends('sale_order_id.order_line.product_uom_qty', 'sale_order_id.order_line.price_total')
|
|
def _compute_material_line_totals(self):
|
|
material_sale_lines_by_task = self._get_material_sale_lines_by_task()
|
|
for task in self:
|
|
material_sale_lines = material_sale_lines_by_task.get(task, self.env['sale.order.line'])
|
|
task.material_line_total_price = sum(material_sale_lines.mapped('price_total'))
|
|
task.material_line_product_count = round(sum(material_sale_lines.mapped('product_uom_qty')))
|
|
|
|
@api.depends(
|
|
'is_fsm', 'fsm_done', 'allow_billable', 'timer_start',
|
|
'task_to_invoice', 'invoice_status')
|
|
def _compute_display_create_invoice_buttons(self):
|
|
for task in self:
|
|
primary, secondary = True, True
|
|
if not task.is_fsm or not task.fsm_done or not task.allow_billable or task.sudo().timer_start or \
|
|
not task.sale_order_id or task.invoice_status == 'invoiced' or \
|
|
task.sale_order_id.state in ['cancel']:
|
|
primary, secondary = False, False
|
|
else:
|
|
if task.invoice_status in ['upselling', 'to invoice']:
|
|
secondary = False
|
|
elif task.invoice_count > 0 and task.invoice_status == 'no':
|
|
secondary = False
|
|
primary = False
|
|
elif task.under_warranty and task._has_no_billable_products():
|
|
primary, secondary = False, False
|
|
else: # Means invoice status is 'Nothing to Invoice'
|
|
primary = False
|
|
task.update({
|
|
'display_create_invoice_primary': primary,
|
|
'display_create_invoice_secondary': secondary,
|
|
})
|
|
|
|
@api.depends('sale_line_id')
|
|
def _compute_warning_message(self):
|
|
employee_rate_fsm_tasks = self.sudo().filtered(lambda task:
|
|
task.pricing_type == 'employee_rate'
|
|
and task.sale_line_id
|
|
and task.timesheet_ids
|
|
and task.fsm_done)
|
|
for task in employee_rate_fsm_tasks:
|
|
if task.sale_line_id.order_id != task._origin.sale_line_id.order_id:
|
|
task.warning_message = _('By saving this change, all timesheet entries will be linked to the selected Sales Order Item without distinction.')
|
|
else:
|
|
task.warning_message = False
|
|
(self - employee_rate_fsm_tasks).update({'warning_message': False})
|
|
|
|
@api.depends_context('uid')
|
|
@api.depends('sale_order_id.invoice_ids')
|
|
def _compute_portal_invoice_count(self):
|
|
""" The goal of portal_invoice_count field is to show the Invoices stat button in Project sharing feature. """
|
|
is_portal_user = self.env.user._is_portal()
|
|
invoices_by_so = {}
|
|
available_invoices = False
|
|
if is_portal_user:
|
|
sale_orders_sudo = self.sale_order_id.sudo()
|
|
invoices_by_so = {so.id: set(so.invoice_ids.ids) for so in sale_orders_sudo}
|
|
available_invoices = set(self.env['account.move'].search([('id', 'in', sale_orders_sudo.invoice_ids.ids)]).ids)
|
|
for task in self:
|
|
task.portal_invoice_count = len(invoices_by_so.get(task.sale_order_id.id, set()).intersection(available_invoices)) if is_portal_user else task.invoice_count
|
|
|
|
def _compute_sale_order_id(self):
|
|
fsm_tasks = self.filtered('is_fsm')
|
|
fsm_task_to_sale_order = {task.id: task.sale_order_id for task in fsm_tasks}
|
|
super()._compute_sale_order_id()
|
|
for task in fsm_tasks:
|
|
if task.sale_order_id:
|
|
continue
|
|
|
|
sale_order_id = fsm_task_to_sale_order.get(task.id, False)
|
|
# the super call will remove the sale order from the task,
|
|
# if the partner on the task is not the same as the partner on the sale order.
|
|
# But for fsm tasks, the partner on the task could be the delivery address,
|
|
# so we redo the integrity check but with the shipping partner in mind
|
|
if sale_order_id and task.partner_id.commercial_partner_id in (
|
|
sale_order_id.partner_id.commercial_partner_id +
|
|
sale_order_id.partner_shipping_id.commercial_partner_id):
|
|
task.sale_order_id = sale_order_id
|
|
|
|
@api.depends('sale_order_id.partner_shipping_id')
|
|
def _compute_partner_id(self):
|
|
if self.env.user.has_group('account.group_delivery_invoice_address'):
|
|
for task in self:
|
|
if task.is_fsm and task.sale_order_id:
|
|
task.partner_id = task.sale_order_id.partner_shipping_id
|
|
return super()._compute_partner_id() # Call to super will reset partner_id for non-billable tasks
|
|
|
|
def action_create_invoice(self):
|
|
if not self.sale_order_id:
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _("Tasks need to be linked to a sales order to be invoiced."),
|
|
'type': 'danger',
|
|
},
|
|
}
|
|
# ensure the SO exists before invoicing, then confirm it
|
|
so_to_confirm = self.filtered(
|
|
lambda task: task.sale_order_id and task.sale_order_id.state in ['draft', 'sent']
|
|
).mapped('sale_order_id')
|
|
so_to_confirm.action_confirm()
|
|
|
|
# redirect create invoice wizard (of the Sales Order)
|
|
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_view_sale_advance_payment_inv")
|
|
context = literal_eval(action.get('context', "{}"))
|
|
so_task_mapping = defaultdict(list)
|
|
for task in self:
|
|
if task.sale_order_id:
|
|
# As the key is anyway stringified in the JS, we casted the key here to make it clear.
|
|
so_task_mapping[str(task.sale_order_id.id)].append(task.id)
|
|
context.update({
|
|
'active_id': self.sale_order_id.id if len(self) == 1 else False,
|
|
'active_ids': self.mapped('sale_order_id').ids,
|
|
'industry_fsm_message_post_task_id': so_task_mapping,
|
|
})
|
|
if not self.sale_order_id._get_invoiceable_lines(False):
|
|
context['default_advance_payment_method'] = 'percentage'
|
|
action['context'] = context
|
|
return action
|
|
|
|
def _get_last_sol_of_customer_domain(self):
|
|
self.ensure_one()
|
|
# For FSM task, we don't want to search the last SOL of the customer.
|
|
if self.is_fsm:
|
|
return []
|
|
return super()._get_last_sol_of_customer_domain()
|
|
|
|
def _show_time_and_material(self):
|
|
# check time and material section should visible or not in portal
|
|
return self.allow_material and self.allow_billable and self.sale_order_id and self.is_fsm and \
|
|
not self.under_warranty and not self._has_no_billable_products()
|
|
|
|
def _get_material_sale_lines_by_task(self):
|
|
def if_fsm_material_line(sale_line_id, task, employee_mapping_product_ids=None):
|
|
is_not_timesheet_line = sale_line_id.product_id != task.sudo().timesheet_product_id
|
|
if employee_mapping_product_ids: # Then we need to search the product in the employee mappings
|
|
is_not_timesheet_line = is_not_timesheet_line and sale_line_id.product_id.id not in employee_mapping_product_ids
|
|
is_not_empty = sale_line_id.product_uom_qty != 0
|
|
is_not_service_from_so = sale_line_id != task.sale_line_id
|
|
is_task_related = sale_line_id.task_id == (task or task._origin)
|
|
return all([is_not_timesheet_line, is_not_empty, is_not_service_from_so, is_task_related])
|
|
|
|
employee_mapping_read_group = self.env['project.sale.line.employee.map'].sudo()._read_group(
|
|
[('project_id', 'in', self.filtered('is_fsm').project_id.ids)],
|
|
['project_id'],
|
|
['timesheet_product_id:array_agg'],
|
|
)
|
|
employee_mapping_timesheet_product_ids = {project.id: timesheet_product_ids for project, timesheet_product_ids in employee_mapping_read_group}
|
|
sols = self.env['sale.order.line'].sudo().search([('order_id', 'in', self.sudo().sale_order_id.ids)])
|
|
sols_by_so = sols.grouped('order_id')
|
|
material_sale_lines_by_task = {
|
|
task: (
|
|
sols_by_so.get(task.sudo().sale_order_id, self.env['sale.order.line']).sudo().filtered(
|
|
lambda sol: if_fsm_material_line(sol, task, employee_mapping_timesheet_product_ids.get(task.project_id.id))
|
|
)
|
|
) for task in self
|
|
}
|
|
return material_sale_lines_by_task
|
|
|
|
def action_view_invoices(self):
|
|
invoices = self.mapped('sale_order_id.invoice_ids')
|
|
# prevent view with onboarding banner
|
|
list_view = self.env.ref('account.view_move_tree')
|
|
kanban_view = self.env.ref('account.view_account_move_kanban')
|
|
form_view = self.env.ref('account.view_move_form')
|
|
if len(invoices) == 1:
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Invoice'),
|
|
'res_model': 'account.move',
|
|
'view_mode': 'form',
|
|
'views': [[form_view.id, 'form']],
|
|
'res_id': invoices.id,
|
|
'context': {
|
|
'create': False,
|
|
'default_move_type': 'out_invoice',
|
|
}
|
|
}
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Invoices'),
|
|
'res_model': 'account.move',
|
|
'view_mode': 'list,kanban,form',
|
|
'views': [[list_view.id, 'list'], [kanban_view.id, 'kanban'], [form_view.id, 'form']],
|
|
'domain': [('id', 'in', invoices.ids)],
|
|
'context': {
|
|
'create': False,
|
|
'default_move_type': 'out_invoice',
|
|
}
|
|
}
|
|
|
|
def action_project_sharing_view_invoices(self):
|
|
""" Action used only in project sharing feature """
|
|
return {
|
|
"name": self.env._("Portal Invoices"),
|
|
"type": "ir.actions.act_url",
|
|
"url":
|
|
self.env['account.move'].search([('id', 'in', self.sale_order_id.sudo().invoice_ids.ids)], limit=1).get_portal_url()
|
|
if self.portal_invoice_count == 1
|
|
else f"/my/projects/{self.project_id.id}/task/{self.id}/invoices",
|
|
}
|
|
|
|
def action_fsm_create_quotation(self):
|
|
view_form_id = self.env.ref('sale.view_order_form').id
|
|
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_quotations")
|
|
action.update({
|
|
'views': [(view_form_id, 'form')],
|
|
'view_mode': 'form',
|
|
'name': self.name,
|
|
'context': {
|
|
'fsm_mode': True,
|
|
'default_partner_id': self.partner_id.id,
|
|
'default_task_id': self.id,
|
|
'default_company_id': self.company_id.id or self.env.company.id,
|
|
'default_origin': _("%(project_name)s - %(task_name)s", project_name=self.project_id.name, task_name=self.name),
|
|
},
|
|
})
|
|
return action
|
|
|
|
def action_fsm_view_quotations(self):
|
|
self.ensure_one()
|
|
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_quotations")
|
|
action.update({
|
|
'name': self.name,
|
|
'domain': [('task_id', '=', self.id)],
|
|
'context': {
|
|
'fsm_mode': True,
|
|
'default_task_id': self.id,
|
|
'default_partner_id': self.partner_id.id},
|
|
})
|
|
if self.quotation_count == 1:
|
|
action['res_id'] = self.env['sale.order'].search([('task_id', '=', self.id)]).id
|
|
action['views'] = [(self.env.ref('sale.view_order_form').id, 'form')]
|
|
return action
|
|
|
|
def action_project_sharing_view_quotations(self):
|
|
""" Action used only in project sharing feature """
|
|
self.ensure_one()
|
|
return {
|
|
"name": self.env._("Portal Quotations"),
|
|
"type": "ir.actions.act_url",
|
|
"url":
|
|
self.env['sale.order'].search([('task_id', '=', self.id)], limit=1).get_portal_url()
|
|
if self.portal_quotation_count == 1
|
|
else f"/my/projects/{self.project_id.id}/task/{self.id}/quotes",
|
|
}
|
|
|
|
def action_fsm_view_material(self):
|
|
if not self.partner_id:
|
|
raise UserError(_('A customer should be set on the task to generate a worksheet.'))
|
|
|
|
self = self.with_company(self.company_id)
|
|
|
|
domain = Domain([
|
|
('company_id', 'in', [self.company_id.id, False]),
|
|
('sale_ok', '=', True),
|
|
'|', ('type', '=', 'consu'),
|
|
'&', '&',
|
|
('type', '=', 'service'),
|
|
('invoice_policy', '=', 'delivery'),
|
|
('service_type', '=', 'manual'),
|
|
])
|
|
if self.project_id and self.timesheet_product_id:
|
|
domain &= Domain('id', '!=', self.timesheet_product_id.id)
|
|
|
|
kanban_view = self.env.ref('industry_fsm_sale.industry_fsm_sale_product_catalog_kanban_view')
|
|
search_view = self.env.ref('industry_fsm_sale.industry_fsm_sale_product_catalog_inherit_search_view')
|
|
|
|
context = {
|
|
'fsm_mode': True,
|
|
'create': self.env['product.template'].has_access('create'),
|
|
'fsm_task_id': self.id, # avoid 'default_' context key as we are going to create SOL with this context
|
|
'pricelist': self.partner_id.property_product_pricelist.id,
|
|
'order_id': self.sale_order_id.id,
|
|
**self.sale_order_id.sudo()._get_action_add_from_catalog_extra_context(),
|
|
'hide_qty_buttons': self.sale_order_id.sudo().locked,
|
|
'default_invoice_policy': 'delivery',
|
|
'search_default_fsm_quantity': self.state == '1_done',
|
|
'child_field': 'order_line',
|
|
}
|
|
if not context['product_catalog_currency_id']:
|
|
# fallback currency in case no SO yet
|
|
context['product_catalog_currency_id'] = self.currency_id.id
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Add Products'),
|
|
'res_model': 'product.product',
|
|
'views': [(kanban_view.id, 'kanban'), (False, 'form')],
|
|
'search_view_id': [search_view.id, 'search'],
|
|
'domain': domain,
|
|
'context': context,
|
|
'help': _("""<p class="o_view_nocontent_smiling_face">
|
|
No products found. Let's create one!
|
|
</p><p>
|
|
Keep track of the products you are using to complete your tasks, and invoice your customers for the goods.
|
|
Tip: using kits, you can add multiple products at once.
|
|
</p><p>
|
|
When your task is marked as done, your stock will be updated automatically. Simply choose a warehouse
|
|
in your profile from where to draw stock.
|
|
</p>""")
|
|
}
|
|
|
|
def action_fsm_validate(self, stop_running_timers=False):
|
|
""" If allow billable on task, timesheet product set on project and user has privileges :
|
|
Create SO confirmed with time and material.
|
|
"""
|
|
res = super().action_fsm_validate(stop_running_timers)
|
|
if res is True:
|
|
billable_tasks = self.filtered(lambda task: task.allow_billable and (task.allow_timesheets or task.allow_material))
|
|
timesheets_read_group = self.env['account.analytic.line'].sudo()._read_group([('task_id', 'in', billable_tasks.ids), ('project_id', '!=', False)], ['task_id'], ['__count'])
|
|
timesheet_count_by_task_dict = {task.id: count for task, count in timesheets_read_group}
|
|
for task in billable_tasks:
|
|
timesheet_count = timesheet_count_by_task_dict.get(task.id)
|
|
# Prevent creating/confirming a SO if there are no products and timesheets
|
|
# or task is under warranty and there is no material product on the task
|
|
if (not task.sale_order_id and not timesheet_count) or (task.under_warranty and task.material_line_product_count == 0):
|
|
continue
|
|
task._fsm_ensure_sale_order()
|
|
if task.allow_timesheets and not task.under_warranty:
|
|
task._fsm_create_sale_order_line()
|
|
if task.sudo().sale_order_id.state in ['draft', 'sent']:
|
|
task.sudo().sale_order_id.action_confirm()
|
|
billable_tasks._prepare_materials_delivery()
|
|
return res
|
|
|
|
def _fsm_ensure_sale_order(self):
|
|
""" get the SO of the task. If no one, create it and return it """
|
|
self.ensure_one()
|
|
if not self.sale_order_id:
|
|
self._fsm_create_sale_order()
|
|
sale_order = self.sale_order_id
|
|
if self.env.user.has_group('project.group_project_user'):
|
|
sale_order = self.sale_order_id.sudo()
|
|
if not sale_order.project_id:
|
|
sale_order.project_id = self.project_id
|
|
return self.sale_order_id
|
|
|
|
def _prepare_sale_order_values(self, team):
|
|
vals = {
|
|
'partner_id': self.partner_id.id,
|
|
'company_id': self.company_id.id or self.partner_id.company_id.id or self.env.company.id,
|
|
'project_id': self.project_id.id,
|
|
'team_id': team.id if team else False,
|
|
'origin': _("%(project_name)s - %(task_name)s", project_name=self.project_id.name, task_name=self.name),
|
|
}
|
|
return vals
|
|
|
|
def _fsm_create_sale_order(self):
|
|
""" Create the SO from the task, with the 'service product' sales line and link all timesheet to that line it """
|
|
self.ensure_one()
|
|
if not self.partner_id:
|
|
raise UserError(_('A customer should be set on the task to generate a worksheet.'))
|
|
|
|
SaleOrder = self.env['sale.order']
|
|
if self.env.user.has_group('project.group_project_user'):
|
|
SaleOrder = SaleOrder.sudo()
|
|
|
|
user_id = self.user_ids[0] if self.user_ids else self.env['res.users']
|
|
team = self.env['crm.team'].sudo()._get_default_team_id(user_id=user_id.id, domain=None)
|
|
vals = self._prepare_sale_order_values(team)
|
|
sale_order = SaleOrder.create(vals)
|
|
# update after creation since onchange_partner_id sets the current user
|
|
if user_id:
|
|
sale_order.user_id = user_id
|
|
|
|
self.sale_order_id = sale_order
|
|
|
|
def _get_sale_order_line_vals(self):
|
|
self.ensure_one()
|
|
return {
|
|
'order_id': self.sale_order_id.id,
|
|
# The project and the task are given to prevent the SOL to create a new project or task based on the config of the product.
|
|
'project_id': self.project_id.id,
|
|
'task_id': self.id,
|
|
}
|
|
|
|
def _fsm_create_sale_order_line(self):
|
|
""" Generate sales order item based on the pricing_type on the project and the timesheets in the current task
|
|
|
|
When the pricing_type = 'employee_rate', we need to search the employee mappings for the employee who timesheeted
|
|
in the current task to retrieve the product in each mapping and generate an SOL for this product with the total
|
|
hours of the related timesheet(s) as the ordered quantity. Some SOLs can be already generated if the user manually
|
|
adds the SOL in the task or when he adds some materials in the tasks, a SO is generated.
|
|
If the user manually adds in the SO some service products, we must check in these before generating new one.
|
|
When no SOL is linked to the task before marking this task as done and no existing SOLs correspond to the default
|
|
product in the project, we take the first SOL generated if no generated SOL contain the default product of the project.
|
|
Here are the steps realized for this case:
|
|
1) Get all timesheets in the tasks
|
|
2) Classify this timesheets by employee
|
|
3) Search the employee mappings (project.sale.line.employee.map model or the sale_line_employee_ids field in the
|
|
project model) for the employee who timesheets to have the product linked to the employee.
|
|
4) Use the dict created in the second step to classify the timesheets in another dict in which the key is the id
|
|
and the price_unit of the product and the id uom. This information is important for the generation of the SOL.
|
|
5) if no SOL is linked in the task then we add the default service project defined in the project into the dict
|
|
created in the previous step and value is the remaining timesheets.
|
|
That is, the ones are no impacted in the employee mappings (sale_line_employee_ids field) defined in the project.
|
|
6) Classify the existing SOLs of the SO linked to the task, because the SO can be generated before the user clicks
|
|
on 'mark as done' button, for instance, when the user adds materials for this task. A dict is created containing
|
|
the id and price_unit of the product as key and the SOL(s) containing this product.
|
|
6.1) If no SOL is linked, then we check in the existing SOLs if there is a SOL with the default product defined
|
|
in the product, if it is the case then the SOL will be linked to the task.
|
|
This step can be useless if the user doesn't manually add a service product in the SO. In fact, this step
|
|
searchs in the SOLs of the SO, if there is an SOL with the default service product defined in the project.
|
|
If it is the case then the SOL will be linked to the task.
|
|
7) foreach in the dict created in the step 4, in this loop, first of all, we search in the dict containing the
|
|
existing SOLs if the id of the product is containing in an existing SOL. If yes then, we don't generate an SOL
|
|
and link it to the timesheets linked to this product. Otherwise, we generate the SOL with the information containing
|
|
in the key and the timesheets containing in the value of the dict for this key.
|
|
|
|
When the pricing_type = 'task_rate', we generate a sales order item with product_uom_qty is equal to the total hours of timesheets in the task.
|
|
Once the SOL is generated we link this one to the task and its timesheets.
|
|
"""
|
|
self.ensure_one()
|
|
# Get all timesheets in the current task (step 1)
|
|
not_billed_timesheets = self.env['account.analytic.line'].sudo().search([('task_id', '=', self.id), ('project_id', '!=', False), ('is_so_line_edited', '=', False)]).filtered(lambda t: t._is_not_billed())
|
|
if not not_billed_timesheets: # prevent creating SOL without timesheets
|
|
return
|
|
if self.pricing_type == 'employee_rate':
|
|
# classify these timesheets by employee (step 2)
|
|
timesheets_by_employee_dict = defaultdict(lambda: self.env['account.analytic.line']) # key: employee_id, value: timesheets
|
|
for timesheet in not_billed_timesheets:
|
|
timesheets_by_employee_dict[timesheet.employee_id.id] |= timesheet
|
|
|
|
# Search the employee mappings for the employees whose timesheets in the task (step 3)
|
|
employee_mappings = self.env['project.sale.line.employee.map'].search([
|
|
('employee_id', 'in', list(timesheets_by_employee_dict.keys())),
|
|
('timesheet_product_id', '!=', False),
|
|
('project_id', '=', self.project_id.id)])
|
|
|
|
# Classify the timesheets by product (step 4)
|
|
product_timesheets_dict = defaultdict(lambda: self.env['account.analytic.line']) # key: (timesheet_product_id.id, price_unit, uom_id.id), value: list of timesheets
|
|
for mapping in employee_mappings:
|
|
employee_timesheets = timesheets_by_employee_dict[mapping.employee_id.id]
|
|
product_timesheets_dict[mapping.timesheet_product_id.id, mapping.price_unit, mapping.timesheet_product_id.uom_id.id] |= employee_timesheets
|
|
not_billed_timesheets -= employee_timesheets # we remove the timesheets because are linked to the mapping
|
|
|
|
product = self.env['product.product']
|
|
sol_in_task = bool(self.sale_line_id)
|
|
if not sol_in_task: # Then, add the default product of the project and remaining timesheets in the dict (step 5)
|
|
default_product = self.project_id.timesheet_product_id
|
|
if not_billed_timesheets:
|
|
# The remaining timesheets must be added in the sol with the default product defined in the fsm project
|
|
# if there is not SOL in the task
|
|
product = default_product
|
|
product_timesheets_dict[product.id, product.lst_price, product.uom_id.id] |= not_billed_timesheets
|
|
elif (default_product.id, default_product.lst_price, default_product.uom_id.id) in product_timesheets_dict:
|
|
product = default_product
|
|
|
|
# Get all existing service sales order items in the sales order (step 6)
|
|
existing_service_sols = self.sudo().sale_order_id.order_line.filtered('is_service')
|
|
sols_by_product_and_price_dict = defaultdict(lambda: self.env['sale.order.line']) # key: (product_id, price_unit), value: sales order items
|
|
for sol in existing_service_sols: # classify the SOLs to easily find the ones that we want.
|
|
sols_by_product_and_price_dict.setdefault((sol.product_id.id, sol.price_unit), sol)
|
|
|
|
task_values = defaultdict() # values to update the current task
|
|
update_timesheet_commands = [] # used to update the so_line field of each timesheet in the current task.
|
|
|
|
if not sol_in_task and sols_by_product_and_price_dict: # Then check in the existing sol if a SOL has the default product defined in the project to set the SOL of the task (step 6.1)
|
|
sol = sols_by_product_and_price_dict.get((self.project_id.timesheet_product_id.id, self.project_id.timesheet_product_id.lst_price))
|
|
if sol:
|
|
task_values['sale_line_id'] = sol.id
|
|
sol_in_task = True
|
|
|
|
for (timesheet_product_id, price_unit, uom_id), timesheets in product_timesheets_dict.items():
|
|
sol = sols_by_product_and_price_dict.get((timesheet_product_id, price_unit)) # get the existing SOL with the product and the correct price unit
|
|
mapping_uom = self.env['uom.uom'].browse(uom_id)
|
|
total_amount = 0
|
|
timesheet_product = self.env['product.product'].browse(timesheet_product_id)
|
|
for timesheet in timesheets:
|
|
if timesheet.product_uom_id != mapping_uom:
|
|
total_amount += timesheet.product_uom_id._compute_quantity(timesheet.unit_amount, mapping_uom, rounding_method='HALF-UP')
|
|
else:
|
|
total_amount += timesheet.unit_amount
|
|
if not sol: # Then we create it
|
|
order = self.sale_order_id
|
|
sol_vals = {
|
|
**self._get_sale_order_line_vals(),
|
|
'price_unit': 0.0 if self.under_warranty else timesheet_product._get_tax_included_unit_price(
|
|
order.company_id,
|
|
order.currency_id,
|
|
order.date_order,
|
|
'sale',
|
|
fiscal_position=order.fiscal_position_id,
|
|
product_price_unit=price_unit,
|
|
product_currency=timesheet_product.currency_id
|
|
),
|
|
'product_id': timesheet_product_id,
|
|
'product_uom_qty': total_amount,
|
|
}
|
|
sol = self.env['sale.order.line'].sudo().create(sol_vals)
|
|
# Link the SOL to the timesheets
|
|
update_timesheet_commands.extend([fields.Command.update(timesheet.id, {'so_line': sol.id}) for timesheet in timesheets if not timesheet.is_so_line_edited])
|
|
if not sol_in_task and (not product or (product.id == timesheet_product_id and product.lst_price == price_unit)):
|
|
# If there is no sol in task and the product variable is empty then we give the first sol in this loop to the task
|
|
# However, if the product is not empty then we search the sol with the same product and unit price to give to the current task
|
|
task_values['sale_line_id'] = sol.id
|
|
sol_in_task = True
|
|
|
|
if update_timesheet_commands:
|
|
task_values['timesheet_ids'] = update_timesheet_commands
|
|
|
|
self.sudo().write(task_values)
|
|
elif not self.sale_line_id:
|
|
# Check if there is a SOL containing the default product of the project before to create a new one.
|
|
sale_order_line = self.sale_order_id and self.sudo().sale_order_id.order_line.filtered(lambda sol: sol.product_id == self.project_id.timesheet_product_id)[:1]
|
|
if not sale_order_line:
|
|
quantity = sum(timesheet_id.unit_amount for timesheet_id in not_billed_timesheets)
|
|
price_unit = self.timesheet_product_id.lst_price
|
|
if self.under_warranty:
|
|
price_unit = 0.0
|
|
elif price_list := self.sale_order_id.pricelist_id:
|
|
product_rule = self.env['product.pricelist.item'].browse(price_list._get_product_rule(self.timesheet_product_id, quantity))
|
|
if not (product_rule and product_rule._show_discount()):
|
|
price_unit = price_list._get_product_price(self.timesheet_product_id, quantity)
|
|
|
|
sol_vals = {
|
|
**self._get_sale_order_line_vals(),
|
|
'price_unit': price_unit,
|
|
'product_id': self.timesheet_product_id.id,
|
|
'product_uom_qty': quantity,
|
|
}
|
|
sale_order_line = self.env['sale.order.line'].sudo().create(sol_vals)
|
|
self.sudo().write({ # We need to sudo in case the user cannot see all timesheets in the current task.
|
|
'sale_line_id': sale_order_line.id,
|
|
# assign SOL to timesheets
|
|
'timesheet_ids': [fields.Command.update(timesheet.id, {'so_line': sale_order_line.id}) for timesheet in not_billed_timesheets if not timesheet.is_so_line_edited]
|
|
})
|
|
|
|
def write(self, vals):
|
|
res = super().write(vals)
|
|
|
|
if 'under_warranty' in vals:
|
|
line = self.env['sale.order.line']
|
|
for task in self:
|
|
if task.fsm_done or not task.sale_order_id or task.sale_order_id.locked:
|
|
continue
|
|
line |= task.sale_order_id.order_line.filtered(
|
|
lambda l: l.task_id.id == task.id and l.qty_invoiced == 0)
|
|
if vals['under_warranty']:
|
|
line.sudo().write({'price_unit': 0, 'technical_price_unit': 0})
|
|
else:
|
|
line.with_context(force_price_recomputation=True).sudo()._compute_price_unit()
|
|
|
|
return res
|
|
|
|
def copy_data(self, default=None):
|
|
vals_list = super().copy_data(default)
|
|
for task, vals in zip(self, vals_list):
|
|
if self.env.context.get('copy_from_template') and task.under_warranty:
|
|
vals['under_warranty'] = task.under_warranty
|
|
if task.is_fsm and not task.recurring_task and not self.env.context.get('copy_from_template'):
|
|
vals.update({
|
|
'sale_order_id': False,
|
|
'sale_line_id': False,
|
|
})
|
|
return vals_list
|
|
|
|
def _prepare_materials_delivery(self):
|
|
# While industry_fsm_stock is not installed then we automatically deliver materials
|
|
read_group_timesheets = self.env['account.analytic.line'].sudo().search_read([('task_id', 'in', self.ids), ('project_id', '!=', False), ('so_line', '!=', False)], ['so_line'])
|
|
timesheet_sol_ids = [timesheet['so_line'][0] for timesheet in read_group_timesheets]
|
|
sale_order_lines = self.env['sale.order.line'].sudo().search([
|
|
('id', 'not in', timesheet_sol_ids),
|
|
('task_id', 'in', self.ids),
|
|
('order_id', 'in', self.sale_order_id.sudo().filtered(lambda so: so.state == 'sale').ids),
|
|
])
|
|
for sol in sale_order_lines:
|
|
# if a SOL with service product that has invoicing policy based on milestones,
|
|
# the delivered quantity will be computed based on the milestones reached
|
|
if sol.product_id.service_policy != 'delivered_milestones':
|
|
sol.qty_delivered = sol.product_uom_qty
|
|
|
|
def _server_action_project_task_fsm(self, xml_id_multiple_fsm_projects, xml_id_one_fsm_project, default_user_ids=False):
|
|
action = super()._server_action_project_task_fsm(xml_id_multiple_fsm_projects, xml_id_one_fsm_project, default_user_ids)
|
|
action['context']['allow_billable'] = bool(self.env['project.project'].search_count([('is_fsm', '=', True), ('allow_billable', '=', True)], limit=1))
|
|
return action
|