diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py
index a015b56a..c7feda08 100644
--- a/fusion_plating/fusion_plating/__manifest__.py
+++ b/fusion_plating/fusion_plating/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
- 'version': '19.0.18.15.15',
+ 'version': '19.0.18.15.16',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
diff --git a/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py b/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py
index c5c0ccdb..0d6af25c 100644
--- a/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py
+++ b/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py
@@ -13,6 +13,7 @@ for the design rationale.
"""
import re
+from markupsafe import Markup
from odoo import fields, models
from odoo.exceptions import UserError
from odoo.tools.translate import _
@@ -115,9 +116,9 @@ class FpParentNumberedMixin(models.AbstractModel):
(new_name, new_index, self.id),
)
self.invalidate_recordset(['name', 'x_fc_doc_index'])
- so.message_post(body=_(
+ so.message_post(body=Markup(_(
'Issued %s to %s #%s.'
- ) % (new_name, self._name, self.id))
+ )) % (new_name, self._name, self.id))
return True
# ------------------------------------------------------------------
diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py
index a854d8f5..e4e415b3 100644
--- a/fusion_plating/fusion_plating_jobs/__manifest__.py
+++ b/fusion_plating/fusion_plating_jobs/__manifest__.py
@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
- 'version': '19.0.8.26.0',
+ 'version': '19.0.8.27.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
diff --git a/fusion_plating/fusion_plating_jobs/models/sale_order.py b/fusion_plating/fusion_plating_jobs/models/sale_order.py
index 477a2f21..eb114c35 100644
--- a/fusion_plating/fusion_plating_jobs/models/sale_order.py
+++ b/fusion_plating/fusion_plating_jobs/models/sale_order.py
@@ -279,9 +279,9 @@ class SaleOrder(models.Model):
'name': f'SO-{parent_int}',
'x_fc_parent_number': parent_int,
})
- so.message_post(body=_(
+ so.message_post(body=Markup(_(
'Confirmed quote %s as %s.'
- ) % (old_name, so.name))
+ )) % (old_name, so.name))
result = super().action_confirm()
for so in self:
so._fp_auto_create_job()
diff --git a/fusion_plating/fusion_plating_quality/__manifest__.py b/fusion_plating/fusion_plating_quality/__manifest__.py
index 612e9127..2ca3ccb3 100644
--- a/fusion_plating/fusion_plating_quality/__manifest__.py
+++ b/fusion_plating/fusion_plating_quality/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
- 'version': '19.0.4.13.0',
+ 'version': '19.0.4.14.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',
diff --git a/fusion_plating/fusion_plating_quality/models/fp_contract_review.py b/fusion_plating/fusion_plating_quality/models/fp_contract_review.py
index 541427d2..725955e8 100644
--- a/fusion_plating/fusion_plating_quality/models/fp_contract_review.py
+++ b/fusion_plating/fusion_plating_quality/models/fp_contract_review.py
@@ -3,6 +3,9 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
+from datetime import timedelta
+
+from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
@@ -22,9 +25,9 @@ class FpContractReview(models.Model):
"""Contract Review (QA-005).
Per-part, two-section QA review: Section 2.0 Planning / Production
- Review (signed by a QA Assistant) and Section 3.0 Quality Review
- (signed by a QA Manager). Both sections must be signed for the
- review to be complete.
+ Review (Planning Review stage, signed by a Planning Signer) and
+ Section 3.0 Quality Review (QA Review stage, signed by a QA Signer).
+ Both sections must be signed for the review to be complete.
The review is always optional. It never blocks MO/SO/WO progression.
Its purpose is an audit artefact and a printable 1:1 of the paper
@@ -79,7 +82,7 @@ class FpContractReview(models.Model):
qty = fields.Integer(string='Qty')
due_date = fields.Date(string='Due')
- # ---- Section 2.0 — Planning / Production Review (QA Assistant) ---------
+ # ---- Section 2.0 — Planning / Production Review (Planning Review) ------
s20_acceptable_lead_time = fields.Boolean(string='Acceptable Lead Time')
s20_capacity_to_process = fields.Boolean(string='Capacity to Process')
@@ -118,7 +121,7 @@ class FpContractReview(models.Model):
copy=False,
)
- # ---- Section 3.0 — Quality Review (QA Manager) -------------------------
+ # ---- Section 3.0 — Quality Review (QA Review) --------------------------
s30_source_control_docs = fields.Boolean(string="Source Control Documents (Customer Spec's)")
s30_quality_clauses_supplied = fields.Boolean(string='Quality Clause(s) supplied')
@@ -180,8 +183,9 @@ class FpContractReview(models.Model):
state = fields.Selection(
[('draft', 'Draft'),
- ('assistant_review', 'QA Assistant Review'),
- ('manager_review', 'QA Manager Review'),
+ ('assistant_review', 'Planning Review'),
+ ('manager_review', 'QA Review'),
+ ('awaiting_info', 'Awaiting Client Info'),
('complete', 'Complete'),
('dismissed', 'Dismissed')],
default='draft',
@@ -189,6 +193,56 @@ class FpContractReview(models.Model):
tracking=True,
)
+ # ---- "Failed QA — Awaiting Client Info" workflow ------------------------
+ # When a QA Signer (Brett or whoever the company has rostered) finds a
+ # client requirement that fails during the QA Review, they mark the
+ # review failed. The state moves to `awaiting_info`, an activity is
+ # scheduled for every QA Signer to follow up, and a smart button on
+ # the form gives them a one-click email composer to ping the client.
+ # When the client replies, the QA Signer captures notes in
+ # `special_instructions` and marks complete — the notes print on the
+ # final QA-005 PDF for the audit trail.
+ qa_failure_reason = fields.Html(
+ string='QA Failure Reason',
+ copy=False,
+ help='What client requirement failed and why we need more info. '
+ 'Captured here before flipping the review to '
+ '"Awaiting Client Info" so every QA Signer sees the same '
+ 'context. Pre-fills the client email composer.',
+ )
+ info_requested_date = fields.Datetime(
+ string='Info Requested Date',
+ readonly=True,
+ copy=False,
+ help='Stamped automatically the first time the client email '
+ 'composer is sent.',
+ )
+ info_received_date = fields.Datetime(
+ string='Info Received Date',
+ copy=False,
+ help='Manually stamped when the QA Signer marks the review '
+ 'complete after receiving the client info.',
+ )
+ special_instructions = fields.Html(
+ string='Special Instructions',
+ copy=False,
+ help='Free-form notes captured by the QA Signer when they close '
+ 'out the review. Prints at the bottom of the QA-005 PDF '
+ 'so the audit record carries the agreed resolution.',
+ )
+ client_email_count = fields.Integer(
+ compute='_compute_client_email_count',
+ help='Smart-button counter — number of emails posted to chatter '
+ 'against this review. Always non-zero after the first send.',
+ )
+
+ @api.depends('message_ids', 'message_ids.message_type')
+ def _compute_client_email_count(self):
+ for rec in self:
+ rec.client_email_count = len(rec.message_ids.filtered(
+ lambda m: m.message_type == 'email'
+ ))
+
# ---- Constraints --------------------------------------------------------
_sql_constraints = [
@@ -351,6 +405,133 @@ class FpContractReview(models.Model):
'fusion_plating_quality.action_report_contract_review'
).report_action(self)
+ # ---- "Failed QA — Awaiting Client Info" workflow ------------------------
+ def action_mark_qa_failed(self):
+ """QA Signer marks the review failed because a client requirement
+ is missing or unclear. Captures the reason, flips state to
+ `awaiting_info`, and schedules a follow-up activity for every QA
+ Signer rostered on the company (so the work doesn't fall through
+ the cracks if Brett is on vacation)."""
+ self.ensure_one()
+ if self.state not in ('manager_review', 'assistant_review'):
+ raise UserError(_(
+ 'Only a review at the QA Review (or Planning Review) stage '
+ 'can be flagged as failed. Current state: %s.'
+ ) % dict(self._fields['state'].selection).get(self.state, self.state))
+ # Reuse the section-30 signer roster — the same group of people
+ # who can sign QA can flag a QA failure.
+ self._check_signer(30)
+ if not self.qa_failure_reason or not self.qa_failure_reason.strip():
+ raise UserError(_(
+ 'Capture the QA Failure Reason before flagging the '
+ 'review failed — the reason pre-fills the client email '
+ 'and is required for the audit trail.'
+ ))
+ self.write({'state': 'awaiting_info'})
+ self.message_post(body=Markup(_(
+ 'QA Review failed by %(user)s. Awaiting client '
+ 'information.
Reason:
%(reason)s'
+ )) % {
+ 'user': self.env.user.name,
+ 'reason': Markup(self.qa_failure_reason or ''),
+ })
+ # Schedule activity for every QA Signer (any of them can pick it up).
+ signers = self.company_id._fp_get_qa_signers(30)
+ if not signers:
+ # Fall back to the user who flagged it, so the activity is
+ # not orphaned on shops that haven't configured a roster.
+ signers = self.env.user
+ try:
+ activity_type = self.env.ref('mail.mail_activity_data_todo')
+ except ValueError:
+ activity_type = self.env['mail.activity.type'].search(
+ [('category', '=', 'default')], limit=1)
+ for user in signers:
+ self.activity_schedule(
+ activity_type_id=activity_type.id if activity_type else False,
+ summary=_('Follow up on QA-005 — client info required'),
+ note=self.qa_failure_reason or '',
+ user_id=user.id,
+ date_deadline=fields.Date.context_today(self) +
+ timedelta(days=2),
+ )
+ return True
+
+ def action_open_client_email_wizard(self):
+ """Smart-button target — opens the email composer wizard pre-filled
+ with the customer's contact email + a body templated from the
+ QA failure reason. The wizard handles the actual mail.mail send
+ and stamps `info_requested_date` on this review."""
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Email Client — Request Info'),
+ 'res_model': 'fp.contract.review.client.email.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'default_review_id': self.id,
+ 'default_recipient_email':
+ self.customer_id.email or '',
+ 'default_recipient_name':
+ self.customer_id.name or '',
+ },
+ }
+
+ def action_view_client_emails(self):
+ """Drill-down behind the smart button counter — shows the chatter
+ messages of type=email for this review."""
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Client Emails — %s') % self.name,
+ 'res_model': 'mail.message',
+ 'view_mode': 'list,form',
+ 'domain': [
+ ('model', '=', 'fp.contract.review'),
+ ('res_id', '=', self.id),
+ ('message_type', '=', 'email'),
+ ],
+ }
+
+ def action_complete_after_info(self):
+ """Close out a review that was in `awaiting_info` once the client
+ info has been received and `special_instructions` captured. Stamps
+ Section 3.0 sign-off with the current user + timestamp so the QA
+ review is fully closed and the QA-005 PDF carries a complete
+ audit trail."""
+ self.ensure_one()
+ if self.state != 'awaiting_info':
+ raise UserError(_(
+ 'Only a review in "Awaiting Client Info" can be marked '
+ 'complete via this action.'
+ ))
+ self._check_signer(30)
+ now = fields.Datetime.now()
+ vals = {
+ 'state': 'complete',
+ 'info_received_date': self.info_received_date or now,
+ 's30_signed_by': self.env.user.id,
+ 's30_signed_date': now,
+ 's30_locked': True,
+ }
+ self.write(vals)
+ # Mark the activity as done so the follow-up disappears from
+ # everyone's inbox once the case is closed.
+ self.activity_feedback(
+ ['mail.mail_activity_data_todo'],
+ feedback=_('Client info received — review closed.'),
+ )
+ self.message_post(body=Markup(_(
+ 'QA Review completed by %(user)s after receiving '
+ 'client information.
'
+ 'Special Instructions captured:
%(notes)s'
+ )) % {
+ 'user': self.env.user.name,
+ 'notes': Markup(self.special_instructions or '') or _('(none)'),
+ })
+ return True
+
# ---- Helpers ------------------------------------------------------------
def _check_signer(self, section):