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