feat(notifications): cert authority events + QM activity (Tasks 17-20)
TRIGGER_EVENTS extended with three new events:
- cert_awaiting_issuance — fires on in_progress → awaiting_cert
- cert_voided_re_notify — fires on awaiting_ship → awaiting_cert
regress (cert voided post-issue)
- job_shipped — fires on button_mark_shipped
_dispatch routes cert events through new internal-recipient resolver
(QM/Manager/Owner via all_group_ids, transitive per Rule 13l)
instead of the partner-based stream lookup. Other events unchanged.
Mail templates (fp_cert_authority_templates.xml): two new
mail.template records bound to fp.job. Amber accent bar for awaiting,
red accent bar for void-re-issue. Deep-link to
/odoo/action-...?tab=certificates so QM lands on the right tab.
Activity type (fp_activity_types_data.xml): mail.activity.type
activity_type_issue_coc — bound to fp.job, 1-day delay, certificate
icon.
fp.job helpers:
_fp_schedule_cert_activity: round-robin by oldest login_date,
idempotent on existing open activity, soft-fails if helpers
are missing.
_fp_resolve_cert_activities: auto-resolves on awaiting_ship,
soft-fails on per-activity exceptions.
Manifest bumps:
fusion_plating_notifications 19.0.6.6.1 → 19.0.7.0.0
fusion_plating_jobs: data list gains fp_activity_types_data.xml
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
|
||||
|
||||
mail.activity.type for the belt-and-suspenders in-app activity
|
||||
assigned to a QM when a job transitions to awaiting_cert. Auto-
|
||||
resolves when the cert is issued and the job advances to
|
||||
awaiting_ship.
|
||||
|
||||
noupdate="1" so admin edits in the UI survive -u.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="activity_type_issue_coc" model="mail.activity.type">
|
||||
<field name="name">Issue CoC</field>
|
||||
<field name="summary">Issue Certificate of Conformance</field>
|
||||
<field name="icon">fa-certificate</field>
|
||||
<field name="delay_count">1</field>
|
||||
<field name="delay_unit">days</field>
|
||||
<field name="delay_from">current_date</field>
|
||||
<field name="res_model">fp.job</field>
|
||||
<field name="default_note">Job has finished the shop floor. Review the inspection prompts captured on the final step, then issue the CoC from the Quality Dashboard.</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
|
||||
|
||||
Templates for the two new internal-recipient events that fire on
|
||||
fp.job state transitions through awaiting_cert. Recipients are
|
||||
resolved by _fp_resolve_cert_authority_users (QM / Manager / Owner
|
||||
via all_group_ids, transitive). The mail templates are bound to
|
||||
fp.job — the source record passed to _dispatch.
|
||||
|
||||
noupdate="1" so admin edits in the UI survive -u (per CLAUDE.md
|
||||
Rule 22 / mail-template gotcha).
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 1. Cert Awaiting Issuance (Warn, #F59E0B) -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="fp_mail_template_cert_awaiting_issuance" model="mail.template">
|
||||
<field name="name">FP: Cert Awaiting Issuance</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="subject">🏷️ Job {{ object.display_wo_name or object.name }} ready for CoC issuance</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #F59E0B; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #F59E0B; font-weight: 600; margin-bottom: 8px;">
|
||||
Quality Action Required
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">CoC Awaiting Issuance</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.75;">
|
||||
Job <strong t-out="object.display_wo_name or object.name"/>
|
||||
(<t t-out="object.partner_id.name"/>) has finished the shop floor
|
||||
and is awaiting CoC issuance.
|
||||
</p>
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
|
||||
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
|
||||
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Value</th>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Customer</td>
|
||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.partner_id.name or ''"/></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Part</td>
|
||||
<td style="padding: 8px 4px; text-align: right; font-family: monospace;">
|
||||
<t t-out="(object.part_catalog_id.part_number if object.part_catalog_id else '') or '—'"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Quantity</td>
|
||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.qty_done or 0"/></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Recipe</td>
|
||||
<td style="padding: 8px 4px; text-align: right;">
|
||||
<t t-out="(object.recipe_id.name if object.recipe_id else '') or '—'"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 16px 0; font-size: 14px; opacity: 0.75;">
|
||||
Review the inspection prompts captured by the operator on the
|
||||
Final Inspection step, then issue the CoC from the Quality
|
||||
Dashboard.
|
||||
</p>
|
||||
<p style="margin: 20px 0;">
|
||||
<a t-attf-href="/odoo/action-fusion_plating_quality.action_fp_quality_dashboard?tab=certificates"
|
||||
style="display: inline-block; padding: 10px 20px; background-color: #F59E0B; color: white; text-decoration: none; font-weight: 600; border-radius: 4px;">
|
||||
Open Quality Dashboard →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="fp_notif_cert_awaiting_issuance" model="fp.notification.template">
|
||||
<field name="name">Cert Awaiting Issuance</field>
|
||||
<field name="trigger_event">cert_awaiting_issuance</field>
|
||||
<field name="mail_template_id" ref="fp_mail_template_cert_awaiting_issuance"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 2. Cert Voided — Please Re-Issue (Urgent, #DC2626) -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="fp_mail_template_cert_voided_re_notify" model="mail.template">
|
||||
<field name="name">FP: Cert Voided — Re-Issue</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="subject">⚠️ Job {{ object.display_wo_name or object.name }} CoC voided — please re-issue</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #DC2626; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #DC2626; font-weight: 600; margin-bottom: 8px;">
|
||||
Cert Regression
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">CoC Voided — Please Re-Issue</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.75;">
|
||||
A previously-issued CoC for job
|
||||
<strong t-out="object.display_wo_name or object.name"/>
|
||||
(<t t-out="object.partner_id.name"/>) was voided. The job has
|
||||
slid back to <em>Awaiting Cert</em> and is waiting for re-issuance.
|
||||
</p>
|
||||
<p style="margin: 20px 0;">
|
||||
<a t-attf-href="/odoo/action-fusion_plating_quality.action_fp_quality_dashboard?tab=certificates"
|
||||
style="display: inline-block; padding: 10px 20px; background-color: #DC2626; color: white; text-decoration: none; font-weight: 600; border-radius: 4px;">
|
||||
Open Quality Dashboard →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="fp_notif_cert_voided_re_notify" model="fp.notification.template">
|
||||
<field name="name">Cert Voided — Re-Issue Required</field>
|
||||
<field name="trigger_event">cert_voided_re_notify</field>
|
||||
<field name="mail_template_id" ref="fp_mail_template_cert_voided_re_notify"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user