From 7cafab1b9fbd390c04a715628f0df640756c8d4a Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 15 May 2026 01:16:25 -0400 Subject: [PATCH] =?UTF-8?q?feat(promote-customer-spec):=20Phase=20B=20?= =?UTF-8?q?=E2=80=94=20two-picker=20SO=20line=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../__manifest__.py | 2 +- .../models/account_move_line.py | 4 +- .../models/fp_part_catalog.py | 2 + .../models/sale_order_line.py | 29 ++-- .../views/sale_order_views.xml | 8 +- .../wizard/fp_direct_order_line.py | 14 +- .../wizard/fp_direct_order_wizard.py | 2 + .../wizard/fp_direct_order_wizard_views.xml | 8 +- .../fusion_plating_logistics/__manifest__.py | 2 +- .../models/fp_delivery.py | 2 +- .../fusion_plating_quality/__init__.py | 1 + .../fusion_plating_quality/__manifest__.py | 5 +- .../fusion_plating_quality/models/__init__.py | 3 + .../models/account_move_line_inherit.py | 23 +++ .../models/fp_direct_order_line_inherit.py | 58 ++++++++ .../models/fp_part_catalog.py | 6 + .../models/sale_order_line_inherit.py | 61 ++++++++ .../fp_direct_order_wizard_views_inherit.xml | 36 +++++ .../views/fp_part_catalog_views_inherit.xml | 28 ++++ .../views/sale_order_views_inherit.xml | 43 ++++++ .../wizards/__init__.py | 6 + .../fp_contract_review_client_email_wizard.py | 136 ++++++++++++++++++ ...tract_review_client_email_wizard_views.xml | 36 +++++ 23 files changed, 486 insertions(+), 29 deletions(-) create mode 100644 fusion_plating/fusion_plating_quality/models/account_move_line_inherit.py create mode 100644 fusion_plating/fusion_plating_quality/models/fp_direct_order_line_inherit.py create mode 100644 fusion_plating/fusion_plating_quality/models/sale_order_line_inherit.py create mode 100644 fusion_plating/fusion_plating_quality/views/fp_direct_order_wizard_views_inherit.xml create mode 100644 fusion_plating/fusion_plating_quality/views/fp_part_catalog_views_inherit.xml create mode 100644 fusion_plating/fusion_plating_quality/views/sale_order_views_inherit.xml create mode 100644 fusion_plating/fusion_plating_quality/wizards/__init__.py create mode 100644 fusion_plating/fusion_plating_quality/wizards/fp_contract_review_client_email_wizard.py create mode 100644 fusion_plating/fusion_plating_quality/wizards/fp_contract_review_client_email_wizard_views.xml diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py index 48c40aab..935ec8f2 100644 --- a/fusion_plating/fusion_plating_configurator/__manifest__.py +++ b/fusion_plating/fusion_plating_configurator/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating_configurator/models/account_move_line.py b/fusion_plating/fusion_plating_configurator/models/account_move_line.py index 3383fd3e..86994560 100644 --- a/fusion_plating/fusion_plating_configurator/models/account_move_line.py +++ b/fusion_plating/fusion_plating_configurator/models/account_move_line.py @@ -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.', diff --git a/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py b/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py index 18fed129..f2fb8d73 100644 --- a/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py +++ b/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py @@ -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', diff --git a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py index 8695a22d..50fe2a0a 100644 --- a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py +++ b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py @@ -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): diff --git a/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml b/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml index ba9d76b7..3aaa455f 100644 --- a/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml +++ b/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml @@ -264,9 +264,9 @@ optional="hide"/> - + + invisible="1"/> + + + + + fp.direct.order.wizard.form.spec.inherit + fp.direct.order.wizard + + + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_quality/views/fp_part_catalog_views_inherit.xml b/fusion_plating/fusion_plating_quality/views/fp_part_catalog_views_inherit.xml new file mode 100644 index 00000000..a85deb71 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/views/fp_part_catalog_views_inherit.xml @@ -0,0 +1,28 @@ + + + + + + fp.part.catalog.form.spec.inherit + fp.part.catalog + + + + + + + + + diff --git a/fusion_plating/fusion_plating_quality/views/sale_order_views_inherit.xml b/fusion_plating/fusion_plating_quality/views/sale_order_views_inherit.xml new file mode 100644 index 00000000..b6080b18 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/views/sale_order_views_inherit.xml @@ -0,0 +1,43 @@ + + + + + + + sale.order.form.quality.spec.inherit + sale.order + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_quality/wizards/__init__.py b/fusion_plating/fusion_plating_quality/wizards/__init__.py new file mode 100644 index 00000000..cb5cc38c --- /dev/null +++ b/fusion_plating/fusion_plating_quality/wizards/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_quality/wizards/fp_contract_review_client_email_wizard.py b/fusion_plating/fusion_plating_quality/wizards/fp_contract_review_client_email_wizard.py new file mode 100644 index 00000000..3e9f22d2 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/wizards/fp_contract_review_client_email_wizard.py @@ -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 _( + '

(Reason not yet captured — type details here.)

' + ) + 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'] = _( + '

Hello %(recipient)s,

' + '

We are reviewing your contract for %(part)s ' + '(PO %(po)s) and need additional information to ' + 'finalise our QA-005 review.

' + '

Items requiring clarification:

' + '%(failure)s' + '

Please reply with the requested information at ' + 'your earliest convenience so we can complete the ' + 'review and proceed with production.

' + '

Thank you,
%(company)s — Quality Team

' + ) % { + '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'} diff --git a/fusion_plating/fusion_plating_quality/wizards/fp_contract_review_client_email_wizard_views.xml b/fusion_plating/fusion_plating_quality/wizards/fp_contract_review_client_email_wizard_views.xml new file mode 100644 index 00000000..b4b1666f --- /dev/null +++ b/fusion_plating/fusion_plating_quality/wizards/fp_contract_review_client_email_wizard_views.xml @@ -0,0 +1,36 @@ + + + + + fp.contract.review.client.email.wizard.form + fp.contract.review.client.email.wizard + +
+ + + + + + + + + + + + +
+
+
+