Compare commits
149 Commits
4cd7357aa0
...
fusion_acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7cc334c98 | ||
|
|
92f93de47b | ||
|
|
f0577c1788 | ||
|
|
51b26838b9 | ||
|
|
6731260cde | ||
|
|
de71a61a8b | ||
|
|
db90b1ad5b | ||
|
|
512467788b | ||
|
|
7ac01991e5 | ||
|
|
10140a6968 | ||
|
|
e79f11f5f0 | ||
|
|
b637723c6a | ||
|
|
182978606d | ||
|
|
f18afe7380 | ||
|
|
484314625e | ||
|
|
e983a370aa | ||
|
|
2ead351c30 | ||
|
|
6791246def | ||
|
|
2a41f48123 | ||
|
|
f8b97211ab | ||
|
|
086b24ab36 | ||
|
|
d331dc5fa6 | ||
|
|
6d02389b80 | ||
|
|
a2efc9f2d4 | ||
|
|
7025f62107 | ||
|
|
6a775db444 | ||
|
|
f8dfff5ce6 | ||
|
|
8f1cb3abd2 | ||
|
|
1c44f458ad | ||
|
|
6c72f2ab49 | ||
|
|
b7483d5177 | ||
|
|
c6d1008810 | ||
|
|
75eb084687 | ||
|
|
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 | ||
|
|
3cc93b8783 | ||
|
|
c66bdf5089 |
79
.gitea/workflows/fusion_accounting_ci.yml
Normal file
79
.gitea/workflows/fusion_accounting_ci.yml
Normal file
@@ -0,0 +1,79 @@
|
||||
name: fusion_accounting CI
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'fusion_accounting/**'
|
||||
- 'fusion_accounting_core/**'
|
||||
- 'fusion_accounting_ai/**'
|
||||
- 'fusion_accounting_migration/**'
|
||||
- '.gitea/workflows/fusion_accounting_ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'fusion_accounting/**'
|
||||
- 'fusion_accounting_core/**'
|
||||
- 'fusion_accounting_ai/**'
|
||||
- 'fusion_accounting_migration/**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
# NOTE: This workflow assumes a self-hosted runner (or Docker-in-Docker)
|
||||
# that provides an Odoo 19 install. Adjust the `runs-on` and
|
||||
# `Install Odoo 19` step to match Nexa's environment.
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: odoo
|
||||
POSTGRES_PASSWORD: odoo
|
||||
POSTGRES_DB: postgres
|
||||
ports: ['5432:5432']
|
||||
options: --health-cmd pg_isready --health-interval 10s
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
sub_module:
|
||||
- fusion_accounting_core
|
||||
- fusion_accounting_ai
|
||||
- fusion_accounting_migration
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install AI client deps
|
||||
run: |
|
||||
pip install --break-system-packages anthropic openai
|
||||
|
||||
- name: Install Odoo 19
|
||||
run: |
|
||||
# TODO(Phase 1 CI hardening): align with Nexa's Odoo 19 source-of-truth.
|
||||
# Option A: pull the same image used at odoo-westin (docker pull <registry>/odoo:19)
|
||||
# Option B: odoo-bin pip install from the pinned Odoo 19 tag
|
||||
# Option C: host a self-hosted runner on odoo-westin with Odoo pre-installed
|
||||
echo "TODO: install Odoo 19 here"
|
||||
exit 1 # fail loudly until this step is implemented
|
||||
|
||||
- name: Stage fusion sub-modules in addons-path
|
||||
run: |
|
||||
mkdir -p /tmp/addons
|
||||
cp -r fusion_accounting fusion_accounting_core fusion_accounting_ai fusion_accounting_migration /tmp/addons/
|
||||
|
||||
- name: Install + Test ${{ matrix.sub_module }}
|
||||
run: |
|
||||
createdb -h localhost -U odoo fusion_test_${{ matrix.sub_module }}
|
||||
odoo --addons-path=/tmp/addons \
|
||||
-d fusion_test_${{ matrix.sub_module }} \
|
||||
-i ${{ matrix.sub_module }} \
|
||||
--test-tags post_install \
|
||||
--stop-after-init \
|
||||
--without-demo=all \
|
||||
--log-handler=odoo.tests:INFO
|
||||
env:
|
||||
PGPASSWORD: odoo
|
||||
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.
129
batch3_models.sql
Normal file
129
batch3_models.sql
Normal file
@@ -0,0 +1,129 @@
|
||||
-- ============================================================
|
||||
-- BATCH 3: Reconciliation Models for Westin Healthcare
|
||||
-- Database: westin-v19 | Date: 2026-04-03
|
||||
-- ============================================================
|
||||
-- Tax IDs: 20 = HST PURCHASE (13%), 32 = NO TAX PURCHASE (0%)
|
||||
-- ============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Helper function to create writeoff models in one shot
|
||||
CREATE OR REPLACE FUNCTION _tmp_create_writeoff(
|
||||
p_name text, p_seq int, p_match text,
|
||||
p_account_id int, p_tax_id int, p_label text
|
||||
) RETURNS void AS $$
|
||||
DECLARE
|
||||
v_model_id int;
|
||||
v_line_id int;
|
||||
BEGIN
|
||||
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, false, 2, 2, NOW(), NOW())
|
||||
RETURNING id INTO v_model_id;
|
||||
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (v_model_id, 1, 10, p_account_id, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW())
|
||||
RETURNING id INTO v_line_id;
|
||||
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
|
||||
VALUES (v_line_id, p_tax_id);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Helper function for partner-mapping models
|
||||
CREATE OR REPLACE FUNCTION _tmp_create_partner_map(
|
||||
p_name text, p_seq int, p_match text, p_partner_id int
|
||||
) RETURNS void AS $$
|
||||
BEGIN
|
||||
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, mapped_partner_id, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, p_partner_id, true, false, 2, 2, NOW(), NOW());
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ============================================================
|
||||
-- PART 1: WRITEOFF MODELS (36 models)
|
||||
-- ============================================================
|
||||
-- Acct 495=Computer/IT, 496=Advertising, 497=Car/Van, 499=Bank Charges
|
||||
-- Acct 501=Dues/Subs, 506=Meals, 507=Office, 518=Shipping
|
||||
-- Acct 523=Telephone, 526=Utilities, 552=Gas, 557=Security
|
||||
|
||||
-- Rideshare / Transportation
|
||||
SELECT _tmp_create_writeoff('Uber Rides', 200, 'uber', 497, 20, 'Uber Rideshare');
|
||||
SELECT _tmp_create_writeoff('Lyft Rides', 201, 'Lyft', 497, 20, 'Lyft Rideshare');
|
||||
SELECT _tmp_create_writeoff('407 ETR Highway Tolls', 202, '407 ETR', 497, 20, '407 ETR Highway Tolls');
|
||||
SELECT _tmp_create_writeoff('Klassic Car Wash', 203, 'KLASSIC CAR WASH', 497, 20, 'Klassic Car Wash');
|
||||
|
||||
-- Web Hosting / IT (NO TAX - foreign companies)
|
||||
SELECT _tmp_create_writeoff('Cloud Clusters Hosting', 210, 'CLOUD CLUSTERS', 495, 32, 'Cloud Clusters Web Hosting');
|
||||
SELECT _tmp_create_writeoff('Siteground Web Hosting', 211, 'SITEGROUND', 495, 32, 'Siteground Web Hosting');
|
||||
SELECT _tmp_create_writeoff('WP Media / Imagify Plugin',212, 'WP MEDIA', 495, 32, 'WP Media Imagify Image Optimization');
|
||||
SELECT _tmp_create_writeoff('Railway.app Cloud Hosting',213, 'RAILWAY', 495, 32, 'Railway.app Cloud Hosting');
|
||||
SELECT _tmp_create_writeoff('Fiverr Freelance Services',214, 'FIVERR', 495, 32, 'Fiverr Freelance Services');
|
||||
|
||||
-- IT Services (HST - Canadian)
|
||||
SELECT _tmp_create_writeoff('Microsoft 365 Subscription',215, 'Microsoft', 495, 20, 'Microsoft 365 Subscription');
|
||||
SELECT _tmp_create_writeoff('Webware Website Platform', 216, 'Webware', 495, 20, 'Webware Website Platform');
|
||||
SELECT _tmp_create_writeoff('Google Workspace', 217, 'WORKSPACE', 495, 20, 'Google Workspace Subscription');
|
||||
|
||||
-- Advertising (NO TAX - foreign companies)
|
||||
SELECT _tmp_create_writeoff('Yelp Advertising', 220, 'YELP', 496, 32, 'Yelp Online Advertising');
|
||||
SELECT _tmp_create_writeoff('ClickCease Ad Protection', 221, 'CLICKCEASE', 496, 32, 'ClickCease Ad Fraud Protection');
|
||||
SELECT _tmp_create_writeoff('Kliken / SiteWit Ads', 222, 'KLIKEN', 496, 32, 'Kliken SiteWit Online Advertising');
|
||||
SELECT _tmp_create_writeoff('Constant Contact Email', 223, 'CONSTANT CONTACT', 496, 32, 'Constant Contact Email Marketing');
|
||||
|
||||
-- Advertising (HST - Canadian)
|
||||
SELECT _tmp_create_writeoff('Yellow Pages Advertising', 224, 'YELLOW PAGES', 496, 20, 'Yellow Pages Directory Advertising');
|
||||
SELECT _tmp_create_writeoff('Microsoft Advertising', 225, 'MICROSOFT*ADVERTISING', 496, 20, 'Microsoft Bing Advertising');
|
||||
|
||||
-- Telephone / Communications
|
||||
SELECT _tmp_create_writeoff('Bell Canada Telecom', 230, 'BELL CANADA', 523, 20, 'Bell Canada Telephone & Internet');
|
||||
SELECT _tmp_create_writeoff('eFax Online Fax Service', 231, 'EFAX', 523, 32, 'eFax Online Fax Service');
|
||||
SELECT _tmp_create_writeoff('Faxdeck Online Fax', 232, 'FAXDECK', 523, 32, 'Faxdeck Online Fax Service');
|
||||
SELECT _tmp_create_writeoff('RingCentral Phone', 233, 'RINGCENTRAL', 523, 32, 'RingCentral Cloud Phone Service');
|
||||
|
||||
-- Subscriptions / Dues
|
||||
SELECT _tmp_create_writeoff('Scribd Medical Reference', 240, 'SCRIBD', 501, 32, 'Scribd Medical Reference Subscription');
|
||||
SELECT _tmp_create_writeoff('Amazon Channels', 241, 'Amazon Channel', 501, 20, 'Amazon Channels Subscription');
|
||||
SELECT _tmp_create_writeoff('Dominion Insurance', 242, 'DOMINION PREM', 501, 32, 'Dominion Insurance Premium');
|
||||
|
||||
-- Meals & Entertainment
|
||||
SELECT _tmp_create_writeoff('Tim Hortons - Meals', 250, 'Tim Horton', 506, 20, 'Tim Hortons Meals');
|
||||
SELECT _tmp_create_writeoff('Malton Best Restaurant', 251, 'malton best', 506, 20, 'Malton Best Restaurant Meals');
|
||||
|
||||
-- Office / Supplies
|
||||
SELECT _tmp_create_writeoff('Princess Auto - Supplies', 260, 'Princess Auto', 507, 20, 'Princess Auto Supplies');
|
||||
SELECT _tmp_create_writeoff('Canadian Tire - Supplies', 261, 'CANADIAN TIRE', 507, 20, 'Canadian Tire Office/Shop Supplies');
|
||||
SELECT _tmp_create_writeoff('Staples Office Supplies', 262, 'STAPLES', 507, 20, 'Staples Office Supplies');
|
||||
SELECT _tmp_create_writeoff('MGS Business Registration',263, 'MGS-BUSINESS', 507, 20, 'MGS Ontario Business Registration');
|
||||
|
||||
-- Shipping
|
||||
SELECT _tmp_create_writeoff('DHL Express Shipping', 270, 'DHL', 518, 20, 'DHL Express Shipping');
|
||||
SELECT _tmp_create_writeoff('FedEx Shipping', 271, 'Fedex', 518, 20, 'FedEx Shipping & Delivery');
|
||||
|
||||
-- Bank Fees
|
||||
SELECT _tmp_create_writeoff('Scotia Service Charge', 280, 'Service Charge', 499, 32, 'Scotia Bank Service Charge');
|
||||
|
||||
-- Security / Building
|
||||
SELECT _tmp_create_writeoff('ADT Canada Security', 290, 'ADT CANADA', 557, 20, 'ADT Canada Security Monitoring');
|
||||
SELECT _tmp_create_writeoff('Seccan Security', 291, 'seccan', 557, 20, 'Seccan Security Services');
|
||||
|
||||
-- Utilities
|
||||
SELECT _tmp_create_writeoff('Alectra Utilities - Hydro',292, 'ALECTRA', 526, 20, 'Alectra Utilities Hydro Payment');
|
||||
|
||||
-- ============================================================
|
||||
-- PART 2: PARTNER-MAPPING MODELS (9 models)
|
||||
-- ============================================================
|
||||
SELECT _tmp_create_partner_map('VGM Canada', 300, 'VGM Canada', 5024);
|
||||
SELECT _tmp_create_partner_map('Medical Mart', 301, 'Medical Mart', 4991);
|
||||
SELECT _tmp_create_partner_map('AMG Medical', 302, 'AMG medical', 4934);
|
||||
SELECT _tmp_create_partner_map('HoMedics Group Canada', 303, 'HOMEDICS', 4975);
|
||||
SELECT _tmp_create_partner_map('Stevens Company Limited', 304, 'Stevens Company', 5017);
|
||||
SELECT _tmp_create_partner_map('Ki Mobility Canada', 305, 'Ki Mobility', 4981);
|
||||
SELECT _tmp_create_partner_map('R82 Inc', 306, 'R82', 5009);
|
||||
SELECT _tmp_create_partner_map('Harmony Group / Products',307, 'HARMONY', 6216);
|
||||
SELECT _tmp_create_partner_map('Continent Globe Freight', 308, 'CONTINENT GLOBE', NULL);
|
||||
|
||||
-- Cleanup temp functions
|
||||
DROP FUNCTION _tmp_create_writeoff(text, int, text, int, int, text);
|
||||
DROP FUNCTION _tmp_create_partner_map(text, int, text, int);
|
||||
|
||||
COMMIT;
|
||||
53
batch4_models.sql
Normal file
53
batch4_models.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
|
||||
DECLARE v_mid int; v_lid int;
|
||||
BEGIN
|
||||
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION _tmp_pm(p_name text, p_seq int, p_match text, p_pid int) RETURNS void AS $$
|
||||
BEGIN
|
||||
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, mapped_partner_id, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, p_pid, true, true, 2, 2, NOW(), NOW());
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
|
||||
SELECT _tmp_wo('UPS Shipping', 400, 'UPS', 518, 20, 'UPS Shipping & Delivery');
|
||||
SELECT _tmp_wo('Shopify Subscription', 401, 'SHOPIFY', 495, 20, 'Shopify E-Commerce Platform');
|
||||
SELECT _tmp_wo('Canva Design', 402, 'CANVA', 495, 32, 'Canva Design Subscription');
|
||||
SELECT _tmp_wo('Massive.com / Polygon.io', 403, 'MASSIVE.COM', 495, 32, 'Polygon.io Stock Data API');
|
||||
SELECT _tmp_wo('Air Canada Travel', 404, 'AIR CAN', 525, 20, 'Air Canada Travel');
|
||||
SELECT _tmp_wo('Enterprise Rent-A-Car', 405, 'ENTERPRISE RENT', 497, 20, 'Enterprise Car Rental');
|
||||
SELECT _tmp_wo('Walmart Purchases', 406, 'WALMART', 507, 20, 'Walmart Office/Shop Supplies');
|
||||
SELECT _tmp_wo('FlightHub Travel', 407, 'FLIGHTHUB', 525, 20, 'FlightHub Travel Booking');
|
||||
SELECT _tmp_wo('G2A Software', 408, 'G2A.COM', 495, 32, 'G2A Software Licenses');
|
||||
SELECT _tmp_wo('Ubiquiti Network Equipment', 409, 'UBIQUITI', 495, 20, 'Ubiquiti Network Hardware');
|
||||
SELECT _tmp_wo('Facebook Ads (FACEBK)', 410, 'FACEBK', 496, 20, 'Facebook/Meta Advertising');
|
||||
SELECT _tmp_wo('Eventbrite Events', 411, 'eventbrite', 496, 20, 'Eventbrite Event Registration');
|
||||
SELECT _tmp_wo('WP Mail SMTP Plugin', 412, 'WPMAILSMTP', 495, 32, 'WP Mail SMTP Plugin');
|
||||
SELECT _tmp_wo('Synthesia AI Video', 413, 'SYNTHESIA', 495, 32, 'Synthesia AI Video Platform');
|
||||
SELECT _tmp_wo('E2PDF WordPress Plugin', 414, 'E2PDF', 495, 32, 'E2PDF WordPress Plugin');
|
||||
SELECT _tmp_wo('Plugins For WP', 415, 'PLUGINSFORWP', 495, 32, 'WordPress Plugins');
|
||||
SELECT _tmp_wo('Google Cloud Platform', 416, 'GOOGLE*CLOUD', 495, 20, 'Google Cloud Platform');
|
||||
SELECT _tmp_wo('Best Buy Retail', 417, 'BEST BUY', 507, 20, 'Best Buy Electronics/Supplies');
|
||||
SELECT _tmp_wo('Scotia Visa Annual Fee', 418, 'annual fee', 499, 32, 'Scotia Visa Annual Fee');
|
||||
SELECT _tmp_wo('Corp Canada Registration', 419, 'CORP CANADA', 507, 20, 'Corporation Canada Registration');
|
||||
SELECT _tmp_wo('NUANS Name Search', 420, 'NUANS', 507, 20, 'NUANS Business Name Search');
|
||||
SELECT _tmp_wo('Wisprflow AI', 421, 'WISPRFLOW', 495, 32, 'Wisprflow AI Platform');
|
||||
SELECT _tmp_wo('LawDepot Legal Docs', 422, 'lawdepot', 507, 20, 'LawDepot Legal Documents');
|
||||
SELECT _tmp_wo('eBay Purchases', 423, 'eBay', 507, 20, 'eBay Online Purchases');
|
||||
SELECT _tmp_wo('Ooma VoIP Phone', 424, 'OOMA', 523, 20, 'Ooma VoIP Phone Service');
|
||||
SELECT _tmp_wo('Paddle / Synergy App', 425, 'PADDLE.NET', 495, 32, 'Paddle Software Subscription');
|
||||
|
||||
SELECT _tmp_pm('Power Plus Mobility', 500, 'POWER PLUS', 35);
|
||||
SELECT _tmp_pm('Best Buy Medical Supplies', 501, 'Best Buy Medical', 4939);
|
||||
SELECT _tmp_pm('Cheelcare Canada', 502, 'cheelcare', 11955);
|
||||
|
||||
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
|
||||
DROP FUNCTION _tmp_pm(text, int, text, int);
|
||||
|
||||
COMMIT;
|
||||
188
batch5_models.sql
Normal file
188
batch5_models.sql
Normal file
@@ -0,0 +1,188 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
|
||||
DECLARE v_mid int; v_lid int;
|
||||
BEGIN
|
||||
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION _tmp_pm(p_name text, p_seq int, p_match text, p_pid int) RETURNS void AS $$
|
||||
BEGIN
|
||||
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, mapped_partner_id, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, p_pid, true, true, 2, 2, NOW(), NOW());
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
|
||||
-- MJR Capital = collections payments (Office Expense, HST)
|
||||
SELECT _tmp_wo('MJR Capital Services - Collections', 600, 'mjr capital', 507, 20, 'MJR Capital Collections Payment');
|
||||
|
||||
-- Landry & Jacobs = legal/collections (Office Expense, NO TAX - US company in AZ)
|
||||
SELECT _tmp_wo('Landry & Jacobs - Collections', 601, 'landry', 507, 32, 'Landry & Jacobs Collections');
|
||||
|
||||
-- Micro Center = US electronics retailer (Computer/IT, NO TAX - US)
|
||||
SELECT _tmp_wo('Micro Center Electronics', 602, 'MICRO CENTER', 495, 32, 'Micro Center Electronics Purchase');
|
||||
|
||||
-- Maravi Canada = medical supplies vendor (partner mapping)
|
||||
-- Need partner ID first - create as writeoff to Office for now
|
||||
SELECT _tmp_wo('Maravi Canada Medical', 603, 'MARAVI', 507, 20, 'Maravi Canada Medical Supplies');
|
||||
|
||||
-- Google Turbo AI Note (SaaS, HST Canadian)
|
||||
SELECT _tmp_wo('Google Turbo AI Note', 604, 'TURBO AI NOTE', 495, 20, 'Google Turbo AI Note');
|
||||
|
||||
-- FUSION NEXASYSTEMS = own company test charges (Office Expense, HST)
|
||||
SELECT _tmp_wo('Fusion NexaSystems Test', 605, 'NEXASYSTEMS', 507, 20, 'NexaSystems Test Charge');
|
||||
|
||||
-- VPS IT NEXASYSTEMS = own company VPS hosting (Computer/IT, HST)
|
||||
-- already covered by NEXASYSTEMS match above
|
||||
|
||||
-- Sunnybrook / St Josephs = hospital parking (Meals & Ent or Office, HST)
|
||||
SELECT _tmp_wo('Hospital Parking - Sunnybrook', 606, 'sunnybrook', 506, 20, 'Sunnybrook Hospital Parking/Meals');
|
||||
SELECT _tmp_wo('Hospital Parking - St Josephs', 607, 'st josephs', 506, 20, 'St Josephs Hospital Parking');
|
||||
|
||||
-- Canada Post (CPC SCP) - already exists but let's check
|
||||
-- Model 49 matches "CPC SCP" - should work
|
||||
|
||||
-- Bolts Plus Inc = hardware supplies (Office Expense, HST)
|
||||
SELECT _tmp_wo('Bolts Plus Hardware', 608, 'BOLTS PLUS', 507, 20, 'Bolts Plus Hardware Supplies');
|
||||
|
||||
-- Durafast Label Company = labels/printing (Office Expense, HST)
|
||||
SELECT _tmp_wo('Durafast Label Company', 609, 'durafast', 507, 20, 'Durafast Label Printing');
|
||||
|
||||
-- Better Business Bureau = membership (Dues & Subs, HST)
|
||||
SELECT _tmp_wo('Better Business Bureau', 610, 'better business bureau', 501, 20, 'Better Business Bureau Membership');
|
||||
|
||||
-- AmySystems = software (Computer/IT, HST Canadian - QC)
|
||||
SELECT _tmp_wo('AmySystems Software', 611, 'AMYSYSTEMS', 495, 20, 'AmySystems Software');
|
||||
|
||||
-- Thermor Limited = medical equipment vendor
|
||||
SELECT _tmp_pm('Thermor Limited', 612, 'thermor', NULL);
|
||||
|
||||
-- Aqua Creek Products = pool/medical equipment (US vendor)
|
||||
-- Large amounts ($21K) - this is a PO vendor
|
||||
SELECT _tmp_pm('Aqua Creek Products', 613, 'aqua creek', NULL);
|
||||
|
||||
-- Rogers (line 20184 with ******4596) - existing model should match
|
||||
-- 407 ETR (line 20131) - existing model matches "407 ETR" but this says "407ETR (WEB)"
|
||||
SELECT _tmp_wo('407 ETR Web Payment', 614, '407ETR', 497, 20, '407 ETR Web Highway Tolls');
|
||||
|
||||
-- 7 Spice Bistro / The Kebob / Momo2Go = restaurants (Meals, HST)
|
||||
SELECT _tmp_wo('7 Spice Bistro', 615, '7 SPICE', 506, 20, '7 Spice Bistro Meals');
|
||||
SELECT _tmp_wo('The Kebob Restaurant', 616, 'KEBOB', 506, 20, 'The Kebob Restaurant Meals');
|
||||
SELECT _tmp_wo('Momo2Go Restaurant', 617, 'MOMO2GO', 506, 20, 'Momo2Go Restaurant Meals');
|
||||
|
||||
-- Jay Cee Sales & Rivet = hardware/industrial (Office, NO TAX - US in MI)
|
||||
SELECT _tmp_wo('Jay Cee Sales & Rivet', 618, 'jay cee sales', 507, 32, 'Jay Cee Sales Industrial Supplies');
|
||||
|
||||
-- Kickstarter / Eufymake = crowdfunding purchase (Computer/IT, NO TAX - US)
|
||||
SELECT _tmp_wo('Kickstarter Purchase', 619, 'kickstarter', 495, 32, 'Kickstarter Crowdfunding Purchase');
|
||||
|
||||
-- Bambu Lab = 3D printer (Computer/IT, NO TAX - Hong Kong)
|
||||
SELECT _tmp_wo('Bambu Lab 3D Printer', 620, 'bambulab', 495, 32, 'Bambu Lab 3D Printer');
|
||||
|
||||
-- Dhillon Video Karo = video production (Advertising, HST)
|
||||
SELECT _tmp_wo('Dhillon Video Karo', 621, 'dhillon video', 496, 20, 'Dhillon Video Production');
|
||||
|
||||
-- Cansew = sewing/upholstery supplies (Office Expense, HST)
|
||||
SELECT _tmp_wo('Cansew Supplies', 622, 'cansew', 507, 20, 'Cansew Sewing/Upholstery Supplies');
|
||||
|
||||
-- NuthutVancouver = food/snacks (Meals, HST)
|
||||
SELECT _tmp_wo('SP Nuthut', 623, 'NUTHUT', 506, 20, 'Nuthut Food/Snacks');
|
||||
|
||||
-- Flywire = payment processing for education (Office, HST)
|
||||
SELECT _tmp_wo('Flywire Payment', 624, 'flywire', 507, 20, 'Flywire Education Payment');
|
||||
|
||||
-- IELTS Humber = education/testing (Office, HST)
|
||||
SELECT _tmp_wo('IELTS Humber College', 625, 'IELTS', 507, 20, 'IELTS Testing Fee');
|
||||
|
||||
-- York University = education (Office, HST)
|
||||
SELECT _tmp_wo('York University', 626, 'york u', 507, 20, 'York University Application Fee');
|
||||
|
||||
-- ESW US Direct = e-commerce (Office, NO TAX - US)
|
||||
SELECT _tmp_wo('ESW US Direct E-Commerce', 627, 'ESW U.S.', 507, 32, 'ESW US Direct E-Commerce');
|
||||
|
||||
-- Corp Canada = already created (419), skip
|
||||
|
||||
-- NextDigitalKeys = software keys (Computer/IT, NO TAX - UK)
|
||||
SELECT _tmp_wo('NextDigitalKeys Software', 628, 'nextdigitalkeys', 495, 32, 'NextDigitalKeys Software License');
|
||||
|
||||
-- StenoKeyboards = keyboard hardware (Computer/IT, NO TAX - foreign)
|
||||
SELECT _tmp_wo('StenoKeyboards', 629, 'stenokeyboards', 495, 32, 'StenoKeyboards Hardware');
|
||||
|
||||
-- Global Technologies of Barrie = IT services vendor
|
||||
SELECT _tmp_wo('Global Technologies Barrie', 630, 'global technologies', 495, 20, 'Global Technologies IT Services');
|
||||
|
||||
-- Milutin Vuicin = contractor/consultant (Computer/IT, NO TAX - US TX)
|
||||
SELECT _tmp_wo('Milutin Vuicin Consulting', 631, 'milutin vuicin', 495, 32, 'Milutin Vuicin Consulting');
|
||||
|
||||
-- Maple Leaf Wheelchair = PO vendor
|
||||
SELECT _tmp_pm('Maple Leaf Wheelchair', 632, 'maple leaf wheelchair', NULL);
|
||||
|
||||
-- Distributions GNX = distribution vendor (QC)
|
||||
SELECT _tmp_wo('Distributions GNX', 633, 'distributions gnx', 507, 20, 'Distributions GNX');
|
||||
|
||||
-- ParkWhiz / ParkLink = parking (Car/Van, HST)
|
||||
SELECT _tmp_wo('ParkWhiz / ParkLink Parking', 634, 'park', 497, 20, 'Parking Fee');
|
||||
-- Actually 'park' is too broad, skip that. Use specific ones:
|
||||
-- delete that last one, too generic
|
||||
DELETE FROM account_reconcile_model WHERE name::text LIKE '%ParkWhiz%';
|
||||
-- Re-do with specific matches
|
||||
SELECT _tmp_wo('ParkWhiz Parking', 635, 'ParkWhiz', 497, 20, 'ParkWhiz Parking Fee');
|
||||
SELECT _tmp_wo('Precise ParkLink', 636, 'parklink', 497, 20, 'Precise ParkLink Parking');
|
||||
|
||||
-- Span Medical Products = PO vendor
|
||||
SELECT _tmp_pm('Span Medical Products', 637, 'SPAN MEDICAL', NULL);
|
||||
|
||||
-- NSC Medical = PO vendor
|
||||
SELECT _tmp_pm('NSC Medical', 638, 'nsc medical', NULL);
|
||||
|
||||
-- WOW Mobile Boutique = phone accessories (Office, HST)
|
||||
SELECT _tmp_wo('WOW Mobile Boutique', 639, 'MOBILE BOUTIQ', 507, 20, 'WOW Mobile Boutique');
|
||||
|
||||
-- Triumph Mobility = PO vendor
|
||||
SELECT _tmp_pm('Triumph Mobility', 640, 'triumph mobility', NULL);
|
||||
|
||||
-- Home Healthcare Store = PO vendor
|
||||
SELECT _tmp_pm('Home Healthcare Store', 641, 'home healthcare store', NULL);
|
||||
|
||||
-- Ubiquiti already created (409)
|
||||
-- Anthropic already matched by model 138 (ANTHROPIC)
|
||||
|
||||
-- Royalmount Town = travel/accommodation (Travel, HST QC)
|
||||
SELECT _tmp_wo('Royalmount Town Hotel', 642, 'royalmount', 525, 20, 'Royalmount Town Accommodation');
|
||||
|
||||
-- Westin Healthcare own charges = test transactions
|
||||
SELECT _tmp_wo('Westin Healthcare Test', 643, 'WESTIN HEALTHCARE', 507, 20, 'Westin Healthcare Test Charge');
|
||||
|
||||
-- XTool Canada = laser cutter/tools (Computer/IT, HST - Canadian store)
|
||||
SELECT _tmp_wo('XTool Canada', 644, 'xtool', 495, 20, 'XTool Canada Equipment');
|
||||
|
||||
-- Providence Healthcare = hospital parking (Meals, HST)
|
||||
SELECT _tmp_wo('Providence Healthcare', 645, 'providence healthcare', 506, 20, 'Providence Healthcare Parking');
|
||||
|
||||
-- Glentel Wirelesswave = phone accessory (Office, HST)
|
||||
SELECT _tmp_wo('Glentel Wirelesswave', 646, 'wirelesswave', 507, 20, 'Glentel Wirelesswave Phone');
|
||||
|
||||
-- 3DMouse = computer peripheral (Computer/IT, HST)
|
||||
SELECT _tmp_wo('3DMouse Input Device', 647, '3dmouse', 495, 20, '3DMouse Input Device');
|
||||
|
||||
-- LawDepot already created (422)
|
||||
|
||||
-- Best Buy Medical already created as partner_map (501)
|
||||
|
||||
-- Catherwood & Vittoria = restaurant (Meals, HST)
|
||||
SELECT _tmp_wo('Catherwood & Vittoria', 648, 'catherwood', 506, 20, 'Catherwood & Vittoria Restaurant');
|
||||
|
||||
-- SB M Wing = hospital cafeteria (Meals, HST)
|
||||
SELECT _tmp_wo('Sunnybrook M Wing Cafe', 649, 'sb m wing', 506, 20, 'Sunnybrook M Wing Cafeteria');
|
||||
|
||||
-- Canada/Ottawa lines = government fees/parking
|
||||
SELECT _tmp_wo('Canada Ottawa Govt Fee', 650, 'canada-Ottawa', 507, 20, 'Ottawa Government Fee');
|
||||
SELECT _tmp_wo('Canada Ottawa Fee 2', 651, 'canada ottawa on', 507, 20, 'Ottawa Government Fee');
|
||||
|
||||
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
|
||||
DROP FUNCTION _tmp_pm(text, int, text, int);
|
||||
|
||||
COMMIT;
|
||||
22
batch6_transfers.sql
Normal file
22
batch6_transfers.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION _tmp_wo_transfer(p_name text, p_seq int, p_match text, p_acct int, p_label text) RETURNS void AS $$
|
||||
DECLARE v_mid int; v_lid int;
|
||||
BEGIN
|
||||
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, partner_id, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 1, 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
|
||||
-- No tax on internal transfers
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, 32);
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
|
||||
-- These models post PAYMENT FROM lines directly to Outstanding Receipts (493)
|
||||
-- This handles cases where the source side was already reconciled
|
||||
SELECT _tmp_wo_transfer('Scotia Visa - Payment From Current (7814)', 50, 'PAYMENT FROM', 493, 'CC Payment from Scotia Current');
|
||||
SELECT _tmp_wo_transfer('Scotia Visa - Transfer From Current', 51, 'from - *****', 493, 'CC Payment from Scotia Current');
|
||||
SELECT _tmp_wo_transfer('Scotia Visa - Payment From (X0)', 52, 'payment from -', 493, 'CC Payment from Scotia Current');
|
||||
|
||||
DROP FUNCTION _tmp_wo_transfer(text, int, text, int, text);
|
||||
|
||||
COMMIT;
|
||||
124
batch7_rbc.sql
Normal file
124
batch7_rbc.sql
Normal file
@@ -0,0 +1,124 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
|
||||
DECLARE v_mid int; v_lid int;
|
||||
BEGIN
|
||||
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
|
||||
-- ============================================================
|
||||
-- GOVERNMENT CUSTOMER PAYMENTS → Outstanding Receipts (493)
|
||||
-- These are payments FROM government agencies TO Westin for equipment/services
|
||||
-- No tax on government transfer payments
|
||||
-- ============================================================
|
||||
|
||||
-- ODSP = Ontario Disability Support Program (already partially matched by other models)
|
||||
-- Check: model already exists? No - "Misc Payment ODSP" has no model
|
||||
SELECT _tmp_wo('ODSP Government Payment', 700, 'ODSP', 493, 32, 'ODSP Customer Payment');
|
||||
|
||||
-- MODC = March of Dimes Canada (Expense Payment MODC = incoming govt payment)
|
||||
SELECT _tmp_wo('MODC - March of Dimes Payment', 701, 'MODC', 493, 32, 'March of Dimes Customer Payment');
|
||||
|
||||
-- Revera Long Term Care payments
|
||||
SELECT _tmp_wo('Revera LTC Payment', 702, 'Revera', 493, 32, 'Revera Long-Term Care Payment');
|
||||
|
||||
-- Medavie Blue Cross insurance payments
|
||||
SELECT _tmp_wo('Medavie Blue Cross Payment', 703, 'MEDAVIE', 493, 32, 'Medavie Blue Cross Insurance Payment');
|
||||
|
||||
-- OMOD (Ontario March of Dimes variant)
|
||||
SELECT _tmp_wo('OMOD Payment', 704, 'OMOD', 493, 32, 'Ontario March of Dimes Payment');
|
||||
|
||||
-- Peel Region payroll deposits (home care worker funding)
|
||||
SELECT _tmp_wo('Peel Region North Deposit', 705, 'PEEL NORTH', 493, 32, 'Region of Peel North Payment');
|
||||
SELECT _tmp_wo('Peel Region South Deposit', 706, 'PEEL SOUTH', 493, 32, 'Region of Peel South Payment');
|
||||
SELECT _tmp_wo('Peel Region CMSM Deposit', 707, 'PEEL CMSM', 493, 32, 'Region of Peel CMSM Payment');
|
||||
|
||||
-- WSIB payments
|
||||
SELECT _tmp_wo('WSIB Payment', 708, 'WSIB', 493, 32, 'WSIB Workers Compensation Payment');
|
||||
|
||||
-- GST Refund from CRA
|
||||
SELECT _tmp_wo('CRA GST Refund', 709, 'GSTCANADA', 493, 32, 'CRA GST/HST Refund');
|
||||
|
||||
-- Affinity Health bill payments (incoming)
|
||||
SELECT _tmp_wo('Affinity Health Payment', 710, 'Affinity Health', 493, 32, 'Affinity Health Customer Payment');
|
||||
|
||||
-- Amica Senior Living AP payments
|
||||
SELECT _tmp_wo('Amica Senior Living Payment', 711, 'AMICA', 493, 32, 'Amica Senior Living Payment');
|
||||
|
||||
-- PCHS = Peel Community Health Services
|
||||
SELECT _tmp_wo('PCHS Payment', 712, 'PCHS', 493, 32, 'PCHS Community Health Payment');
|
||||
|
||||
-- Run Care Canada
|
||||
SELECT _tmp_wo('Run Care Canada Payment', 713, 'RUN CARE', 493, 32, 'Run Care Canada Payment');
|
||||
|
||||
-- Teskie International
|
||||
SELECT _tmp_wo('Teskie International Payment', 714, 'TESKIE', 493, 32, 'Teskie International Payment');
|
||||
|
||||
-- ============================================================
|
||||
-- STRIPE DEPOSITS → Outstanding Receipts (493)
|
||||
-- Online payment gateway deposits
|
||||
-- ============================================================
|
||||
SELECT _tmp_wo('Stripe Payment Deposit', 720, 'STRIPE', 493, 32, 'Stripe Online Payment Deposit');
|
||||
|
||||
-- ============================================================
|
||||
-- DEPOSITS / CHEQUE DEPOSITS → Outstanding Receipts (493)
|
||||
-- Customer payments received
|
||||
-- ============================================================
|
||||
SELECT _tmp_wo('Mobile Cheque Deposit', 730, 'Mobile cheque deposit', 493, 32, 'Customer Cheque Deposit');
|
||||
SELECT _tmp_wo('ATM Deposit', 731, 'ATM deposit', 493, 32, 'Customer ATM Cash/Cheque Deposit');
|
||||
|
||||
-- ============================================================
|
||||
-- NSF RETURNS → Outstanding Receipts (493)
|
||||
-- Bounced cheques — need to reverse original payment
|
||||
-- ============================================================
|
||||
SELECT _tmp_wo('Item Returned NSF', 740, 'Item returned NSF', 493, 32, 'NSF Item Return');
|
||||
SELECT _tmp_wo('Cheque Returned NSF', 741, 'Cheque returned NSF', 493, 32, 'NSF Cheque Return');
|
||||
|
||||
-- ============================================================
|
||||
-- OUTGOING PAYMENTS / BILLS
|
||||
-- ============================================================
|
||||
|
||||
-- Personal Loan SPL (already has model 80 but checking)
|
||||
-- Wawanesa Insurance (already model 28 — partner_map, needs bills)
|
||||
-- Bill Payment Telus (already model for Telus)
|
||||
-- Bill Payment BuildingStack
|
||||
SELECT _tmp_wo('BuildingStack Rent Payment', 750, 'BUILDING_STACK', 560, 20, 'BuildingStack Building Rent');
|
||||
|
||||
-- Commercial Taxes
|
||||
SELECT _tmp_wo('Commercial Property Tax', 751, 'COMMERCIAL TAXES', 507, 20, 'Commercial Property Tax Payment');
|
||||
|
||||
-- HMS Auto Service
|
||||
SELECT _tmp_wo('HMS Auto Service', 752, 'HMS AUTO', 497, 20, 'HMS Auto Service Vehicle Repair');
|
||||
|
||||
-- Dixie Tailoring (alterations)
|
||||
SELECT _tmp_wo('Dixie Tailoring', 753, 'DIXIE TAILORIN', 507, 20, 'Dixie Tailoring Services');
|
||||
|
||||
-- Hardware Agency
|
||||
SELECT _tmp_wo('Hardware Agency', 754, 'HARDWARE AGENC', 507, 20, 'Hardware Agency Supplies');
|
||||
|
||||
-- Desi Haveli / Bamiyan Kabob (meals)
|
||||
SELECT _tmp_wo('Desi Haveli Restaurant', 755, 'DESI HAVELI', 506, 20, 'Desi Haveli Restaurant Meals');
|
||||
SELECT _tmp_wo('Bamiyan Kabob Restaurant', 756, 'BAMIYAN KABOB', 506, 20, 'Bamiyan Kabob Restaurant Meals');
|
||||
|
||||
-- Intuit/ADP payroll verification
|
||||
SELECT _tmp_wo('Intuit Payroll Verification', 757, 'INTUITCANADAULC', 507, 32, 'Intuit Canada Payroll Verification');
|
||||
|
||||
-- ============================================================
|
||||
-- BRANCH TRANSFERS → Outstanding Receipts (493)
|
||||
-- Internal RBC account transfers
|
||||
-- ============================================================
|
||||
SELECT _tmp_wo('RBC Branch Transfer 1306', 760, 'BR TO BR - 1306', 493, 32, 'RBC Branch Transfer 1306');
|
||||
SELECT _tmp_wo('RBC Branch Transfer 9970', 761, 'BR TO BR - 9970', 493, 32, 'RBC Branch Transfer 9970');
|
||||
|
||||
-- ============================================================
|
||||
-- GENERIC DEPOSIT → Outstanding Receipts
|
||||
-- ============================================================
|
||||
SELECT _tmp_wo('Generic Bank Deposit', 770, 'Deposit', 493, 32, 'Bank Deposit');
|
||||
|
||||
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
|
||||
|
||||
COMMIT;
|
||||
91
batch8_fixes.sql
Normal file
91
batch8_fixes.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
|
||||
DECLARE v_mid int; v_lid int;
|
||||
BEGIN
|
||||
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
|
||||
-- ============================================================
|
||||
-- FIX 1: Wawanesa (model 28) — convert from partner_map to writeoff
|
||||
-- Insurance → Car Insurance (548), NO TAX (insurance is exempt)
|
||||
-- ============================================================
|
||||
-- Deactivate old partner_map model
|
||||
UPDATE account_reconcile_model SET active = false WHERE id = 28;
|
||||
|
||||
-- Create new writeoff model for Wawanesa
|
||||
SELECT _tmp_wo('Wawanesa Insurance Premium', 800, 'WAWANESA', 548, 32, 'Wawanesa Car Insurance Premium');
|
||||
|
||||
-- ============================================================
|
||||
-- FIX 2: Personal Loan SPL (model 80) — fix match param
|
||||
-- "Personal Loan SPL" doesn't match "Personal Loan : SPL"
|
||||
-- ============================================================
|
||||
UPDATE account_reconcile_model SET match_label_param = 'Personal Loan' WHERE id = 80;
|
||||
|
||||
-- ============================================================
|
||||
-- FIX 3: IFS Insurance (model 23) — same issue, convert from partner_map
|
||||
-- ============================================================
|
||||
-- Check if model 23 has writeoff line
|
||||
-- Model 23: match "IFS PREMIUM", mapped_partner_id=7291, no writeoff line
|
||||
UPDATE account_reconcile_model SET active = false WHERE id = 23;
|
||||
SELECT _tmp_wo('IFS Insurance Premium', 801, 'IFS PREMIUM', 550, 32, 'IFS Commercial Insurance Premium');
|
||||
|
||||
-- ============================================================
|
||||
-- NEW MODELS for remaining repeated patterns
|
||||
-- ============================================================
|
||||
|
||||
-- Telus Bill Payment (523 = Telephone, HST)
|
||||
SELECT _tmp_wo('Telus Bill Payment', 802, 'Telus Comm', 523, 20, 'Telus Communications Bill Payment');
|
||||
|
||||
-- e-Transfer fee (already model 5 but check if matching)
|
||||
-- Model 5 matches "e-Transfer fee" — should work
|
||||
|
||||
-- Account Payable Pmt HOME (LTC home customer payments → Outstanding Receipts)
|
||||
SELECT _tmp_wo('HOME LTC Customer Payment', 803, 'Account Payable PmtHOME', 493, 32, 'HOME Long-Term Care Payment');
|
||||
SELECT _tmp_wo('HOME LTC Customer Payment 2', 804, 'Account Payable Pmt HOME', 493, 32, 'HOME Long-Term Care Payment');
|
||||
SELECT _tmp_wo('HOME LTC Customer Payment 3', 805, 'Account Payable Pmt-HOME', 493, 32, 'HOME Long-Term Care Payment');
|
||||
|
||||
-- R & M Health Supplies payment
|
||||
SELECT _tmp_wo('R&M Health Supplies Payment', 806, 'R & M HEALTH', 493, 32, 'R&M Health Supplies Customer Payment');
|
||||
|
||||
-- BCCL payment
|
||||
SELECT _tmp_wo('BCCL Customer Payment', 807, 'Account Payable Pmt-BCCL', 493, 32, 'BCCL Customer Payment');
|
||||
|
||||
-- CARE payment
|
||||
SELECT _tmp_wo('CARE Customer Payment', 808, 'Account Payable PmtCARE', 493, 32, 'CARE Customer Payment');
|
||||
|
||||
-- Amica Senior Life (different spelling from earlier model)
|
||||
SELECT _tmp_wo('Amica Senior Life Payment', 809, 'AMICASENIORLIFE', 493, 32, 'Amica Senior Life Customer Payment');
|
||||
|
||||
-- Cash withdrawal (no tax, Office Expense)
|
||||
SELECT _tmp_wo('Cash Withdrawal', 810, 'Cash withdrawal', 507, 32, 'Cash Withdrawal');
|
||||
|
||||
-- ATM/Mobile adjustment
|
||||
SELECT _tmp_wo('ATM Mobile Adjustment Credit', 811, 'ATM/Mobile adjustment credit', 499, 32, 'ATM Mobile Adjustment Credit');
|
||||
SELECT _tmp_wo('ATM Mobile Adjustment Debit', 812, 'ATM/Mobile adjustment debit', 499, 32, 'ATM Mobile Adjustment Debit');
|
||||
|
||||
-- e-Transfer cancel (returned funds → Outstanding Receipts)
|
||||
SELECT _tmp_wo('e-Transfer Cancellation', 813, 'e-Transfer cancel', 493, 32, 'e-Transfer Cancelled Return');
|
||||
|
||||
-- OnRoute (highway rest stop meals)
|
||||
SELECT _tmp_wo('OnRoute Highway Meals', 814, 'ONROUTE', 506, 20, 'OnRoute Highway Rest Stop');
|
||||
|
||||
-- Opening Balance
|
||||
SELECT _tmp_wo('Opening Balance', 815, 'Opening Balance', 493, 32, 'Opening Balance Entry');
|
||||
|
||||
-- Foreign Exchange withdrawal
|
||||
SELECT _tmp_wo('Royal Foreign Exchange', 816, 'Royal Foreign Exchange', 525, 32, 'Royal Foreign Exchange Withdrawal');
|
||||
|
||||
-- Online Banking wire payment
|
||||
SELECT _tmp_wo('Online Banking Wire Payment', 817, 'Online Banking wire', 494, 32, 'Online Banking Wire Payment');
|
||||
|
||||
-- Henrys camera store refund
|
||||
SELECT _tmp_wo('Henrys Camera Refund', 818, 'Henry', 507, 20, 'Henrys Camera Store');
|
||||
|
||||
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
|
||||
|
||||
COMMIT;
|
||||
18
batch9_rbc_visa.sql
Normal file
18
batch9_rbc_visa.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
BEGIN;
|
||||
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
|
||||
DECLARE v_mid int; v_lid int;
|
||||
BEGIN
|
||||
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, partner_id, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 1, 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
|
||||
SELECT _tmp_wo('RBC Visa - CC Payment Received', 900, 'PAYMENT - THANK YOU', 77, 32, 'RBC CC Payment from Chequing');
|
||||
SELECT _tmp_wo('RBC Visa - Credit Card Payment', 901, 'credit card payment', 77, 32, 'RBC CC Payment from Chequing');
|
||||
SELECT _tmp_wo('RBC Visa - RBC CC Payment', 902, 'RBC credit card', 77, 32, 'RBC CC Payment from Chequing');
|
||||
SELECT _tmp_wo('RBC Visa - Payment to CC', 903, 'payment to credit card', 77, 32, 'RBC CC Payment from Chequing');
|
||||
|
||||
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
|
||||
COMMIT;
|
||||
30
batch_reconcile.py
Normal file
30
batch_reconcile.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import logging
|
||||
|
||||
RecModel = env['account.reconcile.model']
|
||||
StLine = env['account.bank.statement.line']
|
||||
|
||||
models = RecModel.search([('trigger', '=', 'auto_reconcile'), ('can_be_proposed', '=', True)])
|
||||
print(f'Auto-reconcile models: {len(models)}', flush=True)
|
||||
|
||||
# Run on ALL 4 journals
|
||||
for jid, name in [(53, 'RBC Chequing'), (28, 'RBC Visa'), (50, 'Scotia Current'), (51, 'Scotia Passport Visa')]:
|
||||
lines = StLine.search([('journal_id', '=', jid), ('is_reconciled', '=', False)])
|
||||
count_before = len(lines)
|
||||
if not count_before:
|
||||
continue
|
||||
|
||||
batch_size = 100
|
||||
for i in range(0, count_before, batch_size):
|
||||
batch = lines[i:i+batch_size]
|
||||
try:
|
||||
models._apply_reconcile_models(batch)
|
||||
except Exception as e:
|
||||
print(f' Error: {e}', flush=True)
|
||||
env.cr.commit()
|
||||
|
||||
remaining = StLine.search_count([('journal_id', '=', jid), ('is_reconciled', '=', False)])
|
||||
reconciled = count_before - remaining
|
||||
if reconciled > 0:
|
||||
print(f'{name}: reconciled {reconciled}/{count_before}, remaining {remaining}', flush=True)
|
||||
else:
|
||||
print(f'{name}: no new matches ({count_before} remaining)', flush=True)
|
||||
73
cleanup_duplicates.py
Normal file
73
cleanup_duplicates.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import logging
|
||||
_logger = logging.getLogger('cleanup_duplicates')
|
||||
|
||||
BSL = env['account.bank.statement.line'].sudo()
|
||||
AML = env['account.move.line'].sudo()
|
||||
AM = env['account.move'].sudo()
|
||||
|
||||
# All 64 duplicate statement line IDs (the second import set, 18703-18767)
|
||||
dupe_ids = [
|
||||
18703, 18704, 18705, 18706, 18707, 18708, 18709, 18710, 18711, 18712,
|
||||
18713, 18714, 18715, 18716, 18717, 18718, 18719, 18720, 18721, 18722,
|
||||
18723, 18724, 18725, 18726, 18727, 18728, 18729, 18730, 18731, 18732,
|
||||
18733, 18734, 18735, 18736, 18737, 18738, 18739, 18740, 18741, 18742,
|
||||
18743, 18744, 18745, 18746, 18747, 18748, 18749, 18750, 18751, 18752,
|
||||
18753, 18754, 18755, 18756, 18757, 18758, 18759, 18760, 18761, 18762,
|
||||
18763, 18764, 18766, 18767,
|
||||
]
|
||||
|
||||
dupes = BSL.browse(dupe_ids)
|
||||
print(f'Processing {len(dupes)} duplicate statement lines', flush=True)
|
||||
|
||||
reconciled_count = 0
|
||||
unreconciled_count = 0
|
||||
error_count = 0
|
||||
|
||||
for line in dupes:
|
||||
move = line.move_id
|
||||
|
||||
if line.is_reconciled:
|
||||
# Step 1: Un-reconcile — remove partial reconcile entries
|
||||
# Find the statement line's AML and its partial reconciliations
|
||||
st_aml = move.line_ids.filtered(lambda l: l.statement_line_id == line)
|
||||
if st_aml:
|
||||
# Find and remove partial reconcile entries
|
||||
partials = env['account.partial.reconcile'].sudo().search([
|
||||
'|',
|
||||
('debit_move_id', 'in', st_aml.ids),
|
||||
('credit_move_id', 'in', st_aml.ids),
|
||||
])
|
||||
if partials:
|
||||
partials.unlink()
|
||||
|
||||
# Also check full reconcile
|
||||
full_recs = st_aml.mapped('full_reconcile_id')
|
||||
if full_recs:
|
||||
full_recs.unlink()
|
||||
|
||||
reconciled_count += 1
|
||||
|
||||
# Step 2: Reset move to draft so we can delete it
|
||||
try:
|
||||
if move.state == 'posted':
|
||||
move.button_draft()
|
||||
# Step 3: Cancel and delete the move (which deletes the statement line too)
|
||||
move.button_cancel()
|
||||
move.with_context(force_delete=True).unlink()
|
||||
unreconciled_count += 1
|
||||
except Exception as e:
|
||||
print(f' Error on line {line.id}: {e}', flush=True)
|
||||
error_count += 1
|
||||
env.cr.rollback()
|
||||
continue
|
||||
|
||||
if unreconciled_count % 20 == 0:
|
||||
env.cr.commit()
|
||||
print(f' Progress: {unreconciled_count} deleted...', flush=True)
|
||||
|
||||
env.cr.commit()
|
||||
print(f'DONE: {unreconciled_count} deleted, {reconciled_count} were reconciled, {error_count} errors', flush=True)
|
||||
|
||||
# Verify
|
||||
remaining = BSL.search_count([('id', 'in', dupe_ids)])
|
||||
print(f'Verification: {remaining} duplicate lines still exist (should be 0)', flush=True)
|
||||
63
debug_reconcile.py
Normal file
63
debug_reconcile.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from odoo.tools import SQL
|
||||
|
||||
lines = env['account.bank.statement.line'].browse([20262])
|
||||
models = env['account.reconcile.model'].search([('trigger', '=', 'auto_reconcile'), ('can_be_proposed', '=', True)])
|
||||
|
||||
env['account.reconcile.model'].flush_model()
|
||||
lines.flush_recordset()
|
||||
|
||||
# Run a simplified version of the _apply_reconcile_models SQL
|
||||
env.cr.execute("""
|
||||
WITH matching_journal_ids AS (
|
||||
SELECT account_reconcile_model_id, ARRAY_AGG(account_journal_id) AS ids
|
||||
FROM account_journal_account_reconcile_model_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
matching_partner_ids AS (
|
||||
SELECT account_reconcile_model_id, ARRAY_AGG(res_partner_id) AS ids
|
||||
FROM account_reconcile_model_res_partner_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
)
|
||||
SELECT st_line.id AS st_line_id,
|
||||
reco_model.id AS reco_model_id,
|
||||
reco_model.trigger
|
||||
FROM account_bank_statement_line st_line
|
||||
JOIN account_move move ON st_line.move_id = move.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT reco_model.id, reco_model.trigger
|
||||
FROM account_reconcile_model reco_model
|
||||
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
|
||||
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
|
||||
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
|
||||
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
|
||||
AND (reco_model.match_label IS NULL OR (
|
||||
reco_model.match_label = 'contains'
|
||||
AND (st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%')
|
||||
))
|
||||
AND reco_model.id IN %s
|
||||
AND reco_model.can_be_proposed IS TRUE
|
||||
AND reco_model.company_id = st_line.company_id
|
||||
ORDER BY reco_model.sequence ASC, reco_model.id ASC
|
||||
LIMIT 1
|
||||
) AS reco_model ON TRUE
|
||||
WHERE st_line.id IN %s
|
||||
""", (tuple(models.ids), tuple(lines.ids)))
|
||||
|
||||
results = env.cr.fetchall()
|
||||
print(f'SQL results: {results}', flush=True)
|
||||
|
||||
# Now check what the full _apply_reconcile_models method SQL has that's different
|
||||
# The key is that the method joins with model_fees and account_reconcile_model_line
|
||||
# Let me check if the model 47 has an account_reconcile_model_line with account_id set
|
||||
model47 = env['account.reconcile.model'].browse(47)
|
||||
print(f'Model 47 lines: {[(l.id, l.account_id.id, l.account_id.name) for l in model47.line_ids]}', flush=True)
|
||||
|
||||
# Check the full method result
|
||||
print('Calling _apply_reconcile_models...', flush=True)
|
||||
lines2 = env['account.bank.statement.line'].browse([20266]) # FACEBK line
|
||||
print(f'Line 20266 before: reconciled={lines2.is_reconciled}', flush=True)
|
||||
models._apply_reconcile_models(lines2)
|
||||
env.cr.commit()
|
||||
lines2.invalidate_recordset()
|
||||
print(f'Line 20266 after: reconciled={lines2.is_reconciled}', flush=True)
|
||||
@@ -0,0 +1,96 @@
|
||||
# Interactive Tables for Fusion AI Chat
|
||||
|
||||
**Date:** 2026-04-03
|
||||
**Module:** fusion_accounting
|
||||
**Status:** Approved for implementation
|
||||
|
||||
## Problem
|
||||
|
||||
AI tool results render as plain Markdown tables in the chat. Users cannot annotate, act on, or provide feedback on individual rows. For actionable reports (missing ITCs, duplicate bills, overdue invoices), users need per-row input and bulk actions.
|
||||
|
||||
## Solution
|
||||
|
||||
A `fusion-table` structured data block that the AI returns instead of Markdown tables for actionable results. The frontend parses these blocks and renders an interactive table widget with: AI recommendations per row, user input fields, checkboxes, and a bulk action bar.
|
||||
|
||||
## AI Output Format
|
||||
|
||||
The AI wraps structured data in a fenced code block with language `fusion-table`:
|
||||
|
||||
```fusion-table
|
||||
{
|
||||
"mode": "interactive",
|
||||
"title": "Missing ITC Bills",
|
||||
"columns": ["Date", "Vendor", "Amount", "ITC Risk"],
|
||||
"rows": [
|
||||
{
|
||||
"id": 123,
|
||||
"cells": ["2024-01-10", "Ki Mobility LLC", "-$14,917.95", "HST ITC?"],
|
||||
"recommendation": {"action": "dismiss", "reason": "US vendor, no HST applies"}
|
||||
}
|
||||
],
|
||||
"actions": ["dismiss", "flag", "create_rule"],
|
||||
"source_tool": "find_missing_itc_bills"
|
||||
}
|
||||
```
|
||||
|
||||
- `mode`: `"interactive"` (full widget) or `"readonly"` (styled table, no inputs)
|
||||
- `columns`: header labels for the data columns
|
||||
- `rows[].id`: Odoo record ID (e.g., account.move ID)
|
||||
- `rows[].cells`: display values matching columns
|
||||
- `rows[].recommendation`: AI's suggested action + reasoning (optional)
|
||||
- `actions`: which bulk action buttons to show
|
||||
- `source_tool`: which tool produced this data
|
||||
|
||||
## Frontend Components
|
||||
|
||||
### 1. mdToHtml() Enhancement (chat_panel.js)
|
||||
|
||||
Detect `fusion-table` fenced blocks during Markdown parsing. Extract the JSON payload and render a placeholder `<div class="fusion_interactive_table" data-table-idx="N"/>` that the OWL component will mount into.
|
||||
|
||||
### 2. FusionInteractiveTable (new OWL component)
|
||||
|
||||
Renders inside the chat message area. Structure:
|
||||
|
||||
- **Header row**: Select-all checkbox + data columns + "AI Recommendation" + "Your Input"
|
||||
- **Body rows**: Per-row checkbox + data cells + recommendation badge (colour-coded: green=dismiss, amber=flag, blue=create_rule) + text input
|
||||
- **Action bar** (bottom): "Apply Recommendations", "Flag Selected", "Create Rules", "Dismiss Selected", "Submit All Notes to AI"
|
||||
|
||||
### 3. Action Flow
|
||||
|
||||
Button clicks collect `{rowIds, notes, action}` and call `this.props.onTableAction(payload)`. The chat panel formats this into a structured user message and sends it via the existing `/fusion_accounting/chat` endpoint:
|
||||
|
||||
```
|
||||
[TABLE_ACTION] source=find_missing_itc_bills action=dismiss
|
||||
Rows: #123 (note: "Confirmed, no ITC needed"), #125 (note: "Need to check PO")
|
||||
```
|
||||
|
||||
The AI processes this through its normal tool-calling flow — dismissing, flagging, creating rules, etc.
|
||||
|
||||
## Styling
|
||||
|
||||
All colours via Odoo CSS variables and Bootstrap utilities:
|
||||
- Dismiss badge: `bg-success-subtle` / `text-success`
|
||||
- Flag badge: `bg-warning-subtle` / `text-warning`
|
||||
- Create Rule badge: `bg-info-subtle` / `text-info`
|
||||
- Input fields: Odoo form control classes
|
||||
- Action bar: `bg-view` with `border-top`
|
||||
- No hardcoded colours — dark/light mode handled by Odoo theme
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `static/src/components/chat/chat_panel.js` | Parse fusion-table blocks in mdToHtml(), mount interactive tables, wire action handler |
|
||||
| `static/src/components/chat/chat_panel.xml` | Add template slot for interactive tables |
|
||||
| `static/src/components/chat/interactive_table.js` | New OWL component |
|
||||
| `static/src/components/chat/interactive_table.xml` | New template |
|
||||
| `static/src/scss/chat.scss` | Interactive table styles (CSS variables only) |
|
||||
| `services/prompts/system_prompt.py` | Add fusion-table format instructions to system prompt |
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- Backend tools (same return data)
|
||||
- AI adapters/orchestrator
|
||||
- Tier 3 approval cards (separate flow)
|
||||
- Controller endpoints
|
||||
- Regular Markdown rendering for non-table content
|
||||
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
145
fix_elavon.py
Normal file
145
fix_elavon.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import logging
|
||||
_logger = logging.getLogger('fix_elavon')
|
||||
|
||||
AML = env['account.move.line'].sudo()
|
||||
BSL = env['account.bank.statement.line'].sudo()
|
||||
|
||||
# ============================================================
|
||||
# PART 1: Fix 144 incoming Elavon payments (Bank Charges -> Outstanding Receipts)
|
||||
# ============================================================
|
||||
print('=== PART 1: Fix incoming Elavon payments ===', flush=True)
|
||||
|
||||
incoming_bad_amls = AML.search([
|
||||
('account_id', '=', 499), # Bank Charges
|
||||
('statement_line_id', '!=', False),
|
||||
('statement_line_id.payment_ref', 'ilike', 'elavon'),
|
||||
('credit', '>', 0), # Credit to Bank Charges = incoming payment writeoff
|
||||
])
|
||||
|
||||
# Filter to only those where the statement line amount > 0 (incoming)
|
||||
incoming_ids = []
|
||||
for aml in incoming_bad_amls:
|
||||
if aml.statement_line_id.amount > 0:
|
||||
incoming_ids.append(aml.id)
|
||||
|
||||
print(f'Found {len(incoming_ids)} incoming Elavon writeoff lines to fix', flush=True)
|
||||
|
||||
if incoming_ids:
|
||||
# Direct SQL update - change account from 499 to 493
|
||||
env.cr.execute("""
|
||||
UPDATE account_move_line
|
||||
SET account_id = 493
|
||||
WHERE id IN %s
|
||||
""", (tuple(incoming_ids),))
|
||||
env.cr.commit()
|
||||
print(f'Changed {len(incoming_ids)} lines: Bank Charges (499) -> Outstanding Receipts (493)', flush=True)
|
||||
|
||||
# ============================================================
|
||||
# PART 2: Fix 6 round-number refund Business PADs
|
||||
# ============================================================
|
||||
print('\n=== PART 2: Fix round-number customer refunds ===', flush=True)
|
||||
|
||||
refund_bad_amls = AML.search([
|
||||
('account_id', '=', 499), # Bank Charges
|
||||
('statement_line_id', '!=', False),
|
||||
('statement_line_id.payment_ref', 'ilike', 'elavon'),
|
||||
('debit', '>', 0), # Debit to Bank Charges = outgoing writeoff
|
||||
])
|
||||
|
||||
refund_ids = []
|
||||
for aml in refund_bad_amls:
|
||||
st_line = aml.statement_line_id
|
||||
if st_line.amount < 0 and st_line.amount == round(st_line.amount, 0):
|
||||
refund_ids.append(aml.id)
|
||||
print(f' Refund: line {st_line.id}, ${st_line.amount}, {st_line.move_id.date}', flush=True)
|
||||
|
||||
print(f'Found {len(refund_ids)} round-number refund lines to fix', flush=True)
|
||||
|
||||
if refund_ids:
|
||||
env.cr.execute("""
|
||||
UPDATE account_move_line
|
||||
SET account_id = 493
|
||||
WHERE id IN %s
|
||||
""", (tuple(refund_ids),))
|
||||
env.cr.commit()
|
||||
print(f'Changed {len(refund_ids)} lines: Bank Charges (499) -> Outstanding Receipts (493)', flush=True)
|
||||
|
||||
# ============================================================
|
||||
# PART 3: Fix reconcile model 96 - should ONLY match fees (Business PAD)
|
||||
# and create new model for incoming Elavon payments
|
||||
# ============================================================
|
||||
print('\n=== PART 3: Update reconcile models ===', flush=True)
|
||||
|
||||
# Model 96 currently matches "Elavon Mrch Svc" which catches EVERYTHING
|
||||
# Change it to only match "Business PAD" (the fees)
|
||||
model96 = env['account.reconcile.model'].sudo().browse(96)
|
||||
print(f'Model 96 before: match="{model96.match_label_param}", account={model96.line_ids.account_id.name}', flush=True)
|
||||
|
||||
model96.write({'match_label_param': 'Business PAD'})
|
||||
# Keep account 499 (Bank Charges) for the fees - that's correct
|
||||
print(f'Model 96 after: match="{model96.match_label_param}" (now only matches fees)', flush=True)
|
||||
|
||||
# Model 85 matches "MRCH" which also catches Elavon payments on RBC Chequing
|
||||
# Leave it for now - those are the RBC monthly MRCH fee lines, different pattern
|
||||
|
||||
# Create new model for incoming Elavon payments -> Outstanding Receipts (493)
|
||||
existing = env['account.reconcile.model'].sudo().search([
|
||||
('match_label_param', '=', 'Elavon Mrch Svc : Miscellaneous'),
|
||||
])
|
||||
if not existing:
|
||||
new_model = env['account.reconcile.model'].sudo().create({
|
||||
'name': 'Elavon Customer Payment Deposit',
|
||||
'sequence': 55,
|
||||
'company_id': 1,
|
||||
'trigger': 'auto_reconcile',
|
||||
'match_label': 'contains',
|
||||
'match_label_param': 'Elavon Mrch Svc : Miscellaneous',
|
||||
'can_be_proposed': True,
|
||||
})
|
||||
new_line = env['account.reconcile.model.line'].sudo().create({
|
||||
'model_id': new_model.id,
|
||||
'company_id': 1,
|
||||
'sequence': 10,
|
||||
'account_id': 493, # Outstanding Receipts
|
||||
'amount_type': 'percentage',
|
||||
'amount': 100,
|
||||
'amount_string': '100',
|
||||
'label': 'Elavon Visa Terminal Customer Payment',
|
||||
'partner_id': 1, # Westin Healthcare (company)
|
||||
})
|
||||
# No tax on payment deposits
|
||||
env.cr.execute("""
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel
|
||||
(account_reconcile_model_line_id, account_tax_id) VALUES (%s, 32)
|
||||
""", (new_line.id,))
|
||||
print(f'Created new model: "Elavon Customer Payment Deposit" -> Outstanding Receipts (493)', flush=True)
|
||||
else:
|
||||
print(f'Model for Elavon incoming already exists: {existing.name}', flush=True)
|
||||
|
||||
env.cr.commit()
|
||||
|
||||
# ============================================================
|
||||
# PART 4: Verify
|
||||
# ============================================================
|
||||
print('\n=== VERIFICATION ===', flush=True)
|
||||
|
||||
# Count remaining Elavon lines posted to Bank Charges
|
||||
remaining_499 = env.cr.execute("""
|
||||
SELECT COUNT(*), ROUND(SUM(ABS(aml.balance))::numeric, 2)
|
||||
FROM account_move_line aml
|
||||
JOIN account_bank_statement_line bsl ON bsl.id = aml.statement_line_id
|
||||
WHERE aml.account_id = 499 AND bsl.payment_ref ILIKE '%%elavon%%'
|
||||
""")
|
||||
row = env.cr.fetchone()
|
||||
print(f'Elavon lines still on Bank Charges: {row[0]} lines, ${row[1]}', flush=True)
|
||||
print('(These should be the monthly processing fees only)', flush=True)
|
||||
|
||||
# Count Elavon lines now on Outstanding Receipts
|
||||
env.cr.execute("""
|
||||
SELECT COUNT(*), ROUND(SUM(ABS(aml.balance))::numeric, 2)
|
||||
FROM account_move_line aml
|
||||
JOIN account_bank_statement_line bsl ON bsl.id = aml.statement_line_id
|
||||
WHERE aml.account_id = 493 AND bsl.payment_ref ILIKE '%%elavon%%'
|
||||
""")
|
||||
row = env.cr.fetchone()
|
||||
print(f'Elavon lines now on Outstanding Receipts: {row[0]} lines, ${row[1]}', flush=True)
|
||||
27
fix_from_lines.py
Normal file
27
fix_from_lines.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Manually reconcile the 4 "from" lines — they're Scotia Current transfers
|
||||
# with no account number in the ref
|
||||
AML = env['account.move.line'].sudo()
|
||||
BSL = env['account.bank.statement.line'].sudo()
|
||||
|
||||
line_ids = [16375, 16380, 16383, 16433]
|
||||
for lid in line_ids:
|
||||
line = BSL.browse(lid)
|
||||
if line.is_reconciled:
|
||||
continue
|
||||
print(f'Line {lid}: {line.payment_ref}, ${line.amount}, {line.move_id.date}', flush=True)
|
||||
|
||||
# These are transfers from Scotia Current — post to Outstanding Receipts (493)
|
||||
model = env['account.reconcile.model'].search([
|
||||
('match_label_param', '=', 'PAYMENT FROM'),
|
||||
('trigger', '=', 'auto_reconcile'),
|
||||
], limit=1)
|
||||
|
||||
if model:
|
||||
try:
|
||||
model._trigger_reconciliation_model(line)
|
||||
env.cr.commit()
|
||||
line.invalidate_recordset()
|
||||
print(f' -> Reconciled: {line.is_reconciled}', flush=True)
|
||||
except Exception as e:
|
||||
print(f' -> Error: {e}', flush=True)
|
||||
env.cr.rollback()
|
||||
17
fix_no_tax.sql
Normal file
17
fix_no_tax.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
BEGIN;
|
||||
|
||||
-- Fix ALL model lines that have NO explicit tax set.
|
||||
-- These inherit the account's default tax (HST PURCHASE) which is WRONG
|
||||
-- for bank fees, foreign vendors, insurance, interest, etc.
|
||||
-- Set them all to NO TAX PURCHASE (ID 32) explicitly.
|
||||
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
|
||||
SELECT rml.id, 32
|
||||
FROM account_reconcile_model rm
|
||||
JOIN account_reconcile_model_line rml ON rml.model_id = rm.id
|
||||
LEFT JOIN account_reconcile_model_line_account_tax_rel tr ON tr.account_reconcile_model_line_id = rml.id
|
||||
WHERE rm.active = true AND rm.company_id = 1
|
||||
AND tr.account_tax_id IS NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
105
fix_po_vendor_models.sql
Normal file
105
fix_po_vendor_models.sql
Normal file
@@ -0,0 +1,105 @@
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================
|
||||
-- Partner-mapping reconciliation models for PO vendors
|
||||
-- These auto-assign the vendor to the bank line so the payment
|
||||
-- appears on the vendor's account. When the bill is posted from
|
||||
-- the PO, the payment shows up as "outstanding credit" on the bill.
|
||||
-- ============================================================
|
||||
|
||||
-- Access BDD / TK Access Solutions (partner 6895)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Access BDD / TK Access"}', 1, 'auto_reconcile', 'contains', 'TK ACCESS', 6895, false, 200, true, 2, 2, NOW(), NOW());
|
||||
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Access BDD - Storage"}', 1, 'auto_reconcile', 'contains', 'access storage', 6895, false, 201, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Blake Medical (partner 4944)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Blake Medical"}', 1, 'auto_reconcile', 'contains', 'blake medical', 4944, false, 202, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Drive Medical (partner 15)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Drive Medical"}', 1, 'auto_reconcile', 'contains', 'DRIVE MEDICAL', 15, false, 203, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Evolution Technologies (partner 4962)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Evolution Technologies"}', 1, 'auto_reconcile', 'contains', 'Evolution Tech', 4962, false, 204, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- HumanCare Canada (partner 4976)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "HumanCare Canada"}', 1, 'auto_reconcile', 'contains', 'HumanCare', 4976, false, 205, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Sunrise Medical (partner 42)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Sunrise Medical"}', 1, 'auto_reconcile', 'contains', 'Sunrise Medical', 42, false, 206, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- East Penn Canada (partner 4959)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "East Penn Canada"}', 1, 'auto_reconcile', 'contains', 'EAST PENN', 4959, false, 207, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Invacare Canada (partner 24)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Invacare Canada"}', 1, 'auto_reconcile', 'contains', 'Invacare', 24, false, 208, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Joerns Healthcare (partner 25)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Joerns Healthcare"}', 1, 'auto_reconcile', 'contains', 'joerns', 25, false, 209, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Nighthawk Manufacturing (partner 4998)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Nighthawk Manufacturing"}', 1, 'auto_reconcile', 'contains', 'NIGHTHAWK', 4998, false, 210, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Savaria Concord (partner 6864)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Savaria Concord Lifts"}', 1, 'auto_reconcile', 'contains', 'SAVARIA', 6864, false, 211, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Parsons ADL (partner 5001)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Parsons ADL"}', 1, 'auto_reconcile', 'contains', 'PARSONS', 5001, false, 212, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Cardinal Health (partner 4948)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Cardinal Health"}', 1, 'auto_reconcile', 'contains', 'Cardinal Health', 4948, false, 213, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- HPU Rehab / HPU Medical (partner 5137)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "HPU Rehab"}', 1, 'auto_reconcile', 'contains', 'hpu medical', 5137, false, 214, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Interstate Batteries (partner 6200)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Interstate Batteries"}', 1, 'auto_reconcile', 'contains', 'INTERSTATE', 6200, false, 215, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Standers Inc (partner 5014)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Standers Inc"}', 1, 'auto_reconcile', 'contains', 'STANDERS', 5014, false, 216, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Handicare / Accessibility Canada (partner 5588)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Handicare Canada"}', 1, 'auto_reconcile', 'contains', 'handicare', 5588, false, 217, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Mobb Healthcare (partner 4994)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Mobb Healthcare"}', 1, 'auto_reconcile', 'contains', 'Mobb Healthcare', 4994, false, 218, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Healthcraft Products (partner 4973)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Healthcraft Products"}', 1, 'auto_reconcile', 'contains', 'HEALTHCRAFT', 4973, false, 219, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Medline Canada (partner 28)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Medline Canada"}', 1, 'auto_reconcile', 'contains', 'MEDLINE', 28, false, 220, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Carex Health Brands (partner 6779)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Carex Health"}', 1, 'auto_reconcile', 'contains', 'CAREX HEALTH', 6779, false, 221, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Advanced Mobility Systems (partner 5158)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Advanced Mobility Systems"}', 1, 'auto_reconcile', 'contains', 'advanced mobility', 5158, false, 222, true, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Enhance Mobility (partner 6745)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Enhance Mobility"}', 1, 'auto_reconcile', 'contains', 'ENHANCE MOBILITY', 6745, false, 223, true, 2, 2, NOW(), NOW());
|
||||
|
||||
COMMIT;
|
||||
170
fix_reconcile_models.sql
Normal file
170
fix_reconcile_models.sql
Normal file
@@ -0,0 +1,170 @@
|
||||
BEGIN;
|
||||
|
||||
-- ============================================
|
||||
-- FIX 1: Wawanesa model 28 — add missing match_label_param
|
||||
-- ============================================
|
||||
UPDATE account_reconcile_model
|
||||
SET match_label = 'contains', match_label_param = 'WAWANESA'
|
||||
WHERE id = 28 AND company_id = 1;
|
||||
|
||||
-- ============================================
|
||||
-- FIX 2: IFS Insurance model 23 — remove HST (insurance is exempt)
|
||||
-- ============================================
|
||||
DELETE FROM account_reconcile_model_line_account_tax_rel
|
||||
WHERE account_reconcile_model_line_id IN (
|
||||
SELECT id FROM account_reconcile_model_line WHERE model_id = 23
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- NEW MODELS — each needs a model row + a line row (+ tax rel if HST)
|
||||
-- ============================================
|
||||
|
||||
-- Helper: create models via INSERT
|
||||
-- Personal Loan SPL → 6028 Car/Van Expenses + HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Personal Loan SPL"}', 1, 'auto_reconcile', 'contains', 'Personal Loan SPL', true, 100, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 497, 'percentage', 100, '100', '{"en_US": "Vehicle Finance Payment"}', 10, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
|
||||
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
|
||||
|
||||
-- Overdraft Fee → 6560 Bank Overdraft Charges, no HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Overdraft Fee"}', 1, 'auto_reconcile', 'contains', 'Overdraft', true, 101, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 558, 'percentage', 100, '100', '{"en_US": "Bank Overdraft Fee/Interest"}', 10, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Overlimit Fee → 6560 Bank Overdraft Charges, no HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Overlimit Fee"}', 1, 'auto_reconcile', 'contains', 'OVERLIMIT', true, 102, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 558, 'percentage', 100, '100', '{"en_US": "Credit Card Overlimit Fee"}', 10, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Transaction Fee → 6030 Bank Charges, no HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Bank Transaction Fee"}', 1, 'auto_reconcile', 'contains', 'transaction fee', true, 103, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 499, 'percentage', 100, '100', '{"en_US": "Bank Transaction Fee"}', 10, 2, 2, NOW(), NOW());
|
||||
|
||||
-- PAY-FILE FEES → 6030 Bank Charges, no HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "PAY-FILE Fee"}', 1, 'auto_reconcile', 'contains', 'PAY-FILE', true, 104, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 499, 'percentage', 100, '100', '{"en_US": "Payroll File Processing Fee"}', 10, 2, 2, NOW(), NOW());
|
||||
|
||||
-- MRCH Merchant Fees → 6030 Bank Charges, no HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Merchant MRCH Fee"}', 1, 'auto_reconcile', 'contains', 'MRCH', true, 105, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 499, 'percentage', 100, '100', '{"en_US": "Merchant Processing Fee"}', 10, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Reliance Esso → 6026 Car Gas + HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Reliance Esso"}', 1, 'auto_reconcile', 'contains', 'RELIANCE ESSO', true, 106, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 552, 'percentage', 100, '100', '{"en_US": "Vehicle Fuel"}', 10, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
|
||||
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
|
||||
|
||||
-- Facebook Ads → 6025 Advertising, no HST (US company)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Facebook Ads"}', 1, 'auto_reconcile', 'contains', 'facebook', true, 107, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 496, 'percentage', 100, '100', '{"en_US": "Facebook/Meta Advertising"}', 10, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Cloudflare → 6050 IT Expenses, no HST (US company)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Cloudflare"}', 1, 'auto_reconcile', 'contains', 'cloudflare', true, 108, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Cloudflare Web Services"}', 10, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Equifax → 6050 IT/Credit Check Expenses + HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Equifax"}', 1, 'auto_reconcile', 'contains', 'equifax', true, 109, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Equifax Credit Check Service"}', 10, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
|
||||
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
|
||||
|
||||
-- GoDaddy → 6050 IT Expenses, no HST (US/QC, typically no ON HST)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "GoDaddy"}', 1, 'auto_reconcile', 'contains', 'godaddy', true, 110, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "GoDaddy Domain/Hosting"}', 10, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Clover App → 6563 Clover Fee + HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Clover POS"}', 1, 'auto_reconcile', 'contains', 'CLOVER', true, 111, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 570, 'percentage', 100, '100', '{"en_US": "Clover POS Monthly Fee"}', 10, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
|
||||
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
|
||||
|
||||
-- Google Workspace → 6050 IT Expenses + HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Google Workspace"}', 1, 'auto_reconcile', 'contains', 'GSUITE', true, 112, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Google Workspace Subscription"}', 10, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
|
||||
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
|
||||
|
||||
-- Bell Maison Intelligente → 6050 IT/Smart Home + HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Bell Smart Home"}', 1, 'auto_reconcile', 'contains', 'bell maison', true, 113, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Bell Smart Home/Security"}', 10, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
|
||||
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
|
||||
|
||||
-- CRA PAD → 2200 CRA Payroll Tax Liabilities, no HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "CRA PAD Payment"}', 1, 'auto_reconcile', 'contains', 'ccra canada', true, 114, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 17, 'percentage', 100, '100', '{"en_US": "CRA Payroll Remittance"}', 10, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Device Protection → 6558 Commercial Insurance, no HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Device Protection"}', 1, 'auto_reconcile', 'contains', 'device protection', true, 115, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 550, 'percentage', 100, '100', '{"en_US": "Device Protection Insurance"}', 10, 2, 2, NOW(), NOW());
|
||||
|
||||
-- Elavon PAD Fee (Scotia) → 6030 Bank Charges, no HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Elavon Merchant Fee"}', 1, 'auto_reconcile', 'contains', 'Elavon Mrch Svc', true, 116, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 499, 'percentage', 100, '100', '{"en_US": "Elavon Merchant Service Fee"}', 10, 2, 2, NOW(), NOW());
|
||||
|
||||
-- WSIB → need to check what account WSIB uses
|
||||
-- Investment MERCH PAD → 6050 IT Expenses + HST (based on historical coding)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Investment Merchant PAD"}', 1, 'auto_reconcile', 'contains', 'Investment MERCH', true, 117, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Merchant Investment PAD"}', 10, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
|
||||
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
|
||||
|
||||
-- Debit Memo Loan Payment → 6028 Car/Van Expenses + HST (same as Personal Loan SPL)
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Debit Memo Loan"}', 1, 'auto_reconcile', 'contains', 'debit memo loan', true, 118, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 497, 'percentage', 100, '100', '{"en_US": "Vehicle Loan Payment"}', 10, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
|
||||
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
|
||||
|
||||
-- Prime Video → 6070 Dues and Subscriptions + HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Prime Video"}', 1, 'auto_reconcile', 'contains', 'prime video', true, 119, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 501, 'percentage', 100, '100', '{"en_US": "Amazon Prime Video Subscription"}', 10, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
|
||||
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
|
||||
|
||||
-- Canada Post (via Visa) → 8010 Shipping + HST
|
||||
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ('{"en_US": "Canada Post Visa"}', 1, 'auto_reconcile', 'contains', 'canada post', true, 120, true, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
|
||||
VALUES (currval('account_reconcile_model_id_seq'), 1, 518, 'percentage', 100, '100', '{"en_US": "Canada Post Shipping"}', 10, 2, 2, NOW(), NOW());
|
||||
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
|
||||
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
|
||||
|
||||
COMMIT;
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1775192388322}
|
||||
@@ -0,0 +1,92 @@
|
||||
<h2>Hybrid: AI Recommendation + Your Input + Bulk Actions</h2>
|
||||
<p class="subtitle">The AI pre-fills its recommendation. You get an editable input per row to override or add notes. Checkboxes for bulk actions.</p>
|
||||
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Chat Panel — find_missing_itc_bills result</div>
|
||||
<div class="mockup-body" style="padding: 0; overflow-x: auto;">
|
||||
<table style="width:100%; border-collapse: collapse; font-size: 13px;">
|
||||
<thead>
|
||||
<tr style="background: rgba(255,255,255,0.05); border-bottom: 2px solid rgba(255,255,255,0.15);">
|
||||
<th style="padding: 8px 6px; width:30px; text-align:center;"><input type="checkbox" title="Select all"></th>
|
||||
<th style="padding: 8px 6px; text-align:left; font-weight:600;">Date</th>
|
||||
<th style="padding: 8px 6px; text-align:left; font-weight:600;">Vendor</th>
|
||||
<th style="padding: 8px 6px; text-align:right; font-weight:600;">Amount</th>
|
||||
<th style="padding: 8px 6px; text-align:left; font-weight:600; color:#60a5fa;">AI Recommendation</th>
|
||||
<th style="padding: 8px 6px; text-align:left; font-weight:600; color:#fbbf24;">Your Input</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
|
||||
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
|
||||
<td style="padding: 6px;">2024-01-10</td>
|
||||
<td style="padding: 6px;">Ki Mobility LLC</td>
|
||||
<td style="padding: 6px; text-align:right; font-weight:600;">-$14,917.95</td>
|
||||
<td style="padding: 6px;"><span style="background:rgba(34,197,94,0.15); color:#4ade80; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Dismiss</span> <span style="opacity:0.7; font-size:12px;">US vendor, no HST applies</span></td>
|
||||
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..." value="Confirmed, no ITC needed"></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
|
||||
<td style="padding: 6px; text-align:center;"><input type="checkbox" checked></td>
|
||||
<td style="padding: 6px;">2024-02-16</td>
|
||||
<td style="padding: 6px;">Savaria Concord Lifts</td>
|
||||
<td style="padding: 6px; text-align:right; font-weight:600;">-$10,173.00</td>
|
||||
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Canadian vendor, ITC likely missing</span></td>
|
||||
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
|
||||
<td style="padding: 6px; text-align:center;"><input type="checkbox" checked></td>
|
||||
<td style="padding: 6px;">2024-02-13</td>
|
||||
<td style="padding: 6px;">Savaria Concord Lifts</td>
|
||||
<td style="padding: 6px; text-align:right; font-weight:600;">-$9,599.50</td>
|
||||
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Canadian vendor, ITC likely missing</span></td>
|
||||
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..." value="Need to check PO"></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
|
||||
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
|
||||
<td style="padding: 6px;">2024-01-11</td>
|
||||
<td style="padding: 6px;">Joerns Healthcare</td>
|
||||
<td style="padding: 6px; text-align:right; font-weight:600;">-$2,392.80</td>
|
||||
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Check fiscal position</span></td>
|
||||
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
|
||||
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
|
||||
<td style="padding: 6px;">2024-01-11</td>
|
||||
<td style="padding: 6px;">Maple Leaf Wheelchair</td>
|
||||
<td style="padding: 6px; text-align:right; font-weight:600;">-$2,181.30</td>
|
||||
<td style="padding: 6px;"><span style="background:rgba(96,165,250,0.15); color:#60a5fa; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Create Rule</span> <span style="opacity:0.7; font-size:12px;">Recurring vendor, always has HST</span></td>
|
||||
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
|
||||
<td style="padding: 6px;">2024-01-17</td>
|
||||
<td style="padding: 6px;">Human Care Canada Inc.</td>
|
||||
<td style="padding: 6px; text-align:right; font-weight:600;">-$2,446.20</td>
|
||||
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Canadian vendor, ITC likely missing</span></td>
|
||||
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Bulk action bar -->
|
||||
<div style="padding: 10px 12px; background: rgba(255,255,255,0.03); border-top: 1px solid rgba(255,255,255,0.1); display:flex; gap:8px; align-items:center; flex-wrap: wrap;">
|
||||
<span style="font-size:12px; opacity:0.7; margin-right:4px;">2 selected</span>
|
||||
<button style="padding:5px 12px; font-size:12px; background:#22c55e; border:none; border-radius:4px; color:white; cursor:pointer; font-weight:500;">✓ Apply Recommendations</button>
|
||||
<button style="padding:5px 12px; font-size:12px; background:rgba(251,191,36,0.2); border:1px solid rgba(251,191,36,0.4); border-radius:4px; color:#fbbf24; cursor:pointer; font-weight:500;">⚑ Flag Selected</button>
|
||||
<button style="padding:5px 12px; font-size:12px; background:rgba(96,165,250,0.2); border:1px solid rgba(96,165,250,0.4); border-radius:4px; color:#60a5fa; cursor:pointer; font-weight:500;">+ Create Rules</button>
|
||||
<button style="padding:5px 12px; font-size:12px; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit; cursor:pointer;">Dismiss Selected</button>
|
||||
<div style="flex:1;"></div>
|
||||
<button style="padding:5px 12px; font-size:12px; background:rgba(139,92,246,0.2); border:1px solid rgba(139,92,246,0.4); border-radius:4px; color:#a78bfa; cursor:pointer; font-weight:500;">✍ Submit All Notes to AI</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<h3>How it works</h3>
|
||||
<ul style="font-size: 14px; line-height: 1.8; opacity: 0.85;">
|
||||
<li><strong>AI Recommendation</strong> column — pre-filled by AI with a colour-coded badge (Dismiss/Flag/Create Rule) + reasoning</li>
|
||||
<li><strong>Your Input</strong> column — editable text field per row for your notes, corrections, or instructions</li>
|
||||
<li><strong>Checkboxes</strong> — select rows for bulk actions</li>
|
||||
<li><strong>Bulk action bar</strong> — Apply Recommendations, Flag, Create Rules, Dismiss, or Submit All Notes back to the AI</li>
|
||||
<li><strong>"Submit All Notes to AI"</strong> — sends your row-level annotations back into the chat so the AI can learn and act on your feedback</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,95 @@
|
||||
<h2>How should AI report tables become interactive?</h2>
|
||||
<p class="subtitle">Looking at the "Missing ITC Bills" report — you want to annotate rows with your input. Which approach feels right?</p>
|
||||
|
||||
<div class="options">
|
||||
<div class="option" data-choice="a" onclick="toggleSelect(this)">
|
||||
<div class="letter">A</div>
|
||||
<div class="content">
|
||||
<h3>Inline Action Column</h3>
|
||||
<p>Every table the AI generates gets an extra column at the right with a <strong>text input + action dropdown</strong> per row. You type your note (e.g., "Exempt - no HST required") and pick an action (Dismiss, Flag, Create Rule, Ask AI). The AI sees your annotations and can act on them.</p>
|
||||
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; font-size: 13px; font-family: monospace;">
|
||||
<table style="width:100%; border-collapse: collapse; font-size: 12px;">
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.15);">
|
||||
<th style="padding: 6px; text-align:left;">Vendor</th>
|
||||
<th style="padding: 6px; text-align:left;">Amount</th>
|
||||
<th style="padding: 6px; text-align:left;">Risk</th>
|
||||
<th style="padding: 6px; text-align:left; color: #fbbf24;">Your Input</th>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<td style="padding: 6px;">Ki Mobility LLC</td>
|
||||
<td style="padding: 6px;">-$14,917.95</td>
|
||||
<td style="padding: 6px;">HST ITC?</td>
|
||||
<td style="padding: 6px;"><input style="width:100px; padding:2px 4px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;" placeholder="Your note..." value="US vendor, no HST"><select style="margin-left:4px; padding:2px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;"><option>Dismiss</option><option>Flag</option><option>Rule</option></select></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 6px;">Savaria Concord</td>
|
||||
<td style="padding: 6px;">-$10,173.00</td>
|
||||
<td style="padding: 6px;">HST ITC?</td>
|
||||
<td style="padding: 6px;"><input style="width:100px; padding:2px 4px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;" placeholder="Your note..."><select style="margin-left:4px; padding:2px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;"><option>Dismiss</option><option>Flag</option><option>Rule</option></select></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="b" onclick="toggleSelect(this)">
|
||||
<div class="letter">B</div>
|
||||
<div class="content">
|
||||
<h3>Row-Click Expandable Panel</h3>
|
||||
<p>Tables render normally, but <strong>clicking a row expands a detail panel</strong> below it with: the AI's recommendation, a text input for your notes, and action buttons (Approve, Dismiss, Create Rule, Ask AI about this). Keeps the table clean, shows detail on demand.</p>
|
||||
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; font-size: 13px;">
|
||||
<div style="padding: 6px; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 12px;">Ki Mobility LLC -$14,917.95 <span style="color:#fbbf24">HST ITC?</span> <span style="font-size:10px; opacity:0.6">Click to expand ▼</span></div>
|
||||
<div style="padding: 10px; margin: 4px 0; background: rgba(251,191,36,0.08); border-left: 3px solid #fbbf24; border-radius: 4px; font-size: 12px;">
|
||||
<div><strong style="color:#fbbf24;">AI Recommendation:</strong> US-based vendor. No HST should apply. Consider dismissing or creating a rule for all Ki Mobility bills.</div>
|
||||
<div style="margin-top: 8px; display:flex; gap:6px; align-items:center;">
|
||||
<input style="flex:1; padding:4px 6px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;" placeholder="Your note or correction...">
|
||||
<button style="padding:3px 8px; font-size:11px; background:#22c55e; border:none; border-radius:3px; color:white; cursor:pointer;">Dismiss</button>
|
||||
<button style="padding:3px 8px; font-size:11px; background:#3b82f6; border:none; border-radius:3px; color:white; cursor:pointer;">Create Rule</button>
|
||||
<button style="padding:3px 8px; font-size:11px; background:rgba(255,255,255,0.15); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit; cursor:pointer;">Ask AI</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 6px; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 12px; opacity: 0.7;">Savaria Concord -$10,173.00 <span style="color:#fbbf24">HST ITC?</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="c" onclick="toggleSelect(this)">
|
||||
<div class="letter">C</div>
|
||||
<div class="content">
|
||||
<h3>AI Recommendation Column + Bulk Actions</h3>
|
||||
<p>The AI proactively fills a <strong>"Recommendation" column</strong> with its suggested action per row (e.g., "Dismiss - US vendor", "Flag - check with accountant"). You can <strong>edit the recommendation</strong>, check rows, and use bulk action buttons (Apply Selected, Dismiss Selected, Create Rules). The AI pre-fills its best guess so you only edit what's wrong.</p>
|
||||
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; font-size: 13px; font-family: monospace;">
|
||||
<table style="width:100%; border-collapse: collapse; font-size: 12px;">
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.15);">
|
||||
<th style="padding: 6px; width:20px;"><input type="checkbox" checked></th>
|
||||
<th style="padding: 6px; text-align:left;">Vendor</th>
|
||||
<th style="padding: 6px; text-align:left;">Amount</th>
|
||||
<th style="padding: 6px; text-align:left; color: #22c55e;">AI Recommendation</th>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<td style="padding: 6px;"><input type="checkbox" checked></td>
|
||||
<td style="padding: 6px;">Ki Mobility LLC</td>
|
||||
<td style="padding: 6px;">-$14,917.95</td>
|
||||
<td style="padding: 6px; color:#22c55e;">Dismiss - US vendor, no HST</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<td style="padding: 6px;"><input type="checkbox"></td>
|
||||
<td style="padding: 6px;">Savaria Concord</td>
|
||||
<td style="padding: 6px;">-$10,173.00</td>
|
||||
<td style="padding: 6px; color:#fbbf24;">Flag - Canadian vendor, ITC likely missing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 6px;"><input type="checkbox"></td>
|
||||
<td style="padding: 6px;">Joerns Healthcare</td>
|
||||
<td style="padding: 6px;">-$2,392.80</td>
|
||||
<td style="padding: 6px; color:#fbbf24;">Flag - check fiscal position</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="margin-top:8px; display:flex; gap:6px;">
|
||||
<button style="padding:4px 10px; font-size:11px; background:#22c55e; border:none; border-radius:3px; color:white;">Apply Selected</button>
|
||||
<button style="padding:4px 10px; font-size:11px; background:rgba(255,255,255,0.15); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;">Create Rules from Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Continuing in terminal...</p>
|
||||
</div>
|
||||
@@ -1,154 +1,46 @@
|
||||
# fusion_accounting — AI Accounting Co-Pilot
|
||||
# fusion_accounting (meta-module) — Cursor / Claude Context
|
||||
|
||||
## What This Module Does
|
||||
An AI agent (Claude/GPT with tool-calling) embedded in Odoo 19 Enterprise Accounting. Conversational interface backed by a dashboard for bank reconciliation, HST/GST management, AR/AP analysis, journal review, month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing.
|
||||
## Purpose
|
||||
|
||||
## Architecture
|
||||
```
|
||||
fusion_accounting/
|
||||
├── models/ 7 models (6 new + 1 inherit on account.move)
|
||||
├── services/
|
||||
│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop)
|
||||
│ ├── adapters/ Claude + OpenAI adapters with native tool-calling
|
||||
│ ├── tools/ 85 tool functions across 11 domain files
|
||||
│ ├── prompts/ System prompt builder + 12 domain-specific prompts
|
||||
│ └── scoring.py Confidence scoring + tier promotion logic
|
||||
├── controllers/ 8 JSON-RPC endpoints
|
||||
├── wizards/ Rule creation wizard
|
||||
├── static/src/ OWL dashboard + chat panel + approval cards
|
||||
├── views/ List/form/search views, menus, settings
|
||||
├── security/ 3 groups (User/Manager/Admin), record rules, ACLs
|
||||
├── data/ 82 tool definitions, 2 default rules, 2 crons
|
||||
└── report/ Audit report QWeb template
|
||||
```
|
||||
Meta-module that installs the entire Fusion Accounting sub-module suite with
|
||||
one click. Owns no Python, JS, XML data, or views of its own. Just a manifest
|
||||
that depends on the sub-modules.
|
||||
|
||||
## Key Design Decisions
|
||||
## Sub-modules (current)
|
||||
|
||||
### AI Provider Integration
|
||||
- Uses `fusion.api.service` (from fusion_api module) for API key resolution with fallback to `ir.config_parameter` — NO hard dependency on fusion_api
|
||||
- Claude adapter: native `tool_use` blocks, extended thinking enabled (8K budget) for 4.5+ models
|
||||
- OpenAI adapter: Chat Completions API with o-series reasoning model support (`developer` role, `max_completion_tokens`, `reasoning_effort`)
|
||||
- API keys stored in `ir.config_parameter` with `fusion_accounting.` prefix
|
||||
|
||||
### Tool Tiering
|
||||
- **Tier 1** (Free): Read-only, execute immediately — 60+ tools
|
||||
- **Tier 2** (Auto-approved): Low-risk writes, logged — ~10 tools
|
||||
- **Tier 3** (Requires approval): Financial writes, user must approve — ~15 tools
|
||||
- Auto-promotion: Tier 3 → Tier 2 at 95% accuracy over 30+ decisions (atomic SQL counters)
|
||||
|
||||
### Menu Location
|
||||
- **Parent**: `accountant.menu_accounting` (NOT `account.menu_finance` — that's Community Edition only)
|
||||
- Enterprise uses `accountant.menu_accounting` (ID 1663) as the visible menu root
|
||||
|
||||
### Session Persistence
|
||||
- Chat sessions stored in `fusion.accounting.session` with `message_ids_json` (JSON text field)
|
||||
- On page load, chat panel calls `/session/latest` to restore the most recent active session
|
||||
- "New Chat" button closes current session and creates a fresh one
|
||||
|
||||
## Odoo 19 Gotchas (Learned the Hard Way)
|
||||
|
||||
### Search Views
|
||||
- NO `string` attribute on `<search>` element
|
||||
- NO `string` attribute on `<group>` element inside search views
|
||||
- Group-by filters MUST have `domain="[]"` attribute
|
||||
- Add `<separator/>` before `<group>` in search views
|
||||
|
||||
### OWL Client Actions
|
||||
- Components registered as client actions receive props: `action`, `actionId`, `updateActionState`, `className`
|
||||
- Must use `static props = ["*"]` (accept any) — NOT `static props = []` (accept none)
|
||||
|
||||
### Cron Safe Eval
|
||||
- NO `import` statements (forbidden opcode `IMPORT_NAME`)
|
||||
- `datetime` module available as `datetime` (use `datetime.datetime.now()`, `datetime.timedelta()`)
|
||||
- NO `from datetime import X` pattern
|
||||
|
||||
### read_group Deprecated
|
||||
- `read_group()` is deprecated in Odoo 19 — use `_read_group()` instead
|
||||
- Still works but throws DeprecationWarning
|
||||
|
||||
### Config Parameter Values
|
||||
- When changing a Selection field's options, the stored DB value in `ir_config_parameter` must match one of the new options or Settings page will crash with `ValueError: Wrong value`
|
||||
- Fix: UPDATE the value in DB after changing selection options
|
||||
|
||||
### Field Label Conflicts
|
||||
- Odoo warns if two fields on the same model have the same `string` label
|
||||
- Our `display_name_field` conflicted with built-in `display_name` — renamed string to "Tool Label"
|
||||
|
||||
### Group Assignment
|
||||
- `implied_ids` on groups only applies to NEWLY added users, not existing ones
|
||||
- After installing, manually add existing users to groups via SQL:
|
||||
```sql
|
||||
INSERT INTO res_groups_users_rel (gid, uid)
|
||||
SELECT <group_id>, gu.uid FROM res_groups_users_rel gu
|
||||
JOIN ir_model_data imd ON imd.res_id = gu.gid AND imd.model = 'res.groups'
|
||||
WHERE imd.module = 'account' AND imd.name = 'group_account_manager'
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
## Server Details
|
||||
- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`)
|
||||
- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL)
|
||||
- **Database**: westin-v19
|
||||
- **Module path**: `/mnt/extra-addons/fusion_accounting/`
|
||||
- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages`
|
||||
|
||||
## Deployment Commands
|
||||
```bash
|
||||
# Deploy module to server
|
||||
ssh odoo-westin "docker exec -u 0 odoo-dev-app rm -rf /mnt/extra-addons/fusion_accounting"
|
||||
scp -r "K:\Github\Odoo-Modules\fusion_accounting" odoo-westin:/tmp/fusion_accounting
|
||||
ssh odoo-westin "docker cp /tmp/fusion_accounting odoo-dev-app:/mnt/extra-addons/fusion_accounting && rm -rf /tmp/fusion_accounting"
|
||||
|
||||
# Upgrade module (use alt port to avoid conflict with running instance)
|
||||
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_accounting --stop-after-init --http-port=8099 -c /etc/odoo/odoo.conf"
|
||||
|
||||
# Restart container
|
||||
ssh odoo-westin "docker restart odoo-dev-app"
|
||||
|
||||
# Check logs
|
||||
ssh odoo-westin "docker logs odoo-dev-app --tail 100"
|
||||
```
|
||||
|
||||
## Security Groups
|
||||
| Group ID | XML ID | Name | Access |
|
||||
|---|---|---|---|
|
||||
| 564 | `group_fusion_accounting_user` | User | Dashboard, chat (read-only tools) |
|
||||
| 565 | `group_fusion_accounting_manager` | Manager | + Approve/reject, Tier 2 tools, rules |
|
||||
| 566 | `group_fusion_accounting_admin` | Administrator | + Config, all tools, rule admin |
|
||||
|
||||
Auto-assigned: `account.group_account_user` → User, `account.group_account_manager` → Admin
|
||||
|
||||
## Models
|
||||
| Model | Type | Purpose |
|
||||
| Sub-module | Phase | Purpose |
|
||||
|---|---|---|
|
||||
| `fusion.accounting.session` | Model | Chat sessions with message JSON storage |
|
||||
| `fusion.accounting.match.history` | Model | Every AI tool call + decision (approved/rejected/pending) |
|
||||
| `fusion.accounting.rule` | Model | Fusion Rules engine with versioning and auto-promotion |
|
||||
| `fusion.accounting.tool` | Model | Tool registry (82 tools seeded from XML) |
|
||||
| `fusion.accounting.dashboard` | TransientModel | Computed health metrics (use `.new()` not `.create()`) |
|
||||
| `fusion.accounting.agent` | AbstractModel | AI orchestrator |
|
||||
| `fusion.accounting.adapter.claude` | AbstractModel | Claude tool-calling adapter |
|
||||
| `fusion.accounting.adapter.openai` | AbstractModel | OpenAI tool-calling adapter |
|
||||
| `fusion.accounting.scoring` | AbstractModel | Confidence scoring |
|
||||
| `account.move` (inherit) | Model | Post-action audit hook |
|
||||
| `fusion_accounting_core` | 0 | Security groups, shared schema, Enterprise detection helper |
|
||||
| `fusion_accounting_ai` | 0 | AI Co-Pilot (Claude/GPT) — was the original `fusion_accounting` code |
|
||||
| `fusion_accounting_migration` | 0 | Transitional Enterprise->Fusion data migration |
|
||||
|
||||
## AI Models Available
|
||||
**Claude** (default: claude-sonnet-4-6):
|
||||
- claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5
|
||||
- claude-sonnet-4-5, claude-opus-4-5, claude-sonnet-4-0, claude-opus-4-0
|
||||
## Sub-modules (planned)
|
||||
|
||||
**OpenAI** (default: gpt-5.4-mini):
|
||||
- gpt-5.4, gpt-5.4-mini, gpt-5.4-nano
|
||||
- o3, o4-mini
|
||||
- gpt-4o, gpt-4o-mini (legacy)
|
||||
Per the roadmap design at `docs/superpowers/specs/2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md`:
|
||||
|
||||
## Theme / Styling Rules
|
||||
- NO hardcoded colours — use CSS variables (`var(--o-border-color)`, `var(--bs-body-color-rgb)`) and Bootstrap utility classes
|
||||
- Must work in both light and dark mode
|
||||
- Box shadows: use `rgba(var(--bs-body-color-rgb), 0.1)` not `rgba(0,0,0,0.1)`
|
||||
| Sub-module | Phase | Purpose |
|
||||
|---|---|---|
|
||||
| `fusion_accounting_bank_rec` | 1 | Native bank reconciliation (replaces account_accountant bank rec) |
|
||||
| `fusion_accounting_reports` | 2 | Native financial reports engine (replaces account_reports) |
|
||||
| `fusion_accounting_dashboard` | 3 | Journal kanban + digest |
|
||||
| `fusion_accounting_followup` | 5 | Customer payment follow-ups |
|
||||
| `fusion_accounting_assets` | 6 | Asset register + depreciation |
|
||||
| `fusion_accounting_budget` | 6 | Budget vs actual |
|
||||
|
||||
## Known Issues / Future Work
|
||||
- `read_group()` deprecation warnings — migrate to `_read_group()` when format is documented
|
||||
- `verify_source_deductions`, `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2)
|
||||
- `account.return` model used in HST tools may not exist in all Odoo 19 setups — needs try/except guard
|
||||
- Batch approval "Approve All" / "Reject All" buttons are in the chat panel but not yet in the match history list view
|
||||
## Roadmap and plans
|
||||
|
||||
- Roadmap design: `docs/superpowers/specs/2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md`
|
||||
- Phase 0 plan: `docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md`
|
||||
- Empirical uninstall test results: `docs/superpowers/specs/2026-04-18-empirical-uninstall-test-results.md` (produced in Task 18 of Phase 0)
|
||||
|
||||
## Tooling
|
||||
|
||||
- `tools/check_odoo_diff.sh` — annual upgrade ritual: diff Enterprise source between Odoo versions
|
||||
|
||||
## Per-sub-module CLAUDE.md
|
||||
|
||||
Each sub-module has its own `CLAUDE.md` with feature-specific context. Read them when working on that sub-module.
|
||||
|
||||
## Workspace-wide conventions
|
||||
|
||||
`/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md` — common Odoo 19 rules (search views, OWL components, SCSS, asset bundle cache busting, dark mode, etc.). Apply to every sub-module.
|
||||
|
||||
38
fusion_accounting/README.md
Normal file
38
fusion_accounting/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Fusion Accounting (meta-module)
|
||||
|
||||
One-click install of the entire Fusion Accounting suite for Odoo 19.
|
||||
|
||||
## What it installs
|
||||
|
||||
- AI Co-Pilot for accounting (Claude / GPT)
|
||||
- Native foundation (security, schema preservation)
|
||||
- Transitional Enterprise -> Fusion migration helper
|
||||
|
||||
As later sub-modules ship (bank rec, reports, follow-ups, assets, budgets),
|
||||
they're added to the meta-module's `depends` and installed automatically when
|
||||
the client upgrades fusion_accounting.
|
||||
|
||||
## Install
|
||||
|
||||
docker exec odoo-dev-app odoo -d <db> -i fusion_accounting --stop-after-init
|
||||
|
||||
## Uninstall
|
||||
|
||||
Uninstalling the meta-module does NOT uninstall its sub-modules (Odoo
|
||||
behavior). To fully remove Fusion Accounting:
|
||||
|
||||
docker exec odoo-dev-app odoo-shell -d <db> --no-http <<EOF
|
||||
env['ir.module.module'].search([
|
||||
('name', 'in', [
|
||||
'fusion_accounting',
|
||||
'fusion_accounting_ai',
|
||||
'fusion_accounting_migration',
|
||||
'fusion_accounting_core',
|
||||
]),
|
||||
('state', '=', 'installed'),
|
||||
]).button_immediate_uninstall()
|
||||
EOF
|
||||
|
||||
## Documentation
|
||||
|
||||
See `docs/superpowers/specs/` for the design and `docs/superpowers/plans/` for implementation plans.
|
||||
@@ -1,4 +1 @@
|
||||
from . import models
|
||||
from . import services
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
# Meta-module: no Python code. All implementation is in sub-modules listed in __manifest__.py 'depends'.
|
||||
|
||||
@@ -1,61 +1,41 @@
|
||||
{
|
||||
'name': 'Fusion Accounting AI',
|
||||
'name': 'Fusion Accounting',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 25,
|
||||
'summary': 'AI Accounting Co-Pilot with conversational interface and automated analysis',
|
||||
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
|
||||
'description': """
|
||||
Fusion Accounting AI
|
||||
====================
|
||||
An AI-powered accounting co-pilot that embeds Claude/GPT into the Odoo Accounting
|
||||
module. Features conversational bank reconciliation, HST management, AR/AP analysis,
|
||||
audit scanning, and a comprehensive dashboard.
|
||||
Fusion Accounting (Meta-Module)
|
||||
===============================
|
||||
One-click install of the entire Fusion Accounting suite.
|
||||
|
||||
Currently installs:
|
||||
- fusion_accounting_core Shared schema, security, runtime helpers
|
||||
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
|
||||
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
|
||||
|
||||
Future sub-modules (added per the roadmap as each Phase ships):
|
||||
- fusion_accounting_bank_rec (Phase 1)
|
||||
- fusion_accounting_reports (Phase 2)
|
||||
- fusion_accounting_dashboard (Phase 3)
|
||||
- fusion_accounting_followup (Phase 5)
|
||||
- fusion_accounting_assets (Phase 6)
|
||||
- fusion_accounting_budget (Phase 6)
|
||||
|
||||
Built by Nexa Systems Inc.
|
||||
""",
|
||||
'icon': '/fusion_accounting/static/description/icon.png',
|
||||
'icon': '/fusion_accounting_ai/static/description/icon.png',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://nexasystems.ca',
|
||||
'support': 'support@nexasystems.ca',
|
||||
'maintainer': 'Nexa Systems Inc.',
|
||||
'depends': [
|
||||
'account',
|
||||
'account_accountant',
|
||||
'account_reports',
|
||||
'account_followup',
|
||||
'mail',
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['anthropic', 'openai'],
|
||||
},
|
||||
'data': [
|
||||
# Security
|
||||
'security/security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
# Data
|
||||
'data/cron.xml',
|
||||
'data/tool_definitions.xml',
|
||||
'data/default_rules.xml',
|
||||
# Views
|
||||
'views/config_views.xml',
|
||||
'views/session_views.xml',
|
||||
'views/match_history_views.xml',
|
||||
'views/rule_views.xml',
|
||||
'views/dashboard_views.xml',
|
||||
'views/menus.xml',
|
||||
# Wizards
|
||||
'wizards/rule_wizard.xml',
|
||||
# Reports
|
||||
'report/audit_report_template.xml',
|
||||
'fusion_accounting_core',
|
||||
'fusion_accounting_ai',
|
||||
'fusion_accounting_migration',
|
||||
],
|
||||
'data': [],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'application': True,
|
||||
'license': 'OPL-1',
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_accounting/static/src/**/*.js',
|
||||
'fusion_accounting/static/src/**/*.xml',
|
||||
'fusion_accounting/static/src/**/*.scss',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingChatController(http.Controller):
|
||||
|
||||
@http.route('/fusion_accounting/session/create', type='jsonrpc', auth='user')
|
||||
def create_session(self, context_domain=None, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].create({
|
||||
'user_id': request.env.user.id,
|
||||
'company_id': request.env.company.id,
|
||||
'context_domain': context_domain,
|
||||
})
|
||||
return {'session_id': session.id, 'name': session.name}
|
||||
|
||||
@http.route('/fusion_accounting/session/close', type='jsonrpc', auth='user')
|
||||
def close_session(self, session_id, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if session.exists() and session.state == 'active':
|
||||
session.action_close_session()
|
||||
return {'status': 'closed'}
|
||||
|
||||
@http.route('/fusion_accounting/chat', type='jsonrpc', auth='user')
|
||||
def chat(self, session_id, message, context=None, **kwargs):
|
||||
if not message:
|
||||
return {'error': 'Message is required'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
result = agent.chat(int(session_id), message, context=context)
|
||||
return result
|
||||
|
||||
@http.route('/fusion_accounting/approve', type='jsonrpc', auth='user')
|
||||
def approve_action(self, match_history_id, **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to approve actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
result = agent.approve_action(int(match_history_id))
|
||||
return result
|
||||
|
||||
@http.route('/fusion_accounting/reject', type='jsonrpc', auth='user')
|
||||
def reject_action(self, match_history_id, reason='', **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to reject actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
result = agent.reject_action(int(match_history_id), reason)
|
||||
return result
|
||||
|
||||
@http.route('/fusion_accounting/dashboard/data', type='jsonrpc', auth='user')
|
||||
def dashboard_data(self, **kwargs):
|
||||
dashboard = request.env['fusion.accounting.dashboard'].new({
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
return {
|
||||
'bank_recon': {'count': dashboard.bank_recon_count, 'amount': dashboard.bank_recon_amount},
|
||||
'ar': {'total': dashboard.ar_total, 'overdue_count': dashboard.ar_overdue_count},
|
||||
'ap': {'total': dashboard.ap_total, 'due_this_week': dashboard.ap_due_this_week},
|
||||
'hst': {'balance': dashboard.hst_balance},
|
||||
'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count},
|
||||
'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items},
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user')
|
||||
def approve_all(self, match_history_ids, **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to approve actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
results = []
|
||||
for mid in match_history_ids:
|
||||
try:
|
||||
result = agent.approve_action(int(mid))
|
||||
results.append({'id': mid, 'status': 'approved', 'result': result})
|
||||
except Exception as e:
|
||||
results.append({'id': mid, 'status': 'error', 'error': str(e)})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user')
|
||||
def reject_all(self, match_history_ids, reason='', **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to reject actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
results = []
|
||||
for mid in match_history_ids:
|
||||
try:
|
||||
result = agent.reject_action(int(mid), reason)
|
||||
results.append({'id': mid, 'status': 'rejected'})
|
||||
except Exception as e:
|
||||
results.append({'id': mid, 'status': 'error', 'error': str(e)})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_accounting/session/latest', type='jsonrpc', auth='user')
|
||||
def session_latest(self, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
('state', '=', 'active'),
|
||||
], limit=1, order='create_date desc')
|
||||
if not session:
|
||||
return {'session_id': None, 'messages': [], 'name': None}
|
||||
messages = json.loads(session.message_ids_json or '[]')
|
||||
display_messages = []
|
||||
for msg in messages:
|
||||
if isinstance(msg.get('content'), str) and msg['content'].strip():
|
||||
display_messages.append(msg)
|
||||
elif isinstance(msg.get('content'), list):
|
||||
for block in msg['content']:
|
||||
if isinstance(block, dict) and block.get('type') == 'text' and block['text'].strip():
|
||||
display_messages.append({'role': msg['role'], 'content': block['text']})
|
||||
return {
|
||||
'session_id': session.id,
|
||||
'messages': display_messages,
|
||||
'name': session.name,
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/session/history', type='jsonrpc', auth='user')
|
||||
def session_history(self, session_id, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if not session.exists():
|
||||
return {'error': 'Session not found'}
|
||||
return {
|
||||
'messages': json.loads(session.message_ids_json or '[]'),
|
||||
'session_id': session.id,
|
||||
'state': session.state,
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- Session name sequence -->
|
||||
<record id="seq_fusion_accounting_session" model="ir.sequence">
|
||||
<field name="name">Fusion AI Session</field>
|
||||
<field name="code">fusion.accounting.session</field>
|
||||
<field name="prefix">FAS/%(year)s/</field>
|
||||
<field name="padding">5</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily audit scan: expire stale pending approvals -->
|
||||
<record id="cron_fusion_audit_scan" model="ir.cron">
|
||||
<field name="name">Fusion AI: Periodic Audit Scan</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_match_history"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
cutoff = datetime.datetime.now() - datetime.timedelta(days=30)
|
||||
stale = model.search([('decision', '=', 'pending'), ('proposed_at', '<', cutoff.strftime('%Y-%m-%d %H:%M:%S'))])
|
||||
stale.write({'decision': 'rejected', 'rejection_reason': 'Auto-expired after 30 days'})
|
||||
</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Weekly tier promotion check -->
|
||||
<record id="cron_fusion_tier_promotion" model="ir.cron">
|
||||
<field name="name">Fusion AI: Tier Promotion Check</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_rule"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
for rule in model.search([('active', '=', True), ('approval_tier', '=', 'needs_approval')]):
|
||||
rule._check_promotion()
|
||||
</field>
|
||||
<field name="interval_number">7</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
</odoo>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
# CI Currently Manual (Phase 0 note)
|
||||
|
||||
The CI yaml at `.gitea/workflows/fusion_accounting_ci.yml` (or `.github/`)
|
||||
describes the target workflow, but the `Install Odoo 19` step is a TODO
|
||||
placeholder in Phase 0 because the repo does not yet pin a reproducible
|
||||
Odoo 19 build environment for CI runners.
|
||||
|
||||
## Current workflow (Phase 0)
|
||||
|
||||
Tests are run manually via the dev server:
|
||||
|
||||
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 \
|
||||
--test-tags post_install --stop-after-init --no-http \
|
||||
-c /etc/odoo/odoo.conf -u <sub_module> \
|
||||
--log-handler=odoo.tests:INFO"
|
||||
|
||||
This pattern is embedded in the Phase 0 plan's per-task verification steps.
|
||||
|
||||
## To activate CI (deferred to Phase 1)
|
||||
|
||||
Three realistic approaches:
|
||||
|
||||
1. **Dockerfile + DinD**: Build a reproducible Odoo-19 image in the repo
|
||||
(e.g. `docker/odoo-19.Dockerfile`). CI runner uses Docker-in-Docker.
|
||||
Slowest to boot, fully reproducible.
|
||||
2. **Self-hosted runner on odoo-westin**: Register a runner on the existing
|
||||
dev box. Tests run against a throwaway DB (per-CI-run). Fastest; ties
|
||||
CI to odoo-westin availability.
|
||||
3. **Pip-installable Odoo**: `pip install odoo==19.0.*` (if Odoo publishes
|
||||
wheels that match the Enterprise-aware build). Simplest if it works.
|
||||
|
||||
Pick when Phase 1 (Bank Reconciliation) begins — Phase 1 benefits from
|
||||
automated test runs because its scope is broader than Phase 0's.
|
||||
|
||||
## What the current yaml gets right
|
||||
|
||||
- Path filters only trigger on fusion_accounting* changes
|
||||
- Matrix tests each sub-module independently
|
||||
- Python deps (anthropic, openai) preinstalled
|
||||
- PostgreSQL 15 service wired
|
||||
- Odoo stdout/stderr captured at INFO level to see test results
|
||||
@@ -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,81 +0,0 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingMatchHistory(models.Model):
|
||||
_name = 'fusion.accounting.match.history'
|
||||
_description = 'Fusion Accounting Match History'
|
||||
_order = 'proposed_at desc'
|
||||
|
||||
session_id = fields.Many2one(
|
||||
'fusion.accounting.session', string='Session',
|
||||
index=True, ondelete='cascade',
|
||||
)
|
||||
tool_name = fields.Char(string='Tool Name', required=True, index=True)
|
||||
tool_params = fields.Text(string='Tool Parameters (JSON)')
|
||||
tool_result = fields.Text(string='Tool Result (JSON)')
|
||||
ai_reasoning = fields.Text(string='AI Reasoning')
|
||||
ai_confidence = fields.Float(string='AI Confidence', digits=(3, 2))
|
||||
rule_id = fields.Many2one(
|
||||
'fusion.accounting.rule', string='Applied Rule',
|
||||
ondelete='set null',
|
||||
)
|
||||
proposed_at = fields.Datetime(
|
||||
string='Proposed At',
|
||||
default=fields.Datetime.now,
|
||||
required=True,
|
||||
)
|
||||
decision = fields.Selection(
|
||||
selection=[
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
('pending', 'Pending'),
|
||||
('auto', 'Auto-Executed'),
|
||||
],
|
||||
string='Decision',
|
||||
default='pending',
|
||||
index=True,
|
||||
)
|
||||
decided_at = fields.Datetime(string='Decided At')
|
||||
decided_by = fields.Many2one('res.users', string='Decided By')
|
||||
rejection_reason = fields.Text(string='Rejection Reason')
|
||||
correct_action = fields.Text(string='Correct Action (JSON)')
|
||||
bank_statement_line_id = fields.Many2one(
|
||||
'account.bank.statement.line', string='Bank Statement Line',
|
||||
ondelete='set null',
|
||||
)
|
||||
move_line_ids = fields.Many2many(
|
||||
'account.move.line', string='Journal Items',
|
||||
)
|
||||
amount = fields.Monetary(string='Amount', currency_field='currency_id')
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
partner_id = fields.Many2one('res.partner', string='Partner')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
def action_approve(self):
|
||||
self.write({
|
||||
'decision': 'approved',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
})
|
||||
for rec in self:
|
||||
if rec.rule_id:
|
||||
rec.rule_id._record_decision(approved=True)
|
||||
|
||||
def action_reject(self):
|
||||
self.write({
|
||||
'decision': 'rejected',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
})
|
||||
for rec in self:
|
||||
if rec.rule_id:
|
||||
rec.rule_id._record_decision(approved=False)
|
||||
@@ -1,13 +0,0 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_session_user,fusion.accounting.session.user,model_fusion_accounting_session,group_fusion_accounting_user,1,1,1,0
|
||||
access_fusion_session_admin,fusion.accounting.session.admin,model_fusion_accounting_session,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_history_user,fusion.accounting.match.history.user,model_fusion_accounting_match_history,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_history_manager,fusion.accounting.match.history.manager,model_fusion_accounting_match_history,group_fusion_accounting_manager,1,1,1,0
|
||||
access_fusion_history_admin,fusion.accounting.match.history.admin,model_fusion_accounting_match_history,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_rule_user,fusion.accounting.rule.user,model_fusion_accounting_rule,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_rule_manager,fusion.accounting.rule.manager,model_fusion_accounting_rule,group_fusion_accounting_manager,1,1,1,0
|
||||
access_fusion_rule_admin,fusion.accounting.rule.admin,model_fusion_accounting_rule,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_tool_user,fusion.accounting.tool.user,model_fusion_accounting_tool,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_tool_admin,fusion.accounting.tool.admin,model_fusion_accounting_tool,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_dashboard_user,fusion.accounting.dashboard.user,model_fusion_accounting_dashboard,group_fusion_accounting_user,1,1,1,1
|
||||
access_fusion_rule_wizard_manager,fusion.accounting.rule.wizard.manager,model_fusion_accounting_rule_wizard,group_fusion_accounting_manager,1,1,1,1
|
||||
|
@@ -1,94 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Module Category -->
|
||||
<record id="module_category_fusion_accounting" model="ir.module.category">
|
||||
<field name="name">Fusion Accounting AI</field>
|
||||
<field name="sequence">25</field>
|
||||
</record>
|
||||
|
||||
<!-- Groups Privilege -->
|
||||
<record id="res_groups_privilege_fusion_accounting" model="res.groups.privilege">
|
||||
<field name="name">Fusion Accounting AI</field>
|
||||
<field name="category_id" ref="module_category_fusion_accounting"/>
|
||||
</record>
|
||||
|
||||
<!-- User Group (Staff) -->
|
||||
<record id="group_fusion_accounting_user" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="implied_ids" eval="[(4, ref('account.group_account_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
|
||||
</record>
|
||||
|
||||
<!-- Manager Group -->
|
||||
<record id="group_fusion_accounting_manager" model="res.groups">
|
||||
<field name="name">Manager</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
|
||||
</record>
|
||||
|
||||
<!-- Admin Group -->
|
||||
<record id="group_fusion_accounting_admin" model="res.groups">
|
||||
<field name="name">Administrator</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
|
||||
</record>
|
||||
|
||||
<!-- Auto-assign: Accounting users get Fusion AI User, Advisers get Admin -->
|
||||
<record id="account.group_account_user" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_user'))]"/>
|
||||
</record>
|
||||
<record id="account.group_account_manager" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Record Rules -->
|
||||
<record id="rule_fusion_session_user" model="ir.rule">
|
||||
<field name="name">Fusion Session: Own Sessions</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_session"/>
|
||||
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_accounting_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_fusion_session_manager" model="ir.rule">
|
||||
<field name="name">Fusion Session: All Sessions</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_session"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_fusion_history_user" model="ir.rule">
|
||||
<field name="name">Fusion History: Own History</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_match_history"/>
|
||||
<field name="domain_force">[('session_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_accounting_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_fusion_history_manager" model="ir.rule">
|
||||
<field name="name">Fusion History: All History</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_match_history"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Multi-company rules -->
|
||||
<record id="rule_fusion_tool_company" model="ir.rule">
|
||||
<field name="name">Fusion Tool: Multi-Company</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_tool"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_fusion_rule_company" model="ir.rule">
|
||||
<field name="name">Fusion Rule: Multi-Company</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_rule"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_fusion_history_company" model="ir.rule">
|
||||
<field name="name">Fusion History: Multi-Company</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_match_history"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,315 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingAgent(models.AbstractModel):
|
||||
_name = 'fusion.accounting.agent'
|
||||
_description = 'Fusion Accounting AI Agent Orchestrator'
|
||||
|
||||
def _get_config(self, key, default=None):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
return ICP.get_param(f'fusion_accounting.{key}', default)
|
||||
|
||||
def _get_adapter(self):
|
||||
provider = self._get_config('ai_provider', 'claude')
|
||||
if provider == 'claude':
|
||||
return self.env['fusion.accounting.adapter.claude']
|
||||
return self.env['fusion.accounting.adapter.openai']
|
||||
|
||||
def _get_tool_registry(self):
|
||||
return self.env['fusion.accounting.tool'].search([('active', '=', True)])
|
||||
|
||||
def _get_tools_for_user(self, user=None):
|
||||
user = user or self.env.user
|
||||
tools = self._get_tool_registry()
|
||||
filtered = self.env['fusion.accounting.tool']
|
||||
for tool in tools:
|
||||
if not tool.required_groups:
|
||||
filtered |= tool
|
||||
continue
|
||||
group_xmlids = [g.strip() for g in tool.required_groups.split(',') if g.strip()]
|
||||
if all(user.has_group(g) for g in group_xmlids):
|
||||
filtered |= tool
|
||||
return filtered
|
||||
|
||||
def _build_tool_definitions(self, tools):
|
||||
definitions = []
|
||||
for tool in tools:
|
||||
defn = {
|
||||
'name': tool.name,
|
||||
'description': tool.description,
|
||||
}
|
||||
if tool.parameters_schema:
|
||||
try:
|
||||
defn['parameters'] = json.loads(tool.parameters_schema)
|
||||
except json.JSONDecodeError:
|
||||
defn['parameters'] = {'type': 'object', 'properties': {}}
|
||||
else:
|
||||
defn['parameters'] = {'type': 'object', 'properties': {}}
|
||||
definitions.append(defn)
|
||||
return definitions
|
||||
|
||||
def _load_rules(self, domain=None):
|
||||
rule_domain = [('active', '=', True), ('company_id', '=', self.env.company.id)]
|
||||
if domain:
|
||||
rule_domain.append(('rule_type', '=', domain))
|
||||
rules = self.env['fusion.accounting.rule'].search(rule_domain, order='sequence')
|
||||
admin_rules = rules.filtered(lambda r: r.created_by == 'admin')
|
||||
ai_auto = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'auto')
|
||||
ai_pending = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'needs_approval')
|
||||
return admin_rules | ai_auto | ai_pending
|
||||
|
||||
def _load_match_history(self, domain=None, limit=None):
|
||||
limit = limit or int(self._get_config('history_in_prompt', '50'))
|
||||
history_domain = [('company_id', '=', self.env.company.id)]
|
||||
if domain:
|
||||
history_domain.append(('tool_name', 'ilike', domain))
|
||||
return self.env['fusion.accounting.match.history'].search(
|
||||
history_domain, limit=limit, order='proposed_at desc',
|
||||
)
|
||||
|
||||
def _build_system_prompt(self, rules, history, context=None, domain=None):
|
||||
from .prompts.system_prompt import build_system_prompt
|
||||
from .prompts.domain_prompts import get_domain_prompt
|
||||
base = build_system_prompt(rules, history, context)
|
||||
if domain:
|
||||
domain_prompt = get_domain_prompt(domain)
|
||||
if domain_prompt:
|
||||
base = f"{base}\n\n{domain_prompt}"
|
||||
return base
|
||||
|
||||
def _execute_tool(self, tool_name, params, session_id=None):
|
||||
from .tools import TOOL_DISPATCH
|
||||
if tool_name not in TOOL_DISPATCH:
|
||||
return {'error': f'Unknown tool: {tool_name}'}
|
||||
try:
|
||||
result = TOOL_DISPATCH[tool_name](self.env, params)
|
||||
return result
|
||||
except Exception as e:
|
||||
_logger.error("Tool execution error (%s): %s", tool_name, e)
|
||||
return {'error': str(e)}
|
||||
|
||||
def _log_match_history(self, session, tool_name, params, result, reasoning='',
|
||||
confidence=0.0, rule=None, tier='1'):
|
||||
vals = {
|
||||
'session_id': session.id if session else False,
|
||||
'tool_name': tool_name,
|
||||
'tool_params': json.dumps(params) if params else '{}',
|
||||
'tool_result': json.dumps(result) if result else '{}',
|
||||
'ai_reasoning': reasoning,
|
||||
'ai_confidence': confidence,
|
||||
'rule_id': rule.id if rule else False,
|
||||
'proposed_at': fields.Datetime.now(),
|
||||
'decision': 'auto' if tier in ('1', '2') else 'pending',
|
||||
'company_id': self.env.company.id,
|
||||
}
|
||||
return self.env['fusion.accounting.match.history'].sudo().create(vals)
|
||||
|
||||
def chat(self, session_id, user_message, context=None):
|
||||
session = self.env['fusion.accounting.session'].browse(session_id)
|
||||
if not session.exists():
|
||||
raise UserError(_("Session not found."))
|
||||
|
||||
adapter = self._get_adapter()
|
||||
tools = self._get_tools_for_user()
|
||||
tool_definitions = self._build_tool_definitions(tools)
|
||||
rules = self._load_rules()
|
||||
history = self._load_match_history()
|
||||
system_prompt = self._build_system_prompt(
|
||||
rules, history, context, domain=session.context_domain,
|
||||
)
|
||||
|
||||
messages_json = json.loads(session.message_ids_json or '[]')
|
||||
messages_json.append({'role': 'user', 'content': user_message})
|
||||
|
||||
max_turns = max(int(self._get_config('max_tool_calls', '20')), 1)
|
||||
total_tokens_in = 0
|
||||
total_tokens_out = 0
|
||||
response = {'text': '', 'tool_calls': None}
|
||||
|
||||
for turn in range(max_turns):
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=tool_definitions,
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
|
||||
if response.get('tool_calls'):
|
||||
tool_results = []
|
||||
for tc in response['tool_calls']:
|
||||
tool_name = tc['name']
|
||||
tool_params = tc.get('arguments', {})
|
||||
tool_rec = tools.filtered(lambda t: t.name == tool_name)
|
||||
tier = tool_rec.tier if tool_rec else '1'
|
||||
|
||||
if tier == '3':
|
||||
history_rec = self._log_match_history(
|
||||
session, tool_name, tool_params, None,
|
||||
reasoning=tc.get('reasoning', ''),
|
||||
confidence=tc.get('confidence', 0.0),
|
||||
tier='3',
|
||||
)
|
||||
tool_results.append({
|
||||
'tool_call_id': tc.get('id', ''),
|
||||
'result': json.dumps({
|
||||
'status': 'pending_approval',
|
||||
'match_history_id': history_rec.id,
|
||||
'message': f'Action requires user approval. Match history ID: {history_rec.id}',
|
||||
}),
|
||||
})
|
||||
else:
|
||||
result = self._execute_tool(tool_name, tool_params, session.id)
|
||||
self._log_match_history(
|
||||
session, tool_name, tool_params, result,
|
||||
reasoning=tc.get('reasoning', ''),
|
||||
tier=tier,
|
||||
)
|
||||
tool_results.append({
|
||||
'tool_call_id': tc.get('id', ''),
|
||||
'result': json.dumps(result) if not isinstance(result, str) else result,
|
||||
})
|
||||
try:
|
||||
self._check_rule_proposal(tool_name, tool_params, session)
|
||||
except Exception:
|
||||
_logger.exception("Error in _check_rule_proposal for tool %s", tool_name)
|
||||
|
||||
messages_json = adapter.append_tool_results(
|
||||
messages_json, response, tool_results,
|
||||
)
|
||||
session.tool_call_count += len(tool_results)
|
||||
else:
|
||||
assistant_text = response.get('text', '')
|
||||
messages_json.append({'role': 'assistant', 'content': assistant_text})
|
||||
break
|
||||
else:
|
||||
# Loop exhausted — force a final text response without tools
|
||||
try:
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=[],
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': response.get('text', 'I reached the maximum number of steps. Please continue the conversation.'),
|
||||
})
|
||||
except Exception:
|
||||
_logger.warning("Failed to get final summary after max tool calls")
|
||||
|
||||
session.write({
|
||||
'message_ids_json': json.dumps(messages_json),
|
||||
'token_count_in': session.token_count_in + total_tokens_in,
|
||||
'token_count_out': session.token_count_out + total_tokens_out,
|
||||
'ai_provider': self._get_config('ai_provider', 'claude'),
|
||||
'ai_model': adapter._get_model_name(),
|
||||
})
|
||||
|
||||
pending = self.env['fusion.accounting.match.history'].search([
|
||||
('session_id', '=', session.id),
|
||||
('decision', '=', 'pending'),
|
||||
])
|
||||
|
||||
return {
|
||||
'text': response.get('text', ''),
|
||||
'pending_approvals': [{
|
||||
'id': p.id,
|
||||
'tool_name': p.tool_name,
|
||||
'params': p.tool_params,
|
||||
'reasoning': p.ai_reasoning,
|
||||
'confidence': p.ai_confidence,
|
||||
'amount': p.amount,
|
||||
} for p in pending],
|
||||
'session_id': session.id,
|
||||
}
|
||||
|
||||
def approve_action(self, match_history_id):
|
||||
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
|
||||
if not history.exists() or history.decision != 'pending':
|
||||
raise UserError(_("Action not found or already decided."))
|
||||
|
||||
params = json.loads(history.tool_params or '{}')
|
||||
result = self._execute_tool(history.tool_name, params, history.session_id.id)
|
||||
|
||||
history.write({
|
||||
'decision': 'approved',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
'tool_result': json.dumps(result) if not isinstance(result, str) else result,
|
||||
})
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=True)
|
||||
|
||||
return result
|
||||
|
||||
def _check_rule_proposal(self, tool_name, params, session):
|
||||
"""Detect repeated patterns and propose new rules when 3+ identical matches."""
|
||||
recent = self.env['fusion.accounting.match.history'].search([
|
||||
('tool_name', '=', tool_name),
|
||||
('decision', 'in', ('approved', 'auto')),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=20, order='proposed_at desc')
|
||||
|
||||
if len(recent) < 3:
|
||||
return
|
||||
|
||||
from collections import Counter
|
||||
param_keys = []
|
||||
for h in recent:
|
||||
try:
|
||||
p = json.loads(h.tool_params or '{}')
|
||||
key_parts = []
|
||||
for k in sorted(p.keys()):
|
||||
if k not in ('limit', 'date_from', 'date_to'):
|
||||
key_parts.append(f'{k}={json.dumps(p[k], sort_keys=True)}')
|
||||
if key_parts:
|
||||
param_keys.append('|'.join(key_parts))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
counts = Counter(param_keys)
|
||||
for pattern, count in counts.most_common(3):
|
||||
if count < 3:
|
||||
break
|
||||
existing = self.env['fusion.accounting.rule'].search([
|
||||
('match_logic', 'ilike', pattern[:50]),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
self.env['fusion.accounting.rule'].create({
|
||||
'name': f'AI Pattern: {tool_name} ({pattern[:40]})',
|
||||
'rule_type': 'match',
|
||||
'description': f'Automatically detected pattern from {count} approved uses of {tool_name}.',
|
||||
'match_logic': f'When using {tool_name} with parameters matching: {pattern}',
|
||||
'created_by': 'ai',
|
||||
'approval_tier': 'needs_approval',
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
_logger.info("AI proposed rule for pattern: %s (%d matches)", tool_name, count)
|
||||
|
||||
def reject_action(self, match_history_id, reason=''):
|
||||
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
|
||||
if not history.exists() or history.decision != 'pending':
|
||||
raise UserError(_("Action not found or already decided."))
|
||||
|
||||
history.write({
|
||||
'decision': 'rejected',
|
||||
'decided_at': fields.Datetime.now(),
|
||||
'decided_by': self.env.user.id,
|
||||
'rejection_reason': reason,
|
||||
})
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=False)
|
||||
|
||||
return {'status': 'rejected', 'reason': reason}
|
||||
@@ -1,109 +0,0 @@
|
||||
DOMAIN_PROMPTS = {
|
||||
'bank_reconciliation': """
|
||||
BANK RECONCILIATION CONTEXT:
|
||||
You are helping with bank statement reconciliation. Key concepts:
|
||||
- Bank statement lines (account.bank.statement.line) represent transactions from the bank feed.
|
||||
- Each line needs to be matched to one or more journal items (account.move.line).
|
||||
- Matching is done via set_line_bank_statement_line(move_line_ids).
|
||||
- Fee differences (e.g., Elavon card processing fees) should be allocated to the fee account.
|
||||
- Weekend batches may combine multiple days of card payments.
|
||||
- Always verify amounts before proposing a match.
|
||||
""",
|
||||
|
||||
'hst_management': """
|
||||
HST/GST MANAGEMENT CONTEXT:
|
||||
You are helping with Canadian HST/GST tax management.
|
||||
- HST Collected is tracked on account 2005 (credit balance = liability).
|
||||
- Input Tax Credits (ITCs) are on account 2006 (debit balance = asset).
|
||||
- Net HST = Collected - ITCs. Positive means owing to CRA.
|
||||
- Quarterly filing periods. Check for missing tax on invoices/bills.
|
||||
- All vendor bills should have ITCs unless explicitly exempt.
|
||||
""",
|
||||
|
||||
'accounts_receivable': """
|
||||
ACCOUNTS RECEIVABLE CONTEXT:
|
||||
- AR aging: current, 1-30, 31-60, 61-90, 90+ days overdue.
|
||||
- Follow-up actions escalate by aging bucket.
|
||||
- Payments should be matched to specific invoices.
|
||||
- Unmatched payments sit on the Outstanding Receipts account (1122).
|
||||
""",
|
||||
|
||||
'accounts_payable': """
|
||||
ACCOUNTS PAYABLE CONTEXT:
|
||||
- AP aging mirrors AR: current through 90+ days.
|
||||
- Watch for duplicate bills (same vendor + amount + date).
|
||||
- Bills should match purchase orders when applicable.
|
||||
- Tax on bills should match the vendor's fiscal position.
|
||||
""",
|
||||
|
||||
'journal_review': """
|
||||
JOURNAL REVIEW CONTEXT:
|
||||
- Check for wrong-direction balances (e.g., expense account with credit balance).
|
||||
- Detect duplicate entries (same partner + amount + date + journal).
|
||||
- Flag entries on unlikely accounts (revenue on a tax account, etc.).
|
||||
- Sequence gaps may indicate deleted entries.
|
||||
- Draft entries older than 30 days should be reviewed.
|
||||
""",
|
||||
|
||||
'month_end': """
|
||||
MONTH-END CLOSE CONTEXT:
|
||||
- Aggregate all domain checks into a close checklist.
|
||||
- Verify all bank reconciliations are current.
|
||||
- Check accrual account balances (vacation, sick leave, etc.).
|
||||
- Verify no entries exist after lock date.
|
||||
- Run hash integrity check.
|
||||
- Produce period trial balance summary.
|
||||
""",
|
||||
|
||||
'payroll_verification': """
|
||||
PAYROLL VERIFICATION CONTEXT:
|
||||
- Cross-reference payroll journal entries to bank statement cheques.
|
||||
- Verify CPP, EI, and income tax deductions against CRA rate tables.
|
||||
- Check CRA remittance account balance vs payments made.
|
||||
""",
|
||||
|
||||
'inventory': """
|
||||
INVENTORY & COGS CONTEXT:
|
||||
- Stock In Hand tracked on account 1069.
|
||||
- Price differences on account 5010 (PO price vs bill price).
|
||||
- COGS ratio by product category helps spot anomalies.
|
||||
- Large inventory adjustments need review.
|
||||
""",
|
||||
|
||||
'adp': """
|
||||
ADP RECONCILIATION CONTEXT:
|
||||
- ADP Receivable tracked on account 1101.
|
||||
- ADP invoices have customer portion + ADP portion = total.
|
||||
- Government deposits should match ADP invoices.
|
||||
""",
|
||||
|
||||
'reporting': """
|
||||
FINANCIAL REPORTING CONTEXT:
|
||||
- Reports use Odoo's account.report engine.
|
||||
- Available: P&L, Balance Sheet, Trial Balance, Cash Flow.
|
||||
- Period comparison available for trend analysis.
|
||||
- Export to PDF or XLSX for external distribution.
|
||||
""",
|
||||
|
||||
'audit': """
|
||||
AUDIT & INTEGRITY CONTEXT:
|
||||
- Run comprehensive checks on posted entries.
|
||||
- Verify hash chain integrity on journals.
|
||||
- Check sequence continuity.
|
||||
- Flag entries with chatter messages for review tracking.
|
||||
- Audit status per account: todo / reviewed / supervised / anomaly.
|
||||
""",
|
||||
|
||||
'payroll_management': """
|
||||
PAYROLL MANAGEMENT CONTEXT:
|
||||
- Parse pasted payroll summaries from QBO or fusion_payroll.
|
||||
- Create payroll journal entries with proper debit/credit lines.
|
||||
- Match payroll cheques to bank statement lines.
|
||||
- Calculate CRA obligations (CPP employer + employee, EI, income tax).
|
||||
- Prepare CRA remittance payment entries.
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def get_domain_prompt(domain):
|
||||
return DOMAIN_PROMPTS.get(domain, '')
|
||||
@@ -1,98 +0,0 @@
|
||||
import json
|
||||
|
||||
|
||||
def build_system_prompt(rules, history, context=None):
|
||||
parts = [
|
||||
CORE_SYSTEM_PROMPT,
|
||||
_build_rules_section(rules),
|
||||
_build_history_section(history),
|
||||
]
|
||||
if context:
|
||||
parts.append(_build_context_section(context))
|
||||
return '\n\n'.join(p for p in parts if p)
|
||||
|
||||
|
||||
CORE_SYSTEM_PROMPT = """You are Fusion AI, an expert accounting co-pilot embedded in Odoo 19.
|
||||
You assist with bank reconciliation, HST/GST management, AR/AP analysis, journal review,
|
||||
month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing.
|
||||
|
||||
BEHAVIOUR:
|
||||
- Use tools to query and act on Odoo data. Never invent financial figures.
|
||||
- For Tier 1 tools: execute immediately and report results.
|
||||
- For Tier 2 tools: execute and log. Inform the user what was done.
|
||||
- For Tier 3 tools: propose the action with clear reasoning. The user must approve.
|
||||
- When proposing a Tier 3 action, explain: what you want to do, why, the amounts involved, and your confidence level.
|
||||
- Apply Fusion Rules (below) before general reasoning.
|
||||
- Reference match history for patterns the user has approved/rejected before.
|
||||
- Use Canadian English. Format monetary amounts with $ and two decimals.
|
||||
- When you encounter ambiguity, ask clarifying questions rather than guessing.
|
||||
|
||||
RESPONSE FORMATTING:
|
||||
- Use rich Markdown formatting in your responses. The chat renders Markdown as HTML.
|
||||
- Use **bold** for account names, amounts, and key terms.
|
||||
- Use ## and ### headers to organize sections in longer responses.
|
||||
- Use Markdown tables for tabular data (| col1 | col2 | format).
|
||||
- Use bullet lists (- item) for findings, issues, and action items.
|
||||
- Use numbered lists (1. item) for sequential steps or ranked items.
|
||||
- Use `code` for account codes, reference numbers, and technical IDs.
|
||||
- Use --- horizontal rules to separate sections in long reports.
|
||||
|
||||
LINKING TO ODOO RECORDS:
|
||||
- When referencing specific records, include clickable Odoo links.
|
||||
- Journal entries: [INV/2026/00123](/odoo/accounting/123) where 123 is the move ID.
|
||||
- Partners: [Customer Name](/odoo/contacts/456) where 456 is the partner ID.
|
||||
- Accounts: reference by code in bold, e.g. **1001 - Cash**.
|
||||
- Bank statement lines: mention the date, reference, and amount clearly.
|
||||
- When tool results include record IDs, always link them.
|
||||
|
||||
TOOL CALLING:
|
||||
- Call tools by name with the required parameters.
|
||||
- You may call multiple tools in sequence to gather data before proposing an action.
|
||||
- Do not exceed the maximum tool calls per turn.
|
||||
- When presenting tool results, format them richly with tables, bold amounts, and links.
|
||||
"""
|
||||
|
||||
|
||||
def _build_rules_section(rules):
|
||||
if not rules:
|
||||
return ''
|
||||
lines = ['ACTIVE FUSION RULES:']
|
||||
for rule in rules:
|
||||
priority = 'ADMIN' if rule.created_by == 'admin' else 'AI'
|
||||
tier = 'auto' if rule.approval_tier == 'auto' else 'needs-approval'
|
||||
lines.append(
|
||||
f'- [{priority}/{tier}] {rule.name} ({rule.rule_type}): '
|
||||
f'{rule.description or rule.match_logic or "No description"}'
|
||||
)
|
||||
if rule.match_logic:
|
||||
lines.append(f' Match logic: {rule.match_logic}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _build_history_section(history):
|
||||
if not history:
|
||||
return ''
|
||||
lines = ['RECENT MATCH HISTORY (learn from these patterns):']
|
||||
for h in history[:50]:
|
||||
status = h.decision
|
||||
reason = ''
|
||||
if h.rejection_reason:
|
||||
reason = f' (reason: {h.rejection_reason})'
|
||||
lines.append(
|
||||
f'- {h.tool_name}: {status}{reason} '
|
||||
f'[confidence={h.ai_confidence:.0%}]'
|
||||
)
|
||||
if h.ai_reasoning:
|
||||
lines.append(f' Reasoning: {h.ai_reasoning[:200]}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _build_context_section(context):
|
||||
if not context:
|
||||
return ''
|
||||
if isinstance(context, dict):
|
||||
parts = ['CURRENT CONTEXT:']
|
||||
for k, v in context.items():
|
||||
parts.append(f'- {k}: {v}')
|
||||
return '\n'.join(parts)
|
||||
return f'CURRENT CONTEXT: {context}'
|
||||
@@ -1,150 +0,0 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
from datetime import timedelta
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ap_aging(env, params):
|
||||
today = fields.Date.today()
|
||||
domain = [
|
||||
('account_id.account_type', '=', 'liability_payable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
amls = env['account.move.line'].search(domain)
|
||||
|
||||
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
|
||||
for aml in amls:
|
||||
amt = abs(aml.amount_residual)
|
||||
if not aml.date_maturity or aml.date_maturity >= today:
|
||||
buckets['current'] += amt
|
||||
else:
|
||||
days = (today - aml.date_maturity).days
|
||||
if days <= 30:
|
||||
buckets['1_30'] += amt
|
||||
elif days <= 60:
|
||||
buckets['31_60'] += amt
|
||||
elif days <= 90:
|
||||
buckets['61_90'] += amt
|
||||
else:
|
||||
buckets['90_plus'] += amt
|
||||
|
||||
return {'total': sum(buckets.values()), 'buckets': buckets, 'line_count': len(amls)}
|
||||
|
||||
|
||||
def find_duplicate_bills(env, params):
|
||||
window_days = int(params.get('window_days', 7))
|
||||
bills = env['account.move'].search([
|
||||
('move_type', 'in', ('in_invoice', 'in_refund')),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='partner_id, amount_total, date')
|
||||
|
||||
duplicates = []
|
||||
prev = None
|
||||
for bill in bills:
|
||||
if prev and (
|
||||
prev.partner_id == bill.partner_id
|
||||
and abs(prev.amount_total - bill.amount_total) < 0.01
|
||||
and abs((prev.date - bill.date).days) <= window_days
|
||||
):
|
||||
duplicates.append({
|
||||
'bill_1': {'id': prev.id, 'name': prev.name, 'date': str(prev.date)},
|
||||
'bill_2': {'id': bill.id, 'name': bill.name, 'date': str(bill.date)},
|
||||
'partner': bill.partner_id.name,
|
||||
'amount': bill.amount_total,
|
||||
})
|
||||
prev = bill
|
||||
|
||||
return {'count': len(duplicates), 'duplicates': duplicates[:20]}
|
||||
|
||||
|
||||
def match_bill_to_po(env, params):
|
||||
bill_id = int(params['bill_id'])
|
||||
bill = env['account.move'].browse(bill_id)
|
||||
if not bill.exists():
|
||||
return {'error': 'Bill not found'}
|
||||
matches = []
|
||||
for line in bill.invoice_line_ids:
|
||||
if line.purchase_line_id:
|
||||
matches.append({
|
||||
'bill_line': line.name or '',
|
||||
'po': line.purchase_line_id.order_id.name,
|
||||
'po_line': line.purchase_line_id.name,
|
||||
'po_qty': line.purchase_line_id.product_qty,
|
||||
'bill_qty': line.quantity,
|
||||
'match': abs(line.quantity - line.purchase_line_id.product_qty) < 0.01,
|
||||
})
|
||||
return {'bill': bill.name, 'matches': matches, 'unmatched_lines': len(bill.invoice_line_ids) - len(matches)}
|
||||
|
||||
|
||||
def get_unpaid_bills(env, params):
|
||||
domain = [
|
||||
('move_type', 'in', ('in_invoice', 'in_refund')),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if params.get('partner_id'):
|
||||
domain.append(('partner_id', '=', int(params['partner_id'])))
|
||||
bills = env['account.move'].search(domain, order='invoice_date_due asc', limit=int(params.get('limit', 50)))
|
||||
return {
|
||||
'count': len(bills),
|
||||
'total': sum(b.amount_residual for b in bills),
|
||||
'bills': [{
|
||||
'id': b.id, 'name': b.name,
|
||||
'partner': b.partner_id.name if b.partner_id else '',
|
||||
'amount_total': b.amount_total,
|
||||
'amount_residual': b.amount_residual,
|
||||
'date_due': str(b.invoice_date_due) if b.invoice_date_due else '',
|
||||
} for b in bills],
|
||||
}
|
||||
|
||||
|
||||
def verify_bill_taxes(env, params):
|
||||
bill_id = int(params['bill_id'])
|
||||
bill = env['account.move'].browse(bill_id)
|
||||
if not bill.exists():
|
||||
return {'error': 'Bill not found'}
|
||||
issues = []
|
||||
for line in bill.invoice_line_ids:
|
||||
if line.product_id and not line.tax_ids:
|
||||
issues.append({
|
||||
'line': line.name or line.product_id.name,
|
||||
'issue': 'No tax applied to product line',
|
||||
})
|
||||
return {'bill': bill.name, 'issues': issues, 'clean': len(issues) == 0}
|
||||
|
||||
|
||||
def get_payment_schedule(env, params):
|
||||
days_ahead = int(params.get('days_ahead', 30))
|
||||
cutoff = fields.Date.today() + timedelta(days=days_ahead)
|
||||
bills = env['account.move'].search([
|
||||
('move_type', '=', 'in_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('invoice_date_due', '<=', cutoff),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='invoice_date_due asc')
|
||||
return {
|
||||
'period': f'Next {days_ahead} days',
|
||||
'total': sum(b.amount_residual for b in bills),
|
||||
'bills': [{
|
||||
'id': b.id, 'name': b.name,
|
||||
'partner': b.partner_id.name if b.partner_id else '',
|
||||
'amount_residual': b.amount_residual,
|
||||
'date_due': str(b.invoice_date_due) if b.invoice_date_due else '',
|
||||
} for b in bills[:50]],
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_ap_aging': get_ap_aging,
|
||||
'find_duplicate_bills': find_duplicate_bills,
|
||||
'match_bill_to_po': match_bill_to_po,
|
||||
'get_unpaid_bills': get_unpaid_bills,
|
||||
'verify_bill_taxes': verify_bill_taxes,
|
||||
'get_payment_schedule': get_payment_schedule,
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ar_aging(env, params):
|
||||
today = fields.Date.today()
|
||||
domain = [
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
amls = env['account.move.line'].search(domain)
|
||||
|
||||
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
|
||||
for aml in amls:
|
||||
if not aml.date_maturity or aml.date_maturity >= today:
|
||||
buckets['current'] += aml.amount_residual
|
||||
else:
|
||||
days = (today - aml.date_maturity).days
|
||||
if days <= 30:
|
||||
buckets['1_30'] += aml.amount_residual
|
||||
elif days <= 60:
|
||||
buckets['31_60'] += aml.amount_residual
|
||||
elif days <= 90:
|
||||
buckets['61_90'] += aml.amount_residual
|
||||
else:
|
||||
buckets['90_plus'] += aml.amount_residual
|
||||
|
||||
return {
|
||||
'total': sum(buckets.values()),
|
||||
'buckets': buckets,
|
||||
'line_count': len(amls),
|
||||
}
|
||||
|
||||
|
||||
def get_overdue_invoices(env, params):
|
||||
today = fields.Date.today()
|
||||
days_overdue = int(params.get('min_days_overdue', 1))
|
||||
from datetime import timedelta
|
||||
cutoff = today - timedelta(days=days_overdue)
|
||||
invoices = env['account.move'].search([
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('invoice_date_due', '<', cutoff),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='invoice_date_due asc', limit=int(params.get('limit', 50)))
|
||||
return {
|
||||
'count': len(invoices),
|
||||
'invoices': [{
|
||||
'id': inv.id,
|
||||
'name': inv.name,
|
||||
'partner': inv.partner_id.name if inv.partner_id else '',
|
||||
'email': inv.partner_id.email or '' if inv.partner_id else '',
|
||||
'phone': inv.partner_id.phone or '' if inv.partner_id else '',
|
||||
'amount_total': inv.amount_total,
|
||||
'amount_residual': inv.amount_residual,
|
||||
'date_due': str(inv.invoice_date_due),
|
||||
'days_overdue': (today - inv.invoice_date_due).days,
|
||||
} for inv in invoices],
|
||||
}
|
||||
|
||||
|
||||
def get_partner_balance(env, params):
|
||||
partner_id = int(params['partner_id'])
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': 'Partner not found'}
|
||||
amls = env['account.move.line'].search([
|
||||
('partner_id', '=', partner_id),
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
return {
|
||||
'partner': partner.name,
|
||||
'balance': sum(aml.amount_residual for aml in amls),
|
||||
'open_items': [{
|
||||
'id': aml.id,
|
||||
'ref': aml.ref or aml.move_id.name,
|
||||
'date': str(aml.date),
|
||||
'amount_residual': aml.amount_residual,
|
||||
'date_maturity': str(aml.date_maturity) if aml.date_maturity else '',
|
||||
} for aml in amls],
|
||||
}
|
||||
|
||||
|
||||
def send_followup(env, params):
|
||||
partner_id = int(params['partner_id'])
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': 'Partner not found'}
|
||||
options = {
|
||||
'partner_id': partner_id,
|
||||
'email': params.get('send_email', False),
|
||||
'print': params.get('print_letter', False),
|
||||
'sms': False,
|
||||
}
|
||||
if params.get('email_subject'):
|
||||
options['email_subject'] = params['email_subject']
|
||||
if params.get('body'):
|
||||
options['body'] = params['body']
|
||||
result = partner.execute_followup(options)
|
||||
return {'status': 'sent', 'partner': partner.name, 'result': str(result) if result else 'done'}
|
||||
|
||||
|
||||
def get_followup_report(env, params):
|
||||
partner_id = int(params['partner_id'])
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': 'Partner not found'}
|
||||
try:
|
||||
report = env['account.followup.report']
|
||||
html = report._get_followup_report_html(partner)
|
||||
return {'partner': partner.name, 'html': html}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def reconcile_payment_to_invoice(env, params):
|
||||
move_line_ids = [int(x) for x in params['move_line_ids']]
|
||||
amls = env['account.move.line'].browse(move_line_ids)
|
||||
if len(amls) < 2:
|
||||
return {'error': 'Need at least 2 journal items to reconcile'}
|
||||
amls.reconcile()
|
||||
return {
|
||||
'status': 'reconciled',
|
||||
'move_line_ids': move_line_ids,
|
||||
}
|
||||
|
||||
|
||||
def get_unmatched_payments(env, params):
|
||||
domain = [
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('parent_state', '=', 'posted'),
|
||||
('reconciled', '=', False),
|
||||
('move_id.payment_id', '!=', False),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
amls = env['account.move.line'].search(domain, order='date desc')
|
||||
return {
|
||||
'count': len(amls),
|
||||
'payments': [{
|
||||
'id': aml.id,
|
||||
'date': str(aml.date),
|
||||
'ref': aml.ref or aml.move_id.name,
|
||||
'partner': aml.partner_id.name if aml.partner_id else '',
|
||||
'amount': abs(aml.amount_residual),
|
||||
} for aml in amls[:50]],
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_ar_aging': get_ar_aging,
|
||||
'get_overdue_invoices': get_overdue_invoices,
|
||||
'get_partner_balance': get_partner_balance,
|
||||
'send_followup': send_followup,
|
||||
'get_followup_report': get_followup_report,
|
||||
'reconcile_payment_to_invoice': reconcile_payment_to_invoice,
|
||||
'get_unmatched_payments': get_unmatched_payments,
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import logging
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_adp_receivable_aging(env, params):
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '1101%'),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
today = fields.Date.today()
|
||||
amls = env['account.move.line'].search([
|
||||
('account_id', 'in', accounts.ids),
|
||||
('reconciled', '=', False),
|
||||
('parent_state', '=', 'posted'),
|
||||
])
|
||||
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
|
||||
for aml in amls:
|
||||
amt = abs(aml.amount_residual)
|
||||
if not aml.date_maturity or aml.date_maturity >= today:
|
||||
buckets['current'] += amt
|
||||
else:
|
||||
days = (today - aml.date_maturity).days
|
||||
if days <= 30:
|
||||
buckets['1_30'] += amt
|
||||
elif days <= 60:
|
||||
buckets['31_60'] += amt
|
||||
elif days <= 90:
|
||||
buckets['61_90'] += amt
|
||||
else:
|
||||
buckets['90_plus'] += amt
|
||||
return {'total': sum(buckets.values()), 'buckets': buckets}
|
||||
|
||||
|
||||
def match_adp_payment_to_invoice(env, params):
|
||||
move_line_ids = [int(x) for x in params['move_line_ids']]
|
||||
amls = env['account.move.line'].browse(move_line_ids).exists()
|
||||
if len(amls) < 2:
|
||||
return {'error': 'Need at least 2 existing journal items to reconcile'}
|
||||
amls.reconcile()
|
||||
return {'status': 'matched', 'move_line_ids': amls.ids}
|
||||
|
||||
|
||||
def verify_adp_split(env, params):
|
||||
invoice_id = int(params['invoice_id'])
|
||||
invoice = env['account.move'].browse(invoice_id)
|
||||
if not invoice.exists():
|
||||
return {'error': 'Invoice not found'}
|
||||
lines = invoice.invoice_line_ids
|
||||
total = invoice.amount_untaxed
|
||||
return {
|
||||
'invoice': invoice.name,
|
||||
'total_untaxed': total,
|
||||
'total_with_tax': invoice.amount_total,
|
||||
'lines': [{'name': l.name, 'subtotal': l.price_subtotal, 'total': l.price_total} for l in lines],
|
||||
'balanced': abs(sum(l.price_subtotal for l in lines) - total) < 0.01,
|
||||
}
|
||||
|
||||
|
||||
def find_adp_without_payment(env, params):
|
||||
adp_partner = env['res.partner'].search([('name', 'ilike', 'ADP')], limit=1)
|
||||
if not adp_partner:
|
||||
return {'status': 'info', 'message': 'No ADP partner found in the system.'}
|
||||
invoices = env['account.move'].search([
|
||||
('partner_id', '=', adp_partner.id),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
])
|
||||
return {
|
||||
'count': len(invoices),
|
||||
'invoices': [{
|
||||
'id': inv.id, 'name': inv.name,
|
||||
'amount': inv.amount_residual, 'date': str(inv.date),
|
||||
} for inv in invoices[:20]],
|
||||
}
|
||||
|
||||
|
||||
def get_adp_summary(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', '1101%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
domain = [
|
||||
('account_id', 'in', accounts.ids),
|
||||
('parent_state', '=', 'posted'),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
lines = env['account.move.line'].search(domain)
|
||||
total_debit = sum(l.debit for l in lines)
|
||||
total_credit = sum(l.credit for l in lines)
|
||||
return {
|
||||
'period': f'{date_from or "all"} to {date_to or "now"}',
|
||||
'billed': total_debit,
|
||||
'collected': total_credit,
|
||||
'outstanding': total_debit - total_credit,
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_adp_receivable_aging': get_adp_receivable_aging,
|
||||
'match_adp_payment_to_invoice': match_adp_payment_to_invoice,
|
||||
'verify_adp_split': verify_adp_split,
|
||||
'find_adp_without_payment': find_adp_without_payment,
|
||||
'get_adp_summary': get_adp_summary,
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_unreconciled_bank_lines(env, params):
|
||||
domain = [('is_reconciled', '=', False), ('company_id', '=', env.company.id)]
|
||||
if params.get('journal_id'):
|
||||
domain.append(('journal_id', '=', int(params['journal_id'])))
|
||||
if params.get('date_from'):
|
||||
domain.append(('date', '>=', params['date_from']))
|
||||
if params.get('date_to'):
|
||||
domain.append(('date', '<=', params['date_to']))
|
||||
if params.get('min_amount'):
|
||||
domain.append(('amount', '>=', float(params['min_amount'])))
|
||||
limit = int(params.get('limit', 50))
|
||||
lines = env['account.bank.statement.line'].search(domain, limit=limit, order='date desc')
|
||||
return {
|
||||
'count': len(lines),
|
||||
'total_amount': sum(abs(l.amount) for l in lines),
|
||||
'lines': [{
|
||||
'id': l.id,
|
||||
'date': str(l.date),
|
||||
'payment_ref': l.payment_ref or '',
|
||||
'partner_name': l.partner_name or (l.partner_id.name if l.partner_id else ''),
|
||||
'amount': l.amount,
|
||||
'journal': l.journal_id.name,
|
||||
} for l in lines],
|
||||
}
|
||||
|
||||
|
||||
def get_unreconciled_receipts(env, params):
|
||||
account_code = params.get('account_code', '1122')
|
||||
accounts = env['account.account'].search([
|
||||
('code', '=like', f'{account_code}%'),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
domain = [
|
||||
('account_id', 'in', accounts.ids),
|
||||
('reconciled', '=', False),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
lines = env['account.move.line'].search(domain, order='date desc')
|
||||
return {
|
||||
'count': len(lines),
|
||||
'total_amount': sum(abs(l.amount_residual) for l in lines),
|
||||
'lines': [{
|
||||
'id': l.id,
|
||||
'date': str(l.date),
|
||||
'ref': l.ref or l.move_id.name,
|
||||
'partner': l.partner_id.name if l.partner_id else '',
|
||||
'amount_residual': l.amount_residual,
|
||||
} for l in lines],
|
||||
}
|
||||
|
||||
|
||||
def match_bank_line_to_payments(env, params):
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
move_line_ids = [int(x) for x in params['move_line_ids']]
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not st_line.exists():
|
||||
return {'error': 'Statement line not found'}
|
||||
st_line.set_line_bank_statement_line(move_line_ids)
|
||||
return {
|
||||
'status': 'matched',
|
||||
'statement_line_id': st_line_id,
|
||||
'matched_move_lines': move_line_ids,
|
||||
'is_reconciled': st_line.is_reconciled,
|
||||
}
|
||||
|
||||
|
||||
def auto_reconcile_bank_lines(env, params):
|
||||
company_id = params.get('company_id', env.company.id)
|
||||
lines = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', int(company_id)),
|
||||
])
|
||||
before_count = len(lines)
|
||||
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
|
||||
still_unreconciled = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', int(company_id)),
|
||||
])
|
||||
reconciled_count = before_count - len(still_unreconciled)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'lines_before': before_count,
|
||||
'lines_reconciled': reconciled_count,
|
||||
'lines_remaining': len(still_unreconciled),
|
||||
}
|
||||
|
||||
|
||||
def apply_reconcile_model(env, params):
|
||||
model_id = int(params['model_id'])
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
reco_model = env['account.reconcile.model'].browse(model_id)
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not reco_model.exists() or not st_line.exists():
|
||||
return {'error': 'Model or statement line not found'}
|
||||
_liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines()
|
||||
residual = sum(l.amount_residual for l in suspense_lines) if suspense_lines else st_line.amount
|
||||
write_off_vals = reco_model._get_write_off_move_lines_dict(st_line, residual)
|
||||
if write_off_vals:
|
||||
line_ids_create_command = [(0, 0, vals) for vals in write_off_vals]
|
||||
st_line.move_id.write({'line_ids': line_ids_create_command})
|
||||
return {
|
||||
'status': 'applied',
|
||||
'model': reco_model.name,
|
||||
'write_off_lines': len(write_off_vals) if write_off_vals else 0,
|
||||
}
|
||||
|
||||
|
||||
def unmatch_bank_line(env, params):
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not st_line.exists():
|
||||
return {'error': 'Statement line not found'}
|
||||
st_line.action_unreconcile_entry()
|
||||
return {'status': 'unmatched', 'statement_line_id': st_line_id}
|
||||
|
||||
|
||||
def get_reconcile_suggestions(env, params):
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not st_line.exists():
|
||||
return {'error': 'Statement line not found'}
|
||||
models = env['account.reconcile.model'].search([
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
return {
|
||||
'models': [{
|
||||
'id': m.id,
|
||||
'name': m.name,
|
||||
'trigger': m.trigger if hasattr(m, 'trigger') else 'manual',
|
||||
} for m in models],
|
||||
}
|
||||
|
||||
|
||||
def sum_payments_by_date(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
if not date_from or not date_to:
|
||||
return {'error': 'date_from and date_to are required'}
|
||||
journal_ids = params.get('journal_ids', [])
|
||||
domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
]
|
||||
if journal_ids:
|
||||
domain.append(('journal_id', 'in', [int(j) for j in journal_ids]))
|
||||
lines = env['account.move.line'].search(domain)
|
||||
total_debit = sum(l.debit for l in lines)
|
||||
total_credit = sum(l.credit for l in lines)
|
||||
return {
|
||||
'date_from': date_from,
|
||||
'date_to': date_to,
|
||||
'total_debit': total_debit,
|
||||
'total_credit': total_credit,
|
||||
'net': total_debit - total_credit,
|
||||
'line_count': len(lines),
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
|
||||
'get_unreconciled_receipts': get_unreconciled_receipts,
|
||||
'match_bank_line_to_payments': match_bank_line_to_payments,
|
||||
'auto_reconcile_bank_lines': auto_reconcile_bank_lines,
|
||||
'apply_reconcile_model': apply_reconcile_model,
|
||||
'unmatch_bank_line': unmatch_bank_line,
|
||||
'get_reconcile_suggestions': get_reconcile_suggestions,
|
||||
'sum_payments_by_date': sum_payments_by_date,
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calculate_hst_balance(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
base_domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
base_domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
base_domain.append(('date', '<=', date_to))
|
||||
|
||||
collected_accounts = env['account.account'].search([
|
||||
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
|
||||
collected_lines = env['account.move.line'].search(
|
||||
base_domain + [('account_id', 'in', collected_accounts.ids)]
|
||||
)
|
||||
itc_lines = env['account.move.line'].search(
|
||||
base_domain + [('account_id', 'in', itc_accounts.ids)]
|
||||
)
|
||||
|
||||
hst_collected = abs(sum(l.balance for l in collected_lines))
|
||||
itcs = abs(sum(l.balance for l in itc_lines))
|
||||
|
||||
return {
|
||||
'hst_collected': hst_collected,
|
||||
'input_tax_credits': itcs,
|
||||
'net_hst': hst_collected - itcs,
|
||||
'status': 'owing' if (hst_collected - itcs) > 0 else 'refund',
|
||||
'period': f'{date_from or "all"} to {date_to or "now"}',
|
||||
}
|
||||
|
||||
|
||||
def get_tax_report(env, params):
|
||||
report_ref = params.get('report_ref', 'account.generic_tax_report')
|
||||
try:
|
||||
report = env.ref(report_ref)
|
||||
except Exception:
|
||||
return {'error': f'Report not found: {report_ref}'}
|
||||
options = report.get_options({
|
||||
'date': {
|
||||
'date_from': params.get('date_from', ''),
|
||||
'date_to': params.get('date_to', ''),
|
||||
}
|
||||
})
|
||||
lines = report._get_lines(options)
|
||||
return {
|
||||
'report_name': report.name,
|
||||
'lines': [{
|
||||
'name': l.get('name', ''),
|
||||
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
|
||||
} for l in lines[:50]],
|
||||
}
|
||||
|
||||
|
||||
def find_missing_tax_invoices(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
domain = [
|
||||
('move_type', 'in', ('out_invoice', 'out_refund')),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
|
||||
invoices = env['account.move'].search(domain)
|
||||
missing = invoices.filtered(
|
||||
lambda inv: not any(line.tax_ids for line in inv.invoice_line_ids)
|
||||
)
|
||||
return {
|
||||
'total_invoices': len(invoices),
|
||||
'missing_tax_count': len(missing),
|
||||
'invoices': [{
|
||||
'id': inv.id,
|
||||
'name': inv.name,
|
||||
'partner': inv.partner_id.name if inv.partner_id else '',
|
||||
'amount_total': inv.amount_total,
|
||||
'date': str(inv.date),
|
||||
} for inv in missing[:30]],
|
||||
}
|
||||
|
||||
|
||||
def find_missing_itc_bills(env, params):
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
domain = [
|
||||
('move_type', 'in', ('in_invoice', 'in_refund')),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
|
||||
bills = env['account.move'].search(domain)
|
||||
missing = bills.filtered(
|
||||
lambda b: not any(line.tax_ids for line in b.invoice_line_ids)
|
||||
)
|
||||
return {
|
||||
'total_bills': len(bills),
|
||||
'missing_itc_count': len(missing),
|
||||
'bills': [{
|
||||
'id': b.id,
|
||||
'name': b.name,
|
||||
'partner': b.partner_id.name if b.partner_id else '',
|
||||
'amount_total': b.amount_total,
|
||||
'date': str(b.date),
|
||||
} for b in missing[:30]],
|
||||
}
|
||||
|
||||
|
||||
def get_tax_return_status(env, params):
|
||||
returns = env['account.return'].search([
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date_start desc', limit=10)
|
||||
return {
|
||||
'returns': [{
|
||||
'id': r.id,
|
||||
'name': r.display_name,
|
||||
'date_start': str(r.date_start) if hasattr(r, 'date_start') else '',
|
||||
'date_end': str(r.date_end) if hasattr(r, 'date_end') else '',
|
||||
'state': r.state if hasattr(r, 'state') else '',
|
||||
} for r in returns],
|
||||
}
|
||||
|
||||
|
||||
def generate_tax_return(env, params):
|
||||
try:
|
||||
env['account.return']._generate_or_refresh_all_returns(
|
||||
company=env.company
|
||||
)
|
||||
return {'status': 'generated', 'message': 'Tax returns refreshed successfully.'}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def validate_tax_return(env, params):
|
||||
return_id = int(params['return_id'])
|
||||
tax_return = env['account.return'].browse(return_id)
|
||||
if not tax_return.exists():
|
||||
return {'error': 'Tax return not found'}
|
||||
try:
|
||||
tax_return.action_validate()
|
||||
return {'status': 'validated', 'return_id': return_id}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'calculate_hst_balance': calculate_hst_balance,
|
||||
'get_tax_report': get_tax_report,
|
||||
'find_missing_tax_invoices': find_missing_tax_invoices,
|
||||
'find_missing_itc_bills': find_missing_itc_bills,
|
||||
'get_tax_return_status': get_tax_return_status,
|
||||
'generate_tax_return': generate_tax_return,
|
||||
'validate_tax_return': validate_tax_return,
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import logging
|
||||
import base64
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_report(env, ref_id):
|
||||
try:
|
||||
return env.ref(ref_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _run_report(env, report_ref, params):
|
||||
report = _get_report(env, report_ref)
|
||||
if not report:
|
||||
return {'error': f'Report {report_ref} not found'}
|
||||
date_opts = {}
|
||||
if params.get('date_from'):
|
||||
date_opts['date_from'] = params['date_from']
|
||||
if params.get('date_to'):
|
||||
date_opts['date_to'] = params['date_to']
|
||||
options = report.get_options({'date': date_opts} if date_opts else {})
|
||||
lines = report._get_lines(options)
|
||||
return {
|
||||
'report_name': report.name,
|
||||
'lines': [{
|
||||
'name': l.get('name', ''),
|
||||
'level': l.get('level', 0),
|
||||
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
|
||||
} for l in lines[:100]],
|
||||
}
|
||||
|
||||
|
||||
def get_profit_loss(env, params):
|
||||
return _run_report(env, 'account_reports.profit_and_loss', params)
|
||||
|
||||
|
||||
def get_balance_sheet(env, params):
|
||||
return _run_report(env, 'account_reports.balance_sheet', params)
|
||||
|
||||
|
||||
def get_trial_balance(env, params):
|
||||
return _run_report(env, 'account_reports.trial_balance_report', params)
|
||||
|
||||
|
||||
def get_cash_flow(env, params):
|
||||
return _run_report(env, 'account_reports.cash_flow_statement', params)
|
||||
|
||||
|
||||
def compare_periods(env, params):
|
||||
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
|
||||
report = _get_report(env, report_ref)
|
||||
if not report:
|
||||
return {'error': f'Report {report_ref} not found'}
|
||||
|
||||
period1 = _run_report(env, report_ref, {
|
||||
'date_from': params.get('period1_from'),
|
||||
'date_to': params.get('period1_to'),
|
||||
})
|
||||
period2 = _run_report(env, report_ref, {
|
||||
'date_from': params.get('period2_from'),
|
||||
'date_to': params.get('period2_to'),
|
||||
})
|
||||
return {'period_1': period1, 'period_2': period2}
|
||||
|
||||
|
||||
def answer_financial_question(env, params):
|
||||
question = params.get('question', '')
|
||||
sql_query = params.get('sql_query')
|
||||
if sql_query:
|
||||
return {'error': 'Direct SQL not permitted. Use report tools instead.'}
|
||||
return {'status': 'info', 'message': f'Use specific report tools to answer: {question}'}
|
||||
|
||||
|
||||
def export_report(env, params):
|
||||
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
|
||||
fmt = params.get('format', 'pdf')
|
||||
report = _get_report(env, report_ref)
|
||||
if not report:
|
||||
return {'error': f'Report {report_ref} not found'}
|
||||
date_opts = {}
|
||||
if params.get('date_from'):
|
||||
date_opts['date_from'] = params['date_from']
|
||||
if params.get('date_to'):
|
||||
date_opts['date_to'] = params['date_to']
|
||||
options = report.get_options({'date': date_opts} if date_opts else {})
|
||||
|
||||
try:
|
||||
if fmt == 'xlsx':
|
||||
result = report.dispatch_report_action(options, 'export_to_xlsx')
|
||||
else:
|
||||
result = report.dispatch_report_action(options, 'export_to_pdf')
|
||||
|
||||
if isinstance(result, dict) and result.get('file_content'):
|
||||
return {
|
||||
'file_name': result.get('file_name', f'report.{fmt}'),
|
||||
'file_type': result.get('file_type', fmt),
|
||||
'file_content_b64': base64.b64encode(result['file_content']).decode(),
|
||||
}
|
||||
return {
|
||||
'status': 'generated',
|
||||
'message': f'Report exported as {fmt}. Use the Odoo UI to download.',
|
||||
}
|
||||
except Exception as e:
|
||||
return {'error': f'Export failed: {str(e)}'}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_profit_loss': get_profit_loss,
|
||||
'get_balance_sheet': get_balance_sheet,
|
||||
'get_trial_balance': get_trial_balance,
|
||||
'get_cash_flow': get_cash_flow,
|
||||
'compare_periods': compare_periods,
|
||||
'answer_financial_question': answer_financial_question,
|
||||
'export_report': export_report,
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FusionApprovalCard extends Component {
|
||||
static template = "fusion_accounting.ApprovalCard";
|
||||
static props = ["approval", "onApprove", "onReject"];
|
||||
|
||||
get confidencePercent() {
|
||||
return Math.round((this.props.approval.confidence || 0) * 100);
|
||||
}
|
||||
|
||||
approve() {
|
||||
this.props.onApprove(this.props.approval.id);
|
||||
}
|
||||
|
||||
reject() {
|
||||
this.props.onReject(this.props.approval.id);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.ApprovalCard">
|
||||
<div class="fusion_approval_card card border-warning mb-2">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<strong t-esc="props.approval.tool_name"/>
|
||||
<span class="badge bg-warning text-dark">
|
||||
<t t-esc="confidencePercent"/>% conf
|
||||
</span>
|
||||
</div>
|
||||
<p class="small mb-1 text-muted" t-esc="props.approval.reasoning"/>
|
||||
<t t-if="props.approval.amount">
|
||||
<p class="small mb-1">
|
||||
Amount: <strong>$<t t-esc="(props.approval.amount || 0).toFixed(2)"/></strong>
|
||||
</p>
|
||||
</t>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success btn-sm flex-grow-1" t-on-click="approve">
|
||||
<i class="fa fa-check"/> Approve
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm flex-grow-1" t-on-click="reject">
|
||||
<i class="fa fa-times"/> Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,304 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { FusionApprovalCard } from "./approval_card";
|
||||
|
||||
function mdToHtml(text) {
|
||||
if (!text) return "";
|
||||
|
||||
// Split into lines for block-level processing
|
||||
const lines = text.split("\n");
|
||||
const output = [];
|
||||
let inTable = false;
|
||||
let tableHeader = false;
|
||||
let inList = false;
|
||||
let listType = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Close list if we're not in a list item anymore
|
||||
if (inList && !trimmed.match(/^[-*]\s/) && !trimmed.match(/^\d+\.\s/) && trimmed !== "") {
|
||||
output.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
inList = false;
|
||||
listType = null;
|
||||
}
|
||||
|
||||
// Table row detection (line has at least 2 pipes)
|
||||
const pipeCount = (trimmed.match(/\|/g) || []).length;
|
||||
if (pipeCount >= 2 && trimmed.includes("|")) {
|
||||
// Separator row (|---|---|)
|
||||
if (/^[\s|:\-]+$/.test(trimmed)) {
|
||||
tableHeader = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split cells
|
||||
let cells = trimmed.split("|").map(c => c.trim());
|
||||
// Remove empty first/last from leading/trailing pipes
|
||||
if (cells[0] === "") cells.shift();
|
||||
if (cells.length > 0 && cells[cells.length - 1] === "") cells.pop();
|
||||
|
||||
if (!inTable) {
|
||||
output.push('<div class="table-responsive my-2"><table class="table table-sm table-bordered align-middle">');
|
||||
inTable = true;
|
||||
// First row is header
|
||||
output.push("<thead><tr>");
|
||||
cells.forEach(c => output.push(`<th class="px-2 py-1 fw-semibold">${inlineFormat(c)}</th>`));
|
||||
output.push("</tr></thead><tbody>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Body row
|
||||
output.push("<tr>");
|
||||
cells.forEach(c => output.push(`<td class="px-2 py-1">${inlineFormat(c)}</td>`));
|
||||
output.push("</tr>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Close table if we were in one
|
||||
if (inTable) {
|
||||
output.push("</tbody></table></div>");
|
||||
inTable = false;
|
||||
tableHeader = false;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (trimmed === "") {
|
||||
output.push("");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headers
|
||||
const headerMatch = trimmed.match(/^(#{1,5})\s+(.+)$/);
|
||||
if (headerMatch) {
|
||||
const level = Math.min(headerMatch[1].length + 2, 6); // ## -> h4, ### -> h5
|
||||
output.push(`<h${level} class="mt-3 mb-1">${inlineFormat(headerMatch[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^[-*_]{3,}$/.test(trimmed)) {
|
||||
output.push('<hr class="my-2"/>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
const ulMatch = trimmed.match(/^[-*]\s+(.+)$/);
|
||||
if (ulMatch) {
|
||||
if (!inList || listType !== "ul") {
|
||||
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
output.push('<ul class="mb-1">');
|
||||
inList = true;
|
||||
listType = "ul";
|
||||
}
|
||||
output.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
|
||||
if (olMatch) {
|
||||
if (!inList || listType !== "ol") {
|
||||
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
output.push('<ol class="mb-1">');
|
||||
inList = true;
|
||||
listType = "ol";
|
||||
}
|
||||
output.push(`<li>${inlineFormat(olMatch[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
output.push(`<p class="mb-1">${inlineFormat(trimmed)}</p>`);
|
||||
}
|
||||
|
||||
// Close open elements
|
||||
if (inTable) output.push("</tbody></table></div>");
|
||||
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
|
||||
return output.join("\n");
|
||||
}
|
||||
|
||||
function inlineFormat(text) {
|
||||
if (!text) return "";
|
||||
return text
|
||||
// Escape HTML entities
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
// Bold + italic
|
||||
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// Links [text](url)
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
|
||||
// Odoo record links #model,id
|
||||
.replace(/#([\w.]+),(\d+)/g, '<a href="/odoo/$1/$2" class="badge text-bg-primary text-decoration-none">$1#$2</a>');
|
||||
}
|
||||
|
||||
|
||||
export class FusionChatPanel extends Component {
|
||||
static template = "fusion_accounting.ChatPanel";
|
||||
static components = { FusionApprovalCard };
|
||||
static props = ["sessionId?"];
|
||||
|
||||
setup() {
|
||||
this.inputRef = useRef("chatInput");
|
||||
this.messagesRef = useRef("messages");
|
||||
this.state = useState({
|
||||
messages: [],
|
||||
pendingApprovals: [],
|
||||
inputText: "",
|
||||
sending: false,
|
||||
loading: true,
|
||||
internalSessionId: null,
|
||||
sessionName: null,
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
await this.loadLatestSession();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
this._renderRichMessages();
|
||||
});
|
||||
|
||||
onPatched(() => {
|
||||
this._renderRichMessages();
|
||||
});
|
||||
}
|
||||
|
||||
_renderRichMessages() {
|
||||
const container = this.messagesRef.el;
|
||||
if (!container) return;
|
||||
const richDivs = container.querySelectorAll(".fusion_rich_slot[data-idx]");
|
||||
for (const div of richDivs) {
|
||||
const idx = parseInt(div.dataset.idx);
|
||||
const msg = this.state.messages[idx];
|
||||
if (msg && msg.role === "assistant" && msg.content) {
|
||||
const html = mdToHtml(msg.content);
|
||||
if (div.innerHTML !== html) {
|
||||
div.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get sessionId() {
|
||||
return this.state.internalSessionId || this.props.sessionId;
|
||||
}
|
||||
|
||||
async loadLatestSession() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const data = await rpc("/fusion_accounting/session/latest", {});
|
||||
if (data.session_id) {
|
||||
this.state.internalSessionId = data.session_id;
|
||||
this.state.messages = data.messages || [];
|
||||
this.state.sessionName = data.name;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load session:", e);
|
||||
}
|
||||
this.state.loading = false;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
async onNewChat() {
|
||||
if (this.sessionId) {
|
||||
try {
|
||||
await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
|
||||
} catch (e) { /* not critical */ }
|
||||
}
|
||||
const session = await rpc("/fusion_accounting/session/create", {});
|
||||
this.state.internalSessionId = session.session_id;
|
||||
this.state.sessionName = session.name;
|
||||
this.state.messages = [];
|
||||
this.state.pendingApprovals = [];
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const text = this.state.inputText.trim();
|
||||
if (!text || this.state.sending) return;
|
||||
|
||||
if (!this.sessionId) {
|
||||
const session = await rpc("/fusion_accounting/session/create", {});
|
||||
this.state.internalSessionId = session.session_id;
|
||||
this.state.sessionName = session.name;
|
||||
}
|
||||
|
||||
this.state.messages.push({ role: "user", content: text });
|
||||
this.state.inputText = "";
|
||||
this.state.sending = true;
|
||||
this.scrollToBottom();
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_accounting/chat", {
|
||||
session_id: this.sessionId,
|
||||
message: text,
|
||||
});
|
||||
if (result.text) {
|
||||
this.state.messages.push({ role: "assistant", content: result.text });
|
||||
}
|
||||
if (result.pending_approvals) {
|
||||
this.state.pendingApprovals = result.pending_approvals;
|
||||
}
|
||||
} catch (e) {
|
||||
this.state.messages.push({
|
||||
role: "assistant",
|
||||
content: `Error: ${e.message || "Something went wrong."}`,
|
||||
});
|
||||
}
|
||||
this.state.sending = false;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
onKeyDown(ev) {
|
||||
if (ev.key === "Enter" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
const el = this.messagesRef.el;
|
||||
if (el) {
|
||||
setTimeout(() => { el.scrollTop = el.scrollHeight; }, 100);
|
||||
}
|
||||
}
|
||||
|
||||
async onApprove(matchHistoryId) {
|
||||
await rpc("/fusion_accounting/approve", { match_history_id: matchHistoryId });
|
||||
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);
|
||||
this.state.messages.push({ role: "assistant", content: "Action approved and executed." });
|
||||
}
|
||||
|
||||
async onReject(matchHistoryId) {
|
||||
await rpc("/fusion_accounting/reject", { match_history_id: matchHistoryId, reason: "User rejected" });
|
||||
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);
|
||||
this.state.messages.push({ role: "assistant", content: "Action rejected." });
|
||||
}
|
||||
|
||||
async onApproveAll() {
|
||||
const ids = this.state.pendingApprovals.map(a => a.id);
|
||||
if (!ids.length) return;
|
||||
await rpc("/fusion_accounting/approve_all", { match_history_ids: ids });
|
||||
const count = this.state.pendingApprovals.length;
|
||||
this.state.pendingApprovals = [];
|
||||
this.state.messages.push({ role: "assistant", content: `${count} actions approved and executed.` });
|
||||
}
|
||||
|
||||
async onRejectAll() {
|
||||
const ids = this.state.pendingApprovals.map(a => a.id);
|
||||
if (!ids.length) return;
|
||||
await rpc("/fusion_accounting/reject_all", { match_history_ids: ids, reason: "Batch rejected" });
|
||||
const count = this.state.pendingApprovals.length;
|
||||
this.state.pendingApprovals = [];
|
||||
this.state.messages.push({ role: "assistant", content: `${count} actions rejected.` });
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.ChatPanel">
|
||||
<div class="fusion_chat_panel card h-100 d-flex flex-column">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||
<div>
|
||||
<h5 class="mb-0 d-inline"><i class="fa fa-comments-o me-2"/>Fusion AI</h5>
|
||||
<small class="text-muted ms-2" t-if="state.sessionName" t-esc="state.sessionName"/>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
|
||||
title="Start a new conversation">
|
||||
<i class="fa fa-plus me-1"/>New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="fusion_chat_messages flex-grow-1 overflow-auto p-3" t-ref="messages">
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<p class="mt-2">Loading conversation...</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="state.messages.length === 0">
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fa fa-robot fa-3x mb-3 d-block"/>
|
||||
<p>Ask me about your accounting data.<br/>
|
||||
I can help with bank reconciliation, tax analysis, AR/AP, auditing, and more.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
|
||||
<!-- User message -->
|
||||
<t t-if="msg.role === 'user'">
|
||||
<div class="fusion_chat_msg mb-2 p-2 rounded bg-primary-subtle ms-4">
|
||||
<small class="text-muted d-block mb-1">
|
||||
<i class="fa fa-user me-1"/>You
|
||||
</small>
|
||||
<span style="white-space: pre-wrap;" t-esc="msg.content"/>
|
||||
</div>
|
||||
</t>
|
||||
<!-- AI message — rich HTML rendered via onPatched -->
|
||||
<t t-else="">
|
||||
<div class="fusion_chat_msg fusion_ai_msg mb-3 p-3 rounded me-4">
|
||||
<small class="text-muted d-block mb-2">
|
||||
<i class="fa fa-robot me-1"/>Fusion AI
|
||||
</small>
|
||||
<div class="fusion_rich_content fusion_rich_slot"
|
||||
t-att-data-idx="msg_index"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="state.sending">
|
||||
<div class="fusion_ai_msg rounded p-3 me-4 mb-2">
|
||||
<small class="text-muted d-block mb-1">
|
||||
<i class="fa fa-robot me-1"/>Fusion AI
|
||||
</small>
|
||||
<i class="fa fa-spinner fa-spin me-1"/> Thinking...
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Pending Approvals -->
|
||||
<t t-if="state.pendingApprovals.length > 0">
|
||||
<div class="border-top p-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted">Pending Approvals (<t t-esc="state.pendingApprovals.length"/>):</small>
|
||||
<div class="d-flex gap-1" t-if="state.pendingApprovals.length > 1">
|
||||
<button class="btn btn-success btn-sm" t-on-click="onApproveAll">
|
||||
<i class="fa fa-check-double"/> Approve All
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" t-on-click="onRejectAll">
|
||||
Reject All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<t t-foreach="state.pendingApprovals" t-as="approval" t-key="approval.id">
|
||||
<FusionApprovalCard
|
||||
approval="approval"
|
||||
onApprove.bind="onApprove"
|
||||
onReject.bind="onReject"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="fusion_chat_input border-top p-2">
|
||||
<div class="input-group">
|
||||
<textarea
|
||||
t-ref="chatInput"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Ask Fusion AI..."
|
||||
rows="2"
|
||||
t-model="state.inputText"
|
||||
t-on-keydown="onKeyDown"/>
|
||||
<button class="btn btn-primary btn-sm" t-on-click="sendMessage"
|
||||
t-att-disabled="state.sending">
|
||||
<i class="fa fa-paper-plane"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,55 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.Dashboard">
|
||||
<div class="o_action fusion_accounting_dashboard">
|
||||
<div class="fusion_dashboard_header d-flex justify-content-between align-items-center p-3">
|
||||
<h2 class="mb-0">Fusion AI Dashboard</h2>
|
||||
<button class="btn btn-outline-primary btn-sm" t-on-click="loadDashboard">
|
||||
<i class="fa fa-refresh"/> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center p-5">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<p class="mt-2">Loading dashboard...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<!-- Health Cards -->
|
||||
<div class="fusion_health_cards d-flex flex-wrap gap-3 p-3">
|
||||
<t t-foreach="cards" t-as="card" t-key="card.domain">
|
||||
<FusionHealthCard
|
||||
title="card.title"
|
||||
metric="card.metric"
|
||||
subtext="card.subtext"
|
||||
status="card.status"
|
||||
domain="card.domain"
|
||||
onCardClick.bind="onCardClick"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Action Centre + Chat -->
|
||||
<div class="d-flex gap-3 p-3" style="min-height: 500px;">
|
||||
<!-- Action Centre -->
|
||||
<div class="flex-grow-1">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Needs Attention</h5>
|
||||
</div>
|
||||
<div class="card-body overflow-auto">
|
||||
<p class="text-muted">AI-prioritised items will appear here after the first audit scan.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel (720px = original 400 + 80%) -->
|
||||
<div style="width: 720px; min-width: 600px;">
|
||||
<FusionChatPanel sessionId="state.chatSessionId"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,22 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FusionHealthCard extends Component {
|
||||
static template = "fusion_accounting.HealthCard";
|
||||
static props = ["title", "metric", "subtext", "status", "domain", "onCardClick"];
|
||||
|
||||
get statusClass() {
|
||||
const map = {
|
||||
green: "bg-success-subtle border-success",
|
||||
yellow: "bg-warning-subtle border-warning",
|
||||
red: "bg-danger-subtle border-danger",
|
||||
blue: "bg-info-subtle border-info",
|
||||
};
|
||||
return map[this.props.status] || "bg-light";
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.props.onCardClick(this.props.domain);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.HealthCard">
|
||||
<div class="fusion_health_card card border-2 cursor-pointer"
|
||||
t-attf-class="{{statusClass}}"
|
||||
style="min-width: 180px; flex: 1;"
|
||||
t-on-click="onClick">
|
||||
<div class="card-body text-center p-3">
|
||||
<h6 class="card-title text-muted mb-1" t-esc="props.title"/>
|
||||
<h3 class="mb-1" t-esc="props.metric"/>
|
||||
<small class="text-muted" t-esc="props.subtext"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,72 +0,0 @@
|
||||
.fusion_chat_panel {
|
||||
.fusion_chat_messages {
|
||||
max-height: 500px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.fusion_chat_msg {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.fusion_ai_msg {
|
||||
background: var(--o-view-background-color);
|
||||
border: 1px solid var(--o-border-color);
|
||||
}
|
||||
|
||||
.fusion_rich_content {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
|
||||
h3, h4, h5 {
|
||||
font-weight: 600;
|
||||
color: var(--o-main-color-5, inherit);
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--o-action-color, var(--bs-link-color));
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
a.badge {
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: var(--o-border-color);
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.fusion_chat_input {
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fusion_approval_card {
|
||||
border-left: 3px solid var(--bs-warning);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.fusion_accounting_dashboard {
|
||||
.fusion_dashboard_header {
|
||||
border-bottom: 1px solid var(--o-border-color);
|
||||
background: var(--o-view-background-color);
|
||||
}
|
||||
|
||||
.fusion_health_cards {
|
||||
.fusion_health_card {
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(var(--bs-body-color-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
fusion_accounting/tools/README.md
Normal file
37
fusion_accounting/tools/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Fusion Accounting Tooling
|
||||
|
||||
## check_odoo_diff.sh
|
||||
|
||||
Diff a single Odoo Enterprise accounting module across two pinned snapshots
|
||||
in `RePackaged-Odoo/` and produce a categorized change report (markdown).
|
||||
|
||||
### Usage
|
||||
|
||||
tools/check_odoo_diff.sh <module> <from_version> <to_version> [<output_md>]
|
||||
|
||||
### Example
|
||||
|
||||
# When Odoo 20 ships, get a full report on what changed in account_accountant
|
||||
tools/check_odoo_diff.sh account_accountant v19 v20 > reports/v20_accountant.md
|
||||
|
||||
### Classification tags
|
||||
|
||||
- `[MIRROR]` — mechanical port required (view XML, OWL component, PDF template, wizard view)
|
||||
- `[ABSTRACT]` — verify our adapter still aligns; update if Odoo's public API surface changed
|
||||
- `[MANIFEST]` — manifest changes (deps, asset bundles, version, hooks)
|
||||
- `[TEST]` — Odoo's tests changed; check if our equivalents need updates
|
||||
- `[REVIEW]` — uncategorized; manual review needed
|
||||
|
||||
### Snapshot conventions
|
||||
|
||||
Snapshots live at `$REPACKAGED_ODOO_ROOT/accounting-<version>/<module>` (default
|
||||
root: `/Users/gurpreet/Github/RePackaged-Odoo`). Override the root with the
|
||||
`REPACKAGED_ODOO_ROOT` env var.
|
||||
|
||||
The current workspace has only the V19 snapshot at
|
||||
`/Users/gurpreet/Github/RePackaged-Odoo/accounting/` (unversioned). When
|
||||
Odoo 20 ships:
|
||||
|
||||
1. Rename the current snapshot: `mv accounting accounting-v19`
|
||||
2. Drop the new V20 source at `accounting-v20/`
|
||||
3. Run `tools/check_odoo_diff.sh account_accountant v19 v20` per sub-module
|
||||
83
fusion_accounting/tools/check_odoo_diff.sh
Executable file
83
fusion_accounting/tools/check_odoo_diff.sh
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# check_odoo_diff.sh
|
||||
#
|
||||
# Diff a single Odoo Enterprise accounting module across two pinned snapshots
|
||||
# and produce a categorized change report.
|
||||
#
|
||||
# Usage:
|
||||
# tools/check_odoo_diff.sh <module> <from_version> <to_version> [<output_md>]
|
||||
#
|
||||
# Example:
|
||||
# tools/check_odoo_diff.sh account_accountant v19 v20 reports/v20_accountant_diff.md
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODULE="${1:?Usage: check_odoo_diff.sh <module> <from_version> <to_version> [<output_md>]}"
|
||||
FROM="${2:?from_version required (e.g. v19)}"
|
||||
TO="${3:?to_version required (e.g. v20)}"
|
||||
OUT="${4:-/dev/stdout}"
|
||||
|
||||
ROOT="${REPACKAGED_ODOO_ROOT:-/Users/gurpreet/Github/RePackaged-Odoo}"
|
||||
FROM_DIR="$ROOT/accounting-$FROM/$MODULE"
|
||||
TO_DIR="$ROOT/accounting-$TO/$MODULE"
|
||||
|
||||
if [ ! -d "$FROM_DIR" ]; then
|
||||
echo "ERROR: $FROM_DIR does not exist. Snapshot $FROM not yet present?" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$TO_DIR" ]; then
|
||||
echo "ERROR: $TO_DIR does not exist. Snapshot $TO not yet present?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
classify() {
|
||||
local f="$1"
|
||||
case "$f" in
|
||||
*/views/*|*/static/src/components/*|*/report/*|*/wizard/*_views.xml|*/wizards/*_views.xml)
|
||||
echo "[MIRROR]" ;;
|
||||
*/models/*_engine.py|*/services/*)
|
||||
echo "[ABSTRACT]" ;;
|
||||
*/__manifest__.py)
|
||||
echo "[MANIFEST]" ;;
|
||||
*/tests/*)
|
||||
echo "[TEST]" ;;
|
||||
*)
|
||||
echo "[REVIEW]" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
{
|
||||
echo "# Diff Report: $MODULE ($FROM -> $TO)"
|
||||
echo ""
|
||||
echo "Generated: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo ""
|
||||
echo "## Changed Files (with classification suggestion)"
|
||||
echo ""
|
||||
diff -ruN --brief "$FROM_DIR" "$TO_DIR" | while read -r line; do
|
||||
case "$line" in
|
||||
"Files "*" and "*" differ")
|
||||
file=$(echo "$line" | sed -E 's/^Files (.+) and .+ differ$/\1/' | sed "s|$FROM_DIR/||")
|
||||
tag=$(classify "$file")
|
||||
echo "- $tag \`$file\`"
|
||||
;;
|
||||
"Only in $TO_DIR"*)
|
||||
file=$(echo "$line" | sed -E "s|Only in $TO_DIR(.*): (.+)|\1/\2|" | sed "s|^/||")
|
||||
tag=$(classify "$file")
|
||||
echo "- $tag NEW: \`$file\`"
|
||||
;;
|
||||
"Only in $FROM_DIR"*)
|
||||
file=$(echo "$line" | sed -E "s|Only in $FROM_DIR(.*): (.+)|\1/\2|" | sed "s|^/||")
|
||||
tag=$(classify "$file")
|
||||
echo "- $tag REMOVED: \`$file\`"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
echo ""
|
||||
echo "## Full Diff (truncated to first 2000 lines)"
|
||||
echo ""
|
||||
echo '```diff'
|
||||
diff -ruN "$FROM_DIR" "$TO_DIR" | head -2000
|
||||
echo '```'
|
||||
} > "$OUT"
|
||||
|
||||
echo "Diff report written to: $OUT" >&2
|
||||
@@ -1,97 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_history_list" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.match.history.list</field>
|
||||
<field name="model">fusion.accounting.match.history</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Match History">
|
||||
<field name="proposed_at"/>
|
||||
<field name="tool_name"/>
|
||||
<field name="decision" widget="badge"
|
||||
decoration-success="decision == 'approved'"
|
||||
decoration-danger="decision == 'rejected'"
|
||||
decoration-warning="decision == 'pending'"
|
||||
decoration-info="decision == 'auto'"/>
|
||||
<field name="ai_confidence" widget="progressbar"/>
|
||||
<field name="amount"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="decided_by"/>
|
||||
<field name="decided_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_history_form" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.match.history.form</field>
|
||||
<field name="model">fusion.accounting.match.history</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Match History">
|
||||
<header>
|
||||
<button name="action_approve" string="Approve" type="object"
|
||||
class="btn-primary" invisible="decision != 'pending'"
|
||||
groups="fusion_accounting.group_fusion_accounting_manager"/>
|
||||
<button name="action_reject" string="Reject" type="object"
|
||||
class="btn-danger" invisible="decision != 'pending'"
|
||||
groups="fusion_accounting.group_fusion_accounting_manager"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="tool_name"/>
|
||||
<field name="decision"/>
|
||||
<field name="ai_confidence"/>
|
||||
<field name="amount"/>
|
||||
<field name="partner_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="session_id"/>
|
||||
<field name="rule_id"/>
|
||||
<field name="proposed_at"/>
|
||||
<field name="decided_at"/>
|
||||
<field name="decided_by"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="AI Details">
|
||||
<field name="ai_reasoning"/>
|
||||
<field name="tool_params"/>
|
||||
<field name="tool_result"/>
|
||||
</group>
|
||||
<group string="Correction" invisible="decision != 'rejected'">
|
||||
<field name="rejection_reason"/>
|
||||
<field name="correct_action"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_history_search" model="ir.ui.view">
|
||||
<field name="name">fusion.accounting.match.history.search</field>
|
||||
<field name="model">fusion.accounting.match.history</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="tool_name"/>
|
||||
<field name="partner_id"/>
|
||||
<filter name="pending" string="Pending" domain="[('decision', '=', 'pending')]"/>
|
||||
<filter name="approved" string="Approved" domain="[('decision', '=', 'approved')]"/>
|
||||
<filter name="rejected" string="Rejected" domain="[('decision', '=', 'rejected')]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_tool" string="Tool" domain="[]" context="{'group_by': 'tool_name'}"/>
|
||||
<filter name="group_decision" string="Decision" domain="[]" context="{'group_by': 'decision'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_history" model="ir.actions.act_window">
|
||||
<field name="name">Match History</field>
|
||||
<field name="res_model">fusion.accounting.match.history</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_history_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">No match history yet</p>
|
||||
<p>AI tool calls and their outcomes will appear here.</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
272
fusion_accounting_ai/CLAUDE.md
Normal file
272
fusion_accounting_ai/CLAUDE.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# fusion_accounting_ai — Cursor / Claude Context
|
||||
|
||||
## Purpose
|
||||
Conversational AI co-pilot for Odoo Accounting using Claude or GPT with native
|
||||
tool-calling. Embeds in any Odoo install via the data-adapter pattern (works on
|
||||
Community-only, Community + fusion native sub-modules, or Community + Enterprise).
|
||||
|
||||
## Sub-module relationships
|
||||
- `fusion_accounting_core`: hard dep, provides security groups + Enterprise detection
|
||||
- `fusion_accounting_bank_rec` (Phase 1): adapter routes to it when present
|
||||
- `fusion_accounting_reports` (Phase 2): same
|
||||
- `fusion_accounting_followup` (Phase 5): same
|
||||
- Odoo Enterprise modules: detected at runtime, AI tools route through them via adapters
|
||||
|
||||
## Data-adapter pattern (Phase 0 addition)
|
||||
- `services/data_adapters/base.py` — `DataAdapter` + `AdapterMode`
|
||||
- `services/data_adapters/_registry.py` — `get_adapter(env, name)` + `register_adapter`
|
||||
- One adapter file per domain: `bank_rec.py`, `reports.py`, `followup.py`, `assets.py`
|
||||
- Each adapter implements `<method>_via_fusion`, `<method>_via_enterprise`, `<method>_via_community`
|
||||
- Adapter `_select_mode()` picks fusion if model loaded, else enterprise if module installed, else community
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
```
|
||||
fusion_accounting_ai/
|
||||
├── models/ 7 files (5 new models + 2 inherits: account.move, res.config.settings)
|
||||
├── services/
|
||||
│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop)
|
||||
│ ├── adapters/ Claude + OpenAI adapters with native tool-calling
|
||||
│ ├── data_adapters/ Tri-mode domain routers (fusion / enterprise / community)
|
||||
│ ├── tools/ 93 tool functions across 11 domain files
|
||||
│ ├── prompts/ System prompt builder + 12 domain-specific prompts
|
||||
│ └── scoring.py Confidence scoring + tier promotion logic
|
||||
├── controllers/ 10 JSON-RPC endpoints
|
||||
├── wizards/ Rule creation wizard
|
||||
├── static/src/ OWL dashboard + chat panel + approval cards
|
||||
├── views/ List/form/search views, menus, settings
|
||||
├── security/ ACLs + record rules (groups themselves live in fusion_accounting_core)
|
||||
├── data/ 88 tool definitions, 2 default rules, 2 crons, 1 sequence
|
||||
├── tests/ API integration tests
|
||||
└── report/ Audit report QWeb template
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### AI Provider Integration
|
||||
- Uses `fusion.api.service` (from fusion_api module) for API key resolution with fallback to `ir.config_parameter` — NO hard dependency on fusion_api
|
||||
- Claude adapter: native `tool_use` blocks, extended thinking enabled (8K budget) for all Claude 4.x models
|
||||
- OpenAI adapter: Chat Completions API with o-series reasoning model support (`developer` role, `max_completion_tokens`, `reasoning_effort`)
|
||||
- API keys stored in `ir.config_parameter` with `fusion_accounting.` prefix
|
||||
- API key fields in Settings use `password="True"` widget — labels include "(Fusion AI)" suffix to avoid conflicts with other modules' key fields
|
||||
- **Provider pinning**: Sessions remember which provider was used. If the global provider changes mid-session, the session continues with its original provider to prevent cross-adapter message format contamination.
|
||||
|
||||
### Tool Tiering
|
||||
- **Tier 1** (Free): Read-only, execute immediately — 60+ tools
|
||||
- **Tier 2** (Auto-approved): Low-risk writes, logged — ~10 tools
|
||||
- **Tier 3** (Requires approval): Financial writes, user must approve — ~15 tools
|
||||
- Auto-promotion: Tier 3 → Tier 2 at 95% accuracy over 30+ decisions (atomic SQL counters on `fusion.accounting.rule._record_decision`)
|
||||
- Tool descriptions include tier labels (e.g., `[Tier 3: Requires user approval]`) so the AI knows which tools need approval
|
||||
- When a Tier 3 tool is encountered during the chat loop, the loop short-circuits: a final text response is forced so the AI can present approval cards to the user
|
||||
|
||||
### Tier 3 Approval Flow
|
||||
- When a Tier 3 action is approved/rejected, the session's `message_ids_json` is updated to replace the `pending_approval` placeholder with the actual tool result — this prevents dangling `tool_use` blocks that would cause API errors on the next chat turn
|
||||
- After approval, `scoring.check_promotions()` is called to check if any rules should be promoted
|
||||
|
||||
### Menu Location
|
||||
- **Parent**: `accountant.menu_accounting` (NOT `account.menu_finance` — that's Community Edition only)
|
||||
- Enterprise uses `accountant.menu_accounting` (ID 1663) as the visible menu root
|
||||
- `account.menu_finance` (ID 180) exists but has NO visible children in Enterprise — it's the Community root
|
||||
|
||||
### Session Persistence
|
||||
- Chat sessions stored in `fusion.accounting.session` with `message_ids_json` (JSON text field)
|
||||
- On page load, chat panel calls `/session/latest` to restore the most recent active session
|
||||
- Empty assistant messages (tool-call-only responses with no text) are filtered out by the controller
|
||||
- "New Chat" button closes current session and creates a fresh one
|
||||
- Session name (e.g., FAS/2026/00001) shown in the chat header
|
||||
- **Session ownership**: Controllers verify the current user owns the session (managers can access any session)
|
||||
|
||||
### Rich Text Chat Output
|
||||
- AI responses are rendered as rich HTML, not plain text
|
||||
- Markdown-to-HTML conversion happens client-side in `chat_panel.js` via `mdToHtml()` function
|
||||
- HTML is injected via `innerHTML` on `onMounted` + `onPatched` (NOT via OWL's `markup()` / `t-out` — those proved unreliable in Odoo 19)
|
||||
- The `_renderRichMessages()` method finds `.fusion_rich_slot[data-idx]` divs and sets their innerHTML
|
||||
- Supported: headers (# through #####), **bold**, *italic*, `code`, tables, bullet/numbered lists, horizontal rules, [links](url)
|
||||
- System prompt instructs AI to use markdown formatting and include Odoo record links like `[INV/2026/00123](/odoo/accounting/123)`
|
||||
|
||||
### Interactive Tables (fusion-table)
|
||||
- AI can return `fusion-table` fenced code blocks instead of Markdown tables for actionable results
|
||||
- `mdToHtml()` detects these blocks, extracts JSON, and renders `FusionInteractiveTable` OWL components via `mount()`
|
||||
- **Interactive mode**: checkbox column + data columns + AI Recommendation column (colour-coded badge) + Your Input column (text field per row) + bottom bulk action bar
|
||||
- **Read-only mode**: styled table, no inputs/actions
|
||||
- Actions: Apply Recommendations, Flag Selected, Create Rules, Dismiss Selected, Submit All Notes to AI
|
||||
- Action button clicks format a `[TABLE_ACTION]` structured message and send it back through the chat endpoint
|
||||
- The AI decides per-response whether to use interactive or Markdown tables based on whether the data is actionable
|
||||
- Used for: `find_missing_itc_bills`, `find_duplicate_bills`, `get_overdue_invoices`, `find_draft_entries`, `get_unreconciled_bank_lines`, etc.
|
||||
- NOT used for: `get_profit_loss`, `get_balance_sheet`, `get_trial_balance` (informational, read-only)
|
||||
- All styles use Odoo CSS variables — dark/light mode handled automatically
|
||||
|
||||
### Dashboard Layout
|
||||
- Health cards row at top (6 cards: Bank Recon, AR, AP, HST, Audit Score, Month-End)
|
||||
- Below: side-by-side layout — "Needs Attention" panel (flex-grow) + Chat panel (720px fixed width)
|
||||
- Chat panel is 720px (80% larger than original 400px design)
|
||||
- Dashboard endpoint returns `needs_attention` and `recent_activity` JSON arrays alongside health card metrics
|
||||
|
||||
### HST Filing Workflow (4-Phase AI-Driven)
|
||||
- Phase 1: AI runs all HST reports (tax report, missing ITCs, compliance audit, HST balance)
|
||||
- Phase 2: AI sweeps ALL bank accounts for unreconciled expense payments
|
||||
- Phase 3: Per-line processing — check for existing bills, check history for coding patterns, ask about HST, create bills, register payments
|
||||
- Phase 4: Re-run reports to verify updated HST position
|
||||
- New tools added: `search_partners` (Tier 1), `find_similar_bank_lines` (Tier 1), `get_bank_line_details` (Tier 1), `create_vendor_bill` (Tier 3), `register_bill_payment` (Tier 3), `create_expense_entry` (Tier 3)
|
||||
- Two paths for recording expenses: (a) formal vendor bill + payment, or (b) direct GL entry in MISC journal with optional HST split
|
||||
- The `create_expense_entry` tool posts directly to the Miscellaneous Operations journal — debit expense + debit HST ITC (2006) + credit bank
|
||||
- Domain prompt (`hst_management` in domain_prompts.py) includes bank journal IDs and the full 4-phase workflow instructions
|
||||
|
||||
## Odoo 19 Gotchas (Learned the Hard Way)
|
||||
|
||||
### Search Views
|
||||
- NO `string` attribute on `<search>` element
|
||||
- NO `string` attribute on `<group>` element inside search views
|
||||
- Group-by filters MUST have `domain="[]"` attribute
|
||||
- Add `<separator/>` before `<group>` in search views
|
||||
|
||||
### OWL Client Actions
|
||||
- Components registered as client actions receive props: `action`, `actionId`, `updateActionState`, `className`
|
||||
- Must use `static props = ["*"]` (accept any) — NOT `static props = []` (accept none)
|
||||
|
||||
### OWL Rich HTML Rendering
|
||||
- `markup()` from `@odoo/owl` + `t-out` is UNRELIABLE in Odoo 19 for rendering HTML in OWL components
|
||||
- Use `onMounted` + `onPatched` hooks to find DOM elements and set `innerHTML` directly
|
||||
- Pattern: render a placeholder `<div class="slot" t-att-data-idx="index"/>`, then in the hook find it and set `.innerHTML`
|
||||
- Always use BOTH `onMounted` AND `onPatched` — `onPatched` alone misses the first render
|
||||
|
||||
### Cron Safe Eval
|
||||
- NO `import` statements (forbidden opcode `IMPORT_NAME`)
|
||||
- `datetime` module available as `datetime` (use `datetime.datetime.now()`, `datetime.timedelta()`)
|
||||
- NO `from datetime import X` pattern
|
||||
|
||||
### read_group Deprecated
|
||||
- `read_group()` is deprecated in Odoo 19 — use `_read_group()` instead
|
||||
- Still works but throws DeprecationWarning
|
||||
- Dashboard `accounting_dashboard.py` still uses `read_group()` — migrate to `_read_group()` when the new API is stable
|
||||
|
||||
### Config Parameter Values
|
||||
- When changing a Selection field's options, the stored DB value in `ir_config_parameter` must match one of the new options or Settings page will crash with `ValueError: Wrong value`
|
||||
- Fix: UPDATE the value in DB after changing selection options:
|
||||
```sql
|
||||
UPDATE ir_config_parameter SET value = 'new_value' WHERE key = 'fusion_accounting.field_name';
|
||||
```
|
||||
|
||||
### Field Label Conflicts
|
||||
- Odoo warns if two fields on the same model have the same `string` label
|
||||
- Our `display_name_field` conflicted with built-in `display_name` — renamed string to "Tool Label"
|
||||
- API key fields use "(Fusion AI)" suffix to avoid label conflicts with other modules
|
||||
- Tool model uses `domain` (not `domain_name`) and `parameters_schema` (not `parameters`) as field names
|
||||
|
||||
### Group Assignment
|
||||
- `implied_ids` on groups only applies to NEWLY added users, not existing ones
|
||||
- After installing, manually add existing users to groups via SQL:
|
||||
```sql
|
||||
INSERT INTO res_groups_users_rel (gid, uid)
|
||||
SELECT <group_id>, gu.uid FROM res_groups_users_rel gu
|
||||
JOIN ir_model_data imd ON imd.res_id = gu.gid AND imd.model = 'res.groups'
|
||||
WHERE imd.module = 'account' AND imd.name = 'group_account_manager'
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
### TransientModel in Controllers
|
||||
- Use `.new({...})` NOT `.create({...})` for TransientModels in controller endpoints
|
||||
- `.create()` writes a DB row on every request; `.new()` is in-memory only
|
||||
- Dashboard controller uses `.new()` to compute health metrics without DB writes
|
||||
|
||||
## Server Details
|
||||
- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`)
|
||||
- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL)
|
||||
- **Database**: westin-v19
|
||||
- **Module path**: `/mnt/extra-addons/fusion_accounting_ai/`
|
||||
- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages`
|
||||
- **URL**: erp.westinhealthcare.ca
|
||||
|
||||
## Deployment Commands
|
||||
```bash
|
||||
# Full deploy cycle (clean + copy + upgrade + restart)
|
||||
ssh odoo-westin "docker exec -u 0 odoo-dev-app rm -rf /mnt/extra-addons/fusion_accounting_ai"
|
||||
scp -r "K:\Github\Odoo-Modules\fusion_accounting_ai" odoo-westin:/tmp/fusion_accounting_ai
|
||||
ssh odoo-westin "docker cp /tmp/fusion_accounting_ai odoo-dev-app:/mnt/extra-addons/fusion_accounting_ai && rm -rf /tmp/fusion_accounting_ai"
|
||||
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_accounting_ai --stop-after-init --http-port=8099 -c /etc/odoo/odoo.conf"
|
||||
ssh odoo-westin "docker restart odoo-dev-app"
|
||||
|
||||
# Check logs
|
||||
ssh odoo-westin "docker logs odoo-dev-app --tail 100"
|
||||
|
||||
# Quick DB queries
|
||||
ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -t -c \"<SQL>\""
|
||||
|
||||
# Check module state
|
||||
ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -t -c \"SELECT name, state, latest_version FROM ir_module_module WHERE name = 'fusion_accounting_ai';\""
|
||||
```
|
||||
|
||||
## Security Groups
|
||||
(The three groups themselves are now defined in `fusion_accounting_core`. This
|
||||
module's `security/ir.model.access.csv` grants access on AI-specific models
|
||||
using those group XML-ids.)
|
||||
|
||||
| XML ID (in fusion_accounting_core) | Name | Access in AI module |
|
||||
|---|---|---|
|
||||
| `group_fusion_accounting_user` | User | Dashboard, chat (read-only tools) |
|
||||
| `group_fusion_accounting_manager` | Manager | + Approve/reject, Tier 2 tools, rules |
|
||||
| `group_fusion_accounting_admin` | Administrator | + Config, all tools, rule admin |
|
||||
|
||||
Auto-assigned (configured in _core): `account.group_account_user` → User,
|
||||
`account.group_account_manager` → Admin
|
||||
|
||||
## Controller Endpoints
|
||||
| Route | Auth | Purpose |
|
||||
|---|---|---|
|
||||
| `/fusion_accounting/session/create` | user | Create new chat session |
|
||||
| `/fusion_accounting/session/close` | user (ownership check) | Close active session |
|
||||
| `/fusion_accounting/session/latest` | user (own sessions only) | Load most recent active session + messages |
|
||||
| `/fusion_accounting/session/history` | user (ownership check, managers see all) | Load specific session messages |
|
||||
| `/fusion_accounting/chat` | user (ownership check) | Send message, get AI response |
|
||||
| `/fusion_accounting/approve` | user + manager group check | Approve single Tier 3 action |
|
||||
| `/fusion_accounting/reject` | user + manager group check | Reject single Tier 3 action |
|
||||
| `/fusion_accounting/approve_all` | user + manager group check | Batch approve multiple actions |
|
||||
| `/fusion_accounting/reject_all` | user + manager group check | Batch reject multiple actions |
|
||||
| `/fusion_accounting/dashboard/data` | user | Get dashboard health card metrics + needs_attention + recent_activity |
|
||||
|
||||
Note: Approve/reject endpoints use `auth='user'` at the decorator level with an imperative `has_group()` check inside the handler (Odoo has no built-in `auth='manager'`).
|
||||
|
||||
## Models
|
||||
| Model | Type | Location | Purpose |
|
||||
|---|---|---|---|
|
||||
| `fusion.accounting.session` | Model | models/ | Chat sessions with message JSON storage |
|
||||
| `fusion.accounting.match.history` | Model | models/ | Every AI tool call + decision (approved/rejected/pending) |
|
||||
| `fusion.accounting.rule` | Model | models/ | Fusion Rules engine with versioning and auto-promotion |
|
||||
| `fusion.accounting.tool` | Model | models/ | Tool registry (82 tools seeded from XML) |
|
||||
| `fusion.accounting.dashboard` | TransientModel | models/ | Computed health metrics (use `.new()` not `.create()`) |
|
||||
| `res.config.settings` (inherit) | TransientModel | models/ | Settings page (API keys, thresholds, toggles) |
|
||||
| `account.move` (inherit) | Model | models/ | Post-action audit hook |
|
||||
| `fusion.accounting.agent` | AbstractModel | services/ | AI orchestrator |
|
||||
| `fusion.accounting.adapter.claude` | AbstractModel | services/ | Claude tool-calling adapter |
|
||||
| `fusion.accounting.adapter.openai` | AbstractModel | services/ | OpenAI tool-calling adapter |
|
||||
| `fusion.accounting.scoring` | AbstractModel | services/ | Confidence scoring |
|
||||
| `fusion.accounting.rule.wizard` | TransientModel | wizards/ | Quick-create rule from chat suggestion |
|
||||
|
||||
## AI Models Available
|
||||
**Claude** (default: claude-sonnet-4-6):
|
||||
- claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5
|
||||
- claude-sonnet-4-5, claude-opus-4-5, claude-sonnet-4-0, claude-opus-4-0
|
||||
|
||||
**OpenAI** (default: gpt-5.4-mini):
|
||||
- gpt-5.4, gpt-5.4-mini, gpt-5.4-nano
|
||||
- o3, o4-mini
|
||||
- gpt-4o, gpt-4o-mini (legacy)
|
||||
|
||||
## Theme / Styling Rules
|
||||
- NO hardcoded colours — use CSS variables (`var(--o-border-color)`, `var(--bs-body-color-rgb)`) and Bootstrap utility classes
|
||||
- Must work in both light and dark mode
|
||||
- Box shadows: use `rgba(var(--bs-body-color-rgb), 0.1)` not `rgba(0,0,0,0.1)`
|
||||
- AI messages use `var(--o-view-background-color)` background + `var(--o-border-color)` border
|
||||
- Links use `var(--o-action-color)` for theme awareness
|
||||
|
||||
## Known Issues / Future Work
|
||||
- `read_group()` deprecation warnings in `accounting_dashboard.py` — migrate to `_read_group()` when the new API format is stable
|
||||
- `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2)
|
||||
- `get_payroll_schedule`, `verify_source_deductions`, `verify_payroll_deductions` are stubs (Phase 2 — fusion_payroll integration)
|
||||
- `answer_financial_question` is a stub (returns message to use other tools instead)
|
||||
- Batch approval "Approve All" / "Reject All" buttons are in the chat panel but not yet in the match history list view
|
||||
- "Needs Attention" panel shows placeholder text in the dashboard — the data is computed and returned by the API but the frontend rendering needs to be connected
|
||||
- Consider switching OpenAI adapter from Chat Completions API to Responses API for better tool handling with newer models
|
||||
- `o1` model does not support tool calling — no guard in place (o3/o4-mini do support it)
|
||||
- Multi-company record rule on `fusion.accounting.session` — added in Phase 0 split-out (see UPGRADE_NOTES.md)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user