feat(plating): Sub 4 — Contract Review (optional, QA-005 1:1 PDF)

Per-part contract review record (fp.contract.review) gated by a
customer-level toggle, signed in two sections (QA Assistant → QA
Manager), settings-based signer rosters (no new res.groups), banner on
the part form that auto-dismisses once the first MO for the part hits
confirmed. QA-005 Rev. 0 paper form reproduced 1:1 in a QWeb PDF.

Never blocks MO/SO/WO — review is purely an audit artefact.

Smoke test run on entech: 12 assertions pass including the 25-cell
risk matrix parity with the paper form and 22 KB PDF render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-22 21:43:06 -04:00
parent 98a8bc234b
commit 21da526aa7
17 changed files with 1472 additions and 2 deletions

View File

@@ -0,0 +1,326 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
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 (signed by a QA Assistant) and Section 3.0 Quality Review
(signed by a QA Manager). 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 (QA Assistant) ---------
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 Manager) -------------------------
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', 'QA Assistant Review'),
('manager_review', 'QA Manager Review'),
('complete', 'Complete'),
('dismissed', 'Dismissed')],
default='draft',
required=True,
tracking=True,
)
# ---- 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
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_fusion_plating_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)
# ---- 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_fusion_plating_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,
})