feat(fp.certificate): ACL guard + state hooks + age field (Tasks 6-8)
action_issue gated to Manager/QM/Owner via Python AccessError +
view-level groups= on the Issue button (two-layer enforcement).
Manager bypass via fp_skip_cert_authority_gate=True context flag
with chatter audit.
action_issue post-callback calls job._fp_check_advance_after_cert_issue
so the job auto-advances awaiting_cert → awaiting_ship when every
required cert is issued.
write({'state':'voided'}) override calls
job._fp_check_regress_after_cert_void so a previously-issued cert
being voided slides the job back to awaiting_cert and re-notifies
the QM.
x_fc_age_hours non-stored Float drives the Quality Dashboard age
chip + overdue filter.
Version bump 19.0.7.9.3 → 19.0.8.0.0 (spec said 19.0.6.0.0 but
current is already higher; bumped to next major instead).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Certificates',
|
'name': 'Fusion Plating — Certificates',
|
||||||
'version': '19.0.7.9.3',
|
'version': '19.0.8.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -224,6 +224,13 @@ class FpCertificate(models.Model):
|
|||||||
[('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')],
|
[('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')],
|
||||||
string='Status', default='draft', tracking=True, required=True,
|
string='Status', default='draft', tracking=True, required=True,
|
||||||
)
|
)
|
||||||
|
x_fc_age_hours = fields.Float(
|
||||||
|
string='Age (hours)',
|
||||||
|
compute='_compute_x_fc_age_hours',
|
||||||
|
help='Hours since the cert was created. Drives the Quality '
|
||||||
|
'Dashboard age chip and overdue filter. Non-stored — '
|
||||||
|
'always fresh on read. Spec 2026-05-25.',
|
||||||
|
)
|
||||||
void_reason = fields.Text(string='Void Reason')
|
void_reason = fields.Text(string='Void Reason')
|
||||||
notes = fields.Html(string='Notes')
|
notes = fields.Html(string='Notes')
|
||||||
|
|
||||||
@@ -285,6 +292,17 @@ class FpCertificate(models.Model):
|
|||||||
|
|
||||||
@api.depends('thickness_reading_ids', 'thickness_reading_ids.nip_mils',
|
@api.depends('thickness_reading_ids', 'thickness_reading_ids.nip_mils',
|
||||||
'spec_min_mils', 'spec_max_mils')
|
'spec_min_mils', 'spec_max_mils')
|
||||||
|
def _compute_x_fc_age_hours(self):
|
||||||
|
"""Hours since cert creation. Non-stored, recomputed on read."""
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.now()
|
||||||
|
for rec in self:
|
||||||
|
if not rec.create_date:
|
||||||
|
rec.x_fc_age_hours = 0.0
|
||||||
|
continue
|
||||||
|
delta = now - rec.create_date
|
||||||
|
rec.x_fc_age_hours = delta.total_seconds() / 3600.0
|
||||||
|
|
||||||
def _compute_reading_stats(self):
|
def _compute_reading_stats(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
readings = rec.thickness_reading_ids
|
readings = rec.thickness_reading_ids
|
||||||
@@ -420,6 +438,38 @@ class FpCertificate(models.Model):
|
|||||||
|
|
||||||
# ----- State actions ----------------------------------------------------
|
# ----- State actions ----------------------------------------------------
|
||||||
def action_issue(self):
|
def action_issue(self):
|
||||||
|
# ===== ACL guard (spec 2026-05-25 §ACL changes) ===============
|
||||||
|
# Only QM / Manager / Owner can issue certificates. Two-layer
|
||||||
|
# enforcement; view-level groups= on the button is the other
|
||||||
|
# layer. Manager bypass via context for cron / scripted issuance.
|
||||||
|
if not self.env.context.get('fp_skip_cert_authority_gate'):
|
||||||
|
cert_authority_gids = []
|
||||||
|
for xmlid in (
|
||||||
|
'fusion_plating.group_fp_quality_manager',
|
||||||
|
'fusion_plating.group_fp_manager',
|
||||||
|
'fusion_plating.group_fp_owner',
|
||||||
|
):
|
||||||
|
grp = self.env.ref(xmlid, raise_if_not_found=False)
|
||||||
|
if grp:
|
||||||
|
cert_authority_gids.append(grp.id)
|
||||||
|
if cert_authority_gids and not (
|
||||||
|
set(self.env.user.all_group_ids.ids)
|
||||||
|
& set(cert_authority_gids)
|
||||||
|
):
|
||||||
|
from odoo.exceptions import AccessError
|
||||||
|
raise AccessError(_(
|
||||||
|
'Only Quality Managers, Managers, and Owners can '
|
||||||
|
'issue certificates. Ask your QM to review and '
|
||||||
|
'issue this CoC.'
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
from markupsafe import Markup
|
||||||
|
for rec in self:
|
||||||
|
rec.message_post(body=Markup(_(
|
||||||
|
'Cert authority gate <b>bypassed</b> by '
|
||||||
|
'<b>%(u)s</b> (context flag '
|
||||||
|
'fp_skip_cert_authority_gate).'
|
||||||
|
)) % {'u': self.env.user.name})
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if rec.state != 'draft':
|
if rec.state != 'draft':
|
||||||
raise UserError(_('Only draft certificates can be issued.'))
|
raise UserError(_('Only draft certificates can be issued.'))
|
||||||
@@ -607,6 +657,39 @@ class FpCertificate(models.Model):
|
|||||||
rec.name, e,
|
rec.name, e,
|
||||||
)
|
)
|
||||||
rec.message_post(body=_('Certificate issued.'))
|
rec.message_post(body=_('Certificate issued.'))
|
||||||
|
# Post-issue: ask the job to check whether ALL required
|
||||||
|
# certs are now issued. If so, the helper auto-advances to
|
||||||
|
# awaiting_ship and resolves any open Issue-CoC activity.
|
||||||
|
# Spec 2026-05-25.
|
||||||
|
if ('x_fc_job_id' in rec._fields and rec.x_fc_job_id
|
||||||
|
and hasattr(rec.x_fc_job_id,
|
||||||
|
'_fp_check_advance_after_cert_issue')):
|
||||||
|
rec.x_fc_job_id._fp_check_advance_after_cert_issue()
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
"""Override to detect cert voiding and trigger the job state
|
||||||
|
regress (awaiting_ship → awaiting_cert). Spec 2026-05-25.
|
||||||
|
|
||||||
|
Captures which certs were `issued` BEFORE the write, so we know
|
||||||
|
post-write whether a void actually downgraded a previously-
|
||||||
|
issued cert. Calls the inverse-direction helper on the job.
|
||||||
|
"""
|
||||||
|
was_issued = {}
|
||||||
|
if 'state' in vals and vals['state'] == 'voided':
|
||||||
|
was_issued = {
|
||||||
|
rec.id: (rec.state == 'issued')
|
||||||
|
for rec in self
|
||||||
|
}
|
||||||
|
result = super().write(vals)
|
||||||
|
if was_issued:
|
||||||
|
for rec in self:
|
||||||
|
if not was_issued.get(rec.id):
|
||||||
|
continue # wasn't issued — no regress
|
||||||
|
if ('x_fc_job_id' in rec._fields and rec.x_fc_job_id
|
||||||
|
and hasattr(rec.x_fc_job_id,
|
||||||
|
'_fp_check_regress_after_cert_void')):
|
||||||
|
rec.x_fc_job_id._fp_check_regress_after_cert_void()
|
||||||
|
return result
|
||||||
|
|
||||||
def _fp_sync_coc_to_delivery(self):
|
def _fp_sync_coc_to_delivery(self):
|
||||||
"""Push this CoC's attachment onto its job's delivery so the
|
"""Push this CoC's attachment onto its job's delivery so the
|
||||||
|
|||||||
@@ -49,7 +49,8 @@
|
|||||||
rule 13f.) -->
|
rule 13f.) -->
|
||||||
<button name="action_issue" string="Issue"
|
<button name="action_issue" string="Issue"
|
||||||
type="object" class="btn-primary"
|
type="object" class="btn-primary"
|
||||||
invisible="state != 'draft'"/>
|
invisible="state != 'draft'"
|
||||||
|
groups="fusion_plating.group_fp_quality_manager,fusion_plating.group_fp_manager,fusion_plating.group_fp_owner"/>
|
||||||
<!-- Print = the same EN report action the gear-menu
|
<!-- Print = the same EN report action the gear-menu
|
||||||
Print > Certificate of Conformance (English)
|
Print > Certificate of Conformance (English)
|
||||||
calls. Routes through fusion_pdf_preview's
|
calls. Routes through fusion_pdf_preview's
|
||||||
|
|||||||
Reference in New Issue
Block a user