From 299cae8a4efe11016692b5f335270c571697fe72 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 26 May 2026 21:11:15 -0400 Subject: [PATCH] feat(configurator): B2+B7+B8 - Express override helper + serial-bulk + DWG/OPEN buttons on sale.order.line --- .../models/sale_order_line.py | 143 +++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) 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 b4011096..dc775386 100644 --- a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py +++ b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py @@ -6,7 +6,7 @@ from datetime import timedelta from odoo import _, api, fields, models -from odoo.exceptions import ValidationError +from odoo.exceptions import UserError, ValidationError class SaleOrderLine(models.Model): @@ -775,3 +775,144 @@ class SaleOrderLine(models.Model): readonly=True, store=True, ) + + # ============================================================ + # Express Orders backend helpers (Phase B — 2026-05-26) + # ============================================================ + + def _fp_apply_express_overrides_to_job(self, job): + """Convert Express per-line flags into fp.job.node.override + step instructions. + + Called from sale_order._fp_auto_create_job() immediately after Job.create + (creates override rows; step instructions skipped if no steps yet), and + again from fp.job.action_confirm() after _generate_steps_from_recipe() + (override rows recreate identically; step instructions land this time). + + Idempotent: pre-deletes prior masking/bake override rows on each call. + + Algorithm: + - x_fc_masking_enabled=False → opt out of masking + de_masking nodes + - x_fc_bake_instructions empty → opt out of baking nodes + - x_fc_bake_instructions non-empty → keep baking + write text to step.instructions + """ + self.ensure_one() + if not job or not job.recipe_id: + return + + recipe = job.recipe_id + Override = self.env['fp.job.node.override'].sudo() + + # Idempotency: clear prior masking/bake override rows on this job + prior = Override.search([ + ('job_id', '=', job.id), + ('node_id.default_kind', 'in', ('masking', 'de_masking', 'baking')), + ]) + if prior: + prior.unlink() + + msgs = [] + + # 1. Masking — opt out of masking + de_masking AS A PAIR + if not self.x_fc_masking_enabled: + nodes = recipe._fp_all_nodes_with_kind(('masking', 'de_masking')) + for node in nodes: + Override.create({ + 'job_id': job.id, + 'node_id': node.id, + 'included': False, + }) + if nodes: + msgs.append(_('Masking + de-masking steps opted out (per SO line)')) + + # 2. Bake — empty = opt out; non-empty = keep + write step.instructions + bake_text = (self.x_fc_bake_instructions or '').strip() + bake_nodes = recipe._fp_all_nodes_with_kind(('baking',)) + if not bake_text: + for node in bake_nodes: + Override.create({ + 'job_id': job.id, + 'node_id': node.id, + 'included': False, + }) + if bake_nodes: + msgs.append(_('Baking steps opted out (per SO line)')) + else: + # Step instructions write only succeeds if steps exist. The + # helper is called twice — first call (before action_confirm) + # finds no steps and skips; second call (after step gen) lands. + bake_steps = job.step_ids.filtered( + lambda s: s.recipe_node_id.default_kind == 'baking' + ) + if bake_steps: + bake_steps.sudo().write({'instructions': bake_text}) + msgs.append(_('Bake step instructions set to: %s') % bake_text) + + # 3. Audit chatter post on the job (only on the call that actually wrote) + if msgs: + job.sudo().message_post(body='\n'.join('• ' + m for m in msgs)) + + def action_open_serial_bulk_add(self): + """Open the existing fp.serial.bulk.add.wizard targeting this SO line. + + Express Orders surfaces this as the inline '+ bulk' button on the + Part cell's serial row (post-confirm). The wizard model and its + action already handle both sale.order.line and fp.direct.order.line. + """ + self.ensure_one() + action = self.env.ref( + 'fusion_plating_configurator.action_fp_serial_bulk_add_wizard' + ).read()[0] + action['context'] = { + 'default_target_model': 'sale.order.line', + 'default_target_id': self.id, + 'default_qty_expected': int(self.product_uom_qty or 0), + } + return action + + def action_open_part(self): + """Open the linked fp.part.catalog form in a modal.""" + self.ensure_one() + if not self.x_fc_part_catalog_id: + return False + return { + 'type': 'ir.actions.act_window', + 'name': self.x_fc_part_catalog_id.display_name, + 'res_model': 'fp.part.catalog', + 'view_mode': 'form', + 'res_id': self.x_fc_part_catalog_id.id, + 'target': 'new', + } + + def action_upload_drawing(self): + """Attach a file (via context) to the line's part as a drawing. + + Frontend calling pattern: read file picker → base64-encode → + set context['fp_drawing_file'] + context['fp_drawing_filename'] → + call this method. The drawing lives on the PART (not the line) + so future orders for the same part reuse it. + """ + self.ensure_one() + if not self.x_fc_part_catalog_id: + raise UserError(_('Pick or create a part on this line first.')) + file_data = self.env.context.get('fp_drawing_file') + filename = self.env.context.get('fp_drawing_filename', 'drawing.pdf') + if not file_data: + raise UserError(_('No file data received.')) + att = self.env['ir.attachment'].sudo().create({ + 'name': filename, + 'datas': file_data, + 'res_model': 'fp.part.catalog', + 'res_id': self.x_fc_part_catalog_id.id, + }) + self.x_fc_part_catalog_id.sudo().write({ + 'drawing_attachment_ids': [(4, att.id)], + }) + self.x_fc_part_catalog_id.sudo().message_post(body=_( + 'Drawing "%(name)s" uploaded by %(user)s from line %(seq)s on SO %(so)s.' + ) % { + 'name': filename, + 'user': self.env.user.display_name, + 'seq': self.sequence or self.id, + 'so': self.order_id.name, + }) + return {'type': 'ir.actions.client', 'tag': 'reload'}