diff --git a/fusion-plating/fusion_plating_configurator/__manifest__.py b/fusion-plating/fusion_plating_configurator/__manifest__.py index ff5ffc94..c78adbed 100644 --- a/fusion-plating/fusion_plating_configurator/__manifest__.py +++ b/fusion-plating/fusion_plating_configurator/__manifest__.py @@ -44,6 +44,7 @@ Provides: 'views/fp_pricing_rule_views.xml', 'views/fp_quote_configurator_views.xml', 'views/sale_order_views.xml', + 'views/res_partner_views.xml', 'views/fp_configurator_menu.xml', ], 'assets': { diff --git a/fusion-plating/fusion_plating_configurator/models/__init__.py b/fusion-plating/fusion_plating_configurator/models/__init__.py index 4469994b..0889d9aa 100644 --- a/fusion-plating/fusion_plating_configurator/models/__init__.py +++ b/fusion-plating/fusion_plating_configurator/models/__init__.py @@ -10,3 +10,4 @@ from . import fp_pricing_complexity_surcharge from . import fp_pricing_rule from . import fp_quote_configurator from . import sale_order +from . import res_partner 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 4a50a3d0..5d6e1a1d 100644 --- a/fusion-plating/fusion_plating_configurator/models/fp_part_catalog.py +++ b/fusion-plating/fusion_plating_configurator/models/fp_part_catalog.py @@ -25,6 +25,18 @@ class FpPartCatalog(models.Model): ) part_number = fields.Char(string='Part Number', tracking=True, help="Customer's part number (e.g. VS-R392007E01).") revision = fields.Char(string='Revision', help='Revision letter or number (e.g. Rev: 1B).') + revision_number = fields.Integer(string='Rev #', default=1) + revision_note = fields.Char(string='Revision Note', help='What changed in this revision.') + revision_date = fields.Datetime(string='Revision Date', default=fields.Datetime.now) + parent_part_id = fields.Many2one( + 'fp.part.catalog', string='Original Part', + ondelete='set null', index=True, + help='Links to the original part for revision history.', + ) + is_latest_revision = fields.Boolean(string='Latest Revision', default=True) + revision_ids = fields.One2many( + 'fp.part.catalog', 'parent_part_id', string='Revision History', + ) substrate_material = fields.Selection( [('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'), ('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')], @@ -121,6 +133,35 @@ class FpPartCatalog(models.Model): 'target': 'current', } + def action_create_revision(self): + """Create a new revision of this part. Copies all data, increments revision number.""" + self.ensure_one() + # Mark current as no longer latest + self.is_latest_revision = False + # Determine the root part for the chain + root = self.parent_part_id or self + # Find highest revision number in chain + all_revs = self.env['fp.part.catalog'].search([ + '|', ('id', '=', root.id), ('parent_part_id', '=', root.id), + ]) + max_rev = max(all_revs.mapped('revision_number') or [0]) + new_rev = self.copy({ + 'revision_number': max_rev + 1, + 'revision': f'Rev {max_rev + 1}', + 'revision_date': fields.Datetime.now(), + 'revision_note': False, + 'parent_part_id': root.id, + 'is_latest_revision': True, + 'model_attachment_id': self.model_attachment_id.id, + }) + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fp.part.catalog', + 'res_id': new_rev.id, + 'view_mode': 'form', + 'target': 'current', + } + @api.onchange('model_attachment_id') def _onchange_model_attachment_id(self): """Auto-calculate surface area when a 3D model is attached.""" diff --git a/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py b/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py index 05409650..2e847ad6 100644 --- a/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py +++ b/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py @@ -387,16 +387,38 @@ class FpQuoteConfigurator(models.Model): 'mimetype': 'application/octet-stream', }) - # Auto-create or update part catalog + # Auto-create or update part catalog with revision tracking part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title() - if self.part_catalog_id: - # Update existing part + if self.part_catalog_id and self.part_catalog_id.model_attachment_id: + # Part already has a 3D model — create a new revision + old_part = self.part_catalog_id + old_part.is_latest_revision = False + root = old_part.parent_part_id or old_part + all_revs = self.env['fp.part.catalog'].search([ + '|', ('id', '=', root.id), ('parent_part_id', '=', root.id), + ]) + max_rev = max(all_revs.mapped('revision_number') or [0]) + new_part = old_part.copy({ + 'revision_number': max_rev + 1, + 'revision': f'Rev {max_rev + 1}', + 'revision_date': fields.Datetime.now(), + 'revision_note': f'Updated 3D model: {fname}', + 'parent_part_id': root.id, + 'is_latest_revision': True, + 'model_attachment_id': att.id, + }) + self.part_catalog_id = new_part.id + new_part._compute_surface_area_from_model() + self.surface_area = new_part.surface_area + self.surface_area_uom = new_part.surface_area_uom + elif self.part_catalog_id: + # Part exists but no 3D model yet — just attach self.part_catalog_id.model_attachment_id = att.id self.part_catalog_id._compute_surface_area_from_model() self.surface_area = self.part_catalog_id.surface_area self.surface_area_uom = self.part_catalog_id.surface_area_uom else: - # Create new part catalog entry + # No part catalog — create new entry part = self.env['fp.part.catalog'].create({ 'name': part_name, 'partner_id': self.partner_id.id, @@ -404,7 +426,6 @@ class FpQuoteConfigurator(models.Model): 'model_attachment_id': att.id, }) self.part_catalog_id = part.id - # Calculate surface area part._compute_surface_area_from_model() self.surface_area = part.surface_area self.surface_area_uom = part.surface_area_uom @@ -446,13 +467,26 @@ class FpQuoteConfigurator(models.Model): def action_recalculate_price(self): """Recalculate surface area from 3D model and recompute price.""" self.ensure_one() + msg = _('No 3D model to calculate from.') # Recalculate surface area from part catalog's 3D model if self.part_catalog_id and self.part_catalog_id.model_attachment_id: result = self.part_catalog_id._compute_surface_area_from_model() if not result.get('error'): self.surface_area = self.part_catalog_id.surface_area self.surface_area_uom = self.part_catalog_id.surface_area_uom - # Price recomputes automatically via _compute_price dependency + msg = _('Surface area: %.4f %s | Price: %.2f %s') % ( + self.surface_area, self.surface_area_uom or '', + self.calculated_price, self.currency_id.symbol or '$', + ) + else: + msg = result['error'] + # Post result to chatter so user sees it after form reload + self.message_post( + body=msg, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + return False def action_cancel(self): self.write({'state': 'cancelled'}) diff --git a/fusion-plating/fusion_plating_configurator/models/res_partner.py b/fusion-plating/fusion_plating_configurator/models/res_partner.py new file mode 100644 index 00000000..790d3840 --- /dev/null +++ b/fusion-plating/fusion_plating_configurator/models/res_partner.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, fields, models + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + x_fc_part_catalog_ids = fields.One2many( + 'fp.part.catalog', 'partner_id', + string='Part Catalog', + ) + x_fc_part_count = fields.Integer( + string='Parts', + compute='_compute_part_count', + ) + + def _compute_part_count(self): + for partner in self: + partner.x_fc_part_count = self.env['fp.part.catalog'].search_count([ + ('partner_id', '=', partner.id), + ('is_latest_revision', '=', True), + ]) + + def action_view_parts(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': f'Parts — {self.name}', + 'res_model': 'fp.part.catalog', + 'view_mode': 'list,form', + 'domain': [('partner_id', '=', self.id), ('is_latest_revision', '=', True)], + 'context': {'default_partner_id': self.id}, + 'target': 'current', + } diff --git a/fusion-plating/fusion_plating_configurator/views/fp_part_catalog_views.xml b/fusion-plating/fusion_plating_configurator/views/fp_part_catalog_views.xml index 3f48edf9..14888276 100644 --- a/fusion-plating/fusion_plating_configurator/views/fp_part_catalog_views.xml +++ b/fusion-plating/fusion_plating_configurator/views/fp_part_catalog_views.xml @@ -29,6 +29,13 @@ fp.part.catalog
+
+
+
+ + + + + + + + + + + + + diff --git a/fusion-plating/fusion_plating_configurator/views/res_partner_views.xml b/fusion-plating/fusion_plating_configurator/views/res_partner_views.xml new file mode 100644 index 00000000..0b1e7214 --- /dev/null +++ b/fusion-plating/fusion_plating_configurator/views/res_partner_views.xml @@ -0,0 +1,42 @@ + + + + + res.partner.form.fp.parts + res.partner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion-plating/fusion_plating_receiving/views/fp_receiving_views.xml b/fusion-plating/fusion_plating_receiving/views/fp_receiving_views.xml index 0192286f..16afc6da 100644 --- a/fusion-plating/fusion_plating_receiving/views/fp_receiving_views.xml +++ b/fusion-plating/fusion_plating_receiving/views/fp_receiving_views.xml @@ -123,11 +123,7 @@
-
- - - -
+