feat(promote-customer-spec): Phase B — two-picker SO line UX

Spec-side picker (x_fc_customer_spec_id / customer_spec_id) added on:
- sale.order.line (via quality inherit — onchange autofill, create()
  fallback to part default, _prepare_invoice_line carry)
- account.move.line (via quality inherit — invoice rendering)
- fp.part.catalog (via quality inherit — x_fc_default_customer_spec_id)
- fp.direct.order.line (via quality inherit — wizard picker + autofill)
- fp.direct.order.wizard (action_create_order post-creates spec on SO line)

Thickness picker switched to fp.recipe.thickness (replaces coating-scoped):
- sale.order.line.x_fc_thickness_id comodel + domain rewired to recipe
- account.move.line + fp.delivery same
- fp.direct.order.line.thickness_id same

View inherits in quality add Specification picker next to legacy
Primary Treatment column on:
- SO form line tree
- part catalog Default Treatments block
- direct-order wizard line tree + drawer

Wizard files (fp.contract.review.client.email.wizard) pulled from
entech into the repo — they were ahead of the repo. Quality __init__
now imports wizards/.

Legacy x_fc_coating_config_id + treatment_ids remain visible during
transition; Phase E removes them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-15 01:16:25 -04:00
parent c96f27b96c
commit 7cafab1b9f
23 changed files with 486 additions and 29 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.18.10.4',
'version': '19.0.19.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -66,10 +66,12 @@ class AccountMoveLine(models.Model):
help='Copied from sale.order.line.',
)
x_fc_thickness_id = fields.Many2one(
'fp.coating.thickness',
'fp.recipe.thickness',
string='Thickness',
help='Copied from sale.order.line for customer-facing invoice PDFs.',
)
# x_fc_customer_spec_id is added by fusion_plating_quality (where
# fusion.plating.customer.spec lives).
x_fc_revision_snapshot = fields.Char(
string='Revision (snapshot)',
help='Revision letter from the source SO line.',

View File

@@ -283,6 +283,8 @@ class FpPartCatalog(models.Model):
help='Default coating applied when this part is dropped onto a '
'direct order line. Updated when "Save as Default" is ticked.',
)
# x_fc_default_customer_spec_id is added by fusion_plating_quality
# (where fusion.plating.customer.spec lives).
x_fc_default_treatment_ids = fields.Many2many(
'fp.treatment',
relation='fp_part_catalog_default_treatment_rel',

View File

@@ -62,6 +62,9 @@ class SaleOrderLine(models.Model):
x_fc_coating_config_id = fields.Many2one(
'fp.coating.config', string='Primary Treatment',
)
# x_fc_customer_spec_id is added by fusion_plating_quality (where
# fusion.plating.customer.spec lives). Configurator can't reference
# it directly without a circular dep.
x_fc_treatment_ids = fields.Many2many(
'fp.treatment', string='Additional Treatments',
)
@@ -308,12 +311,11 @@ class SaleOrderLine(models.Model):
'order confirmation; editable. Blank is allowed.',
)
x_fc_thickness_id = fields.Many2one(
'fp.coating.thickness',
'fp.recipe.thickness',
string='Thickness',
ondelete='set null',
domain="[('coating_config_id', '=', x_fc_coating_config_id)]",
help="Target coating thickness. Options come from the line's "
'coating configuration.',
domain="[('recipe_id', '=', x_fc_process_variant_id)]",
help="Target thickness. Options come from the line's recipe.",
)
x_fc_revision_snapshot = fields.Char(
string='Revision (snapshot)',
@@ -481,6 +483,8 @@ class SaleOrderLine(models.Model):
vals['x_fc_thickness_id'] = self.x_fc_thickness_id.id
if self.x_fc_revision_snapshot:
vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot
# x_fc_customer_spec_id carry-over is handled by an
# extension in fusion_plating_quality (the field lives there).
return vals
@api.onchange('x_fc_part_catalog_id')
@@ -498,6 +502,9 @@ class SaleOrderLine(models.Model):
if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id:
line.x_fc_process_variant_id = line.x_fc_part_catalog_id.default_process_id
# Spec auto-fill onchange lives in fusion_plating_quality
# (the customer.spec model lives there, so the inherit must too).
def _fp_clone_recipe_to_part(self):
"""Deep-copy the picked recipe onto this line's part if it isn't
already scoped there. Returns the cloned (or unchanged) variant.
@@ -575,17 +582,17 @@ class SaleOrderLine(models.Model):
'target': 'current',
}
@api.onchange('x_fc_coating_config_id')
def _onchange_coating_clears_thickness(self):
"""Clear the thickness picker when coating config changes.
@api.onchange('x_fc_process_variant_id')
def _onchange_recipe_clears_thickness(self):
"""Clear the thickness picker when recipe changes.
The thickness options are scoped to the coating config; a value
carried over from a previous coating would fail its domain.
Thickness options are scoped to the recipe; a value carried over
from a previous recipe would fail its domain.
"""
for line in self:
if (line.x_fc_thickness_id
and line.x_fc_thickness_id.coating_config_id
!= line.x_fc_coating_config_id):
and line.x_fc_thickness_id.recipe_id
!= line.x_fc_process_variant_id):
line.x_fc_thickness_id = False
def action_generate_serial(self):

