From 9423a9396165f36017f7da312557f92b93fc73ee Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:32:34 -0400 Subject: [PATCH] feat(configurator): fill per-line logic (price lookup, desc template, rev bump) Task A4. Expands fp.direct.order.line with: part related fields, optional new-revision block, additional treatment M2M, per-line deadline + rush flag, description template + free-text, onchange auto-price-lookup from customer price list, onchange template suggestion (part > customer > coating), and _get_or_bump_revision helper that will be called by the SO-creation loop. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wizard/fp_direct_order_line.py | 157 +++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py index b8bd6242..324cf7a8 100644 --- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py @@ -3,7 +3,8 @@ # 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 class FpDirectOrderLine(models.TransientModel): @@ -18,16 +19,48 @@ class FpDirectOrderLine(models.TransientModel): ) sequence = fields.Integer(default=10) + # ---- Part ---- part_catalog_id = fields.Many2one( 'fp.part.catalog', string='Part', required=True, ) + part_number = fields.Char( + related='part_catalog_id.part_number', readonly=True, + ) + part_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, + ) + + # ---- New revision (optional) ---- + 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 line.', + ) + new_drawing_file = fields.Binary(string='New Drawing / 3D Model') + new_drawing_filename = fields.Char(string='Filename') + revision_note = fields.Char(string='Revision Note') + + # ---- Treatments ---- coating_config_id = fields.Many2one( 'fp.coating.config', string='Primary Treatment', required=True, ) + treatment_ids = fields.Many2many( + 'fp.treatment', + string='Additional Treatments', + help='Extra pre/post treatments applied to this line.', + ) + + # ---- Qty / price ---- quantity = fields.Integer(string='Qty', default=1, required=True) currency_id = fields.Many2one(related='wizard_id.currency_id') unit_price = fields.Monetary( @@ -40,7 +73,129 @@ class FpDirectOrderLine(models.TransientModel): compute='_compute_line_subtotal', ) + # ---- Scheduling / fulfilment ---- + part_deadline = fields.Date( + string='Part Deadline', + help='Per-line deadline. Defaults to SO customer deadline if blank.', + ) + rush_order = fields.Boolean(string='Rush') + + # ---- Description ---- + description_template_id = fields.Many2one( + 'fp.sale.description.template', + string='Description Template', + ) + 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.', + ) + + # ---- Computes ---- @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) + + # ---- Onchange ---- + @api.onchange('coating_config_id', 'quantity', 'part_catalog_id') + def _onchange_lookup_price(self): + """Auto-fill unit_price from customer price list when available.""" + if self.unit_price: + return + partner = self.wizard_id.partner_id + if not (partner and self.coating_config_id): + return + price = self.env['fp.customer.price.list']._find_price( + partner.id, + self.coating_config_id.id, + quantity=self.quantity or 1, + ) + if price: + self.unit_price = price.unit_price + + @api.onchange('description_template_id') + def _onchange_description_template(self): + if self.description_template_id: + self.line_description = self.description_template_id.description + + @api.onchange('part_catalog_id', 'coating_config_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 + Template = self.env['fp.sale.description.template'] + partner = self.wizard_id.partner_id + + 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 + + if partner: + match = Template.search([ + ('active', '=', True), + ('part_catalog_id', '=', False), + ('partner_id', '=', partner.id), + ], order='sequence', limit=1) + if match: + self.description_template_id = match.id + self.line_description = match.description + return + + 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 + + # ---- Helpers ---- + def _get_or_bump_revision(self): + """Return the part to use for the SO line, optionally bumping revision.""" + self.ensure_one() + part = self.part_catalog_id + if not self.create_new_revision: + return part + if not self.new_drawing_file: + raise UserError(_( + 'Line %s: upload the new drawing before confirming.' + ) % (part.name or part.part_number or '?')) + + 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, + }) + 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 not new_rev: + return part + + new_rev.write({'revision_note': self.revision_note or False}) + 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)] + return new_rev