This commit is contained in:
gsinghpal
2026-04-13 02:35:35 -04:00
parent 1176ba68ae
commit 0ff8c0b93f
116 changed files with 14227 additions and 2406 deletions

View File

@@ -3,9 +3,11 @@
## Project
Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and metal finishing shops. Built by Nexa Systems for EN Technologies (the client). Replaces Steelhead Software.
## Module Structure
## Module Structure (30 modules)
```
fusion_plating/ — Core: facilities, process types, tanks, baths, chemistry, recipes
fusion_plating_batch/ — Rack/barrel batch tracking (FpBatch, FpBatchChemistry)
fusion_plating_kpi/ — KPI definitions, daily auto-compute, dashboard views
fusion_plating_configurator/ — Quotation configurator, pricing engine, part catalog, 3D viewer
fusion_plating_receiving/ — Parts receiving, inspection, damage logging
fusion_plating_invoicing/ — Invoice strategies (deposit/progress/net/COD), account holds
@@ -15,6 +17,8 @@ fusion_plating_shopfloor/ — Tablet UI, plant overview kanban, proces
fusion_plating_portal/ — Customer portal + self-service configurator wizard
fusion_plating_reports/ — PDF reports (WO margin, discharge sample, CoC, etc.)
fusion_plating_compliance/ — Compliance framework, jurisdictions
fusion_plating_compliance_on/ — Ontario compliance reference data (data-only, no menus)
fusion_plating_compliance_tor/ — Toronto bylaw discharge limits (data-only, no menus)
fusion_plating_aerospace/ — AS9100 / Nadcap
fusion_plating_nuclear/ — CSA N299 / CNSC
fusion_plating_cgp/ — Controlled Goods Program
@@ -22,9 +26,10 @@ fusion_plating_safety/ — SDS, WHMIS, JHSC
fusion_plating_quality/ — QMS (NCR, CAPA, calibration)
fusion_plating_logistics/ — Pickup & delivery, chain of custody
fusion_plating_culture/ — Values / fundamentals
fusion_plating_bridge_mrp/ — MRP integration (recipe→WO, portal job, delivery bridge)
fusion_plating_bridge_mrp/ — MRP integration (recipe→WO, portal job, work order priorities)
fusion_plating_bridge_sign/ — Digital signatures
fusion_plating_bridge_quality/ — Quality bridge
fusion_plating_bridge_documents/ — Odoo Documents integration (NCR, CAPA, FAIR, Doc Control)
fusion_plating_process_en/ — Electroless nickel process pack
fusion_plating_process_chrome/ — Chrome process pack
fusion_plating_process_anodize/ — Anodizing process pack
@@ -32,6 +37,32 @@ fusion_plating_process_black_oxide/ — Black oxide process pack
fusion_tasks/ — Local delivery dispatch (GPS, maps, driver scheduling)
```
## Menu Structure (Plating App)
The Plating app (`menu_fp_root`, seq 46) has these top-level menus:
| Seq | Menu | Module | Children |
|-----|------|--------|----------|
| 3 | KPIs | fusion_plating_kpi | KPIs, KPI History, Production/Quality/Finance dashboards |
| 5 | Sales | fusion_plating_configurator + portal | Quotations, Sale Orders, Customers, Part Catalog, Quote Requests, Portal Jobs |
| 8 | Configurator | fusion_plating_configurator | New Quote, Coating Configs, Pricing Rules, Treatments |
| 12 | Shop Floor | fusion_plating_shopfloor | Plant Overview, Tablet Station, Bake Windows, First-Piece Gates |
| 15 | Receiving | fusion_plating_receiving | All Receiving, Pending Inspection, Discrepancies |
| 18 | Operations | fusion_plating (core) | Process Recipes, Production Priorities (bridge_mrp), Batches (batch), Baths, Chemistry Logs, Tanks |
| 25 | Certificates | fusion_plating_certificates | All, CoC, Thickness Reports |
| 30 | Quality | fusion_plating_quality | Holds, NCRs, CAPAs, FAIR, Audits, Doc Control |
| 40 | Compliance | fusion_plating_compliance | Permits, Discharge, Waste, Calendar, Spills, Config |
| 45 | Safety | fusion_plating_safety | SDS, Training, Exposure, JHSC, Incidents, PPE |
| 50 | Logistics | fusion_plating_logistics + fusion_tasks | Pickups, Deliveries, Routes, CoC, POD, Field Tasks, Task Map, Task Calendar |
| 60 | Aerospace | fusion_plating_aerospace | AS9100, Nadcap, Counterfeit, Config Items, Risk |
| 65 | Nuclear | fusion_plating_nuclear | Program, ITP, 10CFR21, Pedigree, CNSC |
| 70 | CGP | fusion_plating_cgp | Registration, AI, PSA, Visitors, Goods, Shipments, Security, Access Log |
| 80 | Culture | fusion_plating_culture | Values, Recognitions |
| 90 | Configuration | fusion_plating (core) + many | Facilities, Work Centres, Process Categories/Types, Bath Params, Stations, Ovens, Invoice Strategy, Account Holds, Training Types, Chemicals, Notification Templates/Log, Calibration, Specs, AVL, Value Sets/Rotations, N299 Levels, Vehicles |
**Field Service** (`fusion_tasks`) also has its own standalone root app (seq 45) with Map View, Tasks, Calendar, Configuration. The same task actions are also accessible under Plating > Logistics.
**Key rule**: Sales menu is unified in `fusion_plating_configurator`. Portal module adds Quote Requests + Portal Jobs as children (referencing `fusion_plating_configurator.menu_fp_sales`). Do NOT create a separate Sales menu in portal.
## Critical Rules — Odoo 19
1. **NEVER code from memory** — Read reference files from the server first.
2. **Backend OWL**: `static template`, `static props = ["*"]`, standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`.
@@ -297,6 +328,8 @@ Project: `nexasystems` (id: `ikvdlqkbqsitabxidvnq`)
| `fusion.plating.ncr` | `fusion_plating_quality` | Non-conformance reports |
| `fusion.plating.capa` | `fusion_plating_quality` | Corrective actions |
| `fusion.plating.batch` | `fusion_plating_batch` | Rack/barrel batch tracking |
| `fusion.plating.kpi` | `fusion_plating_kpi` | KPI definition (OTD, yield, throughput, etc.) |
| `fusion.plating.kpi.value` | `fusion_plating_kpi` | KPI daily value (auto-computed or manual) |
| `fusion.plating.delivery` | `fusion_plating_logistics` | Delivery with chain of custody |
| `fusion.plating.pickup.request` | `fusion_plating_logistics` | Customer pickup requests |
| `fusion.plating.route` | `fusion_plating_logistics` | Driver routes with stops |

View File

@@ -17,7 +17,7 @@
<menuitem id="menu_fp_operations"
name="Operations"
parent="menu_fp_root"
sequence="10"/>
sequence="18"/>
<menuitem id="menu_fp_process_recipes"
name="Process Recipes"

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import models

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Maintenance Bridge',
'version': '19.0.1.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge standard Odoo Maintenance with Fusion Plating equipment, '
'plans, checklists, and sensor integration.',
'description': """
Fusion Plating — Maintenance Bridge
====================================
Extends Odoo's standard Maintenance module for electroless nickel plating
and metal finishing operations. Replaces Steelhead Software CMMS.
* Maintenance plans (templates linked to equipment categories)
* Checklist nodes (individual items within a plan)
* Labour cost tracking on maintenance events
* "From last maintenance" recurrence mode
* Equipment linked to Fusion Plating tanks and facilities
* Optional sensor measurement bridge (soft dependency)
Part of the Fusion Plating product family by Nexa Systems Inc.
Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'license': 'OPL-1',
'price': 0.00,
'currency': 'CAD',
'depends': [
'fusion_plating',
'maintenance',
],
'data': [
'security/fp_maintenance_security.xml',
'security/ir.model.access.csv',
'data/fp_maintenance_stage_data.xml',
'data/fp_maintenance_sequence_data.xml',
'data/fp_equipment_category_data.xml',
'views/fp_maintenance_plan_views.xml',
'views/fp_maintenance_node_views.xml',
'views/maintenance_request_views.xml',
'views/maintenance_equipment_views.xml',
'views/fp_maintenance_dashboard_views.xml',
'views/fp_maintenance_menu.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Seed equipment categories from Steelhead -->
<record id="equip_cat_al_tanks" model="maintenance.equipment.category">
<field name="name">AL Tanks</field>
</record>
<record id="equip_cat_specialty_tanks" model="maintenance.equipment.category">
<field name="name">Specialty Tanks</field>
</record>
<record id="equip_cat_steel_tanks" model="maintenance.equipment.category">
<field name="name">Steel Tanks</field>
</record>
<record id="equip_cat_waste_water" model="maintenance.equipment.category">
<field name="name">Waste Water</field>
</record>
<record id="equip_cat_waste_water_treatment" model="maintenance.equipment.category">
<field name="name">Waste Water Treatment</field>
</record>
<record id="equip_cat_common" model="maintenance.equipment.category">
<field name="name">common</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_fp_maintenance_plan" model="ir.sequence">
<field name="name">Maintenance Plan</field>
<field name="code">fp.maintenance.plan</field>
<field name="prefix">MPLAN/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- Override standard stages to match Steelhead lifecycle -->
<record id="maintenance.stage_0" model="maintenance.stage">
<field name="name">New</field>
<field name="sequence" eval="1"/>
<field name="fold" eval="False"/>
<field name="done" eval="False"/>
</record>
<record id="stage_active" model="maintenance.stage">
<field name="name">Active</field>
<field name="sequence" eval="2"/>
<field name="fold" eval="False"/>
<field name="done" eval="False"/>
</record>
<record id="stage_completed" model="maintenance.stage">
<field name="name">Completed</field>
<field name="sequence" eval="3"/>
<field name="fold" eval="True"/>
<field name="done" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_maintenance_plan
from . import fp_maintenance_node
from . import fp_maintenance_label
from . import maintenance_request
from . import maintenance_equipment

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class FpMaintenanceLabel(models.Model):
"""Simple tag model for equipment labels."""
_name = 'fp.maintenance.label'
_description = 'Fusion Plating — Equipment Label'
_order = 'name'
name = fields.Char(string='Name', required=True)
color = fields.Integer(string='Colour')
_sql_constraints = [
('name_uniq', 'unique(name)', 'Label name must be unique.'),
]

View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FpMaintenanceNode(models.Model):
"""Maintenance checklist item.
Individual task or check within a maintenance plan.
Auto-numbered on creation.
"""
_name = 'fp.maintenance.node'
_description = 'Fusion Plating — Maintenance Node'
_order = 'number desc'
name = fields.Char(
string='Name',
required=True,
)
number = fields.Integer(
string='Number',
readonly=True,
copy=False,
)
plan_id = fields.Many2one(
'fp.maintenance.plan',
string='Plan',
ondelete='set null',
)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('number'):
last = self.sudo().search([], order='number desc', limit=1)
vals['number'] = (last.number if last else 0) + 1
return super().create(vals_list)

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FpMaintenancePlan(models.Model):
"""Maintenance plan template.
Groups checklist nodes and links to an equipment category.
Plans are selected when creating maintenance events.
"""
_name = 'fp.maintenance.plan'
_description = 'Fusion Plating — Maintenance Plan'
_inherit = ['mail.thread']
_order = 'name'
name = fields.Char(
string='Name',
required=True,
tracking=True,
help='e.g. "Tank A-10 Nickel Nichem HP 1170 - Daily Titration"',
)
equipment_category_id = fields.Many2one(
'maintenance.equipment.category',
string='Equipment Type',
ondelete='set null',
tracking=True,
)
description = fields.Html(string='Description')
default_assignee_id = fields.Many2one(
'res.users',
string='Default Assignee',
)
node_ids = fields.One2many(
'fp.maintenance.node',
'plan_id',
string='Checklist Items',
)
node_count = fields.Integer(
string='Items',
compute='_compute_node_count',
)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
def _compute_node_count(self):
for plan in self:
plan.node_count = len(plan.node_ids)
def action_view_nodes(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Items — {self.name}',
'res_model': 'fp.maintenance.node',
'view_mode': 'list,form',
'domain': [('plan_id', '=', self.id)],
'context': {'default_plan_id': self.id},
}

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class MaintenanceEquipment(models.Model):
"""Extend standard maintenance.equipment with plating links."""
_inherit = 'maintenance.equipment'
x_fc_tank_id = fields.Many2one(
'fusion.plating.tank',
string='Plating Tank',
help='Link this equipment to a Fusion Plating tank.',
)
x_fc_facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
)
x_fc_location_name = fields.Char(
string='Sub-Location',
help='e.g. "PLANT1.BoilerRoom", "PLANT1.TankLine"',
)
x_fc_label_ids = fields.Many2many(
'fp.maintenance.label',
'fp_maintenance_equipment_label_rel',
'equipment_id',
'label_id',
string='Labels',
)

View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from datetime import timedelta
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class MaintenanceRequest(models.Model):
"""Extend standard maintenance.request with plating-specific fields."""
_inherit = 'maintenance.request'
x_fc_plan_id = fields.Many2one(
'fp.maintenance.plan',
string='Plan',
)
x_fc_node_id = fields.Many2one(
'fp.maintenance.node',
string='Checklist Item',
)
x_fc_labour_cost = fields.Monetary(
string='Labour Cost',
currency_field='x_fc_currency_id',
)
x_fc_currency_id = fields.Many2one(
'res.currency',
string='Currency',
default=lambda self: self.env.company.currency_id,
)
x_fc_completed_at = fields.Datetime(
string='Completed At',
readonly=True,
)
x_fc_from_last = fields.Boolean(
string='From Last Maintenance',
help='When checked, the next recurrence is scheduled relative to '
'completion date instead of a fixed calendar interval.',
)
x_fc_recurrence_days = fields.Integer(
string='Recurrence Days',
help='Number of days after completion to schedule the next event '
'(only used with "From Last Maintenance").',
)
def write(self, vals):
res = super().write(vals)
if 'stage_id' in vals:
for request in self:
if request.stage_id.done and not request.x_fc_completed_at:
request.x_fc_completed_at = fields.Datetime.now()
self._maybe_schedule_from_last(request)
elif not request.stage_id.done:
request.x_fc_completed_at = False
return res
def _maybe_schedule_from_last(self, request):
"""Schedule next maintenance from completion date."""
if not request.x_fc_from_last or not request.x_fc_recurrence_days:
return
next_date = fields.Datetime.now() + timedelta(
days=request.x_fc_recurrence_days,
)
request.copy({
'schedule_date': next_date,
'x_fc_completed_at': False,
'stage_id': self.env['maintenance.stage'].search(
[('done', '=', False)], order='sequence', limit=1,
).id,
})
_logger.info(
'Scheduled next from-last maintenance for %s on %s',
request.name, next_date,
)

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="fp_maintenance_plan_company_rule" model="ir.rule">
<field name="name">Maintenance Plan: multi-company</field>
<field name="model_id" ref="model_fp_maintenance_plan"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="fp_maintenance_node_company_rule" model="ir.rule">
<field name="name">Maintenance Node: multi-company</field>
<field name="model_id" ref="model_fp_maintenance_node"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,10 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_maintenance_plan_operator,fp.maintenance.plan.operator,model_fp_maintenance_plan,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_maintenance_plan_supervisor,fp.maintenance.plan.supervisor,model_fp_maintenance_plan,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_maintenance_plan_manager,fp.maintenance.plan.manager,model_fp_maintenance_plan,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_maintenance_node_operator,fp.maintenance.node.operator,model_fp_maintenance_node,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_maintenance_node_supervisor,fp.maintenance.node.supervisor,model_fp_maintenance_node,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_maintenance_node_manager,fp.maintenance.node.manager,model_fp_maintenance_node,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_maintenance_label_operator,fp.maintenance.label.operator,model_fp_maintenance_label,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_maintenance_label_supervisor,fp.maintenance.label.supervisor,model_fp_maintenance_label,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_maintenance_label_manager,fp.maintenance.label.manager,model_fp_maintenance_label,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_maintenance_plan_operator fp.maintenance.plan.operator model_fp_maintenance_plan fusion_plating.group_fusion_plating_operator 1 0 0 0
3 access_fp_maintenance_plan_supervisor fp.maintenance.plan.supervisor model_fp_maintenance_plan fusion_plating.group_fusion_plating_supervisor 1 1 1 0
4 access_fp_maintenance_plan_manager fp.maintenance.plan.manager model_fp_maintenance_plan fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_maintenance_node_operator fp.maintenance.node.operator model_fp_maintenance_node fusion_plating.group_fusion_plating_operator 1 0 0 0
6 access_fp_maintenance_node_supervisor fp.maintenance.node.supervisor model_fp_maintenance_node fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_maintenance_node_manager fp.maintenance.node.manager model_fp_maintenance_node fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_maintenance_label_operator fp.maintenance.label.operator model_fp_maintenance_label fusion_plating.group_fusion_plating_operator 1 0 0 0
9 access_fp_maintenance_label_supervisor fp.maintenance.label.supervisor model_fp_maintenance_label fusion_plating.group_fusion_plating_supervisor 1 1 1 0
10 access_fp_maintenance_label_manager fp.maintenance.label.manager model_fp_maintenance_label fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Dashboard: Active Events -->
<record id="action_fp_maintenance_active" model="ir.actions.act_window">
<field name="name">Active Events</field>
<field name="res_model">maintenance.request</field>
<field name="view_mode">list,kanban,form,calendar</field>
<field name="domain">[('archive', '=', False), ('stage_id.done', '=', False)]</field>
<field name="context">{'search_default_group_stage': 1}</field>
</record>
<!-- Dashboard: Completed Events -->
<record id="action_fp_maintenance_completed" model="ir.actions.act_window">
<field name="name">Completed Events</field>
<field name="res_model">maintenance.request</field>
<field name="view_mode">list,form</field>
<field name="domain">[('stage_id.done', '=', True)]</field>
<field name="context">{}</field>
</record>
<!-- Dashboard: All Events -->
<record id="action_fp_maintenance_all" model="ir.actions.act_window">
<field name="name">All Events</field>
<field name="res_model">maintenance.request</field>
<field name="view_mode">list,kanban,form,calendar</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a maintenance event
</p>
</field>
</record>
<!-- Dashboard: Equipment -->
<record id="action_fp_maintenance_equipment" model="ir.actions.act_window">
<field name="name">Equipment</field>
<field name="res_model">maintenance.equipment</field>
<field name="view_mode">list,kanban,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Maintenance parent menu under Plating root ===== -->
<menuitem id="menu_fp_maintenance"
name="Maintenance"
parent="fusion_plating.menu_fp_root"
sequence="22"
groups="fusion_plating.group_fusion_plating_operator"/>
<menuitem id="menu_fp_maintenance_active"
name="Active Events"
parent="menu_fp_maintenance"
action="action_fp_maintenance_active"
sequence="5"/>
<menuitem id="menu_fp_maintenance_plans"
name="Plans"
parent="menu_fp_maintenance"
action="action_fp_maintenance_plan"
sequence="10"/>
<menuitem id="menu_fp_maintenance_nodes"
name="Checklist Items"
parent="menu_fp_maintenance"
action="action_fp_maintenance_node"
sequence="20"/>
<menuitem id="menu_fp_maintenance_all"
name="All Events"
parent="menu_fp_maintenance"
action="action_fp_maintenance_all"
sequence="30"/>
<menuitem id="menu_fp_maintenance_completed"
name="Completed Events"
parent="menu_fp_maintenance"
action="action_fp_maintenance_completed"
sequence="35"/>
<menuitem id="menu_fp_maintenance_equipment"
name="Equipment"
parent="menu_fp_maintenance"
action="action_fp_maintenance_equipment"
sequence="40"/>
</odoo>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Node List ===== -->
<record id="view_fp_maintenance_node_list" model="ir.ui.view">
<field name="name">fp.maintenance.node.list</field>
<field name="model">fp.maintenance.node</field>
<field name="arch" type="xml">
<list string="Checklist Items" default_order="number desc">
<field name="name"/>
<field name="number"/>
<field name="plan_id"/>
</list>
</field>
</record>
<!-- ===== Node Form ===== -->
<record id="view_fp_maintenance_node_form" model="ir.ui.view">
<field name="name">fp.maintenance.node.form</field>
<field name="model">fp.maintenance.node</field>
<field name="arch" type="xml">
<form string="Checklist Item">
<sheet>
<group>
<group>
<field name="name"/>
<field name="number" readonly="1"/>
</group>
<group>
<field name="plan_id"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_maintenance_node" model="ir.actions.act_window">
<field name="name">Checklist Items</field>
<field name="res_model">fp.maintenance.node</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a checklist item
</p>
<p>Checklist items are individual tasks within a maintenance plan.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Plan Form ===== -->
<record id="view_fp_maintenance_plan_form" model="ir.ui.view">
<field name="name">fp.maintenance.plan.form</field>
<field name="model">fp.maintenance.plan</field>
<field name="arch" type="xml">
<form string="Maintenance Plan">
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" icon="fa-list-ol"
type="object" name="action_view_nodes"
invisible="node_count == 0">
<field name="node_count" widget="statinfo" string="Items"/>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" placeholder="e.g. Tank A-10 Daily Titration"/>
</h1>
</div>
<group>
<group>
<field name="equipment_category_id" string="Equipment Type"/>
<field name="default_assignee_id"/>
</group>
<group>
<field name="active" invisible="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Description" name="description">
<field name="description"/>
</page>
<page string="Checklist Items" name="nodes">
<field name="node_ids">
<list editable="bottom">
<field name="number" readonly="1"/>
<field name="name"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Plan List ===== -->
<record id="view_fp_maintenance_plan_list" model="ir.ui.view">
<field name="name">fp.maintenance.plan.list</field>
<field name="model">fp.maintenance.plan</field>
<field name="arch" type="xml">
<list string="Maintenance Plans" default_order="name">
<field name="name"/>
<field name="equipment_category_id" string="Equipment Type"/>
<field name="default_assignee_id"/>
<field name="node_count" string="Items"/>
</list>
</field>
</record>
<!-- ===== Plan Search ===== -->
<record id="view_fp_maintenance_plan_search" model="ir.ui.view">
<field name="name">fp.maintenance.plan.search</field>
<field name="model">fp.maintenance.plan</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="equipment_category_id"/>
<group>
<filter string="Equipment Type" name="group_category"
context="{'group_by': 'equipment_category_id'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_maintenance_plan" model="ir.actions.act_window">
<field name="name">Maintenance Plans</field>
<field name="res_model">fp.maintenance.plan</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_maintenance_plan_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a maintenance plan
</p>
<p>Plans are templates for recurring maintenance tasks linked to equipment types.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Extend maintenance.equipment form with plating links -->
<record id="view_maintenance_equipment_form_fp" model="ir.ui.view">
<field name="name">maintenance.equipment.form.fp.bridge</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='category_id']" position="after">
<field name="x_fc_tank_id"/>
<field name="x_fc_facility_id"/>
<field name="x_fc_location_name"
placeholder="e.g. PLANT1.TankLine"/>
<field name="x_fc_label_ids" widget="many2many_tags"
options="{'color_field': 'color'}"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Extend maintenance.request form with plating fields -->
<record id="view_maintenance_request_form_fp" model="ir.ui.view">
<field name="name">maintenance.request.form.fp.bridge</field>
<field name="model">maintenance.request</field>
<field name="inherit_id" ref="maintenance.hr_equipment_request_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='equipment_id'][not(ancestor::kanban)]" position="after">
<field name="x_fc_plan_id"/>
<field name="x_fc_node_id"/>
</xpath>
<xpath expr="//field[@name='schedule_end']" position="after">
<field name="x_fc_completed_at" readonly="1"/>
<field name="x_fc_labour_cost"/>
<field name="x_fc_currency_id" invisible="1"/>
<field name="x_fc_from_last"/>
<field name="x_fc_recurrence_days"
invisible="not x_fc_from_last"/>
</xpath>
</field>
</record>
<!-- Extend maintenance.request list with plating columns -->
<record id="view_maintenance_request_list_fp" model="ir.ui.view">
<field name="name">maintenance.request.list.fp.bridge</field>
<field name="model">maintenance.request</field>
<field name="inherit_id" ref="maintenance.hr_equipment_request_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='stage_id']" position="before">
<field name="x_fc_plan_id" optional="show"/>
<field name="x_fc_node_id" optional="show"/>
</xpath>
<xpath expr="//field[@name='stage_id']" position="after">
<field name="x_fc_completed_at" optional="show"/>
<field name="x_fc_labour_cost" optional="hide"/>
<field name="x_fc_currency_id" column_invisible="1"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -53,6 +53,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/mrp_production_views.xml',
'views/fp_quality_hold_views.xml',
'views/fp_batch_views.xml',
'views/fp_workorder_priority_views.xml',
],
'installable': True,
'application': False,

View File

@@ -91,4 +91,12 @@
(0, 0, {'view_mode': 'list', 'view_id': ref('view_mrp_workorder_fp_list')})]"/>
</record>
<!-- Menu: Production Priorities under Operations -->
<menuitem id="menu_fp_workorder_priority"
name="Production Priorities"
parent="fusion_plating.menu_fp_operations"
action="action_fp_workorder_priority"
sequence="10"
groups="fusion_plating.group_fusion_plating_supervisor"/>
</odoo>

View File

@@ -20,7 +20,7 @@
<menuitem id="menu_fp_certificates"
name="Certificates"
parent="fusion_plating.menu_fp_root"
sequence="15"
sequence="25"
groups="fusion_plating.group_fusion_plating_supervisor"/>
<menuitem id="menu_fp_certificates_all"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_fp_compliance_root" name="Compliance" parent="fusion_plating.menu_fp_root" sequence="30"/>
<menuitem id="menu_fp_compliance_root" name="Compliance" parent="fusion_plating.menu_fp_root" sequence="40"/>
<menuitem id="menu_fp_compliance_permit" name="Permits" parent="menu_fp_compliance_root" action="action_fp_permit" sequence="10"/>
<menuitem id="menu_fp_compliance_discharge_sample" name="Discharge Samples" parent="menu_fp_compliance_root" action="action_fp_discharge_sample" sequence="20"/>

View File

@@ -46,15 +46,13 @@ Provides:
'views/sale_order_views.xml',
'views/fp_configurator_menu.xml',
],
# 3D viewer assets temporarily disabled — causes 'registry already declared'
# error in Odoo 19 asset bundler. Needs investigation.
# 'assets': {
# 'web.assets_backend': [
# 'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
# 'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
# 'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
# ],
# },
'assets': {
'web.assets_backend': [
'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
],
},
'installable': True,
'application': False,
'auto_install': False,

View File

@@ -15,6 +15,55 @@ _logger = logging.getLogger(__name__)
class FpConfiguratorController(http.Controller):
@http.route('/fp/3d-viewer', type='http', auth='user', website=False)
def viewer_3d(self, **kw):
"""Serve the standalone 3D viewer HTML page.
Query params: id (attachment ID), name (filename for format detection).
The HTML page loads Online3DViewer and renders the model.
"""
from odoo.modules.module import get_module_path
import os
mod_path = get_module_path('fusion_plating_configurator')
html_path = os.path.join(
mod_path, 'static', 'src', 'html', '3d_viewer.html',
)
with open(html_path, 'r', encoding='utf-8') as f:
content = f.read()
return request.make_response(content, headers=[
('Content-Type', 'text/html; charset=utf-8'),
])
@http.route('/fp/3d-model/<int:attachment_id>/<string:filename>',
type='http', auth='user', website=False)
def serve_3d_model(self, attachment_id, filename, **kw):
"""Serve a 3D model file from ir.attachment.
This bypasses the /web/content auth issues when loading inside
an iframe. The filename in the URL ensures Online3DViewer can
detect the format from the extension.
"""
attachment = request.env['ir.attachment'].browse(attachment_id)
if not attachment.exists():
return request.not_found()
raw = base64.b64decode(attachment.datas)
# Map common CAD extensions to MIME types
mime_map = {
'.step': 'application/step', '.stp': 'application/step',
'.iges': 'application/iges', '.igs': 'application/iges',
'.stl': 'application/sla',
'.brep': 'application/octet-stream', '.brp': 'application/octet-stream',
'.obj': 'text/plain', '.gltf': 'model/gltf+json', '.glb': 'model/gltf-binary',
}
import os
ext = os.path.splitext(filename)[1].lower()
content_type = mime_map.get(ext, 'application/octet-stream')
return request.make_response(raw, headers=[
('Content-Type', content_type),
('Content-Disposition', f'inline; filename="{filename}"'),
('Content-Length', str(len(raw))),
])
@http.route('/fp/configurator/calculate_surface_area', type='jsonrpc', auth='user')
def calculate_surface_area(self, attachment_id, **kw):
"""Calculate surface area from an uploaded STL file using trimesh."""

View File

@@ -59,43 +59,190 @@ class FpPartCatalog(models.Model):
notes = fields.Html(string='Notes')
active = fields.Boolean(string='Active', default=True)
sale_order_count = fields.Integer(
string='Sale Orders', compute='_compute_sale_order_count',
)
configurator_count = fields.Integer(
string='Quotes', compute='_compute_configurator_count',
)
_sql_constraints = [
('fp_part_catalog_partner_partnum_uniq', 'unique(partner_id, part_number)',
'Part number must be unique per customer.'),
]
def _compute_sale_order_count(self):
for part in self:
part.sale_order_count = self.env['sale.order'].search_count(
[('x_fc_part_catalog_id', '=', part.id)])
def _compute_configurator_count(self):
for part in self:
part.configurator_count = self.env['fp.quote.configurator'].search_count(
[('part_catalog_id', '=', part.id)])
def action_view_sale_orders(self):
self.ensure_one()
orders = self.env['sale.order'].search([('x_fc_part_catalog_id', '=', self.id)])
if len(orders) == 1:
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': orders.id,
'view_mode': 'form',
'target': 'current',
}
return {
'type': 'ir.actions.act_window',
'name': _('Sale Orders'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('x_fc_part_catalog_id', '=', self.id)],
'target': 'current',
}
def action_view_configurators(self):
self.ensure_one()
cfgs = self.env['fp.quote.configurator'].search([('part_catalog_id', '=', self.id)])
if len(cfgs) == 1:
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.quote.configurator',
'res_id': cfgs.id,
'view_mode': 'form',
'target': 'current',
}
return {
'type': 'ir.actions.act_window',
'name': _('Configurator Quotes'),
'res_model': 'fp.quote.configurator',
'view_mode': 'list,form',
'domain': [('part_catalog_id', '=', self.id)],
'target': 'current',
}
@api.onchange('model_attachment_id')
def _onchange_model_attachment_id(self):
"""Auto-calculate surface area when a 3D model is attached."""
if self.model_attachment_id:
self._compute_surface_area_from_model()
def action_calculate_surface_area(self):
"""Calculate surface area from the uploaded 3D model file."""
"""Button: calculate surface area from the uploaded 3D model file."""
self.ensure_one()
if not self.model_attachment_id:
from odoo.exceptions import UserError
raise UserError(_('No 3D model file uploaded.'))
try:
import trimesh
except ImportError:
result = self._compute_surface_area_from_model()
if result.get('error'):
from odoo.exceptions import UserError
raise UserError(_('trimesh library not installed on the server. Contact your administrator.'))
import base64
import io
raw = base64.b64decode(self.model_attachment_id.datas)
mesh = trimesh.load(io.BytesIO(raw), file_type='stl')
area_mm2 = mesh.area
area_sqin = area_mm2 / 645.16
self.surface_area = round(area_sqin, 4)
self.surface_area_uom = 'sq_in'
self.geometry_source = '3d_model'
raise UserError(result['error'])
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Surface Area Calculated'),
'message': _('%.4f sq in (%.2f mm\u00b2) from %d faces') % (area_sqin, area_mm2, len(mesh.faces)),
'message': result.get('message', 'Done'),
'type': 'success',
'sticky': False,
},
}
def _compute_surface_area_from_model(self):
"""Calculate surface area from the 3D model attachment.
Uses OCC (OpenCASCADE) for STEP/IGES/BREP files (exact B-Rep area).
Falls back to trimesh for STL files (mesh-based area).
Returns dict with result or error.
"""
self.ensure_one()
if not self.model_attachment_id:
return {'error': 'No 3D model file attached.'}
import base64
import tempfile
import os
import logging
_logger = logging.getLogger(__name__)
raw = base64.b64decode(self.model_attachment_id.datas)
fname = (self.model_attachment_id.name or '').lower()
ext = os.path.splitext(fname)[1]
area_mm2 = 0.0
volume_mm3 = 0.0
bbox_dims = None
method = 'unknown'
if ext in ('.step', '.stp', '.iges', '.igs', '.brep', '.brp'):
# OCC (OpenCASCADE) for CAD formats -- exact B-Rep area
try:
from OCP.STEPControl import STEPControl_Reader
from OCP.IGESControl import IGESControl_Reader
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
tmp.write(raw)
tmp_path = tmp.name
try:
if ext in ('.step', '.stp'):
reader = STEPControl_Reader()
else:
reader = IGESControl_Reader()
reader.ReadFile(tmp_path)
reader.TransferRoots()
shape = reader.OneShape()
props = GProp_GProps()
BRepGProp.SurfaceProperties_s(shape, props)
area_mm2 = props.Mass()
vol_props = GProp_GProps()
BRepGProp.VolumeProperties_s(shape, vol_props)
volume_mm3 = vol_props.Mass()
bbox = Bnd_Box()
BRepBndLib.Add_s(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
bbox_dims = (xmax - xmin, ymax - ymin, zmax - zmin)
method = 'occ_brep'
finally:
os.unlink(tmp_path)
except ImportError:
return {'error': 'OCC (cadquery) not installed. Cannot process STEP/IGES files.'}
except Exception as e:
_logger.warning('OCC surface area calculation failed: %s', e)
return {'error': f'OCC error: {e}'}
elif ext == '.stl':
# trimesh for STL files
try:
import trimesh
import io
mesh = trimesh.load(io.BytesIO(raw), file_type='stl')
area_mm2 = mesh.area
volume_mm3 = mesh.volume
bbox_dims = tuple(float(x) for x in mesh.bounding_box.extents)
method = 'trimesh_mesh'
except ImportError:
return {'error': 'trimesh not installed. Cannot process STL files.'}
except Exception as e:
_logger.warning('trimesh surface area calculation failed: %s', e)
return {'error': f'trimesh error: {e}'}
else:
return {'error': f'Unsupported file format: {ext}'}
area_sqin = area_mm2 / 645.16
self.surface_area = round(area_sqin, 4)
self.surface_area_uom = 'sq_in'
self.geometry_source = '3d_model'
msg = '%.4f sq in (%.2f mm\u00b2) via %s' % (area_sqin, area_mm2, method)
_logger.info('Part %s: surface area = %s', self.name, msg)
return {'message': msg, 'area_sqin': area_sqin, 'area_mm2': area_mm2,
'volume_mm3': volume_mm3, 'bbox': bbox_dims}

View File

@@ -35,6 +35,25 @@ class FpQuoteConfigurator(models.Model):
domain="[('partner_id', '=', partner_id)]",
help="Select from this customer's part catalog, or leave blank for a one-off.",
)
model_attachment_id = fields.Many2one(
related='part_catalog_id.model_attachment_id',
string='3D Model',
readonly=True,
)
# -- Quick file upload (creates/updates part catalog automatically) --
upload_3d_file = fields.Binary(
string='Upload 3D File',
attachment=False,
help='Upload a STEP, IGES, or STL file. Auto-creates or updates the part catalog entry.',
)
upload_3d_filename = fields.Char(string='3D Filename')
upload_drawing = fields.Binary(
string='Upload Drawing',
attachment=False,
help='Upload a PDF drawing. Attaches to the part catalog entry.',
)
upload_drawing_filename = fields.Char(string='Drawing Filename')
coating_config_id = fields.Many2one(
'fp.coating.config', string='Coating Configuration', required=True,
)
@@ -350,5 +369,126 @@ class FpQuoteConfigurator(models.Model):
'target': 'current',
}
@api.onchange('upload_3d_file')
def _onchange_upload_3d_file(self):
"""When a 3D file is uploaded, auto-create/update part catalog entry."""
if not self.upload_3d_file or not self.partner_id:
return
import base64
import os
fname = self.upload_3d_filename or 'model.step'
raw = base64.b64decode(self.upload_3d_file)
# Create attachment
att = self.env['ir.attachment'].create({
'name': fname,
'datas': self.upload_3d_file,
'mimetype': 'application/octet-stream',
})
# Auto-create or update part catalog
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
if self.part_catalog_id:
# Update existing part
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
part = self.env['fp.part.catalog'].create({
'name': part_name,
'partner_id': self.partner_id.id,
'part_number': fname,
'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
# Clear the upload field (data is now on the part catalog)
self.upload_3d_file = False
self.upload_3d_filename = False
@api.onchange('upload_drawing')
def _onchange_upload_drawing(self):
"""When a drawing is uploaded, attach to part catalog entry."""
if not self.upload_drawing or not self.partner_id:
return
fname = self.upload_drawing_filename or 'drawing.pdf'
att = self.env['ir.attachment'].create({
'name': fname,
'datas': self.upload_drawing,
'mimetype': 'application/pdf',
})
if self.part_catalog_id:
self.part_catalog_id.drawing_attachment_ids = [(4, att.id)]
else:
import os
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
part = self.env['fp.part.catalog'].create({
'name': part_name,
'partner_id': self.partner_id.id,
'part_number': fname,
'drawing_attachment_ids': [(4, att.id)],
})
self.part_catalog_id = part.id
self.upload_drawing = False
self.upload_drawing_filename = False
def action_recalculate_price(self):
"""Recalculate surface area from 3D model and recompute price."""
self.ensure_one()
# 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
def action_cancel(self):
self.write({'state': 'cancelled'})
def action_reset_draft(self):
self.write({'state': 'draft'})
def action_open_3d_fullscreen(self):
"""Open the 3D model viewer in a new browser tab (full screen)."""
self.ensure_one()
att = self.model_attachment_id
if not att:
return
url = f'/fp/3d-viewer?id={att.id}&name={att.name}'
return {
'type': 'ir.actions.act_url',
'url': url,
'target': 'new',
}
def action_view_sale_order(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_view_part_catalog(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.part.catalog',
'res_id': self.part_catalog_id.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -1,411 +0,0 @@
import {
BufferAttribute,
BufferGeometry,
Color,
FileLoader,
Float32BufferAttribute,
Loader,
Vector3,
SRGBColorSpace
} from 'three';
/**
* Description: A THREE loader for STL ASCII files, as created by Solidworks and other CAD programs.
*
* Supports both binary and ASCII encoded files, with automatic detection of type.
*
* The loader returns a non-indexed buffer geometry.
*
* Limitations:
* Binary decoding supports "Magics" color format (http://en.wikipedia.org/wiki/STL_(file_format)#Color_in_binary_STL).
* There is perhaps some question as to how valid it is to always assume little-endian-ness.
* ASCII decoding assumes file is UTF-8.
*
* Usage:
* const loader = new STLLoader();
* loader.load( './models/stl/slotted_disk.stl', function ( geometry ) {
* scene.add( new THREE.Mesh( geometry ) );
* });
*
* For binary STLs geometry might contain colors for vertices. To use it:
* // use the same code to load STL as above
* if (geometry.hasColors) {
* material = new THREE.MeshPhongMaterial({ opacity: geometry.alpha, vertexColors: true });
* } else { .... }
* const mesh = new THREE.Mesh( geometry, material );
*
* For ASCII STLs containing multiple solids, each solid is assigned to a different group.
* Groups can be used to assign a different color by defining an array of materials with the same length of
* geometry.groups and passing it to the Mesh constructor:
*
* const mesh = new THREE.Mesh( geometry, material );
*
* For example:
*
* const materials = [];
* const nGeometryGroups = geometry.groups.length;
*
* const colorMap = ...; // Some logic to index colors.
*
* for (let i = 0; i < nGeometryGroups; i++) {
*
* const material = new THREE.MeshPhongMaterial({
* color: colorMap[i],
* wireframe: false
* });
*
* }
*
* materials.push(material);
* const mesh = new THREE.Mesh(geometry, materials);
*/
class STLLoader extends Loader {
constructor( manager ) {
super( manager );
}
load( url, onLoad, onProgress, onError ) {
const scope = this;
const loader = new FileLoader( this.manager );
loader.setPath( this.path );
loader.setResponseType( 'arraybuffer' );
loader.setRequestHeader( this.requestHeader );
loader.setWithCredentials( this.withCredentials );
loader.load( url, function ( text ) {
try {
onLoad( scope.parse( text ) );
} catch ( e ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
scope.manager.itemError( url );
}
}, onProgress, onError );
}
parse( data ) {
function isBinary( data ) {
const reader = new DataView( data );
const face_size = ( 32 / 8 * 3 ) + ( ( 32 / 8 * 3 ) * 3 ) + ( 16 / 8 );
const n_faces = reader.getUint32( 80, true );
const expect = 80 + ( 32 / 8 ) + ( n_faces * face_size );
if ( expect === reader.byteLength ) {
return true;
}
// An ASCII STL data must begin with 'solid ' as the first six bytes.
// However, ASCII STLs lacking the SPACE after the 'd' are known to be
// plentiful. So, check the first 5 bytes for 'solid'.
// Several encodings, such as UTF-8, precede the text with up to 5 bytes:
// https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding
// Search for "solid" to start anywhere after those prefixes.
// US-ASCII ordinal values for 's', 'o', 'l', 'i', 'd'
const solid = [ 115, 111, 108, 105, 100 ];
for ( let off = 0; off < 5; off ++ ) {
// If "solid" text is matched to the current offset, declare it to be an ASCII STL.
if ( matchDataViewAt( solid, reader, off ) ) return false;
}
// Couldn't find "solid" text at the beginning; it is binary STL.
return true;
}
function matchDataViewAt( query, reader, offset ) {
// Check if each byte in query matches the corresponding byte from the current offset
for ( let i = 0, il = query.length; i < il; i ++ ) {
if ( query[ i ] !== reader.getUint8( offset + i ) ) return false;
}
return true;
}
function parseBinary( data ) {
const reader = new DataView( data );
const faces = reader.getUint32( 80, true );
let r, g, b, hasColors = false, colors;
let defaultR, defaultG, defaultB, alpha;
// process STL header
// check for default color in header ("COLOR=rgba" sequence).
for ( let index = 0; index < 80 - 10; index ++ ) {
if ( ( reader.getUint32( index, false ) == 0x434F4C4F /*COLO*/ ) &&
( reader.getUint8( index + 4 ) == 0x52 /*'R'*/ ) &&
( reader.getUint8( index + 5 ) == 0x3D /*'='*/ ) ) {
hasColors = true;
colors = new Float32Array( faces * 3 * 3 );
defaultR = reader.getUint8( index + 6 ) / 255;
defaultG = reader.getUint8( index + 7 ) / 255;
defaultB = reader.getUint8( index + 8 ) / 255;
alpha = reader.getUint8( index + 9 ) / 255;
}
}
const dataOffset = 84;
const faceLength = 12 * 4 + 2;
const geometry = new BufferGeometry();
const vertices = new Float32Array( faces * 3 * 3 );
const normals = new Float32Array( faces * 3 * 3 );
const color = new Color();
for ( let face = 0; face < faces; face ++ ) {
const start = dataOffset + face * faceLength;
const normalX = reader.getFloat32( start, true );
const normalY = reader.getFloat32( start + 4, true );
const normalZ = reader.getFloat32( start + 8, true );
if ( hasColors ) {
const packedColor = reader.getUint16( start + 48, true );
if ( ( packedColor & 0x8000 ) === 0 ) {
// facet has its own unique color
r = ( packedColor & 0x1F ) / 31;
g = ( ( packedColor >> 5 ) & 0x1F ) / 31;
b = ( ( packedColor >> 10 ) & 0x1F ) / 31;
} else {
r = defaultR;
g = defaultG;
b = defaultB;
}
}
for ( let i = 1; i <= 3; i ++ ) {
const vertexstart = start + i * 12;
const componentIdx = ( face * 3 * 3 ) + ( ( i - 1 ) * 3 );
vertices[ componentIdx ] = reader.getFloat32( vertexstart, true );
vertices[ componentIdx + 1 ] = reader.getFloat32( vertexstart + 4, true );
vertices[ componentIdx + 2 ] = reader.getFloat32( vertexstart + 8, true );
normals[ componentIdx ] = normalX;
normals[ componentIdx + 1 ] = normalY;
normals[ componentIdx + 2 ] = normalZ;
if ( hasColors ) {
color.setRGB( r, g, b, SRGBColorSpace );
colors[ componentIdx ] = color.r;
colors[ componentIdx + 1 ] = color.g;
colors[ componentIdx + 2 ] = color.b;
}
}
}
geometry.setAttribute( 'position', new BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) );
if ( hasColors ) {
geometry.setAttribute( 'color', new BufferAttribute( colors, 3 ) );
geometry.hasColors = true;
geometry.alpha = alpha;
}
return geometry;
}
function parseASCII( data ) {
const geometry = new BufferGeometry();
const patternSolid = /solid([\s\S]*?)endsolid/g;
const patternFace = /facet([\s\S]*?)endfacet/g;
const patternName = /solid\s(.+)/;
let faceCounter = 0;
const patternFloat = /[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source;
const patternVertex = new RegExp( 'vertex' + patternFloat + patternFloat + patternFloat, 'g' );
const patternNormal = new RegExp( 'normal' + patternFloat + patternFloat + patternFloat, 'g' );
const vertices = [];
const normals = [];
const groupNames = [];
const normal = new Vector3();
let result;
let groupCount = 0;
let startVertex = 0;
let endVertex = 0;
while ( ( result = patternSolid.exec( data ) ) !== null ) {
startVertex = endVertex;
const solid = result[ 0 ];
const name = ( result = patternName.exec( solid ) ) !== null ? result[ 1 ] : '';
groupNames.push( name );
while ( ( result = patternFace.exec( solid ) ) !== null ) {
let vertexCountPerFace = 0;
let normalCountPerFace = 0;
const text = result[ 0 ];
while ( ( result = patternNormal.exec( text ) ) !== null ) {
normal.x = parseFloat( result[ 1 ] );
normal.y = parseFloat( result[ 2 ] );
normal.z = parseFloat( result[ 3 ] );
normalCountPerFace ++;
}
while ( ( result = patternVertex.exec( text ) ) !== null ) {
vertices.push( parseFloat( result[ 1 ] ), parseFloat( result[ 2 ] ), parseFloat( result[ 3 ] ) );
normals.push( normal.x, normal.y, normal.z );
vertexCountPerFace ++;
endVertex ++;
}
// every face have to own ONE valid normal
if ( normalCountPerFace !== 1 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the normal of face number ' + faceCounter );
}
// each face have to own THREE valid vertices
if ( vertexCountPerFace !== 3 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the vertices of face number ' + faceCounter );
}
faceCounter ++;
}
const start = startVertex;
const count = endVertex - startVertex;
geometry.userData.groupNames = groupNames;
geometry.addGroup( start, count, groupCount );
groupCount ++;
}
geometry.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
return geometry;
}
function ensureString( buffer ) {
if ( typeof buffer !== 'string' ) {
return new TextDecoder().decode( buffer );
}
return buffer;
}
function ensureBinary( buffer ) {
if ( typeof buffer === 'string' ) {
const array_buffer = new Uint8Array( buffer.length );
for ( let i = 0; i < buffer.length; i ++ ) {
array_buffer[ i ] = buffer.charCodeAt( i ) & 0xff; // implicitly assumes little-endian
}
return array_buffer.buffer || array_buffer;
} else {
return buffer;
}
}
// start
const binData = ensureBinary( data );
return isBinary( binData ) ? parseBinary( binData ) : parseASCII( ensureString( data ) );
}
}
export { STLLoader };

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -0,0 +1,13 @@
importScripts ('occt-import-js.js');
onmessage = async function (ev)
{
let modulOverrides = {
locateFile: function (path) {
return path;
}
};
let occt = await occtimportjs (modulOverrides);
let result = occt.ReadFile (ev.data.format, ev.data.buffer, ev.data.params);
postMessage (result);
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>3D Part Viewer</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;font-family:system-ui,-apple-system,sans-serif;background:#f0f2f5}
#viewer-container{width:100%;height:100%}
#loading{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:#6c757d;z-index:100}
#loading .spinner{width:44px;height:44px;border:3px solid #dee2e6;border-top-color:#0d6efd;border-radius:50%;animation:spin .8s linear infinite;margin:0 auto 12px}
@keyframes spin{to{transform:rotate(360deg)}}
#error{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff3cd;border:1px solid #ffc107;border-radius:8px;padding:20px 28px;color:#664d03;max-width:80%;text-align:center;font-size:13px;z-index:100;display:none}
#format-badge{position:absolute;top:10px;right:10px;font-size:11px;font-weight:600;padding:4px 10px;border-radius:4px;z-index:100;backdrop-filter:blur(4px)}
.fmt-step{background:rgba(33,150,243,.15);color:#1565c0}
.fmt-iges{background:rgba(156,39,176,.15);color:#7b1fa2}
.fmt-stl{background:rgba(76,175,80,.15);color:#2e7d32}
.fmt-brep{background:rgba(255,152,0,.15);color:#e65100}
.fmt-other{background:rgba(158,158,158,.15);color:#616161}
</style>
</head>
<body>
<div id="viewer-container"></div>
<div id="format-badge"></div>
<div id="loading"><div class="spinner"></div><div id="loading-msg">Loading 3D model...</div></div>
<div id="error"></div>
<script src="/fusion_plating_configurator/static/lib/o3dv/o3dv.min.js"></script>
<script>
(function() {
const container = document.getElementById('viewer-container');
const loadingEl = document.getElementById('loading');
const loadingMsg = document.getElementById('loading-msg');
const errorEl = document.getElementById('error');
const fmtBadge = document.getElementById('format-badge');
const params = new URLSearchParams(window.location.search);
const attachmentId = params.get('id');
const fileName = params.get('name') || 'model.stl';
function detectFormat(name) {
if (!name) return 'other';
const n = name.toLowerCase();
if (n.match(/\.(step|stp)$/)) return 'step';
if (n.match(/\.(iges|igs)$/)) return 'iges';
if (n.match(/\.(brep|brp)$/)) return 'brep';
if (n.match(/\.stl$/)) return 'stl';
if (n.match(/\.(obj)$/)) return 'other';
if (n.match(/\.(gltf|glb)$/)) return 'other';
if (n.match(/\.(3ds|fbx|dae|3mf|ply|off|wrl|3dm)$/)) return 'other';
return 'other';
}
function showFormat(fmt) {
fmtBadge.className = 'fmt-' + fmt;
fmtBadge.textContent = fmt.toUpperCase();
}
function showError(msg) {
loadingEl.style.display = 'none';
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
if (!attachmentId) {
showError('No model specified (missing ?id= parameter)');
return;
}
showFormat(detectFormat(fileName));
// Initialize the embedded viewer
// Note: v0.18.0 loads WASM (occt-import-js) from CDN automatically
const viewer = new OV.EmbeddedViewer(container, {
backgroundColor: new OV.RGBAColor(240, 242, 245, 255),
defaultColor: new OV.RGBColor(33, 150, 243),
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
});
// Fetch the file ourselves (with session credentials) then load as blob
loadingMsg.textContent = 'Downloading ' + fileName + '...';
const modelUrl = '/fp/3d-model/' + attachmentId + '/' + encodeURIComponent(fileName);
fetch(modelUrl, { credentials: 'same-origin' })
.then(function(resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + resp.statusText);
return resp.arrayBuffer();
})
.then(function(buffer) {
loadingMsg.textContent = 'Parsing ' + fileName + '...';
// Create a File object so O3DV can detect format from the name
var file = new File([buffer], fileName, { type: 'application/octet-stream' });
viewer.LoadModelFromFileList([file]);
// Poll for completion
var checkCount = 0;
var checkInterval = setInterval(function() {
checkCount++;
try {
var model = viewer.GetModel();
if (model && model.MeshCount() > 0) {
loadingEl.style.display = 'none';
clearInterval(checkInterval);
}
} catch(e) {}
if (checkCount > 600) {
clearInterval(checkInterval);
if (loadingEl.style.display !== 'none') {
showError('Timeout parsing model. STEP files may take a minute on first load (WASM engine init).');
}
}
}, 100);
})
.catch(function(err) {
showError('Failed to load model: ' + err.message);
});
})();
</script>
</body>
</html>

View File

@@ -1,98 +1,29 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating -- 3D STL Viewer (OWL field widget)
// Fusion Plating -- 3D CAD Viewer (iframe wrapper)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Renders STL files using Three.js inside an OWL field widget.
// Three.js (+ STLLoader + OrbitControls) are loaded lazily on first use
// via dynamic import() with a programmatic importmap so the vendored ESM
// addon files can resolve their bare `from 'three'` specifier.
//
// Registered as field widget `fp_3d_preview` for Many2one fields
// (ir.attachment).
// =============================================================================
// Simple OWL field widget that embeds the standalone 3D viewer page
// in an iframe. The viewer page uses Online3DViewer (o3dv) which
// supports STEP, IGES, BREP, STL, OBJ, glTF, and 20+ more formats.
import { Component, useRef, onMounted, onWillUnmount, useState } from "@odoo/owl";
import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
// ---------------------------------------------------------------------------
// Three.js lazy loader
// ---------------------------------------------------------------------------
let _threePromise = null;
/**
* Inject an importmap so `from 'three'` inside STLLoader / OrbitControls
* resolves to our vendored three.module.min.js. Then dynamically import
* all three files and return the combined namespace.
*/
async function loadThreeJs() {
if (_threePromise) return _threePromise;
_threePromise = (async () => {
// Inject importmap (idempotent -- only once)
if (!document.querySelector('script[type="importmap"][data-fp-three]')) {
const map = document.createElement("script");
map.type = "importmap";
map.setAttribute("data-fp-three", "1");
map.textContent = JSON.stringify({
imports: {
three: "/fusion_plating_configurator/static/lib/three.module.min.js",
},
});
document.head.appendChild(map);
}
// Dynamic imports -- browser resolves `from 'three'` via the importmap
const THREE = await import("/fusion_plating_configurator/static/lib/three.module.min.js");
const { STLLoader } = await import("/fusion_plating_configurator/static/lib/STLLoader.js");
const { OrbitControls } = await import("/fusion_plating_configurator/static/lib/OrbitControls.js");
// Attach for convenience
THREE.STLLoader = STLLoader;
THREE.OrbitControls = OrbitControls;
return THREE;
})();
return _threePromise;
}
// ---------------------------------------------------------------------------
// OWL Component
// ---------------------------------------------------------------------------
export class Fp3dViewer extends Component {
static template = "fusion_plating_configurator.Fp3dViewer";
static props = {
...standardFieldProps,
};
static props = { ...standardFieldProps };
setup() {
this.canvasRef = useRef("canvas3d");
this.state = useState({
loading: false,
error: null,
wireframe: false,
vertexCount: 0,
faceCount: 0,
hasAttachment: false,
});
this.scene = null;
this.camera = null;
this.renderer = null;
this.controls = null;
this.mesh = null;
this.animationId = null;
onMounted(() => this._onMounted());
onWillUnmount(() => this._cleanup());
this.state = useState({ hasAttachment: false, iframeSrc: "" });
this._updateState();
}
/** Return the raw value of the Many2one field (could be [id, name] or false). */
get rawValue() {
return this.props.record.data[this.props.name];
}
/** Return the attachment id (integer) or 0. */
get attachmentId() {
const v = this.rawValue;
if (!v) return 0;
@@ -101,190 +32,28 @@ export class Fp3dViewer extends Component {
return typeof v === "number" ? v : 0;
}
async _onMounted() {
get attachmentName() {
const v = this.rawValue;
if (!v) return "";
if (Array.isArray(v)) return v[1] || "";
if (typeof v === "object" && v.display_name) return v.display_name;
return "";
}
_updateState() {
const aid = this.attachmentId;
this.state.hasAttachment = !!aid;
if (!aid || !this.canvasRef.el) return;
await this._initViewer();
}
async _initViewer() {
this.state.loading = true;
this.state.error = null;
let THREE;
try {
THREE = await loadThreeJs();
} catch (e) {
// importmap injection may fail if the page already has one -- fall
// back to loading Three.js core alone and skip addons.
this.state.error = "Three.js failed to load: " + (e.message || e);
this.state.loading = false;
return;
}
const container = this.canvasRef.el;
const width = container.clientWidth || 500;
const height = 350;
// ---- Scene ----
this.scene = new THREE.Scene();
// Respect Odoo theme -- use a neutral slightly-warm grey
this.scene.background = new THREE.Color(0xf5f5f5);
// ---- Camera ----
this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
this.camera.position.set(0, 0, 100);
// ---- Renderer ----
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setPixelRatio(window.devicePixelRatio || 1);
this.renderer.setSize(width, height);
container.appendChild(this.renderer.domElement);
// ---- Lights ----
const ambient = new THREE.AmbientLight(0x808080, 1.5);
this.scene.add(ambient);
const dir1 = new THREE.DirectionalLight(0xffffff, 1.0);
dir1.position.set(1, 1, 1);
this.scene.add(dir1);
const dir2 = new THREE.DirectionalLight(0xffffff, 0.4);
dir2.position.set(-1, -0.5, -1);
this.scene.add(dir2);
// ---- Orbit controls ----
if (THREE.OrbitControls) {
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.12;
}
// ---- Load STL ----
try {
const url = `/web/content/${this.attachmentId}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const buffer = await response.arrayBuffer();
let geometry;
if (THREE.STLLoader) {
const loader = new THREE.STLLoader();
geometry = loader.parse(buffer);
} else {
// Fallback: parse binary STL manually
geometry = this._parseSTLBinary(THREE, buffer);
}
geometry.computeVertexNormals();
const material = new THREE.MeshPhongMaterial({
color: 0x1a8cff,
specular: 0x333333,
shininess: 120,
wireframe: false,
});
this.mesh = new THREE.Mesh(geometry, material);
// Centre and auto-scale to fit viewport
geometry.computeBoundingBox();
const box = geometry.boundingBox;
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 60 / (maxDim || 1);
this.mesh.geometry.translate(-center.x, -center.y, -center.z);
this.mesh.scale.set(scale, scale, scale);
this.scene.add(this.mesh);
this.state.vertexCount = geometry.attributes.position.count;
this.state.faceCount = Math.floor(geometry.attributes.position.count / 3);
this.state.loading = false;
this._animate();
} catch (e) {
this.state.error = "Failed to load STL: " + (e.message || e);
this.state.loading = false;
if (aid) {
const name = encodeURIComponent(this.attachmentName);
this.state.iframeSrc = `/fp/3d-viewer?id=${aid}&name=${name}`;
}
}
/**
* Minimal binary STL parser (fallback when STLLoader is unavailable).
* Binary STL: 80-byte header, 4-byte uint32 triangle count, then
* 50 bytes per triangle (12 floats for normal + 3 vertices, 2-byte attr).
*/
_parseSTLBinary(THREE, buffer) {
const dv = new DataView(buffer);
const triangles = dv.getUint32(80, true);
const positions = new Float32Array(triangles * 9);
const normals = new Float32Array(triangles * 9);
let offset = 84;
for (let i = 0; i < triangles; i++) {
const nx = dv.getFloat32(offset, true);
const ny = dv.getFloat32(offset + 4, true);
const nz = dv.getFloat32(offset + 8, true);
offset += 12;
for (let v = 0; v < 3; v++) {
const idx = i * 9 + v * 3;
positions[idx] = dv.getFloat32(offset, true);
positions[idx + 1] = dv.getFloat32(offset + 4, true);
positions[idx + 2] = dv.getFloat32(offset + 8, true);
normals[idx] = nx;
normals[idx + 1] = ny;
normals[idx + 2] = nz;
offset += 12;
}
offset += 2; // attribute byte count
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
return geo;
}
_animate() {
this.animationId = requestAnimationFrame(() => this._animate());
if (this.controls) this.controls.update();
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
}
toggleWireframe() {
if (!this.mesh) return;
this.state.wireframe = !this.state.wireframe;
this.mesh.material.wireframe = this.state.wireframe;
}
resetView() {
if (!this.camera) return;
this.camera.position.set(0, 0, 100);
this.camera.lookAt(0, 0, 0);
if (this.controls) this.controls.reset();
}
_cleanup() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.controls) {
this.controls.dispose();
this.controls = null;
}
if (this.renderer) {
this.renderer.dispose();
if (this.renderer.domElement && this.renderer.domElement.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
this.renderer = null;
}
this.scene = null;
this.camera = null;
this.mesh = null;
onPatched() {
this._updateState();
}
}
// Register as a field widget for Many2one (ir.attachment) fields
registry.category("fields").add("fp_3d_preview", {
component: Fp3dViewer,
supportedTypes: ["many2one"],

View File

@@ -1,62 +1,63 @@
// =============================================================================
// Fusion Plating -- 3D Viewer Widget Styles
// Fusion Plating -- 3D Viewer + Configurator Layout
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
// =============================================================================
// -- Configurator two-column layout: 3/4 fields + 1/4 preview --
.o_fp_cfg_layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: 16px;
align-items: start;
}
.o_fp_cfg_fields {
min-width: 0;
}
.o_fp_cfg_preview {
position: sticky;
top: 16px;
}
// Responsive: stack on narrow screens
@media (max-width: 1200px) {
.o_fp_cfg_layout {
grid-template-columns: 1fr;
}
.o_fp_cfg_preview {
position: static;
}
}
// -- 3D viewer widget --
.o_fp_3d_viewer_root {
width: 100%;
}
.o_fp_3d_placeholder {
border: 2px dashed $border-color;
border-radius: 0.375rem;
min-height: 120px;
border-radius: 0.5rem;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--bs-secondary-color);
background-color: var(--bs-tertiary-bg);
}
.o_fp_3d_toolbar {
.btn {
font-size: 0.8125rem;
padding: 0.2rem 0.5rem;
}
}
.o_fp_3d_canvas_container {
.o_fp_3d_iframe {
width: 100%;
height: 350px;
height: 500px;
border: 1px solid $border-color;
border-radius: 0.375rem;
overflow: hidden;
position: relative;
background-color: var(--bs-body-bg);
canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
border-radius: 0.5rem;
background-color: #f0f2f5;
display: block;
}
.o_fp_3d_loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
z-index: 10;
}
.o_fp_3d_error {
font-size: 0.875rem;
// Inside the preview column, make iframe taller
.o_fp_cfg_preview .o_fp_3d_iframe {
height: 600px;
}

View File

@@ -1,57 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_configurator.Fp3dViewer">
<div class="o_fp_3d_viewer_root">
<!-- No attachment uploaded yet -->
<t t-if="!state.hasAttachment">
<div class="o_fp_3d_placeholder text-center text-muted p-4">
<i class="fa fa-cube fa-3x mb-2 d-block"/>
<span>Upload a 3D model (STL) to preview it here.</span>
<span>Upload a 3D model (STL, STEP, IGES) to preview it here.</span>
</div>
</t>
<!-- Viewer -->
<t t-if="state.hasAttachment">
<!-- Toolbar -->
<div class="o_fp_3d_toolbar d-flex align-items-center gap-2 mb-1">
<button class="btn btn-sm btn-outline-secondary" t-on-click="toggleWireframe"
title="Toggle wireframe">
<i class="fa fa-th"/> <t t-if="state.wireframe">Solid</t><t t-else="">Wireframe</t>
</button>
<button class="btn btn-sm btn-outline-secondary" t-on-click="resetView"
title="Reset camera">
<i class="fa fa-crosshairs"/> Reset
</button>
<span class="ms-auto small text-muted" t-if="state.vertexCount">
<i class="fa fa-cubes"/>
<t t-esc="state.faceCount"/> faces
/
<t t-esc="state.vertexCount"/> verts
</span>
</div>
<!-- Canvas container -->
<div t-ref="canvas3d" class="o_fp_3d_canvas_container">
<!-- Three.js renderer appends here -->
</div>
<!-- Loading spinner -->
<div t-if="state.loading" class="o_fp_3d_loading text-center p-4">
<i class="fa fa-spinner fa-spin fa-2x"/>
<div class="mt-2">Loading 3D model...</div>
</div>
<!-- Error -->
<div t-if="state.error" class="o_fp_3d_error alert alert-warning mt-2 mb-0">
<i class="fa fa-exclamation-triangle"/>
<t t-esc="state.error"/>
</div>
<iframe t-att-src="state.iframeSrc"
class="o_fp_3d_iframe"
frameborder="0"
allowfullscreen="true"/>
</t>
</div>
</t>

View File

@@ -19,8 +19,8 @@
<menuitem id="menu_fp_sales"
name="Sales"
parent="fusion_plating.menu_fp_root"
sequence="1"
groups="group_fp_estimator"/>
sequence="5"
groups="group_fp_estimator,fusion_plating.group_fusion_plating_supervisor"/>
<menuitem id="menu_fp_quotations"
name="Quotations"
@@ -50,7 +50,7 @@
<menuitem id="menu_fp_configurator"
name="Configurator"
parent="fusion_plating.menu_fp_root"
sequence="2"
sequence="8"
groups="group_fp_estimator"/>
<menuitem id="menu_fp_new_quote"

View File

@@ -30,6 +30,22 @@
<field name="arch" type="xml">
<form string="Part Catalog">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_orders"
type="object"
class="oe_stat_button"
icon="fa-file-text-o"
invisible="sale_order_count == 0">
<field name="sale_order_count" widget="statinfo" string="Sale Orders"/>
</button>
<button name="action_view_configurators"
type="object"
class="oe_stat_button"
icon="fa-sliders"
invisible="configurator_count == 0">
<field name="configurator_count" widget="statinfo" string="Quotes"/>
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
@@ -78,20 +94,19 @@
</page>
<page string="Attachments" name="attachments">
<group>
<field name="model_attachment_id" widget="fp_3d_preview"/>
<field name="model_attachment_id"/>
<field name="drawing_attachment_ids" widget="many2many_binary"/>
</group>
<div invisible="not model_attachment_id" class="mt-3">
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
</div>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Additional notes about this part..."/>
</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>

View File

@@ -19,69 +19,112 @@
class="btn-primary"
confirm="This will create a Sale Order from this configurator session. Continue?"
invisible="state != 'draft'"/>
<button name="action_recalculate_price"
string="Recalculate"
type="object"
class="btn-secondary"/>
<button name="action_cancel"
string="Cancel"
type="object"
invisible="state != 'draft'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed"/>
invisible="state == 'cancelled'"/>
<button name="action_reset_draft"
string="Reset to Draft"
type="object"
invisible="state == 'draft'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed,cancelled"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order"
type="object"
class="oe_stat_button"
icon="fa-file-text-o"
invisible="not sale_order_id">
<field name="sale_order_id" widget="statinfo" string="Sale Order"/>
</button>
<button name="action_view_part_catalog"
type="object"
class="oe_stat_button"
icon="fa-cube"
invisible="not part_catalog_id">
<field name="part_catalog_id" widget="statinfo" string="Part"/>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Customer + Part / Coating + Quantity -->
<group>
<group string="Customer &amp; Part">
<field name="partner_id"/>
<field name="part_catalog_id"/>
</group>
<group string="Coating &amp; Quantity">
<field name="coating_config_id"/>
<field name="quantity"/>
<field name="batch_size"/>
</group>
</group>
<!-- Geometry / Options -->
<group>
<group string="Geometry">
<field name="surface_area"/>
<field name="surface_area_uom"/>
<field name="thickness_requested"/>
<field name="substrate_material"/>
</group>
<group string="Options">
<field name="complexity"/>
<field name="masking_zones"/>
<field name="rush_order"/>
<field name="turnaround_days"/>
</group>
</group>
<!-- Delivery / Fees -->
<group>
<group string="Delivery &amp; Fees">
<field name="delivery_method"/>
<field name="shipping_fee"/>
<field name="delivery_fee"/>
</group>
<group>
<field name="currency_id" invisible="1"/>
</group>
</group>
<separator string="Pricing"/>
<group>
<group>
<field name="calculated_price" widget="monetary" readonly="1"
class="fw-bold fs-4"/>
</group>
<group>
<field name="estimator_override_price" widget="monetary"/>
</group>
</group>
<group>
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
</group>
<!-- Main layout: 3/4 fields (left) + 1/4 3D preview (right) -->
<div class="o_fp_cfg_layout">
<!-- LEFT COLUMN: all fields -->
<div class="o_fp_cfg_fields">
<group>
<group string="Customer &amp; Part">
<field name="partner_id"/>
<field name="part_catalog_id"/>
<field name="coating_config_id"/>
<field name="upload_3d_file" filename="upload_3d_filename"
invisible="state != 'draft'"
string="Attach 3D File"/>
<field name="upload_3d_filename" invisible="1"/>
<field name="upload_drawing" filename="upload_drawing_filename"
invisible="state != 'draft'"
string="Attach Drawing"/>
<field name="upload_drawing_filename" invisible="1"/>
</group>
<group string="Quantity &amp; Options">
<field name="quantity"/>
<field name="batch_size"/>
<field name="complexity"/>
<field name="rush_order"/>
</group>
</group>
<group>
<group string="Geometry">
<field name="surface_area"/>
<field name="surface_area_uom"/>
<field name="thickness_requested"/>
<field name="substrate_material"/>
<field name="masking_zones"/>
<field name="turnaround_days"/>
</group>
<group string="Delivery &amp; Fees">
<field name="delivery_method"/>
<field name="shipping_fee"/>
<field name="delivery_fee"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
<separator string="Pricing"/>
<group>
<group>
<field name="calculated_price" widget="monetary" readonly="1"
class="fw-bold fs-4"/>
</group>
<group>
<field name="estimator_override_price" widget="monetary"/>
</group>
</group>
<group>
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
</group>
</div>
<!-- RIGHT COLUMN: 3D preview (sticky) -->
<div class="o_fp_cfg_preview" invisible="not model_attachment_id">
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
<div class="text-center mt-2">
<button name="action_open_3d_fullscreen"
string="Full Screen"
type="object"
class="btn btn-sm btn-outline-primary"
icon="fa-expand"/>
</div>
</div>
</div>
<notebook>
<page string="Sale Order" name="sale_order">
<group>
@@ -93,10 +136,7 @@
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
<chatter/>
</form>
</field>
</record>
@@ -155,7 +195,7 @@
<field name="res_model">fp.quote.configurator</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_quote_configurator_search"/>
<field name="context">{'search_default_draft': 1}</field>
<field name="context">{}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new quote configurator session

View File

@@ -6,11 +6,11 @@
-->
<odoo>
<!-- ===== DASHBOARD top-level menu ===== -->
<!-- ===== KPIs top-level menu ===== -->
<menuitem id="menu_fp_dashboard"
name="Dashboard"
name="KPIs"
parent="fusion_plating.menu_fp_root"
sequence="5"/>
sequence="85"/>
<menuitem id="menu_fp_kpis"
name="KPIs"

View File

@@ -7,25 +7,19 @@
<odoo>
<!-- ================================================================== -->
<!-- Add a Sales section to the Plating root menu for portal-facing -->
<!-- records (Quote Requests + Portal Jobs). -->
<!-- Portal-facing records live under the unified Sales menu defined -->
<!-- by fusion_plating_configurator. -->
<!-- ================================================================== -->
<menuitem id="menu_fp_sales"
name="Sales"
parent="fusion_plating.menu_fp_root"
sequence="20"
groups="fusion_plating.group_fusion_plating_supervisor"/>
<menuitem id="menu_fp_quote_requests"
name="Quote Requests"
parent="menu_fp_sales"
parent="fusion_plating_configurator.menu_fp_sales"
action="action_fp_quote_request"
sequence="10"/>
sequence="50"/>
<menuitem id="menu_fp_portal_jobs"
name="Portal Jobs"
parent="menu_fp_sales"
parent="fusion_plating_configurator.menu_fp_sales"
action="action_fp_portal_job"
sequence="20"/>
sequence="60"/>
</odoo>

View File

@@ -10,7 +10,7 @@
<menuitem id="menu_fp_quality"
name="Quality"
parent="fusion_plating.menu_fp_root"
sequence="20"
sequence="30"
groups="fusion_plating.group_fusion_plating_operator"/>
<menuitem id="menu_fp_quality_hold"

View File

@@ -25,7 +25,7 @@
<menuitem id="menu_fp_receiving_root"
name="Receiving &amp; Inspection"
parent="fusion_plating.menu_fp_root"
sequence="5"
sequence="15"
groups="group_fp_receiving"/>
<menuitem id="menu_fp_receiving_all"

View File

@@ -10,7 +10,7 @@
<menuitem id="menu_fp_safety_root"
name="Safety"
parent="fusion_plating.menu_fp_root"
sequence="40"
sequence="45"
groups="fusion_plating.group_fusion_plating_operator"/>
<menuitem id="menu_fp_safety_sds"

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import controllers
from . import models
from . import wizard

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Sensors',
'version': '19.0.1.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Tank and process sensor tracking with IoT API, dashboards, and alerts.',
'description': """
Fusion Plating — Sensors
========================
Chemistry and environmental sensor tracking for electroless nickel plating
and metal finishing operations. Replaces Steelhead Software sensor module.
* Sensor type definitions (pH, %, g/L, PPM, conductivity, etc.)
* Individual sensors linked to tanks, work centres, and facilities
* Timestamped measurements (manual entry + IoT API)
* Sensor dashboards with alert thresholds
* Quick-measure wizard for operators
* JSON-RPC endpoint for automated data collection
Part of the Fusion Plating product family by Nexa Systems Inc.
Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'license': 'OPL-1',
'price': 0.00,
'currency': 'CAD',
'depends': [
'fusion_plating',
],
'data': [
'security/fp_sensor_security.xml',
'security/ir.model.access.csv',
'data/fp_sensor_sequence_data.xml',
'views/fp_sensor_type_views.xml',
'views/fp_sensor_views.xml',
'views/fp_sensor_measurement_views.xml',
'views/fp_sensor_dashboard_views.xml',
'views/fp_sensor_measure_wizard_views.xml',
'views/fp_sensor_menu.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import sensor_controller

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import http, fields
from odoo.http import request
_logger = logging.getLogger(__name__)
class SensorController(http.Controller):
"""JSON-RPC endpoint for IoT devices to push sensor readings."""
@http.route(
'/fp/sensor/measure',
type='jsonrpc',
auth='user',
methods=['POST'],
)
def sensor_measure(self, uuid=None, value=None, value_text=None,
value_bool=None, effective_at=None, comment=None):
"""Record a measurement from an IoT device or external API.
Args:
uuid: Sensor UUID (required)
value: Numeric reading (for NUMBER sensors)
value_text: Text reading (for TEXT sensors)
value_bool: Boolean reading (for BOOLEAN sensors)
effective_at: ISO datetime string (optional, defaults to now)
comment: Optional note
Returns:
dict with ok=True and measurement_id on success
"""
if not uuid:
return {'ok': False, 'error': 'uuid is required'}
sensor = request.env['fp.sensor'].sudo().search(
[('uuid', '=', uuid)], limit=1,
)
if not sensor:
return {'ok': False, 'error': f'No sensor with UUID {uuid}'}
vals = {
'sensor_id': sensor.id,
'source': 'api',
'creator_id': request.env.uid,
}
if effective_at:
vals['effective_at'] = effective_at
if comment:
vals['comment'] = comment
mtype = sensor.measurement_type
if mtype == 'number':
if value is None:
return {'ok': False, 'error': 'value is required for NUMBER sensors'}
vals['value'] = float(value)
elif mtype == 'text':
if value_text is None:
return {'ok': False, 'error': 'value_text is required for TEXT sensors'}
vals['value_text'] = str(value_text)
elif mtype == 'boolean':
if value_bool is None:
return {'ok': False, 'error': 'value_bool is required for BOOLEAN sensors'}
vals['value_bool'] = bool(value_bool)
measurement = request.env['fp.sensor.measurement'].sudo().create(vals)
_logger.info(
'Sensor %s (%s): recorded measurement %s via API',
sensor.name, uuid, measurement.name,
)
return {'ok': True, 'measurement_id': measurement.id}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_fp_sensor_measurement" model="ir.sequence">
<field name="name">Sensor Measurement</field>
<field name="code">fp.sensor.measurement</field>
<field name="prefix">SMEAS/%(year)s/</field>
<field name="padding">5</field>
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_sensor_type
from . import fp_sensor
from . import fp_sensor_measurement
from . import fp_sensor_dashboard
from . import fp_sensor_alert_rule

View File

@@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FpSensor(models.Model):
"""Individual measurement point.
Each sensor represents a specific thing being measured at a specific
location, e.g. "Tank SP-7 pH" or "Waste Water Treatment pH".
Linked to a work centre (station) and/or tank for traceability.
UUID field enables IoT device integration.
"""
_name = 'fp.sensor'
_description = 'Fusion Plating — Sensor'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'name'
name = fields.Char(
string='Name',
required=True,
tracking=True,
help='Descriptive name, e.g. "Waste Water Treatment pH".',
)
uuid = fields.Char(
string='UUID',
index=True,
copy=False,
help='Hardware identifier for IoT devices.',
)
unit = fields.Char(
string='Unit',
help='Display unit for readings, e.g. "ph", "%", "g/L", "PPM", "L".',
)
sensor_type_id = fields.Many2one(
'fp.sensor.type',
string='Sensor Type',
required=True,
ondelete='restrict',
tracking=True,
)
measurement_type = fields.Selection(
related='sensor_type_id.measurement_type',
string='Measurement Type',
store=True,
readonly=True,
)
work_center_id = fields.Many2one(
'fusion.plating.work.center',
string='Station',
ondelete='set null',
help='The work centre / station this sensor is attached to.',
)
tank_id = fields.Many2one(
'fusion.plating.tank',
string='Tank',
ondelete='set null',
)
facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
ondelete='set null',
)
location_name = fields.Char(
string='Location',
help='Free-text location, e.g. "WaterTreatmentArea", "PLANT1.TankLine".',
)
use_location = fields.Boolean(
string='Use Location?',
default=False,
)
# -- Computed from latest measurement --
last_value = fields.Float(
string='Last Measurement',
compute='_compute_last_measurement',
store=True,
)
last_value_text = fields.Char(
string='Last Text Value',
compute='_compute_last_measurement',
store=True,
)
last_measured = fields.Datetime(
string='Last Measured',
compute='_compute_last_measurement',
store=True,
)
measurement_ids = fields.One2many(
'fp.sensor.measurement',
'sensor_id',
string='Measurements',
)
measurement_count = fields.Integer(
string='Measurement Count',
compute='_compute_measurement_count',
)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
_sql_constraints = [
('uuid_uniq', 'unique(uuid)',
'A sensor with this UUID already exists.'),
]
@api.depends(
'measurement_ids',
'measurement_ids.value',
'measurement_ids.value_text',
'measurement_ids.effective_at',
)
def _compute_last_measurement(self):
for sensor in self:
latest = self.env['fp.sensor.measurement'].search(
[('sensor_id', '=', sensor.id)],
order='effective_at desc, id desc',
limit=1,
)
if latest:
sensor.last_value = latest.value
sensor.last_value_text = latest.value_text
sensor.last_measured = latest.effective_at
else:
sensor.last_value = 0.0
sensor.last_value_text = False
sensor.last_measured = False
def action_quick_measure(self):
"""Open the quick measurement wizard."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Record Measurement',
'res_model': 'fp.sensor.measure.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_sensor_id': self.id},
}
def action_view_measurements(self):
"""Open measurement list filtered to this sensor."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Measurements — {self.name}',
'res_model': 'fp.sensor.measurement',
'view_mode': 'list,form',
'domain': [('sensor_id', '=', self.id)],
'context': {'default_sensor_id': self.id},
}
def _compute_measurement_count(self):
data = self.env['fp.sensor.measurement']._read_group(
[('sensor_id', 'in', self.ids)],
['sensor_id'],
['__count'],
)
mapped = {sensor.id: count for sensor, count in data}
for sensor in self:
sensor.measurement_count = mapped.get(sensor.id, 0)

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class FpSensorAlertRule(models.Model):
"""Threshold alert rule for a sensor.
When a sensor's last reading exceeds threshold_high or falls
below threshold_low, the parent dashboard's alert_count increments.
"""
_name = 'fp.sensor.alert.rule'
_description = 'Fusion Plating — Sensor Alert Rule'
_order = 'sensor_id, id'
sensor_id = fields.Many2one(
'fp.sensor',
string='Sensor',
required=True,
ondelete='cascade',
)
dashboard_id = fields.Many2one(
'fp.sensor.dashboard',
string='Dashboard',
ondelete='cascade',
)
threshold_high = fields.Float(
string='High Threshold',
help='Alert when sensor value exceeds this.',
)
threshold_low = fields.Float(
string='Low Threshold',
help='Alert when sensor value falls below this.',
)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FpSensorDashboard(models.Model):
"""Sensor chart grouping with alert monitoring.
Groups multiple sensors into a named dashboard for trend
visualization and threshold alerting.
"""
_name = 'fp.sensor.dashboard'
_description = 'Fusion Plating — Sensor Dashboard'
_inherit = ['mail.thread']
_order = 'name'
name = fields.Char(string='Name', required=True, tracking=True)
sensor_ids = fields.Many2many(
'fp.sensor',
'fp_sensor_dashboard_sensor_rel',
'dashboard_id',
'sensor_id',
string='Sensors',
)
alert_rule_ids = fields.One2many(
'fp.sensor.alert.rule',
'dashboard_id',
string='Alert Rules',
)
member_count = fields.Integer(
string='Members',
compute='_compute_counts',
)
alert_count = fields.Integer(
string='Alerts',
compute='_compute_counts',
)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
@api.depends('sensor_ids', 'alert_rule_ids', 'alert_rule_ids.active')
def _compute_counts(self):
for dash in self:
dash.member_count = len(dash.sensor_ids)
active_rules = dash.alert_rule_ids.filtered('active')
alert_count = 0
for rule in active_rules:
sensor = rule.sensor_id
val = sensor.last_value
if rule.threshold_high and val > rule.threshold_high:
alert_count += 1
elif rule.threshold_low and val < rule.threshold_low:
alert_count += 1
dash.alert_count = alert_count

View File

@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FpSensorMeasurement(models.Model):
"""Timestamped sensor reading.
High-volume model — no mail.thread to keep writes fast for IoT
ingestion. Three value columns cover all measurement types;
views branch on the sensor's measurement_type.
"""
_name = 'fp.sensor.measurement'
_description = 'Fusion Plating — Sensor Measurement'
_order = 'effective_at desc, id desc'
name = fields.Char(
string='Reference',
readonly=True,
copy=False,
default='New',
)
sensor_id = fields.Many2one(
'fp.sensor',
string='Sensor',
required=True,
ondelete='cascade',
index=True,
)
# Denormalised for list filtering / grouping
sensor_type_id = fields.Many2one(
related='sensor_id.sensor_type_id',
store=True,
readonly=True,
)
measurement_type = fields.Selection(
related='sensor_id.measurement_type',
store=True,
readonly=True,
)
unit = fields.Char(
related='sensor_id.unit',
readonly=True,
)
# -- Value columns (one per measurement type) --
value = fields.Float(
string='Value',
help='Numeric measurement (for NUMBER type sensors).',
)
value_text = fields.Char(
string='Text Value',
help='Text measurement (for TEXT type sensors).',
)
value_bool = fields.Boolean(
string='Boolean Value',
help='Boolean measurement (for BOOLEAN type sensors).',
)
comment = fields.Text(string='Comment')
effective_at = fields.Datetime(
string='Effective At',
default=fields.Datetime.now,
required=True,
index=True,
help='Timestamp of when the measurement was actually taken.',
)
source = fields.Selection(
[
('manual', 'Manual'),
('api', 'API'),
('iot', 'IoT Device'),
],
string='Source',
default='manual',
required=True,
)
creator_id = fields.Many2one(
'res.users',
string='Creator',
default=lambda self: self.env.uid,
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
@api.model_create_multi
def create(self, vals_list):
seq = self.env['ir.sequence']
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = seq.next_by_code('fp.sensor.measurement') or 'New'
return super().create(vals_list)

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class FpSensorType(models.Model):
"""Sensor measurement template.
Defines what a sensor measures (pH, % Nickel Activity, conductivity, etc.)
and the data type of its readings. The unit lives on the sensor itself
because the same type (e.g. NUMBER) can have different units (ph, %, g/L).
"""
_name = 'fp.sensor.type'
_description = 'Fusion Plating — Sensor Type'
_order = 'name'
name = fields.Char(
string='Name',
required=True,
help='Descriptive name, e.g. "Waste Water Effluent pH", '
'"Tank SP-7 % Nickel Activity".',
)
measurement_type = fields.Selection(
[
('number', 'Number'),
('boolean', 'Boolean'),
('text', 'Text'),
],
string='Measurement Type',
required=True,
default='number',
)
sensor_ids = fields.One2many(
'fp.sensor',
'sensor_type_id',
string='Sensors',
)
sensor_count = fields.Integer(
string='Sensor Count',
compute='_compute_sensor_count',
)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
def _compute_sensor_count(self):
for rec in self:
rec.sensor_count = len(rec.sensor_ids)
def action_view_sensors(self):
"""Open sensor list filtered to this type."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Sensors — {self.name}',
'res_model': 'fp.sensor',
'view_mode': 'list,form',
'domain': [('sensor_type_id', '=', self.id)],
'context': {'default_sensor_type_id': self.id},
}

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Multi-company isolation rules -->
<record id="fp_sensor_type_company_rule" model="ir.rule">
<field name="name">Sensor Type: multi-company</field>
<field name="model_id" ref="model_fp_sensor_type"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="fp_sensor_company_rule" model="ir.rule">
<field name="name">Sensor: multi-company</field>
<field name="model_id" ref="model_fp_sensor"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="fp_sensor_measurement_company_rule" model="ir.rule">
<field name="name">Sensor Measurement: multi-company</field>
<field name="model_id" ref="model_fp_sensor_measurement"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="fp_sensor_dashboard_company_rule" model="ir.rule">
<field name="name">Sensor Dashboard: multi-company</field>
<field name="model_id" ref="model_fp_sensor_dashboard"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="fp_sensor_alert_rule_company_rule" model="ir.rule">
<field name="name">Sensor Alert Rule: multi-company</field>
<field name="model_id" ref="model_fp_sensor_alert_rule"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,17 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_sensor_type_operator,fp.sensor.type.operator,model_fp_sensor_type,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_sensor_type_supervisor,fp.sensor.type.supervisor,model_fp_sensor_type,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_sensor_type_manager,fp.sensor.type.manager,model_fp_sensor_type,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sensor_operator,fp.sensor.operator,model_fp_sensor,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_sensor_supervisor,fp.sensor.supervisor,model_fp_sensor,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_sensor_manager,fp.sensor.manager,model_fp_sensor,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sensor_measurement_operator,fp.sensor.measurement.operator,model_fp_sensor_measurement,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_sensor_measurement_supervisor,fp.sensor.measurement.supervisor,model_fp_sensor_measurement,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_sensor_measurement_manager,fp.sensor.measurement.manager,model_fp_sensor_measurement,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sensor_dashboard_operator,fp.sensor.dashboard.operator,model_fp_sensor_dashboard,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_sensor_dashboard_supervisor,fp.sensor.dashboard.supervisor,model_fp_sensor_dashboard,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_sensor_dashboard_manager,fp.sensor.dashboard.manager,model_fp_sensor_dashboard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sensor_alert_rule_operator,fp.sensor.alert.rule.operator,model_fp_sensor_alert_rule,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_sensor_alert_rule_supervisor,fp.sensor.alert.rule.supervisor,model_fp_sensor_alert_rule,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_sensor_alert_rule_manager,fp.sensor.alert.rule.manager,model_fp_sensor_alert_rule,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sensor_measure_wizard_operator,fp.sensor.measure.wizard.operator,model_fp_sensor_measure_wizard,fusion_plating.group_fusion_plating_operator,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_sensor_type_operator fp.sensor.type.operator model_fp_sensor_type fusion_plating.group_fusion_plating_operator 1 0 0 0
3 access_fp_sensor_type_supervisor fp.sensor.type.supervisor model_fp_sensor_type fusion_plating.group_fusion_plating_supervisor 1 1 1 0
4 access_fp_sensor_type_manager fp.sensor.type.manager model_fp_sensor_type fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_sensor_operator fp.sensor.operator model_fp_sensor fusion_plating.group_fusion_plating_operator 1 0 0 0
6 access_fp_sensor_supervisor fp.sensor.supervisor model_fp_sensor fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_sensor_manager fp.sensor.manager model_fp_sensor fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_sensor_measurement_operator fp.sensor.measurement.operator model_fp_sensor_measurement fusion_plating.group_fusion_plating_operator 1 1 1 0
9 access_fp_sensor_measurement_supervisor fp.sensor.measurement.supervisor model_fp_sensor_measurement fusion_plating.group_fusion_plating_supervisor 1 1 1 0
10 access_fp_sensor_measurement_manager fp.sensor.measurement.manager model_fp_sensor_measurement fusion_plating.group_fusion_plating_manager 1 1 1 1
11 access_fp_sensor_dashboard_operator fp.sensor.dashboard.operator model_fp_sensor_dashboard fusion_plating.group_fusion_plating_operator 1 0 0 0
12 access_fp_sensor_dashboard_supervisor fp.sensor.dashboard.supervisor model_fp_sensor_dashboard fusion_plating.group_fusion_plating_supervisor 1 1 1 0
13 access_fp_sensor_dashboard_manager fp.sensor.dashboard.manager model_fp_sensor_dashboard fusion_plating.group_fusion_plating_manager 1 1 1 1
14 access_fp_sensor_alert_rule_operator fp.sensor.alert.rule.operator model_fp_sensor_alert_rule fusion_plating.group_fusion_plating_operator 1 0 0 0
15 access_fp_sensor_alert_rule_supervisor fp.sensor.alert.rule.supervisor model_fp_sensor_alert_rule fusion_plating.group_fusion_plating_supervisor 1 1 1 0
16 access_fp_sensor_alert_rule_manager fp.sensor.alert.rule.manager model_fp_sensor_alert_rule fusion_plating.group_fusion_plating_manager 1 1 1 1
17 access_fp_sensor_measure_wizard_operator fp.sensor.measure.wizard.operator model_fp_sensor_measure_wizard fusion_plating.group_fusion_plating_operator 1 1 1 1

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Dashboard Form ===== -->
<record id="view_fp_sensor_dashboard_form" model="ir.ui.view">
<field name="name">fp.sensor.dashboard.form</field>
<field name="model">fp.sensor.dashboard</field>
<field name="arch" type="xml">
<form string="Sensor Dashboard">
<sheet>
<div class="oe_title">
<h1>
<field name="name" placeholder="e.g. % Nickel Activity"/>
</h1>
</div>
<group>
<group>
<field name="member_count" readonly="1"/>
<field name="alert_count" readonly="1"/>
</group>
<group>
<field name="active" invisible="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Sensors" name="sensors">
<field name="sensor_ids" widget="many2many_tags"/>
<field name="sensor_ids" mode="list">
<list>
<field name="name"/>
<field name="unit"/>
<field name="last_value" string="Last Value"/>
<field name="last_measured"/>
<field name="work_center_id" string="Station"/>
</list>
</field>
</page>
<page string="Alert Rules" name="alerts">
<field name="alert_rule_ids">
<list editable="bottom">
<field name="sensor_id"/>
<field name="threshold_low"/>
<field name="threshold_high"/>
<field name="active"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Dashboard List ===== -->
<record id="view_fp_sensor_dashboard_list" model="ir.ui.view">
<field name="name">fp.sensor.dashboard.list</field>
<field name="model">fp.sensor.dashboard</field>
<field name="arch" type="xml">
<list string="Sensor Dashboards" default_order="name">
<field name="name"/>
<field name="alert_count" string="Alerts"
decoration-danger="alert_count > 0"/>
<field name="member_count" string="Members"/>
</list>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_sensor_dashboard" model="ir.actions.act_window">
<field name="name">Sensor Dashboards</field>
<field name="res_model">fp.sensor.dashboard</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a sensor dashboard
</p>
<p>Group sensors into dashboards for monitoring and alerting.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Quick Measure Wizard Form ===== -->
<record id="view_fp_sensor_measure_wizard_form" model="ir.ui.view">
<field name="name">fp.sensor.measure.wizard.form</field>
<field name="model">fp.sensor.measure.wizard</field>
<field name="arch" type="xml">
<form string="Record Measurement">
<group>
<field name="sensor_id"/>
<field name="measurement_type" invisible="1"/>
<field name="value"
invisible="measurement_type != 'number'"/>
<field name="value_text"
invisible="measurement_type != 'text'"/>
<field name="value_bool"
invisible="measurement_type != 'boolean'"/>
<field name="unit" invisible="not unit"/>
<field name="effective_at"/>
<field name="comment" placeholder="Write a comment..."/>
</group>
<footer>
<button name="action_confirm" string="Save" type="object"
class="btn-primary"/>
<button string="Cancel" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- ===== Wizard Action (called from sensor form) ===== -->
<record id="action_fp_sensor_measure_wizard" model="ir.actions.act_window">
<field name="name">Record Measurement</field>
<field name="res_model">fp.sensor.measure.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Measurement Form ===== -->
<record id="view_fp_sensor_measurement_form" model="ir.ui.view">
<field name="name">fp.sensor.measurement.form</field>
<field name="model">fp.sensor.measurement</field>
<field name="arch" type="xml">
<form string="Sensor Measurement">
<sheet>
<group>
<group>
<field name="name" readonly="1"/>
<field name="sensor_id"/>
<field name="measurement_type" invisible="1"/>
<field name="value" invisible="measurement_type != 'number'"/>
<field name="value_text" invisible="measurement_type != 'text'"/>
<field name="value_bool" invisible="measurement_type != 'boolean'"/>
<field name="unit"/>
</group>
<group>
<field name="effective_at"/>
<field name="source"/>
<field name="creator_id" widget="many2one_avatar_user"/>
</group>
</group>
<group>
<field name="comment" placeholder="Write a comment..."/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Measurement List ===== -->
<record id="view_fp_sensor_measurement_list" model="ir.ui.view">
<field name="name">fp.sensor.measurement.list</field>
<field name="model">fp.sensor.measurement</field>
<field name="arch" type="xml">
<list string="Sensor Measurements" default_order="effective_at desc"
decoration-muted="source == 'api'">
<field name="value" string="Measurement"/>
<field name="value_text" optional="hide"/>
<field name="comment"/>
<field name="sensor_id"/>
<field name="measurement_type" column_invisible="1"/>
<field name="creator_id" widget="many2one_avatar_user" string="Creator"/>
<field name="effective_at" string="Created At"/>
<field name="source" widget="badge" optional="show"
decoration-info="source == 'manual'"
decoration-success="source == 'api'"
decoration-warning="source == 'iot'"/>
</list>
</field>
</record>
<!-- ===== Measurement Search ===== -->
<record id="view_fp_sensor_measurement_search" model="ir.ui.view">
<field name="name">fp.sensor.measurement.search</field>
<field name="model">fp.sensor.measurement</field>
<field name="arch" type="xml">
<search>
<field name="sensor_id"/>
<field name="creator_id"/>
<separator/>
<filter string="Manual" name="filter_manual"
domain="[('source', '=', 'manual')]"/>
<filter string="API" name="filter_api"
domain="[('source', '=', 'api')]"/>
<filter string="IoT" name="filter_iot"
domain="[('source', '=', 'iot')]"/>
<separator/>
<filter string="Today" name="filter_today"
domain="[('effective_at', '>=', context_today().strftime('%Y-%m-%d'))]"/>
<filter string="This Week" name="filter_week"
domain="[('effective_at', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<group>
<filter string="Sensor" name="group_sensor"
context="{'group_by': 'sensor_id'}"/>
<filter string="Creator" name="group_creator"
context="{'group_by': 'creator_id'}"/>
<filter string="Source" name="group_source"
context="{'group_by': 'source'}"/>
<filter string="Date" name="group_date"
context="{'group_by': 'effective_at:day'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_sensor_measurement" model="ir.actions.act_window">
<field name="name">Sensor Measurements</field>
<field name="res_model">fp.sensor.measurement</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_sensor_measurement_search"/>
<field name="context">{'search_default_filter_today': 0}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No measurements recorded yet
</p>
<p>Measurements are recorded manually via the sensor form or automatically via the IoT API.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Sensors parent menu under Operations ===== -->
<menuitem id="menu_fp_sensors"
name="Sensors"
parent="fusion_plating.menu_fp_operations"
sequence="20"/>
<menuitem id="menu_fp_sensor_dashboards"
name="Dashboards"
parent="menu_fp_sensors"
action="action_fp_sensor_dashboard"
sequence="10"/>
<menuitem id="menu_fp_sensors_all"
name="All Sensors"
parent="menu_fp_sensors"
action="action_fp_sensor"
sequence="20"/>
<menuitem id="menu_fp_sensor_measurements"
name="Measurements"
parent="menu_fp_sensors"
action="action_fp_sensor_measurement"
sequence="30"/>
<menuitem id="menu_fp_sensor_types"
name="Sensor Types"
parent="menu_fp_sensors"
action="action_fp_sensor_type"
sequence="40"/>
</odoo>

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Sensor Type Form ===== -->
<record id="view_fp_sensor_type_form" model="ir.ui.view">
<field name="name">fp.sensor.type.form</field>
<field name="model">fp.sensor.type</field>
<field name="arch" type="xml">
<form string="Sensor Type">
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" icon="fa-microchip"
type="object" name="action_view_sensors"
invisible="sensor_count == 0">
<field name="sensor_count" widget="statinfo" string="Sensors"/>
</button>
</div>
<group>
<group>
<field name="name"/>
<field name="measurement_type"/>
</group>
<group>
<field name="active" invisible="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Sensor Type List ===== -->
<record id="view_fp_sensor_type_list" model="ir.ui.view">
<field name="name">fp.sensor.type.list</field>
<field name="model">fp.sensor.type</field>
<field name="arch" type="xml">
<list string="Sensor Types" default_order="name">
<field name="name"/>
<field name="measurement_type"/>
<field name="sensor_count" string="Sensors"/>
</list>
</field>
</record>
<!-- ===== Sensor Type Search ===== -->
<record id="view_fp_sensor_type_search" model="ir.ui.view">
<field name="name">fp.sensor.type.search</field>
<field name="model">fp.sensor.type</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<separator/>
<filter string="Number" name="filter_number" domain="[('measurement_type', '=', 'number')]"/>
<filter string="Text" name="filter_text" domain="[('measurement_type', '=', 'text')]"/>
<filter string="Boolean" name="filter_boolean" domain="[('measurement_type', '=', 'boolean')]"/>
<group>
<filter string="Type" name="group_type" context="{'group_by': 'measurement_type'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_sensor_type" model="ir.actions.act_window">
<field name="name">Sensor Types</field>
<field name="res_model">fp.sensor.type</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_sensor_type_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a sensor type
</p>
<p>Sensor types define what a sensor measures (Number, Text, or Boolean).</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,148 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Sensor Form ===== -->
<record id="view_fp_sensor_form" model="ir.ui.view">
<field name="name">fp.sensor.form</field>
<field name="model">fp.sensor</field>
<field name="arch" type="xml">
<form string="Sensor">
<header>
<button name="action_quick_measure"
string="+ Measure"
type="object"
class="btn-primary"
icon="fa-plus"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" icon="fa-bar-chart"
type="object" name="action_view_measurements">
<field name="measurement_count" widget="statinfo"
string="Measurements"/>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" placeholder="e.g. Waste Water Treatment pH"/>
</h1>
</div>
<group>
<group string="Sensor Configuration">
<field name="sensor_type_id"/>
<field name="measurement_type" invisible="1"/>
<field name="unit" placeholder="e.g. ph, %, g/L, PPM"/>
<field name="uuid" placeholder="Hardware UUID for IoT"/>
<field name="use_location"/>
</group>
<group string="Location">
<field name="work_center_id" string="Station"/>
<field name="tank_id"/>
<field name="facility_id"/>
<field name="location_name"
invisible="not use_location"
placeholder="e.g. WaterTreatmentArea"/>
</group>
</group>
<group string="Last Reading">
<group>
<field name="last_value" readonly="1"
invisible="measurement_type != 'number'"/>
<field name="last_value_text" readonly="1"
invisible="measurement_type != 'text'"/>
</group>
<group>
<field name="last_measured" readonly="1"/>
</group>
</group>
<notebook>
<page string="Recent Measurements" name="measurements">
<field name="measurement_ids" mode="list" limit="5">
<list editable="bottom" default_order="effective_at desc"
decoration-muted="source == 'api'">
<field name="effective_at" string="Date"/>
<field name="value"
invisible="parent.measurement_type != 'number'"/>
<field name="value_text"
invisible="parent.measurement_type != 'text'"/>
<field name="value_bool"
invisible="parent.measurement_type != 'boolean'"/>
<field name="comment"/>
<field name="creator_id" widget="many2one_avatar_user"/>
<field name="source" widget="badge"
decoration-info="source == 'manual'"
decoration-success="source == 'api'"
decoration-warning="source == 'iot'"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Sensor List ===== -->
<record id="view_fp_sensor_list" model="ir.ui.view">
<field name="name">fp.sensor.list</field>
<field name="model">fp.sensor</field>
<field name="arch" type="xml">
<list string="Sensors" default_order="name">
<field name="name"/>
<field name="sensor_type_id"/>
<field name="last_value" string="Last Measurement"/>
<field name="unit"/>
<field name="measurement_type" column_invisible="1"/>
<field name="last_measured" string="Last Measured"/>
<field name="work_center_id" string="Station" optional="show"/>
<field name="location_name" string="Location" optional="show"/>
<field name="uuid" optional="hide"/>
</list>
</field>
</record>
<!-- ===== Sensor Search ===== -->
<record id="view_fp_sensor_search" model="ir.ui.view">
<field name="name">fp.sensor.search</field>
<field name="model">fp.sensor</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="uuid"/>
<field name="sensor_type_id"/>
<field name="work_center_id" string="Station"/>
<field name="tank_id"/>
<field name="facility_id"/>
<separator/>
<filter string="Number Sensors" name="filter_number"
domain="[('measurement_type', '=', 'number')]"/>
<filter string="Has UUID" name="filter_has_uuid"
domain="[('uuid', '!=', False)]"/>
<group>
<filter string="Station" name="group_station"
context="{'group_by': 'work_center_id'}"/>
<filter string="Type" name="group_type"
context="{'group_by': 'sensor_type_id'}"/>
<filter string="Facility" name="group_facility"
context="{'group_by': 'facility_id'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_sensor" model="ir.actions.act_window">
<field name="name">Sensors</field>
<field name="res_model">fp.sensor</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_sensor_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first sensor
</p>
<p>Sensors track chemistry readings at tanks, work centres, and other locations.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_sensor_measure_wizard

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FpSensorMeasureWizard(models.TransientModel):
"""Quick measurement entry wizard.
Opened from the "+ Measure" button on the sensor form.
Pre-fills sensor and unit, operator enters value + date + comment.
"""
_name = 'fp.sensor.measure.wizard'
_description = 'Record Sensor Measurement'
sensor_id = fields.Many2one(
'fp.sensor',
string='Sensor',
required=True,
readonly=True,
)
measurement_type = fields.Selection(
related='sensor_id.measurement_type',
readonly=True,
)
unit = fields.Char(
related='sensor_id.unit',
readonly=True,
)
value = fields.Float(string='Value')
value_text = fields.Char(string='Text Value')
value_bool = fields.Boolean(string='Boolean Value')
effective_at = fields.Datetime(
string='Date',
default=fields.Datetime.now,
required=True,
)
comment = fields.Text(string='Comment')
def action_confirm(self):
self.ensure_one()
vals = {
'sensor_id': self.sensor_id.id,
'effective_at': self.effective_at,
'comment': self.comment,
'source': 'manual',
}
mtype = self.sensor_id.measurement_type
if mtype == 'number':
vals['value'] = self.value
elif mtype == 'text':
vals['value_text'] = self.value_text
elif mtype == 'boolean':
vals['value_bool'] = self.value_bool
self.env['fp.sensor.measurement'].create(vals)
return {'type': 'ir.actions.act_window_close'}

