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:
gsinghpal
2026-05-25 09:43:08 -04:00
parent 4930a89970
commit 4dc0a7cca5
3 changed files with 86 additions and 2 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.7.9.3',
'version': '19.0.8.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -224,6 +224,13 @@ class FpCertificate(models.Model):
[('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')],
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')
notes = fields.Html(string='Notes')
@@ -285,6 +292,17 @@ class FpCertificate(models.Model):
@api.depends('thickness_reading_ids', 'thickness_reading_ids.nip_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):
for rec in self:
readings = rec.thickness_reading_ids
@@ -420,6 +438,38 @@ class FpCertificate(models.Model):
# ----- State actions ----------------------------------------------------
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:
if rec.state != 'draft':
raise UserError(_('Only draft certificates can be issued.'))
@@ -607,6 +657,39 @@ class FpCertificate(models.Model):
rec.name, e,
)
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):
"""Push this CoC's attachment onto its job's delivery so the

View File

@@ -49,7 +49,8 @@
rule 13f.) -->
<button name="action_issue" string="Issue"
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 > Certificate of Conformance (English)
calls. Routes through fusion_pdf_preview's