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:
@@ -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': """
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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'}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user