Files
Odoo-Modules/fusion_plating/fusion_plating_quality/models/fp_rma.py
gsinghpal f08f328688 changes
2026-04-27 00:11:18 -04:00

776 lines
29 KiB
Python

# -*- 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']
_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/<id> 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
# ------------------------------------------------------------------
@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'] = self._default_name()
return super().create(vals_list)
# ------------------------------------------------------------------
# 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('<p><strong>RMA %s — auto-created from customer return.</strong></p>') % self.name
+ Markup(complaint or '<p>(no description)</p>')
)
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: <b>scrap</b>. %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: <b>scrap</b> 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 <b>%s</b>.') % label,
message_type='comment',
subtype_xmlid='mail.mt_note',
)