# -*- 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. Bundle 11 extension: tech often orders the part directly from the factory WHILE on the phone. They want to: - Pick the vendor from our contacts (filtered to vendors) - Capture the OEM part #, cost, ETA date - Auto-create a draft purchase.order line (office reviews + sends) - Capture the factory's ticket # + RA # for tracking and warranty claims - Tell the system whether the factory said it's under warranty """ 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__) SPARE_PARTS_CATEGORY_NAME = 'Spare Parts' 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, ) # Bundle 11: vendor + PO + cost + factory tracking refs. vendor_partner_id = fields.Many2one( 'res.partner', string='Vendor', tracking=True, domain="[('is_company', '=', True)]", help='Pick from our existing vendors. Filtered to companies; the tech ' 'usually knows the factory by name (Handicare, Bruno, Pride...).', ) purchase_order_id = fields.Many2one( 'purchase.order', string='Draft Purchase Order', readonly=True, copy=False, ondelete='set null', help='Auto-created in DRAFT state when the tech submits a part order ' 'with a vendor + cost. Office reviews and sends it to the factory.', ) product_id = fields.Many2one( 'product.product', string='Product', readonly=True, copy=False, help='Auto-resolved or created based on the OEM part number.', ) unit_cost = fields.Monetary( string='Unit Cost', currency_field='currency_id', tracking=True, help='Per-unit cost from the factory. Used as price_unit on the draft PO.', ) currency_id = fields.Many2one( 'res.currency', default=lambda self: self.env.company.currency_id, ) internal_po_ref = fields.Char( string='PO Reference (read to factory)', tracking=True, help='The Westin PO number the tech read to the factory for tracking. ' 'Stamped on the draft purchase.order as partner_ref.', ) factory_ticket_ref = fields.Char( string='Factory Ticket #', tracking=True, help='Ticket number from the factory call - used for follow-up enquiries.', ) factory_ra_number = fields.Char( string='Factory RA #', tracking=True, help='Return Authorization number, when the factory issued a warranty ' 'replacement that requires the old part returned.', ) under_warranty = fields.Boolean( string='Factory Warranty', tracking=True, help='Tick when the factory confirmed warranty coverage on the call.', ) 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_create_draft_po(self): """Bundle 11: build a draft purchase.order for this part. Idempotent - returns the existing PO if one is already linked. Resolves / creates the product from the OEM number, sets the line cost from unit_cost, stamps Westin's internal PO ref on the purchase.order's partner_ref so the factory can find it. Office reviews + confirms via the normal Odoo flow. """ self.ensure_one() if self.purchase_order_id: return self._open_record(self.purchase_order_id) if not self.vendor_partner_id: raise UserError(_( 'Pick a Vendor first - the draft PO needs to know who to send to.' )) product = self._resolve_or_create_product() PO = self.env['purchase.order'].sudo() # Let Odoo derive product_uom from the product to avoid Odoo 19's # uom_po_id moving between template/variant: passing product_id is # enough for the PO line's onchange to populate the UOM correctly. line_vals = { 'product_id': product.id, 'name': self.description or product.display_name, 'product_qty': self.quantity or 1.0, 'price_unit': self.unit_cost or 0.0, 'date_planned': fields.Datetime.now() + timedelta( days=7 if not self.expected_date else max((self.expected_date - fields.Date.context_today(self)).days, 0) ), } try: order = PO.create({ 'partner_id': self.vendor_partner_id.id, 'partner_ref': self.internal_po_ref or False, 'origin': self.repair_order_id.name or self.name, 'company_id': self.company_id.id, 'order_line': [(0, 0, line_vals)], }) except Exception as e: _logger.exception('Could not create draft PO for part %s: %s', self.name, e) raise UserError(_( 'Could not create the draft purchase order: %s. The part info ' 'was saved - you can create the PO manually from Purchase > ' 'Orders > New.' ) % e) self.write({ 'purchase_order_id': order.id, 'product_id': product.id, }) self.message_post(body=Markup(_( 'Draft purchase order %(po)s created with %(vendor)s. ' 'Office to review and send.' )) % {'po': order.name, 'vendor': self.vendor_partner_id.name}) return self._open_record(order) def _resolve_or_create_product(self): """Find product.product by OEM part number (default_code) or create a new service-type product in the 'Spare Parts' category. Returns the product record.""" self.ensure_one() Product = self.env['product.product'].sudo() if self.product_id: return self.product_id if self.oem_part_number: hit = Product.search([ ('default_code', '=', self.oem_part_number), ], limit=1) if hit: return hit # Create new. Use "service" type so it doesn't need inventory tracking, # purchaseable + can be added to PO lines without warehouse setup. ProductCategory = self.env['product.category'].sudo() category = ProductCategory.search([ ('name', '=', SPARE_PARTS_CATEGORY_NAME), ], limit=1) if not category: category = ProductCategory.create({'name': SPARE_PARTS_CATEGORY_NAME}) return Product.create({ 'name': self.description or (self.oem_part_number or 'Spare Part'), 'default_code': self.oem_part_number or False, 'type': 'consu', 'purchase_ok': True, 'sale_ok': False, 'categ_id': category.id, 'standard_price': self.unit_cost or 0.0, }) def _open_record(self, record): return { 'type': 'ir.actions.act_window', 'name': record.display_name, 'res_model': record._name, 'view_mode': 'form', 'res_id': record.id, } 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() rec._schedule_eta_activity() 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 _schedule_eta_activity(self): """Bundle 11: schedule an activity on the repair so the office is reminded on the ETA day to call the client back and book the return visit.""" for rec in self: repair = rec.repair_order_id if not repair or not rec.expected_date: continue try: act_type = self.env.ref( 'fusion_repairs.mail_activity_type_assign_technician', raise_if_not_found=False, ) repair.activity_schedule( activity_type_id=act_type.id if act_type else False, date_deadline=rec.expected_date, summary=_('Parts arriving (%s) - schedule re-visit') % rec.name, note=_( 'Part %(ref)s (%(desc)s) from %(vendor)s is expected ' 'today. Call the client to confirm a return visit.' ) % { 'ref': rec.name, 'desc': rec.description or '', 'vendor': rec.vendor_partner_id.name or rec.manufacturer or '?', }, user_id=repair.user_id.id or self.env.uid, ) except Exception: _logger.exception( 'Could not schedule ETA activity for part %s on repair %s', rec.name, repair.name, ) 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)})