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.) -->