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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
125
fusion_plating/fusion_plating_quality/models/fp_part_catalog.py
Normal file
125
fusion_plating/fusion_plating_quality/models/fp_part_catalog.py
Normal 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
|
||||
51
fusion_plating/fusion_plating_quality/models/res_company.py
Normal file
51
fusion_plating/fusion_plating_quality/models/res_company.py
Normal 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']
|
||||
@@ -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',
|
||||
)
|
||||
19
fusion_plating/fusion_plating_quality/models/res_partner.py
Normal file
19
fusion_plating/fusion_plating_quality/models/res_partner.py
Normal 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.',
|
||||
)
|
||||
Reference in New Issue
Block a user