View File

@@ -264,9 +264,9 @@
optional="hide"/>
<field name="x_fc_thickness_id"
options="{'no_quick_create': True}"
context="{'default_coating_config_id': x_fc_coating_config_id}"
domain="[('coating_config_id', '=', x_fc_coating_config_id)]"
invisible="not x_fc_coating_config_id"
context="{'default_recipe_id': x_fc_process_variant_id}"
domain="[('recipe_id', '=', x_fc_process_variant_id)]"
invisible="not x_fc_process_variant_id"
optional="show"/>
<field name="x_fc_serial_ids"
widget="many2many_tags"
@@ -290,7 +290,7 @@
<field name="x_fc_revision_snapshot"
readonly="1"
optional="hide"/>
<field name="x_fc_treatment_ids" widget="many2many_tags" optional="hide"/>
<field name="x_fc_treatment_ids" widget="many2many_tags" invisible="1"/>
<field name="x_fc_part_deadline" string="Part Deadline Override" optional="hide"/>
<field name="x_fc_part_deadline_offset_days" string="Days Offset" optional="hide"/>
<field name="x_fc_effective_part_deadline" string="Effective Deadline"

View File

@@ -60,6 +60,8 @@ class FpDirectOrderLine(models.Model):
'workflow downstream — leaving this blank lets that path '
'through.',
)
# customer_spec_id is added by fusion_plating_quality (where
# fusion.plating.customer.spec lives).
treatment_ids = fields.Many2many(
'fp.treatment',
string='Additional Treatments',
@@ -176,6 +178,8 @@ class FpDirectOrderLine(models.Model):
# Pre-fill default treatments if any are configured.
if not rec.treatment_ids and has_default_treatments:
rec.treatment_ids = [(6, 0, part.x_fc_default_treatment_ids.ids)]
# Default-spec auto-fill is implemented by an inherit in
# fusion_plating_quality (where customer_spec_id field lives).
# New-part auto-suggest: if neither default exists, this is
# likely a first-time use of the part. Auto-tick the
# push_to_defaults toggle so whatever Sarah picks becomes
@@ -420,9 +424,9 @@ class FpDirectOrderLine(models.Model):
rec.serial_ids = [(4, rec.serial_id.id)]
job_number = fields.Char(string='Job #')
thickness_id = fields.Many2one(
'fp.coating.thickness',
'fp.recipe.thickness',
string='Thickness',
domain="[('coating_config_id', '=', coating_config_id)]",
domain="[('recipe_id', '=', process_variant_id)]",
ondelete='set null',
)
@@ -442,11 +446,11 @@ class FpDirectOrderLine(models.Model):
and rec.quantity
)
@api.onchange('coating_config_id')
def _onchange_coating_clears_thickness(self):
@api.onchange('process_variant_id')
def _onchange_recipe_clears_thickness(self):
for rec in self:
if (rec.thickness_id
and rec.thickness_id.coating_config_id != rec.coating_config_id):
and rec.thickness_id.recipe_id != rec.process_variant_id):
rec.thickness_id = False
def action_generate_serial(self):

