# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. # # fp.rma — Return Material Authorisation. # # Sub 12 Phase A. Internal-only RMA workflow that ties customer returns to # the existing NCR / CAPA / Hold stack. Portal submission is deferred to a # future sub-project; for now an internal user opens the RMA on behalf of # the customer. # # Lifecycle: # draft -> authorised -> shipped_to_us -> received -> triaged -> # resolving -> resolved -> closed # \ # -> cancelled (manager only, any state) # # Auto-spawn rules at the `received` transition (driven by fp.receiving): # - if auto_spawn_ncr (default True) -> create fusion.plating.ncr # - if auto_spawn_hold (default True) -> create fusion.plating.quality.hold # A manager can flip either toggle off before saving the RMA. import base64 import io import logging from markupsafe import Markup from odoo import _, api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class FpRma(models.Model): _name = 'fusion.plating.rma' _description = 'Fusion Plating — Return Material Authorisation' _inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin'] _order = 'create_date desc, id desc' _rec_name = 'name' name = fields.Char( string='Reference', required=True, copy=False, readonly=True, default=lambda self: self._default_name(), tracking=True, ) state = fields.Selection( [ ('draft', 'Draft'), ('authorised', 'Authorised'), ('shipped_to_us', 'Customer Shipped'), ('received', 'Received at Shop'), ('triaged', 'Triaged'), ('resolving', 'Resolving'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('cancelled', 'Cancelled'), ], string='Status', default='draft', required=True, tracking=True, index=True, ) # ------------------------------------------------------------------ # Customer + originating order # ------------------------------------------------------------------ partner_id = fields.Many2one( 'res.partner', string='Customer', required=True, tracking=True, domain=[('customer_rank', '>', 0)], ) sale_order_id = fields.Many2one( 'sale.order', string='Original Sale Order', required=True, tracking=True, domain="[('partner_id', '=', partner_id)]", help='The order being returned. Required so cert/part/coating ' 'context follows the return through triage and resolution.', ) sale_order_line_ids = fields.Many2many( 'sale.order.line', 'fp_rma_sol_rel', 'rma_id', 'sol_id', string='Returned Lines', domain="[('order_id', '=', sale_order_id)]", help='Subset of the original SO lines that the customer is ' 'returning. Used to pull part/cert context.', ) original_job_ids = fields.Many2many( 'fp.job', string='Original Jobs', compute='_compute_original_job_ids', store=False, help='Jobs derived from the SO. Navigation-only.', ) company_id = fields.Many2one( 'res.company', default=lambda self: self.env.company, readonly=True, ) # ------------------------------------------------------------------ # Why and how bad # ------------------------------------------------------------------ trigger_source = fields.Selection( [ ('customer_complaint', 'Customer Complaint'), ('qc_fail_post_ship', 'Post-Shipment QC Failure'), ('inspection_post_delivery', 'Customer Inspection Post-Delivery'), ('other', 'Other'), ], string='Trigger', default='customer_complaint', required=True, tracking=True, ) severity = fields.Selection( [ ('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical'), ], string='Severity', default='medium', required=True, tracking=True, ) complaint_description = fields.Html( string='Customer Complaint', help='What the customer reported.', ) triage_findings = fields.Html( string='Triage Findings', help='What we found on inspection after receiving the parts.', ) # ------------------------------------------------------------------ # Resolution # ------------------------------------------------------------------ resolution_type = fields.Selection( [ ('replace', 'Replace'), ('rework', 'Rework'), ('refund', 'Refund'), ('scrap', 'Scrap'), ], string='Resolution', tracking=True, ) resolution_notes = fields.Html(string='Resolution Notes') replacement_job_id = fields.Many2one( 'fp.job', string='Replacement Job', ondelete='set null', help='New plating job created for replace/rework resolutions.', ) refund_invoice_id = fields.Many2one( 'account.move', string='Refund / Credit Note', ondelete='set null', domain="[('move_type', '=', 'out_refund')]", ) # ------------------------------------------------------------------ # Inbound logistics # ------------------------------------------------------------------ inbound_receiving_id = fields.Many2one( 'fp.receiving', string='Inbound Receiving', ondelete='set null', help='Receiving record auto-created when the carrier delivers ' 'the returned parts.', ) inbound_picking_id = fields.Many2one( 'stock.picking', string='Inbound Picking', ondelete='set null', ) qty_returned = fields.Integer( string='Qty Returned', tracking=True, help='Total units the customer is returning per the authorisation.', ) qty_received = fields.Integer( string='Qty Received', tracking=True, help='Counted on receipt at our dock.', ) customer_tracking = fields.Char( string='Customer Tracking #', help='Outbound tracking from the customer back to us.', ) our_tracking = fields.Char( string='Our Tracking #', help='Tracking number for the replacement / return shipment ' 'from our shop.', ) carrier_id = fields.Many2one('delivery.carrier', string='Carrier') # ------------------------------------------------------------------ # QR + auto-spawn toggles # ------------------------------------------------------------------ qr_code = fields.Binary( string='QR Code', compute='_compute_qr_code', store=False, help='Encodes /fp/rma/ for the customer authorisation PDF.', ) auto_spawn_ncr = fields.Boolean( string='Auto-create NCR on Receipt', default=True, tracking=True, help='When the carrier delivers the returned parts and an ' 'fp.receiving is created against this RMA, an NCR is ' 'spawned automatically. Manager can toggle off — the ' 'change is tracked on the chatter for audit.', ) auto_spawn_hold = fields.Boolean( string='Auto-place Hold on Receipt', default=True, tracking=True, help='Same trigger as auto_spawn_ncr but creates an ' 'fusion.plating.quality.hold for the returned qty.', ) # ------------------------------------------------------------------ # Linked records (cross-domain) # ------------------------------------------------------------------ linked_ncr_ids = fields.One2many( 'fusion.plating.ncr', 'rma_id', string='NCRs', ) linked_hold_ids = fields.One2many( 'fusion.plating.quality.hold', 'rma_id', string='Holds', ) linked_capa_ids = fields.Many2many( 'fusion.plating.capa', string='CAPAs', compute='_compute_linked_capa_ids', store=False, ) ncr_count = fields.Integer(compute='_compute_link_counts') hold_count = fields.Integer(compute='_compute_link_counts') capa_count = fields.Integer(compute='_compute_link_counts') active = fields.Boolean(default=True) # ------------------------------------------------------------------ # Phase B placeholders (categorisation) — added now so views won't # break when Phase B lands. Kept as M2O/M2M to models added later. # ------------------------------------------------------------------ # tag_ids, reason_id, team_id, stage_id are added in Phase B. # ------------------------------------------------------------------ # Defaults / create / name # ------------------------------------------------------------------ # Parent-numbered mixin hooks. RMAs reach the SO directly via # sale_order_id (set at create-time from the original order). def _fp_parent_sale_order(self): return self.sale_order_id def _fp_name_prefix(self): return 'RMA' def _fp_parent_counter_field(self): return 'x_fc_pn_rma_count' @api.model def _default_name(self): seq = self.env['ir.sequence'].next_by_code('fusion.plating.rma') return seq or '/' @api.model_create_multi def create(self, vals_list): for vals in vals_list: if not vals.get('name') or vals.get('name') == '/': vals['name'] = 'New' records = super().create(vals_list) for rec in records: if rec.name and rec.name != 'New': continue if not rec._fp_assign_parent_name(): seq = self.env['ir.sequence'].next_by_code('fusion.plating.rma') or 'New' self.env.cr.execute( "UPDATE fusion_plating_rma SET name = %s WHERE id = %s", (seq, rec.id), ) rec.invalidate_recordset(['name']) return records # ------------------------------------------------------------------ # Computes # ------------------------------------------------------------------ @api.depends('sale_order_id', 'sale_order_line_ids') def _compute_original_job_ids(self): Job = self.env['fp.job'] for rec in self: if not rec.sale_order_id: rec.original_job_ids = False continue rec.original_job_ids = Job.search([ ('sale_order_id', '=', rec.sale_order_id.id), ]) @api.depends('linked_ncr_ids.capa_ids') def _compute_linked_capa_ids(self): for rec in self: rec.linked_capa_ids = rec.linked_ncr_ids.mapped('capa_ids') @api.depends( 'linked_ncr_ids', 'linked_hold_ids', 'linked_capa_ids', ) def _compute_link_counts(self): for rec in self: rec.ncr_count = len(rec.linked_ncr_ids) rec.hold_count = len(rec.linked_hold_ids) rec.capa_count = len(rec.linked_capa_ids) @api.depends('name') def _compute_qr_code(self): try: import qrcode except ImportError: for rec in self: rec.qr_code = False return base = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url', '', ) for rec in self: if not rec.id: rec.qr_code = False continue url = f'{base}/fp/rma/{rec.id}' buf = io.BytesIO() img = qrcode.make(url) img.save(buf, format='PNG') rec.qr_code = base64.b64encode(buf.getvalue()) # ------------------------------------------------------------------ # Lifecycle actions # ------------------------------------------------------------------ def action_authorise(self): for rec in self: if not rec.sale_order_line_ids: raise UserError(_( 'Select at least one returned line on RMA %s before ' 'authorising.' ) % rec.display_name) if rec.qty_returned <= 0: raise UserError(_( 'RMA %s needs a returned quantity > 0 before ' 'authorising.' ) % rec.display_name) rec.state = 'authorised' rec._post_state_message('Authorised') rec._fire_rma_notification('rma_authorised') def action_mark_shipped_to_us(self): for rec in self: if rec.state != 'authorised': raise UserError(_( 'RMA %s must be Authorised before marking it as ' 'shipped by the customer.' ) % rec.display_name) rec.state = 'shipped_to_us' rec._post_state_message('Customer Shipped') def action_mark_received(self): """Manual fallback when an inbound fp.receiving was not auto-linked.""" for rec in self: if rec.state not in ('authorised', 'shipped_to_us'): raise UserError(_( 'RMA %s must be Authorised or Shipped before being ' 'marked Received.' ) % rec.display_name) rec._enter_received_state(receiving=False) def _enter_received_state(self, receiving=None): """Common receive-side hook. Called either: - from action_mark_received (manual) - from fp.receiving.create override when rma_id was set Flips state to `received` and (optionally) spawns NCR + Hold per the auto_spawn_* toggles. Idempotent — re-entry on an already- received RMA is a no-op (no double-spawn on ORM retry / split deliveries). """ for rec in self: if rec.state == 'received': continue rec.state = 'received' spawned = [] if rec.auto_spawn_ncr: ncr = rec._spawn_ncr() if ncr: spawned.append(_('NCR %s') % ncr.name) if rec.auto_spawn_hold: hold = rec._spawn_hold() if hold: spawned.append(_('Hold %s') % hold.name) label = 'Received' if spawned: label += ' — auto-spawned ' + ', '.join(spawned) rec._post_state_message(label) # Customer notification: parts arrived at the shop. rec._fire_rma_notification('rma_received') def _spawn_ncr(self): self.ensure_one() Ncr = self.env['fusion.plating.ncr'] # Idempotency: if an NCR for this RMA already exists, return it. existing = Ncr.search([('rma_id', '=', self.id)], limit=1) if existing: return existing partner = self.partner_id # Pull a facility — prefer the partner's company facility, fall # back to the first active facility. Facility = self.env['fusion.plating.facility'] facility = ( Facility.search([('company_id', '=', self.company_id.id)], limit=1) or Facility.search([], limit=1) ) if not facility: _logger.warning( 'RMA %s: no fusion.plating.facility found, NCR spawn ' 'skipped', self.name, ) return False part_ref = ', '.join( self.sale_order_line_ids.mapped('product_id.default_code') or [] ) or self.sale_order_line_ids[:1].product_id.display_name or '/' complaint = self.complaint_description or '' body = ( Markup('

