feat(fusion_repairs): Bundle 1 - wizard polish (C1 + C5 + C6 + D2 + T1)
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>
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Repairs',
|
'name': 'Fusion Repairs',
|
||||||
'version': '19.0.1.0.7',
|
'version': '19.0.1.1.0',
|
||||||
'category': 'Inventory/Repairs',
|
'category': 'Inventory/Repairs',
|
||||||
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -83,6 +83,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'views/repair_dashboard_views.xml',
|
'views/repair_dashboard_views.xml',
|
||||||
'views/repair_order_views.xml',
|
'views/repair_order_views.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
|
'views/technician_task_views.xml',
|
||||||
'views/res_partner_views.xml',
|
'views/res_partner_views.xml',
|
||||||
'views/res_users_views.xml',
|
'views/res_users_views.xml',
|
||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class FusionRepairIntakeService(models.AbstractModel):
|
|||||||
:param payload: dict with keys:
|
:param payload: dict with keys:
|
||||||
- partner_id: int (required) or partner_vals: dict to create new partner
|
- partner_id: int (required) or partner_vals: dict to create new partner
|
||||||
- intake_user_id: int (optional, defaults to env.user)
|
- intake_user_id: int (optional, defaults to env.user)
|
||||||
|
- quote_only: bool (optional, C6 - skips dispatch task creation)
|
||||||
- equipment_items: list of dicts, each with:
|
- equipment_items: list of dicts, each with:
|
||||||
- product_id: int (optional)
|
- product_id: int (optional)
|
||||||
- lot_id: int (optional)
|
- lot_id: int (optional)
|
||||||
@@ -68,6 +69,7 @@ class FusionRepairIntakeService(models.AbstractModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
equipment = payload.get('equipment_items') or [{}]
|
equipment = payload.get('equipment_items') or [{}]
|
||||||
|
quote_only = bool(payload.get('quote_only'))
|
||||||
repairs = self.env['repair.order']
|
repairs = self.env['repair.order']
|
||||||
for item in equipment:
|
for item in equipment:
|
||||||
repair = self._create_single_repair(
|
repair = self._create_single_repair(
|
||||||
@@ -76,6 +78,7 @@ class FusionRepairIntakeService(models.AbstractModel):
|
|||||||
session_ref=session_ref,
|
session_ref=session_ref,
|
||||||
source=source,
|
source=source,
|
||||||
item=item,
|
item=item,
|
||||||
|
quote_only=quote_only,
|
||||||
)
|
)
|
||||||
repairs |= repair
|
repairs |= repair
|
||||||
|
|
||||||
@@ -103,7 +106,8 @@ class FusionRepairIntakeService(models.AbstractModel):
|
|||||||
# CORE CREATION
|
# CORE CREATION
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@api.model
|
@api.model
|
||||||
def _create_single_repair(self, partner_id, intake_user, session_ref, source, item):
|
def _create_single_repair(self, partner_id, intake_user, session_ref,
|
||||||
|
source, item, quote_only=False):
|
||||||
Repair = self.env['repair.order']
|
Repair = self.env['repair.order']
|
||||||
product_id = item.get('product_id')
|
product_id = item.get('product_id')
|
||||||
|
|
||||||
@@ -139,7 +143,7 @@ class FusionRepairIntakeService(models.AbstractModel):
|
|||||||
self._create_answers(repair, item.get('answers') or [])
|
self._create_answers(repair, item.get('answers') or [])
|
||||||
|
|
||||||
# Service catalogue auto-match.
|
# Service catalogue auto-match.
|
||||||
self._match_service_catalog(repair, item)
|
self._match_service_catalog(repair, item, quote_only=quote_only)
|
||||||
|
|
||||||
# Check our own repair-warranty (30/90 day re-do free).
|
# Check our own repair-warranty (30/90 day re-do free).
|
||||||
self._check_repair_warranty(repair)
|
self._check_repair_warranty(repair)
|
||||||
@@ -162,11 +166,17 @@ class FusionRepairIntakeService(models.AbstractModel):
|
|||||||
|
|
||||||
# Optional dispatch draft task (urgent / safety).
|
# Optional dispatch draft task (urgent / safety).
|
||||||
# Skip if the catalogue match already auto-created one.
|
# Skip if the catalogue match already auto-created one.
|
||||||
|
# Skip entirely if intake is quote-only (C6).
|
||||||
if (
|
if (
|
||||||
repair.x_fc_urgency in ('urgent', 'safety')
|
not quote_only
|
||||||
|
and repair.x_fc_urgency in ('urgent', 'safety')
|
||||||
and not repair.x_fc_technician_task_ids
|
and not repair.x_fc_technician_task_ids
|
||||||
):
|
):
|
||||||
self._create_dispatch_task(repair)
|
self._create_dispatch_task(repair)
|
||||||
|
elif quote_only:
|
||||||
|
repair.message_post(body=Markup(_(
|
||||||
|
'Created in <b>Quote Only</b> mode - no technician dispatched.'
|
||||||
|
)))
|
||||||
|
|
||||||
# Emails (client + office).
|
# Emails (client + office).
|
||||||
self._send_intake_emails(repair)
|
self._send_intake_emails(repair)
|
||||||
@@ -202,7 +212,7 @@ class FusionRepairIntakeService(models.AbstractModel):
|
|||||||
# SERVICE CATALOGUE MATCH
|
# SERVICE CATALOGUE MATCH
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@api.model
|
@api.model
|
||||||
def _match_service_catalog(self, repair, item):
|
def _match_service_catalog(self, repair, item, quote_only=False):
|
||||||
category = repair.x_fc_repair_category_id
|
category = repair.x_fc_repair_category_id
|
||||||
if not category:
|
if not category:
|
||||||
return
|
return
|
||||||
@@ -222,7 +232,12 @@ class FusionRepairIntakeService(models.AbstractModel):
|
|||||||
'x_fc_estimated_cost': catalog.estimated_cost,
|
'x_fc_estimated_cost': catalog.estimated_cost,
|
||||||
})
|
})
|
||||||
# Auto-create dispatch task if catalogue says so (in addition to urgency rule).
|
# Auto-create dispatch task if catalogue says so (in addition to urgency rule).
|
||||||
if catalog.auto_schedule and repair.x_fc_technician_task_count == 0:
|
# Quote-only intakes skip this too.
|
||||||
|
if (
|
||||||
|
catalog.auto_schedule
|
||||||
|
and repair.x_fc_technician_task_count == 0
|
||||||
|
and not quote_only
|
||||||
|
):
|
||||||
self._create_dispatch_task(repair)
|
self._create_dispatch_task(repair)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -410,29 +425,61 @@ class FusionRepairIntakeService(models.AbstractModel):
|
|||||||
'description': repair.internal_notes or repair.name,
|
'description': repair.internal_notes or repair.name,
|
||||||
}
|
}
|
||||||
# technician_id is required AND constrained to x_fc_is_field_staff.
|
# technician_id is required AND constrained to x_fc_is_field_staff.
|
||||||
# Use the intake user if they qualify, otherwise the lowest-id active
|
# D2: prefer a tech whose x_fc_repair_skills covers this repair's
|
||||||
# field-staff user as a placeholder for the dispatcher to reassign.
|
# category. Falls back to ANY active field-staff user if no skilled
|
||||||
if repair.user_id and repair.user_id.x_fc_is_field_staff:
|
# tech exists, then to the lowest-id field-staff user as a placeholder.
|
||||||
vals['technician_id'] = repair.user_id.id
|
tech_id = self._pick_dispatch_technician(repair)
|
||||||
else:
|
if not tech_id:
|
||||||
fallback = self.env['res.users'].sudo().search([
|
_logger.warning(
|
||||||
('x_fc_is_field_staff', '=', True),
|
'No field-staff user available - skipping auto-dispatch '
|
||||||
('active', '=', True),
|
'task for repair %s (mark a user as Field Staff under '
|
||||||
], order='id', limit=1)
|
'Settings > Users).',
|
||||||
if not fallback:
|
repair.name,
|
||||||
_logger.warning(
|
)
|
||||||
'No field-staff user available - skipping auto-dispatch '
|
return
|
||||||
'task for repair %s (mark a user as Field Staff under '
|
vals['technician_id'] = tech_id
|
||||||
'Settings > Users).',
|
|
||||||
repair.name,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
vals['technician_id'] = fallback.id
|
|
||||||
Task.create(vals)
|
Task.create(vals)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.warning('Failed to auto-create dispatch task for repair %s: %s',
|
_logger.warning('Failed to auto-create dispatch task for repair %s: %s',
|
||||||
repair.name, e)
|
repair.name, e)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _pick_dispatch_technician(self, repair):
|
||||||
|
"""D2: pick the best technician for the initial dispatch task.
|
||||||
|
|
||||||
|
Preference order:
|
||||||
|
1. The intake user IF they are field staff AND have the skill
|
||||||
|
2. Any active field-staff user with x_fc_repair_skills covering
|
||||||
|
the repair's product category
|
||||||
|
3. Any active field-staff user (no skills filter)
|
||||||
|
|
||||||
|
Returns the chosen user id, or False if none found.
|
||||||
|
"""
|
||||||
|
Users = self.env['res.users'].sudo()
|
||||||
|
category = repair.x_fc_repair_category_id
|
||||||
|
|
||||||
|
# Try intake user first if they qualify.
|
||||||
|
if repair.user_id and repair.user_id.x_fc_is_field_staff:
|
||||||
|
if not category or category in repair.user_id.x_fc_repair_skills:
|
||||||
|
return repair.user_id.id
|
||||||
|
|
||||||
|
# Skills-filtered candidates.
|
||||||
|
if category:
|
||||||
|
skilled = Users.search([
|
||||||
|
('x_fc_is_field_staff', '=', True),
|
||||||
|
('active', '=', True),
|
||||||
|
('x_fc_repair_skills', 'in', category.id),
|
||||||
|
], order='id', limit=1)
|
||||||
|
if skilled:
|
||||||
|
return skilled.id
|
||||||
|
|
||||||
|
# Any active field staff.
|
||||||
|
fallback = Users.search([
|
||||||
|
('x_fc_is_field_staff', '=', True),
|
||||||
|
('active', '=', True),
|
||||||
|
], order='id', limit=1)
|
||||||
|
return fallback.id if fallback else False
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# EMAILS
|
# EMAILS
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import _, fields, models
|
||||||
|
|
||||||
|
|
||||||
class FusionTechnicianTaskRepairs(models.Model):
|
class FusionTechnicianTaskRepairs(models.Model):
|
||||||
@@ -69,3 +69,34 @@ class FusionTechnicianTaskRepairs(models.Model):
|
|||||||
'view_mode': 'form',
|
'view_mode': 'form',
|
||||||
'res_id': self.x_fc_repair_order_id.id,
|
'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',
|
||||||
|
}
|
||||||
|
|||||||
32
fusion_repairs/views/technician_task_views.xml
Normal file
32
fusion_repairs/views/technician_task_views.xml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Add Maps + View Repair buttons to the technician task form
|
||||||
|
(header so they're prominent on mobile). -->
|
||||||
|
<record id="view_technician_task_form_inherit_fusion_repairs"
|
||||||
|
model="ir.ui.view">
|
||||||
|
<field name="name">fusion.technician.task.form.inherit.fusion_repairs</field>
|
||||||
|
<field name="model">fusion.technician.task</field>
|
||||||
|
<field name="inherit_id" ref="fusion_tasks.view_technician_task_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//header" position="inside">
|
||||||
|
<button name="action_open_in_maps"
|
||||||
|
type="object"
|
||||||
|
string="Open in Maps"
|
||||||
|
class="btn-secondary"
|
||||||
|
icon="fa-map-marker"/>
|
||||||
|
<button name="action_view_repair_order"
|
||||||
|
type="object"
|
||||||
|
string="View Repair"
|
||||||
|
class="btn-secondary"
|
||||||
|
icon="fa-wrench"
|
||||||
|
invisible="not x_fc_repair_order_id"/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//field[@name='partner_id']" position="after">
|
||||||
|
<field name="x_fc_repair_order_id" readonly="1"
|
||||||
|
invisible="not x_fc_repair_order_id"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -10,9 +10,16 @@ repair.order(s). The shared service guarantees identical behaviour to the
|
|||||||
sales rep portal and the public client portal added in later phases.
|
sales rep portal and the public client portal added in later phases.
|
||||||
|
|
||||||
Multi-equipment per call is supported via the equipment_ids One2many.
|
Multi-equipment per call is supported via the equipment_ids One2many.
|
||||||
|
|
||||||
|
Includes Phase 1 polish:
|
||||||
|
- C1: duplicate-call detection (yellow banner if the partner has an open
|
||||||
|
repair from the last N days)
|
||||||
|
- C5: outstanding-balance warning (red banner if open invoice total > config)
|
||||||
|
- C6: quote-only mode (creates the repair but does NOT dispatch a tech)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from odoo import _, api, fields, models
|
from odoo import _, api, fields, models
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
@@ -45,6 +52,43 @@ class RepairIntakeWizard(models.TransientModel):
|
|||||||
readonly=True,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CONTEXTUAL BANNERS (C1 + C5)
|
||||||
|
# Computed reactively when the partner is selected. Shown in the form
|
||||||
|
# so CS knows immediately about duplicate calls or unpaid invoices.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
duplicate_repair_ids = fields.Many2many(
|
||||||
|
'repair.order',
|
||||||
|
compute='_compute_partner_context',
|
||||||
|
string='Open Repairs (last N days)',
|
||||||
|
)
|
||||||
|
duplicate_count = fields.Integer(
|
||||||
|
compute='_compute_partner_context',
|
||||||
|
string='Duplicate Call Count',
|
||||||
|
)
|
||||||
|
outstanding_balance = fields.Float(
|
||||||
|
compute='_compute_partner_context',
|
||||||
|
string='Open Invoice Balance',
|
||||||
|
)
|
||||||
|
outstanding_invoice_count = fields.Integer(
|
||||||
|
compute='_compute_partner_context',
|
||||||
|
string='Open Invoices',
|
||||||
|
)
|
||||||
|
show_outstanding_warning = fields.Boolean(
|
||||||
|
compute='_compute_partner_context',
|
||||||
|
string='Show Outstanding Balance Warning',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# OPTIONS (C6 quote-only mode)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
quote_only = fields.Boolean(
|
||||||
|
string='Quote Only - Do Not Dispatch',
|
||||||
|
help='Create the service request and quote the client, but do NOT '
|
||||||
|
'auto-create a technician dispatch task. Use this when the client '
|
||||||
|
'is gathering quotes or has not yet authorised the repair.',
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# EQUIPMENT (one-or-many)
|
# EQUIPMENT (one-or-many)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -55,6 +99,56 @@ class RepairIntakeWizard(models.TransientModel):
|
|||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# COMPUTES
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.depends('partner_id')
|
||||||
|
def _compute_partner_context(self):
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
try:
|
||||||
|
window_days = int(ICP.get_param(
|
||||||
|
'fusion_repairs.duplicate_call_window_days', '14'
|
||||||
|
))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
window_days = 14
|
||||||
|
try:
|
||||||
|
threshold = float(ICP.get_param(
|
||||||
|
'fusion_repairs.outstanding_balance_threshold', '100'
|
||||||
|
))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
threshold = 100.0
|
||||||
|
|
||||||
|
Repair = self.env['repair.order'].sudo()
|
||||||
|
Move = self.env['account.move'].sudo()
|
||||||
|
cutoff = fields.Date.context_today(self) - timedelta(days=window_days)
|
||||||
|
|
||||||
|
for w in self:
|
||||||
|
if not w.partner_id:
|
||||||
|
w.duplicate_repair_ids = False
|
||||||
|
w.duplicate_count = 0
|
||||||
|
w.outstanding_balance = 0.0
|
||||||
|
w.outstanding_invoice_count = 0
|
||||||
|
w.show_outstanding_warning = False
|
||||||
|
continue
|
||||||
|
dupes = Repair.search([
|
||||||
|
('partner_id', '=', w.partner_id.id),
|
||||||
|
('state', 'not in', ('done', 'cancel')),
|
||||||
|
('create_date', '>=', cutoff),
|
||||||
|
], order='create_date desc', limit=5)
|
||||||
|
w.duplicate_repair_ids = dupes
|
||||||
|
w.duplicate_count = len(dupes)
|
||||||
|
|
||||||
|
open_invoices = Move.search([
|
||||||
|
('partner_id', 'child_of', w.partner_id.id),
|
||||||
|
('move_type', '=', 'out_invoice'),
|
||||||
|
('state', '=', 'posted'),
|
||||||
|
('payment_state', 'in', ('not_paid', 'partial')),
|
||||||
|
])
|
||||||
|
balance = sum(open_invoices.mapped('amount_residual'))
|
||||||
|
w.outstanding_balance = balance
|
||||||
|
w.outstanding_invoice_count = len(open_invoices)
|
||||||
|
w.show_outstanding_warning = balance >= threshold
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# SUBMIT
|
# SUBMIT
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -66,6 +160,7 @@ class RepairIntakeWizard(models.TransientModel):
|
|||||||
payload = {
|
payload = {
|
||||||
'partner_id': self.partner_id.id,
|
'partner_id': self.partner_id.id,
|
||||||
'intake_user_id': self.intake_user_id.id,
|
'intake_user_id': self.intake_user_id.id,
|
||||||
|
'quote_only': self.quote_only,
|
||||||
'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids],
|
'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +186,39 @@ class RepairIntakeWizard(models.TransientModel):
|
|||||||
'domain': [('id', 'in', repairs.ids)],
|
'domain': [('id', 'in', repairs.ids)],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def action_open_existing_repair(self):
|
||||||
|
"""C1: jump to the most recent duplicate repair so CS can add a note
|
||||||
|
instead of creating a new repair."""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.duplicate_repair_ids:
|
||||||
|
return False
|
||||||
|
repair = self.duplicate_repair_ids[0]
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': repair.name,
|
||||||
|
'res_model': 'repair.order',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'res_id': repair.id,
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_outstanding_invoices(self):
|
||||||
|
"""C5: open the list of unpaid invoices for context."""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Open Invoices - %s', self.partner_id.name or ''),
|
||||||
|
'res_model': 'account.move',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [
|
||||||
|
('partner_id', 'child_of', self.partner_id.id),
|
||||||
|
('move_type', '=', 'out_invoice'),
|
||||||
|
('state', '=', 'posted'),
|
||||||
|
('payment_state', 'in', ('not_paid', 'partial')),
|
||||||
|
],
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
def _equipment_payload(self, eq):
|
def _equipment_payload(self, eq):
|
||||||
"""Render an equipment record as a dict the intake service expects."""
|
"""Render an equipment record as a dict the intake service expects."""
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -16,6 +16,40 @@
|
|||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
<!-- C1: duplicate-call detection banner -->
|
||||||
|
<div class="alert alert-warning d-flex justify-content-between align-items-center"
|
||||||
|
role="alert"
|
||||||
|
invisible="duplicate_count == 0">
|
||||||
|
<div>
|
||||||
|
<i class="fa fa-exclamation-triangle me-1"/>
|
||||||
|
<strong>Open repair already exists for this client</strong>
|
||||||
|
(<field name="duplicate_count" nolabel="1" readonly="1" class="d-inline"/> in last 14 days).
|
||||||
|
Consider adding a note to the existing repair instead.
|
||||||
|
</div>
|
||||||
|
<button name="action_open_existing_repair"
|
||||||
|
type="object"
|
||||||
|
string="Open Existing Repair"
|
||||||
|
class="btn btn-sm btn-warning"/>
|
||||||
|
</div>
|
||||||
|
<field name="duplicate_repair_ids" invisible="1"/>
|
||||||
|
|
||||||
|
<!-- C5: outstanding-balance warning banner -->
|
||||||
|
<div class="alert alert-danger d-flex justify-content-between align-items-center"
|
||||||
|
role="alert"
|
||||||
|
invisible="not show_outstanding_warning">
|
||||||
|
<div>
|
||||||
|
<i class="fa fa-money me-1"/>
|
||||||
|
<strong>Outstanding balance:</strong>
|
||||||
|
<field name="outstanding_balance" widget="monetary" nolabel="1" readonly="1" class="d-inline"/>
|
||||||
|
across <field name="outstanding_invoice_count" nolabel="1" readonly="1" class="d-inline"/> invoice(s).
|
||||||
|
Worth mentioning during this call.
|
||||||
|
</div>
|
||||||
|
<button name="action_view_outstanding_invoices"
|
||||||
|
type="object"
|
||||||
|
string="View Invoices"
|
||||||
|
class="btn btn-sm btn-danger"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<separator string="Equipment Items (one repair per item)"/>
|
<separator string="Equipment Items (one repair per item)"/>
|
||||||
<field name="equipment_ids">
|
<field name="equipment_ids">
|
||||||
<list editable="bottom">
|
<list editable="bottom">
|
||||||
@@ -54,6 +88,11 @@
|
|||||||
<field name="photo_ids" widget="many2many_binary"/>
|
<field name="photo_ids" widget="many2many_binary"/>
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
|
<!-- C6: quote-only mode -->
|
||||||
|
<separator string="Options"/>
|
||||||
|
<group>
|
||||||
|
<field name="quote_only"/>
|
||||||
|
</group>
|
||||||
</sheet>
|
</sheet>
|
||||||
<footer>
|
<footer>
|
||||||
<button string="Submit"
|
<button string="Submit"
|
||||||
|
|||||||
Reference in New Issue
Block a user