Compare commits
100 Commits
a8eacc94bc
...
fusion_acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c898aadf | ||
|
|
6c4ff7751f | ||
|
|
956678dd27 | ||
|
|
e52477e2ba | ||
|
|
83271ee69e | ||
|
|
082c585e24 | ||
|
|
afc01ec1d9 | ||
|
|
11f7791c5e | ||
|
|
81277edb25 | ||
|
|
2588a2b651 | ||
|
|
83a999afad | ||
|
|
067d1f01c8 | ||
|
|
6d1efc6c43 | ||
|
|
298f5942eb | ||
|
|
ae03e32b5d | ||
|
|
d29857078a | ||
|
|
a660f1f05d | ||
|
|
f340c87b6a | ||
|
|
1c6a460ca1 | ||
|
|
095d9f487c | ||
|
|
28dd7fdd76 | ||
|
|
f94be9dfa9 | ||
|
|
70fe10c214 | ||
|
|
b85642816f | ||
|
|
b09538b4e2 | ||
|
|
e07002d550 | ||
|
|
3b5b5cbf7c | ||
|
|
adc27c637a | ||
|
|
838b41cb89 | ||
|
|
cb79186325 | ||
|
|
edd52f16a7 | ||
|
|
22b06f47d9 | ||
|
|
71bd0da5e1 | ||
|
|
44a980c468 | ||
|
|
66f7f6c644 | ||
|
|
96ecf7a9e1 | ||
|
|
fbaf318832 | ||
|
|
a623c6684d | ||
|
|
6658544f85 | ||
|
|
d3dd6376a6 | ||
|
|
7c7ef06057 | ||
|
|
3f3ddcbab4 | ||
|
|
e0e2c6cfda | ||
|
|
b62d4b1f36 | ||
|
|
4f97a8b089 | ||
|
|
d3c8782505 | ||
|
|
0ff8c0b93f | ||
|
|
1176ba68ae | ||
|
|
d58f11384e | ||
|
|
510fd02e9d | ||
|
|
3d0e3e276b | ||
|
|
2af9d37f45 | ||
|
|
3db30339b5 | ||
|
|
795c66c126 | ||
|
|
14d7781f4a | ||
|
|
f06e48e1a2 | ||
|
|
10607c48f0 | ||
|
|
e10bf9d8fd | ||
|
|
bc72486808 | ||
|
|
234a5b2b9f | ||
|
|
ad6906254f | ||
|
|
ab99aaa5da | ||
|
|
24656cc02b | ||
|
|
54540d5b1e | ||
|
|
ec8b26f8c8 | ||
|
|
7dea212c13 | ||
|
|
10e3ada9e9 | ||
|
|
d13517071c | ||
|
|
6a368993bf | ||
|
|
d06b9fd522 | ||
|
|
269469aa4f | ||
|
|
081612c903 | ||
|
|
86985bc023 | ||
|
|
aec7659a2e | ||
|
|
a337a510c1 | ||
|
|
a5761b9863 | ||
|
|
d3e2614620 | ||
|
|
5143245f57 | ||
|
|
2fa7f2aa2e | ||
|
|
2e80fd3ca1 | ||
|
|
87325e2caf | ||
|
|
73b7325b46 | ||
|
|
dde970a2f5 | ||
|
|
d424dfdb19 | ||
|
|
f69b3ac855 | ||
|
|
2b84c31a12 | ||
|
|
8fa53017c4 | ||
|
|
4185b149bd | ||
|
|
cb57585b5a | ||
|
|
6305faccb7 | ||
|
|
330112f29e | ||
|
|
e4b41828a3 | ||
|
|
3316b5d519 | ||
|
|
edc7b11cb6 | ||
|
|
b38310709a | ||
|
|
a7d224899a | ||
|
|
e146daf4c8 | ||
|
|
1159864eb6 | ||
|
|
59dfb3335a | ||
|
|
ccfae66975 |
54
CLAUDE.md
54
CLAUDE.md
@@ -14,6 +14,60 @@
|
||||
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
||||
6. **res.groups**: NO `users` field, NO `category_id` field.
|
||||
7. **Search views**: NO `group expand="0"` syntax.
|
||||
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
|
||||
|
||||
## Card Styling — Copy Odoo's Kanban Pattern
|
||||
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
||||
```css
|
||||
background-color: white;
|
||||
border: 1px solid #d8dadd;
|
||||
```
|
||||
For custom OWL dashboards / client actions use the same approach:
|
||||
- Define a `_tokens.scss` partial with explicit hex values wrapped in a CSS custom property:
|
||||
```scss
|
||||
$fp-card: var(--fp-card-bg, #ffffff);
|
||||
$fp-border: var(--fp-border-color, #d8dadd);
|
||||
```
|
||||
- Reference those tokens everywhere (never `var(--bs-border-color)` directly)
|
||||
- Three-layer contrast: **page** (grayest) → **container/column** (mid) → **card** (brightest). That's what makes cards pop.
|
||||
- Reference implementation: `fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss`.
|
||||
|
||||
## Dark Mode — Branch on `$o-webclient-color-scheme` at SCSS Compile Time
|
||||
Odoo 19 does NOT flip dark mode via a runtime DOM class. It compiles TWO asset bundles:
|
||||
- `web.assets_backend` — compiled with `$o-webclient-color-scheme: bright`
|
||||
- `web.assets_web_dark` — compiled with `$o-webclient-color-scheme: dark` (dark variant primary variables loaded first)
|
||||
|
||||
Your SCSS file is compiled into BOTH bundles. To make the dark bundle have different colors, **branch at compile time** using the SCSS variable Odoo sets:
|
||||
|
||||
```scss
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_my-page-hex: #f3f4f6;
|
||||
$_my-card-hex: #ffffff;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_my-page-hex: #1a1d21 !global;
|
||||
$_my-card-hex: #22262d !global;
|
||||
}
|
||||
|
||||
$my-page: var(--my-page-bg, $_my-page-hex);
|
||||
$my-card: var(--my-card-bg, $_my-card-hex);
|
||||
```
|
||||
|
||||
**Do NOT use** `.o_dark_mode` class selectors, `[data-bs-theme="dark"]`, or `@media (prefers-color-scheme: dark)` — none of those fire reliably in Odoo 19. The user toggles dark mode via the user profile, which sets a `color_scheme` cookie and reloads the page; Odoo then serves the dark bundle. Your SCSS `@if` handles the rest at compile time.
|
||||
|
||||
Verify by inspecting the attachments — you should see two files with different URLs for the two bundles:
|
||||
```python
|
||||
env['ir.qweb']._get_asset_bundle('web.assets_backend').css() # light
|
||||
env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() # dark
|
||||
```
|
||||
|
||||
## Asset Bundle Cache Busting
|
||||
Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS changes but the hash doesn't update, the browser serves the old bundle. Fixes in order of escalation:
|
||||
1. Bump the module `version` in `__manifest__.py`
|
||||
2. `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` then restart odoo
|
||||
3. Call `env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` in odoo-shell to force regeneration
|
||||
4. Hard-refresh browser with cache clear (DevTools → right-click refresh → *Empty Cache and Hard Reload*); on mobile clear website data
|
||||
|
||||
## Naming
|
||||
- New fields: `x_fc_*` prefix
|
||||
|
||||
36
Entech Plating/fusion_tasks/__init__.py
Normal file
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
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
BIN
Entech Plating/fusion_tasks/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -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
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
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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
241
Entech Plating/fusion_tasks/models/email_builder_mixin.py
Normal file
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
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
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
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
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
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
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
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
3028
Entech Plating/fusion_tasks/models/technician_task.py
Normal file
File diff suppressed because it is too large
Load Diff
12
Entech Plating/fusion_tasks/security/ir.model.access.csv
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
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
BIN
Entech Plating/fusion_tasks/static/description/icon.png
Normal file
Binary file not shown.
|
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
1200
Entech Plating/fusion_tasks/static/src/js/fusion_task_map_view.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
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
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
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
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>
|
||||
@@ -1,30 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
# ----- Facility footprint for this legal entity ----------------------
|
||||
x_fc_facility_ids = fields.One2many(
|
||||
'fusion.plating.facility',
|
||||
'company_id',
|
||||
string='Plating Facilities',
|
||||
)
|
||||
x_fc_facility_count = fields.Integer(
|
||||
string='# Facilities',
|
||||
compute='_compute_x_fc_facility_count',
|
||||
)
|
||||
x_fc_default_facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Default Facility',
|
||||
help='Facility used when the context does not specify one (single-site shops).',
|
||||
)
|
||||
|
||||
def _compute_x_fc_facility_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_facility_count = len(rec.x_fc_facility_ids)
|
||||
@@ -1,36 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpDelivery(models.Model):
|
||||
"""Extend delivery to auto-update portal job when delivered.
|
||||
|
||||
GAP 5: Delivery marked "delivered" → portal job → "shipped"
|
||||
+ set actual_ship_date on the job.
|
||||
"""
|
||||
_inherit = 'fusion.plating.delivery'
|
||||
|
||||
def action_mark_delivered(self):
|
||||
"""Override to cascade delivery completion to the portal job."""
|
||||
res = super().action_mark_delivered()
|
||||
PortalJob = self.env['fusion.plating.portal.job']
|
||||
for delivery in self:
|
||||
if not delivery.job_ref:
|
||||
continue
|
||||
# Find the portal job by name/reference
|
||||
job = PortalJob.search(
|
||||
[('name', '=', delivery.job_ref)], limit=1,
|
||||
)
|
||||
if not job:
|
||||
continue
|
||||
job.write({
|
||||
'state': 'shipped',
|
||||
'actual_ship_date': fields.Date.today(),
|
||||
'tracking_ref': delivery.name,
|
||||
})
|
||||
job.message_post(body='Parts shipped — delivery %s marked delivered.' % delivery.name)
|
||||
return res
|
||||
@@ -1,93 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class MrpProduction(models.Model):
|
||||
"""Extend manufacturing order with Fusion Plating references and
|
||||
workflow automations that bridge MO lifecycle → portal job → delivery.
|
||||
"""
|
||||
_inherit = 'mrp.production'
|
||||
|
||||
x_fc_customer_spec_id = fields.Many2one(
|
||||
'fusion.plating.customer.spec',
|
||||
string='Customer Spec',
|
||||
help='The customer specification governing this manufacturing order.',
|
||||
)
|
||||
x_fc_facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Facility',
|
||||
help='The Fusion Plating facility where this order is produced.',
|
||||
)
|
||||
x_fc_portal_job_id = fields.Many2one(
|
||||
'fusion.plating.portal.job',
|
||||
string='Portal Job',
|
||||
help='The portal job linked to this manufacturing order.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GAP 2: SO confirm → MO confirm → auto-create Portal Job
|
||||
# ------------------------------------------------------------------
|
||||
def action_confirm(self):
|
||||
"""Override to auto-create a portal job when the MO is confirmed."""
|
||||
res = super().action_confirm()
|
||||
PortalJob = self.env['fusion.plating.portal.job']
|
||||
for mo in self:
|
||||
if mo.x_fc_portal_job_id:
|
||||
# Already linked — just update state
|
||||
mo.x_fc_portal_job_id.write({'state': 'in_progress'})
|
||||
continue
|
||||
# Resolve customer from sale order via origin
|
||||
partner = False
|
||||
if mo.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', mo.origin)], limit=1,
|
||||
)
|
||||
if so:
|
||||
partner = so.partner_id
|
||||
if not partner:
|
||||
continue # No customer — skip portal job creation
|
||||
job = PortalJob.create({
|
||||
'name': mo.name,
|
||||
'partner_id': partner.id,
|
||||
'state': 'in_progress',
|
||||
'received_date': fields.Date.today(),
|
||||
'target_ship_date': (
|
||||
mo.date_start.date() + __import__('datetime').timedelta(days=10)
|
||||
if mo.date_start else False
|
||||
),
|
||||
'quantity': int(mo.product_qty),
|
||||
'company_id': mo.company_id.id,
|
||||
})
|
||||
mo.x_fc_portal_job_id = job
|
||||
return res
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GAP 3+4: MO done → update portal job + auto-create delivery
|
||||
# ------------------------------------------------------------------
|
||||
def button_mark_done(self):
|
||||
"""Override to cascade MO completion to portal job and delivery."""
|
||||
res = super().button_mark_done()
|
||||
Delivery = self.env.get('fusion.plating.delivery')
|
||||
for mo in self:
|
||||
job = mo.x_fc_portal_job_id
|
||||
if not job:
|
||||
continue
|
||||
# GAP 3: MO done → portal job ready_to_ship
|
||||
job.write({'state': 'ready_to_ship'})
|
||||
job.message_post(body='Manufacturing complete — ready to ship.')
|
||||
|
||||
# GAP 4: Auto-create delivery record
|
||||
if Delivery is None:
|
||||
continue
|
||||
partner = job.partner_id
|
||||
Delivery.create({
|
||||
'partner_id': partner.id,
|
||||
'job_ref': job.name,
|
||||
'source_facility_id': mo.x_fc_facility_id.id if mo.x_fc_facility_id else False,
|
||||
'state': 'draft',
|
||||
})
|
||||
return res
|
||||
@@ -1,7 +0,0 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_bridge_mrp_workcenter_manager,fp.bridge.mrp.workcenter.manager,mrp.model_mrp_workcenter,fusion_plating.group_fusion_plating_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_workcenter_supervisor,fp.bridge.mrp.workcenter.supervisor,mrp.model_mrp_workcenter,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_bridge_mrp_workorder_manager,fp.bridge.mrp.workorder.manager,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_workorder_supervisor,fp.bridge.mrp.workorder.supervisor,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_bridge_mrp_production_manager,fp.bridge.mrp.production.manager,mrp.model_mrp_production,fusion_plating.group_fusion_plating_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_production_supervisor,fp.bridge.mrp.production.supervisor,mrp.model_mrp_production,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
|
@@ -1,31 +0,0 @@
|
||||
<?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.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- Extend mrp.production form: add Fusion Plating fields -->
|
||||
<record id="view_mrp_production_form_fp_bridge" model="ir.ui.view">
|
||||
<field name="name">mrp.production.form.fp.bridge</field>
|
||||
<field name="model">mrp.production</field>
|
||||
<field name="inherit_id" ref="mrp.mrp_production_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Fusion Plating" name="fusion_plating">
|
||||
<group>
|
||||
<field name="x_fc_customer_spec_id"/>
|
||||
<field name="x_fc_facility_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_portal_job_id"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,205 +0,0 @@
|
||||
<?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.
|
||||
Paper format + report actions for all Fusion Plating reports.
|
||||
-->
|
||||
<odoo>
|
||||
<!-- ============================================================= -->
|
||||
<!-- Landscape Paper Format -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="paperformat_fp_a4_landscape" model="report.paperformat">
|
||||
<field name="name">A4 Landscape (Fusion Plating)</field>
|
||||
<field name="default" eval="False"/>
|
||||
<field name="format">A4</field>
|
||||
<field name="orientation">Landscape</field>
|
||||
<field name="margin_top">20</field>
|
||||
<field name="margin_bottom">20</field>
|
||||
<field name="margin_left">7</field>
|
||||
<field name="margin_right">7</field>
|
||||
<field name="header_line" eval="False"/>
|
||||
<field name="header_spacing">20</field>
|
||||
<field name="dpi">90</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 1. Certificate of Conformance (Portal Job) -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_coc" model="ir.actions.report">
|
||||
<field name="name">Certificate of Conformance</field>
|
||||
<field name="model">fusion.plating.portal.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_coc</field>
|
||||
<field name="report_file">fusion_plating_reports.report_coc</field>
|
||||
<field name="print_report_name">'CoC - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_portal.model_fusion_plating_portal_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 2. Non-Conformance Report -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_ncr" model="ir.actions.report">
|
||||
<field name="name">Non-Conformance Report</field>
|
||||
<field name="model">fusion.plating.ncr</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_ncr</field>
|
||||
<field name="report_file">fusion_plating_reports.report_ncr</field>
|
||||
<field name="print_report_name">'NCR - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_ncr"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 3. Corrective / Preventive Action -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_capa" model="ir.actions.report">
|
||||
<field name="name">CAPA Report</field>
|
||||
<field name="model">fusion.plating.capa</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_capa</field>
|
||||
<field name="report_file">fusion_plating_reports.report_capa</field>
|
||||
<field name="print_report_name">'CAPA - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_capa"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 4. Bath Chemistry Log -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_bath_log" model="ir.actions.report">
|
||||
<field name="name">Bath Chemistry Log</field>
|
||||
<field name="model">fusion.plating.bath.log</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_bath_chemistry_log</field>
|
||||
<field name="report_file">fusion_plating_reports.report_bath_chemistry_log</field>
|
||||
<field name="print_report_name">'Bath Log - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fusion_plating_bath_log"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 5. Calibration Certificate -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_calibration" model="ir.actions.report">
|
||||
<field name="name">Calibration Certificate</field>
|
||||
<field name="model">fusion.plating.calibration.equipment</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_calibration_cert</field>
|
||||
<field name="report_file">fusion_plating_reports.report_calibration_cert</field>
|
||||
<field name="print_report_name">'Calibration - %s' % object.code</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_calibration_equipment"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 6. First Article Inspection Report -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_fair" model="ir.actions.report">
|
||||
<field name="name">FAIR Report</field>
|
||||
<field name="model">fusion.plating.fair</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fair</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fair</field>
|
||||
<field name="print_report_name">'FAIR - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_fair"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 7. Audit Report -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_audit" model="ir.actions.report">
|
||||
<field name="name">Audit Report</field>
|
||||
<field name="model">fusion.plating.audit</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_audit</field>
|
||||
<field name="report_file">fusion_plating_reports.report_audit</field>
|
||||
<field name="print_report_name">'Audit - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_audit"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 8. Incident Report -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_incident" model="ir.actions.report">
|
||||
<field name="name">Incident Report</field>
|
||||
<field name="model">fusion.plating.incident</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_incident</field>
|
||||
<field name="report_file">fusion_plating_reports.report_incident</field>
|
||||
<field name="print_report_name">'Incident - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_safety.model_fusion_plating_incident"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 9. Spill Register -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_spill" model="ir.actions.report">
|
||||
<field name="name">Spill Report</field>
|
||||
<field name="model">fusion.plating.spill.register</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_spill</field>
|
||||
<field name="report_file">fusion_plating_reports.report_spill</field>
|
||||
<field name="print_report_name">'Spill - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_compliance.model_fusion_plating_spill_register"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 10. Waste Manifest -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_waste_manifest" model="ir.actions.report">
|
||||
<field name="name">Waste Manifest</field>
|
||||
<field name="model">fusion.plating.waste.manifest</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_waste_manifest</field>
|
||||
<field name="report_file">fusion_plating_reports.report_waste_manifest</field>
|
||||
<field name="print_report_name">'Waste Manifest - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_compliance.model_fusion_plating_waste_manifest"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 11. Discharge Sample -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_discharge_sample" model="ir.actions.report">
|
||||
<field name="name">Discharge Sample Report</field>
|
||||
<field name="model">fusion.plating.discharge.sample</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_discharge_sample</field>
|
||||
<field name="report_file">fusion_plating_reports.report_discharge_sample</field>
|
||||
<field name="print_report_name">'Discharge - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_compliance.model_fusion_plating_discharge_sample"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 12. Work Order Margin Report -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_wo_margin" model="ir.actions.report">
|
||||
<field name="name">Work Order Margin Report</field>
|
||||
<field name="model">mrp.production</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_wo_margin</field>
|
||||
<field name="report_file">fusion_plating_reports.report_wo_margin</field>
|
||||
<field name="print_report_name">'Margin Report - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="mrp.model_mrp_production"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,33 +0,0 @@
|
||||
<?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.
|
||||
Shared landscape CSS for all Fusion Plating reports.
|
||||
-->
|
||||
<odoo>
|
||||
<template id="fp_landscape_styles">
|
||||
<style>
|
||||
.fp-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
|
||||
.fp-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||||
.fp-landscape table.bordered, .fp-landscape table.bordered th, .fp-landscape table.bordered td { border: 1px solid #000; }
|
||||
.fp-landscape th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
|
||||
.fp-landscape td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
|
||||
.fp-landscape .text-center { text-align: center; }
|
||||
.fp-landscape .text-end { text-align: right; }
|
||||
.fp-landscape .text-start { text-align: left; }
|
||||
.fp-landscape .adp-bg { background-color: #e3f2fd; }
|
||||
.fp-landscape .client-bg { background-color: #fff3e0; }
|
||||
.fp-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fp-landscape .note-row { font-style: italic; }
|
||||
.fp-landscape h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
|
||||
.fp-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
|
||||
.fp-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
|
||||
.fp-landscape .totals-table { border: 1px solid #000; }
|
||||
.fp-landscape .totals-table td { border: 1px solid #000; padding: 8px 12px; font-size: 11pt; }
|
||||
.fp-landscape .status-ok { color: #2e7d32; font-weight: bold; }
|
||||
.fp-landscape .status-warning { color: #f57f17; font-weight: bold; }
|
||||
.fp-landscape .status-fail { color: #c62828; font-weight: bold; }
|
||||
</style>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -1,114 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Certificate of Conformance — Portal Job
|
||||
-->
|
||||
<odoo>
|
||||
<template id="report_coc">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
|
||||
<div class="fp-landscape">
|
||||
<div class="page">
|
||||
<h2 style="text-align: left;">
|
||||
Certificate of Conformance
|
||||
<span t-field="doc.name"/>
|
||||
</h2>
|
||||
|
||||
<!-- Job Info -->
|
||||
<table class="bordered info-table">
|
||||
<thead><tr>
|
||||
<th>JOB REF</th>
|
||||
<th>CUSTOMER</th>
|
||||
<th>QUANTITY</th>
|
||||
<th>RECEIVED</th>
|
||||
<th>SHIP DATE</th>
|
||||
<th>TRACKING REF</th>
|
||||
<th>STATUS</th>
|
||||
</tr></thead>
|
||||
<tbody><tr>
|
||||
<td class="text-center"><span t-field="doc.name"/></td>
|
||||
<td><span t-field="doc.partner_id"/></td>
|
||||
<td class="text-center"><span t-field="doc.quantity"/></td>
|
||||
<td class="text-center"><span t-field="doc.received_date" t-options="{'widget': 'date'}"/></td>
|
||||
<td class="text-center"><span t-field="doc.actual_ship_date" t-options="{'widget': 'date'}"/></td>
|
||||
<td class="text-center"><span t-field="doc.tracking_ref"/></td>
|
||||
<td class="text-center"><span t-field="doc.state"/></td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
|
||||
<!-- Customer Address -->
|
||||
<table class="bordered">
|
||||
<thead><tr>
|
||||
<th colspan="2">CUSTOMER DETAILS</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:30%; font-weight:bold;">Name</td>
|
||||
<td><span t-field="doc.partner_id.name"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight:bold;">Address</td>
|
||||
<td>
|
||||
<span t-field="doc.partner_id" t-options="{'widget': 'contact', 'fields': ['address'], 'no_marker': True}"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Processes -->
|
||||
<table class="bordered" t-if="doc.process_type_ids">
|
||||
<thead><tr>
|
||||
<th>PROCESSES APPLIED</th>
|
||||
</tr></thead>
|
||||
<tbody><tr>
|
||||
<td>
|
||||
<t t-foreach="doc.process_type_ids" t-as="pt">
|
||||
<span t-out="pt.name"/>
|
||||
<t t-if="not pt_last">, </t>
|
||||
</t>
|
||||
</td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
|
||||
<!-- Certification Statement -->
|
||||
<table class="bordered">
|
||||
<tr class="section-row"><td>CERTIFICATION</td></tr>
|
||||
<tr><td style="padding: 16px 12px; font-size: 11pt;">
|
||||
This certifies that the above items were processed in accordance
|
||||
with applicable specifications and meet all requirements as stated
|
||||
in the purchase order. All work was performed in compliance with
|
||||
the quality management system.
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- Notes -->
|
||||
<t t-if="doc.notes">
|
||||
<table class="bordered">
|
||||
<tr class="section-row"><td>NOTES</td></tr>
|
||||
<tr><td><t t-out="doc.notes"/></td></tr>
|
||||
</table>
|
||||
</t>
|
||||
|
||||
<!-- Signature Block -->
|
||||
<table class="bordered" style="margin-top: 30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:50%; height: 60px; vertical-align: bottom; font-weight: bold;">
|
||||
Quality Manager Signature: ___________________________
|
||||
</td>
|
||||
<td style="width:50%; height: 60px; vertical-align: bottom; font-weight: bold;">
|
||||
Date: ___________________________
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -1,147 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Plant Overview Dashboard (OWL backend client action)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Steelhead-style multi-column kanban showing all active work orders grouped
|
||||
// by work centre / station. Auto-refreshes every 30 s.
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL component: `static template` + `static props = ["*"]`
|
||||
// * RPC via standalone `rpc()` from @web/core/network/rpc
|
||||
// * Registered under registry.category("actions") → "fp_plant_overview"
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class PlantOverview extends Component {
|
||||
static template = "fusion_plating_shopfloor.PlantOverview";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
|
||||
this.state = useState({
|
||||
facilityName: "",
|
||||
columns: [],
|
||||
searchTerm: "",
|
||||
loading: false,
|
||||
lastRefresh: null,
|
||||
});
|
||||
|
||||
this._refreshInterval = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await this.loadData();
|
||||
// Auto-refresh every 30 seconds
|
||||
this._refreshInterval = setInterval(() => this.loadData(), 30000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._refreshInterval) {
|
||||
clearInterval(this._refreshInterval);
|
||||
this._refreshInterval = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Data loading ------------------------------------------------------
|
||||
|
||||
async loadData() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const result = await rpc("/fp/shopfloor/plant_overview", {
|
||||
search: this.state.searchTerm || null,
|
||||
});
|
||||
if (result) {
|
||||
this.state.facilityName = result.facility_name || "Plant 1";
|
||||
this.state.columns = result.columns || [];
|
||||
this.state.lastRefresh = new Date().toLocaleTimeString();
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
`Failed to load plant overview: ${err.message || err}`,
|
||||
{ type: "danger" },
|
||||
);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Search ------------------------------------------------------------
|
||||
|
||||
onSearchInput(ev) {
|
||||
this.state.searchTerm = ev.target.value;
|
||||
}
|
||||
|
||||
onSearchKey(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
this.loadData();
|
||||
}
|
||||
}
|
||||
|
||||
onSearchClear() {
|
||||
this.state.searchTerm = "";
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
onRefresh() {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
// ----- Card actions ------------------------------------------------------
|
||||
|
||||
onCardClick(card) {
|
||||
if (!card.id) {
|
||||
return;
|
||||
}
|
||||
// Try opening the work order form if MRP is available, otherwise
|
||||
// fall back to bake window or first-piece gate
|
||||
const model = card.source_model || "mrp.workorder";
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: model,
|
||||
res_id: card.id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Helpers -----------------------------------------------------------
|
||||
|
||||
getTagClass(tag) {
|
||||
const lower = (tag || "").toLowerCase();
|
||||
if (lower === "hot") {
|
||||
return "o_fp_tag_hot";
|
||||
}
|
||||
if (lower === "priority" || lower === "high priority") {
|
||||
return "o_fp_tag_priority";
|
||||
}
|
||||
if (lower.includes("attention") || lower.includes("special")) {
|
||||
return "o_fp_tag_attention";
|
||||
}
|
||||
return "o_fp_tag_default";
|
||||
}
|
||||
|
||||
getStateClass(state) {
|
||||
switch (state) {
|
||||
case "progress":
|
||||
return "o_fp_card_progress";
|
||||
case "ready":
|
||||
return "o_fp_card_ready";
|
||||
case "done":
|
||||
return "o_fp_card_done";
|
||||
case "pending":
|
||||
return "o_fp_card_pending";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_plant_overview", PlantOverview);
|
||||
@@ -1,178 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Shop Floor Tablet (OWL backend client action)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL component using `static template` + `static props = []`
|
||||
// (note: empty array, NOT empty object).
|
||||
// * RPC via standalone `rpc()` from @web/core/network/rpc — NOT useService.
|
||||
// * Registered under registry.category("actions") so the menu / record
|
||||
// action can launch it as a client action ("fp_shopfloor_tablet").
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, useRef } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class ShopfloorTablet extends Component {
|
||||
static template = "fusion_plating_shopfloor.ShopfloorTablet";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.scanInput = useRef("scanInput");
|
||||
|
||||
this.state = useState({
|
||||
scannedCode: "",
|
||||
station: null,
|
||||
currentTank: null,
|
||||
currentBath: null,
|
||||
currentJob: null,
|
||||
queueRows: [],
|
||||
message: "",
|
||||
messageType: "info", // info | success | warning | danger
|
||||
loading: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this.refreshQueue();
|
||||
if (this.scanInput.el) {
|
||||
this.scanInput.el.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Helpers --------------------------------------------------------
|
||||
setMessage(text, type = "info") {
|
||||
this.state.message = text;
|
||||
this.state.messageType = type;
|
||||
}
|
||||
|
||||
clearTargets() {
|
||||
this.state.currentTank = null;
|
||||
this.state.currentBath = null;
|
||||
this.state.currentJob = null;
|
||||
}
|
||||
|
||||
// ----- QR scan --------------------------------------------------------
|
||||
async onScan() {
|
||||
const code = (this.state.scannedCode || "").trim();
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const result = await rpc("/fp/shopfloor/scan", { qr_code: code });
|
||||
if (!result || !result.ok) {
|
||||
this.setMessage(
|
||||
(result && result.error) || "Unrecognised QR code",
|
||||
"danger",
|
||||
);
|
||||
this.state.loading = false;
|
||||
return;
|
||||
}
|
||||
this.clearTargets();
|
||||
switch (result.model) {
|
||||
case "fusion.plating.tank":
|
||||
this.state.currentTank = result;
|
||||
this.setMessage(
|
||||
`Tank ${result.name} — ${result.queue_size} in queue`,
|
||||
"info",
|
||||
);
|
||||
break;
|
||||
case "fusion.plating.bath":
|
||||
this.state.currentBath = result;
|
||||
this.setMessage(`Bath ${result.name}`, "info");
|
||||
break;
|
||||
case "fusion.plating.bake.window":
|
||||
this.state.currentJob = result;
|
||||
this.setMessage(
|
||||
`Job ${result.name} — ${result.time_remaining || ""} remaining`,
|
||||
result.state === "missed_window" ? "danger" : "warning",
|
||||
);
|
||||
break;
|
||||
case "fusion.plating.shopfloor.station":
|
||||
this.state.station = result;
|
||||
this.setMessage(
|
||||
`Station paired: ${result.name}`,
|
||||
"success",
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.setMessage(`Scanned ${result.model}`, "info");
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Scan error: ${err.message || err}`, "danger");
|
||||
} finally {
|
||||
this.state.scannedCode = "";
|
||||
this.state.loading = false;
|
||||
if (this.scanInput.el) {
|
||||
this.scanInput.el.focus();
|
||||
}
|
||||
await this.refreshQueue();
|
||||
}
|
||||
}
|
||||
|
||||
onScanKey(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
this.onScan();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Bake controls --------------------------------------------------
|
||||
async onStartBake() {
|
||||
if (!this.state.currentJob) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/start_bake", {
|
||||
bake_window_id: this.state.currentJob.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage("Bake started", "success");
|
||||
this.state.currentJob.state = res.state;
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Start bake failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refreshQueue();
|
||||
}
|
||||
|
||||
async onEndBake() {
|
||||
if (!this.state.currentJob) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/end_bake", {
|
||||
bake_window_id: this.state.currentJob.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage(
|
||||
`Bake complete — ${res.bake_duration_hours.toFixed(2)} h`,
|
||||
"success",
|
||||
);
|
||||
this.state.currentJob.state = res.state;
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`End bake failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refreshQueue();
|
||||
}
|
||||
|
||||
// ----- Queue ----------------------------------------------------------
|
||||
async refreshQueue() {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/queue", {});
|
||||
if (res && res.ok) {
|
||||
this.state.queueRows = res.rows || [];
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal: queue refresh shouldn't block scanning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_shopfloor_tablet", ShopfloorTablet);
|
||||
@@ -1,280 +0,0 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Shop Floor backend / tablet styles
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// THEME AWARENESS
|
||||
// ---------------
|
||||
// All colours come from CSS custom properties (Bootstrap / Odoo tokens) so
|
||||
// the tablet view renders correctly in BOTH light and dark mode without any
|
||||
// duplication or media queries. Status tints use color-mix() against the
|
||||
// theme token so green/yellow/red adapt to the surface.
|
||||
//
|
||||
// background: var(--bs-body-bg)
|
||||
// surface: var(--o-view-background-color)
|
||||
// foreground: var(--bs-body-color)
|
||||
// muted text: var(--bs-secondary-color)
|
||||
// border: var(--bs-border-color)
|
||||
// primary: var(--o-action)
|
||||
// =============================================================================
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Local mixin — semantic tint that respects light/dark mode
|
||||
// -----------------------------------------------------------------------------
|
||||
@mixin fp-shop-tint($color-var, $amount: 14%) {
|
||||
background-color: color-mix(in srgb, var(#{$color-var}) #{$amount}, transparent);
|
||||
color: var(#{$color-var});
|
||||
border: 1px solid color-mix(in srgb, var(#{$color-var}) 35%, transparent);
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tablet root container — large touch targets, generous whitespace
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_tablet {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
|
||||
.o_fp_tablet_header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_station {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.o_fp_tablet_scan_row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.o_fp_tablet_message {
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
|
||||
&.o_fp_msg_info { @include fp-shop-tint(--bs-info); }
|
||||
&.o_fp_msg_success { @include fp-shop-tint(--bs-success); }
|
||||
&.o_fp_msg_warning { @include fp-shop-tint(--bs-warning); }
|
||||
&.o_fp_msg_danger { @include fp-shop-tint(--bs-danger); }
|
||||
}
|
||||
|
||||
.o_fp_tablet_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
|
||||
.o_fp_tablet_queue_title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--bs-border-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_item {
|
||||
background-color: color-mix(in srgb, var(--bs-body-color) 4%, transparent);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
|
||||
.o_fp_tablet_queue_label {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_desc {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Large card surface used for tank / bath info on the tablet
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_tablet_card {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
min-height: 140px;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--o-action) 50%, var(--bs-border-color));
|
||||
box-shadow: 0 2px 10px color-mix(in srgb, var(--bs-body-color) 8%, transparent);
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_meta {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Bake window card — colour shifts with state
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_bake_window_card {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-left-width: 6px;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
min-height: 160px;
|
||||
|
||||
.o_fp_tablet_card_label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.o_fp_tablet_card_value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.o_fp_tablet_card_meta {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.o_fp_tablet_card_actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
&[data-status="awaiting_bake"] {
|
||||
border-left-color: var(--bs-warning);
|
||||
background-color: color-mix(in srgb, var(--bs-warning) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
}
|
||||
&[data-status="bake_in_progress"] {
|
||||
border-left-color: var(--bs-info, var(--o-action));
|
||||
background-color: color-mix(in srgb, var(--bs-info, var(--o-action)) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
}
|
||||
&[data-status="baked"] {
|
||||
border-left-color: var(--bs-success);
|
||||
background-color: color-mix(in srgb, var(--bs-success) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
}
|
||||
&[data-status="missed_window"],
|
||||
&[data-status="scrapped"] {
|
||||
border-left-color: var(--bs-danger);
|
||||
background-color: color-mix(in srgb, var(--bs-danger) 8%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Large QR scan input — friendly to tablet keyboards / wedge scanners
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_scan_input {
|
||||
flex: 1 1 auto;
|
||||
min-height: 56px;
|
||||
padding: 12px 18px;
|
||||
font-size: 1.3rem;
|
||||
border: 2px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--o-action);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--o-action) 25%, transparent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Big touch-friendly action button
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_big_button {
|
||||
min-height: 56px;
|
||||
min-width: 120px;
|
||||
padding: 12px 24px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--o-action);
|
||||
background-color: var(--o-action);
|
||||
color: var(--o-we-text-on-action, #fff);
|
||||
cursor: pointer;
|
||||
transition: filter 120ms ease, transform 80ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Plant Overview Dashboard
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// THEME AWARENESS
|
||||
// ---------------
|
||||
// All colours come from CSS custom properties (Bootstrap / Odoo tokens) so
|
||||
// the dashboard renders correctly in BOTH light and dark mode.
|
||||
//
|
||||
// background: var(--bs-body-bg)
|
||||
// surface: var(--o-view-background-color)
|
||||
// foreground: var(--bs-body-color)
|
||||
// muted text: var(--bs-secondary-color)
|
||||
// border: var(--bs-border-color)
|
||||
// primary: var(--o-action)
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_plant_overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--o-view-background-color, var(--bs-body-bg));
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// ---- Header -----------------------------------------------------------------
|
||||
|
||||
.o_fp_po_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: var(--bs-body-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
box-shadow: 0 1px 3px color-mix(in srgb, var(--bs-body-color) 6%, transparent);
|
||||
|
||||
.o_fp_po_header_left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.o_fp_po_title {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_po_refresh_ts {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.o_fp_po_header_right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Search -----------------------------------------------------------------
|
||||
|
||||
.o_fp_po_search_box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.o_fp_po_search_icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: var(--bs-secondary-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.o_fp_po_search_input {
|
||||
padding: 6px 32px 6px 32px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
width: 260px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--o-action);
|
||||
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--o-action) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_po_search_clear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--bs-secondary-color);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
|
||||
&:hover {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_po_refresh_btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
// ---- Columns container ------------------------------------------------------
|
||||
|
||||
.o_fp_po_columns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
// ---- Single column (work centre) --------------------------------------------
|
||||
|
||||
.o_fp_po_column {
|
||||
flex: 0 0 280px;
|
||||
min-width: 260px;
|
||||
max-width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bs-body-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--bs-body-color) 8%, transparent);
|
||||
max-height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
.o_fp_po_col_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 2px solid var(--bs-border-color);
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-radius: 10px 10px 0 0;
|
||||
|
||||
.o_fp_po_col_name {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
color: var(--bs-body-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.o_fp_po_col_count {
|
||||
background: var(--bs-secondary-bg);
|
||||
color: var(--bs-body-color);
|
||||
font-size: 0.75rem;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_po_col_body {
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// ---- Card -------------------------------------------------------------------
|
||||
|
||||
.o_fp_po_card {
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s, transform 0.1s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--bs-body-color) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// State variants
|
||||
&.o_fp_card_progress {
|
||||
border-left: 4px solid var(--bs-warning);
|
||||
}
|
||||
&.o_fp_card_ready {
|
||||
border-left: 4px solid var(--bs-primary);
|
||||
}
|
||||
&.o_fp_card_done {
|
||||
border-left: 4px solid var(--bs-success);
|
||||
opacity: 0.75;
|
||||
}
|
||||
&.o_fp_card_pending {
|
||||
border-left: 4px solid var(--bs-warning);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Card top row (image + title + step badge) --------------------------------
|
||||
|
||||
.o_fp_po_card_top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.o_fp_po_card_img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.o_fp_po_card_img_placeholder {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
background: var(--bs-tertiary-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.o_fp_po_card_title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--bs-body-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.o_fp_po_card_step_badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--bs-info);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ---- Priority card borders ---------------------------------------------------
|
||||
|
||||
.o_fp_po_card_hot {
|
||||
border-left: 4px solid var(--bs-danger) !important;
|
||||
background: color-mix(in srgb, var(--bs-danger) 8%, var(--bs-body-bg));
|
||||
}
|
||||
|
||||
.o_fp_po_card_urgent {
|
||||
border-left: 4px solid var(--bs-warning) !important;
|
||||
background: color-mix(in srgb, var(--bs-warning) 8%, var(--bs-body-bg));
|
||||
}
|
||||
|
||||
// ---- Product name and step display -------------------------------------------
|
||||
|
||||
.o_fp_po_card_product {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.o_fp_po_card_step {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.o_fp_po_card_customer {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2px;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_po_card_refs {
|
||||
font-size: 0.8rem;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
// ---- Parts progress bar -----------------------------------------------------
|
||||
|
||||
.o_fp_po_card_parts {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.o_fp_po_parts_bar {
|
||||
height: 6px;
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.o_fp_po_parts_fill {
|
||||
height: 100%;
|
||||
background: var(--bs-warning);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.o_fp_po_parts_label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.o_fp_po_card_last {
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
// ---- Tags + date footer -----------------------------------------------------
|
||||
|
||||
.o_fp_po_card_footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.o_fp_po_card_tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.o_fp_po_tag {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.4;
|
||||
|
||||
&.o_fp_tag_hot {
|
||||
background: var(--bs-danger);
|
||||
color: #fff;
|
||||
}
|
||||
&.o_fp_tag_priority {
|
||||
background: var(--bs-success);
|
||||
color: #fff;
|
||||
}
|
||||
&.o_fp_tag_attention {
|
||||
background: var(--bs-warning);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
&.o_fp_tag_default {
|
||||
background: var(--bs-tertiary-bg);
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_po_card_date {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-secondary-color);
|
||||
background: var(--bs-tertiary-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// ---- Empty / no-cards -------------------------------------------------------
|
||||
|
||||
.o_fp_po_no_cards {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
// ---- Responsive -------------------------------------------------------------
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.o_fp_po_columns {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.o_fp_po_column {
|
||||
flex: 1 1 auto;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.o_fp_po_search_input {
|
||||
width: 180px !important;
|
||||
}
|
||||
|
||||
.o_fp_po_header {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ShopfloorTablet">
|
||||
<div class="o_fp_tablet">
|
||||
<div class="o_fp_tablet_header">
|
||||
<div class="o_fp_tablet_title">Fusion Plating — Shop Floor</div>
|
||||
<div class="o_fp_tablet_station" t-if="state.station">
|
||||
Station: <strong t-esc="state.station.name"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_scan_row">
|
||||
<input
|
||||
type="text"
|
||||
class="o_fp_scan_input"
|
||||
placeholder="Scan QR code"
|
||||
t-ref="scanInput"
|
||||
t-model="state.scannedCode"
|
||||
t-on-keydown="onScanKey"
|
||||
/>
|
||||
<button class="o_fp_big_button" t-on-click="onScan" t-att-disabled="state.loading">
|
||||
Scan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div t-if="state.message" t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
|
||||
<span t-esc="state.message"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_grid">
|
||||
<div class="o_fp_tablet_card" t-if="state.currentTank">
|
||||
<div class="o_fp_tablet_card_label">Tank</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentTank.name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentTank.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta" t-if="state.currentTank.current_bath_name">
|
||||
Bath: <t t-esc="state.currentTank.current_bath_name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
Queue: <t t-esc="state.currentTank.queue_size"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_card" t-if="state.currentBath">
|
||||
<div class="o_fp_tablet_card_label">Bath</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentBath.name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentBath.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta" t-if="state.currentBath.tank_name">
|
||||
Tank: <t t-esc="state.currentBath.tank_name"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_bake_window_card"
|
||||
t-if="state.currentJob"
|
||||
t-att-data-status="state.currentJob.state">
|
||||
<div class="o_fp_tablet_card_label">Bake Job</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentJob.name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentJob.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
Remaining: <t t-esc="state.currentJob.time_remaining"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_actions">
|
||||
<button class="o_fp_big_button"
|
||||
t-if="state.currentJob.state === 'awaiting_bake'"
|
||||
t-on-click="onStartBake">
|
||||
Start Bake
|
||||
</button>
|
||||
<button class="o_fp_big_button"
|
||||
t-if="state.currentJob.state === 'bake_in_progress'"
|
||||
t-on-click="onEndBake">
|
||||
End Bake
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_queue">
|
||||
<div class="o_fp_tablet_queue_title">Next Up</div>
|
||||
<div t-if="!state.queueRows.length" class="text-muted">
|
||||
Queue is empty.
|
||||
</div>
|
||||
<ul class="o_fp_tablet_queue_list" t-if="state.queueRows.length">
|
||||
<t t-foreach="state.queueRows" t-as="row" t-key="row.id">
|
||||
<li class="o_fp_tablet_queue_item">
|
||||
<div class="o_fp_tablet_queue_label">
|
||||
<strong t-esc="row.label"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_queue_desc text-muted">
|
||||
<t t-esc="row.description"/>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,949 @@
|
||||
# Fusion Accounting — Enterprise Takeover Roadmap
|
||||
|
||||
**Status:** Design (approved 2026-04-18)
|
||||
**Owner:** Nexa Systems Inc.
|
||||
**Target:** Odoo 19 Community + fusion_accounting becomes a feature-complete drop-in replacement for Odoo 19 Enterprise accounting (`account_accountant`, `account_reports`, `accountant`, `account_followup`, plus selected satellite modules) for clients deployed by Nexa Systems.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context and Goals
|
||||
|
||||
### 1.1 Current State
|
||||
|
||||
`fusion_accounting` today is a thin AI co-pilot that depends on three Enterprise modules:
|
||||
|
||||
```python
|
||||
'depends': ['account', 'account_accountant', 'account_reports', 'account_followup', 'mail']
|
||||
```
|
||||
|
||||
It adds Claude/GPT-driven tool calling, a chat panel, a dashboard, an approval workflow, and rule-based automation on top of Odoo's accounting features. It does not own any core accounting capability — it orchestrates Enterprise's APIs.
|
||||
|
||||
### 1.2 Business Driver
|
||||
|
||||
Nexa Systems deploys Odoo to clients. The Enterprise subscription cost is a friction point. The goal is to deliver Enterprise-equivalent accounting capability on Odoo 19 Community via fusion_accounting, so clients can run on Community without losing core accounting features. fusion_accounting is **not** distributed publicly (no Odoo App Store listing); it ships only as part of a Nexa client engagement.
|
||||
|
||||
### 1.3 Scope of "Takeover"
|
||||
|
||||
The Enterprise modules being targeted, with verified file counts:
|
||||
|
||||
| Enterprise Module | Files | Role | Targeted Phase |
|
||||
|---|---|---|---|
|
||||
| `account_accountant` | 232 | bank-rec widget, journal dashboard, fiscal year, auto-reconcile, deferred revenue/expense, signing | Phases 1, 3 |
|
||||
| `account_reports` | 618 | financial reports engine + 18 standard reports | Phase 2 |
|
||||
| `accountant` | 26 | menu root + glue | Phase 0 |
|
||||
| `account_followup` | 58 | customer payment reminders | Phase 5 |
|
||||
| `account_asset` | n/a | asset register, depreciation | Phase 6 |
|
||||
| `account_budget` | n/a | budgets vs actuals | Phase 6 |
|
||||
| `account_loans`, `account_3way_match`, `account_check_printing`, `account_batch_payment`, `account_iso20022`, `account_intrastat`, `account_saft`, `account_sepa_direct_debit`, `account_online_synchronization`, `account_edi_*` | n/a | various | Phase 7+ (per client need) |
|
||||
|
||||
### 1.4 Existing Reference Material
|
||||
|
||||
- `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/` — current AI module (will be reorganized in Phase 0)
|
||||
- `/Users/gurpreet/Github/Odoo-Modules/Work in Progress/fusion_accounting/` — abandoned earlier attempt; contains 461 files of code that a Feb 2026 audit (in that folder's `AUDIT_REPORT.md`) determined to be near-verbatim copies of Odoo Enterprise. **The WIP code is not continued.** Its `__manifest__.py` is harvested as a feature checklist; its file structure as a target-architecture sanity check
|
||||
- `/Users/gurpreet/Github/RePackaged-Odoo/accounting/` — pinned snapshot of Odoo 19 Enterprise accounting source; used as reference-only for clean-room rewrites and as the diff baseline for V19→V20 upgrades
|
||||
|
||||
### 1.5 Non-Goals
|
||||
|
||||
- Not building a public commercial product (no App Store distribution, no commercial licensing pricing model)
|
||||
- Not replicating every Enterprise feature (Phase 7+ items are deferred until a real client needs them)
|
||||
- Not maintaining backward compatibility with Odoo versions before 19
|
||||
- Not rewriting Community `account` — fusion_accounting builds on top of, never replaces, Community accounting
|
||||
|
||||
---
|
||||
|
||||
## 2. Sub-Module Topology
|
||||
|
||||
fusion_accounting is split into independently installable sub-modules. Each has a single, well-bounded responsibility and a clear Enterprise counterpart it replaces.
|
||||
|
||||
### 2.1 The Sub-Modules
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
community["account<br/>Odoo Community base"]
|
||||
|
||||
core["fusion_accounting_core<br/>shared fields, lock dates, fiscal year base,<br/>company config, security groups, analytic_mixin"]
|
||||
bankrec["fusion_accounting_bank_rec<br/>reconcile widget + auto-reconcile engine"]
|
||||
reports["fusion_accounting_reports<br/>financial reports engine + standard reports"]
|
||||
dashboard["fusion_accounting_dashboard<br/>journal kanban, digest"]
|
||||
followup["fusion_accounting_followup<br/>payment reminders"]
|
||||
assets["fusion_accounting_assets<br/>asset register, depreciation"]
|
||||
budget["fusion_accounting_budget<br/>budgets vs actuals"]
|
||||
ai["fusion_accounting_ai<br/>Claude/GPT copilot + chat + dashboard tiles<br/>(current fusion_accounting code lives here)"]
|
||||
migration["fusion_accounting_migration<br/>transitional Enterprise to fusion data wizard"]
|
||||
|
||||
meta["fusion_accounting<br/>meta-module: depends on all sub-modules"]
|
||||
|
||||
core --> community
|
||||
bankrec --> core
|
||||
reports --> core
|
||||
dashboard --> core
|
||||
followup --> reports
|
||||
assets --> core
|
||||
budget --> core
|
||||
ai --> core
|
||||
migration --> core
|
||||
|
||||
ai -.optional adapter calls.-> bankrec
|
||||
ai -.optional adapter calls.-> reports
|
||||
ai -.optional adapter calls.-> followup
|
||||
ai -.optional adapter calls.-> assets
|
||||
|
||||
meta --> core
|
||||
meta --> bankrec
|
||||
meta --> reports
|
||||
meta --> dashboard
|
||||
meta --> followup
|
||||
meta --> assets
|
||||
meta --> budget
|
||||
meta --> ai
|
||||
meta -.transitional only.-> migration
|
||||
```
|
||||
|
||||
### 2.2 Sub-Module Responsibilities
|
||||
|
||||
| Sub-module | Replaces | Owns | Phase |
|
||||
|---|---|---|---|
|
||||
| `fusion_accounting_core` | `accountant` (menu glue), shared bits of `account_accountant` | Shared field declarations on `account.move`/`account.bank.statement.line` (deferred fields, signing user), `fusion.fiscal.year`, lock-date wizard, security groups, settings page, `analytic_mixin` shared ownership | Phase 0 |
|
||||
| `fusion_accounting_bank_rec` | `account_accountant` bank rec widget + `account_accountant/wizard/account_auto_reconcile_wizard.py` | OWL bank-rec widget, `fusion.reconcile.engine`, auto-reconcile wizard, reconcile model extensions | Phase 1 |
|
||||
| `fusion_accounting_reports` | `account_reports` (entire 618-file engine + reports) | `fusion.account.report`, `fusion.account.report.line`, PDF templates, OWL report viewer, P&L/BS/TB/GL/Aged/Partner/CashFlow/Executive Summary | Phase 2 |
|
||||
| `fusion_accounting_dashboard` | `account_accountant` journal dashboard, `accountant/data/account_accountant_data.xml`, digest | Journal kanban, digest tiles, "Needs Attention" data shape | Phase 3 |
|
||||
| `fusion_accounting_followup` | `account_followup` | `fusion.followup.line`, follow-up workflow, multi-level reminders | Phase 5 |
|
||||
| `fusion_accounting_assets` | `account_asset` | `fusion.asset`, `fusion.asset.group`, depreciation engine, asset-register report | Phase 6 |
|
||||
| `fusion_accounting_budget` | `account_budget` | `fusion.budget`, budget-vs-actual report | Phase 6 |
|
||||
| `fusion_accounting_ai` | (none — original) | Existing AI orchestrator, tools, chat panel, approval workflow, scoring, rules — moved verbatim from current `fusion_accounting` | Phase 0 |
|
||||
| `fusion_accounting_migration` | (none — transitional) | Wizard that copies Enterprise-only data into fusion tables before Enterprise uninstall; safety guard that blocks Enterprise uninstall until wizard runs | Phase 0 |
|
||||
| `fusion_accounting` (meta) | (none — packaging) | Empty shell; `depends` on every sub-module so a single install gets everything | Phase 0 |
|
||||
|
||||
### 2.3 Why Split (vs. monolith)
|
||||
|
||||
- Sub-modules can be enabled per client need (a small client without payroll-style assets installs core + bank_rec + reports + ai only)
|
||||
- Each sub-module has independent test runs and CI (faster feedback loop)
|
||||
- Each sub-module's cross-version upgrade is independent — `fusion_accounting_reports` can absorb V20 changes without touching `fusion_accounting_bank_rec`
|
||||
- The AI sub-module stays cleanly separate, which makes it easy to keep using fusion's AI on top of Odoo Enterprise (when a client retains Enterprise) by installing `_ai` only
|
||||
|
||||
### 2.4 Open Sub-Module Naming Decisions
|
||||
|
||||
The meta-module retains the name `fusion_accounting` so existing client installs don't see a name change. Sub-modules use the `fusion_accounting_*` prefix consistently.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Preservation and Client Switchover Strategy
|
||||
|
||||
The single most important guarantee in this entire design: **client switchover from Odoo Enterprise to Odoo Community + fusion_accounting must lose zero accounting data**, especially bank reconciliations.
|
||||
|
||||
This section is the contract that backs that guarantee.
|
||||
|
||||
### 3.1 What Survives an Enterprise Uninstall Automatically
|
||||
|
||||
Verified by direct read of `RePackaged-Odoo/accounting/account/` source. These models and fields live in the Community `account` module and are unaffected by any Enterprise uninstall:
|
||||
|
||||
| Data | Storage | Verified Location |
|
||||
|---|---|---|
|
||||
| Bank reconciliation links | `account.partial.reconcile` | `account/models/account_partial_reconcile.py` |
|
||||
| Full reconciliation markers | `account.full.reconcile` | `account/models/account_partial_reconcile.py` |
|
||||
| Bank statement lines + `is_reconciled` flag | `account.bank.statement.line` | `account/models/account_bank_statement_line.py` |
|
||||
| Invoices, bills, payments | `account.move`, `account.payment` | `account/models/account_move.py`, `account_payment.py` |
|
||||
| Journal entries + lines | `account.move`, `account.move.line` | `account/models/account_move_line.py` |
|
||||
| Chart of accounts | `account.account` | `account/models/account_account.py` |
|
||||
| Taxes | `account.tax` | `account/models/account_tax.py` |
|
||||
| Journals | `account.journal` | `account/models/account_journal.py` |
|
||||
| Partners | `res.partner` | `base` |
|
||||
| Reconciliation rule base | `account.reconcile.model` | `account/models/account_reconcile_model.py` |
|
||||
| `checked` (Reviewed) flag on moves | `account.move.checked` | `account/models/account_move.py` line 315 |
|
||||
|
||||
**Critical observation about bank reconciliation in Odoo 19:** The Enterprise `account_accountant` module does **not** define a `bank.rec.widget` Python model in V19. The bank-rec widget is implemented entirely as frontend OWL components in `account_accountant/static/src/components/bank_reconciliation/`, with a thin `BankReconciliationService` (`bank_reconciliation_service.js`) that calls Community ORM methods directly. There is no Enterprise-side persistent storage for the widget. When the widget is removed (Enterprise uninstall), the underlying `account.partial.reconcile` rows are untouched; fusion's replacement widget reads the same rows and shows every historical reconciliation as already-matched.
|
||||
|
||||
(The Work-in-Progress code at `Work in Progress/fusion_accounting/models/bank_rec_widget.py` uses the V17/V18 architecture where `bank.rec.widget` was a `_auto = False` Python model. That architecture was removed in V19. Our Phase 1 implementation must match V19 architecture.)
|
||||
|
||||
**Verified Enterprise uninstall hook safety**: `account_accountant/__init__.py` line 32-42 only revokes security group assignments. There are zero destructive DB operations in the uninstall hook.
|
||||
|
||||
**Verified absence of cascade hazards**: grep for `ondelete='cascade'` in `account_accountant/models/` returns zero matches. No Enterprise model deletion can cascade-delete a reconciliation.
|
||||
|
||||
### 3.2 What Is Lost on Enterprise Uninstall (Without Mitigation)
|
||||
|
||||
| Enterprise-owned data | Importance | Mitigation Strategy |
|
||||
|---|---|---|
|
||||
| `account.fiscal.year` records (fiscal year closing definitions) | Medium | Migration wizard → `fusion.fiscal.year` |
|
||||
| `account.asset` records + asset-line links on moves | High if assets used | Migration wizard → `fusion.asset` |
|
||||
| `account.loan` records | Low (rare) | Migration wizard → `fusion.loan` (Phase 7+) |
|
||||
| Budget records | Medium if used | Migration wizard → `fusion.budget` |
|
||||
| Follow-up rule definitions + history | Medium | Migration wizard → `fusion.followup.*` |
|
||||
| `account.move.deferred_move_ids`, `deferred_original_move_ids`, `deferred_entry_type` | **High** if deferred revenue/expense used — breaks the link between original and deferred postings | **Shared-field ownership** in `fusion_accounting_core` |
|
||||
| `account.move.signing_user` (audit signer) | Medium | **Shared-field ownership** |
|
||||
| `account.move.payment_state_before_switch` | Throwaway (technical) | Ignore |
|
||||
| `account.reconcile.model.created_automatically` | Throwaway (single boolean) | Shared-field ownership in `_bank_rec` |
|
||||
| `account.bank.statement.line.cron_last_check` | Throwaway (technical) | Ignore |
|
||||
| Report XML records (P&L, BS structure) | None — reference data, not client data | fusion ships its own equivalents in `_reports` |
|
||||
| Enterprise-only menus, actions | None — UI only | fusion installs its own |
|
||||
|
||||
### 3.3 Mitigation Pattern A: Shared-Field Ownership
|
||||
|
||||
For Enterprise-added fields on Community models (the `deferred_*`, `signing_user`, `created_automatically` fields), `fusion_accounting_core` declares **identical** field definitions with the **same** relation table names:
|
||||
|
||||
```python
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
deferred_move_ids = fields.Many2many(
|
||||
comodel_name='account.move',
|
||||
relation='account_move_deferred_rel', # identical relation table to Enterprise
|
||||
column1='original_move_id',
|
||||
column2='deferred_move_id',
|
||||
copy=False,
|
||||
)
|
||||
deferred_original_move_ids = fields.Many2many(
|
||||
comodel_name='account.move',
|
||||
relation='account_move_deferred_rel',
|
||||
column1='deferred_move_id',
|
||||
column2='original_move_id',
|
||||
copy=False,
|
||||
)
|
||||
deferred_entry_type = fields.Selection(
|
||||
selection=[('expense', 'Deferred Expense'), ('revenue', 'Deferred Revenue')],
|
||||
copy=False,
|
||||
)
|
||||
signing_user = fields.Many2one(comodel_name='res.users', copy=False)
|
||||
payment_state_before_switch = fields.Char(copy=False)
|
||||
```
|
||||
|
||||
**Mechanism**: Odoo's module registry tracks every module that declares a given field on a given model. When `account_accountant` uninstalls, Odoo only drops the column (or relation table) if no other installed module also declares it. Because `fusion_accounting_core` declares these identically, Odoo retains the column/table. Existing data values are preserved row-by-row.
|
||||
|
||||
**Caveat**: this pattern creates a schema dependency on Enterprise's choices. If Odoo ever renames `account_move_deferred_rel` in V20, both the Enterprise and fusion versions of that field break together — the migration is just `ALTER TABLE ... RENAME` in our migration script. We accept this risk because the alternative (renaming to fusion-namespaced fields) requires a much heavier migration of every existing row.
|
||||
|
||||
### 3.4 Mitigation Pattern B: Pre-Uninstall Migration Wizard
|
||||
|
||||
For Enterprise-only models (`account.asset`, `account.fiscal.year`, `account.loan`, budgets, followups), `fusion_accounting_migration` provides a wizard accessible from Settings → Fusion Accounting → Migrate from Enterprise.
|
||||
|
||||
The wizard:
|
||||
|
||||
1. Detects which Enterprise modules are installed
|
||||
2. For each detected module, checks the corresponding fusion module is also installed (and prompts to install if missing)
|
||||
3. Shows a preview: row counts per Enterprise table that will be migrated, listing target fusion table for each
|
||||
4. On confirm, runs `INSERT INTO fusion_<table> SELECT ... FROM <enterprise_table>` for each migration step, preserving primary keys and `ir.model.data` xml_ids
|
||||
5. Generates a migration report (record counts, any rows that failed validation, warnings)
|
||||
6. Marks each Enterprise table as "migrated" via an `ir.config_parameter` flag (`fusion_accounting.migration.<module>.completed`)
|
||||
7. Re-running the wizard is idempotent: already-migrated tables are skipped unless explicitly re-migrated
|
||||
|
||||
A separate **safety guard** in `fusion_accounting_migration` overrides `ir.module.module.button_immediate_uninstall` for Enterprise accounting modules; if the migration flag for that module is False and it has data, the uninstall is blocked with a UserError linking to the wizard.
|
||||
|
||||
### 3.5 Switchover Protocol (the operator workflow)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
start[Client on Odoo 19 Enterprise] --> step1["Install fusion_accounting meta-module<br/>while Enterprise still running"]
|
||||
step1 --> step2["fusion_accounting_core declares shared fields<br/>Odoo registers dual ownership for deferred_*, signing_user, etc."]
|
||||
step2 --> step3["Open Settings → Fusion Accounting → Migrate from Enterprise"]
|
||||
step3 --> step4["Wizard shows preview: row counts per table"]
|
||||
step4 --> step5["Operator confirms"]
|
||||
step5 --> step6["Wizard copies asset, fiscal year, loan, budget, followup rows<br/>into fusion tables"]
|
||||
step6 --> step7["Wizard generates migration report"]
|
||||
step7 --> step8["Operator reviews report"]
|
||||
step8 --> step9["Operator triggers Enterprise uninstall in dep-safe order:<br/>account_reports → account_followup → account_asset →<br/>account_budget → account_loans → account_accountant → accountant"]
|
||||
step9 --> step10["Safety guard verifies migration flags before each uninstall"]
|
||||
step10 --> done["Done: Client on Community + fusion_accounting<br/>Bank recs intact, deferred links preserved,<br/>migrated data accessible via fusion menus"]
|
||||
```
|
||||
|
||||
### 3.6 Empirical Verification Test (Phase 0 deliverable)
|
||||
|
||||
The shared-field-ownership analysis and the inventory of "what survives" is based on reading source. Strong, but not conclusive. **Phase 0 includes a one-time empirical test**:
|
||||
|
||||
1. Provision a throwaway Odoo 19 Enterprise instance
|
||||
2. Install full Enterprise accounting stack
|
||||
3. Create representative test data:
|
||||
- 50 invoices, 30 vendor bills, mix of paid/unpaid
|
||||
- 15 bank reconciliations (full and partial)
|
||||
- 5 deferred revenue entries with `deferred_move_ids` populated
|
||||
- 3 fiscal year closings
|
||||
- 10 asset records with depreciation history
|
||||
- 2 budgets with actuals
|
||||
- Multi-currency journal entries
|
||||
- 1 cash-basis tax move
|
||||
3. Take `pg_dump` snapshot
|
||||
4. Uninstall Enterprise modules in dep-safe order **without** running the migration wizard (this is the worst-case test)
|
||||
5. Diff schema and row counts before and after
|
||||
6. Document findings in `docs/superpowers/specs/2026-04-18-empirical-uninstall-test-results.md`
|
||||
7. If gaps are found vs. Section 3.2, expand the wizard scope or shared-field declarations accordingly
|
||||
|
||||
This test is a Phase 0 acceptance gate. The roadmap does not advance to Phase 1 until empirical verification confirms or expands the analysis.
|
||||
|
||||
### 3.7 Reverse-Migration Note
|
||||
|
||||
The reverse direction (client on Community + fusion adds an Enterprise subscription later) is not a hard requirement. fusion's runtime feature-gating (Section 4.4) handles the coexistence case: when Enterprise is detected, fusion's conflicting menus hide and the AI module continues running on top of Enterprise. A reverse-migration wizard can be added in Phase 7+ if a real client needs it.
|
||||
|
||||
### 3.8 Backup and Rollback
|
||||
|
||||
Every client deployment must include, before any switchover step:
|
||||
|
||||
- `pg_dump` of the live database
|
||||
- Snapshot of all installed module versions (`SELECT name, latest_version FROM ir_module_module WHERE state='installed'`)
|
||||
- Snapshot of `/mnt/extra-addons/` contents
|
||||
|
||||
Rollback procedure: restore DB from `pg_dump`, restore extra-addons from snapshot, restart Odoo. The migration wizard's "Generate Backup First" checkbox is checked by default and must be explicitly unchecked to skip.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phased Roadmap
|
||||
|
||||
Each phase produces shippable value. Phase order is locked. Time estimates are rough single-engineer figures and are not binding deadlines — the user has explicitly stated "no rush, product-first".
|
||||
|
||||
### 4.1 Phase Overview
|
||||
|
||||
| Phase | Focus | Estimate | Depends On |
|
||||
|---|---|---|---|
|
||||
| 0 | Foundation, sub-module split, migration scaffold, empirical test | 1-2 wks | (none) |
|
||||
| 1 | Bank reconciliation (priority) | 3-5 wks | 0 |
|
||||
| 2 | Financial reports engine | 6-10 wks | 0 |
|
||||
| 3 | Dashboard + fiscal year + lock dates | 2-3 wks | 1, 2 |
|
||||
| 4 | Tax reports + returns | 3-5 wks | 2 |
|
||||
| 5 | Payment follow-ups | 2-3 wks | 3, 4 |
|
||||
| 6 | Assets + budgets | 3-5 wks | 5 |
|
||||
| 7+ | Optional satellites (loans, check printing, batch payment, 3-way match, EDI, SEPA, SAFT, intrastat, online sync) | per item | 6 |
|
||||
|
||||
Phases 1 and 2 can run in parallel after Phase 0 (no shared scope).
|
||||
|
||||
### 4.2 Phase 0 — Foundation
|
||||
|
||||
No user-facing features. Pure plumbing so every later phase is cheaper.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Create sub-module scaffolding for `fusion_accounting_core`, `fusion_accounting_migration`, `fusion_accounting_ai`
|
||||
- Move existing AI copilot code from current `fusion_accounting/` into `fusion_accounting_ai/`. Files moved: `models/`, `services/`, `controllers/`, `wizards/`, `data/`, `static/src/`, `views/`, `security/`, `report/`, `tests/`. Update internal imports
|
||||
- Convert current `fusion_accounting/` into the meta-module: empty `__init__.py`, manifest with `depends = ['fusion_accounting_core', 'fusion_accounting_ai', ...]` (sub-modules added as later phases ship), no Python/JS/XML code of its own
|
||||
- Strip hard Enterprise deps from `fusion_accounting_ai/__manifest__.py`. Replace `account_accountant`, `account_reports`, `account_followup` with `account` (Community). Add runtime detection (Section 4.4)
|
||||
- Refactor every AI tool in `fusion_accounting_ai/services/tools/` that calls Enterprise APIs to go through an adapter layer (`services/adapters/bank_rec_adapter.py`, `reports_adapter.py`, `followup_adapter.py`). Adapters pick between Enterprise APIs (when present) and fusion native (when present) and a "feature-unavailable" stub (when neither)
|
||||
- Create `fusion_accounting_core/models/account_move.py` with shared-field declarations (Section 3.3)
|
||||
- Create `fusion_accounting_migration/` shell: empty wizard, safety guard scaffold (no migrations yet)
|
||||
- Create `tools/check_odoo_diff.sh` script that diffs two pinned Odoo source snapshots and outputs a categorized change list
|
||||
- Move security groups: `group_fusion_accounting_user/manager/admin` move from current to `fusion_accounting_core/security/`. Multi-company record rule on `fusion.accounting.session` added (currently missing per existing CLAUDE.md "Known Issues")
|
||||
- Create per-sub-module `CLAUDE.md` (factor common rules from existing `fusion_accounting/CLAUDE.md`) and `UPGRADE_NOTES.md` template
|
||||
- Run the empirical verification test (Section 3.6) on a throwaway V19 Enterprise instance
|
||||
- CI: GitHub Actions or gitea workflow that runs `pytest` per sub-module on every push
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Current AI copilot installs and runs on pure Community (no Enterprise modules present)
|
||||
- Current AI copilot still installs and runs alongside Enterprise (coexistence mode)
|
||||
- Empirical test report committed
|
||||
- All adapter calls wired (no direct Enterprise API access from AI tools)
|
||||
- CI green
|
||||
|
||||
**Risks and mitigations:**
|
||||
|
||||
- **Risk**: moving code between modules breaks existing client deployments. **Mitigation**: meta-module install upgrade hook handles model-record reassignment via `ir_model_data` updates; pre-migration script runs on first install of Phase 0
|
||||
- **Risk**: empirical test reveals gaps. **Mitigation**: scope-expand the migration wizard before declaring Phase 0 complete
|
||||
|
||||
### 4.3 Phase 1 — Bank Reconciliation
|
||||
|
||||
The user's stated priority. Replaces `account_accountant`'s bank-rec widget end-to-end.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Create `fusion_accounting_bank_rec/` sub-module
|
||||
- **Frontend (mirror zone)**: build `static/src/components/bank_reconciliation/` mirroring the file layout of `account_accountant/static/src/components/bank_reconciliation/` (`kanban_controller`, `kanban_renderer`, `bank_reconciliation_service`, `apply_amount`, `bankrec_form_dialog`, `button`, `button_list`, `chatter`, `file_uploader`, `line_info_pop_over`, `line_to_reconcile`, `list_view`, `quick_create`, `reconciled_line_name`, `search_dialog`, `statement_line`, `statement_summary`). Mirror is structural — class names, file names, OWL component boundaries — not copy-paste. Implementation written fresh against documented Odoo behavior
|
||||
- **Backend (abstract zone)**: `models/fusion_reconcile_engine.py` containing the matching algorithm (FIFO, partial reconcile, write-off lines, exchange-rate diff posting, tax splits). Original implementation against documented requirements. Operates on Community `account.partial.reconcile`
|
||||
- `models/fusion_reconcile_model.py` extending Community `account.reconcile.model` with auto-rules, partner mapping, journal mapping. Shared-field ownership for `created_automatically`
|
||||
- `wizards/auto_reconcile_wizard.py` clean-room rewrite of `account_accountant/wizard/account_auto_reconcile_wizard.py`
|
||||
- `wizards/reconcile_wizard.py` clean-room rewrite of `account_accountant/wizard/account_reconcile_wizard.py`
|
||||
- `views/bank_rec_widget_views.xml` defines the action that opens the OWL widget; `views/account_reconcile_model_views.xml` for rule editing
|
||||
- Menu: "Bank Reconciliation" under fusion accounting menu, with feature-gate (hidden if `account_accountant` installed)
|
||||
- AI integration: existing AI tools `get_unreconciled_bank_lines`, `find_similar_bank_lines`, `get_bank_line_details`, `find_missing_itc_bills`, `find_duplicate_bills`, `get_overdue_invoices` get refactored to call fusion's bank rec engine via `fusion_accounting_ai/services/adapters/bank_rec_adapter.py`. The Tier 3 tools `create_vendor_bill`, `register_bill_payment`, `create_expense_entry` keep their existing logic (they write to Community `account.move`)
|
||||
- Migration: wizard validates `account.partial.reconcile` row count is preserved across switchover (read-only check, no migration needed)
|
||||
- Tests:
|
||||
- Unit (engine): matching correctness with fixtures (single partner, multi-partner, multi-currency, partial, exchange diff, write-off, tax split)
|
||||
- Integration: install + create statement + reconcile via UI + assert `account.partial.reconcile` rows
|
||||
- Tour (JS): smoke through the full bank rec workflow
|
||||
- Migration: install Enterprise, create 10 reconciliations, install fusion, uninstall Enterprise, assert reconciliations visible in fusion widget
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Community + fusion_accounting user can reconcile bank statements with feature parity to Enterprise
|
||||
- All Phase 1 tests passing
|
||||
- Migration round-trip (Enterprise → fusion) preserves every reconciliation
|
||||
- AI tools work against fusion bank rec engine
|
||||
|
||||
### 4.4 Phase 2 — Financial Reports Engine
|
||||
|
||||
The largest phase. Replaces `account_reports` (618 files).
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Create `fusion_accounting_reports/` sub-module
|
||||
- **Backend (abstract zone)**: `models/fusion_account_report.py` defining `fusion.account.report` and `fusion.account.report.line`. Generic engine that takes a report definition (sections, filters, computation rules) and produces report rows from `account.move.line` data. Original computation kernel — does not copy `account_reports`'s `account_report.py`
|
||||
- **Backend (mirror zone)**: report definition records mirror Odoo's data files. Files: `data/balance_sheet.xml`, `data/profit_and_loss.xml`, `data/cash_flow_report.xml`, `data/general_ledger.xml`, `data/trial_balance.xml`, `data/aged_partner_balance.xml`, `data/partner_ledger.xml`, `data/executive_summary.xml`, `data/sales_report.xml`, `data/multicurrency_revaluation_report.xml`, `data/bank_reconciliation_report.xml`, `data/deferred_reports.xml`, `data/journal_report.xml`, `data/customer_statement.xml`. XML structure follows Odoo's so V20 ports are diff-and-apply
|
||||
- **Frontend (mirror zone)**: `static/src/components/` mirrors `account_reports/static/src/components/` — filters bar, comparison toggle, drill-down, foldable sections, footnotes
|
||||
- **PDF export**: QWeb templates in `report/` mirror Odoo's `data/pdf_export_templates.xml` and `data/customer_reports_pdf_export_templates.xml`. Asset bundle `fusion_accounting_reports.assets_pdf_export` defined in manifest
|
||||
- Performance: denormalized read paths for trial balance and general ledger (materialized aggregations refreshed on `account.move` post). Drill-down lazy-loads line detail. Per-(company, period, filter_hash) cache invalidated on `account.move.line` write
|
||||
- Multi-company, multi-currency, cash-basis toggle — all handled by the engine
|
||||
- AI integration: tools `get_profit_loss`, `get_balance_sheet`, `get_trial_balance`, `get_aged_receivables`, `get_aged_payables`, `get_partner_ledger`, `answer_financial_question` refactored via `reports_adapter.py`
|
||||
- Migration: report XML records are reference data, not client data. fusion ships its own equivalent records; no migration of report definitions needed. Existing journal entry data (which the reports compute from) is in Community `account` and untouched
|
||||
- Tests:
|
||||
- Unit (engine): SQL-fixture comparisons (compute report → compare against hand-rolled SQL) for every standard report, every filter combination
|
||||
- Integration: install + post entries + open report + assert numbers
|
||||
- Multi-currency: single + multi + revaluation period
|
||||
- Performance: 1k / 10k / 100k journal lines, assert P95 latency under 5s
|
||||
- PDF: render every report to PDF, assert no QWeb errors
|
||||
- Tour: smoke through report viewer with filters
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- All 14 standard reports rendering correctly (numerical match against SQL fixtures)
|
||||
- PDF export working for every report
|
||||
- Performance targets met
|
||||
- AI tools backed by fusion reports
|
||||
|
||||
### 4.5 Phase 3 — Dashboard + Fiscal Year + Lock Dates
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Create `fusion_accounting_dashboard/` sub-module
|
||||
- **Journal kanban dashboard**: mirror layout of `account_accountant/views/account_journal_dashboard_views.xml`. Computed metrics in `models/account_journal.py` extending Community `account.journal` with kanban-state fields (counts, totals, action buttons). Original computation; mirror UI
|
||||
- `models/fusion_fiscal_year.py` defining `fusion.fiscal.year` (replaces `account.fiscal.year`)
|
||||
- Fiscal year wizard: closing workflow, period locks, initial-balance carry-forward
|
||||
- Lock date wizard: clean-room rewrite of `account_accountant/wizard/account_change_lock_date.py`. Operates on Community `account.lock_exception` model (verified at `account/models/account_lock_exception.py`)
|
||||
- Digest tile contributions: extend `mail.digest` with fusion accounting metrics (revenue, expense, AR, AP)
|
||||
- "Needs Attention" panel — connect data already returned by current AI dashboard endpoint to a frontend rendering. Dashboard endpoint (currently in `fusion_accounting_ai/controllers/`) moves to `fusion_accounting_dashboard/controllers/`; AI module's dashboard tiles call dashboard's endpoint via adapter
|
||||
- Tests:
|
||||
- Journal dashboard kanban metrics match expected values for fixtures
|
||||
- Fiscal year close locks subsequent edits
|
||||
- Lock date wizard prevents posting before lock date
|
||||
- Digest renders without errors
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Journal dashboard at parity with Enterprise
|
||||
- Fiscal year management functional
|
||||
- Lock dates enforced
|
||||
- Digest emails delivering
|
||||
|
||||
### 4.6 Phase 4 — Tax Reports + Returns
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Build on Phase 2 reports engine; tax reports are specialized `fusion.account.report` records
|
||||
- Generic tax report (`data/generic_tax_report.xml`) with country-specific overrides
|
||||
- Canadian HST: unify the existing HST workflow in `fusion_accounting_ai` (currently in `services/prompts/domain_prompts.py` and tool functions) with the new tax report engine. The existing `find_missing_itc_bills`, `get_overdue_invoices`, etc. tools call into the tax report
|
||||
- `fusion.account.return` model (replaces `account.return` from `account_reports`) tracking tax return drafts, submitted state, payment status
|
||||
- Return creation wizard, return submission wizard, return generic payment wizard — clean-room rewrites of the corresponding `account_reports` wizards
|
||||
- Tax closing entries (move generation on tax period close)
|
||||
- Tests:
|
||||
- Tax report numbers match SQL fixtures
|
||||
- Return workflow: draft → review → submitted → paid
|
||||
- HST 4-phase workflow (per existing CLAUDE.md) end-to-end
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Generic tax report functional
|
||||
- Canadian HST workflow runs through fusion (no Enterprise dependency)
|
||||
- Return tracking working
|
||||
|
||||
### 4.7 Phase 5 — Payment Follow-ups
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Create `fusion_accounting_followup/` sub-module
|
||||
- `models/fusion_followup_line.py` (replaces `account_followup.followup.line`)
|
||||
- `models/res_partner.py` extends `res.partner` with follow-up level, last reminder date, dunning history
|
||||
- `models/account_move.py` extends `account.move` with follow-up state (overdue days, last reminder)
|
||||
- Multi-level reminder workflow: each level has email template, days delay, optional SMS, optional `mail.activity`
|
||||
- `wizards/followup_send_wizard.py` for manual sends; cron for automatic
|
||||
- Follow-up report (PDF): clean-room template
|
||||
- AI integration: `fusion_accounting_ai` adds tools `draft_followup_message_for_partner`, `send_followup_to_overdue_partners` calling the followup engine via adapter
|
||||
- Migration: wizard copies `account_followup.followup.line` and partner-level follow-up state into `fusion.followup.line` and shared-field-owned partner fields
|
||||
- Tests:
|
||||
- Multi-level escalation
|
||||
- Email template rendering
|
||||
- SMS delivery (mock)
|
||||
- AI-drafted message quality (snapshot tests)
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Multi-level dunning working
|
||||
- Migration from `account_followup` preserves history
|
||||
|
||||
### 4.8 Phase 6 — Assets + Budgets
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Create `fusion_accounting_assets/` sub-module
|
||||
- `models/fusion_asset.py` (replaces `account.asset`)
|
||||
- `models/fusion_asset_group.py` (replaces `account.asset.group`)
|
||||
- Depreciation engine: linear, declining, custom schedules. Original implementation
|
||||
- `wizards/asset_modify.py` for revaluation, sale, disposal — clean-room rewrite
|
||||
- Asset register report integrates with Phase 2 reports engine
|
||||
- Migration wizard copies `account.asset` rows + line links on moves
|
||||
- Create `fusion_accounting_budget/` sub-module
|
||||
- `models/fusion_budget.py` (replaces `budget.analytic`)
|
||||
- Budget vs actual report integrates with Phase 2 reports engine
|
||||
- Migration wizard copies budget records
|
||||
- Tests for both
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Asset depreciation schedules computed correctly
|
||||
- Disposal generates correct GL entries
|
||||
- Budget variance report functional
|
||||
|
||||
### 4.9 Phase 7+ — Optional Satellites
|
||||
|
||||
Not scheduled. Each is its own brainstorming → spec → plan → implementation cycle when a real client needs it. Candidate satellite modules:
|
||||
|
||||
- `fusion_accounting_loans` — loan amortization
|
||||
- `fusion_accounting_check_printing` — check printing
|
||||
- `fusion_accounting_batch_payment` — batch payments
|
||||
- `fusion_accounting_3way_match` — purchase 3-way match
|
||||
- `fusion_accounting_edi` — UBL/CII e-invoicing
|
||||
- `fusion_accounting_sepa` — SEPA direct debit + credit transfer
|
||||
- `fusion_accounting_saft` — SAFT export
|
||||
- `fusion_accounting_intrastat` — intrastat report
|
||||
- `fusion_accounting_iso20022` — ISO 20022 payment files
|
||||
- `fusion_accounting_online_sync` — online bank sync (Yodlee/Plaid integration)
|
||||
|
||||
### 4.10 Per-Phase Deliverables (uniform)
|
||||
|
||||
Each phase produces:
|
||||
|
||||
1. A separate **design document** in `docs/superpowers/specs/YYYY-MM-DD-fusion-accounting-phase-N-*-design.md` (brainstormed in its own session)
|
||||
2. A separate **implementation plan** via the `writing-plans` skill
|
||||
3. Working code with passing tests
|
||||
4. Entry in the sub-module's `UPGRADE_NOTES.md` listing Odoo source files referenced and intentional deltas
|
||||
5. Coverage in `fusion_accounting_migration` if the phase replaces an Enterprise data-bearing model
|
||||
6. Manual QA checklist (install, migrate, smoke, uninstall) committed to the sub-module
|
||||
7. Update to the meta-module `__manifest__.py` adding the new sub-module to its `depends`
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture Rules
|
||||
|
||||
These rules apply to every sub-module and every phase. They are the discipline that keeps V19→V20 upgrades mechanical and prevents the WIP-style descent into copied code with stale architecture.
|
||||
|
||||
### 5.1 The Hybrid Split
|
||||
|
||||
Every sub-module has two zones with different rules:
|
||||
|
||||
**Mirror zone** (follows Odoo structure 1:1):
|
||||
|
||||
- XML view definitions and xpath targets
|
||||
- Frontend OWL component file layout, service registration, widget props
|
||||
- PDF/QWeb templates: structure, CSS class names
|
||||
- Wizard flows: step order, field names where they appear in views
|
||||
- Asset bundle declarations in manifests
|
||||
|
||||
**Locations**: `views/`, `static/src/components/`, `report/` QWeb templates, `wizards/*_views.xml`, `__manifest__.py` asset bundles
|
||||
|
||||
**Abstract zone** (our own design, insulated from Odoo internals):
|
||||
|
||||
- Core algorithms: matching, aggregation, computation, depreciation
|
||||
- Data access helpers
|
||||
- Business validation, approval flows
|
||||
- AI integration adapters
|
||||
- Engine classes (e.g. `fusion_reconcile_engine.py`)
|
||||
|
||||
**Locations**: `models/fusion_*_engine.py`, `services/`, `controllers/` (business logic only — request routing is mirror-zone)
|
||||
|
||||
**Rule of thumb**: if Odoo refactors it every release, mirror it. If it's been stable for a decade (FIFO matching, accrual rules, depreciation math), abstract it.
|
||||
|
||||
### 5.2 Naming Conventions
|
||||
|
||||
| Thing | Convention | Example |
|
||||
|---|---|---|
|
||||
| Model `_name` | `fusion.*` prefix always | `fusion.bank.rec.widget`, `fusion.account.report`, `fusion.fiscal.year` |
|
||||
| Model `_inherit` on Community | Keep `account.*` (no rename) | `class AccountMove(models.Model): _inherit = 'account.move'` |
|
||||
| Model `_inherit` on Enterprise | **Forbidden** — duplicate fields via shared-field-ownership instead | n/a |
|
||||
| Python class names | `Fusion` prefix for new models | `FusionBankRecWidget`, `FusionAccountReport` |
|
||||
| Table names (auto-derived) | Follows model prefix | `fusion_bank_rec_widget`, `fusion_account_report` |
|
||||
| XML record IDs | `fusion_*` prefix | `<record id="fusion_view_bank_rec_form">` |
|
||||
| Menu IDs | `fusion_menu_*` prefix | Avoids collision with `account_menu_*` |
|
||||
| Action IDs | `fusion_action_*` | Same |
|
||||
| Controller routes | `/fusion_accounting/*` | Already in use; carries forward |
|
||||
| Security groups | `group_fusion_*` | Already in use |
|
||||
| Field names on inherited Community models | Identical to Enterprise if shared-field-owned; otherwise `x_fusion_*` prefix | `deferred_move_ids` (shared), `x_fusion_ai_confidence` (our own) |
|
||||
| CSS/SCSS classes | `.fusion_*` or `.o_fusion_*` | Avoids Bootstrap/Odoo collision |
|
||||
| `ir.config_parameter` keys | `fusion_accounting.*` | Already in use |
|
||||
|
||||
### 5.3 Coexistence Detection
|
||||
|
||||
Every sub-module that replaces an Enterprise feature must detect Enterprise at install time and at runtime, and feature-gate accordingly.
|
||||
|
||||
**Helper function** (lives in `fusion_accounting_core/models/ir_module_module.py`):
|
||||
|
||||
```python
|
||||
class IrModuleModule(models.Model):
|
||||
_inherit = "ir.module.module"
|
||||
|
||||
@api.model
|
||||
def _fusion_is_enterprise_accounting_installed(self):
|
||||
return bool(self.sudo().search_count([
|
||||
('name', 'in', ['account_accountant', 'account_reports', 'accountant']),
|
||||
('state', '=', 'installed'),
|
||||
]))
|
||||
```
|
||||
|
||||
**Three coexistence modes per sub-module**, configurable in Settings → Fusion Accounting → Integration Mode:
|
||||
|
||||
1. **Replace** (default when Enterprise absent): fusion menus visible, fusion views primary, fusion workflows active
|
||||
2. **Augment** (default when Enterprise present): fusion menus hidden, fusion widgets disabled, fusion AI module continues to call Enterprise APIs via adapters
|
||||
3. **Force-replace** (manual): fusion menus visible alongside Enterprise (operator's choice — risk of confusion, used during migration)
|
||||
|
||||
Menu visibility achieved via `groups` attribute referencing a dynamically-computed group (`group_fusion_show_menus_when_enterprise_absent`), implemented as a `@api.depends` computed field on `res.users` that recomputes membership when modules change state.
|
||||
|
||||
### 5.4 Zero Hard Enterprise Dependencies
|
||||
|
||||
After Phase 0:
|
||||
|
||||
- `fusion_accounting_core/__manifest__.py`: `depends = ['account', 'mail', 'web_tour']`
|
||||
- `fusion_accounting_ai/__manifest__.py`: `depends = ['fusion_accounting_core']` plus `external_dependencies` for `anthropic`, `openai`
|
||||
- Every other `fusion_accounting_*/__manifest__.py`: `depends = ['fusion_accounting_core']` plus fusion siblings as needed (e.g., `_followup` depends on `_reports`)
|
||||
|
||||
**No `fusion_accounting_*` module may have `account_accountant`, `account_reports`, `accountant`, `account_followup`, `account_asset`, `account_budget`, `account_loans`, `account_3way_match`, `account_check_printing`, `account_batch_payment`, `account_iso20022`, `account_intrastat`, `account_saft`, `account_sepa_direct_debit`, `account_online_synchronization`, or any `account_edi_*` in its `depends`.**
|
||||
|
||||
Runtime detection (Section 5.3) replaces compile-time dependency.
|
||||
|
||||
### 5.5 Canonical Sub-Module Directory Layout
|
||||
|
||||
```
|
||||
fusion_accounting_<feature>/
|
||||
├── __manifest__.py
|
||||
├── __init__.py
|
||||
├── CLAUDE.md # module-specific context for Cursor agent
|
||||
├── UPGRADE_NOTES.md # Odoo version deltas absorbed
|
||||
├── README.md # operator-facing install/configure/troubleshoot
|
||||
├── docs/
|
||||
│ └── odoo_diff/ # snapshots of relevant Odoo source for diffing
|
||||
│ └── v19/
|
||||
│ └── account_accountant__bank_reconciliation_service.js
|
||||
├── controllers/
|
||||
│ └── __init__.py
|
||||
├── data/
|
||||
├── demo/
|
||||
├── i18n/
|
||||
├── models/
|
||||
│ ├── __init__.py
|
||||
│ ├── fusion_<feature>_engine.py # abstract zone: core algorithm
|
||||
│ ├── account_<x>.py # mirror zone: inherits Community model
|
||||
│ └── fusion_<y>.py # mirror zone: our own models
|
||||
├── report/
|
||||
├── security/
|
||||
│ ├── ir.model.access.csv
|
||||
│ └── <feature>_security.xml
|
||||
├── services/ # AI / heavy business logic
|
||||
├── static/
|
||||
│ ├── description/
|
||||
│ │ ├── icon.png
|
||||
│ │ └── index.html
|
||||
│ └── src/
|
||||
│ ├── components/ # mirror zone: OWL components
|
||||
│ ├── scss/
|
||||
│ ├── services/ # frontend services
|
||||
│ └── views/
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── test_<feature>_engine.py # abstract zone unit tests
|
||||
│ ├── test_<feature>_integration.py # full-stack integration tests
|
||||
│ ├── test_migration.py # Enterprise → fusion round-trip
|
||||
│ └── tours/
|
||||
├── views/
|
||||
├── wizards/
|
||||
└── migrations/ # Odoo version migration scripts (XX.0.x.y.z)
|
||||
└── 19.0.1.0.0/
|
||||
├── pre-migration.py
|
||||
└── post-migration.py
|
||||
```
|
||||
|
||||
### 5.6 Odoo 19 Gotchas (carried forward, factored across CLAUDE.md files)
|
||||
|
||||
The current `fusion_accounting/CLAUDE.md` documents Odoo 19-specific traps that have already cost time. All carry forward:
|
||||
|
||||
- Search views: no `string` attribute on `<search>` or `<group>`; group-by filters need `domain="[]"`; `<separator/>` before `<group>`
|
||||
- OWL client actions: `static props = ["*"]` (accept any), not `static props = []` (accept none)
|
||||
- OWL rich HTML: `markup()` and `t-out` unreliable in Odoo 19; use `onMounted` + `onPatched` + direct `innerHTML`
|
||||
- Cron `safe_eval`: no `import` statements; use `datetime.datetime.now()` not `from datetime import datetime`
|
||||
- `read_group()` deprecated → use `_read_group()`
|
||||
- `ir_config_parameter` Selection field migrations: stored DB value must match new options or Settings page crashes
|
||||
- `implied_ids` on groups only applies to newly-added users — existing users need SQL backfill
|
||||
- `TransientModel` in controllers: use `.new({...})` not `.create({...})`
|
||||
- HTTP routes: `type="jsonrpc"`, not `type="json"` (deprecated)
|
||||
- `res.config.settings`: only boolean/integer/float/char/selection/many2one/datetime; no Date fields
|
||||
- `res.groups`: no `users` field, no `category_id` field
|
||||
- Search views: no `group expand="0"` syntax
|
||||
- SCSS imports: `@import "./partial"` is forbidden in Odoo 19 custom SCSS; register every SCSS file as a separate entry in `web.assets_backend`
|
||||
- Card styling: don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)`; use Odoo's kanban explicit-hex pattern with custom-property tokens
|
||||
- Dark mode: branch on `$o-webclient-color-scheme` at SCSS compile time, not runtime DOM class
|
||||
- Asset bundle cache busting: bump manifest version + `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'` if needed
|
||||
|
||||
These rules belong in each sub-module's `CLAUDE.md` (the relevant subset) plus the workspace-root `CLAUDE.md` (common rules).
|
||||
|
||||
### 5.7 Manifest Versioning and Branch Strategy
|
||||
|
||||
- Per-sub-module manifest: `'version': 'XX.0.x.y.z'` where XX is the Odoo version (e.g., `19.0.1.0.0` for V19, first release)
|
||||
- Bump `XX` on Odoo version change (V19 → V20 → V21)
|
||||
- Bump `x` on major feature additions within an Odoo version
|
||||
- Bump `y` on minor features and bug fixes
|
||||
- Bump `z` on hotfixes
|
||||
- Git branches: `main-v19`, `main-v20`, etc. Each client deployment is pinned to one branch
|
||||
- Release tags: `<sub-module>/v19.0.1.0.0` per sub-module per release
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Version Upgrade Workflow
|
||||
|
||||
This section is the user's stated top concern: how to keep porting Enterprise changes forward each year without it becoming a rewrite project.
|
||||
|
||||
### 6.1 Snapshot Discipline
|
||||
|
||||
Maintain one pinned snapshot of the relevant Odoo source per Odoo version:
|
||||
|
||||
```
|
||||
/Users/gurpreet/Github/RePackaged-Odoo/
|
||||
├── accounting-v19/ # current snapshot (already in place at accounting/)
|
||||
├── accounting-v20/ # added when V20 ships
|
||||
├── accounting-v21/ # added when V21 ships
|
||||
```
|
||||
|
||||
Older snapshots are never deleted — they are the diff source for upgrade work.
|
||||
|
||||
### 6.2 Annual Upgrade Ritual
|
||||
|
||||
When Odoo V<N+1> ships:
|
||||
|
||||
1. Add the snapshot folder
|
||||
2. For each fusion sub-module:
|
||||
- Run `tools/check_odoo_diff.sh <enterprise_module> v<N> v<N+1> > reports/v<N+1>_<module>_diff.md`
|
||||
- Manually classify each change in the diff:
|
||||
- `[MIRROR]` — apply the same hunk to fusion's mirror-zone files (mechanical)
|
||||
- `[ABSTRACT]` — verify the Odoo public API surface our adapter uses still works; update the adapter if signatures changed
|
||||
- `[NEW FEATURE]` — decide port or defer
|
||||
- `[BUG FIX]` — port (usually cheap)
|
||||
- `[REMOVED]` — clean up our equivalent
|
||||
- Apply mirror-zone hunks (these are usually direct `patch -p1` operations)
|
||||
- Write Odoo version migration scripts in `migrations/<N+1>.0.0.0.0/` for any data-shape changes
|
||||
- Update `UPGRADE_NOTES.md`
|
||||
- Run all tests
|
||||
3. Tag releases on `main-v<N+1>` branch
|
||||
4. Pilot upgrade on one client first; ratchet outward
|
||||
|
||||
### 6.3 `UPGRADE_NOTES.md` Template
|
||||
|
||||
```markdown
|
||||
# UPGRADE_NOTES — fusion_accounting_bank_rec
|
||||
|
||||
## V19.0.1.0.0 (initial)
|
||||
- Ported from: account_accountant V19 (snapshot date 2026-04-18)
|
||||
- Mirror sources:
|
||||
- account_accountant/static/src/components/bank_reconciliation/* → fusion_accounting_bank_rec/static/src/components/bank_reconciliation/*
|
||||
- account_accountant/wizard/account_auto_reconcile_wizard.py → fusion_accounting_bank_rec/wizards/auto_reconcile_wizard.py (clean-room)
|
||||
- Abstract zone:
|
||||
- models/fusion_reconcile_engine.py — original implementation
|
||||
- Intentional deltas from Odoo:
|
||||
- AI hook in reconcile step (calls fusion_accounting_ai.suggest_match adapter)
|
||||
- Different default colour palette (SCSS var overrides)
|
||||
|
||||
## V20.0.x.y.z (planned, not yet shipped)
|
||||
- Odoo changes account_accountant V19 → V20 absorbed:
|
||||
- [MIRROR] kanban_renderer.js: column layout changed, applied identical hunk
|
||||
- [ABSTRACT] account.reconcile.model._apply_lines_for_bank_widget signature changed — updated adapter
|
||||
- [NEW FEATURE] batch-reconcile-across-journals — deferred to V20.1
|
||||
- Migration scripts:
|
||||
- migrations/20.0.0.0.0/pre-migration.py: rename column foo → bar
|
||||
```
|
||||
|
||||
### 6.4 `tools/check_odoo_diff.sh` Specification
|
||||
|
||||
The script lives at `fusion_accounting/tools/check_odoo_diff.sh` (workspace root, shared across sub-modules). Usage:
|
||||
|
||||
```bash
|
||||
tools/check_odoo_diff.sh <enterprise_module> <from_version> <to_version> [<output_file>]
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Runs `diff -ruN /Users/gurpreet/Github/RePackaged-Odoo/accounting-<from>/<module> /Users/gurpreet/Github/RePackaged-Odoo/accounting-<to>/<module>`
|
||||
- Splits output into per-file sections
|
||||
- For each file, classifies based on file path: `views/` and `static/src/components/` and `report/` → `[MIRROR]` candidate; `models/*_engine.py`-like → `[ABSTRACT]` review; new files → `[NEW FEATURE]` review
|
||||
- Outputs a markdown report with per-file sections and classification suggestions
|
||||
- Exit code: 0 if no changes, non-zero if changes (CI can use to flag annual upgrades)
|
||||
|
||||
### 6.5 Pinning and Rollback
|
||||
|
||||
- Git: `main-v19`, `main-v20`, etc. branches in fusion repo. Each client stays on their pinned Odoo version
|
||||
- Manifest version pinned per sub-module per Odoo version
|
||||
- Client deployment: never auto-upgrade. Upgrade is a deliberate, tested, per-client migration
|
||||
- Rollback: restore DB from `pg_dump` taken before upgrade, restore `fusion_accounting_*` checkout from git tag, restart Odoo
|
||||
|
||||
### 6.6 Cross-Version Migration Scripts
|
||||
|
||||
Odoo's standard migration mechanism applies. Each sub-module has a `migrations/` folder with subfolders named after manifest versions. Scripts run automatically when the manifest version bumps in the database vs. on disk.
|
||||
|
||||
```python
|
||||
# fusion_accounting_assets/migrations/20.0.0.0.0/pre-migration.py
|
||||
def migrate(cr, version):
|
||||
# V20 renamed fusion_asset.original_value to fusion_asset.acquisition_cost
|
||||
cr.execute("ALTER TABLE fusion_asset RENAME COLUMN original_value TO acquisition_cost")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. AI Integration, Testing, Documentation
|
||||
|
||||
### 7.1 AI Integration
|
||||
|
||||
The AI copilot (existing `fusion_accounting/services/`, `fusion_accounting/static/src/`, `fusion_accounting/controllers/` etc.) moves to `fusion_accounting_ai/` in Phase 0 and stays original code. What changes:
|
||||
|
||||
**Adapter pattern**: every AI tool that today calls Enterprise APIs gets routed through an adapter:
|
||||
|
||||
```
|
||||
fusion_accounting_ai/services/adapters/
|
||||
├── bank_rec_adapter.py
|
||||
├── reports_adapter.py
|
||||
├── followup_adapter.py
|
||||
├── assets_adapter.py
|
||||
└── _registry.py
|
||||
```
|
||||
|
||||
Adapter behavior (uniform pattern across all adapters):
|
||||
|
||||
```python
|
||||
class BankRecAdapter:
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
|
||||
def list_unreconciled_lines(self, journal_id, limit=100):
|
||||
# Prefer fusion native if installed
|
||||
if 'fusion.bank.rec.widget' in self.env.registry:
|
||||
return self.env['fusion.bank.rec.widget'].sudo().get_unreconciled(journal_id, limit)
|
||||
# Fall back to Enterprise if installed
|
||||
elif self.env['ir.module.module']._fusion_is_module_installed('account_accountant'):
|
||||
return self._enterprise_unreconciled_lines(journal_id, limit)
|
||||
# Last resort: pure Community search
|
||||
else:
|
||||
return self.env['account.bank.statement.line'].sudo().search([
|
||||
('journal_id', '=', journal_id),
|
||||
('is_reconciled', '=', False),
|
||||
], limit=limit)
|
||||
```
|
||||
|
||||
This pattern means `fusion_accounting_ai` always works, regardless of which other modules are installed. The AI tool functions in `fusion_accounting_ai/services/tools/` get refactored once in Phase 0 to call adapters; subsequent phases just enrich the adapters.
|
||||
|
||||
**New AI capabilities unlocked by native implementations**: each native phase exposes engine internals to AI tools that Enterprise didn't expose cleanly. Examples:
|
||||
|
||||
- Phase 1: AI gets access to fusion's match-confidence scores
|
||||
- Phase 2: AI can request a report computation with custom comparison periods on the fly
|
||||
- Phase 4: AI has direct access to tax-grid-by-account decomposition
|
||||
- Phase 5: AI drafts follow-up messages with full payment history context
|
||||
|
||||
**Existing AI patterns carry forward unchanged**:
|
||||
|
||||
- Tool tiering (Tier 1 / 2 / 3 with auto-promotion)
|
||||
- Provider pinning per session (Claude vs OpenAI consistency within a session)
|
||||
- Tier 3 approval flow with `pending_approval` placeholder swap on approve/reject
|
||||
- Rich-text chat output via `mdToHtml()` and `innerHTML` injection
|
||||
- Interactive `fusion-table` blocks for actionable results
|
||||
- Session ownership / multi-company record rules (the `fusion.accounting.session` rule that's currently missing gets added in Phase 0)
|
||||
|
||||
### 7.2 Testing Strategy
|
||||
|
||||
Every phase must pass these test categories before exit:
|
||||
|
||||
| Category | Scope | Where it lives |
|
||||
|---|---|---|
|
||||
| **Unit (engine)** | Pure-Python; no Odoo DB. Algorithm correctness with fixtures | `tests/test_<feature>_engine.py` |
|
||||
| **Integration (Odoo TestCase)** | Full Odoo DB; install + create data + exercise workflow + assert state | `tests/test_<feature>_integration.py` |
|
||||
| **Migration round-trip** | Install Enterprise, create Enterprise-only data, install fusion, run wizard, uninstall Enterprise, assert data integrity | `tests/test_migration.py` |
|
||||
| **Tour (JS)** | End-to-end widget UI smoke | `tests/tours/<feature>_tour.js` |
|
||||
| **Performance** | Phase 2 reports especially; assert P95 latency at 1k/10k/100k rows | `tests/test_<feature>_performance.py` |
|
||||
| **Multi-matrix** | Single-company, multi-company, multi-currency, cash-basis on/off | parameterized within other tests |
|
||||
|
||||
CI runs all tests on every push. A nightly job runs migration tests against a fixture Enterprise DB.
|
||||
|
||||
### 7.3 Documentation Deliverables
|
||||
|
||||
Per sub-module:
|
||||
|
||||
- `CLAUDE.md` — module-specific context for Cursor/AI assistance
|
||||
- `UPGRADE_NOTES.md` — Odoo version porting log
|
||||
- `README.md` — operator-facing: install, configure, troubleshoot, common gotchas
|
||||
- One screencast or animated GIF per major user workflow, in `static/description/`
|
||||
- Per-feature feature flag documentation in `CLAUDE.md` if applicable
|
||||
|
||||
Workspace-root documentation:
|
||||
|
||||
- `/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md` — common Odoo 19 conventions (already substantial; carries forward)
|
||||
- `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/CLAUDE.md` — meta-module overview pointing at sub-modules
|
||||
- `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/docs/superpowers/specs/` — design and plan docs (this doc and future ones)
|
||||
|
||||
### 7.4 Security
|
||||
|
||||
- Three groups carry forward from existing module: `group_fusion_accounting_user/manager/admin`. Move from current location to `fusion_accounting_core/security/security.xml` in Phase 0
|
||||
- Auto-assignments from Community accounting groups: `account.group_account_user` → fusion User; `account.group_account_manager` → fusion Admin (already in place)
|
||||
- Multi-company record rules on every fusion model with `company_id`. Add the missing rule on `fusion.accounting.session` in Phase 0
|
||||
- ACLs in `security/ir.model.access.csv` per sub-module, scoped to that sub-module's models only
|
||||
- Approve/reject endpoints continue to use `auth='user'` with imperative `has_group()` check inside the handler (Odoo has no built-in `auth='manager'`)
|
||||
|
||||
### 7.5 Performance Considerations (Phase 2 in particular)
|
||||
|
||||
Odoo Enterprise reports have known performance issues on large databases. The Phase 2 design doc must lock in:
|
||||
|
||||
- Denormalized read paths for trial balance and general ledger (materialized aggregations refreshed on `account.move` post)
|
||||
- Lazy-load line detail (drill-down fetches separately, not all at once)
|
||||
- Cache report runs per `(company_id, period, filter_hash)` with invalidation on `account.move.line` write/post/cancel
|
||||
- Parallel computation across companies in multi-company reports
|
||||
- SQL query review (no Python aggregation of large result sets)
|
||||
|
||||
### 7.6 Multi-Company, Multi-Currency, Analytic
|
||||
|
||||
Not a separate phase. Woven into every phase's exit criteria:
|
||||
|
||||
- Every fusion model with company-scoped data has `company_id` field and a multi-company record rule
|
||||
- Every monetary field pairs with `currency_id`
|
||||
- `analytic_mixin` (currently in `account_accountant/models/analytic_mixin.py`): declared in `fusion_accounting_core` via shared-field-ownership pattern so analytic tags survive Enterprise uninstall
|
||||
|
||||
### 7.7 Localization
|
||||
|
||||
Canadian HST is built into the existing AI module (`fusion_accounting_ai/services/prompts/domain_prompts.py`) and carries forward. Other localizations are deferred:
|
||||
|
||||
- Each country-specific tax report becomes a `fusion.account.report` record in `fusion_accounting_reports/data/<country>_<report>.xml`
|
||||
- Country-specific chart of accounts: continue to use Odoo's `account.chart.template` mechanism (Community)
|
||||
- New countries are added on demand, per client engagement
|
||||
|
||||
### 7.8 Hosting and Deployment
|
||||
|
||||
Out of scope for this design doc; covered in workspace-root operational docs. fusion_accounting deploys to the existing Nexa Odoo infrastructure (per existing `fusion_accounting/CLAUDE.md`: `odoo-westin` for Westin Healthcare, equivalents for other clients). Deploy commands in CLAUDE.md carry forward.
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance Criteria for This Roadmap
|
||||
|
||||
This roadmap is considered "done" (and ready for the first writing-plans session for Phase 0) when:
|
||||
|
||||
- The user has reviewed this document and signed off
|
||||
- No unresolved ambiguity remains in any of the locked decisions (sub-module topology, data preservation, phase order, architecture rules, upgrade workflow)
|
||||
- The empirical verification test (Section 3.6) is scheduled as part of Phase 0 and not deferred
|
||||
|
||||
The next session's deliverable will be the Phase 0 implementation plan (via the `writing-plans` skill), which will turn Section 4.2 into actionable, testable tasks.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions Deferred to Future Sessions
|
||||
|
||||
Items consciously left open here, to be resolved in their respective phase brainstorming sessions:
|
||||
|
||||
- Phase 1: exact UI deltas from Odoo's bank rec widget (colour palette, AI confidence badge placement, keyboard shortcuts)
|
||||
- Phase 2: report definition data format (XML mirroring Odoo vs. our own simpler format)
|
||||
- Phase 2: caching layer implementation (in-memory vs. Redis vs. PostgreSQL materialized views)
|
||||
- Phase 4: which non-Canadian tax jurisdictions to seed
|
||||
- Phase 5: SMS provider integration (Twilio? `mail.sms` Odoo built-in?)
|
||||
- Phase 6: depreciation methods to support beyond linear/declining (sum-of-years-digits, units-of-production)
|
||||
- Phase 7+: which satellites have actual client demand right now
|
||||
|
||||
---
|
||||
|
||||
## 10. References
|
||||
|
||||
- Workspace root: `/Users/gurpreet/Github/Odoo-Modules/`
|
||||
- Current AI module: `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/`
|
||||
- Current AI module conventions: `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/CLAUDE.md`
|
||||
- Workspace conventions: `/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md`
|
||||
- WIP code (not continued): `/Users/gurpreet/Github/Odoo-Modules/Work in Progress/fusion_accounting/`
|
||||
- WIP audit report: `/Users/gurpreet/Github/Odoo-Modules/Work in Progress/fusion_accounting/AUDIT_REPORT.md`
|
||||
- Pinned Odoo source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/`
|
||||
- Plan file (this session): `/Users/gurpreet/.cursor/plans/fusion_accounting_takeover_roadmap_c851fdb4.plan.md`
|
||||
@@ -168,5 +168,26 @@
|
||||
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
|
||||
</record>
|
||||
|
||||
<!-- Cron Job: ADP Hold Expiry Reminders (2026-04).
|
||||
For each on-hold ADP case:
|
||||
- Sends monthly reminder to the CLIENT (authorizer excluded per
|
||||
2026-04 authorizer email policy). Cadence:
|
||||
fusion_claims.adp_hold_reminder_interval_days (default 30).
|
||||
- Sends ONE final warning to client + authorizer when funding
|
||||
expires within fusion_claims.adp_hold_final_warning_days_before_expiry
|
||||
(default 30 days before expiry).
|
||||
- Silently skips cases where the client has no email on file.
|
||||
Flags reset automatically when the case resumes from hold. -->
|
||||
<record id="ir_cron_adp_hold_expiry_reminders" model="ir.cron">
|
||||
<field name="name">Fusion Claims: ADP Hold Expiry Reminders</field>
|
||||
<field name="model_id" ref="sale.model_sale_order"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_adp_hold_expiry_reminders()</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=9, minute=30, second=0)"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,12 +12,16 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="is_adp" t-value="doc.x_fc_is_adp_invoice"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
|
||||
.fc-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||||
.fc-landscape table.bordered, .fc-landscape table.bordered th, .fc-landscape table.bordered td { border: 1px solid #000; }
|
||||
.fc-landscape th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
|
||||
.fc-landscape th { background-color: <t t-out="primary"/>; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
|
||||
.fc-landscape td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
|
||||
.fc-landscape .text-center { text-align: center; }
|
||||
.fc-landscape .text-end { text-align: right; }
|
||||
@@ -26,7 +30,7 @@
|
||||
.fc-landscape .client-bg { background-color: #fff3e0; }
|
||||
.fc-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-landscape .note-row { font-style: italic; }
|
||||
.fc-landscape h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
|
||||
.fc-landscape h2 { color: <t t-out="primary"/>; margin: 10px 0; font-size: 18pt; }
|
||||
.fc-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
|
||||
.fc-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
|
||||
.fc-landscape .totals-table { border: 1px solid #000; }
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="is_adp" t-value="doc.x_fc_is_adp_invoice"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#005a83'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-report { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-report table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
@@ -25,7 +29,7 @@
|
||||
.fc-report .client-bg { background-color: #fff3e0; }
|
||||
.fc-report .section-row { background-color: #f8f8f8; font-weight: bold; }
|
||||
.fc-report .note-row { font-style: italic; }
|
||||
.fc-report h4 { color: #005a83; margin: 0 0 15px 0; }
|
||||
.fc-report h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; }
|
||||
.fc-report .totals-table { border: 1px solid #000; border-collapse: collapse; }
|
||||
.fc-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
|
||||
</style>
|
||||
|
||||
@@ -12,19 +12,22 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="company" t-value="doc.company_id"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="primary" t-value="(company.primary_color if company else False) or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="(company.secondary_color if company else False) or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-contract { font-family: Arial, sans-serif; font-size: 8pt; line-height: 1.3; }
|
||||
.fc-contract h1 { color: #0066a1; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
|
||||
.fc-contract h2 { color: #0066a1; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
|
||||
.fc-contract h4 { color: #0066a1; margin: 0 0 10px 0; font-size: 13pt; }
|
||||
.fc-contract h1 { color: <t t-out="primary"/>; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
|
||||
.fc-contract h2 { color: <t t-out="primary"/>; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
|
||||
.fc-contract h4 { color: <t t-out="primary"/>; margin: 0 0 10px 0; font-size: 13pt; }
|
||||
.fc-contract p { margin: 2px 0; text-align: justify; }
|
||||
.fc-contract .intro { margin-bottom: 8px; font-size: 8pt; }
|
||||
.fc-contract ul { margin: 2px 0 2px 15px; padding: 0; }
|
||||
.fc-contract li { margin-bottom: 1px; }
|
||||
.fc-contract table { width: 100%; border-collapse: collapse; }
|
||||
.fc-contract table.bordered, .fc-contract table.bordered th, .fc-contract table.bordered td { border: 1px solid #000; }
|
||||
.fc-contract th { background-color: #0066a1; color: white; padding: 4px 6px; font-weight: bold; text-align: center; font-size: 8pt; }
|
||||
.fc-contract th { background-color: <t t-out="primary"/>; color: white; padding: 4px 6px; font-weight: bold; text-align: center; font-size: 8pt; }
|
||||
.fc-contract td { padding: 3px 5px; vertical-align: top; font-size: 8pt; }
|
||||
.fc-contract .text-center { text-align: center; }
|
||||
.fc-contract .text-end { text-align: right; }
|
||||
@@ -48,7 +51,7 @@
|
||||
.fc-contract .sig-row { display: table; width: 100%; margin-bottom: 20px; }
|
||||
.fc-contract .sig-col { display: table-cell; width: 48%; vertical-align: top; }
|
||||
.fc-contract .sig-spacer { display: table-cell; width: 4%; }
|
||||
.fc-contract .sig-title { font-weight: bold; font-size: 9pt; color: #0066a1; margin-bottom: 8px; border-bottom: 2px solid #0066a1; padding-bottom: 3px; }
|
||||
.fc-contract .sig-title { font-weight: bold; font-size: 9pt; color: <t t-out="primary"/>; margin-bottom: 8px; border-bottom: 2px solid <t t-out="primary"/>; padding-bottom: 3px; }
|
||||
.fc-contract .sig-field { margin-bottom: 12px; }
|
||||
.fc-contract .sig-line { border-bottom: 1px solid #000; min-height: 25px; }
|
||||
.fc-contract .sig-label { font-size: 7pt; color: #666; margin-top: 2px; }
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
<t t-set="is_deduction" t-value="doc.x_fc_adp_application_status == 'approved_deduction'"/>
|
||||
<t t-set="lines" t-value="doc.order_line.filtered(lambda l: l.product_id and l.display_type not in ('line_section', 'line_note'))"/>
|
||||
<t t-set="has_deduction" t-value="any(l.x_fc_deduction_type and l.x_fc_deduction_type != 'none' for l in lines)"/>
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="primary" t-value="(company.primary_color if company else False) or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="(company.secondary_color if company else False) or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-ai { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-ai h2 { color: #0066a1; font-size: 16pt; text-align: center; margin: 25px 0 20px 0; }
|
||||
.fc-ai h2 { color: <t t-out="primary"/>; font-size: 16pt; text-align: center; margin: 25px 0 20px 0; }
|
||||
.fc-ai table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-ai table.bordered, .fc-ai table.bordered th, .fc-ai table.bordered td { border: 1px solid #000; }
|
||||
.fc-ai th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-ai th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-ai td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
|
||||
.fc-ai .text-center { text-align: center; }
|
||||
.fc-ai .text-end { text-align: right; }
|
||||
|
||||
@@ -12,10 +12,13 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="company" t-value="doc.company_id"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="primary" t-value="(company.primary_color if company else False) or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="(company.secondary_color if company else False) or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-waiver { font-family: Arial, sans-serif; font-size: 10pt; line-height: 1.5; }
|
||||
.fc-waiver h1 { color: #0066a1; font-size: 16pt; text-align: center; margin: 10px 0 20px 0; }
|
||||
.fc-waiver h1 { color: <t t-out="primary"/>; font-size: 16pt; text-align: center; margin: 10px 0 20px 0; }
|
||||
.fc-waiver h2 { color: #333; font-size: 11pt; margin: 15px 0 8px 0; }
|
||||
.fc-waiver p { margin: 8px 0; text-align: justify; }
|
||||
.fc-waiver .intro { margin-bottom: 15px; font-style: italic; }
|
||||
|
||||
@@ -17,20 +17,24 @@
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<!-- Get sale order for MOD fields -->
|
||||
<t t-set="so" t-value="doc.x_fc_source_sale_order_id"/>
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#1a5276'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-mod-inv { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-mod-inv table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-mod-inv table.bordered, .fc-mod-inv table.bordered th, .fc-mod-inv table.bordered td { border: 1px solid #000; }
|
||||
.fc-mod-inv th { background-color: #1a5276; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
|
||||
.fc-mod-inv th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
|
||||
.fc-mod-inv td { padding: 6px 8px; vertical-align: top; }
|
||||
.fc-mod-inv .text-center { text-align: center; }
|
||||
.fc-mod-inv .text-end { text-align: right; }
|
||||
.fc-mod-inv .text-start { text-align: left; }
|
||||
.fc-mod-inv .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-mod-inv .note-row { font-style: italic; color: #555; font-size: 9pt; }
|
||||
.fc-mod-inv h4 { color: #1a5276; margin: 0 0 15px 0; font-size: 16pt; }
|
||||
.fc-mod-inv .req-box { border: 2px solid #1a5276; padding: 8px 12px; margin: 6px 0; background-color: #fafafa; }
|
||||
.fc-mod-inv h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; font-size: 16pt; }
|
||||
.fc-mod-inv .req-box { border: 2px solid <t t-out="primary"/>; padding: 8px 12px; margin: 6px 0; background-color: #fafafa; }
|
||||
</style>
|
||||
|
||||
<div class="fc-mod-inv">
|
||||
|
||||
@@ -9,21 +9,25 @@
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#1a5276'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-mod { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-mod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-mod table.bordered, .fc-mod table.bordered th, .fc-mod table.bordered td { border: 1px solid #000; }
|
||||
.fc-mod th { background-color: #1a5276; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
|
||||
.fc-mod th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
|
||||
.fc-mod td { padding: 6px 8px; vertical-align: top; }
|
||||
.fc-mod .text-center { text-align: center; }
|
||||
.fc-mod .text-end { text-align: right; }
|
||||
.fc-mod .text-start { text-align: left; }
|
||||
.fc-mod .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-mod h4 { color: #1a5276; margin: 0 0 15px 0; font-size: 16pt; }
|
||||
.fc-mod h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; font-size: 16pt; }
|
||||
.fc-mod .info-header { background-color: #eaf2f8; color: #333; }
|
||||
.fc-mod .mod-accent { color: #1a5276; font-weight: bold; }
|
||||
.fc-mod .highlight-box { border: 2px solid #1a5276; padding: 10px; margin: 10px 0; background-color: #eaf2f8; }
|
||||
.fc-mod .mod-accent { color: <t t-out="primary"/>; font-weight: bold; }
|
||||
.fc-mod .highlight-box { border: 2px solid <t t-out="primary"/>; padding: 10px; margin: 10px 0; background-color: #eaf2f8; }
|
||||
</style>
|
||||
|
||||
<div class="fc-mod">
|
||||
|
||||
@@ -12,12 +12,16 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="is_adp" t-value="doc.x_fc_is_adp_sale"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-pod { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-pod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-pod table.bordered, .fc-pod table.bordered th, .fc-pod table.bordered td { border: 1px solid #000; }
|
||||
.fc-pod th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-pod th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-pod td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
|
||||
.fc-pod .text-center { text-align: center; }
|
||||
.fc-pod .text-end { text-align: right; }
|
||||
@@ -25,7 +29,7 @@
|
||||
.fc-pod .adp-bg { background-color: #e3f2fd; }
|
||||
.fc-pod .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-pod .note-row { font-style: italic; }
|
||||
.fc-pod h2 { color: #0066a1; margin: 8px 0; font-size: 16pt; }
|
||||
.fc-pod h2 { color: <t t-out="primary"/>; margin: 8px 0; font-size: 16pt; }
|
||||
.fc-pod .info-table td { padding: 6px 10px; font-size: 10pt; }
|
||||
.fc-pod .info-table th { background-color: #f5f5f5; color: #333; font-size: 9pt; padding: 5px 10px; }
|
||||
.fc-pod .signature-section { margin-top: 20px; border: 1px solid #000; padding: 15px; }
|
||||
|
||||
@@ -11,19 +11,23 @@
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-pod { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-pod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-pod table.bordered, .fc-pod table.bordered th, .fc-pod table.bordered td { border: 1px solid #000; }
|
||||
.fc-pod th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-pod th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-pod td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
|
||||
.fc-pod .text-center { text-align: center; }
|
||||
.fc-pod .text-end { text-align: right; }
|
||||
.fc-pod .text-start { text-align: left; }
|
||||
.fc-pod .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-pod .note-row { font-style: italic; }
|
||||
.fc-pod h2 { color: #0066a1; margin: 8px 0; font-size: 16pt; }
|
||||
.fc-pod h2 { color: <t t-out="primary"/>; margin: 8px 0; font-size: 16pt; }
|
||||
.fc-pod .info-table td { padding: 6px 10px; font-size: 10pt; }
|
||||
.fc-pod .info-table th { background-color: #f5f5f5; color: #333; font-size: 9pt; padding: 5px 10px; }
|
||||
.fc-pod .signature-section { margin-top: 20px; border: 1px solid #000; padding: 15px; }
|
||||
|
||||
@@ -11,19 +11,25 @@
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
|
||||
<!-- Pickup uses the company's secondary brand colour (green by convention,
|
||||
distinct from the delivery report's primary). Falls back to legacy
|
||||
green if the company has not set a secondary colour. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#2e7d32'"/>
|
||||
|
||||
<style>
|
||||
.fc-pop { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-pop table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-pop table.bordered, .fc-pop table.bordered th, .fc-pop table.bordered td { border: 1px solid #000; }
|
||||
.fc-pop th { background-color: #2e7d32; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-pop th { background-color: <t t-out="secondary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-pop td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
|
||||
.fc-pop .text-center { text-align: center; }
|
||||
.fc-pop .text-end { text-align: right; }
|
||||
.fc-pop .text-start { text-align: left; }
|
||||
.fc-pop .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-pop .note-row { font-style: italic; }
|
||||
.fc-pop h2 { color: #2e7d32; margin: 8px 0; font-size: 16pt; }
|
||||
.fc-pop h2 { color: <t t-out="secondary"/>; margin: 8px 0; font-size: 16pt; }
|
||||
.fc-pop .info-table td { padding: 6px 10px; font-size: 10pt; }
|
||||
.fc-pop .info-table th { background-color: #f5f5f5; color: #333; font-size: 9pt; padding: 5px 10px; }
|
||||
.fc-pop .signature-section { margin-top: 20px; border: 1px solid #000; padding: 15px; }
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
Part of the Fusion Claim Assistant product family.
|
||||
-->
|
||||
<odoo>
|
||||
<!--
|
||||
Colour convention used across fusion_claims reports (2026-04):
|
||||
Each report should set `primary` and `secondary` near the top of
|
||||
its template body, drawing from the company's brand colours
|
||||
configured via the document-layout wizard:
|
||||
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
Then reference `<t t-out="primary"/>` inside the <style> block and
|
||||
inline `style="..."` attributes. Fallbacks preserve legacy rendering
|
||||
on databases that have never set a colour.
|
||||
-->
|
||||
|
||||
<!-- Shared Report Header Template -->
|
||||
<template id="report_header_fusion_claims">
|
||||
<div class="fc-header">
|
||||
@@ -117,20 +132,21 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Report Styles -->
|
||||
<!-- Report Styles (callers should set `primary` before t-call). -->
|
||||
<template id="report_styles_fusion_claims">
|
||||
<t t-set="primary" t-value="primary or ((company.primary_color if company else False) or '#0077b6')"/>
|
||||
<style>
|
||||
.fc-header { margin-bottom: 20px; }
|
||||
.fc-footer { margin-top: 20px; }
|
||||
.fc-table { width: 100%; border-collapse: collapse; }
|
||||
.fc-table th { background-color: #0077b6; color: white; padding: 8px; text-align: left; }
|
||||
.fc-table th { background-color: <t t-out="primary"/>; color: white; padding: 8px; text-align: left; }
|
||||
.fc-table td { padding: 6px; border-bottom: 1px solid #ddd; }
|
||||
.fc-table .section-header { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-table .text-end { text-align: right; }
|
||||
.fc-totals { margin-top: 20px; }
|
||||
.fc-totals table { width: 300px; float: right; }
|
||||
.fc-totals td { padding: 4px 8px; }
|
||||
.fc-totals .total-row { font-weight: bold; background-color: #0077b6; color: white; }
|
||||
.fc-totals .total-row { font-weight: bold; background-color: <t t-out="primary"/>; color: white; }
|
||||
</style>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,12 +12,16 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="is_adp" t-value="doc.x_fc_is_adp_sale"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
|
||||
.fc-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||||
.fc-landscape table.bordered, .fc-landscape table.bordered th, .fc-landscape table.bordered td { border: 1px solid #000; }
|
||||
.fc-landscape th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
|
||||
.fc-landscape th { background-color: <t t-out="primary"/>; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
|
||||
.fc-landscape td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
|
||||
.fc-landscape .text-center { text-align: center; }
|
||||
.fc-landscape .text-end { text-align: right; }
|
||||
@@ -26,7 +30,7 @@
|
||||
.fc-landscape .client-bg { background-color: #fff3e0; }
|
||||
.fc-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-landscape .note-row { font-style: italic; }
|
||||
.fc-landscape h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
|
||||
.fc-landscape h2 { color: <t t-out="primary"/>; margin: 10px 0; font-size: 18pt; }
|
||||
.fc-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
|
||||
.fc-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
|
||||
.fc-landscape .totals-table { border: 1px solid #000; }
|
||||
|
||||
@@ -11,12 +11,16 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="is_adp" t-value="doc.x_fc_is_adp_sale"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-report { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-report table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-report table.bordered, .fc-report table.bordered th, .fc-report table.bordered td { border: 1px solid #000; }
|
||||
.fc-report th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
|
||||
.fc-report th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
|
||||
.fc-report td { padding: 6px 8px; vertical-align: top; }
|
||||
.fc-report .text-center { text-align: center; }
|
||||
.fc-report .text-end { text-align: right; }
|
||||
@@ -25,7 +29,7 @@
|
||||
.fc-report .client-bg { background-color: #fff3e0; }
|
||||
.fc-report .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-report .note-row { font-style: italic; }
|
||||
.fc-report h4 { color: #0066a1; margin: 0 0 15px 0; font-size: 16pt; }
|
||||
.fc-report h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; font-size: 16pt; }
|
||||
.fc-report .totals-table { border: 1px solid #000; border-collapse: collapse; }
|
||||
.fc-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
|
||||
.fc-report .info-header { background-color: #f5f5f5; color: #333; }
|
||||
|
||||
@@ -1385,12 +1385,12 @@
|
||||
confirm="Reopen this cancelled application at the Quotation stage?"
|
||||
help="Return a cancelled application to Quotation"/>
|
||||
|
||||
<button name="action_adp_reopen_expired" type="object"
|
||||
string="Reopen" class="btn-info"
|
||||
icon="fa-refresh"
|
||||
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'expired'"
|
||||
confirm="Reopen this expired application at the Quotation stage?"
|
||||
help="Return an expired application to Quotation"/>
|
||||
<button name="action_adp_duplicate_for_reassessment" type="object"
|
||||
string="Create Reassessment Order" class="btn-primary"
|
||||
icon="fa-copy"
|
||||
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('expired', 'cancelled')"
|
||||
confirm="Create a new sale order for reassessment? The old order stays as a historical record. The authorizer will need to complete a new assessment before resubmission."
|
||||
help="Create a new order linked to this one so the authorizer can reassess the client's needs"/>
|
||||
|
||||
<button name="action_adp_resubmit_from_denied" type="object"
|
||||
string="Resubmit" class="btn-primary"
|
||||
@@ -1604,11 +1604,11 @@
|
||||
icon="fa-refresh"
|
||||
invisible="x_fc_adp_application_status != 'cancelled'"
|
||||
confirm="Reopen this cancelled application at the Quotation stage?"/>
|
||||
<button name="action_adp_reopen_expired" type="object"
|
||||
string="Reopen" class="btn-info btn-sm me-1"
|
||||
icon="fa-refresh"
|
||||
invisible="x_fc_adp_application_status != 'expired'"
|
||||
confirm="Reopen this expired application at the Quotation stage?"/>
|
||||
<button name="action_adp_duplicate_for_reassessment" type="object"
|
||||
string="Create Reassessment Order" class="btn-primary btn-sm me-1"
|
||||
icon="fa-copy"
|
||||
invisible="x_fc_adp_application_status not in ('expired', 'cancelled')"
|
||||
confirm="Create a new sale order for reassessment? The authorizer will need to complete a new assessment before resubmission."/>
|
||||
<button name="action_adp_resubmit_from_denied" type="object"
|
||||
string="Resubmit" class="btn-primary btn-sm me-1"
|
||||
icon="fa-repeat"
|
||||
@@ -2635,10 +2635,134 @@
|
||||
context="{'group_by': 'x_fc_adp_application_status'}"/>
|
||||
<filter string="Client Type" name="group_client_type"
|
||||
context="{'group_by': 'x_fc_client_type'}"/>
|
||||
<filter string="Authorizer" name="group_authorizer"
|
||||
<filter string="Authorizer" name="group_authorizer"
|
||||
context="{'group_by': 'x_fc_authorizer_id'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SALE ORDER FORM: WSIB / Insurance / MDC / Hardship Case Details -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="view_order_form_fusion_claims_funder_workflows" model="ir.ui.view">
|
||||
<field name="name">sale.order.form.fusion.claims.funder.workflows</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="priority">48</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='sale_header']" position="after">
|
||||
<field name="x_fc_show_wsib_fields" invisible="1"/>
|
||||
<field name="x_fc_show_insurance_fields" invisible="1"/>
|
||||
<field name="x_fc_show_mdc_fields" invisible="1"/>
|
||||
<field name="x_fc_show_hardship_fields" invisible="1"/>
|
||||
<field name="x_fc_is_wsib_sale" invisible="1"/>
|
||||
<field name="x_fc_is_insurance_sale" invisible="1"/>
|
||||
<field name="x_fc_is_mdc_sale" invisible="1"/>
|
||||
<field name="x_fc_is_hardship_sale" invisible="1"/>
|
||||
|
||||
<!-- ================== WSIB ================== -->
|
||||
<group name="wsib_case_details" string="WSIB Case"
|
||||
invisible="not x_fc_show_wsib_fields">
|
||||
<group>
|
||||
<field name="x_fc_wsib_status" string="Status"
|
||||
required="x_fc_sale_type == 'wsib'"/>
|
||||
<field name="x_fc_wsib_claim_number"/>
|
||||
<field name="x_fc_wsib_adjudicator_name"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_wsib_form_7_date"/>
|
||||
<field name="x_fc_wsib_approval_date"
|
||||
invisible="x_fc_wsib_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'documents_ready', 'submitted_to_wsib')"/>
|
||||
<field name="x_fc_wsib_approval_letter"
|
||||
filename="x_fc_wsib_approval_letter_filename"
|
||||
invisible="x_fc_wsib_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'documents_ready', 'submitted_to_wsib')"/>
|
||||
<field name="x_fc_wsib_approval_letter_filename" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- ================== INSURANCE ================== -->
|
||||
<group name="insurance_case_details" string="Insurance Case"
|
||||
invisible="not x_fc_show_insurance_fields">
|
||||
<group>
|
||||
<field name="x_fc_insurance_status" string="Status"
|
||||
required="x_fc_sale_type == 'insurance'"/>
|
||||
<field name="x_fc_insurance_submission_mode"/>
|
||||
<field name="x_fc_insurance_company_id"/>
|
||||
<field name="x_fc_insurance_letter_source"/>
|
||||
<field name="x_fc_insurance_home_assessment_required"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_insurance_policy_number"/>
|
||||
<field name="x_fc_insurance_claim_number"/>
|
||||
<field name="x_fc_insurance_pre_auth_amount"
|
||||
invisible="x_fc_insurance_submission_mode != 'direct_bill'"/>
|
||||
<field name="x_fc_insurance_pre_auth_expiry"
|
||||
invisible="x_fc_insurance_submission_mode != 'direct_bill'"/>
|
||||
<field name="x_fc_insurance_approval_letter"
|
||||
filename="x_fc_insurance_approval_letter_filename"
|
||||
invisible="x_fc_insurance_status in ('quotation', 'home_assessment_scheduled', 'home_assessment_completed', 'documents_ready', 'submitted_by_client', 'pre_auth_submitted')"/>
|
||||
<field name="x_fc_insurance_approval_letter_filename" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- ================== MDC ================== -->
|
||||
<group name="mdc_case_details" string="Muscular Dystrophy Case"
|
||||
invisible="not x_fc_show_mdc_fields">
|
||||
<group>
|
||||
<field name="x_fc_mdc_status" string="Status"
|
||||
required="x_fc_sale_type == 'muscular_dystrophy'"/>
|
||||
<field name="x_fc_mdc_client_id_number"/>
|
||||
<field name="x_fc_mdc_enrollment_verified"/>
|
||||
<field name="x_fc_mdc_enrollment_verified_date"
|
||||
invisible="not x_fc_mdc_enrollment_verified"/>
|
||||
<field name="x_fc_mdc_letter_source"/>
|
||||
<field name="x_fc_mdc_submitted_by"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_mdc_po_number"
|
||||
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
|
||||
<field name="x_fc_mdc_po_date"
|
||||
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
|
||||
<field name="x_fc_mdc_po_amount"
|
||||
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
|
||||
<field name="x_fc_mdc_payment_due_date" readonly="1"
|
||||
invisible="not x_fc_mdc_po_date"/>
|
||||
<field name="x_fc_mdc_po_document"
|
||||
filename="x_fc_mdc_po_document_filename"
|
||||
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
|
||||
<field name="x_fc_mdc_po_document_filename" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- ================== HARDSHIP ================== -->
|
||||
<group name="hardship_case_details" string="Hardship Funding Case"
|
||||
invisible="not x_fc_show_hardship_fields">
|
||||
<group>
|
||||
<field name="x_fc_hardship_status" string="Status"
|
||||
required="x_fc_sale_type == 'hardship'"/>
|
||||
<field name="x_fc_hardship_funder_id"/>
|
||||
<field name="x_fc_hardship_submitted_by"/>
|
||||
<field name="x_fc_hardship_pre_assessment_source"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_hardship_interview_date"
|
||||
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf')"/>
|
||||
<field name="x_fc_hardship_approval_date"
|
||||
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
|
||||
<field name="x_fc_hardship_approval_received_via"
|
||||
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
|
||||
<field name="x_fc_hardship_approval_amount"
|
||||
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
|
||||
<field name="x_fc_hardship_client_portion"
|
||||
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
|
||||
<field name="x_fc_hardship_approval_letter"
|
||||
filename="x_fc_hardship_approval_letter_filename"
|
||||
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
|
||||
<field name="x_fc_hardship_approval_letter_filename" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
345
fusion_plating/CLAUDE.md
Normal file
345
fusion_plating/CLAUDE.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# Fusion Plating — Claude Code Instructions
|
||||
|
||||
## Project
|
||||
Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and metal finishing shops. Built by Nexa Systems for EN Technologies (the client). Replaces Steelhead Software.
|
||||
|
||||
## Module Structure (30 modules)
|
||||
```
|
||||
fusion_plating/ — Core: facilities, process types, tanks, baths, chemistry, recipes
|
||||
fusion_plating_batch/ — Rack/barrel batch tracking (FpBatch, FpBatchChemistry)
|
||||
fusion_plating_kpi/ — KPI definitions, daily auto-compute, dashboard views
|
||||
fusion_plating_configurator/ — Quotation configurator, pricing engine, part catalog, 3D viewer
|
||||
fusion_plating_receiving/ — Parts receiving, inspection, damage logging
|
||||
fusion_plating_invoicing/ — Invoice strategies (deposit/progress/net/COD), account holds
|
||||
fusion_plating_certificates/ — Certificate registry (CoC, thickness reports), Fischerscope data
|
||||
fusion_plating_notifications/ — Auto-email engine, notification templates, audit log
|
||||
fusion_plating_shopfloor/ — Tablet UI, plant overview kanban, process tree visualization
|
||||
fusion_plating_portal/ — Customer portal + self-service configurator wizard
|
||||
fusion_plating_reports/ — PDF reports (WO margin, discharge sample, CoC, etc.)
|
||||
fusion_plating_compliance/ — Compliance framework, jurisdictions
|
||||
fusion_plating_compliance_on/ — Ontario compliance reference data (data-only, no menus)
|
||||
fusion_plating_compliance_tor/ — Toronto bylaw discharge limits (data-only, no menus)
|
||||
fusion_plating_aerospace/ — AS9100 / Nadcap
|
||||
fusion_plating_nuclear/ — CSA N299 / CNSC
|
||||
fusion_plating_cgp/ — Controlled Goods Program
|
||||
fusion_plating_safety/ — SDS, WHMIS, JHSC
|
||||
fusion_plating_quality/ — QMS (NCR, CAPA, calibration)
|
||||
fusion_plating_logistics/ — Pickup & delivery, chain of custody
|
||||
fusion_plating_culture/ — Values / fundamentals
|
||||
fusion_plating_bridge_mrp/ — MRP integration (recipe→WO, portal job, work order priorities)
|
||||
fusion_plating_bridge_sign/ — Digital signatures
|
||||
fusion_plating_bridge_quality/ — Quality bridge
|
||||
fusion_plating_bridge_documents/ — Odoo Documents integration (NCR, CAPA, FAIR, Doc Control)
|
||||
fusion_plating_process_en/ — Electroless nickel process pack
|
||||
fusion_plating_process_chrome/ — Chrome process pack
|
||||
fusion_plating_process_anodize/ — Anodizing process pack
|
||||
fusion_plating_process_black_oxide/ — Black oxide process pack
|
||||
fusion_tasks/ — Local delivery dispatch (GPS, maps, driver scheduling)
|
||||
```
|
||||
|
||||
## Menu Structure (Plating App)
|
||||
The Plating app (`menu_fp_root`, seq 46) has these top-level menus:
|
||||
|
||||
| Seq | Menu | Module | Children |
|
||||
|-----|------|--------|----------|
|
||||
| 3 | KPIs | fusion_plating_kpi | KPIs, KPI History, Production/Quality/Finance dashboards |
|
||||
| 5 | Sales | fusion_plating_configurator + portal | Quotations, Sale Orders, Customers, Part Catalog, Quote Requests, Portal Jobs |
|
||||
| 8 | Configurator | fusion_plating_configurator | New Quote, Coating Configs, Pricing Rules, Treatments |
|
||||
| 12 | Shop Floor | fusion_plating_shopfloor | Plant Overview, Tablet Station, Bake Windows, First-Piece Gates |
|
||||
| 15 | Receiving | fusion_plating_receiving | All Receiving, Pending Inspection, Discrepancies |
|
||||
| 18 | Operations | fusion_plating (core) | Process Recipes, Production Priorities (bridge_mrp), Batches (batch), Baths, Chemistry Logs, Tanks |
|
||||
| 25 | Certificates | fusion_plating_certificates | All, CoC, Thickness Reports |
|
||||
| 30 | Quality | fusion_plating_quality | Holds, NCRs, CAPAs, FAIR, Audits, Doc Control |
|
||||
| 40 | Compliance | fusion_plating_compliance | Permits, Discharge, Waste, Calendar, Spills, Config |
|
||||
| 45 | Safety | fusion_plating_safety | SDS, Training, Exposure, JHSC, Incidents, PPE |
|
||||
| 50 | Logistics | fusion_plating_logistics + fusion_tasks | Pickups, Deliveries, Routes, CoC, POD, Field Tasks, Task Map, Task Calendar |
|
||||
| 60 | Aerospace | fusion_plating_aerospace | AS9100, Nadcap, Counterfeit, Config Items, Risk |
|
||||
| 65 | Nuclear | fusion_plating_nuclear | Program, ITP, 10CFR21, Pedigree, CNSC |
|
||||
| 70 | CGP | fusion_plating_cgp | Registration, AI, PSA, Visitors, Goods, Shipments, Security, Access Log |
|
||||
| 80 | Culture | fusion_plating_culture | Values, Recognitions |
|
||||
| 90 | Configuration | fusion_plating (core) + many | Facilities, Work Centres, Process Categories/Types, Bath Params, Stations, Ovens, Invoice Strategy, Account Holds, Training Types, Chemicals, Notification Templates/Log, Calibration, Specs, AVL, Value Sets/Rotations, N299 Levels, Vehicles |
|
||||
|
||||
**Field Service** (`fusion_tasks`) also has its own standalone root app (seq 45) with Map View, Tasks, Calendar, Configuration. The same task actions are also accessible under Plating > Logistics.
|
||||
|
||||
**Key rule**: Sales menu is unified in `fusion_plating_configurator`. Portal module adds Quote Requests + Portal Jobs as children (referencing `fusion_plating_configurator.menu_fp_sales`). Do NOT create a separate Sales menu in portal.
|
||||
|
||||
## Critical Rules — Odoo 19
|
||||
1. **NEVER code from memory** — Read reference files from the server first.
|
||||
2. **Backend OWL**: `static template`, `static props = ["*"]`, standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`.
|
||||
3. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated in Odoo 19).
|
||||
4. **Search views**: NO `group expand="0"`, NO `string` attribute on `<search>`, NO `<group string="...">` wrapper for group-by filters. Use bare `<group>` for group-by.
|
||||
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
||||
6. **res.groups**: Use `privilege_id` (NOT `category_id`). `user_ids` is OK but the deprecated `users` alias is NOT. Always include `sequence` field.
|
||||
7. **Field params**: `parent_path` does NOT accept `unaccent` parameter in Odoo 19.
|
||||
8. **SCSS borders**: Use `$border-color` (SCSS variable) for card borders, NOT `color-mix()` in border shorthand — the SCSS compiler drops it. `color-mix()` works fine in `background-color`, `box-shadow`, etc.
|
||||
9. **Theme awareness**: All colours must use CSS custom properties (`var(--bs-body-bg)`, `var(--bs-body-color)`, `var(--bs-border-color)`, `var(--bs-secondary-color)`, `var(--o-action)`). NO hardcoded hex. See `fusion_plating_shopfloor.scss` as the gold standard.
|
||||
10. **XML comments**: No double-hyphens (`--`) inside `<!-- -->` comments — invalid XML, causes lxml parse error.
|
||||
11. **XML data ordering**: Window actions must be defined BEFORE `<menuitem>` elements that reference them in the same file.
|
||||
12. **Module install on new modules**: Use `--update=base` alongside `-i MODULE` to ensure Odoo rescans the addons path and finds the new module directory.
|
||||
13. **Implied group cascade**: `implied_ids` on `res.groups` does NOT reliably propagate to users on module install. Always include `user_ids` to explicitly assign admin, or fix via SQL post-install.
|
||||
|
||||
## Naming
|
||||
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
|
||||
- **Existing custom models**: Keep `fusion.plating.*` (e.g. `fusion.plating.portal.job`, `fusion.plating.delivery`)
|
||||
- **New fields on standard Odoo models**: `x_fc_*` prefix
|
||||
- **Legacy fields**: `x_studio_*`
|
||||
- Canadian English for all user-facing text
|
||||
- SCSS class prefix: `o_fp_*` (shopfloor: `o_fp_po_*`, `o_fp_pt_*`; recipes: `o_fp_recipe_*`)
|
||||
- Monetary fields: always pair with `currency_id` field on the same model
|
||||
|
||||
## Process Recipe System (NEW — v19.0.2.x)
|
||||
**Model**: `fusion.plating.process.node` (in `fusion_plating` core)
|
||||
- Hierarchical tree with `_parent_store = True`
|
||||
- Node types: `recipe`, `sub_process`, `operation`, `step`
|
||||
- Companion model: `fusion.plating.process.node.input` (operator inputs)
|
||||
- `icon` is a Selection field (24 curated plating icons), NOT a Char
|
||||
- Auto-icon: JS `guessIcon(name)` maps keywords → icons when adding nodes
|
||||
- OWL tree editor: registered as `fp_recipe_tree_editor` client action
|
||||
- Controller: `fusion_plating/controllers/recipe_controller.py` (7 endpoints)
|
||||
- SCSS: `fusion_plating/static/src/scss/recipe_tree_editor.scss`
|
||||
|
||||
### Recipe Endpoints
|
||||
```
|
||||
POST /fp/recipe/tree — full nested tree for OWL editor
|
||||
POST /fp/recipe/node/create — add child node
|
||||
POST /fp/recipe/node/write — update fields
|
||||
POST /fp/recipe/node/unlink — delete + cascade
|
||||
POST /fp/recipe/node/reorder — bulk sequence update
|
||||
POST /fp/recipe/node/move — change parent_id
|
||||
POST /fp/recipe/duplicate — deep-copy recipe
|
||||
```
|
||||
|
||||
### Steelhead Features Status
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Hierarchical process tree | Done |
|
||||
| Node types (recipe/sub/op/step) | Done |
|
||||
| Auto-complete flag | Done |
|
||||
| Customer visible flag | Done |
|
||||
| Manual/automated flag | Done |
|
||||
| Requires sign-off | Done |
|
||||
| Opt In/Out (disabled/opt-in/opt-out) | Done |
|
||||
| Icon picker | Done |
|
||||
| Time tracking (created/updated with seconds) | Done |
|
||||
| Operator inputs | Done |
|
||||
| Description (rich text) | Done |
|
||||
| File attachments (via mail.thread) | Done |
|
||||
| OWL tree editor with drag-drop | Done |
|
||||
| Tags | Not yet |
|
||||
| Dashboard Transitions | Not yet |
|
||||
| Treatment Groups / Choices | Not yet |
|
||||
| Go To Node Options | Not yet |
|
||||
| Spec Fields | Not yet |
|
||||
|
||||
### Client Recipes Created
|
||||
- `ENP-ALUM-BASIC` — Electroless Nickel Plating Aluminium Basic (9 operations, 15 steps). Data file: `fusion_plating/data/fp_recipe_enp_alum_basic.xml`
|
||||
|
||||
## Plant Overview Dashboard
|
||||
- OWL client action: `fp_plant_overview` in `fusion_plating_shopfloor`
|
||||
- Kanban columns = work centres, cards = active `mrp.workorder` records
|
||||
- Drag & drop between columns (writes `workcenter_id` on the work order)
|
||||
- Endpoint: `POST /fp/shopfloor/plant_overview`
|
||||
- Move endpoint: `POST /fp/shopfloor/plant_overview/move_card`
|
||||
- Auto-refreshes every 30s
|
||||
|
||||
## Deployment
|
||||
|
||||
### odoo-entech (LXC 111 on pve-worker5)
|
||||
- **Type**: Native Odoo (apt package, NOT Docker)
|
||||
- **IP**: 10.200.1.26
|
||||
- **DB**: `admin` (PostgreSQL local, user `odoo`)
|
||||
- **Config**: `/etc/odoo/odoo.conf`
|
||||
- **Addons**: `/mnt/extra-addons/custom/` (fusion_plating modules live here)
|
||||
- **Service**: `systemctl {start|stop|restart} odoo`
|
||||
- **Update command**:
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u MODULE_NAME --stop-after-init\" && systemctl start odoo'"
|
||||
```
|
||||
- **Copy files**: `cat LOCAL_FILE | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/REMOTE_PATH'"`
|
||||
- **IMPORTANT**: Must pass `-c /etc/odoo/odoo.conf` or Odoo won't find the repackaged enterprise addons
|
||||
|
||||
### odoo-trial (VM 316 on pve-worker1)
|
||||
- **Type**: Docker (container `odoo-trial-app`, db `odoo-trial-db`)
|
||||
- **DB**: `trial` (user `odoo`)
|
||||
- **Host addons path**: `/opt/odoo/custom-addons/` → mounts as `/mnt/extra-addons/` in Docker
|
||||
- **Docker network**: `odoo_odoo-network`
|
||||
- **Copy files** (base64 pipe through qm guest exec):
|
||||
```bash
|
||||
B64=$(base64 -w0 "LOCAL_FILE")
|
||||
ssh pve-worker1 "qm guest exec 316 -- bash -c 'echo $B64 | base64 -d > /opt/odoo/custom-addons/REMOTE_PATH'"
|
||||
```
|
||||
- **Clear asset cache** (required after SCSS/JS changes):
|
||||
```bash
|
||||
ssh pve-worker1 "qm guest exec 316 -- bash -c \"docker exec odoo-trial-db psql -U odoo -d trial -c \\\"DELETE FROM ir_attachment WHERE url LIKE '%/web/assets/%';\\\"\""
|
||||
```
|
||||
- **Update command**:
|
||||
```bash
|
||||
ssh pve-worker1 "qm guest exec 316 -- bash -c 'docker stop odoo-trial-app && docker run --rm --network odoo_odoo-network -v odoo_odoo-data:/var/lib/odoo -v /opt/odoo/custom-addons:/mnt/extra-addons -v /opt/odoo/enterprise-addons:/mnt/enterprise-addons -v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf odoo:19 odoo -d trial -u MODULE_NAME --stop-after-init && docker start odoo-trial-app'"
|
||||
```
|
||||
|
||||
### Git Push
|
||||
```bash
|
||||
cd K:/Github/Odoo-Modules/fusion-plating && git push origin main
|
||||
```
|
||||
Pushes to both GitHub and Gitea (nexasystems.ca) via multiple remotes.
|
||||
|
||||
## Supabase Knowledge Base
|
||||
Project: `nexasystems` (id: `ikvdlqkbqsitabxidvnq`)
|
||||
- `fusionapps.decisions` — past architecture decisions
|
||||
- `fusionapps.issues` — known issues and fixes
|
||||
- `fusionapps.code_snippets` — reference code
|
||||
- `fusionapps.quick_commands` — deployment and admin commands
|
||||
- `fusionapps.vm_registry` — VM inventory
|
||||
- `fusionapps.proxmox_nodes` — cluster node specs
|
||||
|
||||
## End-to-End Business Workflow
|
||||
|
||||
### Full Lifecycle (What Exists Today)
|
||||
|
||||
```
|
||||
┌─ QUOTATION ──────────────────────────────────────────────────────┐
|
||||
│ 1. Customer submits RFQ on portal [DONE] │
|
||||
│ → FpQuoteRequest (state: new → under_review → quoted) │
|
||||
│ → Model: fusion_plating_portal/models/fp_quote_request.py │
|
||||
│ │
|
||||
│ 2. Customer accepts → "Create Sale Order" button [DONE] │
|
||||
│ → action_create_sale_order() creates SO with lines │
|
||||
│ → Links SO origin back to RFQ ref │
|
||||
│ │
|
||||
│ 3. SO confirmed → MRP creates Manufacturing Order [DONE] │
|
||||
│ → Standard Odoo sale_mrp flow │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ MANUFACTURING ──────────────────────────────────────────────────┐
|
||||
│ 4. MO confirmed → Portal Job auto-created [DONE] │
|
||||
│ → MrpProduction.action_confirm() override │
|
||||
│ → Creates FpPortalJob (state: in_progress) │
|
||||
│ → Links via x_fc_portal_job_id │
|
||||
│ │
|
||||
│ 5. Planner assigns recipe + configures steps [DONE] │
|
||||
│ → x_fc_recipe_id set on MO │
|
||||
│ → Opens fp.recipe.config.wizard for opt-in/out │
|
||||
│ → Creates fusion.plating.job.node.override records │
|
||||
│ │
|
||||
│ 6. Work orders generated from recipe [DONE] │
|
||||
│ → _generate_workorders_from_recipe() in bridge_mrp │
|
||||
│ → One WO per operation node, steps become WO instructions │
|
||||
│ → Respects opt-in/out overrides from job.node.override │
|
||||
│ │
|
||||
│ 7. Operators execute WOs on shopfloor [DONE] │
|
||||
│ → Plant Overview kanban (drag between work centres) │
|
||||
│ → Batch chemistry tracking (FpBatch + FpBatchChemistry) │
|
||||
│ → Quality holds (FpQualityHold → FpNcr → FpCapa) │
|
||||
│ │
|
||||
│ 8. MO marked done → Portal job ready_to_ship [DONE] │
|
||||
│ → MrpProduction.button_mark_done() override │
|
||||
│ → Auto-creates FpDelivery (draft) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ SHIPPING & INVOICING ───────────────────────────────────────────┐
|
||||
│ 9. CoC report generated [DONE] │
|
||||
│ → report_coc.xml (PDF with job info, certification, sig) │
|
||||
│ → Attached to delivery + portal job │
|
||||
│ │
|
||||
│ 10. Delivery scheduled & executed [DONE] │
|
||||
│ → FpDelivery: draft → scheduled → en_route → delivered │
|
||||
│ → Chain of custody auto-logged (FpChainOfCustody) │
|
||||
│ → Proof of delivery captured (FpProofOfDelivery) │
|
||||
│ → Routes with stops (FpRoute + FpRouteStop) │
|
||||
│ │
|
||||
│ 11. Delivery marked → Portal job shipped [DONE] │
|
||||
│ → FpDelivery.action_mark_delivered() override │
|
||||
│ → Sets actual_ship_date + tracking_ref on portal job │
|
||||
│ │
|
||||
│ 12. Account hold check before invoicing [DONE] │
|
||||
│ → x_fc_account_hold on res.partner (fusion_plating_invoicing)│
|
||||
│ → Blocks SO confirm, invoice post, shipping for non-managers │
|
||||
│ │
|
||||
│ 13. Invoice posted → Portal job complete [DONE] │
|
||||
│ → AccountMove.action_post() override │
|
||||
│ → Sets invoice_ref on portal job, state → complete │
|
||||
│ │
|
||||
│ 14. Auto-email with CoC + Invoice + Tracking [DONE] │
|
||||
│ → fusion_plating_notifications module │
|
||||
│ → fp.notification.template (configurable per trigger event) │
|
||||
│ → fp.notification.log (audit trail) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ CUSTOMER PORTAL ────────────────────────────────────────────────┐
|
||||
│ 15. Customer sees on portal [DONE] │
|
||||
│ → Job progress bar (received → complete) │
|
||||
│ → CoC download, invoice access, tracking ref │
|
||||
│ → Quote request history │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Per-Job Recipe Overrides (v19.0.2.0.0 bridge_mrp)
|
||||
- `x_fc_recipe_id` on `mrp.production` → links MO to recipe
|
||||
- `fusion.plating.job.node.override` → per-job opt-in/out decisions
|
||||
- `fp.recipe.config.wizard` → checklist wizard for planner
|
||||
- "Overrides" stat button on MO form
|
||||
- Located in `fusion_plating_bridge_mrp`
|
||||
|
||||
### All Gaps Resolved (2026-04-12/13)
|
||||
|
||||
| Gap | Resolution | Module |
|
||||
|-----|-----------|--------|
|
||||
| **Recipe → Work Orders** | `_generate_workorders_from_recipe()` — one WO per operation, steps become instructions | `fusion_plating_bridge_mrp` v2.1.0 |
|
||||
| **Account Hold Check** | `x_fc_account_hold` on res.partner, blocks SO/invoice/shipping for non-managers | `fusion_plating_invoicing` |
|
||||
| **Auto-Email Package** | `fp.notification.template` + `fp.notification.log` with hooks on SO confirm, receiving, invoice | `fusion_plating_notifications` |
|
||||
| **Quotation Configurator** | Part catalog, coating configs, pricing engine, 3D STL viewer, portal wizard | `fusion_plating_configurator` |
|
||||
| **Parts Receiving** | Receiving records, inspection, damage logging, SO auto-create, MRP soft gate | `fusion_plating_receiving` |
|
||||
| **Certificate Registry** | Unified fp.certificate with thickness readings, CoC/thickness/Nadcap types | `fusion_plating_certificates` |
|
||||
| **Local Delivery** | Forked fusion_tasks with GPS/maps, stripped of claims/sync, delivery-specific fields | `fusion_tasks` |
|
||||
|
||||
### Architectural Decisions Made
|
||||
1. **Recipe → WO**: One WO per `operation` node, child `step` nodes become numbered instructions in WO description
|
||||
2. **Account hold**: Manual flag on `res.partner` (auto from aging is roadmap)
|
||||
3. **Email triggers**: SO confirmed, parts received, invoice posted (configurable per trigger)
|
||||
4. **Configurator**: Custom build with formula-based pricing, estimator override, portal self-service wizard
|
||||
5. **Model naming**: New models use `fp.*` prefix, existing keep `fusion.plating.*`
|
||||
6. **Security groups**: Role-based (Estimator, Receiving, Accounting, Shop Manager) layered on existing privilege hierarchy (Operator→Supervisor→Manager→Admin)
|
||||
|
||||
### Key Models Quick Reference
|
||||
|
||||
| Model | Module | Purpose |
|
||||
|-------|--------|---------|
|
||||
| `fusion.plating.process.node` | `fusion_plating` | Recipe tree (template) |
|
||||
| `fusion.plating.process.node.input` | `fusion_plating` | Operator input definitions |
|
||||
| `fusion.plating.job.node.override` | `fusion_plating_bridge_mrp` | Per-job opt-in/out |
|
||||
| `fp.part.catalog` | `fusion_plating_configurator` | Customer part library (geometry, material) |
|
||||
| `fp.coating.config` | `fusion_plating_configurator` | Coating configuration templates |
|
||||
| `fp.treatment` | `fusion_plating_configurator` | Pre/post treatment steps |
|
||||
| `fp.pricing.rule` | `fusion_plating_configurator` | Formula-based pricing engine |
|
||||
| `fp.pricing.complexity.surcharge` | `fusion_plating_configurator` | Complexity surcharge lines |
|
||||
| `fp.quote.configurator` | `fusion_plating_configurator` | Configurator session + price calc |
|
||||
| `fp.receiving` | `fusion_plating_receiving` | Parts receiving record |
|
||||
| `fp.receiving.line` | `fusion_plating_receiving` | Per-part receiving detail |
|
||||
| `fp.receiving.damage` | `fusion_plating_receiving` | Damage log entry |
|
||||
| `fp.invoice.strategy.default` | `fusion_plating_invoicing` | Customer-level invoice strategy |
|
||||
| `fp.certificate` | `fusion_plating_certificates` | Certificate registry (CoC, thickness, etc.) |
|
||||
| `fp.thickness.reading` | `fusion_plating_certificates` | Fischerscope measurement data |
|
||||
| `fp.notification.template` | `fusion_plating_notifications` | Configurable email notification |
|
||||
| `fp.notification.log` | `fusion_plating_notifications` | Email audit trail |
|
||||
| `fusion.plating.quote.request` | `fusion_plating_portal` | Customer RFQ |
|
||||
| `fusion.plating.portal.job` | `fusion_plating_portal` | Portal-facing job tracker |
|
||||
| `fusion.plating.customer.spec` | `fusion_plating_quality` | Spec library |
|
||||
| `fusion.plating.quality.hold` | `fusion_plating_quality` | Parts on hold |
|
||||
| `fusion.plating.ncr` | `fusion_plating_quality` | Non-conformance reports |
|
||||
| `fusion.plating.capa` | `fusion_plating_quality` | Corrective actions |
|
||||
| `fusion.plating.batch` | `fusion_plating_batch` | Rack/barrel batch tracking |
|
||||
| `fusion.plating.kpi` | `fusion_plating_kpi` | KPI definition (OTD, yield, throughput, etc.) |
|
||||
| `fusion.plating.kpi.value` | `fusion_plating_kpi` | KPI daily value (auto-computed or manual) |
|
||||
| `fusion.plating.delivery` | `fusion_plating_logistics` | Delivery with chain of custody |
|
||||
| `fusion.plating.pickup.request` | `fusion_plating_logistics` | Customer pickup requests |
|
||||
| `fusion.plating.route` | `fusion_plating_logistics` | Driver routes with stops |
|
||||
| `fusion.technician.task` | `fusion_tasks` | Local delivery task (GPS, maps) |
|
||||
| `fusion.technician.location` | `fusion_tasks` | Driver GPS tracking |
|
||||
|
||||
## Repackaged Enterprise Modules
|
||||
See `K:\Github\RePackaged-Odoo\CLAUDE.md` for full details. Key points:
|
||||
- Odoo 19 enterprise modules repackaged for community edition
|
||||
- All OEEL-1 licenses changed to LGPL-3
|
||||
- Phone-home/telemetry gutted
|
||||
- `web_enterprise` and `mail_enterprise` are installed on odoo-entech
|
||||
- Addons path includes: `_dependencies`, `accounting`, `inventory_manufacturing`, `hr`, `sales`, `ai`, `fusion_backend`, `custom`, `website`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,854 @@
|
||||
# EN Tech Plating — End-to-End Workflow Design Spec
|
||||
|
||||
**Date:** 2026-04-12
|
||||
**Author:** Nexa Systems (Claude-assisted)
|
||||
**Client:** EN Technologies (Electroless Nickel Technologies Inc.)
|
||||
**Status:** Approved for implementation planning
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Complete end-to-end ERP workflow for an electroless nickel plating shop, replacing Steelhead Software. The system covers customer inquiry through invoicing, with a quotation configurator, parts receiving/inspection, flexible invoicing strategies, automated email notifications, certificate management, and local delivery dispatch.
|
||||
|
||||
### Model Naming Convention
|
||||
|
||||
| Context | Convention | Example |
|
||||
|---------|-----------|---------|
|
||||
| New custom models (all new modules) | `fp.*` prefix | `fp.part.catalog`, `fp.certificate` |
|
||||
| Existing custom models (already in codebase) | Keep `fusion.plating.*` | `fusion.plating.portal.job`, `fusion.plating.delivery` |
|
||||
| New fields on standard Odoo models | `x_fc_*` prefix | `x_fc_po_number` on `sale.order` |
|
||||
| Legacy fields (from Studio era) | `x_studio_*` | Preserved, not renamed |
|
||||
|
||||
The `fp.*` prefix is the new short-form convention for all models created in the new modules. Existing `fusion.plating.*` models are NOT renamed — they keep their current `_name`. All references in this spec use canonical `_name` values.
|
||||
|
||||
### Architecture: Approach 2 — Dedicated Modules per Sub-System
|
||||
|
||||
| Module | Purpose | Location |
|
||||
|--------|---------|----------|
|
||||
| `fusion_plating_configurator` | Quotation configurator, 3D viewer, pricing engine, part catalog | `fusion-plating/` |
|
||||
| `fusion_plating_receiving` | Parts receiving, inspection, damage logging, PO matching | `fusion-plating/` |
|
||||
| `fusion_plating_invoicing` | Invoice strategy engine (deposit/progress/net/COD), account holds | `fusion-plating/` |
|
||||
| `fusion_plating_notifications` | Auto-email engine, notification templates, audit log | `fusion-plating/` |
|
||||
| `fusion_plating_certificates` | Certificate registry (CoC, thickness, Nadcap, customer-specific) | `fusion-plating/` |
|
||||
| `fusion_tasks` | Local delivery dispatch (forked from Westin, stripped of claims) | `Entech Plating/` |
|
||||
|
||||
Plus updates to existing modules:
|
||||
- `fusion_plating_bridge_mrp` — Recipe-to-WO generation (lightweight; gates live in their own modules)
|
||||
- `fusion_plating_portal` — Customer-facing configurator UI, 3D preview
|
||||
- `fusion_plating_reports` — CoC template updates, thickness report template
|
||||
- `fusion_plating_quality` — Thickness reading model for Fischerscope data
|
||||
|
||||
---
|
||||
|
||||
## 2. Sales Integration — Fusion Plating as Single Hub
|
||||
|
||||
The Fusion Plating app becomes the single workspace. Users never leave it. Sale orders are managed with custom plating-specific views.
|
||||
|
||||
### Menu Structure
|
||||
|
||||
```
|
||||
Fusion Plating (app)
|
||||
├── Sales (default landing)
|
||||
│ ├── Quotations (sale.order, state=draft/sent)
|
||||
│ ├── Sale Orders (sale.order, state=sale)
|
||||
│ ├── Customers (res.partner, customer_rank > 0)
|
||||
│ └── Part Catalog (fp.part.catalog)
|
||||
├── Configurator
|
||||
│ ├── New Quote (fp.quote.configurator, persistent model)
|
||||
│ ├── Coating Configurations (fp.coating.config)
|
||||
│ └── Pricing Rules (fp.pricing.rule)
|
||||
├── Manufacturing
|
||||
│ ├── Manufacturing Orders
|
||||
│ ├── Work Orders
|
||||
│ └── Plant Overview
|
||||
├── Receiving & Inspection
|
||||
├── Shipping & Delivery
|
||||
│ ├── Deliveries (fp.delivery)
|
||||
│ ├── Local Delivery Tasks (fusion.delivery.task)
|
||||
│ └── Routes (fp.route)
|
||||
├── Certificates
|
||||
│ ├── All Certificates (fp.certificate)
|
||||
│ ├── Certificates of Conformance (filtered: type=coc)
|
||||
│ └── Thickness Reports (filtered: type=thickness_report)
|
||||
├── Quality
|
||||
├── Portal Jobs
|
||||
├── Reports
|
||||
└── Configuration
|
||||
```
|
||||
|
||||
### Sale Order Extensions (fields on `sale.order`)
|
||||
|
||||
- `x_fc_configurator_id` — link back to configurator session
|
||||
- `x_fc_part_catalog_id` — customer part being ordered
|
||||
- `x_fc_coating_config_id` — coating configuration
|
||||
- `x_fc_po_number` — customer PO reference (Char)
|
||||
- `x_fc_po_attachment_id` — uploaded PO document
|
||||
- `x_fc_po_received` — Boolean
|
||||
- `x_fc_po_override` — Boolean (manager override — proceed without PO)
|
||||
- `x_fc_po_override_reason` — Text
|
||||
- `x_fc_invoice_strategy` — Selection (deposit, progress, net_terms, cod_prepay)
|
||||
- `x_fc_deposit_percent` — Float
|
||||
- `x_fc_rush_order` — Boolean
|
||||
- `x_fc_delivery_method` — Selection (local_delivery, shipping_partner, customer_pickup)
|
||||
- `x_fc_receiving_status` — Selection (not_received, partial, received, inspected) — computed
|
||||
- Smart buttons: Portal Job, Manufacturing Order, Delivery, Receiving, Invoices, Certificates
|
||||
|
||||
### Custom Sale Order Views
|
||||
|
||||
- **List**: Customer, PO#, Part, Coating, Qty, Total, Receiving Status, Job Status, Delivery Method
|
||||
- **Form**: inherits sale.order form, adds plating tabs (Part Details, Coating Config, Receiving, Job Tracking)
|
||||
- **Kanban**: cards grouped by stage (Draft → Quoted → PO Received → Parts Received → In Production → Shipped → Invoiced)
|
||||
|
||||
### Permission-Based Visibility
|
||||
|
||||
The existing codebase defines a 4-level privilege hierarchy in `fusion_plating/security/fp_security.xml`: Operator → Supervisor → Manager → Administrator. These new groups are **role-based** and work **alongside** (not replacing) the existing privilege levels. A user has both a privilege level (what they can do: read/write/create/delete) and one or more roles (what they can see: which menus appear).
|
||||
|
||||
| Role Group | Menu Visibility | Required Privilege Level |
|
||||
|------------|----------------|------------------------|
|
||||
| `fp_group_estimator` | Sales, Configurator, Customers, Part Catalog | Supervisor+ |
|
||||
| `fp_group_shop_manager` | Everything (full menu) | Manager+ |
|
||||
| `fp_group_shop_floor` | Manufacturing, Work Orders, Plant Overview only | Operator+ |
|
||||
| `fp_group_receiving` | Receiving & Inspection, can view Sales (read-only) | Operator+ |
|
||||
| `fp_group_shipping` | Shipping & Delivery, can view Sales (read-only) | Operator+ |
|
||||
| `fp_group_quality` | Quality, can view Manufacturing | Supervisor+ |
|
||||
| `fp_group_accounting` | Sales (invoicing fields), Reports | Supervisor+ |
|
||||
|
||||
Users are assigned to one or more role groups. The existing privilege hierarchy controls CRUD permissions; role groups control menu/view visibility. `fp_group_shop_manager` implies all other role groups (full access).
|
||||
|
||||
Standard Odoo groups are still required for underlying model access (e.g. `sales_team.group_sale_salesman` for SO access).
|
||||
|
||||
---
|
||||
|
||||
## 3. `fusion_plating_configurator` — Quotation Configurator & Pricing Engine
|
||||
|
||||
### Users
|
||||
|
||||
- **Primary:** Internal estimator (full control, detailed configurator, price override)
|
||||
- **Secondary:** Portal customer (simplified self-service, estimated pricing, 3D preview, lead gen)
|
||||
|
||||
### Core Models
|
||||
|
||||
#### `fp.part.catalog` — Customer Part Library
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `partner_id` | Many2one res.partner | Customer (required) |
|
||||
| `name` | Char | Part name/description |
|
||||
| `part_number` | Char | Customer's part number |
|
||||
| `revision` | Char | Revision letter/number |
|
||||
| `substrate_material` | Selection | aluminium, steel, stainless, copper, titanium, other |
|
||||
| `geometry_source` | Selection | 3d_model, manual, pdf_drawing |
|
||||
| `model_attachment_id` | Many2one ir.attachment | STEP/STL/IGES file |
|
||||
| `drawing_attachment_ids` | Many2many ir.attachment | PDF drawings |
|
||||
| `surface_area` | Float | Surface area value |
|
||||
| `surface_area_uom` | Selection | sq_in, sq_ft, sq_cm, sq_m |
|
||||
| `weight` | Float | For shipping cost calc |
|
||||
| `dimensions_length` | Float | Manual measurement |
|
||||
| `dimensions_width` | Float | Manual measurement |
|
||||
| `dimensions_height` | Float | Manual measurement |
|
||||
| `complexity` | Selection | simple, moderate, complex, very_complex |
|
||||
| `masking_zones` | Integer | Number of areas requiring masking |
|
||||
| `masking_description` | Text | e.g. "mask threaded holes" |
|
||||
| `has_blind_holes` | Boolean | Complexity flag |
|
||||
| `has_recesses` | Boolean | Complexity flag |
|
||||
| `has_threads` | Boolean | Complexity flag |
|
||||
| `notes` | Html | |
|
||||
| `active` | Boolean | Archivable |
|
||||
|
||||
#### `fp.coating.config` — Coating Configuration Template
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | Char | e.g. "EN Mid-Phos AMS 2404" |
|
||||
| `process_type_id` | Many2one fusion.plating.process.type | Process type |
|
||||
| `recipe_id` | Many2one fusion.plating.process.node | Default recipe. **Domain: `[('node_type', '=', 'recipe')]`** |
|
||||
| `phosphorus_level` | Selection | low_phos, mid_phos, high_phos, na |
|
||||
| `thickness_min` | Float | Min thickness |
|
||||
| `thickness_max` | Float | Max thickness |
|
||||
| `thickness_uom` | Selection | mils, microns, inches |
|
||||
| `spec_reference` | Char | e.g. "AMS 2404" |
|
||||
| `certification_level` | Selection | commercial, mil_spec, nadcap, nuclear |
|
||||
| `pre_treatment_ids` | Many2many fp.treatment | Bead blast, zincate, etc. |
|
||||
| `post_treatment_ids` | Many2many fp.treatment | Bake, passivate, chromate, etc. |
|
||||
| `active` | Boolean | |
|
||||
|
||||
#### `fp.treatment` — Pre/Post Treatment
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | Char | e.g. "Bead Blast", "Zincate", "Bake" |
|
||||
| `treatment_type` | Selection | pre, post |
|
||||
| `default_duration_minutes` | Float | Estimated duration |
|
||||
| `currency_id` | Many2one res.currency | Company currency (default) |
|
||||
| `default_cost` | Monetary | Cost per application |
|
||||
|
||||
#### `fp.pricing.rule` — Formula-Based Pricing Engine
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | Char | Rule description |
|
||||
| `coating_config_id` | Many2one fp.coating.config | Optional filter (global if blank) |
|
||||
| `substrate_material` | Selection | Optional filter |
|
||||
| `certification_level` | Selection | Optional filter |
|
||||
| `pricing_method` | Selection | per_sqin, per_sqft, per_piece, flat_rate, formula |
|
||||
| `currency_id` | Many2one res.currency | Company currency (default) |
|
||||
| `base_rate` | Monetary | $ per unit |
|
||||
| `thickness_factor` | Float | Multiplier per mil of thickness |
|
||||
| `complexity_surcharge_ids` | One2many fp.pricing.complexity.surcharge | Surcharges by complexity level |
|
||||
| `masking_rate_per_zone` | Monetary | $ per masking area |
|
||||
| `setup_fee` | Monetary | One-time per batch |
|
||||
| `minimum_charge` | Monetary | Floor price |
|
||||
| `rush_surcharge_percent` | Float | Rush premium % |
|
||||
| `sequence` | Integer | Priority — first matching rule wins |
|
||||
| `active` | Boolean | |
|
||||
|
||||
#### `fp.pricing.complexity.surcharge` — Complexity-Based Surcharge Line
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `rule_id` | Many2one fp.pricing.rule | Parent rule (cascade) |
|
||||
| `complexity` | Selection | simple, moderate, complex, very_complex |
|
||||
| `surcharge_percent` | Float | Surcharge % for this complexity level |
|
||||
|
||||
#### `fp.quote.configurator` — The Configurator Session (Persistent Model)
|
||||
|
||||
This is a `models.Model` (NOT transient). Records persist for audit trail, re-quoting, and linking back from sale orders. The SO links back via `x_fc_configurator_id`.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | Char | Auto-sequence (CFG-00001) |
|
||||
| `state` | Selection | draft, confirmed, cancelled |
|
||||
| `partner_id` | Many2one res.partner | Customer |
|
||||
| `part_catalog_id` | Many2one fp.part.catalog | For repeat parts |
|
||||
| `coating_config_id` | Many2one fp.coating.config | Coating selection |
|
||||
| `quantity` | Integer | Number of parts |
|
||||
| `batch_size` | Integer | Parts per rack/barrel |
|
||||
| `surface_area` | Float | From catalog or entered |
|
||||
| `thickness_requested` | Float | |
|
||||
| `masking_zones` | Integer | |
|
||||
| `complexity` | Selection | simple, moderate, complex, very_complex |
|
||||
| `rush_order` | Boolean | |
|
||||
| `turnaround_days` | Integer | |
|
||||
| `delivery_method` | Selection | local_delivery, shipping_partner, customer_pickup |
|
||||
| `currency_id` | Many2one res.currency | Company currency (default) |
|
||||
| `shipping_fee` | Monetary | |
|
||||
| `delivery_fee` | Monetary | |
|
||||
| `calculated_price` | Monetary | Computed from pricing rules |
|
||||
| `price_breakdown_html` | Html | Rendered breakdown |
|
||||
| `estimator_override_price` | Monetary | Final price (defaults to calculated) |
|
||||
| `sale_order_id` | Many2one sale.order | Created SO (set on "Create Quotation") |
|
||||
| `notes` | Text | |
|
||||
|
||||
**Lifecycle:** draft → (estimator builds quote) → confirmed (when SO created) → cancelled (if abandoned). Confirmed records are read-only. Re-quoting creates a new configurator record.
|
||||
|
||||
### Price Calculation Flow
|
||||
|
||||
```
|
||||
Part Catalog (surface area, complexity, masking)
|
||||
+ Coating Config (process, thickness, spec level)
|
||||
+ Pricing Rules (matched by coating + substrate + cert level)
|
||||
+ Quantity / Batch Size
|
||||
+ Rush surcharge (if applicable)
|
||||
+ Delivery / Shipping fees
|
||||
= Calculated Price
|
||||
→ Estimator reviews & overrides if needed
|
||||
→ Final quote price
|
||||
```
|
||||
|
||||
### 3D Viewer
|
||||
|
||||
- OWL component using **Three.js** for STL rendering and **OCCT (OpenCascade) WASM** for STEP parsing
|
||||
- Renders on both backend configurator form and portal page
|
||||
- Features: wireframe/solid toggle, rotate/zoom, surface area highlight
|
||||
- Server-side surface area calculation: Python `trimesh` (STL) / `cadquery`/`OCP` (STEP)
|
||||
- Fallback: manual measurements if server can't parse the file
|
||||
- Roadmap: Claude Vision for PDF drawing measurement extraction
|
||||
|
||||
### Portal Side (Customer-Facing)
|
||||
|
||||
- Simplified wizard: upload part → select coating type → see estimated price range
|
||||
- Uses same `fp.quote.configurator` model with restricted fields
|
||||
- Customer sees estimated price range (not exact), 3D preview
|
||||
- Submits → creates `fp.quote.request` with configurator data attached
|
||||
- Internal estimator sees customer's config and refines it
|
||||
|
||||
### Configurator → Sale Order Flow
|
||||
|
||||
1. Estimator opens Configurator → builds quote
|
||||
2. Clicks "Create Quotation" → sale.order created with all x_fc_* fields
|
||||
3. SO line(s) auto-created (product = service product per coating type, qty, price = estimator's final price)
|
||||
4. Estimator reviews SO → sends quotation
|
||||
5. Customer accepts → confirms SO → triggers downstream flow
|
||||
|
||||
---
|
||||
|
||||
## 4. `fusion_plating_receiving` — Parts Receiving & Inspection
|
||||
|
||||
### Core Models
|
||||
|
||||
#### `fp.receiving` — Receiving Record
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | Char | Auto-sequence (RCV-00001) |
|
||||
| `sale_order_id` | Many2one sale.order | Required. **Design decision:** one receiving record per SO. If a customer ships parts for multiple SOs in one box, create separate receiving records per SO (receiver splits the count). This keeps the SO↔receiving link clean and avoids Many2many complexity. |
|
||||
| `partner_id` | Many2one res.partner | Related from SO |
|
||||
| `po_number` | Char | Related from SO x_fc_po_number |
|
||||
| `received_by_id` | Many2one res.users | Who logged it |
|
||||
| `received_date` | Datetime | Default=now |
|
||||
| `state` | Selection | draft, inspecting, accepted, discrepancy, resolved |
|
||||
| `expected_qty` | Integer | From SO line |
|
||||
| `received_qty` | Integer | Entered by receiver |
|
||||
| `qty_match` | Boolean | Computed: received == expected |
|
||||
| `carrier_name` | Char | Who delivered |
|
||||
| `carrier_tracking` | Char | Inbound tracking # |
|
||||
| `notes` | Html | |
|
||||
| `line_ids` | One2many fp.receiving.line | Per-part detail |
|
||||
| `damage_ids` | One2many fp.receiving.damage | Damage log |
|
||||
| `attachment_ids` | Many2many ir.attachment | Photos |
|
||||
|
||||
#### `fp.receiving.line` — Per-Part Receiving Detail
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `receiving_id` | Many2one fp.receiving | Cascade |
|
||||
| `part_catalog_id` | Many2one fp.part.catalog | Optional |
|
||||
| `part_number` | Char | |
|
||||
| `description` | Char | |
|
||||
| `expected_qty` | Integer | |
|
||||
| `received_qty` | Integer | |
|
||||
| `condition` | Selection | good, damaged, mixed |
|
||||
| `notes` | Text | |
|
||||
|
||||
#### `fp.receiving.damage` — Damage Log Entry
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `receiving_id` | Many2one fp.receiving | Cascade |
|
||||
| `description` | Text | What's damaged |
|
||||
| `severity` | Selection | cosmetic, functional, rejected |
|
||||
| `photo_ids` | Many2many ir.attachment | |
|
||||
| `action_required` | Selection | none, notify_customer, return_parts, proceed_as_is |
|
||||
| `customer_notified` | Boolean | |
|
||||
| `customer_response` | Text | |
|
||||
| `resolved` | Boolean | |
|
||||
|
||||
### Workflow
|
||||
|
||||
```
|
||||
SO Confirmed → Receiving record auto-created (state=draft)
|
||||
→ Parts arrive → receiver enters qty, inspects condition
|
||||
→ Match + good → state=accepted
|
||||
→ Mismatch or damage → state=discrepancy
|
||||
→ SO flagged, follow-up activity created
|
||||
→ Customer contacted → resolution logged → state=resolved
|
||||
→ Accepted/Resolved → SO x_fc_receiving_status = 'received'
|
||||
→ Manufacturing can proceed
|
||||
```
|
||||
|
||||
### Manufacturing Gate
|
||||
|
||||
- `mrp.production.action_confirm()` checks `sale_order.x_fc_receiving_status`
|
||||
- If not received → warning dialog (manager can override)
|
||||
- Soft gate — warns, doesn't hard-block (flexibility for handshake deals, urgent jobs)
|
||||
|
||||
---
|
||||
|
||||
## 5. `fusion_plating_invoicing` — Invoice Strategy Engine
|
||||
|
||||
### Invoice Strategies
|
||||
|
||||
| Strategy | Behaviour |
|
||||
|----------|-----------|
|
||||
| `deposit` | Deposit invoice for X% on SO confirmation. Balance after shipping. |
|
||||
| `progress` | Invoice per MO as each completes. |
|
||||
| `net_terms` | Single invoice after shipping. Payment on terms. |
|
||||
| `cod_prepay` | Full invoice on SO confirmation. Manufacturing blocked until paid. |
|
||||
|
||||
### Core Models
|
||||
|
||||
#### `fp.invoice.strategy.default` — Customer-Level Default
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `partner_id` | Many2one res.partner | Unique per customer |
|
||||
| `default_strategy` | Selection | deposit, progress, net_terms, cod_prepay |
|
||||
| `default_deposit_percent` | Float | e.g. 50.0 |
|
||||
| `payment_term_id` | Many2one account.payment.term | |
|
||||
| `notes` | Text | |
|
||||
|
||||
### Auto-Population Rules
|
||||
|
||||
When a customer is selected on a new SO:
|
||||
1. Look up `fp.invoice.strategy.default` for that `partner_id`
|
||||
2. If found → auto-fill `x_fc_invoice_strategy` and `x_fc_deposit_percent` from the default
|
||||
3. If not found → leave blank (estimator must select manually)
|
||||
4. Estimator can always override per order
|
||||
|
||||
When a coating config is selected in the configurator:
|
||||
1. Auto-fill `thickness_requested` from `coating_config.thickness_min` (default to minimum)
|
||||
2. Auto-fill surface area UOM from company default setting
|
||||
|
||||
When a part catalog entry is selected:
|
||||
1. Auto-fill `surface_area`, `complexity`, `masking_zones`, `substrate_material` from the catalog entry
|
||||
2. These can be overridden per-quote if the part has changed
|
||||
|
||||
### Account Hold (extends `res.partner`)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `x_fc_account_hold` | Boolean | Manually set by accounting |
|
||||
| `x_fc_account_hold_reason` | Text | Why hold was placed |
|
||||
| `x_fc_account_hold_date` | Datetime | When placed (Datetime for audit precision) |
|
||||
| `x_fc_account_hold_by_id` | Many2one res.users | Who placed it |
|
||||
|
||||
### Account Hold Behaviour
|
||||
|
||||
| Action | Hold Active | Result |
|
||||
|--------|-------------|--------|
|
||||
| Create new SO | Yes | Warning banner. SO can still be created. |
|
||||
| Confirm SO | Yes | Blocked. Manager override available. |
|
||||
| Create invoice | Yes | Blocked. Manager override available. |
|
||||
| Ship / mark delivered | Yes | Blocked. Manager override available. |
|
||||
| Customer visits portal | Yes | No visible indication. |
|
||||
|
||||
Roadmap: auto-hold computed from account.move aging.
|
||||
|
||||
### Invoice Automation
|
||||
|
||||
- **Deposit:** SO confirmed → auto-create deposit invoice (X%) → balance invoice after shipping
|
||||
- **Progress:** Each MO done → invoice for that MO's portion → final invoice for remaining balance
|
||||
- **Net terms:** Delivery complete → auto-create full invoice → payment terms applied
|
||||
- **COD/Prepay:** SO confirmed → auto-create full invoice → MO blocked until payment reconciled
|
||||
|
||||
### Shipping Method Price Adjustment
|
||||
|
||||
- Method changes after invoicing:
|
||||
- Draft invoice → amend the line
|
||||
- Posted invoice → supplementary invoice or credit note
|
||||
|
||||
---
|
||||
|
||||
## 6. `fusion_plating_notifications` — Auto-Email Engine
|
||||
|
||||
### Notification Triggers
|
||||
|
||||
| Trigger Event | Email Name | Attachments | Recipient |
|
||||
|---------------|-----------|-------------|-----------|
|
||||
| Quotation sent | Quote Ready | Quote PDF | Customer contact |
|
||||
| SO confirmed | Order Confirmation | SO PDF | Customer contact |
|
||||
| Parts received | Parts Received | — | Customer contact |
|
||||
| MO complete | Ready for Pickup/Ship | — | Customer contact |
|
||||
| Delivery shipped (carrier) | Shipment Notification | CoC, Thickness Report, Invoice | Customer contact |
|
||||
| Delivery completed (local) | Delivery Confirmation | CoC, Thickness Report, Invoice, POD | Customer contact |
|
||||
| Invoice posted | Invoice Notification | Invoice PDF | Billing contact |
|
||||
| Deposit invoice created | Deposit Required | Deposit Invoice PDF | Billing contact |
|
||||
|
||||
### Core Models
|
||||
|
||||
#### `fp.notification.template` — Configurable Email Templates
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | Char | Template name |
|
||||
| `trigger_event` | Selection | Event type |
|
||||
| `mail_template_id` | Many2one mail.template | Actual Odoo template |
|
||||
| `active` | Boolean | Can disable specific notifications |
|
||||
| `attach_coc` | Boolean | |
|
||||
| `attach_thickness_report` | Boolean | |
|
||||
| `attach_invoice` | Boolean | |
|
||||
| `attach_packing_list` | Boolean | |
|
||||
| `attach_pod` | Boolean | |
|
||||
| `cc_internal_ids` | Many2many res.users | Internal CCs |
|
||||
|
||||
#### `fp.notification.log` — Audit Trail
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `template_id` | Many2one fp.notification.template | |
|
||||
| `trigger_event` | Selection | |
|
||||
| `sale_order_id` | Many2one sale.order | |
|
||||
| `partner_id` | Many2one res.partner | |
|
||||
| `sent_date` | Datetime | |
|
||||
| `recipient_email` | Char | |
|
||||
| `attachment_names` | Text | Comma-separated list |
|
||||
| `status` | Selection | sent, failed, bounced |
|
||||
| `error_message` | Text | |
|
||||
| `mail_mail_id` | Many2one mail.mail | |
|
||||
|
||||
### Document Assembly (Shipment Email)
|
||||
|
||||
1. Find portal job linked to SO/MO
|
||||
2. Generate CoC PDF (bilingual EN/FR, customer logo, Nadcap badge if applicable)
|
||||
3. Attach thickness report if available
|
||||
4. Attach invoice PDF
|
||||
5. Include tracking info in email body (carrier tracking # or driver ETA)
|
||||
6. Send to customer contact
|
||||
7. Log in fp.notification.log
|
||||
|
||||
### CoC Report Updates
|
||||
|
||||
- Customer logo placement (from partner.image_1920)
|
||||
- Nadcap badge (conditional)
|
||||
- EN Tech branding (replace Steelhead)
|
||||
- Recorded thickness field
|
||||
- Process description with spec references
|
||||
- Bilingual certification statement
|
||||
- Quantities: Shipped/Exp, NC Qty columns
|
||||
- Configurable certifying authority signature
|
||||
|
||||
### Thickness / Measurement Report (NEW template)
|
||||
|
||||
Based on Fischerscope XDAL 600 output:
|
||||
- EN Tech header
|
||||
- Equipment info (model, product, application)
|
||||
- Microscope image (attached photo)
|
||||
- Reading data table (NiP mils, Ni %, P %)
|
||||
- Statistical summary (Mean, Std Dev, CoV%, Range)
|
||||
- Calibration standard reference
|
||||
- Operator, date/time
|
||||
- Data entry: manual for now, future Fischerscope CSV import
|
||||
|
||||
### Work Centre Mapping Note
|
||||
|
||||
The codebase has two work centre models: `fusion.plating.work.center` (core) and `mrp.workcenter` (standard MRP). Recipe nodes reference `fusion.plating.work.center`; MRP work orders use `mrp.workcenter`. The recipe-to-WO generation logic in `fusion_plating_bridge_mrp` must map between them. Each `fusion.plating.work.center` should have an `x_fc_mrp_workcenter_id` field linking to the corresponding `mrp.workcenter`. This mapping field should be added to the core module.
|
||||
|
||||
---
|
||||
|
||||
## 7. `fusion_plating_certificates` — Certificate Registry
|
||||
|
||||
**Module owner:** `fusion_plating_certificates` (NEW dedicated module).
|
||||
**Dependencies:** `fusion_plating`, `fusion_plating_portal`, `fusion_plating_reports`, `mrp`
|
||||
|
||||
This module owns `fp.certificate` and `fp.thickness.reading`. It depends on `fusion_plating_portal` for the `fusion.plating.portal.job` link and on `fusion_plating_reports` for report generation.
|
||||
|
||||
### Model: `fp.certificate`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | Char | Auto-sequence (CERT-00001) |
|
||||
| `certificate_type` | Selection | coc, thickness_report, mill_test, nadcap_cert, customer_specific |
|
||||
| `partner_id` | Many2one res.partner | Customer |
|
||||
| `sale_order_id` | Many2one sale.order | |
|
||||
| `production_id` | Many2one mrp.production | |
|
||||
| `portal_job_id` | Many2one fusion.plating.portal.job | Uses canonical model name |
|
||||
| `part_number` | Char | Denormalized for fast search |
|
||||
| `process_description` | Char | e.g. "ELECTROLESS NICKEL PLATING PER AMS 2404" |
|
||||
| `spec_reference` | Char | |
|
||||
| `po_number` | Char | Customer PO ref |
|
||||
| `entech_wo_number` | Char | Internal WO # |
|
||||
| `quantity_shipped` | Integer | |
|
||||
| `issued_by_id` | Many2one res.users | |
|
||||
| `certified_by_id` | Many2one res.users | Signing authority |
|
||||
| `issue_date` | Date | Default=today |
|
||||
| `attachment_id` | Many2one ir.attachment | Generated PDF |
|
||||
| `thickness_reading_ids` | One2many fp.thickness.reading | Linked measurements |
|
||||
| `state` | Selection | draft, issued, voided |
|
||||
| `void_reason` | Text | |
|
||||
| `notes` | Html | |
|
||||
|
||||
### Model: `fp.thickness.reading` — Fischerscope Measurement Data
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `certificate_id` | Many2one fp.certificate | Parent certificate (cascade) |
|
||||
| `production_id` | Many2one mrp.production | Link to MO (independent of cert) |
|
||||
| `reading_number` | Integer | Reading sequence (n=1, n=2, n=3) |
|
||||
| `nip_mils` | Float(10,4) | NiP thickness in mils |
|
||||
| `ni_percent` | Float(6,3) | Nickel content % |
|
||||
| `p_percent` | Float(6,4) | Phosphorus content % |
|
||||
| `position_label` | Char | Where on the part this reading was taken |
|
||||
| `equipment_model` | Char | e.g. "Fischerscope XDAL 600" |
|
||||
| `product_ref` | Char | e.g. "2805031 / NiP/Al-alloys 2805030" |
|
||||
| `calibration_std_ref` | Char | e.g. "NiP/Al STD SET SN 100174568" |
|
||||
| `microscope_image_id` | Many2one ir.attachment | Microscope photo |
|
||||
| `operator_id` | Many2one res.users | Who took the reading |
|
||||
| `reading_datetime` | Datetime | When reading was taken |
|
||||
| `measuring_time_seconds` | Integer | e.g. 120 |
|
||||
|
||||
**Statistical fields** (computed from reading lines per certificate):
|
||||
- `mean_nip_mils`, `stddev_nip_mils`, `cov_percent`, `range_nip_mils` — computed on `fp.certificate` from its `thickness_reading_ids`
|
||||
|
||||
### Auto-Creation
|
||||
|
||||
When CoC or thickness report is generated, `fp.certificate` record auto-created with PDF attached.
|
||||
|
||||
### Views
|
||||
|
||||
- **List** (default, newest first): Issue Date, Cert #, Type, Customer, Part #, PO #, Entech WO#, Process, Qty, Issued By, Status
|
||||
- **Search**: Quick filters by Customer, Certificate Type, Date Range, Part Number, PO Number. Group by: Customer, Type, Month, Issued By.
|
||||
- **Form**: Certificate type badge, state buttons (Issue/Void), customer info, part details, tabs for Thickness Readings, Attachments, Notes. "Regenerate PDF" and "Send to Customer" buttons.
|
||||
|
||||
### CoC Bilingual Implementation
|
||||
|
||||
The bilingual EN/FR certification statement uses QWeb template logic with `t-if` on a `bilingual` flag (default: True for Canadian compliance). The English and French text blocks are both rendered in the same template — not using Odoo's `ir.translation` system, since both languages must appear on the same document simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## 8. `fusion_tasks` (Entech Plating) — Local Delivery Dispatch
|
||||
|
||||
### Fork & Strip Strategy
|
||||
|
||||
**Remove:**
|
||||
- Cross-instance sync (fusion.task.sync.config, shadow tasks)
|
||||
- `fusion_claims.*` config parameters → rename to `fusion_tasks.*`
|
||||
- `sales_team` dependency
|
||||
- Irrelevant task types (repair, troubleshoot, assessment, ltc_visit, maintenance, installation)
|
||||
- All sync-related fields (x_fc_sync_*)
|
||||
|
||||
**Keep:**
|
||||
- Google Maps integration (Leaflet.js map view)
|
||||
- GPS tracking (fusion.technician.location → fusion.driver.location)
|
||||
- Geocoding (_geocode_address())
|
||||
- Route planning / scheduling / conflict avoidance
|
||||
- Push notifications (fusion.push.subscription)
|
||||
- Map view JS/SCSS/XML
|
||||
|
||||
### Adapted Model: `fusion.delivery.task`
|
||||
|
||||
Renamed from `fusion.technician.task`.
|
||||
|
||||
**Task Types** (reduced):
|
||||
- `delivery` — outbound delivery
|
||||
- `pickup` — collect parts from customer
|
||||
- `return` — return rejected/damaged parts
|
||||
- `rush` — same-day urgent
|
||||
|
||||
**Status Workflow:**
|
||||
- `pending` → `scheduled` → `en_route` → `delivered` (or `failed`)
|
||||
|
||||
**Key Fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `delivery_id` | Many2one fusion.plating.delivery | Links to logistics |
|
||||
| `sale_order_id` | Many2one sale.order | |
|
||||
| `portal_job_id` | Many2one fusion.plating.portal.job | Uses canonical model name |
|
||||
| `partner_id` | Many2one res.partner | Customer |
|
||||
| `driver_id` | Many2one hr.employee | Renamed from technician_id |
|
||||
| `vehicle_id` | Many2one fusion.plating.vehicle | From logistics module |
|
||||
| `packages_count` | Integer | Number of boxes/crates |
|
||||
| `weight_total` | Float | Total weight |
|
||||
| `requires_signature` | Boolean | POD required |
|
||||
| `requires_photo` | Boolean | Photo proof required |
|
||||
| `coc_attachment_id` | Many2one ir.attachment | CoC to hand to customer |
|
||||
|
||||
**Delivery Integration:**
|
||||
- `action_mark_delivered()`: logs GPS + timestamp, captures signature/photo, updates fp.delivery → delivered, cascades to portal job → shipped, triggers shipment notification email
|
||||
- `action_mark_failed()`: logs reason, creates follow-up activity
|
||||
|
||||
### Dependencies & Deployment
|
||||
|
||||
```python
|
||||
'depends': ['base', 'mail', 'hr', 'fusion_plating_logistics'],
|
||||
```
|
||||
|
||||
**Deployment strategy:** `fusion_tasks` lives in a separate repo (`Entech Plating/`) but is deployed to the SAME server as the fusion_plating modules. Both repos are copied to `/mnt/extra-addons/custom/` on the target server. The `fusion_plating_logistics` dependency is therefore always available at install time. There is no standalone driver-only install scenario — drivers access the system via the same Odoo instance. The standalone "Delivery Dispatch" menu (below) provides a driver-focused view without needing a separate deployment.
|
||||
|
||||
### Menu Placement
|
||||
|
||||
Inside Fusion Plating: Shipping & Delivery → Local Delivery Tasks, Driver Map
|
||||
|
||||
Standalone (optional): Delivery Dispatch app for drivers.
|
||||
|
||||
---
|
||||
|
||||
## 9. Complete End-to-End Workflow
|
||||
|
||||
### Stage 1: Customer Inquiry
|
||||
|
||||
- **Portal path:** Customer uploads 3D/PDF on portal → sees estimated price → submits → fp.quote.request created
|
||||
- **Email/phone path:** Estimator creates fp.quote.request manually, uploads customer's files
|
||||
- **Modules:** fusion_plating_portal, fusion_plating_configurator
|
||||
|
||||
### Stage 2: Quotation
|
||||
|
||||
- Estimator opens Configurator inside Fusion Plating app
|
||||
- Selects/creates part in Part Catalog
|
||||
- 3D model → auto surface area + 3D preview; PDF → manual measurements
|
||||
- Selects Coating Configuration
|
||||
- Pricing engine calculates from rules; estimator reviews/overrides
|
||||
- Adds delivery/shipping fees, sets invoice strategy
|
||||
- "Create Quotation" → sale.order with all x_fc_* fields
|
||||
- Sends quotation → email notification with Quote PDF
|
||||
- **Modules:** fusion_plating_configurator, fusion_plating_notifications
|
||||
|
||||
### Stage 3: Order Confirmation
|
||||
|
||||
- Customer accepts + submits PO (email or portal)
|
||||
- PO number entered on SO, PO document uploaded (or manager override for handshake deals)
|
||||
- SO confirmed:
|
||||
- Account hold check → blocked if hold active (manager override)
|
||||
- Invoice strategy fires (deposit/COD → auto-invoice; net_terms/progress → no invoice yet)
|
||||
- Email: Order Confirmation + SO PDF
|
||||
- Receiving record auto-created (draft)
|
||||
- Portal job auto-created (received)
|
||||
- **Modules:** fusion_plating_invoicing, fusion_plating_receiving, fusion_plating_bridge_mrp, fusion_plating_notifications
|
||||
|
||||
### Stage 4: Parts Receiving
|
||||
|
||||
- Parts arrive → receiver opens receiving record
|
||||
- Counts parts, inspects condition
|
||||
- Good → accepted; damage/mismatch → discrepancy → follow-up → resolved
|
||||
- Email: Parts Received
|
||||
- Portal job → in_progress
|
||||
- **Modules:** fusion_plating_receiving, fusion_plating_notifications
|
||||
|
||||
### Stage 5: Manufacturing Planning
|
||||
|
||||
- MO created from SO (standard sale_mrp)
|
||||
- Receiving gate: warns if parts not received
|
||||
- COD gate: warns if prepay not paid
|
||||
- Planner assigns recipe, configures opt-in/out steps
|
||||
- Recipe → Work Orders generated (one WO per operation node, steps = WO instructions)
|
||||
- **Modules:** fusion_plating_bridge_mrp
|
||||
|
||||
### Stage 6: Manufacturing Execution
|
||||
|
||||
- Operators work WOs on shopfloor (Plant Overview kanban, timers, bath/tank assignment)
|
||||
- Quality holds if needed
|
||||
- All WOs done → MO done:
|
||||
- Portal job → ready_to_ship
|
||||
- fp.delivery auto-created (draft)
|
||||
- Thickness readings entered
|
||||
- CoC + thickness report generated → fp.certificate records created
|
||||
- Progress invoicing: if strategy=progress, invoice this MO's portion
|
||||
- **Modules:** fusion_plating_shopfloor, fusion_plating_bridge_mrp, fusion_plating_quality, fusion_plating_invoicing
|
||||
|
||||
### Stage 7: Shipping / Local Delivery
|
||||
|
||||
**Shipping Partner (Purolator, FedEx, UPS, etc.):**
|
||||
- fp.delivery scheduled with carrier + tracking #
|
||||
- Packing list generated, delivery marked shipped
|
||||
- Module: fusion_plating_logistics
|
||||
|
||||
**Local Delivery (EN Tech driver):**
|
||||
- fusion.delivery.task created, driver + vehicle assigned
|
||||
- Driver Map shows live GPS tracking
|
||||
- Driver delivers → signature/photo POD → cascades to fp.delivery
|
||||
- Module: fusion_tasks (Entech Plating)
|
||||
|
||||
**Customer Pickup:**
|
||||
- Email: Ready for Pickup
|
||||
- Customer arrives → parts released → signature → fp.delivery marked delivered
|
||||
|
||||
**All paths:**
|
||||
- Portal job → shipped
|
||||
- Email: CoC + Thickness Report + Invoice + Tracking/ETA
|
||||
- **Modules:** fusion_plating_logistics, fusion_tasks, fusion_plating_notifications
|
||||
|
||||
### Stage 8: Invoicing & Payment
|
||||
|
||||
- Strategy determines timing:
|
||||
- deposit → balance invoice after shipping
|
||||
- progress → final invoice for remaining balance
|
||||
- net_terms → full invoice after shipping
|
||||
- cod_prepay → already invoiced & paid
|
||||
- Delivery method change after invoice → supplementary invoice or credit note
|
||||
- Invoice posted → portal job → complete → email with Invoice PDF
|
||||
- **Modules:** fusion_plating_invoicing, fusion_plating_bridge_mrp, fusion_plating_notifications
|
||||
|
||||
### Stage 9: Customer Portal
|
||||
|
||||
- Full job lifecycle visible: progress bar (received → complete)
|
||||
- Documents tab: CoC, thickness report, invoice — downloadable
|
||||
- Part catalog: saved parts with 3D preview
|
||||
- Order history: past orders, re-order from catalog
|
||||
- Quote request history, tracking info, notification history
|
||||
- **Modules:** fusion_plating_portal
|
||||
|
||||
---
|
||||
|
||||
## 10. Module Dependency Graph
|
||||
|
||||
```
|
||||
fusion_plating (core)
|
||||
├── fusion_plating_configurator
|
||||
│ └── depends: fusion_plating, sale_management
|
||||
├── fusion_plating_receiving
|
||||
│ └── depends: fusion_plating, sale_management
|
||||
│ └── provides: mrp.production gate mixin (overrides action_confirm)
|
||||
├── fusion_plating_invoicing
|
||||
│ └── depends: fusion_plating, sale_management, account
|
||||
│ └── provides: invoice strategy automation, account hold on res.partner
|
||||
├── fusion_plating_notifications
|
||||
│ └── depends: fusion_plating, fusion_plating_reports, mail
|
||||
├── fusion_plating_certificates
|
||||
│ └── depends: fusion_plating, fusion_plating_portal,
|
||||
│ fusion_plating_reports, mrp
|
||||
├── fusion_plating_bridge_mrp (lighter — gates live in receiving/invoicing)
|
||||
│ └── depends: fusion_plating, fusion_plating_configurator, mrp
|
||||
│ └── soft-depends: fusion_plating_receiving, fusion_plating_invoicing
|
||||
├── fusion_plating_portal
|
||||
│ └── depends: fusion_plating, fusion_plating_configurator,
|
||||
│ fusion_plating_notifications, portal
|
||||
├── fusion_plating_logistics
|
||||
│ └── depends: fusion_plating
|
||||
└── fusion_tasks (Entech Plating — separate repo, same server)
|
||||
└── depends: fusion_plating_logistics, hr, mail
|
||||
```
|
||||
|
||||
**Note on bridge_mrp:** The receiving gate and invoice strategy gates are implemented as lightweight mixins within `fusion_plating_receiving` and `fusion_plating_invoicing` respectively (each overrides `mrp.production` independently). This avoids funnelling all dependencies through bridge_mrp. Bridge_mrp focuses on recipe-to-WO generation and the configurator link.
|
||||
|
||||
---
|
||||
|
||||
## 11. Pricing Variables Reference
|
||||
|
||||
All 10 pricing variables that drive the configurator:
|
||||
|
||||
1. **Surface area** — more area = more chemistry consumed
|
||||
2. **Coating type** — EN, chrome, anodize, black oxide (different bath costs)
|
||||
3. **Thickness spec** — more passes/dwell time
|
||||
4. **Substrate material** — aluminium needs zincate pre-treatment
|
||||
5. **Quantity / batch size** — more parts per rack = lower per-unit cost
|
||||
6. **Part complexity** — blind holes, recesses, masking areas
|
||||
7. **Masking requirements** — labour-intensive
|
||||
8. **Spec / certification level** — Nadcap/aerospace = more QC overhead
|
||||
9. **Turnaround time** — rush = premium
|
||||
10. **Pre/post treatment** — bead blast, bake, passivate
|
||||
|
||||
---
|
||||
|
||||
## 12. Key Architectural Decisions
|
||||
|
||||
| Decision | Resolution |
|
||||
|----------|------------|
|
||||
| Configurator primary user | Internal estimator; portal is simplified lead-gen |
|
||||
| 3D file handling | STEP/STL auto surface area calc + 3D preview; PDF manual (Claude Vision roadmap) |
|
||||
| Pricing model | Formula-calculated with estimator override |
|
||||
| Part catalog | Customer part library for repeat business + one-off support |
|
||||
| PO requirement | Required before manufacturing, but manager override available |
|
||||
| Invoice strategies | All 4 supported (deposit, progress, net_terms, cod_prepay), configurable per order |
|
||||
| Account hold | Manual for now, auto from aging on roadmap |
|
||||
| Shipping decision | Set at quote time, changeable later with price adjustment |
|
||||
| Local delivery | Fork fusion_tasks, strip claims, keep GPS/maps |
|
||||
| Certificate management | Unified fp.certificate registry with filters, auto-creation on report generation |
|
||||
| Recipe → WO mapping | One WO per operation node, steps become WO instructions |
|
||||
|
||||
---
|
||||
|
||||
## 13. Data Migration: Existing Quote Request Flow
|
||||
|
||||
The existing `fusion.plating.quote.request` model has an `action_create_sale_order()` method that creates basic SOs. The new configurator introduces a parallel, richer path.
|
||||
|
||||
**Coexistence strategy:**
|
||||
- The existing `action_create_sale_order()` on `fusion.plating.quote.request` remains functional — it is the "quick path" for simple quotes that don't need the full configurator
|
||||
- The new configurator is the "full path" for detailed quotes with part catalog, coating config, and pricing rules
|
||||
- When a quote request comes in via portal, the estimator chooses: use the configurator (creates `fp.quote.configurator` → SO) or use the quick path (existing `action_create_sale_order()`)
|
||||
- Both paths create SOs with `x_fc_*` fields. The quick path leaves configurator-specific fields blank; the full path populates everything
|
||||
- No existing data needs migration — the two paths coexist
|
||||
|
||||
---
|
||||
|
||||
## 14. Roadmap Items (Not in Initial Build)
|
||||
|
||||
- Claude Vision for PDF drawing measurement extraction
|
||||
- Auto account hold computed from invoice aging
|
||||
- Fischerscope CSV import (auto-populate thickness readings)
|
||||
- Multi-driver route optimization
|
||||
- Customer-specific certificate templates
|
||||
- Product configurator on portal (dynamic pricing preview)
|
||||
- Tags on recipe nodes
|
||||
- Dashboard transitions on recipe nodes
|
||||
- Treatment groups / choices on recipe nodes
|
||||
32
fusion_plating/fusion_plating/__init__.py
Normal file
32
fusion_plating/fusion_plating/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def post_init_hook(env):
|
||||
"""Auto-detect a sensible default timezone on first install.
|
||||
|
||||
Sets ``res.company.x_fc_default_tz`` to the admin user's timezone
|
||||
(Odoo populates that from the browser on first login), falling back
|
||||
to the host server's timezone, then to ``America/Toronto`` as a
|
||||
last resort. Only writes when the field is still empty so re-installs
|
||||
never clobber a user's choice.
|
||||
"""
|
||||
from .models.fp_tz import detect_default_tz
|
||||
|
||||
detected = detect_default_tz(env)
|
||||
for company in env['res.company'].sudo().search([]):
|
||||
if not company.x_fc_default_tz:
|
||||
company.x_fc_default_tz = detected
|
||||
_logger.info(
|
||||
'Fusion Plating: set default timezone for company %s -> %s',
|
||||
company.name, detected,
|
||||
)
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.3.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
@@ -89,15 +89,26 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'views/fp_bath_log_views.xml',
|
||||
'views/fp_facility_views.xml',
|
||||
'views/fp_bath_views.xml',
|
||||
'views/fp_process_node_views.xml',
|
||||
'views/fp_rack_views.xml',
|
||||
'views/fp_bath_replenishment_views.xml',
|
||||
'views/fp_operator_certification_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/fp_menu.xml',
|
||||
'data/fp_recipe_enp_alum_basic.xml',
|
||||
],
|
||||
'post_init_hook': 'post_init_hook',
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_plating/static/src/scss/fusion_plating.scss',
|
||||
'fusion_plating/static/src/scss/recipe_tree_editor.scss',
|
||||
'fusion_plating/static/src/xml/recipe_tree_editor.xml',
|
||||
'fusion_plating/static/src/js/recipe_tree_editor.js',
|
||||
],
|
||||
},
|
||||
'demo': [
|
||||
'data/fp_demo_data.xml',
|
||||
'data/fp_demo_recipe_data.xml',
|
||||
],
|
||||
'images': ['static/description/icon.png'],
|
||||
'installable': True,
|
||||
5
fusion_plating/fusion_plating/controllers/__init__.py
Normal file
5
fusion_plating/fusion_plating/controllers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import recipe_controller
|
||||
188
fusion_plating/fusion_plating/controllers/recipe_controller.py
Normal file
188
fusion_plating/fusion_plating/controllers/recipe_controller.py
Normal file
@@ -0,0 +1,188 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpRecipeController(http.Controller):
|
||||
"""JSON-RPC endpoints for the process recipe tree editor."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Read — full tree
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/tree', type='jsonrpc', auth='user')
|
||||
def get_tree(self, recipe_id):
|
||||
"""Return the full nested tree for a recipe."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
recipe = Node.browse(int(recipe_id))
|
||||
if not recipe.exists():
|
||||
return {'ok': False, 'error': f'Recipe {recipe_id} not found.'}
|
||||
return {
|
||||
'ok': True,
|
||||
'recipe': {
|
||||
'id': recipe.id,
|
||||
'name': recipe.name,
|
||||
'code': recipe.code or '',
|
||||
'version': recipe.version,
|
||||
'process_type': recipe.process_type_id.name if recipe.process_type_id else '',
|
||||
},
|
||||
'tree': recipe.get_tree_data(),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Create node
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/node/create', type='jsonrpc', auth='user')
|
||||
def create_node(self, parent_id, name, node_type='operation', vals=None):
|
||||
"""Create a new child node under parent_id."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
parent = Node.browse(int(parent_id))
|
||||
if not parent.exists():
|
||||
return {'ok': False, 'error': 'Parent node not found.'}
|
||||
|
||||
# Determine next sequence
|
||||
max_seq = max((c.sequence for c in parent.child_ids), default=0)
|
||||
data = {
|
||||
'name': name,
|
||||
'node_type': node_type,
|
||||
'parent_id': parent.id,
|
||||
'sequence': max_seq + 10,
|
||||
}
|
||||
if vals:
|
||||
data.update(vals)
|
||||
|
||||
try:
|
||||
new_node = Node.create(data)
|
||||
_logger.info('Recipe: created node %s (%s) under %s by uid %s',
|
||||
new_node.id, name, parent.id, request.env.uid)
|
||||
return {'ok': True, 'node_id': new_node.id}
|
||||
except Exception as exc:
|
||||
_logger.exception('Recipe create_node failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Update node
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/node/write', type='jsonrpc', auth='user')
|
||||
def write_node(self, node_id, vals):
|
||||
"""Update fields on an existing node."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
node = Node.browse(int(node_id))
|
||||
if not node.exists():
|
||||
return {'ok': False, 'error': 'Node not found.'}
|
||||
|
||||
# Filter to allowed fields only
|
||||
allowed = {
|
||||
'name', 'code', 'node_type', 'icon', 'color',
|
||||
'process_type_id', 'work_center_id',
|
||||
'description', 'notes',
|
||||
'estimated_duration',
|
||||
'auto_complete', 'customer_visible', 'is_manual',
|
||||
'requires_signoff', 'opt_in_out', 'sequence', 'version',
|
||||
}
|
||||
safe_vals = {k: v for k, v in vals.items() if k in allowed}
|
||||
if not safe_vals:
|
||||
return {'ok': False, 'error': 'No valid fields to update.'}
|
||||
|
||||
try:
|
||||
node.write(safe_vals)
|
||||
return {'ok': True}
|
||||
except Exception as exc:
|
||||
_logger.exception('Recipe write_node failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Delete node
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/node/unlink', type='jsonrpc', auth='user')
|
||||
def unlink_node(self, node_id):
|
||||
"""Delete a node and all its children (cascade)."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
node = Node.browse(int(node_id))
|
||||
if not node.exists():
|
||||
return {'ok': False, 'error': 'Node not found.'}
|
||||
if node.node_type == 'recipe':
|
||||
return {'ok': False, 'error': 'Cannot delete a recipe root from the tree editor. Use the list view.'}
|
||||
|
||||
try:
|
||||
name = node.name
|
||||
node.unlink()
|
||||
_logger.info('Recipe: deleted node %s (%s) by uid %s',
|
||||
node_id, name, request.env.uid)
|
||||
return {'ok': True}
|
||||
except Exception as exc:
|
||||
_logger.exception('Recipe unlink_node failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reorder siblings
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/node/reorder', type='jsonrpc', auth='user')
|
||||
def reorder_nodes(self, node_ids):
|
||||
"""Bulk-update sequence for an ordered list of sibling node IDs."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
try:
|
||||
for idx, nid in enumerate(node_ids):
|
||||
Node.browse(int(nid)).write({'sequence': (idx + 1) * 10})
|
||||
return {'ok': True}
|
||||
except Exception as exc:
|
||||
_logger.exception('Recipe reorder failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Move node to new parent
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/node/move', type='jsonrpc', auth='user')
|
||||
def move_node(self, node_id, new_parent_id):
|
||||
"""Move a node to a new parent (drag between sub-trees)."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
node = Node.browse(int(node_id))
|
||||
parent = Node.browse(int(new_parent_id))
|
||||
if not node.exists() or not parent.exists():
|
||||
return {'ok': False, 'error': 'Node or parent not found.'}
|
||||
|
||||
# Prevent moving a recipe root
|
||||
if node.node_type == 'recipe':
|
||||
return {'ok': False, 'error': 'Cannot move a recipe root.'}
|
||||
|
||||
# Prevent making a node its own descendant
|
||||
if f'/{node.id}/' in (parent.parent_path or ''):
|
||||
return {'ok': False, 'error': 'Cannot move a node under its own descendant.'}
|
||||
|
||||
try:
|
||||
max_seq = max((c.sequence for c in parent.child_ids), default=0)
|
||||
node.write({
|
||||
'parent_id': parent.id,
|
||||
'sequence': max_seq + 10,
|
||||
})
|
||||
return {'ok': True}
|
||||
except Exception as exc:
|
||||
_logger.exception('Recipe move_node failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Duplicate recipe
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/duplicate', type='jsonrpc', auth='user')
|
||||
def duplicate_recipe(self, recipe_id):
|
||||
"""Deep-copy an entire recipe tree."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
recipe = Node.browse(int(recipe_id))
|
||||
if not recipe.exists():
|
||||
return {'ok': False, 'error': 'Recipe not found.'}
|
||||
if recipe.node_type != 'recipe':
|
||||
return {'ok': False, 'error': 'Can only duplicate recipe roots.'}
|
||||
|
||||
try:
|
||||
new_recipe = recipe.copy()
|
||||
return {'ok': True, 'recipe_id': new_recipe.id}
|
||||
except Exception as exc:
|
||||
_logger.exception('Recipe duplicate failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
262
fusion_plating/fusion_plating/data/fp_demo_recipe_data.xml
Normal file
262
fusion_plating/fusion_plating/data/fp_demo_recipe_data.xml
Normal file
@@ -0,0 +1,262 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Demo recipe: Electroless Nickel Plating — Steel Line
|
||||
-->
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- ===== ROOT: Electroless Nickel Plating — Steel Line ===== -->
|
||||
<record id="demo_recipe_en_steel" model="fusion.plating.process.node">
|
||||
<field name="name">Electroless Nickel Plating — Steel Line</field>
|
||||
<field name="code">EN_STEEL</field>
|
||||
<field name="node_type">recipe</field>
|
||||
<field name="icon">fa-flask</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="customer_visible">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 1. Blasting -->
|
||||
<record id="demo_node_blasting" model="fusion.plating.process.node">
|
||||
<field name="name">Blasting</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-bullseye</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="estimated_duration">30</field>
|
||||
<field name="is_manual">True</field>
|
||||
</record>
|
||||
<record id="demo_step_ready_blast" model="fusion.plating.process.node">
|
||||
<field name="name">Ready for Blast</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_blasting"/>
|
||||
<field name="icon">fa-clock-o</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="demo_step_blast" model="fusion.plating.process.node">
|
||||
<field name="name">Blast</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_blasting"/>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 2. Masking -->
|
||||
<record id="demo_node_masking" model="fusion.plating.process.node">
|
||||
<field name="name">Masking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-paint-brush</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="estimated_duration">45</field>
|
||||
<field name="is_manual">True</field>
|
||||
</record>
|
||||
<record id="demo_step_ready_mask" model="fusion.plating.process.node">
|
||||
<field name="name">Ready for Masking</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_masking"/>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="demo_step_mask" model="fusion.plating.process.node">
|
||||
<field name="name">Masking</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_masking"/>
|
||||
<field name="icon">fa-paint-brush</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 3. Racking -->
|
||||
<record id="demo_node_racking" model="fusion.plating.process.node">
|
||||
<field name="name">Racking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-th</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="estimated_duration">20</field>
|
||||
</record>
|
||||
|
||||
<!-- 4. Steel Line (sub-process with many children) -->
|
||||
<record id="demo_node_steel_line" model="fusion.plating.process.node">
|
||||
<field name="name">Steel Line</field>
|
||||
<field name="node_type">sub_process</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-industry</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="auto_complete">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 4a. Cleaner (sub-process inside Steel Line) -->
|
||||
<record id="demo_node_cleaner" model="fusion.plating.process.node">
|
||||
<field name="name">Cleaner</field>
|
||||
<field name="node_type">sub_process</field>
|
||||
<field name="parent_id" ref="demo_node_steel_line"/>
|
||||
<field name="icon">fa-shower</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="demo_step_soak_clean" model="fusion.plating.process.node">
|
||||
<field name="name">Soak Clean (S-3)</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_cleaner"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="estimated_duration">10</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
<record id="demo_step_electroclean" model="fusion.plating.process.node">
|
||||
<field name="name">Electroclean (S-3)</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_cleaner"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="estimated_duration">5</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
<record id="demo_step_primary_rinse_1" model="fusion.plating.process.node">
|
||||
<field name="name">Primary Rinse (S-4)</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_cleaner"/>
|
||||
<field name="sequence">30</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
|
||||
<!-- 4b. Acid Dip -->
|
||||
<record id="demo_node_acid_dip" model="fusion.plating.process.node">
|
||||
<field name="name">Acid Dip (S-5)</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_node_steel_line"/>
|
||||
<field name="icon">fa-flask</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="estimated_duration">5</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
|
||||
<!-- 4c. Nickel Strike -->
|
||||
<record id="demo_node_nickel_strike" model="fusion.plating.process.node">
|
||||
<field name="name">Nickel Strike (S-7 / SP-5)</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_node_steel_line"/>
|
||||
<field name="icon">fa-bolt</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="estimated_duration">8</field>
|
||||
<field name="is_manual">False</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 4d. E-Nickel Plate (Mid Phos) -->
|
||||
<record id="demo_node_en_plate" model="fusion.plating.process.node">
|
||||
<field name="name">E-Nickel Plate (Mid Phos) (S-9)</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_node_steel_line"/>
|
||||
<field name="icon">fa-diamond</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="estimated_duration">90</field>
|
||||
<field name="is_manual">False</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
<record id="demo_step_rinse_after_plate" model="fusion.plating.process.node">
|
||||
<field name="name">Primary Rinse (S-11)</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_en_plate"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
<record id="demo_step_hot_rinse" model="fusion.plating.process.node">
|
||||
<field name="name">Hot Rinse (S-13)</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_en_plate"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
|
||||
<!-- 4e. Hot Water Porosity -->
|
||||
<record id="demo_node_porosity" model="fusion.plating.process.node">
|
||||
<field name="name">Hot Water Porosity (A-15)</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_node_steel_line"/>
|
||||
<field name="icon">fa-tint</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="estimated_duration">15</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 4f. Dry -->
|
||||
<record id="demo_node_dry" model="fusion.plating.process.node">
|
||||
<field name="name">Dry</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_node_steel_line"/>
|
||||
<field name="icon">fa-sun-o</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
|
||||
<!-- 5. Oven Baking -->
|
||||
<record id="demo_node_oven_bake" model="fusion.plating.process.node">
|
||||
<field name="name">Oven Baking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="estimated_duration">240</field>
|
||||
<field name="is_manual">False</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 6. De-racking -->
|
||||
<record id="demo_node_derack" model="fusion.plating.process.node">
|
||||
<field name="name">De-Racking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-th</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="estimated_duration">15</field>
|
||||
</record>
|
||||
|
||||
<!-- 7. De-Masking -->
|
||||
<record id="demo_node_demask" model="fusion.plating.process.node">
|
||||
<field name="name">De-Masking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-eraser</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="estimated_duration">20</field>
|
||||
</record>
|
||||
|
||||
<!-- 8. Oven Bake (Post De-Rack) -->
|
||||
<record id="demo_node_post_bake" model="fusion.plating.process.node">
|
||||
<field name="name">Oven Bake (Post De-Rack)</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="sequence">80</field>
|
||||
<field name="estimated_duration">120</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
|
||||
<!-- 9. Post Plate Inspection -->
|
||||
<record id="demo_node_inspection" model="fusion.plating.process.node">
|
||||
<field name="name">Post Plate Inspection</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-search</field>
|
||||
<field name="sequence">90</field>
|
||||
<field name="estimated_duration">30</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
<record id="demo_step_ready_inspect" model="fusion.plating.process.node">
|
||||
<field name="name">Ready for Post Plate Inspection</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_inspection"/>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="demo_step_inspect" model="fusion.plating.process.node">
|
||||
<field name="name">Post Plate Inspection</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_inspection"/>
|
||||
<field name="icon">fa-check-circle</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
233
fusion_plating/fusion_plating/data/fp_recipe_enp_alum_basic.xml
Normal file
233
fusion_plating/fusion_plating/data/fp_recipe_enp_alum_basic.xml
Normal file
@@ -0,0 +1,233 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Recipe: ENP-ALUM-BASIC (Electroless Nickel Plating — Aluminium Basic)
|
||||
Source: Client's Steelhead export
|
||||
-->
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
|
||||
<!-- ===== ROOT ===== -->
|
||||
<record id="recipe_enp_alum_basic" model="fusion.plating.process.node">
|
||||
<field name="name">ENP-ALUM-BASIC</field>
|
||||
<field name="code">ENP_ALUM_BASIC</field>
|
||||
<field name="node_type">recipe</field>
|
||||
<field name="icon">fa-flask</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="auto_complete">True</field>
|
||||
<field name="customer_visible">False</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== 1. Masking ===== -->
|
||||
<record id="enp_ab_masking" model="fusion.plating.process.node">
|
||||
<field name="name">Masking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="recipe_enp_alum_basic"/>
|
||||
<field name="icon">fa-paint-brush</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="auto_complete">True</field>
|
||||
<field name="is_manual">True</field>
|
||||
</record>
|
||||
<record id="enp_ab_masking_ready" model="fusion.plating.process.node">
|
||||
<field name="name">Ready For Masking</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_masking"/>
|
||||
<field name="icon">fa-clock-o</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="is_manual">True</field>
|
||||
</record>
|
||||
<record id="enp_ab_masking_do" model="fusion.plating.process.node">
|
||||
<field name="name">Masking</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_masking"/>
|
||||
<field name="icon">fa-paint-brush</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="is_manual">True</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== 2. Racking ===== -->
|
||||
<record id="enp_ab_racking" model="fusion.plating.process.node">
|
||||
<field name="name">Racking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="recipe_enp_alum_basic"/>
|
||||
<field name="icon">fa-th</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="auto_complete">True</field>
|
||||
</record>
|
||||
<record id="enp_ab_racking_ready" model="fusion.plating.process.node">
|
||||
<field name="name">Ready for Racking</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_racking"/>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="enp_ab_racking_do" model="fusion.plating.process.node">
|
||||
<field name="name">Racking</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_racking"/>
|
||||
<field name="icon">fa-th</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== 3. Ready for processing ===== -->
|
||||
<record id="enp_ab_ready_processing" model="fusion.plating.process.node">
|
||||
<field name="name">Ready for processing</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="recipe_enp_alum_basic"/>
|
||||
<field name="icon">fa-clock-o</field>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== 4. ENP-Alum Line (sub-process) ===== -->
|
||||
<record id="enp_ab_alum_line" model="fusion.plating.process.node">
|
||||
<field name="name">ENP-Alum Line</field>
|
||||
<field name="node_type">sub_process</field>
|
||||
<field name="parent_id" ref="recipe_enp_alum_basic"/>
|
||||
<field name="icon">fa-industry</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="auto_complete">True</field>
|
||||
</record>
|
||||
<!-- 4a. E-Nickel Plating -->
|
||||
<record id="enp_ab_enickel_plating" model="fusion.plating.process.node">
|
||||
<field name="name">E-Nickel Plating</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="enp_ab_alum_line"/>
|
||||
<field name="icon">fa-diamond</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== 5. De-Masking ===== -->
|
||||
<record id="enp_ab_demasking" model="fusion.plating.process.node">
|
||||
<field name="name">De-Masking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="recipe_enp_alum_basic"/>
|
||||
<field name="icon">fa-eraser</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="auto_complete">True</field>
|
||||
</record>
|
||||
<record id="enp_ab_demasking_ready" model="fusion.plating.process.node">
|
||||
<field name="name">Ready for De-Masking</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_demasking"/>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="enp_ab_demasking_do" model="fusion.plating.process.node">
|
||||
<field name="name">De-Masking</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_demasking"/>
|
||||
<field name="icon">fa-eraser</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== 6. Oven baking ===== -->
|
||||
<record id="enp_ab_oven_bake" model="fusion.plating.process.node">
|
||||
<field name="name">Oven baking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="recipe_enp_alum_basic"/>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="auto_complete">True</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
<record id="enp_ab_oven_bake_ready" model="fusion.plating.process.node">
|
||||
<field name="name">Ready for bake</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_oven_bake"/>
|
||||
<field name="icon">fa-clock-o</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="is_manual">True</field>
|
||||
</record>
|
||||
<record id="enp_ab_oven_bake_do" model="fusion.plating.process.node">
|
||||
<field name="name">Bake</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_oven_bake"/>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="is_manual">True</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== 7. De-racking ===== -->
|
||||
<record id="enp_ab_deracking" model="fusion.plating.process.node">
|
||||
<field name="name">De-racking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="recipe_enp_alum_basic"/>
|
||||
<field name="icon">fa-th</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="auto_complete">True</field>
|
||||
</record>
|
||||
<record id="enp_ab_deracking_ready" model="fusion.plating.process.node">
|
||||
<field name="name">Ready For DeRacking</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_deracking"/>
|
||||
<field name="icon">fa-clock-o</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="is_manual">True</field>
|
||||
</record>
|
||||
<record id="enp_ab_deracking_do" model="fusion.plating.process.node">
|
||||
<field name="name">DeRacking</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_deracking"/>
|
||||
<field name="icon">fa-th</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== 8. Oven bake (Post de-rack) ===== -->
|
||||
<record id="enp_ab_post_bake" model="fusion.plating.process.node">
|
||||
<field name="name">Oven bake (Post de-rack)</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="recipe_enp_alum_basic"/>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="sequence">80</field>
|
||||
<field name="auto_complete">True</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
<record id="enp_ab_post_bake_ready" model="fusion.plating.process.node">
|
||||
<field name="name">Ready for bake</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_post_bake"/>
|
||||
<field name="icon">fa-clock-o</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="is_manual">True</field>
|
||||
</record>
|
||||
<record id="enp_ab_post_bake_do" model="fusion.plating.process.node">
|
||||
<field name="name">Bake</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_post_bake"/>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="is_manual">True</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== 9. Post-plate Inspection ===== -->
|
||||
<record id="enp_ab_inspection" model="fusion.plating.process.node">
|
||||
<field name="name">Post-plate Inspection</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="recipe_enp_alum_basic"/>
|
||||
<field name="icon">fa-search</field>
|
||||
<field name="sequence">90</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
<record id="enp_ab_inspection_ready" model="fusion.plating.process.node">
|
||||
<field name="name">Ready for post-plate Inspection</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_inspection"/>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="enp_ab_inspection_do" model="fusion.plating.process.node">
|
||||
<field name="name">Post-plate Inspection</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="enp_ab_inspection"/>
|
||||
<field name="icon">fa-check-circle</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -12,4 +12,10 @@ from . import fp_bath
|
||||
from . import fp_bath_log
|
||||
from . import fp_bath_log_line
|
||||
from . import fp_bath_parameter
|
||||
from . import fp_bath_replenishment_rule
|
||||
from . import fp_process_node
|
||||
from . import fp_rack
|
||||
from . import fp_operator_certification
|
||||
from . import fp_tz
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
@@ -112,3 +112,47 @@ class FpBathLogLine(models.Model):
|
||||
rec.status = 'warning'
|
||||
else:
|
||||
rec.status = 'ok'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# T1.2 — Auto-suggest replenishment on every log line
|
||||
# ------------------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
lines = super().create(vals_list)
|
||||
lines._spawn_replenishment_suggestions()
|
||||
return lines
|
||||
|
||||
def _spawn_replenishment_suggestions(self):
|
||||
"""For every out-of-spec reading, run the matching replenishment
|
||||
rule and create a pending suggestion the operator can apply."""
|
||||
Rule = self.env['fusion.plating.bath.replenishment.rule']
|
||||
Suggestion = self.env['fusion.plating.bath.replenishment.suggestion']
|
||||
for line in self:
|
||||
if not line.parameter_id or not line.log_id.bath_id:
|
||||
continue
|
||||
bath = line.log_id.bath_id
|
||||
rules = Rule._find_rules(bath, line.parameter_id.id)
|
||||
for rule in rules:
|
||||
dose = rule._compute_dose(
|
||||
line.value, line.target_min, line.target_max, bath.volume,
|
||||
)
|
||||
if dose <= 0:
|
||||
continue
|
||||
Suggestion.create({
|
||||
'bath_id': bath.id,
|
||||
'log_line_id': line.id,
|
||||
'rule_id': rule.id,
|
||||
'parameter_id': line.parameter_id.id,
|
||||
'current_value': line.value,
|
||||
'target_min': line.target_min,
|
||||
'target_max': line.target_max,
|
||||
'product_name': rule.product_name,
|
||||
'dose_amount': dose,
|
||||
'dose_uom': rule.dose_uom,
|
||||
'state': 'pending',
|
||||
})
|
||||
bath.message_post(
|
||||
body=f'Replenishment suggested: add {dose} {rule.dose_uom} '
|
||||
f'of {rule.product_name} ({line.parameter_id.name} '
|
||||
f'reading: {line.value})',
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpBathReplenishmentRule(models.Model):
|
||||
"""Linear replenishment rule: when a chemistry reading drifts outside
|
||||
target, calculate how much replenisher to add.
|
||||
|
||||
The formula is deliberately simple:
|
||||
dose = deficit × bath.volume × dose_rate
|
||||
|
||||
where deficit = (target_min − value) for below_min rules
|
||||
or = (value − target_max) for above_max rules.
|
||||
|
||||
Shops wanting non-linear or piecewise rules can extend this model.
|
||||
"""
|
||||
_name = 'fusion.plating.bath.replenishment.rule'
|
||||
_description = 'Fusion Plating — Replenishment Rule'
|
||||
_order = 'process_type_id, parameter_id'
|
||||
|
||||
name = fields.Char(string='Rule Name', required=True)
|
||||
process_type_id = fields.Many2one(
|
||||
'fusion.plating.process.type', string='Process Type',
|
||||
help='If set, this rule applies to every bath running this process. '
|
||||
'Leave blank and set bath_id for a bath-specific rule.',
|
||||
)
|
||||
bath_id = fields.Many2one(
|
||||
'fusion.plating.bath', string='Specific Bath',
|
||||
help='Narrow the rule to a single bath (overrides process-level rule).',
|
||||
)
|
||||
parameter_id = fields.Many2one(
|
||||
'fusion.plating.bath.parameter', string='Parameter', required=True,
|
||||
)
|
||||
trigger = fields.Selection(
|
||||
[('below_min', 'Reading Below Target Min'),
|
||||
('above_max', 'Reading Above Target Max')],
|
||||
string='Trigger', required=True, default='below_min',
|
||||
)
|
||||
product_name = fields.Char(
|
||||
string='Replenisher Name', required=True,
|
||||
help='Human-readable chemical name, e.g. "Nickel Sulfamate 30% — Grade A"',
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product', string='Product (Inventory)',
|
||||
help='Optional link to an inventory product for consumption tracking.',
|
||||
)
|
||||
dose_rate = fields.Float(
|
||||
string='Dose Rate', required=True, digits=(12, 4),
|
||||
help='Amount of replenisher per unit of parameter deficit per gallon '
|
||||
'of bath volume. E.g. 0.5 means "add 0.5 mL per (g/L deficit) per gallon".',
|
||||
)
|
||||
dose_uom = fields.Selection(
|
||||
[('ml', 'mL'), ('oz', 'fl oz'), ('g', 'g'), ('lb', 'lb'), ('l', 'L')],
|
||||
string='Dose UoM', required=True, default='ml',
|
||||
)
|
||||
min_dose = fields.Float(
|
||||
string='Minimum Dose', default=0.0,
|
||||
help='Do not suggest doses below this (useful to avoid noise).',
|
||||
)
|
||||
max_dose = fields.Float(
|
||||
string='Safety Cap', default=0.0,
|
||||
help='Cap the suggested dose. 0 = no cap.',
|
||||
)
|
||||
notes = fields.Text(string='Operator Notes')
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
@api.model
|
||||
def _find_rules(self, bath, parameter_id):
|
||||
"""Return rules applicable to this (bath, parameter). Bath-specific
|
||||
rules take precedence over process-level ones.
|
||||
"""
|
||||
bath_rule = self.search([
|
||||
('bath_id', '=', bath.id),
|
||||
('parameter_id', '=', parameter_id),
|
||||
('active', '=', True),
|
||||
])
|
||||
if bath_rule:
|
||||
return bath_rule
|
||||
return self.search([
|
||||
('bath_id', '=', False),
|
||||
('process_type_id', '=', bath.process_type_id.id),
|
||||
('parameter_id', '=', parameter_id),
|
||||
('active', '=', True),
|
||||
])
|
||||
|
||||
def _compute_dose(self, value, target_min, target_max, bath_volume):
|
||||
"""Return a dose amount for this rule given the reading context.
|
||||
Returns 0.0 if the trigger doesn't apply.
|
||||
"""
|
||||
self.ensure_one()
|
||||
deficit = 0.0
|
||||
if self.trigger == 'below_min' and target_min and value < target_min:
|
||||
deficit = target_min - value
|
||||
elif self.trigger == 'above_max' and target_max and value > target_max:
|
||||
deficit = value - target_max
|
||||
if deficit <= 0:
|
||||
return 0.0
|
||||
dose = deficit * (bath_volume or 1.0) * self.dose_rate
|
||||
if self.min_dose and dose < self.min_dose:
|
||||
return 0.0
|
||||
if self.max_dose and dose > self.max_dose:
|
||||
dose = self.max_dose
|
||||
return round(dose, 3)
|
||||
|
||||
|
||||
class FpBathReplenishmentSuggestion(models.Model):
|
||||
"""One suggestion generated from a bath-log reading. Operators mark
|
||||
them applied or dismissed once the dose has been added."""
|
||||
_name = 'fusion.plating.bath.replenishment.suggestion'
|
||||
_description = 'Fusion Plating — Replenishment Suggestion'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
bath_id = fields.Many2one(
|
||||
'fusion.plating.bath', string='Bath', required=True, ondelete='cascade',
|
||||
)
|
||||
log_line_id = fields.Many2one(
|
||||
'fusion.plating.bath.log.line', string='Triggering Reading',
|
||||
ondelete='cascade',
|
||||
)
|
||||
rule_id = fields.Many2one(
|
||||
'fusion.plating.bath.replenishment.rule', string='Rule',
|
||||
ondelete='set null',
|
||||
)
|
||||
parameter_id = fields.Many2one(
|
||||
'fusion.plating.bath.parameter', string='Parameter', required=True,
|
||||
)
|
||||
current_value = fields.Float(string='Current Reading', digits=(12, 4))
|
||||
target_min = fields.Float(string='Target Min', digits=(12, 4))
|
||||
target_max = fields.Float(string='Target Max', digits=(12, 4))
|
||||
product_name = fields.Char(string='Replenisher', required=True)
|
||||
dose_amount = fields.Float(string='Suggested Dose', digits=(12, 3))
|
||||
dose_uom = fields.Selection(
|
||||
[('ml', 'mL'), ('oz', 'fl oz'), ('g', 'g'), ('lb', 'lb'), ('l', 'L')],
|
||||
string='UoM', required=True, default='ml',
|
||||
)
|
||||
state = fields.Selection(
|
||||
[('pending', 'Pending'), ('applied', 'Applied'), ('dismissed', 'Dismissed')],
|
||||
default='pending', tracking=True,
|
||||
)
|
||||
applied_at = fields.Datetime(readonly=True)
|
||||
applied_by_id = fields.Many2one('res.users', readonly=True)
|
||||
charged_to_mo_ref = fields.Char(
|
||||
string='Charged to MO',
|
||||
help='Manufacturing order this replenishment was charged against '
|
||||
'(for job costing). Blank = unassigned.',
|
||||
)
|
||||
|
||||
def action_apply(self):
|
||||
"""Mark applied + log to bath chatter. A follow-up JobConsumption
|
||||
record can be created by `action_apply_and_charge()` to attribute
|
||||
cost to a specific MO.
|
||||
"""
|
||||
for rec in self:
|
||||
rec.write({
|
||||
'state': 'applied',
|
||||
'applied_at': fields.Datetime.now(),
|
||||
'applied_by_id': self.env.user.id,
|
||||
})
|
||||
rec.bath_id.message_post(
|
||||
body=f'Replenishment applied: {rec.dose_amount} {rec.dose_uom} '
|
||||
f'of {rec.product_name} (parameter: {rec.parameter_id.name})'
|
||||
)
|
||||
|
||||
def action_dismiss(self):
|
||||
self.write({'state': 'dismissed'})
|
||||
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class FpOperatorCertification(models.Model):
|
||||
"""A signed-off training record that certifies an operator on a
|
||||
specific process type.
|
||||
|
||||
Used to gate shop-floor work orders: an operator cannot start a
|
||||
plating WO unless they hold a current (non-expired) certification
|
||||
for that process.
|
||||
"""
|
||||
_name = 'fp.operator.certification'
|
||||
_description = 'Fusion Plating — Operator Certification'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'employee_id, process_type_id'
|
||||
|
||||
name = fields.Char(
|
||||
string='Certification Ref',
|
||||
compute='_compute_name', store=True,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee', string='Operator', required=True,
|
||||
ondelete='cascade', tracking=True,
|
||||
)
|
||||
process_type_id = fields.Many2one(
|
||||
'fusion.plating.process.type', string='Process Type',
|
||||
required=True, ondelete='restrict', tracking=True,
|
||||
)
|
||||
issued_date = fields.Date(
|
||||
string='Issued', default=fields.Date.today, required=True,
|
||||
)
|
||||
expires_date = fields.Date(
|
||||
string='Expires',
|
||||
help='Blank = no expiry. Set a date for re-certification tracking.',
|
||||
)
|
||||
issued_by_id = fields.Many2one(
|
||||
'res.users', string='Certified By', default=lambda self: self.env.user,
|
||||
)
|
||||
training_record_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='Training Record',
|
||||
)
|
||||
notes = fields.Text(string='Notes')
|
||||
revoked = fields.Boolean(string='Revoked', tracking=True)
|
||||
revoked_reason = fields.Text(string='Revoked Reason')
|
||||
state = fields.Selection(
|
||||
[('active', 'Active'),
|
||||
('expired', 'Expired'),
|
||||
('revoked', 'Revoked')],
|
||||
string='Status',
|
||||
compute='_compute_state', store=True, tracking=True,
|
||||
# NOT readonly=False — this is purely derived from revoked + expires_date
|
||||
# so the nightly recompute never fights with manual edits.
|
||||
)
|
||||
|
||||
@api.depends('employee_id', 'process_type_id')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
if rec.employee_id and rec.process_type_id:
|
||||
rec.name = f'{rec.employee_id.name} / {rec.process_type_id.name}'
|
||||
else:
|
||||
rec.name = ''
|
||||
|
||||
@api.depends('expires_date', 'revoked')
|
||||
def _compute_state(self):
|
||||
today = fields.Date.today()
|
||||
for rec in self:
|
||||
if rec.revoked:
|
||||
rec.state = 'revoked'
|
||||
elif rec.expires_date and rec.expires_date < today:
|
||||
rec.state = 'expired'
|
||||
else:
|
||||
rec.state = 'active'
|
||||
|
||||
@api.constrains('employee_id', 'process_type_id', 'revoked', 'expires_date')
|
||||
def _check_single_active(self):
|
||||
"""At most one active certification per (employee, process_type)."""
|
||||
today = fields.Date.today()
|
||||
for rec in self:
|
||||
if rec.revoked:
|
||||
continue
|
||||
if rec.expires_date and rec.expires_date < today:
|
||||
continue
|
||||
# This record is active — look for another active sibling
|
||||
dupes = self.search_count([
|
||||
('id', '!=', rec.id),
|
||||
('employee_id', '=', rec.employee_id.id),
|
||||
('process_type_id', '=', rec.process_type_id.id),
|
||||
('revoked', '=', False),
|
||||
'|', ('expires_date', '=', False),
|
||||
('expires_date', '>=', today),
|
||||
])
|
||||
if dupes:
|
||||
from odoo.exceptions import ValidationError
|
||||
raise ValidationError(_(
|
||||
'Operator %s already has an active certification for "%s". '
|
||||
'Revoke or expire the existing one before adding another.'
|
||||
) % (rec.employee_id.name, rec.process_type_id.name))
|
||||
|
||||
def action_revoke(self):
|
||||
for rec in self:
|
||||
rec.revoked = True
|
||||
rec.message_post(body=_('Certification revoked.'))
|
||||
|
||||
@api.model
|
||||
def has_active_cert(self, employee_id, process_type_id):
|
||||
"""Utility — True if this employee holds a current certification.
|
||||
|
||||
Checks revoked + expires_date directly instead of the computed
|
||||
`state` column, so even a certification that expired yesterday
|
||||
is caught immediately (no wait for nightly recompute).
|
||||
"""
|
||||
if not employee_id or not process_type_id:
|
||||
return False
|
||||
today = fields.Date.today()
|
||||
return bool(self.search_count([
|
||||
('employee_id', '=', employee_id),
|
||||
('process_type_id', '=', process_type_id),
|
||||
('revoked', '=', False),
|
||||
'|', ('expires_date', '=', False),
|
||||
('expires_date', '>=', today),
|
||||
]))
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
x_fc_certification_ids = fields.One2many(
|
||||
'fp.operator.certification', 'employee_id',
|
||||
string='Plating Certifications',
|
||||
)
|
||||
x_fc_certified_process_ids = fields.Many2many(
|
||||
'fusion.plating.process.type', compute='_compute_certified_processes',
|
||||
string='Certified Processes',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_certification_ids.state', 'x_fc_certification_ids.process_type_id')
|
||||
def _compute_certified_processes(self):
|
||||
for emp in self:
|
||||
active = emp.x_fc_certification_ids.filtered(lambda c: c.state == 'active')
|
||||
emp.x_fc_certified_process_ids = active.mapped('process_type_id')
|
||||
405
fusion_plating/fusion_plating/models/fp_process_node.py
Normal file
405
fusion_plating/fusion_plating/models/fp_process_node.py
Normal file
@@ -0,0 +1,405 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .fp_tz import fp_isoformat_utc
|
||||
|
||||
|
||||
class FpProcessNode(models.Model):
|
||||
"""A node in the process recipe tree.
|
||||
|
||||
Recipes are hierarchical templates that define how to plate a part.
|
||||
They are reusable across production orders and serve as the single
|
||||
source of truth for the shop's plating processes.
|
||||
|
||||
Node types
|
||||
----------
|
||||
* recipe — top-level root (e.g. "Electroless Nickel — Steel Line")
|
||||
* sub_process — a group of operations (e.g. "Steel Line", "Cleaner")
|
||||
* operation — a single production step (e.g. "Acid Dip", "Nickel Strike")
|
||||
* step — a sub-step within an operation (e.g. "Ready for Blast", "Blast")
|
||||
|
||||
Hierarchy uses Odoo's _parent_store for efficient tree queries.
|
||||
"""
|
||||
_name = 'fusion.plating.process.node'
|
||||
_description = 'Fusion Plating — Process Node'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_parent_store = True
|
||||
_parent_name = 'parent_id'
|
||||
_order = 'parent_path, sequence, id'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
# ---- Identity & hierarchy ------------------------------------------------
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
help='Optional short code (e.g. EN_STEEL).',
|
||||
tracking=True,
|
||||
)
|
||||
node_type = fields.Selection(
|
||||
[
|
||||
('recipe', 'Recipe'),
|
||||
('sub_process', 'Sub-Process'),
|
||||
('operation', 'Operation'),
|
||||
('step', 'Step'),
|
||||
],
|
||||
string='Type',
|
||||
required=True,
|
||||
default='operation',
|
||||
tracking=True,
|
||||
)
|
||||
parent_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Parent',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
parent_path = fields.Char(
|
||||
index=True,
|
||||
)
|
||||
child_ids = fields.One2many(
|
||||
'fusion.plating.process.node',
|
||||
'parent_id',
|
||||
string='Child Steps',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
depth = fields.Integer(
|
||||
string='Depth',
|
||||
compute='_compute_depth',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# ---- Process references --------------------------------------------------
|
||||
|
||||
process_type_id = fields.Many2one(
|
||||
'fusion.plating.process.type',
|
||||
string='Process Type',
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
work_center_id = fields.Many2one(
|
||||
'fusion.plating.work.center',
|
||||
string='Work Centre',
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- Content & metadata --------------------------------------------------
|
||||
|
||||
description = fields.Html(
|
||||
string='Description',
|
||||
help='Rich text instructions for this step.',
|
||||
)
|
||||
notes = fields.Text(
|
||||
string='Internal Notes',
|
||||
help='Internal notes (not shown to customers).',
|
||||
)
|
||||
icon = fields.Selection(
|
||||
[
|
||||
('fa-flask', 'Flask / Chemistry'),
|
||||
('fa-industry', 'Industry / Line'),
|
||||
('fa-sitemap', 'Sitemap / Process'),
|
||||
('fa-wrench', 'Wrench / Operation'),
|
||||
('fa-cog', 'Gear / General'),
|
||||
('fa-cogs', 'Gears / System'),
|
||||
('fa-paint-brush', 'Paint / Masking'),
|
||||
('fa-eraser', 'Eraser / De-Masking'),
|
||||
('fa-th', 'Grid / Racking'),
|
||||
('fa-fire', 'Fire / Bake'),
|
||||
('fa-bolt', 'Bolt / Electric'),
|
||||
('fa-diamond', 'Diamond / Plating'),
|
||||
('fa-tint', 'Tint / Rinse'),
|
||||
('fa-shower', 'Shower / Clean'),
|
||||
('fa-bullseye', 'Target / Blast'),
|
||||
('fa-search', 'Search / Inspect'),
|
||||
('fa-check-circle', 'Check / Approve'),
|
||||
('fa-clock-o', 'Clock / Wait'),
|
||||
('fa-sun-o', 'Sun / Dry'),
|
||||
('fa-thermometer-half', 'Temp / Heat'),
|
||||
('fa-eye', 'Eye / Visual'),
|
||||
('fa-hand-paper-o', 'Hand / Manual'),
|
||||
('fa-cube', 'Cube / Part'),
|
||||
('fa-shield', 'Shield / Protect'),
|
||||
],
|
||||
string='Icon',
|
||||
default='fa-cog',
|
||||
)
|
||||
color = fields.Integer(
|
||||
string='Colour',
|
||||
default=0,
|
||||
)
|
||||
|
||||
# ---- Timing --------------------------------------------------------------
|
||||
|
||||
estimated_duration = fields.Float(
|
||||
string='Estimated Duration (min)',
|
||||
help='Expected time in minutes.',
|
||||
)
|
||||
|
||||
# ---- Behaviour flags -----------------------------------------------------
|
||||
|
||||
auto_complete = fields.Boolean(
|
||||
string='Auto-Complete',
|
||||
default=False,
|
||||
help='Automatically marks done when all children complete.',
|
||||
)
|
||||
customer_visible = fields.Boolean(
|
||||
string='Customer Visible',
|
||||
default=True,
|
||||
help='Whether to show this step name to customers.',
|
||||
)
|
||||
is_manual = fields.Boolean(
|
||||
string='Manual Operation',
|
||||
default=True,
|
||||
help='Unchecked = automated (e.g. timed immersion).',
|
||||
)
|
||||
requires_signoff = fields.Boolean(
|
||||
string='Requires Sign-Off',
|
||||
default=False,
|
||||
help='Quality hold point — requires operator sign-off.',
|
||||
)
|
||||
opt_in_out = fields.Selection(
|
||||
[
|
||||
('disabled', 'Disabled'),
|
||||
('opt_in', 'Opt-In'),
|
||||
('opt_out', 'Opt-Out'),
|
||||
],
|
||||
string='Opt In/Out',
|
||||
default='disabled',
|
||||
help='Controls whether this step is optional for a given job.',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- Lifecycle -----------------------------------------------------------
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
version = fields.Integer(
|
||||
string='Version',
|
||||
default=1,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- Computed fields -----------------------------------------------------
|
||||
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
recursive=True,
|
||||
)
|
||||
child_count = fields.Integer(
|
||||
string='Children',
|
||||
compute='_compute_child_count',
|
||||
)
|
||||
recipe_root_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Recipe Root',
|
||||
compute='_compute_recipe_root_id',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# ---- Operator inputs (one2many) ------------------------------------------
|
||||
|
||||
input_ids = fields.One2many(
|
||||
'fusion.plating.process.node.input',
|
||||
'node_id',
|
||||
string='Operator Inputs',
|
||||
)
|
||||
|
||||
# ---- SQL constraints -----------------------------------------------------
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_process_node_code_uniq',
|
||||
'unique(code)',
|
||||
'Recipe node code must be unique.'),
|
||||
]
|
||||
|
||||
# ---- Computes ------------------------------------------------------------
|
||||
|
||||
@api.depends('name', 'code', 'parent_id.display_name')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
if rec.parent_id and rec.node_type != 'recipe':
|
||||
rec.display_name = f'{rec.parent_id.display_name} / {rec.name}'
|
||||
else:
|
||||
rec.display_name = rec.name or ''
|
||||
|
||||
@api.depends('parent_path')
|
||||
def _compute_depth(self):
|
||||
for rec in self:
|
||||
rec.depth = (rec.parent_path or '').count('/') - 1
|
||||
|
||||
@api.depends('child_ids')
|
||||
def _compute_child_count(self):
|
||||
for rec in self:
|
||||
rec.child_count = len(rec.child_ids)
|
||||
|
||||
@api.depends('parent_path')
|
||||
def _compute_recipe_root_id(self):
|
||||
for rec in self:
|
||||
if rec.parent_path:
|
||||
root_id = int(rec.parent_path.split('/')[0])
|
||||
rec.recipe_root_id = root_id
|
||||
else:
|
||||
rec.recipe_root_id = rec.id
|
||||
|
||||
# ---- Constraints ---------------------------------------------------------
|
||||
|
||||
@api.constrains('parent_id')
|
||||
def _check_recursion_constraint(self):
|
||||
if not self._check_recursion():
|
||||
raise ValidationError(
|
||||
_('A process node cannot be its own ancestor.'))
|
||||
|
||||
# ---- Tree data for OWL component -----------------------------------------
|
||||
|
||||
def get_tree_data(self):
|
||||
"""Return full nested dict for the OWL recipe tree editor.
|
||||
|
||||
Called via the controller. Returns the tree rooted at `self`,
|
||||
recursively including all descendants.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self._node_to_dict()
|
||||
|
||||
def _node_to_dict(self, max_depth=10):
|
||||
"""Recursively convert this node + children to a dict."""
|
||||
if max_depth <= 0:
|
||||
return None
|
||||
children = []
|
||||
for child in self.child_ids.sorted('sequence'):
|
||||
child_dict = child._node_to_dict(max_depth=max_depth - 1)
|
||||
if child_dict:
|
||||
children.append(child_dict)
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name or '',
|
||||
'code': self.code or '',
|
||||
'node_type': self.node_type,
|
||||
'sequence': self.sequence,
|
||||
'depth': self.depth,
|
||||
'icon': self.icon or 'fa-cog',
|
||||
'color': self.color,
|
||||
'process_type': self.process_type_id.name if self.process_type_id else '',
|
||||
'process_type_id': self.process_type_id.id if self.process_type_id else False,
|
||||
'work_center': self.work_center_id.name if self.work_center_id else '',
|
||||
'work_center_id': self.work_center_id.id if self.work_center_id else False,
|
||||
'description': self.description or '',
|
||||
'notes': self.notes or '',
|
||||
'estimated_duration': self.estimated_duration,
|
||||
'auto_complete': self.auto_complete,
|
||||
'customer_visible': self.customer_visible,
|
||||
'is_manual': self.is_manual,
|
||||
'requires_signoff': self.requires_signoff,
|
||||
'version': self.version,
|
||||
'child_count': len(children),
|
||||
'opt_in_out': self.opt_in_out or 'disabled',
|
||||
'input_count': len(self.input_ids),
|
||||
# ISO with explicit UTC marker so JS new Date() parses it
|
||||
# correctly and re-localises to the browser's timezone.
|
||||
'create_date': fp_isoformat_utc(self.create_date),
|
||||
'create_uid_name': self.create_uid.name if self.create_uid else '',
|
||||
'write_date': fp_isoformat_utc(self.write_date),
|
||||
'write_uid_name': self.write_uid.name if self.write_uid else '',
|
||||
'children': children,
|
||||
}
|
||||
|
||||
# ---- Actions -------------------------------------------------------------
|
||||
|
||||
def action_open_tree_editor(self):
|
||||
"""Open the OWL recipe tree editor for this recipe."""
|
||||
self.ensure_one()
|
||||
root = self if self.node_type == 'recipe' else self.recipe_root_id
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_recipe_tree_editor',
|
||||
'name': f'Recipe — {root.name}',
|
||||
'context': {'recipe_id': root.id},
|
||||
}
|
||||
|
||||
# ---- Copy (deep-duplicate) -----------------------------------------------
|
||||
|
||||
def copy(self, default=None):
|
||||
"""Deep-copy: duplicates the node and all descendants."""
|
||||
default = dict(default or {})
|
||||
if self.node_type == 'recipe':
|
||||
default.setdefault('name', _('%s (Copy)', self.name))
|
||||
default.setdefault('code', f'{self.code}_copy' if self.code else False)
|
||||
new_node = super().copy(default)
|
||||
for child in self.child_ids.sorted('sequence'):
|
||||
child.copy({'parent_id': new_node.id})
|
||||
return new_node
|
||||
|
||||
|
||||
class FpProcessNodeInput(models.Model):
|
||||
"""An operator input definition attached to a process node.
|
||||
|
||||
These define what the operator needs to record when executing this
|
||||
step — temperature readings, visual inspections, timing, etc.
|
||||
"""
|
||||
_name = 'fusion.plating.process.node.input'
|
||||
_description = 'Fusion Plating — Process Node Input'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
help='E.g. "Temperature Reading", "Visual Inspection".',
|
||||
)
|
||||
node_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Process Node',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
input_type = fields.Selection(
|
||||
[
|
||||
('text', 'Text'),
|
||||
('number', 'Number'),
|
||||
('boolean', 'Yes / No'),
|
||||
('selection', 'Selection'),
|
||||
('photo', 'Photo'),
|
||||
],
|
||||
string='Input Type',
|
||||
required=True,
|
||||
default='text',
|
||||
)
|
||||
required = fields.Boolean(
|
||||
string='Required',
|
||||
default=False,
|
||||
)
|
||||
hint = fields.Char(
|
||||
string='Hint',
|
||||
help='Placeholder text shown to the operator.',
|
||||
)
|
||||
selection_options = fields.Text(
|
||||
string='Options',
|
||||
help='Comma-separated list of options (for Selection type).',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
uom = fields.Char(
|
||||
string='Unit',
|
||||
help='Unit label (e.g. °C, min, psi).',
|
||||
)
|
||||
@@ -39,6 +39,20 @@ class FpProcessType(models.Model):
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
)
|
||||
process_family = fields.Selection(
|
||||
[('pre_treatment', 'Pre-Treatment'),
|
||||
('plating', 'Plating'),
|
||||
('post_treatment', 'Post-Treatment'),
|
||||
('bake', 'Hydrogen Bake / Heat Treat'),
|
||||
('strip', 'Strip'),
|
||||
('passivation', 'Passivation'),
|
||||
('masking', 'Masking / De-masking'),
|
||||
('inspection', 'Inspection / QC')],
|
||||
string='Family', default='plating', required=True, tracking=True,
|
||||
help='High-level grouping used to filter baths and plan routings. '
|
||||
'Pre-treatments (alkaline clean, acid etch, zincate) should be '
|
||||
'tracked as full baths with their own chemistry logs.',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
117
fusion_plating/fusion_plating/models/fp_rack.py
Normal file
117
fusion_plating/fusion_plating/models/fp_rack.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class FpRack(models.Model):
|
||||
"""Plating rack / barrel / fixture.
|
||||
|
||||
Racks carry parts through baths and accumulate nickel themselves over
|
||||
time. Once the rack's metal turnover (MTO) count exceeds the strip
|
||||
interval, the rack must be stripped before re-use to avoid bald spots
|
||||
on parts.
|
||||
"""
|
||||
_name = 'fusion.plating.rack'
|
||||
_description = 'Fusion Plating — Rack / Fixture'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'facility_id, rack_type, name'
|
||||
|
||||
name = fields.Char(string='Rack ID', required=True, tracking=True)
|
||||
rack_type = fields.Selection(
|
||||
[('rack', 'Rack'), ('barrel', 'Barrel'),
|
||||
('fixture', 'Fixture'), ('basket', 'Basket')],
|
||||
string='Type', required=True, default='rack',
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility', string='Facility', required=True, tracking=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', related='facility_id.company_id', store=True, readonly=True,
|
||||
)
|
||||
capacity = fields.Integer(
|
||||
string='Capacity (parts)',
|
||||
help='Max parts per load. Used for batch planning.',
|
||||
)
|
||||
contact_points = fields.Integer(
|
||||
string='Contact Points',
|
||||
help='Number of clips/tips that touch parts. Wear points for re-stripping.',
|
||||
)
|
||||
|
||||
# --- Wear tracking ---
|
||||
mto_count = fields.Float(
|
||||
string='MTO (current)', default=0.0, tracking=True,
|
||||
help='Metal turnover accumulated since last strip.',
|
||||
)
|
||||
strip_interval_mto = fields.Float(
|
||||
string='Strip After (MTO)', default=3.0,
|
||||
help='When MTO crosses this value, rack needs stripping.',
|
||||
)
|
||||
last_stripped_date = fields.Datetime(string='Last Stripped', tracking=True)
|
||||
last_stripped_by_id = fields.Many2one(
|
||||
'res.users', string='Stripped By', tracking=True,
|
||||
)
|
||||
strips_count = fields.Integer(string='Total Strips', default=0, readonly=True)
|
||||
|
||||
state = fields.Selection(
|
||||
[('active', 'Active'),
|
||||
('needs_strip', 'Needs Strip'),
|
||||
('stripping', 'Stripping'),
|
||||
('retired', 'Retired')],
|
||||
string='Status', default='active', required=True, tracking=True,
|
||||
compute='_compute_state', store=True, readonly=False,
|
||||
)
|
||||
status_color = fields.Integer(compute='_compute_status_color')
|
||||
notes = fields.Html(string='Notes')
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_rack_facility_name_uniq', 'unique(facility_id, name)',
|
||||
'Rack ID must be unique per facility.'),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Computes
|
||||
# ------------------------------------------------------------------
|
||||
@api.depends('mto_count', 'strip_interval_mto')
|
||||
def _compute_state(self):
|
||||
for rec in self:
|
||||
if rec.state in ('stripping', 'retired'):
|
||||
continue # Manually set — don't override
|
||||
if rec.strip_interval_mto and rec.mto_count >= rec.strip_interval_mto:
|
||||
rec.state = 'needs_strip'
|
||||
elif rec.state != 'active':
|
||||
rec.state = 'active'
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_status_color(self):
|
||||
mapping = {'active': 4, 'needs_strip': 3, 'stripping': 2, 'retired': 10}
|
||||
for rec in self:
|
||||
rec.status_color = mapping.get(rec.state, 0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions
|
||||
# ------------------------------------------------------------------
|
||||
def action_start_strip(self):
|
||||
self.write({'state': 'stripping'})
|
||||
|
||||
def action_mark_stripped(self):
|
||||
for rec in self:
|
||||
rec.write({
|
||||
'state': 'active',
|
||||
'mto_count': 0.0,
|
||||
'last_stripped_date': fields.Datetime.now(),
|
||||
'last_stripped_by_id': self.env.user.id,
|
||||
'strips_count': rec.strips_count + 1,
|
||||
})
|
||||
rec.message_post(body=_('Rack stripped and returned to service.'))
|
||||
|
||||
def action_retire(self):
|
||||
self.write({'state': 'retired', 'active': False})
|
||||
|
||||
def _increment_mto(self, delta=1.0):
|
||||
"""Add `delta` to the rack's MTO count. Called by the WO finish hook."""
|
||||
for rec in self:
|
||||
rec.mto_count = (rec.mto_count or 0.0) + delta
|
||||
161
fusion_plating/fusion_plating/models/fp_tz.py
Normal file
161
fusion_plating/fusion_plating/models/fp_tz.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Timezone helpers for Fusion Plating.
|
||||
|
||||
The Postgres database stores all datetimes naive-UTC. Anything that is
|
||||
shown to a user — dashboards, PDFs, emails, OWL frontends — must be
|
||||
converted to a human's local timezone first.
|
||||
|
||||
Resolution order for "what timezone does this user see":
|
||||
1. The current user's `res.users.tz`
|
||||
2. The current company's `x_fc_default_tz` (Fusion Plating setting)
|
||||
3. UTC
|
||||
|
||||
Use ``fp_user_tz(env)`` to get the resolved pytz tzinfo, then either
|
||||
convert datetimes yourself or use the convenience helpers
|
||||
``fp_format`` / ``fp_isoformat_utc``.
|
||||
"""
|
||||
|
||||
import pytz
|
||||
|
||||
|
||||
def fp_user_tz(env):
|
||||
"""Return a pytz tzinfo for the current user (or company fallback)."""
|
||||
name = (
|
||||
(env.user.tz if env and env.user else None)
|
||||
or (env.company.x_fc_default_tz if env and env.company else None)
|
||||
or 'UTC'
|
||||
)
|
||||
try:
|
||||
return pytz.timezone(name)
|
||||
except Exception:
|
||||
return pytz.UTC
|
||||
|
||||
|
||||
def fp_to_user_tz(env, dt):
|
||||
"""Convert a naive UTC datetime to a tz-aware datetime in the user's tz.
|
||||
|
||||
Returns ``None`` if ``dt`` is falsy. Datetimes that already carry a
|
||||
tzinfo are converted in place; naive ones are assumed to be UTC
|
||||
(matching Odoo's storage convention).
|
||||
"""
|
||||
if not dt:
|
||||
return None
|
||||
tz = fp_user_tz(env)
|
||||
if dt.tzinfo is None:
|
||||
dt = pytz.UTC.localize(dt)
|
||||
return dt.astimezone(tz)
|
||||
|
||||
|
||||
def fp_format(env, dt, fmt='%Y-%m-%d %H:%M'):
|
||||
"""Format a naive UTC datetime as a string in the user's tz.
|
||||
|
||||
Returns an empty string when ``dt`` is falsy so callers can use the
|
||||
result directly in dicts / templates without ``if`` guards.
|
||||
"""
|
||||
if not dt:
|
||||
return ''
|
||||
return fp_to_user_tz(env, dt).strftime(fmt)
|
||||
|
||||
|
||||
def fp_isoformat_utc(dt):
|
||||
"""Return an ISO-8601 string with an explicit UTC marker.
|
||||
|
||||
Naive datetimes from Odoo are assumed UTC. Adding the ``+00:00``
|
||||
suffix tells JavaScript ``new Date(...)`` to parse the string as
|
||||
UTC (without it, the browser interprets the string as *local*
|
||||
wall time and silently shifts it). Pair this with frontend code
|
||||
that calls ``.toLocaleString()`` on the resulting Date object so
|
||||
the user sees their own local time.
|
||||
"""
|
||||
if not dt:
|
||||
return ''
|
||||
if dt.tzinfo is None:
|
||||
dt = pytz.UTC.localize(dt)
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
def fp_time_ago(env, dt):
|
||||
"""Return a 'just now / 5m ago / 2h ago / 3d ago' string.
|
||||
|
||||
Both sides of the comparison are converted to UTC tz-aware first so
|
||||
that the delta is meaningful regardless of where Odoo or the user
|
||||
happens to be running.
|
||||
"""
|
||||
if not dt:
|
||||
return ''
|
||||
if dt.tzinfo is None:
|
||||
dt = pytz.UTC.localize(dt)
|
||||
from datetime import datetime
|
||||
now = datetime.now(pytz.UTC)
|
||||
delta = now - dt
|
||||
total_seconds = int(delta.total_seconds())
|
||||
if total_seconds < 0:
|
||||
total_seconds = 0
|
||||
if total_seconds < 60:
|
||||
return 'just now'
|
||||
minutes = total_seconds // 60
|
||||
if minutes < 60:
|
||||
return f'{minutes}m ago'
|
||||
hours = minutes // 60
|
||||
if hours < 24:
|
||||
return f'{hours}h ago'
|
||||
days = hours // 24
|
||||
if days < 7:
|
||||
return f'{days}d ago'
|
||||
weeks = days // 7
|
||||
remaining_days = days % 7
|
||||
if remaining_days:
|
||||
return f'{weeks}w {remaining_days}d ago'
|
||||
return f'{weeks}w ago'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-detection used by the post_init_hook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FALLBACK_TZ = 'America/Toronto'
|
||||
|
||||
|
||||
def detect_default_tz(env=None):
|
||||
"""Best guess at a sensible default tz when the module is installed.
|
||||
|
||||
Tries, in order:
|
||||
1. The admin user's ``tz`` (Odoo sets it from the browser on login).
|
||||
2. The current company's ``partner_id.tz``.
|
||||
3. The host server's IANA timezone (Linux typical).
|
||||
4. ``America/Toronto`` as the final fallback (this project is
|
||||
Canada-focused and that's the most likely correct guess).
|
||||
"""
|
||||
if env is not None:
|
||||
admin = env.ref('base.user_admin', raise_if_not_found=False)
|
||||
if admin and admin.tz:
|
||||
return admin.tz
|
||||
try:
|
||||
partner_tz = env.company.partner_id.tz
|
||||
except Exception:
|
||||
partner_tz = None
|
||||
if partner_tz:
|
||||
return partner_tz
|
||||
|
||||
# Server-side detection — works on most Linux hosts.
|
||||
try:
|
||||
from datetime import datetime
|
||||
local = datetime.now().astimezone()
|
||||
name = str(local.tzinfo)
|
||||
if name and name in pytz.all_timezones:
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
with open('/etc/timezone', 'r') as fh:
|
||||
tz = fh.read().strip()
|
||||
if tz in pytz.all_timezones:
|
||||
return tz
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _FALLBACK_TZ
|
||||
85
fusion_plating/fusion_plating/models/res_company.py
Normal file
85
fusion_plating/fusion_plating/models/res_company.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
def _fp_tz_get(self):
|
||||
"""Same selection list Odoo uses on res.partner.tz."""
|
||||
import pytz
|
||||
return [(tz, tz) for tz in pytz.all_timezones]
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
# ----- Fusion Plating default timezone --------------------------------
|
||||
# Used as the fallback whenever a user's res.users.tz is empty (cron
|
||||
# jobs, batch emails, headless contexts). Auto-populated by the
|
||||
# post_init_hook in fusion_plating/__init__.py.
|
||||
x_fc_default_tz = fields.Selection(
|
||||
_fp_tz_get,
|
||||
string='Fusion Plating Timezone',
|
||||
default=lambda self: self.env.user.tz or 'America/Toronto',
|
||||
help='Timezone used for plating dashboards, reports, and emails when '
|
||||
'a user has no personal timezone set. Detected automatically on '
|
||||
'install; the admin can change it any time from '
|
||||
'Settings > Fusion Plating.',
|
||||
)
|
||||
|
||||
# ----- Facility footprint for this legal entity ----------------------
|
||||
x_fc_facility_ids = fields.One2many(
|
||||
'fusion.plating.facility',
|
||||
'company_id',
|
||||
string='Plating Facilities',
|
||||
)
|
||||
x_fc_facility_count = fields.Integer(
|
||||
string='# Facilities',
|
||||
compute='_compute_x_fc_facility_count',
|
||||
)
|
||||
x_fc_default_facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Default Facility',
|
||||
help='Facility used when the context does not specify one (single-site shops).',
|
||||
)
|
||||
|
||||
def _compute_x_fc_facility_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_facility_count = len(rec.x_fc_facility_ids)
|
||||
|
||||
# =====================================================================
|
||||
# CoC / Certificate report settings
|
||||
# =====================================================================
|
||||
x_fc_owner_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Certificate Owner (Default Signer)',
|
||||
help='Quality manager / owner whose signature appears on Certificates '
|
||||
'of Conformance by default. Signature is pulled from their linked '
|
||||
'HR Employee record.',
|
||||
)
|
||||
x_fc_coc_signature_override = fields.Binary(
|
||||
string='Signature Override Image',
|
||||
help='Optional. Upload a pre-scanned signature image to use on '
|
||||
'Certificates of Conformance. Overrides the Owner user\'s '
|
||||
'employee signature when set. Useful if the owner doesn\'t have '
|
||||
'an HR record or wants a different signature for plating certs.',
|
||||
)
|
||||
|
||||
# --- Accreditation logos shown in CoC header ---
|
||||
x_fc_nadcap_logo = fields.Binary(string='Nadcap Logo')
|
||||
x_fc_nadcap_active = fields.Boolean(
|
||||
string='Nadcap Accredited',
|
||||
help='Show the Nadcap logo on certificates.',
|
||||
)
|
||||
x_fc_as9100_logo = fields.Binary(string='AS9100 / ISO 9001 Logo')
|
||||
x_fc_as9100_active = fields.Boolean(
|
||||
string='AS9100 / ISO 9001 Certified',
|
||||
help='Show the AS9100 / ISO 9001 logo on certificates.',
|
||||
)
|
||||
x_fc_cgp_logo = fields.Binary(string='Controlled Goods Program Logo')
|
||||
x_fc_cgp_active = fields.Boolean(
|
||||
string='CGP Registered',
|
||||
help='Show the Controlled Goods Program logo on certificates.',
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user