Compare commits
114 Commits
3cc93b8783
...
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 | ||
|
|
a8eacc94bc | ||
|
|
b79e3d5c2e | ||
|
|
be611876ad | ||
|
|
d07159b9b5 | ||
|
|
5d89e04f82 | ||
|
|
b6d101c9a2 | ||
|
|
0fe8a71c05 | ||
|
|
8b2cbd9085 | ||
|
|
d60a75a391 | ||
|
|
c30a61c93f | ||
|
|
f4c6dca171 | ||
|
|
87a649b63d | ||
|
|
7d8f30627f | ||
|
|
4fde4c7bd1 |
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>
|
||||
BIN
at_accounting-18.0.1.7.zip
Normal file
BIN
at_accounting-18.0.1.7.zip
Normal file
Binary file not shown.
668
docs/workflow-explorer/index.html
Normal file
668
docs/workflow-explorer/index.html
Normal file
@@ -0,0 +1,668 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Fusion Claims — Workflow Explorer</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"></script>
|
||||
<script src="./workflows.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #161a22;
|
||||
--panel-2: #1d2330;
|
||||
--border: #2a3040;
|
||||
--text: #e6e9ef;
|
||||
--muted: #8a93a8;
|
||||
--accent: #4f8cff;
|
||||
--accent-soft: rgba(79,140,255,.15);
|
||||
--ok: #3fbf7f;
|
||||
--warn: #f4b400;
|
||||
--err: #ff5c6c;
|
||||
--entry: #9b5de5;
|
||||
--terminal: #5f6b80;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; height: 100%; }
|
||||
body { display: flex; min-height: 100vh; }
|
||||
aside {
|
||||
width: 280px; flex-shrink: 0;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 20px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
aside h1 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
margin: 0 20px 12px;
|
||||
}
|
||||
aside .subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin: 0 20px 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.wf-list { list-style: none; padding: 0; margin: 0; }
|
||||
.wf-list li {
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background .15s;
|
||||
}
|
||||
.wf-list li:hover { background: var(--panel-2); }
|
||||
.wf-list li.active {
|
||||
background: var(--accent-soft);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.wf-list li .name { font-weight: 500; }
|
||||
.wf-list li .gap-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--err);
|
||||
color: #fff;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.wf-list li .gap-badge.zero { background: var(--ok); }
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 32px 40px;
|
||||
overflow-y: auto;
|
||||
max-width: calc(100vw - 280px);
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.field-name {
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.stat .label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.stat .value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.stat.ok .value { color: var(--ok); }
|
||||
.stat.err .value { color: var(--err); }
|
||||
.stat.warn .value { color: var(--warn); }
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tabs button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tabs button:hover { color: var(--text); }
|
||||
.tabs button.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
.mermaid-wrap {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mermaid-wrap svg { max-width: 100%; height: auto; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
th {
|
||||
background: var(--panel-2);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
color: var(--muted);
|
||||
}
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover { background: var(--panel-2); }
|
||||
tbody tr.has-issue td:first-child { border-left: 3px solid var(--err); }
|
||||
code.state-key {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
background: var(--panel-2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
.pill.ok { background: rgba(63,191,127,.2); color: var(--ok); }
|
||||
.pill.warn { background: rgba(244,180,0,.2); color: var(--warn); }
|
||||
.pill.err { background: rgba(255,92,108,.2); color: var(--err); }
|
||||
.pill.entry { background: rgba(155,93,229,.2); color: var(--entry); }
|
||||
.pill.terminal { background: rgba(95,107,128,.35); color: #c2c9d9; }
|
||||
.count {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tr-list { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 8px; }
|
||||
.tr-row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 30px 200px 1fr 140px;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.tr-row:last-child { border-bottom: none; }
|
||||
.tr-row:hover { background: var(--panel-2); }
|
||||
.tr-arrow { color: var(--muted); text-align: center; }
|
||||
.tr-trigger { color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
|
||||
.tr-trigger .file { color: #555; display: block; margin-top: 2px; }
|
||||
.tr-kind {
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.kind-wizard { color: #4f8cff; }
|
||||
.kind-action_method { color: #9b5de5; }
|
||||
.kind-cron { color: #f4b400; }
|
||||
.kind-auto_write { color: #3fbf7f; }
|
||||
.kind-ui_button { color: #ff5c6c; }
|
||||
|
||||
.gaps {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--err);
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.gaps.zero { border-left-color: var(--ok); }
|
||||
.gaps h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.gaps p { color: var(--muted); margin: 0; }
|
||||
.gaps ul { margin: 0; padding-left: 20px; }
|
||||
.gaps li { padding: 4px 0; font-size: 13px; }
|
||||
.gaps li code {
|
||||
font-family: ui-monospace, monospace;
|
||||
background: var(--panel-2);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
}
|
||||
.gap-kind {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
.gap-kind.unreachable { background: rgba(244,180,0,.25); color: var(--warn); }
|
||||
.gap-kind.dead-end { background: rgba(255,92,108,.25); color: var(--err); }
|
||||
.gap-kind.missing-path { background: rgba(79,140,255,.25); color: var(--accent); }
|
||||
.gap-kind.hold-loss { background: rgba(155,93,229,.25); color: var(--entry); }
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.legend .swatch { width: 10px; height: 10px; border-radius: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside>
|
||||
<h1>Fusion Claims</h1>
|
||||
<p class="subtitle">Workflow Explorer — 5 parallel state machines on <code style="color:var(--accent)">sale.order</code>. Click a workflow to inspect.</p>
|
||||
<ul class="wf-list" id="wf-list"></ul>
|
||||
</aside>
|
||||
<main id="main">
|
||||
<div id="wf-content"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ============================================================
|
||||
// DOM helpers — no innerHTML, all createElement / textContent
|
||||
// ============================================================
|
||||
function el(tag, opts, children) {
|
||||
const node = document.createElement(tag);
|
||||
if (opts) {
|
||||
if (opts.class) node.className = opts.class;
|
||||
if (opts.id) node.id = opts.id;
|
||||
if (opts.text != null) node.textContent = opts.text;
|
||||
if (opts.style) Object.assign(node.style, opts.style);
|
||||
if (opts.data) Object.entries(opts.data).forEach(([k,v]) => node.dataset[k] = v);
|
||||
if (opts.on) Object.entries(opts.on).forEach(([evt,fn]) => node.addEventListener(evt, fn));
|
||||
}
|
||||
if (children) {
|
||||
(Array.isArray(children) ? children : [children]).forEach(c => {
|
||||
if (c == null) return;
|
||||
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||
});
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// Render a string that may contain <code>...</code> spans safely.
|
||||
// Splits on our own markers and builds real DOM nodes.
|
||||
function renderSafeInline(parent, text) {
|
||||
// Only recognise <code>...</code> — everything else is literal text.
|
||||
const parts = text.split(/(<code>[^<]*<\/code>)/);
|
||||
parts.forEach(part => {
|
||||
if (part.startsWith('<code>') && part.endsWith('</code>')) {
|
||||
const codeText = part.slice(6, -7);
|
||||
parent.appendChild(el('code', {text: codeText}));
|
||||
} else if (part) {
|
||||
parent.appendChild(document.createTextNode(part));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Gap analysis
|
||||
// ============================================================
|
||||
function analyseWorkflow(wf) {
|
||||
const stateKeys = wf.states.map(s => s.key);
|
||||
const inbound = new Map();
|
||||
const outbound = new Map();
|
||||
stateKeys.forEach(k => { inbound.set(k, []); outbound.set(k, []); });
|
||||
|
||||
wf.transitions.forEach(t => {
|
||||
if (t.to && inbound.has(t.to)) inbound.get(t.to).push(t);
|
||||
if (t.from && t.from !== '*' && outbound.has(t.from)) outbound.get(t.from).push(t);
|
||||
});
|
||||
|
||||
const wildcardInTo = new Set();
|
||||
wf.transitions.forEach(t => { if (t.from === '*') wildcardInTo.add(t.to); });
|
||||
|
||||
const terminal = new Set(wf.terminal || []);
|
||||
const gaps = [];
|
||||
const stateStatus = {};
|
||||
|
||||
stateKeys.forEach(key => {
|
||||
const label = wf.states.find(s => s.key === key).label;
|
||||
const isDefault = key === wf.default;
|
||||
const isTerminal = terminal.has(key);
|
||||
const hasInbound = inbound.get(key).length > 0 || wildcardInTo.has(key);
|
||||
const hasOutbound = outbound.get(key).length > 0 || wf.transitions.some(t => t.from === key);
|
||||
|
||||
let status = 'ok';
|
||||
const issues = [];
|
||||
|
||||
if (!isDefault && !hasInbound) {
|
||||
status = 'err';
|
||||
issues.push({kind: 'unreachable', msg: 'No code path sets this state. It will never be reached via normal workflow — only via manual DB edit or stale ORM context.'});
|
||||
}
|
||||
if (!isTerminal && !hasOutbound && !isDefault) {
|
||||
status = 'err';
|
||||
issues.push({kind: 'dead-end', msg: 'Once an order lands here, there is no action method or wizard to transition it out. Users will have to edit the record directly.'});
|
||||
}
|
||||
|
||||
stateStatus[key] = {
|
||||
status, issues, isDefault, isTerminal,
|
||||
inbound: inbound.get(key),
|
||||
outbound: wf.transitions.filter(t => t.from === key)
|
||||
};
|
||||
issues.forEach(iss => gaps.push({state: key, label, ...iss}));
|
||||
});
|
||||
|
||||
// Workflow-specific heuristics
|
||||
if (wf.field === 'x_fc_adp_application_status') {
|
||||
if (!wf.transitions.some(t => t.to === 'rejected')) {
|
||||
gaps.push({kind: 'missing-path', state: 'rejected', label: 'Rejected by ADP',
|
||||
msg: 'No transition writes <code>rejected</code>. The state is declared but nothing reaches it. An ADP rejection has nowhere to land.'});
|
||||
}
|
||||
if (!wf.transitions.some(t => t.from === 'rejected')) {
|
||||
gaps.push({kind: 'missing-path', state: 'rejected', label: 'Rejected by ADP',
|
||||
msg: 'No <code>action_resubmit_from_rejected</code> exists (only <code>action_resubmit_from_withdrawn</code>). A rejected application cannot be brought back into the workflow.'});
|
||||
}
|
||||
if (!wf.transitions.some(t => t.to === 'denied')) {
|
||||
gaps.push({kind: 'missing-path', state: 'denied', label: 'Application Denied',
|
||||
msg: 'No code path sets <code>denied</code>. Declared as a selection value but has no action method to assign it.'});
|
||||
}
|
||||
if (!wf.transitions.some(t => t.to === 'expired')) {
|
||||
gaps.push({kind: 'missing-path', state: 'expired', label: 'Application Expired',
|
||||
msg: 'No cron or method sets <code>expired</code>. Declared but unreachable — the ADP expiry logic was never implemented.'});
|
||||
}
|
||||
if (!wf.transitions.some(t => t.to === 'cancelled')) {
|
||||
gaps.push({kind: 'missing-path', state: 'cancelled', label: 'Cancelled',
|
||||
msg: 'No action method writes <code>cancelled</code> on the ADP workflow.'});
|
||||
}
|
||||
if (!wf.transitions.some(t => t.to === 'withdrawn')) {
|
||||
gaps.push({kind: 'missing-path', state: 'withdrawn', label: 'Withdrawn',
|
||||
msg: '<code>action_resubmit_from_withdrawn</code> exists (line 3667) but no method WRITES <code>withdrawn</code> in the first place. Dead end on entry.'});
|
||||
}
|
||||
if (!wf.transitions.some(t => t.to === 'needs_correction')) {
|
||||
gaps.push({kind: 'missing-path', state: 'needs_correction', label: 'Needs Correction',
|
||||
msg: 'The write() override at line 6017 handles <code>needs_correction</code> document-clearing logic, but no code path sets the state TO <code>needs_correction</code>. Only reachable via manual edit.'});
|
||||
}
|
||||
}
|
||||
|
||||
if (wf.field === 'x_fc_mod_status') {
|
||||
if (!wf.transitions.some(t => t.from === 'funding_denied')) {
|
||||
gaps.push({kind: 'dead-end', state: 'funding_denied', label: 'Denied',
|
||||
msg: 'No way to revive a denied MOD case. No resubmit, no cancellation path. Once denied, the order is stuck unless someone edits <code>x_fc_mod_status</code> directly.'});
|
||||
}
|
||||
}
|
||||
|
||||
if (['x_fc_sa_status', 'x_fc_odsp_std_status', 'x_fc_ow_status'].includes(wf.field)) {
|
||||
const resume = wf.transitions.find(t => t.from === 'on_hold');
|
||||
if (resume && resume.to === 'quotation') {
|
||||
gaps.push({kind: 'hold-loss', state: 'on_hold', label: 'On Hold',
|
||||
msg: '<code>action_odsp_resume</code> always resumes to <code>quotation</code>, losing all progress regardless of where the order was put on hold. An order held at <code>ready_delivery</code> is reset to the start.'});
|
||||
}
|
||||
if (!wf.transitions.some(t => t.from === 'denied')) {
|
||||
gaps.push({kind: 'dead-end', state: 'denied', label: 'Denied',
|
||||
msg: 'No path out of <code>denied</code>. Once set, the case is stuck.'});
|
||||
}
|
||||
}
|
||||
|
||||
return {gaps, stateStatus};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Mermaid flowchart builder — produces plain text, Mermaid parses it.
|
||||
// ============================================================
|
||||
function buildMermaid(wf, stateStatus) {
|
||||
const lines = ['flowchart LR'];
|
||||
wf.states.forEach(s => {
|
||||
const st = stateStatus[s.key];
|
||||
const safeLabel = s.label.replace(/"/g, '"');
|
||||
const shape = st.isTerminal ? `(("${safeLabel}"))` :
|
||||
st.isDefault ? `(["${safeLabel}"])` :
|
||||
`["${safeLabel}"]`;
|
||||
lines.push(` ${s.key}${shape}`);
|
||||
});
|
||||
const seen = new Set();
|
||||
wf.transitions.forEach(t => {
|
||||
if (t.from === '*') return;
|
||||
const key = `${t.from}->${t.to}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
lines.push(` ${t.from} --> ${t.to}`);
|
||||
});
|
||||
wf.states.forEach(s => {
|
||||
const st = stateStatus[s.key];
|
||||
let cls = 'ok';
|
||||
if (st.status === 'err') {
|
||||
if (st.issues.some(i => i.kind === 'unreachable')) cls = 'unreachable';
|
||||
else cls = 'deadend';
|
||||
} else if (st.isDefault) cls = 'entry';
|
||||
else if (st.isTerminal) cls = 'terminal';
|
||||
lines.push(` class ${s.key} ${cls}`);
|
||||
});
|
||||
lines.push(' classDef ok fill:#1d2330,stroke:#3fbf7f,color:#e6e9ef,stroke-width:1.5px');
|
||||
lines.push(' classDef entry fill:#2b1d40,stroke:#9b5de5,color:#e6e9ef,stroke-width:2.5px');
|
||||
lines.push(' classDef terminal fill:#1a2030,stroke:#5f6b80,color:#c2c9d9,stroke-width:1.5px');
|
||||
lines.push(' classDef unreachable fill:#2a2418,stroke:#f4b400,color:#f4b400,stroke-width:2px,stroke-dasharray:5 3');
|
||||
lines.push(' classDef deadend fill:#2a1820,stroke:#ff5c6c,color:#ff5c6c,stroke-width:2px');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Renderer — DOM-based, no innerHTML
|
||||
// ============================================================
|
||||
const wfData = window.WORKFLOWS_DATA;
|
||||
const wfKeys = Object.keys(wfData);
|
||||
let activeWf = wfKeys[0];
|
||||
let activeTab = 'flow';
|
||||
|
||||
function renderSidebar() {
|
||||
const list = document.getElementById('wf-list');
|
||||
while (list.firstChild) list.removeChild(list.firstChild);
|
||||
wfKeys.forEach(k => {
|
||||
const wf = wfData[k];
|
||||
const {gaps} = analyseWorkflow(wf);
|
||||
const li = el('li', {
|
||||
class: k === activeWf ? 'active' : '',
|
||||
on: {click: () => { activeWf = k; activeTab = 'flow'; renderSidebar(); renderContent(); }}
|
||||
}, [
|
||||
el('span', {class: 'name', text: wf.label}),
|
||||
el('span', {class: 'gap-badge' + (gaps.length === 0 ? ' zero' : ''), text: String(gaps.length)})
|
||||
]);
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function makeStat(label, value, cls) {
|
||||
return el('div', {class: 'stat' + (cls ? ' ' + cls : '')}, [
|
||||
el('div', {class: 'label', text: label}),
|
||||
el('div', {class: 'value', text: String(value)})
|
||||
]);
|
||||
}
|
||||
|
||||
function makeGapListItem(g) {
|
||||
const li = el('li');
|
||||
const kind = el('span', {class: 'gap-kind ' + g.kind, text: g.kind.replace('-', ' ')});
|
||||
li.appendChild(kind);
|
||||
const strong = el('strong', {text: g.label});
|
||||
li.appendChild(strong);
|
||||
li.appendChild(document.createTextNode(' — '));
|
||||
renderSafeInline(li, g.msg);
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
const wf = wfData[activeWf];
|
||||
const {gaps, stateStatus} = analyseWorkflow(wf);
|
||||
const container = document.getElementById('wf-content');
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
const wizardCount = wf.transitions.filter(t => t.kind === 'wizard').length;
|
||||
const cronCount = wf.transitions.filter(t => t.kind === 'cron').length;
|
||||
const autoCount = wf.transitions.filter(t => t.kind === 'auto_write').length;
|
||||
|
||||
container.appendChild(el('h2', {text: wf.label}));
|
||||
const fn = el('div', {class: 'field-name'});
|
||||
fn.appendChild(document.createTextNode(wf.field + ' · default: '));
|
||||
fn.appendChild(el('code', {text: wf.default}));
|
||||
container.appendChild(fn);
|
||||
|
||||
const stats = el('div', {class: 'stats'}, [
|
||||
makeStat('States', wf.states.length),
|
||||
makeStat('Transitions', wf.transitions.length),
|
||||
makeStat('Gaps', gaps.length, gaps.length === 0 ? 'ok' : 'err'),
|
||||
makeStat('Wizards', wizardCount),
|
||||
makeStat('Crons / Auto', cronCount + autoCount)
|
||||
]);
|
||||
container.appendChild(stats);
|
||||
|
||||
// Gaps panel
|
||||
const gapsBox = el('div', {class: 'gaps' + (gaps.length === 0 ? ' zero' : '')});
|
||||
gapsBox.appendChild(el('h3', {text: gaps.length === 0
|
||||
? '\u2713 No gaps detected'
|
||||
: '\u26A0 ' + gaps.length + ' gap' + (gaps.length === 1 ? '' : 's') + ' detected'}));
|
||||
if (gaps.length === 0) {
|
||||
gapsBox.appendChild(el('p', {text: 'This workflow has full coverage: every declared state is reachable, every non-terminal state has an exit, and all transitions are backed by code paths.'}));
|
||||
} else {
|
||||
const ul = el('ul');
|
||||
gaps.forEach(g => ul.appendChild(makeGapListItem(g)));
|
||||
gapsBox.appendChild(ul);
|
||||
}
|
||||
container.appendChild(gapsBox);
|
||||
|
||||
// Tabs
|
||||
const tabs = el('div', {class: 'tabs'});
|
||||
const tabDefs = [
|
||||
{key: 'flow', label: 'Flowchart'},
|
||||
{key: 'states', label: 'States (' + wf.states.length + ')'},
|
||||
{key: 'transitions', label: 'Transitions (' + wf.transitions.length + ')'}
|
||||
];
|
||||
tabDefs.forEach(t => {
|
||||
tabs.appendChild(el('button', {
|
||||
class: activeTab === t.key ? 'active' : '',
|
||||
text: t.label,
|
||||
on: {click: () => { activeTab = t.key; renderContent(); }}
|
||||
}));
|
||||
});
|
||||
container.appendChild(tabs);
|
||||
|
||||
// Flow tab
|
||||
if (activeTab === 'flow') {
|
||||
const legend = el('div', {class: 'legend'}, [
|
||||
el('span', null, [el('span', {class: 'swatch', style: {background: '#9b5de5'}}), 'Entry state']),
|
||||
el('span', null, [el('span', {class: 'swatch', style: {background: '#3fbf7f'}}), 'Healthy']),
|
||||
el('span', null, [el('span', {class: 'swatch', style: {background: '#f4b400'}}), 'Unreachable']),
|
||||
el('span', null, [el('span', {class: 'swatch', style: {background: '#ff5c6c'}}), 'Dead-end']),
|
||||
el('span', null, [el('span', {class: 'swatch', style: {background: '#5f6b80'}}), 'Terminal'])
|
||||
]);
|
||||
container.appendChild(legend);
|
||||
const wrap = el('div', {class: 'mermaid-wrap'});
|
||||
const mm = el('div', {class: 'mermaid', id: 'mermaid-' + activeWf});
|
||||
mm.textContent = buildMermaid(wf, stateStatus);
|
||||
wrap.appendChild(mm);
|
||||
container.appendChild(wrap);
|
||||
|
||||
// Render mermaid async
|
||||
mermaid.initialize({startOnLoad: false, theme: 'base', securityLevel: 'strict', themeVariables: {
|
||||
background: '#161a22', primaryColor: '#1d2330', primaryTextColor: '#e6e9ef',
|
||||
primaryBorderColor: '#3fbf7f', lineColor: '#4f8cff'
|
||||
}});
|
||||
const src = mm.textContent;
|
||||
const renderId = 'mm-svg-' + activeWf + '-' + Date.now();
|
||||
mermaid.render(renderId, src).then(result => {
|
||||
while (mm.firstChild) mm.removeChild(mm.firstChild);
|
||||
// mermaid.render returns an SVG string — parse via DOMParser, no innerHTML
|
||||
const doc = new DOMParser().parseFromString(result.svg, 'image/svg+xml');
|
||||
const svgNode = doc.documentElement;
|
||||
mm.appendChild(document.importNode(svgNode, true));
|
||||
}).catch(err => {
|
||||
while (mm.firstChild) mm.removeChild(mm.firstChild);
|
||||
const pre = el('pre', {style: {color: 'var(--err)', whiteSpace: 'pre-wrap'}});
|
||||
pre.textContent = 'Mermaid error: ' + err.message + '\n\n' + src;
|
||||
mm.appendChild(pre);
|
||||
});
|
||||
}
|
||||
|
||||
// States tab
|
||||
if (activeTab === 'states') {
|
||||
const table = el('table');
|
||||
const thead = el('thead');
|
||||
const headRow = el('tr');
|
||||
['State', 'Key', 'Status', 'In', 'Out'].forEach(h => headRow.appendChild(el('th', {text: h})));
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
const tbody = el('tbody');
|
||||
wf.states.forEach(s => {
|
||||
const st = stateStatus[s.key];
|
||||
let pillClass = 'ok', pillLabel = 'Healthy';
|
||||
if (st.isDefault) { pillClass = 'entry'; pillLabel = 'Entry'; }
|
||||
else if (st.isTerminal) { pillClass = 'terminal'; pillLabel = 'Terminal'; }
|
||||
if (st.status === 'err') {
|
||||
if (st.issues.some(i => i.kind === 'unreachable')) { pillClass = 'warn'; pillLabel = 'Unreachable'; }
|
||||
else { pillClass = 'err'; pillLabel = 'Dead-end'; }
|
||||
}
|
||||
const tr = el('tr', {class: st.status === 'err' ? 'has-issue' : ''});
|
||||
tr.appendChild(el('td', null, [el('strong', {text: s.label})]));
|
||||
tr.appendChild(el('td', null, [el('code', {class: 'state-key', text: s.key})]));
|
||||
tr.appendChild(el('td', null, [el('span', {class: 'pill ' + pillClass, text: pillLabel})]));
|
||||
tr.appendChild(el('td', {class: 'count', text: String(st.inbound.length)}));
|
||||
tr.appendChild(el('td', {class: 'count', text: String(st.outbound.length)}));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
container.appendChild(table);
|
||||
}
|
||||
|
||||
// Transitions tab
|
||||
if (activeTab === 'transitions') {
|
||||
const list = el('div', {class: 'tr-list'});
|
||||
wf.transitions.forEach(t => {
|
||||
const row = el('div', {class: 'tr-row'});
|
||||
row.appendChild(el('div', null, [el('code', {class: 'state-key', text: t.from})]));
|
||||
row.appendChild(el('div', {class: 'tr-arrow', text: '\u2192'}));
|
||||
row.appendChild(el('div', null, [el('code', {class: 'state-key', text: t.to})]));
|
||||
const trig = el('div', {class: 'tr-trigger'});
|
||||
trig.appendChild(document.createTextNode(t.trigger));
|
||||
const fileLine = el('span', {class: 'file', text: t.file + (t.line ? ':' + t.line : '')});
|
||||
trig.appendChild(fileLine);
|
||||
row.appendChild(trig);
|
||||
const kind = el('div', {class: 'tr-kind'});
|
||||
kind.appendChild(el('span', {class: 'kind-' + t.kind, text: t.kind.replace('_', ' ')}));
|
||||
row.appendChild(kind);
|
||||
list.appendChild(row);
|
||||
});
|
||||
container.appendChild(list);
|
||||
}
|
||||
}
|
||||
|
||||
renderSidebar();
|
||||
renderContent();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
197
docs/workflow-explorer/workflows.js
Normal file
197
docs/workflow-explorer/workflows.js
Normal file
@@ -0,0 +1,197 @@
|
||||
// Workflow data extracted from fusion_claims/models/sale_order.py and wizard/*.py
|
||||
// Generated 2026-04-08. If the code changes, regenerate this file.
|
||||
|
||||
window.WORKFLOWS_DATA = {
|
||||
"adp_application": {
|
||||
"field": "x_fc_adp_application_status",
|
||||
"label": "ADP Application",
|
||||
"default": "quotation",
|
||||
"terminal": ["case_closed", "cancelled"],
|
||||
"states": [
|
||||
{"key": "quotation", "label": "Quotation Stage"},
|
||||
{"key": "assessment_scheduled", "label": "Assessment Scheduled"},
|
||||
{"key": "assessment_completed", "label": "Assessment Completed"},
|
||||
{"key": "waiting_for_application", "label": "Waiting for Application"},
|
||||
{"key": "application_received", "label": "Application Received"},
|
||||
{"key": "ready_submission", "label": "Ready for Submission"},
|
||||
{"key": "submitted", "label": "Application Submitted"},
|
||||
{"key": "accepted", "label": "Accepted by ADP"},
|
||||
{"key": "rejected", "label": "Rejected by ADP"},
|
||||
{"key": "resubmitted", "label": "Application Resubmitted"},
|
||||
{"key": "needs_correction", "label": "Needs Correction"},
|
||||
{"key": "approved", "label": "Application Approved"},
|
||||
{"key": "approved_deduction", "label": "Approved with Deduction"},
|
||||
{"key": "ready_delivery", "label": "Ready for Delivery"},
|
||||
{"key": "denied", "label": "Application Denied"},
|
||||
{"key": "withdrawn", "label": "Application Withdrawn"},
|
||||
{"key": "ready_bill", "label": "Ready to Bill"},
|
||||
{"key": "billed", "label": "Billed to ADP"},
|
||||
{"key": "case_closed", "label": "Case Closed"},
|
||||
{"key": "on_hold", "label": "On Hold"},
|
||||
{"key": "cancelled", "label": "Cancelled"},
|
||||
{"key": "expired", "label": "Application Expired"}
|
||||
],
|
||||
"transitions": [
|
||||
{"from": "quotation", "to": "assessment_scheduled", "trigger": "schedule_assessment_wizard.action_schedule", "file": "wizard/schedule_assessment_wizard.py", "line": 118, "kind": "wizard"},
|
||||
{"from": "assessment_scheduled", "to": "assessment_completed", "trigger": "assessment_completed_wizard.action_confirm", "file": "wizard/assessment_completed_wizard.py", "line": 105, "kind": "wizard"},
|
||||
{"from": "assessment_completed", "to": "waiting_for_application", "trigger": "auto-transition on status_email write()", "file": "models/sale_order.py", "line": 6017, "kind": "auto_write"},
|
||||
{"from": "assessment_completed", "to": "application_received", "trigger": "application_received_wizard.action_confirm", "file": "wizard/application_received_wizard.py", "line": 136, "kind": "wizard"},
|
||||
{"from": "waiting_for_application", "to": "application_received", "trigger": "application_received_wizard.action_confirm", "file": "wizard/application_received_wizard.py", "line": 136, "kind": "wizard"},
|
||||
{"from": "application_received", "to": "ready_submission", "trigger": "ready_for_submission_wizard.action_confirm", "file": "wizard/ready_for_submission_wizard.py", "line": 159, "kind": "wizard"},
|
||||
{"from": "ready_submission", "to": "submitted", "trigger": "submission_verification_wizard.action_confirm_submission", "file": "wizard/submission_verification_wizard.py", "line": 288, "kind": "wizard"},
|
||||
{"from": "needs_correction", "to": "resubmitted", "trigger": "submission_verification_wizard.action_confirm_submission", "file": "wizard/submission_verification_wizard.py", "line": 288, "kind": "wizard"},
|
||||
{"from": "submitted", "to": "accepted", "trigger": "action_mark_accepted", "file": "models/sale_order.py", "line": 3563, "kind": "action_method"},
|
||||
{"from": "resubmitted", "to": "accepted", "trigger": "action_mark_accepted", "file": "models/sale_order.py", "line": 3563, "kind": "action_method"},
|
||||
{"from": "submitted", "to": "approved", "trigger": "device_approval_wizard.action_confirm", "file": "wizard/device_approval_wizard.py", "line": 290, "kind": "wizard"},
|
||||
{"from": "resubmitted", "to": "approved", "trigger": "device_approval_wizard.action_confirm", "file": "wizard/device_approval_wizard.py", "line": 290, "kind": "wizard"},
|
||||
{"from": "accepted", "to": "approved", "trigger": "device_approval_wizard.action_confirm", "file": "wizard/device_approval_wizard.py", "line": 290, "kind": "wizard"},
|
||||
{"from": "submitted", "to": "approved_deduction", "trigger": "device_approval_wizard.action_confirm", "file": "wizard/device_approval_wizard.py", "line": 290, "kind": "wizard"},
|
||||
{"from": "resubmitted", "to": "approved_deduction", "trigger": "device_approval_wizard.action_confirm", "file": "wizard/device_approval_wizard.py", "line": 290, "kind": "wizard"},
|
||||
{"from": "accepted", "to": "approved_deduction", "trigger": "device_approval_wizard.action_confirm", "file": "wizard/device_approval_wizard.py", "line": 290, "kind": "wizard"},
|
||||
{"from": "approved", "to": "ready_delivery", "trigger": "ready_for_delivery_wizard.action_confirm", "file": "wizard/ready_for_delivery_wizard.py", "line": 108, "kind": "wizard"},
|
||||
{"from": "approved_deduction", "to": "ready_delivery", "trigger": "ready_for_delivery_wizard.action_confirm", "file": "wizard/ready_for_delivery_wizard.py", "line": 108, "kind": "wizard"},
|
||||
{"from": "*", "to": "ready_delivery", "trigger": "technician_task complete", "file": "models/technician_task.py", "line": 228, "kind": "auto_write"},
|
||||
{"from": "ready_delivery", "to": "approved", "trigger": "technician_task cancel", "file": "models/technician_task.py", "line": 327, "kind": "auto_write"},
|
||||
{"from": "ready_delivery", "to": "ready_bill", "trigger": "ready_to_bill_wizard.action_confirm", "file": "wizard/ready_to_bill_wizard.py", "line": null, "kind": "wizard"},
|
||||
{"from": "ready_bill", "to": "billed", "trigger": "adp_export_wizard.action_export", "file": "wizard/adp_export_wizard.py", "line": null, "kind": "wizard"},
|
||||
{"from": "billed", "to": "case_closed", "trigger": "_cron_auto_close_billed_cases", "file": "models/sale_order.py", "line": 6852, "kind": "cron"},
|
||||
{"from": "withdrawn", "to": "ready_submission", "trigger": "action_resubmit_from_withdrawn", "file": "models/sale_order.py", "line": 3667, "kind": "action_method"}
|
||||
]
|
||||
},
|
||||
"mod": {
|
||||
"field": "x_fc_mod_status",
|
||||
"label": "March of Dimes",
|
||||
"default": "need_to_schedule",
|
||||
"terminal": ["case_closed", "cancelled"],
|
||||
"states": [
|
||||
{"key": "need_to_schedule", "label": "Schedule Assessment"},
|
||||
{"key": "assessment_scheduled", "label": "Assessment Booked"},
|
||||
{"key": "assessment_completed", "label": "Assessment Done"},
|
||||
{"key": "processing_drawings", "label": "Processing Drawing"},
|
||||
{"key": "quote_submitted", "label": "Quote Sent"},
|
||||
{"key": "awaiting_funding", "label": "Awaiting Funding"},
|
||||
{"key": "funding_approved", "label": "Approved"},
|
||||
{"key": "funding_denied", "label": "Denied"},
|
||||
{"key": "contract_received", "label": "PCA Received"},
|
||||
{"key": "in_production", "label": "In Production"},
|
||||
{"key": "project_complete", "label": "Complete"},
|
||||
{"key": "pod_submitted", "label": "POD Sent"},
|
||||
{"key": "case_closed", "label": "Closed"},
|
||||
{"key": "on_hold", "label": "On Hold"},
|
||||
{"key": "cancelled", "label": "Cancelled"}
|
||||
],
|
||||
"transitions": [
|
||||
{"from": "need_to_schedule", "to": "assessment_scheduled", "trigger": "action_mod_schedule_assessment", "file": "models/sale_order.py", "line": 7018, "kind": "action_method"},
|
||||
{"from": "assessment_scheduled", "to": "assessment_completed", "trigger": "action_mod_complete_assessment", "file": "models/sale_order.py", "line": 7025, "kind": "action_method"},
|
||||
{"from": "assessment_completed", "to": "processing_drawings", "trigger": "action_mod_processing_drawing", "file": "models/sale_order.py", "line": 7035, "kind": "action_method"},
|
||||
{"from": "processing_drawings", "to": "quote_submitted", "trigger": "send_to_mod_wizard.action_send (quote)", "file": "wizard/send_to_mod_wizard.py", "line": 203, "kind": "wizard"},
|
||||
{"from": "quote_submitted", "to": "awaiting_funding", "trigger": "mod_awaiting_funding_wizard.action_confirm", "file": "wizard/mod_awaiting_funding_wizard.py", "line": 34, "kind": "wizard"},
|
||||
{"from": "awaiting_funding", "to": "funding_approved", "trigger": "mod_funding_approved_wizard.action_confirm", "file": "wizard/mod_funding_approved_wizard.py", "line": 48, "kind": "wizard"},
|
||||
{"from": "awaiting_funding", "to": "funding_denied", "trigger": "action_mod_funding_denied", "file": "models/sale_order.py", "line": 7076, "kind": "action_method"},
|
||||
{"from": "funding_approved", "to": "contract_received", "trigger": "mod_pca_received_wizard.action_confirm", "file": "wizard/mod_pca_received_wizard.py", "line": 143, "kind": "wizard"},
|
||||
{"from": "contract_received", "to": "in_production", "trigger": "action_mod_in_production", "file": "models/sale_order.py", "line": 7093, "kind": "action_method"},
|
||||
{"from": "in_production", "to": "project_complete", "trigger": "action_mod_project_complete", "file": "models/sale_order.py", "line": 7100, "kind": "action_method"},
|
||||
{"from": "project_complete", "to": "pod_submitted", "trigger": "send_to_mod_wizard.action_send (pod)", "file": "wizard/send_to_mod_wizard.py", "line": 221, "kind": "wizard"},
|
||||
{"from": "pod_submitted", "to": "case_closed", "trigger": "action_mod_close_case", "file": "models/sale_order.py", "line": 7123, "kind": "action_method"},
|
||||
{"from": "*", "to": "on_hold", "trigger": "action_mod_on_hold", "file": "models/sale_order.py", "line": 7129, "kind": "action_method"},
|
||||
{"from": "on_hold", "to": "in_production", "trigger": "action_mod_resume", "file": "models/sale_order.py", "line": 7134, "kind": "action_method"},
|
||||
{"from": "*", "to": "cancelled", "trigger": "action_cancel", "file": "models/sale_order.py", "line": 7142, "kind": "action_method"}
|
||||
]
|
||||
},
|
||||
"sa_mobility": {
|
||||
"field": "x_fc_sa_status",
|
||||
"label": "SA Mobility",
|
||||
"default": "quotation",
|
||||
"terminal": ["case_closed", "cancelled"],
|
||||
"states": [
|
||||
{"key": "quotation", "label": "Quotation"},
|
||||
{"key": "form_ready", "label": "SA Form Ready"},
|
||||
{"key": "submitted_to_sa", "label": "Submitted to SA Mobility"},
|
||||
{"key": "pre_approved", "label": "Pre-Approved"},
|
||||
{"key": "ready_delivery", "label": "Ready for Delivery"},
|
||||
{"key": "delivered", "label": "Delivered"},
|
||||
{"key": "pod_submitted", "label": "POD Submitted"},
|
||||
{"key": "payment_received", "label": "Payment Received"},
|
||||
{"key": "case_closed", "label": "Case Closed"},
|
||||
{"key": "on_hold", "label": "On Hold"},
|
||||
{"key": "cancelled", "label": "Cancelled"},
|
||||
{"key": "denied", "label": "Denied"}
|
||||
],
|
||||
"transitions": [
|
||||
{"from": "quotation", "to": "form_ready", "trigger": "odsp_sa_mobility_wizard.action_confirm", "file": "wizard/odsp_sa_mobility_wizard.py", "line": null, "kind": "wizard"},
|
||||
{"from": "form_ready", "to": "submitted_to_sa", "trigger": "odsp_submit_to_odsp_wizard.action_confirm", "file": "wizard/odsp_submit_to_odsp_wizard.py", "line": 105, "kind": "wizard"},
|
||||
{"from": "submitted_to_sa", "to": "pre_approved", "trigger": "odsp_pre_approved_wizard.action_confirm", "file": "wizard/odsp_pre_approved_wizard.py", "line": 68, "kind": "wizard"},
|
||||
{"from": "pre_approved", "to": "ready_delivery", "trigger": "odsp_ready_delivery_wizard.action_confirm", "file": "wizard/odsp_ready_delivery_wizard.py", "line": 170, "kind": "wizard"},
|
||||
{"from": "ready_delivery", "to": "delivered", "trigger": "_odsp_advance_status('delivered')", "file": "models/sale_order.py", "line": 1212, "kind": "auto_write"},
|
||||
{"from": "delivered", "to": "pod_submitted", "trigger": "_odsp_advance_status('pod_submitted')", "file": "models/sale_order.py", "line": 1225, "kind": "auto_write"},
|
||||
{"from": "pod_submitted", "to": "payment_received", "trigger": "invoice payment posted", "file": "models/account_move.py", "line": 59, "kind": "auto_write"},
|
||||
{"from": "payment_received", "to": "case_closed", "trigger": "_cron_auto_close_odsp_paid_cases", "file": "models/sale_order.py", "line": 6899, "kind": "cron"},
|
||||
{"from": "*", "to": "on_hold", "trigger": "action_odsp_on_hold", "file": "models/sale_order.py", "line": 1396, "kind": "action_method"},
|
||||
{"from": "on_hold", "to": "quotation", "trigger": "action_odsp_resume", "file": "models/sale_order.py", "line": 1401, "kind": "action_method"},
|
||||
{"from": "*", "to": "denied", "trigger": "action_odsp_denied", "file": "models/sale_order.py", "line": 1405, "kind": "action_method"},
|
||||
{"from": "*", "to": "cancelled", "trigger": "action_cancel", "file": "models/sale_order.py", "line": 1141, "kind": "action_method"}
|
||||
]
|
||||
},
|
||||
"odsp_standard": {
|
||||
"field": "x_fc_odsp_std_status",
|
||||
"label": "ODSP Standard",
|
||||
"default": "quotation",
|
||||
"terminal": ["case_closed", "cancelled"],
|
||||
"states": [
|
||||
{"key": "quotation", "label": "Quotation"},
|
||||
{"key": "submitted_to_odsp", "label": "Submitted to ODSP"},
|
||||
{"key": "pre_approved", "label": "Pre-Approved"},
|
||||
{"key": "ready_delivery", "label": "Ready for Delivery"},
|
||||
{"key": "delivered", "label": "Delivered"},
|
||||
{"key": "pod_submitted", "label": "POD Submitted"},
|
||||
{"key": "payment_received", "label": "Payment Received"},
|
||||
{"key": "case_closed", "label": "Case Closed"},
|
||||
{"key": "on_hold", "label": "On Hold"},
|
||||
{"key": "cancelled", "label": "Cancelled"},
|
||||
{"key": "denied", "label": "Denied"}
|
||||
],
|
||||
"transitions": [
|
||||
{"from": "quotation", "to": "submitted_to_odsp", "trigger": "odsp_submit_to_odsp_wizard.action_confirm", "file": "wizard/odsp_submit_to_odsp_wizard.py", "line": 105, "kind": "wizard"},
|
||||
{"from": "submitted_to_odsp", "to": "pre_approved", "trigger": "odsp_pre_approved_wizard.action_confirm", "file": "wizard/odsp_pre_approved_wizard.py", "line": 68, "kind": "wizard"},
|
||||
{"from": "pre_approved", "to": "ready_delivery", "trigger": "odsp_ready_delivery_wizard.action_confirm", "file": "wizard/odsp_ready_delivery_wizard.py", "line": 170, "kind": "wizard"},
|
||||
{"from": "ready_delivery", "to": "delivered", "trigger": "_odsp_advance_status('delivered')", "file": "models/sale_order.py", "line": 1215, "kind": "auto_write"},
|
||||
{"from": "delivered", "to": "pod_submitted", "trigger": "_odsp_advance_status('pod_submitted')", "file": "models/sale_order.py", "line": 1225, "kind": "auto_write"},
|
||||
{"from": "pod_submitted", "to": "payment_received", "trigger": "invoice payment posted", "file": "models/account_move.py", "line": 59, "kind": "auto_write"},
|
||||
{"from": "payment_received", "to": "case_closed", "trigger": "_cron_auto_close_odsp_paid_cases", "file": "models/sale_order.py", "line": 6899, "kind": "cron"},
|
||||
{"from": "*", "to": "on_hold", "trigger": "action_odsp_on_hold", "file": "models/sale_order.py", "line": 1396, "kind": "action_method"},
|
||||
{"from": "on_hold", "to": "quotation", "trigger": "action_odsp_resume", "file": "models/sale_order.py", "line": 1401, "kind": "action_method"},
|
||||
{"from": "*", "to": "denied", "trigger": "action_odsp_denied", "file": "models/sale_order.py", "line": 1405, "kind": "action_method"},
|
||||
{"from": "*", "to": "cancelled", "trigger": "action_cancel", "file": "models/sale_order.py", "line": 1141, "kind": "action_method"}
|
||||
]
|
||||
},
|
||||
"ontario_works": {
|
||||
"field": "x_fc_ow_status",
|
||||
"label": "Ontario Works",
|
||||
"default": "quotation",
|
||||
"terminal": ["case_closed", "cancelled"],
|
||||
"states": [
|
||||
{"key": "quotation", "label": "Quotation"},
|
||||
{"key": "documents_ready", "label": "Documents Ready"},
|
||||
{"key": "submitted_to_ow", "label": "Submitted to Ontario Works"},
|
||||
{"key": "payment_received", "label": "Payment Received"},
|
||||
{"key": "ready_delivery", "label": "Ready for Delivery"},
|
||||
{"key": "delivered", "label": "Delivered"},
|
||||
{"key": "case_closed", "label": "Case Closed"},
|
||||
{"key": "on_hold", "label": "On Hold"},
|
||||
{"key": "cancelled", "label": "Cancelled"},
|
||||
{"key": "denied", "label": "Denied"}
|
||||
],
|
||||
"transitions": [
|
||||
{"from": "quotation", "to": "documents_ready", "trigger": "odsp_discretionary_wizard.action_confirm (docs)", "file": "wizard/odsp_discretionary_wizard.py", "line": 245, "kind": "wizard"},
|
||||
{"from": "documents_ready", "to": "submitted_to_ow", "trigger": "odsp_discretionary_wizard.action_confirm (submit)", "file": "wizard/odsp_discretionary_wizard.py", "line": 260, "kind": "wizard"},
|
||||
{"from": "submitted_to_ow", "to": "payment_received", "trigger": "invoice payment posted", "file": "models/account_move.py", "line": 59, "kind": "auto_write"},
|
||||
{"from": "payment_received", "to": "ready_delivery", "trigger": "odsp_ready_delivery_wizard.action_confirm", "file": "wizard/odsp_ready_delivery_wizard.py", "line": 170, "kind": "wizard"},
|
||||
{"from": "ready_delivery", "to": "delivered", "trigger": "_odsp_advance_status('delivered')", "file": "models/sale_order.py", "line": 1217, "kind": "auto_write"},
|
||||
{"from": "delivered", "to": "case_closed", "trigger": "_cron_auto_close_odsp_paid_cases", "file": "models/sale_order.py", "line": 6899, "kind": "cron"},
|
||||
{"from": "*", "to": "on_hold", "trigger": "action_odsp_on_hold", "file": "models/sale_order.py", "line": 1396, "kind": "action_method"},
|
||||
{"from": "on_hold", "to": "quotation", "trigger": "action_odsp_resume", "file": "models/sale_order.py", "line": 1401, "kind": "action_method"},
|
||||
{"from": "*", "to": "denied", "trigger": "action_odsp_denied", "file": "models/sale_order.py", "line": 1405, "kind": "action_method"},
|
||||
{"from": "*", "to": "cancelled", "trigger": "action_cancel", "file": "models/sale_order.py", "line": 1141, "kind": "action_method"}
|
||||
]
|
||||
}
|
||||
};
|
||||
1197
entech-website-design.html
Normal file
1197
entech-website-design.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1074,7 +1074,21 @@ class WooInstance(models.Model):
|
||||
|
||||
if pm.last_synced:
|
||||
odoo_changed = pm.product_id.write_date > pm.last_synced
|
||||
woo_changed = True
|
||||
# WooCommerce returns ISO 8601 in date_modified_gmt (UTC).
|
||||
wc_modified_str = (
|
||||
wc_product.get('date_modified_gmt')
|
||||
or wc_product.get('date_modified')
|
||||
)
|
||||
if wc_modified_str:
|
||||
try:
|
||||
wc_modified = fields.Datetime.from_string(
|
||||
wc_modified_str.replace('T', ' ').split('.')[0]
|
||||
)
|
||||
woo_changed = wc_modified and wc_modified > pm.last_synced
|
||||
except (ValueError, TypeError):
|
||||
woo_changed = False
|
||||
else:
|
||||
woo_changed = False
|
||||
|
||||
if odoo_changed and woo_changed:
|
||||
self.env['woo.conflict'].create({
|
||||
|
||||
@@ -28,7 +28,7 @@ access_woo_category_map_manager,woo.category.map.manager,model_woo_category_map,
|
||||
access_woo_setup_wizard_manager,woo.setup.wizard.manager,model_woo_setup_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1
|
||||
access_woo_product_fetch_manager,woo.product.fetch.manager,model_woo_product_fetch,fusion_woocommerce.group_woo_manager,1,1,1,1
|
||||
access_woo_product_create_wizard_manager,woo.product.create.wizard.manager,model_woo_product_create_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1
|
||||
access_woo_category_filter_manager,woo.category.filter.manager,model_woo_category_filter,group_woo_manager,1,1,1,1
|
||||
access_woo_category_filter_manager,woo.category.filter.manager,model_woo_category_filter,fusion_woocommerce.group_woo_manager,1,1,1,1
|
||||
access_woo_product_create_variant_line_manager,woo.product.create.variant.line.manager,model_woo_product_create_variant_line,fusion_woocommerce.group_woo_manager,1,1,1,1
|
||||
access_woo_variant_push_wizard_manager,woo.variant.push.wizard.manager,model_woo_variant_push_wizard,group_woo_manager,1,1,1,1
|
||||
access_woo_variant_push_line_manager,woo.variant.push.line.manager,model_woo_variant_push_line,group_woo_manager,1,1,1,1
|
||||
access_woo_variant_push_wizard_manager,woo.variant.push.wizard.manager,model_woo_variant_push_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1
|
||||
access_woo_variant_push_line_manager,woo.variant.push.line.manager,model_woo_variant_push_line,fusion_woocommerce.group_woo_manager,1,1,1,1
|
||||
|
||||
|
@@ -14,4 +14,11 @@
|
||||
<field name="name">WooCommerce Manager</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_woo_user'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Auto-grant WooCommerce Manager to every internal user so the module
|
||||
works out of the box without permission errors. Admins can revoke
|
||||
later by removing users from this implied group. -->
|
||||
<record id="base.group_user" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('group_woo_manager'))]"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
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`
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Fusion Authorizer & Sales Portal',
|
||||
'version': '19.0.2.7.0',
|
||||
'version': '19.0.2.8.0',
|
||||
'category': 'Sales/Portal',
|
||||
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
|
||||
'description': """
|
||||
|
||||
@@ -1097,6 +1097,18 @@ class AssessmentPortal(CustomerPortal):
|
||||
if kw.get('client_postal'):
|
||||
address_parts.append(kw['client_postal'])
|
||||
|
||||
# 2026-04 portal audit fix — accept funding source from the form
|
||||
# so the generated sale order enters the correct workflow. Defaults
|
||||
# to direct_private for backwards compatibility if the form field
|
||||
# is missing (old cached browsers, legacy API calls).
|
||||
valid_funding_sources = (
|
||||
'march_of_dimes', 'odsp', 'wsib',
|
||||
'insurance', 'direct_private', 'other',
|
||||
)
|
||||
funding_source = kw.get('funding_source', 'direct_private')
|
||||
if funding_source not in valid_funding_sources:
|
||||
funding_source = 'direct_private'
|
||||
|
||||
vals = {
|
||||
'assessment_type': kw['assessment_type'],
|
||||
'client_name': kw['client_name'],
|
||||
@@ -1110,6 +1122,7 @@ class AssessmentPortal(CustomerPortal):
|
||||
'assessment_date': assessment_date,
|
||||
'booking_source': booking_source,
|
||||
'modification_requested': kw.get('modification_requested', ''),
|
||||
'x_fc_funding_source': funding_source,
|
||||
}
|
||||
|
||||
if sales_rep_id:
|
||||
|
||||
@@ -50,13 +50,49 @@ class FusionAccessibilityAssessment(models.Model):
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
('draft', 'Draft'),
|
||||
('scheduled', 'Visit Scheduled'),
|
||||
('in_progress', 'Visit In Progress'),
|
||||
('pending_review', 'Pending Review'),
|
||||
('completed', 'Completed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
],
|
||||
string='Status',
|
||||
default='draft',
|
||||
tracking=True,
|
||||
copy=False,
|
||||
group_expand='_expand_states',
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# FUNDING SOURCE (2026-04 portal audit fix)
|
||||
# Captures which funding stream the accessibility project is for so the
|
||||
# generated sale order enters the right downstream workflow.
|
||||
# ==========================================================================
|
||||
x_fc_funding_source = fields.Selection(
|
||||
selection=[
|
||||
('march_of_dimes', 'March of Dimes'),
|
||||
('odsp', 'ODSP'),
|
||||
('wsib', 'WSIB'),
|
||||
('insurance', 'Private Insurance'),
|
||||
('direct_private', 'Private Pay (Direct)'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='Funding Source',
|
||||
required=True,
|
||||
default='direct_private',
|
||||
tracking=True,
|
||||
help='Which funding stream this accessibility project is for. Determines '
|
||||
'the x_fc_sale_type on the generated sale order and which downstream '
|
||||
'workflow (MOD / ODSP / WSIB / direct pay) the case enters.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _expand_states(self, states, domain):
|
||||
"""Kanban group expansion — always show all 6 workflow states."""
|
||||
return [
|
||||
'draft', 'scheduled', 'in_progress',
|
||||
'pending_review', 'completed', 'cancelled',
|
||||
]
|
||||
|
||||
# Client Information
|
||||
client_name = fields.Char(string='Client Name', required=True)
|
||||
@@ -455,15 +491,37 @@ class FusionAccessibilityAssessment(models.Model):
|
||||
# ==========================================================================
|
||||
|
||||
def action_complete(self):
|
||||
"""Complete the assessment and create a Sale Order"""
|
||||
"""Complete the assessment and create a Sale Order.
|
||||
|
||||
2026-04 portal audit fix — guards against double-completion
|
||||
(clicking the button twice used to create two sale orders) and
|
||||
requires a funding source to be set so the generated sale order
|
||||
enters the correct downstream workflow.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
|
||||
if self.state == 'completed':
|
||||
raise UserError(_('This assessment is already completed.'))
|
||||
if self.state == 'cancelled':
|
||||
raise UserError(_('Cancelled assessments cannot be completed.'))
|
||||
if self.sale_order_id:
|
||||
raise UserError(_(
|
||||
'A sale order has already been created from this assessment (%s). '
|
||||
'Cannot create a second one.'
|
||||
) % self.sale_order_id.name)
|
||||
|
||||
if not self.client_name:
|
||||
raise UserError(_('Please enter the client name.'))
|
||||
|
||||
if not self.x_fc_funding_source:
|
||||
raise UserError(_(
|
||||
'Please select a funding source before completing the assessment. '
|
||||
'This determines which workflow the case enters (March of Dimes, '
|
||||
'ODSP, WSIB, Insurance, or Private Pay).'
|
||||
))
|
||||
|
||||
# Create or find partner
|
||||
partner = self._ensure_partner()
|
||||
|
||||
|
||||
# Create draft sale order
|
||||
sale_order = self._create_draft_sale_order(partner)
|
||||
|
||||
@@ -691,22 +749,56 @@ class FusionAccessibilityAssessment(models.Model):
|
||||
return partner
|
||||
|
||||
def _create_draft_sale_order(self, partner):
|
||||
"""Create a draft sale order from the assessment"""
|
||||
"""Create a draft sale order from the accessibility assessment.
|
||||
|
||||
2026-04 portal audit fix — previously hardcoded x_fc_sale_type to
|
||||
'direct_private' for ALL accessibility assessments, which meant MOD,
|
||||
ODSP, WSIB, and insurance cases never entered their respective
|
||||
workflows. Now uses x_fc_funding_source to pick the correct sale
|
||||
type, wires the authorizer through, sets the back-reference, and
|
||||
for MOD cases pre-populates x_fc_mod_accessibility_specialist_id
|
||||
from the sales rep's partner record.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
|
||||
SaleOrder = self.env['sale.order'].sudo()
|
||||
|
||||
|
||||
type_labels = dict(self._fields['assessment_type'].selection)
|
||||
type_label = type_labels.get(self.assessment_type, 'Accessibility')
|
||||
|
||||
|
||||
# Map funding source → sale type (currently 1:1 but kept explicit
|
||||
# so the mapping is easy to adjust later without breaking data).
|
||||
sale_type_map = {
|
||||
'march_of_dimes': 'march_of_dimes',
|
||||
'odsp': 'odsp',
|
||||
'wsib': 'wsib',
|
||||
'insurance': 'insurance',
|
||||
'direct_private': 'direct_private',
|
||||
'other': 'other',
|
||||
}
|
||||
sale_type = sale_type_map.get(self.x_fc_funding_source, 'direct_private')
|
||||
|
||||
so_vals = {
|
||||
'partner_id': partner.id,
|
||||
'user_id': self.sales_rep_id.id if self.sales_rep_id else self.env.user.id,
|
||||
'state': 'draft',
|
||||
'origin': f'Accessibility: {self.reference} ({type_label})',
|
||||
'x_fc_sale_type': 'direct_private', # Accessibility items typically private pay
|
||||
'x_fc_sale_type': sale_type,
|
||||
# Back-reference so the sale order knows which accessibility
|
||||
# assessment spawned it (see field definition in fusion_authorizer_portal/models/sale_order.py).
|
||||
'accessibility_assessment_id': self.id,
|
||||
}
|
||||
|
||||
|
||||
# Propagate the authorizer (OT) when one is set on the assessment.
|
||||
if self.authorizer_id:
|
||||
so_vals['x_fc_authorizer_id'] = self.authorizer_id.id
|
||||
|
||||
# For MOD cases: pre-populate the accessibility specialist from the
|
||||
# sales rep who did the visit, so the MOD workflow knows who to
|
||||
# credit on the case without manual data entry.
|
||||
if sale_type == 'march_of_dimes' and self.sales_rep_id and self.sales_rep_id.partner_id:
|
||||
so_vals['x_fc_mod_accessibility_specialist_id'] = self.sales_rep_id.partner_id.id
|
||||
|
||||
sale_order = SaleOrder.create(so_vals)
|
||||
_logger.info(f"Created draft sale order {sale_order.name} from accessibility assessment {self.reference}")
|
||||
|
||||
|
||||
@@ -33,12 +33,25 @@ class SaleOrder(models.Model):
|
||||
compute='_compute_portal_document_count',
|
||||
)
|
||||
|
||||
# Link to assessment
|
||||
# Link to assessment (ADP equipment assessment — rollators, wheelchairs, powerchairs)
|
||||
assessment_id = fields.Many2one(
|
||||
'fusion.assessment',
|
||||
string='Source Assessment',
|
||||
string='Source ADP Assessment',
|
||||
readonly=True,
|
||||
help='The assessment that created this sale order',
|
||||
help='The ADP equipment assessment that created this sale order',
|
||||
)
|
||||
|
||||
# 2026-04 portal audit fix — link to the accessibility (modification)
|
||||
# assessment if this sale order came from a stair lift / VPL / ceiling lift
|
||||
# / ramp / bathroom modification / tub cutout visit. Previously there
|
||||
# was no way to trace a MOD/ODSP sale order back to its source assessment.
|
||||
accessibility_assessment_id = fields.Many2one(
|
||||
'fusion.accessibility.assessment',
|
||||
string='Source Accessibility Assessment',
|
||||
readonly=True,
|
||||
help='The accessibility (modification) assessment that created this '
|
||||
'sale order — stair lift, VPL, ceiling lift, ramp, bathroom mod, '
|
||||
'or tub cutout visits.',
|
||||
)
|
||||
|
||||
# Authorizer helper field (consolidates multiple possible fields)
|
||||
|
||||
@@ -45,6 +45,25 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Funding Source (2026-04 portal audit fix —
|
||||
required so the generated sale order enters
|
||||
the correct downstream workflow: MOD, ODSP, etc) -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">How is this project being funded?<span class="text-danger"> *</span></label>
|
||||
<select name="funding_source" class="form-select" required="">
|
||||
<option value="">Select...</option>
|
||||
<option value="march_of_dimes">March of Dimes</option>
|
||||
<option value="odsp">ODSP</option>
|
||||
<option value="wsib">WSIB</option>
|
||||
<option value="insurance">Private Insurance</option>
|
||||
<option value="direct_private">Private Pay (Direct)</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
This determines which workflow the case enters after the visit.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">What are you looking for?</label>
|
||||
<textarea name="modification_requested" class="form-control" rows="2"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
# App information
|
||||
"name": "Canada Post",
|
||||
"version": "19.0.2.0.0",
|
||||
"version": "19.0.2.1.0",
|
||||
"category": "Inventory/Delivery",
|
||||
"summary": "Canada Post shipping integration — live pricing, "
|
||||
"label generation, shipment tracking, and "
|
||||
@@ -10,6 +10,7 @@
|
||||
"depends": ["stock_delivery", "sale_management", "mail"],
|
||||
# Views
|
||||
"data": [
|
||||
"security/security.xml",
|
||||
"security/ir.model.access.csv",
|
||||
"data/ir_sequence_data.xml",
|
||||
"data/fusion_canada_post_data.xml",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_cp_shipment_user,fusion.cp.shipment.user,model_fusion_cp_shipment,base.group_user,1,0,0,0
|
||||
access_fusion_cp_shipment_stock_user,fusion.cp.shipment.stock.user,model_fusion_cp_shipment,stock.group_stock_user,1,1,1,0
|
||||
access_fusion_cp_shipment_manager,fusion.cp.shipment.manager,model_fusion_cp_shipment,stock.group_stock_manager,1,1,1,1
|
||||
access_fusion_cp_tracking_event_user,fusion.cp.tracking.event.user,model_fusion_cp_tracking_event,base.group_user,1,0,0,0
|
||||
access_fusion_cp_tracking_event_stock_user,fusion.cp.tracking.event.stock.user,model_fusion_cp_tracking_event,stock.group_stock_user,1,1,1,0
|
||||
access_fusion_cp_tracking_event_manager,fusion.cp.tracking.event.manager,model_fusion_cp_tracking_event,stock.group_stock_manager,1,1,1,1
|
||||
access_choose_delivery_cp_rate_user,choose.delivery.cp.rate.user,model_choose_delivery_cp_rate,base.group_user,1,1,1,1
|
||||
access_choose_delivery_cp_package_user,choose.delivery.cp.package.user,model_choose_delivery_cp_package,base.group_user,1,1,1,1
|
||||
access_fusion_cp_order_package_user,fusion.cp.order.package.user,model_fusion_cp_order_package,base.group_user,1,0,0,0
|
||||
access_fusion_cp_order_package_stock_user,fusion.cp.order.package.stock.user,model_fusion_cp_order_package,stock.group_stock_user,1,1,1,0
|
||||
access_fusion_cp_order_package_manager,fusion.cp.order.package.manager,model_fusion_cp_order_package,stock.group_stock_manager,1,1,1,1
|
||||
access_fusion_cp_shipment_base,fusion.cp.shipment.base,model_fusion_cp_shipment,base.group_user,1,0,0,0
|
||||
access_fusion_cp_shipment_user,fusion.cp.shipment.user,model_fusion_cp_shipment,group_fusion_cp_user,1,1,1,0
|
||||
access_fusion_cp_shipment_manager,fusion.cp.shipment.manager,model_fusion_cp_shipment,group_fusion_cp_manager,1,1,1,1
|
||||
access_fusion_cp_tracking_event_base,fusion.cp.tracking.event.base,model_fusion_cp_tracking_event,base.group_user,1,0,0,0
|
||||
access_fusion_cp_tracking_event_user,fusion.cp.tracking.event.user,model_fusion_cp_tracking_event,group_fusion_cp_user,1,1,1,0
|
||||
access_fusion_cp_tracking_event_manager,fusion.cp.tracking.event.manager,model_fusion_cp_tracking_event,group_fusion_cp_manager,1,1,1,1
|
||||
access_choose_delivery_cp_rate_user,choose.delivery.cp.rate.user,model_choose_delivery_cp_rate,group_fusion_cp_user,1,1,1,1
|
||||
access_choose_delivery_cp_package_user,choose.delivery.cp.package.user,model_choose_delivery_cp_package,group_fusion_cp_user,1,1,1,1
|
||||
access_fusion_cp_order_package_base,fusion.cp.order.package.base,model_fusion_cp_order_package,base.group_user,1,0,0,0
|
||||
access_fusion_cp_order_package_user,fusion.cp.order.package.user,model_fusion_cp_order_package,group_fusion_cp_user,1,1,1,0
|
||||
access_fusion_cp_order_package_manager,fusion.cp.order.package.manager,model_fusion_cp_order_package,group_fusion_cp_manager,1,1,1,1
|
||||
|
||||
|
48
fusion_canada_post/security/security.xml
Normal file
48
fusion_canada_post/security/security.xml
Normal file
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- MODULE CATEGORY -->
|
||||
<!-- Creates a "Fusion Canada Post" section in Settings › Users -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="module_category_fusion_canada_post" model="ir.module.category">
|
||||
<field name="name">Fusion Canada Post</field>
|
||||
<field name="sequence">52</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- PRIVILEGE (Odoo 19 pattern) -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="res_groups_privilege_fusion_canada_post" model="res.groups.privilege">
|
||||
<field name="name">Fusion Canada Post</field>
|
||||
<field name="sequence">52</field>
|
||||
<field name="category_id" ref="module_category_fusion_canada_post"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- USER GROUP -->
|
||||
<!-- Can view, create, and edit shipments. Cannot delete. -->
|
||||
<!-- Users without this group have ZERO access to Canada Post module. -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="group_fusion_cp_user" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_canada_post"/>
|
||||
<field name="comment">Can view and manage Canada Post shipments, create labels, and track packages. Cannot delete shipments or access configuration.</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- ADMINISTRATOR GROUP -->
|
||||
<!-- Full CRUD on all models plus Configuration menu. -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="group_fusion_cp_manager" model="res.groups">
|
||||
<field name="name">Administrator</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_cp_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_canada_post"/>
|
||||
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
<field name="comment">Full access to all Canada Post shipments including delete. Can manage carrier settings and module configuration.</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -4,7 +4,8 @@
|
||||
<menuitem id="menu_fusion_cp_root"
|
||||
name="Canada Post"
|
||||
web_icon="fusion_canada_post,static/description/icon.png"
|
||||
sequence="85"/>
|
||||
sequence="85"
|
||||
groups="fusion_canada_post.group_fusion_cp_user"/>
|
||||
|
||||
<!-- Shipments (parent — no action) -->
|
||||
<menuitem id="menu_fusion_cp_shipments"
|
||||
@@ -47,7 +48,8 @@
|
||||
<menuitem id="menu_fusion_cp_config"
|
||||
name="Configuration"
|
||||
parent="menu_fusion_cp_root"
|
||||
sequence="90"/>
|
||||
sequence="90"
|
||||
groups="fusion_canada_post.group_fusion_cp_manager"/>
|
||||
|
||||
<!-- Carrier Settings action -->
|
||||
<record id="action_fusion_cp_carrier_settings" model="ir.actions.act_window">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Claims',
|
||||
'version': '19.0.8.0.0',
|
||||
'version': '19.0.8.0.6',
|
||||
'category': 'Sales',
|
||||
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
||||
'description': """
|
||||
@@ -122,6 +122,10 @@
|
||||
'wizard/mod_awaiting_funding_wizard_views.xml',
|
||||
'wizard/mod_funding_approved_wizard_views.xml',
|
||||
'wizard/mod_pca_received_wizard_views.xml',
|
||||
'wizard/mod_submission_path_wizard_views.xml',
|
||||
'wizard/mod_funding_denied_wizard_views.xml',
|
||||
'wizard/mod_resubmit_wizard_views.xml',
|
||||
'wizard/mod_submission_confirmed_wizard_views.xml',
|
||||
'wizard/odsp_sa_mobility_wizard_views.xml',
|
||||
'wizard/odsp_discretionary_wizard_views.xml',
|
||||
'wizard/odsp_submit_to_odsp_wizard_views.xml',
|
||||
|
||||
@@ -116,6 +116,30 @@
|
||||
<field name="key">fusion_claims.mod_followup_escalation_days</field>
|
||||
<field name="value">3</field>
|
||||
</record>
|
||||
<!-- Hard cap on auto follow-up emails per order per 30-day window. -->
|
||||
<record id="config_mod_followup_max_per_month" model="ir.config_parameter">
|
||||
<field name="key">fusion_claims.mod_followup_max_per_month</field>
|
||||
<field name="value">2</field>
|
||||
</record>
|
||||
<record id="config_mod_followup_window_days" model="ir.config_parameter">
|
||||
<field name="key">fusion_claims.mod_followup_window_days</field>
|
||||
<field name="value">30</field>
|
||||
</record>
|
||||
<!-- Per-cron-run throttle so a backlog of stale MOD cases cannot
|
||||
blast 30+ emails in one minute. Remaining orders roll over to
|
||||
the next day's cron run. -->
|
||||
<record id="config_mod_followup_max_per_cron_run" model="ir.config_parameter">
|
||||
<field name="key">fusion_claims.mod_followup_max_per_cron_run</field>
|
||||
<field name="value">10</field>
|
||||
</record>
|
||||
|
||||
<!-- ADP funding window: approved applications auto-expire after N months
|
||||
if not progressed to ready_delivery. ADP doesn't actively notify of
|
||||
expiry — we trust this window to reflect reality. -->
|
||||
<record id="config_adp_approval_expiry_months" model="ir.config_parameter">
|
||||
<field name="key">fusion_claims.adp_approval_expiry_months</field>
|
||||
<field name="value">12</field>
|
||||
</record>
|
||||
|
||||
<!-- ODSP Settings -->
|
||||
<record id="config_sa_mobility_email" model="ir.config_parameter">
|
||||
|
||||
@@ -136,5 +136,58 @@
|
||||
<field name="nextcall" eval="DateTime.now().replace(hour=2, minute=0, second=0)"/>
|
||||
</record>
|
||||
|
||||
<!-- Cron Job: MOD Handoff Follow-up (2026-04 update)
|
||||
Creates a mail.activity for the office to call the client or
|
||||
authorizer to confirm they have submitted the MOD application.
|
||||
Uses the rolling-window cap (shared with existing MOD follow-up)
|
||||
so activities are spaced out, max 2 per 30-day window per order. -->
|
||||
<record id="ir_cron_mod_handoff_followup" model="ir.cron">
|
||||
<field name="name">Fusion Claims: MOD Handoff Follow-up</field>
|
||||
<field name="model_id" ref="sale.model_sale_order"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_mod_handoff_followup()</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=0, second=0)"/>
|
||||
</record>
|
||||
|
||||
<!-- Cron Job: ADP 12-month auto-expire for approved applications.
|
||||
Approved / approved_deduction orders past the configured funding
|
||||
window (fusion_claims.adp_approval_expiry_months, default 12 months)
|
||||
are auto-transitioned to 'expired'. Users can reopen via the
|
||||
"Reopen Expired" button on the order form. -->
|
||||
<record id="ir_cron_adp_expire_approved" model="ir.cron">
|
||||
<field name="name">Fusion Claims: ADP Expire Approved Applications</field>
|
||||
<field name="model_id" ref="sale.model_sale_order"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_adp_expire_approved()</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: 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>
|
||||
|
||||
@@ -62,8 +62,63 @@ class ResCompany(models.Model):
|
||||
help='Contacts who will receive a copy (CC) of all automated ADP notifications',
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# MARCH OF DIMES SETTINGS (shared blank forms + follow-up assignment)
|
||||
# Added 2026-04 MOD update. Latest revision of the MOD-issued blank forms
|
||||
# lives here so every case can auto-attach them to outgoing emails.
|
||||
# ==========================================================================
|
||||
x_fc_mod_application_form = fields.Binary(
|
||||
string='MOD Application Form (blank)',
|
||||
attachment=True,
|
||||
help='Blank March of Dimes Application Form (latest MOD revision). '
|
||||
'Auto-attached to the handoff email sent to the client or authorizer '
|
||||
'when they will be submitting the application themselves.',
|
||||
)
|
||||
x_fc_mod_application_form_filename = fields.Char(
|
||||
string='MOD Application Form Filename',
|
||||
)
|
||||
x_fc_mod_vod_form = fields.Binary(
|
||||
string='Verification of Disability Form (blank)',
|
||||
attachment=True,
|
||||
help='Blank March of Dimes Verification of Disability form. '
|
||||
'Auto-attached to the VOD request email sent to the authorizer when '
|
||||
'our office is handling the MOD submission internally.',
|
||||
)
|
||||
x_fc_mod_vod_form_filename = fields.Char(
|
||||
string='VOD Form Filename',
|
||||
)
|
||||
x_fc_mod_followup_assignee_mode = fields.Selection(
|
||||
selection=[
|
||||
('office_contact', 'Designated Office Contact'),
|
||||
('sales_rep', 'Order Sales Rep'),
|
||||
],
|
||||
string='MOD Follow-up Assignee',
|
||||
default='office_contact',
|
||||
help='Who gets the follow-up activity when a client or authorizer is '
|
||||
'handling the MOD submission themselves. Office contact = one '
|
||||
'designated person handles all follow-ups. Sales rep = the rep '
|
||||
'on each order is responsible for their own follow-ups.',
|
||||
)
|
||||
x_fc_mod_followup_office_contact_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='MOD Follow-up Office Contact',
|
||||
help='The office user who receives MOD handoff follow-up activities '
|
||||
'when assignee mode is set to Office Contact. Only used when '
|
||||
'x_fc_mod_followup_assignee_mode == office_contact.',
|
||||
)
|
||||
|
||||
def _get_cheque_payable_name(self):
|
||||
"""Get the name for cheque payments, defaulting to company name."""
|
||||
self.ensure_one()
|
||||
return self.x_fc_cheque_payable_to or self.name
|
||||
|
||||
def _get_mod_followup_assignee(self, sale_order):
|
||||
"""Return the res.users who should receive the MOD handoff follow-up
|
||||
activity for the given order, based on this company's configuration.
|
||||
Falls back to the order's sales rep when the office contact is missing.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.x_fc_mod_followup_assignee_mode == 'office_contact' and self.x_fc_mod_followup_office_contact_id:
|
||||
return self.x_fc_mod_followup_office_contact_id
|
||||
return sale_order.user_id or self.env.user
|
||||
|
||||
|
||||
@@ -59,6 +59,38 @@ class ResConfigSettings(models.TransientModel):
|
||||
string='Refund Policy',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# MARCH OF DIMES SETTINGS (added 2026-04 MOD workflow update)
|
||||
# =========================================================================
|
||||
fc_mod_application_form = fields.Binary(
|
||||
related='company_id.x_fc_mod_application_form',
|
||||
readonly=False,
|
||||
string='MOD Application Form (blank)',
|
||||
)
|
||||
fc_mod_application_form_filename = fields.Char(
|
||||
related='company_id.x_fc_mod_application_form_filename',
|
||||
readonly=False,
|
||||
)
|
||||
fc_mod_vod_form = fields.Binary(
|
||||
related='company_id.x_fc_mod_vod_form',
|
||||
readonly=False,
|
||||
string='Verification of Disability Form (blank)',
|
||||
)
|
||||
fc_mod_vod_form_filename = fields.Char(
|
||||
related='company_id.x_fc_mod_vod_form_filename',
|
||||
readonly=False,
|
||||
)
|
||||
fc_mod_followup_assignee_mode = fields.Selection(
|
||||
related='company_id.x_fc_mod_followup_assignee_mode',
|
||||
readonly=False,
|
||||
string='MOD Follow-up Assignee',
|
||||
)
|
||||
fc_mod_followup_office_contact_id = fields.Many2one(
|
||||
related='company_id.x_fc_mod_followup_office_contact_id',
|
||||
readonly=False,
|
||||
string='Office Follow-up Contact',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# ADP BILLING SETTINGS
|
||||
# =========================================================================
|
||||
|
||||
@@ -26,6 +26,7 @@ class ResPartner(models.Model):
|
||||
('muscular_dystrophy', 'Muscular Dystrophy'),
|
||||
('occupational_therapist', 'Occupational Therapist'),
|
||||
('physiotherapist', 'Physiotherapist'),
|
||||
('accessibility_specialist', 'Accessibility Specialist'),
|
||||
('vendor', 'Vendor'),
|
||||
('funding_agency', 'Funding Agency'),
|
||||
('government_agency', 'Government Agency'),
|
||||
|
||||
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; }
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FormController } from "@web/views/form/form_controller";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
// Store for autocomplete instances and services
|
||||
let googleMapsLoaded = false;
|
||||
@@ -55,6 +56,18 @@ async function loadGoogleMapsApi(apiKey) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Odoo Enterprise google_address_autocomplete module is installed and active.
|
||||
* When active, its field widget handles res.partner street autocomplete — we skip ours.
|
||||
*/
|
||||
function isOdooAddressAutocompleteActive() {
|
||||
try {
|
||||
return registry.category("fields").contains("google_address_autocomplete");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key from Odoo config
|
||||
*/
|
||||
@@ -639,7 +652,14 @@ function initCompanyAutocomplete(input, formModel) {
|
||||
*/
|
||||
async function setupPartnerAutocomplete(el, model, orm) {
|
||||
globalOrm = orm;
|
||||
|
||||
|
||||
// If the Odoo Enterprise google_address_autocomplete widget is active,
|
||||
// it handles res.partner street autocomplete — skip our custom implementation.
|
||||
if (isOdooAddressAutocompleteActive()) {
|
||||
console.log('[GooglePlaces] Odoo Google Address Autocomplete detected — skipping custom partner autocomplete');
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = await getGoogleMapsApiKey(orm);
|
||||
if (!apiKey) {
|
||||
console.log('[GooglePlaces] API key not configured');
|
||||
@@ -1027,6 +1047,7 @@ function initSimpleAddressAutocomplete(input) {
|
||||
* Works for res.users (x_fc_start_address) and res.config.settings (fc_technician_start_address).
|
||||
*/
|
||||
async function setupSimpleAddressFields(el, orm) {
|
||||
if (!el) return; // guard against null rootRef.el from observer callbacks firing after unmount
|
||||
const apiKey = await getGoogleMapsApiKey(orm);
|
||||
if (!apiKey) return;
|
||||
try { await loadGoogleMapsApi(apiKey); } catch (e) { return; }
|
||||
@@ -1252,6 +1273,9 @@ function _startDialogWatcher(orm) {
|
||||
* Check all open dialogs for partner address forms
|
||||
*/
|
||||
function _checkDialogsForPartnerForms() {
|
||||
// Enterprise widget handles dialogs via its own widget — skip ours
|
||||
if (isOdooAddressAutocompleteActive()) return;
|
||||
|
||||
// Get all visible modals/dialogs
|
||||
const modals = document.querySelectorAll(
|
||||
'.o_dialog, .modal, .o_FormViewDialog, [role="dialog"]'
|
||||
|
||||
@@ -330,6 +330,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- MOD BLANK FORMS + HANDOFF FOLLOW-UP (2026-04 update) -->
|
||||
<!-- ============================================================= -->
|
||||
<h2>March of Dimes — Blank Forms & Handoff Follow-Up</h2>
|
||||
<p class="text-muted">
|
||||
Upload the latest revision of the MOD-issued blank forms here.
|
||||
They are auto-attached to outgoing emails so every case uses the
|
||||
current version. Re-upload whenever March of Dimes publishes a
|
||||
new revision.
|
||||
</p>
|
||||
<div class="row mt-4 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">MOD Application Form (blank)</span>
|
||||
<div class="text-muted">
|
||||
Attached to the handoff email when client or authorizer
|
||||
is submitting the application themselves.
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<field name="fc_mod_application_form"
|
||||
filename="fc_mod_application_form_filename"
|
||||
widget="binary"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Verification of Disability Form (blank)</span>
|
||||
<div class="text-muted">
|
||||
Attached to the VOD request email sent to the authorizer
|
||||
when our office is handling the MOD submission internally.
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<field name="fc_mod_vod_form"
|
||||
filename="fc_mod_vod_form_filename"
|
||||
widget="binary"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Handoff Follow-up Assignee</span>
|
||||
<div class="text-muted">
|
||||
Who gets the follow-up activity when a client or authorizer
|
||||
is handling the MOD submission themselves. "Office contact"
|
||||
routes all follow-ups to a single designated person. "Sales
|
||||
rep" assigns to the rep on each order.
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<field name="fc_mod_followup_assignee_mode" widget="radio"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Office Follow-up Contact</span>
|
||||
<div class="text-muted">
|
||||
The office user who receives MOD handoff follow-up activities
|
||||
when assignee mode is set to Office Contact.
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<field name="fc_mod_followup_office_contact_id"
|
||||
invisible="fc_mod_followup_assignee_mode != 'office_contact'"
|
||||
options="{'no_create': True}"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- DATA MIGRATION -->
|
||||
<!-- ============================================================= -->
|
||||
|
||||
@@ -193,12 +193,57 @@
|
||||
<!-- Exception buttons -->
|
||||
<button name="action_mod_on_hold" type="object"
|
||||
string="Hold" class="btn-warning" icon="fa-pause"
|
||||
invisible="not x_fc_is_mod_sale or x_fc_mod_status not in ('contract_received', 'in_production', 'project_complete')"/>
|
||||
invisible="not x_fc_is_mod_sale or x_fc_mod_status in ('on_hold', 'case_closed', 'cancelled', False)"/>
|
||||
|
||||
<button name="action_mod_resume" type="object"
|
||||
string="Resume" class="btn-success" icon="fa-play"
|
||||
invisible="not x_fc_is_mod_sale or x_fc_mod_status != 'on_hold'"/>
|
||||
|
||||
<!-- ======================================================= -->
|
||||
<!-- MOD 2026-04 UPDATE — new submission-path + recovery btns -->
|
||||
<!-- ======================================================= -->
|
||||
|
||||
<!-- Set MOD Submission Path (internal / client / authorizer) -->
|
||||
<button name="action_mod_set_submission_path" type="object"
|
||||
string="Set Submission Path" class="btn-info" icon="fa-sitemap"
|
||||
invisible="not x_fc_is_mod_sale or x_fc_mod_status not in ('assessment_completed', 'processing_drawings', 'quote_submitted')"
|
||||
help="Choose who is submitting the MOD application (our office, the client, or the authorizer)"/>
|
||||
|
||||
<!-- Request VOD from Authorizer (manual re-send) -->
|
||||
<button name="action_mod_request_vod" type="object"
|
||||
string="Request VOD from Authorizer" class="btn-warning" icon="fa-envelope"
|
||||
invisible="not x_fc_is_mod_sale or x_fc_mod_submitted_by != 'internal' or x_fc_mod_vod_letter"
|
||||
help="Email the authorizer the blank Verification of Disability form to complete. Fires automatically once on first setting submitted_by=internal, this button is for manual re-sends."/>
|
||||
|
||||
<!-- Handoff to Client/Authorizer (paths B/C) -->
|
||||
<button name="action_mod_handoff_to_client" type="object"
|
||||
string="Handoff Package" class="btn-primary" icon="fa-share"
|
||||
invisible="not x_fc_is_mod_sale or x_fc_mod_submitted_by not in ('client', 'authorizer') or x_fc_mod_status not in ('processing_drawings', 'quote_submitted')"
|
||||
confirm="This will email the full MOD application package to the client or authorizer and move the case to Handoff state. Continue?"
|
||||
help="Email proposal + drawing + blank MOD application form to whoever is submitting, and move the case to handoff_to_client"/>
|
||||
|
||||
<!-- Confirm Submission (for handoff_to_client state) -->
|
||||
<button name="action_mod_confirmed_submission" type="object"
|
||||
string="Confirm Submission" class="btn-success" icon="fa-check-circle"
|
||||
invisible="not x_fc_is_mod_sale or x_fc_mod_status != 'handoff_to_client'"
|
||||
help="Client or authorizer confirmed they submitted the application to MOD. Record the date and advance to Awaiting Funding."/>
|
||||
|
||||
<!-- Recovery buttons for funding_denied -->
|
||||
<button name="action_mod_resubmit_from_denied" type="object"
|
||||
string="Resubmit" class="btn-primary" icon="fa-undo"
|
||||
invisible="not x_fc_is_mod_sale or x_fc_mod_status != 'funding_denied'"
|
||||
help="Revise and resubmit the case to March of Dimes after a denial"/>
|
||||
|
||||
<button name="action_mod_cancel_from_denied" type="object"
|
||||
string="Cancel Case" class="btn-danger" icon="fa-times"
|
||||
invisible="not x_fc_is_mod_sale or x_fc_mod_status != 'funding_denied'"
|
||||
confirm="Cancel this denied MOD case? This action can be reversed with Reopen."/>
|
||||
|
||||
<button name="action_mod_reopen_cancelled" type="object"
|
||||
string="Reopen" class="btn-info" icon="fa-refresh"
|
||||
invisible="not x_fc_is_mod_sale or x_fc_mod_status != 'cancelled'"
|
||||
confirm="Reopen this cancelled MOD case? Status will reset to Need to Schedule."/>
|
||||
|
||||
</xpath>
|
||||
|
||||
<!-- ============================================= -->
|
||||
@@ -234,6 +279,59 @@
|
||||
Upload Completion Photos and Proof of Delivery to submit to the case worker.
|
||||
</div>
|
||||
|
||||
<!-- ===== 2026-04 MOD Update: Submission Path + Application Package ===== -->
|
||||
<separator string="MOD Submission Path"/>
|
||||
<div class="alert alert-secondary" role="alert"
|
||||
invisible="x_fc_mod_submitted_by">
|
||||
<i class="fa fa-question-circle"/> <strong>Who is submitting this application?</strong>
|
||||
Click <strong>Set Submission Path</strong> in the status bar above to choose:
|
||||
internal (we submit), client, or authorizer.
|
||||
</div>
|
||||
<group col="4">
|
||||
<field name="x_fc_mod_submitted_by" readonly="1"/>
|
||||
<field name="x_fc_mod_handoff_date" readonly="1"
|
||||
invisible="x_fc_mod_submitted_by == 'internal'"/>
|
||||
<field name="x_fc_mod_application_submitted_date" readonly="1"/>
|
||||
<field name="x_fc_mod_vod_requested_date" readonly="1"
|
||||
invisible="x_fc_mod_submitted_by != 'internal'"/>
|
||||
<field name="x_fc_mod_accessibility_specialist_id"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
|
||||
<separator string="MOD Application Package (when WE submit on client's behalf)"
|
||||
invisible="x_fc_mod_submitted_by != 'internal'"/>
|
||||
<group col="2" invisible="x_fc_mod_submitted_by != 'internal'">
|
||||
<field name="x_fc_mod_vod_letter"
|
||||
filename="x_fc_mod_vod_letter_filename"
|
||||
widget="binary"/>
|
||||
<field name="x_fc_mod_application_form_doc"
|
||||
filename="x_fc_mod_application_form_doc_filename"
|
||||
widget="binary"/>
|
||||
<field name="x_fc_mod_notice_of_assessment"
|
||||
filename="x_fc_mod_notice_of_assessment_filename"
|
||||
widget="binary"/>
|
||||
<field name="x_fc_mod_property_tax"
|
||||
filename="x_fc_mod_property_tax_filename"
|
||||
widget="binary"/>
|
||||
</group>
|
||||
|
||||
<separator string="Proposal Document"/>
|
||||
<group col="2">
|
||||
<field name="x_fc_mod_proposal_doc"
|
||||
filename="x_fc_mod_proposal_doc_filename"
|
||||
widget="binary"/>
|
||||
</group>
|
||||
|
||||
<separator string="Funding Denial Details" invisible="x_fc_mod_status != 'funding_denied'"/>
|
||||
<group invisible="x_fc_mod_status != 'funding_denied'">
|
||||
<field name="x_fc_mod_funding_denial_reason" readonly="1" nolabel="1"/>
|
||||
</group>
|
||||
|
||||
<separator string="Hold History" invisible="not x_fc_mod_previous_status_before_hold"/>
|
||||
<group invisible="not x_fc_mod_previous_status_before_hold">
|
||||
<field name="x_fc_mod_previous_status_before_hold" readonly="1"/>
|
||||
</group>
|
||||
|
||||
<!-- ===== Row 1: Assessment Documents ===== -->
|
||||
<div class="row">
|
||||
<!-- Column 1: Assessment and Design -->
|
||||
@@ -1274,7 +1372,33 @@
|
||||
icon="fa-play"
|
||||
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'on_hold'"
|
||||
help="Resume this application from hold"/>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- RECOVERY BUTTONS - bring stuck orders back into the workflow -->
|
||||
<!-- Added 2026-04 to wire up the exit paths drawn in the FigJam -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<button name="action_adp_reopen_cancelled" type="object"
|
||||
string="Reopen" class="btn-info"
|
||||
icon="fa-refresh"
|
||||
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'cancelled'"
|
||||
confirm="Reopen this cancelled application at the Quotation stage?"
|
||||
help="Return a cancelled 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"
|
||||
icon="fa-repeat"
|
||||
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'denied'"
|
||||
confirm="Send this denied application back to Ready for Submission for a fresh attempt?"
|
||||
help="Send a denied application back to Ready for Submission"/>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- EXCEPTION BUTTONS - Put On Hold / Withdraw -->
|
||||
<!-- ============================================================ -->
|
||||
@@ -1355,7 +1479,7 @@
|
||||
<field name="x_fc_is_mod_sale" invisible="1"/>
|
||||
<field name="x_fc_mod_status" widget="statusbar"
|
||||
invisible="not x_fc_is_mod_sale"
|
||||
statusbar_visible="need_to_schedule,assessment_scheduled,assessment_completed,processing_drawings,quote_submitted,funding_approved,contract_received,in_production,project_complete,case_closed"
|
||||
statusbar_visible="need_to_schedule,assessment_scheduled,assessment_completed,processing_drawings,quote_submitted,handoff_to_client,awaiting_funding,funding_approved,contract_received,in_production,project_complete,case_closed"
|
||||
nolabel="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
@@ -1472,8 +1596,28 @@
|
||||
required="x_fc_stage_after_ready_submission"
|
||||
readonly="x_fc_case_locked"/>
|
||||
|
||||
<!-- Recovery Action Buttons - visible only for stuck states -->
|
||||
<div class="mt-2" colspan="2"
|
||||
invisible="x_fc_case_locked or x_fc_adp_application_status not in ('cancelled', 'expired', 'denied')">
|
||||
<button name="action_adp_reopen_cancelled" type="object"
|
||||
string="Reopen" class="btn-info btn-sm me-1"
|
||||
icon="fa-refresh"
|
||||
invisible="x_fc_adp_application_status != 'cancelled'"
|
||||
confirm="Reopen this cancelled 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"
|
||||
invisible="x_fc_adp_application_status != 'denied'"
|
||||
confirm="Send this denied application back to Ready for Submission for a fresh attempt?"/>
|
||||
</div>
|
||||
|
||||
<!-- Status Action Buttons (require reason popup) - Hidden when locked -->
|
||||
<div class="mt-2" colspan="2"
|
||||
<div class="mt-2" colspan="2"
|
||||
invisible="x_fc_case_locked or x_fc_adp_application_status in ('quotation', 'cancelled', 'case_closed', 'expired')">
|
||||
<button name="%(fusion_claims.action_set_status_on_hold)d"
|
||||
type="action" string="Put On Hold"
|
||||
@@ -2491,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>
|
||||
|
||||
@@ -23,6 +23,10 @@ from . import send_to_mod_wizard
|
||||
from . import mod_awaiting_funding_wizard
|
||||
from . import mod_funding_approved_wizard
|
||||
from . import mod_pca_received_wizard
|
||||
from . import mod_submission_path_wizard
|
||||
from . import mod_funding_denied_wizard
|
||||
from . import mod_resubmit_wizard
|
||||
from . import mod_submission_confirmed_wizard
|
||||
from . import odsp_sa_mobility_wizard
|
||||
from . import odsp_discretionary_wizard
|
||||
from . import odsp_pre_approved_wizard
|
||||
|
||||
84
fusion_claims/wizard/mod_funding_denied_wizard.py
Normal file
84
fusion_claims/wizard/mod_funding_denied_wizard.py
Normal file
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""
|
||||
MOD Funding Denied Wizard — captures the denial reason and sets the order
|
||||
status. Previously action_mod_funding_denied was a bare write with no reason
|
||||
capture; this wizard replaces that flow.
|
||||
"""
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
from markupsafe import Markup
|
||||
|
||||
|
||||
class ModFundingDeniedWizard(models.TransientModel):
|
||||
_name = 'fusion_claims.mod.funding.denied.wizard'
|
||||
_description = 'MOD - Funding Denied Reason Capture'
|
||||
|
||||
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
|
||||
denial_date = fields.Date(
|
||||
string='Denial Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
)
|
||||
denial_reason_category = fields.Selection(
|
||||
selection=[
|
||||
('income_too_high', 'Client income exceeds MOD threshold'),
|
||||
('residency', 'Residency requirement not met'),
|
||||
('project_scope', 'Project scope not eligible'),
|
||||
('missing_docs', 'Missing documentation'),
|
||||
('funding_depleted', 'MOD funding depleted for this period'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='Denial Category',
|
||||
required=True,
|
||||
)
|
||||
denial_reason = fields.Text(
|
||||
string='Denial Details',
|
||||
required=True,
|
||||
help='MOD\'s stated reason for denying funding. Captured for the '
|
||||
'audit trail and used when generating the client denial email.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
if self.env.context.get('active_id'):
|
||||
order = self.env['sale.order'].browse(self.env.context['active_id'])
|
||||
res['sale_order_id'] = order.id
|
||||
return res
|
||||
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
order = self.sale_order_id
|
||||
if order.x_fc_mod_status not in ('awaiting_funding', 'quote_submitted', 'handoff_to_client'):
|
||||
raise UserError(
|
||||
_("Funding can only be denied from awaiting_funding, "
|
||||
"quote_submitted or handoff_to_client. Current: %s") % order.x_fc_mod_status
|
||||
)
|
||||
|
||||
labels = dict(self._fields['denial_reason_category'].selection)
|
||||
category_label = labels.get(self.denial_reason_category, self.denial_reason_category)
|
||||
full_reason = f'[{category_label}] {self.denial_reason}'
|
||||
|
||||
order.write({
|
||||
'x_fc_mod_status': 'funding_denied',
|
||||
'x_fc_mod_funding_denial_reason': full_reason,
|
||||
})
|
||||
|
||||
body = (
|
||||
f'<div class="alert alert-danger" role="alert">'
|
||||
f'<strong><i class="fa fa-ban"></i> Funding Denied by March of Dimes</strong>'
|
||||
f'<ul>'
|
||||
f'<li><strong>Date:</strong> {self.denial_date.strftime("%B %d, %Y")}</li>'
|
||||
f'<li><strong>Category:</strong> {category_label}</li>'
|
||||
f'<li><strong>Details:</strong> {self.denial_reason}</li>'
|
||||
f'</ul>'
|
||||
f'</div>'
|
||||
)
|
||||
order.message_post(
|
||||
body=Markup(body),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
40
fusion_claims/wizard/mod_funding_denied_wizard_views.xml
Normal file
40
fusion_claims/wizard/mod_funding_denied_wizard_views.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_mod_funding_denied_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion_claims.mod.funding.denied.wizard.form</field>
|
||||
<field name="model">fusion_claims.mod.funding.denied.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="MOD Funding Denied">
|
||||
<sheet>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<strong><i class="fa fa-ban"/> Record MOD Funding Denial</strong>
|
||||
<p class="mb-0 mt-2">
|
||||
Capture the denial reason from March of Dimes. This will be logged
|
||||
to the case history and included in the notification sent to the
|
||||
client and authorizer.
|
||||
</p>
|
||||
</div>
|
||||
<field name="sale_order_id" invisible="1"/>
|
||||
<group>
|
||||
<field name="denial_date"/>
|
||||
<field name="denial_reason_category" required="1"/>
|
||||
<field name="denial_reason" required="1"
|
||||
placeholder="Paste or summarise MOD's stated reason..."/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Record Denial" class="btn-danger"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_mod_funding_denied_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Funding Denied by MOD</field>
|
||||
<field name="res_model">fusion_claims.mod.funding.denied.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
87
fusion_claims/wizard/mod_resubmit_wizard.py
Normal file
87
fusion_claims/wizard/mod_resubmit_wizard.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""
|
||||
MOD Resubmit Wizard — lets the office revise an order that MOD has denied,
|
||||
and kick it back into the workflow at `processing_drawings` so the specialist
|
||||
can update drawings/proposal/quotation before it is re-submitted to MOD.
|
||||
"""
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
from markupsafe import Markup
|
||||
|
||||
|
||||
class ModResubmitWizard(models.TransientModel):
|
||||
_name = 'fusion_claims.mod.resubmit.wizard'
|
||||
_description = 'MOD - Resubmit After Denial'
|
||||
|
||||
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
|
||||
revision_notes = fields.Text(
|
||||
string='Revision Notes',
|
||||
required=True,
|
||||
help='Describe what is being revised for the resubmission '
|
||||
'(scope changes, updated pricing, additional documentation, etc).',
|
||||
)
|
||||
clear_old_documents = fields.Boolean(
|
||||
string='Clear old drawings / proposal / quotation',
|
||||
default=False,
|
||||
help='Tick this if the new submission needs entirely new drawings and '
|
||||
'proposal. The previous documents are preserved in chatter before '
|
||||
'being cleared.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
if self.env.context.get('active_id'):
|
||||
order = self.env['sale.order'].browse(self.env.context['active_id'])
|
||||
res['sale_order_id'] = order.id
|
||||
return res
|
||||
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
order = self.sale_order_id
|
||||
if order.x_fc_mod_status != 'funding_denied':
|
||||
raise UserError(_("Only denied orders can be resubmitted via this wizard."))
|
||||
|
||||
if self.clear_old_documents:
|
||||
# Preserve the old docs in chatter before clearing them.
|
||||
preserved = []
|
||||
if order.x_fc_mod_drawing:
|
||||
preserved.append('Drawing')
|
||||
if order.x_fc_mod_proposal_doc:
|
||||
preserved.append('Proposal')
|
||||
if preserved:
|
||||
order.message_post(
|
||||
body=Markup(
|
||||
f'<div class="alert alert-warning" role="alert">'
|
||||
f'<strong>Previous documents cleared for resubmission:</strong> '
|
||||
f'{", ".join(preserved)}<br/>'
|
||||
f'See prior chatter for the originals.'
|
||||
f'</div>'
|
||||
),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
order.with_context(skip_status_emails=True).write({
|
||||
'x_fc_mod_drawing': False,
|
||||
'x_fc_mod_drawing_filename': False,
|
||||
'x_fc_mod_proposal_doc': False,
|
||||
'x_fc_mod_proposal_doc_filename': False,
|
||||
})
|
||||
|
||||
order.with_context(skip_status_emails=True).write({
|
||||
'x_fc_mod_status': 'processing_drawings',
|
||||
})
|
||||
body = (
|
||||
f'<div class="alert alert-info" role="alert">'
|
||||
f'<strong><i class="fa fa-undo"></i> Resubmitting to March of Dimes after denial</strong>'
|
||||
f'<p class="mb-0 mt-2"><strong>Revision notes:</strong> {self.revision_notes}</p>'
|
||||
f'</div>'
|
||||
)
|
||||
order.message_post(
|
||||
body=Markup(body),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
39
fusion_claims/wizard/mod_resubmit_wizard_views.xml
Normal file
39
fusion_claims/wizard/mod_resubmit_wizard_views.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_mod_resubmit_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion_claims.mod.resubmit.wizard.form</field>
|
||||
<field name="model">fusion_claims.mod.resubmit.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Resubmit to MOD">
|
||||
<sheet>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong><i class="fa fa-undo"/> Resubmit Denied Application</strong>
|
||||
<p class="mb-0 mt-2">
|
||||
Return this denied case to <strong>Processing Drawings</strong> so the
|
||||
accessibility specialist can update the proposal, drawings, or quote
|
||||
before resubmitting to March of Dimes.
|
||||
</p>
|
||||
</div>
|
||||
<field name="sale_order_id" invisible="1"/>
|
||||
<group>
|
||||
<field name="revision_notes" required="1"
|
||||
placeholder="What is being revised? Scope change, updated pricing, new docs..."/>
|
||||
<field name="clear_old_documents"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Resubmit" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_mod_resubmit_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Resubmit to MOD</field>
|
||||
<field name="res_model">fusion_claims.mod.resubmit.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
92
fusion_claims/wizard/mod_submission_confirmed_wizard.py
Normal file
92
fusion_claims/wizard/mod_submission_confirmed_wizard.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""
|
||||
MOD Submission Confirmed Wizard — office confirms that a client or authorizer
|
||||
has submitted the application to MOD, captures the actual submission date,
|
||||
and advances the order from handoff_to_client to awaiting_funding.
|
||||
"""
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
from markupsafe import Markup
|
||||
|
||||
|
||||
class ModSubmissionConfirmedWizard(models.TransientModel):
|
||||
_name = 'fusion_claims.mod.submission.confirmed.wizard'
|
||||
_description = 'MOD - Confirm Application Submitted'
|
||||
|
||||
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
|
||||
submitted_by_label = fields.Char(string='Submitted By', readonly=True)
|
||||
application_submitted_date = fields.Date(
|
||||
string='Actual Submission Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
help='Date the client or authorizer told us they actually submitted '
|
||||
'the application to March of Dimes.',
|
||||
)
|
||||
confirmation_source = fields.Selection(
|
||||
selection=[
|
||||
('phone_call', 'Phone call with client'),
|
||||
('email', 'Email from client'),
|
||||
('client_portal', 'Client used our portal'),
|
||||
('authorizer', 'Confirmed by authorizer (OT)'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='How was this confirmed?',
|
||||
required=True,
|
||||
default='phone_call',
|
||||
)
|
||||
confirmation_notes = fields.Text(
|
||||
string='Notes from confirmation',
|
||||
help='What did the client say? Confirmation number from MOD if given?',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
if self.env.context.get('active_id'):
|
||||
order = self.env['sale.order'].browse(self.env.context['active_id'])
|
||||
res['sale_order_id'] = order.id
|
||||
path_labels = {
|
||||
'internal': 'We (internal)',
|
||||
'client': 'Client',
|
||||
'authorizer': 'Authorizer (OT)',
|
||||
}
|
||||
res['submitted_by_label'] = path_labels.get(
|
||||
order.x_fc_mod_submitted_by, 'Unknown')
|
||||
return res
|
||||
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
order = self.sale_order_id
|
||||
if order.x_fc_mod_status != 'handoff_to_client':
|
||||
raise UserError(
|
||||
_("This wizard is only for orders that have been handed off "
|
||||
"to the client or authorizer for submission. Current status: %s")
|
||||
% order.x_fc_mod_status
|
||||
)
|
||||
|
||||
order.write({
|
||||
'x_fc_mod_status': 'awaiting_funding',
|
||||
'x_fc_mod_application_submitted_date': self.application_submitted_date,
|
||||
})
|
||||
|
||||
source_labels = dict(self._fields['confirmation_source'].selection)
|
||||
body = (
|
||||
f'<div class="alert alert-success" role="alert">'
|
||||
f'<strong><i class="fa fa-check-circle"></i> Application Submission Confirmed</strong>'
|
||||
f'<ul>'
|
||||
f'<li><strong>Submitted By:</strong> {self.submitted_by_label}</li>'
|
||||
f'<li><strong>Submission Date:</strong> {self.application_submitted_date.strftime("%B %d, %Y")}</li>'
|
||||
f'<li><strong>Confirmed Via:</strong> {source_labels[self.confirmation_source]}</li>'
|
||||
f'</ul>'
|
||||
)
|
||||
if self.confirmation_notes:
|
||||
body += f'<p class="mb-0"><strong>Notes:</strong> {self.confirmation_notes}</p>'
|
||||
body += '</div>'
|
||||
order.message_post(
|
||||
body=Markup(body),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_mod_submission_confirmed_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion_claims.mod.submission.confirmed.wizard.form</field>
|
||||
<field name="model">fusion_claims.mod.submission.confirmed.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Confirm MOD Submission">
|
||||
<sheet>
|
||||
<div class="alert alert-success" role="alert">
|
||||
<strong><i class="fa fa-check-circle"/> Confirm Application Submission</strong>
|
||||
<p class="mb-0 mt-2">
|
||||
Record that the <strong>client</strong> or <strong>authorizer</strong>
|
||||
has actually submitted the application to March of Dimes. The order
|
||||
will advance to <strong>Awaiting Funding</strong>.
|
||||
</p>
|
||||
</div>
|
||||
<field name="sale_order_id" invisible="1"/>
|
||||
<group>
|
||||
<field name="submitted_by_label" readonly="1"/>
|
||||
<field name="application_submitted_date" required="1"/>
|
||||
<field name="confirmation_source" required="1"/>
|
||||
<field name="confirmation_notes"
|
||||
placeholder="What did the client say? Any reference number from MOD?"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm Submission" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_mod_submission_confirmed_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Confirm MOD Submission</field>
|
||||
<field name="res_model">fusion_claims.mod.submission.confirmed.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
76
fusion_claims/wizard/mod_submission_path_wizard.py
Normal file
76
fusion_claims/wizard/mod_submission_path_wizard.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""
|
||||
MOD Submission Path Wizard — asks the office which party is submitting the
|
||||
March of Dimes application. Sets x_fc_mod_submitted_by on the order and,
|
||||
when the path is 'internal', auto-triggers the VOD request email to the
|
||||
authorizer the first time it is selected.
|
||||
"""
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
from markupsafe import Markup
|
||||
|
||||
|
||||
class ModSubmissionPathWizard(models.TransientModel):
|
||||
_name = 'fusion_claims.mod.submission.path.wizard'
|
||||
_description = 'MOD - Set Submission Path'
|
||||
|
||||
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
|
||||
submitted_by = fields.Selection(
|
||||
selection=[
|
||||
('internal', 'We submit on client\'s behalf'),
|
||||
('client', 'Client submits themselves'),
|
||||
('authorizer', 'Authorizer (OT) submits'),
|
||||
],
|
||||
string='Who is submitting to March of Dimes?',
|
||||
required=True,
|
||||
)
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
if self.env.context.get('active_id'):
|
||||
order = self.env['sale.order'].browse(self.env.context['active_id'])
|
||||
res['sale_order_id'] = order.id
|
||||
if order.x_fc_mod_submitted_by:
|
||||
res['submitted_by'] = order.x_fc_mod_submitted_by
|
||||
return res
|
||||
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
order = self.sale_order_id
|
||||
previous = order.x_fc_mod_submitted_by
|
||||
vals = {'x_fc_mod_submitted_by': self.submitted_by}
|
||||
order.with_context(skip_status_emails=True).write(vals)
|
||||
|
||||
labels = dict(self._fields['submitted_by'].selection)
|
||||
body = (
|
||||
f'<div class="alert alert-info" role="alert">'
|
||||
f'<strong>MOD submission path set:</strong> {labels[self.submitted_by]}'
|
||||
)
|
||||
if self.notes:
|
||||
body += f'<br/>{self.notes}'
|
||||
body += '</div>'
|
||||
order.message_post(
|
||||
body=Markup(body),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
# First-time internal path → auto-trigger the VOD request email so
|
||||
# the authorizer knows to fill and send the VOD form back.
|
||||
if (
|
||||
self.submitted_by == 'internal'
|
||||
and previous != 'internal'
|
||||
and not order.x_fc_mod_vod_requested_date
|
||||
):
|
||||
try:
|
||||
order._send_mod_vod_request_email()
|
||||
except Exception:
|
||||
# Don't block the wizard if the email fails — office can
|
||||
# retry via the manual "Request VOD" button.
|
||||
pass
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
39
fusion_claims/wizard/mod_submission_path_wizard_views.xml
Normal file
39
fusion_claims/wizard/mod_submission_path_wizard_views.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_mod_submission_path_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion_claims.mod.submission.path.wizard.form</field>
|
||||
<field name="model">fusion_claims.mod.submission.path.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Set MOD Submission Path">
|
||||
<sheet>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong><i class="fa fa-info-circle"/> Who is submitting this application to March of Dimes?</strong>
|
||||
<p class="mb-0 mt-2">
|
||||
This determines which document gates apply and whether we hand off materials
|
||||
to the client or authorizer for them to submit. Selecting
|
||||
<strong>"We submit on client's behalf"</strong> will automatically email the
|
||||
authorizer the blank Verification of Disability form to complete.
|
||||
</p>
|
||||
</div>
|
||||
<field name="sale_order_id" invisible="1"/>
|
||||
<group>
|
||||
<field name="submitted_by" widget="radio" required="1"/>
|
||||
<field name="notes" placeholder="Optional notes..."/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_mod_submission_path_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Set MOD Submission Path</field>
|
||||
<field name="res_model">fusion_claims.mod.submission.path.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion LTC Management',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.1.0',
|
||||
'category': 'Sales',
|
||||
'summary': 'Long-Term Care Facility Management - Repairs, Cleanups, and Portal Forms',
|
||||
'description': """
|
||||
@@ -46,6 +46,7 @@
|
||||
'fusion_tasks',
|
||||
],
|
||||
'data': [
|
||||
'security/security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/ltc_data.xml',
|
||||
'wizard/ltc_repair_create_so_wizard_views.xml',
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_ltc_facility_user,fusion.ltc.facility.user,model_fusion_ltc_facility,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_fusion_ltc_facility_manager,fusion.ltc.facility.manager,model_fusion_ltc_facility,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_ltc_floor_user,fusion.ltc.floor.user,model_fusion_ltc_floor,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_fusion_ltc_floor_manager,fusion.ltc.floor.manager,model_fusion_ltc_floor,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_ltc_station_user,fusion.ltc.station.user,model_fusion_ltc_station,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_fusion_ltc_station_manager,fusion.ltc.station.manager,model_fusion_ltc_station,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_ltc_repair_user,fusion.ltc.repair.user,model_fusion_ltc_repair,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_fusion_ltc_repair_manager,fusion.ltc.repair.manager,model_fusion_ltc_repair,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_ltc_repair_stage_user,fusion.ltc.repair.stage.user,model_fusion_ltc_repair_stage,sales_team.group_sale_salesman,1,0,0,0
|
||||
access_fusion_ltc_repair_stage_manager,fusion.ltc.repair.stage.manager,model_fusion_ltc_repair_stage,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_ltc_cleanup_user,fusion.ltc.cleanup.user,model_fusion_ltc_cleanup,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_fusion_ltc_cleanup_manager,fusion.ltc.cleanup.manager,model_fusion_ltc_cleanup,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_ltc_family_contact_user,fusion.ltc.family.contact.user,model_fusion_ltc_family_contact,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_fusion_ltc_family_contact_manager,fusion.ltc.family.contact.manager,model_fusion_ltc_family_contact,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_ltc_form_submission_user,fusion.ltc.form.submission.user,model_fusion_ltc_form_submission,sales_team.group_sale_salesman,1,1,0,0
|
||||
access_fusion_ltc_form_submission_manager,fusion.ltc.form.submission.manager,model_fusion_ltc_form_submission,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_ltc_repair_create_so_wizard_user,fusion.ltc.repair.create.so.wizard.user,model_fusion_ltc_repair_create_so_wizard,sales_team.group_sale_salesman,1,1,1,1
|
||||
access_fusion_ltc_repair_create_so_wizard_manager,fusion.ltc.repair.create.so.wizard.manager,model_fusion_ltc_repair_create_so_wizard,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_ltc_facility_base,fusion.ltc.facility.base,model_fusion_ltc_facility,base.group_user,1,0,0,0
|
||||
access_fusion_ltc_facility_user,fusion.ltc.facility.user,model_fusion_ltc_facility,group_fusion_ltc_user,1,1,1,0
|
||||
access_fusion_ltc_facility_manager,fusion.ltc.facility.manager,model_fusion_ltc_facility,group_fusion_ltc_manager,1,1,1,1
|
||||
access_fusion_ltc_floor_user,fusion.ltc.floor.user,model_fusion_ltc_floor,group_fusion_ltc_user,1,1,1,0
|
||||
access_fusion_ltc_floor_manager,fusion.ltc.floor.manager,model_fusion_ltc_floor,group_fusion_ltc_manager,1,1,1,1
|
||||
access_fusion_ltc_station_user,fusion.ltc.station.user,model_fusion_ltc_station,group_fusion_ltc_user,1,1,1,0
|
||||
access_fusion_ltc_station_manager,fusion.ltc.station.manager,model_fusion_ltc_station,group_fusion_ltc_manager,1,1,1,1
|
||||
access_fusion_ltc_repair_base,fusion.ltc.repair.base,model_fusion_ltc_repair,base.group_user,1,0,0,0
|
||||
access_fusion_ltc_repair_user,fusion.ltc.repair.user,model_fusion_ltc_repair,group_fusion_ltc_user,1,1,1,0
|
||||
access_fusion_ltc_repair_manager,fusion.ltc.repair.manager,model_fusion_ltc_repair,group_fusion_ltc_manager,1,1,1,1
|
||||
access_fusion_ltc_repair_stage_user,fusion.ltc.repair.stage.user,model_fusion_ltc_repair_stage,group_fusion_ltc_user,1,0,0,0
|
||||
access_fusion_ltc_repair_stage_manager,fusion.ltc.repair.stage.manager,model_fusion_ltc_repair_stage,group_fusion_ltc_manager,1,1,1,1
|
||||
access_fusion_ltc_cleanup_user,fusion.ltc.cleanup.user,model_fusion_ltc_cleanup,group_fusion_ltc_user,1,1,1,0
|
||||
access_fusion_ltc_cleanup_manager,fusion.ltc.cleanup.manager,model_fusion_ltc_cleanup,group_fusion_ltc_manager,1,1,1,1
|
||||
access_fusion_ltc_family_contact_base,fusion.ltc.family.contact.base,model_fusion_ltc_family_contact,base.group_user,1,0,0,0
|
||||
access_fusion_ltc_family_contact_user,fusion.ltc.family.contact.user,model_fusion_ltc_family_contact,group_fusion_ltc_user,1,1,1,0
|
||||
access_fusion_ltc_family_contact_manager,fusion.ltc.family.contact.manager,model_fusion_ltc_family_contact,group_fusion_ltc_manager,1,1,1,1
|
||||
access_fusion_ltc_form_submission_user,fusion.ltc.form.submission.user,model_fusion_ltc_form_submission,group_fusion_ltc_user,1,1,0,0
|
||||
access_fusion_ltc_form_submission_manager,fusion.ltc.form.submission.manager,model_fusion_ltc_form_submission,group_fusion_ltc_manager,1,1,1,1
|
||||
access_fusion_ltc_repair_create_so_wizard_user,fusion.ltc.repair.create.so.wizard.user,model_fusion_ltc_repair_create_so_wizard,group_fusion_ltc_user,1,1,1,1
|
||||
access_fusion_ltc_repair_create_so_wizard_manager,fusion.ltc.repair.create.so.wizard.manager,model_fusion_ltc_repair_create_so_wizard,group_fusion_ltc_manager,1,1,1,1
|
||||
|
||||
|
51
fusion_ltc_management/security/security.xml
Normal file
51
fusion_ltc_management/security/security.xml
Normal file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- MODULE CATEGORY -->
|
||||
<!-- Creates a "Fusion LTC" section in Settings › Users › Access Rights -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="module_category_fusion_ltc" model="ir.module.category">
|
||||
<field name="name">Fusion LTC</field>
|
||||
<field name="sequence">50</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- PRIVILEGE (Odoo 19 pattern) -->
|
||||
<!-- Links groups to the category so they render as a toggle in user -->
|
||||
<!-- settings rather than loose checkboxes. -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="res_groups_privilege_fusion_ltc" model="res.groups.privilege">
|
||||
<field name="name">Fusion LTC</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="category_id" ref="module_category_fusion_ltc"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- USER GROUP -->
|
||||
<!-- Can view, create, and edit LTC records. Cannot delete. -->
|
||||
<!-- Only implies base.group_user — no sales_team dependency. -->
|
||||
<!-- Users without this group have ZERO access to LTC Management. -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="group_fusion_ltc_user" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_ltc"/>
|
||||
<field name="comment">Can view and manage LTC facilities, repairs, cleanups, and form submissions. Cannot delete records or access configuration.</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- ADMINISTRATOR GROUP -->
|
||||
<!-- Full CRUD on all LTC models plus access to Configuration menu. -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="group_fusion_ltc_manager" model="res.groups">
|
||||
<field name="name">Administrator</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_ltc_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_ltc"/>
|
||||
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
<field name="comment">Full access to all LTC records including delete. Can manage repair stages and module configuration.</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -8,7 +8,7 @@
|
||||
name="LTC Management"
|
||||
web_icon="fusion_ltc_management,static/description/icon.png"
|
||||
sequence="45"
|
||||
groups="sales_team.group_sale_salesman"/>
|
||||
groups="fusion_ltc_management.group_fusion_ltc_user"/>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- OVERVIEW -->
|
||||
@@ -85,7 +85,7 @@
|
||||
name="Configuration"
|
||||
parent="menu_ltc_root"
|
||||
sequence="90"
|
||||
groups="sales_team.group_sale_manager"/>
|
||||
groups="fusion_ltc_management.group_fusion_ltc_manager"/>
|
||||
<menuitem id="menu_ltc_repair_stages"
|
||||
name="Repair Stages"
|
||||
parent="menu_ltc_config"
|
||||
|
||||
@@ -3,3 +3,44 @@
|
||||
from . import models
|
||||
from . import wizards
|
||||
from . import controllers
|
||||
|
||||
|
||||
def _fusion_payroll_post_init(env):
|
||||
"""Archive default salary rules auto-added to Canadian structure.
|
||||
|
||||
When hr_payroll creates a new structure, it automatically generates
|
||||
default rules (BASIC, GROSS, NET, etc.). Since this module defines
|
||||
its own custom rules, the defaults must be archived to avoid duplicates.
|
||||
"""
|
||||
structure = env.ref(
|
||||
'fusion_payroll.hr_payroll_structure_canada',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not structure:
|
||||
return
|
||||
|
||||
default_codes = [
|
||||
'BASIC', 'GROSS', 'NET',
|
||||
'ATTACH_SALARY', 'ASSIG_SALARY',
|
||||
'CHILD_SUPPORT', 'DEDUCTION', 'REIMBURSEMENT',
|
||||
]
|
||||
|
||||
own_rule_xmlids = [
|
||||
'fusion_payroll.hr_rule_basic',
|
||||
'fusion_payroll.hr_rule_gross',
|
||||
'fusion_payroll.hr_rule_net',
|
||||
]
|
||||
own_rule_ids = set()
|
||||
for xmlid in own_rule_xmlids:
|
||||
rule = env.ref(xmlid, raise_if_not_found=False)
|
||||
if rule:
|
||||
own_rule_ids.add(rule.id)
|
||||
|
||||
default_rules = env['hr.salary.rule'].search([
|
||||
('struct_id', '=', structure.id),
|
||||
('code', 'in', default_codes),
|
||||
])
|
||||
|
||||
rules_to_archive = default_rules.filtered(lambda r: r.id not in own_rule_ids)
|
||||
if rules_to_archive:
|
||||
rules_to_archive.active = False
|
||||
|
||||
@@ -49,20 +49,22 @@ Comprehensive Canadian payroll functionality inspired by QuickBooks Online Payro
|
||||
|
||||
Built for Odoo Enterprise Payroll (hr_payroll).
|
||||
""",
|
||||
'author': 'Your Company',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': '',
|
||||
'license': 'LGPL-3',
|
||||
'license': 'OPL-1',
|
||||
'depends': [
|
||||
'hr_payroll', # Core payroll functionality
|
||||
'hr_payroll', # Core payroll functionality (includes hr.version for contracts)
|
||||
'hr_work_entry_enterprise', # For payroll menu structure (Odoo 19)
|
||||
'hr_holidays', # For vacation/leave reports
|
||||
'hr_attendance', # For punch-in/out time tracking
|
||||
'mail', # For ROE chatter/tracking
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['lxml'],
|
||||
},
|
||||
'data': [
|
||||
# Views
|
||||
'views/pay_period_views.xml',
|
||||
'views/tax_yearly_rates_views.xml',
|
||||
'views/hr_employee_views.xml',
|
||||
'views/hr_roe_views.xml',
|
||||
'views/hr_payslip_views.xml',
|
||||
@@ -75,12 +77,12 @@ Built for Odoo Enterprise Payroll (hr_payroll).
|
||||
'views/payroll_tax_payment_schedule_views.xml',
|
||||
'views/payroll_config_settings_views.xml',
|
||||
'views/payroll_work_location_views.xml',
|
||||
'views/payroll_tax_payment_schedule_views.xml',
|
||||
'views/payroll_dashboard_views.xml',
|
||||
'views/payroll_cheque_views.xml',
|
||||
'views/payroll_cheque_print_wizard_views.xml',
|
||||
'views/cheque_number_wizard_views.xml',
|
||||
'views/cheque_layout_settings_views.xml',
|
||||
'views/payroll_migration_views.xml',
|
||||
# Central Menu Structure (must be last - references other actions)
|
||||
'views/fusion_payroll_menus.xml',
|
||||
|
||||
@@ -91,22 +93,22 @@ Built for Odoo Enterprise Payroll (hr_payroll).
|
||||
'reports/payroll_report_pdf.xml',
|
||||
|
||||
# Data (order matters!)
|
||||
# 1. Rule parameters (CPP, EI, Federal, Provincial rates - Odoo native approach)
|
||||
# 1. Rule parameters (CPP, EI, Federal, Provincial rates)
|
||||
'data/hr_rule_parameter_data.xml',
|
||||
# 2. Input types for additional pay (OT, Stat, Bonus)
|
||||
'data/hr_payslip_input_type_data.xml',
|
||||
# 3. Legacy tax rates data
|
||||
'data/tax_yearly_rates_data.xml',
|
||||
# 4. Payroll structure (creates structure and category)
|
||||
# 2. Payroll structure + categories (must be before rules and input types)
|
||||
'data/hr_payroll_structure.xml',
|
||||
# 5. Canadian salary rules (references structure and parameters)
|
||||
# 3. Input types (references structure)
|
||||
'data/hr_payslip_input_type_data.xml',
|
||||
# 4. Canadian salary rules (references structure and parameters)
|
||||
'data/hr_salary_rules.xml',
|
||||
# 6. Demo/Sample data (loads on install)
|
||||
'demo/demo_data.xml',
|
||||
# 6. Sequences
|
||||
'data/ir_sequence_data.xml',
|
||||
# Security (load last to ensure all models are registered)
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'demo': [],
|
||||
'demo': [
|
||||
'demo/demo_data.xml',
|
||||
],
|
||||
'images': ['static/description/icon.png'],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
@@ -123,4 +125,5 @@ Built for Odoo Enterprise Payroll (hr_payroll).
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
'post_init_hook': '_fusion_payroll_post_init',
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import payroll_report
|
||||
from . import migration_download
|
||||
|
||||
116
fusion_payroll/controllers/migration_download.py
Normal file
116
fusion_payroll/controllers/migration_download.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import io
|
||||
import csv
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request, content_disposition
|
||||
|
||||
|
||||
class MigrationDownloadController(http.Controller):
|
||||
|
||||
@http.route('/fusion_payroll/download_sample/<string:template_name>', type='http', auth='user')
|
||||
def download_sample_csv(self, template_name, **kwargs):
|
||||
"""Download a sample CSV template with demo data."""
|
||||
templates = {
|
||||
'employee': self._employee_sample(),
|
||||
'payslip': self._payslip_sample(),
|
||||
'ytd': self._ytd_sample(),
|
||||
't4': self._t4_sample(),
|
||||
}
|
||||
if template_name not in templates:
|
||||
return request.not_found()
|
||||
|
||||
headers, rows = templates[template_name]
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(headers)
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
|
||||
csv_bytes = output.getvalue().encode('utf-8-sig')
|
||||
filename = '%s_import_sample.csv' % template_name
|
||||
|
||||
return request.make_response(
|
||||
csv_bytes,
|
||||
headers=[
|
||||
('Content-Type', 'text/csv; charset=utf-8'),
|
||||
('Content-Disposition', content_disposition(filename)),
|
||||
],
|
||||
)
|
||||
|
||||
def _employee_sample(self):
|
||||
headers = [
|
||||
'First Name', 'Last Name', 'SIN', 'Street Address', 'City', 'Province',
|
||||
'Postal Code', 'Hire Date', 'Pay Type', 'Hourly Rate', 'Annual Salary',
|
||||
'Pay Frequency', 'Federal TD1 Amount', 'Provincial TD1 Amount',
|
||||
'Additional Federal Tax', 'Vacation Rate', 'Payment Method', 'Dental Benefits Code',
|
||||
]
|
||||
rows = [
|
||||
['Sarah', 'Johnson', '123-456-789', '123 Maple Street', 'Toronto', 'ON',
|
||||
'M5V 2T6', '2023-03-15', 'Hourly', '28.50', '', 'Bi-Weekly', '16452', '12989',
|
||||
'0', '4', 'Direct Deposit', '1'],
|
||||
['Michael', 'Chen', '987-654-321', '456 Oak Avenue', 'Vancouver', 'BC',
|
||||
'V6B 3K9', '2022-09-01', 'Salary', '', '78000', 'Bi-Weekly', '16452', '12273',
|
||||
'50', '4', 'Direct Deposit', '3'],
|
||||
['Emily', 'Tremblay', '456-789-123', '789 Pine Boulevard', 'Calgary', 'AB',
|
||||
'T2P 1J9', '2024-01-10', 'Hourly', '35.00', '', 'Bi-Weekly', '16452', '21885',
|
||||
'0', '6', 'Cheque', '1'],
|
||||
]
|
||||
return headers, rows
|
||||
|
||||
def _payslip_sample(self):
|
||||
headers = [
|
||||
'Employee Name', 'Pay Date', 'Period Start', 'Period End',
|
||||
'Regular Pay', 'Overtime Pay', 'Vacation Pay', 'Stat Holiday Pay',
|
||||
'Bonus', 'Gross Pay', 'RRSP', 'Union Dues',
|
||||
'CPP', 'CPP2', 'EI', 'Federal Tax', 'Provincial Tax',
|
||||
'Net Pay', 'Cheque #',
|
||||
]
|
||||
rows = [
|
||||
['Sarah Johnson', '2026-01-16', '2026-01-01', '2026-01-15',
|
||||
'2280.00', '0.00', '91.20', '0.00', '0.00', '2371.20', '0.00', '0.00',
|
||||
'131.75', '0.00', '38.65', '272.15', '119.75', '1808.90', ''],
|
||||
['Sarah Johnson', '2026-01-30', '2026-01-16', '2026-01-31',
|
||||
'2280.00', '213.75', '99.75', '0.00', '0.00', '2593.50', '0.00', '0.00',
|
||||
'144.98', '0.00', '42.27', '298.25', '131.22', '1976.78', ''],
|
||||
['Michael Chen', '2026-01-16', '2026-01-01', '2026-01-15',
|
||||
'3000.00', '0.00', '120.00', '0.00', '0.00', '3120.00', '100.00', '45.00',
|
||||
'171.52', '0.00', '50.86', '385.60', '157.56', '2209.46', ''],
|
||||
]
|
||||
return headers, rows
|
||||
|
||||
def _ytd_sample(self):
|
||||
headers = [
|
||||
'Employee', 'YTD Gross', 'YTD CPP', 'YTD CPP2', 'YTD EI',
|
||||
'YTD Federal Tax', 'YTD Provincial Tax', 'YTD RRSP', 'YTD Union Dues', 'YTD Net',
|
||||
]
|
||||
rows = [
|
||||
['Sarah Johnson', '32500.00', '1831.06', '0.00', '529.75',
|
||||
'3737.50', '1641.25', '0.00', '0.00', '24760.44'],
|
||||
['Michael Chen', '42000.00', '2372.04', '168.00', '684.60',
|
||||
'5460.00', '2118.60', '1300.00', '585.00', '29311.76'],
|
||||
['Emily Tremblay', '36400.00', '2050.68', '0.00', '593.32',
|
||||
'3276.00', '2912.00', '0.00', '0.00', '27567.00'],
|
||||
]
|
||||
return headers, rows
|
||||
|
||||
def _t4_sample(self):
|
||||
headers = [
|
||||
'Employee Name', 'SIN', 'Tax Year', 'Box 14 - Employment Income',
|
||||
'Box 16 - CPP', 'Box 16A - CPP2', 'Box 18 - EI Premiums',
|
||||
'Box 20 - RPP/RRSP', 'Box 22 - Income Tax', 'Box 24 - EI Insurable',
|
||||
'Box 26 - CPP Pensionable', 'Box 44 - Union Dues',
|
||||
]
|
||||
rows = [
|
||||
['Sarah Johnson', '123456789', '2025', '65000.00',
|
||||
'3754.45', '0.00', '1064.20', '0.00', '10750.00', '65000.00',
|
||||
'65000.00', '0.00'],
|
||||
['Michael Chen', '987654321', '2025', '78000.00',
|
||||
'4034.10', '268.00', '1077.48', '2400.00', '14820.00', '68900.00',
|
||||
'71300.00', '1170.00'],
|
||||
['Emily Tremblay', '456789123', '2025', '72800.00',
|
||||
'4034.10', '60.00', '1077.48', '0.00', '12376.00', '68900.00',
|
||||
'71300.00', '0.00'],
|
||||
]
|
||||
return headers, rows
|
||||
@@ -2,133 +2,55 @@
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CANADA SALARY STRUCTURE -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<!-- Salary Structure Type (if needed) -->
|
||||
<!-- Structure Type -->
|
||||
<record id="structure_type_canada" model="hr.payroll.structure.type">
|
||||
<field name="name">Canada</field>
|
||||
<field name="name">Canadian Employee</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
</record>
|
||||
|
||||
<!-- Canada Salary Structure -->
|
||||
<!-- Salary Structure -->
|
||||
<record id="hr_payroll_structure_canada" model="hr.payroll.structure">
|
||||
<field name="name">Canada salary structure</field>
|
||||
<field name="name">Canadian Employee Salary</field>
|
||||
<field name="code">Canada</field>
|
||||
<field name="type_id" ref="structure_type_canada"/>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SALARY RULE CATEGORY - CANADA (Deduction) -->
|
||||
<!-- This category links to CPP, EI, Federal and Provincial Tax -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_payroll_category_canada" model="hr.salary.rule.category">
|
||||
<field name="name">Deduction</field>
|
||||
<field name="code">CANADA</field>
|
||||
<field name="parent_id" ref="hr_payroll.DED"/>
|
||||
<!-- These links are set via the UI after tax rates are created -->
|
||||
<!-- cpp_deduction_id, ei_deduction_id, fed_tax_id, provincial_tax_id -->
|
||||
<!-- Salary Rule Categories -->
|
||||
<record id="hr_salary_rule_category_ca_cpp" model="hr.salary.rule.category">
|
||||
<field name="name">CPP</field>
|
||||
<field name="code">CPP</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- LINK SALARY RULES TO CANADA STRUCTURE -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<!-- Basic Salary -->
|
||||
<record id="hr_rule_basic" model="hr.salary.rule">
|
||||
<field name="name">Basic Salary</field>
|
||||
<field name="code">BASIC</field>
|
||||
<field name="sequence">1</field>
|
||||
<field name="category_id" ref="hr_payroll.BASIC"/>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">result = payslip.paid_amount</field>
|
||||
<record id="hr_salary_rule_category_ca_ei" model="hr.salary.rule.category">
|
||||
<field name="name">EI</field>
|
||||
<field name="code">EI</field>
|
||||
</record>
|
||||
|
||||
<!-- House Rent Allowance -->
|
||||
<record id="hr_rule_hra" model="hr.salary.rule">
|
||||
<field name="name">House Rent Allowance</field>
|
||||
<field name="code">HRA</field>
|
||||
<field name="sequence">5</field>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">fix</field>
|
||||
<field name="amount_fix">0.0</field>
|
||||
<record id="hr_salary_rule_category_ca_fed_tax" model="hr.salary.rule.category">
|
||||
<field name="name">Federal Tax</field>
|
||||
<field name="code">FED_TAX</field>
|
||||
</record>
|
||||
|
||||
<!-- Dearness Allowance -->
|
||||
<record id="hr_rule_da" model="hr.salary.rule">
|
||||
<field name="name">Dearness Allowance</field>
|
||||
<field name="code">DA</field>
|
||||
<field name="sequence">6</field>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">fix</field>
|
||||
<field name="amount_fix">0.0</field>
|
||||
<record id="hr_salary_rule_category_ca_prov_tax" model="hr.salary.rule.category">
|
||||
<field name="name">Provincial Tax</field>
|
||||
<field name="code">PROV_TAX</field>
|
||||
</record>
|
||||
|
||||
<!-- Travel Allowance -->
|
||||
<record id="hr_rule_travel" model="hr.salary.rule">
|
||||
<field name="name">Travel Allowance</field>
|
||||
<field name="code">Travel</field>
|
||||
<field name="sequence">7</field>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">fix</field>
|
||||
<field name="amount_fix">0.0</field>
|
||||
<record id="hr_salary_rule_category_ca_ohp" model="hr.salary.rule.category">
|
||||
<field name="name">Ontario Health Premium</field>
|
||||
<field name="code">OHP</field>
|
||||
</record>
|
||||
|
||||
<!-- Meal Allowance -->
|
||||
<record id="hr_rule_meal" model="hr.salary.rule">
|
||||
<field name="name">Meal Allowance</field>
|
||||
<field name="code">Meal</field>
|
||||
<field name="sequence">8</field>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">fix</field>
|
||||
<field name="amount_fix">0.0</field>
|
||||
<record id="hr_salary_rule_category_ca_employer" model="hr.salary.rule.category">
|
||||
<field name="name">Employer Contributions</field>
|
||||
<field name="code">EMPLOYER</field>
|
||||
</record>
|
||||
|
||||
<!-- Medical Allowance -->
|
||||
<record id="hr_rule_medical" model="hr.salary.rule">
|
||||
<field name="name">Medical Allowance</field>
|
||||
<field name="code">Medical</field>
|
||||
<field name="sequence">9</field>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">fix</field>
|
||||
<field name="amount_fix">0.0</field>
|
||||
</record>
|
||||
|
||||
<!-- Gross Salary -->
|
||||
<record id="hr_rule_gross" model="hr.salary.rule">
|
||||
<field name="name">Gross</field>
|
||||
<field name="code">GROSS</field>
|
||||
<field name="sequence">100</field>
|
||||
<field name="category_id" ref="hr_payroll.GROSS"/>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">result = categories.BASIC + categories.ALW</field>
|
||||
</record>
|
||||
|
||||
<!-- Net Salary -->
|
||||
<record id="hr_rule_net" model="hr.salary.rule">
|
||||
<field name="name">Net Salary</field>
|
||||
<field name="code">NET</field>
|
||||
<field name="sequence">200</field>
|
||||
<field name="category_id" ref="hr_payroll.NET"/>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">result = categories.GROSS + categories.DED</field>
|
||||
<record id="hr_salary_rule_category_ca_earnings" model="hr.salary.rule.category">
|
||||
<field name="name">Earnings</field>
|
||||
<field name="code">EARN</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
|
||||
@@ -2,37 +2,28 @@
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CANADIAN PAYSLIP INPUT TYPES -->
|
||||
<!-- These are used to pass additional pay inputs to salary rules -->
|
||||
<!-- ============================================================ -->
|
||||
<!-- Overtime (dollar amount) -->
|
||||
<record id="input_type_ot" model="hr.payslip.input.type">
|
||||
<field name="name">Overtime</field>
|
||||
<field name="code">OT</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="struct_ids" eval="[(4, ref('hr_payroll_structure_canada'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Overtime Hours -->
|
||||
<record id="input_type_ot_hours" model="hr.payslip.input.type">
|
||||
<field name="name">Overtime Hours</field>
|
||||
<field name="code">OT_HOURS</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="struct_ids" eval="[(4, ref('hr_payroll_structure_canada'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Stat Holiday Hours -->
|
||||
<record id="input_type_stat_hours" model="hr.payslip.input.type">
|
||||
<field name="name">Stat Holiday Hours</field>
|
||||
<field name="code">STAT_HOURS</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
</record>
|
||||
|
||||
<!-- Bonus Amount -->
|
||||
<!-- Bonus -->
|
||||
<record id="input_type_bonus" model="hr.payslip.input.type">
|
||||
<field name="name">Bonus</field>
|
||||
<field name="code">BONUS</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
</record>
|
||||
|
||||
<!-- Vacation Payout -->
|
||||
<record id="input_type_vacation_payout" model="hr.payslip.input.type">
|
||||
<field name="name">Vacation Payout</field>
|
||||
<field name="code">VACATION_PAYOUT</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="struct_ids" eval="[(4, ref('hr_payroll_structure_canada'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Commission -->
|
||||
@@ -40,6 +31,39 @@
|
||||
<field name="name">Commission</field>
|
||||
<field name="code">COMMISSION</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="struct_ids" eval="[(4, ref('hr_payroll_structure_canada'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- RRSP Deduction -->
|
||||
<record id="input_type_rrsp" model="hr.payslip.input.type">
|
||||
<field name="name">RRSP Deduction</field>
|
||||
<field name="code">RRSP</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="struct_ids" eval="[(4, ref('hr_payroll_structure_canada'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Union Dues -->
|
||||
<record id="input_type_union" model="hr.payslip.input.type">
|
||||
<field name="name">Union Dues</field>
|
||||
<field name="code">UNION</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="struct_ids" eval="[(4, ref('hr_payroll_structure_canada'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Vacation Pay -->
|
||||
<record id="input_type_vac_pay" model="hr.payslip.input.type">
|
||||
<field name="name">Vacation Pay</field>
|
||||
<field name="code">VAC_PAY</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="struct_ids" eval="[(4, ref('hr_payroll_structure_canada'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Stat Holiday Hours -->
|
||||
<record id="input_type_stat_hours" model="hr.payslip.input.type">
|
||||
<field name="name">Stat Holiday Hours</field>
|
||||
<field name="code">STAT_HOURS</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="struct_ids" eval="[(4, ref('hr_payroll_structure_canada'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Retroactive Pay -->
|
||||
@@ -47,6 +71,7 @@
|
||||
<field name="name">Retroactive Pay</field>
|
||||
<field name="code">RETRO_PAY</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="struct_ids" eval="[(4, ref('hr_payroll_structure_canada'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Shift Premium -->
|
||||
@@ -54,6 +79,7 @@
|
||||
<field name="name">Shift Premium</field>
|
||||
<field name="code">SHIFT_PREMIUM</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="struct_ids" eval="[(4, ref('hr_payroll_structure_canada'))]"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CANADA PENSION PLAN (CPP) PARAMETERS - 2025 -->
|
||||
<!-- CANADA PENSION PLAN (CPP) PARAMETERS - 2026 -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
|
||||
<record id="rule_parameter_ca_cpp_rate" model="hr.rule.parameter">
|
||||
<field name="name">Canada - CPP Rate</field>
|
||||
<field name="code">ca_cpp_rate</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">CPP employee/employer contribution rate</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_cpp_rate_2025" model="hr.rule.parameter.value">
|
||||
<record id="rule_parameter_ca_cpp_rate_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_cpp_rate"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">0.0595</field>
|
||||
</record>
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">CPP basic exemption amount per year</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_cpp_exemption_2025" model="hr.rule.parameter.value">
|
||||
<record id="rule_parameter_ca_cpp_exemption_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_cpp_exemption"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="parameter_value">3500.00</field>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">3500</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_cpp_max" model="hr.rule.parameter">
|
||||
@@ -36,14 +36,38 @@
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Maximum CPP employee contribution per year</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_cpp_max_2025" model="hr.rule.parameter.value">
|
||||
<record id="rule_parameter_ca_cpp_max_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_cpp_max"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="parameter_value">4034.10</field>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">4230.45</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_ympe" model="hr.rule.parameter">
|
||||
<field name="name">Canada - YMPE (CPP Ceiling 1)</field>
|
||||
<field name="code">ca_ympe</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Year's Maximum Pensionable Earnings - CPP first ceiling</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_ympe_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_ympe"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">74600</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_yampe" model="hr.rule.parameter">
|
||||
<field name="name">Canada - YAMPE (CPP Ceiling 2)</field>
|
||||
<field name="code">ca_yampe</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Year's Additional Maximum Pensionable Earnings - CPP second ceiling</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_yampe_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_yampe"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">85000</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECOND CANADA PENSION PLAN (CPP2) PARAMETERS - 2025 -->
|
||||
<!-- SECOND CANADA PENSION PLAN (CPP2) PARAMETERS - 2026 -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="rule_parameter_ca_cpp2_rate" model="hr.rule.parameter">
|
||||
@@ -52,9 +76,9 @@
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">CPP2 contribution rate (on earnings above YMPE)</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_cpp2_rate_2025" model="hr.rule.parameter.value">
|
||||
<record id="rule_parameter_ca_cpp2_rate_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_cpp2_rate"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">0.04</field>
|
||||
</record>
|
||||
|
||||
@@ -64,38 +88,14 @@
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Maximum CPP2 employee contribution per year</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_cpp2_max_2025" model="hr.rule.parameter.value">
|
||||
<record id="rule_parameter_ca_cpp2_max_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_cpp2_max"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="parameter_value">396.00</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_ympe" model="hr.rule.parameter">
|
||||
<field name="name">Canada - YMPE (CPP Ceiling 1)</field>
|
||||
<field name="code">ca_ympe</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Year's Maximum Pensionable Earnings - CPP first ceiling</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_ympe_2025" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_ympe"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="parameter_value">71300.00</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_yampe" model="hr.rule.parameter">
|
||||
<field name="name">Canada - YAMPE (CPP Ceiling 2)</field>
|
||||
<field name="code">ca_yampe</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Year's Additional Maximum Pensionable Earnings - CPP second ceiling</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_yampe_2025" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_yampe"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="parameter_value">81200.00</field>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">416.00</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- EMPLOYMENT INSURANCE (EI) PARAMETERS - 2025 -->
|
||||
<!-- EMPLOYMENT INSURANCE (EI) PARAMETERS - 2026 -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="rule_parameter_ca_ei_rate" model="hr.rule.parameter">
|
||||
@@ -104,10 +104,22 @@
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">EI employee contribution rate</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_ei_rate_2025" model="hr.rule.parameter.value">
|
||||
<record id="rule_parameter_ca_ei_rate_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_ei_rate"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="parameter_value">0.0164</field>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">0.0163</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_ei_max_insurable" model="hr.rule.parameter">
|
||||
<field name="name">Canada - EI Maximum Insurable Earnings</field>
|
||||
<field name="code">ca_ei_max_insurable</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Maximum annual insurable earnings for EI</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_ei_max_insurable_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_ei_max_insurable"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">68900</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_ei_max" model="hr.rule.parameter">
|
||||
@@ -116,10 +128,10 @@
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Maximum EI employee premium per year</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_ei_max_2025" model="hr.rule.parameter.value">
|
||||
<record id="rule_parameter_ca_ei_max_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_ei_max"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="parameter_value">1077.48</field>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">1123.07</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_ei_employer_mult" model="hr.rule.parameter">
|
||||
@@ -128,26 +140,150 @@
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">EI employer contribution multiplier (1.4x employee)</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_ei_employer_mult_2025" model="hr.rule.parameter.value">
|
||||
<record id="rule_parameter_ca_ei_employer_mult_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_ei_employer_mult"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">1.4</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- FEDERAL TAX PARAMETERS - 2025 -->
|
||||
<!-- FEDERAL TAX BRACKETS - 2026 -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="rule_parameter_ca_fed_bracket_1" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Federal Tax Bracket 1 Threshold</field>
|
||||
<field name="code">ca_fed_bracket_1</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Upper limit of the first federal tax bracket</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_bracket_1_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_bracket_1"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">58523</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_fed_rate_1" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Federal Tax Rate 1</field>
|
||||
<field name="code">ca_fed_rate_1</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Federal tax rate for the first bracket</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_rate_1_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_rate_1"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">0.14</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_fed_bracket_2" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Federal Tax Bracket 2 Threshold</field>
|
||||
<field name="code">ca_fed_bracket_2</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Upper limit of the second federal tax bracket</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_bracket_2_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_bracket_2"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">117045</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_fed_rate_2" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Federal Tax Rate 2</field>
|
||||
<field name="code">ca_fed_rate_2</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Federal tax rate for the second bracket</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_rate_2_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_rate_2"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">0.205</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_fed_bracket_3" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Federal Tax Bracket 3 Threshold</field>
|
||||
<field name="code">ca_fed_bracket_3</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Upper limit of the third federal tax bracket</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_bracket_3_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_bracket_3"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">181440</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_fed_rate_3" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Federal Tax Rate 3</field>
|
||||
<field name="code">ca_fed_rate_3</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Federal tax rate for the third bracket</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_rate_3_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_rate_3"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">0.26</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_fed_bracket_4" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Federal Tax Bracket 4 Threshold</field>
|
||||
<field name="code">ca_fed_bracket_4</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Upper limit of the fourth federal tax bracket</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_bracket_4_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_bracket_4"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">258482</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_fed_rate_4" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Federal Tax Rate 4</field>
|
||||
<field name="code">ca_fed_rate_4</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Federal tax rate for the fourth bracket</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_rate_4_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_rate_4"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">0.29</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_fed_rate_5" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Federal Tax Rate 5</field>
|
||||
<field name="code">ca_fed_rate_5</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Federal tax rate for the fifth bracket (above bracket 4)</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_rate_5_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_rate_5"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">0.33</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- FEDERAL TAX CREDITS - 2026 -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="rule_parameter_ca_fed_bpa" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Federal Basic Personal Amount</field>
|
||||
<field name="name">Canada - Federal Basic Personal Amount (Max)</field>
|
||||
<field name="code">ca_fed_bpa</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Federal basic personal amount (TD1 default)</field>
|
||||
<field name="description">Federal basic personal amount - maximum (TD1 default)</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_bpa_2025" model="hr.rule.parameter.value">
|
||||
<record id="rule_parameter_ca_fed_bpa_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_bpa"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="parameter_value">16129</field>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">16452</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_fed_bpa_min" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Federal Basic Personal Amount (Min)</field>
|
||||
<field name="code">ca_fed_bpa_min</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Federal basic personal amount - minimum for high-income phase-out</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_bpa_min_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_bpa_min"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">14829</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_fed_cea" model="hr.rule.parameter">
|
||||
@@ -156,54 +292,14 @@
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Canada Employment Amount for federal tax credit</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_cea_2025" model="hr.rule.parameter.value">
|
||||
<record id="rule_parameter_ca_fed_cea_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_cea"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">1433</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_fed_brackets" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Federal Tax Brackets</field>
|
||||
<field name="code">ca_fed_brackets</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Federal income tax brackets: [(threshold, rate), ...]</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_fed_brackets_2025" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_brackets"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="parameter_value">[(57375, 0.15), (114750, 0.205), (177882, 0.26), (253414, 0.29), (float('inf'), 0.33)]</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ONTARIO PROVINCIAL TAX PARAMETERS - 2025 -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="rule_parameter_ca_on_bpa" model="hr.rule.parameter">
|
||||
<field name="name">Canada Ontario - Basic Personal Amount</field>
|
||||
<field name="code">ca_on_bpa</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Ontario basic personal amount</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_on_bpa_2025" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_on_bpa"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="parameter_value">12399</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_on_brackets" model="hr.rule.parameter">
|
||||
<field name="name">Canada Ontario - Tax Brackets</field>
|
||||
<field name="code">ca_on_brackets</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Ontario income tax brackets: [(threshold, rate), ...]</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_on_brackets_2025" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_on_brackets"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="parameter_value">[(52886, 0.0505), (105775, 0.0915), (150000, 0.1116), (220000, 0.1216), (float('inf'), 0.1316)]</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- VACATION PAY RATE -->
|
||||
<!-- VACATION PAY - 2026 -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="rule_parameter_ca_vacation_rate" model="hr.rule.parameter">
|
||||
@@ -212,11 +308,39 @@
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Vacation pay percentage (Ontario minimum 4%)</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_vacation_rate_2025" model="hr.rule.parameter.value">
|
||||
<record id="rule_parameter_ca_vacation_rate_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_vacation_rate"/>
|
||||
<field name="date_from">2025-01-01</field>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">0.04</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- OVERTIME PARAMETERS - 2026 -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="rule_parameter_ca_overtime_multiplier" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Overtime Pay Multiplier</field>
|
||||
<field name="code">ca_overtime_multiplier</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Overtime pay multiplier (1.5x regular rate)</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_overtime_multiplier_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_overtime_multiplier"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">1.5</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ca_standard_hours_per_period" model="hr.rule.parameter">
|
||||
<field name="name">Canada - Standard Hours Per Pay Period</field>
|
||||
<field name="code">ca_standard_hours_per_period</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="description">Standard hours per bi-weekly pay period</field>
|
||||
</record>
|
||||
<record id="rule_parameter_ca_standard_hours_per_period_2026" model="hr.rule.parameter.value">
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ca_standard_hours_per_period"/>
|
||||
<field name="date_from">2026-01-01</field>
|
||||
<field name="parameter_value">80</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -1,58 +1,101 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<data noupdate="0">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- OVERTIME PAY - 1.5x Regular Rate -->
|
||||
<!-- BASIC PAY (seq 1) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_overtime_pay" model="hr.salary.rule">
|
||||
<field name="name">Overtime Pay</field>
|
||||
<field name="code">OT_PAY</field>
|
||||
<field name="sequence">101</field>
|
||||
<record id="hr_rule_basic" model="hr.salary.rule">
|
||||
<field name="name">Basic Pay</field>
|
||||
<field name="code">BASIC</field>
|
||||
<field name="sequence">1</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = 'OT_HOURS' in inputs</field>
|
||||
<field name="category_id" ref="hr_payroll.BASIC"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
# Overtime Pay - 1.5x regular hourly rate
|
||||
OT_MULTIPLIER = 1.5
|
||||
ot_hours = inputs['OT_HOURS'].amount if 'OT_HOURS' in inputs else 0
|
||||
# Calculate hourly rate from paid amount (assuming semi-monthly ~86.67 hours)
|
||||
hourly_rate = payslip.paid_amount / 86.67 if payslip.paid_amount else 0
|
||||
result = ot_hours * hourly_rate * OT_MULTIPLIER
|
||||
result = payslip.paid_amount
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- STAT HOLIDAY PAY -->
|
||||
<!-- OVERTIME PAY (seq 3) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_overtime_pay" model="hr.salary.rule">
|
||||
<field name="name">Overtime Pay</field>
|
||||
<field name="code">OT_PAY</field>
|
||||
<field name="sequence">3</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = (inputs.get('OT_HOURS') and inputs['OT_HOURS'].amount > 0) or (inputs.get('OT') and inputs['OT'].amount > 0)</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
if inputs.get('OT_HOURS') and inputs['OT_HOURS'].amount > 0:
|
||||
basic = result_rules.get('BASIC', {}).get('total', 0)
|
||||
hours_per_period = payslip._rule_parameter('ca_standard_hours_per_period')
|
||||
ot_multiplier = payslip._rule_parameter('ca_overtime_multiplier')
|
||||
hourly_rate = basic / hours_per_period if hours_per_period else 0
|
||||
result = inputs['OT_HOURS'].amount * hourly_rate * ot_multiplier
|
||||
elif inputs.get('OT') and inputs['OT'].amount > 0:
|
||||
result = inputs['OT'].amount
|
||||
else:
|
||||
result = 0
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- STAT HOLIDAY PAY (seq 4) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_stat_holiday_pay" model="hr.salary.rule">
|
||||
<field name="name">Stat Holiday Pay</field>
|
||||
<field name="code">STAT_PAY</field>
|
||||
<field name="sequence">102</field>
|
||||
<field name="sequence">4</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = 'STAT_HOURS' in inputs</field>
|
||||
<field name="condition_python">result = inputs.get('STAT_HOURS') and inputs['STAT_HOURS'].amount > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
# Stat Holiday Pay
|
||||
stat_hours = inputs['STAT_HOURS'].amount if 'STAT_HOURS' in inputs else 0
|
||||
hourly_rate = payslip.paid_amount / 86.67 if payslip.paid_amount else 0
|
||||
stat_hours = inputs['STAT_HOURS'].amount if inputs.get('STAT_HOURS') else 0
|
||||
basic = result_rules.get('BASIC', {}).get('total', 0)
|
||||
hours_per_period = payslip._rule_parameter('ca_standard_hours_per_period')
|
||||
hourly_rate = basic / hours_per_period if hours_per_period else 0
|
||||
result = stat_hours * hourly_rate
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- BONUS PAY -->
|
||||
<!-- VACATION PAY (seq 5) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_vacation_pay" model="hr.salary.rule">
|
||||
<field name="name">Vacation Pay</field>
|
||||
<field name="code">VAC_PAY</field>
|
||||
<field name="sequence">5</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_salary_rule_category_ca_earnings"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = inputs.get('VAC_PAY') and inputs['VAC_PAY'].amount > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
if inputs.get('VAC_PAY') and inputs['VAC_PAY'].amount > 0:
|
||||
result = round(float(inputs['VAC_PAY'].amount), 2)
|
||||
else:
|
||||
result = 0
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- BONUS PAY (seq 6) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_bonus_pay" model="hr.salary.rule">
|
||||
<field name="name">Bonus</field>
|
||||
<field name="code">BONUS_PAY</field>
|
||||
<field name="sequence">103</field>
|
||||
<field name="sequence">6</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="condition_select">python</field>
|
||||
@@ -60,297 +103,468 @@ result = stat_hours * hourly_rate
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
# Bonus Pay - direct amount from input
|
||||
result = inputs['BONUS'].amount if 'BONUS' in inputs else 0
|
||||
result = inputs['BONUS'].amount if inputs.get('BONUS') else 0
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CPP EMPLOYEE - Canada Pension Plan (Employee Portion) -->
|
||||
<!-- Uses rule parameters for rates and limits -->
|
||||
<!-- COMMISSION (seq 7) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_commission_pay" model="hr.salary.rule">
|
||||
<field name="name">Commission</field>
|
||||
<field name="code">COMMISSION</field>
|
||||
<field name="sequence">7</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = inputs.get('COMMISSION') and inputs['COMMISSION'].amount > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
result = inputs['COMMISSION'].amount if inputs.get('COMMISSION') else 0
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- RETROACTIVE PAY (seq 8) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_retro_pay" model="hr.salary.rule">
|
||||
<field name="name">Retroactive Pay</field>
|
||||
<field name="code">RETRO_PAY</field>
|
||||
<field name="sequence">8</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = inputs.get('RETRO_PAY') and inputs['RETRO_PAY'].amount > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
result = inputs['RETRO_PAY'].amount if inputs.get('RETRO_PAY') else 0
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SHIFT PREMIUM (seq 9) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_shift_premium" model="hr.salary.rule">
|
||||
<field name="name">Shift Premium</field>
|
||||
<field name="code">SHIFT_PREMIUM</field>
|
||||
<field name="sequence">9</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = inputs.get('SHIFT_PREMIUM') and inputs['SHIFT_PREMIUM'].amount > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
result = inputs['SHIFT_PREMIUM'].amount if inputs.get('SHIFT_PREMIUM') else 0
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- GROSS (seq 10) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_rule_gross" model="hr.salary.rule">
|
||||
<field name="name">Gross</field>
|
||||
<field name="code">GROSS</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.GROSS"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
result = (
|
||||
result_rules.get('BASIC', {}).get('total', 0)
|
||||
+ result_rules.get('OT_PAY', {}).get('total', 0)
|
||||
+ result_rules.get('STAT_PAY', {}).get('total', 0)
|
||||
+ result_rules.get('VAC_PAY', {}).get('total', 0)
|
||||
+ result_rules.get('BONUS_PAY', {}).get('total', 0)
|
||||
+ result_rules.get('COMMISSION', {}).get('total', 0)
|
||||
+ result_rules.get('RETRO_PAY', {}).get('total', 0)
|
||||
+ result_rules.get('SHIFT_PREMIUM', {}).get('total', 0)
|
||||
)
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- RRSP DEDUCTION (seq 15) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_rrsp_deduction" model="hr.salary.rule">
|
||||
<field name="name">RRSP Deduction</field>
|
||||
<field name="code">RRSP</field>
|
||||
<field name="sequence">15</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.DED"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = inputs.get('RRSP') and inputs['RRSP'].amount > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
result = -(inputs['RRSP'].amount if inputs.get('RRSP') else 0)
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- UNION DUES (seq 16) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_union_dues" model="hr.salary.rule">
|
||||
<field name="name">Union Dues</field>
|
||||
<field name="code">UNION_DUES</field>
|
||||
<field name="sequence">16</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.DED"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = inputs.get('UNION') and inputs['UNION'].amount > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
result = -(inputs['UNION'].amount if inputs.get('UNION') else 0)
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CPP EMPLOYEE (seq 20) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_cpp_employee" model="hr.salary.rule">
|
||||
<field name="name">CPP Employee</field>
|
||||
<field name="code">CPP_EE</field>
|
||||
<field name="sequence">150</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.DED"/>
|
||||
<field name="category_id" ref="hr_salary_rule_category_ca_cpp"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
# CPP Employee Deduction - Using Rule Parameters
|
||||
CPP_RATE = payslip._rule_parameter('ca_cpp_rate')
|
||||
CPP_EXEMPTION = payslip._rule_parameter('ca_cpp_exemption')
|
||||
CPP_MAX = payslip._rule_parameter('ca_cpp_max')
|
||||
PAY_PERIODS = 24 # Semi-monthly
|
||||
|
||||
exemption_per_period = CPP_EXEMPTION / PAY_PERIODS
|
||||
gross = categories['GROSS']
|
||||
pensionable = max(0, gross - exemption_per_period)
|
||||
cpp = pensionable * CPP_RATE
|
||||
|
||||
# YTD check - get year start
|
||||
from datetime import date
|
||||
year_start = date(payslip.date_from.year, 1, 1)
|
||||
ytd = payslip._sum('CPP_EE', year_start, payslip.date_from) or 0
|
||||
remaining = CPP_MAX + ytd # ytd is negative
|
||||
|
||||
if remaining <= 0:
|
||||
gross_amount = result_rules.get('GROSS', {}).get('total', 0)
|
||||
if employee.exempt_cpp:
|
||||
result = 0
|
||||
elif cpp > remaining:
|
||||
result = -remaining
|
||||
else:
|
||||
result = -cpp
|
||||
cpp_rate = payslip._rule_parameter('ca_cpp_rate')
|
||||
cpp_exemption = payslip._rule_parameter('ca_cpp_exemption')
|
||||
cpp_max = payslip._rule_parameter('ca_cpp_max')
|
||||
ympe = payslip._rule_parameter('ca_ympe')
|
||||
period_exemption = cpp_exemption / 26
|
||||
period_max = cpp_max / 26
|
||||
period_ympe = ympe / 26
|
||||
pensionable = min(gross_amount, period_ympe)
|
||||
pensionable = max(pensionable - period_exemption, 0)
|
||||
result = -min(pensionable * cpp_rate, period_max)
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CPP EMPLOYER - 1:1 Match -->
|
||||
<!-- CPP EMPLOYER (seq 21) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_cpp_employer" model="hr.salary.rule">
|
||||
<field name="name">CPP Employer</field>
|
||||
<field name="code">CPP_ER</field>
|
||||
<field name="sequence">151</field>
|
||||
<field name="sequence">21</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.COMP"/>
|
||||
<field name="category_id" ref="hr_salary_rule_category_ca_employer"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
# CPP Employer - 1:1 match with employee
|
||||
result = abs(CPP_EE) if CPP_EE else 0
|
||||
result = -result_rules.get('CPP_EE', {}).get('total', 0)
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CPP2 EMPLOYEE - Second Canada Pension Plan -->
|
||||
<!-- Uses rule parameters for rates and limits -->
|
||||
<!-- CPP2 EMPLOYEE (seq 22) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_cpp2_employee" model="hr.salary.rule">
|
||||
<field name="name">CPP2 Employee</field>
|
||||
<field name="code">CPP2_EE</field>
|
||||
<field name="sequence">152</field>
|
||||
<field name="sequence">22</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.DED"/>
|
||||
<field name="category_id" ref="hr_salary_rule_category_ca_cpp"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
# CPP2 (Second CPP) - Using Rule Parameters
|
||||
CPP2_RATE = payslip._rule_parameter('ca_cpp2_rate')
|
||||
YMPE = payslip._rule_parameter('ca_ympe')
|
||||
YAMPE = payslip._rule_parameter('ca_yampe')
|
||||
CPP2_MAX = payslip._rule_parameter('ca_cpp2_max')
|
||||
PAY_PERIODS = 24
|
||||
|
||||
gross = categories['GROSS']
|
||||
annual_equiv = gross * PAY_PERIODS
|
||||
|
||||
result = 0
|
||||
# CPP2 only on earnings between YMPE and YAMPE
|
||||
if annual_equiv > YMPE:
|
||||
cpp2_base = min(annual_equiv, YAMPE) - YMPE
|
||||
cpp2_per_period = (cpp2_base * CPP2_RATE) / PAY_PERIODS
|
||||
|
||||
# YTD check
|
||||
from datetime import date
|
||||
year_start = date(payslip.date_from.year, 1, 1)
|
||||
ytd = abs(payslip._sum('CPP2_EE', year_start, payslip.date_from) or 0)
|
||||
remaining = CPP2_MAX - ytd
|
||||
|
||||
if remaining > 0:
|
||||
result = -min(cpp2_per_period, remaining)
|
||||
gross_amount = result_rules.get('GROSS', {}).get('total', 0)
|
||||
if employee.exempt_cpp:
|
||||
result = 0
|
||||
else:
|
||||
ympe = payslip._rule_parameter('ca_ympe')
|
||||
cpp2_rate = payslip._rule_parameter('ca_cpp2_rate')
|
||||
yampe = payslip._rule_parameter('ca_yampe')
|
||||
cpp2_max = payslip._rule_parameter('ca_cpp2_max')
|
||||
period_ympe = ympe / 26
|
||||
period_ceiling = yampe / 26
|
||||
period_max = cpp2_max / 26
|
||||
if gross_amount > period_ympe:
|
||||
cpp2_pensionable = min(gross_amount, period_ceiling) - period_ympe
|
||||
result = -min(cpp2_pensionable * cpp2_rate, period_max)
|
||||
else:
|
||||
result = 0
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CPP2 EMPLOYER - 1:1 Match -->
|
||||
<!-- CPP2 EMPLOYER (seq 23) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_cpp2_employer" model="hr.salary.rule">
|
||||
<field name="name">CPP2 Employer</field>
|
||||
<field name="code">CPP2_ER</field>
|
||||
<field name="sequence">153</field>
|
||||
<field name="sequence">23</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.COMP"/>
|
||||
<field name="category_id" ref="hr_salary_rule_category_ca_employer"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
# CPP2 Employer - 1:1 match
|
||||
result = abs(CPP2_EE) if CPP2_EE else 0
|
||||
result = -result_rules.get('CPP2_EE', {}).get('total', 0)
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- EI EMPLOYEE - Employment Insurance -->
|
||||
<!-- Uses rule parameters for rates and limits -->
|
||||
<!-- EI EMPLOYEE (seq 25) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_ei_employee" model="hr.salary.rule">
|
||||
<field name="name">EI Employee</field>
|
||||
<field name="code">EI_EE</field>
|
||||
<field name="sequence">154</field>
|
||||
<field name="sequence">25</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.DED"/>
|
||||
<field name="category_id" ref="hr_salary_rule_category_ca_ei"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
# EI Employee - Using Rule Parameters
|
||||
EI_RATE = payslip._rule_parameter('ca_ei_rate')
|
||||
EI_MAX = payslip._rule_parameter('ca_ei_max')
|
||||
|
||||
gross = categories['GROSS']
|
||||
ei = gross * EI_RATE
|
||||
|
||||
# YTD check
|
||||
from datetime import date
|
||||
year_start = date(payslip.date_from.year, 1, 1)
|
||||
ytd = abs(payslip._sum('EI_EE', year_start, payslip.date_from) or 0)
|
||||
remaining = EI_MAX - ytd
|
||||
|
||||
if remaining <= 0:
|
||||
gross_amount = result_rules.get('GROSS', {}).get('total', 0)
|
||||
if employee.exempt_ei:
|
||||
result = 0
|
||||
elif ei > remaining:
|
||||
result = -remaining
|
||||
else:
|
||||
result = -ei
|
||||
ei_rate = payslip._rule_parameter('ca_ei_rate')
|
||||
ei_max_insurable = payslip._rule_parameter('ca_ei_max_insurable')
|
||||
ei_max = payslip._rule_parameter('ca_ei_max')
|
||||
period_max_insurable = ei_max_insurable / 26
|
||||
period_max_premium = ei_max / 26
|
||||
insurable = min(gross_amount, period_max_insurable)
|
||||
result = -min(insurable * ei_rate, period_max_premium)
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- EI EMPLOYER - 1.4x Employee Premium -->
|
||||
<!-- EI EMPLOYER (seq 26) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_ei_employer" model="hr.salary.rule">
|
||||
<field name="name">EI Employer</field>
|
||||
<field name="code">EI_ER</field>
|
||||
<field name="sequence">155</field>
|
||||
<field name="sequence">26</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.COMP"/>
|
||||
<field name="category_id" ref="hr_salary_rule_category_ca_employer"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
# EI Employer - Using Rule Parameter for multiplier
|
||||
EI_ER_MULT = payslip._rule_parameter('ca_ei_employer_mult')
|
||||
result = abs(EI_EE) * EI_ER_MULT if EI_EE else 0
|
||||
ei_employer_mult = payslip._rule_parameter('ca_ei_employer_mult')
|
||||
result = -result_rules.get('EI_EE', {}).get('total', 0) * ei_employer_mult
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- FEDERAL INCOME TAX -->
|
||||
<!-- Uses rule parameters for brackets and credits -->
|
||||
<!-- FEDERAL INCOME TAX (seq 30) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_fed_tax" model="hr.salary.rule">
|
||||
<field name="name">Federal Income Tax</field>
|
||||
<field name="code">FED_TAX</field>
|
||||
<field name="sequence">160</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.DED"/>
|
||||
<field name="category_id" ref="hr_salary_rule_category_ca_fed_tax"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
# Federal Income Tax - Using Rule Parameters
|
||||
PAY_PERIODS = 24
|
||||
brackets = payslip._rule_parameter('ca_fed_brackets')
|
||||
BPA = payslip._rule_parameter('ca_fed_bpa')
|
||||
CEA = payslip._rule_parameter('ca_fed_cea')
|
||||
CPP_MAX = payslip._rule_parameter('ca_cpp_max')
|
||||
EI_MAX = payslip._rule_parameter('ca_ei_max')
|
||||
if hasattr(employee, 'exempt_federal_tax') and employee.exempt_federal_tax:
|
||||
result = 0
|
||||
else:
|
||||
gross_amount = result_rules.get('GROSS', {}).get('total', 0)
|
||||
rrsp = abs(result_rules.get('RRSP', {}).get('total', 0))
|
||||
union = abs(result_rules.get('UNION_DUES', {}).get('total', 0))
|
||||
taxable_per_period = gross_amount - rrsp - union
|
||||
annual_income = taxable_per_period * 26
|
||||
|
||||
gross = categories['GROSS']
|
||||
annual = gross * PAY_PERIODS
|
||||
fed_brackets = [
|
||||
(payslip._rule_parameter('ca_fed_bracket_1'), payslip._rule_parameter('ca_fed_rate_1')),
|
||||
(payslip._rule_parameter('ca_fed_bracket_2'), payslip._rule_parameter('ca_fed_rate_2')),
|
||||
(payslip._rule_parameter('ca_fed_bracket_3'), payslip._rule_parameter('ca_fed_rate_3')),
|
||||
(payslip._rule_parameter('ca_fed_bracket_4'), payslip._rule_parameter('ca_fed_rate_4')),
|
||||
(float('inf'), payslip._rule_parameter('ca_fed_rate_5')),
|
||||
]
|
||||
|
||||
# Calculate tax using brackets
|
||||
tax = 0
|
||||
prev_threshold = 0
|
||||
for threshold, rate in brackets:
|
||||
if annual <= threshold:
|
||||
tax += (annual - prev_threshold) * rate
|
||||
break
|
||||
bpa_max = payslip._rule_parameter('ca_fed_bpa')
|
||||
bpa_min = payslip._rule_parameter('ca_fed_bpa_min')
|
||||
cea = payslip._rule_parameter('ca_fed_cea')
|
||||
phase_out_start = payslip._rule_parameter('ca_fed_bracket_3')
|
||||
phase_out_end = payslip._rule_parameter('ca_fed_bracket_4')
|
||||
|
||||
td1_override = employee.federal_td1_amount if hasattr(employee, 'federal_td1_amount') and employee.federal_td1_amount > 0 else 0
|
||||
if td1_override:
|
||||
fed_bpa = td1_override
|
||||
elif annual_income <= phase_out_start:
|
||||
fed_bpa = bpa_max
|
||||
elif annual_income >= phase_out_end:
|
||||
fed_bpa = bpa_min
|
||||
else:
|
||||
tax += (threshold - prev_threshold) * rate
|
||||
prev_threshold = threshold
|
||||
fed_bpa = bpa_max - (bpa_max - bpa_min) * (annual_income - phase_out_start) / (phase_out_end - phase_out_start)
|
||||
|
||||
# Basic personal amount credit
|
||||
tax_credit = BPA * brackets[0][1] # Lowest rate
|
||||
tax = 0
|
||||
prev_bracket = 0
|
||||
for bracket, rate in fed_brackets:
|
||||
taxable_in_bracket = min(annual_income, bracket) - prev_bracket
|
||||
if taxable_in_bracket > 0:
|
||||
tax += taxable_in_bracket * rate
|
||||
prev_bracket = bracket
|
||||
if annual_income <= bracket:
|
||||
break
|
||||
|
||||
# CPP/EI credits
|
||||
cpp_credit = min(abs(CPP_EE) * PAY_PERIODS if CPP_EE else 0, CPP_MAX) * brackets[0][1]
|
||||
ei_credit = min(abs(EI_EE) * PAY_PERIODS if EI_EE else 0, EI_MAX) * brackets[0][1]
|
||||
|
||||
# Canada Employment Amount credit
|
||||
cea_credit = CEA * brackets[0][1]
|
||||
|
||||
annual_tax = max(0, tax - tax_credit - cpp_credit - ei_credit - cea_credit)
|
||||
result = -(annual_tax / PAY_PERIODS)
|
||||
credit = fed_bpa * fed_brackets[0][1]
|
||||
cea_credit = cea * fed_brackets[0][1]
|
||||
annual_tax = max(tax - credit - cea_credit, 0)
|
||||
additional = employee.federal_additional_tax or 0
|
||||
result = -(annual_tax / 26 + additional)
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- PROVINCIAL INCOME TAX (ONTARIO) -->
|
||||
<!-- Uses rule parameters for brackets and credits -->
|
||||
<!-- PROVINCIAL INCOME TAX (seq 35) -->
|
||||
<!-- All 12 provinces/territories with surtax support -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_prov_tax" model="hr.salary.rule">
|
||||
<field name="name">Provincial Income Tax</field>
|
||||
<field name="code">PROV_TAX</field>
|
||||
<field name="sequence">161</field>
|
||||
<field name="sequence">35</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.DED"/>
|
||||
<field name="category_id" ref="hr_salary_rule_category_ca_prov_tax"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
# Ontario Provincial Tax - Using Rule Parameters
|
||||
PAY_PERIODS = 24
|
||||
brackets = payslip._rule_parameter('ca_on_brackets')
|
||||
BPA_ON = payslip._rule_parameter('ca_on_bpa')
|
||||
CPP_MAX = payslip._rule_parameter('ca_cpp_max')
|
||||
EI_MAX = payslip._rule_parameter('ca_ei_max')
|
||||
gross_amount = result_rules.get('GROSS', {}).get('total', 0)
|
||||
rrsp = abs(result_rules.get('RRSP', {}).get('total', 0))
|
||||
union = abs(result_rules.get('UNION_DUES', {}).get('total', 0))
|
||||
taxable_per_period = gross_amount - rrsp - union
|
||||
annual_income = taxable_per_period * 26
|
||||
|
||||
gross = categories['GROSS']
|
||||
annual = gross * PAY_PERIODS
|
||||
province = employee.home_province or 'ON'
|
||||
|
||||
# Calculate tax using brackets
|
||||
tax = 0
|
||||
prev_threshold = 0
|
||||
for threshold, rate in brackets:
|
||||
if annual <= threshold:
|
||||
tax += (annual - prev_threshold) * rate
|
||||
break
|
||||
else:
|
||||
tax += (threshold - prev_threshold) * rate
|
||||
prev_threshold = threshold
|
||||
if province == 'QC':
|
||||
result = 0
|
||||
else:
|
||||
PROV = {
|
||||
'ON': {'b': [[53891, 0.0505], [107785, 0.0915], [150000, 0.1116], [220000, 0.1216], [0, 0.1316]], 'bpa': 12989, 'st': [[5818, 0.20], [7446, 0.36]]},
|
||||
'AB': {'b': [[61200, 0.08], [154259, 0.10], [185111, 0.12], [246813, 0.13], [370220, 0.14], [0, 0.15]], 'bpa': 21885, 'st': []},
|
||||
'BC': {'b': [[50363, 0.0506], [100728, 0.077], [115648, 0.105], [140430, 0.1229], [190405, 0.147], [265545, 0.168], [0, 0.205]], 'bpa': 12273, 'st': []},
|
||||
'SK': {'b': [[54532, 0.105], [155805, 0.125], [0, 0.145]], 'bpa': 18635, 'st': []},
|
||||
'MB': {'b': [[47000, 0.108], [100000, 0.1275], [0, 0.174]], 'bpa': 15780, 'st': []},
|
||||
'NB': {'b': [[52333, 0.094], [104666, 0.14], [193861, 0.16], [0, 0.195]], 'bpa': 12458, 'st': []},
|
||||
'NS': {'b': [[30995, 0.0879], [61991, 0.1495], [97417, 0.1667], [157124, 0.175], [0, 0.21]], 'bpa': 11481, 'st': []},
|
||||
'PE': {'b': [[33928, 0.095], [65820, 0.1347], [106890, 0.166], [142250, 0.1762], [0, 0.19]], 'bpa': 12750, 'st': []},
|
||||
'NL': {'b': [[44678, 0.087], [89354, 0.145], [159528, 0.158], [223340, 0.178], [285319, 0.198], [570638, 0.208], [1141275, 0.213], [0, 0.218]], 'bpa': 10382, 'st': []},
|
||||
'NT': {'b': [[53003, 0.059], [106009, 0.086], [172346, 0.122], [0, 0.1405]], 'bpa': 16442, 'st': []},
|
||||
'YT': {'b': [[58523, 0.064], [117045, 0.09], [181440, 0.109], [500000, 0.128], [0, 0.15]], 'bpa': 16729, 'st': []},
|
||||
'NU': {'b': [[55801, 0.04], [111602, 0.07], [181439, 0.09], [0, 0.115]], 'bpa': 17091, 'st': []},
|
||||
}
|
||||
|
||||
# Ontario Basic Personal Amount credit
|
||||
tax_credit = BPA_ON * brackets[0][1] # Lowest rate
|
||||
cfg = PROV.get(province, PROV['ON'])
|
||||
prov_brackets = []
|
||||
for br in cfg['b']:
|
||||
t = br[0] if br[0] != 0 else float('inf')
|
||||
prov_brackets.append((t, br[1]))
|
||||
|
||||
# CPP/EI credits at lowest rate
|
||||
cpp_credit = min(abs(CPP_EE) * PAY_PERIODS if CPP_EE else 0, CPP_MAX) * brackets[0][1]
|
||||
ei_credit = min(abs(EI_EE) * PAY_PERIODS if EI_EE else 0, EI_MAX) * brackets[0][1]
|
||||
tax = 0
|
||||
prev_bracket = 0
|
||||
for bracket, rate in prov_brackets:
|
||||
taxable_in_bracket = min(annual_income, bracket) - prev_bracket
|
||||
if taxable_in_bracket > 0:
|
||||
tax += taxable_in_bracket * rate
|
||||
prev_bracket = bracket
|
||||
if annual_income <= bracket:
|
||||
break
|
||||
|
||||
annual_tax = max(0, tax - tax_credit - cpp_credit - ei_credit)
|
||||
result = -(annual_tax / PAY_PERIODS)
|
||||
prov_bpa = cfg['bpa']
|
||||
if employee.provincial_claim_amount and employee.provincial_claim_amount > 0:
|
||||
prov_bpa = employee.provincial_claim_amount
|
||||
prov_credit = prov_bpa * prov_brackets[0][1]
|
||||
basic_provincial_tax = max(tax - prov_credit, 0)
|
||||
|
||||
surtax = 0
|
||||
for s in cfg['st']:
|
||||
if basic_provincial_tax > s[0]:
|
||||
surtax += (basic_provincial_tax - s[0]) * s[1]
|
||||
|
||||
total_provincial_tax = basic_provincial_tax + surtax
|
||||
result = -(total_provincial_tax / 26)
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- VACATION PAY - 4% of Earnings -->
|
||||
<!-- ONTARIO HEALTH PREMIUM (seq 36) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_vacation_pay" model="hr.salary.rule">
|
||||
<field name="name">Vacation Pay</field>
|
||||
<field name="code">VAC_PAY</field>
|
||||
<field name="sequence">170</field>
|
||||
<record id="hr_ohp" model="hr.salary.rule">
|
||||
<field name="name">Ontario Health Premium</field>
|
||||
<field name="code">OHP</field>
|
||||
<field name="sequence">36</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.ALW"/>
|
||||
<field name="category_id" ref="hr_salary_rule_category_ca_ohp"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">
|
||||
province = employee.home_province or 'ON'
|
||||
result = (province == 'ON')
|
||||
</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
gross_amount = result_rules.get('GROSS', {}).get('total', 0)
|
||||
rrsp = abs(result_rules.get('RRSP', {}).get('total', 0))
|
||||
union = abs(result_rules.get('UNION_DUES', {}).get('total', 0))
|
||||
taxable_per_period = gross_amount - rrsp - union
|
||||
annual_income = taxable_per_period * 26
|
||||
|
||||
ohp = 0
|
||||
if annual_income <= 20000:
|
||||
ohp = 0
|
||||
elif annual_income <= 36000:
|
||||
ohp = min((annual_income - 20000) * 0.06, 300)
|
||||
elif annual_income <= 48000:
|
||||
ohp = 300 + min((annual_income - 36000) * 0.06, 150)
|
||||
elif annual_income <= 72000:
|
||||
ohp = 450 + min((annual_income - 48000) * 0.0025, 150)
|
||||
elif annual_income <= 200000:
|
||||
ohp = 600 + min((annual_income - 72000) * 0.0025, 300)
|
||||
else:
|
||||
ohp = 900
|
||||
|
||||
result = -(ohp / 26)
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- NET PAY (seq 100) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="hr_rule_net" model="hr.salary.rule">
|
||||
<field name="name">Net Pay</field>
|
||||
<field name="code">NET</field>
|
||||
<field name="sequence">100</field>
|
||||
<field name="struct_id" ref="hr_payroll_structure_canada"/>
|
||||
<field name="category_id" ref="hr_payroll.NET"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="appears_on_payslip">True</field>
|
||||
<field name="amount_python_compute">
|
||||
# Vacation Pay - Using Rule Parameter
|
||||
VAC_RATE = payslip._rule_parameter('ca_vacation_rate')
|
||||
result = categories['BASIC'] * VAC_RATE
|
||||
result = (
|
||||
result_rules.get('GROSS', {}).get('total', 0)
|
||||
+ result_rules.get('RRSP', {}).get('total', 0)
|
||||
+ result_rules.get('UNION_DUES', {}).get('total', 0)
|
||||
+ result_rules.get('CPP_EE', {}).get('total', 0)
|
||||
+ result_rules.get('CPP2_EE', {}).get('total', 0)
|
||||
+ result_rules.get('EI_EE', {}).get('total', 0)
|
||||
+ result_rules.get('FED_TAX', {}).get('total', 0)
|
||||
+ result_rules.get('PROV_TAX', {}).get('total', 0)
|
||||
+ result_rules.get('OHP', {}).get('total', 0)
|
||||
)
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user