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