bemade_fsm: manually merge in 15.0 differences and fix 2 failing tests

This commit is contained in:
Marc Durepos 2024-04-01 08:45:29 -04:00
parent 2a0ca65382
commit 149a30f6f3
12 changed files with 246 additions and 66 deletions

View file

@ -8,4 +8,5 @@ from . import equipment_tag
from . import task
from . import res_partner
from . import fsm_visit
from . import res_company
from . import res_company
from . import project

View file

@ -5,9 +5,9 @@ class FSMVisit(models.Model):
_name = "bemade_fsm.visit"
_description = 'Represents a single visit by assigned service personnel.'
label = fields.Text(string="Label", required=True, related='so_section_id.name', readonly=False)
label = fields.Text(string="Label", required=True, related='so_section_id.name', readonly=False, copy=True)
approx_date = fields.Date(string='Approximate Date')
approx_date = fields.Date(string='Approximate Date', copy=False)
so_section_id = fields.Many2one(
comodel_name="sale.order.line",
@ -19,7 +19,7 @@ class FSMVisit(models.Model):
sale_order_id = fields.Many2one(
comodel_name="sale.order",
string="Sales Order",
required=True
readonly=True,
)
is_completed = fields.Boolean(string="Completed", related="so_section_id.is_fully_delivered")
@ -61,10 +61,10 @@ class FSMVisit(models.Model):
@api.model_create_multi
def create(self, vals_list):
recs = super().create(vals_list)
for i, rec in enumerate(recs):
for i, rec in enumerate(recs.filtered(lambda visit: not visit.so_section_id)):
rec.so_section_id = rec.env['sale.order.line'].create({
'order_id': rec.sale_order_id.id,
'display_type': 'line_section',
'name': vals_list[i]['label'],
'name': vals_list[i].get('label', False),
})
return recs

View file

@ -0,0 +1,11 @@
from odoo import models
class Project(models.Model):
_inherit = "project.project"
def _fetch_sale_order_item_ids(self, domain_per_model=None, limit=None, offset=None):
# Override to flush the ORM cache to the database prior to running the query
# Temporary fix until Odoo fixes this method (PR #160067 submitted for this)
self.env.flush_all()
return super()._fetch_sale_order_item_ids(domain_per_model, limit, offset)

View file

@ -50,11 +50,17 @@ class SaleOrder(models.Model):
readonly=False
)
is_fsm = fields.Boolean(
compute='_compute_is_fsm',
string='Is FSM',
store=True,
)
@api.depends('order_line.task_id')
def get_relevant_order_lines(self, task_id):
self.ensure_one()
linked_lines = self.order_line.filtered(lambda l: l.task_id == task_id
or l == task_id.visit_id.so_section_id)
or l == task_id.visit_id.so_section_id)
visit_lines = linked_lines.filtered(lambda l: l.visit_id)
for line in visit_lines:
linked_lines |= line.get_section_line_ids()
@ -102,3 +108,41 @@ class SaleOrder(models.Model):
rec.visit_ids = [Command.set(rec.order_line.visit_ids.ids)]
return rec
def _create_default_visit(self):
""" Called when an order is confirmed with lines that will create an FSM task, in order to make sure there is
a visit line grouping all the service being done."""
self.ensure_one()
visit = self.env['bemade_fsm.visit'].create({
'label': _('Service Visit'),
'sale_order_id': self.id,
})
# Make sure it goes to the top of the list
visit.so_section_id.sequence = 0
def _create_or_organize_visits_if_needed(self):
""" Adds a visit line to the top of the order if there are not already visit lines for an order with lines that
will create an FSM task."""
for order in self.filtered("company_id.create_default_fsm_visit"):
if not order.visit_ids and order.is_fsm:
order._create_default_visit()
if order.is_fsm:
# Make sure that all the lines producing FSM tasks are under a visit
visit_line_ids = order.mapped('visit_ids').mapped('so_section_id').mapped('section_line_ids')
if any([
True for line in
order.order_line.filtered(lambda line: not line.display_type)
if line not in visit_line_ids
]):
# If not, promote the first visit to the top of the order items list
for line in order.order_line:
line.sequence += 1
order.mapped('visit_ids').mapped('so_section_id')[0].sequence = 0
@api.depends('order_line.is_fsm')
def _compute_is_fsm(self):
for rec in self:
rec.is_fsm = any([line.is_fsm for line in rec.order_line])
def action_confirm(self):
self._create_or_organize_visits_if_needed()
return super().action_confirm()

