Compare commits

..

18 Commits

Author SHA1 Message Date
gsinghpal
21cfd55419 feat(fusion_portal): Phase 3 - assessment visit workspace (accessibility path)
Adds the portal workspace: /my/visit/new starts a visit; /my/visit/<id> shows the
add-as-you-go workspace (add buttons -> existing forms carrying ?visit_id, a
deferred client+funding form, and a Complete button). Accessibility forms launched
from a visit save as a DRAFT linked to it (JS carries visit_id into the form; the
controller captures it and skips the per-assessment SO) - the VISIT completion then
creates the grouped per-funding sale orders.

NOT YET: express/ADP form visit-linking, email consolidation, polished tablet UI.
Untested locally (Enterprise dep) - clone verification pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:37:08 -04:00
gsinghpal
89467432a7 feat(fusion_portal): Phase 2b - ADP multi-device grouping + combination guard
The visit groups its ADP assessments by funding type onto ONE ADP order (first
device creates the SO via the existing express completion; the rest attach),
enforcing the combination rule: at most one seated-mobility device (manual WC /
power WC / scooter) + optionally one walker, no duplicates. Also fixes a Phase 1b
bug - it called action_complete() (needs signatures, returns an action dict) for
ADP; now uses action_complete_express() which returns the SO.

Untested locally (Enterprise dep) - clone verification pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:31:38 -04:00
gsinghpal
e0ddd9ef40 feat(fusion_portal): Phase 2a - mobility scooter ADP type + power-mobility home-access rule
Adds 'scooter' as a 4th ADP equipment type with a lean Express-form section
(scooter type + max range) and the power-mobility home-accessibility hard rule
(scooter + powerchair): "is the home usable inside and outside, no lifting?" - if No,
prompts adding an accessibility item (ramp / porch lift). Captures
x_fc_power_home_accessible + notes; the section toggles via the existing
equipment-select JS; controller parses the new fields.

NOT YET: ADP multi-device + combination rules (the bigger restructure).
Untested locally (Enterprise dep) - to be verified on a westin-v19 clone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:22:47 -04:00
gsinghpal
b17bd615bf feat(fusion_portal): Phase 1b - Assessment Visit model + accessibility funding grouping
Adds fusion.assessment.visit: the hub that bundles a home visit's assessments and,
on completion, groups its ACCESSIBILITY assessments by funding workflow
(x_fc_sale_type) and creates ONE draft sale order per workflow, reusing the existing
MOD/ODSP/etc. pipelines + the chatter-note pattern. ADP assessments keep one-SO-each
for now (ADP multi-device grouping is Phase 2).

- New model + sequence (VISIT/YYYY/NNNN) + ACL + backend list/form/menu.
- visit_id added to fusion.assessment, fusion.accessibility.assessment, sale.order.
- action_complete_visit() group-and-routes; MOD $15k cap + income-threshold flag
  surfaced (informational, no auto-enforcement).

NOT YET: completion-email consolidation; ADP multi-device (Phase 2); portal
add-as-you-go workspace (Phase 3). Untested locally (Enterprise knowledge dep) -
to be verified on a westin-v19 clone before prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:00:53 -04:00
gsinghpal
e36aaab306 fix(fusion_portal): validate funding_source in accessibility save (parity with booking)
Coerce an unexpected/tampered funding_source to direct_private instead of passing
it raw into create() (which would raise on the Selection field). Mirrors the
/book-assessment controller; the whitelist is derived from the model selection so
it auto-covers hardship and any future values.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:38:42 -04:00
gsinghpal
37efc5b858 feat(fusion_portal): funding-source selector on accessibility forms
Reps can now mark an accessibility assessment's funding source on the web form
(Private / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other) so the
generated draft sale order routes to the correct funding pipeline instead of
always defaulting to private pay. Adds Hardship to the x_fc_funding_source
selection + sale_type_map; the new form <select> is auto-serialised by the
existing FormData submit, and accessibility_assessment_save now maps
funding_source -> x_fc_funding_source. The model + SO routing were already in
place (2026-04 audit fix) — this closes the form + controller gap.

Plan: docs/superpowers/plans/2026-06-02-accessibility-funding-selector.md
Spec: docs/superpowers/specs/2026-06-02-assessment-visit-funding-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:33:24 -04:00
gsinghpal
aafc2db8a8 docs(fusion_portal): spec for assessment Visit + funding-routed sale orders
Brainstormed design: bundle a home visit's assessments (ADP + accessibility),
measurement-first with client/funding deferred, add-as-you-go workspace,
per-item funding selector (fixes the March-of-Dimes routing gap), and on
completion group items into ONE draft sale order per funding workflow
(ADP / MOD / ODSP / Hardship / private) reusing the existing pipelines.
Adds ADP multi-device + combination rules, a new mobility-scooter type, and a
power-mobility home-accessibility rule that feeds the accessibility upsell.
v1 keeps manual quotation (no auto-pricing); MOD $15k cap is a reminder only.
Phased 1-3; risks + file map included.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:20:26 -04:00
gsinghpal
747c814249 refactor(fusion_portal): rename from fusion_authorizer_portal + modern photo cards on accessibility selector
Rename module fusion_authorizer_portal -> fusion_portal everywhere:
manifest/assets, controllers, models, views, JS (odoo.define + asset URLs),
migration MODULE constants; plus cross-module refs in fusion_schedule,
fusion_repairs, fusion_quotations (depends + inherit_id) and the pdf_filler
import in fusion_claims. Add rename_module.sql for the one-time in-place DB
rename (ir_module_module, ir_model_data, ir_ui_view.key,
ir_module_module_dependency) required on installed envs before -u fusion_portal.
Document the rename gotcha as rule 16 in CLAUDE.md.

Redesign the Accessibility Assessment selector: replace Font Awesome icon tiles
with photo-banner cards using 7 optimized images (1000x750 PNG -> 800x600 JPEG,
~8MB -> 488KB), per-type colour accent bar + centered pill button, hover
lift/zoom. Images ship as module static files so they deploy/sync with the module.

Drop the regenerable graphify-out cache from the module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:38:14 -04:00
gsinghpal
c527c7cade fix(fusion_clock): migration must recompute net_hours/overtime, not just break
Recomputing only x_fclk_break_minutes left historical x_fclk_net_hours / x_fclk_overtime_hours stale (add_to_compute+flush of one field does not cascade to dependents). Recompute the full chain in dependency order. Caught verifying the entech deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:28:12 -04:00
gsinghpal
f7ec1e28f9 feat(fusion_clock): province-aware automatic unpaid break (2-tier)
Statutory unpaid break now deducts automatically from worked hours on every path - portal, kiosk, NFC, auto-clock-out cron, AND manual backend entry.

- new fusion.clock.break.rule per-province table (seed Ontario 5h->30, 10h->+30), resolved from the employee's company province with a global default fallback
- x_fclk_break_minutes is now a single idempotent stored compute (statutory(worked_hours) + penalties), replacing the 4 duplicated write sites (_apply_break_deduction x3 callsites + auto-clock-out cron + penalty write)
- retire break_threshold_hours (superseded by per-rule break1_after_hours); post-migrate drops the param and recomputes historical breaks
- 11 tests all green; module install + 19.0.4.1.0 migration verified on modsdev

Bump 19.0.4.0.3 -> 19.0.4.1.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:15:42 -04:00
gsinghpal
96b3f124f8 docs(fusion_clock): fix plan so the 19.0.4.1.0 migration fires in dev (bump in Task 4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:50:08 -04:00
gsinghpal
2c32e7bcd0 docs(fusion_clock): implementation plan for province-aware auto break
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:46:42 -04:00
gsinghpal
aa9b95bd5d docs(fusion_clock): spec for province-aware automatic unpaid break
2-tier statutory break deduction: new fusion.clock.break.rule per-province table (seed Ontario 5h/30 + 10h/30); x_fclk_break_minutes becomes an idempotent stored compute (statutory + penalties) firing on every path incl. manual backend entry; collapses the 4 duplicated break-write sites into one calculator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:35:58 -04:00
gsinghpal
493f01827e Update res_config_settings.cpython-312.pyc 2026-05-31 22:53:43 -04:00
gsinghpal
2ab59bccde feat(fusion_clock): default clock-in/out times as 12-hour AM/PM dropdowns
People aren't good with 24h. Default Clock-In/Out are now AM/PM dropdowns (15-min
grid) instead of 24h float_time inputs. Stored value stays the float-string
(e.g. '9.0'), so all downstream float(get_param(...)) reads are unchanged;
persisted manually with get-snap for any off-grid value. Bump 19.0.4.0.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:47:12 -04:00
gsinghpal
914c96a09a fix(fusion_clock): add role=button to kiosk Open links (a11y warning)
Odoo warns that <a class=btn> needs role=button. Bump 19.0.4.0.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:31:30 -04:00
gsinghpal
b015958edc feat(fusion_clock): unify + label kiosk settings, add quick-open buttons
Settings tidy-up: one 'Kiosk' block holding both PIN Kiosk and NFC Kiosk
(clearly described so users know which is which), each with an Open-kiosk
button when enabled; Corrections + Sounds split into a 'Portal' block. Move
Auto-Wipe Photos under Photo Verification (was hidden for PIN-only clients).
Bump 19.0.4.0.0 -> 19.0.4.0.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:07:00 -04:00
gsinghpal
ca94a4c42a changes 2026-05-31 21:33:44 -04:00
151 changed files with 2952 additions and 12366 deletions

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ __pycache__/
# Local-only diagnostic logs from test runs
_test_*.log
.superpowers/

View File

@@ -33,6 +33,8 @@
15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`.
16. **Renaming a module's technical name needs a DB rename, not just a folder rename.** The technical name is baked into the database: `ir_module_module.name`, every external ID in `ir_model_data.module`, each view's `ir_ui_view.key` prefix, and the `ir_module_module_dependency.name` rows of every module that depends on it. Rename only the folder + in-code references and Odoo treats the new name as a fresh uninstalled module — installing it **duplicates** groups/templates/menus and **orphans** all existing data. On every DB that already has it installed, run an in-place SQL rename (the 4 tables above) **before** `-u <newname>`; a fresh DB needs nothing. Reference script + full rationale: [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql) (written for the `fusion_authorizer_portal` → `fusion_portal` rename). Also update cross-module `depends`, `inherit_id="<old>.view"`, `t-call`, `env.ref('<old>.xmlid')`, asset paths (`<old>/static/...`), and `from odoo.addons.<old>... import`.
## 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
@@ -94,7 +96,7 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
## Module-Specific Notes
- **fusion_clock** — developed in **Claude Code** (no longer Cursor; no concurrent-editing conflicts). Changed a lot recently (NFC kiosk: tap-to-clock, enrollment + program-from-unknown-tap, manager page, sounds, screen lock, guided profile-photo capture, faster animations). Still read files fresh before editing rather than assuming the layout. Live on entech (`odoo-entech` / LXC 111 on `pve-worker5`).
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.2.4`.** Bundles 111 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_authorizer_portal`.
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.2.4`.** Bundles 111 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_portal`.
## Workflow
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`

View File

