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

@@ -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 1530 min, seed 610 tank sensors) | Pending | client transcript D |

View File

@@ -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 ===')

View File

@@ -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': [

View File

@@ -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

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,
})

View File

@@ -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

View File

@@ -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']

View File

@@ -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',
)

View File

@@ -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.',
)

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 4 — Contract Review QWeb report action.
-->
<odoo>
<record id="action_report_contract_review" model="ir.actions.report">
<field name="name">Contract Review (QA-005)</field>
<field name="model">fp.contract.review</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_quality.report_contract_review_qa005</field>
<field name="report_file">fusion_plating_quality.report_contract_review_qa005</field>
<field name="print_report_name">'QA-005 - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="model_fp_contract_review"/>
<field name="binding_type">report</field>
</record>
</odoo>

View File

@@ -0,0 +1,296 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 4 — Contract Review QA-005 QWeb template (1:1 paper form).
-->
<odoo>
<template id="report_contract_review_qa005">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<div class="page" style="font-family: Arial, sans-serif; font-size: 10pt; color: #000;">
<style>
.qa005-title-row { display: table; width: 100%; border-collapse: collapse; margin-bottom: 8pt; }
.qa005-title-row &gt; div { display: table-cell; border: 1.5pt solid #000; padding: 6pt; vertical-align: middle; }
.qa005-title-logo { width: 22%; text-align: center; }
.qa005-title-center { width: 44%; text-align: center; font-weight: bold; font-size: 13pt; }
.qa005-title-frm { width: 17%; font-weight: bold; }
.qa005-title-date { width: 17%; font-weight: bold; }
.qa005-header-tbl { width: 100%; border-collapse: collapse; margin-bottom: 6pt; }
.qa005-header-tbl td { border: 1pt solid #000; padding: 4pt 6pt; }
.qa005-section-title { font-weight: bold; margin-top: 6pt; margin-bottom: 3pt; border-bottom: 1pt solid #000; padding-bottom: 2pt; }
.qa005-section-desc { font-style: italic; margin-bottom: 6pt; }
.qa005-check-tbl { width: 100%; border-collapse: collapse; }
.qa005-check-tbl td { border: 1pt solid #000; padding: 6pt; width: 25%; }
.qa005-sig-tbl { width: 100%; border-collapse: collapse; margin-top: 4pt; }
.qa005-sig-tbl td { border: 1pt solid #000; padding: 6pt; }
.qa005-risk-line { margin: 6pt 0; }
.qa005-matrix { border-collapse: collapse; margin-top: 8pt; }
.qa005-matrix td { border: 1pt solid #000; width: 28pt; height: 22pt; text-align: center; }
.qa005-matrix .cell-g { background-color: #8bb36b; }
.qa005-matrix .cell-y { background-color: #fff59d; }
.qa005-matrix .cell-r { background-color: #e06666; }
.qa005-matrix .cell-sel { outline: 3pt solid #000; outline-offset: -3pt; font-weight: bold; }
.qa005-consequences-tbl, .qa005-likelihood-tbl { font-size: 9pt; width: 100%; }
.qa005-consequences-tbl td, .qa005-likelihood-tbl td { padding: 2pt 4pt; }
.qa005-box-filled::before { content: "\2612 "; font-size: 12pt; }
.qa005-box-empty::before { content: "\2610 "; font-size: 12pt; }
.qa005-evaluate-inline span { margin-right: 14pt; }
</style>
<!-- ============ HEADER BAR (logo + title + code + date) ============ -->
<div class="qa005-title-row">
<div class="qa005-title-logo">
<img t-if="doc.company_id.logo"
t-att-src="'data:image/png;base64,%s' % to_text(doc.company_id.logo)"
style="max-height: 45pt; max-width: 100%;"/>
</div>
<div class="qa005-title-center">
CONTRACT REVIEW AND<br/>RISK ASSESSMENT
</div>
<div class="qa005-title-frm">
FRM: QA-005<br/>Rev. 0
</div>
<div class="qa005-title-date">
Issue Date:<br/>Nov 25, 2021
</div>
</div>
<!-- ============ IDENTITY TABLE ============ -->
<table class="qa005-header-tbl">
<tr>
<td style="width:12%;"><b>Customer:</b></td>
<td style="width:28%;"><span t-field="doc.customer_id.name"/></td>
<td style="width:14%;"><b>Quote or Job #:</b></td>
<td style="width:14%;"><span t-field="doc.quote_or_job_number"/></td>
<td style="width:10%;"><b>Part No:<br/>Prime</b></td>
<td style="width:12%;">
<span t-field="doc.part_number"/>
<br/>Rev: <span t-field="doc.part_revision"/>
</td>
</tr>
<tr>
<td><b>Contract / P.O. No:</b></td>
<td><span t-field="doc.contract_po_number"/></td>
<td><b>Date Rec'd:</b></td>
<td><span t-field="doc.date_received"/></td>
<td><b>Qty</b></td>
<td><span t-field="doc.qty"/>
— Due <span t-field="doc.due_date"/></td>
</tr>
</table>
<p class="qa005-section-desc">
I have reviewed the Purchase Order / RFQ and have verified that all
the information supplied is accurate and reflects the terms and
conditions as specified on the referenced quotation (if applicable),
and meets the general criteria for acceptance for the reviewing
function.
</p>
<!-- ============ SECTION 2.0 ============ -->
<div class="qa005-section-title">2.0 Planning / Production Review</div>
<table class="qa005-check-tbl">
<tr>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s20_acceptable_lead_time else 'empty' }}"/>Acceptable Lead Time</td>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s20_capacity_to_process else 'empty' }}"/>Capacity to Process</td>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s20_skills_to_process else 'empty' }}"/>Skills to Process</td>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s20_fixtures_required else 'empty' }}"/>Fixtures Required</td>
</tr>
<tr>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s20_prime_approvals else 'empty' }}"/>Prime approvals on file</td>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s20_pricing else 'empty' }}"/>Pricing</td>
<td colspan="2"><span t-attf-class="qa005-box-{{ 'filled' if doc.s20_approved_technique else 'empty' }}"/>Approved Technique by Customer</td>
</tr>
<tr>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s20_drawings_available else 'empty' }}"/>Drawings available</td>
<td colspan="2"><span t-attf-class="qa005-box-{{ 'filled' if doc.s20_process_type_class_grade else 'empty' }}"/>Process Type / Class / Grade</td>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s20_pre_post_processing_steps else 'empty' }}"/>Pre / Post Processing Steps</td>
</tr>
</table>
<table class="qa005-sig-tbl">
<tr>
<td style="width:12%;">
<b>Accepted</b>
<span t-attf-class="qa005-box-{{ 'filled' if doc.s20_accepted else 'empty' }}"/>
</td>
<td style="width:20%;"><b>Production Signature</b></td>
<td style="width:48%;">
<span t-field="doc.s20_signed_by.name"/>
</td>
<td style="width:8%;"><b>Date:</b></td>
<td style="width:12%;">
<span t-field="doc.s20_signed_date"/>
</td>
</tr>
</table>
<div class="qa005-risk-line">
<b>Comments:</b> <span t-field="doc.s20_comments"/>
</div>
<div class="qa005-risk-line">
<b><i>EVALUATE RISK</i></b>
<span class="qa005-evaluate-inline">
<span>
<span t-attf-class="qa005-box-{{ 'filled' if doc.s20_evaluate_risk else 'empty' }}"/>YES
</span>
<span>
<span t-attf-class="qa005-box-{{ 'filled' if not doc.s20_evaluate_risk else 'empty' }}"/>NO
</span>
<b>Level:</b>
<span t-esc="' '.join(['[%s]' % n if str(n) == doc.s20_risk_level else str(n) for n in range(1, 6)])"/>
</span>
</div>
<!-- ============ SECTION 3.0 ============ -->
<div class="qa005-section-title" style="margin-top:10pt;">3.0 Quality Review</div>
<table class="qa005-check-tbl">
<tr>
<td colspan="2"><span t-attf-class="qa005-box-{{ 'filled' if doc.s30_source_control_docs else 'empty' }}"/>Source Control Documents (Customer Spec's)</td>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s30_quality_clauses_supplied else 'empty' }}"/>Quality Clause(s) supplied</td>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s30_quality_clauses_attainable else 'empty' }}"/>Quality Clause(s) attainable</td>
</tr>
<tr>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s30_critical_tolerance else 'empty' }}"/>Critical Tolerance(s)</td>
<td colspan="2"><span t-attf-class="qa005-box-{{ 'filled' if doc.s30_measuring_tooling else 'empty' }}"/>Measuring Tooling Available</td>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s30_quality_tests_verified else 'empty' }}"/>Quality Tests Requirements Verified</td>
</tr>
<tr>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s30_specification_revisions else 'empty' }}"/>Specification Revisions</td>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s30_certifications_requirements else 'empty' }}"/>Certifications Requirements</td>
<td colspan="2"><span t-attf-class="qa005-box-{{ 'filled' if doc.s30_psd_rfd_reviewed else 'empty' }}"/>PSD, RFD etc. Reviewed</td>
</tr>
<tr>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s30_specification_deviations else 'empty' }}"/>Specification Deviations</td>
<td><span t-attf-class="qa005-box-{{ 'filled' if doc.s30_design_authority else 'empty' }}"/>Design Authority</td>
<td colspan="2"></td>
</tr>
</table>
<table class="qa005-sig-tbl">
<tr>
<td style="width:12%;">
<b>Accepted</b>
<span t-attf-class="qa005-box-{{ 'filled' if doc.s30_accepted else 'empty' }}"/>
</td>
<td style="width:20%;"><b>Quality Signature</b></td>
<td style="width:48%;">
<span t-field="doc.s30_signed_by.name"/>
</td>
<td style="width:8%;"><b>Date:</b></td>
<td style="width:12%;">
<span t-field="doc.s30_signed_date"/>
</td>
</tr>
</table>
<div class="qa005-risk-line">
<b><i>EVALUATE RISK</i></b>
<span class="qa005-evaluate-inline">
<span>
<span t-attf-class="qa005-box-{{ 'filled' if doc.s30_evaluate_risk else 'empty' }}"/>YES
</span>
<span>
<span t-attf-class="qa005-box-{{ 'filled' if not doc.s30_evaluate_risk else 'empty' }}"/>NO
</span>
<b>Level:</b>
<span t-esc="doc.s30_risk_band and doc.s30_risk_band.upper() or ''"/>
</span>
</div>
<!-- ============ RISK MATRIX ============ -->
<div style="display: table; width: 100%; margin-top: 10pt;">
<!-- Consequences legend (left) -->
<div style="display: table-cell; width: 42%; vertical-align: top; padding-right: 10pt;">
<b>CONSEQUENCES</b>
<table class="qa005-consequences-tbl">
<tr><td>1</td><td>Minimal</td><td>No impact</td></tr>
<tr><td>2</td><td>Moderate</td><td>Additional activities req'd</td></tr>
<tr><td>3</td><td>Mod. / Applicable</td><td>Unable to meet commitments</td></tr>
<tr><td>4</td><td>Major / Changes</td><td>Unable to meet commitments</td></tr>
<tr><td>5</td><td>Unacceptable</td><td>Unable to meet commitments</td></tr>
</table>
</div>
<!-- Likelihood legend (right) -->
<div style="display: table-cell; width: 58%; vertical-align: top;">
<b>LIKELIHOOD</b>
<table class="qa005-likelihood-tbl">
<tr><td>1</td><td>Not Likely</td><td>Current approach / process will effectively avoid this risk</td></tr>
<tr><td>2</td><td>Low Likelihood</td><td>Current approach / process has usually mitigated</td></tr>
<tr><td>3</td><td>Likely</td><td>Current approach / process may mitigate this risk</td></tr>
<tr><td>4</td><td>Highly Likely</td><td>Current approach / process cannot mitigate — different approach might</td></tr>
<tr><td>5</td><td>Near Certainty</td><td>Current approach / process cannot mitigate the risk and no processes are available</td></tr>
</table>
</div>
</div>
<!-- 5x5 coloured grid. The selected cell (consequence, likelihood)
gets an extra black outline for visual pickup on print. -->
<div style="display: table; width: 100%; margin-top: 10pt;">
<div style="display: table-cell; vertical-align: top;">
<table class="qa005-matrix">
<t t-set="bands" t-value="{
(1,5):'g',(2,5):'y',(3,5):'r',(4,5):'r',(5,5):'r',
(1,4):'g',(2,4):'y',(3,4):'y',(4,4):'r',(5,4):'r',
(1,3):'g',(2,3):'y',(3,3):'y',(4,3):'y',(5,3):'r',
(1,2):'g',(2,2):'g',(3,2):'y',(4,2):'y',(5,2):'y',
(1,1):'g',(2,1):'g',(3,1):'g',(4,1):'y',(5,1):'y',
}"/>
<t t-set="sel_c" t-value="int(doc.s30_risk_consequence) if doc.s30_risk_consequence else 0"/>
<t t-set="sel_l" t-value="int(doc.s30_risk_likelihood) if doc.s30_risk_likelihood else 0"/>
<tr t-foreach="[5,4,3,2,1]" t-as="lik">
<td style="border:none; font-weight:bold;" t-esc="lik"/>
<t t-foreach="[1,2,3,4,5]" t-as="cons">
<t t-set="band" t-value="bands.get((cons, lik))"/>
<t t-set="is_sel" t-value="(cons == sel_c) and (lik == sel_l)"/>
<td t-attf-class="cell-#{band}#{' cell-sel' if is_sel else ''}">
<t t-if="is_sel">X</t>
</td>
</t>
</tr>
<tr>
<td style="border:none;"/>
<td style="border:none; font-weight:bold;">1</td>
<td style="border:none; font-weight:bold;">2</td>
<td style="border:none; font-weight:bold;">3</td>
<td style="border:none; font-weight:bold;">4</td>
<td style="border:none; font-weight:bold;">5</td>
</tr>
</table>
<div style="font-size: 8pt; margin-top: 2pt;">
<span style="writing-mode: vertical-rl; transform: rotate(180deg);">LIKELIHOOD</span>
&#160;&#160;&#160;&#160;&#160;&#160;CONSEQUENCES
</div>
</div>
<!-- Mitigation plan required Y/N -->
<div style="display: table-cell; vertical-align: top; padding-left: 20pt;">
<div style="border: 1pt solid #000; padding: 6pt; margin-top: 10pt;">
<b>Mitigation Plan Required?</b>
<div style="margin-top: 4pt;">
<span>
<span t-attf-class="qa005-box-{{ 'filled' if doc.s30_mitigation_plan_required else 'empty' }}"/>YES
</span>
&#160;&#160;
<span>
<span t-attf-class="qa005-box-{{ 'filled' if not doc.s30_mitigation_plan_required else 'empty' }}"/>NO
</span>
</div>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -19,4 +19,12 @@
groups, so a single user role works across the core and the QMS.
-->
<!-- Multi-company rule for Contract Reviews (Sub 4). -->
<record id="fp_contract_review_company_rule" model="ir.rule">
<field name="name">Fusion Plating: Contract Review — multi-company</field>
<field name="model_id" ref="model_fp_contract_review"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
</record>
</odoo>

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
29 access_fp_quality_hold_operator fp.quality.hold.operator model_fusion_plating_quality_hold fusion_plating.group_fusion_plating_operator 1 0 1 0
30 access_fp_quality_hold_supervisor fp.quality.hold.supervisor model_fusion_plating_quality_hold fusion_plating.group_fusion_plating_supervisor 1 1 1 0
31 access_fp_quality_hold_manager fp.quality.hold.manager model_fusion_plating_quality_hold fusion_plating.group_fusion_plating_manager 1 1 1 1
32 access_fp_contract_review_operator fp.contract.review.operator model_fp_contract_review fusion_plating.group_fusion_plating_operator 1 0 0 0
33 access_fp_contract_review_supervisor fp.contract.review.supervisor model_fp_contract_review fusion_plating.group_fusion_plating_supervisor 1 1 1 0
34 access_fp_contract_review_manager fp.contract.review.manager model_fp_contract_review fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,270 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 4 — Contract Review (QA-005 1:1 reproduction).
-->
<odoo>
<!-- ================================================================== -->
<!-- FORM VIEW — laid out to mirror QA-005 Rev. 0 as closely as Odoo -->
<!-- backend forms permit. Printed PDF is the 1:1 artefact; this form -->
<!-- is the data-entry counterpart. -->
<!-- ================================================================== -->
<record id="view_fp_contract_review_form" model="ir.ui.view">
<field name="name">fp.contract.review.form</field>
<field name="model">fp.contract.review</field>
<field name="arch" type="xml">
<form string="Contract Review (QA-005)">
<header>
<button name="action_sign_section_20" type="object"
string="Sign Section 2.0"
class="btn-primary"
invisible="s20_locked or state == 'dismissed'"/>
<button name="action_sign_section_30" type="object"
string="Sign Section 3.0"
class="btn-primary"
invisible="s30_locked or not s20_locked or state == 'dismissed'"/>
<button name="action_print_qa005" type="object"
string="Print QA-005"
class="btn-secondary"/>
<button name="action_reopen" type="object"
string="Re-open"
confirm="This will clear both signatures and reset the review. Continue?"
groups="fusion_plating.group_fusion_plating_manager"
invisible="state != 'complete'"/>
<button name="action_dismiss" type="object"
string="Dismiss"
invisible="state in ('complete','dismissed')"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,assistant_review,manager_review,complete"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<!-- ===== HEADER (Customer, PO, Part, Qty, Due) ===== -->
<group>
<group string="Customer &amp; Order">
<field name="customer_id" readonly="1"/>
<field name="contract_po_number"/>
<field name="quote_or_job_number"/>
<field name="date_received"/>
</group>
<group string="Part">
<field name="part_id" readonly="1"/>
<field name="part_number" readonly="1"/>
<field name="part_revision" readonly="1"/>
<field name="qty"/>
<field name="due_date"/>
</group>
</group>
<notebook>
<!-- ========= SECTION 2.0 — Planning / Production Review ========= -->
<page string="2.0 Planning / Production Review"
name="section_20">
<group>
<field name="s20_locked" invisible="1"/>
</group>
<group col="4" string="Checklist">
<field name="s20_acceptable_lead_time" readonly="s20_locked"/>
<field name="s20_capacity_to_process" readonly="s20_locked"/>
<field name="s20_skills_to_process" readonly="s20_locked"/>
<field name="s20_fixtures_required" readonly="s20_locked"/>
<field name="s20_prime_approvals" readonly="s20_locked"/>
<field name="s20_pricing" readonly="s20_locked"/>
<field name="s20_approved_technique" readonly="s20_locked"/>
<field name="s20_drawings_available" readonly="s20_locked"/>
<field name="s20_process_type_class_grade" readonly="s20_locked"/>
<field name="s20_pre_post_processing_steps" readonly="s20_locked"/>
</group>
<group string="Outcome">
<field name="s20_accepted" readonly="s20_locked"/>
<field name="s20_evaluate_risk" readonly="s20_locked"/>
<field name="s20_risk_level" readonly="s20_locked"
invisible="not s20_evaluate_risk"/>
</group>
<group string="Comments">
<field name="s20_comments" nolabel="1" readonly="s20_locked"/>
</group>
<group string="Signature" invisible="not s20_locked">
<field name="s20_signed_by" readonly="1"/>
<field name="s20_signed_date" readonly="1"/>
</group>
</page>
<!-- ========= SECTION 3.0 — Quality Review ========= -->
<page string="3.0 Quality Review" name="section_30">
<group>
<field name="s30_locked" invisible="1"/>
</group>
<group col="4" string="Checklist">
<field name="s30_source_control_docs" readonly="s30_locked"/>
<field name="s30_quality_clauses_supplied" readonly="s30_locked"/>
<field name="s30_quality_clauses_attainable" readonly="s30_locked"/>
<field name="s30_critical_tolerance" readonly="s30_locked"/>
<field name="s30_measuring_tooling" readonly="s30_locked"/>
<field name="s30_quality_tests_verified" readonly="s30_locked"/>
<field name="s30_specification_revisions" readonly="s30_locked"/>
<field name="s30_certifications_requirements" readonly="s30_locked"/>
<field name="s30_psd_rfd_reviewed" readonly="s30_locked"/>
<field name="s30_specification_deviations" readonly="s30_locked"/>
<field name="s30_design_authority" readonly="s30_locked"/>
</group>
<group string="Outcome">
<field name="s30_accepted" readonly="s30_locked"/>
<field name="s30_evaluate_risk" readonly="s30_locked"/>
</group>
<group string="Risk Assessment"
invisible="not s30_evaluate_risk">
<field name="s30_risk_consequence" readonly="s30_locked"/>
<field name="s30_risk_likelihood" readonly="s30_locked"/>
<field name="s30_risk_band" readonly="1"
decoration-success="s30_risk_band == 'green'"
decoration-warning="s30_risk_band == 'yellow'"
decoration-danger="s30_risk_band == 'red'"
widget="badge"/>
<field name="s30_mitigation_plan_required" readonly="s30_locked"/>
</group>
<group string="Signature" invisible="not s30_locked">
<field name="s30_signed_by" readonly="1"/>
<field name="s30_signed_date" readonly="1"/>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- LIST VIEW -->
<!-- ================================================================== -->
<record id="view_fp_contract_review_list" model="ir.ui.view">
<field name="name">fp.contract.review.list</field>
<field name="model">fp.contract.review</field>
<field name="arch" type="xml">
<list string="Contract Reviews"
decoration-success="state == 'complete'"
decoration-muted="state == 'dismissed'"
decoration-info="state == 'manager_review'"
decoration-warning="state == 'assistant_review'">
<field name="name"/>
<field name="customer_id"/>
<field name="part_number"/>
<field name="part_revision"/>
<field name="date_received"/>
<field name="s20_signed_by"/>
<field name="s30_signed_by"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- KANBAN VIEW (grouped by state) -->
<!-- ================================================================== -->
<record id="view_fp_contract_review_kanban" model="ir.ui.view">
<field name="name">fp.contract.review.kanban</field>
<field name="model">fp.contract.review</field>
<field name="arch" type="xml">
<kanban default_group_by="state"
class="o_kanban_small_column">
<field name="name"/>
<field name="customer_id"/>
<field name="part_number"/>
<field name="part_revision"/>
<field name="state"/>
<templates>
<t t-name="card">
<div class="oe_kanban_global_click">
<div class="o_kanban_record_title">
<strong><field name="name"/></strong>
</div>
<div class="o_kanban_record_subtitle">
<field name="customer_id"/>
</div>
<div>
<span>Part: </span>
<field name="part_number"/>
<span> Rev </span>
<field name="part_revision"/>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ================================================================== -->
<!-- SEARCH VIEW -->
<!-- ================================================================== -->
<record id="view_fp_contract_review_search" model="ir.ui.view">
<field name="name">fp.contract.review.search</field>
<field name="model">fp.contract.review</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="customer_id"/>
<field name="part_number"/>
<field name="part_revision"/>
<field name="contract_po_number"/>
<field name="quote_or_job_number"/>
<separator/>
<filter name="filter_pending"
string="Pending"
domain="[('state','in',['draft','assistant_review','manager_review'])]"/>
<filter name="filter_complete"
string="Complete"
domain="[('state','=','complete')]"/>
<filter name="filter_dismissed"
string="Dismissed"
domain="[('state','=','dismissed')]"/>
<group>
<filter name="group_state" string="Status"
context="{'group_by': 'state'}"/>
<filter name="group_customer" string="Customer"
context="{'group_by': 'customer_id'}"/>
</group>
</search>
</field>
</record>
<!-- ================================================================== -->
<!-- ACTION -->
<!-- ================================================================== -->
<record id="action_fp_contract_review" model="ir.actions.act_window">
<field name="name">Contract Reviews</field>
<field name="res_model">fp.contract.review</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_fp_contract_review_search"/>
<field name="context">{'search_default_filter_pending': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No Contract Reviews yet.
</p>
<p>
Contract Reviews (QA-005) capture QA sign-off on new parts
before production. They are always optional and never
block manufacturing. Toggle
<b>Require Contract Review for new parts</b> on a customer
to show the reminder banner on new parts under that customer.
</p>
</field>
</record>
<!-- ================================================================== -->
<!-- MENU (under Plating → Quality) -->
<!-- ================================================================== -->
<menuitem id="menu_fp_quality_contract_review"
name="Contract Reviews"
parent="menu_fp_quality"
action="action_fp_contract_review"
sequence="15"/>
</odoo>

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 4 — Contract Review banner + tab on fp.part.catalog.
-->
<odoo>
<record id="view_fp_part_catalog_form_contract_review" model="ir.ui.view">
<field name="name">fp.part.catalog.form.contract.review</field>
<field name="model">fp.part.catalog</field>
<field name="inherit_id" ref="fusion_plating_configurator.view_fp_part_catalog_form"/>
<field name="arch" type="xml">
<!-- ===== Reminder banner above the title block ===== -->
<xpath expr="//div[hasclass('oe_title')]" position="before">
<field name="x_fc_contract_review_banner_visible" invisible="1"/>
<field name="x_fc_contract_review_state" invisible="1"/>
<field name="x_fc_contract_review_dismissed" invisible="1"/>
<div class="alert alert-info"
role="alert"
invisible="not x_fc_contract_review_banner_visible">
<i class="fa fa-info-circle me-2"/>
<strong>New part created.</strong>
Please complete the Contract Review (QA-005) if applicable.
This is optional — it can be dismissed and never blocks production.
<div class="mt-2">
<button name="action_start_contract_review"
type="object"
string="Start Contract Review"
class="btn btn-primary btn-sm me-2"/>
<button name="action_dismiss_contract_review"
type="object"
string="Dismiss"
class="btn btn-link btn-sm"/>
</div>
</div>
</xpath>
<!-- ===== Contract Review tab at end of notebook ===== -->
<xpath expr="//notebook" position="inside">
<page string="Contract Review" name="contract_review">
<group>
<field name="x_fc_contract_review_id" readonly="1"/>
<field name="x_fc_contract_review_state" readonly="1"
widget="badge"
decoration-success="x_fc_contract_review_state == 'complete'"
decoration-info="x_fc_contract_review_state == 'manager_review'"
decoration-warning="x_fc_contract_review_state == 'assistant_review'"
decoration-muted="x_fc_contract_review_state == 'dismissed'"
invisible="not x_fc_contract_review_id"/>
</group>
<div invisible="x_fc_contract_review_id or not partner_id">
<field name="x_fc_customer_requires_contract_review" invisible="1"/>
<p invisible="x_fc_customer_requires_contract_review">
This customer is not configured to require
Contract Reviews on new parts. You can still
start one manually.
</p>
<button name="action_start_contract_review"
type="object"
string="Start Contract Review"
class="btn btn-primary"/>
</div>
<div invisible="not x_fc_contract_review_id">
<button name="action_start_contract_review"
type="object"
string="Open Review"
class="btn btn-secondary me-2"/>
<button name="action_undismiss_contract_review"
type="object"
string="Undismiss Reminder"
class="btn btn-link"
groups="fusion_plating.group_fusion_plating_manager"
invisible="not x_fc_contract_review_dismissed"/>
</div>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 4 — Contract Review signer rosters in Fusion Plating settings.
-->
<odoo>
<record id="res_config_settings_view_form_fp_quality" model="ir.ui.view">
<field name="name">res.config.settings.view.form.fp.quality</field>
<field name="model">res.config.settings</field>
<field name="inherit_id"
ref="fusion_plating.res_config_settings_view_form_fp_core"/>
<field name="arch" type="xml">
<xpath expr="//app[@name='fusion_plating']" position="inside">
<block title="Contract Review (QA-005)"
name="fp_contract_review_settings"
help="Choose which users are authorised to sign each
section of a Contract Review. Users on these
lists gain only the right to sign — no other
permissions are affected. Plating Managers can
always sign regardless of these lists.">
<setting id="fp_qa_assistant_signers"
string="QA Assistant Signers"
help="Users authorised to sign Section 2.0
(Planning / Production Review). Typically
one or two people. Leave empty if any
user should be able to sign — in that
case a Plating Manager is the only
required approver.">
<field name="x_fc_qa_assistant_user_ids"
widget="many2many_tags"/>
</setting>
<setting id="fp_qa_manager_signers"
string="QA Manager Signers"
help="Users authorised to sign Section 3.0
(Quality Review). Typically the QA
Manager for the shop.">
<field name="x_fc_qa_manager_user_ids"
widget="many2many_tags"/>
</setting>
</block>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 4 — Contract Review toggle on customer.
-->
<odoo>
<record id="view_partner_form_fp_contract_review" model="ir.ui.view">
<field name="name">res.partner.form.fp.contract.review</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Plating Contract Review"
name="fp_contract_review_prefs"
invisible="is_company != True">
<group name="fp_contract_review_group">
<p class="text-muted" colspan="2">
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.
</p>
<field name="x_fc_contract_review_required"
widget="boolean_toggle"/>
</group>
</page>
</xpath>
</field>
</record>
</odoo>