Files
Odoo-Modules/fusion_plating/fusion_plating_quality/models/fp_contract_review.py
gsinghpal de3ec7d97a feat(plating-sec): SO confirm gate + fix _administrator typo + Python sweep
Phase G of permissions overhaul.

G2: sale.order.action_confirm now requires group_fp_sales_manager
(spec Section 2.B). Sales Reps can save drafts but cannot move SOs
to 'sale' state. UserError raised with clear message if attempted.

G3: Fixed audit-finding-11 typo bug in 2 files. The original code
checked has_group('fusion_plating.group_fusion_plating_administrator'),
an xmlid that has NEVER existed - so the gate always returned False
and only the Manager-side check actually fired. Fixed both:
  - fusion_plating_invoicing/models/res_partner.py:34
  - fusion_plating_configurator/wizard/fp_direct_order_wizard.py:467
Both now check has_group('fusion_plating.group_fp_manager') which
transitively includes Owner via implied_ids.

G4: Swept all Python has_group() calls to reference new group xmlids.
Backward-compat keeps old refs working today (Phase A's implied_ids),
but the sweep ensures correctness after the 30-day rollback window
deletes old groups. Replacements:
  group_fusion_plating_operator    -> group_fp_technician
  group_fusion_plating_supervisor  -> group_fp_shop_manager_v2
  group_fusion_plating_manager     -> group_fp_manager
  group_fusion_plating_admin       -> group_fp_owner
  group_fusion_plating_cgp_officer -> group_fp_quality_manager
  group_fusion_plating_cgp_designated_official -> group_fp_owner
  group_fp_estimator               -> group_fp_sales_rep
  group_fp_accounting              -> group_fp_manager
  group_fp_receiving               -> group_fp_shop_manager_v2
  group_fp_shop_manager (legacy)   -> group_fp_manager

G1: test_sales_manager_gate.py covers the new confirm gate (SR
blocked, SMg allowed, Manager allowed via diamond implication).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:11:35 -04:00

564 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from datetime import timedelta
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
# QA-005 Rev. 0 risk matrix, keyed (consequence, likelihood) → band.
# Reproduces the coloured 5×5 grid printed on the paper form.
_RISK_MATRIX = {
(1, 1): 'green', (2, 1): 'green', (3, 1): 'green', (4, 1): 'yellow', (5, 1): 'yellow',
(1, 2): 'green', (2, 2): 'green', (3, 2): 'yellow', (4, 2): 'yellow', (5, 2): 'yellow',
(1, 3): 'green', (2, 3): 'yellow', (3, 3): 'yellow', (4, 3): 'yellow', (5, 3): 'red',
(1, 4): 'green', (2, 4): 'yellow', (3, 4): 'yellow', (4, 4): 'red', (5, 4): 'red',
(1, 5): 'green', (2, 5): 'yellow', (3, 5): 'red', (4, 5): 'red', (5, 5): 'red',
}
class FpContractReview(models.Model):
"""Contract Review (QA-005).
Per-part, two-section QA review: Section 2.0 Planning / Production
Review (Planning Review stage, signed by a Planning Signer) and
Section 3.0 Quality Review (QA Review stage, signed by a QA Signer).
Both sections must be signed for the review to be complete.
The review is always optional. It never blocks MO/SO/WO progression.
Its purpose is an audit artefact and a printable 1:1 of the paper
QA-005 form.
"""
_name = 'fp.contract.review'
_description = 'Contract Review (QA-005)'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc, id desc'
# ---- Identity & header ---------------------------------------------------
name = fields.Char(
string='Reference',
compute='_compute_name',
store=True,
)
part_id = fields.Many2one(
'fp.part.catalog',
string='Part',
required=True,
ondelete='cascade',
tracking=True,
)
customer_id = fields.Many2one(
related='part_id.partner_id',
store=True,
string='Customer',
)
part_number = fields.Char(
related='part_id.part_number',
store=True,
string='Part Number',
)
part_revision = fields.Char(
related='part_id.revision',
store=True,
string='Revision',
)
company_id = fields.Many2one(
'res.company',
required=True,
default=lambda s: s.env.company,
)
date_received = fields.Date(
string='Date Received',
default=fields.Date.context_today,
tracking=True,
)
contract_po_number = fields.Char(string='Contract / PO No.')
quote_or_job_number = fields.Char(string='Quote or Job #')
qty = fields.Integer(string='Qty')
due_date = fields.Date(string='Due')
# ---- Section 2.0 — Planning / Production Review (Planning Review) ------
s20_acceptable_lead_time = fields.Boolean(string='Acceptable Lead Time')
s20_capacity_to_process = fields.Boolean(string='Capacity to Process')
s20_skills_to_process = fields.Boolean(string='Skills to Process')
s20_fixtures_required = fields.Boolean(string='Fixtures Required')
s20_prime_approvals = fields.Boolean(string='Prime approvals on file')
s20_pricing = fields.Boolean(string='Pricing')
s20_approved_technique = fields.Boolean(string='Approved Technique by Customer')
s20_drawings_available = fields.Boolean(string='Drawings available')
s20_process_type_class_grade = fields.Boolean(string='Process Type / Class / Grade')
s20_pre_post_processing_steps = fields.Boolean(string='Pre / Post Processing Steps')
s20_accepted = fields.Boolean(string='Accepted (Section 2.0)')
s20_comments = fields.Text(string='Comments')
s20_evaluate_risk = fields.Boolean(string='Evaluate Risk (Section 2.0)')
s20_risk_level = fields.Selection(
[('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5')],
string='Risk Level (Section 2.0)',
)
s20_signed_by = fields.Many2one(
'res.users',
string='Production Signature',
readonly=True,
copy=False,
tracking=True,
)
s20_signed_date = fields.Datetime(
string='Section 2.0 Signed On',
readonly=True,
copy=False,
tracking=True,
)
s20_locked = fields.Boolean(
string='Section 2.0 Locked',
readonly=True,
default=False,
copy=False,
)
# ---- Section 3.0 — Quality Review (QA Review) --------------------------
s30_source_control_docs = fields.Boolean(string="Source Control Documents (Customer Spec's)")
s30_quality_clauses_supplied = fields.Boolean(string='Quality Clause(s) supplied')
s30_quality_clauses_attainable = fields.Boolean(string='Quality Clause(s) attainable')
s30_critical_tolerance = fields.Boolean(string='Critical Tolerance(s)')
s30_measuring_tooling = fields.Boolean(string='Measuring Tooling Available')
s30_quality_tests_verified = fields.Boolean(string='Quality Tests Requirements Verified')
s30_specification_revisions = fields.Boolean(string='Specification Revisions')
s30_certifications_requirements = fields.Boolean(string='Certifications Requirements')
s30_psd_rfd_reviewed = fields.Boolean(string='PSD, RFD etc. Reviewed')
s30_specification_deviations = fields.Boolean(string='Specification Deviations')
s30_design_authority = fields.Boolean(string='Design Authority')
s30_accepted = fields.Boolean(string='Accepted (Section 3.0)')
s30_evaluate_risk = fields.Boolean(string='Evaluate Risk (Section 3.0)')
s30_risk_consequence = fields.Selection(
[('1', '1 — Minimal'),
('2', '2 — Moderate'),
('3', '3 — Mod. / Applicable'),
('4', '4 — Major / Changes'),
('5', '5 — Unacceptable')],
string='Consequence',
)
s30_risk_likelihood = fields.Selection(
[('1', '1 — Not Likely'),
('2', '2 — Low Likelihood'),
('3', '3 — Likely'),
('4', '4 — Highly Likely'),
('5', '5 — Near Certainty')],
string='Likelihood',
)
s30_risk_band = fields.Selection(
[('green', 'Green'), ('yellow', 'Yellow'), ('red', 'Red')],
string='Risk Band',
compute='_compute_risk_band',
store=True,
)
s30_mitigation_plan_required = fields.Boolean(string='Mitigation Plan Required')
s30_signed_by = fields.Many2one(
'res.users',
string='Quality Signature',
readonly=True,
copy=False,
tracking=True,
)
s30_signed_date = fields.Datetime(
string='Section 3.0 Signed On',
readonly=True,
copy=False,
tracking=True,
)
s30_locked = fields.Boolean(
string='Section 3.0 Locked',
readonly=True,
default=False,
copy=False,
)
# ---- State --------------------------------------------------------------
state = fields.Selection(
[('draft', 'Draft'),
('assistant_review', 'Planning Review'),
('manager_review', 'QA Review'),
('awaiting_info', 'Awaiting Client Info'),
('complete', 'Complete'),
('dismissed', 'Dismissed')],
default='draft',
required=True,
tracking=True,
)
# ---- "Failed QA — Awaiting Client Info" workflow ------------------------
# When a QA Signer (Brett or whoever the company has rostered) finds a
# client requirement that fails during the QA Review, they mark the
# review failed. The state moves to `awaiting_info`, an activity is
# scheduled for every QA Signer to follow up, and a smart button on
# the form gives them a one-click email composer to ping the client.
# When the client replies, the QA Signer captures notes in
# `special_instructions` and marks complete — the notes print on the
# final QA-005 PDF for the audit trail.
qa_failure_reason = fields.Html(
string='QA Failure Reason',
copy=False,
help='What client requirement failed and why we need more info. '
'Captured here before flipping the review to '
'"Awaiting Client Info" so every QA Signer sees the same '
'context. Pre-fills the client email composer.',
)
info_requested_date = fields.Datetime(
string='Info Requested Date',
readonly=True,
copy=False,
help='Stamped automatically the first time the client email '
'composer is sent.',
)
info_received_date = fields.Datetime(
string='Info Received Date',
copy=False,
help='Manually stamped when the QA Signer marks the review '
'complete after receiving the client info.',
)
special_instructions = fields.Html(
string='Special Instructions',
copy=False,
help='Free-form notes captured by the QA Signer when they close '
'out the review. Prints at the bottom of the QA-005 PDF '
'so the audit record carries the agreed resolution.',
)
client_email_count = fields.Integer(
compute='_compute_client_email_count',
help='Smart-button counter — number of emails posted to chatter '
'against this review. Always non-zero after the first send.',
)
@api.depends('message_ids', 'message_ids.message_type')
def _compute_client_email_count(self):
for rec in self:
rec.client_email_count = len(rec.message_ids.filtered(
lambda m: m.message_type == 'email'
))
# ---- Constraints --------------------------------------------------------
_sql_constraints = [
('fp_contract_review_part_uniq',
'unique(part_id)',
'A Contract Review already exists for this part.'),
]
# ---- Computes -----------------------------------------------------------
@api.depends('part_id.part_number', 'part_id.revision', 'customer_id.name')
def _compute_name(self):
for rec in self:
if rec.part_id:
rec.name = _('QA-005 — %(pn)s Rev %(rev)s') % {
'pn': rec.part_id.part_number or _('(no part#)'),
'rev': rec.part_id.revision or _('(no rev)'),
}
else:
rec.name = _('QA-005 Contract Review')
@api.depends('s30_risk_consequence', 's30_risk_likelihood')
def _compute_risk_band(self):
for rec in self:
try:
c = int(rec.s30_risk_consequence) if rec.s30_risk_consequence else 0
l = int(rec.s30_risk_likelihood) if rec.s30_risk_likelihood else 0
except (ValueError, TypeError):
c = l = 0
rec.s30_risk_band = _RISK_MATRIX.get((c, l), False)
# ---- Actions ------------------------------------------------------------
def action_sign_section_20(self):
"""Lock Section 2.0 after roster check. Advance state."""
self.ensure_one()
if self.s20_locked:
raise UserError(_('Section 2.0 is already signed.'))
self._check_signer(20)
self.write({
's20_signed_by': self.env.user.id,
's20_signed_date': fields.Datetime.now(),
's20_locked': True,
'state': ('manager_review'
if self.state in ('draft', 'assistant_review')
else self.state),
})
self.message_post(body=_(
'Section 2.0 (Planning / Production Review) signed by %s.'
) % self.env.user.name)
return True
def action_sign_section_30(self):
"""Lock Section 3.0 after roster check. Mark complete."""
self.ensure_one()
if self.s30_locked:
raise UserError(_('Section 3.0 is already signed.'))
self._check_signer(30)
self.write({
's30_signed_by': self.env.user.id,
's30_signed_date': fields.Datetime.now(),
's30_locked': True,
'state': 'complete',
})
self.message_post(body=_(
'Section 3.0 (Quality Review) signed by %s. Review complete.'
) % self.env.user.name)
return True
# Checklist fields per section, for the "Check All" / "Clear All"
# bulk-toggle buttons. Only the checklist boxes are flipped —
# outcome fields (Accepted, Evaluate Risk, Risk Level / Matrix,
# Mitigation Plan Required) remain under the user's explicit
# decision so they don't get accidentally ticked.
_SECTION_20_CHECKLIST = (
's20_acceptable_lead_time',
's20_capacity_to_process',
's20_skills_to_process',
's20_fixtures_required',
's20_prime_approvals',
's20_pricing',
's20_approved_technique',
's20_drawings_available',
's20_process_type_class_grade',
's20_pre_post_processing_steps',
)
_SECTION_30_CHECKLIST = (
's30_source_control_docs',
's30_quality_clauses_supplied',
's30_quality_clauses_attainable',
's30_critical_tolerance',
's30_measuring_tooling',
's30_quality_tests_verified',
's30_specification_revisions',
's30_certifications_requirements',
's30_psd_rfd_reviewed',
's30_specification_deviations',
's30_design_authority',
)
def _bulk_toggle_checklist(self, fields_tuple, value, locked_field):
self.ensure_one()
if self[locked_field]:
raise UserError(_(
'Section is already signed — checklist is locked.'
))
self.write({f: value for f in fields_tuple})
return True
def action_check_all_section_20(self):
return self._bulk_toggle_checklist(
self._SECTION_20_CHECKLIST, True, 's20_locked')
def action_clear_all_section_20(self):
return self._bulk_toggle_checklist(
self._SECTION_20_CHECKLIST, False, 's20_locked')
def action_check_all_section_30(self):
return self._bulk_toggle_checklist(
self._SECTION_30_CHECKLIST, True, 's30_locked')
def action_clear_all_section_30(self):
return self._bulk_toggle_checklist(
self._SECTION_30_CHECKLIST, False, 's30_locked')
def action_reopen(self):
"""Clear all sign-off data and revert to draft. Manager only."""
self.ensure_one()
if not self.env.user.has_group('fusion_plating.group_fp_manager'):
raise UserError(_(
'Only a Plating Manager can re-open a signed Contract Review.'
))
self.write({
's20_signed_by': False,
's20_signed_date': False,
's20_locked': False,
's30_signed_by': False,
's30_signed_date': False,
's30_locked': False,
'state': 'assistant_review',
})
self.message_post(body=_(
'Contract Review re-opened by %s. Both sections cleared for '
're-signing.'
) % self.env.user.name)
return True
def action_dismiss(self):
"""Cancel the review. Banner stays off on the part (dismissed flag)."""
self.ensure_one()
self.write({'state': 'dismissed'})
if self.part_id:
self.part_id.x_fc_contract_review_dismissed = True
return True
def action_print_qa005(self):
"""Render the 1:1 QA-005 PDF."""
self.ensure_one()
return self.env.ref(
'fusion_plating_quality.action_report_contract_review'
).report_action(self)
# ---- "Failed QA — Awaiting Client Info" workflow ------------------------
def action_mark_qa_failed(self):
"""QA Signer marks the review failed because a client requirement
is missing or unclear. Captures the reason, flips state to
`awaiting_info`, and schedules a follow-up activity for every QA
Signer rostered on the company (so the work doesn't fall through
the cracks if Brett is on vacation)."""
self.ensure_one()
if self.state not in ('manager_review', 'assistant_review'):
raise UserError(_(
'Only a review at the QA Review (or Planning Review) stage '
'can be flagged as failed. Current state: %s.'
) % dict(self._fields['state'].selection).get(self.state, self.state))
# Reuse the section-30 signer roster — the same group of people
# who can sign QA can flag a QA failure.
self._check_signer(30)
if not self.qa_failure_reason or not self.qa_failure_reason.strip():
raise UserError(_(
'Capture the QA Failure Reason before flagging the '
'review failed — the reason pre-fills the client email '
'and is required for the audit trail.'
))
self.write({'state': 'awaiting_info'})
self.message_post(body=Markup(_(
'<b>QA Review failed</b> by %(user)s. Awaiting client '
'information.<br/><b>Reason:</b><br/>%(reason)s'
)) % {
'user': self.env.user.name,
'reason': Markup(self.qa_failure_reason or ''),
})
# Schedule activity for every QA Signer (any of them can pick it up).
signers = self.company_id._fp_get_qa_signers(30)
if not signers:
# Fall back to the user who flagged it, so the activity is
# not orphaned on shops that haven't configured a roster.
signers = self.env.user
try:
activity_type = self.env.ref('mail.mail_activity_data_todo')
except ValueError:
activity_type = self.env['mail.activity.type'].search(
[('category', '=', 'default')], limit=1)
for user in signers:
self.activity_schedule(
activity_type_id=activity_type.id if activity_type else False,
summary=_('Follow up on QA-005 — client info required'),
note=self.qa_failure_reason or '',
user_id=user.id,
date_deadline=fields.Date.context_today(self) +
timedelta(days=2),
)
return True
def action_open_client_email_wizard(self):
"""Smart-button target — opens the email composer wizard pre-filled
with the customer's contact email + a body templated from the
QA failure reason. The wizard handles the actual mail.mail send
and stamps `info_requested_date` on this review."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Email Client — Request Info'),
'res_model': 'fp.contract.review.client.email.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_review_id': self.id,
'default_recipient_email':
self.customer_id.email or '',
'default_recipient_name':
self.customer_id.name or '',
},
}
def action_view_client_emails(self):
"""Drill-down behind the smart button counter — shows the chatter
messages of type=email for this review."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Client Emails — %s') % self.name,
'res_model': 'mail.message',
'view_mode': 'list,form',
'domain': [
('model', '=', 'fp.contract.review'),
('res_id', '=', self.id),
('message_type', '=', 'email'),
],
}
def action_complete_after_info(self):
"""Close out a review that was in `awaiting_info` once the client
info has been received and `special_instructions` captured. Stamps
Section 3.0 sign-off with the current user + timestamp so the QA
review is fully closed and the QA-005 PDF carries a complete
audit trail."""
self.ensure_one()
if self.state != 'awaiting_info':
raise UserError(_(
'Only a review in "Awaiting Client Info" can be marked '
'complete via this action.'
))
self._check_signer(30)
now = fields.Datetime.now()
vals = {
'state': 'complete',
'info_received_date': self.info_received_date or now,
's30_signed_by': self.env.user.id,
's30_signed_date': now,
's30_locked': True,
}
self.write(vals)
# Mark the activity as done so the follow-up disappears from
# everyone's inbox once the case is closed.
self.activity_feedback(
['mail.mail_activity_data_todo'],
feedback=_('Client info received — review closed.'),
)
self.message_post(body=Markup(_(
'<b>QA Review completed</b> by %(user)s after receiving '
'client information.<br/>'
'<b>Special Instructions captured:</b><br/>%(notes)s'
)) % {
'user': self.env.user.name,
'notes': Markup(self.special_instructions or '') or _('(none)'),
})
return True
# ---- Helpers ------------------------------------------------------------
def _check_signer(self, section):
"""Enforce the settings-based signer roster.
Plating Managers override the roster so operations never stall
waiting on a designated signer who is away.
"""
self.ensure_one()
if self.env.user.has_group('fusion_plating.group_fp_manager'):
return
allowed = self.company_id._fp_get_qa_signers(section)
if self.env.user not in allowed:
names = ', '.join(allowed.mapped('name')) or _('(none configured)')
section_label = {
20: _('Section 2.0 (Planning / Production Review)'),
30: _('Section 3.0 (Quality Review)'),
}.get(section, _('this section'))
raise UserError(_(
'%(user)s is not authorised to sign %(section)s.\n\n'
'Authorised signers: %(allowed)s\n\n'
'Ask an authorised signer, or have a Plating Manager sign '
'on their behalf. Signer rosters are managed under '
'Settings → Fusion Plating → Contract Review.'
) % {
'user': self.env.user.name,
'section': section_label,
'allowed': names,
})