@@ -28,7 +28,7 @@
'website',
'mail',
'fusion_claims',
'fusion_authorizer_portal',
'fusion_portal',
],
'data': [
'security/security.xml',

View File

@@ -0,0 +1,864 @@
# Fusion Clock — Province-Aware Automatic Unpaid Break Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the unpaid meal break deduct automatically from worked hours on every path (portal, kiosk, NFC, cron, **and manual backend entry**), using a 2-tier per-province rule table (Ontario: 5h→30min, 10h→+30min), with no duplicated logic.
**Architecture:** A new `fusion.clock.break.rule` table holds the per-province thresholds. `hr.employee._get_fclk_break_rule()` resolves an employee's rule from its company's province (global default fallback). `hr.attendance.x_fclk_break_minutes` becomes a single stored **computed** field — `statutory_break(worked_hours) + Σ penalty_minutes` — that recomputes on every save and replaces the four scattered write sites (controller `_apply_break_deduction` ×3 call sites, the auto-clock-out cron, and the penalty code's manual write).
**Tech Stack:** Odoo 19, Python, QWeb/XML views, Odoo test framework (`TransactionCase`).
**Spec:** `docs/superpowers/specs/2026-05-31-fusion-clock-statutory-break-design.md`
---
## Dev environment & sync (READ FIRST — applies to every task)
**Two working copies (per project memory `feedback_dual_path_fusion_clock`):**
- **Git/source tree (edit + commit here):** `K:\Github\Odoo-Modules\fusion_clock`
- **Docker/active tree (what the container loads):** `K:\Github\odoo-modsdev\addons\fusion_clock`
Edit in the **git tree**, then **mirror to the Docker tree before every test run**:
```powershell
robocopy "K:\Github\Odoo-Modules\fusion_clock" "K:\Github\odoo-modsdev\addons\fusion_clock" /MIR /XD ".git" "__pycache__" /XF "*.pyc" /NFL /NDL /NJH /NJS; if ($LASTEXITCODE -lt 8) { "sync ok" } else { "sync FAILED" }
```
(robocopy exit codes < 8 = success.) **Preflight:** if `K:\Github\odoo-modsdev\addons\fusion_clock` does not exist, the dual-tree setup changed — STOP and confirm the active copy with the user before continuing.
**Container/DB:** `odoo-modsdev-app` / db `modsdev` (per memory `reference_docker_env_names`).
**Canonical commands** (note the ephemeral ports — `--test-enable` forces `http_spawn()` so 8069/8072 collide without them; per repo CLAUDE.md):
- Run this module's tests:
```bash
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -100
```
- Plain upgrade (no tests):
```bash
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -50
```
- Pyflakes a changed Python file (catches undefined names instantly):
```bash
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/<relpath>.py
```
**Commit:** only from the git tree (`git -C "K:/Github/Odoo-Modules" ...`). Per memory `feedback_always_push_to_main`, push after each commit on `main`.
---
## File Structure
**Created:**
- `fusion_clock/models/clock_break_rule.py` — the `fusion.clock.break.rule` model + tier engine + constraints.
- `fusion_clock/data/clock_break_rule_data.xml` — seed Ontario rule (`is_default`).
- `fusion_clock/views/clock_break_rule_views.xml` — list/form/action for the rule.
- `fusion_clock/migrations/19.0.4.1.0/post-migrate.py` — drop retired param + recompute break.
- `fusion_clock/tests/test_break_rules.py` — all new tests.
**Modified:**
- `fusion_clock/models/__init__.py` — import the new model.
- `fusion_clock/models/hr_employee.py` — add `_get_fclk_break_rule()`.
- `fusion_clock/models/hr_attendance.py` — `x_fclk_break_minutes` → stored compute; drop cron break-write.
- `fusion_clock/controllers/clock_api.py` — delete `_apply_break_deduction`, its clock-out call, and the penalty break-write.
- `fusion_clock/controllers/clock_kiosk.py` — delete the `_apply_break_deduction` call.
- `fusion_clock/controllers/clock_nfc_kiosk.py` — delete the `_apply_break_deduction` call.
- `fusion_clock/models/res_config_settings.py` — remove `fclk_break_threshold_hours`.
- `fusion_clock/views/res_config_settings_views.xml` — remove threshold row; relabel default-break as scheduling-only; point to Break Rules.
- `fusion_clock/data/ir_config_parameter_data.xml` — remove the `break_threshold_hours` seed record.
- `fusion_clock/security/ir.model.access.csv` — manager access for the new model.
- `fusion_clock/views/clock_menus.xml` — "Break Rules" config menu.
- `fusion_clock/__manifest__.py` — version bump + new data/view files.
- `fusion_clock/tests/__init__.py` — import the new test module.
- `fusion_clock/tests/test_settings.py` — assert the retired field is gone.
- `fusion_clock/CLAUDE.md` — model map, settings keys, break gotcha (Task 5).
**Behaviour-change note (intentional, approved by spec §4.3):** today a *late-in* penalty written at clock-in (e.g. +15) is silently swallowed at clock-out because `_apply_break_deduction` does `max(break, current)`. The new compute makes **all** penalty minutes strictly additive (`statutory + Σ penalties`), so a late-in penalty on a long shift is no longer lost. Net hours for such shifts will be correctly lower than before.
---
## Task 1: New model `fusion.clock.break.rule`
**Files:**
- Create: `fusion_clock/models/clock_break_rule.py`
- Create: `fusion_clock/data/clock_break_rule_data.xml`
- Create: `fusion_clock/views/clock_break_rule_views.xml`
- Create: `fusion_clock/tests/test_break_rules.py`
- Modify: `fusion_clock/models/__init__.py`
- Modify: `fusion_clock/tests/__init__.py`
- Modify: `fusion_clock/security/ir.model.access.csv`
- Modify: `fusion_clock/views/clock_menus.xml`
- Modify: `fusion_clock/__manifest__.py`
- [ ] **Step 1: Write the failing tests** — create `fusion_clock/tests/test_break_rules.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import datetime, timedelta
from odoo.tests import tagged, TransactionCase
from odoo.exceptions import ValidationError
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestBreakRules(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.auto_deduct_break', 'True')
cls.Rule = cls.env['fusion.clock.break.rule']
cls.default_rule = cls.Rule.search([('is_default', '=', True)], limit=1)
cls.employee = cls.env['hr.employee'].create({'name': 'FCLK Break Test'})
def _mk_att(self, hours):
check_in = datetime(2026, 1, 5, 9, 0, 0)
return self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': check_in,
'check_out': check_in + timedelta(hours=hours),
})
# ---- Task 1: tier engine + constraints ----
def test_break_minutes_for_tiers(self):
rule = self.Rule.create({
'name': 'Tier Test', 'is_default': False,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
})
self.assertEqual(rule.break_minutes_for(4.99), 0.0)
self.assertEqual(rule.break_minutes_for(5.0), 30.0)
self.assertEqual(rule.break_minutes_for(9.99), 30.0)
self.assertEqual(rule.break_minutes_for(10.0), 60.0)
self.assertEqual(rule.break_minutes_for(12.0), 60.0)
def test_second_tier_must_exceed_first(self):
with self.assertRaises(ValidationError):
self.Rule.create({
'name': 'Bad', 'is_default': False,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 5.0, 'break2_minutes': 30.0,
})
def test_single_default_enforced(self):
self.assertTrue(self.default_rule, "seed default rule must exist")
with self.assertRaises(ValidationError):
self.Rule.create({
'name': 'Another Default', 'is_default': True, 'active': True,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
})
```
Append the import to `fusion_clock/tests/__init__.py` (add the line if not already present):
```python
from . import test_break_rules
```
- [ ] **Step 2: Create the model** — `fusion_clock/models/clock_break_rule.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
class FusionClockBreakRule(models.Model):
_name = 'fusion.clock.break.rule'
_description = 'Statutory Break Rule'
_order = 'sequence, name'
name = fields.Char(string='Name', required=True)
country_id = fields.Many2one('res.country', string='Country')
state_id = fields.Many2one(
'res.country.state',
string='Province / State',
help="Employees whose company is in this province use this rule.",
)
is_default = fields.Boolean(
string='Default Rule',
help="Used when an employee's company province matches no other rule. "
"Only one active rule may be the default.",
)
break1_after_hours = fields.Float(
string='First Break After (h)', default=5.0,
help="Worked hours at or above this trigger the first unpaid break.",
)
break1_minutes = fields.Float(
string='First Break (min)', default=30.0,
help="Length of the first unpaid break. 0 disables it.",
)
break2_after_hours = fields.Float(
string='Second Break After (h)', default=10.0,
help="Worked hours at or above this add the second unpaid break.",
)
break2_minutes = fields.Float(
string='Second Break (min)', default=30.0,
help="Length of the second unpaid break. 0 disables it.",
)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
def break_minutes_for(self, worked_hours):
"""Total statutory unpaid break (minutes) for the given worked hours.
Tiers are inclusive (``>=``): a break applies when worked hours are
equal to or greater than the threshold. The second tier adds on top of
the first.
"""
self.ensure_one()
worked = worked_hours or 0.0
total = 0.0
if self.break1_minutes and worked >= self.break1_after_hours:
total += self.break1_minutes
if self.break2_minutes and worked >= self.break2_after_hours:
total += self.break2_minutes
return total
@api.constrains('break1_after_hours', 'break1_minutes',
'break2_after_hours', 'break2_minutes')
def _check_tiers(self):
for rule in self:
if min(rule.break1_after_hours, rule.break1_minutes,
rule.break2_after_hours, rule.break2_minutes) < 0:
raise ValidationError(_("Break hours and minutes cannot be negative."))
if rule.break2_minutes and rule.break2_after_hours <= rule.break1_after_hours:
raise ValidationError(_(
"The second break threshold (%(n2)s h) must be greater than "
"the first (%(n1)s h).",
n2=rule.break2_after_hours, n1=rule.break1_after_hours))
@api.constrains('is_default', 'active')
def _check_single_default(self):
for rule in self:
if rule.is_default and rule.active:
dupe = self.search([
('is_default', '=', True), ('active', '=', True),
('id', '!=', rule.id),
], limit=1)
if dupe:
raise ValidationError(_(
"Only one active break rule can be the default "
"(currently: %s).", dupe.name))
```
- [ ] **Step 3: Register the model** — add to `fusion_clock/models/__init__.py` after the `clock_penalty` import:
```python
from . import clock_break_rule
```
- [ ] **Step 4: Grant access** — append one row to `fusion_clock/security/ir.model.access.csv`:
```
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
```
(No user/portal grant needed — the resolver reads the table via `sudo()`.)
- [ ] **Step 5: Seed the Ontario rule** — create `fusion_clock/data/clock_break_rule_data.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="break_rule_ontario" model="fusion.clock.break.rule">
<field name="name">Ontario</field>
<field name="country_id" ref="base.ca"/>
<field name="state_id" ref="base.state_ca_on"/>
<field name="is_default" eval="True"/>
<field name="break1_after_hours">5.0</field>
<field name="break1_minutes">30.0</field>
<field name="break2_after_hours">10.0</field>
<field name="break2_minutes">30.0</field>
</record>
</odoo>
```
- [ ] **Step 6: Views + action** — create `fusion_clock/views/clock_break_rule_views.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_clock_break_rule_list" model="ir.ui.view">
<field name="name">fusion.clock.break.rule.list</field>
<field name="model">fusion.clock.break.rule</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="state_id"/>
<field name="country_id" optional="hide"/>
<field name="break1_after_hours" widget="float_time"/>
<field name="break1_minutes"/>
<field name="break2_after_hours" widget="float_time"/>
<field name="break2_minutes"/>
<field name="is_default"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fusion_clock_break_rule_form" model="ir.ui.view">
<field name="name">fusion.clock.break.rule.form</field>
<field name="model">fusion.clock.break.rule</field>
<field name="arch" type="xml">
<form>
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
invisible="active"/>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Ontario"/></h1>
</div>
<group>
<group string="Jurisdiction">
<field name="country_id"/>
<field name="state_id"
domain="[('country_id', '=', country_id)]"/>
<field name="is_default"/>
<field name="active"/>
</group>
<group string="Unpaid Break Tiers">
<label for="break1_after_hours" string="First break after"/>
<div class="o_row">
<field name="break1_after_hours" widget="float_time"/>
<span>h →</span>
<field name="break1_minutes"/>
<span>min</span>
</div>
<label for="break2_after_hours" string="Second break after"/>
<div class="o_row">
<field name="break2_after_hours" widget="float_time"/>
<span>h →</span>
<field name="break2_minutes"/>
<span>min</span>
</div>
</group>
</group>
<p class="text-muted">
Breaks are unpaid and deducted from actual worked hours. A tier with
0 minutes is disabled. Triggers are inclusive — a break applies when
worked hours are equal to or above the threshold.
</p>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_clock_break_rule" model="ir.actions.act_window">
<field name="name">Break Rules</field>
<field name="res_model">fusion.clock.break.rule</field>
<field name="view_mode">list,form</field>
<field name="context">{'active_test': False}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Create a statutory break rule</p>
<p>Define unpaid meal-break thresholds per province/country. Employees inherit
the rule matching their company's province, or the default rule.</p>
</field>
</record>
</odoo>
```
- [ ] **Step 7: Add the menu** — in `fusion_clock/views/clock_menus.xml`, insert after the `menu_fusion_clock_locations_config` menuitem (the Locations config item) and before `menu_fusion_clock_nfc_enrollment`:
```xml
<menuitem id="menu_fusion_clock_break_rules"
name="Break Rules"
parent="menu_fusion_clock_config"
action="action_fusion_clock_break_rule"
sequence="25"
groups="group_fusion_clock_manager"/>
```
- [ ] **Step 8: Wire the manifest** — in `fusion_clock/__manifest__.py`:
**Do NOT bump the version yet** — it stays `19.0.4.0.3` until Task 4, so the
`19.0.4.1.0` migration actually fires in dev (Odoo only runs a version's migration
when the installed version is *lower* than the manifest version).
Add the seed data file after `'data/ir_config_parameter_data.xml',`:
```python
'data/clock_break_rule_data.xml',
```
Add the view file after `'views/clock_schedule_views.xml',`:
```python
'views/clock_break_rule_views.xml',
```
(Data and view files reload on every `-u` regardless of the version number, so the
new model/menu install without a bump. No assets change in this plan, so the bump's
only purpose is the migration trigger — deferred to Task 4.)
- [ ] **Step 9: Sync, upgrade, run tests**
Sync (see preamble), then:
```bash
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -100
```
Expected: module upgrades cleanly; `test_break_minutes_for_tiers`, `test_second_tier_must_exceed_first`, `test_single_default_enforced` PASS. (Other tests in the class will error until Tasks 23 add their dependencies — that's expected if you scoped the run; otherwise the not-yet-added methods simply don't exist yet.)
- [ ] **Step 10: Commit**
```bash
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/clock_break_rule.py fusion_clock/models/__init__.py fusion_clock/data/clock_break_rule_data.xml fusion_clock/views/clock_break_rule_views.xml fusion_clock/views/clock_menus.xml fusion_clock/security/ir.model.access.csv fusion_clock/__manifest__.py fusion_clock/tests/test_break_rules.py fusion_clock/tests/__init__.py
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): add fusion.clock.break.rule per-province break table" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
```
---
## Task 2: Jurisdiction resolver on `hr.employee`
**Files:**
- Modify: `fusion_clock/models/hr_employee.py`
- Modify: `fusion_clock/tests/test_break_rules.py`
- [ ] **Step 1: Add the resolver tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`:
```python
# ---- Task 2: jurisdiction resolver ----
def test_resolver_matches_company_province(self):
bc = self.env.ref('base.state_ca_bc')
bc_rule = self.Rule.create({
'name': 'British Columbia', 'state_id': bc.id, 'is_default': False,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
})
self.employee.company_id.state_id = bc.id
self.assertEqual(self.employee._get_fclk_break_rule(), bc_rule)
def test_resolver_falls_back_to_default(self):
self.assertTrue(self.default_rule, "seed default rule must exist")
alberta = self.env.ref('base.state_ca_ab') # no rule for AB
self.employee.company_id.state_id = alberta.id
self.assertEqual(self.employee._get_fclk_break_rule(), self.default_rule)
```
- [ ] **Step 2: Run to verify they fail**
Sync, then:
```bash
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
```
Expected: FAIL — `AttributeError: 'hr.employee' object has no attribute '_get_fclk_break_rule'`.
- [ ] **Step 3: Implement the resolver** — in `fusion_clock/models/hr_employee.py`, add this method immediately after the `_get_fclk_break_minutes` method (after its `return float(...)` block, before `_get_fclk_scheduled_times`):
```python
def _get_fclk_break_rule(self):
"""Return the statutory break rule for this employee.
Resolution: company's province → matching rule; else the global default
rule; else an empty recordset (caller treats as zero break). Read via
sudo so the portal net-hours compute can resolve it without a direct ACL.
"""
self.ensure_one()
Rule = self.env['fusion.clock.break.rule'].sudo()
rule = Rule.browse()
state = self.company_id.state_id
if state:
rule = Rule.search([('state_id', '=', state.id)], limit=1)
if not rule:
rule = Rule.search([('is_default', '=', True)], limit=1)
return rule
```
- [ ] **Step 4: Run to verify they pass**
Sync, then re-run the Step 2 command. Expected: `test_resolver_matches_company_province` and `test_resolver_falls_back_to_default` PASS.
- [ ] **Step 5: Commit**
```bash
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_employee.py fusion_clock/tests/test_break_rules.py
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): resolve employee break rule from company province" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
```
---
## Task 3: `x_fclk_break_minutes` → stored compute; remove all manual writes
This task is atomic: once the field is computed (no inverse), any remaining `write({'x_fclk_break_minutes': ...})` raises at runtime, so the field conversion and the removal of all four write sites must land together.
**Files:**
- Modify: `fusion_clock/models/hr_attendance.py`
- Modify: `fusion_clock/controllers/clock_api.py`
- Modify: `fusion_clock/controllers/clock_kiosk.py`
- Modify: `fusion_clock/controllers/clock_nfc_kiosk.py`
- Modify: `fusion_clock/tests/test_break_rules.py`
- [ ] **Step 1: Add the attendance tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`:
```python
# ---- Task 3: automatic deduction on every path ----
def test_manual_attendance_applies_statutory_break(self):
att = self._mk_att(6) # 6h >= 5 -> first break
self.assertEqual(att.x_fclk_break_minutes, 30.0)
self.assertAlmostEqual(att.x_fclk_net_hours, 5.5, places=2)
def test_manual_edit_extends_break(self):
att = self._mk_att(6)
self.assertEqual(att.x_fclk_break_minutes, 30.0)
att.check_out = att.check_in + timedelta(hours=10) # now >= 10
self.assertEqual(att.x_fclk_break_minutes, 60.0)
self.assertAlmostEqual(att.x_fclk_net_hours, 9.0, places=2)
def test_under_first_threshold_no_break(self):
att = self._mk_att(4) # 4h < 5 -> nothing
self.assertEqual(att.x_fclk_break_minutes, 0.0)
self.assertAlmostEqual(att.x_fclk_net_hours, 4.0, places=2)
def test_penalty_minutes_are_additive(self):
att = self._mk_att(6) # statutory 30
self.env['fusion.clock.penalty'].create({
'attendance_id': att.id,
'employee_id': self.employee.id,
'penalty_type': 'early_out',
'penalty_minutes': 15.0,
'date': att.check_in.date(),
})
self.assertEqual(att.x_fclk_break_minutes, 45.0)
def test_master_toggle_off_zero_statutory(self):
self.ICP.set_param('fusion_clock.auto_deduct_break', 'False')
att = self._mk_att(6)
self.assertEqual(att.x_fclk_break_minutes, 0.0)
def test_open_attendance_zero_break(self):
att = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2026, 1, 5, 9, 0, 0),
})
self.assertEqual(att.x_fclk_break_minutes, 0.0)
```
- [ ] **Step 2: Run to verify they fail**
Sync, then run the module tests. Expected: the new tests FAIL — e.g. `test_manual_attendance_applies_statutory_break` asserts 30 but gets 0 (no write override exists yet).
- [ ] **Step 3: Convert the field to a stored compute** — in `fusion_clock/models/hr_attendance.py`, replace the field definition:
OLD:
```python
x_fclk_break_minutes = fields.Float(
string='Break (min)',
default=0.0,
tracking=True,
help="Break duration in minutes to deduct from worked hours.",
)
```
NEW:
```python
x_fclk_break_minutes = fields.Float(
string='Break (min)',
compute='_compute_fclk_break_minutes',
store=True,
tracking=True,
help="Unpaid break deducted from worked hours: statutory break (per the "
"employee's province rule, from actual hours worked) plus any penalty "
"minutes. Computed automatically on every save.",
)
```
- [ ] **Step 4: Add the compute method** — in the same file, insert this method immediately before the `_compute_net_hours` method (just above its `@api.depends('worked_hours', 'x_fclk_break_minutes')` decorator):
```python
@api.depends('worked_hours', 'check_out',
'x_fclk_penalty_ids.penalty_minutes', 'employee_id')
def _compute_fclk_break_minutes(self):
ICP = self.env['ir.config_parameter'].sudo()
auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True'
for att in self:
statutory = 0.0
if auto and att.check_out and att.employee_id:
rule = att.employee_id._get_fclk_break_rule()
if rule:
statutory = rule.break_minutes_for(att.worked_hours or 0.0)
penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes'))
att.x_fclk_break_minutes = statutory + penalties
```
- [ ] **Step 5: Remove the cron's break write** — in the same file, inside `_cron_fusion_auto_clock_out`:
Remove the now-unused threshold read (the line near the top of the method):
```python
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
```
Remove the two now-unused locals in the per-attendance loop:
```python
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
```
Remove the break-write block (the compute now applies the break when `check_out` is set):
```python
if (att.worked_hours or 0) >= threshold:
att.sudo().write(
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
)
```
(Leave the surrounding `employee = att.employee_id` and `clock_out_time = effective_deadline` lines intact.)
- [ ] **Step 6: Delete the controller helper and its call sites** — in `fusion_clock/controllers/clock_api.py`:
Delete the entire `_apply_break_deduction` method:
```python
def _apply_break_deduction(self, attendance, employee):
"""Apply automatic break deduction if configured."""
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
return
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
worked = attendance.worked_hours or 0.0
if worked >= threshold:
local_date = get_local_today(request.env, employee)
if attendance.check_in:
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
break_min = employee._get_fclk_break_minutes(local_date)
current = attendance.x_fclk_break_minutes or 0.0
# Set to whichever is higher: configured break or existing (penalty-inflated) value
new_val = max(break_min, current)
if new_val != current:
attendance.sudo().write({'x_fclk_break_minutes': new_val})
```
Delete its clock-out call (in the CLOCK OUT branch):
```python
# Apply break deduction
self._apply_break_deduction(attendance, employee)
```
Delete the penalty break-write in `_check_and_create_penalty` (keep the penalty-record `create` above it and the activity log below it):
```python
# Deduct penalty minutes from attendance (adds to break deduction)
current_break = attendance.x_fclk_break_minutes or 0.0
attendance.sudo().write({
'x_fclk_break_minutes': current_break + deduction,
})
```
- [ ] **Step 7: Delete the kiosk call sites**
In `fusion_clock/controllers/clock_kiosk.py`, delete the line:
```python
api._apply_break_deduction(attendance, employee)
```
In `fusion_clock/controllers/clock_nfc_kiosk.py`, delete the line:
```python
api._apply_break_deduction(attendance, employee)
```
- [ ] **Step 8: Pyflakes the touched controllers/models** (catches a missed `pytz`/var reference instantly)
```bash
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/controllers/clock_api.py /mnt/extra-addons/fusion_clock/controllers/clock_kiosk.py /mnt/extra-addons/fusion_clock/controllers/clock_nfc_kiosk.py /mnt/extra-addons/fusion_clock/models/hr_attendance.py
```
Expected: no output (clean). If it flags `pytz` as unused in `hr_attendance.py`, that's fine only if no other code uses it — verify before removing the import (the absence/overtime crons still use `pytz`, so leave the import).
- [ ] **Step 9: Run to verify all Task 3 tests pass**
Sync, then run the module tests. Expected: all `test_manual_*`, `test_under_first_threshold_no_break`, `test_penalty_minutes_are_additive`, `test_master_toggle_off_zero_statutory`, `test_open_attendance_zero_break` PASS, and the existing NFC/kiosk/dashboard tests still PASS.
- [ ] **Step 10: Commit**
```bash
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_attendance.py fusion_clock/controllers/clock_api.py fusion_clock/controllers/clock_kiosk.py fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_break_rules.py
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): auto-apply statutory break via one stored compute" -m "x_fclk_break_minutes is now statutory(worked_hours) + penalties, recomputed on every path including manual backend entry. Removes the four duplicated write sites (controller _apply_break_deduction + 3 call sites, auto-clock-out cron, penalty write)." -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
```
---
## Task 4: Retire `break_threshold_hours`; clean settings & migrate
**Files:**
- Modify: `fusion_clock/models/res_config_settings.py`
- Modify: `fusion_clock/views/res_config_settings_views.xml`
- Modify: `fusion_clock/data/ir_config_parameter_data.xml`
- Create: `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`
- Modify: `fusion_clock/tests/test_settings.py`
- [ ] **Step 1: Add the dead-setting assertion** — in `fusion_clock/tests/test_settings.py`, add one line to `test_dead_settings_removed`:
```python
self.assertNotIn('fclk_break_threshold_hours', fields)
```
- [ ] **Step 2: Remove the settings field** — in `fusion_clock/models/res_config_settings.py`, delete:
```python
fclk_break_threshold_hours = fields.Float(
string='Break Threshold (hours)',
config_parameter='fusion_clock.break_threshold_hours',
default=4.0,
help="Only deduct break if shift is longer than this many hours.",
)
```
- [ ] **Step 3: Fix the settings view** — in `fusion_clock/views/res_config_settings_views.xml`, replace the whole `fclk_auto_break` setting block:
OLD:
```xml
<setting id="fclk_auto_break" string="Auto-Deduct Break"
help="Automatically deduct unpaid break from worked hours on clock-out.">
<field name="fclk_auto_deduct_break"/>
<div class="content-group" invisible="not fclk_auto_deduct_break">
<div class="row mt16">
<label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_default_break_minutes"/>
</div>
<div class="row mt8">
<label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/>
<field name="fclk_break_threshold_hours" widget="float_time"/>
</div>
</div>
</setting>
```
NEW:
```xml
<setting id="fclk_auto_break" string="Auto-Deduct Break"
help="Automatically deduct the statutory unpaid break from worked hours. Break lengths and thresholds are configured per province under Configuration → Break Rules.">
<field name="fclk_auto_deduct_break"/>
<div class="content-group" invisible="not fclk_auto_deduct_break">
<div class="row mt16">
<label for="fclk_default_break_minutes" string="Default scheduling break (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_default_break_minutes"/>
</div>
<div class="text-muted small mt4">
Used as the default break when building shifts/schedules
(planned hours). Actual deductions follow the province Break Rules.
</div>
</div>
</setting>
```
- [ ] **Step 4: Remove the seed param** — in `fusion_clock/data/ir_config_parameter_data.xml`, delete:
```xml
<record id="config_break_threshold_hours" model="ir.config_parameter">
<field name="key">fusion_clock.break_threshold_hours</field>
<field name="value">4.0</field>
</record>
```
- [ ] **Step 5: Bump the version + create the migration**
First bump the manifest so the migration fires (installed `19.0.4.0.3` < manifest
`19.0.4.1.0`). In `fusion_clock/__manifest__.py`:
```python
'version': '19.0.4.1.0',
```
Then create `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, SUPERUSER_ID
def migrate(cr, version):
"""Retire the single-threshold break param (superseded by per-rule
break1_after_hours), and force-recompute the now-computed break field so
existing closed attendances reflect the province rule + their penalties."""
cr.execute(
"DELETE FROM ir_config_parameter WHERE key = %s",
('fusion_clock.break_threshold_hours',),
)
env = api.Environment(cr, SUPERUSER_ID, {})
Attendance = env['hr.attendance']
field = Attendance._fields['x_fclk_break_minutes']
closed = Attendance.search([('check_out', '!=', False)])
if closed:
env.add_to_compute(field, closed)
closed.flush_recordset(['x_fclk_break_minutes'])
```
- [ ] **Step 6: Sync, upgrade, run tests**
Sync, then run the module tests. Expected: module upgrades cleanly and the `19.0.4.1.0` migration executes (installed `19.0.4.0.3` < manifest `19.0.4.1.0`; modsdev shows the INFO line, nexa/entech run `log_level=warn`), `test_dead_settings_removed` PASS, full `fusion_clock` suite green.
- [ ] **Step 7: Verify the param is gone and historical rows recomputed** (sanity)
```bash
docker exec odoo-modsdev-app odoo shell -d modsdev --no-http 2>/dev/null <<'PY'
ICP = env['ir.config_parameter'].sudo()
print('threshold param:', ICP.get_param('fusion_clock.break_threshold_hours', 'ABSENT'))
print('default rule:', env['fusion.clock.break.rule'].search([('is_default','=',True)]).mapped('name'))
PY
```
Expected: `threshold param: ABSENT`; `default rule: ['Ontario']`.
- [ ] **Step 8: Commit**
```bash
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml fusion_clock/data/ir_config_parameter_data.xml fusion_clock/migrations/19.0.4.1.0/post-migrate.py fusion_clock/tests/test_settings.py fusion_clock/__manifest__.py
git -C "K:/Github/Odoo-Modules" commit -m "refactor(fusion_clock): retire break_threshold_hours; breaks now driven by Break Rules" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
```
---
## Task 5: Full verification, docs, manual smoke
**Files:**
- Modify: `fusion_clock/CLAUDE.md`
- [ ] **Step 1: Full test run (whole module)**
Sync, then:
```bash
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -120
```
Expected: all `fusion_clock` tests PASS, zero tracebacks. If anything fails, fix before continuing.
- [ ] **Step 2: Manual smoke (manager UI)** at http://localhost:8082
- Configuration → **Break Rules** exists; the **Ontario** row shows 5h→30 / 10h→30, Default ticked.
- Attendances → create a manual attendance, check-in 09:00 check-out 15:00 (6h) → **Break = 30**, Net = 5.5h, with no clock action.
- Edit that record's check-out to 19:00 (10h) → **Break = 60**, Net = 9.0h.
- Create a 4h attendance → **Break = 0**.
- Settings → the old "Min. Shift" threshold field is gone; the Auto-Deduct Break help points to Break Rules.
- [ ] **Step 3: Update the module CLAUDE.md** — in `fusion_clock/CLAUDE.md`:
- §4 Model Map: add a row — `fusion.clock.break.rule | models/clock_break_rule.py | Per-province statutory unpaid-break thresholds (2-tier).`
- §5 Clocking Flow: note that the break deduction is no longer a controller step — `x_fclk_break_minutes` is a stored compute (`statutory(worked_hours) + Σ penalties`) that fires on every path including manual backend entry; resolved rule via `hr.employee._get_fclk_break_rule()` (company province → default).
- §11 Settings Keys: remove `fusion_clock.break_threshold_hours`.
- §13 Gotchas: add — "Unpaid break is computed, not written: never `write({'x_fclk_break_minutes': ...})`; change the province rule (`fusion.clock.break.rule`) or `auto_deduct_break` instead. Penalty minutes are now strictly additive (the old `max()` that swallowed late-in penalties is gone)."
- Bump the version line in §1 to `19.0.4.1.0`.
- [ ] **Step 4: Commit the docs**
```bash
git -C "K:/Github/Odoo-Modules" add fusion_clock/CLAUDE.md
git -C "K:/Github/Odoo-Modules" commit -m "docs(fusion_clock): document province break rules + computed break field" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
```
- [ ] **Step 5: Report** — summarize what changed, the behaviour-change note (penalties now additive), and that live deployment to entech (`odoo-entech`) is a separate step pending user sign-off.
---
## Self-Review (performed against the spec)
**1. Spec coverage**
- §4.1 model → Task 1. §4.2 resolver → Task 2. §4.3 stored compute → Task 3. §4.4 removals → Task 3 (writes) + Task 4 (setting/param/view). §4.5 UI/security/data → Task 1 (+ settings view in Task 4). §5 edge cases → tests in Tasks 1 & 3. §6 migration → Task 4. §7 tests → all six+ cases present across Tasks 13. §8 rollout → preamble + Task 5. ✓ No gaps.
**2. Placeholder scan** — every step has full code/commands; no TBD/TODO/"similar to". ✓
**3. Type/name consistency** — `break_minutes_for`, `_get_fclk_break_rule`, `_compute_fclk_break_minutes`, fields `break1_after_hours/break1_minutes/break2_after_hours/break2_minutes/is_default`, model `fusion.clock.break.rule`, access id `model_fusion_clock_break_rule`, action `action_fusion_clock_break_rule`, menu `menu_fusion_clock_break_rules` — all used identically across tasks. The compute folds `Σ penalty_minutes` (field `penalty_minutes` on `fusion.clock.penalty`, confirmed). ✓

View File

@@ -0,0 +1,43 @@
# Accessibility Funding-Source Selector — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline) — this is a 3-file change. Steps use `- [ ]` checkboxes.
**Goal:** Let the rep mark an accessibility assessment's funding source (Private / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other) on the web form, so the generated sale order routes to the correct funding pipeline instead of always defaulting to private pay.
**Architecture:** The model (`fusion.accessibility.assessment.x_fc_funding_source`) and the SO routing (`_create_draft_sale_order``sale_type_map``x_fc_sale_type`) already exist (the "2026-04 portal audit fix"). The only gaps: (1) the form has no funding field, (2) the save controller never reads `funding_source` from the POST, (3) `hardship` is missing from the selectable funding sources. The submit JS already serialises every named form field via `FormData`, so no JS change is needed.
**Tech Stack:** Odoo 19, QWeb portal template, JSON-RPC controller. Module `fusion_portal` (worktree `K:\Github\Odoo-Modules-wt-portal`, branch `feat/assessment-visit`).
**Verification constraint:** `fusion_portal` depends on Enterprise `knowledge`, so it can NOT be installed on the local Community Docker. Syntax-check with host Python; functional verification is on westin (or a clone): pick "March of Dimes" on a form → the draft SO gets `x_fc_sale_type='march_of_dimes'` and lands in the MOD pipeline.
---
### Task 1: Add Hardship to the funding source + route it
**Files:** Modify `fusion_portal/models/accessibility_assessment.py` (selection ~:71-87, `sale_type_map` ~:771-779)
- [ ] **Step 1:** Add `('hardship', 'Hardship Funding')` to the `x_fc_funding_source` selection list (after `'wsib'`).
- [ ] **Step 2:** Add `'hardship': 'hardship',` to `sale_type_map` in `_create_draft_sale_order` (the target `x_fc_sale_type='hardship'` already exists in `fusion_claims` `sale_order.py:332`).
- [ ] **Step 3:** `python -m py_compile fusion_portal/models/accessibility_assessment.py` → no error.
- [ ] **Step 4:** Commit.
### Task 2: Add the funding select to the shared client-info form
**Files:** Modify `fusion_portal/views/portal_accessibility_templates.xml` (`accessibility_client_info_section`, ~:366-375)
- [ ] **Step 1:** Add a new row with a `<select name="funding_source">` (options mirror the model selection; `direct_private` pre-selected so existing private behaviour is unchanged) right after the phone/email row, before the card closes.
- [ ] **Step 2:** Validate XML well-formedness (`[xml]` parse).
- [ ] **Step 3:** Commit.
### Task 3: Capture funding_source in the save controller
**Files:** Modify `fusion_portal/controllers/portal_main.py` (`accessibility_assessment_save` vals, ~:2498-2511)
- [ ] **Step 1:** Add `'x_fc_funding_source': post.get('funding_source') or 'direct_private',` to the `vals` dict.
- [ ] **Step 2:** `python -m pyflakes fusion_portal/controllers/portal_main.py` → no new undefined-name errors.
- [ ] **Step 3:** Commit.
### Task 4: Verify + ship
- [ ] **Step 1:** Grep confirms `funding_source` flows form → controller → `x_fc_funding_source``sale_type_map`.
- [ ] **Step 2:** Deploy to westin (backup → scp the 3 files → `-u fusion_portal` → cache-bust → restart) and confirm: open `/my/accessibility/stairlift/straight`, pick "March of Dimes", complete → the new SO shows `x_fc_sale_type = march_of_dimes` and appears in the MOD pipeline.

View File

@@ -109,7 +109,7 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
| T1 | **Open in Maps button on task** | 2 | `geo:` / Apple Maps URL; one-tap |
| T2 | **AI pre-visit brief on mobile form** | 2 | Surfaces `x_fc_ai_summary` prominently; "What to bring" + safety flags |
| T3 | Labour timer via fusion_clock | 3 | Tap Start/Pause; final time pre-fills visit report |
| T4 | **Client signature on completion** | 2 | OWL signature pad on visit report wizard; attached to repair (pattern from [`fusion_authorizer_portal`](fusion_authorizer_portal)) |
| T4 | **Client signature on completion** | 2 | OWL signature pad on visit report wizard; attached to repair (pattern from [`fusion_portal`](fusion_portal)) |
| T5 | "Found another issue" button | 2 | Spawn new repair from current visit, same partner, different equipment |
| T6 | Parts replaced — serial capture | 3 | Scan/type replaced part serials; stores for OEM warranty + traceability |
| T7 | No-show photo proof | 3 | "Client not home" → camera → photo attached → repair flagged + service-call fee added |
@@ -164,11 +164,11 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
| CL19 | **Voice input → AI transcription** | 4 | Client speaks the problem into mic, AI transcribes + classifies |
| CL20 | **Resolution survey + Google review** | 2 | After "resolved" outcome, ask "save you time today?" + Google review CTA |
### Sales rep portal (mirrors fusion_authorizer_portal pattern)
### Sales rep portal (mirrors fusion_portal pattern)
| ID | Feature | Phase | Notes |
|----|---------|-------|-------|
| S1 | **Sales rep web intake form** | 1 | `/my/repair/new` — same question flow as backend wizard, mobile-friendly. Reuses `is_sales_rep_portal` flag on `res.partner` from [`fusion_authorizer_portal/security/portal_security.xml`](fusion_authorizer_portal/security/portal_security.xml) line 11 |
| S1 | **Sales rep web intake form** | 1 | `/my/repair/new` — same question flow as backend wizard, mobile-friendly. Reuses `is_sales_rep_portal` flag on `res.partner` from [`fusion_portal/security/portal_security.xml`](fusion_portal/security/portal_security.xml) line 11 |
| S2 | Sales rep dashboard tile | 1 | Add "Service Calls" tile to `/my/sales-rep/dashboard` showing count of repairs they logged + recent 5 |
| S3 | **My Service Calls** list page | 1 | `/my/repairs` — sales rep sees their submitted repairs, status, assigned tech, scheduled date |
| S4 | View repair status from portal | 1 | `/my/repair/<id>` — read-only timeline, chatter for non-internal messages, ability to add a comment |
@@ -183,7 +183,7 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
**Routing namespace:** `/my/repair/*` (intake + my list) and a `/my/sales-rep/repairs` summary route added to the existing sales rep dashboard.
**Record rule** (mirrors [`fusion_authorizer_portal/security/portal_security.xml`](fusion_authorizer_portal/security/portal_security.xml) line 129 pattern):
**Record rule** (mirrors [`fusion_portal/security/portal_security.xml`](fusion_portal/security/portal_security.xml) line 129 pattern):
```xml
<record id="rule_repair_order_sales_rep_portal" model="ir.rule">
@@ -236,7 +236,7 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
'website', # QWeb portal templates
'fusion_tasks', # technician tasks + fusion.email.builder.mixin
'fusion_poynt', # payment collection
'fusion_authorizer_portal', # sales rep portal flag + group + dashboard scaffold
'fusion_portal', # sales rep portal flag + group + dashboard scaffold
]
# Phase 3 soft-add: 'appointment', 'fusion_schedule' for client self-booking
# Phase 3 soft-add: 'fusion_clock' for tech labour timer (T3)
@@ -245,7 +245,7 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
# Phase 3 soft-add: 'fusion_ringcentral' for SMS verify (CL12) + voicemail greeting (CL16) + caller-ID launch (Phase 4)
# Phase 4 soft-add: 'fusion_shipping', 'fusion_canada_post' for mail-in repairs (M4)
# Soft-call (no depend) at runtime: 'fusion.api.service' via try/except per fusion-api-integration rule
# NOTE: fusion_authorizer_portal transitively pulls fusion_claims — accepted for portal reuse
# NOTE: fusion_portal transitively pulls fusion_claims — accepted for portal reuse
```
Before coding any Odoo 19 view/JS, read reference files from local OrbStack Docker per project rules.
@@ -712,7 +712,7 @@ Themes adapt via project SCSS rules — no hardcoded colours per CLAUDE.md.
---
## Sales rep portal (Phase 1 — mirrors fusion_authorizer_portal)
## Sales rep portal (Phase 1 — mirrors fusion_portal)
**Goal:** A sales rep on the road takes a client call and submits a repair request from their phone — same intake flow as backend CS wizard, no Odoo login screen.
@@ -720,10 +720,10 @@ Themes adapt via project SCSS rules — no hardcoded colours per CLAUDE.md.
| Option | Recommendation |
|--------|----------------|
| **Hard depend on `fusion_authorizer_portal`** | RECOMMENDED — reuses the existing `is_sales_rep_portal` flag, `group_sales_rep_portal`, sales rep dashboard scaffolding. Transitively pulls fusion_claims (already core in your stack). |
| **Hard depend on `fusion_portal`** | RECOMMENDED — reuses the existing `is_sales_rep_portal` flag, `group_sales_rep_portal`, sales rep dashboard scaffolding. Transitively pulls fusion_claims (already core in your stack). |
| Soft depend (try/except + own fallback flag) | Possible but doubles the code: own `is_sales_rep_portal` mirror + own group. Only worth it if you ever want fusion_repairs standalone. |
We go with hard depend. Add `fusion_authorizer_portal` to the manifest `depends` list.
We go with hard depend. Add `fusion_portal` to the manifest `depends` list.
### Architecture
@@ -740,7 +740,7 @@ flowchart LR
### Controller layout ([`controllers/portal_sales_rep_repair.py`](fusion_repairs/controllers/portal_sales_rep_repair.py))
Routes scoped to `is_sales_rep_portal` users (gate at controller top, pattern from [`fusion_authorizer_portal/controllers/portal_assessment.py`](fusion_authorizer_portal/controllers/portal_assessment.py) line 25):
Routes scoped to `is_sales_rep_portal` users (gate at controller top, pattern from [`fusion_portal/controllers/portal_assessment.py`](fusion_portal/controllers/portal_assessment.py) line 25):
| Route | Type | Purpose |
|-------|------|---------|
@@ -767,14 +767,14 @@ Avoids the trap of two intake flows drifting out of sync.
### Templates ([`views/portal_sales_rep_templates.xml`](fusion_repairs/views/portal_sales_rep_templates.xml))
QWeb templates following [`fusion_authorizer_portal/views/portal_assessment_express.xml`](fusion_authorizer_portal/views/portal_assessment_express.xml) style:
QWeb templates following [`fusion_portal/views/portal_assessment_express.xml`](fusion_portal/views/portal_assessment_express.xml) style:
- `portal_repair_intake_form` — multi-step (accordion or stepper) with same 5 sections as backend wizard
- `portal_repair_list` — card list with status badge, scheduled date, tech name
- `portal_repair_detail` — timeline + chatter
- `portal_repair_intake_thanks` — confirmation page with "Submit Another" button (common on multi-call days)
Reuses portal gradient/header style via `portal_gradient` template variable already set by [`portal_main.home()`](fusion_authorizer_portal/controllers/portal_main.py) line 85.
Reuses portal gradient/header style via `portal_gradient` template variable already set by [`portal_main.home()`](fusion_portal/controllers/portal_main.py) line 85.
### JS ([`static/src/js/portal_repair_intake.js`](fusion_repairs/static/src/js/portal_repair_intake.js))
@@ -860,7 +860,7 @@ Extend repair order form view with Intake tab (answers), Maintenance tab, and st
**Reused (do NOT recreate):**
- [`fusion_tasks.group_field_technician`](fusion_tasks/security/security.xml) — for technician access to `repair.order` (parallel to existing tech task rules). Same domain `('technician_id', '=', user.id)` adapted as `('x_fc_technician_task_ids.technician_id', '=', user.id)` on repair orders
- [`fusion_authorizer_portal.group_sales_rep_portal`](fusion_authorizer_portal/security/portal_security.xml) — for sales rep portal access (see Sales rep portal section)
- [`fusion_portal.group_sales_rep_portal`](fusion_portal/security/portal_security.xml) — for sales rep portal access (see Sales rep portal section)
**New groups specific to fusion_repairs:**
- `group_fusion_repairs_user` — CS intake, view repairs (implied by `base.group_user`)
@@ -896,7 +896,7 @@ Extend repair order form view with Intake tab (answers), Maintenance tab, and st
**Sales rep portal (S1-S4, S6, S8):**
- Portal controllers `/my/repair/new`, `/my/repairs`, `/my/repair/<id>`
- Mobile-friendly QWeb templates following [`fusion_authorizer_portal/views/portal_assessment_express.xml`](fusion_authorizer_portal/views/portal_assessment_express.xml) style
- Mobile-friendly QWeb templates following [`fusion_portal/views/portal_assessment_express.xml`](fusion_portal/views/portal_assessment_express.xml) style
- Same intake question flow as backend (via shared service layer)
- Mobile photo / camera capture
- Client history sidebar exposed in portal form
@@ -1137,7 +1137,7 @@ After implementation, test on local dev only:
| Backend wizard and sales rep portal drift apart | Both call the same `fusion.repair.intake.service.create_repair_orders(payload)` AbstractModel method; no duplicate business logic |
| Sales rep accidentally sees other reps' repairs | Record rule `('x_fc_intake_user_id', '=', user.id)` scoped to `base.group_portal`; integration test asserts cross-rep isolation |
| Portal form abandoned mid-flow on call drop | Save partial state to `localStorage` keyed by partner + timestamp; "Resume" prompt on `/my/repair/new` if recent draft exists |
| fusion_authorizer_portal install becomes mandatory | Documented in module description; if a deployment doesn't want fusion_authorizer_portal, fall back to a `fusion_repairs_portal_lite` companion module that recreates only the `is_sales_rep_portal` flag |
| fusion_portal install becomes mandatory | Documented in module description; if a deployment doesn't want fusion_portal, fall back to a `fusion_repairs_portal_lite` companion module that recreates only the `is_sales_rep_portal` flag |
| **Public form spam / abuse** | reCAPTCHA v3 + honeypot + per-IP rate limit + per-phone rate limit + SMS verify before submit (Phase 2). Block ASN ranges via Odoo's `ir.rule` if needed |
| **AI giving unsafe medical advice** | Strict system prompt + JSON schema validation + keyword filter (rejects "diagnose", "you have", "stop using"); falls back to deterministic rules on any malformed/unsafe output; legal disclaimer "this is not medical advice" shown on every AI step |
| **AI cost runaway from public traffic** | Hard daily/monthly budget cap via `fusion.api.service`; CAPTCHA gates AI calls; cache results for identical symptom-category pairs; deterministic fallback never costs anything |

View File

@@ -0,0 +1,256 @@
# Fusion Clock — Province-Aware Automatic Unpaid Break (2-tier)
- **Date:** 2026-05-31
- **Module:** `fusion_clock`
- **Version bump:** `19.0.4.0.3``19.0.4.1.0`
- **Status:** Approved design, pending implementation plan
- **Author:** Claude Code (brainstormed with user)
## 1. Problem
Statutory unpaid meal breaks are jurisdiction-driven: a break is required after N1
hours of work, and a second break after a higher N2 threshold. Ontario, for example:
a 30-minute eating period after 5 hours of work, and (per the user's policy) another
30 minutes after 10 hours. The deduction must be **automatic** and must apply on **every**
way an attendance is recorded — including a manager manually adding or editing hours.
### Audit of current behaviour (what exists today)
The deduction field is `hr.attendance.x_fclk_break_minutes` (minutes). Net hours are
`x_fclk_net_hours = worked_hours x_fclk_break_minutes/60` (`models/hr_attendance.py:261`).
Break minutes are written from **four** places, all implementing variations of one rule:
1. `controllers/clock_api.py::_apply_break_deduction` (line 161) — on **clock-out**;
reused by the PIN kiosk (`controllers/clock_kiosk.py:158`) and NFC kiosk
(`controllers/clock_nfc_kiosk.py:381`). Logic: `if worked_hours >= break_threshold_hours`
(default **4.0h**) → set break to `employee._get_fclk_break_minutes()` (default **30**),
using `max(new, current)` so it doesn't wipe penalty minutes.
2. Auto-clock-out cron (`models/hr_attendance.py:343`) — same single-threshold write.
3. `controllers/clock_api.py::_check_and_create_penalty` (line 140) — **adds** penalty
minutes into the same `x_fclk_break_minutes` field.
### Gaps vs. requirement
1. **Single tier only** — one threshold (4h), one break (30m). No second break.
2. **Not applied on manual entry** — there is **no `create`/`write` override** on
`hr.attendance`. A manager-created or manager-edited attendance gets break `= 0`.
This is the central gap.
3. **No province/country awareness** — no jurisdiction field exists anywhere (location
has address/timezone but no province; company has none). Threshold + amount are flat
global config params.
4. **First-break default is 4h, not 5h** (Ontario is 5h).
## 2. Goals / Non-goals
**Goals**
- Statutory unpaid break applies automatically based on **actual worked hours**, on every
path (portal, systray, PIN kiosk, NFC kiosk, auto-clock-out cron, **and manual backend
create/edit**).
- Two tiers: first break after N1 hours, second break adds after N2 hours. Trigger is
`worked_hours >= N` (inclusive; nothing under N1).
- Rules are defined **per province/country** in a table; an employee resolves its rule
from its **company's province**, with a single global default fallback.
- **Eliminate the duplicated deduction logic** — one calculator, called everywhere.
**Non-goals (YAGNI)**
- Per-employee break-rule override (resolver is structured so this is a cheap add later).
- GPS/location-based jurisdiction detection.
- More than two tiers (the table is 2-tier; a 3rd break would be a future schema change).
- Changing the *planned* break concept used for scheduled-hours math.
## 3. Locked decisions
| # | Decision | Choice |
|---|---|---|
| 1 | Rule model | **Per-province table**, 2-tier (`fusion.clock.break.rule`) |
| 2 | Jurisdiction source | **Company province** (`company_id.state_id`) + global default fallback |
| 3 | Override behaviour | **Fully automatic** — idempotent stored compute, recomputes on every save |
| 4 | Planned-vs-statute | **Statutory only** — the planned/scheduled break never affects the actual deduction |
## 4. Design
### 4.1 New model `fusion.clock.break.rule`
`models/clock_break_rule.py`, `_name = 'fusion.clock.break.rule'`,
`_description = 'Statutory Break Rule'`, `_order = 'sequence, name'`.
| Field | Type | Default | Notes |
|---|---|---|---|
| `name` | Char (required) | — | e.g. "Ontario" |
| `country_id` | Many2one `res.country` | — | scopes the province picker |
| `state_id` | Many2one `res.country.state` | — | the province; `domain` on `country_id` |
| `is_default` | Boolean | False | global fallback when no province matches |
| `break1_after_hours` | Float | 5.0 | first break trigger N1 |
| `break1_minutes` | Float | 30.0 | first break amount M1 (0 = disabled) |
| `break2_after_hours` | Float | 10.0 | second break trigger N2 |
| `break2_minutes` | Float | 30.0 | second break amount M2 (0 = disabled) |
| `sequence` | Integer | 10 | |
| `active` | Boolean | True | |
**Constraints** (`models.Constraint`, per repo Odoo-19 rule 9):
- `break1_after_hours >= 0`, `break2_after_hours >= 0`, minutes `>= 0`.
- When `break2_minutes > 0`: `break2_after_hours > break1_after_hours`
(a misordered second tier is a config error).
- (Soft) at most one `is_default = True` — enforced in a Python `@api.constrains`
rather than a partial unique index, to give a friendly message.
**Method**`break_minutes_for(self, worked_hours)`:
```
self.ensure_one()
total = 0.0
if self.break1_minutes and worked_hours >= self.break1_after_hours:
total += self.break1_minutes
if self.break2_minutes and worked_hours >= self.break2_after_hours:
total += self.break2_minutes
return total
```
`>=` is intentional and matches the requirement ("equal to or more than N1").
**Seed** (`data/clock_break_rule_data.xml`, `noupdate="1"`): one row —
`name="Ontario"`, `state_id=base.state_ca_on`, `is_default=True`,
`break1_after_hours=5.0`, `break1_minutes=30.0`,
`break2_after_hours=10.0`, `break2_minutes=30.0`.
(Acting as both the Ontario match and the global fallback for this deployment.
Other provinces can be added as rows.)
### 4.2 Jurisdiction resolver — `hr.employee._get_fclk_break_rule()`
```
self.ensure_one()
Rule = self.env['fusion.clock.break.rule'].sudo()
state = self.company_id.state_id
rule = Rule.browse()
if state:
rule = Rule.search([('state_id', '=', state.id)], limit=1)
if not rule:
rule = Rule.search([('is_default', '=', True)], limit=1)
return rule # may be empty recordset → caller treats as 0 break
```
`sudo()` so the portal net-hours compute (run as the employee) can read the rule table
without a direct ACL grant. Resolver is a single method → adding a per-employee override
(`x_fclk_break_rule_id`) later is a two-line change.
### 4.3 `hr.attendance` — `x_fclk_break_minutes` becomes a stored compute
The field changes from a plain editable Float to a **stored computed** field — this is the
single calculator that replaces all four write sites.
```python
x_fclk_break_minutes = fields.Float(
string='Break (min)',
compute='_compute_fclk_break_minutes',
store=True,
tracking=True,
help="Unpaid break deducted from worked hours: statutory break (by province "
"rule, from actual hours worked) plus any penalty minutes.",
)
@api.depends('worked_hours', 'check_out',
'x_fclk_penalty_ids.penalty_minutes', 'employee_id')
def _compute_fclk_break_minutes(self):
ICP = self.env['ir.config_parameter'].sudo()
auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True'
for att in self:
statutory = 0.0
if auto and att.check_out and att.employee_id:
rule = att.employee_id._get_fclk_break_rule()
if rule:
statutory = rule.break_minutes_for(att.worked_hours or 0.0)
penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes'))
att.x_fclk_break_minutes = statutory + penalties
```
Properties:
- **Idempotent** — same hours + same penalties always yield the same value; no drift,
nothing to wipe.
- **Fires on every path** — `worked_hours` recomputes whenever `check_in`/`check_out`
change, so portal, kiosk, NFC, cron, **and manual backend create/edit** all recompute
automatically. This is what fixes the manual-entry gap.
- **Mid-shift = 0** — `check_out` empty → statutory 0 (penalties, if any, still counted).
- **Master toggle preserved** — `auto_deduct_break` False → statutory 0 (penalties remain).
- `_compute_net_hours` is unchanged (still `worked_hours break/60`); it now depends on a
computed-stored field, which Odoo chains correctly.
The attendance form's Break field becomes read-only (consistent with "fully automatic").
`views/hr_attendance_views.xml` updated accordingly.
### 4.4 Removals (the de-duplication)
| Remove | File | Replaced by |
|---|---|---|
| `_apply_break_deduction` method + its 3 call sites | `controllers/clock_api.py:161`, `controllers/clock_kiosk.py:158`, `controllers/clock_nfc_kiosk.py:381` | the compute |
| cron's `x_fclk_break_minutes` write | `models/hr_attendance.py:343-346` | the compute |
| penalty's `current_break + deduction` write | `controllers/clock_api.py:140-144` | the compute's `Σ penalty_minutes` |
| setting `fclk_break_threshold_hours` + `fusion_clock.break_threshold_hours` | `models/res_config_settings.py:39`, seed in `data/ir_config_parameter_data.xml` | per-rule `break1_after_hours` |
**Kept and untouched:** `hr.employee._get_fclk_break_minutes()`, `fusion_clock.default_break_minutes`,
`fusion.clock.shift.break_minutes`, `fusion.clock.schedule.break_minutes` — these are the
**planned** break (used to compute scheduled `planned_hours`), a separate concept from the
actual worked-hours deduction. Decision #4 keeps them out of the deduction path.
**Kept:** the `auto_deduct_break` master toggle (now gates the statutory portion only).
### 4.5 UI / security / data
- **Menu:** *Fusion Clock → Configuration → Break Rules* (new `ir.actions.act_window` +
list/form views in `views/clock_break_rule_views.xml`), gated to
`group_fusion_clock_manager`. Add the menu item in `views/clock_menus.xml`.
- **Security:** `security/ir.model.access.csv``fusion.clock.break.rule`: manager =
full CRUD; team-lead/user = read (or none — the resolver uses sudo, so no direct grant
is strictly required; grant manager full, no portal access).
- **Manifest `data`:** add `data/clock_break_rule_data.xml` (after security, before crons)
and `views/clock_break_rule_views.xml` (with the other config views, before
`clock_menus.xml`). Bump `version` to `19.0.4.1.0`.
## 5. Edge cases
- **No rule resolvable** (no province match, no default) → statutory 0. The seeded default
prevents this in practice.
- **Company has no `state_id`** → falls to the default rule.
- **`break2_after_hours <= break1_after_hours`** → blocked by constraint.
- **Penalty created after clock-out** → `x_fclk_penalty_ids` change retriggers the compute;
final break = statutory + penalty (preserves today's combined-field semantics, reported
as one "Break" number).
- **Open attendance** (no checkout) → break 0; recomputed when it's closed.
- **Worked hours exactly at a boundary** (5.0h, 10.0h) → tier fires (`>=`).
## 6. Migration / upgrade
- On upgrade, flipping `x_fclk_break_minutes` to `store=True compute` makes Odoo recompute
it for all existing rows. For closed attendances this re-derives break from
`worked_hours` + linked penalties using the seeded Ontario rule — which is the intended
corrected value. Any historical hand-edited break values are replaced (acceptable per
Decision #3, "fully automatic"). Call this out in the change log.
- No `pre`/`post` migration script is required; the recompute is automatic. (If we later
want to *avoid* touching very old periods, a guarded post-migrate could pin them — out of
scope for now.)
## 7. Testing (`tests/test_break_rules.py`, `@tagged('-at_install','post_install','fusion_clock')`)
1. `break_minutes_for`: 4.99h→0, 5.0h→30, 9.99h→30, 10.0h→60.
2. Resolver: company in Ontario → Ontario rule; company with unset/other province → default.
3. **Manual backend create** of a closed attendance (check_in/out spanning 6h) → break 30,
net = worked 0.5. **Manual edit** extending to 10h → break 60. (This is the headline
gap; assert it directly via `env['hr.attendance'].create(...)`, not via a controller.)
4. Penalty additivity: 6h + one 15-min penalty record → break 45.
5. Master toggle off (`auto_deduct_break=False`) → statutory 0 (penalty-only).
6. Constraint: `break2_after_hours <= break1_after_hours` raises.
Run (note ephemeral ports per repo CLAUDE.md):
```
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock \
-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
```
## 8. Rollout notes
- **Dual-path write** during dev: edit files in **both** `K:\Github\odoo-modsdev\addons\fusion_clock`
(Docker-mounted, for tests) **and** `K:\Github\Odoo-Modules\fusion_clock` (git); commit
from the git path only. (Per project memory.)
- Live target is **entech** (`odoo-entech`); deploy after local tests pass and user review.
- Asset/version bump already covered by the manifest `version` change.
## 9. Open questions
None — all four design forks resolved (see §3).

View File

@@ -0,0 +1,164 @@
# Assessment Visit — bundled, funding-routed assessments
**Date:** 2026-06-02
**Module:** `fusion_portal` (depends on `fusion_claims`, `fusion_tasks`); live on `odoo-westin` (DB `westin-v19`)
**Status:** Draft for review
**Author:** Brainstormed with Gurpreet (Fusion / Westin Healthcare)
---
## 1. Problem & goals
A sales rep visits a client's home **with an occupational therapist (OT) and the client present for only 3045 minutes**, and the OT's time is the scarcest resource. In that window the team often does more than one assessment — a wheelchair (ADP) plus, opportunistically, accessibility products the rep spots (a ramp at the front steps, a stair lift inside, a tub cutout, a patient lift for transfers). Today each assessment is a **separate, standalone web form** that re-collects the client's details and creates its own sale order, and the front-end forms give the rep **no way to mark a case's funding source** — so March-of-Dimes work silently defaults to private pay and never reaches the MOD pipeline.
**Goals**
1. **One visit, many assessments, entered once.** Bundle every assessment from one home visit; capture the client + funding details a single time.
2. **Measurement-first.** Capture measurements while the OT is present; defer client/health-card data to after they leave; let the OT sign the ADP application on the spot.
3. **Add as you go.** The rep adds an assessment/product the instant they spot it — repeatable, with a location tag (Front / Back / Inside).
4. **Route by funding workflow.** On completion the visit emits **one sale order per funding workflow** (ADP, March of Dimes, ODSP, WSIB, private, …) — never one combined SO, and never a separate SO per item within the same funding.
5. **Let the rep set funding at assessment time** (the real MOD "tracking" gap).
6. **ADP multi-device** with valid-combination rules, including a new **mobility scooter** type and a **home-accessibility hard rule** for power mobility that feeds the accessibility upsell.
**Non-goals (v1):** voice/dictated entry; rebuilding the measurement math; a new MOD/ADP claim model (the pipelines already exist — we reuse them).
---
## 2. Current state (verified against source)
- **Two assessment models, already two separate SO lineages.** `fusion.assessment` (ADP: rollator/wheelchair/powerchair) and `fusion.accessibility.assessment` (the 7 lift/mod types) each have their own `_create_draft_sale_order` (`assessment.py:587`, `accessibility_assessment.py:751`), their own `x_fc_sale_type`, and their own state machine — ADP's 24-state `x_fc_adp_application_status` vs MOD's 16-state `x_fc_mod_status`. Each guards against a second SO (`accessibility_assessment.py:503-511`). SO back-links are **scalar** Many2one: `assessment_id`, `accessibility_assessment_id` (`fusion_portal/models/sale_order.py:37,48`).
- **SOs are born with no order lines.** Specs become a **chatter HTML note** (`_format_assessment_html_table`, `accessibility_assessment.py:815`); a human prices the draft afterward. **No per-type product mapping exists.**
- **Funding is modelled but not on the measurement forms.** `x_fc_funding_source` (required, default `direct_private`) on the accessibility model — values `march_of_dimes`, `odsp`, `wsib`, `insurance`, `direct_private`, `other` (`accessibility_assessment.py:71-87`) — is present on the public booking form but **absent from all 7 measurement forms**, so they default to private. Canonical billing type `sale.order.x_fc_sale_type` (`fusion_claims/models/sale_order.py:320`) carries the full set incl. `adp`, `adp_odsp`, `march_of_dimes`, etc.
- **MOD tracking already exists** as `x_fc_mod_status` (16 states) + ~60 `x_fc_mod_*` fields (HVMP reference #, vendor code, drawings, PCA, POD, approved/payment amounts, dated audit trail) + MOD views + ~7 wizards + ~40 MOD/ODSP stage emails (`fusion_claims/models/sale_order.py:438,877`). An accessibility assessment funded `march_of_dimes` already lands its SO in this pipeline at `need_to_schedule`. **The gap is purely that the rep can't choose `march_of_dimes` on the form.**
- **Emails** are mostly Python-built via the shared `fusion.email.builder.mixin._email_build` (`fusion_tasks/models/email_builder_mixin.py:8`), gated by `ir.config_parameter` `fusion_claims.enable_email_notifications`. Completion email fires from inside `_create_draft_sale_order` (`assessment.py:847`; `accessibility_assessment.py:624`). Stage emails (`_adp_send_stage_email`, `_mod_email_build`, `_odsp_email_build`) are keyed off the SO's funding type + status, so **they keep working per-SO unchanged**.
- **Known bug:** backend ADP `action_complete()` sends the authorizer **two** completion emails (template pair at `assessment.py:494` + inline report via `:847`). Must consolidate before fanning out across a visit.
---
## 3. The design
### 3.1 The Visit aggregate (only net-new model)
`fusion.assessment.visit` — the hub for one home visit.
- **Client/context, entered once:** `partner_id`, address fields, `visit_date`, `sales_rep_id`, `authorizer_id` (OT), `x_fc_funding_source`-style default, `state` (`measuring``client_pending``done`).
- **Links to its assessments:** `adp_assessment_ids` (One2many → `fusion.assessment`) and `accessibility_assessment_ids` (One2many → `fusion.accessibility.assessment`). Each assessment gains `visit_id`.
- **Links to its sale orders:** `sale_order_ids` (One2many → `sale.order`) — one per funding workflow it produced.
- On the SO side, add `visit_id`. Each assessment already carries `sale_order_id` (Many2one — `accessibility_assessment.py:153`, `assessment.py:422`), so several same-funding assessments can already point at one SO; the redundant **scalar** `assessment_id` / `accessibility_assessment_id` on the SO (`fusion_portal/models/sale_order.py:37,48`) become **One2many** (or are dropped in favour of the `sale_order_id` reverse) so an SO no longer assumes a single source assessment.
Client info moves to the Visit as the single source of truth; the per-assessment `client_name`-required gate is relaxed (the model keeps the field for back-compat / standalone use but the Visit flow fills it from `partner_id`).
### 3.2 Add-as-you-go workspace (portal UX)
A portal "visit workspace" (reps are portal users, tablet-first):
- Always-present **"+ Add"** → pick a type + location tag (Front / Back / Inside / custom) → drop **straight into the existing measurement form** for that type. No client paperwork required to start.
- Each added assessment is a **card** showing type, location, status (To measure / Measured / Signed), and — once priced — its amount.
- **Measurement-first:** the forms render with client fields hidden/optional; a **deferred "Client + funding" step** is completed after the OT leaves and is shared by every item.
- The **OT signs the ADP application (Page 11)** inline on the wheelchair/ADP item, on-site, independent of client demographics (reuse `portal_assessment_express` Page-11 section + signature pad).
- Mockups (for reference, in repo `docs/mockups/` if committed): `fusion_portal_new_approach_mockup.html`.
### 3.3 Multi-instance + location tags
Any type can be added **more than once**, each its own assessment record with a **location label** ("Main stairs", "Basement", "Front porch"). Two stair lifts = two assessment records (→ two lines on the same funding SO; see §3.6). A **"Same as the previous"** action copies shared options so the rep only re-enters the differing measurements.
### 3.4 Per-item funding selector — the MOD gap fix
Expose `x_fc_funding_source` on **each accessibility assessment** in the flow: **Private Pay / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other**. This one field drives the existing `sale_type_map``x_fc_sale_type` → correct pipeline (MOD 16-state tracker, ODSP, hardship, …). Defaults to the previous item's funding so an all-MOD visit isn't re-picked each time. **ADP/wheelchair items are fixed to ADP** (no picker). This is the minimal change that closes the "can't mark a case as March of Dimes" gap — no new tracking model.
> **Patient lift** is an accessibility/equipment item that uses this same picker — funded by March of Dimes, **ODSP**, or **Hardship** (e.g. Toronto residents), so its funding is chosen per case, not fixed.
> **`sale_type_map` gap:** `x_fc_funding_source` currently lacks `hardship` while `x_fc_sale_type` already has it (`sale_order.py:320`) — add `hardship` to the picker + a `sale_type_map` entry (`accessibility_assessment.py:771`), and review the map so every offered funding routes to a real `x_fc_sale_type`.
> **MOD funding cap** applies to MOD items — see Resolved decision 1 (§4).
### 3.5 ADP multi-device + combinations + scooter + home-access rule
**Multi-device ADP order.** Today one ADP device per order; the visit allows a **valid combination** of ADP devices for one client, all landing on the **one ADP SO**. Each ADP device is an item; the combination check runs across the visit's ADP items.
**Device categories:** Walker/Rollator · Manual Wheelchair · Power Wheelchair · **Scooter (new)**.
**Combination rules (confirmed):**
| Combination | Allowed? |
|---|---|
| Any single device | ✓ |
| Walker + Manual Wheelchair | ✓ |
| Walker + Power Wheelchair | ✓ |
| Walker + Scooter | ✓ |
| Manual + Power Wheelchair | ✗ |
| Power Wheelchair + Scooter | ✗ |
| Manual Wheelchair + Scooter | ✗ |
| Two walkers / any duplicate | ✗ |
Rule in words: **at most one "seated-mobility" device** {manual wheelchair, power wheelchair, scooter}, **optionally one walker/rollator alongside, no duplicates.** Enforced when adding/saving an ADP device.
**Scooter (new ADP type) fields:** `client_weight` (exists), scooter type, **maximum travel range**, and the home-accessibility check (below). Gets its own measurement section in the ADP form, mirroring the rollator/wheelchair/powerchair sections.
**Power-mobility home-accessibility hard rule.** For **scooter and power wheelchair**, a required check: *"Is the home accessible enough for the device to be used **inside and outside** the home independently — no lifting, not left outside/in the garage?"* ADP will not fund power mobility a home can't accommodate. If the answer is **No**, the visit **flags an accessibility need** and prompts the rep to add an accessibility item (ramp / porch lift, typically March of Dimes) to remediate. This is the explicit bridge between the ADP power-mobility item and the accessibility/MOD upsell.
> **The power-wheelchair form is already well-optimized — do NOT change its fields.** The *only* addition there is this home-accessibility warning. The new **scooter** type gets its own section (fields above); the manual-wheelchair and rollator sections are unchanged.
### 3.6 Funding-workflow grouping → one SO per workflow
On visit completion, group its assessments by **funding workflow** (`x_fc_sale_type`) and create **one SO per group**:
- All `march_of_dimes` items (stair lift + porch lift + tub cutout, or two stair lifts) → **one MOD SO, multiple lines** (funding permitting).
- All ADP devices (the valid combination) → **one ADP SO**.
- Private / ODSP / WSIB / insurance → their own SO each.
- A separate SO appears **only when the case type changes**, never per-item within a funding.
Refactor the two per-model `_create_draft_sale_order` routines into a **shared, group-aware builder** that takes a set of same-funding assessments and produces one SO, branching on funding type to stamp the right starting status field (`x_fc_adp_application_status` for ADP, `x_fc_mod_status` for MOD, etc. — mirroring `assessment.py:600-622`) and the right links. **Reuse the existing MOD/ADP/ODSP pipelines unchanged.**
### 3.7 Emails
- Reuse `fusion.email.builder.mixin` and the existing per-funding stage emails (they're keyed off SO type + status, so per-SO they keep working).
- **Move the completion send to per-SO** inside the new builder (not per-assessment), and **dedupe recipients**, so a 3-item visit doesn't emit 36 completion emails.
- **Fix the existing duplicate** (authorizer gets two completion emails on backend ADP completion) as part of this.
- Make `enable_email_notifications` gating consistent across the sends the visit touches.
### 3.8 Reused vs net-new
- **Reused, largely untouched:** the 7 accessibility measurement forms + their JS/Python calc; the ADP Express form + Page-11 signature; the MOD/ADP/ODSP pipelines, views, wizards, and stage emails; the email branding mixin.
- **Net-new:** the `fusion.assessment.visit` model + workspace UI; per-item funding selector on the accessibility forms; the group-aware SO builder + link-cardinality change; ADP multi-device + combination validation; scooter type + fields; power-mobility home-access rule + cross-sell flag; completion-email consolidation.
---
## 4. Resolved decisions
1. **MOD funding cap — documented rule, light-touch in v1.** March of Dimes covers **up to $15,000 per person, lifetime**, income-gated: if the client's income is **under** that year's threshold (the threshold changes annually), MOD funds the full $15k; if **over**, MOD may **deny or partially approve**. **v1:** surface this cap as a reminder on MOD items and capture an *"income under MOD threshold? (yes / no / unknown)"* flag so the rep can judge — **do not** auto-compute lifetime used-vs-remaining across the client's prior MOD orders (the SO's existing `x_fc_mod_*` approved/payment fields already record per-order amounts). **Future:** yearly-threshold config + automatic lifetime-remaining tracking + a hard warning.
2. **No auto pricing / products in v1.** The visit creates a **draft** SO per funding workflow and appends each assessment's specs to that SO's chatter (today's pattern); **the sales rep builds the quotation lines manually.** One SO can hold many items. No per-assessment-type product mapping. (Auto-pricing is a future expansion.)
3. **Patient-lift funding is chosen per case** via the funding picker — March of Dimes, **ODSP**, or **Hardship** (e.g. Toronto residents) all fund it; it is not fixed (see §3.4).
4. **Power-wheelchair form unchanged** — already well-optimized; the only addition is the **home-accessibility warning** (device usable **inside and outside** the home). The home-access rule applies to **scooter (new type, new section) and power wheelchair (warning only)**.
---
## 5. Phasing
- **Phase 1 — Funding correctness + visit backbone:** `fusion.assessment.visit`, link-cardinality change, **funding selector on the accessibility forms** (incl. Hardship; patient-lift routing), **MOD $15k-cap reminder + income-threshold flag** (informational), group-and-route to per-workflow **draft** SOs (specs to chatter, manual pricing) reusing existing pipelines, completion-email consolidation + duplicate fix. *(Delivers the MOD-routing fix and the multi-SO split.)*
- **Phase 2 — ADP expansion:** multi-device ADP order + combination validation, **scooter** type + fields, power-mobility **home-access hard rule** + accessibility cross-sell prompt.
- **Phase 3 — Seamless field UX:** the full add-as-you-go workspace, measurement-first deferral, location tags, "same as previous", OT on-site sign-off polish.
- **Later:** product-line auto-pricing, MOD funding-cap tracking, voice/quick entry.
---
## 6. Risks (from investigation)
- **Duplicate completion emails** already live on the ADP backend path — fix before fan-out (§3.7).
- **Scalar back-links + double-SO guards** assume one SO per assessment; grouping breaks them — must move to `visit_id` / One2many and make the guard visit-aware.
- **Inconsistent `enable_email_notifications`** — template sends ignore the kill-switch; don't route new traffic through templates without honoring it.
- **Label drift** `x_fc_funding_source` vs `x_fc_sale_type` (`insurance`="Private Insurance" vs "Insurance"; `direct_private`="Private Pay (Direct)" vs "Direct/Private") — keys match so routing works; align labels in any shared UI.
- **Unreachable funding types from accessibility:** `sale_type_map` (`accessibility_assessment.py:771`) covers 6 values; decide which funding types each assessment type may emit.
---
## 7. Files in scope
- `fusion_portal/models/assessment.py` — ADP `_create_draft_sale_order` (:587), completion email (:847), multi-device + scooter + home-access.
- `fusion_portal/models/accessibility_assessment.py` — accessibility `_create_draft_sale_order` (:751), `action_complete` (:493), completion email (:624), funding routing.
- `fusion_portal/models/sale_order.py` — back-links (:37,:48) → `visit_id` / One2many.
- `fusion_portal/models/visit.py`**new** `fusion.assessment.visit`.
- `fusion_portal/views/portal_accessibility_forms.xml` + `portal_assessment_express.xml` — funding selector, scooter section, home-access check; workspace shell.
- `fusion_portal/controllers/portal_main.py` (`/my/accessibility/save` :2482) + `portal_assessment.py` — visit-aware save/group/route.
- `fusion_claims/models/sale_order.py` — reuse `x_fc_sale_type` (:320), `x_fc_mod_status` (:438), stage emails (:6876,:9038,:10063); no pipeline rebuild.
- `fusion_tasks/models/email_builder_mixin.py` — reuse for any new visit emails.
**Deployment note:** `fusion_portal` is live on `odoo-westin` (`westin-v19`, container `odoo-dev-app`). Ship per the rename/deploy procedure (backup → code sync → `-u fusion_portal` → cache-bust → restart → verify).

Binary file not shown.

View File

@@ -1,883 +0,0 @@
# Graph Report - /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal (2026-04-22)
## Corpus Check
- 33 files · ~40,589 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 470 nodes · 550 edges · 123 communities detected
- Extraction: 89% EXTRACTED · 11% INFERRED · 0% AMBIGUOUS · INFERRED: 60 edges (avg confidence: 0.76)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
- [[_COMMUNITY_Community 0|Community 0]]
- [[_COMMUNITY_Community 1|Community 1]]
- [[_COMMUNITY_Community 2|Community 2]]
- [[_COMMUNITY_Community 3|Community 3]]
- [[_COMMUNITY_Community 4|Community 4]]
- [[_COMMUNITY_Community 5|Community 5]]
- [[_COMMUNITY_Community 6|Community 6]]
- [[_COMMUNITY_Community 7|Community 7]]
- [[_COMMUNITY_Community 8|Community 8]]
- [[_COMMUNITY_Community 9|Community 9]]
- [[_COMMUNITY_Community 10|Community 10]]
- [[_COMMUNITY_Community 11|Community 11]]
- [[_COMMUNITY_Community 12|Community 12]]
- [[_COMMUNITY_Community 13|Community 13]]
- [[_COMMUNITY_Community 14|Community 14]]
- [[_COMMUNITY_Community 15|Community 15]]
- [[_COMMUNITY_Community 16|Community 16]]
- [[_COMMUNITY_Community 17|Community 17]]
- [[_COMMUNITY_Community 18|Community 18]]
- [[_COMMUNITY_Community 19|Community 19]]
- [[_COMMUNITY_Community 20|Community 20]]
- [[_COMMUNITY_Community 21|Community 21]]
- [[_COMMUNITY_Community 22|Community 22]]
- [[_COMMUNITY_Community 23|Community 23]]
- [[_COMMUNITY_Community 24|Community 24]]
- [[_COMMUNITY_Community 25|Community 25]]
- [[_COMMUNITY_Community 26|Community 26]]
- [[_COMMUNITY_Community 27|Community 27]]
- [[_COMMUNITY_Community 28|Community 28]]
- [[_COMMUNITY_Community 29|Community 29]]
- [[_COMMUNITY_Community 30|Community 30]]
- [[_COMMUNITY_Community 31|Community 31]]
- [[_COMMUNITY_Community 32|Community 32]]
- [[_COMMUNITY_Community 33|Community 33]]
- [[_COMMUNITY_Community 34|Community 34]]
- [[_COMMUNITY_Community 35|Community 35]]
- [[_COMMUNITY_Community 36|Community 36]]
- [[_COMMUNITY_Community 37|Community 37]]
- [[_COMMUNITY_Community 38|Community 38]]
- [[_COMMUNITY_Community 39|Community 39]]
- [[_COMMUNITY_Community 40|Community 40]]
- [[_COMMUNITY_Community 41|Community 41]]
- [[_COMMUNITY_Community 42|Community 42]]
- [[_COMMUNITY_Community 43|Community 43]]
- [[_COMMUNITY_Community 44|Community 44]]
- [[_COMMUNITY_Community 45|Community 45]]
- [[_COMMUNITY_Community 46|Community 46]]
- [[_COMMUNITY_Community 47|Community 47]]
- [[_COMMUNITY_Community 48|Community 48]]
- [[_COMMUNITY_Community 49|Community 49]]
- [[_COMMUNITY_Community 50|Community 50]]
- [[_COMMUNITY_Community 51|Community 51]]
- [[_COMMUNITY_Community 52|Community 52]]
- [[_COMMUNITY_Community 53|Community 53]]
- [[_COMMUNITY_Community 54|Community 54]]
- [[_COMMUNITY_Community 55|Community 55]]
- [[_COMMUNITY_Community 56|Community 56]]
- [[_COMMUNITY_Community 57|Community 57]]
- [[_COMMUNITY_Community 58|Community 58]]
- [[_COMMUNITY_Community 59|Community 59]]
- [[_COMMUNITY_Community 60|Community 60]]
- [[_COMMUNITY_Community 61|Community 61]]
- [[_COMMUNITY_Community 62|Community 62]]
- [[_COMMUNITY_Community 63|Community 63]]
- [[_COMMUNITY_Community 64|Community 64]]
- [[_COMMUNITY_Community 65|Community 65]]
- [[_COMMUNITY_Community 66|Community 66]]
- [[_COMMUNITY_Community 67|Community 67]]
- [[_COMMUNITY_Community 68|Community 68]]
- [[_COMMUNITY_Community 69|Community 69]]
- [[_COMMUNITY_Community 70|Community 70]]
- [[_COMMUNITY_Community 71|Community 71]]
- [[_COMMUNITY_Community 72|Community 72]]
- [[_COMMUNITY_Community 73|Community 73]]
- [[_COMMUNITY_Community 74|Community 74]]
- [[_COMMUNITY_Community 75|Community 75]]
- [[_COMMUNITY_Community 76|Community 76]]
- [[_COMMUNITY_Community 77|Community 77]]
- [[_COMMUNITY_Community 78|Community 78]]
- [[_COMMUNITY_Community 79|Community 79]]
- [[_COMMUNITY_Community 80|Community 80]]
- [[_COMMUNITY_Community 81|Community 81]]
- [[_COMMUNITY_Community 82|Community 82]]
- [[_COMMUNITY_Community 83|Community 83]]
- [[_COMMUNITY_Community 84|Community 84]]
- [[_COMMUNITY_Community 85|Community 85]]
- [[_COMMUNITY_Community 86|Community 86]]
- [[_COMMUNITY_Community 87|Community 87]]
- [[_COMMUNITY_Community 88|Community 88]]
- [[_COMMUNITY_Community 89|Community 89]]
- [[_COMMUNITY_Community 90|Community 90]]
- [[_COMMUNITY_Community 91|Community 91]]
- [[_COMMUNITY_Community 92|Community 92]]
- [[_COMMUNITY_Community 93|Community 93]]
- [[_COMMUNITY_Community 94|Community 94]]
- [[_COMMUNITY_Community 95|Community 95]]
- [[_COMMUNITY_Community 96|Community 96]]
- [[_COMMUNITY_Community 97|Community 97]]
- [[_COMMUNITY_Community 98|Community 98]]
- [[_COMMUNITY_Community 99|Community 99]]
- [[_COMMUNITY_Community 100|Community 100]]
- [[_COMMUNITY_Community 101|Community 101]]
- [[_COMMUNITY_Community 102|Community 102]]
- [[_COMMUNITY_Community 103|Community 103]]
- [[_COMMUNITY_Community 104|Community 104]]
- [[_COMMUNITY_Community 105|Community 105]]
- [[_COMMUNITY_Community 106|Community 106]]
- [[_COMMUNITY_Community 107|Community 107]]
- [[_COMMUNITY_Community 108|Community 108]]
- [[_COMMUNITY_Community 109|Community 109]]
- [[_COMMUNITY_Community 110|Community 110]]
- [[_COMMUNITY_Community 111|Community 111]]
- [[_COMMUNITY_Community 112|Community 112]]
- [[_COMMUNITY_Community 113|Community 113]]
- [[_COMMUNITY_Community 114|Community 114]]
- [[_COMMUNITY_Community 115|Community 115]]
- [[_COMMUNITY_Community 116|Community 116]]
- [[_COMMUNITY_Community 117|Community 117]]
- [[_COMMUNITY_Community 118|Community 118]]
- [[_COMMUNITY_Community 119|Community 119]]
- [[_COMMUNITY_Community 120|Community 120]]
- [[_COMMUNITY_Community 121|Community 121]]
- [[_COMMUNITY_Community 122|Community 122]]
## God Nodes (most connected - your core abstractions)
1. `create()` - 22 edges
2. `FusionAssessment` - 20 edges
3. `AuthorizerPortal` - 19 edges
4. `ResPartner` - 16 edges
5. `accessibility_assessment_save()` - 12 edges
6. `FusionAccessibilityAssessment` - 11 edges
7. `selectField()` - 11 edges
8. `PDFTemplateFiller` - 10 edges
9. `SaleOrder` - 10 edges
10. `FusionPdfTemplate` - 9 edges
## Surprising Connections (you probably didn't know these)
- `create_field()` --calls--> `create()` [INFERRED]
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/pdf_editor.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/accessibility_assessment.py
- `FusionPdfTemplatePreview` --uses--> `PDFTemplateFiller` [INFERRED]
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/pdf_template.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/pdf_filler.py
- `FusionPdfTemplateField` --uses--> `PDFTemplateFiller` [INFERRED]
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/pdf_template.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/pdf_filler.py
- `Generate PNG preview images from the PDF using poppler (pdftoppm). Falls` --uses--> `PDFTemplateFiller` [INFERRED]
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/pdf_template.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/pdf_filler.py
- `Set template to active.` --uses--> `PDFTemplateFiller` [INFERRED]
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/pdf_template.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/pdf_filler.py
## Communities
### Community 0 - "Community 0"
Cohesion: 0.05
Nodes (29): accessibility_bathroom(), accessibility_ceiling_lift(), accessibility_ramp(), accessibility_stairlift_curved(), accessibility_stairlift_straight(), accessibility_tub_cutout(), accessibility_vpl(), home() (+21 more)
### Community 1 - "Community 1"
Cohesion: 0.06
Nodes (20): Assign role-specific portal groups to a portal user based on contact checkboxes., Assign backend groups to an internal user based on contact checkboxes. A, Grant portal access to this partner, or update permissions for existing users., Create a role-specific welcome Knowledge article for the new portal user., Send a professional portal invitation email to the partner. Gen, Resend portal invitation email to an existing portal user., Open the list of assigned sale orders, Open the list of assessments for this partner (+12 more)
### Community 2 - "Community 2"
Cohesion: 0.07
Nodes (19): create(), FusionAssessment, Format assessment data as HTML table for chatter, Format wheelchair specifications for the sale order notes (legacy), Generate document records for signed pages, Send email notifications when assessment is completed, View related documents, View the created sale order (+11 more)
### Community 3 - "Community 3"
Cohesion: 0.08
Nodes (15): create(), FusionAccessibilityAssessment, Complete the assessment and create a Sale Order. 2026-04 portal audit f, Add a tag to the sale order based on assessment type, Copy assessment photos to sale order chatter, Send email notification to office about assessment completion, Schedule a follow-up activity for the sales rep, Find or create a partner for the client (+7 more)
### Community 4 - "Community 4"
Cohesion: 0.08
Nodes (20): Complete express assessment and create draft sale order (no signatures required), CustomerPortal, Ensure all module views are active after install/update. Odoo silently deac, _reactivate_views(), AssessmentPortal, portal_assessment_express_edit(), portal_assessment_express_new(), portal_assessment_express_save() (+12 more)
### Community 5 - "Community 5"
Cohesion: 0.09
Nodes (14): authorizer_cases_search(), sales_rep_cases_search(), get_authorizer_portal_cases(), get_sales_rep_portal_cases(), Open composer to send message to authorizer only, Send email when an authorizer is assigned to the order, View portal documents, Get data for portal display, excluding sensitive information (+6 more)
### Community 6 - "Community 6"
Cohesion: 0.12
Nodes (14): preview_pdf(), _draw_field(), fill_template(), PDFTemplateFiller, Generic PDF template filler. Works with any template, any number of pages., create(), FusionPdfTemplate, FusionPdfTemplateField (+6 more)
### Community 7 - "Community 7"
Cohesion: 0.11
Nodes (14): accessibility_assessment_save(), AuthorizerPortal, Portal controller for Authorizers (OTs/Therapists), Parse straight stair lift specific fields, Parse curved stair lift specific fields, Parse VPL specific fields, Parse ceiling lift specific fields, Parse ramp specific fields (+6 more)
### Community 8 - "Community 8"
Cohesion: 0.21
Nodes (22): buildDataKeyOptions(), buildDataKeysSidebar(), init(), jsonrpc(), loadFields(), normalize(), onFieldDragStart(), renderFieldMarker() (+14 more)
### Community 9 - "Community 9"
Cohesion: 0.29
Nodes (11): checkClockStatus(), ensureModal(), getLocation(), hideModal(), isTechnicianPortal(), logLocation(), showDeniedBanner(), showModal() (+3 more)
### Community 10 - "Community 10"
Cohesion: 0.18
Nodes (3): ADPDocument, Download the document, Get the download URL for portal access
### Community 11 - "Community 11"
Cohesion: 0.2
Nodes (5): create_field(), FusionPdfEditorController, Controller for the PDF field position visual editor., update_field(), upload_preview_image()
### Community 12 - "Community 12"
Cohesion: 0.38
Nodes (4): page11_sign_form(), page11_sign_submit(), Page11PublicSignController, Look up and validate a signing request by token.
### Community 13 - "Community 13"
Cohesion: 0.4
Nodes (1): migrate()
### Community 14 - "Community 14"
Cohesion: 0.5
Nodes (1): AuthorizerComment
### Community 15 - "Community 15"
Cohesion: 0.83
Nodes (3): _detectAndSaveTimezone(), _getCookie(), start()
### Community 16 - "Community 16"
Cohesion: 0.67
Nodes (1): FusionLoanerCheckoutAssessment
### Community 17 - "Community 17"
Cohesion: 0.67
Nodes (0):
### Community 18 - "Community 18"
Cohesion: 1.0
Nodes (2): registerPushSubscription(), urlBase64ToUint8Array()
### Community 19 - "Community 19"
Cohesion: 1.0
Nodes (0):
### Community 20 - "Community 20"
Cohesion: 1.0
Nodes (0):
### Community 21 - "Community 21"
Cohesion: 1.0
Nodes (0):
### Community 22 - "Community 22"
Cohesion: 1.0
Nodes (0):
### Community 23 - "Community 23"
Cohesion: 1.0
Nodes (1): Fill a PDF template by overlaying text/checkmarks/signatures at configured posit
### Community 24 - "Community 24"
Cohesion: 1.0
Nodes (1): Draw a single field onto the reportlab canvas. Args: c: rep
### Community 25 - "Community 25"
Cohesion: 1.0
Nodes (1): Override create to generate reference number
### Community 26 - "Community 26"
Cohesion: 1.0
Nodes (1): Get authorizer from x_fc_authorizer_id field
### Community 27 - "Community 27"
Cohesion: 1.0
Nodes (1): Get cases for authorizer portal with optional search
### Community 28 - "Community 28"
Cohesion: 1.0
Nodes (1): Get cases for sales rep portal with optional search
### Community 29 - "Community 29"
Cohesion: 1.0
Nodes (1): Override create to handle revision numbering
### Community 30 - "Community 30"
Cohesion: 1.0
Nodes (1): Get documents for a sale order, optionally filtered by type
### Community 31 - "Community 31"
Cohesion: 1.0
Nodes (1): Get all revisions of a specific document type
### Community 32 - "Community 32"
Cohesion: 1.0
Nodes (1): Override create to set author from current user if not provided
### Community 33 - "Community 33"
Cohesion: 1.0
Nodes (1): Kanban group expansion — always show all 6 workflow states.
### Community 34 - "Community 34"
Cohesion: 1.0
Nodes (1): Straight stair lift: (steps × nose_to_nose) + 13" top landing
### Community 35 - "Community 35"
Cohesion: 1.0
Nodes (1): Use manual override if provided, otherwise use calculated
### Community 36 - "Community 36"
Cohesion: 1.0
Nodes (1): Curved stair lift calculation: - 12" per step - 16" per curve
### Community 37 - "Community 37"
Cohesion: 1.0
Nodes (1): Use manual override if provided, otherwise use calculated
### Community 38 - "Community 38"
Cohesion: 1.0
Nodes (1): Ontario Building Code: 12 inches length per 1 inch height (1:12 ratio)
### Community 39 - "Community 39"
Cohesion: 1.0
Nodes (1): Landing required every 30 feet (360 inches)
### Community 40 - "Community 40"
Cohesion: 1.0
Nodes (1): Total length including landings (5 feet = 60 inches each)
### Community 41 - "Community 41"
Cohesion: 1.0
Nodes (1): Compute portal access status based on user account and login history.
### Community 42 - "Community 42"
Cohesion: 1.0
Nodes (1): Count sale orders where this partner is the authorizer
### Community 43 - "Community 43"
Cohesion: 1.0
Nodes (1): Count assessments where this partner is involved
### Community 44 - "Community 44"
Cohesion: 1.0
Nodes (1): Count sale orders assigned to this partner as delivery technician
### Community 45 - "Community 45"
Cohesion: 1.0
Nodes (0):
### Community 46 - "Community 46"
Cohesion: 1.0
Nodes (0):
### Community 47 - "Community 47"
Cohesion: 1.0
Nodes (0):
### Community 48 - "Community 48"
Cohesion: 1.0
Nodes (0):
### Community 49 - "Community 49"
Cohesion: 1.0
Nodes (0):
### Community 50 - "Community 50"
Cohesion: 1.0
Nodes (1): Display the Page 11 signing form.
### Community 51 - "Community 51"
Cohesion: 1.0
Nodes (1): Process the submitted Page 11 signature.
### Community 52 - "Community 52"
Cohesion: 1.0
Nodes (1): Download the signed Page 11 PDF.
### Community 53 - "Community 53"
Cohesion: 1.0
Nodes (1): Start a new assessment
### Community 54 - "Community 54"
Cohesion: 1.0
Nodes (1): View/edit an assessment
### Community 55 - "Community 55"
Cohesion: 1.0
Nodes (1): Save assessment data (create or update)
### Community 56 - "Community 56"
Cohesion: 1.0
Nodes (1): Signature capture page
### Community 57 - "Community 57"
Cohesion: 1.0
Nodes (1): Save a signature (AJAX)
### Community 58 - "Community 58"
Cohesion: 1.0
Nodes (1): Complete the assessment
### Community 59 - "Community 59"
Cohesion: 1.0
Nodes (1): Start a new express assessment (Page 1 - Equipment Selection)
### Community 60 - "Community 60"
Cohesion: 1.0
Nodes (1): Continue/edit an express assessment
### Community 61 - "Community 61"
Cohesion: 1.0
Nodes (1): Save express assessment data (create or update)
### Community 62 - "Community 62"
Cohesion: 1.0
Nodes (1): Public page for booking an accessibility assessment.
### Community 63 - "Community 63"
Cohesion: 1.0
Nodes (1): Process assessment booking form submission.
### Community 64 - "Community 64"
Cohesion: 1.0
Nodes (1): Render the visual field editor for a PDF template.
### Community 65 - "Community 65"
Cohesion: 1.0
Nodes (1): Return all fields for a template.
### Community 66 - "Community 66"
Cohesion: 1.0
Nodes (1): Update a field's position or properties.
### Community 67 - "Community 67"
Cohesion: 1.0
Nodes (1): Create a new field on a template.
### Community 68 - "Community 68"
Cohesion: 1.0
Nodes (1): Delete a field from a template.
### Community 69 - "Community 69"
Cohesion: 1.0
Nodes (1): Return the preview image URL for a specific page.
### Community 70 - "Community 70"
Cohesion: 1.0
Nodes (1): Upload a preview image for a template page directly from the editor.
### Community 71 - "Community 71"
Cohesion: 1.0
Nodes (1): Generate a preview filled PDF with sample data.
### Community 72 - "Community 72"
Cohesion: 1.0
Nodes (1): Auto-save browser-detected timezone to the user profile if not already set.
### Community 73 - "Community 73"
Cohesion: 1.0
Nodes (1): Override home to add ADP posting info for Fusion users
### Community 74 - "Community 74"
Cohesion: 1.0
Nodes (1): Authorizer dashboard - simplified mobile-first view
### Community 75 - "Community 75"
Cohesion: 1.0
Nodes (1): List of cases assigned to the authorizer
### Community 76 - "Community 76"
Cohesion: 1.0
Nodes (1): AJAX search endpoint for real-time search
### Community 77 - "Community 77"
Cohesion: 1.0
Nodes (1): Add a comment to a case - posts to sale order chatter and emails salesperson
### Community 78 - "Community 78"
Cohesion: 1.0
Nodes (1): Upload a document for a case
### Community 79 - "Community 79"
Cohesion: 1.0
Nodes (1): Download an attachment from sale order (original application, xml, proof of deli
### Community 80 - "Community 80"
Cohesion: 1.0
Nodes (1): View an approval photo
### Community 81 - "Community 81"
Cohesion: 1.0
Nodes (1): Sales rep dashboard with search and filters
### Community 82 - "Community 82"
Cohesion: 1.0
Nodes (1): List of cases for the sales rep
### Community 83 - "Community 83"
Cohesion: 1.0
Nodes (1): AJAX search endpoint for sales rep real-time search
### Community 84 - "Community 84"
Cohesion: 1.0
Nodes (1): View a specific case for sales rep
### Community 85 - "Community 85"
Cohesion: 1.0
Nodes (1): Add a comment to a case (sales rep) - posts to sale order chatter and emails aut
### Community 86 - "Community 86"
Cohesion: 1.0
Nodes (1): List of funding claims for the client
### Community 87 - "Community 87"
Cohesion: 1.0
Nodes (1): View a specific funding claim
### Community 88 - "Community 88"
Cohesion: 1.0
Nodes (1): Download a document from a funding claim
### Community 89 - "Community 89"
Cohesion: 1.0
Nodes (1): Download proof of delivery from a funding claim
### Community 90 - "Community 90"
Cohesion: 1.0
Nodes (1): Technician dashboard - today's schedule with timeline.
### Community 91 - "Community 91"
Cohesion: 1.0
Nodes (1): List of all tasks for the technician.
### Community 92 - "Community 92"
Cohesion: 1.0
Nodes (1): View a specific technician task.
### Community 93 - "Community 93"
Cohesion: 1.0
Nodes (1): Add notes (and optional photos) to a completed task. :param notes: text
### Community 94 - "Community 94"
Cohesion: 1.0
Nodes (1): Handle task status changes (start, complete, en_route, cancel). Location
### Community 95 - "Community 95"
Cohesion: 1.0
Nodes (1): Transcribe voice recording using OpenAI Whisper, translate to English.
### Community 96 - "Community 96"
Cohesion: 1.0
Nodes (1): Use GPT to clean up and format raw notes text.
### Community 97 - "Community 97"
Cohesion: 1.0
Nodes (1): Format transcription with GPT and complete the task.
### Community 98 - "Community 98"
Cohesion: 1.0
Nodes (1): Next day preparation view.
### Community 99 - "Community 99"
Cohesion: 1.0
Nodes (1): View schedule for a specific date.
### Community 100 - "Community 100"
Cohesion: 1.0
Nodes (1): Admin map view showing latest technician locations using Google Maps.
### Community 101 - "Community 101"
Cohesion: 1.0
Nodes (1): Log the technician's current GPS location.
### Community 102 - "Community 102"
Cohesion: 1.0
Nodes (1): Check if the current technician is clocked in. Returns {clocked_in: boo
### Community 103 - "Community 103"
Cohesion: 1.0
Nodes (1): Save the technician's personal start location.
### Community 104 - "Community 104"
Cohesion: 1.0
Nodes (1): Register a push notification subscription.
### Community 105 - "Community 105"
Cohesion: 1.0
Nodes (1): Legacy: List of deliveries for the technician (redirects to tasks).
### Community 106 - "Community 106"
Cohesion: 1.0
Nodes (1): View a specific delivery for technician (legacy, still works).
### Community 107 - "Community 107"
Cohesion: 1.0
Nodes (1): POD signature capture page - accessible by technicians and sales reps
### Community 108 - "Community 108"
Cohesion: 1.0
Nodes (1): Save POD signature via AJAX
### Community 109 - "Community 109"
Cohesion: 1.0
Nodes (1): Task-level POD signature capture page (works for all tasks including shadow).
### Community 110 - "Community 110"
Cohesion: 1.0
Nodes (1): Save POD signature directly on a task.
### Community 111 - "Community 111"
Cohesion: 1.0
Nodes (1): Show the accessibility assessment type selector
### Community 112 - "Community 112"
Cohesion: 1.0
Nodes (1): List all accessibility assessments for the current user (sales rep or authorizer
### Community 113 - "Community 113"
Cohesion: 1.0
Nodes (1): Straight stair lift assessment form
### Community 114 - "Community 114"
Cohesion: 1.0
Nodes (1): Curved stair lift assessment form
### Community 115 - "Community 115"
Cohesion: 1.0
Nodes (1): Vertical Platform Lift assessment form
### Community 116 - "Community 116"
Cohesion: 1.0
Nodes (1): Ceiling Lift assessment form
### Community 117 - "Community 117"
Cohesion: 1.0
Nodes (1): Custom Ramp assessment form
### Community 118 - "Community 118"
Cohesion: 1.0
Nodes (1): Bathroom Modification assessment form
### Community 119 - "Community 119"
Cohesion: 1.0
Nodes (1): Tub Cutout assessment form
### Community 120 - "Community 120"
Cohesion: 1.0
Nodes (1): Save an accessibility assessment and optionally create a Sale Order
### Community 121 - "Community 121"
Cohesion: 1.0
Nodes (1): Render the rental pickup inspection form for the technician.
### Community 122 - "Community 122"
Cohesion: 1.0
Nodes (1): Save the rental inspection results.
## Knowledge Gaps
- **177 isolated node(s):** `Ensure all module views are active after install/update. Odoo silently deac`, `Generic PDF template filler. Works with any template, any number of pages.`, `Fill a PDF template by overlaying text/checkmarks/signatures at configured posit`, `Draw a single field onto the reportlab canvas. Args: c: rep`, `Override create to generate reference number` (+172 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **Thin community `Community 19`** (1 nodes): `__init__.py`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 20`** (1 nodes): `__init__.py`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 21`** (1 nodes): `__init__.py`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 22`** (1 nodes): `__manifest__.py`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 23`** (1 nodes): `Fill a PDF template by overlaying text/checkmarks/signatures at configured posit`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 24`** (1 nodes): `Draw a single field onto the reportlab canvas. Args: c: rep`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 25`** (1 nodes): `Override create to generate reference number`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 26`** (1 nodes): `Get authorizer from x_fc_authorizer_id field`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 27`** (1 nodes): `Get cases for authorizer portal with optional search`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 28`** (1 nodes): `Get cases for sales rep portal with optional search`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 29`** (1 nodes): `Override create to handle revision numbering`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 30`** (1 nodes): `Get documents for a sale order, optionally filtered by type`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 31`** (1 nodes): `Get all revisions of a specific document type`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 32`** (1 nodes): `Override create to set author from current user if not provided`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 33`** (1 nodes): `Kanban group expansion — always show all 6 workflow states.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 34`** (1 nodes): `Straight stair lift: (steps × nose_to_nose) + 13" top landing`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 35`** (1 nodes): `Use manual override if provided, otherwise use calculated`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 36`** (1 nodes): `Curved stair lift calculation: - 12" per step - 16" per curve`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 37`** (1 nodes): `Use manual override if provided, otherwise use calculated`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 38`** (1 nodes): `Ontario Building Code: 12 inches length per 1 inch height (1:12 ratio)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 39`** (1 nodes): `Landing required every 30 feet (360 inches)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 40`** (1 nodes): `Total length including landings (5 feet = 60 inches each)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 41`** (1 nodes): `Compute portal access status based on user account and login history.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 42`** (1 nodes): `Count sale orders where this partner is the authorizer`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 43`** (1 nodes): `Count assessments where this partner is involved`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 44`** (1 nodes): `Count sale orders assigned to this partner as delivery technician`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 45`** (1 nodes): `assessment_form.js`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 46`** (1 nodes): `technician_sw.js`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 47`** (1 nodes): `loaner_portal.js`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 48`** (1 nodes): `signature_pad.js`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 49`** (1 nodes): `portal_search.js`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 50`** (1 nodes): `Display the Page 11 signing form.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 51`** (1 nodes): `Process the submitted Page 11 signature.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 52`** (1 nodes): `Download the signed Page 11 PDF.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 53`** (1 nodes): `Start a new assessment`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 54`** (1 nodes): `View/edit an assessment`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 55`** (1 nodes): `Save assessment data (create or update)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 56`** (1 nodes): `Signature capture page`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 57`** (1 nodes): `Save a signature (AJAX)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 58`** (1 nodes): `Complete the assessment`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 59`** (1 nodes): `Start a new express assessment (Page 1 - Equipment Selection)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 60`** (1 nodes): `Continue/edit an express assessment`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 61`** (1 nodes): `Save express assessment data (create or update)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 62`** (1 nodes): `Public page for booking an accessibility assessment.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 63`** (1 nodes): `Process assessment booking form submission.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 64`** (1 nodes): `Render the visual field editor for a PDF template.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 65`** (1 nodes): `Return all fields for a template.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 66`** (1 nodes): `Update a field's position or properties.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 67`** (1 nodes): `Create a new field on a template.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 68`** (1 nodes): `Delete a field from a template.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 69`** (1 nodes): `Return the preview image URL for a specific page.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 70`** (1 nodes): `Upload a preview image for a template page directly from the editor.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 71`** (1 nodes): `Generate a preview filled PDF with sample data.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 72`** (1 nodes): `Auto-save browser-detected timezone to the user profile if not already set.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 73`** (1 nodes): `Override home to add ADP posting info for Fusion users`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 74`** (1 nodes): `Authorizer dashboard - simplified mobile-first view`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 75`** (1 nodes): `List of cases assigned to the authorizer`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 76`** (1 nodes): `AJAX search endpoint for real-time search`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 77`** (1 nodes): `Add a comment to a case - posts to sale order chatter and emails salesperson`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 78`** (1 nodes): `Upload a document for a case`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 79`** (1 nodes): `Download an attachment from sale order (original application, xml, proof of deli`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 80`** (1 nodes): `View an approval photo`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 81`** (1 nodes): `Sales rep dashboard with search and filters`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 82`** (1 nodes): `List of cases for the sales rep`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 83`** (1 nodes): `AJAX search endpoint for sales rep real-time search`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 84`** (1 nodes): `View a specific case for sales rep`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 85`** (1 nodes): `Add a comment to a case (sales rep) - posts to sale order chatter and emails aut`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 86`** (1 nodes): `List of funding claims for the client`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 87`** (1 nodes): `View a specific funding claim`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 88`** (1 nodes): `Download a document from a funding claim`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 89`** (1 nodes): `Download proof of delivery from a funding claim`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 90`** (1 nodes): `Technician dashboard - today's schedule with timeline.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 91`** (1 nodes): `List of all tasks for the technician.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 92`** (1 nodes): `View a specific technician task.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 93`** (1 nodes): `Add notes (and optional photos) to a completed task. :param notes: text`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 94`** (1 nodes): `Handle task status changes (start, complete, en_route, cancel). Location`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 95`** (1 nodes): `Transcribe voice recording using OpenAI Whisper, translate to English.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 96`** (1 nodes): `Use GPT to clean up and format raw notes text.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 97`** (1 nodes): `Format transcription with GPT and complete the task.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 98`** (1 nodes): `Next day preparation view.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 99`** (1 nodes): `View schedule for a specific date.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 100`** (1 nodes): `Admin map view showing latest technician locations using Google Maps.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 101`** (1 nodes): `Log the technician's current GPS location.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 102`** (1 nodes): `Check if the current technician is clocked in. Returns {clocked_in: boo`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 103`** (1 nodes): `Save the technician's personal start location.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 104`** (1 nodes): `Register a push notification subscription.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 105`** (1 nodes): `Legacy: List of deliveries for the technician (redirects to tasks).`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 106`** (1 nodes): `View a specific delivery for technician (legacy, still works).`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 107`** (1 nodes): `POD signature capture page - accessible by technicians and sales reps`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 108`** (1 nodes): `Save POD signature via AJAX`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 109`** (1 nodes): `Task-level POD signature capture page (works for all tasks including shadow).`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 110`** (1 nodes): `Save POD signature directly on a task.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 111`** (1 nodes): `Show the accessibility assessment type selector`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 112`** (1 nodes): `List all accessibility assessments for the current user (sales rep or authorizer`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 113`** (1 nodes): `Straight stair lift assessment form`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 114`** (1 nodes): `Curved stair lift assessment form`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 115`** (1 nodes): `Vertical Platform Lift assessment form`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 116`** (1 nodes): `Ceiling Lift assessment form`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 117`** (1 nodes): `Custom Ramp assessment form`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 118`** (1 nodes): `Bathroom Modification assessment form`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 119`** (1 nodes): `Tub Cutout assessment form`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 120`** (1 nodes): `Save an accessibility assessment and optionally create a Sale Order`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 121`** (1 nodes): `Render the rental pickup inspection form for the technician.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 122`** (1 nodes): `Save the rental inspection results.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `create()` connect `Community 3` to `Community 0`, `Community 1`, `Community 4`, `Community 7`, `Community 11`?**
_High betweenness centrality (0.080) - this node is a cross-community bridge._
- **Why does `FusionAssessment` connect `Community 2` to `Community 4`?**
_High betweenness centrality (0.059) - this node is a cross-community bridge._
- **Why does `AuthorizerPortal` connect `Community 7` to `Community 0`, `Community 4`?**
_High betweenness centrality (0.047) - this node is a cross-community bridge._
- **Are the 17 inferred relationships involving `create()` (e.g. with `._generate_tutorial_articles()` and `.action_grant_portal_access()`) actually correct?**
_`create()` has 17 INFERRED edges - model-reasoned connections that need verification._
- **Are the 2 inferred relationships involving `accessibility_assessment_save()` (e.g. with `create()` and `.action_complete()`) actually correct?**
_`accessibility_assessment_save()` has 2 INFERRED edges - model-reasoned connections that need verification._
- **What connects `Ensure all module views are active after install/update. Odoo silently deac`, `Generic PDF template filler. Works with any template, any number of pages.`, `Fill a PDF template by overlaying text/checkmarks/signatures at configured posit` to the rest of the system?**
_177 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Community 0` be split into smaller, more focused modules?**
_Cohesion score 0.05 - nodes in this community are weakly interconnected._

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "label": "authorizer_comment.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L1"}, {"id": "authorizer_comment_authorizercomment", "label": "AuthorizerComment", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L9"}, {"id": "authorizer_comment_compute_display_name", "label": "_compute_display_name()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L70"}, {"id": "authorizer_comment_create", "label": "create()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L78"}, {"id": "authorizer_comment_rationale_79", "label": "Override create to set author from current user if not provided", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L79"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "authorizer_comment_authorizercomment", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "authorizer_comment_compute_display_name", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L70", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "authorizer_comment_create", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L78", "weight": 1.0}, {"source": "authorizer_comment_rationale_79", "target": "authorizer_comment_authorizercomment_create", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L79", "weight": 1.0}], "raw_calls": [{"caller_nid": "authorizer_comment_compute_display_name", "callee": "strftime", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L73"}, {"caller_nid": "authorizer_comment_compute_display_name", "callee": "_", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L75"}, {"caller_nid": "authorizer_comment_create", "callee": "get", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L81"}, {"caller_nid": "authorizer_comment_create", "callee": "get", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L83"}, {"caller_nid": "authorizer_comment_create", "callee": "super", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L85"}]}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_5_0_end_migrate_py", "label": "end-migrate.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L1"}, {"id": "end_migrate_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L16"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_5_0_end_migrate_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_5_0_end_migrate_py", "target": "end_migrate_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L16", "weight": 1.0}], "raw_calls": [{"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L20"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L31"}, {"caller_nid": "end_migrate_migrate", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L33"}, {"caller_nid": "end_migrate_migrate", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L35"}]}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "label": "chatter_message_authorizer.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L1"}, {"id": "chatter_message_authorizer_setup", "label": "setup()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L14"}, {"id": "chatter_message_authorizer_onclickmessageauthorizer", "label": "onClickMessageAuthorizer()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L20"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "chatter", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "patch", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L10", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "hooks", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L11", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "chatter_message_authorizer_setup", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L14", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "chatter_message_authorizer_onclickmessageauthorizer", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L20", "weight": 1.0}], "raw_calls": [{"caller_nid": "chatter_message_authorizer_setup", "callee": "useService", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L16"}, {"caller_nid": "chatter_message_authorizer_setup", "callee": "useService", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L17"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "call", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L25"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "map", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L32"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "split", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L32"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "doAction", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L34"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "warn", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L37"}]}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L6", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L7", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L8", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L10", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L11", "weight": 1.0}], "raw_calls": []}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "label": "timezone_detect.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L1"}, {"id": "timezone_detect_start", "label": "start()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L8"}, {"id": "timezone_detect_detectandsavetimezone", "label": "_detectAndSaveTimezone()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L13"}, {"id": "timezone_detect_getcookie", "label": "_getCookie()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L30"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "target": "public_widget", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "target": "timezone_detect_start", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L8", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "target": "timezone_detect_detectandsavetimezone", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L13", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "target": "timezone_detect_getcookie", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L30", "weight": 1.0}, {"source": "timezone_detect_start", "target": "timezone_detect_detectandsavetimezone", "relation": "calls", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L10", "weight": 1.0}, {"source": "timezone_detect_detectandsavetimezone", "target": "timezone_detect_getcookie", "relation": "calls", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L22", "weight": 1.0}], "raw_calls": [{"caller_nid": "timezone_detect_start", "callee": "_super", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L9"}, {"caller_nid": "timezone_detect_detectandsavetimezone", "callee": "resolvedOptions", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L16"}, {"caller_nid": "timezone_detect_detectandsavetimezone", "callee": "DateTimeFormat", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L16"}, {"caller_nid": "timezone_detect_detectandsavetimezone", "callee": "catch", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L27"}, {"caller_nid": "timezone_detect_detectandsavetimezone", "callee": "_rpc", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L27"}, {"caller_nid": "timezone_detect_getcookie", "callee": "match", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L31"}, {"caller_nid": "timezone_detect_getcookie", "callee": "decodeURIComponent", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L32"}]}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L6", "weight": 1.0}], "raw_calls": []}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_6_0_end_migrate_py", "label": "end-migrate.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L1"}, {"id": "end_migrate_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L24"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_6_0_end_migrate_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L11", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_6_0_end_migrate_py", "target": "end_migrate_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L24", "weight": 1.0}], "raw_calls": [{"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L28"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchone", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L33"}, {"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L35"}, {"caller_nid": "end_migrate_migrate", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L36"}, {"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L39"}, {"caller_nid": "end_migrate_migrate", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L44"}, {"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L49"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L60"}, {"caller_nid": "end_migrate_migrate", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L62"}, {"caller_nid": "end_migrate_migrate", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L64"}]}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L1"}, {"id": "init_reactivate_views", "label": "_reactivate_views()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L7"}, {"id": "init_rationale_8", "label": "Ensure all module views are active after install/update. Odoo silently deac", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L8"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "target": "init_reactivate_views", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L7", "weight": 1.0}, {"source": "init_rationale_8", "target": "init_reactivate_views", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L8", "weight": 1.0}], "raw_calls": [{"caller_nid": "init_reactivate_views", "callee": "search", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L15"}, {"caller_nid": "init_reactivate_views", "callee": "sudo", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L15"}, {"caller_nid": "init_reactivate_views", "callee": "write", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L20"}, {"caller_nid": "init_reactivate_views", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L21"}, {"caller_nid": "init_reactivate_views", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L26"}, {"caller_nid": "init_reactivate_views", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L28"}, {"caller_nid": "init_reactivate_views", "callee": "getLogger", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L28"}, {"caller_nid": "init_reactivate_views", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L29"}]}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_manifest_py", "label": "__manifest__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__manifest__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_signature_pad_js", "label": "signature_pad.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/signature_pad.js", "source_location": "L1"}], "edges": [], "raw_calls": []}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_assessment_form_js", "label": "assessment_form.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/assessment_form.js", "source_location": "L1"}], "edges": [], "raw_calls": []}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_4_0_end_migrate_py", "label": "end-migrate.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L1"}, {"id": "end_migrate_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L16"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_4_0_end_migrate_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_4_0_end_migrate_py", "target": "end_migrate_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L16", "weight": 1.0}], "raw_calls": [{"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L20"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L31"}, {"caller_nid": "end_migrate_migrate", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L33"}, {"caller_nid": "end_migrate_migrate", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L35"}]}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_3_0_end_migrate_py", "label": "end-migrate.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L1"}, {"id": "end_migrate_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L16"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_3_0_end_migrate_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_3_0_end_migrate_py", "target": "end_migrate_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L16", "weight": 1.0}], "raw_calls": [{"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L20"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L31"}, {"caller_nid": "end_migrate_migrate", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L33"}, {"caller_nid": "end_migrate_migrate", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L35"}]}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_portal_search_js", "label": "portal_search.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/portal_search.js", "source_location": "L1"}], "edges": [], "raw_calls": []}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_loaner_checkout_py", "label": "loaner_checkout.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L1"}, {"id": "loaner_checkout_fusionloanercheckoutassessment", "label": "FusionLoanerCheckoutAssessment", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L6"}, {"id": "loaner_checkout_fusionloanercheckoutassessment_action_view_assessment", "label": ".action_view_assessment()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L17"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_loaner_checkout_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_loaner_checkout_py", "target": "loaner_checkout_fusionloanercheckoutassessment", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L6", "weight": 1.0}, {"source": "loaner_checkout_fusionloanercheckoutassessment", "target": "loaner_checkout_fusionloanercheckoutassessment_action_view_assessment", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L17", "weight": 1.0}], "raw_calls": [{"caller_nid": "loaner_checkout_fusionloanercheckoutassessment_action_view_assessment", "callee": "ensure_one", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L18"}]}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_loaner_portal_js", "label": "loaner_portal.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/loaner_portal.js", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_loaner_portal_js", "target": "public_widget", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/loaner_portal.js", "source_location": "L3", "weight": 1.0}], "raw_calls": []}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_technician_sw_js", "label": "technician_sw.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/technician_sw.js", "source_location": "L1"}], "edges": [], "raw_calls": []}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_utils_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_utils_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_utils_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/__init__.py", "source_location": "L3", "weight": 1.0}], "raw_calls": []}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -33,14 +33,14 @@ fusion_ringcentral, fusion_tasks
`wizard/odsp_submit_to_odsp_wizard.py` calls into `fusion_faxes.send.fax.wizard` (the fax composer) and reads `partner.x_ff_fax_number`**but `fusion_faxes` is NOT in `__manifest__.py.depends`**. The fax actions are guarded by `hasattr` checks so the wizard still loads if `fusion_faxes` is missing, but the "Send Fax" / "Send Email + Fax" buttons will fail at click-time. If you're moving this module to a new database, install `fusion_faxes` alongside it.
### ⚠ Reverse-dependency: `fusion_authorizer_portal` always installed alongside
### ⚠ Reverse-dependency: `fusion_portal` always installed alongside
The dependency direction is **`fusion_authorizer_portal``fusion_claims`** (hard, declared in fusion_authorizer_portal's manifest), but fusion_claims uses APIs that only exist when fusion_authorizer_portal is installed:
The dependency direction is **`fusion_portal``fusion_claims`** (hard, declared in fusion_portal's manifest), but fusion_claims uses APIs that only exist when fusion_portal is installed:
- `sale.order._apply_pod_signature_to_approval_form` imports `PDFTemplateFiller` from `odoo.addons.fusion_authorizer_portal.utils.pdf_filler``ImportError` if missing.
- `fusion.page11.sign.request` renders PDFs using `fusion.pdf.template` records — that **model lives in fusion_authorizer_portal**, not here.
- The `/page11/sign/<token>` URL that the Page 11 wizard generates is handled by `fusion_authorizer_portal.controllers.portal_page11_sign` — without it the public signing flow is dead.
- `page11_sign_request._generate_signed_pdf` references `fusion.assessment` records — that model also lives in fusion_authorizer_portal.
- `sale.order._apply_pod_signature_to_approval_form` imports `PDFTemplateFiller` from `odoo.addons.fusion_portal.utils.pdf_filler``ImportError` if missing.
- `fusion.page11.sign.request` renders PDFs using `fusion.pdf.template` records — that **model lives in fusion_portal**, not here.
- The `/page11/sign/<token>` URL that the Page 11 wizard generates is handled by `fusion_portal.controllers.portal_page11_sign` — without it the public signing flow is dead.
- `page11_sign_request._generate_signed_pdf` references `fusion.assessment` records — that model also lives in fusion_portal.
In practice both modules are always installed together. See §29 for the full integration map.
@@ -861,7 +861,7 @@ Mirrors the MOD on_hold pattern. `x_fc_odsp_previous_status_before_hold` saves t
| Method | Used when | Mechanism |
|---|---|---|
| `action_sign_sa_mobility_form` | Client signs the SA Mobility form directly (Page 2 client consent) | **Hard-coded coordinates**: writes printed name at `(180, h-180)` and `(72, h-560)`, date at `(350, h-560)`, signature image at `(72, h-540, 200×50px)`. Uses `reportlab.pdfgen.canvas` + `odoo.tools.pdf.PdfFileReader/Writer`. **Brittle** — if the gov PDF layout changes, the coordinates must be re-measured. |
| `_apply_pod_signature_to_approval_form` | POD signature collected (auto-fired by `write` override when `x_fc_pod_signature` is set) | **PDFTemplateFiller** from `fusion_authorizer_portal` — reads field positions from the active `fusion.pdf.template` (category=`odsp`), uses per-case `x_fc_sa_signature_page`. Configurable via drag-and-drop visual editor, not code. Bypass via `skip_pod_signature_hook=True` context. |
| `_apply_pod_signature_to_approval_form` | POD signature collected (auto-fired by `write` override when `x_fc_pod_signature` is set) | **PDFTemplateFiller** from `fusion_portal` — reads field positions from the active `fusion.pdf.template` (category=`odsp`), uses per-case `x_fc_sa_signature_page`. Configurable via drag-and-drop visual editor, not code. Bypass via `skip_pod_signature_hook=True` context. |
The PDFTemplateFiller approach is the preferred path going forward — it survives gov form revisions because positions live in the database, not in Python code.
@@ -1588,7 +1588,7 @@ All user-facing text is **Canadian English** (per repo CLAUDE.md). All monetary
74. **`odsp_sa_mobility_wizard._get_template_path()` uses raw `os.path`** instead of Odoo's `tools.misc.file_path`. If the module is ever deployed as a zip (rare in Odoo deployments but possible), this will fail. Migrate to `file_path('fusion_claims/static/src/pdf/sa_mobility_form_template.pdf')` if you ship this for multi-tenant.
75. **PDF template field positions for ODSP signing live in `fusion.pdf.template` (category=odsp)** — managed via a drag-and-drop editor that lives in `fusion_authorizer_portal`. The OWL editor reads field positions per-page; `_apply_pod_signature_to_approval_form` consumes them. If the gov SA form layout changes, edit the template via the visual editor, not by changing Python coordinates.
75. **PDF template field positions for ODSP signing live in `fusion.pdf.template` (category=odsp)** — managed via a drag-and-drop editor that lives in `fusion_portal`. The OWL editor reads field positions per-page; `_apply_pod_signature_to_approval_form` consumes them. If the gov SA form layout changes, edit the template via the visual editor, not by changing Python coordinates.
76. **SA Mobility wizard limits rows**: 6 parts, 5 labour, 4 fees. The gov PDF only has that many slots. If the SO has more lines, the rest are silently dropped from the form fill (but still appear in the invoice). The wizard truncates via slicing in `default_get`.
@@ -1862,11 +1862,11 @@ This module is the **lower-level engine**. Two sibling modules layer on top of i
The whole technician task → sale order coupling lives in `fusion_claims/models/technician_task.py:674` — and the calendar / map / scheduling logic stays in the base `fusion.technician.task` model in fusion_tasks.
### 29.2 `fusion_authorizer_portal` (portal layer — undeclared but co-installed)
### 29.2 `fusion_portal` (portal layer — undeclared but co-installed)
fusion_authorizer_portal manifest declares `fusion_claims` + `fusion_tasks` + `fusion_loaners_management` as hard deps. fusion_claims uses APIs that only exist when fusion_authorizer_portal is installed — see the dependency note at the top of §2.
fusion_portal manifest declares `fusion_claims` + `fusion_tasks` + `fusion_loaners_management` as hard deps. fusion_claims uses APIs that only exist when fusion_portal is installed — see the dependency note at the top of §2.
| Provided by fusion_authorizer_portal | Used by fusion_claims |
| Provided by fusion_portal | Used by fusion_claims |
|---|---|
| `PDFTemplateFiller` class (`utils/pdf_filler.py`) | `sale.order._apply_pod_signature_to_approval_form` imports it. Same pattern as Odoo Enterprise Sign module — overlays text/checkmarks/signatures via reportlab Canvas + `mergePage()`. |
| `fusion.pdf.template` model + `fusion.pdf.template.field` + `fusion.pdf.template.preview` | Drag-and-drop visual editor for placing fields on PDF preview images. Categories: `adp`, `mod`, `odsp`, `hardship`, `other`. fusion_claims searches for `(category='odsp', state='active')` for SA Mobility / OW signature overlays. The Page 11 wizard searches for `name ilike 'adp_page_11'` or `'page 11'`. |
@@ -1888,7 +1888,7 @@ fusion_authorizer_portal manifest declares `fusion_claims` + `fusion_tasks` + `f
- Renaming a field on `sale.order` likely affects portal templates (`portal_templates.xml`, `portal_assessment_express.xml`, `portal_accessibility_*.xml`) that reference it via QWeb.
- Adding a new `x_fc_adp_application_status` value may need a portal-side handler in `portal_main.py` to render the new state.
- The `fusion.pdf.template` schema (page-positioned fields) is the ground truth for ODSP signature placement — DON'T hard-code coordinates in fusion_claims when you could create a template field instead.
- The `_reactivate_views` post-init hook on fusion_authorizer_portal exists specifically because the inheritance from this module's views is fragile — if you rename a field referenced by an xpath in fusion_authorizer_portal, that view goes dead and stays dead.
- The `_reactivate_views` post-init hook on fusion_portal exists specifically because the inheritance from this module's views is fragile — if you rename a field referenced by an xpath in fusion_portal, that view goes dead and stays dead.
### 29.3 Other co-installed Nexa modules
@@ -1896,7 +1896,7 @@ fusion_authorizer_portal manifest declares `fusion_claims` + `fusion_tasks` + `f
|---|---|---|
| `fusion_ringcentral` | RingCentral softphone, click-to-dial widget, fax composer | Click-to-dial works on any phone field — no direct API calls from this module |
| `fusion_faxes` | `fusion_faxes.send.fax.wizard` + `partner.x_ff_fax_number` | Hard-soft-dep: `odsp_submit_to_odsp_wizard` calls the fax wizard for ODSP submissions |
| `fusion_loaners_management` | Loaner equipment lending | fusion_authorizer_portal depends on this; fusion_claims doesn't touch it directly |
| `fusion_loaners_management` | Loaner equipment lending | fusion_portal depends on this; fusion_claims doesn't touch it directly |
| `fusion_pdf_preview` | PDF preview client action + report intercept | Project CLAUDE.md says prefer this over `act_url`+`target=new` for attachments. fusion_claims still has legacy attachment buttons using the old pattern — see gotcha #12 |
## 30. Per-funder workflow state machines
@@ -2323,7 +2323,7 @@ Creates a `fusion.technician.location` record on the remote with `source='sync'`
- `context['skip_travel_recalc']` — prevents the pull from triggering local recalculations.
- Terminal-state tasks (`completed`, `cancelled`) — push side does write, but pull side does NOT update existing shadow records that are already terminal (defensive against late race conditions).
## 33. `fusion.assessment` (OT assessment model — lives in `fusion_authorizer_portal`)
## 33. `fusion.assessment` (OT assessment model — lives in `fusion_portal`)
The 1,636-line model that captures an OT's assessment of a client + their equipment needs, then generates the draft sale order.
@@ -2395,7 +2395,7 @@ The model has `signature_page_11` + `signature_page_12` binary fields. `signatur
`action_complete_express()` skips step 3 (signatures) entirely — used for the "express" assessment route from the sales-rep portal where the rep just needs to spec a wheelchair without doing the full ADP assessment.
## 34. `fusion.accessibility.assessment` (MOD/accessibility assessment — lives in `fusion_authorizer_portal`)
## 34. `fusion.accessibility.assessment` (MOD/accessibility assessment — lives in `fusion_portal`)
The 966-line sibling for accessibility modifications (not ADP).
@@ -2466,7 +2466,7 @@ The model has hundreds of measurement fields, only some of which are visible per
`stairlift_curved`, `vpl`, `ceiling_lift`, `ramp`, `bathroom`, `tub_cutout` each have their own set of fields.
## 35. `fusion_authorizer_portal` controller routes — detailed
## 35. `fusion_portal` controller routes — detailed
Full per-route inventory from `portal_main.py` (2,827 lines), `portal_assessment.py` (1,238), `portal_page11_sign.py` (206), `pdf_editor.py` (218).
@@ -2816,7 +2816,7 @@ All filtered to `move_type in ['out_invoice', 'out_refund']` (customer invoices
ACSD (Assistance to Children with Severe Disabilities) is a CLIENT TYPE, not a sale type. The menu has a dedicated ACSD entry that catches any sale type but with `client_type='ACS'`.
## 40. `fusion_authorizer_portal.sale_order` extensions (266 lines)
## 40. `fusion_portal.sale_order` extensions (266 lines)
Adds 6 fields to `sale.order` + 5 methods:
@@ -2843,7 +2843,7 @@ JSON-RPC methods (called from portal JS):
`_get_partner_address_display()` — formatted address string.
`_get_product_lines_for_portal()` — product lines minus internal-only data.
## 41. `fusion_authorizer_portal.res_partner` extensions (767 lines)
## 41. `fusion_portal.res_partner` extensions (767 lines)
Adds geolocation + portal access management:
@@ -3019,7 +3019,7 @@ ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_claims --
ssh odoo-mobility "docker exec odoo-mobility-app odoo -d mobility -u fusion_claims --stop-after-init && docker restart odoo-mobility-app"
```
For multiple modules: `-u fusion_claims,fusion_tasks,fusion_authorizer_portal`.
For multiple modules: `-u fusion_claims,fusion_tasks,fusion_portal`.
### 46.3 Database probes
@@ -3077,7 +3077,7 @@ After 9 rounds of deep diving, here's what CLAUDE.md covers vs the codebase:
- Every cron job with cadence + logic
- Every constraint method with regex + rule
- Every special-character/edge-case behaviour I encountered
- Every cross-module integration point with both sibling modules (fusion_tasks, fusion_authorizer_portal)
- Every cross-module integration point with both sibling modules (fusion_tasks, fusion_portal)
- Every PDF report's conditional sections + business logic
- Every ICP setting (~60+)
- Every gotcha (~83)
@@ -3103,4 +3103,4 @@ After 9 rounds of deep diving, here's what CLAUDE.md covers vs the codebase:
- Build new reports following the established color/header/footer conventions
- Add new gotchas in the right format
- Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview`
- Know the deployment fact that fusion_authorizer_portal is always co-installed
- Know the deployment fact that fusion_portal is always co-installed

View File

@@ -1709,7 +1709,7 @@ class SaleOrder(models.Model):
return
import base64
from odoo.addons.fusion_authorizer_portal.utils.pdf_filler import PDFTemplateFiller
from odoo.addons.fusion_portal.utils.pdf_filler import PDFTemplateFiller
tpl = self.env['fusion.pdf.template'].search([
('category', '=', 'odsp'), ('state', '=', 'active'),

View File

@@ -5,7 +5,7 @@
## 1. What This Module Is
- **Name**: Fusion Clock.
- **Version**: `19.0.3.3.0`.
- **Version**: `19.0.4.1.0`.
- **Category**: Human Resources/Attendances.
- **License**: OPL-1, Nexa Systems Inc.
- **Purpose**: complete time and attendance app built on Odoo `hr.attendance`.
@@ -68,6 +68,7 @@ Custom models:
| `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. |
| `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. |
| `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. |
| `fusion.clock.break.rule` | `models/clock_break_rule.py` | Per-province statutory unpaid-break thresholds (2-tier: first break after N1 h, second after N2 h). |
| `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. |
Inherited models:
@@ -101,7 +102,7 @@ Clock-out flow:
1. Verify location again.
2. Call `_attendance_action_change()`.
3. Write out-distance.
4. Apply break deduction when configured.
4. Break is deducted automatically — `x_fclk_break_minutes` is a stored compute (see §13), not an explicit controller step.
5. Create `early_out` penalty when outside grace.
6. Log `clock_out`.
7. Log overtime if computed overtime is positive.
@@ -252,7 +253,6 @@ fusion_clock.default_clock_in_time
fusion_clock.default_clock_out_time
fusion_clock.default_break_minutes
fusion_clock.auto_deduct_break
fusion_clock.break_threshold_hours
fusion_clock.enable_auto_clockout
fusion_clock.max_shift_hours
fusion_clock.enable_penalties
@@ -327,8 +327,9 @@ All new JSON endpoints must use `type="jsonrpc"`, not deprecated `type="json"`.
- Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets.
- `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons.
- Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field.
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes.
- **`hr.attendance.x_fclk_break_minutes` is a stored COMPUTE, not a writable field** (`_compute_fclk_break_minutes`): statutory break (per the employee's province `fusion.clock.break.rule`, from actual `worked_hours`, 2-tier — first break after N1 h, second after N2 h, inclusive `>=`) **plus** Σ penalty minutes. It recomputes on every path incl. manual backend create/edit, which is what makes the break auto-apply on manually-entered hours. NEVER `write()` it — change the province rule or toggle `fusion_clock.auto_deduct_break` instead. Penalty minutes are now strictly additive (the old controller `max()` that could swallow a late clock-in penalty is gone). Rule resolved via `hr.employee._get_fclk_break_rule()` (company `state_id` → matching rule → global `is_default` rule). The retired `break_threshold_hours` setting is superseded by per-rule `break1_after_hours`.
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes. **Gotcha: `worked_hours` itself subtracts the resource-calendar lunch interval for NON-flexible employees** (Odoo core `hr.attendance._get_worked_hours_in_range`), so the statutory tiers run on lunch-excluded hours; flexible / no-calendar employees get the raw check_in→check_out span. Tests that need a deterministic span give the employee a `flexible_hours` calendar.
- **Migration recompute gotcha**: recomputing ONE stored computed field via `env.add_to_compute(field, recs) + recs.flush_recordset([field])` does NOT cascade to fields that depend on it. The `19.0.4.1.0` post-migrate recomputes `x_fclk_break_minutes`, `x_fclk_net_hours` AND `x_fclk_overtime_hours` (in that dependency order, flushing each) — recomputing only the break left historical `net_hours` stale (caught on the entech deploy 2026-06-01).
- Daily overtime compares net hours to the employee's scheduled hours or the daily threshold. (The old `weekly_overtime_threshold` and `grace_period_minutes` settings were removed 2026-05-31 — they were defined/shown but never consumed.)
- `fusion_clock.enable_ip_fallback` is honoured: `_verify_location()` only attempts IP-whitelist matching when the toggle is on (default on).
- **All fusion_clock Boolean settings are persisted explicitly** (`'True'`/`'False'`) via the `_FCLK_BOOL_PARAMS` loop in `res.config.settings.get_values/set_values`, NOT via `config_parameter=`. Reason: a `config_parameter` Boolean can't be turned OFF (Odoo deletes the param row on a falsy value, so `get_param` returns the default and the feature stays on). When adding a new Boolean setting, add it to `_FCLK_BOOL_PARAMS` with its default; don't use `config_parameter=`.

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
'version': '19.0.4.0.0',
'version': '19.0.4.1.0',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """
@@ -52,6 +52,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'security/ir.model.access.csv',
# Data
'data/ir_config_parameter_data.xml',
'data/clock_break_rule_data.xml',
'data/ir_cron_data.xml',
# Reports (must load before mail templates that reference them)
'report/clock_report_template.xml',
@@ -71,6 +72,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/clock_dashboard_views.xml',
'views/hr_employee_views.xml',
'views/clock_schedule_views.xml',
'views/clock_break_rule_views.xml',
# Wizards (must load before clock_menus.xml since menu references wizard action)
'wizard/clock_nfc_enrollment_views.xml',
'wizard/clock_period_picker_views.xml',

View File

@@ -5,7 +5,6 @@
import base64
import math
import logging
import pytz
from datetime import datetime, timedelta
from odoo import http, fields, _
from odoo.http import request
@@ -137,12 +136,6 @@ class FusionClockAPI(http.Controller):
'date': actual_dt.date() if isinstance(actual_dt, datetime) else get_local_today(request.env, employee),
})
# Deduct penalty minutes from attendance (adds to break deduction)
current_break = attendance.x_fclk_break_minutes or 0.0
attendance.sudo().write({
'x_fclk_break_minutes': current_break + deduction,
})
# Log penalty
log_type = 'late_clock_in' if penalty_type == 'late_in' else 'early_clock_out'
request.env['fusion.clock.activity.log'].sudo().create({
@@ -158,32 +151,6 @@ class FusionClockAPI(http.Controller):
if penalty_type == 'late_in':
employee.sudo().write({'x_fclk_ontime_streak': 0})
def _apply_break_deduction(self, attendance, employee):
"""Apply automatic break deduction if configured."""
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
return
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
worked = attendance.worked_hours or 0.0
if worked >= threshold:
local_date = get_local_today(request.env, employee)
if attendance.check_in:
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
break_min = employee._get_fclk_break_minutes(local_date)
current = attendance.x_fclk_break_minutes or 0.0
# Set to whichever is higher: configured break or existing (penalty-inflated) value
new_val = max(break_min, current)
if new_val != current:
attendance.sudo().write({'x_fclk_break_minutes': new_val})
def _log_activity(self, employee, log_type, description, attendance=None,
location=None, latitude=0, longitude=0, distance=0, source='portal'):
"""Create an activity log entry."""
@@ -405,9 +372,6 @@ class FusionClockAPI(http.Controller):
'x_fclk_out_distance': round(distance, 1),
})
# Apply break deduction
self._apply_break_deduction(attendance, employee)
# Check for early clock-out penalty
if not is_scheduled_off:
_, scheduled_out = self._get_scheduled_times(employee, today)

View File

@@ -155,7 +155,6 @@ class FusionClockKiosk(http.Controller):
'x_fclk_out_distance': 0.0,
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
})
api._apply_break_deduction(attendance, employee)
if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)

View File

@@ -378,7 +378,6 @@ class FusionClockNfcKiosk(http.Controller):
'x_fclk_out_distance': 0.0,
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
})
api._apply_break_deduction(attendance, employee)
if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="break_rule_ontario" model="fusion.clock.break.rule">
<field name="name">Ontario</field>
<field name="country_id" ref="base.ca"/>
<field name="state_id" ref="base.state_ca_on"/>
<field name="is_default" eval="True"/>
<field name="break1_after_hours">5.0</field>
<field name="break1_minutes">30.0</field>
<field name="break2_after_hours">10.0</field>
<field name="break2_minutes">30.0</field>
</record>
</odoo>

View File

@@ -20,10 +20,6 @@
<field name="key">fusion_clock.auto_deduct_break</field>
<field name="value">True</field>
</record>
<record id="config_break_threshold_hours" model="ir.config_parameter">
<field name="key">fusion_clock.break_threshold_hours</field>
<field name="value">4.0</field>
</record>
<!-- Auto Clock-Out -->
<record id="config_enable_auto_clockout" model="ir.config_parameter">

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, SUPERUSER_ID
def migrate(cr, version):
"""Retire the single-threshold break param (superseded by per-rule
break1_after_hours), and force-recompute the now-computed break field so
existing closed attendances reflect the province rule + their penalties."""
cr.execute(
"DELETE FROM ir_config_parameter WHERE key = %s",
('fusion_clock.break_threshold_hours',),
)
env = api.Environment(cr, SUPERUSER_ID, {})
Attendance = env['hr.attendance']
closed = Attendance.search([('check_out', '!=', False)])
if closed:
# Recompute the break AND everything that derives from it, in dependency
# order (break -> net hours -> overtime). Recomputing break alone leaves
# stored x_fclk_net_hours / x_fclk_overtime_hours stale, because
# add_to_compute + flush of one field does not cascade to its dependents.
for fname in ('x_fclk_break_minutes', 'x_fclk_net_hours', 'x_fclk_overtime_hours'):
env.add_to_compute(Attendance._fields[fname], closed)
closed.flush_recordset([fname])

View File

@@ -5,6 +5,7 @@ from . import clock_location
from . import hr_attendance
from . import hr_employee
from . import clock_penalty
from . import clock_break_rule
from . import clock_report
from . import res_config_settings
from . import clock_activity_log

View File

@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
class FusionClockBreakRule(models.Model):
_name = 'fusion.clock.break.rule'
_description = 'Statutory Break Rule'
_order = 'sequence, name'
name = fields.Char(string='Name', required=True)
country_id = fields.Many2one('res.country', string='Country')
state_id = fields.Many2one(
'res.country.state',
string='Province / State',
help="Employees whose company is in this province use this rule.",
)
is_default = fields.Boolean(
string='Default Rule',
help="Used when an employee's company province matches no other rule. "
"Only one active rule may be the default.",
)
break1_after_hours = fields.Float(
string='First Break After (h)', default=5.0,
help="Worked hours at or above this trigger the first unpaid break.",
)
break1_minutes = fields.Float(
string='First Break (min)', default=30.0,
help="Length of the first unpaid break. 0 disables it.",
)
break2_after_hours = fields.Float(
string='Second Break After (h)', default=10.0,
help="Worked hours at or above this add the second unpaid break.",
)
break2_minutes = fields.Float(
string='Second Break (min)', default=30.0,
help="Length of the second unpaid break. 0 disables it.",
)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
def break_minutes_for(self, worked_hours):
"""Total statutory unpaid break (minutes) for the given worked hours.
Tiers are inclusive (``>=``): a break applies when worked hours are
equal to or greater than the threshold. The second tier adds on top of
the first.
"""
self.ensure_one()
worked = worked_hours or 0.0
total = 0.0
if self.break1_minutes and worked >= self.break1_after_hours:
total += self.break1_minutes
if self.break2_minutes and worked >= self.break2_after_hours:
total += self.break2_minutes
return total
@api.constrains('break1_after_hours', 'break1_minutes',
'break2_after_hours', 'break2_minutes')
def _check_tiers(self):
for rule in self:
if min(rule.break1_after_hours, rule.break1_minutes,
rule.break2_after_hours, rule.break2_minutes) < 0:
raise ValidationError(_("Break hours and minutes cannot be negative."))
if rule.break2_minutes and rule.break2_after_hours <= rule.break1_after_hours:
raise ValidationError(_(
"The second break threshold (%(n2)s h) must be greater than "
"the first (%(n1)s h).",
n2=rule.break2_after_hours, n1=rule.break1_after_hours))
@api.constrains('is_default', 'active')
def _check_single_default(self):
for rule in self:
if rule.is_default and rule.active:
dupe = self.search([
('is_default', '=', True), ('active', '=', True),
('id', '!=', rule.id),
], limit=1)
if dupe:
raise ValidationError(_(
"Only one active break rule can be the default "
"(currently: %s).", dupe.name))

View File

@@ -161,9 +161,12 @@ class HrAttendance(models.Model):
)
x_fclk_break_minutes = fields.Float(
string='Break (min)',
default=0.0,
compute='_compute_fclk_break_minutes',
store=True,
tracking=True,
help="Break duration in minutes to deduct from worked hours.",
help="Unpaid break deducted from worked hours: statutory break (per the "
"employee's province rule, from actual hours worked) plus any penalty "
"minutes. Computed automatically on every save.",
)
x_fclk_net_hours = fields.Float(
string='Net Hours',
@@ -258,6 +261,20 @@ class HrAttendance(models.Model):
def _search_fclk_in_next_period(self, operator, value):
return self._fclk_period_search('next', operator, value)
@api.depends('worked_hours', 'check_out',
'x_fclk_penalty_ids.penalty_minutes', 'employee_id')
def _compute_fclk_break_minutes(self):
ICP = self.env['ir.config_parameter'].sudo()
auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True'
for att in self:
statutory = 0.0
if auto and att.check_out and att.employee_id:
rule = att.employee_id._get_fclk_break_rule()
if rule:
statutory = rule.break_minutes_for(att.worked_hours or 0.0)
penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes'))
att.x_fclk_break_minutes = statutory + penalties
@api.depends('worked_hours', 'x_fclk_break_minutes')
def _compute_net_hours(self):
for att in self:
@@ -314,7 +331,6 @@ class HrAttendance(models.Model):
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0'))
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
now = fields.Datetime.now()
open_attendances = self.sudo().search([('check_out', '=', False)])
@@ -329,8 +345,6 @@ class HrAttendance(models.Model):
continue
employee = att.employee_id
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
clock_out_time = effective_deadline
try:
with self.env.cr.savepoint():
@@ -340,10 +354,6 @@ class HrAttendance(models.Model):
'x_fclk_grace_used': True,
'x_fclk_clock_source': 'auto',
})
if (att.worked_hours or 0) >= threshold:
att.sudo().write(
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
)
att.sudo().message_post(
body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} "
f"(max-shift cap reached). Net hours: {att.x_fclk_net_hours:.1f}h",

View File

@@ -215,6 +215,23 @@ class HrEmployee(models.Model):
)
)
def _get_fclk_break_rule(self):
"""Return the statutory break rule for this employee.
Resolution: company's province -> matching rule; else the global default
rule; else an empty recordset (caller treats as zero break). Read via
sudo so the portal net-hours compute can resolve it without a direct ACL.
"""
self.ensure_one()
Rule = self.env['fusion.clock.break.rule'].sudo()
rule = Rule.browse()
state = self.company_id.state_id
if state:
rule = Rule.search([('state_id', '=', state.id)], limit=1)
if not rule:
rule = Rule.search([('is_default', '=', True)], limit=1)
return rule
def _get_fclk_scheduled_times(self, date):
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.

View File

@@ -9,17 +9,21 @@ class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# ── Work Schedule ──────────────────────────────────────────────────
fclk_default_clock_in_time = fields.Float(
# 12-hour AM/PM dropdowns (people aren't good with 24h). The selection VALUE
# is the float-as-string the backend stores (e.g. '9.0', '17.5'), so all
# downstream float(get_param(...)) reads are unchanged. Persisted manually in
# get_values/set_values (a 15-min grid; get snaps any off-grid stored value).
fclk_default_clock_in_time = fields.Selection(
selection='_fclk_time_selection',
string='Default Clock-In Time',
config_parameter='fusion_clock.default_clock_in_time',
default=9.0,
help="Default scheduled clock-in time (24h format, e.g. 9.0 = 9:00 AM).",
default='9.0',
help="Default scheduled clock-in time, used when no shift is assigned.",
)
fclk_default_clock_out_time = fields.Float(
fclk_default_clock_out_time = fields.Selection(
selection='_fclk_time_selection',
string='Default Clock-Out Time',
config_parameter='fusion_clock.default_clock_out_time',
default=17.0,
help="Default scheduled clock-out time (24h format, e.g. 17.0 = 5:00 PM).",
default='17.0',
help="Default scheduled clock-out time, used when no shift is assigned.",
)
fclk_auto_deduct_break = fields.Boolean(
string='Auto-Deduct Break',
@@ -32,12 +36,6 @@ class ResConfigSettings(models.TransientModel):
default=30.0,
help="Default unpaid break duration in minutes.",
)
fclk_break_threshold_hours = fields.Float(
string='Break Threshold (hours)',
config_parameter='fusion_clock.break_threshold_hours',
default=4.0,
help="Only deduct break if shift is longer than this many hours.",
)
# ── Attendance Rules ───────────────────────────────────────────────
fclk_enable_auto_clockout = fields.Boolean(
@@ -280,11 +278,40 @@ class ResConfigSettings(models.TransientModel):
('fclk_nfc_kiosk_debug', 'fusion_clock.nfc_kiosk_debug', False),
]
@api.model
def _fclk_time_selection(self):
"""15-minute grid of 12-hour clock times. Each option's VALUE is the
float-as-string the backend stores (e.g. '9.0', '17.5'); the LABEL is
the friendly 12-hour form (e.g. '9:00 AM')."""
opts = []
for i in range(96):
f = i * 0.25
h24 = int(f)
mm = int(round((f - h24) * 60))
ap = 'AM' if h24 < 12 else 'PM'
h12 = h24 % 12 or 12
opts.append((str(f), '%d:%02d %s' % (h12, mm, ap)))
return opts
@staticmethod
def _fclk_snap_time(value, default_float):
"""Snap a stored float-ish time to the nearest 15-min grid key string."""
try:
f = float(value)
except (ValueError, TypeError):
f = default_float
f = round(f * 4) / 4
if f < 0 or f >= 24:
f = default_float
return str(f)
def set_values(self):
super().set_values()
ICP = self.env['ir.config_parameter'].sudo()
for fname, key, _default in self._FCLK_BOOL_PARAMS:
ICP.set_param(key, 'True' if self[fname] else 'False')
ICP.set_param('fusion_clock.default_clock_in_time', self.fclk_default_clock_in_time or '9.0')
ICP.set_param('fusion_clock.default_clock_out_time', self.fclk_default_clock_out_time or '17.0')
if self.fclk_office_user_id:
ICP.set_param('fusion_clock.office_user_id', str(self.fclk_office_user_id.id))
else:
@@ -323,4 +350,8 @@ class ResConfigSettings(models.TransientModel):
res['fclk_pay_period_start'] = fields.Date.to_date(anchor_str[:10])
except (ValueError, TypeError):
pass
res['fclk_default_clock_in_time'] = self._fclk_snap_time(
ICP.get_param('fusion_clock.default_clock_in_time', '9.0'), 9.0)
res['fclk_default_clock_out_time'] = self._fclk_snap_time(
ICP.get_param('fusion_clock.default_clock_out_time', '17.0'), 17.0)
return res

View File

@@ -27,3 +27,4 @@ access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,ba
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
27 access_fusion_clock_shift_portal fusion.clock.shift.portal model_fusion_clock_shift base.group_portal 1 0 0 0
28 access_fusion_clock_schedule_portal fusion.clock.schedule.portal model_fusion_clock_schedule base.group_portal 1 0 0 0
29 access_fusion_clock_nfc_enrollment_wizard_manager fusion.clock.nfc.enrollment.wizard.manager model_fusion_clock_nfc_enrollment_wizard group_fusion_clock_manager 1 1 1 1
30 access_fusion_clock_break_rule_manager fusion.clock.break.rule.manager model_fusion_clock_break_rule group_fusion_clock_manager 1 1 1 1

View File

@@ -9,3 +9,4 @@ from . import test_dashboard
from . import test_pay_period
from . import test_settings
from . import test_clock_kiosk
from . import test_break_rules

View File

@@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import datetime, timedelta
from odoo.tests import tagged, TransactionCase
from odoo.exceptions import ValidationError
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestBreakRules(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.auto_deduct_break', 'True')
cls.Rule = cls.env['fusion.clock.break.rule']
cls.default_rule = cls.Rule.search([('is_default', '=', True)], limit=1)
# Flexible calendar -> hr.attendance.worked_hours is the raw check_in..check_out
# span (no lunch interval subtracted), so the tier math below is deterministic.
# This also mirrors real clock-in/out employees, who are effectively flexible.
flex = cls.env['resource.calendar'].create({
'name': 'FCLK Flexible', 'flexible_hours': True,
})
cls.employee = cls.env['hr.employee'].create({
'name': 'FCLK Break Test', 'resource_calendar_id': flex.id,
})
def _mk_att(self, hours):
check_in = datetime(2026, 1, 5, 9, 0, 0)
return self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': check_in,
'check_out': check_in + timedelta(hours=hours),
})
# ---- Task 1: tier engine + constraints ----
def test_break_minutes_for_tiers(self):
rule = self.Rule.create({
'name': 'Tier Test', 'is_default': False,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
})
self.assertEqual(rule.break_minutes_for(4.99), 0.0)
self.assertEqual(rule.break_minutes_for(5.0), 30.0)
self.assertEqual(rule.break_minutes_for(9.99), 30.0)
self.assertEqual(rule.break_minutes_for(10.0), 60.0)
self.assertEqual(rule.break_minutes_for(12.0), 60.0)
def test_second_tier_must_exceed_first(self):
with self.assertRaises(ValidationError):
self.Rule.create({
'name': 'Bad', 'is_default': False,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 5.0, 'break2_minutes': 30.0,
})
def test_single_default_enforced(self):
self.assertTrue(self.default_rule, "seed default rule must exist")
with self.assertRaises(ValidationError):
self.Rule.create({
'name': 'Another Default', 'is_default': True, 'active': True,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
})
# ---- Task 2: jurisdiction resolver ----
def test_resolver_matches_company_province(self):
bc = self.env.ref('base.state_ca_bc')
bc_rule = self.Rule.create({
'name': 'British Columbia', 'state_id': bc.id, 'is_default': False,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
})
self.employee.company_id.state_id = bc.id
self.assertEqual(self.employee._get_fclk_break_rule(), bc_rule)
def test_resolver_falls_back_to_default(self):
self.assertTrue(self.default_rule, "seed default rule must exist")
alberta = self.env.ref('base.state_ca_ab') # no rule for AB
self.employee.company_id.state_id = alberta.id
self.assertEqual(self.employee._get_fclk_break_rule(), self.default_rule)
# ---- Task 3: automatic deduction on every path ----
def test_manual_attendance_applies_statutory_break(self):
att = self._mk_att(6) # 6h >= 5 -> first break
self.assertEqual(att.x_fclk_break_minutes, 30.0)
self.assertAlmostEqual(att.x_fclk_net_hours, 5.5, places=2)
def test_manual_edit_extends_break(self):
att = self._mk_att(6)
self.assertEqual(att.x_fclk_break_minutes, 30.0)
att.check_out = att.check_in + timedelta(hours=10) # now >= 10
self.assertEqual(att.x_fclk_break_minutes, 60.0)
self.assertAlmostEqual(att.x_fclk_net_hours, 9.0, places=2)
def test_under_first_threshold_no_break(self):
att = self._mk_att(4) # 4h < 5 -> nothing
self.assertEqual(att.x_fclk_break_minutes, 0.0)
self.assertAlmostEqual(att.x_fclk_net_hours, 4.0, places=2)
def test_penalty_minutes_are_additive(self):
att = self._mk_att(6) # statutory 30
self.env['fusion.clock.penalty'].create({
'attendance_id': att.id,
'employee_id': self.employee.id,
'penalty_type': 'early_out',
'penalty_minutes': 15.0,
'date': att.check_in.date(),
})
self.assertEqual(att.x_fclk_break_minutes, 45.0)
def test_master_toggle_off_zero_statutory(self):
self.ICP.set_param('fusion_clock.auto_deduct_break', 'False')
att = self._mk_att(6)
self.assertEqual(att.x_fclk_break_minutes, 0.0)
def test_open_attendance_zero_break(self):
att = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2026, 1, 5, 9, 0, 0),
})
self.assertEqual(att.x_fclk_break_minutes, 0.0)

View File

@@ -40,3 +40,4 @@ class TestFusionClockSettings(TransactionCase):
fields = self.env['res.config.settings']._fields
self.assertNotIn('fclk_grace_period_minutes', fields)
self.assertNotIn('fclk_weekly_overtime_threshold', fields)
self.assertNotIn('fclk_break_threshold_hours', fields)

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_clock_break_rule_list" model="ir.ui.view">
<field name="name">fusion.clock.break.rule.list</field>
<field name="model">fusion.clock.break.rule</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="state_id"/>
<field name="country_id" optional="hide"/>
<field name="break1_after_hours" widget="float_time"/>
<field name="break1_minutes"/>
<field name="break2_after_hours" widget="float_time"/>
<field name="break2_minutes"/>
<field name="is_default"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fusion_clock_break_rule_form" model="ir.ui.view">
<field name="name">fusion.clock.break.rule.form</field>
<field name="model">fusion.clock.break.rule</field>
<field name="arch" type="xml">
<form>
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
invisible="active"/>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Ontario"/></h1>
</div>
<group>
<group string="Jurisdiction">
<field name="country_id"/>
<field name="state_id"
domain="[('country_id', '=', country_id)]"/>
<field name="is_default"/>
<field name="active"/>
</group>
<group string="Unpaid Break Tiers">
<label for="break1_after_hours" string="First break after"/>
<div class="o_row">
<field name="break1_after_hours" widget="float_time"/>
<span>h →</span>
<field name="break1_minutes"/>
<span>min</span>
</div>
<label for="break2_after_hours" string="Second break after"/>
<div class="o_row">
<field name="break2_after_hours" widget="float_time"/>
<span>h →</span>
<field name="break2_minutes"/>
<span>min</span>
</div>
</group>
</group>
<p class="text-muted">
Breaks are unpaid and deducted from actual worked hours. A tier with
0 minutes is disabled. Triggers are inclusive — a break applies when
worked hours are equal to or above the threshold.
</p>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_clock_break_rule" model="ir.actions.act_window">
<field name="name">Break Rules</field>
<field name="res_model">fusion.clock.break.rule</field>
<field name="view_mode">list,form</field>
<field name="context">{'active_test': False}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Create a statutory break rule</p>
<p>Define unpaid meal-break thresholds per province/country. Employees inherit
the rule matching their company's province, or the default rule.</p>
</field>
</record>
</odoo>

View File

@@ -196,6 +196,13 @@
sequence="20"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_break_rules"
name="Break Rules"
parent="menu_fusion_clock_config"
action="action_fusion_clock_break_rule"
sequence="25"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_nfc_enrollment"
name="Enroll NFC Card"
parent="menu_fusion_clock_config"

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Clock link removed from portal home - now handled by fusion_authorizer_portal -->
<!-- Clock link removed from portal home - now handled by fusion_portal -->
<template id="portal_my_home_clock" name="Portal My Home: Clock"
inherit_id="portal.portal_my_home" priority="60">
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">

View File

@@ -19,25 +19,25 @@
<div class="content-group">
<div class="row mt16">
<label for="fclk_default_clock_in_time" string="Clock-In" class="col-lg-5 o_light_label"/>
<field name="fclk_default_clock_in_time" widget="float_time"/>
<field name="fclk_default_clock_in_time"/>
</div>
<div class="row mt8">
<label for="fclk_default_clock_out_time" string="Clock-Out" class="col-lg-5 o_light_label"/>
<field name="fclk_default_clock_out_time" widget="float_time"/>
<field name="fclk_default_clock_out_time"/>
</div>
</div>
</setting>
<setting id="fclk_auto_break" string="Auto-Deduct Break"
help="Automatically deduct unpaid break from worked hours on clock-out.">
help="Automatically deduct the statutory unpaid break from worked hours. Break lengths and thresholds are configured per province under Configuration → Break Rules.">
<field name="fclk_auto_deduct_break"/>
<div class="content-group" invisible="not fclk_auto_deduct_break">
<div class="row mt16">
<label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/>
<label for="fclk_default_break_minutes" string="Default scheduling break (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_default_break_minutes"/>
</div>
<div class="row mt8">
<label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/>
<field name="fclk_break_threshold_hours" widget="float_time"/>
<div class="text-muted small mt4">
Used as the default break when building shifts/schedules
(planned hours). Actual deductions follow the province Break Rules.
</div>
</div>
</setting>
@@ -133,8 +133,14 @@
<field name="fclk_enable_ip_fallback"/>
</setting>
<setting id="fclk_photo_verification" string="Photo Verification"
help="Master switch for clock-in/out selfies. When OFF, no photos are captured anywhere (portal or NFC kiosk). When ON, the per-location and NFC-kiosk photo settings apply.">
help="Master switch for clock-in/out selfies. When OFF, no photos are captured anywhere (portal, PIN kiosk or NFC kiosk). When ON, the per-location and kiosk photo settings apply.">
<field name="fclk_enable_photo_verification"/>
<div class="content-group" invisible="not fclk_enable_photo_verification">
<div class="row mt16">
<label for="fclk_photo_retention_days" string="Auto-Wipe Photos After (days)" class="col-lg-5 o_light_label"/>
<field name="fclk_photo_retention_days"/>
</div>
</div>
</setting>
<setting id="fclk_google_maps" string="Google Maps API Key"
help="Required for location geocoding and map previews in clock location setup.">
@@ -157,13 +163,53 @@
</block>
<!-- ============================================================ -->
<!-- Kiosk & Portal -->
<!-- Kiosk (shared-device clock-in: PIN or NFC) -->
<!-- ============================================================ -->
<block title="Kiosk &amp; Portal" name="fclk_kiosk_portal">
<setting id="fclk_kiosk" string="Kiosk Mode"
help="Allow employees to clock in/out from a shared device (tablet or computer).">
<block title="Kiosk" name="fclk_kiosk_modes">
<setting id="fclk_pin_kiosk" string="PIN Kiosk"
help="Shared tablet where staff tap their photo and enter a PIN. Best when you don't use NFC cards/fobs.">
<field name="fclk_enable_kiosk"/>
<div class="content-group" invisible="not fclk_enable_kiosk">
<div class="mt16">
<a href="/fusion_clock/kiosk" target="_blank" role="button" class="btn btn-secondary btn-sm">
<i class="fa fa-external-link me-1"/> Open PIN Kiosk
</a>
</div>
</div>
</setting>
<setting id="fclk_nfc_kiosk" string="NFC Kiosk"
help="Shared tablet where staff tap an NFC card or fob (contactless). Fastest for shop floors.">
<field name="fclk_enable_nfc_kiosk"/>
<div class="content-group" invisible="not fclk_enable_nfc_kiosk">
<div class="row mt16">
<label for="fclk_nfc_kiosk_location_id" string="Location" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_kiosk_location_id"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_photo_required" string="Require Photo" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_photo_required"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_enroll_password" string="Enroll Password" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_enroll_password" password="True"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_kiosk_debug" string="Debug Overlay" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_kiosk_debug"/>
</div>
<div class="mt16">
<a href="/fusion_clock/kiosk/nfc" target="_blank" role="button" class="btn btn-secondary btn-sm">
<i class="fa fa-external-link me-1"/> Open NFC Kiosk
</a>
</div>
</div>
</setting>
</block>
<!-- ============================================================ -->
<!-- Portal -->
<!-- ============================================================ -->
<block title="Portal" name="fclk_portal">
<setting id="fclk_corrections" string="Correction Requests"
help="Allow employees to request timesheet corrections from the portal.">
<field name="fclk_enable_correction_requests"/>
@@ -228,38 +274,6 @@
</setting>
</block>
<!-- ============================================================ -->
<!-- NFC Clock Kiosk -->
<!-- ============================================================ -->
<block title="NFC Clock Kiosk" name="fclk_nfc_kiosk">
<setting id="fclk_nfc_enable" string="Enable NFC Kiosk"
help="Tap-to-clock kiosk for shop-floor tablets at /fusion_clock/kiosk/nfc">
<field name="fclk_enable_nfc_kiosk"/>
<div class="content-group" invisible="not fclk_enable_nfc_kiosk">
<div class="row mt16">
<label for="fclk_nfc_kiosk_location_id" string="Location" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_kiosk_location_id"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_photo_required" string="Require Photo" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_photo_required"/>
</div>
<div class="row mt8">
<label for="fclk_photo_retention_days" string="Auto-Wipe Photos After (days)" class="col-lg-5 o_light_label"/>
<field name="fclk_photo_retention_days"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_enroll_password" string="Enroll Password" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_enroll_password" password="True"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_kiosk_debug" string="Debug Overlay" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_kiosk_debug"/>
</div>
</div>
</setting>
</block>
</app>
</xpath>
</field>

View File

@@ -79,7 +79,7 @@ This module integrates with the `fusion_claims` module to provide a seamless wor
1. **Copy the module** to your Odoo addons directory:
```bash
cp -r fusion_authorizer_portal /path/to/odoo/custom-addons/
cp -r fusion_portal /path/to/odoo/custom-addons/
```
2. **Update the apps list** in Odoo:
@@ -627,14 +627,14 @@ Enable debug logging for this module:
```python
import logging
_logger = logging.getLogger('fusion_authorizer_portal')
_logger = logging.getLogger('fusion_portal')
_logger.setLevel(logging.DEBUG)
```
Or in Odoo configuration:
```ini
[options]
log_handler = fusion_authorizer_portal:DEBUG
log_handler = fusion_portal:DEBUG
```
---
@@ -671,7 +671,7 @@ log_handler = fusion_authorizer_portal:DEBUG
## File Structure
```
fusion_authorizer_portal/
fusion_portal/
├── __init__.py
├── __manifest__.py
├── README.md

View File

@@ -13,14 +13,14 @@ def _reactivate_views(env):
This hook prevents that from silently breaking the portal.
"""
views = env['ir.ui.view'].sudo().search([
('key', 'like', 'fusion_authorizer_portal.%'),
('key', 'like', 'fusion_portal.%'),
('active', '=', False),
])
if views:
views.write({'active': True})
env.cr.execute("""
SELECT key FROM ir_ui_view
WHERE key LIKE 'fusion_authorizer_portal.%%'
WHERE key LIKE 'fusion_portal.%%'
AND id = ANY(%s)
""", [views.ids])
keys = [r[0] for r in env.cr.fetchall()]

View File

@@ -64,12 +64,14 @@ This module provides external portal access for:
'data/portal_menu_data.xml',
'data/ir_actions_server_data.xml',
'data/welcome_articles.xml',
'data/visit_data.xml',
# Views
'views/res_partner_views.xml',
'views/sale_order_views.xml',
'views/assessment_views.xml',
'views/loaner_checkout_views.xml',
'views/pdf_template_views.xml',
'views/visit_views.xml',
# Portal Templates
'views/portal_templates.xml',
'views/portal_assessment_express.xml',
@@ -79,23 +81,24 @@ This module provides external portal access for:
'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml',
'views/portal_page11_sign_templates.xml',
'views/portal_visit.xml',
],
'assets': {
'web.assets_backend': [
'fusion_authorizer_portal/static/src/xml/chatter_message_authorizer.xml',
'fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js',
'fusion_portal/static/src/xml/chatter_message_authorizer.xml',
'fusion_portal/static/src/js/chatter_message_authorizer.js',
],
'web.assets_frontend': [
'fusion_authorizer_portal/static/src/css/portal_style.css',
'fusion_authorizer_portal/static/src/css/technician_portal.css',
'fusion_authorizer_portal/static/src/js/portal_search.js',
'fusion_authorizer_portal/static/src/js/assessment_form.js',
'fusion_authorizer_portal/static/src/js/signature_pad.js',
'fusion_portal/static/src/css/portal_style.css',
'fusion_portal/static/src/css/technician_portal.css',
'fusion_portal/static/src/js/portal_search.js',
'fusion_portal/static/src/js/assessment_form.js',
'fusion_portal/static/src/js/signature_pad.js',
'fusion_authorizer_portal/static/src/js/pdf_field_editor.js',
'fusion_authorizer_portal/static/src/js/technician_push.js',
'fusion_authorizer_portal/static/src/js/technician_location.js',
'fusion_authorizer_portal/static/src/js/timezone_detect.js',
'fusion_portal/static/src/js/pdf_field_editor.js',
'fusion_portal/static/src/js/technician_push.js',
'fusion_portal/static/src/js/technician_location.js',
'fusion_portal/static/src/js/timezone_detect.js',
],
},
'images': ['static/description/icon.png'],

View File

@@ -38,7 +38,7 @@ class FusionPdfEditorController(http.Controller):
'text_align',
])
return request.render('fusion_authorizer_portal.portal_pdf_field_editor', {
return request.render('fusion_portal.portal_pdf_field_editor', {
'template': template,
'fields': fields,
'preview_url': preview_url,

View File

@@ -91,7 +91,7 @@ class AssessmentPortal(CustomerPortal):
'page_name': 'assessments',
}
return request.render('fusion_authorizer_portal.portal_assessments', values)
return request.render('fusion_portal.portal_assessments', values)
@http.route('/my/assessment/new', type='http', auth='user', website=True)
def portal_assessment_new(self, **kw):
@@ -116,7 +116,7 @@ class AssessmentPortal(CustomerPortal):
'page_name': 'assessment_new',
}
return request.render('fusion_authorizer_portal.portal_assessment_form', values)
return request.render('fusion_portal.portal_assessment_form', values)
@http.route('/my/assessment/<int:assessment_id>', type='http', auth='user', website=True)
def portal_assessment_view(self, assessment_id, **kw):
@@ -166,7 +166,7 @@ class AssessmentPortal(CustomerPortal):
'photos': photos,
}
return request.render('fusion_authorizer_portal.portal_assessment_form', values)
return request.render('fusion_portal.portal_assessment_form', values)
@http.route('/my/assessment/save', type='http', auth='user', website=True, methods=['POST'], csrf=True)
def portal_assessment_save(self, assessment_id=None, **kw):
@@ -312,7 +312,7 @@ class AssessmentPortal(CustomerPortal):
'page_name': 'assessment_signatures',
}
return request.render('fusion_authorizer_portal.portal_assessment_signatures', values)
return request.render('fusion_portal.portal_assessment_signatures', values)
@http.route('/my/assessment/<int:assessment_id>/save_signature', type='jsonrpc', auth='user')
def portal_save_signature(self, assessment_id, signature_type='', signature_data='', signer_name='', **kw):
@@ -461,7 +461,7 @@ class AssessmentPortal(CustomerPortal):
'google_maps_api_key': google_maps_api_key,
}
return request.render('fusion_authorizer_portal.portal_assessment_express', values)
return request.render('fusion_portal.portal_assessment_express', values)
@http.route('/my/assessment/express/<int:assessment_id>', type='http', auth='user', website=True)
def portal_assessment_express_edit(self, assessment_id, page=1, **kw):
@@ -528,7 +528,7 @@ class AssessmentPortal(CustomerPortal):
'google_maps_api_key': google_maps_api_key,
}
return request.render('fusion_authorizer_portal.portal_assessment_express', values)
return request.render('fusion_portal.portal_assessment_express', values)
@http.route('/my/assessment/express/save', type='http', auth='user', website=True, methods=['POST'], csrf=True)
def portal_assessment_express_save(self, **kw):
@@ -815,7 +815,15 @@ class AssessmentPortal(CustomerPortal):
vals['wheelchair_type'] = kw.get('wheelchair_type')
if kw.get('powerchair_type'):
vals['powerchair_type'] = kw.get('powerchair_type')
if kw.get('scooter_type'):
vals['scooter_type'] = kw.get('scooter_type')
if kw.get('scooter_max_range'):
vals['scooter_max_range'] = float(kw.get('scooter_max_range') or 0)
if kw.get('x_fc_power_home_accessible'):
vals['x_fc_power_home_accessible'] = kw.get('x_fc_power_home_accessible')
if kw.get('x_fc_power_home_access_notes'):
vals['x_fc_power_home_access_notes'] = kw.get('x_fc_power_home_access_notes')
# Float measurements
float_fields = [
'rollator_handle_height', 'rollator_seat_height',
@@ -1050,7 +1058,7 @@ class AssessmentPortal(CustomerPortal):
'success': kw.get('success'),
'error': kw.get('error'),
}
return request.render('fusion_authorizer_portal.portal_book_assessment', values)
return request.render('fusion_portal.portal_book_assessment', values)
@http.route('/book-assessment/submit', type='http', auth='public', website=True, methods=['POST'], csrf=True)
def portal_book_assessment_submit(self, **kw):

