1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/industry_fsm_sale/models/project_task.py
2024-12-10 09:04:09 +07:00

594 lines
34 KiB
Python

# -*- coding: utf-8 -*-
# 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.osv import expression
from odoo.tools.misc import unquote
class Task(models.Model):
_inherit = "project.task"
def _domain_sale_line_id(self):
domain = expression.AND([
self.env['sale.order.line']._sellable_lines_domain(),
[
'|',
'|',
('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')),
('is_service', '=', True), ('is_expense', '=', False), ('state', '=', 'sale'),
],
])
return domain
allow_material = fields.Boolean(related='project_id.allow_material')
allow_quotations = fields.Boolean(related='project_id.allow_quotations')
quotation_count = fields.Integer(compute='_compute_quotation_count')
material_line_product_count = fields.Integer(compute='_compute_material_line_totals')
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')
display_create_invoice_secondary = fields.Boolean(compute='_compute_display_create_invoice_buttons')
invoice_status = fields.Selection(related='sale_order_id.invoice_status')
warning_message = fields.Char('Warning Message', compute='_compute_warning_message')
invoice_count = fields.Integer("Number of invoices", related='sale_order_id.invoice_count')
pricelist_id = fields.Many2one('product.pricelist', compute="_compute_pricelist_id")
# 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')
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_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.user_has_groups('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(Task, self)._compute_display_conditions_count()
for task in self:
enabled = task.display_enabled_conditions_count
satisfied = task.display_satisfied_conditions_count
enabled += 1 if task.allow_material else 0
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 = [('task_id', 'in', self.ids)]
if self.user_has_groups('base.group_portal'):
domain = expression.AND([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)
@api.depends('sale_order_id.order_line.product_uom_qty', 'sale_order_id.order_line.price_total')
def _compute_material_line_totals(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.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 = defaultdict(lambda: self.env['sale.order.line'])
for sol in sols:
sols_by_so[sol.order_id.id] |= sol
for task in self:
material_sale_lines = sols_by_so[task.sudo().sale_order_id.id].sudo().filtered(lambda sol: if_fsm_material_line(sol, task, employee_mapping_timesheet_product_ids.get(task.project_id.id)))
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.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
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.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.user_has_groups('base.group_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(Task, self)._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
def action_create_invoice(self):
# 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,
})
action['context'] = context
return action
def _get_last_sol_of_customer(self):
self.ensure_one()
# For FSM task, we don't want to search the last SOL of the customer.
if self.is_fsm:
return False
return super(Task, self)._get_last_sol_of_customer()
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
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,
}
}
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,
}
}
def action_project_sharing_view_invoices(self):
""" Action used only in project sharing feature """
return {
"name": "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': f'{self.project_id.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": "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 = [
('company_id', 'in', [self.company_id.id, False]),
('sale_ok', '=', True),
'|', ('detailed_type', 'in', ['consu', 'product']),
'&', '&',
('detailed_type', '=', 'service'),
('invoice_policy', '=', 'delivery'),
('service_type', '=', 'manual'),
]
if self.project_id and self.timesheet_product_id:
domain = expression.AND([domain, [('id', '!=', self.timesheet_product_id.id)]])
deposit_product = self.company_id.sale_down_payment_product_id
if deposit_product:
domain = expression.AND([domain, [('id', '!=', deposit_product.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')
return {
'type': 'ir.actions.act_window',
'name': _('Choose Products'),
'res_model': 'product.product',
'views': [(kanban_view.id, 'kanban'), (False, 'form')],
'search_view_id': [search_view.id, 'search'],
'domain': domain,
'context': {
'fsm_mode': True,
'create': self.env['product.template'].check_access_rights('create', raise_exception=False),
'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',
},
'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)
if not task.sale_order_id and not timesheet_count: # Prevent creating/confirming a SO if there are no products and timesheets
continue
task._fsm_ensure_sale_order()
if task.allow_timesheets:
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()
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,
'analytic_account_id': self._get_task_analytic_account_id().id,
'team_id': team.id if team else False,
'origin': f'{self.project_id.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.user_has_groups('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 _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 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[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
for timesheet in timesheets:
if timesheet.product_uom_category_id == mapping_uom.category_id and 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
timesheet_product = self.env['product.product'].browse(timesheet_product_id)
sol = self.env['sale.order.line'].sudo().create({
'order_id': order.id,
'product_id': timesheet_product_id,
'price_unit': 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=order.currency_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,
'product_uom_qty': total_amount,
'product_uom': uom_id,
})
# 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:
sale_order_line = self.env['sale.order.line'].sudo().create({
'order_id': self.sale_order_id.id,
'product_id': self.timesheet_product_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,
'product_uom_qty': sum(timesheet_id.unit_amount for timesheet_id in not_billed_timesheets),
})
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 _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
class ProjectTaskRecurrence(models.Model):
_inherit = 'project.task.recurrence'
def _get_sale_line_id(self, task):
if not task.is_fsm:
return super()._get_sale_line_id(task)
return False