View File

@@ -575,6 +575,8 @@ class FpDirectOrderWizard(models.Model):
'x_fc_internal_description': line.internal_description or False,
'x_fc_coating_config_id': line.coating_config_id.id,
'x_fc_treatment_ids': [(6, 0, line.treatment_ids.ids)],
# x_fc_customer_spec_id is added to vals by an extension
# of this method in fusion_plating_quality.
'x_fc_part_deadline': line.part_deadline,
'x_fc_part_deadline_offset_days': line.part_deadline_offset_days,
'x_fc_rush_order': line.rush_order,

View File

@@ -176,9 +176,9 @@
optional="hide"/>
<field name="thickness_id"
options="{'no_quick_create': True}"
context="{'default_coating_config_id': coating_config_id}"
domain="[('coating_config_id', '=', coating_config_id)]"
invisible="not coating_config_id"
context="{'default_recipe_id': process_variant_id}"
domain="[('recipe_id', '=', process_variant_id)]"
invisible="not process_variant_id"
optional="show"/>
<field name="serial_ids"
widget="many2many_tags"
@@ -196,7 +196,7 @@
<field name="job_number" optional="hide"/>
<field name="treatment_ids"
widget="many2many_tags"
optional="hide"/>
invisible="1"/>
<field name="quantity"
optional="show"/>
<field name="unit_price"

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Logistics',
'version': '19.0.3.6.0',
'version': '19.0.3.7.0',
'category': 'Manufacturing/Plating',
'summary': (
'Pickup & delivery for plating shops: vehicle master, driver '

View File

@@ -68,7 +68,7 @@ class FpDelivery(models.Model):
help='Shop-floor job number from the MO. Prints on packing slip.',
)
x_fc_thickness_id = fields.Many2one(
'fp.coating.thickness', string='Thickness',
'fp.recipe.thickness', string='Thickness',
ondelete='set null',
)
x_fc_revision_snapshot = fields.Char(

View File

@@ -5,3 +5,4 @@
from . import models
from . import controllers
from . import wizards

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.5.0.0',
'version': '19.0.5.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',
@@ -91,6 +91,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_avl_views.xml',
'views/fp_customer_spec_views.xml',
'views/fp_process_node_inherit_views.xml',
'views/sale_order_views_inherit.xml',
'views/fp_part_catalog_views_inherit.xml',
'views/fp_direct_order_wizard_views_inherit.xml',
'views/fp_audit_views.xml',
'views/fp_fair_views.xml',
'views/fp_doc_control_views.xml',

View File

@@ -10,6 +10,9 @@ from . import fp_calibration_event
from . import fp_avl
from . import fp_customer_spec
from . import fp_process_node_inherit
from . import sale_order_line_inherit
from . import account_move_line_inherit
from . import fp_direct_order_line_inherit
from . import fp_audit
from . import fp_fair
from . import fp_doc_control

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class AccountMoveLine(models.Model):
"""Add the Specification reference to the invoice line.
Lives here (not in configurator) because fusion.plating.customer.spec
lives in fusion_plating_quality and configurator can't reference it
without a circular dep.
"""
_inherit = 'account.move.line'
x_fc_customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Specification',
help='Carried from the SO line so the invoice PDF can render the '
'spec reference next to the part number.',
)

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpDirectOrderLine(models.Model):
"""Add the Specification picker to the direct-order wizard line.
Lives in fusion_plating_quality because fusion.plating.customer.spec
lives here.
"""
_inherit = 'fp.direct.order.line'
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Specification',
help='Customer / industry specification the work ships to. '
'Carried onto the SO line at order creation.',
)
@api.onchange('part_catalog_id')
def _onchange_part_default_spec(self):
"""Pre-fill the line's specification from the part's default."""
for rec in self:
if (rec.part_catalog_id
and rec.part_catalog_id.x_fc_default_customer_spec_id
and not rec.customer_spec_id):
rec.customer_spec_id = (
rec.part_catalog_id.x_fc_default_customer_spec_id
)
class FpDirectOrderWizard(models.Model):
_inherit = 'fp.direct.order.wizard'
def action_create_order(self):
"""Carry customer_spec_id from each wizard line to its SO line.
The base method (in configurator) builds the SO with all the
coating/treatment/process fields. We can't insert spec into the
vals dict from here without a circular dep, so post-create we
pair wizard lines to SO lines by sequence and patch.
"""
result = super().action_create_order()
if self.sale_order_id:
wiz_lines = self.line_ids.sorted(
key=lambda r: (r.sequence, r.id)
)
so_lines = self.sale_order_id.order_line.sorted(
key=lambda r: (r.sequence, r.id)
)
for wiz_line, so_line in zip(wiz_lines, so_lines):
if wiz_line.customer_spec_id and not so_line.x_fc_customer_spec_id:
so_line.x_fc_customer_spec_id = wiz_line.customer_spec_id.id
return result

