C1 duplicate-call detection - Wizard computes duplicate_count + duplicate_repair_ids when partner is picked (open repairs from the configurable window, default 14 days). - Yellow banner with "Open Existing Repair" button to jump to the most recent duplicate so CS can add a note instead of creating a new repair. C5 outstanding-balance warning - Wizard sums posted unpaid account.move.amount_residual across all invoices of the partner. - Red banner shown when balance >= fusion_repairs.outstanding_balance_threshold (default $100) with a "View Invoices" button. C6 quote-only mode - New quote_only boolean on the wizard; passed through the shared intake service. Skips dispatch-task creation for urgent/safety AND for catalogue auto_schedule. Chatter note "Created in Quote Only mode" posted on the resulting repair.order. D2 skills filter on dispatch picker - _pick_dispatch_technician(repair) prefers users whose x_fc_repair_skills Many2many contains the repair's product category. Three-tier preference: 1) intake user if field staff AND has the skill 2) any active field-staff user with the skill 3) any active field-staff user (no skill filter) - last-resort - Logs a warning + skips task creation if no field-staff user exists at all. T1 Open in Maps on technician task - action_open_in_maps() returns ir.actions.act_url to https://www.google.com/maps?q=<URL-encoded address>. Deep-links into Apple Maps / Google Maps native apps on iOS / Android, browser otherwise. - Header button added on the fusion.technician.task form (after the existing buttons) plus a "View Repair" button when x_fc_repair_order_id is set. Verified end-to-end on local westin-v19: Existing repair: RO-202605-06 C1 duplicate_count = 5 (>=1 expected) - last duplicate: RO-202605-06 C5 balance check ran without error (target partner had $0) C6 quote-only repair: RO-202605-07 tech_tasks = 0 (expected 0) D2 picked the only stairlift-skilled field-staff user T1 Maps URL: https://www.google.com/maps?q=15+Fisherman+Dr%2C+Brampton%2C+ON+L7A+1B7%2C+Canad... Bumped to 19.0.1.1.0. Co-authored-by: Cursor <cursoragent@cursor.com>
103 lines
3.9 KiB
Python
103 lines
3.9 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2024-2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
|
|
from markupsafe import Markup
|
|
|
|
from odoo import _, fields, models
|
|
|
|
|
|
class FusionTechnicianTaskRepairs(models.Model):
|
|
"""Adds the back-link from fusion.technician.task to repair.order so
|
|
repairs and tasks share one timeline. Also hooks task completion to
|
|
roll a linked maintenance contract to its next cycle.
|
|
"""
|
|
|
|
_inherit = 'fusion.technician.task'
|
|
|
|
x_fc_repair_order_id = fields.Many2one(
|
|
'repair.order',
|
|
string='Repair Order',
|
|
ondelete='set null',
|
|
index=True,
|
|
tracking=True,
|
|
help='Repair order this task fulfils. Set automatically when the intake '
|
|
'wizard auto-creates a draft task for urgent / safety calls.',
|
|
)
|
|
|
|
x_fc_repair_intake_session_id = fields.Char(
|
|
related='x_fc_repair_order_id.x_fc_intake_session_id',
|
|
string='Intake Session',
|
|
store=True,
|
|
index=True,
|
|
)
|
|
|
|
def write(self, vals):
|
|
"""When a maintenance task transitions to 'completed', roll the
|
|
linked contract to its next cycle. Failure to roll never blocks
|
|
the underlying task write.
|
|
"""
|
|
res = super().write(vals)
|
|
if vals.get('status') == 'completed':
|
|
for task in self:
|
|
if task.task_type != 'maintenance':
|
|
continue
|
|
repair = task.x_fc_repair_order_id
|
|
contract = repair.x_fc_maintenance_contract_id if repair else False
|
|
if not contract:
|
|
continue
|
|
try:
|
|
contract.last_service_date = fields.Date.context_today(task)
|
|
contract.roll_next_due_date()
|
|
contract.message_post(body=Markup(
|
|
'Rolled forward after maintenance task '
|
|
'<b>%s</b> completed. Next due %s.'
|
|
) % (task.name or '', str(contract.next_due_date or '')))
|
|
except Exception:
|
|
# Never let a contract roll failure block the task write.
|
|
pass
|
|
return res
|
|
|
|
def action_view_repair_order(self):
|
|
self.ensure_one()
|
|
if not self.x_fc_repair_order_id:
|
|
return False
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': self.x_fc_repair_order_id.name,
|
|
'res_model': 'repair.order',
|
|
'view_mode': 'form',
|
|
'res_id': self.x_fc_repair_order_id.id,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# T1: Open in Maps - returns an act_url action that opens the device's
|
|
# default maps app (Apple Maps on iOS, Google Maps on Android, browser
|
|
# otherwise). Address is built from the task's address fields with the
|
|
# partner address as a fallback.
|
|
# ------------------------------------------------------------------
|
|
def action_open_in_maps(self):
|
|
self.ensure_one()
|
|
from urllib.parse import quote_plus
|
|
parts = []
|
|
for f in ('address_street', 'address_city', 'address_zip'):
|
|
v = getattr(self, f, None)
|
|
if v:
|
|
parts.append(str(v))
|
|
if not parts and self.partner_id:
|
|
for f in ('street', 'street2', 'city', 'state_id', 'zip'):
|
|
v = getattr(self.partner_id, f, None)
|
|
if v:
|
|
parts.append(v.name if hasattr(v, 'name') else str(v))
|
|
if not parts:
|
|
from odoo.exceptions import UserError
|
|
raise UserError(_('No address on this task or its client.'))
|
|
query = quote_plus(', '.join(parts))
|
|
# https://www.google.com/maps?q=ADDR works on every platform and
|
|
# automatically deep-links into the native app where supported.
|
|
return {
|
|
'type': 'ir.actions.act_url',
|
|
'url': f'https://www.google.com/maps?q={query}',
|
|
'target': 'new',
|
|
}
|