# -*- 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)})