# Sub 4 — Contract Review (Optional, Per-Part, QA-005 1:1) **Date:** 2026-04-22 **Module scope:** `fusion_plating_quality` (gains `fusion_plating_configurator` dependency) **Status:** Design approved; implementing in this session **Predecessor context:** Fine-Tuning Initiative, entry in `fusion_plating/CLAUDE.md`; supersedes the name-matched `contract_review_user_ids` gate on `mrp.workorder` --- ## 1. Scope (Revised) Originally framed as a pre-production gate (block MO confirm until two-stage QA sign-off). **Revised on client feedback during brainstorming (2026-04-22):** the review is **fully optional** and **never blocks** MO/SO/WO progression. Its value is as an audit artefact, not a gate. ### In scope 1. **Customer-level toggle** (`res.partner.x_fc_contract_review_required`) — when ON, new parts under that customer surface a reminder banner on the part form. 2. **Per-part review record** (`fp.contract.review`) — one per `fp.part.catalog` record. Two-section structure mirroring the paper QA-005 form (Section 2.0 QA Assistant / Section 3.0 QA Manager). 3. **Settings-driven QA roster** — two Many2many user lists (`qa_assistant_user_ids`, `qa_manager_user_ids`) stored on `res.company`, exposed via `res.config.settings`. Controls *who can sign*, without creating new `res.groups` that cascade other permissions. 4. **Printable QWeb PDF** reproducing QA-005 Rev. 0 one-to-one, so a completed digital review prints identically to the existing paper form. 5. **Lazy creation** — no `fp.contract.review` exists until the user clicks "Start Contract Review" on the part. Clean data for parts that never get reviewed. 6. **Auto-dismiss** — banner disappears once the part's first `mrp.production` reaches `confirmed` state; the review record (if created) is preserved for audit. 7. **Dedicated menu** — "Plating → Quality → Contract Reviews" for QA team triage (filters: pending / in progress / complete / dismissed). ### Out of scope (explicit) - Pre-production gate — withdrawn in brainstorm. - New `res.groups` for QA Assistant / QA Manager — rejected; settings-roster replaces. - Contract-review activity/reminder via `mail.activity` — user wants a view banner, not an activity. - Auto-copy from prior revision — explicit "Copy from previous revision" button only if requested later. - Per-customer roster override — deferred to Sub 6 (contact profiles) if needed. - Retroactive removal of `fp.process.node.contract_review_user_ids` and the WO-finish gate in `fusion_plating_bridge_mrp`. They stay for now; Sub 4's review is orthogonal. Clean-up is a separate concern (can fold into Sub 6 or later). --- ## 2. Data Model ### 2.1 New model: `fp.contract.review` ```python class FpContractReview(models.Model): _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(compute='_compute_name', store=True) part_id = fields.Many2one('fp.part.catalog', required=True, ondelete='cascade', tracking=True) customer_id = fields.Many2one(related='part_id.customer_id', store=True) part_number = fields.Char(related='part_id.part_number', store=True) part_revision = fields.Char(related='part_id.revision', store=True) company_id = fields.Many2one('res.company', required=True, default=lambda s: s.env.company) date_received = fields.Date(default=fields.Date.context_today, tracking=True) # Optional commercial context — blank unless triggered from an SO line contract_po_number = fields.Char(string='Contract / PO No.') quote_or_job_number = fields.Char(string='Quote or Job #') qty = fields.Integer() due_date = fields.Date() # ---- 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') s20_comments = fields.Text(string='Comments') s20_evaluate_risk = fields.Boolean(string='Evaluate Risk') s20_risk_level = fields.Selection( [('1','1'),('2','2'),('3','3'),('4','4'),('5','5')], string='Risk Level') s20_signed_by = fields.Many2one('res.users', readonly=True, string='Production Signature') s20_signed_date = fields.Datetime(readonly=True) s20_locked = fields.Boolean(readonly=True, default=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') s30_evaluate_risk = fields.Boolean(string='Evaluate Risk') s30_risk_consequence = fields.Selection( [('1','1 — Minimal'),('2','2 — Moderate'),('3','3 — Mod./Applicable'), ('4','4 — Major/Changes'),('5','5 — Unacceptable')]) s30_risk_likelihood = fields.Selection( [('1','1 — Not Likely'),('2','2 — Low Likelihood'),('3','3 — Likely'), ('4','4 — Highly Likely'),('5','5 — Near Certainty')]) s30_risk_band = fields.Selection( [('green','Green'),('yellow','Yellow'),('red','Red')], compute='_compute_risk_band', store=True) s30_mitigation_plan_required = fields.Boolean(string='Mitigation Plan Required') s30_signed_by = fields.Many2one('res.users', readonly=True, string='Quality Signature') s30_signed_date = fields.Datetime(readonly=True) s30_locked = fields.Boolean(readonly=True, default=False) # ---- State ----------------------------------------------------------- state = fields.Selection([ ('draft', 'Draft'), ('assistant_review', 'QA Assistant Review'), ('manager_review', 'QA Manager Review'), ('complete', 'Complete'), ('dismissed', 'Dismissed'), ], default='draft', tracking=True, required=True) ``` ### 2.2 Risk band compute (5×5 matrix, matches QA-005) Severity × Likelihood matrix from the printed form (reading bottom-row as likelihood=1): ``` Likelihood 5 | G Y R R R (row reads left→right for consequence 1..5) 4 | G Y Y R R 3 | G Y Y Y R 2 | G G Y Y Y 1 | G G G Y Y ``` Compute: ```python @api.depends('s30_risk_consequence', 's30_risk_likelihood') def _compute_risk_band(self): 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', } for rec in self: 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 rec.s30_risk_band = MATRIX.get((c, l), False) ``` ### 2.3 New fields — `res.partner` ```python 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 is always optional — ' 'the reminder can be dismissed and never blocks production.', ) ``` Placed on the customer form's `Sales & Purchase` tab next to the existing `x_fc_send_coc` / `x_fc_send_thickness_report` flags. ### 2.4 New fields — `fp.part.catalog` ```python x_fc_contract_review_id = fields.Many2one( 'fp.contract.review', string='Contract Review', ondelete='set null', copy=False, ) x_fc_contract_review_dismissed = fields.Boolean( string='Reminder dismissed', copy=False, ) x_fc_has_confirmed_mo = fields.Boolean( compute='_compute_has_confirmed_mo', store=False, ) x_fc_contract_review_banner_visible = fields.Boolean( compute='_compute_contract_review_banner_visible', store=False, ) ``` Compute semantics: ```python @api.depends('customer_id.x_fc_contract_review_required', 'x_fc_contract_review_dismissed', 'x_fc_contract_review_id.state') def _compute_contract_review_banner_visible(self): for part in self: needs = part.customer_id.x_fc_contract_review_required dismissed = part.x_fc_contract_review_dismissed complete = (part.x_fc_contract_review_id and part.x_fc_contract_review_id.state == 'complete') in_prod = part._fp_has_confirmed_mo() # helper, see 2.4.1 part.x_fc_contract_review_banner_visible = ( needs and not dismissed and not complete and not in_prod ) ``` **2.4.1 `_fp_has_confirmed_mo()` helper.** Searches `mrp.production` joined to `sale.order.line` by `part_catalog_id` (Sub 3 field). Returns True if any MO in `confirmed`, `progress`, `to_close`, or `done` state exists for the part. Uses a single `search_count` for efficiency; compute stays `store=False` to avoid write-amplification on MO state changes. ### 2.5 New fields — `res.company` ```python 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.', ) 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.', ) ``` Exposed via `res.config.settings` as `related=` fields with `readonly=False`. ### 2.6 Migration New fields only. No data migration required. - `fusion_plating_quality` version bump: `19.0.1.3.0 → 19.0.2.0.0` - `fusion_plating_quality.__manifest__.depends` adds `fusion_plating_configurator` - Existing parts will compute `x_fc_contract_review_banner_visible = False` (because `customer_id.x_fc_contract_review_required` defaults to False). No false-positive banners. --- ## 3. User Flow ### 3.1 Banner & Start 1. Admin enables `x_fc_contract_review_required` on a customer. 2. Estimator / user creates a new part under that customer. 3. Part form renders a blue info banner: > ℹ️ *New part created — please complete Contract Review if applicable.* > [Start Contract Review] [Dismiss] 4. Clicking **Start Contract Review** → `fp.part.catalog.action_start_contract_review()`: - Creates `fp.contract.review` with `part_id = self.id`, `state = 'assistant_review'` - Sets `part.x_fc_contract_review_id = review.id` - Returns `ir.actions.act_window` opening the review form 5. Clicking **Dismiss** → sets `x_fc_contract_review_dismissed = True`. Banner disappears. Dismissal is reversible: Plating Manager sees an "Undismiss" button on the part's Contract Review tab. ### 3.2 Signing sections - On the review form, the user fills Section 2.0 fields (editable while `not s20_locked`), then clicks **Sign Section 2.0**. - `action_sign_section_20()`: - Checks `self.env.user in company.x_fc_qa_assistant_user_ids` OR `self.env.user.has_group('fusion_plating.group_fusion_plating_manager')` - If blocked: `UserError` listing the allowed signers. - If pass: writes `s20_signed_by = env.user`, `s20_signed_date = now`, `s20_locked = True`, `state = 'manager_review'`. Posts a chatter message ("Section 2.0 signed by ..."). - Same pattern for Section 3.0 (`action_sign_section_30()`). - On Section 3.0 sign: `state = 'complete'`. Part's banner flips off via compute (state transition triggers recompute). ### 3.3 Reset / Re-open - Plating Manager sees a **Re-open** button on complete reviews. - Resets whichever section(s) the user chooses (wizard confirmation), clearing `sN0_signed_by/date`, `sN0_locked`, and bumping state back. ### 3.4 Auto-dismiss on production - No explicit write; `x_fc_contract_review_banner_visible` is a compute. When the first `mrp.production` reaches `confirmed` (or later state) for the part, `_fp_has_confirmed_mo()` returns True → banner hides. - The review record persists for audit regardless of MO state. ### 3.5 Part form — Contract Review tab Always present (not invisible). Shows: - **If no review:** "No contract review. [Start Contract Review]" (button enabled iff customer toggle is on; otherwise text "Customer does not require contract review"). - **If review in progress:** state badge, section 2.0/3.0 completion indicators, "[Open Review]". - **If complete:** green badge "Complete — signed by *X* on *date*, *Y* on *date*", "[Open Review]" + "[Print PDF]". - **If dismissed:** "Reminder dismissed [Undismiss]" (Plating Manager only). ### 3.6 QA worklist menu New menu: *Plating → Quality → Contract Reviews* (sequence after NCRs). List view: Part Number, Revision, Customer, State, Created On, Section 2.0 Signer, Section 3.0 Signer. Default filter: `state != 'complete' and state != 'dismissed'`. Kanban grouped by state. --- ## 4. QWeb Report — QA-005 Rev. 0 (1:1 Paper Form) **External ID:** `fusion_plating_quality.action_report_contract_review` **Template:** `fusion_plating_quality.report_contract_review_qa005` **Paper size:** Letter, portrait, 0.5" margins. **Layout target:** visually indistinguishable from the provided QA-005 PDF (colour logo top-left, title block "CONTRACT REVIEW AND RISK ASSESSMENT", form code "FRM: QA-005 Rev. 0", issue date "Nov 25, 2021"; identical section table arrangement; checkboxes render filled (■) when True, empty (☐) when False; 5×5 risk matrix reproduced as a coloured HTML table with the current consequence × likelihood cell highlighted). **Company logo:** `env.company.logo` at ~100 px wide. On the EN Technologies tenant this is already the ENTECH colour logo; no per-module image asset needed. **Bilingual / blank-printable:** the template renders empty boxes and blank signature areas when fields are unset, so the same report can be printed with a blank review and filled by hand — matches the existing paper workflow for customers who prefer it. **Print button:** on the `fp.contract.review` form and on the part's Contract Review tab when review exists. --- ## 5. Settings UI **Location:** Settings → Fusion Plating → Contract Review section (new). ``` Contract Review ├── QA Assistant Signers [many2many_tags widget] └── QA Manager Signers [many2many_tags widget] ``` Backed by `res.company.x_fc_qa_assistant_user_ids` / `x_fc_qa_manager_user_ids` (multi-company safe). Domain `[('share','=','False')]` excludes portal users. --- ## 6. Security & Access - `fp.contract.review` CRUD: - **Operator:** read own company's records only - **Supervisor:** read all, write if signer rosters allow - **Manager:** full CRUD + re-open - **Admin:** full CRUD + delete - `ir.model.access.csv` entries for each group. - `ir.rule` for company-scoped visibility (`company_id in company_ids`). - Sign-method rosters are enforced in Python (`_check_signer`), not via access rules. --- ## 7. Defensive Measures (Prevent Rework for Later Subs) 1. **Cert-resolver-style roster helper** — single `res.company._fp_get_qa_signers(section)` method returns the effective signer list for a given section (20 or 30). Every sign action calls this. When Sub 6 adds per-customer / per-contact overrides, only this helper changes; call sites stay put. 2. **Banner compute is stateless** — derived purely from customer toggle + MO state + review state + dismissed flag. No separate banner-state column to migrate when Sub 8 restructures receiving/inspection. 3. **Review record survives MO lifecycle** — never deleted on MO confirm/cancel, so audit trail is stable across Sub 5's order-line changes. 4. **QWeb template isolates styling** — uses `env.company.logo` and inline CSS. No reliance on `fusion_plating_reports` macros. Sub 5's customer-line-header macro can be added later without touching the QA-005 template. 5. **No `res.groups` added** — means Sub 6's contact-profile permission work can layer on freely. If aerospace customers later require named signers, that's a per-customer override on `res.partner`, not a group. --- ## 8. Testing Strategy ### 8.1 Smoke (Python unit test) - Create customer with `x_fc_contract_review_required = True` - Create part under that customer → assert `x_fc_contract_review_banner_visible` - Click `action_start_contract_review()` → assert review created, state = `assistant_review` - Sign section 2.0 with roster user → assert locked + state = `manager_review` - Sign section 3.0 → assert `state = complete` + banner hidden - Confirm MO linked to part → re-open blank review part, assert banner hidden ### 8.2 Negative-path tests - Non-roster user attempts sign → `UserError` - Plating Manager bypass → succeeds - Customer toggle OFF → banner never shows - Dismiss → banner hidden; Plating Manager undismisses → banner returns ### 8.3 Risk-matrix compute test - Parametrise all 25 (consequence, likelihood) pairs → assert band matches QA-005 paper form colour map. ### 8.4 PDF render smoke - Call `action_report_contract_review` on a complete review → assert bytes returned and content-type `application/pdf`. - Visually compare first-page render to the provided QA-005 PDF during manual QA. ### 8.5 Migration smoke - Upgrade module on a DB with existing parts → assert no banner appears for any existing part (customer toggle defaults to False). --- ## 9. File Manifest (what gets touched) ``` fusion_plating_quality/ ├── __manifest__.py ← depends +=[configurator], version bump ├── models/ │ ├── __init__.py ← +fp_contract_review, +res_partner, │ │ +res_company, +res_config_settings, │ │ +fp_part_catalog (inherit) │ ├── fp_contract_review.py ← NEW │ ├── res_partner.py ← NEW (x_fc_contract_review_required) │ ├── res_company.py ← NEW (QA signer rosters) │ ├── res_config_settings.py ← NEW (related fields) │ └── fp_part_catalog.py ← NEW (banner compute + actions) ├── views/ │ ├── fp_contract_review_views.xml ← NEW (form, list, kanban, search, action, menu) │ ├── res_partner_views.xml ← NEW (toggle on Sales tab) │ ├── res_config_settings_views.xml ← NEW (settings UI) │ └── fp_part_catalog_views.xml ← NEW (banner + tab inherit) ├── reports/ │ ├── __init__.py │ ├── contract_review_report.xml ← NEW (report action) │ └── contract_review_template.xml ← NEW (QWeb 1:1 QA-005) ├── security/ │ ├── fp_quality_security.xml ← unchanged │ └── ir.model.access.csv ← +fp.contract.review rows └── data/ └── (no data seeds needed for Sub 4) ``` Approximate LOC: ~900 Python, ~500 XML. Single-session implementable. --- ## 10. Rollout & Versioning - `fusion_plating_quality` → `19.0.2.0.0` - Entech deployment order: 1. Rsync module to `/mnt/extra-addons/custom/fusion_plating_quality/` 2. `systemctl stop odoo` 3. `odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_quality --stop-after-init` 4. `systemctl start odoo` - Post-deploy verification: - Open customer, toggle `x_fc_contract_review_required`, create test part, confirm banner appears, complete review end-to-end, print PDF. --- ## 11. Open Items for Future Subs (Tracked Here, Not Here) - Sub 6 — per-customer QA signer overrides - Eventual retirement of legacy `contract_review_user_ids` on `fusion.plating.process.node` and the WO-finish gate in `fusion_plating_bridge_mrp` (orthogonal to Sub 4; clean up when Sub 6 lands or earlier if requested). --- *End of spec.*