diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py
index 9423c7c0..9c0fdbcb 100644
--- a/fusion_plating/fusion_plating_certificates/__manifest__.py
+++ b/fusion_plating/fusion_plating_certificates/__manifest__.py
@@ -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': """
diff --git a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py
index 8c1f2516..65be18b3 100644
--- a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py
+++ b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py
@@ -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 bypassed by '
+ '%(u)s (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
diff --git a/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml b/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
index b4b0dc36..f22d4bbf 100644
--- a/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
+++ b/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
@@ -49,7 +49,8 @@
rule 13f.) -->
+ invisible="state != 'draft'"
+ groups="fusion_plating.group_fp_quality_manager,fusion_plating.group_fp_manager,fusion_plating.group_fp_owner"/>