diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 2b18fb34..0941659b 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -53,6 +53,8 @@ full design rationale and §6.2 of the implementation plan for task list. 'security/legacy_groups.xml', 'security/ir.model.access.csv', 'data/fp_cron_data.xml', + # Spec 2026-05-25 — mail.activity.type for QM Issue-CoC nudge. + 'data/fp_activity_types_data.xml', # Sub 14 — workflow state catalog (must load before fp_job_form_inherit # so the statusbar's m2o has its targets available at view-render time). 'data/fp_workflow_state_data.xml', diff --git a/fusion_plating/fusion_plating_jobs/data/fp_activity_types_data.xml b/fusion_plating/fusion_plating_jobs/data/fp_activity_types_data.xml new file mode 100644 index 00000000..ede83e60 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/data/fp_activity_types_data.xml @@ -0,0 +1,28 @@ + + + + + + Issue CoC + Issue Certificate of Conformance + fa-certificate + 1 + days + current_date + fp.job + Job has finished the shop floor. Review the inspection prompts captured on the final step, then issue the CoC from the Quality Dashboard. + + + diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index f59d6540..489fb9fc 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -2390,6 +2390,78 @@ class FpJob(models.Model): if hasattr(job, '_fp_schedule_cert_activity'): job._fp_schedule_cert_activity() + def _fp_schedule_cert_activity(self): + """Schedule an Issue-CoC mail.activity for one QM. Round-robin + by oldest login_date (least recently active QM, likely least + busy). Idempotent — re-firing while an open activity already + exists is a no-op. + + Spec 2026-05-25 §mail.activity belt + suspenders. + """ + self.ensure_one() + activity_type = self.env.ref( + 'fusion_plating_jobs.activity_type_issue_coc', + raise_if_not_found=False, + ) + if not activity_type: + return + # Idempotency: skip if an open activity of this type exists. + existing = self.activity_ids.filtered( + lambda a: a.activity_type_id == activity_type + ) + if existing: + return + Template = self.env.get('fp.notification.template') + if not Template or not hasattr( + Template, '_fp_resolve_cert_authority_users'): + return + qms = Template.sudo()._fp_resolve_cert_authority_users(self) + if not qms: + return + # Round-robin: pick the QM who logged in least recently (likely + # least busy). NULL login_date sorts first. + qm = qms.sorted( + lambda u: u.login_date or fields.Datetime.from_string( + '1970-01-01 00:00:00' + ) + )[:1] + try: + self.activity_schedule( + activity_type_id=activity_type.id, + user_id=qm.id, + summary=_('Issue CoC for %s') % ( + self.display_wo_name or self.name or 'job' + ), + ) + except Exception as e: + _logger.warning( + "Job %s: schedule cert activity failed: %s", self.name, e, + ) + + def _fp_resolve_cert_activities(self): + """Auto-resolve all open Issue-CoC activities on this job. + Called from _fp_check_advance_after_cert_issue when the job + transitions awaiting_cert → awaiting_ship. Spec 2026-05-25. + """ + self.ensure_one() + activity_type = self.env.ref( + 'fusion_plating_jobs.activity_type_issue_coc', + raise_if_not_found=False, + ) + if not activity_type: + return + open_activities = self.activity_ids.filtered( + lambda a: a.activity_type_id == activity_type + ) + for act in open_activities: + try: + act.action_feedback(feedback=_('Cert issued — auto-resolved.')) + except Exception as e: + _logger.warning( + "Job %s: auto-resolve cert activity failed: %s", + self.name, e, + ) + def _fp_create_certificates(self): """Auto-create one draft fp.certificate per type returned by _resolve_required_cert_types. Idempotent per type — re-running diff --git a/fusion_plating/fusion_plating_notifications/__manifest__.py b/fusion_plating/fusion_plating_notifications/__manifest__.py index fe03f20b..d2aeed3e 100644 --- a/fusion_plating/fusion_plating_notifications/__manifest__.py +++ b/fusion_plating/fusion_plating_notifications/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Notifications', - 'version': '19.0.6.6.1', + 'version': '19.0.7.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.', 'author': 'Nexa Systems Inc.', @@ -31,6 +31,7 @@ 'security/ir.model.access.csv', 'data/mail_template_data.xml', 'data/fp_notification_template_data.xml', + 'data/fp_cert_authority_templates.xml', 'views/fp_notification_template_views.xml', 'views/fp_notification_log_views.xml', 'views/fp_notifications_menu.xml', diff --git a/fusion_plating/fusion_plating_notifications/data/fp_cert_authority_templates.xml b/fusion_plating/fusion_plating_notifications/data/fp_cert_authority_templates.xml new file mode 100644 index 00000000..8d022114 --- /dev/null +++ b/fusion_plating/fusion_plating_notifications/data/fp_cert_authority_templates.xml @@ -0,0 +1,127 @@ + + + + + + + + + FP: Cert Awaiting Issuance + + 🏷️ Job {{ object.display_wo_name or object.name }} ready for CoC issuance + {{ (object.company_id.email or user.email) }} + + +
+
+
+ Quality Action Required +
+