View file

@ -14,7 +14,8 @@ class SaleOrderLine(models.Model):
visit_ids = fields.One2many(
comodel_name="bemade_fsm.visit",
inverse_name="so_section_id",
string="Visits"
string="Visits",
copy=True,
)
visit_id = fields.Many2one(
@ -22,7 +23,7 @@ class SaleOrderLine(models.Model):
compute="_compute_visit_id",
string="Visit",
ondelete='cascade',
store=True
store=True,
)
is_fully_delivered = fields.Boolean(
@ -56,6 +57,17 @@ class SaleOrderLine(models.Model):
compute="_compute_task_duration"
)
is_fsm = fields.Boolean(
string='Is FSM',
compute='_compute_is_fsm',
store=True,
)
section_line_ids = fields.One2many(
comodel_name='sale.order.line',
compute='_compute_section_line_ids',
)
@api.depends('visit_ids')
def _compute_visit_id(self):
for rec in self:
@ -74,6 +86,13 @@ class SaleOrderLine(models.Model):
rec.equipment_ids = rec.order_id.default_equipment_ids
return recs
def copy_data(self, default=None):
if default is None:
default = {}
if 'visit_ids' not in default:
default['visit_ids'] = [(0, 0, visit.copy_data()[0]) for visit in self.visit_ids]
return super().copy_data(default)
def _timesheet_create_task(self, project):
""" Generate task for the given so line, and link it.
:param project: record of project.project in which the task should be created
@ -140,7 +159,7 @@ class SaleOrderLine(models.Model):
def _timesheet_service_generation(self):
super()._timesheet_service_generation()
visit_lines = self.filtered(lambda l: l.visit_id)
for line in visit_lines:
for index, line in enumerate(visit_lines):
task_ids = line.get_section_line_ids().mapped('task_id')
if not task_ids:
continue
@ -148,16 +167,15 @@ class SaleOrderLine(models.Model):
# Can't group up the tasks if they're part of different projects
return
project_id = task_ids[0].project_id
line.visit_id.task_id = line._generate_task_for_visit_line(project_id)
line.visit_id.task_id = line._generate_task_for_visit_line(project_id, index + 1)
task_ids.write({'parent_id': line.visit_id.task_id.id})
self.task_id.filtered("is_fsm").synchronize_name_fsm()
(self.mapped('task_id') | self.visit_ids.task_id).filtered("is_fsm").synchronize_name_fsm()
def _generate_task_for_visit_line(self, project):
def _generate_task_for_visit_line(self, project, visit_no: int):
self.ensure_one()
task = self.env['project.task'].create({
'name': 'Temp Name',
'description': f"Parent task for {self.order_id.name}, visit {self.name}",
'name': f"{self.order_id.name} - " + _("Visit") + f" {visit_no} - {self.name}",
'project_id': project.id,
'equipment_ids': self.get_section_line_ids().mapped('equipment_ids').ids,
'sale_order_id': self.order_id.id,
@ -207,6 +225,14 @@ class SaleOrderLine(models.Model):
lines.append(line)
return self.env['sale.order.line'].union(*lines)
@api.depends('display_type', 'order_id.order_line')
def _compute_section_line_ids(self):
for rec in self:
if rec.display_type == 'line_section':
rec.section_line_ids = [Command.set(rec.get_section_line_ids().ids)]
else:
rec.section_line_ids = False
def _iterate_items_compute_bool(self, single_line_func):
if not self.display_type:
return single_line_func(self)
@ -244,8 +270,7 @@ class SaleOrderLine(models.Model):
uom_hour = self.env.ref('uom.product_uom_hour')
uom_unit = self.env.ref('uom.product_uom_unit')
templated_lines = self.filtered(lambda l: l.product_id.task_template_id
and l.product_id.task_template_id.
planned_hours)
and l.product_id.task_template_id.planned_hours)
visit_lines = self.filtered(lambda l: l.visit_id)
regular_lines = self - templated_lines - visit_lines
for line in regular_lines:
@ -265,3 +290,9 @@ class SaleOrderLine(models.Model):
for line in visit_lines:
line.task_duration = sum(line.get_section_line_ids().
mapped('task_duration'))
@api.depends('product_id.detailed_type', 'product_id.service_tracking')
def _compute_is_fsm(self):
for rec in self:
rec.is_fsm = (rec.product_id.detailed_type == 'service'
and rec.product_id.service_tracking == 'task_global_project')

View file

@ -101,9 +101,9 @@ class Task(models.Model):
prev_seqs = self.sale_order_id.tasks_ids and \
self.sale_order_id.tasks_ids.mapped('work_order_number')
if prev_seqs:
pattern = re.compile(r"\d+$")
pattern = re.compile(r"(\d+)$")
matches = map(lambda n: pattern.search(n), prev_seqs)
seq += max(map(lambda n: int(n.group(1)) if n else 0), matches)
seq += max(map(lambda n: int(n.group(1)) if n else 0, matches))
rec.work_order_number = rec.sale_order_id.name.replace('SO', 'SVR', 1) \
+ f"-{seq}"
return res

View file

@ -3,9 +3,8 @@
<data>
<template id="workorder_page_materials_table">
<t t-set="order_lines"
t-value="doc.relevant_order_lines"/>
<!--.filtered(lambda l:
l.product_id.type != 'service' and not l.is_downpayment and not l.visit_id)"/>-->
t-value="doc.relevant_order_lines.filtered(lambda l:
l.product_id.type != 'service' and not l.is_downpayment and not l.visit_id)"/>
<t t-if="order_lines">
<t t-set="visit_lines" t-value="order_lines.mapped('visit_id')"/>
<t t-set="section_lines"

View file

@ -4,12 +4,6 @@ from odoo import Command
@tagged("-at_install", "post_install")
class BemadeFSMBaseTest(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env.user.groups_id += cls.env.ref("account.group_delivery_invoice_address")
cls.env.company.create_default_fsm_visit = True
@classmethod
def _generate_project_manager_user(cls, name, login):
@ -43,7 +37,6 @@ class BemadeFSMBaseTest(TransactionCase):
user_group_fsm_user = cls.env.ref('industry_fsm.group_fsm_user')
user_group_sales_user = cls.env.ref('sales_team.group_sale_salesman')
user_group_sales_manager = cls.env.ref('sales_team.group_sale_manager')
# TODO: Split this out into a bemade_fsm_customer_product_code module if it's wanted
user_product_customer = cls.env.ref(
'customer_product_code.group_product_customer_code_user',
raise_if_not_found=False
@ -79,11 +72,8 @@ class BemadeFSMBaseTest(TransactionCase):
@classmethod
def _generate_sale_order(cls, partner=None, client_order_ref='Test Order', equipment=None, shipping_location=None):
partner = partner or cls._generate_partner()
vals = {
'partner_id': partner.id,
'client_order_ref': client_order_ref,
'payment_term_id': cls.env.ref('account.account_payment_term_immediate').id,
}
vals = {'partner_id': partner.id,
'client_order_ref': client_order_ref}
if equipment:
vals.update({'default_equipment_ids': [Command.set([equipment.id])]})
if shipping_location:
@ -110,22 +100,6 @@ class BemadeFSMBaseTest(TransactionCase):
'partner_location_id': partner and partner.id or False,
})
@classmethod
def _generate_product_category(cls):
company_id = cls.env.company.id
return cls.env['product.category'].create({
'name': 'Test Category',
'parent_id': cls.env.ref('product.product_category_all', False).id or False,
'property_account_income_categ_id': cls.env['account.account'].search(
[('account_type', '=', 'income'), ('company_id', '=', company_id)],
limit=1
).id,
'property_account_expense_categ_id': cls.env['account.account'].search(
[('account_type', '=', 'expense_direct_cost'), ('company_id', '=', company_id)],
limit=1
).id,
})
@classmethod
def _generate_product(cls, name='Test Product', product_type='service', service_tracking='task_global_project',
project=None, task_template=None, service_policy='delivered_manual', uom=None):
@ -142,7 +116,6 @@ class BemadeFSMBaseTest(TransactionCase):
'service_policy': service_policy,
'uom_id': uom_id,
'uom_po_id': uom_id,
'categ_id': cls._generate_product_category().id,
})
@classmethod
@ -201,7 +174,7 @@ class BemadeFSMBaseTest(TransactionCase):
def _invoice_sale_order(self, so):
wiz = self.env['sale.advance.payment.inv'].with_context(
{'active_ids': [so.id]}).create([{}])
{'active_ids': [so.id]}).create({})
wiz.create_invoices()
inv = so.invoice_ids[-1]
inv.action_post()
@ -222,3 +195,16 @@ class BemadeFSMBaseTest(TransactionCase):
sol1.sequence = 2
sol2.sequence = 3
return so, visit, sol1, sol2
def _generate_so_with_one_visit_two_lines_and_descendants(self):
so = self._generate_sale_order()
visit = self._generate_visit(sale_order=so)
task_template = self._generate_task_template(structure=[2, 2, 2],
names=['Parent', 'Child', 'Grandchild', 'Great-grandchild'])
product = self._generate_product(task_template=task_template)
sol1 = self._generate_sale_order_line(sale_order=so, product=product)
sol2 = self._generate_sale_order_line(sale_order=so, product=product)
visit.so_section_id.sequence = 1
sol1.sequence = 2
sol2.sequence = 3
return so, visit, sol1, sol2

View file

@ -19,3 +19,7 @@ class TestEquipment(BemadeFSMBaseTest):
partner_company.write({'equipment_ids': [Command.set([])]})
with self.assertRaises(MissingError):
equipment.name
def test_compute_complete_name_when_name_blank(self):
equipment = self._generate_equipment(name=False)
complete_name = equipment.complete_name

View file

@ -40,7 +40,7 @@ class FSMVisitTest(BemadeFSMBaseTest):
so.action_confirm()
task = so.order_line.filtered(lambda l: l.task_id).task_id
task.action_fsm_validate(stop_running_timers=True)
task.action_fsm_validate()
self.assertTrue(visit.is_completed)
@ -50,7 +50,7 @@ class FSMVisitTest(BemadeFSMBaseTest):
self._generate_sale_order_line(so)
so.action_confirm()
task = so.order_line.filtered(lambda l: l.task_id).task_id
task.action_fsm_validate(stop_running_timers=True)
task.action_fsm_validate()
self._invoice_sale_order(so)
@ -102,23 +102,34 @@ class FSMVisitTest(BemadeFSMBaseTest):
self.assertEqual(len(so.order_line), 3)
def test_marking_visit_task_done_completes_descendants(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines_and_descendants()
so.action_confirm()
parent, child1, child2 = visit.task_id, sol1.task_id, sol2.task_id
parent = visit.task_id
parent.action_fsm_validate(stop_running_timers=True)
parent.action_fsm_validate()
self.assertTrue(parent.is_closed)
self.assertTrue(child1.is_closed)
self.assertTrue(child2.is_closed)
self.assertEqual(sol1.qty_to_deliver, 0)
self.assertEqual(sol2.qty_to_deliver, 0)
self.assertTrue(visit.is_completed)
self._assert_is_done(parent)
def _assert_is_done(self, task):
""" Recursively assert all tasks in a hierarchy are complete """
self.assertTrue(task.is_closed)
for child in task.child_ids:
self._assert_is_done(child)
def test_marking_visit_task_done_does_not_create_sale_order_line(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit.task_id.action_fsm_validate(stop_running_timers=True)
visit.task_id.action_fsm_validate()
self.assertEqual(len(so.order_line), 3)
def test_confirming_so_names_visit_properly(self):
""" Visits should be named <SO NUMBER> - Visit <visit #> - <visit label>"""
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
task = visit.task_id
supposed_name = f"{so.name} - Visit 1 - {visit.label}"
self.assertEqual(task.name, supposed_name)

