Files
Odoo-Modules/fusion_plating/fusion_plating_quality/models/fp_contract_review.py
gsinghpal 5d9c78f8ce feat(plating): Sub 4 — Check All / Clear All buttons + fix QA-005 PDF logo render
- New bulk-toggle actions on fp.contract.review flip all 10 checklist
  items in Section 2.0 (and all 11 in Section 3.0) in one click.
  Rendered as "Check All" / "Clear All" buttons above each checklist.
  User can still tick boxes individually. Buttons hide once the
  section is signed (locked).
- Fix QA-005 PDF: replaced `to_text(...)` (not in QWeb context) with
  `image_data_uri(...)` for the company logo embed. PDF now renders
  with the full colour ENTECH logo (render size 103 KB).
- Smoke test extended: 5 new assertions covering bulk-toggle on/off
  and locked-section guard. 17/17 pass on entech.

fusion_plating_quality → 19.0.2.1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:50:18 -04:00

383 lines
14 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 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
# 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_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,
})