View File

@@ -49,9 +49,6 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_bake_oven_views.xml',
'views/fp_bake_window_views.xml',
'views/fp_first_piece_gate_views.xml',
# NOTE: fp_workorder_priority_views.xml removed — it references
# mrp.workorder which requires the mrp module (not in depends).
# Move to fusion_plating_bridge_mrp when that module is created.
'views/fp_plant_overview_views.xml',
'views/fp_menu.xml',
],

View File

@@ -6,11 +6,11 @@
-->
<odoo>
<!-- ===== SHOP FLOOR (top-level under Plating, sequence 5) ===== -->
<!-- ===== SHOP FLOOR (top-level under Plating) ===== -->
<menuitem id="menu_fp_shopfloor"
name="Shop Floor"
parent="fusion_plating.menu_fp_root"
sequence="5"
sequence="12"
groups="fusion_plating.group_fusion_plating_operator"/>
<menuitem id="menu_fp_shopfloor_plant_overview"
@@ -37,9 +37,6 @@
action="action_fp_first_piece_gate"
sequence="30"/>
<!-- NOTE: Production Priorities menu removed — requires mrp module.
Move to fusion_plating_bridge_mrp when that module is created. -->
<!-- ===== Configuration (under existing core Configuration menu) ===== -->
<menuitem id="menu_fp_shopfloor_stations_cfg"
name="Shopfloor Stations"

