feat: hide authorizer for rental orders, auto-set sale type

Rental orders no longer show the "Authorizer Required?" question or
the Authorizer field. The sale type is automatically set to 'Rentals'
when creating or confirming a rental order. Validation logic also
skips authorizer checks for rental sale type.

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-02-25 23:33:23 -05:00
parent 3c8f83b8e6
commit 14fe9ab716
51 changed files with 4192 additions and 822 deletions

View File

@@ -30,7 +30,7 @@ class FusionTechnicianTask(models.Model):
_rec_name = 'name'
def _compute_display_name(self):
"""Richer display name: Client - Type | 9:00 AM - 10:00 AM."""
"""Richer display name: Client - Type | 9:00 AM - 10:00 AM [+2 techs]."""
type_labels = dict(self._fields['task_type'].selection)
for task in self:
client = task.x_fc_sync_client_name if task.x_fc_sync_source else (task.partner_id.name or '')
@@ -41,6 +41,9 @@ class FusionTechnicianTask(models.Model):
label = ' - '.join(p for p in parts if p)
if start and end:
label += f' | {start} - {end}'
extra = len(task.additional_technician_ids)
if extra:
label += f' [+{extra} tech{"s" if extra > 1 else ""}]'
task.display_name = label or task.name
# ------------------------------------------------------------------
@@ -111,13 +114,45 @@ class FusionTechnicianTask(models.Model):
required=True,
tracking=True,
domain="[('x_fc_is_field_staff', '=', True)]",
help='Shows: users marked as Field Staff (technicians and sales reps)',
help='Lead technician responsible for this task',
)
technician_name = fields.Char(
related='technician_id.name',
string='Technician Name',
store=True,
)
additional_technician_ids = fields.Many2many(
'res.users',
'technician_task_additional_tech_rel',
'task_id',
'user_id',
string='Additional Technicians',
domain="[('x_fc_is_field_staff', '=', True)]",
tracking=True,
help='Additional technicians assigned to assist on this task',
)
all_technician_ids = fields.Many2many(
'res.users',
compute='_compute_all_technician_ids',
string='All Technicians',
help='Lead + additional technicians combined',
)
additional_tech_count = fields.Integer(
compute='_compute_all_technician_ids',
string='Extra Techs',
)
all_technician_names = fields.Char(
compute='_compute_all_technician_ids',
string='All Technician Names',
)
@api.depends('technician_id', 'additional_technician_ids')
def _compute_all_technician_ids(self):
for task in self:
all_techs = task.technician_id | task.additional_technician_ids
task.all_technician_ids = all_techs
task.additional_tech_count = len(task.additional_technician_ids)
task.all_technician_names = ', '.join(all_techs.mapped('name'))
sale_order_id = fields.Many2one(
'sale.order',
@@ -444,7 +479,9 @@ class FusionTechnicianTask(models.Model):
return (preferred_start, preferred_start + duration)
domain = [
'|',
('technician_id', '=', tech_id),
('additional_technician_ids', 'in', [tech_id]),
('scheduled_date', '=', date),
('status', 'not in', ['cancelled']),
]
@@ -535,6 +572,7 @@ class FusionTechnicianTask(models.Model):
"""Return a list of available (start, end) gaps for a technician on a date.
Used by schedule_info_html to show green "available" badges.
Considers tasks where the tech is either lead or additional.
"""
STORE_OPEN, STORE_CLOSE = self._get_store_hours()
@@ -542,7 +580,9 @@ class FusionTechnicianTask(models.Model):
return [(STORE_OPEN, STORE_CLOSE)]
domain = [
'|',
('technician_id', '=', tech_id),
('additional_technician_ids', 'in', [tech_id]),
('scheduled_date', '=', date),
('status', 'not in', ['cancelled']),
]
@@ -673,9 +713,11 @@ class FusionTechnicianTask(models.Model):
continue
exclude_id = task.id if task.id else 0
# Find other tasks for the same technician+date
# Find other tasks for the same technician+date (lead or additional)
others = self.sudo().search([
'|',
('technician_id', '=', task.technician_id.id),
('additional_technician_ids', 'in', [task.technician_id.id]),
('scheduled_date', '=', task.scheduled_date),
('status', 'not in', ['cancelled']),
('id', '!=', exclude_id),
@@ -749,9 +791,11 @@ class FusionTechnicianTask(models.Model):
continue
exclude_id = task.id if task.id else 0
# Find the task that ends just before this one starts
# Find the task that ends just before this one starts (lead or additional)
prev_tasks = self.sudo().search([
'|',
('technician_id', '=', task.technician_id.id),
('additional_technician_ids', 'in', [task.technician_id.id]),
('scheduled_date', '=', task.scheduled_date),
('status', 'not in', ['cancelled']),
('id', '!=', exclude_id),
@@ -1003,9 +1047,13 @@ class FusionTechnicianTask(models.Model):
))
@api.constrains('technician_id', 'scheduled_date', 'time_start', 'time_end')
@api.constrains('technician_id', 'additional_technician_ids',
'scheduled_date', 'time_start', 'time_end')
def _check_no_overlap(self):
"""Prevent overlapping bookings for the same technician on the same date."""
"""Prevent overlapping bookings for the same technician on the same date.
Checks both the lead technician and all additional technicians.
"""
for task in self:
if task.status == 'cancelled':
continue
@@ -1032,29 +1080,38 @@ class FusionTechnicianTask(models.Model):
current_hour = now.hour + now.minute / 60.0
if task.time_start < current_hour:
pass # Allow editing existing tasks that started earlier today
# Check overlap with other tasks
overlapping = self.sudo().search([
('technician_id', '=', task.technician_id.id),
('scheduled_date', '=', task.scheduled_date),
('status', 'not in', ['cancelled']),
('id', '!=', task.id),
('time_start', '<', task.time_end),
('time_end', '>', task.time_start),
], limit=1)
if overlapping:
start_str = self._float_to_time_str(overlapping.time_start)
end_str = self._float_to_time_str(overlapping.time_end)
raise ValidationError(_(
"Time slot overlaps with %(task)s (%(start)s - %(end)s). "
"Please choose a different time.",
task=overlapping.name,
start=start_str,
end=end_str,
))
# Check overlap for lead + additional technicians
all_tech_ids = (task.technician_id | task.additional_technician_ids).ids
for tech_id in all_tech_ids:
tech_name = self.env['res.users'].browse(tech_id).name
overlapping = self.sudo().search([
'|',
('technician_id', '=', tech_id),
('additional_technician_ids', 'in', [tech_id]),
('scheduled_date', '=', task.scheduled_date),
('status', 'not in', ['cancelled']),
('id', '!=', task.id),
('time_start', '<', task.time_end),
('time_end', '>', task.time_start),
], limit=1)
if overlapping:
start_str = self._float_to_time_str(overlapping.time_start)
end_str = self._float_to_time_str(overlapping.time_end)
raise ValidationError(_(
"%(tech)s has a time conflict with %(task)s "
"(%(start)s - %(end)s). Please choose a different time.",
tech=tech_name,
task=overlapping.name,
start=start_str,
end=end_str,
))
# Check travel time gap to the NEXT task on the same day
# Check travel time gaps for lead technician only
# (additional techs travel with the lead, same destination)
next_task = self.sudo().search([
'|',
('technician_id', '=', task.technician_id.id),
('additional_technician_ids', 'in', [task.technician_id.id]),
('scheduled_date', '=', task.scheduled_date),
('status', 'not in', ['cancelled']),
('id', '!=', task.id),
@@ -1082,9 +1139,10 @@ class FusionTechnicianTask(models.Model):
travel=travel_min,
))
# Check travel time gap FROM the PREVIOUS task on the same day
prev_task = self.sudo().search([
'|',
('technician_id', '=', task.technician_id.id),
('additional_technician_ids', 'in', [task.technician_id.id]),
('scheduled_date', '=', task.scheduled_date),
('status', 'not in', ['cancelled']),
('id', '!=', task.id),
@@ -1151,8 +1209,11 @@ class FusionTechnicianTask(models.Model):
exclude_id = self._origin.id if self._origin else 0
duration = max(self.duration_hours or 1.0, 0.25)
all_tech_ids = (self.technician_id | self.additional_technician_ids).ids
overlapping = self.sudo().search([
('technician_id', '=', self.technician_id.id),
'|',
('technician_id', 'in', all_tech_ids),
('additional_technician_ids', 'in', all_tech_ids),
('scheduled_date', '=', self.scheduled_date),
('status', 'not in', ['cancelled']),
('id', '!=', exclude_id),
@@ -1347,14 +1408,22 @@ class FusionTechnicianTask(models.Model):
# Capture old tech+date combos BEFORE write for travel recalc
travel_fields = {'address_street', 'address_city', 'address_zip', 'address_lat', 'address_lng',
'scheduled_date', 'sequence', 'time_start', 'technician_id'}
'scheduled_date', 'sequence', 'time_start', 'technician_id',
'additional_technician_ids'}
needs_travel_recalc = travel_fields & set(vals.keys())
old_combos = set()
if needs_travel_recalc:
old_combos = {(t.technician_id.id, t.scheduled_date) for t in self}
for t in self:
old_combos.add((t.technician_id.id, t.scheduled_date))
for tech in t.additional_technician_ids:
old_combos.add((tech.id, t.scheduled_date))
res = super().write(vals)
if needs_travel_recalc:
new_combos = {(t.technician_id.id, t.scheduled_date) for t in self}
new_combos = set()
for t in self:
new_combos.add((t.technician_id.id, t.scheduled_date))
for tech in t.additional_technician_ids:
new_combos.add((tech.id, t.scheduled_date))
all_combos = old_combos | new_combos
self._recalculate_combos_travel(all_combos)
@@ -1374,7 +1443,8 @@ class FusionTechnicianTask(models.Model):
old_end=old['time_end'],
)
# Push updates to remote instances for local tasks
sync_fields = {'technician_id', 'scheduled_date', 'time_start', 'time_end',
sync_fields = {'technician_id', 'additional_technician_ids',
'scheduled_date', 'time_start', 'time_end',
'duration_hours', 'status', 'task_type', 'address_street',
'address_city', 'address_zip', 'address_lat', 'address_lng',
'partner_id'}
@@ -1412,7 +1482,7 @@ class FusionTechnicianTask(models.Model):
f'<div style="background:#e8f4fd;border-left:4px solid #17a2b8;padding:10px;border-radius:4px;">'
f'<strong><i class="fa fa-wrench"></i> Technician Task Scheduled</strong><br/>'
f'<strong>{self.name}</strong> ({task_type_label}) - {date_str} at {time_str}<br/>'
f'Technician: {self.technician_id.name}<br/>'
f'Technician(s): {self.all_technician_names or self.technician_id.name}<br/>'
f'<a href="{task_url}">View Task</a>'
f'</div>'
)
@@ -1441,10 +1511,11 @@ class FusionTechnicianTask(models.Model):
previous_status = order.x_fc_adp_application_status
# Update the sale order status and delivery fields
all_tech_ids = (task.technician_id | task.additional_technician_ids).ids
order.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'ready_delivery',
'x_fc_status_before_delivery': previous_status,
'x_fc_delivery_technician_ids': [(4, task.technician_id.id)],
'x_fc_delivery_technician_ids': [(4, tid) for tid in all_tech_ids],
'x_fc_ready_for_delivery_date': fields.Datetime.now(),
'x_fc_scheduled_delivery_datetime': task.datetime_start,
})
@@ -1469,7 +1540,7 @@ class FusionTechnicianTask(models.Model):
f'<h5 class="alert-heading"><i class="fa fa-truck"></i> Ready for Delivery{early_badge}</h5>'
f'<ul>'
f'<li><strong>Marked By:</strong> {user_name}</li>'
f'<li><strong>Technician:</strong> {tech_name}</li>'
f'<li><strong>Technician(s):</strong> {task.all_technician_names or tech_name}</li>'
f'{scheduled_str}'
f'<li><strong>Delivery Address:</strong> {task.address_display or "N/A"}</li>'
f'</ul>'
@@ -1485,7 +1556,7 @@ class FusionTechnicianTask(models.Model):
# Send email notifications
try:
order._send_ready_for_delivery_email(
technicians=task.technician_id,
technicians=task.technician_id | task.additional_technician_ids,
scheduled_datetime=task.datetime_start,
notes=task.description,
)
@@ -1493,8 +1564,18 @@ class FusionTechnicianTask(models.Model):
_logger.warning("Ready for delivery email failed for %s: %s", order.name, e)
def _recalculate_day_travel_chains(self):
"""Recalculate travel for all tech+date combos affected by these tasks."""
combos = {(t.technician_id.id, t.scheduled_date) for t in self if t.technician_id and t.scheduled_date}
"""Recalculate travel for all tech+date combos affected by these tasks.
Includes combos for additional technicians so their schedules update too.
"""
combos = set()
for t in self:
if not t.scheduled_date:
continue
if t.technician_id:
combos.add((t.technician_id.id, t.scheduled_date))
for tech in t.additional_technician_ids:
combos.add((tech.id, t.scheduled_date))
self._recalculate_combos_travel(combos)
def _get_technician_start_address(self, tech_id):
@@ -1543,7 +1624,9 @@ class FusionTechnicianTask(models.Model):
if not tech_id or not date:
continue
all_day_tasks = self.sudo().search([
'|',
('technician_id', '=', tech_id),
('additional_technician_ids', 'in', [tech_id]),
('scheduled_date', '=', date),
('status', 'not in', ['cancelled']),
], order='time_start, sequence, id')
@@ -1574,10 +1657,15 @@ class FusionTechnicianTask(models.Model):
# ------------------------------------------------------------------
def _check_previous_tasks_completed(self):
"""Check that all earlier tasks for the same technician+date are completed."""
"""Check that all earlier tasks for the same technician+date are completed.
Considers tasks where the technician is either lead or additional.
"""
self.ensure_one()
earlier_incomplete = self.sudo().search([
'|',
('technician_id', '=', self.technician_id.id),
('additional_technician_ids', 'in', [self.technician_id.id]),
('scheduled_date', '=', self.scheduled_date),
('time_start', '<', self.time_start),
('status', 'not in', ['completed', 'cancelled']),
@@ -1709,6 +1797,13 @@ class FusionTechnicianTask(models.Model):
),
user_id=order.user_id.id or self.env.uid,
)
try:
order._send_damage_notification_email()
except Exception as e:
_logger.error(
"Failed to send damage notification for %s: %s",
order.name, e,
)
def action_cancel_task(self):
"""Cancel the task. Sends cancellation email and reverts sale order if delivery."""
@@ -1842,7 +1937,7 @@ class FusionTechnicianTask(models.Model):
f'<h5><i class="fa fa-wrench"></i> Technician Task Completed</h5>'
f'<ul>'
f'<li><strong>Task:</strong> {self.name} ({task_type_label})</li>'
f'<li><strong>Technician:</strong> {self.technician_id.name}</li>'
f'<li><strong>Technician(s):</strong> {self.all_technician_names or self.technician_id.name}</li>'
f'<li><strong>Completed:</strong> {self.completion_datetime.strftime("%B %d, %Y at %I:%M %p") if self.completion_datetime else "N/A"}</li>'
f'</ul>'
f'<hr/>'
@@ -1859,7 +1954,7 @@ class FusionTechnicianTask(models.Model):
"""Send an Odoo notification to whoever created/scheduled the task."""
self.ensure_one()
# Notify the task creator (scheduler) if they're not the technician
if self.create_uid and self.create_uid != self.technician_id:
if self.create_uid and self.create_uid not in self.all_technician_ids:
task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form'
client_name = self.partner_id.name or 'N/A'
@@ -1889,8 +1984,8 @@ class FusionTechnicianTask(models.Model):
f'<td style="padding:3px 0;">{case_ref or "N/A"}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Task:</td>'
f'<td style="padding:3px 0;">{self.name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Technician:</td>'
f'<td style="padding:3px 0;">{self.technician_id.name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Technician(s):</td>'
f'<td style="padding:3px 0;">{self.all_technician_names or self.technician_id.name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Location:</td>'
f'<td style="padding:3px 0;">{address_str}</td></tr>'
f'</table>'
@@ -1927,7 +2022,7 @@ class FusionTechnicianTask(models.Model):
end_str = self._float_to_time_str(self.time_end)
rows.append(('Scheduled', f'{date_str}, {start_str} - {end_str}'))
if self.technician_id:
rows.append(('Technician', self.technician_id.name))
rows.append(('Technician', self.all_technician_names or self.technician_id.name))
if self.address_display:
rows.append(('Address', self.address_display))
return rows
@@ -1943,9 +2038,10 @@ class FusionTechnicianTask(models.Model):
if self.partner_id and self.partner_id.email:
to_emails.append(self.partner_id.email)
# Technician email
if self.technician_id and self.technician_id.email:
cc_emails.append(self.technician_id.email)
# Technician emails (lead + additional)
for tech in (self.technician_id | self.additional_technician_ids):
if tech.email:
cc_emails.append(tech.email)
# Sales rep from the sale order
if self.sale_order_id and self.sale_order_id.user_id and \
@@ -2161,10 +2257,15 @@ class FusionTechnicianTask(models.Model):
return False
def get_next_task_for_technician(self):
"""Get the next task in sequence for the same technician+date after this one."""
"""Get the next task in sequence for the same technician+date after this one.
Considers tasks where the technician is either lead or additional.
"""
self.ensure_one()
return self.sudo().search([
'|',
('technician_id', '=', self.technician_id.id),
('additional_technician_ids', 'in', [self.technician_id.id]),
('scheduled_date', '=', self.scheduled_date),
('time_start', '>=', self.time_start),
('status', 'in', ['scheduled', 'en_route']),