View File

@@ -14,6 +14,12 @@ _logger = logging.getLogger(__name__)
class FpPartCatalog(models.Model):
_inherit = 'fp.part.catalog'
x_fc_default_customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Default Specification',
help='Default specification applied when this part is dropped on '
'a direct order line. Operator can override per order.',
)
x_fc_contract_review_id = fields.Many2one(
'fp.contract.review',
string='Contract Review',

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class SaleOrderLine(models.Model):
"""Add the Specification picker to the SO line.
Lives in fusion_plating_quality because fusion.plating.customer.spec
lives here. Configurator can't reference it directly without a
circular dep (quality depends on configurator).
"""
_inherit = 'sale.order.line'
x_fc_customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Specification',
help='Customer / industry specification the work is being shipped '
'to (e.g. AMS 2404 Rev D, BAC 5680 Rev E). Drives certificate '
'auto-fill and FAI / Nadcap routing.',
)
@api.onchange('x_fc_part_catalog_id')
def _onchange_part_default_spec(self):
"""Pre-fill the line's specification from the part's default."""
for line in self:
if (line.x_fc_part_catalog_id
and line.x_fc_part_catalog_id.x_fc_default_customer_spec_id
and not line.x_fc_customer_spec_id):
line.x_fc_customer_spec_id = (
line.x_fc_part_catalog_id.x_fc_default_customer_spec_id
)
@api.model_create_multi
def create(self, vals_list):
"""Fall back to the part's default spec when none is supplied.
Catches programmatic creation paths (wizard, import, sale_mrp
bridge) where the onchange doesn't fire. Explicit spec wins;
only fills when blank AND the part has a default.
"""
Part = self.env['fp.part.catalog']
for vals in vals_list:
if (not vals.get('x_fc_customer_spec_id')
and vals.get('x_fc_part_catalog_id')):
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
if part and part.x_fc_default_customer_spec_id:
vals['x_fc_customer_spec_id'] = (
part.x_fc_default_customer_spec_id.id
)
return super().create(vals_list)
def _prepare_invoice_line(self, **optional_values):
"""Carry x_fc_customer_spec_id to the invoice line."""
vals = super()._prepare_invoice_line(**optional_values)
if self.x_fc_customer_spec_id:
vals['x_fc_customer_spec_id'] = self.x_fc_customer_spec_id.id
return vals

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Adds the Specification picker to the direct-order wizard line.
-->
<odoo>
<record id="view_fp_direct_order_wizard_form_spec_inherit"
model="ir.ui.view">
<field name="name">fp.direct.order.wizard.form.spec.inherit</field>
<field name="model">fp.direct.order.wizard</field>
<field name="inherit_id"
ref="fusion_plating_configurator.view_fp_direct_order_wizard_form"/>
<field name="arch" type="xml">
<!-- Wizard line list (main editable rows) -->
<xpath expr="//field[@name='line_ids']/list/field[@name='coating_config_id']"
position="after">
<field name="customer_spec_id"
string="Specification"
options="{'no_quick_create': True}"
optional="show"/>
</xpath>
<!-- Wizard line drawer / form view (the "expand line" panel) -->
<xpath expr="//field[@name='line_ids']/form//field[@name='coating_config_id']"
position="after">
<field name="customer_spec_id"
string="Specification"
options="{'no_quick_create': True}"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Adds the "Default Specification" picker to the part catalog form
next to "Default Treatment". Phase E removes the legacy field
entirely.
-->
<odoo>
<record id="view_fp_part_catalog_form_spec_inherit" model="ir.ui.view">
<field name="name">fp.part.catalog.form.spec.inherit</field>
<field name="model">fp.part.catalog</field>
<field name="inherit_id"
ref="fusion_plating_configurator.view_fp_part_catalog_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='x_fc_default_coating_config_id']"
position="after">
<field name="x_fc_default_customer_spec_id"
string="Default Specification"
options="{'no_create_edit': True}"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Adds the Specification picker to the SO line tree (the configurator's
main editable line list inside the SO form). The Spec field lives on
sale.order.line as an _inherit added in this module, so the view
that surfaces it must also live here.
During Phases B-D the Spec picker sits ALONGSIDE the legacy
Primary Treatment picker (both visible). Phase E removes the legacy
field entirely.
-->
<odoo>
<!-- Configurator's view_sale_order_form_fp inherits sale.view_order_form
and adds Plating fields to the order_line tree. We inherit THAT
view to add Specification right after Primary Treatment. -->
<record id="view_sale_order_form_quality_inherit" model="ir.ui.view">
<field name="name">sale.order.form.quality.spec.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id"
ref="fusion_plating_configurator.view_sale_order_form_fp"/>
<field name="arch" type="xml">
<!-- Editable order_line tree (estimator's main grid) -->
<xpath expr="//field[@name='order_line']/list/field[@name='x_fc_coating_config_id']"
position="after">
<field name="x_fc_customer_spec_id"
string="Specification"
options="{'no_quick_create': True}"
optional="show"/>
</xpath>
</field>
</record>
<!-- The SO list's coating column is on sale.order itself (header
field). Adding a parallel spec column on the order header is
a Phase B+ enhancement — for now, the line tree (above) is
sufficient for the operator. -->
</odoo>

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import fp_contract_review_client_email_wizard