View file

@ -183,11 +183,11 @@ class TestSalesOrder(BemadeFSMBaseTest):
subtasks = parent_task._get_all_subtasks()
# Marking the subtasks done should not increment delivered quantity
subtasks.action_fsm_validate()
subtasks.action_fsm_validate(True)
self.assertEqual(sol.qty_delivered, 0)
# Marking the top-level tasks done should set the delivered quantity to some non-zero value based on the UOM
parent_task.action_fsm_validate()
parent_task.action_fsm_validate(True)
self.assertTrue(sol.qty_delivered != 0)
def test_task_contacts_through_sale_order(self):
@ -287,3 +287,49 @@ class TestSalesOrder(BemadeFSMBaseTest):
self.assertEqual(task.child_ids[0].description, template.subtasks[0].description)
for t in task.child_ids[1:]:
self.assertFalse(t.description)
def test_duplicate_sale_order_duplicates_visits(self):
""" Duplicated sales orders should have visits tied to their SO lines as in the original. The copied visits
should not have approximate dates set, however."""
so, visit, line1, line2 = self._generate_so_with_one_visit_two_lines()
so2 = so.copy()
self.assertTrue(so2.visit_ids)
visit2 = so2.visit_ids[0]
self.assertEqual(so2.order_line[0].visit_id, visit2)
self.assertEqual(visit2.label, visit.label)
self.assertFalse(visit2.approx_date)
def test_confirming_sale_order_creates_visit_if_none_created(self):
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = True
product = self._generate_product()
sol = self._generate_sale_order_line(so, product)
so.action_confirm()
visit_line = so.order_line.sorted('sequence')[0]
self.assertTrue(so.visit_ids)
self.assertEqual(visit_line.visit_id, so.visit_ids)
def test_confirming_sale_order_with_visit_creates_no_new_lines(self):
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = True
product = self._generate_product()
visit = self._generate_visit(so)
so.action_confirm()
self.assertEqual(len(so.visit_ids), 1)
def test_confirming_sale_order_creates_no_visit_if_setting_off(self):
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = False
product = self._generate_product()
sol = self._generate_sale_order_line(so, product)
so.action_confirm()
visit_line = so.order_line.sorted('sequence')[0]
self.assertFalse(so.visit_ids)

