diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index 2e16d015..731d1b74 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.1.8.0', + 'version': '19.0.1.9.1', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ @@ -75,6 +75,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'data/repair_product_category_data.xml', 'data/intake_template_data.xml', 'data/self_check_data.xml', + 'data/emergency_charge_data.xml', # Views 'views/repair_product_category_views.xml', 'views/intake_template_views.xml', @@ -82,8 +83,10 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'views/repair_warranty_views.xml', 'views/maintenance_contract_views.xml', 'views/repair_dashboard_views.xml', + 'views/repair_emergency_charge_views.xml', 'views/repair_inspection_views.xml', 'views/repair_order_views.xml', + 'views/repair_part_order_views.xml', 'views/repair_service_plan_views.xml', 'views/sale_order_views.xml', 'views/technician_task_views.xml', diff --git a/fusion_repairs/data/emergency_charge_data.xml b/fusion_repairs/data/emergency_charge_data.xml new file mode 100644 index 00000000..a1a8dc6a --- /dev/null +++ b/fusion_repairs/data/emergency_charge_data.xml @@ -0,0 +1,91 @@ + + + + + + + + + same_day + 250.00 + 0.5 + Same-day stairlift dispatch. Squeezed into today's route. + + + + after_hours + 350.00 + 0.5 + + + + weekend + 450.00 + 0.5 + + + + + + same_day + 300.00 + 0.5 + + + + weekend + 500.00 + 0.5 + + + + + + same_day + 175.00 + 0.6 + Bed lifts often need 2 techs (one to hold, one to wrench). + + + + after_hours + 275.00 + 0.6 + + + + + + same_day + 200.00 + 0.5 + + + + + + same_day + 120.00 + 0.5 + + + + + + same_day + 150.00 + 0.5 + + + + weekend + 275.00 + 0.5 + + + + diff --git a/fusion_repairs/data/ir_sequence_data.xml b/fusion_repairs/data/ir_sequence_data.xml index f378e8fb..f58b5f26 100644 --- a/fusion_repairs/data/ir_sequence_data.xml +++ b/fusion_repairs/data/ir_sequence_data.xml @@ -24,6 +24,17 @@ + + + Repair Part Order + fusion.repair.part.order + PART- + 5 + 1 + 1 + + + Service Plan Subscription diff --git a/fusion_repairs/data/mail_template_data.xml b/fusion_repairs/data/mail_template_data.xml index 25881230..562fad8c 100644 --- a/fusion_repairs/data/mail_template_data.xml +++ b/fusion_repairs/data/mail_template_data.xml @@ -143,6 +143,158 @@ + + + + + Repair: Rush Squeeze - Tech Alert + + URGENT: {{ object.partner_id.name or 'rush client' }} added to your route - {{ object.name }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + +
+
+
+

+ Rush stop added to your day +

+

A rush call was squeezed into your route

+

+ 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: +

+
    +
  1. We order the parts from the manufacturer today.
  2. +
  3. When the parts arrive at our warehouse, we'll email you with a confirmed visit date.
  4. +
  5. You don't need to do anything in the meantime.
  6. +
+
+

+ 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. +

+

Reference:

+
+
+
+ {{ object.partner_id.lang }} + +
+ diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py index 05ebd5ab..2710f941 100644 --- a/fusion_repairs/models/__init__.py +++ b/fusion_repairs/models/__init__.py @@ -14,6 +14,8 @@ 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 product_template from . import res_partner from . import res_users diff --git a/fusion_repairs/models/intake_service.py b/fusion_repairs/models/intake_service.py index 324580a7..2d79bf7f 100644 --- a/fusion_repairs/models/intake_service.py +++ b/fusion_repairs/models/intake_service.py @@ -70,6 +70,9 @@ class FusionRepairIntakeService(models.AbstractModel): 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( @@ -79,6 +82,9 @@ class FusionRepairIntakeService(models.AbstractModel): source=source, item=item, quote_only=quote_only, + rush_requested=rush_requested, + rush_tier=rush_tier, + rush_techs_required=rush_techs_required, ) repairs |= repair @@ -107,7 +113,9 @@ class FusionRepairIntakeService(models.AbstractModel): # ------------------------------------------------------------------ @api.model def _create_single_repair(self, partner_id, intake_user, session_ref, - source, item, quote_only=False): + 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') @@ -123,6 +131,9 @@ class FusionRepairIntakeService(models.AbstractModel): '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: @@ -432,11 +443,17 @@ class FusionRepairIntakeService(models.AbstractModel): '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._pick_dispatch_technician(repair) + 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 ' 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_order.py b/fusion_repairs/models/repair_order.py index 07d4e46b..911d348c 100644 --- a/fusion_repairs/models/repair_order.py +++ b/fusion_repairs/models/repair_order.py @@ -7,6 +7,8 @@ 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 @@ -163,6 +165,293 @@ class RepairOrder(models.Model): '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', + ) + + @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. 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/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv index ad3c9531..45dfc323 100644 --- a/fusion_repairs/security/ir.model.access.csv +++ b/fusion_repairs/security/ir.model.access.csv @@ -35,3 +35,11 @@ access_service_plan_sub_dispatcher,Service Plan Sub Dispatcher,model_fusion_repa access_service_plan_sub_manager,Service Plan Sub Manager Full,model_fusion_repair_service_plan_subscription,group_fusion_repairs_manager,1,1,1,1 access_service_plan_burn_user,Service Plan Burn User Read,model_fusion_repair_service_plan_burn,group_fusion_repairs_user,1,0,0,0 access_service_plan_burn_manager,Service Plan Burn Manager Full,model_fusion_repair_service_plan_burn,group_fusion_repairs_manager,1,1,1,1 +access_emergency_charge_user,Emergency Charge User Read,model_fusion_repair_emergency_charge,group_fusion_repairs_user,1,0,0,0 +access_emergency_charge_manager,Emergency Charge Manager Full,model_fusion_repair_emergency_charge,group_fusion_repairs_manager,1,1,1,1 +access_part_order_user,Part Order User Read,model_fusion_repair_part_order,group_fusion_repairs_user,1,0,0,0 +access_part_order_dispatcher,Part Order Dispatcher,model_fusion_repair_part_order,group_fusion_repairs_dispatcher,1,1,1,0 +access_part_order_manager,Part Order Manager Full,model_fusion_repair_part_order,group_fusion_repairs_manager,1,1,1,1 +access_part_order_technician,Part Order Field Tech Create,model_fusion_repair_part_order,fusion_tasks.group_field_technician,1,1,1,0 +access_visit_report_partline_user,Visit Report Part Line User Full,model_fusion_repair_visit_report_wizard_partline,group_fusion_repairs_user,1,1,1,1 +access_visit_report_partline_tech,Visit Report Part Line Field Tech Full,model_fusion_repair_visit_report_wizard_partline,fusion_tasks.group_field_technician,1,1,1,1 diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml index e4f02d84..ddb4bac1 100644 --- a/fusion_repairs/views/menus.xml +++ b/fusion_repairs/views/menus.xml @@ -51,6 +51,18 @@ action="action_service_plan_subscription" sequence="37"/> + + + + + + + + fusion.repair.emergency.charge.list + fusion.repair.emergency.charge + + + + + + + + + + + + + + + Emergency Surcharges + fusion.repair.emergency.charge + list + + + diff --git a/fusion_repairs/views/repair_order_views.xml b/fusion_repairs/views/repair_order_views.xml index 03085cda..a4caa604 100644 --- a/fusion_repairs/views/repair_order_views.xml +++ b/fusion_repairs/views/repair_order_views.xml @@ -31,6 +31,21 @@ icon="fa-handshake-o" invisible="state in ('done', 'cancel')" groups="fusion_repairs.group_fusion_repairs_user"/> + +