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:
@@ -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']),
|
||||
|
||||
Reference in New Issue
Block a user