View File

@@ -25,9 +25,6 @@
<field name="x_fc_start_address"
invisible="not x_fc_is_field_staff"
placeholder="e.g. 123 Main St, Brampton, ON"/>
<field name="x_fc_tech_sync_id"
invisible="not x_fc_is_field_staff"
placeholder="e.g. gordy, manpreet"/>
</xpath>
</field>
</record>
@@ -393,7 +390,7 @@
<record id="action_technician_map_view" model="ir.actions.act_window">
<field name="name">Task Map</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">map,list,kanban,form,calendar</field>
<field name="view_mode">list,kanban,form,calendar</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
</record>
@@ -476,4 +473,26 @@
sequence="90"
groups="fusion_tasks.group_field_technician"/>
<!-- ================================================================== -->
<!-- DUAL VISIBILITY: also appear under Plating > Logistics -->
<!-- (fusion_tasks depends on fusion_plating_logistics) -->
<!-- ================================================================== -->
<menuitem id="menu_fp_logistics_field_tasks"
name="Field Tasks"
parent="fusion_plating_logistics.menu_fp_logistics"
action="action_technician_tasks"
sequence="60"/>
<menuitem id="menu_fp_logistics_task_map"
name="Task Map"
parent="fusion_plating_logistics.menu_fp_logistics"
action="action_technician_map_view"
sequence="70"/>
<menuitem id="menu_fp_logistics_task_calendar"
name="Task Calendar"
parent="fusion_plating_logistics.menu_fp_logistics"
action="action_technician_calendar"
sequence="80"/>
</odoo>

Binary file not shown.