fix(part-catalog): upload slot + swapped Number/Name + smart buttons
Three fixes on fp.part.catalog form:
1. 3D Model upload actually works now. The old field exposed only a
Many2one search dropdown — no way to add a new file. Added a
Binary upload slot (model_upload + model_upload_filename) that
fires an onchange which wraps the bytes in an ir.attachment and
links it to model_attachment_id. The upload slot is hidden once a
model is already attached, so the current file stays visible.
Accepts STEP/STP/STL/IGES/IGS/BREP. Auto-runs the surface-area
calculation after attach, same as before.
2. Part Number is now the big <h1> title, Part Name is the smaller
field underneath. Matches how plating shops actually identify
parts (by customer part number, not a free-text name). Swapped
column order in the list view too — Part Number first, then Name.
3. Four smart buttons now on the part form:
- Customer → opens res.partner record
- Sale Orders (already existed)
- Work Orders → filtered mrp.workorder list across SOs for this part
- Quotes (already existed)
- Revisions → shown only when 2+ revs exist, opens the revision
tree filtered by root part
New compute fields workorder_count + revision_count feed the
statinfo widgets, with matching action_view_customer,
action_view_workorders, action_view_revisions handlers.
Verified on demo data:
VS-ESMC6H00801P01 → SO=2, WO=18, REV=2
VS-PQR8440 → SO=1, WO=9, REV=3
All counts light up, buttons drill in cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,16 @@ class FpPartCatalog(models.Model):
|
|||||||
'ir.attachment', string='3D Model File',
|
'ir.attachment', string='3D Model File',
|
||||||
help='STEP, STL, or IGES file.', tracking=True,
|
help='STEP, STL, or IGES file.', tracking=True,
|
||||||
)
|
)
|
||||||
|
# Binary upload proxy — lets the user drop a file in the form; the
|
||||||
|
# onchange below wraps it in an ir.attachment and links it to
|
||||||
|
# model_attachment_id. Without this, the Many2one only offers a
|
||||||
|
# search dropdown with no upload affordance.
|
||||||
|
model_upload = fields.Binary(
|
||||||
|
string='Upload 3D Model',
|
||||||
|
help='Drop a STEP/STP/STL/IGES/IGS/BREP file here to attach it as '
|
||||||
|
'the 3D model for this part.',
|
||||||
|
)
|
||||||
|
model_upload_filename = fields.Char(string='Upload Filename')
|
||||||
drawing_attachment_ids = fields.Many2many(
|
drawing_attachment_ids = fields.Many2many(
|
||||||
'ir.attachment', 'fp_part_catalog_drawing_rel', 'part_catalog_id', 'attachment_id', string='PDF Drawings',
|
'ir.attachment', 'fp_part_catalog_drawing_rel', 'part_catalog_id', 'attachment_id', string='PDF Drawings',
|
||||||
)
|
)
|
||||||
@@ -174,6 +184,12 @@ class FpPartCatalog(models.Model):
|
|||||||
configurator_count = fields.Integer(
|
configurator_count = fields.Integer(
|
||||||
string='Quotes', compute='_compute_configurator_count',
|
string='Quotes', compute='_compute_configurator_count',
|
||||||
)
|
)
|
||||||
|
workorder_count = fields.Integer(
|
||||||
|
string='Work Orders', compute='_compute_workorder_count',
|
||||||
|
)
|
||||||
|
revision_count = fields.Integer(
|
||||||
|
string='Revisions', compute='_compute_revision_count',
|
||||||
|
)
|
||||||
|
|
||||||
_sql_constraints = [
|
_sql_constraints = [
|
||||||
('fp_part_catalog_partner_partnum_uniq', 'unique(partner_id, part_number)',
|
('fp_part_catalog_partner_partnum_uniq', 'unique(partner_id, part_number)',
|
||||||
@@ -261,6 +277,68 @@ class FpPartCatalog(models.Model):
|
|||||||
part.configurator_count = self.env['fp.quote.configurator'].search_count(
|
part.configurator_count = self.env['fp.quote.configurator'].search_count(
|
||||||
[('part_catalog_id', '=', part.id)])
|
[('part_catalog_id', '=', part.id)])
|
||||||
|
|
||||||
|
def _compute_workorder_count(self):
|
||||||
|
SaleOrder = self.env['sale.order']
|
||||||
|
Production = self.env['mrp.production']
|
||||||
|
MrpWO = self.env.get('mrp.workorder')
|
||||||
|
for part in self:
|
||||||
|
if MrpWO is None:
|
||||||
|
part.workorder_count = 0
|
||||||
|
continue
|
||||||
|
so_names = SaleOrder.search(
|
||||||
|
[('x_fc_part_catalog_id', '=', part.id)]
|
||||||
|
).mapped('name')
|
||||||
|
if not so_names:
|
||||||
|
part.workorder_count = 0
|
||||||
|
continue
|
||||||
|
mos = Production.search([('origin', 'in', so_names)])
|
||||||
|
part.workorder_count = sum(len(m.workorder_ids) for m in mos)
|
||||||
|
|
||||||
|
def _compute_revision_count(self):
|
||||||
|
for part in self:
|
||||||
|
root = part.parent_part_id or part
|
||||||
|
part.revision_count = self.env['fp.part.catalog'].search_count([
|
||||||
|
'|', ('id', '=', root.id), ('parent_part_id', '=', root.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
def action_view_customer(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': self.partner_id.display_name,
|
||||||
|
'res_model': 'res.partner',
|
||||||
|
'res_id': self.partner_id.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_workorders(self):
|
||||||
|
self.ensure_one()
|
||||||
|
so_names = self.env['sale.order'].search(
|
||||||
|
[('x_fc_part_catalog_id', '=', self.id)]
|
||||||
|
).mapped('name')
|
||||||
|
mos = self.env['mrp.production'].search([('origin', 'in', so_names)])
|
||||||
|
wo_ids = mos.mapped('workorder_ids').ids
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Work Orders — %s') % (self.part_number or self.name),
|
||||||
|
'res_model': 'mrp.workorder',
|
||||||
|
'domain': [('id', 'in', wo_ids)],
|
||||||
|
'view_mode': 'list,form,kanban',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_revisions(self):
|
||||||
|
self.ensure_one()
|
||||||
|
root = self.parent_part_id or self
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Revisions — %s') % (root.part_number or root.name),
|
||||||
|
'res_model': 'fp.part.catalog',
|
||||||
|
'domain': ['|', ('id', '=', root.id), ('parent_part_id', '=', root.id)],
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'context': {'default_parent_part_id': root.id},
|
||||||
|
}
|
||||||
|
|
||||||
def action_view_sale_orders(self):
|
def action_view_sale_orders(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
orders = self.env['sale.order'].search([('x_fc_part_catalog_id', '=', self.id)])
|
orders = self.env['sale.order'].search([('x_fc_part_catalog_id', '=', self.id)])
|
||||||
@@ -336,6 +414,30 @@ class FpPartCatalog(models.Model):
|
|||||||
if self.model_attachment_id:
|
if self.model_attachment_id:
|
||||||
self._compute_surface_area_from_model()
|
self._compute_surface_area_from_model()
|
||||||
|
|
||||||
|
@api.onchange('model_upload', 'model_upload_filename')
|
||||||
|
def _onchange_model_upload(self):
|
||||||
|
"""Wrap an uploaded binary file in an ir.attachment and link it.
|
||||||
|
|
||||||
|
Fires as soon as the user drops a file in the "Upload 3D Model"
|
||||||
|
widget — the attachment is created in-memory (no DB commit) so
|
||||||
|
saving the part persists both at once.
|
||||||
|
"""
|
||||||
|
if not self.model_upload:
|
||||||
|
return
|
||||||
|
attachment = self.env['ir.attachment'].create({
|
||||||
|
'name': self.model_upload_filename or 'model.step',
|
||||||
|
'datas': self.model_upload,
|
||||||
|
'res_model': self._name,
|
||||||
|
'res_id': self.id or 0,
|
||||||
|
})
|
||||||
|
self.model_attachment_id = attachment
|
||||||
|
# Clear the upload buffer so the same widget can accept another file
|
||||||
|
self.model_upload = False
|
||||||
|
self.model_upload_filename = False
|
||||||
|
# If attaching triggered auto-area calc, rerun it
|
||||||
|
if self.model_attachment_id:
|
||||||
|
self._compute_surface_area_from_model()
|
||||||
|
|
||||||
def action_calculate_surface_area(self):
|
def action_calculate_surface_area(self):
|
||||||
"""Button: calculate surface area from the uploaded 3D model file."""
|
"""Button: calculate surface area from the uploaded 3D model file."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
<field name="model">fp.part.catalog</field>
|
<field name="model">fp.part.catalog</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list string="Part Catalog" decoration-muted="not active">
|
<list string="Part Catalog" decoration-muted="not active">
|
||||||
<field name="partner_id"/>
|
|
||||||
<field name="part_number"/>
|
<field name="part_number"/>
|
||||||
|
<field name="name" string="Part Name"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
<field name="revision"/>
|
<field name="revision"/>
|
||||||
<field name="substrate_material"/>
|
<field name="substrate_material"/>
|
||||||
<field name="surface_area"/>
|
<field name="surface_area"/>
|
||||||
@@ -38,6 +39,15 @@
|
|||||||
</header>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<div class="oe_button_box" name="button_box">
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_customer"
|
||||||
|
type="object"
|
||||||
|
class="oe_stat_button"
|
||||||
|
icon="fa-user"
|
||||||
|
invisible="not partner_id">
|
||||||
|
<div class="o_stat_info">
|
||||||
|
<span class="o_stat_text">Customer</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
<button name="action_view_sale_orders"
|
<button name="action_view_sale_orders"
|
||||||
type="object"
|
type="object"
|
||||||
class="oe_stat_button"
|
class="oe_stat_button"
|
||||||
@@ -45,6 +55,13 @@
|
|||||||
invisible="sale_order_count == 0">
|
invisible="sale_order_count == 0">
|
||||||
<field name="sale_order_count" widget="statinfo" string="Sale Orders"/>
|
<field name="sale_order_count" widget="statinfo" string="Sale Orders"/>
|
||||||
</button>
|
</button>
|
||||||
|
<button name="action_view_workorders"
|
||||||
|
type="object"
|
||||||
|
class="oe_stat_button"
|
||||||
|
icon="fa-cogs"
|
||||||
|
invisible="workorder_count == 0">
|
||||||
|
<field name="workorder_count" widget="statinfo" string="Work Orders"/>
|
||||||
|
</button>
|
||||||
<button name="action_view_configurators"
|
<button name="action_view_configurators"
|
||||||
type="object"
|
type="object"
|
||||||
class="oe_stat_button"
|
class="oe_stat_button"
|
||||||
@@ -52,13 +69,20 @@
|
|||||||
invisible="configurator_count == 0">
|
invisible="configurator_count == 0">
|
||||||
<field name="configurator_count" widget="statinfo" string="Quotes"/>
|
<field name="configurator_count" widget="statinfo" string="Quotes"/>
|
||||||
</button>
|
</button>
|
||||||
|
<button name="action_view_revisions"
|
||||||
|
type="object"
|
||||||
|
class="oe_stat_button"
|
||||||
|
icon="fa-code-fork"
|
||||||
|
invisible="revision_count < 2">
|
||||||
|
<field name="revision_count" widget="statinfo" string="Revisions"/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
<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"/>
|
<widget name="web_ribbon" title="Superseded" bg_color="text-bg-warning" invisible="is_latest_revision"/>
|
||||||
<div class="oe_title">
|
<div class="oe_title">
|
||||||
<label for="name"/>
|
<label for="part_number" string="Part Number"/>
|
||||||
<h1><field name="name" placeholder="e.g. Valve Body Housing"/></h1>
|
<h1><field name="part_number" placeholder="e.g. VS-R392007E01"/></h1>
|
||||||
<field name="part_number" placeholder="Customer part number (e.g. VS-R392007E01)"/>
|
<field name="name" placeholder="Descriptive part name (e.g. Valve Body Housing)"/>
|
||||||
</div>
|
</div>
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group>
|
||||||
@@ -131,7 +155,26 @@
|
|||||||
</page>
|
</page>
|
||||||
<page string="Attachments" name="attachments">
|
<page string="Attachments" name="attachments">
|
||||||
<group>
|
<group>
|
||||||
<field name="model_attachment_id"/>
|
<!-- Upload slot: Binary field that wraps the file
|
||||||
|
in an ir.attachment on change. Hidden once a
|
||||||
|
3D model is already attached. -->
|
||||||
|
<label for="model_upload" string="Upload 3D Model"
|
||||||
|
invisible="model_attachment_id"/>
|
||||||
|
<div class="o_row" invisible="model_attachment_id">
|
||||||
|
<field name="model_upload" nolabel="1"
|
||||||
|
filename="model_upload_filename"
|
||||||
|
class="oe_inline"/>
|
||||||
|
<field name="model_upload_filename" invisible="1"/>
|
||||||
|
<span class="text-muted ms-2 small">
|
||||||
|
STEP / STP / STL / IGES / IGS / BREP
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Current attachment + remove affordance -->
|
||||||
|
<label for="model_attachment_id" string="3D Model File"
|
||||||
|
invisible="not model_attachment_id"/>
|
||||||
|
<div class="o_row" invisible="not model_attachment_id">
|
||||||
|
<field name="model_attachment_id" nolabel="1" class="oe_inline"/>
|
||||||
|
</div>
|
||||||
<field name="drawing_attachment_ids" widget="fp_pdf_preview_binary"/>
|
<field name="drawing_attachment_ids" widget="fp_pdf_preview_binary"/>
|
||||||
</group>
|
</group>
|
||||||
<div invisible="not model_attachment_id" class="mt-3">
|
<div invisible="not model_attachment_id" class="mt-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user