RMA %s — auto-created from customer return.

') % self.name + Markup(complaint or '

(no description)

') ) ncr = Ncr.create({ 'facility_id': facility.id, 'source': 'customer', 'severity': self.severity or 'medium', 'part_ref': part_ref[:64], 'quantity_affected': self.qty_received or self.qty_returned or 0, 'description': body, 'customer_partner_id': partner.id, 'rma_id': self.id, }) return ncr def _spawn_hold(self): self.ensure_one() Hold = self.env['fusion.plating.quality.hold'] # Idempotency: one auto-Hold per RMA. existing = Hold.search([('rma_id', '=', self.id)], limit=1) if existing: return existing Facility = self.env['fusion.plating.facility'] facility = ( Facility.search([('company_id', '=', self.company_id.id)], limit=1) or Facility.search([], limit=1) ) part_ref = ( self.sale_order_line_ids[:1].product_id.default_code or self.sale_order_line_ids[:1].product_id.display_name or self.name ) hold = Hold.create({ 'part_ref': part_ref[:64], 'qty_on_hold': self.qty_received or self.qty_returned or 0, 'qty_original': self.qty_returned or 0, 'hold_reason': 'customer_complaint', 'description': ( f'Auto-created from RMA {self.name}. ' f'Returned parts on hold pending triage.' ), 'facility_id': facility.id if facility else False, 'rma_id': self.id, }) return hold def action_triage_complete(self): for rec in self: if rec.state != 'received': raise UserError(_( 'RMA %s must be Received before triage can be ' 'completed.' ) % rec.display_name) if not rec.resolution_type: raise UserError(_( 'Set a Resolution (replace / rework / refund / scrap) ' 'on RMA %s before completing triage.' ) % rec.display_name) rec.state = 'triaged' rec._post_state_message('Triaged') def action_start_resolving(self): for rec in self: if rec.state != 'triaged': raise UserError(_( 'RMA %s must be Triaged before resolution work can ' 'start.' ) % rec.display_name) rec.state = 'resolving' rec._post_state_message('Resolving') def action_resolve(self): """Trigger resolution-specific side-effects then flip to resolved. For replace/rework/scrap: spawn the side-effect, flip state. For refund: open the credit-note wizard. State stays at `resolving` until the wizard runs and the accountant links the credit note via action_link_refund (or the AccountMove write hook auto-links by invoice_origin). """ for rec in self: if rec.state not in ('triaged', 'resolving'): raise UserError(_( 'RMA %s must be Triaged or Resolving before being ' 'marked Resolved.' ) % rec.display_name) # Refund path needs a wizard return — handle separately. refund_recs = self.filtered(lambda r: r.resolution_type == 'refund') if len(refund_recs) > 1: raise UserError(_( 'Resolve refund RMAs one at a time so the credit-note ' 'wizard can be filled in.' )) if refund_recs: return refund_recs._resolve_refund() # Non-refund paths: fire side-effect then flip state. for rec in self: handler = { 'replace': rec._resolve_replace, 'rework': rec._resolve_rework, 'scrap': rec._resolve_scrap, }.get(rec.resolution_type) if not handler: raise UserError(_( 'No handler for resolution type "%s" on RMA %s.' ) % (rec.resolution_type, rec.display_name)) handler() rec.state = 'resolved' rec._post_state_message( f'Resolved ({rec.resolution_type})', ) rec._fire_rma_notification('rma_resolved') def _resolve_replace(self): return self._spawn_replacement_job(reason='replace') def _resolve_rework(self): return self._spawn_replacement_job(reason='rework') def _spawn_replacement_job(self, reason='replace'): self.ensure_one() Job = self.env['fp.job'] if self.replacement_job_id: return self.replacement_job_id first = self.original_job_ids[:1] if not first: _logger.info( 'RMA %s: no originating fp.job to clone; creating bare ' 'replacement job.', self.name, ) new_job = Job.create({ 'partner_id': self.partner_id.id, 'sale_order_id': self.sale_order_id.id, 'origin': self.sale_order_id.name or self.name, 'qty': self.qty_returned or 1, }) else: new_job = first.copy({ 'origin': f'{self.name} (RMA {reason})', 'qty': self.qty_returned or first.qty, 'state': 'draft', }) # Drop cloned-from-source steps and regenerate from the # recipe so the rework starts fresh (every step pending, # no inherited timelogs / actuals / completion flags). if hasattr(new_job, 'step_ids') and new_job.step_ids: new_job.step_ids.unlink() if hasattr(new_job, '_generate_steps_from_recipe') \ and new_job.recipe_id: new_job._generate_steps_from_recipe() self.replacement_job_id = new_job.id # Auto-confirm so the portal mirror, racking inspection and # 'job_confirmed' notification all fire — same as a normal job. if hasattr(new_job, 'action_confirm') and new_job.state == 'draft': try: new_job.action_confirm() except Exception as e: _logger.warning( 'RMA %s: replacement job %s auto-confirm failed (%s); ' 'leaving in draft.', self.name, new_job.name, e, ) return new_job def _resolve_refund(self): self.ensure_one() if self.refund_invoice_id: return self.refund_invoice_id # Open the standard refund wizard pre-filled to the original SO. # We don't auto-confirm — accountant verifies amounts first. invoices = self.env['account.move'].search([ ('invoice_origin', '=', self.sale_order_id.name), ('move_type', '=', 'out_invoice'), ], limit=1) if not invoices: raise UserError(_( 'RMA %s: no posted invoice found for SO %s — cannot ' 'create a credit note automatically. Issue refund ' 'manually.' ) % (self.display_name, self.sale_order_id.name)) return { 'name': _('Credit Note'), 'type': 'ir.actions.act_window', 'res_model': 'account.move.reversal', 'view_mode': 'form', 'target': 'new', 'context': { 'active_model': 'account.move', 'active_ids': invoices.ids, 'default_reason': f'RMA {self.name}', 'default_journal_id': invoices.journal_id.id, }, } def _resolve_scrap(self): # NB: spec calls for an fp.job.consumption row with source='rma_scrap' # but fp.job.consumption requires product_id and there's no curated # "scrap" product yet. Phase E will surface scrap via the Monthly # Quality Summary report instead. For now, just narrate. self.ensure_one() qty = self.qty_received or self.qty_returned or 0 self.message_post( body=Markup( 'Resolution: scrap. %s units written off via RMA %s.' ) % (qty, self.name), message_type='comment', subtype_xmlid='mail.mt_note', ) for ncr in self.linked_ncr_ids: ncr.message_post( body=Markup('Resolution: scrap via RMA %s.') % self.name, message_type='comment', subtype_xmlid='mail.mt_note', ) def action_close(self): for rec in self: if rec.state != 'resolved': raise UserError(_( 'RMA %s must be Resolved before it can be closed.' ) % rec.display_name) open_ncrs = rec.linked_ncr_ids.filtered( lambda n: n.state != 'closed' ) if open_ncrs: raise UserError(_( 'RMA %s has open NCRs (%s). Close the NCRs first.' ) % ( rec.display_name, ', '.join(open_ncrs.mapped('name')), )) open_holds = rec.linked_hold_ids.filtered( lambda h: h.state in ('on_hold', 'under_review') ) if open_holds: raise UserError(_( 'RMA %s still has active Holds (%s). Release, scrap, ' 'or send to rework before closing the RMA.' ) % ( rec.display_name, ', '.join(open_holds.mapped('name')), )) rec.state = 'closed' rec._post_state_message('Closed') def _fire_rma_notification(self, event): """Best-effort notification dispatch via fp.notification.template. Silently skips if fusion_plating_notifications is absent or no template is configured for this trigger event. Failures never block the RMA state machine. """ if 'fp.notification.template' not in self.env: return Tpl = self.env['fp.notification.template'].sudo() for rec in self: partner = rec.partner_id if not partner: continue try: Tpl._dispatch( event, rec, partner, sale_order=rec.sale_order_id, ) except Exception as e: _logger.warning( 'RMA %s: notification %s failed: %s', rec.name, event, e, ) def action_cancel(self): is_manager = self.env.user.has_group( 'fusion_plating.group_fusion_plating_manager' ) if not is_manager: raise UserError(_( 'Only Plating Managers can cancel an RMA.' )) for rec in self: if rec.state == 'closed': raise UserError(_( 'RMA %s is already closed and cannot be cancelled.' ) % rec.display_name) rec.state = 'cancelled' rec._post_state_message('Cancelled') # ------------------------------------------------------------------ # Smart-button actions # ------------------------------------------------------------------ def action_view_ncrs(self): self.ensure_one() return { 'name': _('NCRs'), 'type': 'ir.actions.act_window', 'res_model': 'fusion.plating.ncr', 'view_mode': 'list,form', 'domain': [('rma_id', '=', self.id)], 'context': { 'default_rma_id': self.id, 'default_customer_partner_id': self.partner_id.id, 'default_source': 'customer', }, } def action_view_holds(self): self.ensure_one() return { 'name': _('Holds'), 'type': 'ir.actions.act_window', 'res_model': 'fusion.plating.quality.hold', 'view_mode': 'list,form', 'domain': [('rma_id', '=', self.id)], 'context': {'default_rma_id': self.id}, } def action_view_capas(self): self.ensure_one() return { 'name': _('CAPAs'), 'type': 'ir.actions.act_window', 'res_model': 'fusion.plating.capa', 'view_mode': 'list,form', 'domain': [('id', 'in', self.linked_capa_ids.ids)], } def action_view_sale_order(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'sale.order', 'view_mode': 'form', 'res_id': self.sale_order_id.id, } def action_view_replacement_job(self): self.ensure_one() if not self.replacement_job_id: return False return { 'type': 'ir.actions.act_window', 'res_model': 'fp.job', 'view_mode': 'form', 'res_id': self.replacement_job_id.id, } def action_view_refund(self): self.ensure_one() if not self.refund_invoice_id: return False return { 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'view_mode': 'form', 'res_id': self.refund_invoice_id.id, } def action_view_inbound_receiving(self): self.ensure_one() if not self.inbound_receiving_id: return False return { 'type': 'ir.actions.act_window', 'res_model': 'fp.receiving', 'view_mode': 'form', 'res_id': self.inbound_receiving_id.id, } # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _post_state_message(self, label): for rec in self: rec.message_post( body=Markup('RMA status changed to %s.') % label, message_type='comment', subtype_xmlid='mail.mt_note', )