View file

@ -95,6 +95,28 @@
</xpath>
</field>
</record>
<record id="project_task_view_list_fsm_inherit" model="ir.ui.view">
<field name="name">project.task.view.list.fsm.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="industry_fsm.project_task_view_list_fsm"/>
<field name="arch" type="xml">
<tree position="attributes">
<attribute name="js_class">project_list</attribute>
</tree>
<field name="partner_id" position="after">
<field name="work_order_number" optional="show"/>
</field>
<field name="company_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="worksheet_template_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="project_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
</field>
</record>
<record id="industry_fsm.project_task_action_fsm_map"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
@ -169,5 +191,30 @@
</xpath>
</field>
</record>
<record id="project_task_view_search_fsm_inherit" model="ir.ui.view">
<field name="name">project.task.view.search.fsm.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="industry_fsm.project_task_view_search_fsm"/>
<field name="arch" type="xml">
<filter name="schedule" position="attributes">
<attribute name="domain">
[
'&amp;',
('fsm_done', '=', False),
'|',
('user_ids', '=', False),
'&amp;',
('planned_date_start', '=', False),
('date_deadline', '=', False),
]
</attribute>
</filter>
<filter name="my_tasks" position="after">
<filter name="is_parent_task"
string="Parent Task"
domain="[('parent_id', '=', False)]"/>
</filter>
</field>
</record>
</data>
</odoo>