View File

@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpContractReviewClientEmailWizard(models.TransientModel):
"""Email-composer wizard for the Contract Review "Awaiting Client Info"
workflow. Pre-fills subject + body from the QA failure reason so the
QA Signer (Brett, or any other configured signer) can ping the
customer in a single click.
Sending the wizard:
1. Posts a chatter message of message_type='email' on the review
(the smart-button counter on the review form picks this up).
2. Sends the actual email via mail.mail to the customer's email.
3. Stamps `info_requested_date` on the review the first time, so
the form clearly shows when the request went out.
"""
_name = 'fp.contract.review.client.email.wizard'
_description = 'Contract Review — Email Client (Request Info)'
review_id = fields.Many2one(
'fp.contract.review',
string='Contract Review',
required=True,
ondelete='cascade',
)
customer_id = fields.Many2one(
'res.partner',
related='review_id.customer_id',
readonly=True,
)
recipient_email = fields.Char(
string='To',
required=True,
help='Customer contact email. Edit if the request needs to go to a '
'specific buyer / engineer.',
)
recipient_name = fields.Char(
string='Recipient Name',
)
subject = fields.Char(
string='Subject',
required=True,
)
body = fields.Html(
string='Message',
required=True,
sanitize=True,
)
@api.model
def default_get(self, fields_list):
vals = super().default_get(fields_list)
review_id = self.env.context.get('default_review_id')
if review_id:
review = self.env['fp.contract.review'].browse(review_id)
company = review.company_id or self.env.company
part_label = (review.part_id and review.part_id.display_name) or '-'
po_label = review.contract_po_number or review.quote_or_job_number or '-'
failure_html = review.qa_failure_reason or _(
'<p>(Reason not yet captured — type details here.)</p>'
)
if 'subject' in fields_list and not vals.get('subject'):
vals['subject'] = _(
'%(company)s — Information request for Contract Review '
'%(name)s (PO %(po)s)'
) % {
'company': company.name or '',
'name': review.name or '',
'po': po_label,
}
if 'body' in fields_list and not vals.get('body'):
vals['body'] = _(
'<p>Hello %(recipient)s,</p>'
'<p>We are reviewing your contract for <b>%(part)s</b> '
'(PO %(po)s) and need additional information to '
'finalise our QA-005 review.</p>'
'<p><b>Items requiring clarification:</b></p>'
'%(failure)s'
'<p>Please reply with the requested information at '
'your earliest convenience so we can complete the '
'review and proceed with production.</p>'
'<p>Thank you,<br/>%(company)s — Quality Team</p>'
) % {
'recipient': (review.customer_id.name or _('there')),
'part': part_label,
'po': po_label,
'failure': failure_html,
'company': company.name or '',
}
return vals
def action_send(self):
"""Send the email + post chatter + stamp request date."""
self.ensure_one()
if not self.recipient_email:
raise UserError(_(
'A recipient email is required. Set the customer\'s email '
'on their contact card or override here.'
))
review = self.review_id
# Post into the review's chatter as message_type='email' so the
# smart-button counter picks it up. message_post handles the
# actual mail.mail send when partner_ids / email_to is set.
review.message_post(
body=self.body,
subject=self.subject,
message_type='email',
subtype_xmlid='mail.mt_comment',
partner_ids=review.customer_id.ids if review.customer_id else [],
email_layout_xmlid='mail.mail_notification_light',
email_add_signature=True,
)
# Belt-and-braces direct send to the recipient_email when it
# differs from the partner's primary email (e.g. a buyer-specific
# address typed into the wizard).
partner_email = review.customer_id.email if review.customer_id else ''
if self.recipient_email and self.recipient_email != partner_email:
self.env['mail.mail'].sudo().create({
'subject': self.subject,
'body_html': self.body,
'email_from': self.env.user.email_formatted or
(review.company_id and review.company_id.email) or '',
'email_to': self.recipient_email,
'auto_delete': True,
'model': 'fp.contract.review',
'res_id': review.id,
}).send()
if not review.info_requested_date:
review.write({'info_requested_date': fields.Datetime.now()})
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<odoo>
<record id="view_fp_contract_review_client_email_wizard_form" model="ir.ui.view">
<field name="name">fp.contract.review.client.email.wizard.form</field>
<field name="model">fp.contract.review.client.email.wizard</field>
<field name="arch" type="xml">
<form string="Email Client — Request Info">
<sheet>
<group>
<field name="review_id" readonly="1"/>
<field name="customer_id" readonly="1"/>
<field name="recipient_email"/>
<field name="recipient_name"/>
<field name="subject"/>
</group>
<separator string="Message"/>
<field name="body" placeholder="Compose the message to the client. The body has been pre-filled with the QA failure reason — edit as needed."/>
</sheet>
<footer>
<button name="action_send"
type="object"
string="Send Email"
class="btn-primary"
icon="fa-paper-plane"/>
<button special="cancel"
string="Cancel"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
</odoo>