From e34c1bcc8ddae4bb797275df7f692022bab6fc46 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:37:11 -0400 Subject: [PATCH] refactor(configurator): multi-line direct order wizard with notebook form Tasks A3 + A6. Wizard rewritten as header + lines architecture: - Header carries customer/addresses/PO/deadlines/invoicing/notes. - One SO line created per fp.direct.order.line, carrying part, coating, treatments M2M, qty, price, per-line deadline, rush flag, and description. - action_create_order loops wizard lines, invokes revision-bump helper, and builds order_line tuples with x_fc_* fields. - Form view uses notebook (Lines tab with editable tree + drill-in form, Notes tab), amber missing-info banner at top, running totals at bottom. Customer deadline maps to Odoo commitment_date on SO. Single-line fields and their computes/onchanges removed from wizard; moved to fp.direct.order.line in task A4. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wizard/fp_direct_order_wizard.py | 343 +++++------------- .../wizard/fp_direct_order_wizard_views.xml | 169 ++++++--- 2 files changed, 206 insertions(+), 306 deletions(-) diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py index 7c0eca9d..9f923464 100644 --- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. -from odoo import api, fields, models, _ +from odoo import _, api, fields, models from odoo.exceptions import UserError @@ -11,90 +11,17 @@ class FpDirectOrderWizard(models.TransientModel): """Direct order entry for repeat customers. Skips the quotation stage when the customer has already sent a PO. - Creates a sale.order and calls action_confirm() in one step. - Optionally bumps the part catalog revision when a new drawing is uploaded. + Creates a sale.order with one sale.order.line per wizard line and + calls action_confirm() in one step. """ _name = 'fp.direct.order.wizard' - _description = 'Fusion Plating — Direct Order Entry' + _description = 'Fusion Plating - Direct Order Entry' + # ---- Customer ---- partner_id = fields.Many2one( 'res.partner', string='Customer', required=True, domain="[('customer_rank', '>', 0)]", ) - - # Part selection - part_catalog_id = fields.Many2one( - 'fp.part.catalog', string='Part', required=True, - domain="[('partner_id', '=', partner_id), ('is_latest_revision', '=', True)]", - ) - part_number = fields.Char(related='part_catalog_id.part_number', readonly=True) - current_revision = fields.Char(related='part_catalog_id.revision', readonly=True) - surface_area = fields.Float( - related='part_catalog_id.surface_area', readonly=True, digits=(12, 4), - ) - surface_area_uom = fields.Selection( - related='part_catalog_id.surface_area_uom', readonly=True, - ) - - # Revision upload (optional — creates a new revision of the part) - create_new_revision = fields.Boolean( - string='This is a New Revision', - help='Check if the customer sent an updated drawing or 3D model. ' - 'A new part revision will be created and linked to this order.', - ) - new_drawing_file = fields.Binary( - string='New Drawing / 3D Model', - help='STEP, STL, IGES, or PDF. Used when creating a new revision.', - ) - new_drawing_filename = fields.Char(string='Filename') - revision_note = fields.Char( - string='Revision Note', help='What changed in this revision?', - ) - - # Order details - coating_config_id = fields.Many2one( - 'fp.coating.config', string='Coating', required=True, - ) - quantity = fields.Integer(string='Quantity', required=True, default=1) - currency_id = fields.Many2one( - 'res.currency', string='Currency', - default=lambda self: self.env.company.currency_id, - ) - unit_price = fields.Monetary( - string='Unit Price', currency_field='currency_id', - help='Negotiated price per part. Leave blank to set later.', - ) - line_subtotal = fields.Monetary( - string='Line Subtotal', currency_field='currency_id', - compute='_compute_line_subtotal', - ) - rush_order = fields.Boolean(string='Rush Order') - delivery_method = fields.Selection( - [('local_delivery', 'Local Delivery'), - ('shipping_partner', 'Shipping Partner'), - ('customer_pickup', 'Customer Pickup')], - string='Delivery Method', - ) - - # PO (required — that's what makes this a "direct" order) - po_number = fields.Char(string='Customer PO #', required=True) - po_attachment_file = fields.Binary(string='PO Document', required=True) - po_attachment_filename = fields.Char(string='PO Filename') - - # Invoice strategy (pulled from partner default if set) - invoice_strategy = fields.Selection( - [('deposit', 'Deposit'), ('progress', 'Progress Billing'), - ('net_terms', 'Net Terms'), ('cod_prepay', 'COD / Prepay')], - string='Invoice Strategy', - ) - deposit_percent = fields.Float(string='Deposit %') - progress_initial_percent = fields.Float( - string='Progress — Initial %', default=50.0, - ) - - notes = fields.Text(string='Internal Notes') - - # ---- Header additions (Phase A) ---- partner_invoice_id = fields.Many2one( 'res.partner', string='Invoice Address', domain="['|', ('id', '=', partner_id), " @@ -110,13 +37,46 @@ class FpDirectOrderWizard(models.TransientModel): help="Customer's internal job number for cross-referencing. " "Appears on work orders and invoices.", ) + + # ---- Scheduling ---- planned_start_date = fields.Date( string='Planned Start', default=fields.Date.context_today, ) internal_deadline = fields.Date(string='Internal Deadline') customer_deadline = fields.Date(string='Customer Deadline') - # ---- Lines (Phase A) ---- + # ---- PO (required — that's what makes this a "direct" order) ---- + po_number = fields.Char(string='Customer PO #', required=True) + po_attachment_file = fields.Binary(string='PO Document', required=True) + po_attachment_filename = fields.Char(string='PO Filename') + + # ---- Fulfilment (order-level) ---- + delivery_method = fields.Selection( + [('local_delivery', 'Local Delivery'), + ('shipping_partner', 'Shipping Partner'), + ('customer_pickup', 'Customer Pickup')], + string='Delivery Method', + ) + + # ---- Currency + invoicing ---- + currency_id = fields.Many2one( + 'res.currency', string='Currency', + default=lambda self: self.env.company.currency_id, + ) + invoice_strategy = fields.Selection( + [('deposit', 'Deposit'), ('progress', 'Progress Billing'), + ('net_terms', 'Net Terms'), ('cod_prepay', 'COD / Prepay')], + string='Invoice Strategy', + ) + deposit_percent = fields.Float(string='Deposit %') + progress_initial_percent = fields.Float( + string='Progress - Initial %', default=50.0, + ) + + # ---- Notes ---- + notes = fields.Text(string='Internal Notes') + + # ---- Lines ---- line_ids = fields.One2many( 'fp.direct.order.line', 'wizard_id', string='Order Lines', ) @@ -132,32 +92,7 @@ class FpDirectOrderWizard(models.TransientModel): # ---- Missing info banner ---- missing_info_msg = fields.Char(compute='_compute_missing_info_msg') - # Description template picker — the domain is dynamically narrowed to - # this part's canned descriptions first. When no part is chosen it - # falls through to generic templates. - description_template_id = fields.Many2one( - 'fp.sale.description.template', - string='Description Template', - domain="[('active','=',True), " - " '|', '|', '|', " - " ('part_catalog_id','=',part_catalog_id), " - " ('part_catalog_id','=',False), " - " ('partner_id','=',partner_id), " - " ('coating_config_id','=',coating_config_id)]", - help='Pick a saved description and tweak it below. Part-specific ' - 'descriptions appear first, then customer / coating / generic.', - ) - line_description = fields.Text( - string='Line Description', - help='This text becomes the description of the sale order line. ' - 'Edit freely — your changes override the template.', - ) - - @api.depends('quantity', 'unit_price') - def _compute_line_subtotal(self): - for rec in self: - rec.line_subtotal = (rec.quantity or 0) * (rec.unit_price or 0.0) - + # ---- Computes ---- @api.depends('line_ids.line_subtotal', 'line_ids.quantity') def _compute_totals(self): for rec in self: @@ -184,10 +119,10 @@ class FpDirectOrderWizard(models.TransientModel): if has_missing else False ) + # ---- Onchange ---- @api.onchange('partner_id') def _onchange_partner_id(self): - """Reset part selection when customer changes + pull invoice defaults + addresses.""" - self.part_catalog_id = False + """Seed invoice defaults + default addresses when customer changes.""" if self.partner_id and 'x_fc_default_invoice_strategy' in self.partner_id._fields: self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0 @@ -199,124 +134,23 @@ class FpDirectOrderWizard(models.TransientModel): self.partner_invoice_id = False self.partner_shipping_id = False - @api.onchange('description_template_id') - def _onchange_description_template(self): - """Copy the template's text into the editable paragraph — user tweaks from there.""" - if self.description_template_id: - self.line_description = self.description_template_id.description - - @api.onchange('part_catalog_id', 'coating_config_id', 'partner_id') - def _onchange_suggest_template(self): - """Offer a sensible default template — part-specific wins. - - Priority (first non-empty result wins): - 1. This part's lowest-sequence active template - 2. This customer's templates (no part) - 3. This coating's templates (no part) - 4. Don't auto-pick — user has to choose - """ - if self.description_template_id or self.line_description: - return # respect user's choice - Template = self.env['fp.sale.description.template'] - - # 1. Part-specific - if self.part_catalog_id: - match = Template.search([ - ('active', '=', True), - ('part_catalog_id', '=', self.part_catalog_id.id), - ], order='sequence', limit=1) - if match: - self.description_template_id = match.id - self.line_description = match.description - return - - # 2. Customer (no part) - if self.partner_id: - match = Template.search([ - ('active', '=', True), - ('part_catalog_id', '=', False), - ('partner_id', '=', self.partner_id.id), - ], order='sequence', limit=1) - if match: - self.description_template_id = match.id - self.line_description = match.description - return - - # 3. Coating (no part, no customer restriction) - if self.coating_config_id: - match = Template.search([ - ('active', '=', True), - ('part_catalog_id', '=', False), - ('partner_id', '=', False), - ('coating_config_id', '=', self.coating_config_id.id), - ], order='sequence', limit=1) - if match: - self.description_template_id = match.id - self.line_description = match.description - return - - @api.onchange('coating_config_id', 'quantity', 'partner_id') - def _onchange_lookup_price(self): - """Auto-fill unit_price from customer price list when available.""" - if not (self.partner_id and self.coating_config_id): - return - # Don't overwrite a manually-entered price - if self.unit_price: - return - price = self.env['fp.customer.price.list']._find_price( - self.partner_id.id, self.coating_config_id.id, - quantity=self.quantity or 1, - ) - if price: - self.unit_price = price.unit_price - + # ---- Actions ---- def action_create_order(self): - """Create and confirm the sale order, optionally bumping part revision.""" + """Create and confirm the sale order with one SO line per wizard line.""" self.ensure_one() + if not self.line_ids: + raise UserError(_('Add at least one part line before confirming.')) + if not self.po_attachment_file: + raise UserError(_('Upload the customer PO document.')) - if self.create_new_revision and not self.new_drawing_file: - raise UserError(_( - 'Please upload the new drawing when creating a new revision.' - )) - if self.quantity <= 0: - raise UserError(_('Quantity must be positive.')) - - # 1. Optional: create a new part revision from the uploaded drawing - part = self.part_catalog_id - if self.create_new_revision: - drawing_att = self.env['ir.attachment'].create({ - 'name': self.new_drawing_filename or 'drawing.pdf', - 'datas': self.new_drawing_file, - 'res_model': 'fp.part.catalog', - 'res_id': part.id, - }) - # action_create_revision returns an action dict; we keep the part - part.action_create_revision() - new_rev = self.env['fp.part.catalog'].search( - [('parent_part_id', '=', (part.parent_part_id or part).id), - ('is_latest_revision', '=', True)], - limit=1, order='revision_number desc', - ) - if new_rev: - new_rev.write({ - 'revision_note': self.revision_note or False, - }) - # Attach drawing/model based on extension - fname = (self.new_drawing_filename or '').lower() - if fname.endswith(('.step', '.stp', '.stl', '.iges', '.igs', '.brep', '.brp')): - new_rev.model_attachment_id = drawing_att.id - else: - new_rev.drawing_attachment_ids = [(4, drawing_att.id)] - part = new_rev - - # 2. Save the PO attachment + # 1. Save the PO attachment once po_att = self.env['ir.attachment'].create({ 'name': self.po_attachment_filename or 'po.pdf', 'datas': self.po_attachment_file, 'mimetype': 'application/pdf', }) - # 3. Find or create the generic plating service product (same as configurator) + # 2. Find or create the generic plating service product product = self.env['product.product'].search( [('default_code', '=', 'FP-SERVICE')], limit=1, ) @@ -330,53 +164,64 @@ class FpDirectOrderWizard(models.TransientModel): 'purchase_ok': False, }) - # Canonical line label (always present) - header = '%s — %s Rev %s (x%d)' % ( - self.coating_config_id.name, - part.name, - part.revision or part.revision_number, - self.quantity, - ) - # Optional extended description from template / user tweak - extended = (self.line_description or '').strip() - if extended: - line_desc = '%s\n\n%s' % (header, extended) - else: - line_desc = header - - # Bump template usage counter so popular ones float to the top over time - if self.description_template_id: - self.description_template_id._register_usage() - + # 3. Build SO header so_vals = { 'partner_id': self.partner_id.id, - 'x_fc_part_catalog_id': part.id, - 'x_fc_coating_config_id': self.coating_config_id.id, - 'x_fc_rush_order': self.rush_order, - 'x_fc_delivery_method': self.delivery_method, + 'partner_invoice_id': ( + self.partner_invoice_id.id or self.partner_id.id + ), + 'partner_shipping_id': ( + self.partner_shipping_id.id or self.partner_id.id + ), 'x_fc_po_number': self.po_number, 'x_fc_po_attachment_id': po_att.id, 'x_fc_po_received': True, + 'x_fc_customer_job_number': self.customer_job_number or False, + 'x_fc_planned_start_date': self.planned_start_date, + 'x_fc_internal_deadline': self.internal_deadline, + 'commitment_date': self.customer_deadline, 'x_fc_invoice_strategy': self.invoice_strategy, 'x_fc_deposit_percent': self.deposit_percent, 'x_fc_progress_initial_percent': self.progress_initial_percent, + 'x_fc_delivery_method': self.delivery_method, 'origin': 'Direct Order', 'note': self.notes or False, - 'order_line': [(0, 0, { + 'order_line': [], + } + + # 4. One SO line per wizard line + for line in self.line_ids: + part = line._get_or_bump_revision() + header = '%s - %s Rev %s (x%d)' % ( + line.coating_config_id.name, + part.name, + part.revision or part.revision_number, + line.quantity, + ) + extended = (line.line_description or '').strip() + line_desc = (header + '\n\n' + extended) if extended else header + if line.description_template_id: + line.description_template_id._register_usage() + + so_vals['order_line'].append((0, 0, { 'product_id': product.id, 'name': line_desc, - 'product_uom_qty': self.quantity, - 'price_unit': self.unit_price or 0.0, - })], - } + 'product_uom_qty': line.quantity, + 'price_unit': line.unit_price or 0.0, + 'x_fc_part_catalog_id': part.id, + 'x_fc_coating_config_id': line.coating_config_id.id, + 'x_fc_treatment_ids': [(6, 0, line.treatment_ids.ids)], + 'x_fc_part_deadline': line.part_deadline, + 'x_fc_rush_order': line.rush_order, + })) + + # 5. Create + confirm so = self.env['sale.order'].create(so_vals) - # Immediately confirm — skips quote/send step entirely so.action_confirm() - so.message_post( - body=_( - 'Direct order created from PO %s. Quotation stage skipped.' - ) % self.po_number, - ) + so.message_post(body=_( + 'Direct order created from PO %s with %d line(s). ' + 'Quotation stage skipped.' + ) % (self.po_number, len(self.line_ids))) return { 'type': 'ir.actions.act_window', diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml index 1107e737..01c7f7ae 100644 --- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml @@ -6,11 +6,17 @@ fp.direct.order.wizard
+

New Direct Order

- Skip the quotation stage — create a confirmed order + Skip the quotation stage - create a confirmed order when the customer has already sent a PO.

@@ -18,59 +24,30 @@ + + + - + - - - - - - - - - - - - - - - - - - - - - - - + + + + - - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +