# -*- 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(_( 'QA Review failed by %(user)s. Awaiting client ' 'information.
Reason:
%(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(_( 'QA Review completed by %(user)s after receiving ' 'client information.
' 'Special Instructions captured:
%(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, })