View File

@@ -286,7 +286,7 @@ class AuthorizerPortal(CustomerPortal):
'page_name': 'authorizer_dashboard',
}
return request.render('fusion_authorizer_portal.portal_authorizer_dashboard', values)
return request.render('fusion_portal.portal_authorizer_dashboard', values)
@http.route(['/my/authorizer/cases', '/my/authorizer/cases/page/<int:page>'], type='http', auth='user', website=True)
def authorizer_cases(self, page=1, search='', sortby='date', sale_type='', **kw):
@@ -364,7 +364,7 @@ class AuthorizerPortal(CustomerPortal):
'page_name': 'authorizer_cases',
}
return request.render('fusion_authorizer_portal.portal_authorizer_cases', values)
return request.render('fusion_portal.portal_authorizer_cases', values)
@http.route('/my/authorizer/cases/search', type='jsonrpc', auth='user')
def authorizer_cases_search(self, query='', **kw):
@@ -455,7 +455,7 @@ class AuthorizerPortal(CustomerPortal):
'page_name': 'authorizer_case_detail',
}
return request.render('fusion_authorizer_portal.portal_authorizer_case_detail', values)
return request.render('fusion_portal.portal_authorizer_case_detail', values)
@http.route('/my/authorizer/case/<int:order_id>/comment', type='http', auth='user', website=True, methods=['POST'])
def authorizer_add_comment(self, order_id, comment='', **kw):
@@ -758,7 +758,7 @@ class AuthorizerPortal(CustomerPortal):
'status_filter': status,
}
values.update(self._get_clock_status_data())
return request.render('fusion_authorizer_portal.portal_sales_dashboard', values)
return request.render('fusion_portal.portal_sales_dashboard', values)
@http.route(['/my/sales/cases', '/my/sales/cases/page/<int:page>'], type='http', auth='user', website=True)
def sales_rep_cases(self, page=1, search='', sortby='date', **kw):
@@ -818,7 +818,7 @@ class AuthorizerPortal(CustomerPortal):
'page_name': 'sales_cases',
}
return request.render('fusion_authorizer_portal.portal_sales_cases', values)
return request.render('fusion_portal.portal_sales_cases', values)
@http.route('/my/sales/cases/search', type='jsonrpc', auth='user')
def sales_rep_cases_search(self, query='', **kw):
@@ -910,7 +910,7 @@ class AuthorizerPortal(CustomerPortal):
'page_name': 'sales_case_detail',
}
return request.render('fusion_authorizer_portal.portal_sales_case_detail', values)
return request.render('fusion_portal.portal_sales_case_detail', values)
@http.route('/my/sales/case/<int:order_id>/comment', type='http', auth='user', website=True, methods=['POST'])
def sales_rep_add_comment(self, order_id, comment='', **kw):
@@ -1018,7 +1018,7 @@ class AuthorizerPortal(CustomerPortal):
'page_name': 'funding_claims',
}
return request.render('fusion_authorizer_portal.portal_client_claims', values)
return request.render('fusion_portal.portal_client_claims', values)
@http.route('/my/funding-claims/<int:order_id>', type='http', auth='user', website=True)
def client_funding_claim_detail(self, order_id, **kw):
@@ -1055,7 +1055,7 @@ class AuthorizerPortal(CustomerPortal):
'page_name': 'funding_claim_detail',
}
return request.render('fusion_authorizer_portal.portal_client_claim_detail', values)
return request.render('fusion_portal.portal_client_claim_detail', values)
@http.route('/my/funding-claims/<int:order_id>/document/<int:doc_id>/download', type='http', auth='user')
def client_download_document(self, order_id, doc_id, **kw):
@@ -1269,7 +1269,7 @@ class AuthorizerPortal(CustomerPortal):
'page_name': 'technician_dashboard',
}
values.update(clock_data)
return request.render('fusion_authorizer_portal.portal_technician_dashboard', values)
return request.render('fusion_portal.portal_technician_dashboard', values)
@http.route(['/my/technician/tasks', '/my/technician/tasks/page/<int:page>'], type='http', auth='user', website=True)
def technician_tasks(self, page=1, search='', filter_status='all', filter_date='', **kw):
@@ -1323,7 +1323,7 @@ class AuthorizerPortal(CustomerPortal):
'filter_date': filter_date,
'page_name': 'technician_tasks',
}
return request.render('fusion_authorizer_portal.portal_technician_tasks', values)
return request.render('fusion_portal.portal_technician_tasks', values)
@http.route('/my/technician/task/<int:task_id>', type='http', auth='user', website=True)
def technician_task_detail(self, task_id, **kw):
@@ -1382,7 +1382,7 @@ class AuthorizerPortal(CustomerPortal):
'page_name': 'technician_task_detail',
'earlier_incomplete': earlier_incomplete,
}
return request.render('fusion_authorizer_portal.portal_technician_task_detail', values)
return request.render('fusion_portal.portal_technician_task_detail', values)
@http.route('/my/technician/task/<int:task_id>/add-notes', type='json', auth='user', website=True)
def technician_task_add_notes(self, task_id, notes, photos=None, **kw):
@@ -1866,7 +1866,7 @@ class AuthorizerPortal(CustomerPortal):
'all_equipment': all_equipment,
'page_name': 'technician_tomorrow',
}
return request.render('fusion_authorizer_portal.portal_technician_tomorrow', values)
return request.render('fusion_portal.portal_technician_tomorrow', values)
@http.route('/my/technician/schedule/<string:date>', type='http', auth='user', website=True)
def technician_schedule_date(self, date, **kw):
@@ -1898,7 +1898,7 @@ class AuthorizerPortal(CustomerPortal):
'total_travel': total_travel,
'page_name': 'technician_schedule',
}
return request.render('fusion_authorizer_portal.portal_technician_schedule_date', values)
return request.render('fusion_portal.portal_technician_schedule_date', values)
@http.route('/my/technician/admin/map', type='http', auth='user', website=True)
def technician_location_map(self, **kw):
@@ -1916,7 +1916,7 @@ class AuthorizerPortal(CustomerPortal):
'locations': locations,
'google_maps_api_key': api_key,
}
return request.render('fusion_authorizer_portal.portal_technician_map', values)
return request.render('fusion_portal.portal_technician_map', values)
@http.route('/my/technician/location/log', type='json', auth='user', website=True)
def technician_location_log(self, latitude, longitude, accuracy=None, **kw):
@@ -2000,7 +2000,7 @@ class AuthorizerPortal(CustomerPortal):
'order': order,
'page_name': 'technician_delivery_detail',
}
return request.render('fusion_authorizer_portal.portal_technician_delivery_detail_legacy', values)
return request.render('fusion_portal.portal_technician_delivery_detail_legacy', values)
# ==================== POD SIGNATURE CAPTURE ====================
@@ -2068,7 +2068,7 @@ class AuthorizerPortal(CustomerPortal):
'page_name': 'pod_signature',
}
return request.render('fusion_authorizer_portal.portal_pod_signature', values)
return request.render('fusion_portal.portal_pod_signature', values)
@http.route('/my/pod/<int:order_id>/sign', type='json', auth='user', methods=['POST'])
def pod_save_signature(self, order_id, client_name, signature_data, signature_date=None, **kw):
@@ -2246,7 +2246,7 @@ class AuthorizerPortal(CustomerPortal):
'has_existing_signature': bool(task.pod_signature),
'page_name': 'task_pod_signature',
}
return request.render('fusion_authorizer_portal.portal_task_pod_signature', values)
return request.render('fusion_portal.portal_task_pod_signature', values)
@http.route('/my/technician/task/<int:task_id>/pod/sign', type='json', auth='user', methods=['POST'])
def task_pod_save_signature(self, task_id, client_name, signature_data, signature_date=None, **kw):
@@ -2366,7 +2366,7 @@ class AuthorizerPortal(CustomerPortal):
'page_name': 'accessibility_selector',
'google_maps_api_key': google_maps_api_key,
}
return request.render('fusion_authorizer_portal.portal_accessibility_selector', values)
return request.render('fusion_portal.portal_accessibility_selector', values)
@http.route('/my/accessibility/list', type='http', auth='user', website=True)
def accessibility_assessment_list(self, page=1, **kw):
@@ -2407,7 +2407,7 @@ class AuthorizerPortal(CustomerPortal):
'assessments': assessments,
'pager': pager,
}
return request.render('fusion_authorizer_portal.portal_accessibility_list', values)
return request.render('fusion_portal.portal_accessibility_list', values)
@http.route('/my/accessibility/stairlift/straight', type='http', auth='user', website=True)
def accessibility_stairlift_straight(self, **kw):
@@ -2467,18 +2467,68 @@ class AuthorizerPortal(CustomerPortal):
# Route to specific template based on type
template_map = {
'stairlift_straight': 'fusion_authorizer_portal.portal_accessibility_stairlift_straight',
'stairlift_curved': 'fusion_authorizer_portal.portal_accessibility_stairlift_curved',
'vpl': 'fusion_authorizer_portal.portal_accessibility_vpl',
'ceiling_lift': 'fusion_authorizer_portal.portal_accessibility_ceiling',
'ramp': 'fusion_authorizer_portal.portal_accessibility_ramp',
'bathroom': 'fusion_authorizer_portal.portal_accessibility_bathroom',
'tub_cutout': 'fusion_authorizer_portal.portal_accessibility_tub_cutout',
'stairlift_straight': 'fusion_portal.portal_accessibility_stairlift_straight',
'stairlift_curved': 'fusion_portal.portal_accessibility_stairlift_curved',
'vpl': 'fusion_portal.portal_accessibility_vpl',
'ceiling_lift': 'fusion_portal.portal_accessibility_ceiling',
'ramp': 'fusion_portal.portal_accessibility_ramp',
'bathroom': 'fusion_portal.portal_accessibility_bathroom',
'tub_cutout': 'fusion_portal.portal_accessibility_tub_cutout',
}
template = template_map.get(assessment_type, 'fusion_authorizer_portal.portal_accessibility_selector')
template = template_map.get(assessment_type, 'fusion_portal.portal_accessibility_selector')
return request.render(template, values)
# ==========================================================================
# ASSESSMENT VISIT WORKSPACE (Phase 1b/3)
# ==========================================================================
@http.route('/my/visit/new', type='http', auth='user', website=True)
def visit_new(self, **kw):
"""Start a new assessment visit and open its workspace."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return request.redirect('/my')
visit = request.env['fusion.assessment.visit'].sudo().create({
'sales_rep_id': request.env.user.id,
})
return request.redirect('/my/visit/%s' % visit.id)
@http.route('/my/visit/<int:visit_id>', type='http', auth='user', website=True)
def visit_workspace(self, visit_id, **kw):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if not visit.exists():
return request.redirect('/my')
return request.render('fusion_portal.portal_visit_workspace', {
'visit': visit,
'page_name': 'visit',
'error': kw.get('error'),
})
@http.route('/my/visit/<int:visit_id>/save', type='http', auth='user', methods=['POST'], website=True, csrf=True)
def visit_save_client(self, visit_id, **post):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if visit.exists():
visit.write({
'client_name': (post.get('client_name') or '').strip(),
'client_phone': (post.get('client_phone') or '').strip(),
'client_email': (post.get('client_email') or '').strip(),
'client_address': (post.get('client_address') or '').strip(),
'x_fc_income_under_mod_threshold': post.get('x_fc_income_under_mod_threshold') or 'unknown',
})
return request.redirect('/my/visit/%s' % visit_id)
@http.route('/my/visit/<int:visit_id>/complete', type='http', auth='user', methods=['POST'], website=True, csrf=True)
def visit_complete(self, visit_id, **post):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if not visit.exists():
return request.redirect('/my')
try:
visit.action_complete_visit()
except Exception as e:
_logger.warning("Visit %s completion failed: %s", visit_id, e)
return request.redirect('/my/visit/%s?error=%s' % (visit_id, str(e)))
return request.redirect('/my/visit/%s' % visit_id)
@http.route('/my/accessibility/save', type='json', auth='user', methods=['POST'], csrf=True)
def accessibility_assessment_save(self, **post):
"""Save an accessibility assessment and optionally create a Sale Order"""
@@ -2493,7 +2543,14 @@ class AuthorizerPortal(CustomerPortal):
assessment_type = post.get('assessment_type')
if not assessment_type:
return {'success': False, 'error': 'Assessment type is required'}
# Funding source drives the downstream sale-order workflow; coerce
# anything unexpected to private pay (mirrors /book-assessment).
_funding_keys = dict(Assessment._fields['x_fc_funding_source'].selection)
funding_source = post.get('funding_source') or 'direct_private'
if funding_source not in _funding_keys:
funding_source = 'direct_private'
# Build assessment values
vals = {
'assessment_type': assessment_type,
@@ -2507,6 +2564,7 @@ class AuthorizerPortal(CustomerPortal):
'client_address_postal': post.get('client_address_postal', '').strip(),
'client_phone': post.get('client_phone', '').strip(),
'client_email': post.get('client_email', '').strip(),
'x_fc_funding_source': funding_source,
'notes': post.get('notes', '').strip(),
}
@@ -2539,6 +2597,13 @@ class AuthorizerPortal(CustomerPortal):
if partner.is_authorizer:
vals['authorizer_id'] = partner.id
# Link to a visit if this form was launched from the workspace.
if post.get('visit_id'):
try:
vals['visit_id'] = int(post.get('visit_id'))
except (TypeError, ValueError):
pass
# Create the assessment
assessment = Assessment.create(vals)
_logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}")
@@ -2564,8 +2629,12 @@ class AuthorizerPortal(CustomerPortal):
if video_data:
self._attach_accessibility_video(assessment, video_data, video_filename)
# Complete the assessment and create Sale Order if requested
# Complete the assessment and create Sale Order if requested.
# When launched from a visit, always save as a draft linked to the
# visit — the VISIT completion creates the grouped sale order(s).
create_sale_order = post.get('create_sale_order', True)
if vals.get('visit_id'):
create_sale_order = False
if create_sale_order:
sale_order = assessment.action_complete()
return {
@@ -2578,12 +2647,13 @@ class AuthorizerPortal(CustomerPortal):
'redirect_url': f'/my/sales/case/{sale_order.id}',
}
else:
redirect_url = ('/my/visit/%s' % vals['visit_id']) if vals.get('visit_id') else '/my/accessibility/list'
return {
'success': True,
'assessment_id': assessment.id,
'assessment_ref': assessment.reference,
'message': f'Assessment {assessment.reference} saved as draft.',
'redirect_url': '/my/accessibility/list',
'message': f'Assessment {assessment.reference} saved.',
'redirect_url': redirect_url,
}
except Exception as e:

View File

@@ -41,18 +41,18 @@ class Page11PublicSignController(http.Controller):
if status == 'not_found':
return request.render(
'fusion_authorizer_portal.portal_page11_sign_invalid', {}
'fusion_portal.portal_page11_sign_invalid', {}
)
if status in ('expired', 'cancelled'):
return request.render(
'fusion_authorizer_portal.portal_page11_sign_expired',
'fusion_portal.portal_page11_sign_expired',
{'sign_request': sign_req},
)
if status == 'already_signed':
return request.render(
'fusion_authorizer_portal.portal_page11_sign_success',
'fusion_portal.portal_page11_sign_success',
{'sign_request': sign_req, 'token': token},
)
@@ -100,7 +100,7 @@ class Page11PublicSignController(http.Controller):
'client_health_card_version': client_health_card_version,
}
return request.render(
'fusion_authorizer_portal.portal_page11_public_sign', values,
'fusion_portal.portal_page11_public_sign', values,
)
@http.route('/page11/sign/<string:token>/submit', type='http',
@@ -177,7 +177,7 @@ class Page11PublicSignController(http.Controller):
_logger.error("Sale order update failed for sign request %s: %s", sign_req.id, e)
return request.render(
'fusion_authorizer_portal.portal_page11_sign_success',
'fusion_portal.portal_page11_sign_success',
{'sign_request': sign_req, 'token': token},
)

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Reference sequence for assessment visits (VISIT/2026/0001) -->
<record id="seq_fusion_assessment_visit" model="ir.sequence">
<field name="name">Assessment Visit</field>
<field name="code">fusion.assessment.visit</field>
<field name="prefix">VISIT/%(year)s/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -10,7 +10,7 @@ import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
MODULE = 'fusion_portal'
def migrate(cr, version):

View File

@@ -10,7 +10,7 @@ import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
MODULE = 'fusion_portal'
def migrate(cr, version):

View File

@@ -10,7 +10,7 @@ import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
MODULE = 'fusion_portal'
def migrate(cr, version):

View File

@@ -12,7 +12,7 @@ import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
MODULE = 'fusion_portal'
MOVED_XMLIDS = [
'portal_schedule_page',

View File

@@ -7,5 +7,6 @@ from . import adp_document
from . import assessment
from . import accessibility_assessment
from . import sale_order
from . import visit
from . import loaner_checkout
from . import pdf_template

View File

@@ -73,6 +73,7 @@ class FusionAccessibilityAssessment(models.Model):
('march_of_dimes', 'March of Dimes'),
('odsp', 'ODSP'),
('wsib', 'WSIB'),
('hardship', 'Hardship Funding'),
('insurance', 'Private Insurance'),
('direct_private', 'Private Pay (Direct)'),
('other', 'Other'),
@@ -156,7 +157,14 @@ class FusionAccessibilityAssessment(models.Model):
readonly=True,
copy=False,
)
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
ondelete='set null',
index=True,
help='The home visit this accessibility assessment belongs to.',
)
# Dates
assessment_date = fields.Date(
string='Assessment Date',
@@ -772,6 +780,7 @@ class FusionAccessibilityAssessment(models.Model):
'march_of_dimes': 'march_of_dimes',
'odsp': 'odsp',
'wsib': 'wsib',
'hardship': 'hardship',
'insurance': 'insurance',
'direct_private': 'direct_private',
'other': 'other',
@@ -785,7 +794,7 @@ class FusionAccessibilityAssessment(models.Model):
'origin': f'Accessibility: {self.reference} ({type_label})',
'x_fc_sale_type': sale_type,
# Back-reference so the sale order knows which accessibility
# assessment spawned it (see field definition in fusion_authorizer_portal/models/sale_order.py).
# assessment spawned it (see field definition in fusion_portal/models/sale_order.py).
'accessibility_assessment_id': self.id,
}

View File

@@ -45,6 +45,7 @@ class FusionAssessment(models.Model):
('rollator', 'Rollator'),
('wheelchair', 'Wheelchair'),
('powerchair', 'Powerchair'),
('scooter', 'Mobility Scooter'),
], string='Equipment Type', tracking=True, index=True)
# Rollator Types
@@ -69,6 +70,31 @@ class FusionAssessment(models.Model):
('type_2', 'Adult Power Base Type 2'),
('type_3', 'Adult Power Base Type 3'),
], string='Powerchair Type')
# ===== MOBILITY SCOOTER (ADP) — 2026-06 Phase 2 =====
scooter_type = fields.Selection([
('travel_3', '3-Wheel Travel/Portable'),
('travel_4', '4-Wheel Travel/Portable'),
('standard_3', '3-Wheel Standard'),
('standard_4', '4-Wheel Standard'),
('heavy_duty', 'Heavy-Duty / Bariatric'),
], string='Scooter Type')
scooter_max_range = fields.Float(
string='Maximum Range Needed (km)', digits=(10, 1),
help='Maximum distance the client needs the scooter to travel on a charge.',
)
# ===== POWER-MOBILITY HOME ACCESSIBILITY (ADP hard rule) =====
# Applies to scooter + power wheelchair: ADP funds power mobility only if the
# device can enter and be used at the residence independently, without lifting.
x_fc_power_home_accessible = fields.Selection([
('yes', 'Yes — usable inside and outside independently'),
('no', 'No — home needs accessibility work'),
], string='Home accessible for power-mobility device?',
help='ADP will not fund a scooter / power wheelchair if the home cannot '
'take it (device left outside or in the garage). If No, the home '
'needs an accessibility product (ramp / porch lift).')
x_fc_power_home_access_notes = fields.Text(string='Home Access Notes')
# ===== EXPRESS FORM: ROLLATOR MEASUREMENTS =====
rollator_handle_height = fields.Float(string='Handle Height (inches)', digits=(10, 2))
@@ -425,7 +451,15 @@ class FusionAssessment(models.Model):
readonly=True,
copy=False,
)
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
ondelete='set null',
index=True,
help='The home visit this ADP assessment belongs to (groups multiple '
'assessments / funding workflows from one visit).',
)
# ===== COMPUTED FIELDS =====
document_count = fields.Integer(
string='Document Count',
@@ -1458,7 +1492,7 @@ class FusionAssessment(models.Model):
# Send to authorizer
if self.authorizer_id and self.authorizer_id.email:
try:
template = self.env.ref('fusion_authorizer_portal.mail_template_assessment_complete_authorizer', raise_if_not_found=False)
template = self.env.ref('fusion_portal.mail_template_assessment_complete_authorizer', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=True)
_logger.info(f"Sent assessment completion email to authorizer {self.authorizer_id.email}")
@@ -1468,7 +1502,7 @@ class FusionAssessment(models.Model):
# Send to client
if self.client_email:
try:
template = self.env.ref('fusion_authorizer_portal.mail_template_assessment_complete_client', raise_if_not_found=False)
template = self.env.ref('fusion_portal.mail_template_assessment_complete_client', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=True)
_logger.info(f"Sent assessment completion email to client {self.client_email}")

Some files were not shown because too many files have changed in this diff Show More