+ Office added this between your existing stops. Please re-sequence
+ your day and head over as soon as you can finish your current job.
+
+
+
Repair
+
Client
+
+
Phone
+
+
+
Equipment
+
+
+
Address
,
+
+
+
Rush Surcharge
$
+
+
+
+ Open the task in your tech portal to see the full route and tap Start Timer when you arrive.
+
+
+
+
+
+
+
+
+
+
+
+ Repair: Awaiting Parts (Client)
+
+ {{ object.company_id.name }} - update on your repair {{ object.name }}
+ {{ (object.company_id.email_formatted or user.email_formatted) }}
+ {{ object.partner_id.id }}
+
+
+
+
+
+
+
+
We found the problem - here's the plan
+
+ Hello , our technician
+ diagnosed your equipment today but needs a part we don't carry on the
+ truck. We're ordering it right away from the manufacturer.
+
+
+
Reference
+
+
Expected return visit
~
+
+
+
+
+ What happens next:
+
+
+
We order the parts from the manufacturer today.
+
When the parts arrive at our warehouse, we'll email you with a confirmed visit date.
+
You don't need to do anything in the meantime.
+
+
+
+ Questions? Reply to this email or call our office. Reference: .
+
+
+
+
+ {{ object.partner_id.lang }}
+
+
+
+
+
+
+
+ Repair: Parts Ordered (Client)
+
+ Parts ordered for your {{ object.repair_order_id.x_fc_repair_category_id.name or 'equipment' }} - {{ object.repair_order_id.name }}
+ {{ (object.company_id.email_formatted or user.email_formatted) }}
+ {{ object.partner_id.id }}
+
+
+
+
+
Parts ordered
+
+ We've placed an order for the parts your
+ needs. Expected arrival: .
+
+
+
Part
+
+
Manufacturer
+
+
Ref
+
+
We'll email again as soon as the parts arrive at our warehouse.
+
+
+
+ {{ object.partner_id.lang }}
+
+
+
+
+
+
+
+ Repair: Parts Received (Client)
+
+ Parts arrived - scheduling your return visit ({{ object.repair_order_id.name }})
+ {{ (object.company_id.email_formatted or user.email_formatted) }}
+ {{ object.partner_id.id }}
+
+
+
+
+
Good news - your parts arrived
+
+ The parts for your repair are in. Our office will call you in the next business day
+ to confirm a return-visit time. You don't need to do anything right now.
+
+ Hello , the safety inspection
+ certificate on your
+ (certificate ) expires
+ .
+ Annual re-inspection keeps your equipment compliant with local safety regulations.
+
+
+
Certificate
+
Equipment
+
+
Serial
+
+
Expires
+
+
+
+ Reply to this email or call our office to book your re-inspection. We will
+ send our certified technician to confirm everything is safe and renew your
+ certificate.
+
+ Regular maintenance keeps your equipment safe and reliable. Use the
+ button above to confirm and we will reach out to schedule a time that works for you.
+
+
+
+ Contract reference .
+ If you no longer have this equipment, you can ignore this email.
+
+
+
+
+ {{ object.partner_id.lang }}
+
+
+
+
+
+
+
+ Repair: Intake Received (Office)
+
+ [New Service Call] {{ object.partner_id.name or 'Walk-in' }} - {{ object.name or 'n/a' }}
+ {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}
+ {{ ','.join(p.email for p in (object.company_id.x_fc_office_notification_ids if 'x_fc_office_notification_ids' in object.company_id._fields else []) if p.email) or (object.company_id.email or '') }}
+
+
+
+
+
+ Internal: New Service Call
+
+
A new repair has been submitted
+
+ Submitted by
+ via the .
+
+
+
Details
+
Reference
+
Client
+
+
Phone
+
+
+
Equipment
+
+
Urgency
+
+
Third-party
Yes - equipment not sold by us
+
+
+
Warranty
Under warranty
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/data/repair_product_category_data.xml b/fusion_repairs/data/repair_product_category_data.xml
new file mode 100644
index 00000000..e00d255d
--- /dev/null
+++ b/fusion_repairs/data/repair_product_category_data.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+ Hospital Bed
+ hospital_bed
+ 10
+ fa-bed
+ Electric and manual hospital beds, semi-electric beds, low beds.
+
+
+
+ Wheelchair (Manual)
+ wheelchair_manual
+ 20
+ fa-wheelchair
+ Standard, transport, and tilt-in-space manual wheelchairs.
+
+
+
+ Wheelchair (Power)
+ wheelchair_power
+ 30
+ fa-wheelchair
+ Power wheelchairs, scooters, and powered mobility devices.
+
+
+
+
+ Stairlift
+ stairlift
+ 40
+ fa-arrows-v
+ Straight and curved indoor stairlifts. Annual safety inspection required in many jurisdictions.
+
+ lift_elevating
+
+
+
+ Porch Lift
+ porch_lift
+ 50
+ fa-arrow-up
+ Vertical platform lifts for porches, decks, and accessible building entrances.
+
+ lift_elevating
+
+
+
+
+ Lift Chair
+ lift_chair
+ 55
+ fa-chair
+ Powered recliner / lift chairs (Pride, Golden, MedLift). Falls under Lift & Elevating Service per rate card.
+ lift_elevating
+
+
+
+ Walker
+ walker
+ 60
+ fa-male
+ Standard walkers, hemi-walkers, and folding walkers.
+
+
+
+ Rollator
+ rollator
+ 70
+ fa-male
+ Wheeled walkers with seats and brakes.
+
+
+
+ Medical Mattress
+ mattress
+ 80
+ fa-bed
+ Air mattresses, alternating pressure, low air loss, and pressure relief mattresses.
+
+
+
+ Other Equipment
+ other
+ 100
+ fa-question-circle
+ Any other medical equipment not in the standard categories.
+
+
+
+
diff --git a/fusion_repairs/data/self_check_data.xml b/fusion_repairs/data/self_check_data.xml
new file mode 100644
index 00000000..4c49e4df
--- /dev/null
+++ b/fusion_repairs/data/self_check_data.xml
@@ -0,0 +1,245 @@
+
+
+
+
+
+
+
+ Hospital Bed - No Power
+
+ 10
+ won't move,dead,no power,no response
+ Check the bed is plugged in and the outlet has power - try plugging a phone charger into the same outlet to confirm.
+ Bed responds when controls are pressed.
+
+
+ Hospital Bed - Slow / Sluggish
+
+ 20
+ slow,sluggish
+ Unplug the bed for 30 seconds then plug it back in.
+ Movement returns to normal speed.
+
+
+ Hospital Bed - Remote Unresponsive
+
+ 30
+ remote,controller
+ Replace the remote batteries with fresh AAA batteries.
+ Remote lights up and bed responds.
+
+
+ Hospital Bed - Alarm
+
+ 40
+ beep,alarm,alert
+ Check both side rails are fully locked in the raised position.
+ Alarm stops.
+
+
+ Hospital Bed - One Section Won't Move
+
+ 50
+ one section,won't lift,stuck
+ Check nothing is caught under the bed or jamming the mechanism (sheets, blankets, cords).
+ Section moves freely.
+
+
+
+
+ Wheelchair - Brake
+
+ 10
+ brake,stop
+ Push the brake lever fully to the locked position and listen for a click.
+ Brake holds wheel firmly.
+
+
+ Wheelchair - Hard to Push
+
+ 20
+ hard to push,drag,slow
+ Check both tires for full inflation - firm to thumb pressure.
+ Wheelchair rolls freely.
+
+
+ Wheelchair - Wobbly Wheel
+
+ 30
+ wobble,loose wheel
+ Try turning the axle nut gently by hand to feel if it is snug.
+ Wheel feels firm with no play.
+
+
+ Wheelchair - Footrest Loose
+
+ 40
+ footrest,footplate
+ Slide the footrest fully into its housing until you hear a click.
+ Footrest feels secure.
+
+
+
+
+ Power Wheelchair - No Power
+
+ 10
+ won't turn on,dead,no power,battery
+ Confirm the battery indicator shows charge and the key switch is in the ON position.
+ Display lights up.
+
+
+ Power Wheelchair - Error Code
+
+ 20
+ error,flashing,code
+ Note the error code shown on the joystick display, then turn off and back on after 30 seconds.
+ Error clears or a specific code is captured.
+
+
+ Power Wheelchair - One Side Weaker
+
+ 30
+ one side weaker,pulls
+ Charge the batteries fully overnight before testing again.
+ Both sides equal power after a full charge.
+
+
+
+
+ Stairlift - Won't Move
+
+ 10
+ won't move,stuck
+ Check the seat is fully rotated to the forward position and the seatbelt is fastened.
+ Stairlift responds.
+
+
+ Stairlift - Stops Midway
+
+ 20
+ stops midway,halts
+ Check the track for items blocking the sensors - toys, slippers, debris.
+ Stairlift completes its travel.
+
+
+ Stairlift - Call Station Unresponsive
+
+ 30
+ remote,call station
+ Replace the remote / call-station batteries with fresh batteries.
+ Call station responds.
+
+
+ Stairlift - Beeping / Alarm
+
+ 40
+ beep,alarm
+ Confirm the seat swivel lock is engaged in the down position.
+ Beeping stops.
+
+
+
+
+ Porch Lift - Won't Move
+
+ 10
+ won't move,dead
+ Check all gate and door safety switches are fully closed.
+ Lift responds.
+
+
+ Porch Lift - Sticky Controls
+
+ 20
+ sticky,stuck button
+ If outdoors, gently wipe the controls with a dry cloth and let dry.
+ Controls respond.
+
+
+ Porch Lift - Won't Stop at Floor
+
+ 30
+ won't stop,overshoot
+ Note exactly which floor it stops at - do not attempt repeat use.
+ Information captured for technician.
+ Do not use the lift again until a technician inspects it.
+
+
+
+
+ Walker - Wheel Stuck
+
+ 10
+ wheel stick,won't roll
+ Check for hair or debris wrapped around the wheel axle.
+ Wheel spins freely.
+
+
+ Walker - Frame Wobbles
+
+ 20
+ wobble,loose
+ Check all height adjustment pins are fully engaged through both holes.
+ Frame feels solid.
+ Wobbly walkers cause falls - stop using until repaired if movement persists.
+
+
+ Rollator - Brake Won't Lock
+
+ 10
+ brake won't lock,brake loose
+ Push the brake lever fully down until you feel a click.
+ Brake holds.
+
+
+ Rollator - Seat Loose
+
+ 20
+ seat loose
+ Tighten the seat knobs by hand until firm.
+ Seat feels secure.
+ Do not sit on a loose rollator seat - fall risk.
+
+
+
+
+ Mattress - Deflated
+
+ 10
+ deflated,flat,soft
+ Confirm the pump is plugged in, powered on, and the hose is firmly attached.
+ Mattress inflates.
+
+
+ Mattress - Alarm
+
+ 20
+ alarm,beep
+ Check the pump display for the error code shown, then restart the pump by unplugging for 30 seconds.
+ Alarm clears.
+
+
+ Mattress - Hissing / Leak
+
+ 30
+ hiss,leak
+ Listen at the valve - push the valve cap in firmly to ensure it is sealed.
+ Hissing stops.
+
+
+ Mattress - Not Heating
+
+ 40
+ cold,won't heat
+ Confirm the heat dial is set above zero and allow 15 minutes to warm.
+ Mattress feels warm.
+
+
+
+
diff --git a/fusion_repairs/migrations/19.0.2.1.0/post-migration.py b/fusion_repairs/migrations/19.0.2.1.0/post-migration.py
new file mode 100644
index 00000000..680021be
--- /dev/null
+++ b/fusion_repairs/migrations/19.0.2.1.0/post-migration.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+"""Post-migration for 19.0.2.1.0 - align rate-card + categories to Westin's
+printed service-rate card.
+
+Sites that installed any earlier Bundle 9 build have:
+ - Old callout.rate rows with $120/$95/0.85 values (B9 placeholder rates)
+ - Stairlift / porch_lift categories with equipment_class='standard'
+
+Both have noupdate=1 in their seed XML so a normal -u upgrade won't fix
+them. This script:
+ 1. Wipes the four B9-only rate xml_ids and re-imports the seed
+ 2. Updates lift / porch / lift_chair categories to equipment_class='lift_elevating'
+
+After this runs once, future upgrades respect noupdate=1 normally (admin
+tweaks are preserved).
+"""
+
+from odoo.tools.sql import column_exists
+
+
+def migrate(cr, version):
+ if not version:
+ return # fresh install - seed loads correctly
+
+ cr.execute("""
+ UPDATE fusion_repair_product_category
+ SET equipment_class = 'lift_elevating'
+ WHERE code IN ('stairlift', 'porch_lift', 'lift_chair')
+ AND (equipment_class IS NULL OR equipment_class = 'standard');
+ """)
+
+ # Wipe the four B9 rate rows so the new noupdate=1 seed re-creates them
+ # with the printed values. Only deletes rows that were originally seeded
+ # by this module (xml_id present) - admin-created rate rows stay put.
+ cr.execute("""
+ DELETE FROM fusion_repair_callout_rate
+ WHERE id IN (
+ SELECT res_id FROM ir_model_data
+ WHERE module = 'fusion_repairs'
+ AND model = 'fusion.repair.callout.rate'
+ AND name IN ('callout_rate_regular', 'callout_rate_after_hours',
+ 'callout_rate_weekend', 'callout_rate_holiday')
+ );
+ DELETE FROM ir_model_data
+ WHERE module = 'fusion_repairs'
+ AND model = 'fusion.repair.callout.rate'
+ AND name IN ('callout_rate_regular', 'callout_rate_after_hours',
+ 'callout_rate_weekend', 'callout_rate_holiday');
+ """)
diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py
new file mode 100644
index 00000000..5810aaa9
--- /dev/null
+++ b/fusion_repairs/models/__init__.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from . import repair_product_category
+from . import intake_template
+from . import intake_question
+from . import intake_answer
+from . import service_catalog
+from . import repair_warranty
+from . import maintenance_contract
+from . import repair_self_check_rule
+from . import repair_ai_service
+from . import repair_on_call_service
+from . import repair_inspection
+from . import repair_service_plan
+from . import repair_emergency_charge
+from . import repair_part_order
+from . import repair_callout_rate
+from . import repair_labor_warranty
+from . import repair_delivery_charge
+from . import product_template
+from . import res_partner
+from . import res_users
+from . import res_config_settings
+from . import technician_task
+from . import repair_order
+from . import sale_order
+from . import intake_service
+from . import repair_dashboard
diff --git a/fusion_repairs/models/intake_answer.py b/fusion_repairs/models/intake_answer.py
new file mode 100644
index 00000000..0db6b64f
--- /dev/null
+++ b/fusion_repairs/models/intake_answer.py
@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import api, fields, models
+
+
+class FusionRepairIntakeAnswer(models.Model):
+ """An answer to a single intake question on a specific repair order.
+
+ Persists raw answer values for audit + reporting + AI / catalogue matching.
+ """
+
+ _name = 'fusion.repair.intake.answer'
+ _description = 'Repair Intake Answer'
+ _order = 'repair_id, sequence, id'
+
+ repair_id = fields.Many2one(
+ 'repair.order',
+ string='Repair Order',
+ required=True,
+ ondelete='cascade',
+ index=True,
+ )
+ question_id = fields.Many2one(
+ 'fusion.repair.intake.question',
+ string='Question',
+ required=True,
+ ondelete='restrict',
+ )
+ question_name = fields.Char(
+ related='question_id.name',
+ string='Question',
+ store=True,
+ )
+ question_type = fields.Selection(
+ related='question_id.question_type',
+ store=True,
+ )
+ sequence = fields.Integer(
+ related='question_id.sequence',
+ store=True,
+ )
+
+ # Typed value fields - one per supported type, plus a display string.
+ value_char = fields.Char(string='Text Answer')
+ value_text = fields.Text(string='Long Text Answer')
+ value_selection = fields.Char(string='Choice Answer')
+ value_boolean = fields.Boolean(string='Yes/No Answer')
+ value_integer = fields.Integer(string='Number Answer')
+ value_date = fields.Date(string='Date Answer')
+
+ value_display = fields.Char(
+ string='Answer',
+ compute='_compute_value_display',
+ store=True,
+ )
+
+ company_id = fields.Many2one(
+ 'res.company',
+ related='repair_id.company_id',
+ store=True,
+ index=True,
+ )
+
+ @api.depends(
+ 'question_type',
+ 'value_char', 'value_text', 'value_selection',
+ 'value_boolean', 'value_integer', 'value_date',
+ )
+ def _compute_value_display(self):
+ for answer in self:
+ if answer.question_type == 'char':
+ answer.value_display = answer.value_char or ''
+ elif answer.question_type == 'text':
+ answer.value_display = (answer.value_text or '')[:200]
+ elif answer.question_type == 'selection':
+ answer.value_display = answer.value_selection or ''
+ elif answer.question_type == 'boolean':
+ answer.value_display = 'Yes' if answer.value_boolean else 'No'
+ elif answer.question_type == 'integer':
+ answer.value_display = str(answer.value_integer or 0)
+ elif answer.question_type == 'date':
+ answer.value_display = (
+ fields.Date.to_string(answer.value_date) if answer.value_date else ''
+ )
+ else:
+ answer.value_display = ''
diff --git a/fusion_repairs/models/intake_question.py b/fusion_repairs/models/intake_question.py
new file mode 100644
index 00000000..48188f71
--- /dev/null
+++ b/fusion_repairs/models/intake_question.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+QUESTION_TYPES = [
+ ('char', 'Short Text'),
+ ('text', 'Long Text'),
+ ('selection', 'Single Choice'),
+ ('boolean', 'Yes / No'),
+ ('integer', 'Number'),
+ ('date', 'Date'),
+]
+
+
+class FusionRepairIntakeQuestion(models.Model):
+ """A single question on an intake template.
+
+ Supports basic conditional display: a question is only shown when the
+ parent question's answer matches `parent_answer_value`. The wizard and
+ portal forms render based on these rules.
+ """
+
+ _name = 'fusion.repair.intake.question'
+ _description = 'Repair Intake Question'
+ _order = 'sequence, id'
+
+ template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Template',
+ required=True,
+ ondelete='cascade',
+ index=True,
+ )
+ sequence = fields.Integer(string='Sequence', default=10)
+ name = fields.Char(
+ string='Question',
+ required=True,
+ translate=True,
+ help='Text shown to the user.',
+ )
+ code = fields.Char(
+ string='Code',
+ help='Stable identifier for this question (used by automation rules and reporting).',
+ )
+ help_text = fields.Char(
+ string='Help Text',
+ translate=True,
+ help='Optional shorter hint shown beneath the question (e.g. "e.g. SN-12345").',
+ )
+ question_type = fields.Selection(
+ QUESTION_TYPES,
+ string='Type',
+ required=True,
+ default='char',
+ )
+ required = fields.Boolean(default=False)
+
+ selection_options = fields.Text(
+ string='Choices',
+ help='One option per line, only used when type is "Single Choice".',
+ )
+
+ # Conditional display
+ parent_question_id = fields.Many2one(
+ 'fusion.repair.intake.question',
+ string='Show Only If Question',
+ domain="[('template_id', '=', template_id), ('id', '!=', id)]",
+ ondelete='set null',
+ help='Show this question only when the parent question matches the value below.',
+ )
+ parent_answer_value = fields.Char(
+ string='Parent Answer Equals',
+ help='Value the parent answer must equal for this question to be displayed.',
+ )
+
+ # Symptom keyword classification - feeds the service catalogue matcher and AI prompt
+ symptom_keywords = fields.Char(
+ string='Symptom Keywords',
+ help='Comma-separated keywords that, when present in the answer, tag the repair '
+ 'for catalogue matching (e.g. "battery,charge").',
+ )
diff --git a/fusion_repairs/models/intake_service.py b/fusion_repairs/models/intake_service.py
new file mode 100644
index 00000000..2d79bf7f
--- /dev/null
+++ b/fusion_repairs/models/intake_service.py
@@ -0,0 +1,554 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Shared intake service.
+
+This AbstractModel is the SINGLE entry point for creating repair orders from
+any intake surface: the backend wizard (Phase 1), the sales rep portal
+(Phase 1+), and the public client self-service portal (Phase 1+).
+
+All three surfaces call `create_repair_orders(payload, source='...')` so that
+business logic - activities, emails, warranty determination, AI summary,
+catalogue match, third-party flag, dispatch task creation - lives in one
+place and the surfaces never drift apart.
+"""
+
+import logging
+from datetime import timedelta
+
+from markupsafe import Markup
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class FusionRepairIntakeService(models.AbstractModel):
+ _name = 'fusion.repair.intake.service'
+ _description = 'Repair Intake Service (shared by backend / sales rep / client)'
+
+ # ------------------------------------------------------------------
+ # PUBLIC API
+ # ------------------------------------------------------------------
+ @api.model
+ def create_repair_orders(self, payload, source='backend_wizard'):
+ """Create one repair.order per equipment item in the payload.
+
+ :param payload: dict with keys:
+ - partner_id: int (required) or partner_vals: dict to create new partner
+ - 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:
+ - product_id: int (optional)
+ - lot_id: int (optional)
+ - repair_category_id: int (optional)
+ - intake_template_id: int (optional)
+ - third_party: bool (optional)
+ - urgency: str (optional, default 'normal')
+ - issue_summary: str (optional)
+ - internal_notes: str (optional)
+ - photo_attachment_ids: list[int] (optional)
+ - answers: list of dicts with keys
+ (question_id, value_char|value_text|value_selection|
+ value_boolean|value_integer|value_date)
+ :param source: str, one of repair_order.INTAKE_SOURCES values.
+ :return: recordset of repair.order records created.
+ """
+ partner_id = self._resolve_partner(payload)
+ if not partner_id:
+ raise UserError(_('A client is required to create a repair request.'))
+
+ intake_user = self.env['res.users'].browse(
+ payload.get('intake_user_id') or self.env.uid
+ )
+ session_ref = (
+ self.env['ir.sequence'].next_by_code('fusion.repair.intake.session')
+ or 'RIS/NEW'
+ )
+
+ equipment = payload.get('equipment_items') or [{}]
+ quote_only = bool(payload.get('quote_only'))
+ rush_requested = bool(payload.get('rush_requested'))
+ rush_tier = payload.get('rush_tier') or False
+ rush_techs_required = int(payload.get('rush_techs_required') or 1)
+ repairs = self.env['repair.order']
+ for item in equipment:
+ repair = self._create_single_repair(
+ partner_id=partner_id,
+ intake_user=intake_user,
+ session_ref=session_ref,
+ source=source,
+ item=item,
+ quote_only=quote_only,
+ rush_requested=rush_requested,
+ rush_tier=rush_tier,
+ rush_techs_required=rush_techs_required,
+ )
+ repairs |= repair
+
+ return repairs
+
+ # ------------------------------------------------------------------
+ # PARTNER RESOLUTION
+ # ------------------------------------------------------------------
+ @api.model
+ def _resolve_partner(self, payload):
+ partner_id = payload.get('partner_id')
+ if partner_id:
+ return partner_id
+ partner_vals = payload.get('partner_vals')
+ if not partner_vals:
+ return False
+ # Sensible defaults for partners created via public portals so mail
+ # templates pick up the right language / company.
+ partner_vals.setdefault('lang', self.env.user.lang or 'en_CA')
+ partner_vals.setdefault('company_id', self.env.company.id)
+ partner = self.env['res.partner'].sudo().create(partner_vals)
+ return partner.id
+
+ # ------------------------------------------------------------------
+ # CORE CREATION
+ # ------------------------------------------------------------------
+ @api.model
+ def _create_single_repair(self, partner_id, intake_user, session_ref,
+ source, item, quote_only=False,
+ rush_requested=False, rush_tier=False,
+ rush_techs_required=1):
+ Repair = self.env['repair.order']
+ product_id = item.get('product_id')
+
+ vals = {
+ 'partner_id': partner_id,
+ 'user_id': intake_user.id,
+ 'x_fc_intake_user_id': intake_user.id,
+ 'x_fc_intake_session_id': session_ref,
+ 'x_fc_intake_source': source,
+ 'x_fc_repair_category_id': item.get('repair_category_id') or False,
+ 'x_fc_intake_template_id': item.get('intake_template_id') or False,
+ 'x_fc_third_party_equipment': bool(item.get('third_party')),
+ 'x_fc_urgency': item.get('urgency') or 'normal',
+ 'x_fc_issue_category': item.get('issue_category') or False,
+ 'x_fc_is_quote_only': bool(quote_only),
+ 'x_fc_rush_requested': bool(rush_requested),
+ 'x_fc_rush_tier': rush_tier or False,
+ 'x_fc_rush_techs_required': rush_techs_required or 1,
+ 'internal_notes': self._wrap_internal_notes(item),
+ }
+ if product_id:
+ vals['product_id'] = product_id
+ if item.get('lot_id'):
+ vals['lot_id'] = item['lot_id']
+ if item.get('schedule_date'):
+ vals['schedule_date'] = item['schedule_date']
+
+ repair = Repair.create(vals)
+
+ # Determine warranty AFTER creation (needs product on record).
+ if not repair.x_fc_third_party_equipment:
+ self._auto_link_original_sale_order(repair)
+ if repair._fc_compute_warranty_status():
+ repair.under_warranty = True
+
+ # Persist intake answers.
+ self._create_answers(repair, item.get('answers') or [])
+
+ # Service catalogue auto-match.
+ self._match_service_catalog(repair, item, quote_only=quote_only)
+
+ # Check our own repair-warranty (30/90 day re-do free).
+ self._check_repair_warranty(repair)
+
+ # Optional AI brief generation - never blocks intake.
+ self._generate_ai_summary(repair, item)
+
+ # Attach photos.
+ photo_ids = item.get('photo_attachment_ids') or []
+ if photo_ids:
+ attachments = self.env['ir.attachment'].sudo().browse(photo_ids).exists()
+ attachments.write({
+ 'res_model': 'repair.order',
+ 'res_id': repair.id,
+ })
+ repair.write({'x_fc_photo_ids': [(6, 0, attachments.ids)]})
+
+ # Activities.
+ self._schedule_activities(repair)
+
+ # Optional dispatch draft task (urgent / safety).
+ # Skip if the catalogue match already auto-created one.
+ # Skip entirely if intake is quote-only (C6).
+ if (
+ not quote_only
+ and repair.x_fc_urgency in ('urgent', 'safety')
+ and not repair.x_fc_technician_task_ids
+ ):
+ self._create_dispatch_task(repair)
+ elif quote_only:
+ repair.message_post(body=Markup(_(
+ 'Created in Quote Only mode - no technician dispatched.'
+ )))
+
+ # CL15: page the on-call manager for safety intakes after hours.
+ if repair.x_fc_urgency == 'safety':
+ try:
+ self.env['fusion.repair.on.call.service'].sudo().page_on_call(repair)
+ except Exception as e:
+ _logger.warning('On-call page failed for %s: %s', repair.name, e)
+
+ # Emails (client + office).
+ self._send_intake_emails(repair)
+
+ # Audit message in chatter.
+ repair.message_post(
+ body=Markup(_(
+ 'Service call submitted via %(source)s by %(user)s. '
+ 'Session reference: %(ref)s.'
+ )) % {
+ 'source': dict(repair._fields['x_fc_intake_source'].selection).get(source) or '',
+ 'user': intake_user.name or '',
+ 'ref': session_ref or '',
+ },
+ )
+
+ return repair
+
+ @api.model
+ def _wrap_internal_notes(self, item):
+ notes = item.get('internal_notes') or ''
+ summary = item.get('issue_summary') or ''
+ if not (notes or summary):
+ return False
+ parts = []
+ if summary:
+ parts.append('
Issue summary: %s
' % summary)
+ if notes:
+ parts.append('
Notes: %s
' % notes)
+ return ''.join(parts)
+
+ # ------------------------------------------------------------------
+ # SERVICE CATALOGUE MATCH
+ # ------------------------------------------------------------------
+ @api.model
+ def _match_service_catalog(self, repair, item, quote_only=False):
+ category = repair.x_fc_repair_category_id
+ if not category:
+ return
+ text_hints = [
+ (item.get('issue_summary') or ''),
+ (item.get('issue_category') or ''),
+ (item.get('internal_notes') or ''),
+ ]
+ catalog = self.env['fusion.repair.service.catalog'].sudo().find_best_match(
+ category.id, text_hints,
+ )
+ if not catalog:
+ return
+ repair.write({
+ 'x_fc_service_catalog_id': catalog.id,
+ 'x_fc_estimated_duration': catalog.estimated_hours,
+ 'x_fc_estimated_cost': catalog.estimated_cost,
+ })
+ # Auto-create dispatch task if catalogue says so (in addition to urgency rule).
+ # 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)
+
+ # ------------------------------------------------------------------
+ # REPAIR WARRANTY (our 30/90-day re-do free)
+ # ------------------------------------------------------------------
+ @api.model
+ def _check_repair_warranty(self, repair):
+ if not repair.partner_id:
+ return
+ warranty = self.env['fusion.repair.warranty.coverage'].sudo() \
+ .find_active_for(repair.partner_id.id, repair.product_id.id or None,
+ repair.lot_id.id or None)
+ if not warranty:
+ return
+ repair.message_post(
+ body=Markup(_(
+ 'This repair MAY be covered by our active warranty %(ref)s '
+ '(expires %(exp)s). Manager review recommended before invoicing.'
+ )) % {
+ 'ref': warranty.name or '',
+ 'exp': warranty.expiry_date and str(warranty.expiry_date) or '',
+ },
+ message_type='comment',
+ )
+
+ # ------------------------------------------------------------------
+ # AI SUMMARY (try/fallback per fusion-api-integration rule)
+ # ------------------------------------------------------------------
+ @api.model
+ def _generate_ai_summary(self, repair, item):
+ try:
+ ApiService = self.env.get('fusion.api.service')
+ if not ApiService:
+ return
+ issue = (item.get('issue_summary') or '').strip()
+ if not issue:
+ return
+ category = repair.x_fc_repair_category_id.name or 'medical equipment'
+ urgency = repair.x_fc_urgency or 'normal'
+ messages = [
+ {
+ 'role': 'system',
+ 'content': (
+ 'You are an assistant for a medical equipment repair service. '
+ 'Given an intake note, output ONE short paragraph (under 80 words) '
+ 'briefing the technician about: likely cause, what to bring, and '
+ 'any safety considerations. NEVER provide medical advice. NEVER '
+ 'recommend stopping equipment use. NEVER claim a definitive cause. '
+ 'Plain English, no jargon.'
+ ),
+ },
+ {
+ 'role': 'user',
+ 'content': (
+ f'Equipment category: {category}\n'
+ f'Urgency: {urgency}\n'
+ f'Issue: {issue}\n'
+ f'Notes: {(item.get("internal_notes") or "").strip()}'
+ ),
+ },
+ ]
+ summary = ApiService.call_openai(
+ consumer='fusion_repairs',
+ feature='intake_triage',
+ messages=messages,
+ max_tokens=200,
+ )
+ if summary:
+ repair.x_fc_ai_summary = summary.strip()
+ except Exception as e:
+ _logger.info('AI intake summary skipped: %s', e)
+
+ # ------------------------------------------------------------------
+ # ORIGINAL SO AUTO-LINK
+ # ------------------------------------------------------------------
+ @api.model
+ def _auto_link_original_sale_order(self, repair):
+ if not repair.partner_id or not repair.product_id:
+ return
+ SaleOrder = self.env['sale.order'].sudo()
+ domain = [
+ ('partner_id', '=', repair.partner_id.id),
+ ('state', 'in', ('sale', 'done')),
+ ('order_line.product_id', '=', repair.product_id.id),
+ ]
+ if repair.lot_id:
+ domain.append(('order_line.lot_ids', 'in', repair.lot_id.id))
+ candidate = SaleOrder.search(domain, order='date_order desc', limit=1)
+ if candidate:
+ repair.x_fc_original_sale_order_id = candidate
+
+ # ------------------------------------------------------------------
+ # ANSWERS
+ # ------------------------------------------------------------------
+ @api.model
+ def _create_answers(self, repair, answers):
+ if not answers:
+ return
+ Answer = self.env['fusion.repair.intake.answer']
+ for ans in answers:
+ qid = ans.get('question_id')
+ if not qid:
+ continue
+ Answer.create({
+ 'repair_id': repair.id,
+ 'question_id': qid,
+ 'value_char': ans.get('value_char'),
+ 'value_text': ans.get('value_text'),
+ 'value_selection': ans.get('value_selection'),
+ 'value_boolean': bool(ans.get('value_boolean')),
+ 'value_integer': int(ans.get('value_integer') or 0),
+ 'value_date': ans.get('value_date') or False,
+ })
+
+ # ------------------------------------------------------------------
+ # ACTIVITIES
+ # ------------------------------------------------------------------
+ @api.model
+ def _schedule_activities(self, repair):
+ """Create the 4 intake activities described in the spec."""
+ try:
+ cs_callback_type = self.env.ref('fusion_repairs.mail_activity_type_cs_callback')
+ tech_dispatch_type = self.env.ref('fusion_repairs.mail_activity_type_tech_dispatch')
+ manager_review_type = self.env.ref('fusion_repairs.mail_activity_type_manager_review')
+ except ValueError:
+ _logger.warning('Repair activity types missing - skipping')
+ return
+
+ # CS callback - always, intake user
+ repair.activity_schedule(
+ activity_type_id=cs_callback_type.id,
+ summary=_('Call client back if any intake info was missing'),
+ user_id=repair.x_fc_intake_user_id.id or self.env.uid,
+ )
+
+ # Tech dispatch - assigned to responsible user, urgency-adjusted deadline
+ deadline_days = {'safety': 0, 'urgent': 1, 'normal': 2}.get(repair.x_fc_urgency, 2)
+ repair.activity_schedule(
+ activity_type_id=tech_dispatch_type.id,
+ summary=_('Assign a technician (urgency: %s)', repair.x_fc_urgency),
+ user_id=repair.user_id.id or self.env.uid,
+ date_deadline=fields.Date.context_today(self) + timedelta(days=deadline_days),
+ )
+
+ # Manager review - only for third-party equipment
+ if repair.x_fc_third_party_equipment:
+ manager_group = self.env.ref(
+ 'fusion_repairs.group_fusion_repairs_manager',
+ raise_if_not_found=False,
+ )
+ manager_user = self.env.user
+ if manager_group:
+ # res.groups has no .users field in Odoo 19;
+ # query via res.users.all_group_ids (Odoo 19 renamed groups_id).
+ candidate = self.env['res.users'].sudo().search(
+ [('all_group_ids', 'in', manager_group.ids), ('active', '=', True)],
+ limit=1,
+ )
+ if candidate:
+ manager_user = candidate
+ repair.activity_schedule(
+ activity_type_id=manager_review_type.id,
+ summary=_('Third-party equipment - manager awareness'),
+ user_id=manager_user.id,
+ )
+
+ # ------------------------------------------------------------------
+ # DISPATCH TASK
+ # ------------------------------------------------------------------
+ @api.model
+ def _create_dispatch_task(self, repair):
+ """Create a draft fusion.technician.task for urgent / safety repairs.
+
+ Phase 1 simple approach: no date/technician assigned, dispatcher confirms.
+ """
+ Task = self.env['fusion.technician.task'].sudo()
+ try:
+ vals = {
+ 'partner_id': repair.partner_id.id,
+ 'task_type': 'repair',
+ 'status': 'pending',
+ 'scheduled_date': fields.Date.context_today(self),
+ 'duration_hours': repair.x_fc_estimated_duration or 1.0,
+ 'x_fc_repair_order_id': repair.id,
+ 'description': repair.internal_notes or repair.name,
+ }
+ # Bundle 8: allow squeeze / re-dispatch callers to inject a
+ # specific scheduled_date + time_start + time_end via context so
+ # fusion_tasks' conflict validator doesn't reject the create.
+ force_sched = self._context.get('force_schedule') or {}
+ if force_sched:
+ vals.update(force_sched)
+ # technician_id is required AND constrained to x_fc_is_field_staff.
+ # D2: prefer a tech whose x_fc_repair_skills covers this repair's
+ # category. Falls back to ANY active field-staff user if no skilled
+ # tech exists, then to the lowest-id field-staff user as a placeholder.
+ tech_id = self._context.get('force_tech_id') or self._pick_dispatch_technician(repair)
+ if not tech_id:
+ _logger.warning(
+ 'No field-staff user available - skipping auto-dispatch '
+ 'task for repair %s (mark a user as Field Staff under '
+ 'Settings > Users).',
+ repair.name,
+ )
+ return
+ vals['technician_id'] = tech_id
+ Task.create(vals)
+ except Exception as e:
+ _logger.warning('Failed to auto-create dispatch task for repair %s: %s',
+ 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
+ # ------------------------------------------------------------------
+ @api.model
+ def _send_intake_emails(self, repair):
+ if not self._notifications_enabled():
+ return
+ # Client confirmation
+ if repair.partner_id and repair.partner_id.email:
+ try:
+ self.env.ref('fusion_repairs.email_template_intake_received_client') \
+ .send_mail(repair.id, force_send=False)
+ except Exception as e:
+ _logger.warning('Failed to send client intake email for %s: %s',
+ repair.name, e)
+
+ # Office notification
+ office_emails = self._office_emails(repair.company_id)
+ if office_emails:
+ try:
+ tpl = self.env.ref('fusion_repairs.email_template_intake_received_office')
+ tpl.with_context(default_email_to=','.join(office_emails)) \
+ .send_mail(repair.id, force_send=False, email_values={
+ 'email_to': ','.join(office_emails),
+ })
+ except Exception as e:
+ _logger.warning('Failed to send office intake email for %s: %s',
+ repair.name, e)
+
+ @api.model
+ def _notifications_enabled(self):
+ ICP = self.env['ir.config_parameter'].sudo()
+ return ICP.get_param('fusion_repairs.enable_email_notifications', 'True') == 'True'
+
+ @api.model
+ def _office_emails(self, company):
+ # Reuse the office notification recipients defined by fusion_claims.
+ company_sudo = company.sudo()
+ recipients = getattr(company_sudo, 'x_fc_office_notification_ids', False)
+ emails = [p.email for p in (recipients or []) if p.email]
+ if not emails:
+ _logger.info(
+ 'No office notification recipients configured on company %s - '
+ 'skipping office intake email.',
+ company.name,
+ )
+ return emails
diff --git a/fusion_repairs/models/intake_template.py b/fusion_repairs/models/intake_template.py
new file mode 100644
index 00000000..ba6a0416
--- /dev/null
+++ b/fusion_repairs/models/intake_template.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import api, fields, models
+
+
+class FusionRepairIntakeTemplate(models.Model):
+ """A reusable set of intake questions per medical equipment category.
+
+ Each template contains an ordered list of questions; the intake wizard
+ (and sales-rep / client portals) render these dynamically with
+ conditional show/hide based on prior answers.
+ """
+
+ _name = 'fusion.repair.intake.template'
+ _description = 'Repair Intake Question Template'
+ _order = 'sequence, name'
+
+ name = fields.Char(string='Template Name', required=True, translate=True)
+ code = fields.Char(
+ string='Code',
+ help='Optional stable identifier for referencing this template from code/data.',
+ )
+ sequence = fields.Integer(string='Sequence', default=10)
+ active = fields.Boolean(default=True)
+ is_default = fields.Boolean(
+ string='Default Fallback',
+ help='Used when no template is explicitly configured for the selected category. '
+ 'Exactly one template should be flagged as default per company.',
+ )
+ description = fields.Html(string='Description', translate=True)
+
+ product_category_ids = fields.Many2many(
+ 'fusion.repair.product.category',
+ 'fusion_repair_intake_template_category_rel',
+ 'template_id',
+ 'category_id',
+ string='Applies to Categories',
+ help='Categories that automatically select this template during intake.',
+ )
+
+ question_ids = fields.One2many(
+ 'fusion.repair.intake.question',
+ 'template_id',
+ string='Questions',
+ copy=True,
+ )
+ question_count = fields.Integer(
+ compute='_compute_question_count',
+ string='Question Count',
+ )
+
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ default=lambda self: self.env.company,
+ )
+
+ @api.depends('question_ids')
+ def _compute_question_count(self):
+ for tpl in self:
+ tpl.question_count = len(tpl.question_ids)
+
+ def action_view_questions(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.name,
+ 'res_model': 'fusion.repair.intake.question',
+ 'view_mode': 'list,form',
+ 'domain': [('template_id', '=', self.id)],
+ 'context': {'default_template_id': self.id},
+ }
diff --git a/fusion_repairs/models/maintenance_contract.py b/fusion_repairs/models/maintenance_contract.py
new file mode 100644
index 00000000..f9fa93b1
--- /dev/null
+++ b/fusion_repairs/models/maintenance_contract.py
@@ -0,0 +1,227 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Maintenance contracts.
+
+One contract per sold unit (partner + product + lot). When the underlying
+sale order is delivered and the product has x_fc_maintenance_interval_months>0,
+a contract is auto-created. A daily cron walks active contracts and sends
+the client a reminder email at 30, 7, and 1 days before next_due_date with
+a tokenized booking link.
+"""
+
+import secrets
+from datetime import timedelta
+
+from dateutil.relativedelta import relativedelta
+from markupsafe import Markup
+
+from odoo import _, api, fields, models
+
+
+CONTRACT_STATES = [
+ ('draft', 'Draft'),
+ ('active', 'Active'),
+ ('paused', 'Paused'),
+ ('cancelled', 'Cancelled'),
+]
+
+
+class FusionRepairMaintenanceContract(models.Model):
+ _name = 'fusion.repair.maintenance.contract'
+ _inherit = ['mail.thread']
+ _description = 'Repair Maintenance Contract'
+ _order = 'next_due_date, id'
+
+ name = fields.Char(string='Reference', required=True, default='New',
+ copy=False, readonly=True)
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string='Client',
+ required=True,
+ index=True,
+ ondelete='restrict',
+ )
+ product_id = fields.Many2one(
+ 'product.product',
+ string='Equipment',
+ required=True,
+ index=True,
+ )
+ lot_id = fields.Many2one('stock.lot', string='Serial Number')
+ original_sale_order_id = fields.Many2one(
+ 'sale.order',
+ string='Original Sale Order',
+ index=True,
+ )
+
+ interval_months = fields.Integer(string='Interval (months)', default=12, required=True)
+ last_service_date = fields.Date(string='Last Service')
+ next_due_date = fields.Date(string='Next Due', required=True, index=True)
+ state = fields.Selection(CONTRACT_STATES, default='active', required=True,
+ tracking=True, index=True)
+
+ booking_token = fields.Char(string='Booking Token', copy=False, index=True)
+ last_reminder_band = fields.Selection(
+ [('30', '30 days'), ('7', '7 days'), ('1', '1 day')],
+ string='Last Reminder Sent',
+ copy=False,
+ help='The most recent reminder band sent for the current cycle.',
+ )
+ booking_repair_id = fields.Many2one(
+ 'repair.order',
+ string='Booked Repair',
+ copy=False,
+ help='The repair.order created when the client used the booking link for this cycle.',
+ )
+
+ company_id = fields.Many2one(
+ 'res.company', default=lambda self: self.env.company,
+ )
+
+ _booking_token_unique = models.Constraint(
+ 'unique(booking_token)',
+ 'Booking token must be unique.',
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get('name', 'New') == 'New':
+ vals['name'] = self.env['ir.sequence'].next_by_code(
+ 'fusion.repair.maintenance.contract'
+ ) or 'MC/NEW'
+ if not vals.get('booking_token'):
+ vals['booking_token'] = secrets.token_urlsafe(20)
+ return super().create(vals_list)
+
+ # ------------------------------------------------------------------
+ # ROLL FORWARD
+ # ------------------------------------------------------------------
+ def roll_next_due_date(self):
+ """Advance next_due_date by interval_months and reset cycle state.
+
+ Called from technician_task.write() when a maintenance task moves to
+ 'completed' (see technician_task.py).
+ """
+ for c in self:
+ base = c.last_service_date or fields.Date.context_today(c)
+ # relativedelta handles month boundaries correctly (28/29/30/31).
+ c.next_due_date = base + relativedelta(months=c.interval_months or 12)
+ c.last_reminder_band = False
+ c.booking_repair_id = False
+
+ # ------------------------------------------------------------------
+ # REMINDER CRON
+ # ------------------------------------------------------------------
+ @api.model
+ def cron_send_due_reminders(self):
+ """Daily cron - send reminder emails at 30/7/1 days before next_due_date."""
+ ICP = self.env['ir.config_parameter'].sudo()
+ if ICP.get_param('fusion_repairs.enable_email_notifications', 'True') != 'True':
+ return
+ today = fields.Date.context_today(self)
+ bands = [
+ ('30', 30),
+ ('7', 7),
+ ('1', 1),
+ ]
+ tpl = self.env.ref(
+ 'fusion_repairs.email_template_maintenance_due_reminder',
+ raise_if_not_found=False,
+ )
+ if not tpl:
+ return
+ for band_label, days in bands:
+ target = today + timedelta(days=days)
+ domain = [
+ ('state', '=', 'active'),
+ ('next_due_date', '=', target),
+ ('partner_id.email', '!=', False),
+ ]
+ # Don't re-send a smaller band if we already sent a larger one
+ # for the same cycle - the band order is 30 -> 7 -> 1.
+ contracts = self.search(domain)
+ for c in contracts:
+ if c.last_reminder_band == band_label:
+ continue
+ try:
+ tpl.send_mail(c.id, force_send=False)
+ c.last_reminder_band = band_label
+ c.message_post(
+ body=Markup(_(
+ 'Sent %(band)s-day maintenance reminder to %(email)s.'
+ )) % {
+ 'band': band_label,
+ 'email': c.partner_id.email or '',
+ },
+ )
+ except Exception:
+ continue
+
+ # ------------------------------------------------------------------
+ # PORTAL BOOKING
+ # ------------------------------------------------------------------
+ def create_repair_from_booking(self, scheduled_date=None):
+ """Spawn a repair.order from the booking link (or any manual booking)."""
+ self.ensure_one()
+ if self.booking_repair_id and self.booking_repair_id.state != 'cancel':
+ return self.booking_repair_id
+ Repair = self.env['repair.order'].sudo()
+ repair = Repair.create({
+ 'partner_id': self.partner_id.id,
+ 'product_id': self.product_id.id,
+ 'lot_id': self.lot_id.id if self.lot_id else False,
+ 'schedule_date': scheduled_date or fields.Datetime.now(),
+ 'x_fc_intake_source': 'client_portal',
+ 'x_fc_urgency': 'normal',
+ 'x_fc_repair_category_id':
+ self.product_id.product_tmpl_id.x_fc_repair_category_id.id
+ if self.product_id.product_tmpl_id.x_fc_repair_category_id else False,
+ 'x_fc_maintenance_contract_id': self.id,
+ 'internal_notes':
+ f'
Maintenance visit booked from reminder for contract {self.name}.
',
+ })
+ self.booking_repair_id = repair
+ self.message_post(
+ body=Markup(_(
+ 'Maintenance visit %(ref)s booked from reminder link.'
+ )) % {'ref': repair.name or ''},
+ )
+ return repair
+
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ def _spawn_maintenance_contracts(self):
+ """Create maintenance contracts for any delivered SO line whose
+ product has x_fc_maintenance_interval_months > 0."""
+ Contract = self.env['fusion.repair.maintenance.contract'].sudo()
+ today = fields.Date.context_today(self)
+ for so in self:
+ if so.state not in ('sale', 'done'):
+ continue
+ for line in so.order_line:
+ product = line.product_id
+ if not product:
+ continue
+ interval = product.product_tmpl_id.x_fc_maintenance_interval_months or 0
+ if interval <= 0:
+ continue
+ existing = Contract.search([
+ ('partner_id', '=', so.partner_id.id),
+ ('product_id', '=', product.id),
+ ('original_sale_order_id', '=', so.id),
+ ], limit=1)
+ if existing:
+ continue
+ Contract.create({
+ 'partner_id': so.partner_id.id,
+ 'product_id': product.id,
+ 'original_sale_order_id': so.id,
+ 'interval_months': interval,
+ 'next_due_date': today + relativedelta(months=interval),
+ 'state': 'active',
+ })
diff --git a/fusion_repairs/models/product_template.py b/fusion_repairs/models/product_template.py
new file mode 100644
index 00000000..778710d9
--- /dev/null
+++ b/fusion_repairs/models/product_template.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ x_fc_repair_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Repair Category',
+ help='Medical equipment category - drives intake template selection and '
+ 'technician skills filter for repairs of this product.',
+ )
+ x_fc_warranty_months = fields.Integer(
+ string='Warranty (Months)',
+ default=12,
+ help='Default warranty period for new units of this product. Used to auto-detect '
+ 'warranty status on repair intake (delivery date + warranty months >= today).',
+ )
+ x_fc_maintenance_interval_months = fields.Integer(
+ string='Maintenance Interval (Months)',
+ default=0,
+ help='If > 0, delivering a unit of this product auto-creates a maintenance contract '
+ 'with this recurring interval. Phase 3 feature.',
+ )
+ x_fc_intake_template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Intake Template Override',
+ help='Optional override of the intake template normally chosen from the '
+ 'repair category. Leave empty to use category default.',
+ )
+ # Bundle 9: store labor warranty granted at point of sale.
+ x_fc_labor_warranty_years = fields.Integer(
+ string='Store Labor Warranty (years)',
+ default=0,
+ help='Years of store labor warranty granted when this product is sold. '
+ '0 = no warranty. Setting this triggers a fusion.repair.labor.warranty '
+ 'record per unit on sale-order confirm.',
+ )
diff --git a/fusion_repairs/models/repair_ai_service.py b/fusion_repairs/models/repair_ai_service.py
new file mode 100644
index 00000000..9e37c96a
--- /dev/null
+++ b/fusion_repairs/models/repair_ai_service.py
@@ -0,0 +1,380 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Repair AI Service - single guardrailed entry point for client-portal AI.
+
+Per the design spec (Appendix A), this AbstractModel:
+1) Builds a strict system prompt forbidding medical advice, diagnoses,
+ stop-using recommendations, etc.
+2) Calls fusion.api.service.call_openai() if available (try/fallback per
+ fusion-api-integration rule - never installs as a hard dep)
+3) JSON-schema validates the response and runs a forbidden-phrase regex
+4) Always falls back to deterministic fusion.repair.self.check.rule
+ records on any failure - intake must never be blocked by AI
+
+System prompt + JSON schema live in ir.config_parameter so the office can
+refine them without code changes.
+"""
+
+import hashlib
+import json
+import logging
+import re
+
+from odoo import api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+# ----- Safety filters -----
+FORBIDDEN_PATTERNS = [
+ re.compile(r'\b(diagnos(e|is|ed|ing))\b', re.I),
+ re.compile(r'\byou have\b', re.I),
+ re.compile(r'\bmedical condition\b', re.I),
+ re.compile(r'\b(stop|should\s+stop)\s+using\b', re.I),
+ re.compile(r'\bconsult\s+(your|a)\s+(doctor|physician|nurse)\b', re.I),
+ re.compile(r'\b(blood\s+pressure|heart\s+rate|pulse|oxygen)\b', re.I),
+ re.compile(r'(\$|CAD|USD)\s?\d+', re.I), # No price mentions
+]
+
+# Universal hard-escalate: ANY equipment category - fire / smoke / sparks /
+# burning / injury / trapped is always an immediate escalation. Word
+# boundaries prevent "unhurt" matching "hurt" and "fireman" matching "fire".
+UNIVERSAL_ESCALATION_RE = re.compile(
+ r'\b(fire|smoke|burning|spark|injur(y|ed)|hurt|bleeding|trapped)\b',
+ re.I,
+)
+
+# Category-specific safety symptoms - only fire if the category is flagged
+# safety_critical=True on fusion.repair.product.category (stairlifts,
+# porch lifts, power wheelchairs). "won.?t" handles both "won't" and "wont".
+SAFETY_SYMPTOMS_RE = re.compile(
+ r"\b(stuck|motor|brake\s*fail|won.?t\s*stop|overshoot)\b",
+ re.I,
+)
+
+
+DEFAULT_SYSTEM_PROMPT = (
+ "You are a triage assistant for Fusion Repairs, a Canadian medical "
+ "equipment service company. Your ONLY job is to suggest 1-3 safe, "
+ "reversible self-check steps a client can try on their medical equipment "
+ "before scheduling a technician visit.\n\n"
+ "ABSOLUTE RULES:\n"
+ "1. NEVER provide medical advice, diagnoses, or health recommendations.\n"
+ "2. NEVER claim a definitive cause for the problem.\n"
+ "3. NEVER recommend stopping use of medical equipment.\n"
+ "4. NEVER use phrases like 'you have', 'I diagnose', 'you should stop', "
+ "'medical condition', 'consult your doctor'.\n"
+ "5. ONLY suggest steps that are: safe, reversible, require no tools, "
+ "take under 2 minutes, and pose zero risk to the client or equipment.\n"
+ "6. If symptoms involve smoke, sparks, burning smell, motors on "
+ "stairlifts/porch lifts, OR if you are uncertain -> return "
+ "escalate_immediately: true.\n"
+ "7. Maximum 3 steps. Each step <= 1 sentence. Grade-6 reading level. "
+ "No technical jargon.\n"
+ "8. NEVER reference part numbers, prices, or other clients.\n"
+ "9. If client reports injury, equipment fire, or person trapped -> "
+ "escalate_immediately: true with escalation_reason: 'emergency'.\n"
+ "10. You MUST output valid JSON matching the provided schema. No prose, "
+ "no markdown, no commentary."
+)
+
+
+class FusionRepairAIService(models.AbstractModel):
+ _name = 'fusion.repair.ai.service'
+ _description = 'Repair AI Service - guardrailed self-check engine'
+
+ # ------------------------------------------------------------------
+ # PUBLIC API
+ # ------------------------------------------------------------------
+ @api.model
+ def suggest_self_check(self, product_category_id=None, symptoms=None, urgency=None):
+ """Return a list of safe self-check steps for the client portal.
+
+ Returns a dict with shape:
+ {
+ 'escalate_immediately': bool,
+ 'escalation_reason': str | None,
+ 'confidence': 'high' | 'medium' | 'low',
+ 'steps': [{'instruction': str, 'expected_result': str,
+ 'safety_note': str | None}, ...],
+ 'source': 'ai' | 'fallback' | 'escalated',
+ 'disclaimer': str,
+ }
+ """
+ symptoms = [s for s in (symptoms or []) if s]
+ category = (
+ self.env['fusion.repair.product.category'].sudo().browse(product_category_id)
+ if product_category_id else False
+ )
+
+ # Pre-check: hard-escalate for safety-critical category + symptom combos
+ # without consulting AI. This is BEFORE any AI call so even if AI is
+ # down we still escalate the right way.
+ if self._should_hard_escalate(category, symptoms, urgency):
+ return self._escalated_response('safety')
+
+ # Try the AI, fall back to deterministic rules on any failure.
+ ai_result = self._try_ai(category, symptoms)
+ if ai_result:
+ ai_result['source'] = 'ai'
+ ai_result['disclaimer'] = self._disclaimer()
+ return ai_result
+
+ return self._deterministic_fallback(category, symptoms)
+
+ # ------------------------------------------------------------------
+ # HARD ESCALATION
+ # ------------------------------------------------------------------
+ @api.model
+ def _should_hard_escalate(self, category, symptoms, urgency):
+ if urgency == 'safety':
+ return True
+ text = ' '.join(symptoms)
+ # Universal: fire / smoke / spark / burning / injury / trapped escalate
+ # regardless of equipment category. Electrical fire on a hospital bed
+ # is exactly as urgent as on a stairlift.
+ if UNIVERSAL_ESCALATION_RE.search(text):
+ return True
+ # Category-specific: 'stuck', 'motor', 'brake fail', etc. only escalate
+ # on safety-critical categories (stairlifts, porch lifts, power chairs).
+ if category and category.safety_critical and SAFETY_SYMPTOMS_RE.search(text):
+ return True
+ return False
+
+ @api.model
+ def _escalated_response(self, reason):
+ return {
+ 'escalate_immediately': True,
+ 'escalation_reason': reason,
+ 'confidence': 'high',
+ 'steps': [],
+ 'source': 'escalated',
+ 'disclaimer': self._disclaimer(),
+ }
+
+ # ------------------------------------------------------------------
+ # AI CALL (try/fallback)
+ # ------------------------------------------------------------------
+ @api.model
+ def _try_ai(self, category, symptoms):
+ try:
+ ApiService = self.env.get('fusion.api.service')
+ if not ApiService:
+ return None
+ messages = [
+ {'role': 'system', 'content': self._system_prompt()},
+ {'role': 'user', 'content': self._user_prompt(category, symptoms)},
+ ]
+ cache_key = self._cache_key(category, symptoms)
+ cached = self._cache_get(cache_key)
+ if cached:
+ return cached
+
+ raw = ApiService.call_openai(
+ consumer='fusion_repairs',
+ feature='client_self_triage',
+ messages=messages,
+ max_tokens=400,
+ )
+ if not raw:
+ return None
+ parsed = self._safe_parse(raw)
+ if not parsed:
+ self._log_incident('parse_failed', raw)
+ return None
+ self._cache_set(cache_key, parsed)
+ return parsed
+ except Exception as e:
+ _logger.info('AI self-check skipped: %s', e)
+ return None
+
+ @api.model
+ def _system_prompt(self):
+ ICP = self.env['ir.config_parameter'].sudo()
+ return ICP.get_param(
+ 'fusion_repairs.ai_self_check_system_prompt',
+ DEFAULT_SYSTEM_PROMPT,
+ )
+
+ @api.model
+ def _user_prompt(self, category, symptoms):
+ cat_name = category.name if category else 'medical equipment'
+ return (
+ f"Equipment category: {cat_name}\n"
+ f"Reported symptoms: {'; '.join(symptoms) if symptoms else '(none provided)'}\n"
+ "Output the JSON object only."
+ )
+
+ # ------------------------------------------------------------------
+ # SAFE PARSE + VALIDATE
+ # ------------------------------------------------------------------
+ @api.model
+ def _safe_parse(self, raw):
+ """Parse the AI response, validate against the JSON schema, and run
+ forbidden-phrase regex filters. Returns None on any failure - caller
+ falls back to deterministic rules."""
+ if not raw:
+ return None
+ text = raw.strip()
+ # Strip code-fence wrapping if AI added it.
+ if text.startswith('```'):
+ text = re.sub(r'^```[a-zA-Z]*\n?', '', text)
+ text = re.sub(r'\n?```$', '', text)
+ try:
+ data = json.loads(text)
+ except (ValueError, TypeError):
+ return None
+ # Schema check (minimal - we don't pull in jsonschema as a dep)
+ if not isinstance(data, dict):
+ return None
+ if not isinstance(data.get('escalate_immediately'), bool):
+ return None
+ confidence = data.get('confidence')
+ if confidence not in ('high', 'medium', 'low'):
+ return None
+ steps = data.get('steps')
+ if not isinstance(steps, list) or len(steps) > 3:
+ return None
+ # Coherence: not-escalated must have at least one step.
+ if not data['escalate_immediately'] and not steps:
+ return None
+ # Per-step validation + forbidden-phrase scan.
+ cleaned_steps = []
+ for step in steps:
+ if not isinstance(step, dict):
+ return None
+ instr = step.get('instruction')
+ expected = step.get('expected_result')
+ if not isinstance(instr, str) or not instr.strip():
+ return None
+ if not isinstance(expected, str) or not expected.strip():
+ return None
+ if len(instr) > 200 or len(expected) > 200:
+ return None
+ if self._contains_forbidden(instr) or self._contains_forbidden(expected):
+ return None
+ note = step.get('safety_note')
+ if note is not None and (not isinstance(note, str) or len(note) > 200):
+ return None
+ if note and self._contains_forbidden(note):
+ return None
+ cleaned_steps.append({
+ 'instruction': instr.strip(),
+ 'expected_result': expected.strip(),
+ 'safety_note': (note or '').strip() or None,
+ })
+ return {
+ 'escalate_immediately': data['escalate_immediately'],
+ 'escalation_reason': data.get('escalation_reason') or None,
+ 'confidence': confidence,
+ 'steps': cleaned_steps,
+ }
+
+ @api.model
+ def _contains_forbidden(self, text):
+ if not text:
+ return False
+ return any(p.search(text) for p in FORBIDDEN_PATTERNS)
+
+ # ------------------------------------------------------------------
+ # DETERMINISTIC FALLBACK
+ # ------------------------------------------------------------------
+ @api.model
+ def _normalise(self, text):
+ """Strip punctuation + lowercase so 'wont move' matches 'won't move'
+ and vice versa.
+
+ IMPORTANT: apostrophes are REMOVED (not replaced with space), so
+ "won't" -> "wont" matches user input "wont" (without apostrophe).
+ Other punctuation collapses to a single space.
+ """
+ s = (text or "").lower()
+ # Remove ALL apostrophe variants (straight + curly) so contraction
+ # forms collide with apostrophe-less forms.
+ for apos in ("'", "\u2019", "\u2018", "\u02bc"):
+ s = s.replace(apos, "")
+ # Everything else non-alphanumeric -> single space.
+ return re.sub(r"[^a-z0-9 ]+", " ", s)
+
+ @api.model
+ def _deterministic_fallback(self, category, symptoms):
+ """Look up fusion.repair.self.check.rule records for the category
+ and return the matching steps. Used when AI is unavailable or
+ returns invalid / unsafe content."""
+ Rule = self.env['fusion.repair.self.check.rule'].sudo()
+ steps = []
+ if category:
+ haystack = self._normalise(' '.join(symptoms))
+ rules = Rule.search([
+ ('category_id', '=', category.id),
+ ('active', '=', True),
+ ], order='sequence')
+ for r in rules:
+ kws = [
+ self._normalise(k)
+ for k in (r.symptom_keywords or '').split(',')
+ if k.strip()
+ ]
+ if not kws or any(kw and kw in haystack for kw in kws):
+ steps.append({
+ 'instruction': r.instruction or '',
+ 'expected_result': r.expected_result or '',
+ 'safety_note': r.safety_note or None,
+ })
+ if len(steps) >= 3:
+ break
+ return {
+ 'escalate_immediately': len(steps) == 0,
+ 'escalation_reason': None if steps else 'no_match',
+ 'confidence': 'medium' if steps else 'low',
+ 'steps': steps,
+ 'source': 'fallback',
+ 'disclaimer': self._disclaimer(),
+ }
+
+ # ------------------------------------------------------------------
+ # CACHE (in-memory per worker, 24h)
+ # ------------------------------------------------------------------
+ _CACHE = {}
+ _CACHE_TTL = 24 * 3600
+
+ @api.model
+ def _cache_key(self, category, symptoms):
+ symptom_hash = hashlib.sha256(
+ ('|'.join(sorted(s.lower() for s in symptoms))).encode()
+ ).hexdigest()[:16]
+ return f"{category.code if category else 'none'}:{symptom_hash}"
+
+ @api.model
+ def _cache_get(self, key):
+ import time
+ entry = self._CACHE.get(key)
+ if not entry:
+ return None
+ ts, value = entry
+ if time.time() - ts > self._CACHE_TTL:
+ self._CACHE.pop(key, None)
+ return None
+ return value
+
+ @api.model
+ def _cache_set(self, key, value):
+ import time
+ # Bound cache size to ~512 entries.
+ if len(self._CACHE) > 512:
+ self._CACHE.clear()
+ self._CACHE[key] = (time.time(), value)
+
+ # ------------------------------------------------------------------
+ # MISC
+ # ------------------------------------------------------------------
+ @api.model
+ def _disclaimer(self):
+ return ("This is not medical advice. If you're unsure, schedule a "
+ "technician visit. In an emergency, call 9-1-1.")
+
+ @api.model
+ def _log_incident(self, kind, raw):
+ _logger.warning('AI self-check incident (%s): %s', kind, (raw or '')[:300])
diff --git a/fusion_repairs/models/repair_callout_rate.py b/fusion_repairs/models/repair_callout_rate.py
new file mode 100644
index 00000000..3950bdd9
--- /dev/null
+++ b/fusion_repairs/models/repair_callout_rate.py
@@ -0,0 +1,178 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Service callout rate card.
+
+When we dispatch a tech to a client's home (as opposed to in-store work),
+this rate card answers "what do we charge?" for any combination of:
+
+ * tier (regular hours / after-hours / weekend / statutory holiday)
+ * number of technicians dispatched
+ * actual labour hours billable (after the 30 min the callout fee covers)
+ * round-trip travel kilometres beyond the threshold
+
+The Bundle 8 emergency surcharge sits ON TOP of this when CS flags a
+repair as a rush (same-day squeeze, etc.). They are separate concepts:
+ - callout rate = the BASELINE house-call price
+ - emergency surcharge = added "drop everything" premium
+"""
+
+from odoo import _, api, fields, models
+
+
+class FusionRepairCalloutRate(models.Model):
+ _name = 'fusion.repair.callout.rate'
+ _description = 'Service Callout Rate Card'
+ _order = 'effective_from desc, tier, id'
+
+ name = fields.Char(compute='_compute_name', store=True)
+ tier = fields.Selection(
+ [
+ ('regular', 'Regular Business Hours'),
+ ('rush', 'Rush Service'),
+ ('after_hours', 'After Hours (weekday evening)'),
+ ('weekend', 'Weekend'),
+ ('holiday', 'Statutory Holiday'),
+ ],
+ string='Tier',
+ required=True,
+ default='regular',
+ )
+
+ # Bundle 10: Westin's rate card splits by equipment class. Lift &
+ # Elevating Service ($160 callout / $110 labour) is distinct from
+ # Standard Service ($95 callout / $85 labour). The lookup falls back
+ # from (tier, equipment_class) to (tier, 'standard').
+ equipment_class = fields.Selection(
+ [
+ ('standard', 'Standard Service'),
+ ('lift_elevating', 'Lift & Elevating Service'),
+ ],
+ string='Equipment Class',
+ default='standard',
+ required=True,
+ )
+
+ # ---- Base callout (covers first 30 minutes of labour) ----
+ base_callout_fee = fields.Monetary(
+ string='Base Callout Fee (1 tech)',
+ currency_field='currency_id',
+ required=True,
+ default=0.0,
+ help='Charge for dispatching one technician to the client. INCLUDES '
+ 'the first 30 minutes for inspection / check / report. Repair '
+ 'labour above the 30 min is charged hourly at hourly_labor_rate.',
+ )
+ second_tech_fee = fields.Monetary(
+ string='Second Technician Fee',
+ currency_field='currency_id',
+ default=0.0,
+ help='Added to the callout when a 2nd technician is dispatched alongside '
+ 'the first. Lower than a second base callout because they share '
+ 'travel.',
+ )
+ additional_tech_fee = fields.Monetary(
+ string='Each Additional Technician Fee',
+ currency_field='currency_id',
+ default=0.0,
+ help='Applied to the 3rd, 4th... technician on the same callout. '
+ 'Defaults to second_tech_fee if left zero.',
+ )
+
+ # ---- Hourly labour (after the included 30 min) ----
+ hourly_labor_rate = fields.Monetary(
+ string='On-Site Hourly Labour Rate (per tech)',
+ currency_field='currency_id',
+ required=True,
+ default=0.0,
+ help='Per-technician hourly rate applied to billable labour above the '
+ '30 min the callout covers. Minimum bill is minimum_labor_hours '
+ 'even if the tech finished faster.',
+ )
+ # Bundle 10: separate IN-SHOP hourly rate. When the client brings the unit
+ # to the store (no callout, no travel) we charge a lower hourly rate.
+ in_shop_labor_rate = fields.Monetary(
+ string='In-Shop Hourly Labour Rate',
+ currency_field='currency_id',
+ default=0.0,
+ help='Hourly rate when work is done IN THE STORE (no callout fee, no '
+ 'travel). Per Westin rate card: $75 standard / $110 lift.',
+ )
+ minimum_labor_hours = fields.Float(
+ string='Minimum Billable Hours',
+ default=1.0,
+ help='Round-up floor for labour beyond the included 30 min. Set 1.0 '
+ 'so a 20-minute fix still bills 1.0 hours. Hours beyond the floor '
+ 'are pro-rated in 30-minute increments per the published card.',
+ )
+
+ # ---- Travel ----
+ travel_distance_threshold_km = fields.Float(
+ string='Free Travel Distance (km, one-way)',
+ default=25.0,
+ help='Travel under this distance is free. Beyond it, every additional '
+ 'kilometre is charged at travel_per_km_fee, BOTH WAYS (so the bill '
+ 'is per-km * (one_way_km - threshold) * 2).',
+ )
+ travel_per_km_fee = fields.Monetary(
+ string='Per-km Fee Over Threshold',
+ currency_field='currency_id',
+ default=0.0,
+ help='Per technician, per kilometre, both ways.',
+ )
+
+ # ---- Bookkeeping ----
+ effective_from = fields.Date(
+ string='Effective From',
+ default=fields.Date.context_today,
+ required=True,
+ help='Rate effective from this date. Newer rates supersede older ones '
+ 'for the same (tier, company).',
+ )
+ currency_id = fields.Many2one(
+ 'res.currency',
+ default=lambda self: self.env.company.currency_id,
+ )
+ company_id = fields.Many2one(
+ 'res.company',
+ default=lambda self: self.env.company,
+ )
+ active = fields.Boolean(default=True)
+ description = fields.Text(
+ help='Optional notes shown to CS / dispatchers - e.g. "applies after 5 PM weekdays".',
+ )
+
+ @api.depends('tier', 'base_callout_fee', 'effective_from')
+ def _compute_name(self):
+ for r in self:
+ tier_label = dict(self._fields['tier'].selection).get(r.tier) or '?'
+ r.name = (
+ f'{tier_label} - ${r.base_callout_fee:.0f} callout'
+ f' (from {r.effective_from})'
+ )
+
+ @api.model
+ def get_for_tier(self, tier, equipment_class='standard', on_date=None):
+ """Return the active rate row for `tier` + `equipment_class` effective
+ on `on_date`. Tries (tier, class) first, falls back to (tier, standard)
+ if no class-specific row is configured. Empty recordset if none at all.
+ """
+ on_date = on_date or fields.Date.context_today(self)
+ Domain = lambda cls: [
+ ('tier', '=', tier),
+ ('equipment_class', '=', cls),
+ ('active', '=', True),
+ ('effective_from', '<=', on_date),
+ ('company_id', 'in', self.env.companies.ids),
+ ]
+ hit = self.sudo().search(
+ Domain(equipment_class or 'standard'),
+ order='effective_from desc', limit=1,
+ )
+ if not hit and equipment_class and equipment_class != 'standard':
+ hit = self.sudo().search(
+ Domain('standard'),
+ order='effective_from desc', limit=1,
+ )
+ return hit
diff --git a/fusion_repairs/models/repair_dashboard.py b/fusion_repairs/models/repair_dashboard.py
new file mode 100644
index 00000000..bde7633e
--- /dev/null
+++ b/fusion_repairs/models/repair_dashboard.py
@@ -0,0 +1,189 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Repair dashboard data provider.
+
+Feeds the OWL client action `fusion_repairs.dashboard` with KPI counts,
+recent activity, and upcoming maintenance. Lives as an AbstractModel
+because it stores nothing - all values are computed on demand.
+"""
+
+from datetime import datetime, timedelta
+
+from odoo import api, fields, models
+
+
+class FusionRepairDashboard(models.AbstractModel):
+ _name = 'fusion.repair.dashboard'
+ _description = 'Repair Dashboard Data Provider'
+
+ @api.model
+ def get_dashboard_data(self):
+ """Return everything the dashboard needs in a single call."""
+ Repair = self.env['repair.order']
+ Contract = self.env['fusion.repair.maintenance.contract']
+ today = fields.Date.context_today(self)
+ month_start = today.replace(day=1)
+ thirty_days = today + timedelta(days=30)
+
+ # ---------------- KPI counters ----------------
+ open_domain = [('state', 'not in', ('done', 'cancel'))]
+ urgent_domain = open_domain + [('x_fc_urgency', 'in', ('urgent', 'safety'))]
+ new_this_month_domain = [('create_date', '>=', month_start)]
+ no_task_domain = open_domain + [
+ ('x_fc_technician_task_ids', '=', False),
+ ]
+ requote_domain = open_domain + [('x_fc_requires_requote', '=', True)]
+
+ stats = {
+ 'open_count': Repair.search_count(open_domain),
+ 'urgent_count': Repair.search_count(urgent_domain),
+ 'new_this_month': Repair.search_count(new_this_month_domain),
+ 'awaiting_dispatch': Repair.search_count(no_task_domain),
+ 'requires_requote': Repair.search_count(requote_domain),
+ 'maintenance_due_30d': Contract.search_count([
+ ('state', '=', 'active'),
+ ('next_due_date', '<=', thirty_days),
+ ]),
+ 'maintenance_active_total': Contract.search_count([
+ ('state', '=', 'active'),
+ ]),
+ }
+
+ # ---------------- Source breakdown for the doughnut ----------------
+ source_rows = Repair._read_group(
+ open_domain,
+ ['x_fc_intake_source'],
+ ['__count'],
+ )
+ source_breakdown = []
+ source_labels = dict(Repair._fields['x_fc_intake_source'].selection)
+ for src, count in source_rows:
+ source_breakdown.append({
+ 'key': src or 'manual',
+ 'label': source_labels.get(src or 'manual', src or 'Other'),
+ 'count': count,
+ })
+
+ # ---------------- Urgency breakdown ----------------
+ urgency_rows = Repair._read_group(
+ open_domain,
+ ['x_fc_urgency'],
+ ['__count'],
+ )
+ urgency_labels = dict(Repair._fields['x_fc_urgency'].selection)
+ urgency_breakdown = [{
+ 'key': u or 'normal',
+ 'label': urgency_labels.get(u or 'normal', 'Normal'),
+ 'count': c,
+ } for u, c in urgency_rows]
+
+ # ---------------- Recent service calls (last 5) ----------------
+ recent = []
+ for r in Repair.search([], order='create_date desc', limit=5):
+ recent.append({
+ 'id': r.id,
+ 'name': r.name,
+ 'partner_name': r.partner_id.name or '',
+ 'category': r.x_fc_repair_category_id.name or '',
+ 'urgency': r.x_fc_urgency,
+ 'state': r.state,
+ 'state_label': dict(Repair._fields['state'].selection).get(r.state, r.state),
+ 'create_date': fields.Datetime.to_string(r.create_date),
+ 'source': r.x_fc_intake_source or '',
+ 'source_label': source_labels.get(r.x_fc_intake_source, ''),
+ })
+
+ # ---------------- Upcoming maintenance (next 5 due) ----------------
+ upcoming = []
+ for c in Contract.search(
+ [('state', '=', 'active'), ('next_due_date', '!=', False)],
+ order='next_due_date asc', limit=5,
+ ):
+ upcoming.append({
+ 'id': c.id,
+ 'name': c.name,
+ 'partner_name': c.partner_id.name or '',
+ 'product_name': c.product_id.display_name or '',
+ 'next_due_date': fields.Date.to_string(c.next_due_date),
+ 'days_until': (c.next_due_date - today).days if c.next_due_date else 0,
+ 'reminder_band': c.last_reminder_band or '',
+ })
+
+ # ---------------- Portal URLs (resolved server-side) ----------------
+ ICP = self.env['ir.config_parameter'].sudo()
+ base_url = ICP.get_param('web.base.url', '').rstrip('/')
+ portals = {
+ 'client_portal_url': base_url + (ICP.get_param(
+ 'fusion_repairs.client_portal_url', '/repair'
+ ) or '/repair'),
+ 'sales_rep_portal_url': base_url + '/my/repair/new',
+ }
+
+ # ---------------- M7: failure-rate analytics ----------------
+ # Top products by repair count in the last 90 days (excludes draft).
+ ninety = datetime.now() - timedelta(days=90)
+ failure_rows = Repair._read_group(
+ [
+ ('create_date', '>=', ninety),
+ ('product_id', '!=', False),
+ ('state', '!=', 'cancel'),
+ ],
+ ['product_id'],
+ ['__count'],
+ order='__count desc',
+ limit=8,
+ )
+ failures_by_product = [{
+ 'product_id': p.id,
+ 'product_name': p.display_name,
+ 'repair_count': c,
+ } for p, c in failure_rows]
+
+ # Top symptom categories (issue_category) in the last 90 days.
+ symptom_rows = Repair._read_group(
+ [
+ ('create_date', '>=', ninety),
+ ('x_fc_issue_category', '!=', False),
+ ('state', '!=', 'cancel'),
+ ],
+ ['x_fc_issue_category'],
+ ['__count'],
+ order='__count desc',
+ limit=8,
+ )
+ failures_by_symptom = [{
+ 'symptom': s or 'Other',
+ 'repair_count': c,
+ } for s, c in symptom_rows]
+
+ # M9: margin summary (open + done in the last 90 days).
+ margin_rows = self.env['repair.order'].search([
+ ('create_date', '>=', ninety),
+ ('state', '!=', 'cancel'),
+ ])
+ total_revenue = sum(margin_rows.mapped('x_fc_revenue'))
+ total_labour = sum(margin_rows.mapped('x_fc_labour_cost'))
+ total_parts = sum(margin_rows.mapped('x_fc_parts_cost'))
+ total_margin = total_revenue - total_labour - total_parts
+ margin_summary = {
+ 'revenue': total_revenue,
+ 'labour_cost': total_labour,
+ 'parts_cost': total_parts,
+ 'margin': total_margin,
+ 'margin_pct': (total_margin / total_revenue * 100) if total_revenue else 0.0,
+ 'sample_size': len(margin_rows),
+ }
+
+ return {
+ 'stats': stats,
+ 'urgency_breakdown': urgency_breakdown,
+ 'source_breakdown': source_breakdown,
+ 'recent': recent,
+ 'upcoming': upcoming,
+ 'portals': portals,
+ 'failures_by_product': failures_by_product,
+ 'failures_by_symptom': failures_by_symptom,
+ 'margin_summary': margin_summary,
+ }
diff --git a/fusion_repairs/models/repair_delivery_charge.py b/fusion_repairs/models/repair_delivery_charge.py
new file mode 100644
index 00000000..066690ad
--- /dev/null
+++ b/fusion_repairs/models/repair_delivery_charge.py
@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Delivery / pickup rate card (separate from repair callouts).
+
+Per Westin's published rate card the DELIVERY / PICKUP CHARGES section is
+a distinct service from repair callouts. These are charged when we move
+equipment (drop-off of a sold unit, post-repair return delivery, removal
+of old equipment, etc.).
+"""
+
+from odoo import api, fields, models
+
+
+class FusionRepairDeliveryCharge(models.Model):
+ _name = 'fusion.repair.delivery.charge'
+ _description = 'Delivery / Pickup Rate Card'
+ _order = 'sequence, charge_type, id'
+
+ name = fields.Char(compute='_compute_name', store=True)
+ sequence = fields.Integer(default=10)
+ charge_type = fields.Selection(
+ [
+ ('local', 'Local Service Area'),
+ ('outside', 'Outside Local Area'),
+ ('rush', 'Rush Pickup / Delivery'),
+ ('lift_chair_install', 'Lift Chair Delivery and Set-Up'),
+ ('hospital_bed_install', 'Hospital Bed Delivery and Set-Up'),
+ ('stairlift_install', 'Stairlift Delivery and Set-Up'),
+ ('stairlift_removal', 'Stairlift Removal'),
+ ('other', 'Other'),
+ ],
+ string='Charge Type',
+ required=True,
+ )
+ amount = fields.Monetary(
+ string='Amount',
+ currency_field='currency_id',
+ required=True,
+ )
+ travel_per_km_fee = fields.Monetary(
+ string='Per-km Fee (Rush, 2-way)',
+ currency_field='currency_id',
+ default=0.0,
+ help='Only applies to rush pickups/deliveries. Per the published card: '
+ '$60 plus $0.70 per km x 2-way.',
+ )
+ travel_distance_threshold_km = fields.Float(
+ string='Free Travel Distance (km, 2-way)',
+ default=0.0,
+ help='Only applies to rush. Above this km, every additional km is '
+ 'charged travel_per_km_fee BOTH WAYS.',
+ )
+ description = fields.Text(translate=True)
+ currency_id = fields.Many2one(
+ 'res.currency',
+ default=lambda self: self.env.company.currency_id,
+ )
+ company_id = fields.Many2one(
+ 'res.company', default=lambda self: self.env.company,
+ )
+ active = fields.Boolean(default=True)
+
+ @api.depends('charge_type', 'amount')
+ def _compute_name(self):
+ for r in self:
+ label = dict(self._fields['charge_type'].selection).get(r.charge_type) or '?'
+ r.name = f'{label} - ${r.amount:.0f}'
+
+ @api.model
+ def get_charge(self, charge_type):
+ """Return the active rate row for `charge_type`, empty recordset if none."""
+ return self.sudo().search([
+ ('charge_type', '=', charge_type),
+ ('active', '=', True),
+ ('company_id', 'in', self.env.companies.ids),
+ ], limit=1)
+
+ @api.model
+ def quote_rush(self, distance_km):
+ """Convenience: returns the total for a Rush Pickup / Delivery at
+ `distance_km` one-way. Returns 0.0 if no rush row configured."""
+ rush = self.get_charge('rush')
+ if not rush:
+ return 0.0
+ over = max(distance_km - rush.travel_distance_threshold_km, 0.0)
+ return rush.amount + (over * 2.0 * rush.travel_per_km_fee)
diff --git a/fusion_repairs/models/repair_emergency_charge.py b/fusion_repairs/models/repair_emergency_charge.py
new file mode 100644
index 00000000..ddbc569f
--- /dev/null
+++ b/fusion_repairs/models/repair_emergency_charge.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Emergency / rush service rate card.
+
+The pissed-off-grumpy-client scenario: stairlift dead at 5 PM Friday, needs
+service yesterday. Office bumps them into today's route OR books them
+priority for tomorrow OR (if after-hours / weekend) charges an emergency
+surcharge. Sometimes more than one technician is needed (e.g. lifting an
+adjustable bed back onto its frame) - per_tech_multiplier handles that.
+
+Pricing logic on repair.order:
+
+ surcharge = base_amount + base_amount * per_tech_multiplier *
+ (techs_required - 1)
+
+Example: same-day stairlift, 1 tech, base $250, multiplier 0.5
+ -> $250 surcharge
+Example: same-day stairlift, 2 techs (one to hold, one to wrench)
+ -> $250 + $250 * 0.5 * 1 = $375 surcharge
+"""
+
+from odoo import _, api, fields, models
+
+
+class FusionRepairEmergencyCharge(models.Model):
+ _name = 'fusion.repair.emergency.charge'
+ _description = 'Rush / Emergency Service Surcharge Rate'
+ _order = 'category_id, tier'
+
+ name = fields.Char(
+ compute='_compute_name',
+ store=True,
+ )
+ category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Equipment Category',
+ required=True,
+ ondelete='cascade',
+ index=True,
+ )
+ tier = fields.Selection(
+ [
+ ('same_day', 'Same Day (during business hours)'),
+ ('next_day', 'Next Day Priority'),
+ ('after_hours', 'After Hours (5pm-9pm weekday)'),
+ ('weekend', 'Weekend'),
+ ('holiday', 'Statutory Holiday'),
+ ],
+ string='Tier',
+ required=True,
+ )
+ base_amount = fields.Monetary(
+ string='Base Surcharge',
+ currency_field='currency_id',
+ required=True,
+ default=0.0,
+ help='Surcharge for ONE technician on top of the normal labour / parts cost.',
+ )
+ per_tech_multiplier = fields.Float(
+ string='Additional Tech Multiplier',
+ default=0.5,
+ help='Each additional technician adds base_amount * this multiplier '
+ 'to the surcharge. Default 0.5 means tech #2 costs half the base.',
+ )
+ currency_id = fields.Many2one(
+ 'res.currency',
+ default=lambda self: self.env.company.currency_id,
+ )
+ company_id = fields.Many2one(
+ 'res.company',
+ default=lambda self: self.env.company,
+ )
+ active = fields.Boolean(default=True)
+ description = fields.Text(
+ help='Internal note - shown to CS when they pick this tier in the wizard.',
+ )
+
+ _cat_tier_unique = models.Constraint(
+ 'unique(category_id, tier, company_id)',
+ 'Only one emergency-charge row per (category, tier, company).',
+ )
+
+ @api.depends('category_id', 'tier', 'base_amount')
+ def _compute_name(self):
+ for r in self:
+ tier_label = dict(self._fields['tier'].selection).get(r.tier) or '?'
+ cat = r.category_id.name or '?'
+ r.name = f'{cat} - {tier_label} (${r.base_amount:.0f})'
+
+ @api.model
+ def calculate(self, category, tier, techs_required=1):
+ """Return the surcharge for the given category + tier + tech count,
+ or 0.0 if no rate is configured."""
+ if not category or not tier or techs_required < 1:
+ return 0.0
+ rate = self.sudo().search([
+ ('category_id', '=', category.id),
+ ('tier', '=', tier),
+ ('active', '=', True),
+ ('company_id', 'in', self.env.companies.ids),
+ ], limit=1)
+ if not rate:
+ return 0.0
+ extra = max(techs_required - 1, 0)
+ return rate.base_amount + (rate.base_amount * rate.per_tech_multiplier * extra)
diff --git a/fusion_repairs/models/repair_inspection.py b/fusion_repairs/models/repair_inspection.py
new file mode 100644
index 00000000..12e6e58e
--- /dev/null
+++ b/fusion_repairs/models/repair_inspection.py
@@ -0,0 +1,229 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Compliance inspection certificates (M1).
+
+Per the design spec section "Phase 4 - Compliance, claims, analytics":
+ Stairlifts / porch lifts need an annual safety inspection certificate
+ (jurisdictional requirement in many places). This model tracks issued
+ certificates, their expiry dates, and a daily cron warns the office +
+ client when one is approaching the 30-day expiry mark.
+
+A certificate is issued AFTER a successful inspection technician visit -
+the visit-report wizard's "Issue Compliance Certificate" button creates
+the record and renders a PDF.
+
+Phase 1 jurisdiction support: a single 'Ontario' jurisdiction text field
+on the certificate; future phases add per-jurisdiction PDF templates.
+"""
+
+from datetime import timedelta
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import _, api, fields, models
+
+
+class FusionRepairInspectionCertificate(models.Model):
+ _name = 'fusion.repair.inspection.certificate'
+ _inherit = ['mail.thread']
+ _description = 'Repair Inspection Certificate'
+ _order = 'issued_date desc, id desc'
+
+ name = fields.Char(
+ string='Certificate Number',
+ required=True,
+ default='New',
+ copy=False,
+ readonly=True,
+ tracking=True,
+ )
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string='Client',
+ required=True,
+ tracking=True,
+ index=True,
+ )
+ product_id = fields.Many2one(
+ 'product.product',
+ string='Equipment',
+ required=True,
+ domain="[('x_fc_repair_category_id.safety_critical', '=', True)]",
+ tracking=True,
+ )
+ lot_id = fields.Many2one(
+ 'stock.lot',
+ string='Serial Number',
+ tracking=True,
+ )
+ repair_order_id = fields.Many2one(
+ 'repair.order',
+ string='Inspection Repair',
+ help='The repair / technician task during which this inspection was done.',
+ ondelete='set null',
+ )
+ inspector_user_id = fields.Many2one(
+ 'res.users',
+ string='Inspector',
+ required=True,
+ default=lambda self: self.env.user,
+ tracking=True,
+ domain="[('x_fc_is_field_staff', '=', True)]",
+ )
+
+ jurisdiction = fields.Selection(
+ [
+ ('on', 'Ontario'),
+ ('bc', 'British Columbia'),
+ ('ab', 'Alberta'),
+ ('qc', 'Quebec'),
+ ('other', 'Other'),
+ ],
+ string='Jurisdiction',
+ default='on',
+ tracking=True,
+ )
+
+ issued_date = fields.Date(
+ string='Issued',
+ required=True,
+ default=fields.Date.context_today,
+ tracking=True,
+ )
+ valid_for_months = fields.Integer(
+ string='Valid For (Months)',
+ default=12,
+ required=True,
+ )
+ expiry_date = fields.Date(
+ string='Expires',
+ compute='_compute_expiry_date',
+ store=True,
+ tracking=True,
+ )
+
+ # Status compute (non-stored - time-dependent, per Bundle 1 C4 fix pattern).
+ status = fields.Selection(
+ [
+ ('valid', 'Valid'),
+ ('expiring', 'Expiring Soon'),
+ ('expired', 'Expired'),
+ ('revoked', 'Revoked'),
+ ],
+ string='Status',
+ compute='_compute_status',
+ )
+ revoked = fields.Boolean(
+ string='Revoked',
+ copy=False,
+ tracking=True,
+ )
+
+ notes = fields.Html(string='Inspector Notes')
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ default=lambda self: self.env.company,
+ )
+
+ # Reminder tracking (X2-style band markers so the cron doesn't spam).
+ last_reminder_band = fields.Selection(
+ [('30', '30 days'), ('7', '7 days')],
+ string='Last Reminder',
+ copy=False,
+ )
+
+ _certificate_number_unique = models.Constraint(
+ 'unique(name)',
+ 'Inspection certificate numbers must be unique.',
+ )
+
+ # ------------------------------------------------------------------
+ # CREATE
+ # ------------------------------------------------------------------
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get('name', 'New') == 'New':
+ vals['name'] = self.env['ir.sequence'].next_by_code(
+ 'fusion.repair.inspection.certificate'
+ ) or 'CERT/NEW'
+ return super().create(vals_list)
+
+ # ------------------------------------------------------------------
+ # COMPUTES
+ # ------------------------------------------------------------------
+ @api.depends('issued_date', 'valid_for_months')
+ def _compute_expiry_date(self):
+ for c in self:
+ if c.issued_date and c.valid_for_months:
+ c.expiry_date = c.issued_date + relativedelta(months=c.valid_for_months)
+ else:
+ c.expiry_date = False
+
+ def _compute_status(self):
+ today = fields.Date.context_today(self)
+ for c in self:
+ if c.revoked:
+ c.status = 'revoked'
+ elif not c.expiry_date:
+ c.status = 'valid'
+ elif c.expiry_date < today:
+ c.status = 'expired'
+ elif c.expiry_date <= today + timedelta(days=30):
+ c.status = 'expiring'
+ else:
+ c.status = 'valid'
+
+ # ------------------------------------------------------------------
+ # ACTIONS
+ # ------------------------------------------------------------------
+ def action_revoke(self):
+ for c in self:
+ c.revoked = True
+ c.message_post(body=_('Certificate revoked.'))
+
+ def action_print(self):
+ self.ensure_one()
+ return self.env.ref(
+ 'fusion_repairs.action_report_inspection_certificate'
+ ).report_action(self)
+
+ # ------------------------------------------------------------------
+ # CRON: warn the client 30 + 7 days before expiry
+ # ------------------------------------------------------------------
+ @api.model
+ def cron_send_expiry_reminders(self):
+ """Daily cron. Sends a reminder at the 30-day band, then again at
+ the 7-day band, so the client books their re-inspection visit
+ before the certificate lapses."""
+ Service = self.env.get('fusion.repair.intake.service')
+ if Service and not Service._notifications_enabled():
+ return
+ today = fields.Date.context_today(self)
+ tpl = self.env.ref(
+ 'fusion_repairs.email_template_inspection_expiry_reminder',
+ raise_if_not_found=False,
+ )
+ if not tpl:
+ return
+ for band_label, days in (('30', 30), ('7', 7)):
+ target = today + timedelta(days=days)
+ certs = self.search([
+ ('revoked', '=', False),
+ ('expiry_date', '=', target),
+ ('partner_id.email', '!=', False),
+ '|', ('last_reminder_band', '=', False),
+ ('last_reminder_band', '!=', band_label),
+ ])
+ for c in certs:
+ # Skip if a smaller band already sent (30 -> 7 progression).
+ if c.last_reminder_band and int(c.last_reminder_band) <= days:
+ continue
+ try:
+ tpl.send_mail(c.id, force_send=False)
+ c.last_reminder_band = band_label
+ except Exception:
+ continue
diff --git a/fusion_repairs/models/repair_labor_warranty.py b/fusion_repairs/models/repair_labor_warranty.py
new file mode 100644
index 00000000..d8b2cc99
--- /dev/null
+++ b/fusion_repairs/models/repair_labor_warranty.py
@@ -0,0 +1,231 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Store labor warranty.
+
+Distinct from the manufacturer warranty. This is what we extend at point
+of sale: "5-year labor warranty - bring it to the store, we fix the labour
+for free". Carve-outs (user negligence, gross negligence, misuse, etc.)
+are tracked explicitly so the visit-report wizard can VOID the warranty
+in real time when the tech encounters one.
+
+Important boundary - WHAT THE WARRANTY COVERS:
+ - In-store labour: FREE
+ - Home callout (tech dispatched): callout fee STILL applies (it includes
+ inspection / report); the hourly labour beyond 30 min is free
+ - Parts: NEVER free unless covered by separate manufacturer warranty
+ - Travel: ALWAYS charged when over the distance threshold
+"""
+
+from dateutil.relativedelta import relativedelta
+
+from markupsafe import Markup
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+
+VOID_REASONS = [
+ ('user_negligence', 'User Negligence'),
+ ('gross_negligence', 'Gross Negligence'),
+ ('misuse', 'Misuse'),
+ ('over_recommended_use', 'Over-Recommended Use'),
+ ('accidental_damage', 'Accidental Damage'),
+ ('not_covered_part', 'Part Not Covered'),
+ ('other', 'Other (see notes)'),
+]
+
+
+class FusionRepairLaborWarranty(models.Model):
+ _name = 'fusion.repair.labor.warranty'
+ _inherit = ['mail.thread']
+ _description = 'Store Labor Warranty'
+ _order = 'end_date desc, id desc'
+
+ name = fields.Char(
+ string='Reference',
+ default='New',
+ copy=False,
+ readonly=True,
+ tracking=True,
+ )
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string='Client',
+ required=True,
+ tracking=True,
+ index=True,
+ )
+ product_id = fields.Many2one(
+ 'product.product',
+ string='Equipment',
+ required=True,
+ tracking=True,
+ )
+ lot_id = fields.Many2one(
+ 'stock.lot',
+ string='Serial',
+ tracking=True,
+ )
+ sale_order_id = fields.Many2one(
+ 'sale.order',
+ string='Sold On',
+ ondelete='set null',
+ tracking=True,
+ )
+
+ warranty_years = fields.Integer(
+ string='Years',
+ default=5,
+ required=True,
+ tracking=True,
+ )
+ start_date = fields.Date(
+ string='Start',
+ default=fields.Date.context_today,
+ required=True,
+ tracking=True,
+ )
+ end_date = fields.Date(
+ string='Ends',
+ compute='_compute_end_date',
+ store=True,
+ tracking=True,
+ )
+
+ state = fields.Selection(
+ [
+ ('active', 'Active'),
+ ('expired', 'Expired'),
+ ('void', 'Void'),
+ ('consumed', 'Consumed'),
+ ],
+ string='Status',
+ default='active',
+ tracking=True,
+ compute='_compute_state',
+ store=True,
+ )
+
+ # When voided
+ void_reason = fields.Selection(
+ VOID_REASONS,
+ string='Void Reason',
+ tracking=True,
+ )
+ void_notes = fields.Text(string='Void Notes')
+ voided_at = fields.Datetime(string='Voided At', copy=False)
+ voided_by_id = fields.Many2one('res.users', string='Voided By', copy=False)
+
+ company_id = fields.Many2one(
+ 'res.company',
+ default=lambda self: self.env.company,
+ )
+
+ _name_unique = models.Constraint(
+ 'unique(name)',
+ 'Labor-warranty references must be unique.',
+ )
+
+ # ------------------------------------------------------------------
+ # CRUD
+ # ------------------------------------------------------------------
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get('name', 'New') == 'New':
+ vals['name'] = self.env['ir.sequence'].next_by_code(
+ 'fusion.repair.labor.warranty'
+ ) or 'LW/NEW'
+ return super().create(vals_list)
+
+ # ------------------------------------------------------------------
+ # COMPUTES
+ # ------------------------------------------------------------------
+ @api.depends('start_date', 'warranty_years')
+ def _compute_end_date(self):
+ for r in self:
+ if r.start_date and r.warranty_years:
+ r.end_date = r.start_date + relativedelta(years=r.warranty_years)
+ else:
+ r.end_date = False
+
+ @api.depends('void_reason', 'end_date')
+ def _compute_state(self):
+ today = fields.Date.context_today(self)
+ for r in self:
+ if r.state == 'consumed':
+ continue
+ if r.void_reason:
+ r.state = 'void'
+ elif r.end_date and r.end_date < today:
+ r.state = 'expired'
+ else:
+ r.state = 'active'
+
+ # ------------------------------------------------------------------
+ # LOOKUP
+ # ------------------------------------------------------------------
+ @api.model
+ def find_active_for(self, partner, product=None, lot=None):
+ """Find the active labor warranty covering (partner, product/lot).
+
+ Specificity order:
+ 1. exact lot match
+ 2. product + partner match
+ 3. partner-only match (last resort)
+ """
+ if not partner:
+ return self.browse()
+ today = fields.Date.context_today(self)
+ base_domain = [
+ ('partner_id', '=', partner.id),
+ ('state', '=', 'active'),
+ ('end_date', '>=', today),
+ ]
+ if lot:
+ hit = self.sudo().search(
+ base_domain + [('lot_id', '=', lot.id)],
+ order='end_date desc', limit=1,
+ )
+ if hit:
+ return hit
+ if product:
+ hit = self.sudo().search(
+ base_domain + [('product_id', '=', product.id)],
+ order='end_date desc', limit=1,
+ )
+ if hit:
+ return hit
+ return self.browse()
+
+ # ------------------------------------------------------------------
+ # VOID
+ # ------------------------------------------------------------------
+ def action_void(self, reason='other', notes=''):
+ if not reason:
+ raise UserError(_('A void reason is required.'))
+ for r in self:
+ r.write({
+ 'void_reason': reason,
+ 'void_notes': notes,
+ 'voided_at': fields.Datetime.now(),
+ 'voided_by_id': self.env.uid,
+ })
+ r.message_post(body=Markup(_(
+ 'Warranty voided by %(user)s. Reason: %(reason)s.'
+ )) % {
+ 'user': self.env.user.name,
+ 'reason': dict(VOID_REASONS).get(reason, reason),
+ })
+
+ def action_reinstate(self):
+ for r in self:
+ r.write({
+ 'void_reason': False,
+ 'void_notes': False,
+ 'voided_at': False,
+ 'voided_by_id': False,
+ })
+ r.message_post(body=_('Warranty reinstated.'))
diff --git a/fusion_repairs/models/repair_on_call_service.py b/fusion_repairs/models/repair_on_call_service.py
new file mode 100644
index 00000000..6cc133e5
--- /dev/null
+++ b/fusion_repairs/models/repair_on_call_service.py
@@ -0,0 +1,226 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""On-call service - finds the next on-call manager and pages them.
+
+Triggered when a safety-flagged repair comes in outside business hours
+(or any time, if the user wants to call us about a stuck stairlift).
+
+Per the design spec section "Weekend safety escalation":
+ 1. 911 disclaimer is shown to the client
+ 2. repair.order created with priority=high + Monday-followup activity
+ 3. Page next on-call manager (lowest x_fc_on_call_priority among
+ active users with x_fc_on_call=True)
+ 4. SMS + email sent; tokenized /repair/on-call/ack/ for ack
+ 5. 15-minute escalation cron pages next priority if first doesn't ack
+ 6. All actions logged to repair chatter
+
+Phase 2 ships with priority-int sorting; Phase 4 will replace with proper
+shift scheduling (date ranges per on-call user).
+"""
+
+import logging
+import secrets
+from datetime import timedelta
+
+from markupsafe import Markup
+
+from odoo import _, api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class FusionRepairOnCallService(models.AbstractModel):
+ _name = 'fusion.repair.on.call.service'
+ _description = 'Repair On-Call Paging Service'
+
+ # ------------------------------------------------------------------
+ # PUBLIC API
+ # ------------------------------------------------------------------
+ @api.model
+ def find_next_on_call(self, exclude_user_ids=None, company_id=None):
+ """Return the highest-priority active on-call user, or empty recordset.
+
+ Multi-company aware: when `company_id` is supplied, restricts to users
+ who belong to that company.
+ """
+ exclude_user_ids = exclude_user_ids or []
+ Users = self.env['res.users'].sudo()
+ domain = [
+ ('x_fc_on_call', '=', True),
+ ('active', '=', True),
+ ('id', 'not in', exclude_user_ids),
+ ]
+ if company_id:
+ domain.append(('company_ids', 'in', company_id))
+ return Users.search(
+ domain, order='x_fc_on_call_priority asc, id asc', limit=1,
+ )
+
+ @api.model
+ def page_on_call(self, repair, force=False):
+ """Page the next on-call manager for the given repair.
+
+ - Excludes anyone already acknowledged this cycle.
+ - Excludes the currently paged user (cron escalates to the NEXT priority).
+ - Skips during business hours unless force=True.
+ - Posts truthful chatter (different line on email send failure).
+ """
+ repair.ensure_one()
+ if not force and self._is_business_hours():
+ _logger.info('On-call page skipped for %s - inside business hours',
+ repair.name)
+ return self.env['res.users']
+
+ # CRITICAL: also exclude the currently-paged user so cron escalation
+ # actually moves to the NEXT priority instead of re-paging the same
+ # person forever.
+ exclude = set(repair.x_fc_on_call_acknowledged_user_ids.ids)
+ if repair.x_fc_on_call_paged_user_id:
+ exclude.add(repair.x_fc_on_call_paged_user_id.id)
+ target = self.find_next_on_call(
+ exclude_user_ids=list(exclude),
+ company_id=repair.company_id.id,
+ )
+ if not target:
+ self._notify_office_no_oncall(repair)
+ return self.env['res.users']
+
+ token = secrets.token_urlsafe(20)
+ repair.write({
+ 'x_fc_on_call_token': token,
+ 'x_fc_on_call_paged_user_id': target.id,
+ 'x_fc_on_call_paged_at': fields.Datetime.now(),
+ })
+
+ sent_ok = self._send_page_email(repair, target, token)
+ if sent_ok:
+ self._post_chatter(repair, target)
+ else:
+ # Truthful chatter when SMTP fails so the office can react.
+ repair.message_post(body=Markup(_(
+ 'Safety paged %(name)s but the page email failed to send. '
+ 'Verify SMTP and retry, or contact the on-call manager directly.'
+ )) % {'name': target.name or target.login or ''})
+ return target
+
+ @api.model
+ def acknowledge(self, repair, user):
+ """Mark a repair's on-call page as acknowledged by `user`."""
+ repair.ensure_one()
+ repair.x_fc_on_call_acknowledged_user_ids = [(4, user.id)]
+ repair.x_fc_on_call_acknowledged_at = fields.Datetime.now()
+ repair.message_post(body=Markup(_(
+ 'On-call page acknowledged by %s.'
+ )) % (user.name or user.login or ''))
+
+ @api.model
+ def cron_escalate_unacknowledged(self):
+ """Cron: re-page the next priority for any repair whose first page
+ is older than 15 minutes without acknowledgement."""
+ ICP = self.env['ir.config_parameter'].sudo()
+ try:
+ window_min = int(ICP.get_param(
+ 'fusion_repairs.on_call_escalate_minutes', '15'
+ ))
+ except (ValueError, TypeError):
+ window_min = 15
+ cutoff = fields.Datetime.now() - timedelta(minutes=window_min)
+ Repair = self.env['repair.order'].sudo()
+ stale = Repair.search([
+ ('x_fc_on_call_paged_at', '!=', False),
+ ('x_fc_on_call_paged_at', '<=', cutoff),
+ ('x_fc_on_call_acknowledged_at', '=', False),
+ ('state', 'not in', ('done', 'cancel')),
+ ])
+ # page_on_call now excludes the currently-paged user internally
+ # (see exclude set), so a plain call escalates to the next priority.
+ for r in stale:
+ self.page_on_call(r, force=True)
+
+ # ------------------------------------------------------------------
+ # HELPERS
+ # ------------------------------------------------------------------
+ @api.model
+ def _is_business_hours(self):
+ """True when within the company resource_calendar's working time."""
+ cal = self.env.company.resource_calendar_id
+ if not cal:
+ return False # Treat "no calendar" as always after-hours so we always page.
+ now = fields.Datetime.now()
+ try:
+ return bool(cal._work_intervals_batch(now, now)[False])
+ except Exception:
+ return False
+
+ @api.model
+ def _send_page_email(self, repair, target, token):
+ """Send the page email, return True on success, False on failure.
+
+ force_send=True because this is the single most time-critical email
+ in the module - mail queue latency would defeat the point.
+ """
+ try:
+ tpl = self.env.ref(
+ 'fusion_repairs.email_template_on_call_page',
+ raise_if_not_found=False,
+ )
+ if not tpl:
+ _logger.warning('On-call email template missing - cannot page %s', target.login)
+ return False
+ tpl.with_context(
+ on_call_token=token,
+ on_call_user=target,
+ ).send_mail(repair.id, force_send=True, email_values={
+ 'email_to': target.email or target.partner_id.email or '',
+ })
+ return True
+ except Exception as e:
+ _logger.warning('On-call page email failed for repair %s: %s',
+ repair.name, e)
+ return False
+
+ @api.model
+ def _post_chatter(self, repair, target):
+ repair.message_post(body=Markup(_(
+ 'After-hours safety paged %(name)s '
+ '(priority %(p)s). Awaiting acknowledgement.'
+ )) % {
+ 'name': target.name or target.login or '',
+ 'p': str(target.x_fc_on_call_priority or 99),
+ })
+
+ @api.model
+ def _notify_office_no_oncall(self, repair):
+ _logger.error(
+ 'No on-call user configured (x_fc_on_call=True) - safety repair '
+ '%s will queue for Monday with no page.',
+ repair.name,
+ )
+ repair.message_post(body=Markup(_(
+ 'WARNING: No on-call user '
+ 'configured. This safety repair was queued but no one was paged. '
+ 'Configure x_fc_on_call on a manager.'
+ )))
+ # Also send a real email to the company's office notification
+ # recipients so this doesn't get lost in chatter at 11 PM Saturday.
+ company_sudo = repair.company_id.sudo()
+ recipients = getattr(company_sudo, 'x_fc_office_notification_ids', False)
+ emails = [p.email for p in (recipients or []) if p.email]
+ if not emails:
+ return
+ try:
+ self.env['mail.mail'].sudo().create({
+ 'subject': '[CRITICAL] No on-call user configured - %s' % repair.name,
+ 'body_html': (
+ '
Safety repair %s was just submitted '
+ 'but no on-call user is configured '
+ '(x_fc_on_call=True). No one was paged.
'
+ '
Set the flag on at least one manager so the next '
+ 'after-hours safety call is paged.
'
+ ) % repair.name,
+ 'email_to': ','.join(emails),
+ }).send()
+ except Exception as e:
+ _logger.warning('Failed to send no-on-call office alert: %s', e)
diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py
new file mode 100644
index 00000000..80595374
--- /dev/null
+++ b/fusion_repairs/models/repair_order.py
@@ -0,0 +1,1217 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+import logging
+from datetime import date, datetime, timedelta
+
+from dateutil.relativedelta import relativedelta
+
+from markupsafe import Markup
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+INTAKE_SOURCES = [
+ ('backend_wizard', 'Backend Wizard (CS)'),
+ ('sales_rep_portal', 'Sales Rep Portal'),
+ ('client_portal', 'Client Self-Service'),
+ ('manual', 'Manual / Other'),
+]
+
+URGENCY_LEVELS = [
+ ('normal', 'Normal'),
+ ('urgent', 'Urgent'),
+ ('safety', 'Safety Issue'),
+]
+
+
+class RepairOrder(models.Model):
+ """Extend Odoo Repairs with intake context, dispatch link, warranty
+ determination, and pricing variance tracking for Fusion Repairs."""
+
+ _inherit = 'repair.order'
+
+ # ------------------------------------------------------------------
+ # CREATE - replace the picking-type default sequence with our
+ # date-based RO-YYYYMM-NN reference. We set vals['name'] BEFORE
+ # super() so Odoo's native create() (which only assigns the picking
+ # type sequence when name is empty or 'New') skips its own numbering.
+ # ------------------------------------------------------------------
+ @api.model_create_multi
+ def create(self, vals_list):
+ Sequence = self.env['ir.sequence'].sudo()
+ for vals in vals_list:
+ if not vals.get('name') or vals.get('name') == 'New':
+ next_name = Sequence.next_by_code('fusion.repair.order.monthly')
+ if next_name:
+ vals['name'] = next_name
+ return super().create(vals_list)
+
+ # ------------------------------------------------------------------
+ # INTAKE METADATA
+ # ------------------------------------------------------------------
+ x_fc_intake_source = fields.Selection(
+ INTAKE_SOURCES,
+ string='Intake Source',
+ default='manual',
+ tracking=True,
+ help='Which intake surface created this repair (backend CS wizard, '
+ 'sales rep portal, public client portal, or manual entry).',
+ )
+ x_fc_intake_user_id = fields.Many2one(
+ 'res.users',
+ string='Intake By',
+ tracking=True,
+ index=True,
+ help='User who took the call / submitted the intake. For client portal, '
+ 'this is the OdooBot or admin user.',
+ )
+ x_fc_intake_session_id = fields.Char(
+ string='Intake Session',
+ index=True,
+ copy=False,
+ help='Reference shared by multiple repair orders created during the same call.',
+ )
+ x_fc_intake_template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Intake Template',
+ help='Question template used during intake.',
+ )
+ x_fc_intake_answer_ids = fields.One2many(
+ 'fusion.repair.intake.answer',
+ 'repair_id',
+ string='Intake Answers',
+ )
+
+ # Catalogue match (Phase 2)
+ x_fc_service_catalog_id = fields.Many2one(
+ 'fusion.repair.service.catalog',
+ string='Service Catalogue Match',
+ index=True,
+ help='Auto-matched catalogue entry that pre-fills estimated cost and duration.',
+ )
+
+ # C6: quote-only flag (set when intake submitted in quote-only mode).
+ x_fc_is_quote_only = fields.Boolean(
+ string='Quote Only',
+ tracking=True,
+ index=True,
+ help='True when the intake was submitted in "Quote Only" mode - the '
+ 'office has not yet authorised dispatching a technician.',
+ )
+
+ # ------------------------------------------------------------------
+ # ON-CALL PAGING (CL15)
+ # Set when a safety repair is paged to the on-call manager. Allows
+ # ack and the 15-minute escalation cron to roll forward to the next
+ # priority if not acknowledged.
+ # ------------------------------------------------------------------
+ x_fc_on_call_token = fields.Char(
+ string='On-Call Ack Token',
+ copy=False,
+ index=True,
+ )
+ x_fc_on_call_paged_user_id = fields.Many2one(
+ 'res.users',
+ string='On-Call Paged User',
+ copy=False,
+ index=True,
+ )
+ x_fc_on_call_paged_at = fields.Datetime(
+ string='On-Call Paged At',
+ copy=False,
+ )
+ x_fc_on_call_acknowledged_user_ids = fields.Many2many(
+ 'res.users',
+ 'fusion_repair_on_call_ack_rel',
+ 'repair_id', 'user_id',
+ string='On-Call Acknowledgements',
+ copy=False,
+ )
+ x_fc_on_call_acknowledged_at = fields.Datetime(
+ string='Acknowledged At',
+ copy=False,
+ )
+
+ _on_call_token_unique = models.Constraint(
+ 'unique(x_fc_on_call_token)',
+ 'On-call acknowledgement tokens must be unique.',
+ )
+
+ # ------------------------------------------------------------------
+ # X4 + M3 - NPS sent flag + loaner offered flag + done-at stamp
+ # (X2 day-before flag now lives on fusion.technician.task per H1)
+ # ------------------------------------------------------------------
+ x_fc_nps_email_sent = fields.Boolean(
+ string='NPS Email Sent',
+ copy=False,
+ )
+ x_fc_done_at = fields.Datetime(
+ string='Closed At',
+ copy=False,
+ readonly=True,
+ help='Stamped when the repair first transitions to state=done. '
+ 'Drives the post-visit NPS cron (24h after close) without '
+ 'getting pushed forward by every subsequent chatter message.',
+ )
+ x_fc_loaner_offered = fields.Boolean(
+ string='Loaner Offered',
+ copy=False,
+ help='True once a loaner-offer activity has been posted for this '
+ 'long-running repair (M3). Avoids re-posting daily.',
+ )
+
+ # ------------------------------------------------------------------
+ # Bundle 8: RUSH / EMERGENCY SERVICE
+ # ------------------------------------------------------------------
+ x_fc_rush_requested = fields.Boolean(
+ string='Rush / Emergency Service',
+ tracking=True,
+ copy=False,
+ )
+ x_fc_rush_tier = fields.Selection(
+ [
+ ('same_day', 'Same Day'),
+ ('next_day', 'Next Day Priority'),
+ ('after_hours', 'After Hours'),
+ ('weekend', 'Weekend'),
+ ('holiday', 'Statutory Holiday'),
+ ],
+ string='Rush Tier',
+ tracking=True,
+ copy=False,
+ )
+ x_fc_rush_techs_required = fields.Integer(
+ string='Technicians Required',
+ default=1,
+ copy=False,
+ help='Some calls need 2+ techs (e.g. heavy lifting, controller programming '
+ 'plus mechanical). Surcharge scales accordingly.',
+ )
+ x_fc_rush_surcharge = fields.Monetary(
+ string='Rush Surcharge',
+ currency_field='company_currency_id',
+ compute='_compute_rush_surcharge',
+ store=True,
+ tracking=True,
+ )
+ x_fc_rush_acknowledged_at = fields.Datetime(
+ string='Rush Surcharge Acknowledged',
+ copy=False,
+ readonly=True,
+ help='Stamped when CS records that the client agreed to the rush price.',
+ )
+ x_fc_rush_acknowledged_by_id = fields.Many2one(
+ 'res.users',
+ string='Acknowledged By',
+ copy=False,
+ readonly=True,
+ help='The CS rep who got the verbal OK from the client.',
+ )
+
+ # ------------------------------------------------------------------
+ # Bundle 8: PARTS AWAITING (when tech can't fix on the first visit)
+ # ------------------------------------------------------------------
+ x_fc_parts_awaiting = fields.Boolean(
+ string='Awaiting Parts',
+ tracking=True,
+ copy=False,
+ index=True,
+ help='Tech could not complete the repair without ordering parts. '
+ 'Repair stays open; clears automatically when the last linked '
+ 'fusion.repair.part.order moves to "received".',
+ )
+ x_fc_parts_eta_date = fields.Date(
+ string='Parts ETA',
+ copy=False,
+ tracking=True,
+ )
+ x_fc_part_order_ids = fields.One2many(
+ 'fusion.repair.part.order',
+ 'repair_order_id',
+ string='Part Orders',
+ copy=False,
+ )
+ x_fc_part_order_count = fields.Integer(
+ compute='_compute_part_order_count',
+ string='# Part Orders',
+ )
+
+ # ------------------------------------------------------------------
+ # Bundle 9: SERVICE CALLOUT PRICING + LABOR WARRANTY
+ # ------------------------------------------------------------------
+ x_fc_callout_tier = fields.Selection(
+ [
+ ('regular', 'Regular Business Hours'),
+ ('rush', 'Rush Service'),
+ ('after_hours', 'After Hours'),
+ ('weekend', 'Weekend'),
+ ('holiday', 'Statutory Holiday'),
+ ],
+ string='Callout Tier',
+ default='regular',
+ tracking=True,
+ help='Which rate-card tier applies. Set by CS at intake; can be changed '
+ 'by dispatcher if the schedule moves into after-hours / weekend.',
+ )
+ x_fc_callout_distance_km = fields.Float(
+ string='One-Way Distance (km)',
+ tracking=True,
+ help='Distance from the shop to the client. Travel beyond the rate-card '
+ "threshold is billed BOTH WAYS at the rate's per-km fee.",
+ )
+ x_fc_callout_techs = fields.Integer(
+ string='Technicians on Callout',
+ default=1,
+ tracking=True,
+ )
+ x_fc_callout_labor_hours = fields.Float(
+ string='Billable Labor Hours',
+ default=0.0,
+ tracking=True,
+ help='Hours of repair work above the 30 min included in the callout fee. '
+ 'Billing applies the minimum_labor_hours floor from the rate card '
+ '(default 1.0) AND rounds up to the next 30-min increment - '
+ '20 minutes bills 1.0 h, 75 minutes bills 1.5 h.',
+ )
+ # Bundle 10: in-shop work uses a different (lower) hourly rate AND
+ # waives both the callout fee and the travel charge - client brought
+ # the unit to the store.
+ x_fc_in_shop = fields.Boolean(
+ string='In-Shop Repair',
+ tracking=True,
+ help='Work done in the store (no callout, no travel). Uses '
+ 'in_shop_labor_rate from the rate card.',
+ )
+ # Labor warranty link + status (resolved at visit time)
+ x_fc_labor_warranty_id = fields.Many2one(
+ 'fusion.repair.labor.warranty',
+ string='Store Labor Warranty',
+ tracking=True,
+ help='Auto-resolved when the visit-report wizard runs - links to the '
+ 'active store labor warranty for this client + product if any.',
+ )
+ x_fc_labor_warranty_status = fields.Selection(
+ [
+ ('not_checked', 'Not Yet Checked'),
+ ('eligible', 'Covered - Labor Free'),
+ ('not_covered', 'No Warranty on File'),
+ ('expired', 'Warranty Expired'),
+ ('void_misuse', 'Void - Misuse / Negligence'),
+ ('waived', 'Manually Waived'),
+ ],
+ string='Labor Warranty Status',
+ default='not_checked',
+ tracking=True,
+ )
+ # Manual labor-fee waiver (manager / sales rep only)
+ x_fc_labor_waived = fields.Boolean(
+ string='Labor Fee Waived',
+ tracking=True,
+ copy=False,
+ readonly=True,
+ )
+ x_fc_labor_waived_by_id = fields.Many2one(
+ 'res.users',
+ string='Labor Waived By',
+ tracking=True,
+ copy=False,
+ readonly=True,
+ )
+ x_fc_labor_waived_at = fields.Datetime(
+ string='Labor Waived At',
+ tracking=True,
+ copy=False,
+ readonly=True,
+ )
+ x_fc_labor_waived_reason = fields.Char(
+ string='Labor Waiver Reason',
+ copy=False,
+ )
+
+ # Computed quote breakdown (all non-stored - depend on the rate-card)
+ x_fc_quote_callout_base = fields.Monetary(
+ string='Base Callout Fee',
+ currency_field='company_currency_id',
+ compute='_compute_callout_quote',
+ )
+ x_fc_quote_extra_techs = fields.Monetary(
+ string='Extra Tech Fees',
+ currency_field='company_currency_id',
+ compute='_compute_callout_quote',
+ )
+ x_fc_quote_labor = fields.Monetary(
+ string='Labor Charge',
+ currency_field='company_currency_id',
+ compute='_compute_callout_quote',
+ )
+ x_fc_quote_travel = fields.Monetary(
+ string='Travel Charge',
+ currency_field='company_currency_id',
+ compute='_compute_callout_quote',
+ )
+ x_fc_quote_waived = fields.Monetary(
+ string='Less: Waived',
+ currency_field='company_currency_id',
+ compute='_compute_callout_quote',
+ )
+ x_fc_quote_total = fields.Monetary(
+ string='Quote Total (excl. parts)',
+ currency_field='company_currency_id',
+ compute='_compute_callout_quote',
+ store=True, # stored so we can show it on list views and search
+ )
+ x_fc_quote_breakdown_text = fields.Text(
+ string='Quote Breakdown',
+ compute='_compute_callout_quote',
+ help='Human-readable line-by-line breakdown - used in the quote email.',
+ )
+
+ @api.depends('x_fc_callout_tier', 'x_fc_callout_distance_km',
+ 'x_fc_callout_techs', 'x_fc_callout_labor_hours',
+ 'x_fc_labor_warranty_status', 'x_fc_labor_waived',
+ 'x_fc_in_shop', 'x_fc_repair_category_id')
+ def _compute_callout_quote(self):
+ import math
+ Rate = self.env['fusion.repair.callout.rate'].sudo()
+ for r in self:
+ tier = r.x_fc_callout_tier or 'regular'
+ cls = (r.x_fc_repair_category_id.equipment_class
+ or 'standard') if r.x_fc_repair_category_id else 'standard'
+ rate = Rate.get_for_tier(tier, equipment_class=cls)
+ techs = max(r.x_fc_callout_techs or 1, 1)
+ hours = max(r.x_fc_callout_labor_hours or 0.0, 0.0)
+ distance = r.x_fc_callout_distance_km or 0.0
+ in_shop = bool(r.x_fc_in_shop)
+
+ if in_shop:
+ # In-shop: no callout, no extra-tech, no travel. Only labour
+ # at the lower in_shop_labor_rate.
+ base = 0.0
+ extra_techs = 0.0
+ travel = 0.0
+ else:
+ base = rate.base_callout_fee if rate else 0.0
+ extra_techs = 0.0
+ if rate and techs >= 2 and rate.second_tech_fee:
+ extra_techs += rate.second_tech_fee
+ if rate and techs >= 3:
+ per_extra = rate.additional_tech_fee or rate.second_tech_fee or 0.0
+ extra_techs += per_extra * (techs - 2)
+ # Travel - both ways, per tech, for distance over threshold.
+ travel = 0.0
+ if rate:
+ over = max(distance - rate.travel_distance_threshold_km, 0.0)
+ travel = over * 2.0 * rate.travel_per_km_fee * techs
+
+ # Labour: per the published rate card -
+ # * minimum_labor_hours floor (default 1.0)
+ # * beyond the floor, pro-rated in 30-min increments
+ # (i.e. round UP to the next 0.5 h)
+ # * per tech (footnote: 'If multiple technicians are required,
+ # rates will apply per technician')
+ # * in-shop uses in_shop_labor_rate
+ labor = 0.0
+ if rate and hours > 0:
+ min_hours = rate.minimum_labor_hours or 1.0
+ # ceil(actual * 2) / 2 -> rounds up to next 0.5 increment
+ rounded_up = math.ceil(hours * 2) / 2.0
+ billable_h = max(rounded_up, min_hours)
+ hourly = (rate.in_shop_labor_rate
+ if in_shop else rate.hourly_labor_rate)
+ labor = billable_h * hourly * techs
+
+ waived = 0.0
+ if (r.x_fc_labor_warranty_status in ('eligible', 'waived')
+ or r.x_fc_labor_waived):
+ waived = labor
+
+ total = base + extra_techs + labor + travel - waived
+
+ r.x_fc_quote_callout_base = base
+ r.x_fc_quote_extra_techs = extra_techs
+ r.x_fc_quote_labor = labor
+ r.x_fc_quote_travel = travel
+ r.x_fc_quote_waived = waived
+ r.x_fc_quote_total = total
+
+ # Human-readable breakdown for chatter / quote emails.
+ class_label = 'Lift & Elevating' if cls == 'lift_elevating' else 'Standard'
+ hourly_used = (
+ (rate.in_shop_labor_rate if in_shop else rate.hourly_labor_rate)
+ if rate else 0.0
+ )
+ mode_label = 'IN-SHOP' if in_shop else f'on-site ({tier})'
+ lines = []
+ if not in_shop:
+ lines.append(f'Service Call ({class_label}, {tier}, incl. 30 min): ${base:.2f}')
+ if extra_techs:
+ lines.append(f'Additional technicians ({techs - 1}): ${extra_techs:.2f}')
+ if labor:
+ lines.append(
+ f'Labour {mode_label}: '
+ f'{hours:.2f} h actual -> billed '
+ f'{max(math.ceil(hours * 2) / 2.0, rate.minimum_labor_hours if rate else 1.0):.2f} h '
+ f'x {techs} tech x ${hourly_used:.2f}/h = ${labor:.2f}'
+ )
+ if travel:
+ over_km = max(distance - (rate.travel_distance_threshold_km if rate else 25), 0)
+ lines.append(
+ f'Travel: {distance:.1f} km, {over_km:.1f} km over threshold, '
+ f'both ways x {techs} tech x ${rate.travel_per_km_fee if rate else 0}/km = ${travel:.2f}'
+ )
+ if waived:
+ lines.append(f'Less labour waived: -${waived:.2f}')
+ lines.append('-' * 50)
+ lines.append(f'TOTAL (excl. parts): ${total:.2f}')
+ r.x_fc_quote_breakdown_text = '\n'.join(lines)
+
+ def action_check_labor_warranty(self):
+ """Look up the active store labor warranty for this repair's
+ partner + product. Updates x_fc_labor_warranty_id and
+ x_fc_labor_warranty_status. Called from the visit-report wizard
+ AND from the dashboard's "check warranty" button."""
+ Warr = self.env['fusion.repair.labor.warranty'].sudo()
+ for r in self:
+ w = Warr.find_active_for(
+ r.partner_id, r.product_id, r.lot_id or False,
+ )
+ r.x_fc_labor_warranty_id = w.id if w else False
+ if not w:
+ r.x_fc_labor_warranty_status = 'not_covered'
+ elif w.state == 'expired':
+ r.x_fc_labor_warranty_status = 'expired'
+ elif w.state == 'void':
+ r.x_fc_labor_warranty_status = 'void_misuse'
+ else:
+ r.x_fc_labor_warranty_status = 'eligible'
+
+ def action_waive_labor_fee(self):
+ """Manager / sales rep only. CS rep cannot waive."""
+ Group = self.env.ref
+ user = self.env.user
+ can_waive = (
+ user.has_group('fusion_repairs.group_fusion_repairs_manager')
+ or user.has_group('fusion_repairs.group_fusion_repairs_sales_rep')
+ )
+ if not can_waive:
+ raise UserError(_(
+ 'Only Repairs Managers and Sales Reps can waive the labor fee. '
+ 'CS staff must escalate to a manager.'
+ ))
+ for r in self:
+ r.write({
+ 'x_fc_labor_waived': True,
+ 'x_fc_labor_waived_by_id': user.id,
+ 'x_fc_labor_waived_at': fields.Datetime.now(),
+ 'x_fc_labor_warranty_status': 'waived',
+ })
+ r.message_post(body=Markup(_(
+ 'Labor fee waived by %(user)s. (Reason: %(reason)s)'
+ )) % {
+ 'user': user.name,
+ 'reason': r.x_fc_labor_waived_reason or 'goodwill',
+ })
+
+ @api.depends('x_fc_rush_tier', 'x_fc_rush_techs_required',
+ 'x_fc_repair_category_id')
+ def _compute_rush_surcharge(self):
+ Rates = self.env['fusion.repair.emergency.charge'].sudo()
+ for r in self:
+ if not r.x_fc_rush_tier or not r.x_fc_repair_category_id:
+ r.x_fc_rush_surcharge = 0.0
+ continue
+ r.x_fc_rush_surcharge = Rates.calculate(
+ r.x_fc_repair_category_id,
+ r.x_fc_rush_tier,
+ r.x_fc_rush_techs_required or 1,
+ )
+
+ @api.depends('x_fc_part_order_ids')
+ def _compute_part_order_count(self):
+ for r in self:
+ r.x_fc_part_order_count = len(r.x_fc_part_order_ids)
+
+ def action_view_part_orders(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Part Orders'),
+ 'res_model': 'fusion.repair.part.order',
+ 'view_mode': 'list,form',
+ 'domain': [('repair_order_id', '=', self.id)],
+ 'context': {'default_repair_order_id': self.id},
+ }
+
+ def action_acknowledge_rush(self):
+ """CS clicks this AFTER getting verbal OK from the client on the rush price."""
+ for r in self:
+ r.x_fc_rush_acknowledged_at = fields.Datetime.now()
+ r.x_fc_rush_acknowledged_by_id = self.env.user
+ r.message_post(body=Markup(_(
+ 'Rush surcharge of %(amt).2f acknowledged by client '
+ '(verbal OK to %(rep)s).'
+ )) % {
+ 'amt': r.x_fc_rush_surcharge,
+ 'rep': self.env.user.name,
+ })
+
+ def action_squeeze_into_today(self):
+ """Squeeze this repair into a field tech's existing route today.
+
+ Picks the lightest-loaded skilled tech, finds the first free 1-hour
+ slot in their day, creates / updates the dispatch task to that slot,
+ and pushes a live bus.bus notification + email so the tech knows
+ mid-shift.
+ """
+ from datetime import date as _date
+ today = _date.today()
+ Task = self.env['fusion.technician.task'].sudo()
+ for r in self:
+ tech_id = self._fc_find_lightest_today_tech()
+ if not tech_id:
+ raise UserError(_(
+ 'No field-staff users available - mark someone as Field '
+ 'Staff under Settings > Users and try again.'
+ ))
+ slot_start, slot_end = self._fc_find_free_slot_today(tech_id)
+ if slot_start is None:
+ raise UserError(_(
+ "%s has no free hour left today. Either bump an existing "
+ "task or schedule for tomorrow instead."
+ ) % self.env['res.users'].sudo().browse(tech_id).name)
+ existing = r.x_fc_technician_task_ids.filtered(
+ lambda t: t.status not in ('completed', 'cancelled')
+ )
+ vals = {
+ 'technician_id': tech_id,
+ 'scheduled_date': today,
+ 'time_start': slot_start,
+ 'time_end': slot_end,
+ }
+ if existing:
+ task = existing[0]
+ task.write(vals)
+ else:
+ self.env['fusion.repair.intake.service'].sudo() \
+ .with_context(
+ force_tech_id=tech_id,
+ force_schedule={
+ 'scheduled_date': today,
+ 'time_start': slot_start,
+ 'time_end': slot_end,
+ },
+ ) \
+ ._create_dispatch_task(r)
+ task = r.x_fc_technician_task_ids[:1]
+ self._notify_tech_of_rush(task)
+ r.message_post(body=Markup(_(
+ 'Squeezed into %(name)s\'s route today at '
+ '%(start).0f:00 - %(end).0f:00; tech notified.'
+ )) % {
+ 'name': task.technician_id.name or '?',
+ 'start': slot_start,
+ 'end': slot_end,
+ })
+
+ def _fc_find_free_slot_today(self, tech_id):
+ """Return (start_float, end_float) for the first free 1-hour window
+ in this tech's day between 9 AM and 6 PM, or (None, None)."""
+ from datetime import date as _date
+ today = _date.today()
+ Task = self.env['fusion.technician.task'].sudo()
+ existing = Task.search([
+ ('technician_id', '=', tech_id),
+ ('scheduled_date', '=', today),
+ ('status', 'not in', ('completed', 'cancelled')),
+ ])
+ # Build a set of busy hours (rounded down to integer hours).
+ busy = set()
+ for t in existing:
+ s = int(t.time_start or 0)
+ e = int(t.time_end or s + 1)
+ for h in range(s, max(s + 1, e)):
+ busy.add(h)
+ # Scan 9 AM - 5 PM (last slot is 17:00-18:00 inclusive).
+ for hour in range(9, 18):
+ if hour not in busy:
+ return float(hour), float(hour + 1)
+ return None, None
+
+ def _fc_find_lightest_today_tech(self):
+ """Return the field-staff user with the fewest scheduled tasks today.
+
+ Honors skills filter if this repair has a category.
+ """
+ Users = self.env['res.users'].sudo()
+ Task = self.env['fusion.technician.task'].sudo()
+ from datetime import date as _date
+ today = _date.today()
+ domain = [
+ ('x_fc_is_field_staff', '=', True),
+ ('active', '=', True),
+ ]
+ if self.x_fc_repair_category_id:
+ domain.append(
+ ('x_fc_repair_skills', 'in', [self.x_fc_repair_category_id.id])
+ )
+ candidates = Users.search(domain)
+ if not candidates:
+ # Fallback: any active field staff (skills filter relaxed).
+ candidates = Users.search([
+ ('x_fc_is_field_staff', '=', True),
+ ('active', '=', True),
+ ])
+ if not candidates:
+ return False
+ # Pick the one with the fewest scheduled tasks today.
+ ranked = []
+ for u in candidates:
+ count = Task.search_count([
+ ('technician_id', '=', u.id),
+ ('scheduled_date', '=', today),
+ ('status', 'not in', ('completed', 'cancelled')),
+ ])
+ ranked.append((count, u.id))
+ ranked.sort()
+ return ranked[0][1]
+
+ def _notify_tech_of_rush(self, task):
+ """Send a real-time bus push + email so the tech sees it mid-shift."""
+ for r in self:
+ tech = task.technician_id
+ if not tech:
+ continue
+ # 1) bus.bus live push (shows as a sticky in-app notification).
+ try:
+ # bus.bus.sendone goes to a specific user channel; the
+ # web client displays it via the simple_notification service.
+ self.env['bus.bus']._sendone(
+ tech.partner_id,
+ 'simple_notification',
+ {
+ 'type': 'warning',
+ 'title': _('RUSH service added to your route'),
+ 'message': (_('Stairlift / urgent stop at %(client)s. '
+ 'Repair %(name)s. See your tasks.') % {
+ 'client': r.partner_id.name or '',
+ 'name': r.name,
+ }),
+ 'sticky': True,
+ },
+ )
+ except Exception:
+ _logger.warning('bus.bus push failed for tech %s', tech.login)
+ # 2) email (matters if the tech is offline at the moment of squeeze).
+ tpl = self.env.ref(
+ 'fusion_repairs.email_template_rush_tech_alert',
+ raise_if_not_found=False,
+ )
+ if tpl:
+ try:
+ tpl.with_context(tech_email=tech.email or tech.partner_id.email or '') \
+ .send_mail(r.id, force_send=True, email_values={
+ 'email_to': tech.email or tech.partner_id.email or '',
+ })
+ except Exception:
+ _logger.warning('Rush-alert email failed for repair %s', r.name)
+ # 3) chatter on the task itself so the tech sees it inline.
+ task.message_post(body=Markup(_(
+ 'RUSH ADDED to your day: %(client)s - %(name)s. '
+ 'Office squeezed it in.'
+ )) % {
+ 'client': r.partner_id.name or '?',
+ 'name': r.name,
+ })
+
+ # ------------------------------------------------------------------
+ # M9 - Margin per repair (revenue - labour cost - parts cost)
+ # All non-stored computes; surfaced in the M7 analytics dashboard.
+ # ------------------------------------------------------------------
+ x_fc_revenue = fields.Monetary(
+ string='Revenue',
+ currency_field='company_currency_id',
+ compute='_compute_margin',
+ help='Sum of posted invoice totals for the repair sale order.',
+ )
+ x_fc_labour_cost = fields.Monetary(
+ string='Labour Cost',
+ currency_field='company_currency_id',
+ compute='_compute_margin',
+ help='Sum of (hours x technician cost rate) over all completed visits.',
+ )
+ x_fc_parts_cost = fields.Monetary(
+ string='Parts Cost',
+ currency_field='company_currency_id',
+ compute='_compute_margin',
+ help='Sum of standard_price for parts consumed via repair operations.',
+ )
+ x_fc_margin = fields.Monetary(
+ string='Margin',
+ currency_field='company_currency_id',
+ compute='_compute_margin',
+ help='Revenue - labour cost - parts cost.',
+ )
+ x_fc_margin_pct = fields.Float(
+ string='Margin %',
+ compute='_compute_margin',
+ )
+
+ def _compute_margin(self):
+ for r in self:
+ revenue = 0.0
+ if r.sale_order_id and hasattr(r.sale_order_id, 'invoice_ids'):
+ for inv in r.sale_order_id.invoice_ids.filtered(
+ lambda m: m.state == 'posted' and m.move_type == 'out_invoice'
+ ):
+ revenue += inv.amount_untaxed or 0.0
+ labour = 0.0
+ for task in r.x_fc_technician_task_ids:
+ if task.status != 'completed':
+ continue
+ rate = task.technician_id.x_fc_tech_cost_rate or 0.0
+ labour += (task.duration_hours or 0.0) * rate
+ parts = 0.0
+ for move in r.move_ids.filtered(lambda m: m.repair_line_type == 'add'):
+ parts += (move.product_id.standard_price or 0.0) * (move.product_uom_qty or 0.0)
+ r.x_fc_revenue = revenue
+ r.x_fc_labour_cost = labour
+ r.x_fc_parts_cost = parts
+ margin = revenue - labour - parts
+ r.x_fc_margin = margin
+ r.x_fc_margin_pct = (margin / revenue * 100) if revenue else 0.0
+
+ def write(self, vals):
+ # H2: stamp x_fc_done_at the first time state transitions to 'done'
+ # so the NPS cron has a stable timestamp (write_date moves on every
+ # chatter / invoice / attachment write).
+ if vals.get('state') == 'done':
+ for r in self:
+ if r.state != 'done' and not r.x_fc_done_at:
+ vals = dict(vals)
+ vals['x_fc_done_at'] = fields.Datetime.now()
+ break
+ return super().write(vals)
+
+ # ------------------------------------------------------------------
+ # X2 / X4 / M3 crons
+ # ------------------------------------------------------------------
+ @api.model
+ def cron_send_day_before_reminders(self):
+ """X2: email the client the day before each scheduled tech visit.
+
+ Per-TASK flag (not per-repair) so multi-visit repairs get a
+ separate reminder for each individual visit.
+ """
+ if not self._notifications_enabled():
+ return
+ tomorrow = date.today() + timedelta(days=1)
+ Task = self.env['fusion.technician.task'].sudo()
+ tasks = Task.search([
+ ('scheduled_date', '=', tomorrow),
+ ('x_fc_day_before_reminder_sent', '=', False),
+ ('x_fc_repair_order_id', '!=', False),
+ ('x_fc_repair_order_id.state', 'in', ('confirmed', 'under_repair')),
+ ])
+ tpl = self.env.ref(
+ 'fusion_repairs.email_template_visit_day_before',
+ raise_if_not_found=False,
+ )
+ if not tpl:
+ _logger.warning('X2 day-before cron: email template missing')
+ return
+ for task in tasks:
+ repair = task.x_fc_repair_order_id
+ if not repair.partner_id or not repair.partner_id.email:
+ task.x_fc_day_before_reminder_sent = True # don't keep retrying
+ continue
+ try:
+ # Pass the specific task via context so the template renders
+ # the right scheduled date / technician (H3).
+ tpl.with_context(reminder_task_id=task.id) \
+ .send_mail(repair.id, force_send=False)
+ except Exception:
+ _logger.exception('X2 day-before reminder failed for task %s', task.name)
+ # Still set the flag - the task's "tomorrow" is gone after midnight
+ # so retrying tomorrow would email about the wrong date.
+ task.x_fc_day_before_reminder_sent = True
+
+ @api.model
+ def cron_send_post_visit_nps(self):
+ """X4: send NPS / Google review email 24h after state=done.
+
+ Uses x_fc_done_at (H2) so chatter writes don't push the timestamp
+ forward.
+ """
+ if not self._notifications_enabled():
+ return
+ cutoff = datetime.now() - timedelta(hours=24)
+ repairs = self.search([
+ ('state', '=', 'done'),
+ ('x_fc_nps_email_sent', '=', False),
+ ('x_fc_done_at', '!=', False),
+ ('x_fc_done_at', '<=', cutoff),
+ ])
+ tpl = self.env.ref(
+ 'fusion_repairs.email_template_post_visit_nps',
+ raise_if_not_found=False,
+ )
+ if not tpl:
+ _logger.warning('X4 NPS cron: email template missing')
+ return
+ for r in repairs:
+ if not r.partner_id or not r.partner_id.email:
+ r.x_fc_nps_email_sent = True # don't keep retrying
+ continue
+ try:
+ tpl.send_mail(r.id, force_send=False)
+ except Exception:
+ _logger.exception('X4 NPS email failed for repair %s', r.name)
+ r.x_fc_nps_email_sent = True
+
+ @api.model
+ def cron_offer_loaner_for_long_repairs(self):
+ """M3: post an Offer-Loaner activity when a confirmed/in-repair
+ order has been waiting longer than threshold days.
+
+ Soft-depends on fusion_loaners_management - silently no-ops when
+ the loaner model isn't installed. Uses schedule_date (or create_date
+ as fallback) so quote-only / draft repairs aren't bothered.
+ """
+ if 'fusion.loaner.checkout' not in self.env:
+ return
+ ICP = self.env['ir.config_parameter'].sudo()
+ try:
+ threshold = int(ICP.get_param(
+ 'fusion_repairs.loaner_offer_threshold_days', '3'
+ ))
+ except (ValueError, TypeError):
+ threshold = 3
+ cutoff = datetime.now() - timedelta(days=threshold)
+ activity_type = self.env.ref(
+ 'fusion_repairs.mail_activity_type_loaner_offer',
+ raise_if_not_found=False,
+ )
+ if not activity_type:
+ _logger.warning('M3 loaner cron: activity type missing, skipping')
+ return
+ repairs = self.search([
+ ('state', 'in', ('confirmed', 'under_repair')),
+ ('x_fc_is_quote_only', '=', False),
+ ('x_fc_loaner_offered', '=', False),
+ '|',
+ '&', ('schedule_date', '!=', False), ('schedule_date', '<=', cutoff),
+ '&', ('schedule_date', '=', False), ('create_date', '<=', cutoff),
+ ], limit=200, order='create_date desc')
+ for r in repairs:
+ try:
+ r.activity_schedule(
+ activity_type_id=activity_type.id,
+ summary='Offer a loaner unit',
+ note=(
+ 'This repair has been waiting more than %s days. '
+ 'Consider offering the client a loaner unit while we '
+ 'complete the repair.'
+ ) % threshold,
+ user_id=r.user_id.id or self.env.uid,
+ )
+ r.x_fc_loaner_offered = True
+ except Exception:
+ _logger.exception(
+ 'M3 loaner cron: activity_schedule failed for repair %s',
+ r.name,
+ )
+
+ @api.model
+ def _notifications_enabled(self):
+ # Delegate to the shared intake-service single source of truth (M2).
+ Service = self.env.get('fusion.repair.intake.service')
+ if Service:
+ return Service._notifications_enabled()
+ return self.env['ir.config_parameter'].sudo().get_param(
+ 'fusion_repairs.enable_email_notifications', 'True'
+ ) == 'True'
+
+ def action_offer_loaner(self):
+ """Open the fusion_loaners_management checkout wizard pre-filled
+ with this repair's partner. Soft-link - raises if the module is
+ not installed."""
+ self.ensure_one()
+ if 'fusion.loaner.checkout' not in self.env:
+ raise UserError(_(
+ 'Loaner management is not installed. Install '
+ 'fusion_loaners_management to enable this feature.'
+ ))
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Offer Loaner'),
+ 'res_model': 'fusion.loaner.checkout',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'default_partner_id': self.partner_id.id,
+ 'default_sale_order_id': self.sale_order_id.id or False,
+ },
+ }
+
+ # Maintenance contract back-link (Phase 3)
+ x_fc_maintenance_contract_id = fields.Many2one(
+ 'fusion.repair.maintenance.contract',
+ string='Maintenance Contract',
+ index=True,
+ help='Set when this repair was spawned from a maintenance reminder booking. '
+ 'Completing the related technician task rolls the contract to its next cycle.',
+ )
+ x_fc_intake_answer_count = fields.Integer(
+ compute='_compute_intake_answer_count',
+ )
+
+ # ------------------------------------------------------------------
+ # EQUIPMENT / WARRANTY
+ # ------------------------------------------------------------------
+ x_fc_repair_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Equipment Category',
+ tracking=True,
+ index=True,
+ help='Medical equipment category - drives intake template and tech skills filter.',
+ )
+ x_fc_third_party_equipment = fields.Boolean(
+ string='Third-Party Equipment',
+ tracking=True,
+ help='True if the equipment was not sold by us. Forces under_warranty=False '
+ 'and typically triggers a service call-out fee.',
+ )
+ x_fc_original_sale_order_id = fields.Many2one(
+ 'sale.order',
+ string='Original Purchase SO',
+ tracking=True,
+ index=True,
+ help='Sale order through which the customer originally purchased this unit. '
+ 'Auto-matched on intake by partner + lot/serial.',
+ )
+ x_fc_warranty_override_reason = fields.Char(
+ string='Warranty Override Reason',
+ help='Required when CS overrides the auto-detected warranty status.',
+ )
+
+ # ------------------------------------------------------------------
+ # TRIAGE / URGENCY
+ # ------------------------------------------------------------------
+ x_fc_urgency = fields.Selection(
+ URGENCY_LEVELS,
+ string='Urgency',
+ default='normal',
+ tracking=True,
+ index=True,
+ )
+ x_fc_issue_category = fields.Char(
+ string='Issue Category',
+ help='Symptom classification (e.g. "battery", "motor", "remote"). Used by '
+ 'service catalogue matcher and AI prompt context.',
+ )
+
+ # ------------------------------------------------------------------
+ # PHOTOS
+ # ------------------------------------------------------------------
+ x_fc_photo_ids = fields.Many2many(
+ 'ir.attachment',
+ 'fusion_repair_order_photo_rel',
+ 'repair_id',
+ 'attachment_id',
+ string='Intake Photos / Videos',
+ help='Photos and videos uploaded during intake.',
+ )
+ x_fc_photo_count = fields.Integer(
+ compute='_compute_photo_count',
+ )
+
+ # ------------------------------------------------------------------
+ # PRICING (estimate vs actual - Phase 2 reconciliation)
+ # ------------------------------------------------------------------
+ x_fc_estimated_duration = fields.Float(
+ string='Estimated Duration (h)',
+ help='Estimated visit duration from service catalogue, used to size technician slot.',
+ )
+ x_fc_estimated_cost = fields.Monetary(
+ string='Estimated Cost',
+ currency_field='company_currency_id',
+ help='Estimated total from catalogue match at intake (pre-visit).',
+ )
+ x_fc_actual_cost = fields.Monetary(
+ string='Actual Cost',
+ currency_field='company_currency_id',
+ help='Actual total recorded from the visit report (post-visit).',
+ )
+ x_fc_cost_variance_pct = fields.Float(
+ string='Cost Variance %',
+ compute='_compute_cost_variance',
+ store=True,
+ help='(actual - estimated) / estimated * 100',
+ )
+ x_fc_requires_requote = fields.Boolean(
+ string='Requires Re-Quote',
+ help='Set when actual cost exceeds estimate beyond the configured threshold; '
+ 'blocks automatic invoicing until manager approves or client re-confirms.',
+ )
+
+ company_currency_id = fields.Many2one(
+ 'res.currency',
+ related='company_id.currency_id',
+ readonly=True,
+ )
+
+ # ------------------------------------------------------------------
+ # FIELD SERVICE LINK
+ # ------------------------------------------------------------------
+ x_fc_technician_task_ids = fields.One2many(
+ 'fusion.technician.task',
+ 'x_fc_repair_order_id',
+ string='Technician Tasks',
+ )
+ x_fc_technician_task_count = fields.Integer(
+ compute='_compute_technician_task_count',
+ )
+
+ # ------------------------------------------------------------------
+ # AI SUMMARY (Phase 2)
+ # ------------------------------------------------------------------
+ x_fc_ai_summary = fields.Text(
+ string='AI Pre-Visit Brief',
+ help='AI-generated short brief for the technician based on intake answers. '
+ 'Optional - never blocks intake submit.',
+ )
+
+ # ------------------------------------------------------------------
+ # COMPUTES
+ # ------------------------------------------------------------------
+ @api.depends('x_fc_intake_answer_ids')
+ def _compute_intake_answer_count(self):
+ for repair in self:
+ repair.x_fc_intake_answer_count = len(repair.x_fc_intake_answer_ids)
+
+ @api.depends('x_fc_photo_ids')
+ def _compute_photo_count(self):
+ for repair in self:
+ repair.x_fc_photo_count = len(repair.x_fc_photo_ids)
+
+ @api.depends('x_fc_technician_task_ids')
+ def _compute_technician_task_count(self):
+ for repair in self:
+ repair.x_fc_technician_task_count = len(repair.x_fc_technician_task_ids)
+
+ @api.depends('x_fc_estimated_cost', 'x_fc_actual_cost')
+ def _compute_cost_variance(self):
+ for repair in self:
+ if repair.x_fc_estimated_cost:
+ repair.x_fc_cost_variance_pct = (
+ (repair.x_fc_actual_cost - repair.x_fc_estimated_cost)
+ / repair.x_fc_estimated_cost * 100
+ )
+ else:
+ repair.x_fc_cost_variance_pct = 0.0
+
+ # ------------------------------------------------------------------
+ # WARRANTY DETERMINATION
+ # ------------------------------------------------------------------
+ def _fc_compute_warranty_status(self):
+ """Auto-detect warranty: not third-party AND within warranty window."""
+ self.ensure_one()
+ if self.x_fc_third_party_equipment:
+ return False
+ if not self.x_fc_original_sale_order_id:
+ return False
+ original = self.x_fc_original_sale_order_id
+ delivery_date = original.commitment_date or original.date_order
+ if not delivery_date:
+ return False
+ warranty_months = (
+ self.product_id.product_tmpl_id.x_fc_warranty_months
+ if self.product_id else 0
+ )
+ if not warranty_months:
+ return False
+ # relativedelta handles month boundaries correctly (28/29/30/31).
+ cutoff = fields.Datetime.from_string(str(delivery_date)) + relativedelta(months=warranty_months)
+ return fields.Datetime.now() <= cutoff
+
+ # ------------------------------------------------------------------
+ # SMART BUTTONS
+ # ------------------------------------------------------------------
+ def action_view_intake_answers(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Intake Answers'),
+ 'res_model': 'fusion.repair.intake.answer',
+ 'view_mode': 'list,form',
+ 'domain': [('repair_id', '=', self.id)],
+ 'context': {'default_repair_id': self.id},
+ }
+
+ def action_view_technician_tasks(self):
+ self.ensure_one()
+ if len(self.x_fc_technician_task_ids) == 1:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_technician_task_ids.name,
+ 'res_model': 'fusion.technician.task',
+ 'view_mode': 'form',
+ 'res_id': self.x_fc_technician_task_ids.id,
+ }
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Technician Tasks'),
+ 'res_model': 'fusion.technician.task',
+ 'view_mode': 'list,form',
+ 'domain': [('x_fc_repair_order_id', '=', self.id)],
+ 'context': {'default_x_fc_repair_order_id': self.id},
+ }
+
+ def action_view_original_sale_order(self):
+ self.ensure_one()
+ if not self.x_fc_original_sale_order_id:
+ return False
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_original_sale_order_id.name,
+ 'res_model': 'sale.order',
+ 'view_mode': 'form',
+ 'res_id': self.x_fc_original_sale_order_id.id,
+ }
+
+ # ------------------------------------------------------------------
+ # WIZARDS / PAYMENT
+ # ------------------------------------------------------------------
+ def action_open_visit_report(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Visit Report'),
+ 'res_model': 'fusion.repair.visit.report.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'default_repair_id': self.id,
+ 'default_labour_hours': self.x_fc_estimated_duration or 1.0,
+ },
+ }
+
+ def action_collect_payment(self):
+ """Open the Poynt payment wizard for the linked posted invoice."""
+ self.ensure_one()
+ # Resolve the linked invoice via the standard repair -> SO -> invoice chain.
+ if not self.sale_order_id:
+ raise UserError(_('Confirm a sale order from this repair first.'))
+ invoice = self.sale_order_id.invoice_ids.filtered(
+ lambda m: m.state == 'posted' and m.payment_state in ('not_paid', 'partial')
+ )[:1]
+ if not invoice:
+ raise UserError(_('No posted, unpaid invoice was found for this repair.'))
+ if hasattr(invoice, 'action_open_poynt_payment_wizard'):
+ return invoice.action_open_poynt_payment_wizard()
+ raise UserError(_('Poynt payment is not available - install or configure fusion_poynt.'))
diff --git a/fusion_repairs/models/repair_part_order.py b/fusion_repairs/models/repair_part_order.py
new file mode 100644
index 00000000..57ff9cb1
--- /dev/null
+++ b/fusion_repairs/models/repair_part_order.py
@@ -0,0 +1,252 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Parts-ordering workflow.
+
+When the tech arrives, diagnoses, and discovers the unit needs a part we
+don't stock (most common with manufacturer-specific items like Handicare
+stairlift control boards), they capture the part info via the mobile
+visit-report wizard in a structured way so:
+
+1. Office can order from the manufacturer in one click (description + OEM
+ part number + photos are exactly what procurement needs)
+2. Client gets an immediate "we found the problem - here's the timeline" email
+3. When parts arrive, office marks the order received and the system
+ auto-creates a follow-up dispatch task
+
+The grumpy-old-client never has to call us asking for status updates.
+"""
+
+from datetime import timedelta
+
+from markupsafe import Markup
+
+from odoo import _, api, fields, models
+
+
+class FusionRepairPartOrder(models.Model):
+ _name = 'fusion.repair.part.order'
+ _inherit = ['mail.thread']
+ _description = 'Repair Part Order'
+ _order = 'create_date desc, id desc'
+
+ name = fields.Char(
+ string='Reference',
+ default='New',
+ copy=False,
+ readonly=True,
+ tracking=True,
+ )
+ repair_order_id = fields.Many2one(
+ 'repair.order',
+ string='Repair',
+ required=True,
+ ondelete='cascade',
+ index=True,
+ )
+ partner_id = fields.Many2one(
+ related='repair_order_id.partner_id',
+ store=True,
+ readonly=True,
+ )
+
+ description = fields.Char(
+ string='Part Description',
+ required=True,
+ tracking=True,
+ help='Plain English - what the tech needs (e.g. "Handicare 1100 control board, '
+ 'silver casing").',
+ )
+ oem_part_number = fields.Char(
+ string='OEM Part Number',
+ tracking=True,
+ help='If the tech could read a part number off the broken component.',
+ )
+ manufacturer = fields.Char(
+ string='Manufacturer',
+ tracking=True,
+ )
+ quantity = fields.Float(
+ string='Quantity',
+ default=1.0,
+ required=True,
+ )
+ notes = fields.Text(
+ string='Tech Notes',
+ help='Anything procurement needs to know (alternative SKUs, colour, '
+ 'dimensions, etc.)',
+ )
+ photo_ids = fields.Many2many(
+ 'ir.attachment',
+ 'fusion_repair_part_order_photo_rel',
+ 'part_order_id', 'attachment_id',
+ string='Photos',
+ help='Photos of the broken part / label / packaging. The more the better.',
+ )
+
+ state = fields.Selection(
+ [
+ ('draft', 'Captured by Tech'),
+ ('ordered', 'Ordered from Manufacturer'),
+ ('received', 'Received in Warehouse'),
+ ('fitted', 'Fitted - Repair Complete'),
+ ('cancelled', 'Cancelled'),
+ ],
+ string='Status',
+ default='draft',
+ tracking=True,
+ copy=False,
+ )
+
+ ordered_date = fields.Date(string='Ordered On', tracking=True)
+ expected_date = fields.Date(string='Expected Arrival', tracking=True)
+ received_date = fields.Date(string='Received On', tracking=True, copy=False)
+ ordered_by_id = fields.Many2one(
+ 'res.users',
+ string='Ordered By',
+ tracking=True,
+ copy=False,
+ )
+
+ company_id = fields.Many2one(
+ 'res.company',
+ default=lambda self: self.env.company,
+ )
+
+ # ------------------------------------------------------------------
+ # CRUD
+ # ------------------------------------------------------------------
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get('name', 'New') == 'New':
+ vals['name'] = self.env['ir.sequence'].next_by_code(
+ 'fusion.repair.part.order'
+ ) or 'PART/NEW'
+ records = super().create(vals_list)
+ for rec in records:
+ rec._post_creation_to_repair()
+ return records
+
+ def _post_creation_to_repair(self):
+ for rec in self:
+ rec.repair_order_id.message_post(body=Markup(_(
+ 'Part order %(ref)s captured: %(desc)s '
+ '(qty %(qty)s%(oem)s).'
+ )) % {
+ 'ref': rec.name,
+ 'desc': rec.description,
+ 'qty': rec.quantity,
+ 'oem': f' / OEM {rec.oem_part_number}' if rec.oem_part_number else '',
+ })
+
+ # ------------------------------------------------------------------
+ # ACTIONS
+ # ------------------------------------------------------------------
+ def action_mark_ordered(self):
+ """Office marks this part as ordered with the manufacturer."""
+ for rec in self:
+ rec.state = 'ordered'
+ rec.ordered_date = fields.Date.context_today(rec)
+ rec.ordered_by_id = self.env.user
+ if not rec.expected_date:
+ rec.expected_date = fields.Date.context_today(rec) + timedelta(days=7)
+ rec._notify_client_parts_ordered()
+
+ def action_mark_received(self):
+ """Office marks this part as received - triggers follow-up dispatch."""
+ for rec in self:
+ rec.state = 'received'
+ rec.received_date = fields.Date.context_today(rec)
+ rec._maybe_redispatch()
+ rec._notify_client_parts_received()
+
+ def action_mark_fitted(self):
+ for rec in self:
+ rec.state = 'fitted'
+
+ def action_cancel(self):
+ for rec in self:
+ rec.state = 'cancelled'
+
+ # ------------------------------------------------------------------
+ # WORKFLOW HELPERS
+ # ------------------------------------------------------------------
+ def _notify_client_parts_ordered(self):
+ for rec in self:
+ tpl = self.env.ref(
+ 'fusion_repairs.email_template_parts_ordered',
+ raise_if_not_found=False,
+ )
+ if tpl and rec.partner_id and rec.partner_id.email:
+ try:
+ tpl.send_mail(rec.id, force_send=False)
+ except Exception:
+ pass
+
+ def _notify_client_parts_received(self):
+ for rec in self:
+ tpl = self.env.ref(
+ 'fusion_repairs.email_template_parts_received',
+ raise_if_not_found=False,
+ )
+ if tpl and rec.partner_id and rec.partner_id.email:
+ try:
+ tpl.send_mail(rec.id, force_send=False)
+ except Exception:
+ pass
+
+ def _maybe_redispatch(self):
+ """When the LAST outstanding part on a repair arrives, auto-create
+ a follow-up tech task so the office doesn't have to remember.
+
+ Schedules for tomorrow + first free hour slot to avoid colliding
+ with existing day-of tasks (the fusion_tasks model raises on
+ time-window conflicts).
+ """
+ from datetime import date as _date
+ for rec in self:
+ repair = rec.repair_order_id
+ outstanding = repair.x_fc_part_order_ids.filtered(
+ lambda p: p.state in ('draft', 'ordered')
+ )
+ if outstanding:
+ continue # still waiting on other parts
+ repair.x_fc_parts_awaiting = False
+ repair.x_fc_parts_eta_date = False
+ # Find tomorrow's first free slot for the same tech (or
+ # lightest-loaded skilled tech).
+ target_date = _date.today() + timedelta(days=1)
+ target_tech = (
+ repair.x_fc_technician_task_ids[:1].technician_id.id
+ if repair.x_fc_technician_task_ids else False
+ )
+ if not target_tech:
+ target_tech = self.env['repair.order'] \
+ .sudo()._fc_find_lightest_today_tech.__func__(repair)
+ ctx = {
+ 'force_schedule': {
+ 'scheduled_date': target_date,
+ 'time_start': 9.0,
+ 'time_end': 10.0,
+ },
+ }
+ if target_tech:
+ ctx['force_tech_id'] = target_tech
+ try:
+ self.env['fusion.repair.intake.service'].sudo() \
+ .with_context(**ctx) \
+ ._create_dispatch_task(repair)
+ repair.message_post(body=Markup(_(
+ 'All ordered parts received. Auto-dispatched a follow-up '
+ 'visit for %(date)s 09:00 - 10:00.'
+ )) % {'date': target_date.isoformat()})
+ except Exception as e:
+ # If slot 9-10 collides, just log and let the dispatcher
+ # pick a slot manually - we don't want to swallow the email.
+ repair.message_post(body=Markup(_(
+ 'All ordered parts received but the auto-dispatch slot '
+ '%(date)s 09:00-10:00 collided. Please pick a time '
+ 'manually. (%(err)s)'
+ )) % {'date': target_date.isoformat(), 'err': str(e)})
diff --git a/fusion_repairs/models/repair_product_category.py b/fusion_repairs/models/repair_product_category.py
new file mode 100644
index 00000000..296430f1
--- /dev/null
+++ b/fusion_repairs/models/repair_product_category.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import api, fields, models
+
+
+class FusionRepairProductCategory(models.Model):
+ """Medical equipment categories used to route repair intake and match skills."""
+
+ _name = 'fusion.repair.product.category'
+ _description = 'Repair Product Category'
+ _order = 'sequence, name'
+
+ name = fields.Char(string='Name', required=True, translate=True)
+ code = fields.Char(
+ string='Code',
+ required=True,
+ help='Stable identifier used by code (e.g. "stairlift"). Lowercase, no spaces.',
+ )
+ sequence = fields.Integer(string='Sequence', default=10)
+ icon = fields.Char(
+ string='Icon',
+ default='fa-wrench',
+ help='Font Awesome icon class shown next to the category in pickers.',
+ )
+ description = fields.Text(string='Description', translate=True)
+ active = fields.Boolean(default=True)
+ safety_critical = fields.Boolean(
+ string='Safety-Critical',
+ help='Categories where motor / mechanical issues warrant immediate escalation '
+ '(stairlifts, porch lifts). Used by the AI self-check engine to skip '
+ 'self-help and force escalation when safety symptoms appear.',
+ )
+
+ # Bundle 10: aligns Westin's printed rate card - LIFT & ELEVATING SERVICE
+ # has its own higher rates (stairlifts, porch lifts, lift chairs, hoyer lifts).
+ equipment_class = fields.Selection(
+ [
+ ('standard', 'Standard Service'),
+ ('lift_elevating', 'Lift & Elevating Service'),
+ ],
+ string='Equipment Class',
+ default='standard',
+ required=True,
+ help='Determines which callout rate row applies. Lift & Elevating uses '
+ 'higher per-card rates (e.g. $160 callout vs $95 standard).',
+ )
+
+ intake_template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Default Intake Template',
+ help='Default intake question set shown when this category is selected.',
+ )
+
+ _code_unique = models.Constraint(
+ 'unique(code)',
+ 'Category code must be unique.',
+ )
+
+ @api.depends('name', 'code')
+ def _compute_display_name(self):
+ for cat in self:
+ cat.display_name = cat.name or cat.code or ''
diff --git a/fusion_repairs/models/repair_self_check_rule.py b/fusion_repairs/models/repair_self_check_rule.py
new file mode 100644
index 00000000..0a5bc089
--- /dev/null
+++ b/fusion_repairs/models/repair_self_check_rule.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Deterministic self-check rules.
+
+Seeded per equipment category + symptom keyword combination. Used by
+fusion.repair.ai.service when:
+- AI is unavailable (fusion_api not installed / OpenAI down)
+- AI returns malformed / unsafe content
+- The category has no AI configured
+
+Also rendered directly on the client portal when AI is disabled per spec.
+"""
+
+from odoo import fields, models
+
+
+class FusionRepairSelfCheckRule(models.Model):
+ _name = 'fusion.repair.self.check.rule'
+ _description = 'Repair Self-Check Rule (deterministic fallback)'
+ _order = 'category_id, sequence, id'
+
+ name = fields.Char(string='Title', required=True, translate=True)
+ sequence = fields.Integer(default=10)
+ active = fields.Boolean(default=True)
+ company_id = fields.Many2one(
+ 'res.company', string='Company',
+ default=lambda self: self.env.company,
+ )
+
+ category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Equipment Category',
+ required=True,
+ index=True,
+ ondelete='cascade',
+ )
+ symptom_keywords = fields.Char(
+ string='Symptom Keywords',
+ help='Comma-separated, lowercase. Empty matches any symptom.',
+ )
+
+ instruction = fields.Text(
+ string='Instruction',
+ required=True,
+ translate=True,
+ help='What to ask the client to do. Plain English, <= 1 sentence.',
+ )
+ expected_result = fields.Text(
+ string='Expected Result',
+ required=True,
+ translate=True,
+ help='What success looks like ("alarm stops", "wheel spins freely").',
+ )
+ safety_note = fields.Text(
+ string='Safety Note',
+ translate=True,
+ help='Optional warning shown in red below the instruction.',
+ )
diff --git a/fusion_repairs/models/repair_service_plan.py b/fusion_repairs/models/repair_service_plan.py
new file mode 100644
index 00000000..41cc41e0
--- /dev/null
+++ b/fusion_repairs/models/repair_service_plan.py
@@ -0,0 +1,270 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+r"""Pre-paid service plans (M5).
+
+Architecture:
+
+ product.template
+ \--> x_fc_is_service_plan = True
+ x_fc_plan_visits_included (e.g. 4)
+ x_fc_plan_duration_months (e.g. 12)
+
+ sale.order.confirm()
+ \--> for each line whose product is a service plan,
+ create a fusion.repair.service.plan.subscription
+ (partner + product + visits_included + start_date + end_date)
+
+ fusion.repair.maintenance.contract.create_repair_from_booking()
+ visit_report_wizard.action_confirm()
+ \--> burns down one visit if the partner has an active matching plan
+ (for the same product or category)
+
+ fusion.repair.dashboard.get_dashboard_data()
+ \--> exposes active_plan_count + plans_low_count for the dashboard
+"""
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import _, api, fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ x_fc_is_service_plan = fields.Boolean(
+ string='Service Plan',
+ help='Sell this product as a pre-paid maintenance package. '
+ 'Confirming a sale order with this product creates a '
+ 'visit subscription for the customer.',
+ )
+ x_fc_plan_visits_included = fields.Integer(
+ string='Visits Included',
+ default=4,
+ help='Number of maintenance visits the customer is entitled to under this plan.',
+ )
+ x_fc_plan_duration_months = fields.Integer(
+ string='Plan Duration (months)',
+ default=12,
+ help='Plan ends this many months after the sale-order date even if visits remain.',
+ )
+ x_fc_plan_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Plan Category',
+ help='If set, plan visits only burn down for repairs on equipment of this category. '
+ 'Leave blank to apply to any equipment from this customer.',
+ )
+
+
+class FusionRepairServicePlanSubscription(models.Model):
+ _name = 'fusion.repair.service.plan.subscription'
+ _inherit = ['mail.thread']
+ _description = 'Pre-paid Service Plan Subscription'
+ _order = 'end_date desc, id desc'
+
+ name = fields.Char(
+ string='Reference', required=True, default='New',
+ copy=False, readonly=True, tracking=True,
+ )
+ partner_id = fields.Many2one(
+ 'res.partner', string='Client',
+ required=True, tracking=True, index=True,
+ )
+ product_id = fields.Many2one(
+ 'product.product', string='Plan Product',
+ required=True, tracking=True,
+ domain="[('x_fc_is_service_plan', '=', True)]",
+ )
+ category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Covers Category',
+ help='Computed from the plan product. Only burns visits for repairs '
+ 'whose category matches.',
+ )
+ sale_order_id = fields.Many2one(
+ 'sale.order', string='Sold On',
+ ondelete='set null', tracking=True,
+ )
+
+ visits_included = fields.Integer(string='Visits Included', required=True, default=4)
+ visits_used = fields.Integer(string='Visits Used', default=0, tracking=True)
+ visits_remaining = fields.Integer(
+ string='Remaining',
+ compute='_compute_visits_remaining', store=True,
+ )
+
+ start_date = fields.Date(
+ string='Start', required=True, default=fields.Date.context_today, tracking=True,
+ )
+ end_date = fields.Date(string='Expires', required=True, tracking=True)
+
+ state = fields.Selection(
+ [
+ ('active', 'Active'),
+ ('exhausted', 'Visits Exhausted'),
+ ('expired', 'Expired'),
+ ('cancelled', 'Cancelled'),
+ ],
+ string='Status',
+ compute='_compute_state', store=True, tracking=True,
+ )
+
+ company_id = fields.Many2one(
+ 'res.company', default=lambda self: self.env.company,
+ )
+
+ burn_history_ids = fields.One2many(
+ 'fusion.repair.service.plan.burn',
+ 'subscription_id',
+ string='Burn History',
+ )
+
+ # ------------------------------------------------------------------
+ # CRUD
+ # ------------------------------------------------------------------
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get('name', 'New') == 'New':
+ vals['name'] = self.env['ir.sequence'].next_by_code(
+ 'fusion.repair.service.plan.subscription'
+ ) or 'PLAN/NEW'
+ if vals.get('product_id') and not vals.get('end_date'):
+ product = self.env['product.product'].sudo().browse(vals['product_id'])
+ months = product.product_tmpl_id.x_fc_plan_duration_months or 12
+ start = vals.get('start_date') or fields.Date.context_today(self)
+ vals['end_date'] = fields.Date.from_string(str(start)) + relativedelta(months=months)
+ if vals.get('product_id') and 'category_id' not in vals:
+ product = self.env['product.product'].sudo().browse(vals['product_id'])
+ if product.product_tmpl_id.x_fc_plan_category_id:
+ vals['category_id'] = product.product_tmpl_id.x_fc_plan_category_id.id
+ if vals.get('product_id') and 'visits_included' not in vals:
+ product = self.env['product.product'].sudo().browse(vals['product_id'])
+ vals['visits_included'] = product.product_tmpl_id.x_fc_plan_visits_included or 4
+ return super().create(vals_list)
+
+ # ------------------------------------------------------------------
+ # COMPUTES
+ # ------------------------------------------------------------------
+ @api.depends('visits_included', 'visits_used')
+ def _compute_visits_remaining(self):
+ for s in self:
+ s.visits_remaining = (s.visits_included or 0) - (s.visits_used or 0)
+
+ @api.depends('visits_remaining', 'end_date')
+ def _compute_state(self):
+ today = fields.Date.context_today(self)
+ for s in self:
+ if s.state == 'cancelled':
+ continue
+ if s.end_date and s.end_date < today:
+ s.state = 'expired'
+ elif s.visits_remaining <= 0:
+ s.state = 'exhausted'
+ else:
+ s.state = 'active'
+
+ # ------------------------------------------------------------------
+ # BURN ENGINE
+ # ------------------------------------------------------------------
+ @api.model
+ def find_for_repair(self, repair):
+ """Return the most-recently-started active subscription covering this
+ repair (partner match + category match if the plan specifies one)."""
+ if not repair.partner_id:
+ return self.browse()
+ domain = [
+ ('partner_id', '=', repair.partner_id.id),
+ ('state', '=', 'active'),
+ ('visits_remaining', '>', 0),
+ ]
+ subs = self.search(domain, order='start_date desc')
+ for s in subs:
+ if not s.category_id or s.category_id == repair.x_fc_repair_category_id:
+ return s
+ return self.browse()
+
+ def burn_visit(self, repair):
+ """Deduct one visit from this subscription and log the burn."""
+ self.ensure_one()
+ if self.visits_remaining <= 0:
+ return False
+ self.visits_used += 1
+ self.env['fusion.repair.service.plan.burn'].sudo().create({
+ 'subscription_id': self.id,
+ 'repair_order_id': repair.id,
+ 'burned_on': fields.Date.context_today(self),
+ })
+ self.message_post(body=_(
+ 'Visit burned for repair %s. %s of %s remaining.'
+ ) % (repair.name, self.visits_remaining, self.visits_included))
+ return True
+
+ def action_cancel(self):
+ for s in self:
+ s.state = 'cancelled'
+ s.message_post(body=_('Plan cancelled.'))
+
+
+class FusionRepairServicePlanBurn(models.Model):
+ _name = 'fusion.repair.service.plan.burn'
+ _description = 'Service Plan Visit Burn'
+ _order = 'burned_on desc, id desc'
+
+ subscription_id = fields.Many2one(
+ 'fusion.repair.service.plan.subscription',
+ string='Subscription', required=True, ondelete='cascade',
+ )
+ repair_order_id = fields.Many2one(
+ 'repair.order', string='Repair', required=True, ondelete='cascade',
+ )
+ burned_on = fields.Date(string='Burned On', required=True,
+ default=fields.Date.context_today)
+
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ def action_confirm(self):
+ res = super().action_confirm()
+ # Spawn subscriptions for each service-plan line.
+ for order in self:
+ for line in order.order_line:
+ tmpl = line.product_id.product_tmpl_id
+ if not tmpl.x_fc_is_service_plan:
+ continue
+ # One subscription per quantity unit (so a SO line with qty=2
+ # creates two distinct plans - rare but supported).
+ qty = int(line.product_uom_qty or 1)
+ for _i in range(max(qty, 1)):
+ self.env['fusion.repair.service.plan.subscription'].sudo().create({
+ 'partner_id': order.partner_id.id,
+ 'product_id': line.product_id.id,
+ 'sale_order_id': order.id,
+ 'start_date': fields.Date.context_today(self),
+ })
+ # Bundle 9: spawn store labor warranties for any product line with
+ # x_fc_labor_warranty_years > 0.
+ self._fc_spawn_labor_warranties()
+ return res
+
+ def _fc_spawn_labor_warranties(self):
+ Warranty = self.env['fusion.repair.labor.warranty'].sudo()
+ for order in self:
+ for line in order.order_line:
+ tmpl = line.product_id.product_tmpl_id
+ years = tmpl.x_fc_labor_warranty_years or 0
+ if years <= 0:
+ continue
+ # One warranty record per unit so each can be voided
+ # independently if a specific unit is misused.
+ qty = int(line.product_uom_qty or 1)
+ for _i in range(max(qty, 1)):
+ Warranty.create({
+ 'partner_id': order.partner_id.id,
+ 'product_id': line.product_id.id,
+ 'sale_order_id': order.id,
+ 'warranty_years': years,
+ 'start_date': fields.Date.context_today(self),
+ })
diff --git a/fusion_repairs/models/repair_warranty.py b/fusion_repairs/models/repair_warranty.py
new file mode 100644
index 00000000..518406f0
--- /dev/null
+++ b/fusion_repairs/models/repair_warranty.py
@@ -0,0 +1,131 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Repair warranty coverage.
+
+Tracks the 30/90-day warranty we offer on completed repair work.
+When a new repair is created on the same equipment within the
+coverage window, the intake wizard / portal shows a banner:
+"This repair may be covered by our warranty - no charge".
+
+Phase 2 ships the model + manual creation from a completed repair.
+Phase 4 will add automatic creation when a repair moves to 'done'.
+"""
+
+from datetime import timedelta
+
+from odoo import api, fields, models
+
+
+class FusionRepairWarrantyCoverage(models.Model):
+ _name = 'fusion.repair.warranty.coverage'
+ _description = 'Repair Warranty Coverage'
+ _order = 'expiry_date desc, id desc'
+
+ name = fields.Char(string='Reference', compute='_compute_name', store=True)
+ repair_id = fields.Many2one(
+ 'repair.order',
+ string='Original Repair',
+ required=True,
+ ondelete='cascade',
+ index=True,
+ )
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string='Client',
+ related='repair_id.partner_id',
+ store=True,
+ index=True,
+ )
+ product_id = fields.Many2one(
+ 'product.product',
+ string='Equipment',
+ related='repair_id.product_id',
+ store=True,
+ index=True,
+ )
+ lot_id = fields.Many2one(
+ 'stock.lot',
+ string='Serial Number',
+ related='repair_id.lot_id',
+ store=True,
+ )
+
+ start_date = fields.Date(
+ string='Start Date',
+ required=True,
+ default=fields.Date.context_today,
+ )
+ coverage_days = fields.Integer(
+ string='Coverage Window (days)',
+ default=30,
+ required=True,
+ )
+ expiry_date = fields.Date(
+ string='Expires',
+ compute='_compute_expiry_date',
+ store=True,
+ )
+ # Non-stored compute - DO NOT add store=True. The 'active vs not' status is
+ # time-dependent (today >= expiry_date), and a stored compute would never
+ # auto-refresh as days pass. find_active_for() filters by expiry_date directly.
+ is_active = fields.Boolean(
+ string='Active',
+ compute='_compute_is_active',
+ )
+
+ notes = fields.Text()
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ related='repair_id.company_id',
+ store=True,
+ )
+
+ @api.depends('repair_id.name', 'expiry_date')
+ def _compute_name(self):
+ for w in self:
+ w.name = (
+ f"Warranty {w.repair_id.name or '?'} (until {w.expiry_date or '?'})"
+ )
+
+ @api.depends('start_date', 'coverage_days')
+ def _compute_expiry_date(self):
+ for w in self:
+ if w.start_date and w.coverage_days:
+ w.expiry_date = w.start_date + timedelta(days=w.coverage_days)
+ else:
+ w.expiry_date = False
+
+ @api.depends('expiry_date')
+ def _compute_is_active(self):
+ today = fields.Date.context_today(self)
+ for w in self:
+ w.is_active = bool(w.expiry_date and w.expiry_date >= today)
+
+ # ------------------------------------------------------------------
+ # LOOKUP
+ # ------------------------------------------------------------------
+ @api.model
+ def find_active_for(self, partner_id, product_id=None, lot_id=None):
+ """Return active warranty coverage matching the partner + equipment, if any.
+
+ Requires at least one of lot_id or product_id - without an equipment
+ identifier we would match any warranty on the partner, which would
+ falsely flag unrelated equipment as covered.
+ """
+ if not partner_id:
+ return self.browse()
+ if not lot_id and not product_id:
+ return self.browse()
+ today = fields.Date.context_today(self)
+ domain = [
+ ('partner_id', '=', partner_id),
+ ('expiry_date', '>=', today),
+ ]
+ if lot_id:
+ domain.append(('lot_id', '=', lot_id))
+ elif product_id:
+ domain.append(('product_id', '=', product_id))
+ return self.search(domain, order='expiry_date desc', limit=1)
diff --git a/fusion_repairs/models/res_config_settings.py b/fusion_repairs/models/res_config_settings.py
new file mode 100644
index 00000000..7bbf4f14
--- /dev/null
+++ b/fusion_repairs/models/res_config_settings.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ # NOTE: res.config.settings only supports boolean/integer/float/char/
+ # selection/many2one/datetime types per project Odoo 19 conventions.
+
+ fc_repairs_enable_email_notifications = fields.Boolean(
+ string='Enable Repair Email Notifications',
+ config_parameter='fusion_repairs.enable_email_notifications',
+ default=True,
+ help='Master toggle for automated repair-related emails to clients and office.',
+ )
+
+ fc_repairs_outstanding_balance_threshold = fields.Float(
+ string='Outstanding Balance Warning ($)',
+ config_parameter='fusion_repairs.outstanding_balance_threshold',
+ default=100.0,
+ help='Show a warning banner during intake if the client has open invoices '
+ 'totalling more than this amount.',
+ )
+
+ fc_repairs_duplicate_call_window_days = fields.Integer(
+ string='Duplicate Call Window (Days)',
+ config_parameter='fusion_repairs.duplicate_call_window_days',
+ default=14,
+ help='When the intake wizard finds an open repair from this many days back on '
+ 'the same phone number, it offers "add note to existing repair instead".',
+ )
+
+ fc_repairs_variance_threshold_pct = fields.Integer(
+ string='Pricing Variance Threshold (%)',
+ config_parameter='fusion_repairs.variance_threshold_pct',
+ default=20,
+ help='If actual cost exceeds estimated cost by more than this percentage, '
+ 'invoicing is blocked until a manager reviews / a re-quote email is sent.',
+ )
+
+ fc_repairs_variance_threshold_amount = fields.Float(
+ string='Pricing Variance Threshold ($)',
+ config_parameter='fusion_repairs.variance_threshold_amount',
+ default=100.0,
+ help='Absolute variance amount that also triggers re-quote (whichever hits first).',
+ )
+
+ fc_repairs_client_portal_url = fields.Char(
+ string='Public Client Portal URL Path',
+ config_parameter='fusion_repairs.client_portal_url',
+ default='/repair',
+ help='URL path mentioned in voicemail greetings and printed on QR stickers. '
+ 'Phase 1 ships with the form at this path.',
+ )
+
+ fc_repairs_client_portal_rate_limit_per_hour = fields.Integer(
+ string='Client Portal Rate Limit (per hour, per IP)',
+ config_parameter='fusion_repairs.client_portal_rate_limit_per_hour',
+ default=10,
+ )
diff --git a/fusion_repairs/models/res_partner.py b/fusion_repairs/models/res_partner.py
new file mode 100644
index 00000000..83d1a4d6
--- /dev/null
+++ b/fusion_repairs/models/res_partner.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+PREFERRED_WINDOW = [
+ ('morning', 'Morning (9 AM - 12 PM)'),
+ ('afternoon', 'Afternoon (12 PM - 5 PM)'),
+ ('evening', 'Evening (after 5 PM)'),
+ ('any', 'Any Time'),
+]
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ # ------------------------------------------------------------------
+ # SERVICE PREFERENCES (P1 - shown in client history sidebar)
+ # ------------------------------------------------------------------
+ x_fc_preferred_tech_id = fields.Many2one(
+ 'res.users',
+ string='Preferred Technician',
+ domain="[('x_fc_is_field_staff', '=', True)]",
+ help='If set, this technician is suggested first on dispatch.',
+ )
+ x_fc_preferred_window = fields.Selection(
+ PREFERRED_WINDOW,
+ string='Preferred Visit Window',
+ default='any',
+ )
+ x_fc_access_notes = fields.Text(
+ string='Access Notes',
+ help='Free-form notes for technicians arriving at this address: '
+ 'gate code, dog warning, where to park, side door entry, etc.',
+ )
+
+ # ------------------------------------------------------------------
+ # CLIENT HISTORY SIDEBAR (C2 - pulled lazily on demand)
+ # ------------------------------------------------------------------
+ x_fc_repair_count = fields.Integer(
+ compute='_compute_x_fc_repair_count',
+ string='Repairs Count',
+ compute_sudo=True,
+ help='Lightweight count of repair orders for this partner. Heavier history '
+ 'data is fetched lazily by the wizard / portal sidebar via RPC.',
+ )
+
+ def _compute_x_fc_repair_count(self):
+ # Non-stored compute - safe to omit @api.depends.
+ if not self.ids:
+ for partner in self:
+ partner.x_fc_repair_count = 0
+ return
+ Repair = self.env['repair.order'].sudo()
+ data = Repair._read_group(
+ [('partner_id', 'in', self.ids)],
+ ['partner_id'],
+ ['__count'],
+ )
+ counts = {row[0].id: row[1] for row in data}
+ for partner in self:
+ partner.x_fc_repair_count = counts.get(partner.id, 0)
diff --git a/fusion_repairs/models/res_users.py b/fusion_repairs/models/res_users.py
new file mode 100644
index 00000000..59297420
--- /dev/null
+++ b/fusion_repairs/models/res_users.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+class ResUsers(models.Model):
+ """Extends res.users with fusion_repairs specific fields.
+
+ Reuses the existing x_fc_is_field_staff Boolean from fusion_tasks
+ as the technician flag - do NOT recreate that field here.
+
+ All technician selectors in fusion_repairs use the same domain
+ [('x_fc_is_field_staff', '=', True)] for consistency with fusion_tasks.
+ """
+
+ _inherit = 'res.users'
+
+ x_fc_repair_skills = fields.Many2many(
+ 'fusion.repair.product.category',
+ 'fusion_repair_user_skill_rel',
+ 'user_id',
+ 'category_id',
+ string='Repair Skills',
+ help='Medical equipment categories this user is qualified to service. '
+ 'Used by dispatcher to filter candidate technicians for a repair.',
+ )
+
+ x_fc_tech_cost_rate = fields.Monetary(
+ string='Tech Cost Rate (/h)',
+ currency_field='company_currency_id',
+ help='Internal cost per hour - used for repair margin calculation (Phase 4).',
+ )
+
+ # On-call rotation - Phase 2 (simple priority-int approach).
+ x_fc_on_call = fields.Boolean(
+ string='On-Call Eligible',
+ help='Tick if this user is eligible for the weekend / after-hours on-call rotation.',
+ )
+ x_fc_on_call_priority = fields.Integer(
+ string='On-Call Priority',
+ default=99,
+ help='Lower number = paged first. The escalation cron picks the lowest priority '
+ 'available user when a safety repair is submitted after hours.',
+ )
+ x_fc_on_call_phone = fields.Char(
+ string='On-Call Phone Override',
+ help='Phone number to use for on-call SMS / calls. If empty, falls back to '
+ 'the user partner phone.',
+ )
+
+ company_currency_id = fields.Many2one(
+ 'res.currency',
+ related='company_id.currency_id',
+ readonly=True,
+ )
diff --git a/fusion_repairs/models/sale_order.py b/fusion_repairs/models/sale_order.py
new file mode 100644
index 00000000..023544fe
--- /dev/null
+++ b/fusion_repairs/models/sale_order.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""sale.order extensions: smart buttons that link an original purchase SO
+to its downstream repairs, maintenance contracts, and repair invoices.
+
+Mirrors the count + action_view_* pattern from
+fusion_claims/views/sale_order_views.xml line ~1176.
+"""
+
+from odoo import _, api, fields, models
+
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ x_fc_repair_order_ids = fields.One2many(
+ 'repair.order',
+ 'x_fc_original_sale_order_id',
+ string='Repairs',
+ )
+ x_fc_repair_order_count = fields.Integer(
+ compute='_compute_x_fc_repair_order_count',
+ )
+
+ x_fc_maintenance_contract_ids = fields.One2many(
+ 'fusion.repair.maintenance.contract',
+ 'original_sale_order_id',
+ string='Maintenance Contracts',
+ )
+ x_fc_maintenance_contract_count = fields.Integer(
+ compute='_compute_x_fc_maintenance_contract_count',
+ )
+
+ @api.depends('x_fc_repair_order_ids')
+ def _compute_x_fc_repair_order_count(self):
+ for so in self:
+ so.x_fc_repair_order_count = len(so.x_fc_repair_order_ids)
+
+ @api.depends('x_fc_maintenance_contract_ids')
+ def _compute_x_fc_maintenance_contract_count(self):
+ for so in self:
+ so.x_fc_maintenance_contract_count = len(so.x_fc_maintenance_contract_ids)
+
+ def action_view_repair_orders(self):
+ self.ensure_one()
+ if len(self.x_fc_repair_order_ids) == 1:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_repair_order_ids.name,
+ 'res_model': 'repair.order',
+ 'view_mode': 'form',
+ 'res_id': self.x_fc_repair_order_ids.id,
+ }
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Repairs from %(name)s', name=self.name),
+ 'res_model': 'repair.order',
+ 'view_mode': 'list,form',
+ 'domain': [('x_fc_original_sale_order_id', '=', self.id)],
+ }
+
+ def action_view_maintenance_contracts(self):
+ self.ensure_one()
+ if len(self.x_fc_maintenance_contract_ids) == 1:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_maintenance_contract_ids.name,
+ 'res_model': 'fusion.repair.maintenance.contract',
+ 'view_mode': 'form',
+ 'res_id': self.x_fc_maintenance_contract_ids.id,
+ }
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Maintenance Contracts from %(name)s', name=self.name),
+ 'res_model': 'fusion.repair.maintenance.contract',
+ 'view_mode': 'list,form',
+ 'domain': [('original_sale_order_id', '=', self.id)],
+ }
diff --git a/fusion_repairs/models/service_catalog.py b/fusion_repairs/models/service_catalog.py
new file mode 100644
index 00000000..973a1717
--- /dev/null
+++ b/fusion_repairs/models/service_catalog.py
@@ -0,0 +1,151 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Service catalogue.
+
+Each fusion.repair.service.catalog record is a named repair / maintenance
+service (e.g. "Stairlift motor replacement", "Bed remote troubleshoot")
+with estimated duration, estimated cost, default parts, and symptom
+keywords used to auto-match an intake to the right catalogue entry.
+
+The catalogue feeds:
+- intake auto-match -> sets x_fc_service_catalog_id +
+ x_fc_estimated_duration + x_fc_estimated_cost on the repair
+- visit report -> default labour line + parts pre-fill
+- pricing variance -> compares estimate vs actual
+"""
+
+from odoo import api, fields, models
+
+
+class FusionRepairServiceCatalog(models.Model):
+ _name = 'fusion.repair.service.catalog'
+ _description = 'Repair Service Catalogue Entry'
+ _order = 'sequence, name'
+
+ name = fields.Char(string='Service Name', required=True, translate=True)
+ code = fields.Char(string='Code', help='Stable identifier (lowercase, no spaces).')
+ sequence = fields.Integer(default=10)
+ active = fields.Boolean(default=True)
+ company_id = fields.Many2one(
+ 'res.company', string='Company',
+ default=lambda self: self.env.company,
+ )
+
+ # Routing & matching
+ product_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Equipment Category',
+ required=True,
+ index=True,
+ )
+ symptom_keywords = fields.Char(
+ string='Symptom Keywords',
+ help='Comma-separated keywords used to auto-match an intake to this catalogue entry. '
+ 'Matched against the issue summary, issue category, and intake answer text.',
+ )
+
+ # Service product (what actually gets invoiced)
+ service_product_id = fields.Many2one(
+ 'product.product',
+ string='Service Product',
+ domain=[('type', '=', 'service')],
+ help='Product line added to the repair sale order for the labour portion.',
+ )
+ default_parts_product_ids = fields.Many2many(
+ 'product.product',
+ 'fusion_repair_catalog_parts_rel',
+ 'catalog_id', 'product_id',
+ string='Default Parts',
+ help='Parts typically used. Pre-loaded onto the visit report wizard for the tech to confirm.',
+ )
+ pricelist_id = fields.Many2one(
+ 'product.pricelist',
+ string='Pricelist Override',
+ help='Optional pricelist applied to repair SOs from this catalogue entry. '
+ 'Leave blank to use the partner default pricelist.',
+ )
+
+ # Estimates
+ estimated_hours = fields.Float(
+ string='Estimated Labour (h)',
+ default=1.0,
+ help='Used to size the technician task and the visit report labour default.',
+ )
+ estimated_cost = fields.Monetary(
+ string='Estimated Cost',
+ currency_field='company_currency_id',
+ help='Headline estimate shown to the client/CS during intake. Phase 1 is a flat number; '
+ 'Phase 2+ may compute from labour + parts.',
+ )
+
+ # Automation hints
+ auto_schedule = fields.Boolean(
+ string='Auto-Create Tech Task',
+ help='When True, the intake service creates a draft technician task immediately for any '
+ 'repair matched to this catalogue entry (even at normal urgency).',
+ )
+ task_type = fields.Selection(
+ [('delivery', 'Delivery'), ('repair', 'Repair'), ('pickup', 'Pickup'),
+ ('troubleshoot', 'Troubleshoot'), ('assessment', 'Assessment'),
+ ('installation', 'Installation'), ('maintenance', 'Maintenance'),
+ ('other', 'Other')],
+ string='Default Task Type',
+ default='repair',
+ )
+
+ company_currency_id = fields.Many2one(
+ 'res.currency',
+ related='company_id.currency_id',
+ readonly=True,
+ )
+
+ @api.depends('name', 'code')
+ def _compute_display_name(self):
+ for c in self:
+ c.display_name = c.name or c.code or ''
+
+ # ------------------------------------------------------------------
+ # MATCHING
+ # ------------------------------------------------------------------
+ @api.model
+ def find_best_match(self, product_category_id, text_hints):
+ """Return the best-matching catalogue entry, or empty recordset.
+
+ Returns empty when no symptom keywords match. We never "guess" a default
+ catalog because the match drives estimated cost + auto-dispatch task -
+ a wrong guess would propagate into pricing and scheduling.
+
+ :param product_category_id: int id of the equipment category
+ :param text_hints: list[str] - text snippets to look for symptom keywords in
+ """
+ import re
+ if not product_category_id:
+ return self.browse()
+ haystack = ' '.join(s.lower() for s in (text_hints or []) if s).strip()
+ if not haystack:
+ return self.browse()
+ candidates = self.search([
+ ('product_category_id', '=', product_category_id),
+ ('active', '=', True),
+ ], order='sequence')
+ if not candidates:
+ return self.browse()
+ best = None
+ best_score = 0
+ for c in candidates:
+ kws = [k.strip().lower() for k in (c.symptom_keywords or '').split(',') if k.strip()]
+ # Word-boundary match avoids false positives where "battery" matches
+ # inside "no battery problem".
+ score = sum(
+ 1 for kw in kws
+ if kw and re.search(rf'\b{re.escape(kw)}\b', haystack)
+ )
+ if score > best_score:
+ best = c
+ best_score = score
+ # No keywords matched -> return empty rather than the lowest-sequence guess.
+ if best and best_score > 0:
+ return best
+ return self.browse()
diff --git a/fusion_repairs/models/technician_task.py b/fusion_repairs/models/technician_task.py
new file mode 100644
index 00000000..36792c33
--- /dev/null
+++ b/fusion_repairs/models/technician_task.py
@@ -0,0 +1,153 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from urllib.parse import quote_plus
+
+from markupsafe import Markup
+
+from odoo import _, fields, models
+from odoo.exceptions import UserError
+
+
+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,
+ )
+
+ # X2: per-task day-before reminder flag. Per-task (not per-repair) so
+ # a repair with multiple visits gets a separate reminder for each one.
+ x_fc_day_before_reminder_sent = fields.Boolean(
+ string='Day-Before Reminder Sent',
+ copy=False,
+ )
+
+ # ------------------------------------------------------------------
+ # T3 - Labour timer. The tech taps Start when they begin work and
+ # Stop when done; the accumulated minutes feeds the visit-report
+ # actual hours field. Multiple start/stop cycles are accumulated.
+ # ------------------------------------------------------------------
+ x_fc_timer_running_since = fields.Datetime(
+ string='Timer Running Since',
+ copy=False,
+ )
+ x_fc_timer_accumulated_minutes = fields.Float(
+ string='Accumulated Minutes',
+ default=0.0,
+ copy=False,
+ help='Total labour minutes captured by the tech timer. '
+ 'Divide by 60 for the hours that prefill the visit report.',
+ )
+
+ def action_timer_start(self):
+ for t in self:
+ if t.x_fc_timer_running_since:
+ continue # already running
+ t.x_fc_timer_running_since = fields.Datetime.now()
+ t.message_post(body=Markup(_('Labour timer started.')))
+
+ def action_timer_stop(self):
+ for t in self:
+ if not t.x_fc_timer_running_since:
+ continue
+ from datetime import datetime
+ elapsed_minutes = (
+ datetime.now() - t.x_fc_timer_running_since
+ ).total_seconds() / 60.0
+ t.x_fc_timer_accumulated_minutes = (
+ t.x_fc_timer_accumulated_minutes or 0.0
+ ) + elapsed_minutes
+ t.x_fc_timer_running_since = False
+ t.message_post(body=Markup(_(
+ 'Labour timer stopped. Added %(mins).1f min, total %(tot).1f min.'
+ )) % {
+ 'mins': elapsed_minutes,
+ 'tot': t.x_fc_timer_accumulated_minutes or 0.0,
+ })
+
+ 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 '
+ '%s 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()
+ # Prefer fusion_tasks.address_display because in real data address_street
+ # often contains the full Google-Places-formatted address; concatenating
+ # the other address_* fields would duplicate city/zip.
+ addr = (getattr(self, 'address_display', '') or '').strip()
+ if not addr and self.partner_id:
+ p = self.partner_id
+ parts = [
+ p.street, p.street2, p.city,
+ p.state_id.name if p.state_id else False,
+ p.zip,
+ p.country_id.name if p.country_id else False,
+ ]
+ addr = ', '.join(str(x) for x in parts if x)
+ if not addr:
+ raise UserError(_('No address on this task or its client.'))
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': f'https://www.google.com/maps?q={quote_plus(addr)}',
+ 'target': 'new',
+ }
diff --git a/fusion_repairs/report/inspection_certificate_report.xml b/fusion_repairs/report/inspection_certificate_report.xml
new file mode 100644
index 00000000..0e8fe1ca
--- /dev/null
+++ b/fusion_repairs/report/inspection_certificate_report.xml
@@ -0,0 +1,167 @@
+
+
+
+
+ Inspection Certificate
+ fusion.repair.inspection.certificate
+ qweb-pdf
+ fusion_repairs.report_inspection_certificate
+ fusion_repairs.report_inspection_certificate
+ 'Inspection Certificate - %s' % (object.name)
+
+ report
+
+
+
+
+
+
+
+
+
+
Certificate of Inspection
+
Safety Inspected
+
+ This certifies that the equipment described below has passed
+ its annual safety inspection in accordance with applicable
+ local regulations.
+
+
+ Your information is private and used only to schedule your repair.
+
+
+ Is anyone hurt right now?
+ If you have a medical emergency, please hang up and dial 9-1-1.
+
+
+ Already a customer? Have your phone number handy - we'll recognize your account.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Service Request
+
+ Fill in the form below. A team member will follow up shortly.
+
+
+
+
Please fill in all required fields.
+
+
+
Submission blocked. If this is a mistake, please call our office.
+
+
+
Too many requests from your location. Please try again in an hour.
+
+
+
Something went wrong. Please try again or call us directly.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Already a client?
+
+
+
+
+
+ We'll pre-fill your contact info so you don't have to retype it.
+
+
+
+
+
1. Your contact details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
2. What equipment needs service?
+
+
+
+
+ Pre-filled from QR scan:
+ (Serial )
+
+
+
+
+
+
+
+
+
+
+
+
+
+
3. What's wrong?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ We'll suggest a couple of things you can safely try in under 2 minutes.
+ If they don't help, just submit and we'll come to you.
+
+
+
+
+
+
+
4. How urgent is it?
+
+
+
+ If anyone is hurt, hang up and call 9-1-1.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Got it!
+
+ Your service request was received.
+ We'll get back to you on the next business day or sooner if you marked it urgent.
+
+
+
+
+
+
+
+
+
+ Want to avoid this next time?
+
+
+ Most of our regular clients enrol in an annual
+ maintenance plan - we visit twice a year, catch wear
+ before it becomes a breakdown, and you pay a lot less for
+ peace of mind than for an emergency call-out.
+
+ This on-call acknowledgement link is no longer valid. It may have been
+ acknowledged already or the page expired. Open Odoo and look up the
+ repair in the Fusion Repairs dashboard.
+
+ Click New in the top-left to open the guided
+ intake wizard. The form will walk you through caller info,
+ equipment selection, the issue, urgency and photos.
+
+
+
+
+
+ Service Catalogue
+ fusion.repair.service.catalog
+ list,form
+
+
+
diff --git a/fusion_repairs/views/technician_task_views.xml b/fusion_repairs/views/technician_task_views.xml
new file mode 100644
index 00000000..2d78de05
--- /dev/null
+++ b/fusion_repairs/views/technician_task_views.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+ fusion.technician.task.form.inherit.fusion_repairs
+ fusion.technician.task
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/wizard/__init__.py b/fusion_repairs/wizard/__init__.py
new file mode 100644
index 00000000..822ed47b
--- /dev/null
+++ b/fusion_repairs/wizard/__init__.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from . import repair_intake_wizard
+from . import repair_visit_report_wizard
+from . import qr_sticker_wizard
diff --git a/fusion_repairs/wizard/qr_sticker_wizard.py b/fusion_repairs/wizard/qr_sticker_wizard.py
new file mode 100644
index 00000000..a1e18ce8
--- /dev/null
+++ b/fusion_repairs/wizard/qr_sticker_wizard.py
@@ -0,0 +1,83 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""QR sticker generator (CL17).
+
+Generates a printable PDF with one sticker per selected serial number.
+Each sticker has a QR code linking to /repair?sn=. Stick on the
+equipment; client scans -> public client portal opens with the unit
+pre-filled.
+
+Accessible from stock.lot via a server action AND as a standalone wizard
+under Fusion Repairs > Configuration.
+"""
+
+import base64
+import io
+import logging
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class FusionRepairQRStickerWizard(models.TransientModel):
+ _name = 'fusion.repair.qr.sticker.wizard'
+ _description = 'QR Sticker Generator Wizard'
+
+ lot_ids = fields.Many2many(
+ 'stock.lot',
+ string='Serials',
+ help='One sticker will be generated per serial.',
+ )
+ product_id = fields.Many2one(
+ 'product.product',
+ string='Filter by Product',
+ help='Optional - limits the serial picker to lots of this product.',
+ )
+ company_id = fields.Many2one(
+ 'res.company',
+ default=lambda self: self.env.company,
+ )
+
+ def action_generate(self):
+ self.ensure_one()
+ if not self.lot_ids:
+ raise UserError(_('Select at least one serial number to print stickers for.'))
+ return self.env.ref('fusion_repairs.action_report_qr_stickers') \
+ .report_action(self)
+
+ # ------------------------------------------------------------------
+ # Helpers used by the QWeb report
+ # ------------------------------------------------------------------
+ def _portal_base_url(self):
+ ICP = self.env['ir.config_parameter'].sudo()
+ base = (ICP.get_param('web.base.url', '') or '').rstrip('/')
+ path = ICP.get_param('fusion_repairs.client_portal_url', '/repair') or '/repair'
+ return base + path
+
+ def get_sticker_url(self, lot):
+ """Return the full URL that the QR code on this sticker encodes."""
+ url = self._portal_base_url()
+ serial = (lot.name or '').strip()
+ return f"{url}?sn={serial}" if serial else url
+
+ def get_qr_data_uri(self, url, size=180):
+ """Return a base64 PNG data URI for the QR code of the given URL.
+
+ Uses the `qrcode` library if available (it's a transitive dep of many
+ Odoo modules); falls back to a simple ASCII placeholder if not so the
+ report still renders (with a warning).
+ """
+ try:
+ import qrcode
+ img = qrcode.make(url)
+ buf = io.BytesIO()
+ img.save(buf, format='PNG')
+ b64 = base64.b64encode(buf.getvalue()).decode('ascii')
+ return f"data:image/png;base64,{b64}"
+ except Exception as e:
+ _logger.warning('QR sticker generation failed for %s: %s', url, e)
+ return ""
diff --git a/fusion_repairs/wizard/qr_sticker_wizard_views.xml b/fusion_repairs/wizard/qr_sticker_wizard_views.xml
new file mode 100644
index 00000000..8af12f6b
--- /dev/null
+++ b/fusion_repairs/wizard/qr_sticker_wizard_views.xml
@@ -0,0 +1,39 @@
+
+
+
+
+ fusion.repair.qr.sticker.wizard.form
+ fusion.repair.qr.sticker.wizard
+
+
+
+
+
+ Each selected serial number prints as one sticker. Affix to
+ the equipment so the client can scan and submit a service request
+ without typing the serial.
+
+
+
+
+
+
+
+
+
+
+
+
+ Generate QR Stickers
+ fusion.repair.qr.sticker.wizard
+ form
+ new
+
+
+
diff --git a/fusion_repairs/wizard/repair_intake_wizard.py b/fusion_repairs/wizard/repair_intake_wizard.py
new file mode 100644
index 00000000..39451433
--- /dev/null
+++ b/fusion_repairs/wizard/repair_intake_wizard.py
@@ -0,0 +1,419 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Backend intake wizard.
+
+A simple Phase 1 transient model that captures one-or-many equipment items
+per call, then delegates to fusion.repair.intake.service to create the
+repair.order(s). The shared service guarantees identical behaviour to the
+sales rep portal and the public client portal added in later phases.
+
+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
+from datetime import timedelta
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class RepairIntakeWizard(models.TransientModel):
+ _name = 'fusion.repair.intake.wizard'
+ _description = 'Repair Intake Wizard'
+
+ # ------------------------------------------------------------------
+ # CALLER / CLIENT
+ # ------------------------------------------------------------------
+ intake_user_id = fields.Many2one(
+ 'res.users',
+ string='Taken By',
+ default=lambda self: self.env.user,
+ required=True,
+ )
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string='Client',
+ required=True,
+ help='Existing client. Use the create-and-edit dialog to add a new contact.',
+ )
+ partner_phone = fields.Char(
+ related='partner_id.phone',
+ string='Phone',
+ 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',
+ )
+ duplicate_window_days = fields.Integer(
+ compute='_compute_partner_context',
+ string='Duplicate Window (days)',
+ )
+ currency_id = fields.Many2one(
+ 'res.currency',
+ compute='_compute_partner_context',
+ string='Currency',
+ )
+ outstanding_balance = fields.Monetary(
+ compute='_compute_partner_context',
+ currency_field='currency_id',
+ 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.',
+ )
+
+ # ------------------------------------------------------------------
+ # Bundle 8: rush / emergency options + live surcharge preview
+ # ------------------------------------------------------------------
+ rush_requested = fields.Boolean(
+ string='Rush / Emergency Service',
+ help='Tick when the client needs faster-than-normal turnaround. '
+ 'Surcharge is calculated automatically from the rate card.',
+ )
+ rush_tier = fields.Selection(
+ [
+ ('same_day', 'Same Day (during business hours)'),
+ ('next_day', 'Next Day Priority'),
+ ('after_hours', 'After Hours (5pm-9pm weekday)'),
+ ('weekend', 'Weekend'),
+ ('holiday', 'Statutory Holiday'),
+ ],
+ string='Rush Tier',
+ )
+ rush_techs_required = fields.Integer(
+ string='Technicians Required',
+ default=1,
+ )
+ rush_surcharge_preview = fields.Monetary(
+ string='Quoted Surcharge',
+ compute='_compute_rush_surcharge_preview',
+ currency_field='currency_id',
+ )
+ rush_acknowledged = fields.Boolean(
+ string='Client Agreed to Price',
+ help='Tick this AFTER you have read the surcharge to the client over the '
+ 'phone and they have said yes. The repair will record the '
+ 'acknowledgement timestamp + your user id for audit.',
+ )
+
+ @api.depends('rush_tier', 'rush_techs_required', 'equipment_ids.repair_category_id')
+ def _compute_rush_surcharge_preview(self):
+ Rates = self.env['fusion.repair.emergency.charge'].sudo()
+ for w in self:
+ if not w.rush_tier or not w.equipment_ids:
+ w.rush_surcharge_preview = 0.0
+ continue
+ # Use the FIRST equipment's category for the preview - per-equipment
+ # surcharges land on each repair.order after create.
+ cat = w.equipment_ids[:1].repair_category_id
+ w.rush_surcharge_preview = Rates.calculate(
+ cat, w.rush_tier, w.rush_techs_required or 1,
+ )
+
+ # ------------------------------------------------------------------
+ # EQUIPMENT (one-or-many)
+ # ------------------------------------------------------------------
+ equipment_ids = fields.One2many(
+ 'fusion.repair.intake.wizard.equipment',
+ 'wizard_id',
+ string='Equipment Items',
+ 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
+
+ # Avoid sudo - CS users already have access to their own company's
+ # repairs/invoices via the standard groups + the Repairs Office rule.
+ Repair = self.env['repair.order']
+ Move = self.env['account.move']
+ company_ids = self.env.companies.ids
+ default_currency = self.env.company.currency_id
+ cutoff = fields.Datetime.now() - timedelta(days=window_days)
+
+ for w in self:
+ w.duplicate_window_days = window_days
+ 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
+ w.currency_id = default_currency
+ continue
+
+ # Multi-company scoped duplicate detection. search_count for the
+ # real total + search(limit=5) for the display list - so the banner
+ # never lies about a partner with >5 open calls.
+ dup_domain = [
+ ('partner_id', '=', w.partner_id.id),
+ ('state', 'not in', ('done', 'cancel')),
+ ('create_date', '>=', cutoff),
+ ('company_id', 'in', company_ids),
+ ]
+ w.duplicate_repair_ids = Repair.search(
+ dup_domain, order='create_date desc', limit=5,
+ )
+ w.duplicate_count = Repair.search_count(dup_domain)
+
+ # commercial_partner_id is the canonical "billed-to root" - covers
+ # child contacts AND walks up from a child if the caller IS a child.
+ commercial = w.partner_id.commercial_partner_id or w.partner_id
+ inv_domain = [
+ ('commercial_partner_id', '=', commercial.id),
+ ('move_type', '=', 'out_invoice'),
+ ('state', '=', 'posted'),
+ ('payment_state', 'in', ('not_paid', 'partial')),
+ ('company_id', 'in', company_ids),
+ ]
+ # _read_group pushes the SUM to Postgres - O(1) load vs O(N) records.
+ rows = Move._read_group(
+ inv_domain, aggregates=['amount_residual:sum', '__count'],
+ )
+ balance, invoice_count = rows[0] if rows else (0.0, 0)
+ w.currency_id = default_currency
+ w.outstanding_balance = balance or 0.0
+ w.outstanding_invoice_count = invoice_count or 0
+ w.show_outstanding_warning = (balance or 0.0) >= threshold
+
+ # ------------------------------------------------------------------
+ # SUBMIT
+ # ------------------------------------------------------------------
+ def action_submit(self):
+ self.ensure_one()
+ if not self.equipment_ids:
+ raise UserError(_('Please add at least one piece of equipment.'))
+
+ payload = {
+ 'partner_id': self.partner_id.id,
+ 'intake_user_id': self.intake_user_id.id,
+ 'quote_only': self.quote_only,
+ 'rush_requested': self.rush_requested,
+ 'rush_tier': self.rush_tier if self.rush_requested else False,
+ 'rush_techs_required': self.rush_techs_required if self.rush_requested else 1,
+ 'rush_acknowledged': self.rush_acknowledged,
+ 'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids],
+ }
+
+ # sudo() so sub-operations (mail.activity, mail.mail, fusion.technician.task)
+ # never trip on permission checks - x_fc_intake_user_id preserves audit identity.
+ repairs = self.env['fusion.repair.intake.service'].sudo().create_repair_orders(
+ payload, source='backend_wizard',
+ )
+
+ # If CS ticked "rush" and "client agreed", stamp the ack on every spawned repair.
+ if self.rush_requested and self.rush_acknowledged:
+ for r in repairs:
+ r.x_fc_rush_acknowledged_at = fields.Datetime.now()
+ r.x_fc_rush_acknowledged_by_id = self.intake_user_id.id or self.env.uid
+
+ if len(repairs) == 1:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': repairs.name,
+ 'res_model': 'repair.order',
+ 'view_mode': 'form',
+ 'res_id': repairs.id,
+ }
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Service Calls Created (%(count)s)', count=len(repairs)),
+ 'res_model': 'repair.order',
+ 'view_mode': 'list,form',
+ '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):
+ """Render an equipment record as a dict the intake service expects."""
+ return {
+ 'product_id': eq.product_id.id or False,
+ 'lot_id': eq.lot_id.id or False,
+ 'repair_category_id': eq.repair_category_id.id or False,
+ 'intake_template_id': eq.intake_template_id.id or False,
+ 'third_party': eq.third_party,
+ 'urgency': eq.urgency,
+ 'issue_summary': eq.issue_summary or '',
+ 'issue_category': eq.issue_category or '',
+ 'internal_notes': eq.internal_notes or '',
+ 'schedule_date': eq.scheduled_date or False,
+ 'photo_attachment_ids': eq.photo_ids.ids if eq.photo_ids else [],
+ 'answers': [], # Phase 1 wizard doesn't expose per-question answer rows yet
+ }
+
+
+class RepairIntakeWizardEquipment(models.TransientModel):
+ """A single piece of equipment captured in the wizard.
+
+ Multiple lines = multi-equipment intake (one repair.order per line).
+ """
+
+ _name = 'fusion.repair.intake.wizard.equipment'
+ _description = 'Repair Intake Wizard - Equipment Line'
+ _order = 'sequence, id'
+
+ wizard_id = fields.Many2one(
+ 'fusion.repair.intake.wizard',
+ string='Wizard',
+ required=True,
+ ondelete='cascade',
+ )
+ sequence = fields.Integer(default=10)
+
+ # Equipment identification
+ repair_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Category',
+ required=True,
+ )
+ product_id = fields.Many2one(
+ 'product.product',
+ string='Product',
+ help='Specific product if known. Leave blank for generic equipment.',
+ )
+ lot_id = fields.Many2one(
+ 'stock.lot',
+ string='Serial Number',
+ domain="[('product_id', '=', product_id)]",
+ help='Lot or serial number if known.',
+ )
+ third_party = fields.Boolean(
+ string='Not Purchased From Us',
+ help='Tick if this equipment was bought elsewhere - we still service it but '
+ 'warranty is not honoured and a service call-out fee applies.',
+ )
+
+ # Intake context
+ intake_template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Question Template',
+ help='Defaults to the template configured on the category if left blank.',
+ )
+
+ # Triage
+ urgency = fields.Selection(
+ [('normal', 'Normal'), ('urgent', 'Urgent'), ('safety', 'Safety Issue')],
+ string='Urgency',
+ default='normal',
+ required=True,
+ )
+ scheduled_date = fields.Datetime(
+ string='Preferred Date',
+ default=fields.Datetime.now,
+ )
+ issue_summary = fields.Char(
+ string='Issue Summary',
+ help='One-line summary of what is wrong (e.g. "stairlift stops halfway up").',
+ )
+ issue_category = fields.Char(
+ string='Symptom Category',
+ help='Optional symptom tag for catalogue matching (e.g. "battery", "motor").',
+ )
+ internal_notes = fields.Text(string='Internal Notes')
+
+ photo_ids = fields.Many2many(
+ 'ir.attachment',
+ 'fusion_repair_intake_wizard_eq_photo_rel',
+ 'eq_id',
+ 'attachment_id',
+ string='Photos / Videos',
+ )
+
+ @api.onchange('repair_category_id')
+ def _onchange_repair_category_id(self):
+ """Pre-fill the intake template from the category default."""
+ if self.repair_category_id and not self.intake_template_id:
+ self.intake_template_id = self.repair_category_id.intake_template_id
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ """Pre-fill the category from the product if defined."""
+ if self.product_id and not self.repair_category_id:
+ cat = self.product_id.product_tmpl_id.x_fc_repair_category_id
+ if cat:
+ self.repair_category_id = cat
diff --git a/fusion_repairs/wizard/repair_intake_wizard_views.xml b/fusion_repairs/wizard/repair_intake_wizard_views.xml
new file mode 100644
index 00000000..980cd4f5
--- /dev/null
+++ b/fusion_repairs/wizard/repair_intake_wizard_views.xml
@@ -0,0 +1,135 @@
+
+
+
+
+ fusion.repair.intake.wizard.form
+ fusion.repair.intake.wizard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Open repair already exists for this client
+ (
+ in last days).
+ Consider adding a note to the existing repair instead.
+
+
+
+
+
+
+
+
+
+
+ Outstanding balance:
+
+ across invoice(s).
+ Worth mentioning during this call.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Read the surcharge to the client and get verbal OK.
+ Then tick the "Client Agreed to Price" box above before submitting.
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/wizard/repair_visit_report_wizard.py b/fusion_repairs/wizard/repair_visit_report_wizard.py
new file mode 100644
index 00000000..4f57b553
--- /dev/null
+++ b/fusion_repairs/wizard/repair_visit_report_wizard.py
@@ -0,0 +1,654 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Technician visit report wizard.
+
+Opened from a completed (or in-progress) repair.order. Captures:
+- labour hours
+- parts/consumables used
+- recommended upsell products
+- optional client signature
+
+On confirm:
+- writes labour + parts as repair.order lines (Odoo native operations)
+- updates x_fc_actual_cost on the repair
+- triggers variance reconciliation (sets x_fc_requires_requote if over threshold)
+- if not requote: confirms the repair (state='under_repair' -> 'done' via Odoo native flow)
+- offers an action_collect_payment shortcut to fire Poynt on the resulting invoice
+"""
+
+import logging
+from datetime import timedelta
+
+from markupsafe import Markup
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class RepairVisitReportWizard(models.TransientModel):
+ _name = 'fusion.repair.visit.report.wizard'
+ _description = 'Repair Visit Report Wizard'
+
+ repair_id = fields.Many2one(
+ 'repair.order',
+ string='Repair Order',
+ required=True,
+ readonly=True,
+ )
+ technician_id = fields.Many2one(
+ 'res.users',
+ string='Technician',
+ default=lambda self: self.env.user,
+ domain="[('x_fc_is_field_staff', '=', True)]",
+ )
+
+ # Labour
+ labour_hours = fields.Float(
+ string='Labour Hours',
+ required=True,
+ default=1.0,
+ )
+
+ # Parts used (simple line model below)
+ parts_line_ids = fields.One2many(
+ 'fusion.repair.visit.report.wizard.line',
+ 'wizard_id',
+ string='Parts Used',
+ )
+
+ # Outcome
+ notes = fields.Html(string='Technician Notes')
+ found_another_issue = fields.Boolean(
+ string='Found Another Issue',
+ help='Tick to spawn a follow-up repair after saving this visit.',
+ )
+
+ # M1: tick when the visit was a safety inspection. On save the wizard
+ # creates a fusion.repair.inspection.certificate.
+ issue_inspection_cert = fields.Boolean(
+ string='Issue Compliance Certificate',
+ help='Tick when the visit was an annual safety inspection. Creates an '
+ 'inspection certificate record and prints the PDF on save.',
+ )
+ inspection_cert_id = fields.Many2one(
+ 'fusion.repair.inspection.certificate',
+ string='Issued Certificate',
+ readonly=True,
+ )
+
+ # ----- T4 client signature -----
+ client_signature = fields.Binary(
+ string='Client Signature',
+ attachment=True,
+ help='Captured via signature widget on tech mobile - proves the '
+ 'client accepted the work.',
+ )
+ client_signature_name = fields.Char(
+ string='Signed By',
+ help='Type the client name as they signed (for the audit log).',
+ )
+
+ # ----- T7 no-show photo proof -----
+ no_show = fields.Boolean(
+ string='Client No-Show',
+ help='Tick if the client was not present. Forces a no-show photo.',
+ )
+ no_show_photo = fields.Binary(
+ string='No-Show Photo',
+ attachment=True,
+ help='Photo of the door / driveway proving the technician attended.',
+ )
+
+ # ----- T6 parts replaced - serial capture -----
+ parts_serial_capture = fields.Text(
+ string='Replaced Parts - Serials',
+ help='One serial per line. Used for OEM warranty claims.',
+ )
+
+ # ----- Bundle 8: Cannot Fix Today - Needs Parts -----
+ outcome = fields.Selection(
+ [
+ ('completed', 'Repair Complete - Close It'),
+ ('parts_needed', "Can't Fix Today - Need to Order Parts"),
+ ('rescheduled', 'Could Not Reach / Rescheduled'),
+ ],
+ string='Visit Outcome',
+ default='completed',
+ required=True,
+ help='Drives what happens after you submit: completed -> closes the '
+ "repair; parts_needed -> captures the part info, emails the client, "
+ "schedules follow-up; rescheduled -> repair stays open.",
+ )
+ needs_parts_line_ids = fields.One2many(
+ 'fusion.repair.visit.report.wizard.partline',
+ 'wizard_id',
+ string='Parts To Order',
+ help='ONE line per distinct part. Description + OEM number + photos go to '
+ 'procurement so they can place the manufacturer order from your input '
+ 'alone.',
+ )
+
+ # ----- Bundle 9: callout pricing + warranty -----
+ callout_distance_km = fields.Float(
+ related='repair_id.x_fc_callout_distance_km',
+ string='One-Way Distance (km)',
+ readonly=False,
+ help='Distance from shop to client. Beyond the rate-card threshold, '
+ 'EVERY km is billed BOTH WAYS, per tech.',
+ )
+ callout_techs = fields.Integer(
+ related='repair_id.x_fc_callout_techs',
+ string='Technicians on Callout',
+ readonly=False,
+ )
+ callout_tier = fields.Selection(
+ related='repair_id.x_fc_callout_tier',
+ string='Callout Tier',
+ readonly=False,
+ )
+ callout_in_shop = fields.Boolean(
+ related='repair_id.x_fc_in_shop',
+ string='In-Shop Repair',
+ readonly=False,
+ )
+ callout_labor_hours_used = fields.Float(
+ string='Repair Hours (after 30 min inspection)',
+ default=1.0,
+ help='Total hours of REPAIR WORK after the 30 minutes the callout fee covers. '
+ 'Minimum 1 hour is billed even if the actual fix took less.',
+ )
+ quote_total_preview = fields.Monetary(
+ related='repair_id.x_fc_quote_total',
+ currency_field='company_currency_id',
+ readonly=True,
+ )
+ quote_breakdown_preview = fields.Text(
+ related='repair_id.x_fc_quote_breakdown_text',
+ readonly=True,
+ )
+ labor_warranty_status_preview = fields.Selection(
+ related='repair_id.x_fc_labor_warranty_status',
+ readonly=True,
+ )
+ labor_warranty_id_preview = fields.Many2one(
+ related='repair_id.x_fc_labor_warranty_id',
+ readonly=True,
+ )
+ # Void path: tech finds misuse / negligence -> warranty is void
+ warranty_void_reason = fields.Selection(
+ [
+ ('user_negligence', 'User Negligence'),
+ ('gross_negligence', 'Gross Negligence'),
+ ('misuse', 'Misuse'),
+ ('over_recommended_use', 'Over-Recommended Use'),
+ ('accidental_damage', 'Accidental Damage'),
+ ],
+ string='Void Warranty Reason',
+ help='If you find evidence the unit was misused, pick the reason. The '
+ 'matching labor warranty record (if any) is voided permanently '
+ 'and the client is billed full labor.',
+ )
+ warranty_void_notes = fields.Text(string='Void Notes')
+
+ # Variance display
+ estimated_cost = fields.Monetary(
+ related='repair_id.x_fc_estimated_cost',
+ currency_field='company_currency_id',
+ readonly=True,
+ )
+ actual_cost = fields.Monetary(
+ string='Actual Cost',
+ compute='_compute_actual_cost',
+ currency_field='company_currency_id',
+ )
+ variance_pct = fields.Float(
+ string='Variance %',
+ compute='_compute_actual_cost',
+ )
+ requires_requote = fields.Boolean(
+ compute='_compute_actual_cost',
+ )
+
+ company_currency_id = fields.Many2one(
+ 'res.currency',
+ related='repair_id.company_currency_id',
+ readonly=True,
+ )
+
+ @api.depends('labour_hours', 'parts_line_ids.subtotal', 'repair_id.x_fc_estimated_cost')
+ def _compute_actual_cost(self):
+ ICP = self.env['ir.config_parameter'].sudo()
+ try:
+ threshold_pct = float(ICP.get_param('fusion_repairs.variance_threshold_pct', '20'))
+ except (ValueError, TypeError):
+ threshold_pct = 20.0
+ try:
+ threshold_amt = float(ICP.get_param('fusion_repairs.variance_threshold_amount', '100'))
+ except (ValueError, TypeError):
+ threshold_amt = 100.0
+
+ for w in self:
+ catalog = w.repair_id.x_fc_service_catalog_id
+ labour_rate = 0.0
+ if catalog and catalog.service_product_id:
+ labour_rate = catalog.service_product_id.list_price
+ parts_total = sum(w.parts_line_ids.mapped('subtotal'))
+ w.actual_cost = (w.labour_hours * labour_rate) + parts_total
+ est = w.estimated_cost or 0.0
+ variance_pct = ((w.actual_cost - est) / est * 100) if est else 0.0
+ w.variance_pct = variance_pct
+ # One-sided: only OVER-cost triggers re-quote. Coming in under
+ # estimate is good news and must not block invoicing.
+ over_pct = variance_pct
+ over_amt = w.actual_cost - est
+ w.requires_requote = est > 0 and (
+ over_pct >= threshold_pct or over_amt >= threshold_amt
+ )
+
+ # ------------------------------------------------------------------
+ # ACTION
+ # ------------------------------------------------------------------
+ def action_confirm(self):
+ self.ensure_one()
+ repair = self.repair_id
+ if not repair:
+ raise UserError(_('No repair selected.'))
+
+ # Create native repair operations (stock moves) for the parts used.
+ # 'add' type moves consume parts from the parts source location and
+ # flow through to the invoice when action_create_sale_order() is run.
+ self._create_repair_part_moves(repair)
+
+ # Persist actual cost + requote flag on the repair.
+ repair.write({
+ 'x_fc_actual_cost': self.actual_cost,
+ 'x_fc_requires_requote': self.requires_requote,
+ # Bundle 9 - persist hours the tech actually worked + resolve warranty
+ 'x_fc_callout_labor_hours': self.callout_labor_hours_used,
+ })
+
+ # Bundle 9: resolve labor warranty + apply void reason if the tech
+ # found misuse during the visit.
+ repair.action_check_labor_warranty()
+ if self.warranty_void_reason and repair.x_fc_labor_warranty_id:
+ repair.x_fc_labor_warranty_id.action_void(
+ reason=self.warranty_void_reason,
+ notes=self.warranty_void_notes or '',
+ )
+ repair.x_fc_labor_warranty_status = 'void_misuse'
+ repair.message_post(body=Markup(_(
+ 'Warranty VOIDED on this visit. Reason: %(r)s. '
+ 'Full labor charged.'
+ )) % {'r': dict(self._fields['warranty_void_reason'].selection).get(
+ self.warranty_void_reason)})
+
+ # Append technician notes to chatter.
+ if self.notes:
+ repair.message_post(body=self.notes)
+
+ # Spawn a follow-up repair if the tech found another issue.
+ stub = False
+ if self.found_another_issue:
+ stub = repair.copy({
+ 'state': 'draft',
+ 'internal_notes': _(
+ '
Spawned from visit report on %(ref)s. Add details for the new issue.
',
+ ref=repair.name,
+ ),
+ 'x_fc_intake_source': 'manual',
+ 'x_fc_intake_session_id': repair.x_fc_intake_session_id,
+ 'x_fc_estimated_cost': 0.0,
+ 'x_fc_actual_cost': 0.0,
+ 'x_fc_requires_requote': False,
+ 'x_fc_intake_template_id': False,
+ 'x_fc_service_catalog_id': False,
+ 'x_fc_maintenance_contract_id': False,
+ })
+ repair.message_post(
+ body=Markup(_(
+ 'Spawned follow-up repair %(name)s for "found another issue".'
+ )) % {'name': stub.name or ''},
+ )
+
+ # M1: issue an inspection certificate when the box is ticked
+ # AND the equipment is safety-critical (stairlift / porch lift / power chair).
+ if self.issue_inspection_cert:
+ self._create_inspection_certificate(repair)
+
+ # T4 / T6 / T7: persist captured artefacts as ir.attachment on the
+ # repair so they survive the wizard close.
+ self._persist_mobile_artefacts(repair)
+
+ # M5: burn a pre-paid service plan visit if the client has one and
+ # the repair is a maintenance visit. The wizard intentionally does NOT
+ # zero out the client's invoice line - the office still posts the
+ # invoice; the burn is informational + the office reconciles credits
+ # in their accounting flow.
+ if not repair.x_fc_is_quote_only:
+ self._burn_service_plan_visit(repair)
+
+ # Bundle 8: parts-needed branch - capture the parts, flag the repair,
+ # email the client, leave the repair OPEN with awaiting_parts substate.
+ if self.outcome == 'parts_needed':
+ self._handle_parts_needed(repair)
+ elif self.outcome == 'rescheduled':
+ repair.message_post(body=Markup(_(
+ 'Visit reported as rescheduled. Repair kept open.'
+ )))
+ # BUG-B1 fix: actually close the repair so the whole downstream chain
+ # (NPS cron, dashboard "done this month" stats, customer survey) fires.
+ # Leave open if requote needed - the office will re-quote and the tech
+ # will revisit. No-show / parts-needed / rescheduled / quote-only also
+ # stay open.
+ elif (self.outcome == 'completed'
+ and not self.requires_requote
+ and not self.no_show
+ and not repair.x_fc_is_quote_only
+ and not stub):
+ self._close_repair(repair)
+ elif self.no_show:
+ repair.message_post(body=Markup(_(
+ 'Repair kept open due to no-show. Office to reschedule.'
+ )))
+ elif self.requires_requote:
+ repair.message_post(body=Markup(_(
+ 'Repair kept open pending re-quote (variance flag).'
+ )))
+
+ # If a stub was spawned, open it directly so the tech can fill in details.
+ # Otherwise, if a certificate was issued, jump to it so the tech can print.
+ if stub:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': stub.name,
+ 'res_model': 'repair.order',
+ 'view_mode': 'form',
+ 'res_id': stub.id,
+ }
+ if self.inspection_cert_id:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.inspection_cert_id.name,
+ 'res_model': 'fusion.repair.inspection.certificate',
+ 'view_mode': 'form',
+ 'res_id': self.inspection_cert_id.id,
+ }
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': repair.name,
+ 'res_model': 'repair.order',
+ 'view_mode': 'form',
+ 'res_id': repair.id,
+ }
+
+ def _persist_mobile_artefacts(self, repair):
+ """T4/T6/T7: attach signature image, no-show photo, and serial list
+ to the repair so they survive after the transient wizard closes."""
+ Attachment = self.env['ir.attachment'].sudo()
+ if self.client_signature:
+ Attachment.create({
+ 'name': f'signature-{repair.name}.png',
+ 'datas': self.client_signature,
+ 'res_model': 'repair.order',
+ 'res_id': repair.id,
+ 'mimetype': 'image/png',
+ })
+ who = self.client_signature_name or repair.partner_id.name or ''
+ repair.message_post(body=Markup(_(
+ 'Client signature captured (%s).'
+ )) % who)
+ if self.no_show:
+ if self.no_show_photo:
+ Attachment.create({
+ 'name': f'no-show-{repair.name}.jpg',
+ 'datas': self.no_show_photo,
+ 'res_model': 'repair.order',
+ 'res_id': repair.id,
+ 'mimetype': 'image/jpeg',
+ })
+ repair.message_post(body=Markup(_(
+ 'Visit recorded as client no-show%s.'
+ )) % (' (photo attached)' if self.no_show_photo else ''))
+ if self.parts_serial_capture and self.parts_serial_capture.strip():
+ repair.message_post(body=Markup(_(
+ 'Replaced part serials captured:
%s
'
+ )) % self.parts_serial_capture.strip())
+
+ def _handle_parts_needed(self, repair):
+ """Capture each part line as a fusion.repair.part.order record,
+ flag the repair as Awaiting Parts, and email the client a
+ "we found the problem - here's the timeline" note."""
+ if not self.needs_parts_line_ids:
+ raise UserError(_(
+ 'Tick "Can\'t Fix Today - Need to Order Parts" but no parts '
+ 'are captured. Add at least one part line so procurement can '
+ 'place the order.'
+ ))
+ PartOrder = self.env['fusion.repair.part.order'].sudo()
+ Attachment = self.env['ir.attachment'].sudo()
+ max_lead = 0
+ for line in self.needs_parts_line_ids:
+ # Copy any uploaded photos onto attachments owned by the part order.
+ photo_ids = []
+ for att in line.photo_ids:
+ copied = Attachment.create({
+ 'name': att.name,
+ 'datas': att.datas,
+ 'mimetype': att.mimetype,
+ })
+ photo_ids.append(copied.id)
+ part = PartOrder.create({
+ 'repair_order_id': repair.id,
+ 'description': line.description,
+ 'oem_part_number': line.oem_part_number,
+ 'manufacturer': line.manufacturer,
+ 'quantity': line.quantity or 1.0,
+ 'notes': line.notes,
+ 'photo_ids': [(6, 0, photo_ids)] if photo_ids else False,
+ 'expected_date': line.expected_lead_days and (
+ fields.Date.context_today(self)
+ + timedelta(days=line.expected_lead_days)
+ ) or False,
+ })
+ max_lead = max(max_lead, int(line.expected_lead_days or 0))
+ repair.write({
+ 'x_fc_parts_awaiting': True,
+ 'x_fc_parts_eta_date': (
+ fields.Date.context_today(self) + timedelta(days=max_lead + 2)
+ if max_lead else False
+ ),
+ })
+ # Office activity - "place these orders today".
+ repair.activity_schedule(
+ summary='Order parts from manufacturer(s)',
+ note=_('Tech captured %d part(s) - place the order(s) today.'
+ ) % len(self.needs_parts_line_ids),
+ user_id=repair.user_id.id or self.env.uid,
+ )
+ # Client comms.
+ tpl = self.env.ref(
+ 'fusion_repairs.email_template_repair_awaiting_parts',
+ raise_if_not_found=False,
+ )
+ if tpl and repair.partner_id and repair.partner_id.email:
+ try:
+ tpl.send_mail(repair.id, force_send=False)
+ except Exception:
+ _logger.exception('Awaiting-parts email failed for %s', repair.name)
+ repair.message_post(body=Markup(_(
+ 'Visit reported as parts needed. Captured %(n)d part order(s); '
+ 'repair flagged "Awaiting Parts". Client notified.'
+ )) % {'n': len(self.needs_parts_line_ids)})
+
+ def _close_repair(self, repair):
+ """Drive the Odoo native state machine from draft -> done.
+
+ Odoo 19 sequence: draft -> action_validate (confirmed/under_repair)
+ -> action_repair_start (under_repair) -> action_repair_end (done).
+ Calls are guarded - silently re-runs only the missing steps.
+ """
+ try:
+ if repair.state == 'draft':
+ # action_validate is the standard entry path; if the product is
+ # storable it expects reservations etc., so fall back to the
+ # simpler _action_repair_confirm() helper if validate refuses.
+ try:
+ repair.action_validate()
+ except Exception as e:
+ _logger.info(
+ 'action_validate skipped for %s: %s; using internal confirm.',
+ repair.name, e,
+ )
+ repair._action_repair_confirm()
+ if repair.state == 'confirmed':
+ repair.action_repair_start()
+ if repair.state == 'under_repair':
+ repair.action_repair_end()
+ repair.message_post(body=Markup(_(
+ 'Visit report submitted - repair closed by %s.'
+ )) % (self.technician_id.name or self.env.user.name))
+ except Exception as e:
+ _logger.exception(
+ 'Visit report could not close repair %s automatically: %s',
+ repair.name, e,
+ )
+ repair.message_post(body=Markup(_(
+ 'Could not auto-close repair: %s. Office must close manually.'
+ )) % str(e))
+
+ def _burn_service_plan_visit(self, repair):
+ """M5: deduct one visit from the most-recently-active service plan
+ covering this repair. Quietly no-ops if the client has no plan."""
+ Plan = self.env['fusion.repair.service.plan.subscription'].sudo()
+ sub = Plan.find_for_repair(repair)
+ if sub:
+ sub.burn_visit(repair)
+
+ def _create_inspection_certificate(self, repair):
+ """M1: create the inspection certificate. Requires a safety-critical
+ equipment category - otherwise just logs to chatter and skips."""
+ category = repair.x_fc_repair_category_id
+ if not category or not category.safety_critical:
+ repair.message_post(body=_(
+ 'Inspection certificate skipped - equipment category is not '
+ 'flagged as safety_critical. Only stairlifts, porch lifts, '
+ 'and power wheelchairs receive annual certificates.'
+ ))
+ return
+ if not repair.product_id:
+ repair.message_post(body=_(
+ 'Inspection certificate skipped - the repair has no product set.'
+ ))
+ return
+ Cert = self.env['fusion.repair.inspection.certificate'].sudo()
+ cert = Cert.create({
+ 'partner_id': repair.partner_id.id,
+ 'product_id': repair.product_id.id,
+ 'lot_id': repair.lot_id.id if repair.lot_id else False,
+ 'repair_order_id': repair.id,
+ 'inspector_user_id': self.technician_id.id or self.env.uid,
+ })
+ self.inspection_cert_id = cert
+ repair.message_post(body=_(
+ 'Issued inspection certificate %s (expires %s).'
+ ) % (cert.name, cert.expiry_date))
+
+ def _create_repair_part_moves(self, repair):
+ """Create stock.move records for each part used (repair_line_type='add').
+
+ Locations follow the repair order's configured source / parts locations;
+ Odoo natively links these moves to the SO line generated by
+ action_create_sale_order() so they invoice correctly.
+ """
+ Move = self.env['stock.move'].sudo()
+ for line in self.parts_line_ids:
+ if not line.product_id or line.quantity <= 0:
+ continue
+ vals = {
+ 'name': line.product_id.display_name,
+ 'product_id': line.product_id.id,
+ 'product_uom_qty': line.quantity,
+ 'product_uom': line.product_id.uom_id.id,
+ 'repair_id': repair.id,
+ 'repair_line_type': 'add',
+ 'location_id': repair.location_id.id,
+ 'location_dest_id': repair.parts_location_id.id or repair.location_id.id,
+ 'company_id': repair.company_id.id,
+ }
+ try:
+ Move.create(vals)
+ except Exception as e:
+ _logger.warning(
+ 'Could not create repair part move on %s for %s: %s',
+ repair.name, line.product_id.display_name, e,
+ )
+
+
+class RepairVisitReportWizardLine(models.TransientModel):
+ _name = 'fusion.repair.visit.report.wizard.line'
+ _description = 'Repair Visit Report Wizard - Part Line'
+
+ wizard_id = fields.Many2one(
+ 'fusion.repair.visit.report.wizard',
+ required=True,
+ ondelete='cascade',
+ )
+ product_id = fields.Many2one(
+ 'product.product',
+ string='Part',
+ required=True,
+ )
+ quantity = fields.Float(default=1.0, required=True)
+ unit_price = fields.Float(string='Unit Price')
+ subtotal = fields.Float(compute='_compute_subtotal', store=True)
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ if self.product_id:
+ self.unit_price = self.product_id.list_price
+
+ @api.depends('quantity', 'unit_price')
+ def _compute_subtotal(self):
+ for line in self:
+ line.subtotal = line.quantity * line.unit_price
+
+
+class RepairVisitReportWizardPartLine(models.TransientModel):
+ """Bundle 8: parts the tech needs the office to ORDER from the manufacturer.
+
+ Captured during the visit report when outcome='parts_needed'; one record per
+ distinct part. On wizard confirm, each line creates a
+ fusion.repair.part.order which is the procurement-facing record.
+ """
+ _name = 'fusion.repair.visit.report.wizard.partline'
+ _description = 'Visit Report - Part to Order'
+
+ wizard_id = fields.Many2one(
+ 'fusion.repair.visit.report.wizard',
+ required=True,
+ ondelete='cascade',
+ )
+ description = fields.Char(
+ string='Description',
+ required=True,
+ help='Plain English (e.g. "Handicare 1100 right armrest").',
+ )
+ oem_part_number = fields.Char(string='OEM #')
+ manufacturer = fields.Char(string='Manufacturer')
+ quantity = fields.Float(default=1.0, required=True)
+ expected_lead_days = fields.Integer(
+ string='Lead Time (days)',
+ default=7,
+ help='Tech estimate. Office uses this to set client ETA expectations.',
+ )
+ notes = fields.Text(string='Notes for Procurement')
+ photo_ids = fields.Many2many(
+ 'ir.attachment',
+ 'fusion_repair_visit_partline_photo_rel',
+ 'partline_id', 'attachment_id',
+ string='Photos',
+ )
diff --git a/fusion_repairs/wizard/repair_visit_report_wizard_views.xml b/fusion_repairs/wizard/repair_visit_report_wizard_views.xml
new file mode 100644
index 00000000..bc1703c0
--- /dev/null
+++ b/fusion_repairs/wizard/repair_visit_report_wizard_views.xml
@@ -0,0 +1,138 @@
+
+
+
+
+ fusion.repair.visit.report.wizard.form
+ fusion.repair.visit.report.wizard
+
+
+
+
+
+
diff --git a/fusion_service_charges/__init__.py b/fusion_service_charges/__init__.py
new file mode 100644
index 00000000..ba959657
--- /dev/null
+++ b/fusion_service_charges/__init__.py
@@ -0,0 +1,196 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+"""Fusion — Service Charges.
+
+Seeds the service-billing product catalog (Service Call, Labour,
+Delivery, Stairlift set-up, etc.) on FIRST install only.
+
+Architecture decision: no data XML in the manifest. All products are
+created imperatively in a post_init_hook. This guarantees:
+
+ - Users can edit prices / names / accounting tags freely after
+ install — upgrades won't overwrite them.
+ - Users can delete products that don't apply to their shop —
+ upgrades won't resurrect them (Odoo's "noupdate=1" doesn't
+ actually prevent re-creation when the ir.model.data row is
+ missing, only updates; see fusion_plating 19.0.20.5.0 hook for
+ the same pattern + investigation).
+ - Re-installing the module after uninstall DOES re-seed (the
+ ir.model.data sentinels are dropped on uninstall, so the next
+ install's hook treats it as a fresh seed).
+
+Per-shop pricing — currently identical on Westin Healthcare and
+Mobility Specialties; if they diverge we can add a setting per
+shop or a pricelist override.
+"""
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Rate schedule — single source of truth.
+#
+# Each row creates one product.template via the post_init_hook.
+# Tuple structure: (xmlid_suffix, name, category, uom_xmlid, list_price,
+# description)
+#
+# Per-km surcharges (Rush / Outside Local / After-Hours) are noted in
+# the description so the dispatcher knows to add a km line manually.
+# A formula-based pricelist would automate this but is out of scope —
+# matching today's manual workflow on both shops.
+# ---------------------------------------------------------------------------
+_SERVICE_CHARGES = [
+ # (xmlid_suffix, name, default_code, uom_xmlid, price, description)
+
+ # ---- Standard Service ----
+ ('standard_service_call',
+ 'Standard Service Call',
+ 'SVC-STD-CALL', 'uom.product_uom_unit', 95.00,
+ 'Service Call — appointment outside a Westin Healthcare location. '
+ 'Billed once per service request. Includes the first 30 minutes '
+ 'of labour; additional time billed at the Standard Labour Rate. '
+ 'Excludes parts (covered by manufacturer warranty when applicable).'),
+ ('standard_labour',
+ 'Standard Labour (Hourly)',
+ 'SVC-STD-LABOUR', 'uom.product_uom_hour', 85.00,
+ 'Standard hourly labour rate. Pro-rated in 30-minute increments. '
+ 'Starts after the 30 minutes included in the Service Call. '
+ 'Applies per technician when multiple are on the job.'),
+ ('in_shop_labour',
+ 'In-Shop Labour (Hourly)',
+ 'SVC-INSHOP-LABOUR', 'uom.product_uom_hour', 75.00,
+ 'Hourly labour rate when the work is done at the Westin Healthcare '
+ 'shop instead of on-site. Pro-rated in 30-minute increments.'),
+ ('rush_service_call',
+ 'Rush Service Call',
+ 'SVC-RUSH-CALL', 'uom.product_uom_unit', 120.00,
+ 'Rush dispatch — same-day / priority response. Adds $0.70 per '
+ 'km (2-way) on top of this flat fee; add the mileage as a '
+ 'separate line.'),
+ ('after_hours_service_call',
+ 'After-Hours Service Call',
+ 'SVC-AH-CALL', 'uom.product_uom_unit', 140.00,
+ 'Service call outside standard business hours. Adds $0.70 per '
+ 'km (2-way) on top of this flat fee; add the mileage as a '
+ 'separate line.'),
+
+ # ---- Lift & Elevating Service ----
+ ('lift_service_call',
+ 'Lift & Elevating Service Call',
+ 'SVC-LIFT-CALL', 'uom.product_uom_unit', 160.00,
+ 'Service Call for stairlift / lift / elevating equipment. '
+ 'Includes the first 30 minutes of labour. Excludes parts '
+ 'unless covered by manufacturer warranty.'),
+ ('lift_labour',
+ 'Lift & Elevating Labour (Hourly)',
+ 'SVC-LIFT-LABOUR', 'uom.product_uom_hour', 110.00,
+ 'Hourly labour rate for stairlift / lift / elevating equipment. '
+ 'Pro-rated in 30-minute increments. Per-technician.'),
+
+ # ---- Delivery / Pickup ----
+ ('delivery_local',
+ 'Local Delivery / Pickup',
+ 'DEL-LOCAL', 'uom.product_uom_unit', 35.00,
+ 'Drop-off or pick-up within Brampton.'),
+ ('delivery_outside_local',
+ 'Outside Local Delivery / Pickup',
+ 'DEL-OUT', 'uom.product_uom_unit', 60.00,
+ 'Drop-off or pick-up outside Brampton.'),
+ ('delivery_rush',
+ 'Rush Delivery / Pickup',
+ 'DEL-RUSH', 'uom.product_uom_unit', 60.00,
+ 'Same-day delivery or pickup. Adds $0.70 per km (2-way) on top '
+ 'of this flat fee; add the mileage as a separate line.'),
+ ('delivery_lift_chair',
+ 'Lift Chair Delivery + Set-up',
+ 'DEL-LIFT-CHAIR', 'uom.product_uom_unit', 120.00,
+ 'Delivery and in-home set-up of a lift chair.'),
+ ('delivery_hospital_bed',
+ 'Hospital Bed Delivery + Set-up',
+ 'DEL-HOSP-BED', 'uom.product_uom_unit', 120.00,
+ 'Delivery and in-home set-up of a hospital bed.'),
+ ('delivery_stairlift',
+ 'Stairlift Delivery + Set-up',
+ 'DEL-STAIRLIFT', 'uom.product_uom_unit', 300.00,
+ 'Delivery and installation of a stairlift.'),
+ ('removal_stairlift',
+ 'Stairlift Removal',
+ 'SVC-STAIRLIFT-RM', 'uom.product_uom_unit', 300.00,
+ 'On-site removal of an existing stairlift.'),
+]
+
+
+def post_init_hook(env):
+ _seed_service_charges_once(env)
+
+
+def _seed_service_charges_once(env):
+ """Create product.template rows for the service catalog.
+
+ Idempotent — each row guarded by an ir.model.data xmlid check.
+ If the xmlid already resolves to a record, that product is left
+ alone (its price / name / accounting tags may have been edited
+ by the shop). New rows are created with an ir.model.data sentinel
+ so a future run sees them as already-seeded.
+
+ Re-running the hook by hand:
+ env['ir.module.module'].search([('name', '=', 'fusion_service_charges')]).button_upgrade()
+ # (post_init_hook only fires on first install in Odoo 19; for
+ # a re-seed you'd uninstall+reinstall, which is fine because
+ # ir.model.data is dropped on uninstall)
+ """
+ Product = env['product.template'].sudo()
+ IMD = env['ir.model.data'].sudo()
+ module_name = 'fusion_service_charges'
+
+ created = []
+ skipped = []
+ for (xmlid_suffix, name, default_code, uom_xmlid, price,
+ description) in _SERVICE_CHARGES:
+ existing = IMD.search([
+ ('module', '=', module_name),
+ ('name', '=', xmlid_suffix),
+ ], limit=1)
+ if existing:
+ skipped.append(default_code)
+ continue
+ uom = env.ref(uom_xmlid, raise_if_not_found=False)
+ if not uom:
+ _logger.warning(
+ 'fusion_service_charges: UoM %s not found, '
+ 'falling back to product_uom_unit', uom_xmlid,
+ )
+ uom = env.ref('uom.product_uom_unit')
+ # Odoo 19 retired uom_po_id on product.template — uom_id is the
+ # single source of truth for sale + purchase.
+ product = Product.create({
+ 'name': name,
+ 'type': 'service',
+ 'default_code': default_code,
+ 'list_price': price,
+ 'sale_ok': True,
+ 'purchase_ok': False,
+ 'uom_id': uom.id,
+ 'description_sale': description,
+ })
+ IMD.create({
+ 'module': module_name,
+ 'name': xmlid_suffix,
+ 'model': 'product.template',
+ 'res_id': product.id,
+ 'noupdate': True,
+ })
+ created.append(default_code)
+
+ if created:
+ _logger.info(
+ 'fusion_service_charges: seeded %d product(s) — %s',
+ len(created), ', '.join(created),
+ )
+ if skipped:
+ _logger.info(
+ 'fusion_service_charges: skipped %d existing product(s) — %s',
+ len(skipped), ', '.join(skipped),
+ )
diff --git a/fusion_service_charges/__manifest__.py b/fusion_service_charges/__manifest__.py
new file mode 100644
index 00000000..e6daaa6b
--- /dev/null
+++ b/fusion_service_charges/__manifest__.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+{
+ 'name': 'Fusion — Service Charges',
+ 'version': '19.0.1.0.0',
+ 'category': 'Sales',
+ 'summary': (
+ 'Standard service-call, labour, delivery, and installation '
+ 'products for Westin Healthcare and Mobility Specialties.'
+ ),
+ 'description': """
+Fusion — Service Charges
+==========================
+
+Seeds the service-billing product catalog used by Westin Healthcare
+and Mobility Specialties:
+
+* Standard Service: Service Call, Labour (hourly), In-Shop Labour,
+ Rush Service Call, After-Hours Service Call
+* Lift & Elevating Service: Service Call, Labour (hourly)
+* Delivery / Pickup: Local, Outside Local Area, Rush, Lift Chair
+ set-up, Hospital Bed set-up, Stairlift set-up, Stairlift Removal
+
+Loading pattern (deliberate):
+
+* Products are created via post_init_hook on FIRST install only.
+* No data XML is registered in the manifest, so ``-u`` upgrades
+ never touch the records. Edits and deletions made by sales/ops
+ survive every upgrade.
+* The hook is idempotent — sentinel xmlid check skips already-
+ seeded products. Re-running the hook by hand is safe.
+* Re-installing the module after uninstall re-creates the products
+ (the ir.model.data sentinels go away on uninstall, so the next
+ install treats it as fresh).
+
+Per-km surcharges (Rush, Outside Local) ARE captured on the
+product as a hint in the product description; actual km billing
+is left as a manual SO-line tweak by the dispatcher (matches
+current shop workflow — formula-based pricing would need a
+sale.order.line.onchange to compute, out of scope here).
+""",
+ 'author': 'Nexa Systems Inc.',
+ 'website': 'https://www.nexasystems.ca',
+ 'license': 'OPL-1',
+ 'depends': [
+ 'product',
+ 'uom',
+ ],
+ # Empty on purpose — no data XML. See the docstring on
+ # _seed_service_charges_once() for why every product is created
+ # imperatively via the post_init_hook.
+ 'data': [],
+ 'post_init_hook': 'post_init_hook',
+ 'installable': True,
+ 'application': False,
+ 'auto_install': False,
+}