changes
This commit is contained in:
775
fusion_plating/fusion_plating_quality/models/fp_rma.py
Normal file
775
fusion_plating/fusion_plating_quality/models/fp_rma.py
Normal file
@@ -0,0 +1,775 @@
|
||||
# -*- 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',
|
||||
)
|
||||
Reference in New Issue
Block a user