changes
36
Entech Plating/fusion_tasks/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
def _fusion_tasks_post_init(env):
|
||||||
|
"""Post-install hook for fusion_tasks.
|
||||||
|
|
||||||
|
1. Sets default ICP values (upsert - safe if keys already exist).
|
||||||
|
2. Adds all active internal users to group_field_technician so
|
||||||
|
the Field Service menus are visible immediately after install.
|
||||||
|
"""
|
||||||
|
ICP = env['ir.config_parameter'].sudo()
|
||||||
|
defaults = {
|
||||||
|
'fusion_claims.google_maps_api_key': '',
|
||||||
|
'fusion_claims.store_open_hour': '9.0',
|
||||||
|
'fusion_claims.store_close_hour': '18.0',
|
||||||
|
'fusion_claims.push_enabled': 'False',
|
||||||
|
'fusion_claims.push_advance_minutes': '30',
|
||||||
|
'fusion_claims.sync_instance_id': '',
|
||||||
|
'fusion_claims.technician_start_address': '',
|
||||||
|
}
|
||||||
|
for key, default_value in defaults.items():
|
||||||
|
if not ICP.get_param(key):
|
||||||
|
ICP.set_param(key, default_value)
|
||||||
|
|
||||||
|
# Add all active internal users to Field Technician group
|
||||||
|
ft_group = env.ref('fusion_tasks.group_field_technician', raise_if_not_found=False)
|
||||||
|
if ft_group:
|
||||||
|
internal_users = env['res.users'].search([
|
||||||
|
('active', '=', True),
|
||||||
|
('share', '=', False),
|
||||||
|
])
|
||||||
|
ft_group.write({'user_ids': [(4, u.id) for u in internal_users]})
|
||||||
38
Entech Plating/fusion_tasks/__manifest__.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
{
|
||||||
|
'name': 'Fusion Tasks',
|
||||||
|
'version': '19.0.1.0.0',
|
||||||
|
'category': 'Services/Field Service',
|
||||||
|
'summary': 'Technician scheduling, route planning, GPS tracking, and cross-instance sync.',
|
||||||
|
'author': 'Nexa Systems Inc.',
|
||||||
|
'website': 'https://www.nexasystems.ca',
|
||||||
|
'license': 'OPL-1',
|
||||||
|
'depends': [
|
||||||
|
'base',
|
||||||
|
'mail',
|
||||||
|
'calendar',
|
||||||
|
'sales_team',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
'security/security.xml',
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/ir_cron_data.xml',
|
||||||
|
'views/technician_task_views.xml',
|
||||||
|
'views/task_sync_views.xml',
|
||||||
|
'views/technician_location_views.xml',
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
|
],
|
||||||
|
'post_init_hook': '_fusion_tasks_post_init',
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'fusion_tasks/static/src/css/fusion_task_map_view.scss',
|
||||||
|
'fusion_tasks/static/src/js/fusion_task_map_view.js',
|
||||||
|
'fusion_tasks/static/src/xml/fusion_task_map_view.xml',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'application': True,
|
||||||
|
}
|
||||||
BIN
Entech Plating/fusion_tasks/__pycache__/__init__.cpython-312.pyc
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!--
|
||||||
|
Default configuration parameters for Fusion Tasks.
|
||||||
|
noupdate="1" ensures these are ONLY set on first install.
|
||||||
|
forcecreate="false" prevents errors if keys already exist.
|
||||||
|
Keys use fusion_claims.* prefix to preserve existing data.
|
||||||
|
-->
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- Google Maps API Key -->
|
||||||
|
<record id="config_google_maps_api_key" model="ir.config_parameter" forcecreate="false">
|
||||||
|
<field name="key">fusion_claims.google_maps_api_key</field>
|
||||||
|
<field name="value"></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Store Hours -->
|
||||||
|
<record id="config_store_open_hour" model="ir.config_parameter" forcecreate="false">
|
||||||
|
<field name="key">fusion_claims.store_open_hour</field>
|
||||||
|
<field name="value">9.0</field>
|
||||||
|
</record>
|
||||||
|
<record id="config_store_close_hour" model="ir.config_parameter" forcecreate="false">
|
||||||
|
<field name="key">fusion_claims.store_close_hour</field>
|
||||||
|
<field name="value">18.0</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Push Notifications -->
|
||||||
|
<record id="config_push_enabled" model="ir.config_parameter" forcecreate="false">
|
||||||
|
<field name="key">fusion_claims.push_enabled</field>
|
||||||
|
<field name="value">False</field>
|
||||||
|
</record>
|
||||||
|
<record id="config_push_advance_minutes" model="ir.config_parameter" forcecreate="false">
|
||||||
|
<field name="key">fusion_claims.push_advance_minutes</field>
|
||||||
|
<field name="value">30</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cross-instance task sync -->
|
||||||
|
<record id="config_sync_instance_id" model="ir.config_parameter" forcecreate="false">
|
||||||
|
<field name="key">fusion_claims.sync_instance_id</field>
|
||||||
|
<field name="value"></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Technician start address (HQ default) -->
|
||||||
|
<record id="config_technician_start_address" model="ir.config_parameter" forcecreate="false">
|
||||||
|
<field name="key">fusion_claims.technician_start_address</field>
|
||||||
|
<field name="value"></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
78
Entech Plating/fusion_tasks/data/ir_cron_data.xml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
|
||||||
|
<!-- Cron Job: Calculate Travel Times for Technician Tasks (every 15 min) -->
|
||||||
|
<record id="ir_cron_technician_travel_times" model="ir.cron">
|
||||||
|
<field name="name">Fusion Tasks: Calculate Technician Travel Times</field>
|
||||||
|
<field name="model_id" ref="model_fusion_technician_task"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_calculate_travel_times()</field>
|
||||||
|
<field name="interval_number">15</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cron Job: Send Push Notifications for Upcoming Tasks -->
|
||||||
|
<record id="ir_cron_technician_push_notifications" model="ir.cron">
|
||||||
|
<field name="name">Fusion Tasks: Technician Push Notifications</field>
|
||||||
|
<field name="model_id" ref="model_fusion_technician_task"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_send_push_notifications()</field>
|
||||||
|
<field name="interval_number">15</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cron Job: Pull Remote Technician Tasks (cross-instance sync) -->
|
||||||
|
<record id="ir_cron_task_sync_pull" model="ir.cron">
|
||||||
|
<field name="name">Fusion Tasks: Sync Remote Tasks (Pull)</field>
|
||||||
|
<field name="model_id" ref="model_fusion_task_sync_config"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_pull_remote_tasks()</field>
|
||||||
|
<field name="interval_number">2</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cron Job: Cleanup Old Shadow Tasks (30+ days) -->
|
||||||
|
<record id="ir_cron_task_sync_cleanup" model="ir.cron">
|
||||||
|
<field name="name">Fusion Tasks: Cleanup Old Shadow Tasks</field>
|
||||||
|
<field name="model_id" ref="model_fusion_task_sync_config"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_cleanup_old_shadows()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cron Job: Check for Late Technician Arrivals -->
|
||||||
|
<record id="ir_cron_check_late_arrivals" model="ir.cron">
|
||||||
|
<field name="name">Fusion Tasks: Check Late Technician Arrivals</field>
|
||||||
|
<field name="model_id" ref="model_fusion_technician_task"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_check_late_arrivals()</field>
|
||||||
|
<field name="interval_number">10</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cron Job: Cleanup Old Technician Locations -->
|
||||||
|
<record id="ir_cron_cleanup_locations" model="ir.cron">
|
||||||
|
<field name="name">Fusion Tasks: Cleanup Old Locations</field>
|
||||||
|
<field name="model_id" ref="model_fusion_technician_location"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_cleanup_old_locations()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="nextcall" eval="DateTime.now().replace(hour=4, minute=0, second=0)"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
13
Entech Plating/fusion_tasks/models/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from . import email_builder_mixin
|
||||||
|
from . import res_partner
|
||||||
|
from . import res_company
|
||||||
|
from . import res_users
|
||||||
|
from . import res_config_settings
|
||||||
|
from . import technician_task
|
||||||
|
from . import task_sync
|
||||||
|
from . import technician_location
|
||||||
|
from . import push_subscription
|
||||||
241
Entech Plating/fusion_tasks/models/email_builder_mixin.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Fusion Claims - Professional Email Builder Mixin
|
||||||
|
# Provides consistent, dark/light mode safe email templates across all modules.
|
||||||
|
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class FusionEmailBuilderMixin(models.AbstractModel):
|
||||||
|
_name = 'fusion.email.builder.mixin'
|
||||||
|
_description = 'Fusion Email Builder Mixin'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Color constants
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
_EMAIL_COLORS = {
|
||||||
|
'info': '#2B6CB0',
|
||||||
|
'success': '#38a169',
|
||||||
|
'attention': '#d69e2e',
|
||||||
|
'urgent': '#c53030',
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _email_build(
|
||||||
|
self,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
sections=None,
|
||||||
|
note=None,
|
||||||
|
note_color=None,
|
||||||
|
email_type='info',
|
||||||
|
attachments_note=None,
|
||||||
|
button_url=None,
|
||||||
|
button_text='View Case Details',
|
||||||
|
sender_name=None,
|
||||||
|
extra_html='',
|
||||||
|
):
|
||||||
|
"""Build a complete professional email HTML string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Email heading (e.g. "Application Approved")
|
||||||
|
summary: One-sentence summary HTML (may contain <strong> tags)
|
||||||
|
sections: list of (heading, rows) where rows is list of (label, value)
|
||||||
|
e.g. [('Case Details', [('Client', 'John'), ('Case', 'S30073')])]
|
||||||
|
note: Optional note/next-steps text (plain or HTML)
|
||||||
|
note_color: Override left-border color for note (default uses email_type)
|
||||||
|
email_type: 'info' | 'success' | 'attention' | 'urgent'
|
||||||
|
attachments_note: Optional string listing attached files
|
||||||
|
button_url: Optional CTA button URL
|
||||||
|
button_text: CTA button label
|
||||||
|
sender_name: Name for sign-off (defaults to current user)
|
||||||
|
extra_html: Any additional HTML to insert before sign-off
|
||||||
|
"""
|
||||||
|
accent = self._EMAIL_COLORS.get(email_type, self._EMAIL_COLORS['info'])
|
||||||
|
company = self._get_company_info()
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
# -- Wrapper open + accent bar (no forced bg/color so it adapts to dark/light)
|
||||||
|
parts.append(
|
||||||
|
f'<div style="font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Arial,sans-serif;'
|
||||||
|
f'max-width:600px;margin:0 auto;">'
|
||||||
|
f'<div style="height:4px;background-color:{accent};"></div>'
|
||||||
|
f'<div style="padding:32px 28px;">'
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Company name (accent color works in both themes)
|
||||||
|
parts.append(
|
||||||
|
f'<p style="color:{accent};font-size:13px;font-weight:600;letter-spacing:0.5px;'
|
||||||
|
f'text-transform:uppercase;margin:0 0 24px 0;">{company["name"]}</p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Title (inherits text color from container)
|
||||||
|
parts.append(
|
||||||
|
f'<h2 style="font-size:22px;font-weight:700;'
|
||||||
|
f'margin:0 0 6px 0;line-height:1.3;">{title}</h2>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Summary (muted via opacity)
|
||||||
|
parts.append(
|
||||||
|
f'<p style="opacity:0.65;font-size:15px;line-height:1.5;'
|
||||||
|
f'margin:0 0 24px 0;">{summary}</p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Sections (details tables)
|
||||||
|
if sections:
|
||||||
|
for heading, rows in sections:
|
||||||
|
parts.append(self._email_section(heading, rows))
|
||||||
|
|
||||||
|
# -- Note / Next Steps
|
||||||
|
if note:
|
||||||
|
nc = note_color or accent
|
||||||
|
parts.append(self._email_note(note, nc))
|
||||||
|
|
||||||
|
# -- Extra HTML
|
||||||
|
if extra_html:
|
||||||
|
parts.append(extra_html)
|
||||||
|
|
||||||
|
# -- Attachment note
|
||||||
|
if attachments_note:
|
||||||
|
parts.append(self._email_attachment_note(attachments_note))
|
||||||
|
|
||||||
|
# -- CTA Button
|
||||||
|
if button_url:
|
||||||
|
parts.append(self._email_button(button_url, button_text, accent))
|
||||||
|
|
||||||
|
# -- Sign-off
|
||||||
|
signer = sender_name or (self.env.user.name if self.env.user else '')
|
||||||
|
parts.append(
|
||||||
|
f'<p style="font-size:14px;line-height:1.6;margin:24px 0 0 0;">'
|
||||||
|
f'Best regards,<br/>'
|
||||||
|
f'<strong>{signer}</strong><br/>'
|
||||||
|
f'<span style="opacity:0.6;">{company["name"]}</span></p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Close content card
|
||||||
|
parts.append('</div>')
|
||||||
|
|
||||||
|
# -- Footer
|
||||||
|
footer_parts = [company['name']]
|
||||||
|
if company['phone']:
|
||||||
|
footer_parts.append(company['phone'])
|
||||||
|
if company['email']:
|
||||||
|
footer_parts.append(company['email'])
|
||||||
|
footer_text = ' · '.join(footer_parts)
|
||||||
|
|
||||||
|
parts.append(
|
||||||
|
f'<div style="padding:16px 28px;text-align:center;">'
|
||||||
|
f'<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">'
|
||||||
|
f'{footer_text}<br/>'
|
||||||
|
f'This is an automated notification from the ADP Claims Management System.</p>'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Close wrapper
|
||||||
|
parts.append('</div>')
|
||||||
|
|
||||||
|
return ''.join(parts)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Building blocks
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _email_section(self, heading, rows):
|
||||||
|
"""Build a labeled details table section.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
heading: Section title (e.g. "Case Details")
|
||||||
|
rows: list of (label, value) tuples. Value can be plain text or HTML.
|
||||||
|
"""
|
||||||
|
if not rows:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
html = (
|
||||||
|
'<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">'
|
||||||
|
f'<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;'
|
||||||
|
f'opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;'
|
||||||
|
f'border-bottom:2px solid rgba(128,128,128,0.25);">{heading}</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for label, value in rows:
|
||||||
|
if value is None or value == '' or value is False:
|
||||||
|
continue
|
||||||
|
html += (
|
||||||
|
f'<tr>'
|
||||||
|
f'<td style="padding:10px 14px;opacity:0.6;font-size:14px;'
|
||||||
|
f'border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">{label}</td>'
|
||||||
|
f'<td style="padding:10px 14px;font-size:14px;'
|
||||||
|
f'border-bottom:1px solid rgba(128,128,128,0.15);">{value}</td>'
|
||||||
|
f'</tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
html += '</table>'
|
||||||
|
return html
|
||||||
|
|
||||||
|
def _email_note(self, text, color='#2B6CB0'):
|
||||||
|
"""Build a left-border accent note block."""
|
||||||
|
return (
|
||||||
|
f'<div style="border-left:3px solid {color};padding:12px 16px;'
|
||||||
|
f'margin:0 0 24px 0;">'
|
||||||
|
f'<p style="margin:0;font-size:14px;line-height:1.5;">{text}</p>'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _email_button(self, url, text='View Case Details', color='#2B6CB0'):
|
||||||
|
"""Build a centered CTA button."""
|
||||||
|
return (
|
||||||
|
f'<p style="text-align:center;margin:28px 0;">'
|
||||||
|
f'<a href="{url}" style="display:inline-block;background:{color};color:#ffffff;'
|
||||||
|
f'padding:12px 28px;text-decoration:none;border-radius:6px;'
|
||||||
|
f'font-size:14px;font-weight:600;">{text}</a></p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _email_attachment_note(self, description):
|
||||||
|
"""Build a dashed-border attachment callout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: e.g. "ADP Application (PDF), XML Data File"
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
f'<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;'
|
||||||
|
f'margin:0 0 24px 0;">'
|
||||||
|
f'<p style="margin:0;font-size:13px;opacity:0.65;">'
|
||||||
|
f'<strong style="opacity:1;">Attached:</strong> {description}</p>'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _email_status_badge(self, label, color='#2B6CB0'):
|
||||||
|
"""Return an inline status badge/pill HTML snippet."""
|
||||||
|
bg_map = {
|
||||||
|
'#38a169': 'rgba(56,161,105,0.12)',
|
||||||
|
'#2B6CB0': 'rgba(43,108,176,0.12)',
|
||||||
|
'#d69e2e': 'rgba(214,158,46,0.12)',
|
||||||
|
'#c53030': 'rgba(197,48,48,0.12)',
|
||||||
|
}
|
||||||
|
bg = bg_map.get(color, 'rgba(43,108,176,0.12)')
|
||||||
|
return (
|
||||||
|
f'<span style="display:inline-block;background:{bg};color:{color};'
|
||||||
|
f'padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600;">'
|
||||||
|
f'{label}</span>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_company_info(self):
|
||||||
|
"""Return company name, phone, email for email templates."""
|
||||||
|
company = getattr(self, 'company_id', None) or self.env.company
|
||||||
|
return {
|
||||||
|
'name': company.name or 'Our Company',
|
||||||
|
'phone': company.phone or '',
|
||||||
|
'email': company.email or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _email_is_enabled(self):
|
||||||
|
"""Check if email notifications are enabled in settings."""
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
val = ICP.get_param('fusion_claims.enable_email_notifications', 'True')
|
||||||
|
return val.lower() in ('true', '1', 'yes')
|
||||||
73
Entech Plating/fusion_tasks/models/push_subscription.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
"""
|
||||||
|
Web Push Subscription model for storing browser push notification subscriptions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import models, fields, api
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionPushSubscription(models.Model):
|
||||||
|
_name = 'fusion.push.subscription'
|
||||||
|
_description = 'Web Push Subscription'
|
||||||
|
_order = 'create_date desc'
|
||||||
|
|
||||||
|
user_id = fields.Many2one(
|
||||||
|
'res.users',
|
||||||
|
string='User',
|
||||||
|
required=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
endpoint = fields.Text(
|
||||||
|
string='Endpoint URL',
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
p256dh_key = fields.Text(
|
||||||
|
string='P256DH Key',
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
auth_key = fields.Text(
|
||||||
|
string='Auth Key',
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
browser_info = fields.Char(
|
||||||
|
string='Browser Info',
|
||||||
|
help='User agent or browser identification',
|
||||||
|
)
|
||||||
|
active = fields.Boolean(
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
_constraints = [
|
||||||
|
models.Constraint(
|
||||||
|
'unique(endpoint)',
|
||||||
|
'This push subscription endpoint already exists.',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def register_subscription(self, user_id, endpoint, p256dh_key, auth_key, browser_info=None):
|
||||||
|
"""Register or update a push subscription."""
|
||||||
|
existing = self.sudo().search([('endpoint', '=', endpoint)], limit=1)
|
||||||
|
if existing:
|
||||||
|
existing.write({
|
||||||
|
'user_id': user_id,
|
||||||
|
'p256dh_key': p256dh_key,
|
||||||
|
'auth_key': auth_key,
|
||||||
|
'browser_info': browser_info or existing.browser_info,
|
||||||
|
'active': True,
|
||||||
|
})
|
||||||
|
return existing
|
||||||
|
return self.sudo().create({
|
||||||
|
'user_id': user_id,
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'p256dh_key': p256dh_key,
|
||||||
|
'auth_key': auth_key,
|
||||||
|
'browser_info': browser_info,
|
||||||
|
})
|
||||||
14
Entech Plating/fusion_tasks/models/res_company.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class ResCompany(models.Model):
|
||||||
|
_inherit = 'res.company'
|
||||||
|
|
||||||
|
x_fc_google_review_url = fields.Char(
|
||||||
|
string='Google Review URL',
|
||||||
|
help='Google Business Profile review link sent to clients after service completion',
|
||||||
|
)
|
||||||
73
Entech Plating/fusion_tasks/models/res_config_settings.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = 'res.config.settings'
|
||||||
|
|
||||||
|
# Google Maps API Settings
|
||||||
|
fc_google_maps_api_key = fields.Char(
|
||||||
|
string='Google Maps API Key',
|
||||||
|
config_parameter='fusion_claims.google_maps_api_key',
|
||||||
|
help='API key for Google Maps Places autocomplete in address fields',
|
||||||
|
)
|
||||||
|
fc_google_review_url = fields.Char(
|
||||||
|
related='company_id.x_fc_google_review_url',
|
||||||
|
readonly=False,
|
||||||
|
string='Google Review URL',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Technician Management
|
||||||
|
fc_store_open_hour = fields.Float(
|
||||||
|
string='Store Open Time',
|
||||||
|
config_parameter='fusion_claims.store_open_hour',
|
||||||
|
help='Store opening time for technician scheduling (e.g. 9.0 = 9:00 AM)',
|
||||||
|
)
|
||||||
|
fc_store_close_hour = fields.Float(
|
||||||
|
string='Store Close Time',
|
||||||
|
config_parameter='fusion_claims.store_close_hour',
|
||||||
|
help='Store closing time for technician scheduling (e.g. 18.0 = 6:00 PM)',
|
||||||
|
)
|
||||||
|
fc_google_distance_matrix_enabled = fields.Boolean(
|
||||||
|
string='Enable Distance Matrix',
|
||||||
|
config_parameter='fusion_claims.google_distance_matrix_enabled',
|
||||||
|
help='Enable Google Distance Matrix API for travel time calculations between technician tasks',
|
||||||
|
)
|
||||||
|
fc_technician_start_address = fields.Char(
|
||||||
|
string='Technician Start Address',
|
||||||
|
config_parameter='fusion_claims.technician_start_address',
|
||||||
|
help='Default start location for technician travel calculations (e.g. warehouse/office address)',
|
||||||
|
)
|
||||||
|
fc_location_retention_days = fields.Char(
|
||||||
|
string='Location History Retention (Days)',
|
||||||
|
config_parameter='fusion_claims.location_retention_days',
|
||||||
|
help='How many days to keep technician location history. '
|
||||||
|
'Leave empty = 30 days (1 month). '
|
||||||
|
'0 = delete at end of each day. '
|
||||||
|
'1+ = keep for that many days.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Web Push Notifications
|
||||||
|
fc_push_enabled = fields.Boolean(
|
||||||
|
string='Enable Push Notifications',
|
||||||
|
config_parameter='fusion_claims.push_enabled',
|
||||||
|
help='Enable web push notifications for technician tasks',
|
||||||
|
)
|
||||||
|
fc_vapid_public_key = fields.Char(
|
||||||
|
string='VAPID Public Key',
|
||||||
|
config_parameter='fusion_claims.vapid_public_key',
|
||||||
|
help='Public key for Web Push VAPID authentication (auto-generated)',
|
||||||
|
)
|
||||||
|
fc_vapid_private_key = fields.Char(
|
||||||
|
string='VAPID Private Key',
|
||||||
|
config_parameter='fusion_claims.vapid_private_key',
|
||||||
|
help='Private key for Web Push VAPID authentication (auto-generated)',
|
||||||
|
)
|
||||||
|
fc_push_advance_minutes = fields.Integer(
|
||||||
|
string='Notification Advance (min)',
|
||||||
|
config_parameter='fusion_claims.push_advance_minutes',
|
||||||
|
help='Send push notifications this many minutes before a scheduled task',
|
||||||
|
)
|
||||||
79
Entech Plating/fusion_tasks/models/res_partner.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ResPartner(models.Model):
|
||||||
|
_inherit = 'res.partner'
|
||||||
|
|
||||||
|
x_fc_start_address = fields.Char(
|
||||||
|
string='Start Location',
|
||||||
|
help='Technician daily start location (home, warehouse, etc.). '
|
||||||
|
'Used as origin for first travel time calculation. '
|
||||||
|
'If empty, the company default HQ address is used.',
|
||||||
|
)
|
||||||
|
x_fc_start_address_lat = fields.Float(
|
||||||
|
string='Start Latitude', digits=(10, 7),
|
||||||
|
)
|
||||||
|
x_fc_start_address_lng = fields.Float(
|
||||||
|
string='Start Longitude', digits=(10, 7),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _geocode_start_address(self, address):
|
||||||
|
if not address or not address.strip():
|
||||||
|
return 0.0, 0.0
|
||||||
|
api_key = self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_claims.google_maps_api_key', '')
|
||||||
|
if not api_key:
|
||||||
|
return 0.0, 0.0
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
'https://maps.googleapis.com/maps/api/geocode/json',
|
||||||
|
params={'address': address.strip(), 'key': api_key, 'region': 'ca'},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
if data.get('status') == 'OK' and data.get('results'):
|
||||||
|
loc = data['results'][0]['geometry']['location']
|
||||||
|
return loc['lat'], loc['lng']
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning("Start address geocoding failed for '%s': %s", address, e)
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
records = super().create(vals_list)
|
||||||
|
for rec, vals in zip(records, vals_list):
|
||||||
|
addr = vals.get('x_fc_start_address')
|
||||||
|
if addr:
|
||||||
|
lat, lng = rec._geocode_start_address(addr)
|
||||||
|
if lat and lng:
|
||||||
|
rec.write({
|
||||||
|
'x_fc_start_address_lat': lat,
|
||||||
|
'x_fc_start_address_lng': lng,
|
||||||
|
})
|
||||||
|
return records
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
res = super().write(vals)
|
||||||
|
if 'x_fc_start_address' in vals:
|
||||||
|
addr = vals['x_fc_start_address']
|
||||||
|
if addr and addr.strip():
|
||||||
|
lat, lng = self._geocode_start_address(addr)
|
||||||
|
if lat and lng:
|
||||||
|
super().write({
|
||||||
|
'x_fc_start_address_lat': lat,
|
||||||
|
'x_fc_start_address_lng': lng,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
super().write({
|
||||||
|
'x_fc_start_address_lat': 0.0,
|
||||||
|
'x_fc_start_address_lng': 0.0,
|
||||||
|
})
|
||||||
|
return res
|
||||||
26
Entech Plating/fusion_tasks/models/res_users.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class ResUsers(models.Model):
|
||||||
|
_inherit = 'res.users'
|
||||||
|
|
||||||
|
x_fc_is_field_staff = fields.Boolean(
|
||||||
|
string='Field Staff',
|
||||||
|
default=False,
|
||||||
|
help='Check this to show the user in the Technician/Field Staff dropdown when scheduling tasks.',
|
||||||
|
)
|
||||||
|
x_fc_start_address = fields.Char(
|
||||||
|
related='partner_id.x_fc_start_address',
|
||||||
|
readonly=False,
|
||||||
|
string='Start Location',
|
||||||
|
)
|
||||||
|
x_fc_tech_sync_id = fields.Char(
|
||||||
|
string='Tech Sync ID',
|
||||||
|
help='Shared identifier for this technician across Odoo instances. '
|
||||||
|
'Must be the same value on all instances for the same person.',
|
||||||
|
copy=False,
|
||||||
|
)
|
||||||
748
Entech Plating/fusion_tasks/models/task_sync.py
Normal file
@@ -0,0 +1,748 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
"""
|
||||||
|
Cross-instance technician task sync.
|
||||||
|
|
||||||
|
Enables two Odoo instances (e.g. Westin and Mobility) that share the same
|
||||||
|
field technicians to see each other's delivery tasks, preventing double-booking.
|
||||||
|
|
||||||
|
Remote tasks appear as read-only "shadow" records in the local calendar.
|
||||||
|
The existing _find_next_available_slot() automatically sees shadow tasks,
|
||||||
|
so collision detection works without changes to the scheduling algorithm.
|
||||||
|
|
||||||
|
Technicians are matched across instances using the x_fc_tech_sync_id field
|
||||||
|
on res.users. Set the same value (e.g. "gordy") on both instances for the
|
||||||
|
same person -- no mapping table needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SYNC_TASK_FIELDS = [
|
||||||
|
'x_fc_sync_uuid', 'name', 'technician_id', 'additional_technician_ids',
|
||||||
|
'task_type', 'status',
|
||||||
|
'scheduled_date', 'time_start', 'time_end', 'duration_hours',
|
||||||
|
'address_street', 'address_street2', 'address_city', 'address_zip',
|
||||||
|
'address_state_id', 'address_buzz_code',
|
||||||
|
'address_lat', 'address_lng', 'priority', 'partner_id', 'partner_phone',
|
||||||
|
'pod_required', 'description',
|
||||||
|
'travel_time_minutes', 'travel_distance_km', 'travel_origin',
|
||||||
|
'completed_latitude', 'completed_longitude',
|
||||||
|
'action_latitude', 'action_longitude',
|
||||||
|
'completion_datetime',
|
||||||
|
]
|
||||||
|
|
||||||
|
TERMINAL_STATUSES = ('completed', 'cancelled')
|
||||||
|
|
||||||
|
|
||||||
|
class FusionTaskSyncConfig(models.Model):
|
||||||
|
_name = 'fusion.task.sync.config'
|
||||||
|
_description = 'Task Sync Remote Instance'
|
||||||
|
|
||||||
|
name = fields.Char('Instance Name', required=True,
|
||||||
|
help='e.g. Westin Healthcare, Mobility Specialties')
|
||||||
|
instance_id = fields.Char('Instance ID', required=True,
|
||||||
|
help='Short identifier, e.g. westin or mobility')
|
||||||
|
url = fields.Char('Odoo URL', required=True,
|
||||||
|
help='e.g. http://192.168.1.40:8069')
|
||||||
|
database = fields.Char('Database', required=True)
|
||||||
|
username = fields.Char('API Username', required=True)
|
||||||
|
api_key = fields.Char('API Key', required=True)
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
last_sync = fields.Datetime('Last Successful Sync', readonly=True)
|
||||||
|
last_sync_error = fields.Text('Last Error', readonly=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# JSON-RPC helpers (uses /jsonrpc dispatch, muted on receiving side)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _jsonrpc(self, service, method, args):
|
||||||
|
"""Execute a JSON-RPC call against the remote Odoo instance."""
|
||||||
|
self.ensure_one()
|
||||||
|
url = f"{self.url.rstrip('/')}/jsonrpc"
|
||||||
|
payload = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'call',
|
||||||
|
'id': 1,
|
||||||
|
'params': {
|
||||||
|
'service': service,
|
||||||
|
'method': method,
|
||||||
|
'args': args,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
if result.get('error'):
|
||||||
|
err = result['error'].get('data', {}).get('message', str(result['error']))
|
||||||
|
raise UserError(f"Remote error: {err}")
|
||||||
|
return result.get('result')
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
_logger.warning("Task sync: cannot connect to %s", self.url)
|
||||||
|
return None
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
_logger.warning("Task sync: timeout connecting to %s", self.url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _authenticate(self):
|
||||||
|
"""Authenticate with the remote instance and return the uid."""
|
||||||
|
self.ensure_one()
|
||||||
|
uid = self._jsonrpc('common', 'authenticate',
|
||||||
|
[self.database, self.username, self.api_key, {}])
|
||||||
|
if not uid:
|
||||||
|
_logger.error("Task sync: authentication failed for %s", self.name)
|
||||||
|
return uid
|
||||||
|
|
||||||
|
def _rpc(self, model, method, args, kwargs=None):
|
||||||
|
"""Execute a method on the remote instance via execute_kw."""
|
||||||
|
self.ensure_one()
|
||||||
|
uid = self._authenticate()
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
call_args = [self.database, uid, self.api_key, model, method, args]
|
||||||
|
if kwargs:
|
||||||
|
call_args.append(kwargs)
|
||||||
|
return self._jsonrpc('object', 'execute_kw', call_args)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Tech sync ID helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_local_tech_map(self):
|
||||||
|
"""Build {local_user_id: x_fc_tech_sync_id} for all local field staff."""
|
||||||
|
techs = self.env['res.users'].sudo().search([
|
||||||
|
('x_fc_is_field_staff', '=', True),
|
||||||
|
('x_fc_tech_sync_id', '!=', False),
|
||||||
|
('active', '=', True),
|
||||||
|
])
|
||||||
|
return {u.id: u.x_fc_tech_sync_id for u in techs}
|
||||||
|
|
||||||
|
def _get_remote_tech_map(self):
|
||||||
|
"""Build {x_fc_tech_sync_id: remote_user_id} from the remote instance."""
|
||||||
|
self.ensure_one()
|
||||||
|
remote_users = self._rpc('res.users', 'search_read', [
|
||||||
|
[('x_fc_is_field_staff', '=', True),
|
||||||
|
('x_fc_tech_sync_id', '!=', False),
|
||||||
|
('active', '=', True)],
|
||||||
|
], {'fields': ['id', 'x_fc_tech_sync_id']})
|
||||||
|
if not remote_users:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
ru['x_fc_tech_sync_id']: ru['id']
|
||||||
|
for ru in remote_users
|
||||||
|
if ru.get('x_fc_tech_sync_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_local_syncid_to_uid(self):
|
||||||
|
"""Build {x_fc_tech_sync_id: local_user_id} for local field staff."""
|
||||||
|
techs = self.env['res.users'].sudo().search([
|
||||||
|
('x_fc_is_field_staff', '=', True),
|
||||||
|
('x_fc_tech_sync_id', '!=', False),
|
||||||
|
('active', '=', True),
|
||||||
|
])
|
||||||
|
return {u.x_fc_tech_sync_id: u.id for u in techs}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Connection test
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def action_test_connection(self):
|
||||||
|
"""Test the connection to the remote instance."""
|
||||||
|
self.ensure_one()
|
||||||
|
uid = self._authenticate()
|
||||||
|
if uid:
|
||||||
|
remote_map = self._get_remote_tech_map()
|
||||||
|
local_map = self._get_local_tech_map()
|
||||||
|
matched = set(local_map.values()) & set(remote_map.keys())
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': 'Connection Successful',
|
||||||
|
'message': f'Connected to {self.name}. '
|
||||||
|
f'{len(matched)} technician(s) matched by sync ID.',
|
||||||
|
'type': 'success',
|
||||||
|
'sticky': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
raise UserError(f"Cannot connect to {self.name}. Check URL, database, and API key.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PUSH: send local task changes to remote instance
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_local_instance_id(self):
|
||||||
|
"""Return this instance's own ID from config parameters."""
|
||||||
|
return self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_claims.sync_instance_id', '')
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _push_tasks(self, tasks, operation='create'):
|
||||||
|
"""Push local task changes to all active remote instances.
|
||||||
|
Called from technician_task create/write overrides.
|
||||||
|
Non-blocking: errors are logged, not raised.
|
||||||
|
"""
|
||||||
|
configs = self.sudo().search([('active', '=', True)])
|
||||||
|
if not configs:
|
||||||
|
return
|
||||||
|
local_id = configs[0]._get_local_instance_id()
|
||||||
|
if not local_id:
|
||||||
|
return
|
||||||
|
for config in configs:
|
||||||
|
try:
|
||||||
|
config._push_tasks_to_remote(tasks, operation, local_id)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception("Task sync push to %s failed", config.name)
|
||||||
|
|
||||||
|
def _push_tasks_to_remote(self, tasks, operation, local_instance_id):
|
||||||
|
"""Push task data to a single remote instance.
|
||||||
|
|
||||||
|
Maps additional_technician_ids via sync IDs so the remote instance
|
||||||
|
also blocks those technicians' schedules.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
local_map = self._get_local_tech_map()
|
||||||
|
remote_map = self._get_remote_tech_map()
|
||||||
|
if not local_map or not remote_map:
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
sync_id = local_map.get(task.technician_id.id)
|
||||||
|
if not sync_id:
|
||||||
|
continue
|
||||||
|
remote_tech_uid = remote_map.get(sync_id)
|
||||||
|
if not remote_tech_uid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Map additional technicians to remote user IDs
|
||||||
|
remote_additional_ids = []
|
||||||
|
for tech in task.additional_technician_ids:
|
||||||
|
add_sync_id = local_map.get(tech.id)
|
||||||
|
if add_sync_id:
|
||||||
|
remote_add_uid = remote_map.get(add_sync_id)
|
||||||
|
if remote_add_uid:
|
||||||
|
remote_additional_ids.append(remote_add_uid)
|
||||||
|
|
||||||
|
task_data = {
|
||||||
|
'x_fc_sync_uuid': task.x_fc_sync_uuid,
|
||||||
|
'x_fc_sync_source': local_instance_id,
|
||||||
|
'x_fc_sync_remote_id': task.id,
|
||||||
|
'name': f"[{local_instance_id.upper()}] {task.name}",
|
||||||
|
'technician_id': remote_tech_uid,
|
||||||
|
'additional_technician_ids': [(6, 0, remote_additional_ids)],
|
||||||
|
'task_type': task.task_type,
|
||||||
|
'status': task.status,
|
||||||
|
'scheduled_date': str(task.scheduled_date) if task.scheduled_date else False,
|
||||||
|
'time_start': task.time_start,
|
||||||
|
'time_end': task.time_end,
|
||||||
|
'duration_hours': task.duration_hours,
|
||||||
|
'address_street': task.address_street or '',
|
||||||
|
'address_street2': task.address_street2 or '',
|
||||||
|
'address_city': task.address_city or '',
|
||||||
|
'address_zip': task.address_zip or '',
|
||||||
|
'address_lat': float(task.address_lat or 0),
|
||||||
|
'address_lng': float(task.address_lng or 0),
|
||||||
|
'priority': task.priority or 'normal',
|
||||||
|
'x_fc_sync_client_name': task.partner_id.name if task.partner_id else '',
|
||||||
|
'travel_time_minutes': task.travel_time_minutes or 0,
|
||||||
|
'travel_distance_km': float(task.travel_distance_km or 0),
|
||||||
|
'travel_origin': task.travel_origin or '',
|
||||||
|
'completed_latitude': float(task.completed_latitude or 0),
|
||||||
|
'completed_longitude': float(task.completed_longitude or 0),
|
||||||
|
'action_latitude': float(task.action_latitude or 0),
|
||||||
|
'action_longitude': float(task.action_longitude or 0),
|
||||||
|
}
|
||||||
|
if task.completion_datetime:
|
||||||
|
task_data['completion_datetime'] = str(task.completion_datetime)
|
||||||
|
|
||||||
|
existing = self._rpc(
|
||||||
|
'fusion.technician.task', 'search',
|
||||||
|
[[('x_fc_sync_uuid', '=', task.x_fc_sync_uuid)]],
|
||||||
|
{'limit': 1})
|
||||||
|
|
||||||
|
if operation in ('create', 'write'):
|
||||||
|
if existing:
|
||||||
|
self._rpc('fusion.technician.task', 'write',
|
||||||
|
[existing, task_data], ctx)
|
||||||
|
elif operation == 'create':
|
||||||
|
task_data['sale_order_id'] = False
|
||||||
|
self._rpc('fusion.technician.task', 'create',
|
||||||
|
[[task_data]], ctx)
|
||||||
|
|
||||||
|
elif operation == 'unlink' and existing:
|
||||||
|
self._rpc('fusion.technician.task', 'write',
|
||||||
|
[existing, {'status': 'cancelled', 'active': False}], ctx)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _push_shadow_status(self, shadow_tasks):
|
||||||
|
"""Push local status changes on shadow tasks back to their source instance.
|
||||||
|
|
||||||
|
When a tech changes a shadow task status locally, update the original
|
||||||
|
task on the remote instance and trigger the appropriate client emails
|
||||||
|
there. Only the parent (originating) instance sends client-facing
|
||||||
|
emails -- the child instance skips them via x_fc_sync_source guards.
|
||||||
|
"""
|
||||||
|
configs = self.sudo().search([('active', '=', True)])
|
||||||
|
config_by_instance = {c.instance_id: c for c in configs}
|
||||||
|
ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}
|
||||||
|
|
||||||
|
for task in shadow_tasks:
|
||||||
|
config = config_by_instance.get(task.x_fc_sync_source)
|
||||||
|
if not config or not task.x_fc_sync_remote_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
update_vals = {'status': task.status}
|
||||||
|
if task.status == 'completed' and task.completion_datetime:
|
||||||
|
update_vals['completion_datetime'] = str(task.completion_datetime)
|
||||||
|
if task.completed_latitude and task.completed_longitude:
|
||||||
|
update_vals['completed_latitude'] = task.completed_latitude
|
||||||
|
update_vals['completed_longitude'] = task.completed_longitude
|
||||||
|
if task.action_latitude and task.action_longitude:
|
||||||
|
update_vals['action_latitude'] = task.action_latitude
|
||||||
|
update_vals['action_longitude'] = task.action_longitude
|
||||||
|
config._rpc(
|
||||||
|
'fusion.technician.task', 'write',
|
||||||
|
[[task.x_fc_sync_remote_id], update_vals], ctx)
|
||||||
|
_logger.info(
|
||||||
|
"Pushed status '%s' for shadow task %s back to %s (remote id %d)",
|
||||||
|
task.status, task.name, config.name, task.x_fc_sync_remote_id)
|
||||||
|
self._trigger_parent_notifications(config, task)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
"Failed to push status for shadow task %s to %s",
|
||||||
|
task.name, config.name)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _push_technician_location(self, user_id, latitude, longitude, accuracy=0):
|
||||||
|
"""Push a technician's location update to all remote instances.
|
||||||
|
|
||||||
|
Called when a technician performs a task action (en_route, complete)
|
||||||
|
so the other instance immediately knows where the tech is, without
|
||||||
|
waiting for the next pull cron cycle.
|
||||||
|
"""
|
||||||
|
configs = self.sudo().search([('active', '=', True)])
|
||||||
|
if not configs:
|
||||||
|
return
|
||||||
|
local_map = configs[0]._get_local_tech_map()
|
||||||
|
sync_id = local_map.get(user_id)
|
||||||
|
if not sync_id:
|
||||||
|
return
|
||||||
|
for config in configs:
|
||||||
|
try:
|
||||||
|
remote_map = config._get_remote_tech_map()
|
||||||
|
remote_uid = remote_map.get(sync_id)
|
||||||
|
if not remote_uid:
|
||||||
|
continue
|
||||||
|
# Create location record on remote instance
|
||||||
|
config._rpc(
|
||||||
|
'fusion.technician.location', 'create',
|
||||||
|
[[{
|
||||||
|
'user_id': remote_uid,
|
||||||
|
'latitude': latitude,
|
||||||
|
'longitude': longitude,
|
||||||
|
'accuracy': accuracy,
|
||||||
|
'source': 'sync',
|
||||||
|
'sync_instance': configs[0]._get_local_instance_id(),
|
||||||
|
}]])
|
||||||
|
except Exception:
|
||||||
|
_logger.warning(
|
||||||
|
"Failed to push location for tech %s to %s",
|
||||||
|
user_id, config.name)
|
||||||
|
|
||||||
|
def _trigger_parent_notifications(self, config, task):
|
||||||
|
"""After pushing a shadow status, trigger appropriate emails and
|
||||||
|
notifications on the parent instance so the client gets notified
|
||||||
|
exactly once (from the originating instance only)."""
|
||||||
|
remote_id = task.x_fc_sync_remote_id
|
||||||
|
if task.status == 'completed':
|
||||||
|
for method in ('_notify_scheduler_on_completion',
|
||||||
|
'_send_task_completion_email'):
|
||||||
|
try:
|
||||||
|
config._rpc('fusion.technician.task', method, [[remote_id]])
|
||||||
|
except Exception:
|
||||||
|
_logger.warning(
|
||||||
|
"Could not call %s on remote for %s", method, task.name)
|
||||||
|
elif task.status == 'en_route':
|
||||||
|
try:
|
||||||
|
config._rpc(
|
||||||
|
'fusion.technician.task',
|
||||||
|
'_send_task_en_route_email', [[remote_id]])
|
||||||
|
except Exception:
|
||||||
|
_logger.warning(
|
||||||
|
"Could not trigger en-route email on remote for %s",
|
||||||
|
task.name)
|
||||||
|
elif task.status == 'cancelled':
|
||||||
|
try:
|
||||||
|
config._rpc(
|
||||||
|
'fusion.technician.task',
|
||||||
|
'_send_task_cancelled_email', [[remote_id]])
|
||||||
|
except Exception:
|
||||||
|
_logger.warning(
|
||||||
|
"Could not trigger cancel email on remote for %s",
|
||||||
|
task.name)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PULL: cron-based full reconciliation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_pull_remote_tasks(self):
|
||||||
|
"""Cron job: pull tasks and technician locations from all active remote instances."""
|
||||||
|
configs = self.sudo().search([('active', '=', True)])
|
||||||
|
for config in configs:
|
||||||
|
try:
|
||||||
|
config._pull_tasks_from_remote()
|
||||||
|
config._pull_technician_locations()
|
||||||
|
config.sudo().write({
|
||||||
|
'last_sync': fields.Datetime.now(),
|
||||||
|
'last_sync_error': False,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("Task sync pull from %s failed", config.name)
|
||||||
|
config.sudo().write({'last_sync_error': str(e)})
|
||||||
|
|
||||||
|
def _pull_tasks_from_remote(self):
|
||||||
|
"""Pull all active tasks for matched technicians from the remote instance.
|
||||||
|
|
||||||
|
After syncing, recalculates travel chains for all affected tech+date
|
||||||
|
combos so route planning accounts for both local and shadow tasks.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
local_syncid_to_uid = self._get_local_syncid_to_uid()
|
||||||
|
if not local_syncid_to_uid:
|
||||||
|
return
|
||||||
|
|
||||||
|
remote_map = self._get_remote_tech_map()
|
||||||
|
if not remote_map:
|
||||||
|
return
|
||||||
|
|
||||||
|
matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
|
||||||
|
if not matched_sync_ids:
|
||||||
|
_logger.info("Task sync: no matched technicians between local and %s", self.name)
|
||||||
|
return
|
||||||
|
|
||||||
|
remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
|
||||||
|
remote_syncid_by_uid = {v: k for k, v in remote_map.items()}
|
||||||
|
|
||||||
|
cutoff = fields.Date.today() - timedelta(days=7)
|
||||||
|
remote_tasks = self._rpc(
|
||||||
|
'fusion.technician.task', 'search_read',
|
||||||
|
[[
|
||||||
|
'|',
|
||||||
|
('technician_id', 'in', remote_tech_ids),
|
||||||
|
('additional_technician_ids', 'in', remote_tech_ids),
|
||||||
|
('scheduled_date', '>=', str(cutoff)),
|
||||||
|
('x_fc_sync_source', '=', False),
|
||||||
|
]],
|
||||||
|
{'fields': SYNC_TASK_FIELDS + ['id']})
|
||||||
|
|
||||||
|
if remote_tasks is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
Task = self.env['fusion.technician.task'].sudo().with_context(
|
||||||
|
skip_task_sync=True, skip_travel_recalc=True)
|
||||||
|
|
||||||
|
remote_uuids = set()
|
||||||
|
affected_combos = set()
|
||||||
|
|
||||||
|
for rt in remote_tasks:
|
||||||
|
sync_uuid = rt.get('x_fc_sync_uuid')
|
||||||
|
if not sync_uuid:
|
||||||
|
continue
|
||||||
|
remote_uuids.add(sync_uuid)
|
||||||
|
|
||||||
|
remote_tech_raw = rt['technician_id']
|
||||||
|
remote_uid = remote_tech_raw[0] if isinstance(remote_tech_raw, (list, tuple)) else remote_tech_raw
|
||||||
|
tech_sync_id = remote_syncid_by_uid.get(remote_uid)
|
||||||
|
local_uid = local_syncid_to_uid.get(tech_sync_id) if tech_sync_id else None
|
||||||
|
if not local_uid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
partner_raw = rt.get('partner_id')
|
||||||
|
client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else ''
|
||||||
|
client_phone = rt.get('partner_phone', '') or ''
|
||||||
|
|
||||||
|
state_raw = rt.get('address_state_id')
|
||||||
|
state_name = ''
|
||||||
|
if isinstance(state_raw, (list, tuple)) and len(state_raw) > 1:
|
||||||
|
state_name = state_raw[1]
|
||||||
|
|
||||||
|
# Map additional technicians from remote to local
|
||||||
|
local_additional_ids = []
|
||||||
|
remote_add_raw = rt.get('additional_technician_ids', [])
|
||||||
|
if remote_add_raw and isinstance(remote_add_raw, list):
|
||||||
|
for add_uid in remote_add_raw:
|
||||||
|
add_sync_id = remote_syncid_by_uid.get(add_uid)
|
||||||
|
if add_sync_id:
|
||||||
|
local_add_uid = local_syncid_to_uid.get(add_sync_id)
|
||||||
|
if local_add_uid:
|
||||||
|
local_additional_ids.append(local_add_uid)
|
||||||
|
|
||||||
|
sched_date = rt.get('scheduled_date')
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'x_fc_sync_uuid': sync_uuid,
|
||||||
|
'x_fc_sync_source': self.instance_id,
|
||||||
|
'x_fc_sync_remote_id': rt['id'],
|
||||||
|
'name': f"[{self.instance_id.upper()}] {rt.get('name', '')}",
|
||||||
|
'technician_id': local_uid,
|
||||||
|
'additional_technician_ids': [(6, 0, local_additional_ids)],
|
||||||
|
'task_type': rt.get('task_type', 'delivery'),
|
||||||
|
'status': rt.get('status', 'scheduled'),
|
||||||
|
'scheduled_date': sched_date,
|
||||||
|
'time_start': rt.get('time_start', 9.0),
|
||||||
|
'time_end': rt.get('time_end', 10.0),
|
||||||
|
'duration_hours': rt.get('duration_hours', 1.0),
|
||||||
|
'address_street': rt.get('address_street', ''),
|
||||||
|
'address_street2': rt.get('address_street2', ''),
|
||||||
|
'address_city': rt.get('address_city', ''),
|
||||||
|
'address_zip': rt.get('address_zip', ''),
|
||||||
|
'address_buzz_code': rt.get('address_buzz_code', ''),
|
||||||
|
'address_lat': rt.get('address_lat', 0),
|
||||||
|
'address_lng': rt.get('address_lng', 0),
|
||||||
|
'priority': rt.get('priority', 'normal'),
|
||||||
|
'pod_required': rt.get('pod_required', False),
|
||||||
|
'description': rt.get('description', ''),
|
||||||
|
'x_fc_sync_client_name': client_name,
|
||||||
|
'x_fc_sync_client_phone': client_phone,
|
||||||
|
'travel_time_minutes': rt.get('travel_time_minutes', 0),
|
||||||
|
'travel_distance_km': rt.get('travel_distance_km', 0),
|
||||||
|
'travel_origin': rt.get('travel_origin', ''),
|
||||||
|
'completed_latitude': rt.get('completed_latitude', 0),
|
||||||
|
'completed_longitude': rt.get('completed_longitude', 0),
|
||||||
|
'action_latitude': rt.get('action_latitude', 0),
|
||||||
|
'action_longitude': rt.get('action_longitude', 0),
|
||||||
|
}
|
||||||
|
if rt.get('completion_datetime'):
|
||||||
|
vals['completion_datetime'] = rt['completion_datetime']
|
||||||
|
|
||||||
|
if state_name:
|
||||||
|
state_rec = self.env['res.country.state'].sudo().search(
|
||||||
|
[('name', '=', state_name)], limit=1)
|
||||||
|
if state_rec:
|
||||||
|
vals['address_state_id'] = state_rec.id
|
||||||
|
|
||||||
|
existing = Task.search([('x_fc_sync_uuid', '=', sync_uuid)], limit=1)
|
||||||
|
if existing:
|
||||||
|
if existing.status in TERMINAL_STATUSES:
|
||||||
|
vals.pop('status', None)
|
||||||
|
existing.write(vals)
|
||||||
|
else:
|
||||||
|
vals['sale_order_id'] = False
|
||||||
|
Task.create([vals])
|
||||||
|
|
||||||
|
if sched_date:
|
||||||
|
affected_combos.add((local_uid, sched_date))
|
||||||
|
for add_uid in local_additional_ids:
|
||||||
|
affected_combos.add((add_uid, sched_date))
|
||||||
|
|
||||||
|
stale_shadows = Task.search([
|
||||||
|
('x_fc_sync_source', '=', self.instance_id),
|
||||||
|
('x_fc_sync_uuid', 'not in', list(remote_uuids)),
|
||||||
|
('scheduled_date', '>=', str(cutoff)),
|
||||||
|
('active', '=', True),
|
||||||
|
])
|
||||||
|
if stale_shadows:
|
||||||
|
for st in stale_shadows:
|
||||||
|
if st.scheduled_date and st.technician_id:
|
||||||
|
affected_combos.add((st.technician_id.id, st.scheduled_date))
|
||||||
|
for tech in st.additional_technician_ids:
|
||||||
|
if st.scheduled_date:
|
||||||
|
affected_combos.add((tech.id, st.scheduled_date))
|
||||||
|
stale_shadows.write({'active': False, 'status': 'cancelled'})
|
||||||
|
_logger.info("Deactivated %d stale shadow tasks from %s",
|
||||||
|
len(stale_shadows), self.instance_id)
|
||||||
|
|
||||||
|
if affected_combos:
|
||||||
|
today = fields.Date.today()
|
||||||
|
today_str = str(today)
|
||||||
|
future_combos = set()
|
||||||
|
for tid, d in affected_combos:
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
d_str = str(d) if not isinstance(d, str) else d
|
||||||
|
if d_str >= today_str:
|
||||||
|
future_combos.add((tid, d_str))
|
||||||
|
if future_combos:
|
||||||
|
TaskModel = self.env['fusion.technician.task'].sudo()
|
||||||
|
try:
|
||||||
|
ungeocode = TaskModel.search([
|
||||||
|
('x_fc_sync_source', '=', self.instance_id),
|
||||||
|
('active', '=', True),
|
||||||
|
('scheduled_date', '>=', today_str),
|
||||||
|
('status', 'not in', ['cancelled']),
|
||||||
|
'|',
|
||||||
|
('address_lat', '=', 0), ('address_lat', '=', False),
|
||||||
|
])
|
||||||
|
geocoded = 0
|
||||||
|
for shadow in ungeocode:
|
||||||
|
if shadow.address_display:
|
||||||
|
if shadow.with_context(skip_travel_recalc=True)._geocode_address():
|
||||||
|
geocoded += 1
|
||||||
|
if geocoded:
|
||||||
|
_logger.info("Geocoded %d shadow tasks from %s",
|
||||||
|
geocoded, self.name)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
"Shadow task geocoding after sync from %s failed", self.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
TaskModel._recalculate_combos_travel(future_combos)
|
||||||
|
_logger.info(
|
||||||
|
"Recalculated travel for %d tech+date combos after sync from %s",
|
||||||
|
len(future_combos), self.name)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
"Travel recalculation after sync from %s failed", self.name)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PULL: technician locations from remote instance
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _pull_technician_locations(self):
|
||||||
|
"""Pull latest GPS locations for matched technicians from the remote instance.
|
||||||
|
|
||||||
|
Creates local location records with source='sync' so the map view
|
||||||
|
shows technician positions from both instances. Only keeps the single
|
||||||
|
most recent synced location per technician (replaces older synced
|
||||||
|
records to avoid clutter).
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
local_syncid_to_uid = self._get_local_syncid_to_uid()
|
||||||
|
if not local_syncid_to_uid:
|
||||||
|
return
|
||||||
|
|
||||||
|
remote_map = self._get_remote_tech_map()
|
||||||
|
if not remote_map:
|
||||||
|
return
|
||||||
|
|
||||||
|
matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
|
||||||
|
if not matched_sync_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
|
||||||
|
remote_syncid_by_uid = {v: k for k, v in remote_map.items()}
|
||||||
|
|
||||||
|
remote_locations = self._rpc(
|
||||||
|
'fusion.technician.location', 'search_read',
|
||||||
|
[[
|
||||||
|
('user_id', 'in', remote_tech_ids),
|
||||||
|
('logged_at', '>', str(fields.Datetime.subtract(
|
||||||
|
fields.Datetime.now(), hours=24))),
|
||||||
|
('source', '!=', 'sync'),
|
||||||
|
]],
|
||||||
|
{
|
||||||
|
'fields': ['user_id', 'latitude', 'longitude',
|
||||||
|
'accuracy', 'logged_at'],
|
||||||
|
'order': 'logged_at desc',
|
||||||
|
})
|
||||||
|
|
||||||
|
if not remote_locations:
|
||||||
|
return
|
||||||
|
|
||||||
|
Location = self.env['fusion.technician.location'].sudo()
|
||||||
|
|
||||||
|
seen_techs = set()
|
||||||
|
synced_count = 0
|
||||||
|
for rloc in remote_locations:
|
||||||
|
remote_uid_raw = rloc['user_id']
|
||||||
|
remote_uid = (remote_uid_raw[0]
|
||||||
|
if isinstance(remote_uid_raw, (list, tuple))
|
||||||
|
else remote_uid_raw)
|
||||||
|
if remote_uid in seen_techs:
|
||||||
|
continue
|
||||||
|
seen_techs.add(remote_uid)
|
||||||
|
|
||||||
|
sync_id = remote_syncid_by_uid.get(remote_uid)
|
||||||
|
local_uid = local_syncid_to_uid.get(sync_id) if sync_id else None
|
||||||
|
if not local_uid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lat = rloc.get('latitude', 0)
|
||||||
|
lng = rloc.get('longitude', 0)
|
||||||
|
if not lat or not lng:
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_synced = Location.search([
|
||||||
|
('user_id', '=', local_uid),
|
||||||
|
('source', '=', 'sync'),
|
||||||
|
('sync_instance', '=', self.instance_id),
|
||||||
|
])
|
||||||
|
if old_synced:
|
||||||
|
old_synced.unlink()
|
||||||
|
|
||||||
|
Location.create({
|
||||||
|
'user_id': local_uid,
|
||||||
|
'latitude': lat,
|
||||||
|
'longitude': lng,
|
||||||
|
'accuracy': rloc.get('accuracy', 0),
|
||||||
|
'logged_at': rloc.get('logged_at', fields.Datetime.now()),
|
||||||
|
'source': 'sync',
|
||||||
|
'sync_instance': self.instance_id,
|
||||||
|
})
|
||||||
|
synced_count += 1
|
||||||
|
|
||||||
|
if synced_count:
|
||||||
|
_logger.info("Synced %d technician location(s) from %s",
|
||||||
|
synced_count, self.name)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CLEANUP
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_cleanup_old_shadows(self):
|
||||||
|
"""Remove shadow tasks older than 30 days (completed/cancelled)."""
|
||||||
|
cutoff = fields.Date.today() - timedelta(days=30)
|
||||||
|
old_shadows = self.env['fusion.technician.task'].sudo().search([
|
||||||
|
('x_fc_sync_source', '!=', False),
|
||||||
|
('scheduled_date', '<', str(cutoff)),
|
||||||
|
('status', 'in', ['completed', 'cancelled']),
|
||||||
|
])
|
||||||
|
if old_shadows:
|
||||||
|
count = len(old_shadows)
|
||||||
|
old_shadows.unlink()
|
||||||
|
_logger.info("Cleaned up %d old shadow tasks", count)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Manual trigger
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def action_sync_now(self):
|
||||||
|
"""Manually trigger a full sync for this config."""
|
||||||
|
self.ensure_one()
|
||||||
|
self._pull_tasks_from_remote()
|
||||||
|
self._pull_technician_locations()
|
||||||
|
self.sudo().write({
|
||||||
|
'last_sync': fields.Datetime.now(),
|
||||||
|
'last_sync_error': False,
|
||||||
|
})
|
||||||
|
shadow_count = self.env['fusion.technician.task'].sudo().search_count([
|
||||||
|
('x_fc_sync_source', '=', self.instance_id),
|
||||||
|
])
|
||||||
|
loc_count = self.env['fusion.technician.location'].sudo().search_count([
|
||||||
|
('source', '=', 'sync'),
|
||||||
|
('sync_instance', '=', self.instance_id),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': 'Sync Complete',
|
||||||
|
'message': (f'Synced from {self.name}. '
|
||||||
|
f'{shadow_count} shadow task(s), '
|
||||||
|
f'{loc_count} technician location(s) visible.'),
|
||||||
|
'type': 'success',
|
||||||
|
'sticky': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
131
Entech Plating/fusion_tasks/models/technician_location.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
"""
|
||||||
|
Fusion Technician Location
|
||||||
|
GPS location logging for field technicians.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionTechnicianLocation(models.Model):
|
||||||
|
_name = 'fusion.technician.location'
|
||||||
|
_description = 'Technician Location Log'
|
||||||
|
_order = 'logged_at desc'
|
||||||
|
|
||||||
|
user_id = fields.Many2one(
|
||||||
|
'res.users',
|
||||||
|
string='Technician',
|
||||||
|
required=True,
|
||||||
|
index=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
)
|
||||||
|
latitude = fields.Float(
|
||||||
|
string='Latitude',
|
||||||
|
digits=(10, 7),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
longitude = fields.Float(
|
||||||
|
string='Longitude',
|
||||||
|
digits=(10, 7),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
accuracy = fields.Float(
|
||||||
|
string='Accuracy (m)',
|
||||||
|
help='GPS accuracy in meters',
|
||||||
|
)
|
||||||
|
logged_at = fields.Datetime(
|
||||||
|
string='Logged At',
|
||||||
|
default=fields.Datetime.now,
|
||||||
|
required=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source = fields.Selection([
|
||||||
|
('portal', 'Portal'),
|
||||||
|
('app', 'Mobile App'),
|
||||||
|
('sync', 'Synced'),
|
||||||
|
], string='Source', default='portal')
|
||||||
|
sync_instance = fields.Char(
|
||||||
|
'Sync Instance', index=True,
|
||||||
|
help='Source instance ID if synced (e.g. westin, mobility)',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def log_location(self, latitude, longitude, accuracy=None):
|
||||||
|
"""Log the current user's location. Called from portal JS."""
|
||||||
|
return self.sudo().create({
|
||||||
|
'user_id': self.env.user.id,
|
||||||
|
'latitude': latitude,
|
||||||
|
'longitude': longitude,
|
||||||
|
'accuracy': accuracy or 0,
|
||||||
|
'source': 'portal',
|
||||||
|
})
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_latest_locations(self):
|
||||||
|
"""Get the most recent location for each technician (for map view).
|
||||||
|
|
||||||
|
Includes both local GPS pings and synced locations from remote
|
||||||
|
instances, so the map shows all shared technicians regardless of
|
||||||
|
which Odoo instance they are clocked into.
|
||||||
|
"""
|
||||||
|
self.env.cr.execute("""
|
||||||
|
SELECT DISTINCT ON (user_id)
|
||||||
|
user_id, latitude, longitude, accuracy, logged_at,
|
||||||
|
COALESCE(sync_instance, '') AS sync_instance
|
||||||
|
FROM fusion_technician_location
|
||||||
|
WHERE logged_at > NOW() - INTERVAL '24 hours'
|
||||||
|
ORDER BY user_id, logged_at DESC
|
||||||
|
""")
|
||||||
|
rows = self.env.cr.dictfetchall()
|
||||||
|
local_id = self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_claims.sync_instance_id', '')
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
user = self.env['res.users'].sudo().browse(row['user_id'])
|
||||||
|
src = row.get('sync_instance') or local_id
|
||||||
|
result.append({
|
||||||
|
'user_id': row['user_id'],
|
||||||
|
'name': user.name,
|
||||||
|
'latitude': row['latitude'],
|
||||||
|
'longitude': row['longitude'],
|
||||||
|
'accuracy': row['accuracy'],
|
||||||
|
'logged_at': str(row['logged_at']),
|
||||||
|
'sync_instance': src,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_cleanup_old_locations(self):
|
||||||
|
"""Remove location logs based on configurable retention setting.
|
||||||
|
|
||||||
|
Setting (fusion_claims.location_retention_days):
|
||||||
|
- Empty / not set => keep 30 days (default)
|
||||||
|
- "0" => delete at end of day (keep today only)
|
||||||
|
- "1" .. "N" => keep for N days
|
||||||
|
"""
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
raw = (ICP.get_param('fusion_claims.location_retention_days') or '').strip()
|
||||||
|
|
||||||
|
if raw == '':
|
||||||
|
retention_days = 30 # default: 1 month
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
retention_days = max(int(raw), 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
retention_days = 30
|
||||||
|
|
||||||
|
cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=retention_days)
|
||||||
|
old_records = self.search([('logged_at', '<', cutoff)])
|
||||||
|
count = len(old_records)
|
||||||
|
if count:
|
||||||
|
old_records.unlink()
|
||||||
|
_logger.info(
|
||||||
|
"Cleaned up %d technician location records (retention=%d days)",
|
||||||
|
count, retention_days,
|
||||||
|
)
|
||||||
3028
Entech Plating/fusion_tasks/models/technician_task.py
Normal file
12
Entech Plating/fusion_tasks/security/ir.model.access.csv
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_fusion_technician_task_user,fusion.technician.task.user,model_fusion_technician_task,sales_team.group_sale_salesman,1,1,1,0
|
||||||
|
access_fusion_technician_task_manager,fusion.technician.task.manager,model_fusion_technician_task,sales_team.group_sale_manager,1,1,1,1
|
||||||
|
access_fusion_technician_task_technician,fusion.technician.task.technician,model_fusion_technician_task,fusion_tasks.group_field_technician,1,1,0,0
|
||||||
|
access_fusion_technician_task_portal,fusion.technician.task.portal,model_fusion_technician_task,base.group_portal,1,0,0,0
|
||||||
|
access_fusion_push_subscription_user,fusion.push.subscription.user,model_fusion_push_subscription,base.group_user,1,1,1,0
|
||||||
|
access_fusion_push_subscription_portal,fusion.push.subscription.portal,model_fusion_push_subscription,base.group_portal,1,1,1,0
|
||||||
|
access_fusion_technician_location_manager,fusion.technician.location.manager,model_fusion_technician_location,sales_team.group_sale_manager,1,1,1,1
|
||||||
|
access_fusion_technician_location_user,fusion.technician.location.user,model_fusion_technician_location,sales_team.group_sale_salesman,1,0,0,0
|
||||||
|
access_fusion_technician_location_portal,fusion.technician.location.portal,model_fusion_technician_location,base.group_portal,0,0,1,0
|
||||||
|
access_fusion_task_sync_config_manager,fusion.task.sync.config.manager,model_fusion_task_sync_config,sales_team.group_sale_manager,1,1,1,1
|
||||||
|
access_fusion_task_sync_config_user,fusion.task.sync.config.user,model_fusion_task_sync_config,sales_team.group_sale_salesman,1,0,0,0
|
||||||
|
103
Entech Plating/fusion_tasks/security/security.xml
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- MODULE CATEGORY -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="module_category_fusion_tasks" model="ir.module.category">
|
||||||
|
<field name="name">Fusion Tasks</field>
|
||||||
|
<field name="sequence">46</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- FUSION TASKS PRIVILEGE (Odoo 19 pattern) -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="res_groups_privilege_fusion_tasks" model="res.groups.privilege">
|
||||||
|
<field name="name">Fusion Tasks</field>
|
||||||
|
<field name="sequence">46</field>
|
||||||
|
<field name="category_id" ref="module_category_fusion_tasks"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- FIELD TECHNICIAN GROUP -->
|
||||||
|
<!-- Standalone group safe for both portal and internal users. -->
|
||||||
|
<!-- Do NOT imply base.group_user — that chain conflicts with portal -->
|
||||||
|
<!-- users (share=True). -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="group_field_technician" model="res.groups">
|
||||||
|
<field name="name">Field Technician</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_fusion_tasks"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- TECHNICIAN TASK RECORD RULES -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
|
||||||
|
<!-- Managers: full access to all tasks -->
|
||||||
|
<record id="rule_technician_task_manager" model="ir.rule">
|
||||||
|
<field name="name">Technician Task: Manager Full Access</field>
|
||||||
|
<field name="model_id" ref="model_fusion_technician_task"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('sales_team.group_sale_manager'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="True"/>
|
||||||
|
<field name="perm_create" eval="True"/>
|
||||||
|
<field name="perm_unlink" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Sales users: read/write all tasks, create tasks -->
|
||||||
|
<record id="rule_technician_task_sales_user" model="ir.rule">
|
||||||
|
<field name="name">Technician Task: Sales User Access</field>
|
||||||
|
<field name="model_id" ref="model_fusion_technician_task"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="True"/>
|
||||||
|
<field name="perm_create" eval="True"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Field Technicians (internal): own tasks only -->
|
||||||
|
<record id="rule_technician_task_technician" model="ir.rule">
|
||||||
|
<field name="name">Technician Task: Technician Own Tasks</field>
|
||||||
|
<field name="model_id" ref="model_fusion_technician_task"/>
|
||||||
|
<field name="domain_force">[('technician_id', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_field_technician'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="True"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Portal technicians: own tasks only, read + limited write -->
|
||||||
|
<record id="rule_technician_task_portal" model="ir.rule">
|
||||||
|
<field name="name">Technician Task: Portal Technician Access</field>
|
||||||
|
<field name="model_id" ref="model_fusion_technician_task"/>
|
||||||
|
<field name="domain_force">[('technician_id', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- PUSH SUBSCRIPTION RECORD RULES -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
|
||||||
|
<!-- Users: own subscriptions only -->
|
||||||
|
<record id="rule_push_subscription_user" model="ir.rule">
|
||||||
|
<field name="name">Push Subscription: Own Only</field>
|
||||||
|
<field name="model_id" ref="model_fusion_push_subscription"/>
|
||||||
|
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Portal: own subscriptions only -->
|
||||||
|
<record id="rule_push_subscription_portal" model="ir.rule">
|
||||||
|
<field name="name">Push Subscription: Portal Own Only</field>
|
||||||
|
<field name="model_id" ref="model_fusion_push_subscription"/>
|
||||||
|
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
BIN
Entech Plating/fusion_tasks/static/description/icon.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,488 @@
|
|||||||
|
// =====================================================================
|
||||||
|
// Fusion Task Map View - Sidebar + Google Maps
|
||||||
|
// Theme-aware: uses Odoo/Bootstrap variables for dark mode support
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
$sidebar-width: 340px;
|
||||||
|
$transition-speed: .25s;
|
||||||
|
|
||||||
|
.o_fusion_task_map_view {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.o_content {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main wrapper: sidebar + map side by side ────────────────────────
|
||||||
|
.fc_map_wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sidebar ─────────────────────────────────────────────────────────
|
||||||
|
.fc_sidebar {
|
||||||
|
width: $sidebar-width;
|
||||||
|
min-width: $sidebar-width;
|
||||||
|
max-width: $sidebar-width;
|
||||||
|
background: var(--o-view-background-color, $o-view-background-color);
|
||||||
|
border-right: 1px solid $border-color;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: width $transition-speed ease, min-width $transition-speed ease,
|
||||||
|
max-width $transition-speed ease, opacity $transition-speed ease;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&--collapsed {
|
||||||
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_sidebar_header {
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $headings-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_sidebar_body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 6px 0;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar { width: 5px; }
|
||||||
|
&::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
&::-webkit-scrollbar-thumb { background: $border-color; border-radius: 4px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_sidebar_footer {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-top: 1px solid $border-color;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_sidebar_empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Day filter chips ────────────────────────────────────────────────
|
||||||
|
.fc_day_filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_day_chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: $text-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
line-height: 18px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba($primary, .3);
|
||||||
|
color: $body-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--all {
|
||||||
|
color: $body-color;
|
||||||
|
font-weight: 500;
|
||||||
|
&:hover { background: rgba($primary, .1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_day_chip_count {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_group_hidden_tag {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
color: $text-muted;
|
||||||
|
background: rgba($secondary, .1);
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Technician filter chips ─────────────────────────────────────────
|
||||||
|
.fc_tech_filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_tech_chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 10px 3px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: transparent;
|
||||||
|
color: $text-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
line-height: 18px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba($primary, .35);
|
||||||
|
color: $body-color;
|
||||||
|
background: rgba($primary, .06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: $primary !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: $primary !important;
|
||||||
|
|
||||||
|
.fc_tech_chip_avatar {
|
||||||
|
background: rgba(#fff, .25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--all {
|
||||||
|
padding: 3px 10px;
|
||||||
|
color: $body-color;
|
||||||
|
font-weight: 500;
|
||||||
|
&:hover { background: rgba($primary, .1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_tech_chip_avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba($secondary, .15);
|
||||||
|
color: $body-color;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_tech_chip_name {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsed toggle button (floating)
|
||||||
|
.fc_sidebar_toggle_btn {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 15;
|
||||||
|
background: var(--o-view-background-color, $o-view-background-color);
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-left: none;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
padding: 12px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 2px 0 6px rgba(0,0,0,.08);
|
||||||
|
color: $text-muted;
|
||||||
|
transition: background .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $o-gray-100;
|
||||||
|
color: $body-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Group headers ───────────────────────────────────────────────────
|
||||||
|
.fc_group_header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-muted;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
background: rgba($secondary, .08);
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
transition: background .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($secondary, .15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa-caret-right,
|
||||||
|
.fa-caret-down {
|
||||||
|
width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_group_label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_group_badge {
|
||||||
|
background: rgba($secondary, .2);
|
||||||
|
color: $body-color;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task cards ──────────────────────────────────────────────────────
|
||||||
|
.fc_group_tasks {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_card {
|
||||||
|
margin: 3px 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--o-view-background-color, $o-view-background-color);
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($primary, .05);
|
||||||
|
border-color: rgba($primary, .2);
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: rgba($primary, .1) !important;
|
||||||
|
border-color: rgba($primary, .35) !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba($primary, .15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_card_top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_num {
|
||||||
|
display: inline-block;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_status {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_client {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $headings-color;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $body-color;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
|
||||||
|
.fa { opacity: .5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
.fa { opacity: .5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $body-color;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
.fa { opacity: .5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_address {
|
||||||
|
font-size: 10px;
|
||||||
|
color: $text-muted;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_bottom_row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_travel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: $body-color;
|
||||||
|
background: rgba($secondary, .1);
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
.fa { opacity: .5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_source {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
.fa { opacity: .8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_task_edit_btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--btn-primary-color, #fff);
|
||||||
|
background: var(--btn-primary-bg, #{$primary});
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: all .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: .85;
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Map area ────────────────────────────────────────────────────────
|
||||||
|
.fc_map_area {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_map_legend_bar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc_map_container {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
position: relative;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Google Maps InfoWindow override ──────────────────────────────────
|
||||||
|
.gm-style-iw-d {
|
||||||
|
overflow: auto !important;
|
||||||
|
}
|
||||||
|
.gm-style .gm-style-iw-c {
|
||||||
|
padding: 0 !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,.15) !important;
|
||||||
|
}
|
||||||
|
.gm-style .gm-style-iw-tc {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.gm-style .gm-ui-hover-effect {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Responsive ──────────────────────────────────────────────────────
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.fc_map_wrapper {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.fc_sidebar {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
max-height: 40vh;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
|
||||||
|
&--collapsed {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fc_sidebar_toggle_btn {
|
||||||
|
top: auto;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
.fc_map_area {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
1200
Entech Plating/fusion_tasks/static/src/js/fusion_task_map_view.js
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_tasks.FusionTaskMapView">
|
||||||
|
<div class="o_fusion_task_map_view">
|
||||||
|
<Layout display="display">
|
||||||
|
<t t-set-slot="control-panel-additional-actions">
|
||||||
|
<CogMenu/>
|
||||||
|
</t>
|
||||||
|
<t t-set-slot="layout-buttons">
|
||||||
|
<t t-call="{{ props.buttonTemplate }}"/>
|
||||||
|
</t>
|
||||||
|
<t t-set-slot="layout-actions">
|
||||||
|
<SearchBar toggler="searchBarToggler"/>
|
||||||
|
</t>
|
||||||
|
<t t-set-slot="control-panel-navigation-additional">
|
||||||
|
<t t-component="searchBarToggler.component" t-props="searchBarToggler.props"/>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<div class="fc_map_wrapper">
|
||||||
|
|
||||||
|
<!-- ========== SIDEBAR ========== -->
|
||||||
|
<div t-att-class="'fc_sidebar' + (state.sidebarOpen ? '' : ' fc_sidebar--collapsed')">
|
||||||
|
|
||||||
|
<!-- Sidebar header -->
|
||||||
|
<div class="fc_sidebar_header">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<h6 class="mb-0 fw-bold">
|
||||||
|
<i class="fa fa-list-ul me-2"/>Deliveries
|
||||||
|
<span class="badge text-bg-primary ms-1" t-esc="state.taskCount"/>
|
||||||
|
</h6>
|
||||||
|
<button class="btn btn-sm btn-link text-muted p-0" t-on-click="toggleSidebar"
|
||||||
|
title="Toggle sidebar">
|
||||||
|
<i t-att-class="'fa ' + (state.sidebarOpen ? 'fa-chevron-left' : 'fa-chevron-right')"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- New task button -->
|
||||||
|
<button class="btn btn-primary btn-sm w-100 mt-2" t-on-click="createNewTask">
|
||||||
|
<i class="fa fa-plus me-1"/>New Delivery Task
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Day filter chips -->
|
||||||
|
<div class="fc_day_filters mt-2">
|
||||||
|
<t t-foreach="state.groups" t-as="group" t-key="group.key + '_filter'">
|
||||||
|
<button t-att-class="'fc_day_chip' + (isGroupVisible(group.key) ? ' fc_day_chip--active' : '')"
|
||||||
|
t-att-style="isGroupVisible(group.key) ? 'background:' + group.dayColor + ';color:#fff;border-color:' + group.dayColor : ''"
|
||||||
|
t-on-click="() => this.toggleDayFilter(group.key)">
|
||||||
|
<t t-esc="group.label"/>
|
||||||
|
<span class="fc_day_chip_count" t-esc="group.count"/>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
<button class="fc_day_chip fc_day_chip--all" t-on-click="showAllDays"
|
||||||
|
title="Show all">All</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Technician filter -->
|
||||||
|
<t t-if="state.allTechnicians.length > 1">
|
||||||
|
<div class="fc_tech_filters mt-2">
|
||||||
|
<t t-foreach="state.allTechnicians" t-as="tech" t-key="tech.id">
|
||||||
|
<button t-att-class="'fc_tech_chip' + (isTechVisible(tech.id) ? ' fc_tech_chip--active' : '')"
|
||||||
|
t-on-click="() => this.toggleTechFilter(tech.id)"
|
||||||
|
t-att-title="tech.name">
|
||||||
|
<span class="fc_tech_chip_avatar" t-esc="tech.initials"/>
|
||||||
|
<span class="fc_tech_chip_name" t-esc="tech.name"/>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
<button class="fc_tech_chip fc_tech_chip--all" t-on-click="showAllTechs"
|
||||||
|
title="Show all technicians">All</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar body: grouped task list -->
|
||||||
|
<div class="fc_sidebar_body">
|
||||||
|
<t t-foreach="state.groups" t-as="group" t-key="group.key">
|
||||||
|
<!-- Group header (collapsible) with day color -->
|
||||||
|
<div class="fc_group_header" t-on-click="() => this.toggleGroup(group.key)">
|
||||||
|
<i t-att-class="'fa me-1 ' + (isGroupCollapsed(group.key) ? 'fa-caret-right' : 'fa-caret-down')"/>
|
||||||
|
<i class="fa fa-circle me-1" style="font-size:8px;"
|
||||||
|
t-att-style="'color:' + group.dayColor"/>
|
||||||
|
<span class="fc_group_label" t-esc="group.label"/>
|
||||||
|
<span t-if="!isGroupVisible(group.key)" class="fc_group_hidden_tag">hidden</span>
|
||||||
|
<span class="fc_group_badge" t-esc="group.count"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group tasks -->
|
||||||
|
<div t-if="!isGroupCollapsed(group.key)" class="fc_group_tasks">
|
||||||
|
<t t-foreach="group.tasks" t-as="task" t-key="task.id">
|
||||||
|
<div t-att-class="'fc_task_card' + (state.activeTaskId === task.id ? ' fc_task_card--active' : '')"
|
||||||
|
t-on-click="() => this.focusTask(task.id)">
|
||||||
|
|
||||||
|
<!-- Card top row: number + status -->
|
||||||
|
<div class="fc_task_card_top">
|
||||||
|
<span class="fc_task_num" t-att-style="'background:' + task._dayColor">
|
||||||
|
<t t-esc="'#' + task._scheduleNum"/>
|
||||||
|
</span>
|
||||||
|
<span class="fc_task_status" t-att-style="'color:' + task._statusColor">
|
||||||
|
<i t-att-class="'fa ' + task._statusIcon" style="margin-right:3px;"/>
|
||||||
|
<t t-esc="task._statusLabel"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client name -->
|
||||||
|
<div class="fc_task_client" t-esc="task._clientName"/>
|
||||||
|
|
||||||
|
<!-- Type + time -->
|
||||||
|
<div class="fc_task_meta">
|
||||||
|
<span><i class="fa fa-tag me-1"/><t t-esc="task._typeLbl"/></span>
|
||||||
|
<span><i class="fa fa-clock-o me-1"/><t t-esc="task._timeRange"/></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<div class="fc_task_date">
|
||||||
|
<i class="fa fa-calendar me-1"/><t t-esc="task._dateLabel"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Technician + address -->
|
||||||
|
<div class="fc_task_detail">
|
||||||
|
<span><i class="fa fa-user me-1"/><t t-esc="task._techName"/></span>
|
||||||
|
</div>
|
||||||
|
<div t-if="task.address_display" class="fc_task_address">
|
||||||
|
<i class="fa fa-map-marker me-1"/>
|
||||||
|
<t t-esc="task.address_display"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Travel + source -->
|
||||||
|
<div class="fc_task_bottom_row">
|
||||||
|
<span t-if="task.travel_time_minutes" class="fc_task_travel">
|
||||||
|
<i class="fa fa-car me-1"/>
|
||||||
|
<t t-esc="task.travel_time_minutes"/> min travel
|
||||||
|
</span>
|
||||||
|
<span t-if="task._sourceLabel" class="fc_task_source"
|
||||||
|
t-att-style="'background:' + task._sourceColor">
|
||||||
|
<i class="fa fa-building-o me-1"/>
|
||||||
|
<t t-esc="task._sourceLabel"/>
|
||||||
|
</span>
|
||||||
|
<span class="fc_task_edit_btn"
|
||||||
|
t-on-click.stop="() => this.openTask(task.id)"
|
||||||
|
title="Edit task">
|
||||||
|
<i class="fa fa-pencil me-1"/>Edit
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div t-if="state.groups.length === 0 and !state.loading" class="fc_sidebar_empty">
|
||||||
|
<i class="fa fa-inbox fa-2x text-muted d-block mb-2"/>
|
||||||
|
<span class="text-muted">No tasks found</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar footer: technician count -->
|
||||||
|
<div class="fc_sidebar_footer">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 48 48">
|
||||||
|
<rect x="2" y="2" width="44" height="44" rx="12" ry="12" fill="#1d4ed8" stroke="#fff" stroke-width="3"/>
|
||||||
|
<text x="24" y="30" text-anchor="middle" fill="#fff" font-size="17" font-family="Arial,sans-serif" font-weight="bold">T</text>
|
||||||
|
</svg>
|
||||||
|
<small class="text-muted">
|
||||||
|
<t t-esc="state.techCount"/> technician(s) online
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapsed sidebar toggle -->
|
||||||
|
<button t-if="!state.sidebarOpen"
|
||||||
|
class="fc_sidebar_toggle_btn" t-on-click="toggleSidebar"
|
||||||
|
title="Open sidebar">
|
||||||
|
<i class="fa fa-chevron-right"/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- ========== MAP AREA ========== -->
|
||||||
|
<div class="fc_map_area">
|
||||||
|
<!-- Legend bar -->
|
||||||
|
<div class="fc_map_legend_bar d-flex align-items-center gap-3 px-3 py-2 border-bottom bg-view flex-wrap">
|
||||||
|
<button class="btn btn-sm d-flex align-items-center gap-1"
|
||||||
|
t-att-class="state.showTasks ? 'btn-primary' : 'btn-outline-secondary'"
|
||||||
|
t-on-click="toggleTasks">
|
||||||
|
<i class="fa fa-map-marker"/>Tasks <t t-esc="state.taskCount"/>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm d-flex align-items-center gap-1"
|
||||||
|
t-att-class="state.showTechnicians ? 'btn-primary' : 'btn-outline-secondary'"
|
||||||
|
t-on-click="toggleTechnicians">
|
||||||
|
<i class="fa fa-user"/>Techs <t t-esc="state.techCount"/>
|
||||||
|
</button>
|
||||||
|
<span class="border-start mx-1" style="height:20px;"/>
|
||||||
|
<span class="text-muted fw-bold" style="font-size:11px;">Pins:</span>
|
||||||
|
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#f59e0b;"/>Pending</span>
|
||||||
|
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#ef4444;"/>Today</span>
|
||||||
|
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#3b82f6;"/>Tomorrow</span>
|
||||||
|
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#10b981;"/>This Week</span>
|
||||||
|
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#a855f7;"/>Upcoming</span>
|
||||||
|
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#9ca3af;"/>Yesterday</span>
|
||||||
|
<span class="flex-grow-1"/>
|
||||||
|
<button class="btn btn-sm d-flex align-items-center gap-1"
|
||||||
|
t-att-class="state.showRoute ? 'btn-info' : 'btn-outline-secondary'"
|
||||||
|
t-on-click="toggleRoute" title="Toggle route animation">
|
||||||
|
<i class="fa fa-road"/>Route
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm d-flex align-items-center gap-1"
|
||||||
|
t-att-class="state.showTraffic ? 'btn-warning' : 'btn-outline-secondary'"
|
||||||
|
t-on-click="toggleTraffic" title="Toggle traffic layer">
|
||||||
|
<i class="fa fa-car"/>Traffic
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" t-on-click="onRefresh" title="Refresh">
|
||||||
|
<i class="fa fa-refresh" t-att-class="{'fa-spin': state.loading}"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map container -->
|
||||||
|
<div class="fc_map_container">
|
||||||
|
<div t-ref="mapContainer" style="position:absolute;top:0;left:0;right:0;bottom:0;"/>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div t-if="state.loading"
|
||||||
|
class="position-absolute top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center"
|
||||||
|
style="z-index:10;background:rgba(255,255,255,.92);">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="fa fa-spinner fa-spin fa-3x text-primary mb-3 d-block"/>
|
||||||
|
<span class="text-muted">Loading Google Maps...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div t-if="state.error"
|
||||||
|
class="position-absolute top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center"
|
||||||
|
style="z-index:10;background:rgba(255,255,255,.92);">
|
||||||
|
<div class="alert alert-danger m-4" role="alert">
|
||||||
|
<i class="fa fa-exclamation-triangle me-2"/><t t-esc="state.error"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div t-if="!state.loading and !state.error and state.taskCount === 0 and state.techCount === 0"
|
||||||
|
class="position-absolute top-50 start-50 translate-middle text-center" style="z-index:5;">
|
||||||
|
<div class="bg-white rounded-3 shadow p-4">
|
||||||
|
<i class="fa fa-map-marker fa-3x text-muted mb-3 d-block"/>
|
||||||
|
<h5>No locations to show</h5>
|
||||||
|
<p class="text-muted mb-0">Try adjusting the filters or date range.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-name="fusion_tasks.FusionTaskMapView.Buttons"/>
|
||||||
|
|
||||||
|
</templates>
|
||||||
156
Entech Plating/fusion_tasks/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Add Fusion Tasks Settings as a new app block -->
|
||||||
|
<record id="res_config_settings_view_form_fusion_tasks" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.fusion.tasks</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//form" position="inside">
|
||||||
|
<app data-string="Fusion Tasks" string="Fusion Tasks" name="fusion_tasks"
|
||||||
|
groups="fusion_tasks.group_field_technician">
|
||||||
|
|
||||||
|
<h2>Technician Management</h2>
|
||||||
|
|
||||||
|
<div class="row mt-4 o_settings_container">
|
||||||
|
<!-- Google Maps API Key -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Google Maps API</span>
|
||||||
|
<div class="text-muted">
|
||||||
|
API key for Google Maps Places autocomplete in address fields and Distance Matrix travel calculations.
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<field name="fc_google_maps_api_key" placeholder="Enter your Google Maps API Key" password="True"/>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info mt-2" role="alert">
|
||||||
|
<i class="fa fa-info-circle"/> Enable the "Places API" and "Distance Matrix API" in your Google Cloud Console.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Google Business Review URL -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Google Business Review URL</span>
|
||||||
|
<div class="text-muted">
|
||||||
|
Link to your Google Business Profile review page.
|
||||||
|
Sent to clients after service completion (when "Request Google Review" is enabled on the task).
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<field name="fc_google_review_url" placeholder="https://g.page/r/your-business/review"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Store Hours -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Store / Scheduling Hours</span>
|
||||||
|
<div class="text-muted">
|
||||||
|
Operating hours for technician task scheduling. Tasks can only be booked
|
||||||
|
within these hours. Calendar view is also restricted to this range.
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 d-flex align-items-center gap-2">
|
||||||
|
<field name="fc_store_open_hour" widget="float_time" style="max-width: 100px;"/>
|
||||||
|
<span>to</span>
|
||||||
|
<field name="fc_store_close_hour" widget="float_time" style="max-width: 100px;"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Distance Matrix Toggle -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<field name="fc_google_distance_matrix_enabled"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="fc_google_distance_matrix_enabled"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Calculate travel time between technician tasks using Google Distance Matrix API.
|
||||||
|
Requires Google Maps API key above with Distance Matrix API enabled.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Start Address (Company Default / Fallback) -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Default HQ / Fallback Address</span>
|
||||||
|
<div class="text-muted">
|
||||||
|
Company default start location used when a technician has no personal
|
||||||
|
start address set. Each technician can set their own start location
|
||||||
|
in their user profile or from the portal.
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<field name="fc_technician_start_address" placeholder="e.g. 123 Main St, Brampton, ON"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Location History Retention -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Location History Retention</span>
|
||||||
|
<div class="text-muted">
|
||||||
|
How many days to keep technician GPS location history before automatic cleanup.
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 d-flex align-items-center gap-2">
|
||||||
|
<field name="fc_location_retention_days" placeholder="30" style="max-width: 80px;"/>
|
||||||
|
<span class="text-muted">days</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
Leave empty = 30 days. Enter 0 = delete at end of each day. 1+ = keep that many days.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Push Notifications</h2>
|
||||||
|
|
||||||
|
<div class="row mt-4 o_settings_container">
|
||||||
|
<!-- Push Enable -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<field name="fc_push_enabled"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="fc_push_enabled"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Send web push notifications to technicians about upcoming tasks.
|
||||||
|
Requires VAPID keys (auto-generated on first save if empty).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Advance Minutes -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Notification Advance Time</span>
|
||||||
|
<div class="text-muted">
|
||||||
|
Send push notification this many minutes before a scheduled task.
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<field name="fc_push_advance_minutes"/> minutes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- VAPID Public Key -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">VAPID Public Key</span>
|
||||||
|
<div class="mt-2">
|
||||||
|
<field name="fc_vapid_public_key" placeholder="Auto-generated"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- VAPID Private Key -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">VAPID Private Key</span>
|
||||||
|
<div class="mt-2">
|
||||||
|
<field name="fc_vapid_private_key" password="True" placeholder="Auto-generated"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</app>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
80
Entech Plating/fusion_tasks/views/task_sync_views.xml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- SYNC CONFIG - FORM VIEW -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="view_task_sync_config_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.task.sync.config.form</field>
|
||||||
|
<field name="model">fusion.task.sync.config</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Task Sync Configuration">
|
||||||
|
<header>
|
||||||
|
<button name="action_test_connection" type="object"
|
||||||
|
string="Test Connection" class="btn-secondary" icon="fa-plug"/>
|
||||||
|
<button name="action_sync_now" type="object"
|
||||||
|
string="Sync Now" class="btn-success" icon="fa-sync"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1><field name="name" placeholder="e.g. Westin Healthcare"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group string="Connection">
|
||||||
|
<field name="instance_id" placeholder="e.g. westin"/>
|
||||||
|
<field name="url" placeholder="http://192.168.1.40:8069"/>
|
||||||
|
<field name="database" placeholder="e.g. westin-v19"/>
|
||||||
|
<field name="username" placeholder="e.g. admin"/>
|
||||||
|
<field name="api_key" password="True"/>
|
||||||
|
<field name="active"/>
|
||||||
|
</group>
|
||||||
|
<group string="Status">
|
||||||
|
<field name="last_sync"/>
|
||||||
|
<field name="last_sync_error" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
<i class="fa fa-info-circle"/>
|
||||||
|
Technicians are matched across instances by their
|
||||||
|
<strong>Tech Sync ID</strong> field (Settings > Users).
|
||||||
|
Set the same ID (e.g. "gordy") on both instances for each shared technician.
|
||||||
|
</div>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- SYNC CONFIG - LIST VIEW -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="view_task_sync_config_list" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.task.sync.config.list</field>
|
||||||
|
<field name="model">fusion.task.sync.config</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="instance_id"/>
|
||||||
|
<field name="url"/>
|
||||||
|
<field name="database"/>
|
||||||
|
<field name="active"/>
|
||||||
|
<field name="last_sync"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- SYNC CONFIG - ACTION + MENU -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="action_task_sync_config" model="ir.actions.act_window">
|
||||||
|
<field name="name">Task Sync Instances</field>
|
||||||
|
<field name="res_model">fusion.task.sync.config</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_task_sync_config"
|
||||||
|
name="Task Sync"
|
||||||
|
parent="menu_technician_config"
|
||||||
|
action="action_task_sync_config"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
102
Entech Plating/fusion_tasks/views/technician_location_views.xml
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- LIST VIEW -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="view_technician_location_list" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.technician.location.list</field>
|
||||||
|
<field name="model">fusion.technician.location</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Technician Locations" create="0" edit="0"
|
||||||
|
default_order="logged_at desc">
|
||||||
|
<field name="user_id" widget="many2one_avatar_user"/>
|
||||||
|
<field name="logged_at" string="Time"/>
|
||||||
|
<field name="latitude" optional="hide"/>
|
||||||
|
<field name="longitude" optional="hide"/>
|
||||||
|
<field name="accuracy" string="Accuracy (m)" optional="hide"/>
|
||||||
|
<field name="source"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- FORM VIEW (read-only) -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="view_technician_location_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.technician.location.form</field>
|
||||||
|
<field name="model">fusion.technician.location</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Location Log" create="0" edit="0">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="logged_at"/>
|
||||||
|
<field name="source"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="latitude"/>
|
||||||
|
<field name="longitude"/>
|
||||||
|
<field name="accuracy"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- SEARCH VIEW -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="view_technician_location_search" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.technician.location.search</field>
|
||||||
|
<field name="model">fusion.technician.location</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Location Logs">
|
||||||
|
<field name="user_id" string="Technician"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Today" name="filter_today"
|
||||||
|
domain="[('logged_at', '>=', context_today().strftime('%Y-%m-%d'))]"/>
|
||||||
|
<filter string="Last 7 Days" name="filter_7d"
|
||||||
|
domain="[('logged_at', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||||
|
<filter string="Last 30 Days" name="filter_30d"
|
||||||
|
domain="[('logged_at', '>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Technician" name="group_user" context="{'group_by': 'user_id'}"/>
|
||||||
|
<filter string="Date" name="group_date" context="{'group_by': 'logged_at:day'}"/>
|
||||||
|
<filter string="Source" name="group_source" context="{'group_by': 'source'}"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- ACTION -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="action_technician_locations" model="ir.actions.act_window">
|
||||||
|
<field name="name">Location History</field>
|
||||||
|
<field name="res_model">fusion.technician.location</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="view_technician_location_search"/>
|
||||||
|
<field name="context">{
|
||||||
|
'search_default_filter_today': 1,
|
||||||
|
'search_default_group_user': 1,
|
||||||
|
}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
No location data logged yet.
|
||||||
|
</p>
|
||||||
|
<p>Technician locations are automatically logged when they use the portal.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- MENU ITEMS (under Configuration) -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<menuitem id="menu_technician_locations"
|
||||||
|
name="Location History"
|
||||||
|
parent="menu_technician_config"
|
||||||
|
action="action_technician_locations"
|
||||||
|
sequence="20"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
507
Entech Plating/fusion_tasks/views/technician_task_views.xml
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- SEQUENCE -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="seq_technician_task" model="ir.sequence">
|
||||||
|
<field name="name">Technician Task</field>
|
||||||
|
<field name="code">fusion.technician.task</field>
|
||||||
|
<field name="prefix">TASK-</field>
|
||||||
|
<field name="padding">5</field>
|
||||||
|
<field name="number_increment">1</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- RES.USERS FORM EXTENSION - Field Staff toggle -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="view_users_form_field_staff" model="ir.ui.view">
|
||||||
|
<field name="name">res.users.form.field.staff</field>
|
||||||
|
<field name="model">res.users</field>
|
||||||
|
<field name="inherit_id" ref="base.view_users_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='login']" position="after">
|
||||||
|
<field name="x_fc_is_field_staff"/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- SEARCH VIEW -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="view_technician_task_search" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.technician.task.search</field>
|
||||||
|
<field name="model">fusion.technician.task</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Tasks">
|
||||||
|
<field name="technician_id" string="Technician"/>
|
||||||
|
<field name="partner_id" string="Client"/>
|
||||||
|
<field name="name" string="Task"/>
|
||||||
|
<separator/>
|
||||||
|
<!-- Quick Filters -->
|
||||||
|
<filter string="Today" name="filter_today"
|
||||||
|
domain="[('scheduled_date', '=', context_today().strftime('%Y-%m-%d'))]"/>
|
||||||
|
<filter string="Tomorrow" name="filter_tomorrow"
|
||||||
|
domain="[('scheduled_date', '=', (context_today() + datetime.timedelta(days=1)).strftime('%Y-%m-%d'))]"/>
|
||||||
|
<filter string="This Week" name="filter_this_week"
|
||||||
|
domain="[('scheduled_date', '>=', (context_today() - datetime.timedelta(days=context_today().weekday())).strftime('%Y-%m-%d')),
|
||||||
|
('scheduled_date', '<=', (context_today() + datetime.timedelta(days=6-context_today().weekday())).strftime('%Y-%m-%d'))]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Pending" name="filter_pending" domain="[('status', '=', 'pending')]"/>
|
||||||
|
<filter string="Scheduled" name="filter_scheduled" domain="[('status', '=', 'scheduled')]"/>
|
||||||
|
<filter string="En Route" name="filter_en_route" domain="[('status', '=', 'en_route')]"/>
|
||||||
|
<filter string="In Progress" name="filter_in_progress" domain="[('status', '=', 'in_progress')]"/>
|
||||||
|
<filter string="Completed" name="filter_completed" domain="[('status', '=', 'completed')]"/>
|
||||||
|
<filter string="Active" name="filter_active" domain="[('status', 'not in', ['cancelled', 'completed'])]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="My Tasks" name="filter_my_tasks"
|
||||||
|
domain="['|', ('technician_id', '=', uid), ('additional_technician_ids', 'in', [uid])]"/>
|
||||||
|
<filter string="Deliveries" name="filter_deliveries" domain="[('task_type', '=', 'delivery')]"/>
|
||||||
|
<filter string="Repairs" name="filter_repairs" domain="[('task_type', '=', 'repair')]"/>
|
||||||
|
<filter string="POD Required" name="filter_pod" domain="[('pod_required', '=', True)]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Local Tasks" name="filter_local"
|
||||||
|
domain="[('x_fc_sync_source', '=', False)]"/>
|
||||||
|
<filter string="Synced Tasks" name="filter_synced"
|
||||||
|
domain="[('x_fc_sync_source', '!=', False)]"/>
|
||||||
|
<separator/>
|
||||||
|
<!-- Group By -->
|
||||||
|
<filter string="Technician" name="group_technician" context="{'group_by': 'technician_id'}"/>
|
||||||
|
<filter string="Date" name="group_date" context="{'group_by': 'scheduled_date'}"/>
|
||||||
|
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
|
||||||
|
<filter string="Task Type" name="group_type" context="{'group_by': 'task_type'}"/>
|
||||||
|
<filter string="Client" name="group_client" context="{'group_by': 'partner_id'}"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- FORM VIEW -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="view_technician_task_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.technician.task.form</field>
|
||||||
|
<field name="model">fusion.technician.task</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Technician Task">
|
||||||
|
<field name="x_fc_is_shadow" invisible="1"/>
|
||||||
|
<field name="x_fc_sync_source" invisible="1"/>
|
||||||
|
<header>
|
||||||
|
<button name="action_start_en_route" type="object" string="En Route"
|
||||||
|
class="btn-primary" invisible="status != 'scheduled' or x_fc_is_shadow"/>
|
||||||
|
<button name="action_start_task" type="object" string="Start Task"
|
||||||
|
class="btn-primary" invisible="status not in ('scheduled', 'en_route') or x_fc_is_shadow"/>
|
||||||
|
<button name="action_complete_task" type="object" string="Complete"
|
||||||
|
class="btn-success" invisible="status not in ('in_progress', 'en_route') or x_fc_is_shadow"/>
|
||||||
|
<button name="action_reschedule" type="object" string="Reschedule"
|
||||||
|
class="btn-warning" invisible="status not in ('scheduled', 'en_route') or x_fc_is_shadow"/>
|
||||||
|
<button name="action_cancel_task" type="object" string="Cancel"
|
||||||
|
class="btn-danger" invisible="status in ('completed', 'cancelled') or x_fc_is_shadow"
|
||||||
|
confirm="Are you sure you want to cancel this task?"/>
|
||||||
|
<button name="action_reset_to_scheduled" type="object" string="Reset to Scheduled"
|
||||||
|
invisible="status not in ('cancelled', 'rescheduled') or x_fc_is_shadow"/>
|
||||||
|
<button string="Calculate Travel"
|
||||||
|
class="btn-secondary o_fc_calculate_travel" icon="fa-car"
|
||||||
|
invisible="x_fc_is_shadow"/>
|
||||||
|
<field name="status" widget="statusbar"
|
||||||
|
statusbar_visible="pending,scheduled,en_route,in_progress,completed"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<!-- Shadow task banner -->
|
||||||
|
<div class="alert alert-info text-center" role="alert"
|
||||||
|
invisible="not x_fc_is_shadow">
|
||||||
|
<strong><i class="fa fa-link"/> This task is synced from
|
||||||
|
<field name="x_fc_sync_source" readonly="1" nolabel="1" class="d-inline"/>
|
||||||
|
— view only.</strong>
|
||||||
|
</div>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
</div>
|
||||||
|
<widget name="web_ribbon" title="Completed" bg_color="text-bg-success"
|
||||||
|
invisible="status != 'completed'"/>
|
||||||
|
<widget name="web_ribbon" title="Cancelled" bg_color="text-bg-danger"
|
||||||
|
invisible="status != 'cancelled'"/>
|
||||||
|
<widget name="web_ribbon" title="Synced" bg_color="text-bg-info"
|
||||||
|
invisible="not x_fc_is_shadow or status in ('completed', 'cancelled')"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>
|
||||||
|
<field name="name" readonly="1"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedule Info Banner -->
|
||||||
|
<field name="schedule_info_html" nolabel="1" colspan="2"
|
||||||
|
invisible="not technician_id or not scheduled_date"/>
|
||||||
|
|
||||||
|
<!-- Previous Task / Travel Warning Banner -->
|
||||||
|
<field name="prev_task_summary_html" nolabel="1" colspan="2"
|
||||||
|
invisible="not technician_id or not scheduled_date"/>
|
||||||
|
|
||||||
|
<!-- Hidden fields for calendar sync and legacy -->
|
||||||
|
<field name="datetime_start" invisible="1"/>
|
||||||
|
<field name="datetime_end" invisible="1"/>
|
||||||
|
<field name="time_start_12h" invisible="1"/>
|
||||||
|
<field name="time_end_12h" invisible="1"/>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Assignment">
|
||||||
|
<field name="technician_id"
|
||||||
|
domain="[('x_fc_is_field_staff', '=', True)]"/>
|
||||||
|
<field name="additional_technician_ids"
|
||||||
|
widget="many2many_tags_avatar"
|
||||||
|
domain="[('x_fc_is_field_staff', '=', True), ('id', '!=', technician_id)]"
|
||||||
|
options="{'color_field': 'color'}"/>
|
||||||
|
<field name="task_type"/>
|
||||||
|
<field name="priority" widget="priority"/>
|
||||||
|
</group>
|
||||||
|
<group string="Schedule">
|
||||||
|
<field name="scheduled_date"/>
|
||||||
|
<field name="time_start" widget="float_time"
|
||||||
|
string="Start Time"/>
|
||||||
|
<field name="duration_hours" widget="float_time"
|
||||||
|
string="Duration"/>
|
||||||
|
<field name="time_end" widget="float_time"
|
||||||
|
string="End Time" readonly="1"
|
||||||
|
force_save="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Client">
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="partner_phone" widget="phone"/>
|
||||||
|
</group>
|
||||||
|
<group string="Location">
|
||||||
|
<field name="is_in_store"/>
|
||||||
|
<field name="address_partner_id" invisible="is_in_store"/>
|
||||||
|
<field name="address_street" readonly="is_in_store"/>
|
||||||
|
<field name="address_street2" string="Unit/Suite #" invisible="is_in_store"/>
|
||||||
|
<field name="address_buzz_code" invisible="is_in_store"/>
|
||||||
|
<field name="address_city" invisible="1"/>
|
||||||
|
<field name="address_state_id" invisible="1"/>
|
||||||
|
<field name="address_zip" invisible="1"/>
|
||||||
|
<field name="address_lat" invisible="1"/>
|
||||||
|
<field name="address_lng" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Travel (Auto-Calculated)">
|
||||||
|
<field name="travel_time_minutes" readonly="1"/>
|
||||||
|
<field name="travel_distance_km" readonly="1"/>
|
||||||
|
<field name="travel_origin" readonly="1"/>
|
||||||
|
<field name="previous_task_id" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Options">
|
||||||
|
<field name="pod_required"/>
|
||||||
|
<field name="x_fc_send_client_updates"/>
|
||||||
|
<field name="x_fc_ask_google_review"/>
|
||||||
|
<field name="active" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<notebook>
|
||||||
|
<page string="Description" name="description">
|
||||||
|
<group>
|
||||||
|
<field name="description" placeholder="What needs to be done..."/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="equipment_needed" placeholder="Tools, parts, materials..."/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
<page string="Completion" name="completion">
|
||||||
|
<group>
|
||||||
|
<field name="completion_datetime"/>
|
||||||
|
<field name="completion_notes"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="voice_note_transcription"/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<chatter/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- LIST VIEW -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="view_technician_task_list" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.technician.task.list</field>
|
||||||
|
<field name="model">fusion.technician.task</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Technician Tasks" decoration-success="status == 'completed'"
|
||||||
|
decoration-warning="status == 'in_progress'"
|
||||||
|
decoration-info="status == 'en_route'"
|
||||||
|
decoration-danger="status == 'cancelled'"
|
||||||
|
decoration-muted="status == 'rescheduled'"
|
||||||
|
default_order="scheduled_date, sequence, time_start">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="technician_id" widget="many2one_avatar_user"/>
|
||||||
|
<field name="additional_technician_ids" widget="many2many_tags_avatar"
|
||||||
|
optional="show" string="+ Techs"/>
|
||||||
|
<field name="task_type" decoration-bf="1"/>
|
||||||
|
<field name="scheduled_date"/>
|
||||||
|
<field name="time_start_display" string="Start"/>
|
||||||
|
<field name="time_end_display" string="End"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="address_city"/>
|
||||||
|
<field name="travel_time_minutes" string="Travel (min)" optional="show"/>
|
||||||
|
<field name="status" widget="badge"
|
||||||
|
decoration-success="status == 'completed'"
|
||||||
|
decoration-warning="status == 'in_progress'"
|
||||||
|
decoration-info="status in ('scheduled', 'en_route')"
|
||||||
|
decoration-danger="status == 'cancelled'"/>
|
||||||
|
<field name="priority" widget="priority" optional="hide"/>
|
||||||
|
<field name="pod_required" optional="hide"/>
|
||||||
|
<field name="x_fc_source_label" string="Source" optional="show"
|
||||||
|
widget="badge" decoration-info="x_fc_is_shadow"
|
||||||
|
decoration-success="not x_fc_is_shadow"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- KANBAN VIEW -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="view_technician_task_kanban" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.technician.task.kanban</field>
|
||||||
|
<field name="model">fusion.technician.task</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<kanban default_group_by="status" class="o_kanban_small_column"
|
||||||
|
records_draggable="1" group_create="0">
|
||||||
|
<field name="color"/>
|
||||||
|
<field name="priority"/>
|
||||||
|
<field name="technician_id"/>
|
||||||
|
<field name="additional_technician_ids"/>
|
||||||
|
<field name="additional_tech_count"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="task_type"/>
|
||||||
|
<field name="scheduled_date"/>
|
||||||
|
<field name="time_start_display"/>
|
||||||
|
<field name="address_city"/>
|
||||||
|
<field name="travel_time_minutes"/>
|
||||||
|
<field name="status"/>
|
||||||
|
<field name="x_fc_is_shadow"/>
|
||||||
|
<field name="x_fc_sync_client_name"/>
|
||||||
|
<templates>
|
||||||
|
<t t-name="card">
|
||||||
|
<div t-attf-class="oe_kanban_color_#{record.color.raw_value} oe_kanban_card oe_kanban_global_click">
|
||||||
|
<div class="oe_kanban_content">
|
||||||
|
<div class="o_kanban_record_top mb-1">
|
||||||
|
<div class="o_kanban_record_headings">
|
||||||
|
<strong class="o_kanban_record_title">
|
||||||
|
<field name="name"/>
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<field name="priority" widget="priority"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<span class="badge bg-primary me-1"><field name="task_type"/></span>
|
||||||
|
<span class="text-muted"><field name="scheduled_date"/> - <field name="time_start_display"/></span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<i class="fa fa-user me-1"/>
|
||||||
|
<t t-if="record.x_fc_is_shadow.raw_value">
|
||||||
|
<span t-out="record.x_fc_sync_client_name.value"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<field name="partner_id"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small" t-if="record.address_city.raw_value">
|
||||||
|
<i class="fa fa-map-marker me-1"/><field name="address_city"/>
|
||||||
|
<t t-if="record.travel_time_minutes.raw_value">
|
||||||
|
<span class="ms-2"><i class="fa fa-car me-1"/><field name="travel_time_minutes"/> min</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div t-if="record.additional_tech_count.raw_value > 0" class="text-muted small mb-1">
|
||||||
|
<i class="fa fa-users me-1"/>
|
||||||
|
<span>+<field name="additional_tech_count"/> technician(s)</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_kanban_record_bottom mt-2">
|
||||||
|
<div class="oe_kanban_bottom_left">
|
||||||
|
<field name="activity_ids" widget="kanban_activity"/>
|
||||||
|
</div>
|
||||||
|
<div class="oe_kanban_bottom_right">
|
||||||
|
<field name="technician_id" widget="many2one_avatar_user"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
</kanban>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- CALENDAR VIEW -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="view_technician_task_calendar" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.technician.task.calendar</field>
|
||||||
|
<field name="model">fusion.technician.task</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<calendar string="Technician Schedule"
|
||||||
|
date_start="datetime_start" date_stop="datetime_end"
|
||||||
|
color="technician_id" mode="week" event_open_popup="1"
|
||||||
|
quick_create="0">
|
||||||
|
<!-- Displayed on the calendar card -->
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="x_fc_sync_client_name"/>
|
||||||
|
<field name="task_type"/>
|
||||||
|
<field name="time_start_display" string="Start"/>
|
||||||
|
<field name="time_end_display" string="End"/>
|
||||||
|
<!-- Popover (hover/click) details -->
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="technician_id" avatar_field="image_128"/>
|
||||||
|
<field name="address_display" string="Address"/>
|
||||||
|
<field name="travel_time_minutes" string="Travel (min)"/>
|
||||||
|
<field name="status"/>
|
||||||
|
<field name="duration_hours" widget="float_time" string="Duration"/>
|
||||||
|
</calendar>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- MAP VIEW (Enterprise web_map) -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="view_technician_task_map" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.technician.task.map</field>
|
||||||
|
<field name="model">fusion.technician.task</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<map res_partner="address_partner_id" default_order="time_start"
|
||||||
|
routing="1" js_class="fusion_task_map">
|
||||||
|
<field name="partner_id" string="Client"/>
|
||||||
|
<field name="task_type" string="Type"/>
|
||||||
|
<field name="technician_id" string="Technician"/>
|
||||||
|
<field name="time_start_display" string="Start"/>
|
||||||
|
<field name="time_end_display" string="End"/>
|
||||||
|
<field name="status" string="Status"/>
|
||||||
|
<field name="travel_time_minutes" string="Travel (min)"/>
|
||||||
|
</map>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- ACTIONS -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
|
||||||
|
<!-- Main Tasks Action (List/Kanban) -->
|
||||||
|
<record id="action_technician_tasks" model="ir.actions.act_window">
|
||||||
|
<field name="name">Technician Tasks</field>
|
||||||
|
<field name="res_model">fusion.technician.task</field>
|
||||||
|
<field name="view_mode">list,kanban,form,calendar,map</field>
|
||||||
|
<field name="search_view_id" ref="view_technician_task_search"/>
|
||||||
|
<field name="context">{'search_default_filter_active': 1}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create your first technician task
|
||||||
|
</p>
|
||||||
|
<p>Schedule deliveries, repairs, and other field tasks for your technicians.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Schedule Action (Map default) -->
|
||||||
|
<record id="action_technician_schedule" model="ir.actions.act_window">
|
||||||
|
<field name="name">Schedule</field>
|
||||||
|
<field name="res_model">fusion.technician.task</field>
|
||||||
|
<field name="view_mode">map,calendar,list,kanban,form</field>
|
||||||
|
<field name="search_view_id" ref="view_technician_task_search"/>
|
||||||
|
<field name="context">{'search_default_filter_active': 1}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Map View Action (for app landing page) -->
|
||||||
|
<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="search_view_id" ref="view_technician_task_search"/>
|
||||||
|
<field name="context">{'search_default_filter_active': 1}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Today's Tasks Action -->
|
||||||
|
<record id="action_technician_tasks_today" model="ir.actions.act_window">
|
||||||
|
<field name="name">Today's Tasks</field>
|
||||||
|
<field name="res_model">fusion.technician.task</field>
|
||||||
|
<field name="view_mode">kanban,list,form,map</field>
|
||||||
|
<field name="search_view_id" ref="view_technician_task_search"/>
|
||||||
|
<field name="context">{'search_default_filter_today': 1, 'search_default_filter_active': 1}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- My Tasks Action -->
|
||||||
|
<record id="action_technician_my_tasks" model="ir.actions.act_window">
|
||||||
|
<field name="name">My Tasks</field>
|
||||||
|
<field name="res_model">fusion.technician.task</field>
|
||||||
|
<field name="view_mode">list,kanban,form,calendar,map</field>
|
||||||
|
<field name="search_view_id" ref="view_technician_task_search"/>
|
||||||
|
<field name="context">{'search_default_filter_my_tasks': 1, 'search_default_filter_active': 1}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Pending Tasks Action -->
|
||||||
|
<record id="action_technician_tasks_pending" model="ir.actions.act_window">
|
||||||
|
<field name="name">Pending Tasks</field>
|
||||||
|
<field name="res_model">fusion.technician.task</field>
|
||||||
|
<field name="view_mode">list,kanban,form</field>
|
||||||
|
<field name="search_view_id" ref="view_technician_task_search"/>
|
||||||
|
<field name="context">{'search_default_filter_pending': 1}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Calendar Action -->
|
||||||
|
<record id="action_technician_calendar" model="ir.actions.act_window">
|
||||||
|
<field name="name">Task Calendar</field>
|
||||||
|
<field name="res_model">fusion.technician.task</field>
|
||||||
|
<field name="view_mode">calendar,list,kanban,form,map</field>
|
||||||
|
<field name="search_view_id" ref="view_technician_task_search"/>
|
||||||
|
<field name="context">{'search_default_filter_active': 1}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- MENU ITEMS - Standalone Field Service App -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
|
||||||
|
<!-- Root app menu -->
|
||||||
|
<menuitem id="menu_field_service_root"
|
||||||
|
name="Field Service"
|
||||||
|
web_icon="fusion_tasks,static/description/icon.png"
|
||||||
|
groups="fusion_tasks.group_field_technician"
|
||||||
|
sequence="45"/>
|
||||||
|
|
||||||
|
<!-- Map View - first item = default landing view -->
|
||||||
|
<menuitem id="menu_technician_map"
|
||||||
|
name="Map View"
|
||||||
|
parent="menu_field_service_root"
|
||||||
|
action="action_technician_map_view"
|
||||||
|
sequence="5"
|
||||||
|
groups="fusion_tasks.group_field_technician"/>
|
||||||
|
|
||||||
|
<!-- Tasks -->
|
||||||
|
<menuitem id="menu_technician_tasks"
|
||||||
|
name="Tasks"
|
||||||
|
parent="menu_field_service_root"
|
||||||
|
action="action_technician_tasks"
|
||||||
|
sequence="10"
|
||||||
|
groups="fusion_tasks.group_field_technician"/>
|
||||||
|
|
||||||
|
<!-- Calendar -->
|
||||||
|
<menuitem id="menu_technician_calendar"
|
||||||
|
name="Calendar"
|
||||||
|
parent="menu_field_service_root"
|
||||||
|
action="action_technician_calendar"
|
||||||
|
sequence="30"
|
||||||
|
groups="fusion_tasks.group_field_technician"/>
|
||||||
|
|
||||||
|
<!-- Task Sync (submenu) -->
|
||||||
|
<menuitem id="menu_technician_config"
|
||||||
|
name="Configuration"
|
||||||
|
parent="menu_field_service_root"
|
||||||
|
sequence="90"
|
||||||
|
groups="fusion_tasks.group_field_technician"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
## Project
|
## 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.
|
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/ — 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_configurator/ — Quotation configurator, pricing engine, part catalog, 3D viewer
|
||||||
fusion_plating_receiving/ — Parts receiving, inspection, damage logging
|
fusion_plating_receiving/ — Parts receiving, inspection, damage logging
|
||||||
fusion_plating_invoicing/ — Invoice strategies (deposit/progress/net/COD), account holds
|
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_portal/ — Customer portal + self-service configurator wizard
|
||||||
fusion_plating_reports/ — PDF reports (WO margin, discharge sample, CoC, etc.)
|
fusion_plating_reports/ — PDF reports (WO margin, discharge sample, CoC, etc.)
|
||||||
fusion_plating_compliance/ — Compliance framework, jurisdictions
|
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_aerospace/ — AS9100 / Nadcap
|
||||||
fusion_plating_nuclear/ — CSA N299 / CNSC
|
fusion_plating_nuclear/ — CSA N299 / CNSC
|
||||||
fusion_plating_cgp/ — Controlled Goods Program
|
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_quality/ — QMS (NCR, CAPA, calibration)
|
||||||
fusion_plating_logistics/ — Pickup & delivery, chain of custody
|
fusion_plating_logistics/ — Pickup & delivery, chain of custody
|
||||||
fusion_plating_culture/ — Values / fundamentals
|
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_sign/ — Digital signatures
|
||||||
fusion_plating_bridge_quality/ — Quality bridge
|
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_en/ — Electroless nickel process pack
|
||||||
fusion_plating_process_chrome/ — Chrome process pack
|
fusion_plating_process_chrome/ — Chrome process pack
|
||||||
fusion_plating_process_anodize/ — Anodizing 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)
|
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
|
## Critical Rules — Odoo 19
|
||||||
1. **NEVER code from memory** — Read reference files from the server first.
|
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")`.
|
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.ncr` | `fusion_plating_quality` | Non-conformance reports |
|
||||||
| `fusion.plating.capa` | `fusion_plating_quality` | Corrective actions |
|
| `fusion.plating.capa` | `fusion_plating_quality` | Corrective actions |
|
||||||
| `fusion.plating.batch` | `fusion_plating_batch` | Rack/barrel batch tracking |
|
| `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.delivery` | `fusion_plating_logistics` | Delivery with chain of custody |
|
||||||
| `fusion.plating.pickup.request` | `fusion_plating_logistics` | Customer pickup requests |
|
| `fusion.plating.pickup.request` | `fusion_plating_logistics` | Customer pickup requests |
|
||||||
| `fusion.plating.route` | `fusion_plating_logistics` | Driver routes with stops |
|
| `fusion.plating.route` | `fusion_plating_logistics` | Driver routes with stops |
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<menuitem id="menu_fp_operations"
|
<menuitem id="menu_fp_operations"
|
||||||
name="Operations"
|
name="Operations"
|
||||||
parent="menu_fp_root"
|
parent="menu_fp_root"
|
||||||
sequence="10"/>
|
sequence="18"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_process_recipes"
|
<menuitem id="menu_fp_process_recipes"
|
||||||
name="Process Recipes"
|
name="Process Recipes"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from . import models
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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.'),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -53,6 +53,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'views/mrp_production_views.xml',
|
'views/mrp_production_views.xml',
|
||||||
'views/fp_quality_hold_views.xml',
|
'views/fp_quality_hold_views.xml',
|
||||||
'views/fp_batch_views.xml',
|
'views/fp_batch_views.xml',
|
||||||
|
'views/fp_workorder_priority_views.xml',
|
||||||
],
|
],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
|
|||||||
@@ -91,4 +91,12 @@
|
|||||||
(0, 0, {'view_mode': 'list', 'view_id': ref('view_mrp_workorder_fp_list')})]"/>
|
(0, 0, {'view_mode': 'list', 'view_id': ref('view_mrp_workorder_fp_list')})]"/>
|
||||||
</record>
|
</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>
|
</odoo>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<menuitem id="menu_fp_certificates"
|
<menuitem id="menu_fp_certificates"
|
||||||
name="Certificates"
|
name="Certificates"
|
||||||
parent="fusion_plating.menu_fp_root"
|
parent="fusion_plating.menu_fp_root"
|
||||||
sequence="15"
|
sequence="25"
|
||||||
groups="fusion_plating.group_fusion_plating_supervisor"/>
|
groups="fusion_plating.group_fusion_plating_supervisor"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_certificates_all"
|
<menuitem id="menu_fp_certificates_all"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<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_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"/>
|
<menuitem id="menu_fp_compliance_discharge_sample" name="Discharge Samples" parent="menu_fp_compliance_root" action="action_fp_discharge_sample" sequence="20"/>
|
||||||
|
|||||||
@@ -46,15 +46,13 @@ Provides:
|
|||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
'views/fp_configurator_menu.xml',
|
'views/fp_configurator_menu.xml',
|
||||||
],
|
],
|
||||||
# 3D viewer assets temporarily disabled — causes 'registry already declared'
|
'assets': {
|
||||||
# error in Odoo 19 asset bundler. Needs investigation.
|
'web.assets_backend': [
|
||||||
# 'assets': {
|
'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
|
||||||
# 'web.assets_backend': [
|
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
|
||||||
# 'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
|
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
|
||||||
# 'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
|
],
|
||||||
# 'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
|
},
|
||||||
# ],
|
|
||||||
# },
|
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
|
|||||||
@@ -15,6 +15,55 @@ _logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class FpConfiguratorController(http.Controller):
|
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')
|
@http.route('/fp/configurator/calculate_surface_area', type='jsonrpc', auth='user')
|
||||||
def calculate_surface_area(self, attachment_id, **kw):
|
def calculate_surface_area(self, attachment_id, **kw):
|
||||||
"""Calculate surface area from an uploaded STL file using trimesh."""
|
"""Calculate surface area from an uploaded STL file using trimesh."""
|
||||||
|
|||||||
@@ -59,43 +59,190 @@ class FpPartCatalog(models.Model):
|
|||||||
notes = fields.Html(string='Notes')
|
notes = fields.Html(string='Notes')
|
||||||
active = fields.Boolean(string='Active', default=True)
|
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 = [
|
_sql_constraints = [
|
||||||
('fp_part_catalog_partner_partnum_uniq', 'unique(partner_id, part_number)',
|
('fp_part_catalog_partner_partnum_uniq', 'unique(partner_id, part_number)',
|
||||||
'Part number must be unique per customer.'),
|
'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):
|
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()
|
self.ensure_one()
|
||||||
if not self.model_attachment_id:
|
if not self.model_attachment_id:
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
raise UserError(_('No 3D model file uploaded.'))
|
raise UserError(_('No 3D model file uploaded.'))
|
||||||
|
result = self._compute_surface_area_from_model()
|
||||||
try:
|
if result.get('error'):
|
||||||
import trimesh
|
|
||||||
except ImportError:
|
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
raise UserError(_('trimesh library not installed on the server. Contact your administrator.'))
|
raise UserError(result['error'])
|
||||||
|
|
||||||
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'
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'type': 'ir.actions.client',
|
'type': 'ir.actions.client',
|
||||||
'tag': 'display_notification',
|
'tag': 'display_notification',
|
||||||
'params': {
|
'params': {
|
||||||
'title': _('Surface Area Calculated'),
|
'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',
|
'type': 'success',
|
||||||
'sticky': False,
|
'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}
|
||||||
|
|||||||
@@ -35,6 +35,25 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
domain="[('partner_id', '=', partner_id)]",
|
domain="[('partner_id', '=', partner_id)]",
|
||||||
help="Select from this customer's part catalog, or leave blank for a one-off.",
|
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(
|
coating_config_id = fields.Many2one(
|
||||||
'fp.coating.config', string='Coating Configuration', required=True,
|
'fp.coating.config', string='Coating Configuration', required=True,
|
||||||
)
|
)
|
||||||
@@ -350,5 +369,126 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
'target': 'current',
|
'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):
|
def action_cancel(self):
|
||||||
self.write({'state': 'cancelled'})
|
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',
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 75 KiB |
@@ -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);
|
||||||
|
};
|
||||||
3985
fusion-plating/fusion_plating_configurator/static/lib/o3dv/o3dv.min.js
vendored
Normal 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>
|
||||||
@@ -1,98 +1,29 @@
|
|||||||
/** @odoo-module **/
|
/** @odoo-module **/
|
||||||
// =============================================================================
|
// Fusion Plating -- 3D CAD Viewer (iframe wrapper)
|
||||||
// Fusion Plating -- 3D STL Viewer (OWL field widget)
|
|
||||||
// Copyright 2026 Nexa Systems Inc.
|
// Copyright 2026 Nexa Systems Inc.
|
||||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
//
|
//
|
||||||
// Renders STL files using Three.js inside an OWL field widget.
|
// Simple OWL field widget that embeds the standalone 3D viewer page
|
||||||
// Three.js (+ STLLoader + OrbitControls) are loaded lazily on first use
|
// in an iframe. The viewer page uses Online3DViewer (o3dv) which
|
||||||
// via dynamic import() with a programmatic importmap so the vendored ESM
|
// supports STEP, IGES, BREP, STL, OBJ, glTF, and 20+ more formats.
|
||||||
// addon files can resolve their bare `from 'three'` specifier.
|
|
||||||
//
|
|
||||||
// Registered as field widget `fp_3d_preview` for Many2one fields
|
|
||||||
// (ir.attachment).
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { Component, useRef, onMounted, onWillUnmount, useState } from "@odoo/owl";
|
import { Component, useState } from "@odoo/owl";
|
||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
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 {
|
export class Fp3dViewer extends Component {
|
||||||
static template = "fusion_plating_configurator.Fp3dViewer";
|
static template = "fusion_plating_configurator.Fp3dViewer";
|
||||||
static props = {
|
static props = { ...standardFieldProps };
|
||||||
...standardFieldProps,
|
|
||||||
};
|
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.canvasRef = useRef("canvas3d");
|
this.state = useState({ hasAttachment: false, iframeSrc: "" });
|
||||||
this.state = useState({
|
this._updateState();
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return the raw value of the Many2one field (could be [id, name] or false). */
|
|
||||||
get rawValue() {
|
get rawValue() {
|
||||||
return this.props.record.data[this.props.name];
|
return this.props.record.data[this.props.name];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return the attachment id (integer) or 0. */
|
|
||||||
get attachmentId() {
|
get attachmentId() {
|
||||||
const v = this.rawValue;
|
const v = this.rawValue;
|
||||||
if (!v) return 0;
|
if (!v) return 0;
|
||||||
@@ -101,190 +32,28 @@ export class Fp3dViewer extends Component {
|
|||||||
return typeof v === "number" ? v : 0;
|
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;
|
const aid = this.attachmentId;
|
||||||
this.state.hasAttachment = !!aid;
|
this.state.hasAttachment = !!aid;
|
||||||
if (!aid || !this.canvasRef.el) return;
|
if (aid) {
|
||||||
await this._initViewer();
|
const name = encodeURIComponent(this.attachmentName);
|
||||||
}
|
this.state.iframeSrc = `/fp/3d-viewer?id=${aid}&name=${name}`;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
onPatched() {
|
||||||
* Minimal binary STL parser (fallback when STLLoader is unavailable).
|
this._updateState();
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register as a field widget for Many2one (ir.attachment) fields
|
|
||||||
registry.category("fields").add("fp_3d_preview", {
|
registry.category("fields").add("fp_3d_preview", {
|
||||||
component: Fp3dViewer,
|
component: Fp3dViewer,
|
||||||
supportedTypes: ["many2one"],
|
supportedTypes: ["many2one"],
|
||||||
|
|||||||
@@ -1,62 +1,63 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Fusion Plating -- 3D Viewer Widget Styles
|
// Fusion Plating -- 3D Viewer + Configurator Layout
|
||||||
// Copyright 2026 Nexa Systems Inc.
|
// Copyright 2026 Nexa Systems Inc.
|
||||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
// 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 {
|
.o_fp_3d_viewer_root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_3d_placeholder {
|
.o_fp_3d_placeholder {
|
||||||
border: 2px dashed $border-color;
|
border: 2px dashed $border-color;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.5rem;
|
||||||
min-height: 120px;
|
min-height: 200px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--bs-secondary-color);
|
color: var(--bs-secondary-color);
|
||||||
|
background-color: var(--bs-tertiary-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_3d_toolbar {
|
.o_fp_3d_iframe {
|
||||||
.btn {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_3d_canvas_container {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 350px;
|
height: 500px;
|
||||||
border: 1px solid $border-color;
|
border: 1px solid $border-color;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
background-color: #f0f2f5;
|
||||||
position: relative;
|
|
||||||
background-color: var(--bs-body-bg);
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
display: block;
|
display: block;
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_3d_loading {
|
// Inside the preview column, make iframe taller
|
||||||
position: absolute;
|
.o_fp_cfg_preview .o_fp_3d_iframe {
|
||||||
top: 0;
|
height: 600px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,19 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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">
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
<t t-name="fusion_plating_configurator.Fp3dViewer">
|
<t t-name="fusion_plating_configurator.Fp3dViewer">
|
||||||
<div class="o_fp_3d_viewer_root">
|
<div class="o_fp_3d_viewer_root">
|
||||||
<!-- No attachment uploaded yet -->
|
|
||||||
<t t-if="!state.hasAttachment">
|
<t t-if="!state.hasAttachment">
|
||||||
<div class="o_fp_3d_placeholder text-center text-muted p-4">
|
<div class="o_fp_3d_placeholder text-center text-muted p-4">
|
||||||
<i class="fa fa-cube fa-3x mb-2 d-block"/>
|
<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>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
<!-- Viewer -->
|
|
||||||
<t t-if="state.hasAttachment">
|
<t t-if="state.hasAttachment">
|
||||||
<!-- Toolbar -->
|
<iframe t-att-src="state.iframeSrc"
|
||||||
<div class="o_fp_3d_toolbar d-flex align-items-center gap-2 mb-1">
|
class="o_fp_3d_iframe"
|
||||||
<button class="btn btn-sm btn-outline-secondary" t-on-click="toggleWireframe"
|
frameborder="0"
|
||||||
title="Toggle wireframe">
|
allowfullscreen="true"/>
|
||||||
<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>
|
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
<menuitem id="menu_fp_sales"
|
<menuitem id="menu_fp_sales"
|
||||||
name="Sales"
|
name="Sales"
|
||||||
parent="fusion_plating.menu_fp_root"
|
parent="fusion_plating.menu_fp_root"
|
||||||
sequence="1"
|
sequence="5"
|
||||||
groups="group_fp_estimator"/>
|
groups="group_fp_estimator,fusion_plating.group_fusion_plating_supervisor"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_quotations"
|
<menuitem id="menu_fp_quotations"
|
||||||
name="Quotations"
|
name="Quotations"
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
<menuitem id="menu_fp_configurator"
|
<menuitem id="menu_fp_configurator"
|
||||||
name="Configurator"
|
name="Configurator"
|
||||||
parent="fusion_plating.menu_fp_root"
|
parent="fusion_plating.menu_fp_root"
|
||||||
sequence="2"
|
sequence="8"
|
||||||
groups="group_fp_estimator"/>
|
groups="group_fp_estimator"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_new_quote"
|
<menuitem id="menu_fp_new_quote"
|
||||||
|
|||||||
@@ -30,6 +30,22 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form string="Part Catalog">
|
<form string="Part Catalog">
|
||||||
<sheet>
|
<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"/>
|
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
||||||
<div class="oe_title">
|
<div class="oe_title">
|
||||||
<label for="name"/>
|
<label for="name"/>
|
||||||
@@ -78,20 +94,19 @@
|
|||||||
</page>
|
</page>
|
||||||
<page string="Attachments" name="attachments">
|
<page string="Attachments" name="attachments">
|
||||||
<group>
|
<group>
|
||||||
<field name="model_attachment_id" widget="fp_3d_preview"/>
|
<field name="model_attachment_id"/>
|
||||||
<field name="drawing_attachment_ids" widget="many2many_binary"/>
|
<field name="drawing_attachment_ids" widget="many2many_binary"/>
|
||||||
</group>
|
</group>
|
||||||
|
<div invisible="not model_attachment_id" class="mt-3">
|
||||||
|
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
|
||||||
|
</div>
|
||||||
</page>
|
</page>
|
||||||
<page string="Notes" name="notes">
|
<page string="Notes" name="notes">
|
||||||
<field name="notes" placeholder="Additional notes about this part..."/>
|
<field name="notes" placeholder="Additional notes about this part..."/>
|
||||||
</page>
|
</page>
|
||||||
</notebook>
|
</notebook>
|
||||||
</sheet>
|
</sheet>
|
||||||
<div class="oe_chatter">
|
<chatter/>
|
||||||
<field name="message_follower_ids"/>
|
|
||||||
<field name="activity_ids"/>
|
|
||||||
<field name="message_ids"/>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
@@ -19,53 +19,81 @@
|
|||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
confirm="This will create a Sale Order from this configurator session. Continue?"
|
confirm="This will create a Sale Order from this configurator session. Continue?"
|
||||||
invisible="state != 'draft'"/>
|
invisible="state != 'draft'"/>
|
||||||
|
<button name="action_recalculate_price"
|
||||||
|
string="Recalculate"
|
||||||
|
type="object"
|
||||||
|
class="btn-secondary"/>
|
||||||
<button name="action_cancel"
|
<button name="action_cancel"
|
||||||
string="Cancel"
|
string="Cancel"
|
||||||
type="object"
|
type="object"
|
||||||
invisible="state != 'draft'"/>
|
invisible="state == 'cancelled'"/>
|
||||||
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed"/>
|
<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>
|
</header>
|
||||||
<sheet>
|
<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">
|
<div class="oe_title">
|
||||||
<h1>
|
<h1>
|
||||||
<field name="name" readonly="1"/>
|
<field name="name" readonly="1"/>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<!-- Customer + Part / Coating + Quantity -->
|
|
||||||
|
<!-- 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>
|
||||||
<group string="Customer & Part">
|
<group string="Customer & Part">
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="part_catalog_id"/>
|
<field name="part_catalog_id"/>
|
||||||
</group>
|
|
||||||
<group string="Coating & Quantity">
|
|
||||||
<field name="coating_config_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 & Options">
|
||||||
<field name="quantity"/>
|
<field name="quantity"/>
|
||||||
<field name="batch_size"/>
|
<field name="batch_size"/>
|
||||||
|
<field name="complexity"/>
|
||||||
|
<field name="rush_order"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<!-- Geometry / Options -->
|
|
||||||
<group>
|
<group>
|
||||||
<group string="Geometry">
|
<group string="Geometry">
|
||||||
<field name="surface_area"/>
|
<field name="surface_area"/>
|
||||||
<field name="surface_area_uom"/>
|
<field name="surface_area_uom"/>
|
||||||
<field name="thickness_requested"/>
|
<field name="thickness_requested"/>
|
||||||
<field name="substrate_material"/>
|
<field name="substrate_material"/>
|
||||||
</group>
|
|
||||||
<group string="Options">
|
|
||||||
<field name="complexity"/>
|
|
||||||
<field name="masking_zones"/>
|
<field name="masking_zones"/>
|
||||||
<field name="rush_order"/>
|
|
||||||
<field name="turnaround_days"/>
|
<field name="turnaround_days"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
|
||||||
<!-- Delivery / Fees -->
|
|
||||||
<group>
|
|
||||||
<group string="Delivery & Fees">
|
<group string="Delivery & Fees">
|
||||||
<field name="delivery_method"/>
|
<field name="delivery_method"/>
|
||||||
<field name="shipping_fee"/>
|
<field name="shipping_fee"/>
|
||||||
<field name="delivery_fee"/>
|
<field name="delivery_fee"/>
|
||||||
</group>
|
|
||||||
<group>
|
|
||||||
<field name="currency_id" invisible="1"/>
|
<field name="currency_id" invisible="1"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
@@ -82,6 +110,21 @@
|
|||||||
<group>
|
<group>
|
||||||
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
|
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
|
||||||
</group>
|
</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>
|
<notebook>
|
||||||
<page string="Sale Order" name="sale_order">
|
<page string="Sale Order" name="sale_order">
|
||||||
<group>
|
<group>
|
||||||
@@ -93,10 +136,7 @@
|
|||||||
</page>
|
</page>
|
||||||
</notebook>
|
</notebook>
|
||||||
</sheet>
|
</sheet>
|
||||||
<div class="oe_chatter">
|
<chatter/>
|
||||||
<field name="message_follower_ids"/>
|
|
||||||
<field name="message_ids"/>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@@ -155,7 +195,7 @@
|
|||||||
<field name="res_model">fp.quote.configurator</field>
|
<field name="res_model">fp.quote.configurator</field>
|
||||||
<field name="view_mode">list,form</field>
|
<field name="view_mode">list,form</field>
|
||||||
<field name="search_view_id" ref="view_fp_quote_configurator_search"/>
|
<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">
|
<field name="help" type="html">
|
||||||
<p class="o_view_nocontent_smiling_face">
|
<p class="o_view_nocontent_smiling_face">
|
||||||
Create a new quote configurator session
|
Create a new quote configurator session
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
-->
|
-->
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- ===== DASHBOARD top-level menu ===== -->
|
<!-- ===== KPIs top-level menu ===== -->
|
||||||
<menuitem id="menu_fp_dashboard"
|
<menuitem id="menu_fp_dashboard"
|
||||||
name="Dashboard"
|
name="KPIs"
|
||||||
parent="fusion_plating.menu_fp_root"
|
parent="fusion_plating.menu_fp_root"
|
||||||
sequence="5"/>
|
sequence="85"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_kpis"
|
<menuitem id="menu_fp_kpis"
|
||||||
name="KPIs"
|
name="KPIs"
|
||||||
|
|||||||
@@ -7,25 +7,19 @@
|
|||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<!-- Add a Sales section to the Plating root menu for portal-facing -->
|
<!-- Portal-facing records live under the unified Sales menu defined -->
|
||||||
<!-- records (Quote Requests + Portal Jobs). -->
|
<!-- 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"
|
<menuitem id="menu_fp_quote_requests"
|
||||||
name="Quote Requests"
|
name="Quote Requests"
|
||||||
parent="menu_fp_sales"
|
parent="fusion_plating_configurator.menu_fp_sales"
|
||||||
action="action_fp_quote_request"
|
action="action_fp_quote_request"
|
||||||
sequence="10"/>
|
sequence="50"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_portal_jobs"
|
<menuitem id="menu_fp_portal_jobs"
|
||||||
name="Portal Jobs"
|
name="Portal Jobs"
|
||||||
parent="menu_fp_sales"
|
parent="fusion_plating_configurator.menu_fp_sales"
|
||||||
action="action_fp_portal_job"
|
action="action_fp_portal_job"
|
||||||
sequence="20"/>
|
sequence="60"/>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<menuitem id="menu_fp_quality"
|
<menuitem id="menu_fp_quality"
|
||||||
name="Quality"
|
name="Quality"
|
||||||
parent="fusion_plating.menu_fp_root"
|
parent="fusion_plating.menu_fp_root"
|
||||||
sequence="20"
|
sequence="30"
|
||||||
groups="fusion_plating.group_fusion_plating_operator"/>
|
groups="fusion_plating.group_fusion_plating_operator"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_quality_hold"
|
<menuitem id="menu_fp_quality_hold"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<menuitem id="menu_fp_receiving_root"
|
<menuitem id="menu_fp_receiving_root"
|
||||||
name="Receiving & Inspection"
|
name="Receiving & Inspection"
|
||||||
parent="fusion_plating.menu_fp_root"
|
parent="fusion_plating.menu_fp_root"
|
||||||
sequence="5"
|
sequence="15"
|
||||||
groups="group_fp_receiving"/>
|
groups="group_fp_receiving"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_receiving_all"
|
<menuitem id="menu_fp_receiving_all"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<menuitem id="menu_fp_safety_root"
|
<menuitem id="menu_fp_safety_root"
|
||||||
name="Safety"
|
name="Safety"
|
||||||
parent="fusion_plating.menu_fp_root"
|
parent="fusion_plating.menu_fp_root"
|
||||||
sequence="40"
|
sequence="45"
|
||||||
groups="fusion_plating.group_fusion_plating_operator"/>
|
groups="fusion_plating.group_fusion_plating_operator"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_safety_sds"
|
<menuitem id="menu_fp_safety_sds"
|
||||||
|
|||||||
7
fusion-plating/fusion_plating_sensors/__init__.py
Normal 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
|
||||||
52
fusion-plating/fusion_plating_sensors/__manifest__.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from . import sensor_controller
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
9
fusion-plating/fusion_plating_sensors/models/__init__.py
Normal 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
|
||||||
167
fusion-plating/fusion_plating_sensors/models/fp_sensor.py
Normal 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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||