This commit is contained in:
gsinghpal
2026-04-13 09:45:28 -04:00
parent 0ff8c0b93f
commit d3c8782505
8 changed files with 188 additions and 11 deletions

View File

@@ -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': {

View File

@@ -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

View File

@@ -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."""

View File

@@ -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'})

View File

@@ -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',
}

View File

@@ -29,6 +29,13 @@
<field name="model">fp.part.catalog</field>
<field name="arch" type="xml">
<form string="Part Catalog">
<header>
<button name="action_create_revision"
string="Create New Revision"
type="object"
class="btn-secondary"
icon="fa-code-fork"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_orders"
@@ -47,6 +54,7 @@
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<widget name="web_ribbon" title="Superseded" bg_color="text-bg-warning" invisible="is_latest_revision"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Valve Body Housing"/></h1>
@@ -56,8 +64,11 @@
<group>
<field name="partner_id"/>
<field name="revision"/>
<field name="revision_number"/>
<field name="substrate_material"/>
<field name="geometry_source"/>
<field name="is_latest_revision" invisible="1"/>
<field name="parent_part_id" invisible="not parent_part_id"/>
</group>
<group>
<label for="surface_area"/>
@@ -101,6 +112,20 @@
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
</div>
</page>
<page string="Revision History" name="revisions"
invisible="not parent_part_id and not revision_ids">
<field name="revision_ids" mode="list">
<list default_order="revision_number desc">
<field name="revision_number" string="Rev #"/>
<field name="revision"/>
<field name="revision_note"/>
<field name="revision_date"/>
<field name="surface_area"/>
<field name="model_attachment_id" string="3D Model"/>
<field name="is_latest_revision" widget="boolean_toggle"/>
</list>
</field>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Additional notes about this part..."/>
</page>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_partner_form_fp_parts" model="ir.ui.view">
<field name="name">res.partner.form.fp.parts</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_parts"
type="object"
class="oe_stat_button"
icon="fa-cube"
invisible="x_fc_part_count == 0">
<field name="x_fc_part_count" widget="statinfo" string="Parts"/>
</button>
</xpath>
<xpath expr="//page[@name='sales_purchases']" position="after">
<page string="Part Library" name="part_library">
<field name="x_fc_part_catalog_ids" mode="list"
domain="[('is_latest_revision', '=', True)]">
<list default_order="name" >
<field name="part_number"/>
<field name="name"/>
<field name="revision"/>
<field name="revision_number" string="Rev #"/>
<field name="substrate_material"/>
<field name="surface_area"/>
<field name="surface_area_uom" string="UoM"/>
<field name="complexity"/>
<field name="model_attachment_id" string="3D Model"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -123,11 +123,7 @@
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
<chatter/>
</form>
</field>
</record>