CoC Awaiting Issuance

+

+ Job + () has finished the shop floor + and is awaiting CoC issuance. +

+ + + + + + + + + + + + + + + + + + + + + +
DetailValue
Customer
Part + +
Quantity
Recipe + +
+

+ Review the inspection prompts captured by the operator on the + Final Inspection step, then issue the CoC from the Quality + Dashboard. +

+

+ + Open Quality Dashboard → + +

+
+
+
+ + + Cert Awaiting Issuance + cert_awaiting_issuance + + + + + + + + + FP: Cert Voided — Re-Issue + + ⚠️ Job {{ object.display_wo_name or object.name }} CoC voided — please re-issue + {{ (object.company_id.email or user.email) }} + + +
+
+
+ Cert Regression +
+

CoC Voided — Please Re-Issue

+

+ A previously-issued CoC for job + + () was voided. The job has + slid back to Awaiting Cert and is waiting for re-issuance. +

+

+ + Open Quality Dashboard → + +

+
+
+
+ + + Cert Voided — Re-Issue Required + cert_voided_re_notify + + + + +
diff --git a/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py b/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py index 005bd937..5b847b84 100644 --- a/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py +++ b/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py @@ -24,6 +24,10 @@ TRIGGER_EVENTS = [ ('rma_authorised', 'RMA Authorised'), # Sub 12 — RMA lifecycle ('rma_received', 'RMA Parts Received'), ('rma_resolved', 'RMA Resolved'), + # Spec 2026-05-25 — post-shop cert + shipping states + ('cert_awaiting_issuance', 'Cert Awaiting Issuance'), + ('cert_voided_re_notify', 'Cert Voided — Please Re-Issue'), + ('job_shipped', 'Job Shipped (manual mark)'), ] # Sub 6 — map each trigger event to a communication stream. Contacts on @@ -119,9 +123,17 @@ class FpNotificationTemplate(models.Model): if attachment_ids: attachment_names = self.env['ir.attachment'].browse(attachment_ids).mapped('name') - # Sub 6 — resolve recipients via the contact-routing helper. + # Spec 2026-05-25 — cert authority events go to INTERNAL users + # (Manager / QM / Owner), not customer contacts. Replace partner- + # based resolution with the group-membership resolver. recipient_emails = [] - if partner: + if trigger_event in ('cert_awaiting_issuance', + 'cert_voided_re_notify'): + authority_users = self._fp_resolve_cert_authority_users(record) + recipient_emails = [ + u.email for u in authority_users if u.email + ] + elif partner: stream = FP_TRIGGER_STREAM.get(trigger_event) if stream: recipient_emails = partner._fp_resolve_notification_recipients( @@ -173,6 +185,33 @@ class FpNotificationTemplate(models.Model): 'error_message': str(exc), }) + @api.model + def _fp_resolve_cert_authority_users(self, source_record=None): + """Return active, non-share users holding QM | Manager | Owner + (transitive via all_group_ids). Per CLAUDE.md Rule 13l, direct + user_ids on a group record only catches DIRECT memberships; + Owners reach QM authority via the implication chain and would + be missed by a naive .user_ids walk. + + Spec 2026-05-25 §Notification recipient resolution. + """ + 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: + gids.append(grp.id) + if not gids: + return self.env['res.users'] + return self.env['res.users'].sudo().search([ + ('all_group_ids', 'in', gids), + ('share', '=', False), + ('active', '=', True), + ]) + def _collect_attachments(self, record): """Return a list of ir.attachment ids to attach to the email based on the template's attach_* flags and the record's context.