diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index 93cd7d48..03c1719b 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -369,7 +369,7 @@ rewrite code as new requirements surface. Each sub-project has its own design do | 1 | Direct Order Wizard fix (no auto-confirm/auto-email) | **Shipped 2026-04-22** (commit afd8bae+) | Gap 1 | | 2 | Part Data Model Overhaul (part#/rev required, dual descriptions, per-part cert requirement, SKU→Part Number on customer docs) | **Shipped 2026-04-22** (commits 868b418..afd8bae) | 2b, 2c, 2d, 4 | | 3 | Default Process + Composer per part (reuse recipe tree) | **Shipped 2026-04-22** (commits ce07daa..f059348) | 2e, 2f | -| 4 | Contract Review two-portion workflow (QA Assistant + QA Manager; pre-production gate) | Pending | 2i | +| 4 | Contract Review (optional, per-part, settings-driven QA roster, QA-005 1:1 PDF) | **Shipped 2026-04-22** | 2i | | 5 | Order-line fields (serial, job#, thickness dropdown, revision picker) | Pending | 5, 6, Q2 | | 6 | Contact Profiles & Communication Routing (sub-contacts + per-location notification lists + global contacts) | Pending | client transcript A/B/C | | 7 | IoT tuning (configurable polling interval 15–30 min, seed 6–10 tank sensors) | Pending | client transcript D | diff --git a/fusion_plating/docs/superpowers/tests/2026-04-22-sub4-smoke.py b/fusion_plating/docs/superpowers/tests/2026-04-22-sub4-smoke.py new file mode 100644 index 00000000..9c0d0933 --- /dev/null +++ b/fusion_plating/docs/superpowers/tests/2026-04-22-sub4-smoke.py @@ -0,0 +1,146 @@ +"""Sub 4 smoke test — runs inside odoo-shell on entech. + +Verifies: toggle-triggered banner, lazy review creation, roster-enforced +sign flow, compute-driven auto-dismiss on confirmed MO, re-open flow, +risk-band matrix. +""" +env = env # odoo-shell injects this + +Partner = env['res.partner'] +Part = env['fp.part.catalog'] +Review = env['fp.contract.review'] +Users = env['res.users'] +Company = env.company + +# ---- Roster: add admin to both rosters --------------------------------- +admin = env.ref('base.user_admin') +Company.write({ + 'x_fc_qa_assistant_user_ids': [(6, 0, [admin.id])], + 'x_fc_qa_manager_user_ids': [(6, 0, [admin.id])], +}) +assert admin in Company.x_fc_qa_assistant_user_ids +assert admin in Company.x_fc_qa_manager_user_ids +print('[OK] Company rosters populated') + +# ---- Customer with contract_review_required ---------------------------- +cust = Partner.create({ + 'name': 'Sub4 Smoke Customer', + 'is_company': True, + 'customer_rank': 1, + 'x_fc_contract_review_required': True, +}) +assert cust.x_fc_contract_review_required +print('[OK] Customer toggle set') + +# ---- Part under that customer → banner visible ------------------------- +part = Part.create({ + 'partner_id': cust.id, + 'part_number': 'SUB4-SMOKE-001', + 'revision': 'A', +}) +part.invalidate_recordset() +assert part.x_fc_customer_requires_contract_review +assert part.x_fc_contract_review_banner_visible, 'banner should be visible' +assert not part.x_fc_contract_review_id +print('[OK] Banner visible on fresh part') + +# ---- Start contract review → record created + state = assistant_review +action = part.action_start_contract_review() +part.invalidate_recordset() +review = part.x_fc_contract_review_id +assert review, 'review should be created' +assert review.state == 'assistant_review' +assert review.customer_id == cust +assert review.part_number == 'SUB4-SMOKE-001' +print('[OK] Review created by action_start_contract_review') + +# ---- Sign section 2.0 as admin (roster ok) ----------------------------- +review.with_user(admin).action_sign_section_20() +review.invalidate_recordset() +assert review.s20_locked +assert review.s20_signed_by == admin +assert review.state == 'manager_review' +print('[OK] Section 2.0 signed; state advanced') + +# ---- Sign section 3.0 → state = complete ------------------------------- +review.s30_risk_consequence = '4' +review.s30_risk_likelihood = '4' +assert review.s30_risk_band == 'red', f'expected red, got {review.s30_risk_band}' +review.with_user(admin).action_sign_section_30() +review.invalidate_recordset() +assert review.s30_locked +assert review.state == 'complete' +print('[OK] Section 3.0 signed; review complete; risk matrix computed') + +# ---- Banner now hidden (review complete) ------------------------------- +part.invalidate_recordset() +assert not part.x_fc_contract_review_banner_visible +print('[OK] Banner hidden after completion') + +# ---- Re-open (admin is manager) ---------------------------------------- +review.action_reopen() +review.invalidate_recordset() +assert review.state == 'assistant_review' +assert not review.s20_locked +assert not review.s30_locked +print('[OK] Re-open cleared both sections') + +# ---- Risk matrix parity with paper form -------------------------------- +expected = { + (1, 1): 'green', (1, 2): 'green', (1, 3): 'green', (1, 4): 'green', (1, 5): 'green', + (2, 1): 'green', (2, 2): 'green', (2, 3): 'yellow', (2, 4): 'yellow', (2, 5): 'yellow', + (3, 1): 'green', (3, 2): 'yellow', (3, 3): 'yellow', (3, 4): 'yellow', (3, 5): 'red', + (4, 1): 'yellow', (4, 2): 'yellow', (4, 3): 'yellow', (4, 4): 'red', (4, 5): 'red', + (5, 1): 'yellow', (5, 2): 'yellow', (5, 3): 'red', (5, 4): 'red', (5, 5): 'red', +} +for (c, l), band in expected.items(): + review.s30_risk_consequence = str(c) + review.s30_risk_likelihood = str(l) + assert review.s30_risk_band == band, ( + f'matrix mismatch at c={c} l={l}: got {review.s30_risk_band} expected {band}' + ) +print('[OK] 25-cell risk matrix matches paper form') + +# ---- Dismiss flow ------------------------------------------------------ +part2 = Part.create({ + 'partner_id': cust.id, + 'part_number': 'SUB4-SMOKE-002', + 'revision': 'A', +}) +part2.invalidate_recordset() +assert part2.x_fc_contract_review_banner_visible +part2.action_dismiss_contract_review() +part2.invalidate_recordset() +assert part2.x_fc_contract_review_dismissed +assert not part2.x_fc_contract_review_banner_visible +print('[OK] Dismiss hides banner') + +# ---- Non-roster user blocked ------------------------------------------ +demo_user = Users.search([('login', '=', 'demo')], limit=1) +if demo_user: + part3 = Part.create({ + 'partner_id': cust.id, + 'part_number': 'SUB4-SMOKE-003', + 'revision': 'A', + }) + part3.action_start_contract_review() + part3.invalidate_recordset() + rev3 = part3.x_fc_contract_review_id + try: + rev3.with_user(demo_user).action_sign_section_20() + assert False, 'non-roster user should have been blocked' + except Exception as e: + assert 'authorised' in str(e) or 'Plating Manager' in str(e) + print('[OK] Non-roster user blocked') +else: + print('[SKIP] No demo user for non-roster check') + +# ---- QWeb render ------------------------------------------------------- +report = env.ref('fusion_plating_quality.action_report_contract_review') +pdf_bytes, mime = report._render_qweb_pdf('fusion_plating_quality.report_contract_review_qa005', [review.id]) +assert pdf_bytes and pdf_bytes[:4] == b'%PDF' +print(f'[OK] QA-005 PDF rendered ({len(pdf_bytes)} bytes)') + +# ---- Cleanup ----------------------------------------------------------- +env.cr.rollback() +print('\n=== SUB 4 SMOKE PASS — all assertions held ===') diff --git a/fusion_plating/fusion_plating_quality/__manifest__.py b/fusion_plating/fusion_plating_quality/__manifest__.py index 13d24594..9bb854ed 100644 --- a/fusion_plating/fusion_plating_quality/__manifest__.py +++ b/fusion_plating/fusion_plating_quality/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Quality (QMS)', - 'version': '19.0.1.3.0', + 'version': '19.0.2.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, ' 'internal audits, customer specs, document control. CE + EE compatible.', @@ -66,6 +66,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'currency': 'CAD', 'depends': [ 'fusion_plating', + 'fusion_plating_configurator', 'mail', ], 'data': [ @@ -82,6 +83,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'views/fp_audit_views.xml', 'views/fp_fair_views.xml', 'views/fp_doc_control_views.xml', + 'views/res_partner_views.xml', + 'views/res_config_settings_views.xml', + 'views/fp_contract_review_views.xml', + 'views/fp_part_catalog_views.xml', + 'reports/fp_contract_review_report.xml', + 'reports/fp_contract_review_template.xml', 'views/fp_menu.xml', ], 'demo': [ diff --git a/fusion_plating/fusion_plating_quality/models/__init__.py b/fusion_plating/fusion_plating_quality/models/__init__.py index 8740e420..b6100d78 100644 --- a/fusion_plating/fusion_plating_quality/models/__init__.py +++ b/fusion_plating/fusion_plating_quality/models/__init__.py @@ -13,3 +13,8 @@ from . import fp_audit from . import fp_fair from . import fp_doc_control from . import fp_quality_hold +from . import fp_contract_review +from . import res_company +from . import res_config_settings +from . import res_partner +from . import fp_part_catalog diff --git a/fusion_plating/fusion_plating_quality/models/fp_contract_review.py b/fusion_plating/fusion_plating_quality/models/fp_contract_review.py new file mode 100644 index 00000000..83a86ffe --- /dev/null +++ b/fusion_plating/fusion_plating_quality/models/fp_contract_review.py @@ -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, + }) diff --git a/fusion_plating/fusion_plating_quality/models/fp_part_catalog.py b/fusion_plating/fusion_plating_quality/models/fp_part_catalog.py new file mode 100644 index 00000000..80bb9c03 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/models/fp_part_catalog.py @@ -0,0 +1,125 @@ +# -*- 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 + + +class FpPartCatalog(models.Model): + _inherit = 'fp.part.catalog' + + x_fc_contract_review_id = fields.Many2one( + 'fp.contract.review', + string='Contract Review', + ondelete='set null', + copy=False, + ) + x_fc_contract_review_state = fields.Selection( + related='x_fc_contract_review_id.state', + store=False, + string='Contract Review Status', + ) + x_fc_contract_review_dismissed = fields.Boolean( + string='Contract Review Reminder Dismissed', + copy=False, + ) + x_fc_customer_requires_contract_review = fields.Boolean( + related='partner_id.x_fc_contract_review_required', + string='Customer Requires Contract Review', + store=False, + ) + x_fc_has_confirmed_mo = fields.Boolean( + string='Has a Confirmed MO', + compute='_compute_has_confirmed_mo', + store=False, + ) + x_fc_contract_review_banner_visible = fields.Boolean( + string='Show Contract Review Reminder', + compute='_compute_contract_review_banner_visible', + store=False, + ) + + # ---- Computes ------------------------------------------------------------ + + def _compute_has_confirmed_mo(self): + """True if this part is referenced by at least one non-draft MO. + + Trace: fp.part.catalog → sale.order.line (x_fc_part_catalog_id) + → sale.order → mrp.production (via origin name match). + Cheap: two bounded search_counts. Kept store=False so MO state + changes don't write-amplify through every part record. + """ + SO = self.env['sale.order'] + MO = self.env['mrp.production'] + live_states = ('confirmed', 'progress', 'to_close', 'done') + for part in self: + if not part.id: + part.x_fc_has_confirmed_mo = False + continue + so_names = SO.search([ + ('order_line.x_fc_part_catalog_id', '=', part.id), + ('state', 'in', ('sale', 'done')), + ]).mapped('name') + if not so_names: + part.x_fc_has_confirmed_mo = False + continue + part.x_fc_has_confirmed_mo = bool(MO.search_count([ + ('origin', 'in', so_names), + ('state', 'in', live_states), + ])) + + @api.depends('partner_id.x_fc_contract_review_required', + 'x_fc_contract_review_dismissed', + 'x_fc_contract_review_state', + 'x_fc_has_confirmed_mo') + def _compute_contract_review_banner_visible(self): + for part in self: + needs = part.partner_id.x_fc_contract_review_required + dismissed = part.x_fc_contract_review_dismissed + completed = (part.x_fc_contract_review_state == 'complete') + in_production = part.x_fc_has_confirmed_mo + part.x_fc_contract_review_banner_visible = ( + bool(needs) and not dismissed + and not completed and not in_production + ) + + # ---- Actions ------------------------------------------------------------- + + def action_start_contract_review(self): + """Create (if missing) + open the contract review for this part.""" + self.ensure_one() + review = self.x_fc_contract_review_id + if not review: + review = self.env['fp.contract.review'].create({ + 'part_id': self.id, + 'state': 'assistant_review', + }) + self.x_fc_contract_review_id = review.id + self.x_fc_contract_review_dismissed = False + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fp.contract.review', + 'res_id': review.id, + 'view_mode': 'form', + 'target': 'current', + } + + def action_dismiss_contract_review(self): + """Hide the reminder banner. Reversible by a Plating Manager.""" + for part in self: + part.x_fc_contract_review_dismissed = True + return True + + def action_undismiss_contract_review(self): + """Restore the reminder banner. Manager only.""" + if not self.env.user.has_group( + 'fusion_plating.group_fusion_plating_manager'): + raise UserError(_( + 'Only a Plating Manager can re-enable a dismissed ' + 'Contract Review reminder.' + )) + for part in self: + part.x_fc_contract_review_dismissed = False + return True diff --git a/fusion_plating/fusion_plating_quality/models/res_company.py b/fusion_plating/fusion_plating_quality/models/res_company.py new file mode 100644 index 00000000..ac2202c3 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/models/res_company.py @@ -0,0 +1,51 @@ +# -*- 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 fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + # Contract Review signer rosters. Company-scoped so multi-tenant + # Odoo deployments keep their own QA team isolated. Managed from + # Settings → Fusion Plating → Contract Review. Stored here (not + # as groups) so granting the right to sign a Contract Review does + # not cascade any other permission elsewhere in Odoo. + x_fc_qa_assistant_user_ids = fields.Many2many( + 'res.users', + 'res_company_qa_assistant_rel', + 'company_id', + 'user_id', + string='QA Assistant Signers', + domain=[('share', '=', False)], + help='Users authorised to sign Section 2.0 (Planning / Production ' + 'Review) on a Contract Review. Plating Managers can sign ' + 'regardless of this list.', + ) + x_fc_qa_manager_user_ids = fields.Many2many( + 'res.users', + 'res_company_qa_manager_rel', + 'company_id', + 'user_id', + string='QA Manager Signers', + domain=[('share', '=', False)], + help='Users authorised to sign Section 3.0 (Quality Review) on a ' + 'Contract Review. Plating Managers can sign regardless of ' + 'this list.', + ) + + def _fp_get_qa_signers(self, section): + """Return effective signer roster for Section 2.0 or 3.0. + + Central helper so Sub 6 (per-customer / contact profiles) can + layer overrides without touching the sign action call sites. + """ + self.ensure_one() + if section == 20: + return self.x_fc_qa_assistant_user_ids + if section == 30: + return self.x_fc_qa_manager_user_ids + return self.env['res.users'] diff --git a/fusion_plating/fusion_plating_quality/models/res_config_settings.py b/fusion_plating/fusion_plating_quality/models/res_config_settings.py new file mode 100644 index 00000000..2c61b8f0 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/models/res_config_settings.py @@ -0,0 +1,24 @@ +# -*- 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 fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + # Mirror the res.company fields so the admin can manage them from + # Settings → Fusion Plating → Contract Review without navigating + # into the company record. + x_fc_qa_assistant_user_ids = fields.Many2many( + related='company_id.x_fc_qa_assistant_user_ids', + readonly=False, + string='QA Assistant Signers', + ) + x_fc_qa_manager_user_ids = fields.Many2many( + related='company_id.x_fc_qa_manager_user_ids', + readonly=False, + string='QA Manager Signers', + ) diff --git a/fusion_plating/fusion_plating_quality/models/res_partner.py b/fusion_plating/fusion_plating_quality/models/res_partner.py new file mode 100644 index 00000000..9fdfd536 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/models/res_partner.py @@ -0,0 +1,19 @@ +# -*- 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 fields, models + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + x_fc_contract_review_required = fields.Boolean( + string='Require Contract Review for new parts', + help='When enabled, newly-created parts under this customer will ' + 'show a reminder banner on the part form inviting QA to ' + 'complete a Contract Review (QA-005). The review remains ' + 'fully optional — the reminder can be dismissed and never ' + 'blocks production.', + ) diff --git a/fusion_plating/fusion_plating_quality/reports/fp_contract_review_report.xml b/fusion_plating/fusion_plating_quality/reports/fp_contract_review_report.xml new file mode 100644 index 00000000..1265947b --- /dev/null +++ b/fusion_plating/fusion_plating_quality/reports/fp_contract_review_report.xml @@ -0,0 +1,21 @@ + + + + + + Contract Review (QA-005) + fp.contract.review + qweb-pdf + fusion_plating_quality.report_contract_review_qa005 + fusion_plating_quality.report_contract_review_qa005 + 'QA-005 - %s' % (object.name or '').replace('/', '-') + + report + + + diff --git a/fusion_plating/fusion_plating_quality/reports/fp_contract_review_template.xml b/fusion_plating/fusion_plating_quality/reports/fp_contract_review_template.xml new file mode 100644 index 00000000..af3e906c --- /dev/null +++ b/fusion_plating/fusion_plating_quality/reports/fp_contract_review_template.xml @@ -0,0 +1,296 @@ + + + + + + + diff --git a/fusion_plating/fusion_plating_quality/security/fp_quality_security.xml b/fusion_plating/fusion_plating_quality/security/fp_quality_security.xml index 00ea7d24..e38842b2 100644 --- a/fusion_plating/fusion_plating_quality/security/fp_quality_security.xml +++ b/fusion_plating/fusion_plating_quality/security/fp_quality_security.xml @@ -19,4 +19,12 @@ groups, so a single user role works across the core and the QMS. --> + + + Fusion Plating: Contract Review — multi-company + + + [('company_id', 'in', company_ids)] + + diff --git a/fusion_plating/fusion_plating_quality/security/ir.model.access.csv b/fusion_plating/fusion_plating_quality/security/ir.model.access.csv index 729c8b01..40a0eb24 100644 --- a/fusion_plating/fusion_plating_quality/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_quality/security/ir.model.access.csv @@ -29,3 +29,6 @@ access_fp_doc_control_manager,fp.doc.control.manager,model_fusion_plating_doc_co access_fp_quality_hold_operator,fp.quality.hold.operator,model_fusion_plating_quality_hold,fusion_plating.group_fusion_plating_operator,1,0,1,0 access_fp_quality_hold_supervisor,fp.quality.hold.supervisor,model_fusion_plating_quality_hold,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_quality_hold_manager,fp.quality.hold.manager,model_fusion_plating_quality_hold,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_contract_review_operator,fp.contract.review.operator,model_fp_contract_review,fusion_plating.group_fusion_plating_operator,1,0,0,0 +access_fp_contract_review_supervisor,fp.contract.review.supervisor,model_fp_contract_review,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_contract_review_manager,fp.contract.review.manager,model_fp_contract_review,fusion_plating.group_fusion_plating_manager,1,1,1,1 diff --git a/fusion_plating/fusion_plating_quality/views/fp_contract_review_views.xml b/fusion_plating/fusion_plating_quality/views/fp_contract_review_views.xml new file mode 100644 index 00000000..f418823a --- /dev/null +++ b/fusion_plating/fusion_plating_quality/views/fp_contract_review_views.xml @@ -0,0 +1,270 @@ + + + + + + + + + + + fp.contract.review.form + fp.contract.review + +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + fp.contract.review.list + fp.contract.review + + + + + + + + + + + + + + + + + + + fp.contract.review.kanban + fp.contract.review + + + + + + + + + +
+
+ +
+
+ +
+
+ Part: + + Rev + +
+
+
+
+
+
+
+ + + + + + fp.contract.review.search + fp.contract.review + + + + + + + + + + + + + + + + + + + + + + + + + Contract Reviews + fp.contract.review + kanban,list,form + + {'search_default_filter_pending': 1} + +

+ No Contract Reviews yet. +

+

+ Contract Reviews (QA-005) capture QA sign-off on new parts + before production. They are always optional and never + block manufacturing. Toggle + Require Contract Review for new parts on a customer + to show the reminder banner on new parts under that customer. +

+
+
+ + + + + + +
diff --git a/fusion_plating/fusion_plating_quality/views/fp_part_catalog_views.xml b/fusion_plating/fusion_plating_quality/views/fp_part_catalog_views.xml new file mode 100644 index 00000000..1d1aa8f4 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/views/fp_part_catalog_views.xml @@ -0,0 +1,84 @@ + + + + + + fp.part.catalog.form.contract.review + fp.part.catalog + + + + + + + + + + + + + + + + + + +
+ +

+ This customer is not configured to require + Contract Reviews on new parts. You can still + start one manually. +

+
+
+
+
+
+ +
+
+ +
diff --git a/fusion_plating/fusion_plating_quality/views/res_config_settings_views.xml b/fusion_plating/fusion_plating_quality/views/res_config_settings_views.xml new file mode 100644 index 00000000..68b52767 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/views/res_config_settings_views.xml @@ -0,0 +1,48 @@ + + + + + + res.config.settings.view.form.fp.quality + res.config.settings + + + + + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_quality/views/res_partner_views.xml b/fusion_plating/fusion_plating_quality/views/res_partner_views.xml new file mode 100644 index 00000000..e82d757c --- /dev/null +++ b/fusion_plating/fusion_plating_quality/views/res_partner_views.xml @@ -0,0 +1,37 @@ + + + + + + res.partner.form.fp.contract.review + res.partner + + + + + +

+ Toggle this on so QA sees a Contract Review + reminder banner whenever a new part is created + under this customer. The reminder is always + optional — it can be dismissed and never blocks + production. Once the part reaches a confirmed + manufacturing order, the banner disappears on + its own. +

+ +
+
+
+
+
+ +