Hold derives parent via job_id.sale_order_id; RMA via sale_order_id directly — both get HOLD-<parent> / RMA-<parent> names. NCR and CAPA have no SO link in core, so they fall back to their legacy sequences (NCR/YYYY/NNN, CAPA/YYYY/NNN); future modules can override the _fp_parent_sale_order hook to enable parent naming. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
798 lines
30 KiB
Python
798 lines
30 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', '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/<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
|
|
# ------------------------------------------------------------------
|
|
# 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('<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',
|
|
)
|