149 Commits

Author SHA1 Message Date
gsinghpal
d7cc334c98 docs(fusion_accounting): record Phase 0 smoke test results
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Made-with: Cursor
2026-04-19 01:29:22 -04:00
gsinghpal
92f93de47b chore(receiving): port received_qty auto-prefill from live entech to main
The auto-prefill logic that fills received_qty from expected_qty on
fp.receiving create was committed to the entech LXC but never made it
back to main. Verified by a full quote→delivery→invoice walkthrough
(scripts/fp_e2e_human.py) — receiving step now passes.

Also adds the human-walkthrough E2E script that exercises every step:
RFQ → quote → SO confirm → MO + portal job auto-create → receiving
prefill → recipe → WO execution → MO done → CoC cert (rich PDF, no
thickness duplicate) → delivery prefill + lifecycle → invoice (posted,
not auto-paid) → notification log audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:26:02 -04:00
gsinghpal
f0577c1788 ci(fusion_accounting): add CI workflow scaffold + Phase 0 deferral note
Workflow structure is complete (path filters, matrix, services).
The 'Install Odoo 19' step is a TODO placeholder — the reproducible
Odoo-19 build environment is deferred to Phase 1 CI hardening.
Current Phase 0 test workflow is manual via ssh odoo-westin.

Made-with: Cursor
2026-04-19 01:18:36 -04:00
gsinghpal
51b26838b9 docs(fusion_accounting): per-sub-module CLAUDE.md, UPGRADE_NOTES.md, README.md
Task 20 of Phase 0: document the sub-module split.

- fusion_accounting_core: foundation doc covering security groups, shared-field
  schema preservation, and the Enterprise-detection helper.
- fusion_accounting_ai: preserves the original module's AI-specific design
  decisions, Odoo 19 gotchas, deployment commands, controllers, models, theme
  rules, and known issues. Adds a new Data-adapter pattern section documenting
  tri-mode routing (fusion / enterprise / community).
- fusion_accounting_migration: doc for the Enterprise uninstall safety guard
  and the wizard shell that future feature sub-modules will extend.
- fusion_accounting (meta): rewritten CLAUDE.md as a pure overview pointing at
  sub-modules, plus a new README.md covering one-click install/uninstall.

Each sub-module now has CLAUDE.md (Cursor/Claude context), UPGRADE_NOTES.md
(version-by-version deltas / reference sources), and README.md (user-facing
install/usage docs). 11 files total.

Made-with: Cursor
2026-04-19 01:10:17 -04:00
gsinghpal
6731260cde feat(fusion_accounting): add check_odoo_diff.sh for cross-version upgrade ritual
Made-with: Cursor
2026-04-19 00:56:49 -04:00
gsinghpal
de71a61a8b fix(fusion_accounting_migration): add menu + tighten safety-guard test coverage
Addresses code review feedback on Task 17:
- Add menuitem so 'Fusion Accounting -> Migrate from Enterprise' is reachable
  (the UserError guidance now actually works). Placed at top level since
  parenting under fusion_accounting_ai.menu_fusion_accounting_root would
  require adding that module as a hard dep, which is wrong semantically
  (migration should not require AI). Both menuitems carry the admin group
  so the menu stays hidden from users who can't open the wizard anyway.
- Update the UserError wording to "Fusion Accounting -> Migrate from
  Enterprise" (no longer "Settings -> ...") to match the actual menu
  location; 'migration' is preserved per the test's assertIn check.
- Add skipTest guard to test_uninstall_not_blocked_when_migration_completed
  so it doesn't pass vacuously on Community-only CI (the guard's
  `if not installed: continue` would otherwise return True regardless of
  the flag value, giving a false green).
- Move GUARDED_MODULES import to top of wizards/migration_wizard.py
  (no circular-import risk -- models/ir_module_module.py doesn't import
  from wizards/).
- Expand docstrings on button_immediate_uninstall and module_uninstall
  overrides to note they may both fire in a single UI uninstall call
  and that the guard is idempotent (pure read + raise).

Made-with: Cursor
2026-04-19 00:51:32 -04:00
gsinghpal
db90b1ad5b feat(fusion_accounting_migration): add Enterprise uninstall safety guard + wizard skeleton
Phase 0 Task 17. Installs a safety guard on ir.module.module that blocks
uninstall of Odoo Enterprise accounting modules (account_accountant,
account_reports, accountant, account_followup, account_asset,
account_budget, account_loans) until the per-module migration flag
fusion_accounting.migration.<name>.completed is set to True. Guard
covers both button_immediate_uninstall (UI) and module_uninstall
(CLI/API) paths, raising UserError with a pointer to the migration
wizard and an escape hatch config parameter.

Also ships a TransientModel fusion.migration.wizard as a shell: it
detects installed Enterprise modules via GUARDED_MODULES and exposes
action_run_migration for sub-modules to extend in later phases. No
per-feature migrations are registered yet -- Phase 1+ sub-modules will
hook in their own steps.

Tests: TestSafetyGuard x2 pass (blocked-when-pending verified with
account_accountant installed; not-blocked-when-completed verified by
setting the flag).

Made-with: Cursor
2026-04-19 00:36:09 -04:00
gsinghpal
512467788b fix(fusion_accounting_core): add pre-migration for security group rename
Task 16's security group rehoming (fusion_accounting → fusion_accounting_core)
only existed in post-migration. That flow fails on fresh pre-Phase-0 upgrades:
data-load runs before post-migration and looks up group xml-ids by
(module, name); if the row still has module='fusion_accounting', Odoo
creates a duplicate res.groups record under
module='fusion_accounting_core'. The subsequent post-migration
UPDATE...SET module='fusion_accounting_core' then trips the (module, name)
unique constraint on ir_model_data, rolling back the whole transaction.

Pre-migration runs BEFORE data-load, renames the five security xml-ids
(module_category, privilege, three groups) to the new module, so data-load
finds the existing rows and UPDATEs them in place. Existing user-group
links via res_groups_users_rel are preserved.

The post-migration is kept as an idempotent safety net (docstring
updated to reflect the new division of labour).

Verified on westin-v19 by simulating the pre-Phase-0 state (UPDATE
ir_model_data SET module='fusion_accounting' ...) and re-running the
upgrade: 5 rows renamed cleanly, zero duplicates, no errors.

Made-with: Cursor
2026-04-19 00:29:33 -04:00
gsinghpal
7ac01991e5 refactor(fusion_accounting): move security groups to _core, add multi-company session rule
Made-with: Cursor
2026-04-19 00:14:36 -04:00
gsinghpal
10140a6968 feat(fusion_accounting_core): shared-field-ownership for deferred fields, signing_user, created_automatically
Made-with: Cursor
2026-04-18 23:55:32 -04:00
gsinghpal
e79f11f5f0 fix(shopfloor): suppress Odoo .o_kanban_record chrome inside fp kanbans
The Bake Window + First-Piece Gate cards looked rounded on their
own, but Odoo's default .o_kanban_record wrapper painted its own
background + border + box-shadow with sharper corners than our
inner .o_fp_kcard — visible as a faint square ghost behind every
card, especially obvious on the missed_window state where the red
wash on the inner card didn't extend to the wrapper edges.

Added a .o_fp_bw_kanban / .o_fp_fpg_kanban scoped override that
zeroes the wrapper's background, border, box-shadow and padding,
letting only our card surface render. Also drops the kanban group
container's tinted bg for the same reason.

Bumped shopfloor to 19.0.13.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:48:26 -04:00
gsinghpal
b637723c6a feat(fusion_accounting_core): add _fusion_is_enterprise_accounting_installed helper
Made-with: Cursor
2026-04-18 23:46:44 -04:00
gsinghpal
182978606d feat(shopfloor): rebuild bake/gate kanban templates with .o_fp_kcard
Companion to commit 4843146 / f7f500f which added the shared
SCSS. This commit wires the views to use it: the manifest now
loads fp_kanbans.scss and the two kanban templates render with
the new .o_fp_kcard structure (state stripe, title, subtitle,
big metric, meta line, chip footer).
2026-04-18 23:41:27 -04:00
gsinghpal
f18afe7380 refactor(fusion_accounting_ai): route month_end + hst_management report tools through ReportsAdapter
Task 13 Step 10 of phase-0 plan.

  - month_end.get_period_summary → ReportsAdapter.run_report(...) with
    Community fallback to the trial_balance() aggregator.
  - hst_management.get_tax_report → ReportsAdapter.run_report(...).

Other tools in these files (get_unreconciled_counts, find_entries_in_locked_period,
get_accrual_status, run_hash_integrity_check, calculate_hst_balance,
find_missing_tax_invoices, find_missing_itc_bills, create_expense_entry) touch
pure-Community models (account.move, account.move.line, account.account,
account.payment) directly and are tri-mode safe.

account.return tools in hst_management (get_tax_return_status, generate_tax_return,
validate_tax_return) and account.audit.account.status tools in audit.py already
handle the missing-model case gracefully. They fall outside this task's target
set of {account.report, account.followup.line, account.asset} and are left
as-is per plan.

All 12 data-adapter tests pass on westin-v19.

Made-with: Cursor
2026-04-18 23:40:27 -04:00
gsinghpal
484314625e feat(shopfloor): match Bake Windows + First-Piece Gates kanbans to Plant Overview
The two standalone menu pages (Bake Windows, First-Piece Gates) were
still on the older o_fp_card design from a pre-Plant-Overview pass —
visually drifted from the polished kanban-pattern cards we settled on
for Plant Overview. Pulling them onto the same design language without
rewriting them as OWL client actions (the 'Option A' from chat).

What changed
============

New shared SCSS — fp_kanbans.scss
---------------------------------
Defines .o_fp_kcard as the base kanban card surface. Mirrors the
Plant Overview .o_fp_po_card recipe: white $fp-card surface, 1px
$fp-border, $fp-radius-md corners, soft $fp-elev-1 shadow, hover
lift, 4px state stripe via ::before clipped by overflow:hidden.
Sub-elements (title, sub, metric, meta line, footer chip) get
their own classes so per-page tweaks stay surgical.

Page-scoped wrappers (.o_fp_bw_kanban, .o_fp_fpg_kanban) carry the
state/result → stripe colour mapping plus exception-state tints
(missed_window + fail get a soft danger wash so the card stands
out in a sea of normal ones).

Bake Window kanban
------------------
Rebuilt template — title (window name), part_ref subtitle, big
time-remaining metric (the operator's primary cue), meta line for
lot/customer/qty, footer with oven badge + state chip.
data-state attribute drives the stripe colour:
  awaiting_bake → warning
  bake_in_progress → info
  baked → success
  missed_window → danger + soft red wash
  scrapped → muted + dimmed

First-Piece Gate kanban
-----------------------
Rebuilt template — title (gate name), part_ref subtitle, bath +
customer meta, inspector + first_piece_produced timestamp,
footer with result chip and an optional 'Released' badge when
the lot has been signed off.
data-result attribute drives the stripe colour:
  pending → warning
  pass → success
  fail → danger + soft red wash

Shopfloor manifest bumped to 19.0.12.0.0 and the new SCSS is
registered in web.assets_backend after manager_dashboard.scss so
the design tokens it references are already in scope.

Plant Overview's existing .o_fp_po_card classes are deliberately
untouched — the OWL client action and the new kanbans share the
visual language but stay loosely coupled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:38:19 -04:00
gsinghpal
e983a370aa refactor(fusion_accounting_ai): route reporting tools through ReportsAdapter
Task 13 Step 9 of phase-0 plan.

All Enterprise account.report entry points now go through ReportsAdapter:

  - get_profit_loss → ReportsAdapter.run_report(account_reports.profit_and_loss)
  - get_balance_sheet → ReportsAdapter.run_report(account_reports.balance_sheet)
  - get_trial_balance → ReportsAdapter.run_report(...) with Community fallback
    to the existing trial_balance() account.move.line aggregation
  - get_cash_flow → ReportsAdapter.run_report(account_reports.cash_flow_statement)
  - compare_periods → two run_report() calls
  - export_report → ReportsAdapter.export_report() (PDF/XLSX via Enterprise)

ReportsAdapter extended with:

  - run_report(ref_id, date_from, date_to, limit) — generic Enterprise
    account.report wrapper. Enterprise mode returns {report_name, lines};
    Community mode returns a graceful error dict pointing users at the
    raw trial_balance() aggregation tool.
  - export_report(ref_id, fmt, date_from, date_to) — Enterprise-only PDF/XLSX
    export; Community mode returns an error dict.

Pure-Community tools in reporting.py (get_invoicing_summary, get_billing_summary,
get_collections_summary) unchanged — they aggregate account.move /
account.payment directly which is tri-mode safe.

3 new data-adapter tests added for run_report happy/error paths and
export_report shape. Total: 12 tests, all passing on westin-v19.

Made-with: Cursor
2026-04-18 23:33:54 -04:00
gsinghpal
2ead351c30 refactor(fusion_accounting_ai): route accounts_payable aged balances through FollowupAdapter
Task 13 Step 8 of phase-0 plan.

get_ap_aging → FollowupAdapter.aged_payables().

The adapter method was added alongside aged_receivables() in the previous
commit, so this is a pure tool-wrapper change. Other AP tools
(find_duplicate_bills, get_unpaid_bills, get_payment_schedule, etc.) touch
account.move / account.move.line with pure-Community filters (move_type in
(in_invoice, in_refund)) which are tri-mode safe and do not need adapter
routing.

All 9 data-adapter tests pass on westin-v19.

Made-with: Cursor
2026-04-18 23:31:19 -04:00
gsinghpal
6791246def refactor(fusion_accounting_ai): route accounts_receivable tools through FollowupAdapter
Task 13 Step 7 of phase-0 plan.

Routes the AR tools through the FollowupAdapter so they work identically on
fusion-native, Enterprise, and pure Community installs:

  - get_ar_aging → FollowupAdapter.aged_receivables()
  - get_overdue_invoices → FollowupAdapter.overdue_invoices()
  - send_followup → FollowupAdapter.send_followup()
  - get_followup_report → FollowupAdapter.followup_report_html()

FollowupAdapter extended:

  - overdue_invoices() now includes partner_email, partner_phone and
    amount_total so the tool wrapper can render its richer response.
  - aged_receivables() and aged_payables() new shared-implementation method
    _aged_buckets() produces the 5-bucket aging shape the AR/AP tools emit.
  - followup_report_html() and send_followup() isolate the Enterprise
    account.followup.report / partner.execute_followup calls; Community mode
    returns a graceful error dict.

Pure-Community tools in accounts_receivable.py (get_partner_balance,
reconcile_payment_to_invoice, get_unmatched_payments) unchanged — they touch
account.move / account.move.line directly which is tri-mode safe.

3 new data-adapter tests added (total: 9; all passing on westin-v19).

Made-with: Cursor
2026-04-18 23:30:20 -04:00
gsinghpal
2a41f48123 refactor(fusion_accounting_ai): route get_unreconciled_bank_lines through BankRecAdapter (pilot)
Pilot refactor per Task 13 Step 2 of phase-0 plan: route the bank-rec AI tool
function through the data adapter so it works identically whether the install
profile is fusion-native, Enterprise, or pure Community.

Extends BankRecAdapter.list_unreconciled() with optional filter params
(date_from, date_to, min_amount, company_id, and optional journal_id) and adds
partner_name / journal_id / journal_name to the returned shape so the tool
wrapper can preserve its existing outward return dict.

All 6 data-adapter tests pass against westin-v19 (TestDataAdapterBase,
TestBankRecAdapter, TestReportsAdapter, TestFollowupAdapter, TestAssetsAdapter).

Made-with: Cursor
2026-04-18 23:26:47 -04:00
gsinghpal
f8b97211ab feat(fusion_accounting_ai): add Followup and Assets data adapters
Made-with: Cursor
2026-04-18 23:21:14 -04:00
gsinghpal
086b24ab36 feat(fusion_accounting_ai): add ReportsAdapter with trial_balance
Made-with: Cursor
2026-04-18 23:14:41 -04:00
gsinghpal
d331dc5fa6 feat(fusion_accounting_ai): add BankRecAdapter for tri-mode bank-rec lookups
Made-with: Cursor
2026-04-18 23:08:53 -04:00
gsinghpal
6d02389b80 fix(bridge_mrp): revert malformed hr_employee.py from conflict-marker commit
a2efc9f committed a hr_employee.py with unresolved <<<<<<<
HEAD / >>>>>>> Stashed changes markers — Python wouldn't have
imported the file. Restoring to f340c87's version. The intended
fix (Odoo 19 'in' operator handling) lives on main as 0f41eb1.
2026-04-18 23:06:27 -04:00
gsinghpal
a2efc9f2d4 fix(employee): handle Odoo 19 'in' operator + empty-list sentinel in clocked-in search
Two compounding bugs in _search_x_fc_is_clocked_in surfaced when
fusion_clock's auto-clock-out closed all demo open attendances:

  1. Odoo 19 normalises ('=', True) to ('in', OrderedSet([True]))
     before invoking the search method. The previous code only
     handled '=' / '!=' and fell through to return [] for 'in' /
     'not in' — which Odoo treats as 'no constraint' and matches
     the entire table.

  2. ('id', 'in', []) is also treated as no-constraint in some
     Odoo versions; replaced with a [0] sentinel so the empty
     case correctly matches nothing.

Rewrite reduces caller intent to a match_set of booleans, flips it
on negative operators, then emits id IN / NOT IN against the cached
open-attendance employee ids. Accepts a 3-arg signature too in case
Odoo's compute-field calling convention shifts again.

Verified on entech: clocked_in==True returns the 3 currently-on-shift
operators (Carlos, James, Marie); ==False returns the other 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:04:22 -04:00
gsinghpal
7025f62107 feat(fusion_accounting_ai): add DataAdapter base + registry
Made-with: Cursor
2026-04-18 22:59:47 -04:00
gsinghpal
6a775db444 feat(fusion_accounting_ai): add post-migration to reassign ir_model_data ownership
Phase 0 Task 7. Pre-Phase-0 all AI code lived in module='fusion_accounting';
the code now lives in 'fusion_accounting_ai' but existing ir_model_data
rows still record the old module name. This post-migration rewrites them.

Handles duplicate-key conflicts by deleting old orphan rows when data-load
has already created a new row under the same name in the new module.

Idempotent: second run reassigns 0 rows.
Made-with: Cursor
2026-04-18 22:42:50 -04:00
gsinghpal
f8dfff5ce6 fix(manager-desk): include 'blocked' WOs + populate empty columns
Two complementary fixes — a real bug in the Manager Desk and demo
data that exercises the now-correct view.

The bug
=======
manager_controller.py used an explicit allow-list of WO states for
its Unassigned / Active columns and for the per-operator team load
count: ('pending','waiting','ready','progress'). That set MISSED the
'blocked' state Odoo emits when a WO's predecessor isn't done yet.

Result: an MO whose first WO is still running has all its downstream
WOs in 'blocked' state. They literally don't appear on the Manager
Desk — neither in "Needs a Worker" (even when unassigned) nor in
"In Progress" (even when assigned). The team load count also
under-reports because the operator's blocked queue is invisible.

Fix: switch all three domains from an allow-list to a deny-list
('done','cancel'). Same shape Plant Overview already uses, so the
two dashboards now agree on what "active" means.

Demo data
=========
Stage-filler gains two steps so the now-corrected view has obvious
data:

  6e. _populate_active_wos walks the in-flight MO's blocked routing
      and explicitly assigns the seven downstream WOs in sequence
      order — Diego (training), Carlos (plating), James (demask),
      Priya (oven), TWO unassigned (de-rack + post-bake — feed
      "Needs a Worker"), Aisha (final inspection). Earlier
      keyword-fuzzy matching missed WOs whose names didn't carry
      the expected substring.

  6f. _mark_so_awaiting_manager pushes two confirmed SOs to
      receiving_status='inspected' + assigned_manager_id=False so
      the "Awaiting Assignment" KPI is non-zero.

Verified on entech: 2 unassigned WOs, 6 active+assigned, 2
awaiting-assignment SOs. Six of seven operators carry at least one
open queue item; Marie has zero current load but a healthy past
completion history (she's on shift, between jobs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:32:53 -04:00
gsinghpal
8f1cb3abd2 feat(plating): demo stage-filler — every workflow step now has data
Companion to fp_demo_seed.py. Bridges the gaps the original seeder
left after the team-skills + timer-audit + presence-aware Manager Desk
work landed (commit 0d12902). Idempotent.

Eight steps, each wrapped in a safe() driver so a failure in one
doesn't abort the rest:

  1. Fill x_fc_work_role_id on any WO that doesn't have one yet.
     Keyword map (mask/rack/plat/bake/oven/inspect/rework) → role
     code, falls back to plating_op. The auto-promotion tracker
     can't credit a worker without a role on the WO.

  2. Backfill the four timer audit fields (started_by/at,
     finished_by/at) on done WOs. Pulls from time_ids when the
     productivity records exist, otherwise synthesises timestamps
     from create_date + duration.

  3. Seed a diverse team of six operators with distinct role
     coverage and lead-hand permissions:
       - Marie Dubois     — masking + racking      (lead: masking)
       - James O'Connor   — plating_op + demask    (lead: plating_op)
       - Priya Sharma     — oven + inspection      (lead: oven, inspection)
       - Diego Ramirez    — racking + plating_op   (TRAINING: 2/3 masking)
       - Aisha Khan       — inspection + rework
       - Carlos Silva     — every role             (lead: every role)
     Each gets a backing res.users so the Manager Desk dropdown
     can assign them.

  3b. Redistribute ~40 historical done WOs across the new team so
      their Task Proficiency lists aren't empty. Plan targets
      realistic per-role counts (Marie 8 masking + 5 racking,
      James 12 plating + 4 demask, etc.) and re-stamps the timer
      audit so finished_by reflects the new owner.

  4. Wipe + rebuild fp.operator.proficiency from completed WOs so
     the per-(employee, role) tally is deterministic. Auto-promotion
     fires naturally during the rebuild — workers who already cleared
     the threshold get promoted=True with timestamps. Diego is
     deliberately seeded at 2/3 on masking so the demo shows the
     "one more job away from promotion" state live.

  5. Clock three operators in via hr.attendance (4-hour shift).
     Wipes any stale open records first because earlier script
     iterations left future-dated check_in timestamps that the
     attendance validator refused to close.

  6a. Two extra quality holds (damaged + out_of_spec).

  6b. Mark the in-progress WO with a started_at but no finished_at
      so the demo has a "paused for lunch" exemplar.

  6c. Three portal RFQs (one per workflow state: new / under_review
      / quoted) so the funnel front-end has data.

  6d. Push one draft SO to "sent" so the quotation pipeline has
      data in every column (was draft → confirmed previously).

Verified on entech: 21 of 21 workflow stages now , including
Diego's 2/3 masking row that shows the auto-promotion mechanic
in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:22:23 -04:00
gsinghpal
1c44f458ad refactor(fusion_accounting): convert to meta-module that depends on sub-modules
Made-with: Cursor
2026-04-18 22:10:26 -04:00
gsinghpal
6c72f2ab49 refactor(fusion_accounting): move AI module code into fusion_accounting_ai sub-module
git mv preserves history. fusion_accounting/ retains only __manifest__.py,
__init__.py, CLAUDE.md, and docs/ — the meta-module shell. All Python,
data, views, security, services, static, tests, wizards, report move to
fusion_accounting_ai/. Manifest data list updated; security.xml move to
_core deferred to Task 12.

Made-with: Cursor
2026-04-18 21:45:06 -04:00
gsinghpal
b7483d5177 feat(fusion_accounting_migration): add empty sub-module skeleton
Made-with: Cursor
2026-04-18 21:33:53 -04:00
gsinghpal
c6d1008810 feat(fusion_accounting_ai): add empty sub-module skeleton
Made-with: Cursor
2026-04-18 21:27:55 -04:00
gsinghpal
75eb084687 feat(fusion_accounting_core): add empty sub-module skeleton
Made-with: Cursor
2026-04-18 21:22:01 -04:00
gsinghpal
76c898aadf docs(fusion_accounting): Phase 0 foundation implementation plan
Detailed task-by-task plan for executing Phase 0 of the Enterprise
Takeover Roadmap. 22 tasks covering:

- Sub-module skeletons (_core, _ai, _migration) and meta-module conversion
- Move all current AI module code into fusion_accounting_ai with git mv
- ir_model_data ownership reassignment via post-migration script
- Data adapter pattern (base + bank_rec + reports + followup + assets adapters)
- Refactor of every AI tool to route through adapters (pilot in bank_rec, then survey + per-file)
- Strip all hard Enterprise dependencies from manifests
- Enterprise-detection helper and shared-field-ownership models in _core
- Multi-company record rule on fusion.accounting.session (was a Known Issue)
- Migration safety guard that blocks Enterprise uninstall until wizard runs
- Migration wizard skeleton (per-feature migrations added by future phases)
- tools/check_odoo_diff.sh for the annual upgrade ritual
- Per-sub-module CLAUDE.md, UPGRADE_NOTES.md, README.md
- CI pipeline (or deferral note if not yet viable)
- Empirical Enterprise-uninstall verification test on a throwaway instance
- End-to-end smoke test + completion tag

Each task uses TDD where applicable (test fails, implement, test passes,
commit) and concrete validation commands where TDD doesn't fit (file moves,
config changes, manual smoke tests).

Made-with: Cursor
2026-04-18 21:13:07 -04:00
gsinghpal
6c4ff7751f feat(plating): comprehensive timezone fix across dashboards/PDFs/emails
Database stores datetimes naive-UTC, but the dashboards and emails were
showing UTC strings to users in EST/EDT — making 9pm Toronto look like 1am
the next day. Adds a single helper module + auto-detection on install.

Core changes (fusion_plating):
- New fp_tz.py helper: fp_user_tz, fp_format, fp_isoformat_utc, fp_time_ago
  Resolves user.tz → company.x_fc_default_tz → UTC.
- res.company.x_fc_default_tz Selection (full pytz IANA list)
- res.config.settings exposes the company tz under a new "Regional
  Settings" block in Settings > Fusion Plating
- post_init_hook auto-populates the tz on first install: tries admin
  user → server /etc/timezone → America/Toronto fallback
- fp_process_node._to_dict now sends create_date/write_date as ISO with
  explicit +00:00 marker so JS new Date() parses it as UTC and the
  recipe tree editor's "time ago" math works correctly

Shop-floor controllers:
- shopfloor_controller.py: every fields.Datetime.to_string() and naive
  .strftime() swapped for fp_format(env, ...) — due_at, bake times,
  last_log_date, gates, server_time all now in user's tz
- _time_ago() removed; replaced with fp_time_ago helper which compares
  tz-aware datetimes (the local one was naive-vs-naive and could be
  off by hours)
- manager_controller.py date_planned: str(...)[:10] slice replaced
  with fp_format MM/DD in user's tz

Notifications + reports:
- mail_template_data.xml: 5 .strftime() calls in body_html → babel
  format_datetime / format_date with tz=(user.tz or company tz)
- report_fp_job_traveller.xml: rec.received_date (Datetime) gets
  t-options="{'widget':'datetime'}" so Odoo's QWeb renders in user tz

Settings view layout:
- fusion_plating now owns the Settings page "Fusion Plating" app shell
- fusion_plating_certificates xpaths into it instead of redefining
  (prevents app-name collision)

Verified on odoo-entech (LXC 111): post_init_hook detects
America/Toronto from /etc/timezone, MO date_start 2026-04-17 05:28 UTC
correctly displays as 2026-04-17 01:28 EDT.

Module versions bumped: fusion_plating 19.0.3.0.0,
fusion_plating_shopfloor 19.0.9.0.0, plus certificates / notifications /
reports → 19.0.3.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:03:02 -04:00
gsinghpal
956678dd27 docs(fusion_accounting): roadmap design for Enterprise takeover
Adds the brainstormed roadmap design that turns fusion_accounting from an
AI-only extension into a full replacement for Odoo 19 Enterprise accounting
(account_accountant, account_reports, accountant, account_followup, plus
selected satellites) for Nexa client deployments.

Covers:
- Sub-module topology (9 modules + meta-module): _core, _bank_rec, _reports,
  _dashboard, _followup, _assets, _budget, _ai, _migration
- Data preservation strategy: bank reconciliations verified preserved
  automatically (live in Community account.partial.reconcile);
  shared-field-ownership pattern for Enterprise extension fields on
  account.move; pre-uninstall migration wizard for Enterprise-only tables
- Phased roadmap: Phase 0 foundation through Phase 7+ optional satellites,
  with Bank Rec as Phase 1 priority and Reports as the largest phase
- Architecture rules: hybrid mirror/abstract zones, fusion.* naming,
  runtime coexistence detection, zero hard Enterprise deps
- Cross-version upgrade workflow: pinned Odoo source snapshots per version,
  annual diff ritual, UPGRADE_NOTES.md per sub-module
- AI integration via adapter pattern (current AI tools route through
  adapters that prefer fusion native, fall back to Enterprise, then to
  pure Community)
- Testing strategy, security, performance, multi-company/currency,
  localization, hosting

Implementation of each phase happens in subsequent sessions, each with
its own writing-plans pass starting with Phase 0 Foundation.

Made-with: Cursor
2026-04-18 20:55:22 -04:00
gsinghpal
e52477e2ba fix(plant-overview): priority stripe clips to card's rounded corners
The coloured priority stripe (4px vertical bar at the card's left
edge, set via ::before pseudo) extended past the top and bottom
rounded corners of the card — visible as sharp corners on cards with
Urgent or HOT priority (yellow/red stripe).

Cause:
  .o_fp_po_card::before was positioned at left/top/bottom: -1px and
  given its own border-radius, but the stripe's own radii didn't
  match the card's 14px radius precisely, and the -1px offsets
  pushed the stripe outside the card's curves.

Fix:
  1. .o_fp_po_card gets overflow: hidden. Shadows are painted outside
     the content box in CSS so box-shadow still renders fine, but any
     child element (including ::before) now clips to the parent's
     border-radius automatically.
  2. Stripe ::before simplified to left/top/bottom: 0 — no more
     negative offsets, no more independent border-radius rules.
     The parent's overflow does the corner-matching.

Verified in /web/assets/5e85f15/web.assets_backend.min.css:
  .o_fp_po_card { ...; overflow: hidden; ... }
  .o_fp_po_card::before { content: ""; position: absolute;
      left: 0; top: 0; bottom: 0; width: 4px; ... }

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:00:14 -04:00
gsinghpal
83271ee69e fix(shopfloor): pages own the scroll + sharp corner fix
Two problems after the previous round:

1) Mobile scroll still not working, even on a real phone.

Dug into /usr/lib/python3/dist-packages/odoo/addons/web/static/src/
webclient/webclient_layout.scss and found Odoo's mobile layout
switches scroll ownership at @media-breakpoint-down(md) (<768px):

  Desktop: .o_content has overflow:auto — your content scrolls there
  Mobile:  .o_action gets overflow:auto, .o_content is overflow:initial

Our client action roots had `min-height: 100%` and relied on an
ancestor for scroll. That ancestor changes between breakpoints, and
somewhere in the transition scroll gets lost — the page fills but
can't scroll.

Fix: make each page OWN its scroll, like .o_content on desktop
kanban/list views. Three roots now have:

  .o_fp_tablet / .o_fp_manager / .o_fp_plant_overview {
      height: 100%;
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
  }

Scroll works regardless of which ancestor Odoo decides owns it at
any given breakpoint.

2) Sharp corner on column header at mobile widths.

The previous commit set `overflow: visible` on .o_fp_po_column at
<=900px trying to help scroll. But the column has border-radius: 20px
and contains .o_fp_po_col_header (which has its own background). When
overflow is visible, the header bg extends to the column's corners
without being clipped — you see squared corners on the mobile card.

Fix: keep `overflow: hidden` on .o_fp_po_column at every breakpoint
(that's what clips the rounded corners). Only lift `max-height` on
mobile so columns size to content naturally. Since the PAGE now owns
the scroll (see fix #1), the column doesn't need internal scroll —
no `overflow: auto` on the body is needed either.

Verified in compiled CSS at /web/assets/7ff5b28/web.assets_backend.min.css:
  .o_fp_tablet          { height: 100%; overflow-y: auto; ... }
  .o_fp_manager         { height: 100%; overflow-y: auto; ... }
  .o_fp_plant_overview  { height: 100%; overflow-y: auto; ... }
  .o_fp_po_column       { border-radius: 20px; overflow: hidden }
  @media (max-width: 900px) .o_fp_po_column {
      flex: 1 1 auto; min-width: 100%; max-width: 100%;
      max-height: none;   // no overflow override — hidden stays
  }

Version bumped 19.0.6.0.0 -> 19.0.7.0.0 to force bundle hash change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:49:35 -04:00
gsinghpal
082c585e24 fix(shopfloor): mobile scroll works — remove nested scroll containers
User: "scrolling is not working" in Chrome DevTools mobile simulation.
Three actual problems:

1. Plant Overview columns had max-height: calc(100vh - 180px) +
   overflow: hidden, with a nested overflow-y: auto on the column
   body. Classic Trello kanban pattern — works on desktop, breaks
   on mobile. You get two scroll containers fighting each other and
   the PAGE itself can't scroll past the viewport height.

2. .o_fp_po_columns had overflow-x: auto on all widths. On the
   phone-stack breakpoint (<600px) this was also still on, creating
   another nested scroll container.

3. Draggable cards can swallow touch events on mobile because
   touch-action defaults to "auto" and Chrome's mobile simulator
   treats touch on draggable elements as potential drag-start.

Fixes — all at the <=900px breakpoint (tablets + phones):

  .o_fp_po_column          max-height: none; overflow: visible
  .o_fp_po_col_body        overflow-y: visible
  .o_fp_po_columns         flex-direction: column; overflow: visible

Plus .o_fp_po_card carries `touch-action: pan-y` unconditionally —
touch-scroll gestures never get hijacked by the draggable="true"
attribute. Desktop mousedown drag still works (HTML5 drag-drop
isn't touch-based by default).

Also added -webkit-overflow-scrolling: touch to all three page
roots (.o_fp_tablet, .o_fp_manager, .o_fp_plant_overview) and to
the internal scroll containers that remain on desktop — gives iOS
Safari proper momentum scroll (11 occurrences in the compiled
bundle).

Drag-drop JS preventDefault calls audited — they only fire on
dragover/drop (HTML5 drag events), which don't exist on touch by
default, so no touch interference there.

Verified via compiled CSS:
  .o_fp_po_card { touch-action: pan-y; ... }
  @media (max-width: 900px) .o_fp_po_column { overflow-x: visible;
         overflow-y: visible; min-height: auto }
  @media (max-width: 900px) .o_fp_po_col_body { overflow-y: visible }

Version bumped 19.0.5.0.0 -> 19.0.6.0.0 to force the bundle hash
to change. New URL: /web/assets/4a1b69e/web.assets_backend.min.css

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:40:52 -04:00
gsinghpal
afc01ec1d9 fix(shopfloor): proper dark-mode via \$o-webclient-color-scheme branch
Dug deeper after the user reported shop-floor pages staying white in
dark mode. Traced through Odoo 19 source:

  _dependencies/web_enterprise/static/src/
    webclient/color_scheme/color_scheme_service.js  <- reads cookie
    scss/primary_variables.scss       \$o-webclient-color-scheme: bright
    scss/primary_variables.dark.scss  \$o-webclient-color-scheme: dark

Odoo compiles TWO separate CSS bundles:
  web.assets_backend       -> compiled with \$...scheme: bright
  web.assets_web_dark      -> compiled with \$...scheme: dark
    (the .dark.scss files are layered in front of the light ones)

Our shop-floor SCSS is in web.assets_backend, which means it gets
compiled into BOTH bundles. But the previous CSS-variable fallback
chain (var(--fp-page-bg, var(--bs-tertiary-bg, #hex))) baked the
SAME hex fallback into both bundles, so cards stayed white in dark.

Odoo's own code doesn't redefine --bs-* CSS custom properties at
runtime either — it just bakes the dark palette straight into the
dark bundle via SCSS \$-variables during compile.

Fix: _fp_shopfloor_tokens.scss now branches at compile time:

    \$o-webclient-color-scheme: bright !default;

    \$_fp-page-hex: #f3f4f6;  // light defaults
    \$_fp-card-hex: #ffffff;
    ...
    @if \$o-webclient-color-scheme == dark {
        \$_fp-page-hex: #1a1d21 !global;
        \$_fp-card-hex: #22262d !global;
        ...
    }

    \$fp-page: var(--fp-page-bg, \$_fp-page-hex);
    \$fp-card: var(--fp-card-bg, \$_fp-card-hex);

The CSS-custom-property fallback stays so deployments can still skin
via --fp-* without touching SCSS; the underlying hex changes between
bundles.

Verified via odoo-shell:
  LIGHT bundle: .o_fp_plant_overview { background-color: var(...#f3f4f6) }
                .o_fp_po_card         { background-color: var(...#ffffff);
                                         border: ... #d8dadd }
  DARK bundle:  .o_fp_plant_overview { background-color: var(...#1a1d21) }
                .o_fp_po_card         { background-color: var(...#22262d);
                                         border: ... #343942 }

Two separate bundle URLs generated:
  /web/assets/a593157/web.assets_backend.min.css
  /web/assets/a9dba7d/web.assets_web_dark.min.css

=== CLAUDE.md ===
Replaced the previous (incorrect) .o_dark_mode override advice with
a proper "Branch on \$o-webclient-color-scheme at SCSS compile time"
section, including the bundle names and the verify-via-odoo-shell
snippet. Future redesigns now have a single, correct pattern to
follow.

Version bumped 19.0.4.0.0 -> 19.0.5.0.0 to force asset hash change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:30:14 -04:00
gsinghpal
11f7791c5e fix(shopfloor): dark mode auto-inverts + Quick View button visible
Two fixes + a memory entry in CLAUDE.md.

=== Dark mode ===

User: "when I change the theme the whole background does not turn
dark like the other pages does". Digging through Odoo 19 source:

  /_dependencies/web_enterprise/static/src/scss/
    bootstrap_overridden.dark.scss
    primary_variables.dark.scss
    secondary_variables.dark.scss

Odoo doesn't flip dark mode via a runtime .o_dark_mode class on the
DOM — it compiles a SEPARATE asset bundle where $o-webclient-color-
scheme: dark is set, which redefines every --bs-* token with dark
values. When the user toggles dark mode, Odoo swaps the whole CSS
bundle.

So my previous :root[data-bs-theme="dark"] { --fp-page-bg: #13161a; }
block was DEAD CODE — nothing ever sets data-bs-theme on the root.

Fixed: tokens now fall through to Bootstrap's --bs-* semantic tokens
before hitting a hex default, so they auto-invert when Odoo swaps
bundles. Three-level fallback chain:

  $fp-page : var(--fp-page-bg,
                 var(--bs-tertiary-bg, #f3f4f6));
  $fp-card : var(--fp-card-bg,
                 var(--bs-card-bg,
                     var(--o-view-background-color, #ffffff)));
  $fp-border : var(--fp-border-color,
                   var(--bs-border-color, #d8dadd));
  $fp-ink : var(--fp-ink, var(--bs-body-color, #1f2937));

Dead .o_dark_mode block removed. No runtime selector needed.

=== Quick View button ===

User: "Quick View button color is white with white button in light
mode." Cause: Bootstrap's .btn-primary loads AFTER our custom CSS
in the bundle and resets color: #fff, background: var(--bs-btn-bg)
— which clobbered our $fp-accent / $fp-ink assignment because a
later rule at the same specificity wins.

Fix: split the primary button into its own rule with higher
specificity (.o_fp_manager .o_fp_manager_head_actions .btn.btn-primary)
and !important on the three key properties — so Bootstrap can't
shout us down. Hover uses brightness(1.08) for a subtle darken
without needing another color assignment.

=== CLAUDE.md additions ===

Added two new rules documenting the lessons so this isn't relearned:

  Rule 8 — Odoo 19 forbids @import in custom SCSS (silent warning,
  falls back to cached bundle). Register partials in the assets list
  in load order; SCSS variables cascade through the bundle.

  "Card Styling — Copy Odoo's Kanban Pattern" section explaining:
  - Don't rely on --bs-border-color directly for card surfaces
  - Chain through $fp-* → --fp-* → --bs-* → hex
  - 3-layer contrast rule (page → container → card)
  - Reference _fp_shopfloor_tokens.scss as canonical

  "Asset Bundle Cache Busting" section with 4-step escalation path
  for when CSS changes don't show up in browser.

Verified: bundle regenerated to /web/assets/b48ab17/web.assets_backend.min.css
(id 1945). Card rule compiled with full fallback chain visible.
Primary button carries !important modifier for bg/border/color.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:22:17 -04:00
gsinghpal
81277edb25 fix(shopfloor): explicit hex colors like Odoo's own kanban
Why the borders weren't showing: the previous approach used
color-mix(var(--bs-body-color) 4%, var(--o-view-background-color)) for
card/column backgrounds. Under Odoo 19 the resolved values for those
variables were nearly identical to var(--bs-body-bg), so the card
surfaces visually merged into the page. Same problem for borders:
var(--bs-border-color) can render extremely faint depending on theme.

Checked what Odoo's native kanban does — dug through the compiled
CSS and found:
    .o_kanban_record { background-color: white;
                        border: 1px solid #d8dadd; }
    .o_kanban_group  { background: var(--KanbanGroup-background); }

Odoo uses EXPLICIT hex values and card-specific tokens, not the
generic body/border variables. Adopted the same approach.

New tokens in _fp_shopfloor_tokens.scss — all explicit, plus a
dark-mode override block keyed off [data-bs-theme="dark"] and
.o_dark_mode (Odoo 19 uses both):

    light                             dark
    ------------------------          ------------------
    --fp-page-bg: #f3f4f6             #13161a
    --fp-column-bg: #e9ebef           #1a1e24
    --fp-card-bg: #ffffff             #22262d
    --fp-card-soft-bg: #f8fafc        #1c2027
    --fp-border-color: #d8dadd        #343942
    --fp-ink: #1f2937                 #e5e7eb
    --fp-ink-mute: #6b7280             #8a909a

    shadow scale switched from color-mix to explicit rgba(0,0,0,...)
    so it renders identically across browsers.

All three SCSS files updated via sed to swap
var(--bs-border-color)  ->  #{$fp-border}
...then $fp-border resolves to var(--fp-border-color, #d8dadd) — a
proper card-level border that is VISIBLE (28 refs to --fp-card-bg
and 35 refs to --fp-border-color confirmed in the compiled bundle).

Plant Overview specifically now has:
  * Column: #f8fafc bg + #d8dadd border + shadow
    (column is brighter than the page it sits on)
  * Column HEADER: #ffffff inside the column, with bottom border
    (clear separator between stages)
  * Card: solid #ffffff bg + #d8dadd border + shadow
    (brightest surface, pops off the column)
  * Gap between columns: 16px so the column borders don't touch

Module version bumped to 19.0.3.0.0. Bundle regenerated at
/web/assets/0cd8bc1/web.assets_backend.min.css (1.45 MB, id 1939).
Verified by parsing compiled CSS:
  .o_fp_po_card: background-color: var(--fp-card-bg, #ffffff);
                 border: 1px solid var(--fp-border-color, #d8dadd);
  .o_fp_po_column: background-color: var(--fp-card-soft-bg, #f8fafc);
                   border: 1px solid var(--fp-border-color, #d8dadd);

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:13:38 -04:00
gsinghpal
2588a2b651 fix(plant-overview): real drop insertion indicator + small logo back
Three direct fixes responding to user feedback:

1. Drag-drop "simulation" — now works like Trello/Linear. As the
   cursor moves over a column, a live DOM placeholder node is
   INJECTED into the card list at the exact position the dragged
   card will drop. The placeholder is a 4px pulsing accent-coloured
   bar with a soft glow ring. Slides smoothly between cards as the
   cursor moves. Column body also gets a tinted background + inset
   accent outline for the "whole column is receptive" cue.

   Previous version only tinted the column — no indicator of WHERE
   the card would land. The new approach actually mimics the physical
   gesture: cards visually make room for the incoming card.

2. Customer logo restored at 32×32px.
   Removing it was the wrong call. It's back now as a small
   thumbnail avatar (rounded 10px corners, soft border, object-fit
   contain so wide logos don't squish). Sits to the left of the
   customer name in the card top row. Fallback icon for customers
   without a logo. Takes the same space as the step badge on the
   right — compact and organised.

3. Module version bumped 19.0.1.0.0 → 19.0.2.0.0 so the asset
   bundle content hash changes. The new compiled CSS is served at
   /web/assets/022171c/web.assets_backend.min.css (previously
   /web/assets/278b43c/...). Fresh URL forces browser to refetch —
   this is what was causing the "still no border" complaint.

Verified in compiled CSS: o_fp_po_card_avatar, o_fp_po_drop_placeholder,
o_fp_placeholder_pulse keyframes, o_fp_drop_target — all present.
Zero SCSS warnings. Module upgrade clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:06:40 -04:00
gsinghpal
83a999afad style(shopfloor): borders back, real drop indicator, logo demoted
Three concrete fixes based on user feedback:

1. Card borders restored — every card / panel / KPI tile / queue row /
   bake row / team card now has a thin 1px border (var(--bs-border-color))
   ON TOP of the soft shadow. That's the classic SaaS card treatment
   and solves the "jobs have no borders, they blend together" problem.
   Hover lifts the border to the accent colour (~45% mix) so cards
   feel responsive.

2. Plant Overview drop-zone indicator restored.
   - Column body gets inset outline + tinted background on dragover
     (.o_fp_drop_target class already added by onColDragOver in JS)
   - A 56px dashed placeholder bar appears at the bottom of the column
     via ::after on the drop target. That's the "here's where the card
     will land" visual the user remembered.
   - Dragged card gets scale(0.97) + slight rotation + opacity 0.4 for
     a clearer "I'm picking this up" feedback.

3. Customer logo removed from Plant Overview cards.
   The big company logo at the top of each kanban card was wasting
   space. Customer NAME still shows (in bold, full-width, with text-
   ellipsis), step badge pill stays on the right. No more wasted
   real estate on visuals nobody looks at twice.

Extra polish while in there:
   - Section headers (Tablet + Manager) now have a coloured icon badge
     — a rounded square 36×36 with tinted background + accent-coloured
     icon next to the H3 title. Adds visual weight without noise.
   - Panel head gets a 1px bottom divider.
   - Manager panels tint the icon badge per panel tone (amber for
     Unassigned, green for In Progress, blue for Team).
   - Header action buttons (Tablet scan/picker, Manager refresh/mode)
     get proper borders + hover state.
   - State dividers on bake/gate/hold rows preserved as inset shadows.

Verified: bundle rebuilt at /web/assets/278b43c/web.assets_backend.min.css
(1.45MB, id 1930). All key classes present: o_fp_drop_target,
o_fp_dragging, o_fp_po_parts_bar, o_fp_po_parts_fill, section-header
icon badges. Zero SCSS warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:01:04 -04:00
gsinghpal
067d1f01c8 redesign(shopfloor): clean slate — depth by shadow, no card borders
User feedback: the previous gradient-heavy look felt cluttered, job
cards had confusing heavy borders, the hierarchy was noisy. Wiped all
three SCSS files and both OWL templates and rebuilt from scratch with
a clean minimalist design language.

Design philosophy — the single source of truth:

  * NO borders on cards — depth comes from elevation (shadow) + a
    tiny surface-tint difference between page and card
  * ONE accent colour (var(--o-action)); semantic red/amber/green only
    for status pills and state bars
  * Shadow-only cards: $fp-elev-1, $fp-elev-2, $fp-elev-3 built on
    color-mix of foreground so they adapt to dark mode automatically
  * Generous whitespace, 8pt spacing scale ($fp-space-1 through
    $fp-space-10)
  * Type-first hierarchy: 32px page titles, 44px KPI numbers, tabular
    numerics so refreshing counts don't jitter
  * Priority/state cues via narrow 4-6px coloured bars and small dots
    — never via loud backgrounds or gradient washes
  * All interactive elements at 48px touch minimum (shop-floor gloves)

New token file (_fp_shopfloor_tokens.scss) exports:
  - $fp-space-1..10, $fp-radius-sm..xl, $fp-radius-pill
  - $fp-page / $fp-card / $fp-card-soft surface tints
  - $fp-ink / $fp-ink-soft / $fp-ink-mute / $fp-ink-faint text tiers
  - $fp-elev-1..3 layered shadows
  - $fp-text-xs..3xl type scale
  - @mixin fp-pill, fp-focus-ring, fp-card, fp-hover-only
  - fp-wash() function for state-coloured soft backgrounds

Tablet Station (fusion_plating_shopfloor.scss + shopfloor_tablet.xml):
  - Clean hero: just the title, station chip, picker + scan button
  - KPI cards: no gradient overlay, just a 10px coloured dot and big
    44px number. Hover lifts with shadow
  - Active WO: soft green wash background, no border, pulsing dot
  - Panels contain queue/baths/bakes/gates/holds — all on the same
    card surface with big rounded corners, no internal borders
  - Queue rows: flat on a soft page-tinted background, hover slides
    right 2px (no lift, cleaner)
  - Bake/Gate/Hold rows: state-coloured inset shadow as a 4px stripe,
    no border
  - Empty states: centred with a 44px muted icon and friendly copy

Manager Desk (manager_dashboard.scss + manager_dashboard.xml):
  - Matching hero with live dot that calmly pulses green during a fetch
  - 4 KPI cards in the same language as the tablet
  - Three panels (Unassigned / In Progress / Team) with coloured dots
    next to their titles instead of top accent bars
  - MO cards NO borders, subtle page-tint background, 4px left stripe
    only for priority (red HOT, amber Urgent)
  - Team cards: avatar + name + live load pill, hover slides right
  - WO expanded rows use card-soft buttons/dropdowns for low contrast

Plant Overview (plant_overview.scss):
  - Columns are now shadow-lifted cards on the tinted page background
  - Kanban cards: no border, small shadow, lift on hover
  - Priority stripe is an inset box-shadow (not a border) so hover
    transform doesn't wobble

Backend contract preserved — OWL class names, prop signatures, RPC
endpoints, and stateBadge mapping all unchanged. Only visuals.

Verified:
  * Bundle compiled to /web/assets/.../web.assets_backend.min.css
    (1.45MB, id 1926)
  * All 6 new classes present in compiled CSS
  * Zero SCSS "forbidden import" warnings
  * Zero Odoo module upgrade errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:45:16 -04:00
gsinghpal
6d1efc6c43 fix(shopfloor): register tokens SCSS in bundle, drop forbidden @import
Odoo 19 forbids local SCSS @import statements for security reasons and
silently falls back to the OLD cached CSS bundle when it sees them. My
redesign commit used:

    @import "./fp_shopfloor_tokens";

in three SCSS files. Odoo logged

    WARNING Local import './fp_shopfloor_tokens' is forbidden for
    security reasons. Please remove all @import {your_file} imports
    in your custom files.

...and the compiled bundle kept rendering the old look. That's what
the user saw.

Fix:
  1. Add _fp_shopfloor_tokens.scss as the FIRST entry in
     web.assets_backend in the manifest. Odoo concatenates the bundle
     in order, so variables/mixins in the first file are visible to
     every later file — native @import is not needed.
  2. Strip the @import "./fp_shopfloor_tokens"; line from all three
     consumer files (tablet, manager, plant overview).

Verified: asset bundle regenerated to /web/assets/.../web.assets_backend.min.css
(1.45 MB). Grepped the compiled CSS and all five new classes are present:
o_fp_tablet_header, o_fp_kpi_strip, o_fp_mgr_card, o_fp_live_dot,
o_fp_panel_unassigned. 8 radial-gradients baked in. Zero warnings in
the Odoo server log post-rebuild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:35:00 -04:00
gsinghpal
298f5942eb refactor(shopfloor): modern redesign w/ gradients, theme-safe tokens
Shop-floor operators and managers live on these screens all day — the
old look worked but felt like a spec sheet. Pass through all 5 pages
with one design language: gradient KPI cards, hero banners, soft
shadows, rounded corners, large touch targets, friendly empty states.
Dark mode and light mode both look deliberate, not inverted.

New shared file: _fp_shopfloor_tokens.scss

  A single source of truth for radii, elevation (shadows that respect
  dark mode via color-mix on foreground), typography scale (tabular
  numerics for KPIs, 18px base for shop-floor readability), animation
  easings, semantic gradients (@mixin fp-grad), tone helpers
  (@mixin fp-tone), focus ring, and the 44px touch-min token.
  Every other SCSS file imports this — no duplicated colour math.

  Gradients are built on color-mix(in srgb, var(--bs-foo) X%, transparent)
  so they layer naturally on either the light or dark page background.
  No @media-prefers-color-scheme forks needed.

Tablet Station (fusion_plating_shopfloor.scss):

  * Hero banner with dual radial-gradient wash (brand + success), live
    station chip, gradient focus ring on the picker.
  * KPI cards (6-up on desktop, 2x3 on phone) get a subtle top accent
    line, coloured gradient overlay, 40px headline number, faded icon
    at corner. Tone variants (info/success/warning/danger/muted) drive
    colour without extra CSS.
  * Active WO banner is a green gradient pill with a breathing-dot
    pulse — unmissable when something is running.
  * Panels get top accents, queue rows get priority pills (HI/M/·),
    bake/gate/hold rows get colour-coded left accent bars.
  * Tiles have a 4px left stripe keyed to state + hover lift.
  * Status chips are uppercase, pill-shaped, tone-tinted with
    color-mix so they respect theme.
  * Empty states now have a large 36px icon + friendly copy instead
    of a one-liner.
  * Focus rings use the shared @mixin fp-focus-ring.

Manager Desk (manager_dashboard.scss):

  * Same hero treatment with radial gradient + live-dot pulse.
  * 3 panels carry a coloured top accent bar — amber (Unassigned),
    green (In Progress), blue (Team). Instant visual routing.
  * KPI strip matches tablet.
  * MO cards get a left priority stripe (red for HOT, amber for
    Urgent), lift on hover, expand cleanly.
  * Team avatars get a border + subtle tint background for depth.
  * Worker/tank pickers have custom focus rings.

Plant Overview (plant_overview.scss):

  * Header is now a gradient wash tied to the brand colour.
  * Work-centre columns get a thin gradient top-stripe and pill-style
    count badges.
  * Cards have real depth (layered shadow), lift harder on hover,
    change border colour on hover.

All three files share the same design tokens, so colours/shadows/
radii are identical across pages. Edit one place, everything updates.

Verified: backend asset bundle compiles clean (no SCSS errors), zero
warnings on module upgrade, asset cache cleared for fresh delivery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:30:47 -04:00
gsinghpal
ae03e32b5d style(shopfloor): phone + iPad responsive across all 5 pages
Shop-floor workers and managers use phones and iPads on the line.
The existing layouts only stacked at 1100px / 1280px, which left
everything cramped on a 375px iPhone or 390px Android. Pass through
all 5 shop-floor screens with disciplined breakpoints and touch-first
sizing.

Breakpoint ladder (consistent across files):
  1400px : manager WO row: worker/tank pickers drop to their own rows
  1280px : manager grid 3 → 2 columns, Team spans both
   1100px : tablet dashboard 2 → 1 column
    900px : manager grid → 1 column; tablet + manager padding shrinks
    768px : plant overview columns stack; first-piece & bake kanbans
            already handled natively by Odoo
    600px : PHONE — all columns stack, everything full-width, every
            button min-height 44px (Apple HIG touch target), font
            shrinks for denser phone screens

Manager Desk (manager_dashboard.scss):
  - Header stacks into two full-width rows on phone, action buttons
    flex-grow to share the row
  - 3 column grid stacks earlier (900px instead of 800px) so iPad
    portrait gets a clean single-column view
  - WO rows: assign/tank pickers go full-width on their own rows at
    1400px, then the whole row stacks to 1 column at 600px
  - Cards min 56px tap zone
  - Team avatars keep their layout but cap gap on phone

Tablet Station (fusion_plating_shopfloor.scss):
  - Header: picker/scan button stack full-width on phone
  - KPI strip auto-fit by default, forced 2×3 grid on phone so 6
    tiles stay visible without scrolling past a wall of tall cards
  - Queue rows: Start/Finish buttons drop to their own row on phone,
    each flexing to 50% width → easy one-thumb tap
  - Bake/Gate/Hold rows: full stack on phone, action buttons flex-grow
  - Bath tile grid: 2-up on phone (not auto-fit)
  - Active WO banner stacks, Open-WO button full-width
  - Station picker and scan input go full-width

Plant Overview (plant_overview.scss):
  - Columns stack at 768px (already there) + 600px padding shrink,
    search input full-width, header wraps sensibly
  - Cards get min-height 64px for touch

Touch-device hover suppression:
  @media (hover: none) — hover highlights were sticking after tap on
  phones/iPads. Block them for .o_fp_queue_row, .o_fp_tile,
  .o_fp_tablet_card, .o_fp_mgr_card, .o_fp_team_card, .o_fp_po_card.

Asset cache cleared so phones pick up the new SCSS on next load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:16:35 -04:00
gsinghpal
d29857078a fix(manager-desk): unstick the spinner + live updates that don't flash
Root cause of the stuck "Loading manager data..." spinner: the overview
endpoint included a search_count on sale.order.x_fc_workflow_stage,
which is a non-stored computed field. Odoo 19 raised:

  ValueError: Cannot convert sale.order.x_fc_workflow_stage to SQL
              because it is not stored

The controller silently logged the error; the JS caught and swallowed
the RPC failure, leaving state.overview=null forever. So the UI just
kept spinning while production changed around the manager.

Fixes:

1. Controller (manager_controller.py)
   - "Awaiting assignment SOs" is now computed from STORED fields only:
       state='sale' AND x_fc_receiving_status='inspected'
             AND x_fc_assigned_manager_id=False
     Same stage, legal SQL.
   - Whole endpoint wrapped in try/except; failures return
     {'ok': False, 'error': '...'} so the UI can surface them instead
     of dying silently.
   - Response carries a payload_hash (md5 of the JSON body minus
     user_name). If the client sends back known_hash and nothing has
     moved, the server returns {'unchanged': True, 'payload_hash': ...}
     and the client skips the repaint entirely. Keeps the UI quiet
     between polls.

2. OWL component (manager_dashboard.js)
   - Poll cadence tightened from 30s → 8s (production-pace).
   - Unchanged payloads don't mutate state.overview → no re-render,
     no flash. Live dot just updates its tooltip.
   - Changed payloads do an in-place MERGE of the overview (copying
     scalars/arrays onto the existing reactive object) instead of
     replacing it wholesale. OWL's diff only re-renders rows that
     actually moved.
   - isFetching guard so overlapping polls can't stack up.
   - state.loadError surfaces backend errors in a red banner with a
     Retry button — no more silent spinner.

3. UX
   - Live dot next to the title: soft green at rest, bright green
     pulsing during a fetch.
   - "Updated Xs ago" subtitle uses a getter so the label freshens
     between polls.
   - Manual Refresh button next to Quick/Detailed toggle.
   - Spinner only appears on the genuine first load; gone forever
     once the first payload lands.

Verified: the old crashing query now runs clean on demo data; odoo
logs show zero errors for the last 5 minutes of polling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:06:04 -04:00
gsinghpal
a660f1f05d fix(configurator): part-level saved descriptions (not generic)
The earlier description templates were global — same 8 generic texts
applied to any part. That's useless when a customer has 3,500 parts
and each part has 3–5 canned variants (standard, masked threads,
masked bore, rework, rush packaging). That's ~17,500 rows total, and
the variants ONLY make sense in the context of a specific part number.

Restructured so descriptions live on each part:

Model changes:
  fp.sale.description.template.part_catalog_id (new M2O, indexed,
    ondelete cascade) — the primary scoping field
  fp.sale.description.template.partner_id — now a related store=True
    field pulled from the part, so customer-level search still works
  fp.part.catalog.description_template_ids (new O2M inverse) — the
    5–10 canned descriptions attached to this specific part
  fp.part.catalog.description_template_count (computed)

UI changes:
  Part Catalog form: new "Descriptions" notebook page with inline
    editable list (sequence + name + tag + description + usage_count).
    5 variants take 30 seconds to enter.
  Part Catalog form: new smart button "Descriptions" showing the count,
    jumps to the full list filtered by this part.
  Template list view: part_catalog_id column added, list ordered by
    part first. Search view adds Part filter + Part-Specific /
    Generic (No Part) filters + Group By Part.

Wizard changes:
  description_template_id domain now prioritises part-specific, falls
    through to partner, coating, or generic on a single dynamic domain.
  _onchange_suggest_template priority: part → customer → coating →
    none. No longer auto-picks a random global template when a part
    has its own.

Smoke-tested on VS-HSA201-B (Amphenol):
  5 canned variants seeded on the part form
  Wizard with this part auto-suggested the lowest-sequence one
  The part's Descriptions smart button shows "5"

Bulk data entry path for the client's 3,500 parts: either use the
inline list on each part form, or import via CSV with the new
part_catalog_id column (external_id or DB id).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:46:53 -04:00
gsinghpal
f340c87b6a feat(bridge_mrp): shop-role auto-routing + tablet worker mode (CHUNK 4/4)
Completes the worker-access story. Handoffs now route themselves.

New model fp.work.role with 8 seeded defaults (noupdate so shops can
rename/prune):
  masking · racking · plating_op · demask · oven · derack ·
  inspection · rework

Each one has a code, icon, description, sequence, active flag.
Config menu: Configuration → Shop Roles (manager-only).

Field additions:
  hr.employee.x_fc_work_role_ids (Many2many) — tag workers with the
    roles they perform. One-person shop: one employee, every role.
    Specialised shop: one role per employee. Cross-trained: multiple.
  fusion.plating.process.node.x_fc_work_role_id (Many2one) — tag
    each recipe operation with the role that performs it.
  mrp.workorder.x_fc_work_role_id (Many2one) — copied from the recipe
    operation on WO generation.

Auto-assignment on WO generation:
  _generate_workorders_from_recipe() now copies the operation's role
  onto the WO, then calls _fp_pick_worker_for_role() which picks the
  least-loaded employee (active WO count) with that role. WO lands in
  their Tablet "My Queue" the moment the MO is confirmed. No manual
  routing needed for the common case.

Tablet Station — worker mode:
  /fp/shopfloor/tablet_overview now filters to WOs where
  x_fc_assigned_user_id == env.user when the field is populated.
  KPIs (WOs Ready / In Progress) reflect the logged-in worker's load,
  not shop-wide totals. "My Queue" rows carry wo_state + can_start +
  can_finish so inline Start/Finish buttons appear.
  New JS handlers onStartWo / onFinishWo call /fp/shopfloor/start_wo
  and /fp/shopfloor/stop_wo (finish=true). One-tap progression.

Views:
  hr.employee form gets a "Shop Roles" notebook page with many2many_tags.
  Process node form gets x_fc_work_role_id inline after work_center_id.
  Work Order form shows role + assigned worker.

Smoke-tested end-to-end on WH/MO/00010:
  Masking      → Administrator (masking role)
  Racking      → Administrator (racking role)
  E-Nickel     → Andrew (plating_op, least-loaded tiebreaker)
  Demask       → Administrator (masking)
  Oven bake    → Andrew (oven)
  Derack       → Administrator (racking fallback)
  Post-plate QA → Administrator (inspection)

80 existing WOs backfilled with role + worker via name-match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 20:08:23 -04:00
gsinghpal
1c6a460ca1 feat(shopfloor): Manager Desk — assign workers, swap tanks, take over (CHUNK 2/4)
New client action "Manager Desk" under Shop Floor menu (manager-only).
Three-column dashboard designed for the shop manager's daily job:

  Column 1 — Needs a Worker
    MOs with active WOs missing user assignment. Each card expands to
    show per-WO rows with:
      - Assign Worker dropdown (pulls from group_fusion_plating_operator)
      - Tank swap dropdown (all tanks with current bath)
      - Take Over (claims for the manager in one click)
      - Open (jump to WO form)

  Column 2 — In Progress
    MOs with workers actively running WOs. Shows who's on each step,
    lets manager reassign or take over if someone steps away.

  Column 3 — Team
    Avatar grid of operators with live queue + in-progress counts.
    Click to drill into that operator's full WO list.

KPI strip on top: Unassigned WOs, In Progress, Ready to Ship, Awaiting
Assignment SOs.

Quick / Detailed view toggle — Detailed auto-expands every card body.

New field mrp.workorder.x_fc_assigned_user_id (indexed, tracked) —
the worker currently owning this step. Will be the pivot the Tablet
Station filters on in Chunk 4.

Three new endpoints:
  /fp/manager/overview       — dashboard snapshot (30s auto-refresh)
  /fp/manager/assign_worker  — set user on a WO
  /fp/manager/assign_tank    — swap tank on a WO
  /fp/manager/take_over      — manager claims the WO (no-show coverage)

Controller is graceful when mrp module isn't installed (empty overview,
no crash) and when the bridge_mrp assignment field isn't present (falls
back to showing all active WOs as "unassigned").

Verified: 4 WOs assigned across 2 users, overview queries return the
expected counts, res.groups.user_ids (Odoo 19 API, not deprecated .users).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 20:03:01 -04:00
gsinghpal
095d9f487c feat(bridge_mrp): SO workflow stage + contextual buttons (CHUNK 3/4)
Sale Order form now guides the user through the next step without
making them navigate between screens.

New computed field sale.order.x_fc_workflow_stage with 12 stages:
  draft → awaiting_parts → inspecting → accept_parts → assign_work
       → in_production → ready_to_ship → shipped → invoicing
       → paid → complete (+ cancelled)

Driven by SO.state + x_fc_receiving_status + MO state +
delivery.state + invoice payment state.

Five contextual header buttons (only 1-2 visible at any time,
fusion_claims pattern — invisible="x_fc_workflow_stage != 'foo'"):

  Mark Inspecting       → flips receiving to 'inspecting'
  Accept Parts          → flips receiving to 'accepted' + SO status
                          to 'inspected', unlocks manager assignment
  Assign To Me & Release → manager claims the job, confirms all draft
                          MOs (which auto-generates WOs already)
  Open Shop Floor       → jumps to Plant Overview during production
  Mark Shipped          → closes open delivery records → triggers
                          auto-invoice per strategy

Info banner shows current stage + assigned manager on the sheet so
users always know where they are.

New fields:
  sale.order.x_fc_assigned_manager_id (Many2one res.users, tracked)
  mrp.production.x_fc_assigned_manager_id (Many2one, propagated on
                                           MO confirm)

MO.action_confirm() now pulls the assigned manager from the SO (or
falls back to SO.user_id) and sets it on the MO — sets up the
Manager Dashboard (chunk 2) and role-based assignment (chunk 4) to
filter "my jobs" cleanly.

Smoke-tested across 10 demo SOs — stages compute correctly:
  S00028 → ready_to_ship, S00027-25 → awaiting_parts,
  S00023-20 → complete/shipped, S00029 → draft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:57:41 -04:00
gsinghpal
28dd7fdd76 feat(certificates): per-customer document preferences (CHUNK 1/4)
Customers can now pick which shipping-time documents they actually want
instead of the shop remembering it per account. Four booleans on
res.partner (only shown on companies, not contacts):

  x_fc_send_coc              (default True)  Certificate of Conformance
  x_fc_send_thickness_report (default True)  Thickness readings
  x_fc_send_packing_slip     (default True)  Packing slip PDF
  x_fc_send_bol              (default False) Bill of Lading

Surfaced in a "Plating Documents" page on the customer form.

Two downstream gates:

1. fp.notification.template._collect_attachments() now reads the flags
   when attaching CoC / thickness / packing / BoL PDFs to the shipping
   confirmation email. Flags missing on the partner (e.g. legacy
   customers) fall back to the original defaults so nothing regresses.

2. mrp.production.button_mark_done() only auto-creates the quality
   documents the customer wants. A customer that unchecks both CoC and
   thickness gets zero certs auto-generated — shop can still create
   them manually if needed.

Note: today a standalone thickness-only report template doesn't exist,
so when a customer asks for thickness only (CoC off, thickness on) the
dispatcher still attaches the CoC PDF (which carries thickness data)
but with CoC creation gated off. A dedicated thickness-only template
is a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:54:54 -04:00
gsinghpal
f94be9dfa9 fix(part-catalog): upload slot + swapped Number/Name + smart buttons
Three fixes on fp.part.catalog form:

1. 3D Model upload actually works now. The old field exposed only a
   Many2one search dropdown — no way to add a new file. Added a
   Binary upload slot (model_upload + model_upload_filename) that
   fires an onchange which wraps the bytes in an ir.attachment and
   links it to model_attachment_id. The upload slot is hidden once a
   model is already attached, so the current file stays visible.
   Accepts STEP/STP/STL/IGES/IGS/BREP. Auto-runs the surface-area
   calculation after attach, same as before.

2. Part Number is now the big <h1> title, Part Name is the smaller
   field underneath. Matches how plating shops actually identify
   parts (by customer part number, not a free-text name). Swapped
   column order in the list view too — Part Number first, then Name.

3. Four smart buttons now on the part form:
     - Customer  → opens res.partner record
     - Sale Orders  (already existed)
     - Work Orders  → filtered mrp.workorder list across SOs for this part
     - Quotes  (already existed)
     - Revisions  → shown only when 2+ revs exist, opens the revision
       tree filtered by root part
   New compute fields workorder_count + revision_count feed the
   statinfo widgets, with matching action_view_customer,
   action_view_workorders, action_view_revisions handlers.

Verified on demo data:
  VS-ESMC6H00801P01 → SO=2, WO=18, REV=2
  VS-PQR8440        → SO=1, WO=9,  REV=3
  All counts light up, buttons drill in cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:00:52 -04:00
gsinghpal
70fe10c214 fix(configurator): money fields now show $ everywhere
Root cause: pricing.rule records had currency_id=NULL because the
default=lambda only applies on new records. Monetary fields without a
currency silently render as plain numbers — no $ symbol.

Fixes:

1. currency_id now required=True on fp.pricing.rule, fp.treatment,
   fp.customer.price.list, fp.quote.configurator, fusion.plating.quote.request
   — so it can never be missing going forward.

2. post_init_hook + matching backfill helper in
   fusion_plating_configurator/__init__.py pins the company currency
   on any existing records that were created before the required flag.
   Ran on upgrade → all 4 pricing.rule rows now have CAD/$.

3. Flipped two remaining Float money fields to Monetary:
   - fp.job.consumption.unit_cost and total_cost (were Float digits=4/2)
   - (mrp.workorder.x_fc_workcenter_cost_hour stays Float — it is a
     related field from core mrp.workcenter.costs_hour which is Float)

4. Every Monetary field reference in views now has explicit:
     widget="monetary" options="{'currency_field': 'currency_id'}"
   Previously Odoo's default rendering dropped the $ in some contexts.
   Touched: fp_pricing_rule_views (list + form), fp_treatment_views,
   fp_customer_price_list_views (already done), fp_quote_configurator_views
   (list + form shipping/delivery/calculated/override), fp_quote_request_views
   (list + form), fp_job_consumption_views, mrp_production_views job-costing
   group, direct-order wizard (already done earlier).

5. Unit / % suffix polish as we went: rush_surcharge_percent shows "%",
   default_duration_minutes shows "min" on treatment form, treatment list
   labels duration column.

Verified: all 4 pricing rules now render "$0.45", "$0.85" etc; 62 records
across 6 models all have currency_id populated; zero remaining Float $
fields in the codebase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 18:54:14 -04:00
gsinghpal
b85642816f feat(configurator): menu reorder, currency/unit display polish, line description templates
Three UX improvements:

1. Sales menu reordered — New Quote (seq 1) is now the first entry,
   followed by New Direct Order (5), Quotations (10), Sale Orders (20).
   "New Quote" moved out of Configurator submenu into Sales so both
   quote-creation paths live side-by-side.

2. Currency + unit display audit:
   - fp.customer.price.list.unit_price flipped from Float to Monetary
     with currency_field='currency_id' — list view now shows $ symbol
     and a Total sum row
   - fp.direct.order.wizard.unit_price flipped to Monetary, added
     currency_id field and computed line_subtotal ($)
   - % suffix appended to deposit_percent and progress_initial_percent
     in the wizard
   - Unit suffixes added where missing: bake_window.quantity (pcs),
     window_hours (h), bake_temp (°F), bake_duration_hours (h);
     bath.volume (L), bath.mto_count (turnovers); tank.volume shows
     volume_uom inline

3. Saved line descriptions (new feature):
   - New model fp.sale.description.template with name, description,
     tag (standard/masking/rework/aerospace/nuclear/packaging/other),
     optional coating_config_id and partner_id, usage_count bumped
     on each use
   - List + form + search views; new "Line Descriptions" menu under
     Configurator
   - 8 starter templates seeded (noupdate=1): ENP Standard/Aerospace/
     Nuclear, masking variants, rework, packaging, delicate handling
   - Direct Order Wizard gets a template picker (searchable Many2one)
     + editable paragraph; picking a template copies text to the
     editable field, user tweaks freely, tweaked text lands on the
     SO line as "<header>\n\n<description>"
   - Auto-suggests template on coating+partner match if nothing
     picked yet

Smoke-tested end-to-end: picked aerospace template, tweaked text,
confirmed wizard → SO S00030 has full description on line, usage
counter bumped from 0 to 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 18:43:58 -04:00
gsinghpal
b09538b4e2 changes 2026-04-17 17:31:12 -04:00
gsinghpal
e07002d550 feat(shopfloor): rich Tablet Station dashboard + full shop-floor demo data
Tablet Station rebuilt as a live dashboard (not just a QR scanner):

  * KPI strip — WOs Ready/Progress, Awaiting/Missed bakes,
    First-Piece pending, Quality Holds (each tinted by state)
  * Active WO banner with pulsing indicator when a WO is running
  * My Queue panel (left) — priority-badged operator next-up list,
    clickable rows that jump to the WO/bake/gate form
  * Baths tile grid (right) — last-log status chips, MTO count,
    hover jump to chemistry log
  * Bake Windows list — inline Start/End/Open actions, colour-coded
    by state (awaiting / in-progress / missed)
  * First-Piece Gates — Pass/Fail buttons for pending inspections
  * Quality Holds — Review jump when any open holds exist
  * Station picker + scan drawer (collapsed by default)
  * 30s auto-refresh, persists picked station in localStorage

New controller endpoints: /fp/shopfloor/tablet_overview,
/fp/shopfloor/pair_station, /fp/shopfloor/mark_gate.

Demo seeder (Phase 12.5) now populates:
  * 5 shop-floor stations (Plating, Bake, Inspection, Shipping, Receiving)
  * +3 bake windows (awaiting / in-progress / near-due)
  * 4 first-piece gates (1 pending, 1 passed+released, 1 passed-holding, 1 failed)
  * 2 quality holds on active MOs (one on_hold, one under_review)

All four Shop Floor menu pages (Plant Overview, Tablet Station, Bake
Windows, First-Piece Gates) now have meaningful content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 07:43:10 -04:00
gsinghpal
3b5b5cbf7c feat(reports): centralised Job Traveller / Shop Router
One PDF that follows a job through the shop — prints from either the
Sale Order or the Manufacturing Order. Matches existing design language
(fp_landscape_styles, .fp-header-primary banners, bordered tables,
.sig-line for sign-off, .highlight-box for callouts).

Sections per traveller:
  1. Title bar with REWORK / RUSH ORDER badges
  2. Job header — customer, PO #, part #, coating, recipe, facility,
     qty, dates, current parts location
  3. Receiving summary — received qty, state, damage flag
  4. Process Routing table — one row per WO with step #, operation,
     work centre, bath, tank, target thickness, dwell, expected
     duration, + sign-off columns (operator, date/time, initials,
     qty pass/reject)
  5. Bath chemistry targets snapshot per bath used
  6. Quality holds — red callout only when present
  7. Certificates issued + Delivery info (side-by-side)
  8. Rework reason block (only on rework MOs)
  9. Ruled notes / exceptions area
  10. Final supervisor + QA sign-off

Four ir.actions.report entries registered:
  - Job Traveller (Landscape) on mrp.production  [default print]
  - Job Traveller (Portrait)  on mrp.production
  - Job Traveller (Landscape) on sale.order      [iterates MOs]
  - Job Traveller (Portrait)  on sale.order

Regression-tested all 15 existing reports (SO, WO, MO margin, invoice,
BoL, CoC EN, receipt) — every one still renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:48:03 -04:00
gsinghpal
adc27c637a feat(bridge_mrp): SO smart buttons for full production lifecycle
Sale Order form now hubs the full flow — Manufacturing, Work Orders,
Portal Jobs, Quality Holds, Certificates, Deliveries — hidden when
count == 0. Clicking each jumps to the filtered list/form so users
can drill in without leaving the SO.

Counts are computed on the fly from: mrp.production.origin == SO.name,
production.workorder_ids, production.x_fc_portal_job_id, quality.hold
production_id, fp.certificate.sale_order_id, fp.delivery.job_ref.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:33:21 -04:00
gsinghpal
838b41cb89 fix(bridge_mrp): WO recipe generator + demo work-order backfill
bridge_mrp._generate_workorders_from_recipe was writing 'description'
on mrp.workorder, which doesn't exist in Odoo 19 — instead the step
instructions now post to each WO's chatter after bulk create, which
is where the operator sees them anyway.

Demo seeder now creates the full WO chain:
- 9 MRP work centres paired with 9 FP work centres (FP-QUEUE, -RACK,
  -MASK, -EN, -BAKE, -INSP, -DERACK, -DEMASK, -POSTBAKE) with
  costs_hour set so actuals-vs-quoted margin can compute.
- Wires the existing ENP-ALUM-BASIC recipe's 9 operation nodes to
  those FP work centres by matching names.
- Links every coating config to the recipe so the auto-assign hook
  (mrp.production.action_confirm → _auto_assign_recipe_from_so) has
  something to pull.
- Backfills work orders on all existing demo MOs: calls the generator
  once recipe is set. For historical (done) MOs, marks all their WOs
  done with backdated durations (25-90 min). For the Cyclone active
  MO, sets a realistic progression: first WO done, second in progress
  (priority: Hot), rest in 'ready'.

Verified: 90 WOs live, 10 per work centre. One MO shows the full
progression state mix. WO Traveller PDF renders (132KB) — both
portrait + landscape variants still work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:18:08 -04:00
gsinghpal
cb79186325 fix(coc): customer logo in 3rd column of customer block (not separate row)
Moved doc.partner_id.image_1920 from a standalone right-aligned div
below the accreditation table to a third column (20% width, centre-
aligned) of the customer-info table — sits inline with Customer
Address (40%) and Contact Name/Email/Phone (40%). The customer
block is now a single bordered 3-column row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:07:03 -04:00
gsinghpal
edd52f16a7 fix(coc): bump top padding to 50mm
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:04:52 -04:00
gsinghpal
22b06f47d9 fix(coc): bump top padding to 36mm to fully clear external_layout header
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:03:40 -04:00
gsinghpal
71bd0da5e1 fix(coc): add 18mm top padding so title clears external_layout header
Body was overlapping the company letterhead band — added padding-top
to .fp-coc so the title starts below it cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:02:09 -04:00
gsinghpal
44a980c468 refactor(coc): use web.external_layout for header/footer + 3-column bordered accreditations
Per feedback, dropped the custom company-contact header and paperformat
in favour of Odoo's standard web.external_layout. This gives the CoC:
  - Company-branded header (logo, name, address, phone, email, tax id)
    matching whichever layout variant the company picked in
    Settings → General → Document Layout (Standard / Boxed / Clean /
    Striped). Automatically themed with company.primary_color.
  - Consistent page X/Y footer + "Printed on" timestamp.
  - Correct header_spacing so the letterhead band lines up with the
    default paperformat.

Our body now owns:
  - Centred "Certificate of Conformance" / "Certificat de Conformité"
  - 3-column bordered accreditation table — one logo per cell (Nadcap,
    AS9100D/ISO 9001, CGP) with equal 33.33% widths and #000 borders,
    2.8cm cell height so logos centre vertically
  - Optional customer logo (res.partner.image_1920) right-aligned
    below the accreditation row
  - Customer info block (name, address, contact, email, phone)
  - Certification info table (date, generated-by, WO#)
  - Quantities table (part, process, PO, shipped, NC qty, job no)
  - Signature image + bordered cert statement
  - "Fusion Plating by Nexa Systems" brand note

Template plumbing:
- Explicit `<t t-set="company" t-value="doc.sale_order_id.company_id
  or doc.production_id.company_id or env.company"/>` in the EN/FR
  wrappers because QWeb's t-call scoping doesn't expose variables set
  inside external_layout to the body we pass through. Without this,
  coc_body's `company.x_fc_owner_user_id` raises KeyError.
- Removed paperformat_fp_coc from the report actions (now uses the
  default paperformat, which is designed for external_layout's
  reserved header_spacing).

Verified: 332KB PDF, 1 page, all 5 images embedded, Amphenol logo on
right side of accreditation row, signature renders, company header
band at top, page footer at bottom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:59:54 -04:00
gsinghpal
66f7f6c644 fix(coc): single-page layout — custom paperformat + strip Odoo wrappers
The CoC was rendering on 2 pages with ~35mm of dead whitespace at the
top. Three compounding causes:

1. Default Odoo paperformat reserves header_spacing=35mm (where the
   standard letterhead would sit when using web.external_layout). Our
   CoC has its own full-bleed header so that reservation was pure
   empty space.
   → New paperformat_fp_coc with header_spacing=0, 8mm all-around
     margins, attached to both report_coc_en and report_coc_fr actions.

2. The `<div class="article o_report_layout_boxed">` and nested
   `<div class="page">` wrappers inherited Odoo's CSS which applies
   `page-break-after: always` on `.page` and additional padding on
   `.article`.
   → Dropped both wrappers — template now renders body directly
     inside html_container.

3. Inline style block didn't override Odoo's body/main padding.
   → Aggressive !important reset at the top of the style block on
     html, body, main, .article, .page, and the hidden header/footer
     classes. Also shrunk all paddings by ~30% and bumped base font
     to 9pt to guarantee single-page fit.

Verified: PDF is now 1 page, content starts at the top (title flush
with top margin), accreditation logos + customer logo + signature all
render correctly within the single page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:50:35 -04:00
gsinghpal
96ecf7a9e1 feat(coc): professional CoC with accreditation badges + signature + company branding
Problem: the rebuilt CoC rendered mostly empty because accreditation logos
had to be uploaded manually via Settings first, and no signature existed —
looked unprofessional next to the Steelhead reference.

Fix:
- Seeder now auto-generates clean text-based accreditation badges with PIL
  (Nadcap blue, AS9100D/ISO 9001 blue, CGP red) sized to match the
  reference layout. Client can swap in real trademarked logos via Settings
  → Fusion Plating → Accreditation Logos at any time.
- Seeder creates a demo "Kris Pathinather" user, sets them as the
  certificate owner on res.company, and renders a scripted-looking
  signature image that matches the printed name on the cert.
- Seeder uploads a generated "Amphenol Canada Corp." badge to Amphenol's
  res.partner.image_1920 so that customer's CoCs include their logo
  on the top-right corner (mirrors how the reference shows it).
- coc_body template: guard hr.employee.signature access with a field-
  exists check (the field is provided by an optional module not
  installed on every Odoo).
- CoC uses web.html_container directly instead of wrapping in
  web.basic_layout — the outer wrapper was injecting top padding that
  pushed the title ~25% down the page. Now starts cleanly at the top.
- Tightened CoC CSS: removed unused label classes, added @page margin
  directive, fixed vertical-align on header cells so logos and company
  contact stay middle-aligned regardless of row height.
- Invoice PDF PAID stamp now also triggers on payment_state =
  'in_payment', so historical demo invoices look paid without needing
  full bank reconciliation.

Verified: renders a 152KB PDF with 5 embedded images, signer name
matches signature, all accreditation badges visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:42:35 -04:00
gsinghpal
fbaf318832 chore(fusion_plating): add story-driven demo seeder + polish invoice PAID stamp
Demo seeder (scripts/fp_demo_seed.py):
- Idempotent Python script run via odoo shell; populates ~60 records
  across 6 customer stories covering every workflow state for live demo
- Customers: Amphenol (net-terms, deep history), Magellan (progress
  billing, active), Cyclone (deposit, in-production), Honeywell
  (net-terms, just confirmed), Westin (COD, direct-order path),
  Delinquent Industries (account hold — Confirm raises UserError)
- Coating configs with realistic AMS specs (2404, 2700 Rev G, 2406)
  and bake-relief flags set on applicable processes
- Part catalog with revision chains (Rev 1 / Rev 2 / Rev 3 for hot parts)
- Customer price lists with volume tiers
- Per-customer invoice strategy defaults
- Bath chemistry logs (15 readings, last 2 OOS → pending replenishment
  suggestion visible in menu)
- Racks: 4 active + 1 needing strip (MTO 3.2 / 3.0) for kanban demo
- Bake windows: 1 awaiting (ticking down), 1 baked, 1 missed (alert)
- Quote configurator sessions: 3 draft, 3 confirmed/won, 3 lost (with
  reasons), 1 expired — populates the win/loss analysis
- Historical closed orders: 8 jobs backdated across 4 months with
  SO → MO → Delivery → Invoice → Payment run through each hook so
  portal-job progression, certificates with thickness readings, and
  invoice AR aging all look real
- Active orders at every workflow stage for the live demo cycle

Polish:
- report_fp_invoice PAID stamp now also triggers on payment_state ==
  'in_payment' (in addition to 'paid'). Odoo leaves payments in
  'in_payment' until the bank reconciliation job matches them against
  a statement line, so historical demo invoices would otherwise never
  show as stamped even though the payment is posted and the customer
  owes nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:30:53 -04:00
gsinghpal
a623c6684d fix(fusion_plating): bug review fixes + progress/net-terms invoicing + formal CoC rebuild
Bug review fixes (found by code review + live QWeb error):
- report_fp_sale.xml: product_uom → product_uom_id (Odoo 19 renamed;
  was raising KeyError during PDF render, blocking all sale-order prints)
- mrp_production.button_mark_done: add idempotency guard on delivery
  auto-create (was duplicating on every re-close)
- fp.certificate._compute_batch_ids: use empty recordset instead of
  False for Many2many computed fields
- fp_notification_template._collect_attachments: collapse attach_quotation
  + attach_sale_order into a single render so email doesn't double-attach
  the same PDF
- fp.operator.certification: SQL unique on computed state was unreliable;
  added explicit `revoked` boolean, made state pure-compute, replaced
  SQL constraint with @api.constrains that checks active-only uniqueness;
  has_active_cert now reads revoked + expires_date directly (no stale
  stored state between nightly recomputes)

Two missing invoice strategies implemented + 1 pre-existing deposit bug fix:
- Progress Billing: new x_fc_progress_initial_percent field on sale.order;
  _create_progress_initial_invoice bills the configured % on SO confirm
  via down-payment wizard, _create_final_balance_invoice bills the
  remainder on delivery
- Net Terms: no invoice on confirm; full invoice auto-created when
  fusion.plating.delivery.action_mark_delivered fires
- Fix for deposit (pre-existing, silent): sale.advance.payment.inv
  reads active_ids at wizard-create time, not on create_invoices();
  context was being set on the wrong call, so every deposit attempt
  raised "Expected singleton" and message-posted to chatter instead
  of actually invoicing
- New fusion_plating_invoicing/models/fp_delivery.py hooks
  action_mark_delivered to dispatch final invoice for progress/net_terms
- fp.direct.order.wizard + SO form surface the progress_initial_percent
  field (conditional on strategy)

Report styling cleanup:
- Hide DISCOUNT column from sale + invoice landscape reports unless at
  least one line has a non-zero discount; colspan auto-adjusts
- Replace hardcoded #0066a1 in all reports with company.primary_color
  driven by doc.company_id → company → user.company_id fallback chain,
  with #1d1f1e as ultimate fallback; new .fp-header-primary class
  exposes the colour for inline section headers (CARGO DESCRIPTION,
  PAYMENT DETAILS, OPERATOR SIGN-OFF, etc.) so they retint with the
  company theme without template edits

Certificate of Conformance — formal ENTECH-style rebuild:
- New res.company fields: x_fc_owner_user_id (default signer, sig from
  hr.employee.signature), x_fc_coc_signature_override (manual upload),
  x_fc_{nadcap,as9100,cgp}_logo + _active toggles for accreditation
  badges
- New res.config.settings section "Fusion Plating" exposing the above
  as configurable blocks; manager-only menu under Configuration →
  Fusion Plating Settings
- New fp.certificate fields: nc_quantity, customer_job_no,
  contact_partner_id (child contact for Name / Email / Phone block)
- New report_coc_en + report_coc_fr templates (primary): custom header
  (company contact | accreditations | company logo), bilingual labels
  per variant, customer info block with customer logo, 3-column cert
  info table, 6-column line-item table (Part # | Process | Customer
  PO | Shipped | NC Qty | Customer Job No.), signature image + bordered
  certification statement, footer "Fusion Plating by Nexa Systems"
- Legacy report_coc + report_coc_portrait kept for existing portal-job
  bindings (no behaviour change)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:18:22 -04:00
gsinghpal
6658544f85 feat(fusion_plating): Tier 2 (quality + audit) and Tier 3 (business) features
Tier 2 — Quality & audit readiness:

- T2.1 SPC on thickness readings (fp.certificate)
  - spec_min_mils / spec_max_mils auto-pulled from coating config on create
  - Computed: std_dev_mils, min/max, cpk, cpk_status (incapable/marginal/
    capable/excellent/insufficient)
  - Western Electric trend rules (rule 1: any point beyond 3σ; rule 4:
    8 consecutive on one side of mean) → trend_alert + explanation
  - New SPC group on certificate form with badge-coloured indicators

- T2.2 Operator certification enforcement (fp.operator.certification)
  - Per (employee, process_type) records with issued/expires dates,
    training record attachment, revocation workflow
  - State auto-computed: active → expired when date passes
  - MrpWorkorder.button_start() blocks with UserError if current user's
    linked hr.employee lacks an active cert for the bath's process_type
  - Managers bypass the check; expiring-soon filter in search view
  - HR Employee form: "Plating Certifications" tab

- T2.3 Material traceability chain
  - fusion.plating.batch.workorder_id (new Many2one) + production_id
    (related through WO) for full chain
  - fp.certificate gets computed batch_ids / bath_ids / batch_count
  - "Batches" stat button → list of batches used for this cert's MO,
    with their chemistry logs intact

- T2.4 Pre-treatment as first-class baths
  - process_family selection on fusion.plating.process.type
    (pre_treatment / plating / post_treatment / bake / strip / passivation /
    masking / inspection)
  - Bath search view: Pre-Treatments / Plating / Post-Treatments / Strip
    quick filters
  - Existing bath infra (logs, replenishment, SPC) now applies to pre-
    treatment baths equally

Tier 3 — Business / revenue:

- T3.1 Customer-specific price lists (fp.customer.price.list)
  - Per (customer, coating_config) with unit_price + basis (per_part /
    sqin / sqft / lb)
  - effective_from / effective_to for annual contract pricing
  - min_quantity for volume breaks (cheapest price at requested qty wins)
  - _find_price() helper resolves active entry by date + qty
  - Direct Order wizard auto-fills unit_price on (partner, coating, qty)
    change unless operator has typed an override
  - Configurator menu → Customer Price Lists

- T3.2 Quote win/loss tracking (fp.quote.configurator)
  - State values: draft → confirmed (won) / lost / expired / cancelled
  - lost_reason selection (price / lead_time / tech / spec_mismatch /
    no_bid / no_response / competitor / other) + lost_competitor_name
    + lost_details text
  - Action buttons: Mark as Lost (requires reason), Mark as Expired
  - won_date auto-set on SO creation; lost_date auto-set on mark_lost
  - New "Win / Loss" tab on configurator form

- T3.3 Actuals vs. quoted margin (mrp.production)
  - Computed monetary fields: x_fc_consumables_cost, x_fc_labour_cost,
    x_fc_actual_cost, x_fc_quoted_revenue, x_fc_margin_actual,
    x_fc_margin_pct
  - Labour = sum(WO duration × workcentre cost_hour)
  - Revenue = SO amount_untaxed via mo.origin lookup
  - New "Job Costing" group on MO form with badge-coloured margin

- T3.4 Job consumables tracking (fp.job.consumption)
  - One row per consumable event (bath replenisher, masking tape, PPE,
    chemistry): product, qty, uom, unit_cost (snapshot), total_cost,
    source, optional workorder link
  - One2many x_fc_consumption_ids on mrp.production
  - "Consumables" stat button on MO → filtered list

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:55:22 -04:00
gsinghpal
d3dd6376a6 feat(fusion_plating): quote-to-cash infra, notifications, wizards, Tier 1 plating features
Quote-to-cash PDF reports (portrait + landscape variants, 16 new actions):
- Quotation / Sales Order, Work Order Traveller, Packing Slip, Bill of Lading,
  Certificate of Conformance (portrait added), Invoice, Payment Receipt
- Shared fp_portrait_styles + fp_landscape_styles base templates

Workflow gap fixes (fusion_plating_bridge_mrp):
- Auto-assign recipe from SO coating config in MrpProduction.action_confirm
- Auto-create draft CoC (fp.certificate) on MrpProduction.button_mark_done

Notifications overhaul (fusion_plating_notifications v2.0):
- Expanded TRIGGER_EVENTS to 7 (added quote_sent, mo_complete, shipped, payment_received)
- Shared _dispatch method replaces three duplicated send helpers
- Auto-attach PDF reports per template config (quote, SO, CoC, invoice, receipt, BoL)
- Rebuilt 7 email templates with fusion_claims accent-bar design
  (info/success color-coded, theme-safe, 600px max-width)
- New hooks: MrpProduction done, FpDelivery mark_delivered, AccountPayment post,
  SaleOrder action_quotation_send

Wizards (fusion_plating_configurator):
- fp.direct.order.wizard — skip quotation for repeat customers with PO in hand;
  optional new-revision drawing upload bumps fp.part.catalog revision and links
  new rev to the SO; creates + confirms the SO in one step
- fp.part.catalog.import.wizard — 3-step CSV import with dry-run preview,
  tolerant parsing (customer by name/email/xmlid, human-readable selections),
  duplicate detection, create-missing-customers option, single transaction commit
- Partner form stat buttons: Direct Order, Import Parts
- CSV template download button

Tier 1 practical plating features:
- T1.1 Hydrogen bake window enforcement (fp.coating.config.requires_bake_relief,
  auto-create fusion.plating.bake.window on plating WO finish, FpDelivery lockout
  when window is open)
- T1.2 Bath replenishment rules + pending suggestion queue
  (fusion.plating.bath.replenishment.rule + .suggestion, hook on bath log line
  create, operator Apply / Dismiss actions)
- T1.3 Rack/fixture library (fusion.plating.rack with MTO counter, strip
  schedule, lifecycle: active → needs_strip → stripping → retired)
- T1.4 Rework / strip-and-replate MOs (x_fc_is_rework, x_fc_original_production_id,
  Create Rework stat button on completed MOs)
- T1.5 Parts location (x_fc_current_location computed on mrp.production —
  "In progress: Alkaline Clean" / "Queued: Bake Oven" / "Ready to Ship")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:41:12 -04:00
gsinghpal
7c7ef06057 folder rename 2026-04-16 20:53:53 -04:00
gsinghpal
3f3ddcbab4 changes 2026-04-14 10:17:55 -04:00
gsinghpal
e0e2c6cfda Merge branch 'main' of https://github.com/gsinghpal/Odoo-Modules 2026-04-14 08:05:58 -04:00
gsinghpal
b62d4b1f36 changes 2026-04-14 08:05:56 -04:00
gsinghpal
4f97a8b089 changes 2026-04-14 05:28:05 -04:00
gsinghpal
d3c8782505 changes 2026-04-13 09:45:28 -04:00
gsinghpal
0ff8c0b93f changes 2026-04-13 02:35:35 -04:00
gsinghpal
1176ba68ae fix(fusion_tasks): disable map view assets — JS imports break factory enterprise bundle 2026-04-12 22:07:00 -04:00
gsinghpal
d58f11384e fix(configurator): disable 3D viewer assets — causes JS fatal error in asset bundle 2026-04-12 22:03:16 -04:00
gsinghpal
510fd02e9d CLAUDE.md: comprehensive update — all 8 phases built, 29 models documented
- Added 7 new modules to structure (configurator, receiving, invoicing,
  certificates, notifications, fusion_tasks)
- Added 5 new critical rules (res.groups privilege_id, XML comments,
  XML action ordering, module install flag, implied group cascade)
- Updated naming conventions (fp.* prefix for new models, currency_id)
- All 3 workflow gaps marked DONE with implementation details
- Architectural decisions documented (recipe→WO, account hold, email,
  configurator, model naming, security groups)
- Key models table expanded from 13 to 29 models

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:10:41 -04:00
gsinghpal
3d0e3e276b fix(portal): remove double-hyphen in XML comment (invalid XML syntax) 2026-04-12 21:05:19 -04:00
gsinghpal
2af9d37f45 feat(portal): customer configurator wizard — upload, coating, estimate, submit
Three-step self-service quoting flow on the customer portal:
- Step 1: Upload part (STL/PDF) or enter manual measurements + material
- Step 2: Select coating config from card grid with specs and thickness
- Step 3: View estimated price range and submit quote request

Adds dependency on fusion_plating_configurator for fp.coating.config
and fp.pricing.rule models. Price estimation uses the same rule-matching
logic as the backend configurator with a +/-15-25% range.

Dashboard updated with prominent "Get a Quote" button and portal sidebar
entry. Breadcrumbs added for configurator pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:00:59 -04:00
gsinghpal
3db30339b5 feat(configurator): Three.js 3D viewer for STL files
Add OWL field widget (fp_3d_preview) that renders uploaded STL files
in an interactive 3D viewport:
- Three.js r170 ESM loaded lazily via dynamic import with importmap
- STLLoader + OrbitControls for full model interaction
- Fallback binary STL parser when addon import fails
- Toolbar with wireframe toggle and camera reset
- Vertex/face count display
- Theme-aware SCSS using CSS custom properties and $border-color
- Registered on model_attachment_id in the Part Catalog form

Vendored libs: three.module.min.js (691KB), STLLoader.js, OrbitControls.js

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:54:30 -04:00
gsinghpal
795c66c126 feat(configurator): server-side surface area calculation from STL
Add trimesh-based surface area calculation for uploaded STL files:
- New /fp/configurator/calculate_surface_area jsonrpc endpoint
- action_calculate_surface_area() method on fp.part.catalog
- "Calculate from 3D Model" button visible when a 3D model is attached
- Returns area in sq in (converted from mm2), vertex/face counts,
  and bounding box dimensions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:53:54 -04:00
gsinghpal
14d7781f4a fix(fusion_tasks): clean sync refs from views and JS map component
Remove x_fc_sync_source, x_fc_is_shadow, x_fc_sync_client_name,
x_fc_source_label references from form/list/kanban/calendar XML
views and the map view JS component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:32:26 -04:00
gsinghpal
f06e48e1a2 feat(fusion_tasks): delivery-specific fields + reduced task types
Add delivery integration fields (delivery_id, sale_order_id,
portal_job_id, packages_count, weight_total, requires_signature,
requires_photo, coc_attachment_id). Reduce task_type selection to
delivery/pickup/return/rush with matching duration defaults.
Add delivery cascade in action_complete_task() that calls
delivery_id.action_mark_delivered() on completion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:30:04 -04:00
gsinghpal
10607c48f0 refactor(fusion_tasks): update deps, config, crons for delivery context
- Change depends to [base, mail, hr, fusion_plating_logistics]
- Rename module to 'Fusion Plating -- Delivery Tasks'
- Replace all fusion_claims.* config params with fusion_tasks.*
- Remove sync crons (pull_remote_tasks, cleanup_old_shadows)
- Remove sync config ACL lines from ir.model.access.csv
- Replace sales_team group refs with hr/base equivalents
- Update license from OPL-1 to LGPL-3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:28:38 -04:00
gsinghpal
e10bf9d8fd refactor(fusion_tasks): strip all sync references from task model
Remove cross-instance sync fields (x_fc_sync_source, x_fc_sync_remote_id,
x_fc_sync_uuid, x_fc_is_shadow, x_fc_sync_client_name, x_fc_sync_client_phone,
x_fc_source_label), sync push calls in create/write/action methods, shadow
task logic in constraints and email guards, and x_fc_tech_sync_id from
res.users. Also remove task_sync import from models/__init__.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:26:23 -04:00
gsinghpal
bc72486808 feat(fusion_tasks): copy from Entech Plating, remove sync system
Forked fusion_tasks module into fusion-plating repo for EN Tech
delivery dispatch. Removed:
- models/task_sync.py (748 lines — cross-instance sync)
- views/task_sync_views.xml
- __pycache__ directories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:22:09 -04:00
gsinghpal
234a5b2b9f feat(notifications): workflow hooks + views + menu
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:11:54 -04:00
gsinghpal
ad6906254f feat(notifications): module scaffold + models + mail template seed data
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:09:07 -04:00
gsinghpal
ab99aaa5da feat(bridge_mrp): recipe-to-workorder generation on MO confirm (v19.0.2.1.0)
Add _generate_workorders_from_recipe() which walks the recipe tree,
creates one mrp.workorder per operation node, and formats child step
nodes as plain-text WO instructions. Respects opt-in/out overrides
from the per-job configuration wizard. Called automatically at the end
of action_confirm() after portal job creation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:01:31 -04:00
gsinghpal
24656cc02b fix(certificates): search view — remove group string attr, fix date filter syntax 2026-04-12 19:46:20 -04:00
gsinghpal
54540d5b1e feat(certificates): fp.certificate model + views + menu
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:44:00 -04:00
gsinghpal
ec8b26f8c8 feat(certificates): module scaffold + fp.thickness.reading model
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:42:33 -04:00
gsinghpal
7dea212c13 feat(invoicing): strategy auto-fill + hold check + deposit/COD automation + menu
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:36:27 -04:00
gsinghpal
10e3ada9e9 feat(invoicing): module scaffold + strategy defaults + account hold
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:33:44 -04:00
gsinghpal
d13517071c feat(receiving): SO auto-create + MRP soft gate + menu structure
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:24:54 -04:00
gsinghpal
6a368993bf feat(receiving): fp.receiving model with state machine and views
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:22:22 -04:00
gsinghpal
d06b9fd522 feat(receiving): module scaffold + fp.receiving.damage + fp.receiving.line models
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:18:57 -04:00
gsinghpal
269469aa4f fix(configurator): add user_ids to shop manager group so admin sees menus 2026-04-12 18:58:36 -04:00
gsinghpal
081612c903 fix(configurator): move action_fp_customers before menu reference 2026-04-12 18:53:13 -04:00
gsinghpal
86985bc023 fix(configurator): use privilege_id not category_id on res.groups (Odoo 19)
category_id is not valid on res.groups in Odoo 19.
Use privilege_id + sequence matching core module pattern.
Also remove user_ids (CLAUDE.md rule).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:52:03 -04:00
gsinghpal
aec7659a2e fix(configurator): address code review findings — pricing engine + views
- Fix thickness factor: now scales linearly (thickness * factor), not
  multiplicatively. Default factor=1.0 means price scales 1:1 with mils.
- Fix batch_size: setup fee now multiplied by ceil(qty/batch_size) batches
- Fix hardcoded $ in price breakdown HTML: uses currency_id.symbol
- Add coating_config_id.certification_level to @api.depends
- Remove readonly on x_fc_receiving_status (placeholder until receiving module)
- Add currency_id to treatment list view for Monetary widget

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:43:02 -04:00
gsinghpal
a337a510c1 feat(configurator): seed data — common pre/post treatments
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:37:03 -04:00
gsinghpal
a5761b9863 feat(configurator): menu restructure — Sales as default landing in Fusion Plating
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:36:47 -04:00
gsinghpal
d3e2614620 feat(configurator): fp.quote.configurator — pricing engine + SO creation
Add the core configurator model that collects part geometry, coating
config, and pricing inputs, calculates a price from matching pricing
rules (scored by specificity), and creates sale orders on confirmation.

- fp.quote.configurator model with mail.thread, sequence numbering
- Stored computed price with full breakdown HTML table
- Estimator override price support
- Auto-population from part catalog and coating config onchanges
- Surface area normalization (sq in/ft/cm/m)
- Specificity-scored rule matching (coating > substrate > cert level)
- action_create_quotation creates SO with FP-SERVICE product
- Form/list/search views with statusbar and chatter
- ACL: operator (read), estimator (read/write/create), manager (full)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:35:08 -04:00
gsinghpal
5143245f57 feat(configurator): sale.order plating extensions + custom list/form views
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:29:24 -04:00
gsinghpal
2fa7f2aa2e feat(configurator): fp.pricing.rule — formula-based pricing engine with complexity surcharges
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:28:43 -04:00
gsinghpal
2e80fd3ca1 feat(configurator): fp.coating.config — coating configuration templates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:25:48 -04:00
gsinghpal
87325e2caf feat(configurator): fp.part.catalog — customer part library with views
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:24:54 -04:00
gsinghpal
73b7325b46 feat(configurator): module scaffold + fp.treatment model with views and ACL
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:21:55 -04:00
gsinghpal
dde970a2f5 docs: Phase 1 implementation plan — configurator + sales integration
10 tasks covering: module scaffold, 7 models (treatment, part catalog,
coating config, pricing rule, complexity surcharge, configurator, SO
extensions), security groups, menu restructure, seed data, integration test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:14:30 -04:00
gsinghpal
d424dfdb19 docs: address spec review findings — 5 critical, 8 major issues fixed
- Add model naming convention table (fp.* for new, fusion.plating.* for existing)
- Add fusion_plating_certificates as dedicated module with fp.thickness.reading model
- Fix complexity_surcharge: companion model instead of JSON text field
- Add recipe_id domain constraint [('node_type', '=', 'recipe')]
- Align security groups with existing 4-level privilege hierarchy
- Add currency_id to all monetary models
- Clarify fp.quote.configurator as persistent model with state lifecycle
- Fix canonical model names (fusion.plating.portal.job, fusion.plating.delivery)
- Add auto-population rules for invoice strategy and configurator defaults
- Lighten bridge_mrp deps: gates as mixins in receiving/invoicing modules
- Add deployment strategy for fusion_tasks (same server, not standalone)
- Add data migration section for existing quote request coexistence
- Add work centre mapping note (fusion.plating.work.center ↔ mrp.workcenter)
- Change x_fc_account_hold_date to Datetime for audit precision
- Add bilingual CoC implementation note (QWeb, not ir.translation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:05:42 -04:00
gsinghpal
f69b3ac855 docs: EN Tech end-to-end workflow design spec
Complete design covering 5 new modules + updates to existing:
- fusion_plating_configurator (3D viewer, pricing engine, part catalog)
- fusion_plating_receiving (inspection, damage logging, PO matching)
- fusion_plating_invoicing (deposit/progress/net/COD, account holds)
- fusion_plating_notifications (auto-email, certificate assembly)
- fusion_tasks fork (local delivery dispatch, GPS tracking)
Plus: sales integration, certificate registry, 9-stage workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:57:56 -04:00
gsinghpal
2b84c31a12 CLAUDE.md: comprehensive workflow status — 12/15 stages built, 3 gaps remain
Full ASCII diagram of the end-to-end lifecycle with [DONE] / [NOT BUILT]
tags. Key models quick reference table. 3 remaining gaps: Recipe→WO
generation, account hold check, auto-email package. Architectural
decisions documented for next session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:05:08 -04:00
gsinghpal
8fa53017c4 CLAUDE.md: document per-job recipe overrides feature
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:39:21 -04:00
gsinghpal
4185b149bd fusion_plating_bridge_mrp: per-job recipe overrides with config wizard (v19.0.2.0.0)
Links recipes to manufacturing orders via x_fc_recipe_id on mrp.production.
New model fusion.plating.job.node.override stores per-job opt-in/out
decisions for optional recipe steps.

Config wizard (fp.recipe.config.wizard) shows all optional nodes as a
checklist — opt-in steps default unchecked, opt-out steps default checked.
Planner toggles and confirms. "Overrides" stat button on MO form opens wizard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:36:03 -04:00
gsinghpal
cb57585b5a CLAUDE.md: add end-to-end custom workflow spec (9-stage plating lifecycle)
Documents the full Quote→PO→SO→Recipe+WO→Invoice→Ship→Email workflow
for next sessions. Includes status table, architectural decisions needed,
and component mapping to Odoo models.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:30:22 -04:00
gsinghpal
6305faccb7 add CLAUDE.md for fusion-plating module context
Comprehensive instructions for future sessions: module structure,
critical Odoo 19 rules, recipe system architecture, deployment commands
for both servers, Steelhead feature status, and naming conventions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:13:08 -04:00
gsinghpal
330112f29e fusion_plating: change icon from Char to Selection dropdown (v19.0.2.0.5)
Icon field is now a selection with 24 curated plating icons. Users pick
from a dropdown with descriptive labels (e.g. "Fire / Bake", "Diamond /
Plating") instead of typing FA class codes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:10:21 -04:00
gsinghpal
e4b41828a3 fusion_plating: add opt_in_out field + time tracking display (v19.0.2.0.4)
New opt_in_out selection field (disabled/opt-in/opt-out) matching
Steelhead's Configure OPT IN/OUT feature. Shown in both the form
view and the tree editor side panel.

Time tracking: form view now shows Created, Created By, Last Updated,
Updated By fields. Tree editor side panel shows relative timestamps
down to the second (e.g. "46w 3d 4h 17m 21s ago by Brett Kinzett").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:05:59 -04:00
gsinghpal
3316b5d519 fusion_plating: icon picker grid + auto-icon from name (v19.0.2.0.3)
Replaced text input with a clickable 24-icon grid picker for the side
panel. Icons are curated for plating (flask, blast, mask, rinse, bake,
plate, inspect, etc.). When adding a new step, the icon is automatically
guessed from the name via keyword matching (e.g. "Masking" → paint-brush,
"Oven baking" → fire, "Acid Dip" → flask).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:01:46 -04:00
gsinghpal
edc7b11cb6 fusion_plating: add ENP-ALUM-BASIC recipe from client's Steelhead export (v19.0.2.0.2)
9 operations, 15 steps matching the client's Electroless Nickel Plating
Aluminium Basic recipe: Masking, Racking, Ready for processing,
ENP-Alum Line (E-Nickel Plating), De-Masking, Oven baking, De-racking,
Oven bake (Post de-rack), Post-plate Inspection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:55:09 -04:00
gsinghpal
b38310709a fusion_plating: fix search view for Odoo 19 + remove unaccent param (v19.0.2.0.1)
Odoo 19 search views: removed <group string="..."> wrapper (invalid),
removed string attr from <search>, removed filter_domain on name field.
Removed unaccent=False from parent_path (unknown param in this version).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:41:33 -04:00
gsinghpal
a7d224899a fusion_plating: add process recipe system with OWL tree editor (v19.0.2.0.0)
New model fusion.plating.process.node with _parent_store hierarchy for
defining reusable plating recipes. Node types: recipe, sub_process,
operation, step. Includes companion model for operator input definitions.

Full OWL tree editor (client action) with:
- Hierarchical tree with connector lines and type-coloured borders
- Click-to-edit side panel with save
- Add/delete child nodes inline
- Drag & drop reorder and reparent
- Theme-aware SCSS (light + dark mode)
- Demo data: Electroless Nickel Plating — Steel Line (30+ nodes)

Backend: 7 JSON-RPC endpoints for tree CRUD, reorder, move, duplicate.
Security: 3-tier ACL (operator read / supervisor write / manager full).
Menu: Process Recipes under Plating > Operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:29:58 -04:00
gsinghpal
e146daf4c8 fusion_plating_shopfloor: use SCSS $border-color for card borders like Odoo core (v19.0.1.1.3)
color-mix() in border shorthand was being dropped by the SCSS compiler.
Switched to the same pattern Odoo core kanban uses: split border into
border-width/border-style/border-color with $border-color SCSS variable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:02:07 -04:00
gsinghpal
1159864eb6 fusion_plating_shopfloor: opaque card border via color-mix against body-bg (v19.0.1.1.2)
Mixing body-color into transparent produced a nearly-invisible border at
1px. Now mixing 22% body-color into body-bg creates an opaque border
that is guaranteed visible in both themes (~#d0d0d0 light, ~#3a3d41 dark).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:59:20 -04:00
gsinghpal
59dfb3335a fusion_plating_shopfloor: stronger card border using color-mix for visibility (v19.0.1.1.1)
var(--bs-border-color) was nearly invisible against var(--bs-body-bg) in
both light and dark mode. Switched to color-mix(var(--bs-body-color) 20%)
which guarantees visible contrast regardless of theme. Hover darkens to 30%.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:53:54 -04:00
gsinghpal
ccfae66975 fusion_plating_shopfloor: card styling fix + drag & drop between work centres (v19.0.1.1.0)
Cards now have visible borders and elevation shadow in both light/dark
mode. Column count badge restored to high-contrast white-on-gray.

Added HTML5 drag & drop: users can drag work order cards between work
centre columns. Backend endpoint writes workcenter_id on mrp.workorder.
Drop target columns highlight with the action colour.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:49:47 -04:00
gsinghpal
a8eacc94bc fusion_plating_shopfloor: replace hardcoded colors with CSS vars for dark mode (v19.0.1.0.1)
Plant overview and process tree SCSS files had 90+ hardcoded hex colors
that broke dark mode. Replaced all with Bootstrap/Odoo CSS custom
properties (--bs-body-bg, --bs-body-color, --bs-border-color, etc.)
matching the pattern already used in fusion_plating_shopfloor.scss.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:27:53 -04:00
gsinghpal
b79e3d5c2e changes 2026-04-12 09:11:35 -04:00
gsinghpal
be611876ad changes 2026-04-12 09:09:50 -04:00
gsinghpal
d07159b9b5 fusion_authorizer_portal: wire accessibility assessments into MOD/ODSP/WSIB workflows (v19.0.2.8.0)
Audit found that fusion.accessibility.assessment._create_draft_sale_order
hardcoded x_fc_sale_type='direct_private' for ALL accessibility cases —
meaning MOD, ODSP, WSIB, and insurance projects never entered their
respective downstream workflows. The MOD workflow rework I shipped in
fusion_claims 19.0.8.0.3 was effectively unreachable from the portal.

Also: x_fc_authorizer_id never propagated from the assessment to the SO,
the new x_fc_mod_accessibility_specialist_id was orphaned, and there
was no back-reference from sale.order to the accessibility assessment.

Fixes:
- New required field x_fc_funding_source on fusion.accessibility.assessment
  (march_of_dimes / odsp / wsib / insurance / direct_private / other)
- _create_draft_sale_order now maps funding_source -> x_fc_sale_type,
  copies authorizer_id -> x_fc_authorizer_id, sets accessibility_assessment_id
  back-ref, and for MOD cases pre-populates
  x_fc_mod_accessibility_specialist_id from sales_rep_id.partner_id
- New accessibility_assessment_id field on sale.order so the back-link
  is queryable both directions (previously only assessment->SO existed)
- action_complete now guards against double-completion and missing
  funding_source: raises UserError instead of silently creating duplicates
- Expanded fusion.accessibility.assessment.state from 3 to 6 values
  (draft/scheduled/in_progress/pending_review/completed/cancelled),
  added copy=False, added _expand_states group_expand for kanban
- Booking form at /book-assessment now collects funding_source
  (required dropdown) so the path is known before the visit happens
- portal_assessment.py book_assessment_submit accepts funding_source
  with whitelist validation (defaults to direct_private if missing)

Deployed to odoo-westin (westin-v19) and odoo-mobility (mobility),
both verified at v19.0.2.8.0 with the new columns present.
2026-04-09 08:24:30 -04:00
gsinghpal
5d89e04f82 fusion_claims: shorten handoff_to_client label to 'Handed Off' (v19.0.8.0.5)
The long label 'Handed Off (Client/Authorizer Submitting)' was squeezing
the MOD statusbar on the sale order form — the parenthetical pushed half
the states off-screen behind a '...' overflow indicator. Context about
WHO is handling the submission is already captured in x_fc_mod_submitted_by
and visible in the MOD Documents tab, so the statusbar label does not need
to repeat it.

Deployed to odoo-westin and odoo-mobility, re-ran -u fusion_claims on
both so the ir_model_fields_selection.name row was resynced.
2026-04-09 08:09:36 -04:00
gsinghpal
b6d101c9a2 fusion_claims: fix handoff_to_client Selection ordering (v19.0.8.0.4)
In v19.0.8.0.3 the new handoff_to_client MOD state was inserted between
project_complete and pod_submitted in the Selection list, so it rendered
near the bottom of status dropdowns even though it happens very early in
the workflow. Also forgot to add it to _expand_mod_statuses, so it would
not appear as a kanban column without holding records.

Fixes:
- Move handoff_to_client in the Selection tuple to position 6 (right after
  quote_submitted, right before awaiting_funding) — parallel to
  quote_submitted as the alternative entry into awaiting_funding
- Add handoff_to_client to the main list in _expand_mod_statuses so it
  always appears as a kanban column
- Add handoff_to_client AND awaiting_funding to statusbar_visible on the
  sale_order form view (awaiting_funding was also missing before)

Deployed to odoo-westin (westin-v19) and odoo-mobility (mobility).
Re-ran -u fusion_claims on both so Odoo resynced the Selection
sequence column. Verified: both databases now show sequence 0..15 with
handoff_to_client at sequence 5, identical between servers.
2026-04-09 08:02:39 -04:00
gsinghpal
0fe8a71c05 fusion_claims: MOD workflow rework — two-assessment split, 3 submission paths, recovery actions (v19.0.8.0.3)
Reworks the March of Dimes workflow to match reality: the OT does their own
disability assessment and provides the VOD letter; our accessibility specialist
then visits to produce the proposal/drawings/quote; and the application can be
submitted by us (internal), the client, or the authorizer themselves. The old
workflow flattened all this into one assessment state with a dead-end
funding_denied and no document tracking.

Data model (13 new sale.order fields):
- 5 new document binaries + filenames: VOD letter, Application Form (filled),
  Notice of Assessment, Property Tax, Proposal Document
- x_fc_mod_submitted_by Selection (internal/client/authorizer)
- x_fc_mod_handoff_date, x_fc_mod_vod_requested_date
- x_fc_mod_accessibility_specialist_id (m2o res.partner — internal or external)
- x_fc_mod_previous_status_before_hold (for proper resume)
- x_fc_mod_funding_denial_reason (captured via wizard)

Settings (4 res.company fields + res_config_settings mirrors):
- x_fc_mod_application_form (blank) + filename
- x_fc_mod_vod_form (blank) + filename
- x_fc_mod_followup_assignee_mode (office_contact / sales_rep)
- x_fc_mod_followup_office_contact_id

res.partner: added 'accessibility_specialist' to x_fc_contact_type.

State machine:
- New state handoff_to_client between quote_submitted and awaiting_funding,
  used for paths B/C (client or authorizer submits themselves)
- Fixed action_mod_on_hold to save x_fc_mod_previous_status_before_hold
- Fixed action_mod_resume to restore previous status (was hardcoded to
  in_production, losing context for cases held earlier)

4 new wizards:
- mod_submission_path_wizard — chooses submitted_by, auto-fires VOD request
  email on first switch to 'internal'
- mod_funding_denied_wizard — captures denial category + reason
- mod_resubmit_wizard — revises + resubmits denied cases (with optional
  doc clearing)
- mod_submission_confirmed_wizard — records client/authorizer confirmed
  submission, advances to awaiting_funding

8 new action methods:
- action_mod_set_submission_path, action_mod_request_vod,
  action_mod_handoff_to_client (validates docs, fires handoff email),
  action_mod_confirmed_submission, action_mod_resubmit_from_denied,
  action_mod_cancel_from_denied, action_mod_reopen_cancelled
- action_mod_funding_denied now opens the denial wizard

3 new email methods + 2 existing fixes:
- _send_mod_vod_request_email — auto-attaches blank VOD form from company
  settings, sent to authorizer when we are handling submission
- _send_mod_handoff_email — two templates (client vs authorizer), attaches
  proposal + drawing + blank MOD Application Form
- _mod_company_attachment helper for building attachments from company Binary
- Fixed _send_mod_assessment_completed_email to include authorizer
- Fixed _send_mod_pod_submitted_email to include client

New cron:
- _cron_mod_handoff_followup (daily 09:00) — creates mail.activity for office
  to confirm MOD submission. Assignee via company setting (office contact or
  sales rep). Uses existing rolling-window cap (2/month per order).

Views:
- sale_order form: new status-bar buttons (set path, request VOD, handoff,
  confirm, resubmit, cancel, reopen), new document section in MOD Documents
  tab with submission-path tracking, denial details, hold history
- res_config_settings: new MOD blank forms upload + assignee config

Deployed to odoo-westin (westin-v19) and odoo-mobility (mobility). Pre-deploy
FK cleanup from earlier session means mobility updated cleanly without
workaround. HTTP 200 on both, cron verified active, all new fields present.
2026-04-09 07:34:17 -04:00
gsinghpal
8b2cbd9085 fusion_claims: ADP workflow recovery actions + email gap fixes
Workflow (from the FigJam ADP board):
- 9 new ADP action methods to wire up the orphan states that the board
  showed had no entry or no exit path: put_on_hold, withdraw, mark_denied,
  mark_rejected, mark_needs_correction, cancel, reopen_cancelled,
  reopen_expired, resubmit_from_denied.
- 12-month auto-expire cron (_cron_adp_expire_approved) configurable via
  fusion_claims.adp_approval_expiry_months, runs daily at 03:00.
- 3 new recovery buttons in the ADP form view (Reopen cancelled, Reopen
  expired, Resubmit from denied) in both the primary status bar and the
  secondary details panel.

Email (from the 2026-04 email audit):
- 6 new ADP stage email methods via a shared _adp_send_stage_email helper:
  assessment_scheduled, assessment_completed, application_received, accepted,
  cancelled, expired. Each has a matching dispatch entry in write().
- _send_rejection_email now includes the client (was authorizer-only).
- _send_accepted_email excludes the authorizer per the new rule: "Accepted"
  is a passive intermediate state with no authorizer action required.
- _send_ready_for_delivery_email excludes the authorizer: operational
  scheduling, not delivery confirmation. Authorizers are notified at
  case_closed when the product is actually delivered.
- action_adp_put_on_hold and action_adp_withdraw now fire their matching
  email methods so direct action-method calls get the same notifications
  as the status_change_reason_wizard path.

Authorizer notification rule (locked in for this update):
  Send to authorizer ONLY for initial involvement (assessment/submit/
  resubmit), delivery confirmation (case_closed), and problem states
  (rejected, denied, needs_correction, withdrawn, on_hold, cancelled,
  expired). Skip for billing, payment, ready_delivery scheduling, and
  passive intermediates (accepted).

Scope: ADP + ADP/ODSP only. MOD workflow emails reverted and deferred
to a separate update.

Deployed to odoo-westin (westin-v19) and odoo-mobility (mobility).
Pre-existing stock_route_warehouse FK orphans on mobility worked around
by verifying fusion_claims transaction committed before container restart.
2026-04-09 06:06:33 -04:00
gsinghpal
d60a75a391 fusion_claims: cap MOD follow-up email flood with rolling 30-day window
Two daily MOD crons were fighting each other. _cron_mod_schedule_followups
created a mail.activity on every MOD order in quote_submitted/awaiting_funding;
_cron_mod_escalate_followups unconditionally deleted the activity after
sending its one-time reminder email. The activity was recreated every day
in a tight loop with no per-period cap — a legitimate 2-4 month wait for
a MOD funding decision would generate dozens of activity churn events and
a bulk email burst the first time the escalate cron ran against a backlog.

Fix:
- New fields x_fc_mod_followup_month_count / _month_start / _cap_notified
  (copy=False) track a rolling window per order.
- New config params mod_followup_max_per_month (default 2),
  mod_followup_window_days (30), mod_followup_max_per_cron_run (10).
- _send_mod_followup_email resets the window after 30 days, refuses to
  send past the cap, and posts a one-shot chatter note explaining why.
- _cron_mod_schedule_followups no longer recreates the activity when the
  cap has been hit and stops daily-bumping x_fc_mod_next_followup_date.
- _cron_mod_escalate_followups processes oldest-deadline-first with a
  per-run throttle, only unlinks the activity on a successful send so
  humans can still action capped cases manually.
- write() resets the rolling counters on any real MOD status change.

Deployed to fusion_claims v19.0.8.0.1 on odoo-westin (westin-v19,
36 affected orders) and odoo-mobility (mobility, 2 affected orders).
2026-04-08 00:01:19 -04:00
gsinghpal
c30a61c93f changes 2026-04-07 22:03:20 -04:00
gsinghpal
f4c6dca171 Update woo_instance.py 2026-04-07 21:47:15 -04:00
gsinghpal
87a649b63d changes 2026-04-07 21:42:21 -04:00
gsinghpal
7d8f30627f changes 2026-04-07 21:42:12 -04:00
gsinghpal
4fde4c7bd1 changes 2026-04-07 20:49:21 -04:00
gsinghpal
3cc93b8783 changes 2026-04-04 15:37:16 -04:00
gsinghpal
c66bdf5089 changes 2026-04-03 15:45:18 -04:00
1014 changed files with 116884 additions and 4029 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -0,0 +1,79 @@
name: fusion_accounting CI
on:
push:
paths:
- 'fusion_accounting/**'
- 'fusion_accounting_core/**'
- 'fusion_accounting_ai/**'
- 'fusion_accounting_migration/**'
- '.gitea/workflows/fusion_accounting_ci.yml'
pull_request:
paths:
- 'fusion_accounting/**'
- 'fusion_accounting_core/**'
- 'fusion_accounting_ai/**'
- 'fusion_accounting_migration/**'
jobs:
test:
# NOTE: This workflow assumes a self-hosted runner (or Docker-in-Docker)
# that provides an Odoo 19 install. Adjust the `runs-on` and
# `Install Odoo 19` step to match Nexa's environment.
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
POSTGRES_DB: postgres
ports: ['5432:5432']
options: --health-cmd pg_isready --health-interval 10s
strategy:
fail-fast: false
matrix:
sub_module:
- fusion_accounting_core
- fusion_accounting_ai
- fusion_accounting_migration
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install AI client deps
run: |
pip install --break-system-packages anthropic openai
- name: Install Odoo 19
run: |
# TODO(Phase 1 CI hardening): align with Nexa's Odoo 19 source-of-truth.
# Option A: pull the same image used at odoo-westin (docker pull <registry>/odoo:19)
# Option B: odoo-bin pip install from the pinned Odoo 19 tag
# Option C: host a self-hosted runner on odoo-westin with Odoo pre-installed
echo "TODO: install Odoo 19 here"
exit 1 # fail loudly until this step is implemented
- name: Stage fusion sub-modules in addons-path
run: |
mkdir -p /tmp/addons
cp -r fusion_accounting fusion_accounting_core fusion_accounting_ai fusion_accounting_migration /tmp/addons/
- name: Install + Test ${{ matrix.sub_module }}
run: |
createdb -h localhost -U odoo fusion_test_${{ matrix.sub_module }}
odoo --addons-path=/tmp/addons \
-d fusion_test_${{ matrix.sub_module }} \
-i ${{ matrix.sub_module }} \
--test-tags post_install \
--stop-after-init \
--without-demo=all \
--log-handler=odoo.tests:INFO
env:
PGPASSWORD: odoo

View File

@@ -14,6 +14,60 @@
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
6. **res.groups**: NO `users` field, NO `category_id` field.
7. **Search views**: NO `group expand="0"` syntax.
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
## Card Styling — Copy Odoo's Kanban Pattern
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
```css
background-color: white;
border: 1px solid #d8dadd;
```
For custom OWL dashboards / client actions use the same approach:
- Define a `_tokens.scss` partial with explicit hex values wrapped in a CSS custom property:
```scss
$fp-card: var(--fp-card-bg, #ffffff);
$fp-border: var(--fp-border-color, #d8dadd);
```
- Reference those tokens everywhere (never `var(--bs-border-color)` directly)
- Three-layer contrast: **page** (grayest) → **container/column** (mid) → **card** (brightest). That's what makes cards pop.
- Reference implementation: `fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss`.
## Dark Mode — Branch on `$o-webclient-color-scheme` at SCSS Compile Time
Odoo 19 does NOT flip dark mode via a runtime DOM class. It compiles TWO asset bundles:
- `web.assets_backend` — compiled with `$o-webclient-color-scheme: bright`
- `web.assets_web_dark` — compiled with `$o-webclient-color-scheme: dark` (dark variant primary variables loaded first)
Your SCSS file is compiled into BOTH bundles. To make the dark bundle have different colors, **branch at compile time** using the SCSS variable Odoo sets:
```scss
$o-webclient-color-scheme: bright !default;
$_my-page-hex: #f3f4f6;
$_my-card-hex: #ffffff;
@if $o-webclient-color-scheme == dark {
$_my-page-hex: #1a1d21 !global;
$_my-card-hex: #22262d !global;
}
$my-page: var(--my-page-bg, $_my-page-hex);
$my-card: var(--my-card-bg, $_my-card-hex);
```
**Do NOT use** `.o_dark_mode` class selectors, `[data-bs-theme="dark"]`, or `@media (prefers-color-scheme: dark)` — none of those fire reliably in Odoo 19. The user toggles dark mode via the user profile, which sets a `color_scheme` cookie and reloads the page; Odoo then serves the dark bundle. Your SCSS `@if` handles the rest at compile time.
Verify by inspecting the attachments — you should see two files with different URLs for the two bundles:
```python
env['ir.qweb']._get_asset_bundle('web.assets_backend').css() # light
env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() # dark
```
## Asset Bundle Cache Busting
Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS changes but the hash doesn't update, the browser serves the old bundle. Fixes in order of escalation:
1. Bump the module `version` in `__manifest__.py`
2. `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` then restart odoo
3. Call `env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` in odoo-shell to force regeneration
4. Hard-refresh browser with cache clear (DevTools → right-click refresh → *Empty Cache and Hard Reload*); on mobile clear website data
## Naming
- New fields: `x_fc_*` prefix

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import models
def _fusion_tasks_post_init(env):
"""Post-install hook for fusion_tasks.
1. Sets default ICP values (upsert - safe if keys already exist).
2. Adds all active internal users to group_field_technician so
the Field Service menus are visible immediately after install.
"""
ICP = env['ir.config_parameter'].sudo()
defaults = {
'fusion_claims.google_maps_api_key': '',
'fusion_claims.store_open_hour': '9.0',
'fusion_claims.store_close_hour': '18.0',
'fusion_claims.push_enabled': 'False',
'fusion_claims.push_advance_minutes': '30',
'fusion_claims.sync_instance_id': '',
'fusion_claims.technician_start_address': '',
}
for key, default_value in defaults.items():
if not ICP.get_param(key):
ICP.set_param(key, default_value)
# Add all active internal users to Field Technician group
ft_group = env.ref('fusion_tasks.group_field_technician', raise_if_not_found=False)
if ft_group:
internal_users = env['res.users'].search([
('active', '=', True),
('share', '=', False),
])
ft_group.write({'user_ids': [(4, u.id) for u in internal_users]})

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Tasks',
'version': '19.0.1.0.0',
'category': 'Services/Field Service',
'summary': 'Technician scheduling, route planning, GPS tracking, and cross-instance sync.',
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'license': 'OPL-1',
'depends': [
'base',
'mail',
'calendar',
'sales_team',
],
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/ir_cron_data.xml',
'views/technician_task_views.xml',
'views/task_sync_views.xml',
'views/technician_location_views.xml',
'views/res_config_settings_views.xml',
],
'post_init_hook': '_fusion_tasks_post_init',
'assets': {
'web.assets_backend': [
'fusion_tasks/static/src/css/fusion_task_map_view.scss',
'fusion_tasks/static/src/js/fusion_task_map_view.js',
'fusion_tasks/static/src/xml/fusion_task_map_view.xml',
],
},
'installable': True,
'application': True,
}

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Default configuration parameters for Fusion Tasks.
noupdate="1" ensures these are ONLY set on first install.
forcecreate="false" prevents errors if keys already exist.
Keys use fusion_claims.* prefix to preserve existing data.
-->
<data noupdate="1">
<!-- Google Maps API Key -->
<record id="config_google_maps_api_key" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.google_maps_api_key</field>
<field name="value"></field>
</record>
<!-- Store Hours -->
<record id="config_store_open_hour" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.store_open_hour</field>
<field name="value">9.0</field>
</record>
<record id="config_store_close_hour" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.store_close_hour</field>
<field name="value">18.0</field>
</record>
<!-- Push Notifications -->
<record id="config_push_enabled" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.push_enabled</field>
<field name="value">False</field>
</record>
<record id="config_push_advance_minutes" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.push_advance_minutes</field>
<field name="value">30</field>
</record>
<!-- Cross-instance task sync -->
<record id="config_sync_instance_id" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.sync_instance_id</field>
<field name="value"></field>
</record>
<!-- Technician start address (HQ default) -->
<record id="config_technician_start_address" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.technician_start_address</field>
<field name="value"></field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<odoo>
<data>
<!-- Cron Job: Calculate Travel Times for Technician Tasks (every 15 min) -->
<record id="ir_cron_technician_travel_times" model="ir.cron">
<field name="name">Fusion Tasks: Calculate Technician Travel Times</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="state">code</field>
<field name="code">model._cron_calculate_travel_times()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Send Push Notifications for Upcoming Tasks -->
<record id="ir_cron_technician_push_notifications" model="ir.cron">
<field name="name">Fusion Tasks: Technician Push Notifications</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="state">code</field>
<field name="code">model._cron_send_push_notifications()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Pull Remote Technician Tasks (cross-instance sync) -->
<record id="ir_cron_task_sync_pull" model="ir.cron">
<field name="name">Fusion Tasks: Sync Remote Tasks (Pull)</field>
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_pull_remote_tasks()</field>
<field name="interval_number">2</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Cleanup Old Shadow Tasks (30+ days) -->
<record id="ir_cron_task_sync_cleanup" model="ir.cron">
<field name="name">Fusion Tasks: Cleanup Old Shadow Tasks</field>
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_cleanup_old_shadows()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
</record>
<!-- Cron Job: Check for Late Technician Arrivals -->
<record id="ir_cron_check_late_arrivals" model="ir.cron">
<field name="name">Fusion Tasks: Check Late Technician Arrivals</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="state">code</field>
<field name="code">model._cron_check_late_arrivals()</field>
<field name="interval_number">10</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Cleanup Old Technician Locations -->
<record id="ir_cron_cleanup_locations" model="ir.cron">
<field name="name">Fusion Tasks: Cleanup Old Locations</field>
<field name="model_id" ref="model_fusion_technician_location"/>
<field name="state">code</field>
<field name="code">model._cron_cleanup_old_locations()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=4, minute=0, second=0)"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import email_builder_mixin
from . import res_partner
from . import res_company
from . import res_users
from . import res_config_settings
from . import technician_task
from . import task_sync
from . import technician_location
from . import push_subscription

View File

@@ -0,0 +1,241 @@
# -*- coding: utf-8 -*-
# Fusion Claims - Professional Email Builder Mixin
# Provides consistent, dark/light mode safe email templates across all modules.
from odoo import models
class FusionEmailBuilderMixin(models.AbstractModel):
_name = 'fusion.email.builder.mixin'
_description = 'Fusion Email Builder Mixin'
# ------------------------------------------------------------------
# Color constants
# ------------------------------------------------------------------
_EMAIL_COLORS = {
'info': '#2B6CB0',
'success': '#38a169',
'attention': '#d69e2e',
'urgent': '#c53030',
}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def _email_build(
self,
title,
summary,
sections=None,
note=None,
note_color=None,
email_type='info',
attachments_note=None,
button_url=None,
button_text='View Case Details',
sender_name=None,
extra_html='',
):
"""Build a complete professional email HTML string.
Args:
title: Email heading (e.g. "Application Approved")
summary: One-sentence summary HTML (may contain <strong> tags)
sections: list of (heading, rows) where rows is list of (label, value)
e.g. [('Case Details', [('Client', 'John'), ('Case', 'S30073')])]
note: Optional note/next-steps text (plain or HTML)
note_color: Override left-border color for note (default uses email_type)
email_type: 'info' | 'success' | 'attention' | 'urgent'
attachments_note: Optional string listing attached files
button_url: Optional CTA button URL
button_text: CTA button label
sender_name: Name for sign-off (defaults to current user)
extra_html: Any additional HTML to insert before sign-off
"""
accent = self._EMAIL_COLORS.get(email_type, self._EMAIL_COLORS['info'])
company = self._get_company_info()
parts = []
# -- Wrapper open + accent bar (no forced bg/color so it adapts to dark/light)
parts.append(
f'<div style="font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Arial,sans-serif;'
f'max-width:600px;margin:0 auto;">'
f'<div style="height:4px;background-color:{accent};"></div>'
f'<div style="padding:32px 28px;">'
)
# -- Company name (accent color works in both themes)
parts.append(
f'<p style="color:{accent};font-size:13px;font-weight:600;letter-spacing:0.5px;'
f'text-transform:uppercase;margin:0 0 24px 0;">{company["name"]}</p>'
)
# -- Title (inherits text color from container)
parts.append(
f'<h2 style="font-size:22px;font-weight:700;'
f'margin:0 0 6px 0;line-height:1.3;">{title}</h2>'
)
# -- Summary (muted via opacity)
parts.append(
f'<p style="opacity:0.65;font-size:15px;line-height:1.5;'
f'margin:0 0 24px 0;">{summary}</p>'
)
# -- Sections (details tables)
if sections:
for heading, rows in sections:
parts.append(self._email_section(heading, rows))
# -- Note / Next Steps
if note:
nc = note_color or accent
parts.append(self._email_note(note, nc))
# -- Extra HTML
if extra_html:
parts.append(extra_html)
# -- Attachment note
if attachments_note:
parts.append(self._email_attachment_note(attachments_note))
# -- CTA Button
if button_url:
parts.append(self._email_button(button_url, button_text, accent))
# -- Sign-off
signer = sender_name or (self.env.user.name if self.env.user else '')
parts.append(
f'<p style="font-size:14px;line-height:1.6;margin:24px 0 0 0;">'
f'Best regards,<br/>'
f'<strong>{signer}</strong><br/>'
f'<span style="opacity:0.6;">{company["name"]}</span></p>'
)
# -- Close content card
parts.append('</div>')
# -- Footer
footer_parts = [company['name']]
if company['phone']:
footer_parts.append(company['phone'])
if company['email']:
footer_parts.append(company['email'])
footer_text = ' &middot; '.join(footer_parts)
parts.append(
f'<div style="padding:16px 28px;text-align:center;">'
f'<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">'
f'{footer_text}<br/>'
f'This is an automated notification from the ADP Claims Management System.</p>'
f'</div>'
)
# -- Close wrapper
parts.append('</div>')
return ''.join(parts)
# ------------------------------------------------------------------
# Building blocks
# ------------------------------------------------------------------
def _email_section(self, heading, rows):
"""Build a labeled details table section.
Args:
heading: Section title (e.g. "Case Details")
rows: list of (label, value) tuples. Value can be plain text or HTML.
"""
if not rows:
return ''
html = (
'<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">'
f'<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;'
f'opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;'
f'border-bottom:2px solid rgba(128,128,128,0.25);">{heading}</td></tr>'
)
for label, value in rows:
if value is None or value == '' or value is False:
continue
html += (
f'<tr>'
f'<td style="padding:10px 14px;opacity:0.6;font-size:14px;'
f'border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">{label}</td>'
f'<td style="padding:10px 14px;font-size:14px;'
f'border-bottom:1px solid rgba(128,128,128,0.15);">{value}</td>'
f'</tr>'
)
html += '</table>'
return html
def _email_note(self, text, color='#2B6CB0'):
"""Build a left-border accent note block."""
return (
f'<div style="border-left:3px solid {color};padding:12px 16px;'
f'margin:0 0 24px 0;">'
f'<p style="margin:0;font-size:14px;line-height:1.5;">{text}</p>'
f'</div>'
)
def _email_button(self, url, text='View Case Details', color='#2B6CB0'):
"""Build a centered CTA button."""
return (
f'<p style="text-align:center;margin:28px 0;">'
f'<a href="{url}" style="display:inline-block;background:{color};color:#ffffff;'
f'padding:12px 28px;text-decoration:none;border-radius:6px;'
f'font-size:14px;font-weight:600;">{text}</a></p>'
)
def _email_attachment_note(self, description):
"""Build a dashed-border attachment callout.
Args:
description: e.g. "ADP Application (PDF), XML Data File"
"""
return (
f'<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;'
f'margin:0 0 24px 0;">'
f'<p style="margin:0;font-size:13px;opacity:0.65;">'
f'<strong style="opacity:1;">Attached:</strong> {description}</p>'
f'</div>'
)
def _email_status_badge(self, label, color='#2B6CB0'):
"""Return an inline status badge/pill HTML snippet."""
bg_map = {
'#38a169': 'rgba(56,161,105,0.12)',
'#2B6CB0': 'rgba(43,108,176,0.12)',
'#d69e2e': 'rgba(214,158,46,0.12)',
'#c53030': 'rgba(197,48,48,0.12)',
}
bg = bg_map.get(color, 'rgba(43,108,176,0.12)')
return (
f'<span style="display:inline-block;background:{bg};color:{color};'
f'padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600;">'
f'{label}</span>'
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _get_company_info(self):
"""Return company name, phone, email for email templates."""
company = getattr(self, 'company_id', None) or self.env.company
return {
'name': company.name or 'Our Company',
'phone': company.phone or '',
'email': company.email or '',
}
def _email_is_enabled(self):
"""Check if email notifications are enabled in settings."""
ICP = self.env['ir.config_parameter'].sudo()
val = ICP.get_param('fusion_claims.enable_email_notifications', 'True')
return val.lower() in ('true', '1', 'yes')

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
Web Push Subscription model for storing browser push notification subscriptions.
"""
from odoo import models, fields, api
import logging
_logger = logging.getLogger(__name__)
class FusionPushSubscription(models.Model):
_name = 'fusion.push.subscription'
_description = 'Web Push Subscription'
_order = 'create_date desc'
user_id = fields.Many2one(
'res.users',
string='User',
required=True,
ondelete='cascade',
index=True,
)
endpoint = fields.Text(
string='Endpoint URL',
required=True,
)
p256dh_key = fields.Text(
string='P256DH Key',
required=True,
)
auth_key = fields.Text(
string='Auth Key',
required=True,
)
browser_info = fields.Char(
string='Browser Info',
help='User agent or browser identification',
)
active = fields.Boolean(
default=True,
)
_constraints = [
models.Constraint(
'unique(endpoint)',
'This push subscription endpoint already exists.',
),
]
@api.model
def register_subscription(self, user_id, endpoint, p256dh_key, auth_key, browser_info=None):
"""Register or update a push subscription."""
existing = self.sudo().search([('endpoint', '=', endpoint)], limit=1)
if existing:
existing.write({
'user_id': user_id,
'p256dh_key': p256dh_key,
'auth_key': auth_key,
'browser_info': browser_info or existing.browser_info,
'active': True,
})
return existing
return self.sudo().create({
'user_id': user_id,
'endpoint': endpoint,
'p256dh_key': p256dh_key,
'auth_key': auth_key,
'browser_info': browser_info,
})

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class ResCompany(models.Model):
_inherit = 'res.company'
x_fc_google_review_url = fields.Char(
string='Google Review URL',
help='Google Business Profile review link sent to clients after service completion',
)

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# Google Maps API Settings
fc_google_maps_api_key = fields.Char(
string='Google Maps API Key',
config_parameter='fusion_claims.google_maps_api_key',
help='API key for Google Maps Places autocomplete in address fields',
)
fc_google_review_url = fields.Char(
related='company_id.x_fc_google_review_url',
readonly=False,
string='Google Review URL',
)
# Technician Management
fc_store_open_hour = fields.Float(
string='Store Open Time',
config_parameter='fusion_claims.store_open_hour',
help='Store opening time for technician scheduling (e.g. 9.0 = 9:00 AM)',
)
fc_store_close_hour = fields.Float(
string='Store Close Time',
config_parameter='fusion_claims.store_close_hour',
help='Store closing time for technician scheduling (e.g. 18.0 = 6:00 PM)',
)
fc_google_distance_matrix_enabled = fields.Boolean(
string='Enable Distance Matrix',
config_parameter='fusion_claims.google_distance_matrix_enabled',
help='Enable Google Distance Matrix API for travel time calculations between technician tasks',
)
fc_technician_start_address = fields.Char(
string='Technician Start Address',
config_parameter='fusion_claims.technician_start_address',
help='Default start location for technician travel calculations (e.g. warehouse/office address)',
)
fc_location_retention_days = fields.Char(
string='Location History Retention (Days)',
config_parameter='fusion_claims.location_retention_days',
help='How many days to keep technician location history. '
'Leave empty = 30 days (1 month). '
'0 = delete at end of each day. '
'1+ = keep for that many days.',
)
# Web Push Notifications
fc_push_enabled = fields.Boolean(
string='Enable Push Notifications',
config_parameter='fusion_claims.push_enabled',
help='Enable web push notifications for technician tasks',
)
fc_vapid_public_key = fields.Char(
string='VAPID Public Key',
config_parameter='fusion_claims.vapid_public_key',
help='Public key for Web Push VAPID authentication (auto-generated)',
)
fc_vapid_private_key = fields.Char(
string='VAPID Private Key',
config_parameter='fusion_claims.vapid_private_key',
help='Private key for Web Push VAPID authentication (auto-generated)',
)
fc_push_advance_minutes = fields.Integer(
string='Notification Advance (min)',
config_parameter='fusion_claims.push_advance_minutes',
help='Send push notifications this many minutes before a scheduled task',
)

View File

@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import requests
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
_inherit = 'res.partner'
x_fc_start_address = fields.Char(
string='Start Location',
help='Technician daily start location (home, warehouse, etc.). '
'Used as origin for first travel time calculation. '
'If empty, the company default HQ address is used.',
)
x_fc_start_address_lat = fields.Float(
string='Start Latitude', digits=(10, 7),
)
x_fc_start_address_lng = fields.Float(
string='Start Longitude', digits=(10, 7),
)
def _geocode_start_address(self, address):
if not address or not address.strip():
return 0.0, 0.0
api_key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.google_maps_api_key', '')
if not api_key:
return 0.0, 0.0
try:
resp = requests.get(
'https://maps.googleapis.com/maps/api/geocode/json',
params={'address': address.strip(), 'key': api_key, 'region': 'ca'},
timeout=10,
)
data = resp.json()
if data.get('status') == 'OK' and data.get('results'):
loc = data['results'][0]['geometry']['location']
return loc['lat'], loc['lng']
except Exception as e:
_logger.warning("Start address geocoding failed for '%s': %s", address, e)
return 0.0, 0.0
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec, vals in zip(records, vals_list):
addr = vals.get('x_fc_start_address')
if addr:
lat, lng = rec._geocode_start_address(addr)
if lat and lng:
rec.write({
'x_fc_start_address_lat': lat,
'x_fc_start_address_lng': lng,
})
return records
def write(self, vals):
res = super().write(vals)
if 'x_fc_start_address' in vals:
addr = vals['x_fc_start_address']
if addr and addr.strip():
lat, lng = self._geocode_start_address(addr)
if lat and lng:
super().write({
'x_fc_start_address_lat': lat,
'x_fc_start_address_lng': lng,
})
else:
super().write({
'x_fc_start_address_lat': 0.0,
'x_fc_start_address_lng': 0.0,
})
return res

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class ResUsers(models.Model):
_inherit = 'res.users'
x_fc_is_field_staff = fields.Boolean(
string='Field Staff',
default=False,
help='Check this to show the user in the Technician/Field Staff dropdown when scheduling tasks.',
)
x_fc_start_address = fields.Char(
related='partner_id.x_fc_start_address',
readonly=False,
string='Start Location',
)
x_fc_tech_sync_id = fields.Char(
string='Tech Sync ID',
help='Shared identifier for this technician across Odoo instances. '
'Must be the same value on all instances for the same person.',
copy=False,
)

View File

@@ -0,0 +1,748 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
Cross-instance technician task sync.
Enables two Odoo instances (e.g. Westin and Mobility) that share the same
field technicians to see each other's delivery tasks, preventing double-booking.
Remote tasks appear as read-only "shadow" records in the local calendar.
The existing _find_next_available_slot() automatically sees shadow tasks,
so collision detection works without changes to the scheduling algorithm.
Technicians are matched across instances using the x_fc_tech_sync_id field
on res.users. Set the same value (e.g. "gordy") on both instances for the
same person -- no mapping table needed.
"""
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
import requests
from datetime import timedelta
_logger = logging.getLogger(__name__)
SYNC_TASK_FIELDS = [
'x_fc_sync_uuid', 'name', 'technician_id', 'additional_technician_ids',
'task_type', 'status',
'scheduled_date', 'time_start', 'time_end', 'duration_hours',
'address_street', 'address_street2', 'address_city', 'address_zip',
'address_state_id', 'address_buzz_code',
'address_lat', 'address_lng', 'priority', 'partner_id', 'partner_phone',
'pod_required', 'description',
'travel_time_minutes', 'travel_distance_km', 'travel_origin',
'completed_latitude', 'completed_longitude',
'action_latitude', 'action_longitude',
'completion_datetime',
]
TERMINAL_STATUSES = ('completed', 'cancelled')
class FusionTaskSyncConfig(models.Model):
_name = 'fusion.task.sync.config'
_description = 'Task Sync Remote Instance'
name = fields.Char('Instance Name', required=True,
help='e.g. Westin Healthcare, Mobility Specialties')
instance_id = fields.Char('Instance ID', required=True,
help='Short identifier, e.g. westin or mobility')
url = fields.Char('Odoo URL', required=True,
help='e.g. http://192.168.1.40:8069')
database = fields.Char('Database', required=True)
username = fields.Char('API Username', required=True)
api_key = fields.Char('API Key', required=True)
active = fields.Boolean(default=True)
last_sync = fields.Datetime('Last Successful Sync', readonly=True)
last_sync_error = fields.Text('Last Error', readonly=True)
# ------------------------------------------------------------------
# JSON-RPC helpers (uses /jsonrpc dispatch, muted on receiving side)
# ------------------------------------------------------------------
def _jsonrpc(self, service, method, args):
"""Execute a JSON-RPC call against the remote Odoo instance."""
self.ensure_one()
url = f"{self.url.rstrip('/')}/jsonrpc"
payload = {
'jsonrpc': '2.0',
'method': 'call',
'id': 1,
'params': {
'service': service,
'method': method,
'args': args,
},
}
try:
resp = requests.post(url, json=payload, timeout=15)
resp.raise_for_status()
result = resp.json()
if result.get('error'):
err = result['error'].get('data', {}).get('message', str(result['error']))
raise UserError(f"Remote error: {err}")
return result.get('result')
except requests.exceptions.ConnectionError:
_logger.warning("Task sync: cannot connect to %s", self.url)
return None
except requests.exceptions.Timeout:
_logger.warning("Task sync: timeout connecting to %s", self.url)
return None
def _authenticate(self):
"""Authenticate with the remote instance and return the uid."""
self.ensure_one()
uid = self._jsonrpc('common', 'authenticate',
[self.database, self.username, self.api_key, {}])
if not uid:
_logger.error("Task sync: authentication failed for %s", self.name)
return uid
def _rpc(self, model, method, args, kwargs=None):
"""Execute a method on the remote instance via execute_kw."""
self.ensure_one()
uid = self._authenticate()
if not uid:
return None
call_args = [self.database, uid, self.api_key, model, method, args]
if kwargs:
call_args.append(kwargs)
return self._jsonrpc('object', 'execute_kw', call_args)
# ------------------------------------------------------------------
# Tech sync ID helpers
# ------------------------------------------------------------------
def _get_local_tech_map(self):
"""Build {local_user_id: x_fc_tech_sync_id} for all local field staff."""
techs = self.env['res.users'].sudo().search([
('x_fc_is_field_staff', '=', True),
('x_fc_tech_sync_id', '!=', False),
('active', '=', True),
])
return {u.id: u.x_fc_tech_sync_id for u in techs}
def _get_remote_tech_map(self):
"""Build {x_fc_tech_sync_id: remote_user_id} from the remote instance."""
self.ensure_one()
remote_users = self._rpc('res.users', 'search_read', [
[('x_fc_is_field_staff', '=', True),
('x_fc_tech_sync_id', '!=', False),
('active', '=', True)],
], {'fields': ['id', 'x_fc_tech_sync_id']})
if not remote_users:
return {}
return {
ru['x_fc_tech_sync_id']: ru['id']
for ru in remote_users
if ru.get('x_fc_tech_sync_id')
}
def _get_local_syncid_to_uid(self):
"""Build {x_fc_tech_sync_id: local_user_id} for local field staff."""
techs = self.env['res.users'].sudo().search([
('x_fc_is_field_staff', '=', True),
('x_fc_tech_sync_id', '!=', False),
('active', '=', True),
])
return {u.x_fc_tech_sync_id: u.id for u in techs}
# ------------------------------------------------------------------
# Connection test
# ------------------------------------------------------------------
def action_test_connection(self):
"""Test the connection to the remote instance."""
self.ensure_one()
uid = self._authenticate()
if uid:
remote_map = self._get_remote_tech_map()
local_map = self._get_local_tech_map()
matched = set(local_map.values()) & set(remote_map.keys())
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Connection Successful',
'message': f'Connected to {self.name}. '
f'{len(matched)} technician(s) matched by sync ID.',
'type': 'success',
'sticky': False,
},
}
raise UserError(f"Cannot connect to {self.name}. Check URL, database, and API key.")
# ------------------------------------------------------------------
# PUSH: send local task changes to remote instance
# ------------------------------------------------------------------
def _get_local_instance_id(self):
"""Return this instance's own ID from config parameters."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.sync_instance_id', '')
@api.model
def _push_tasks(self, tasks, operation='create'):
"""Push local task changes to all active remote instances.
Called from technician_task create/write overrides.
Non-blocking: errors are logged, not raised.
"""
configs = self.sudo().search([('active', '=', True)])
if not configs:
return
local_id = configs[0]._get_local_instance_id()
if not local_id:
return
for config in configs:
try:
config._push_tasks_to_remote(tasks, operation, local_id)
except Exception:
_logger.exception("Task sync push to %s failed", config.name)
def _push_tasks_to_remote(self, tasks, operation, local_instance_id):
"""Push task data to a single remote instance.
Maps additional_technician_ids via sync IDs so the remote instance
also blocks those technicians' schedules.
"""
self.ensure_one()
local_map = self._get_local_tech_map()
remote_map = self._get_remote_tech_map()
if not local_map or not remote_map:
return
ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}
for task in tasks:
sync_id = local_map.get(task.technician_id.id)
if not sync_id:
continue
remote_tech_uid = remote_map.get(sync_id)
if not remote_tech_uid:
continue
# Map additional technicians to remote user IDs
remote_additional_ids = []
for tech in task.additional_technician_ids:
add_sync_id = local_map.get(tech.id)
if add_sync_id:
remote_add_uid = remote_map.get(add_sync_id)
if remote_add_uid:
remote_additional_ids.append(remote_add_uid)
task_data = {
'x_fc_sync_uuid': task.x_fc_sync_uuid,
'x_fc_sync_source': local_instance_id,
'x_fc_sync_remote_id': task.id,
'name': f"[{local_instance_id.upper()}] {task.name}",
'technician_id': remote_tech_uid,
'additional_technician_ids': [(6, 0, remote_additional_ids)],
'task_type': task.task_type,
'status': task.status,
'scheduled_date': str(task.scheduled_date) if task.scheduled_date else False,
'time_start': task.time_start,
'time_end': task.time_end,
'duration_hours': task.duration_hours,
'address_street': task.address_street or '',
'address_street2': task.address_street2 or '',
'address_city': task.address_city or '',
'address_zip': task.address_zip or '',
'address_lat': float(task.address_lat or 0),
'address_lng': float(task.address_lng or 0),
'priority': task.priority or 'normal',
'x_fc_sync_client_name': task.partner_id.name if task.partner_id else '',
'travel_time_minutes': task.travel_time_minutes or 0,
'travel_distance_km': float(task.travel_distance_km or 0),
'travel_origin': task.travel_origin or '',
'completed_latitude': float(task.completed_latitude or 0),
'completed_longitude': float(task.completed_longitude or 0),
'action_latitude': float(task.action_latitude or 0),
'action_longitude': float(task.action_longitude or 0),
}
if task.completion_datetime:
task_data['completion_datetime'] = str(task.completion_datetime)
existing = self._rpc(
'fusion.technician.task', 'search',
[[('x_fc_sync_uuid', '=', task.x_fc_sync_uuid)]],
{'limit': 1})
if operation in ('create', 'write'):
if existing:
self._rpc('fusion.technician.task', 'write',
[existing, task_data], ctx)
elif operation == 'create':
task_data['sale_order_id'] = False
self._rpc('fusion.technician.task', 'create',
[[task_data]], ctx)
elif operation == 'unlink' and existing:
self._rpc('fusion.technician.task', 'write',
[existing, {'status': 'cancelled', 'active': False}], ctx)
@api.model
def _push_shadow_status(self, shadow_tasks):
"""Push local status changes on shadow tasks back to their source instance.
When a tech changes a shadow task status locally, update the original
task on the remote instance and trigger the appropriate client emails
there. Only the parent (originating) instance sends client-facing
emails -- the child instance skips them via x_fc_sync_source guards.
"""
configs = self.sudo().search([('active', '=', True)])
config_by_instance = {c.instance_id: c for c in configs}
ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}
for task in shadow_tasks:
config = config_by_instance.get(task.x_fc_sync_source)
if not config or not task.x_fc_sync_remote_id:
continue
try:
update_vals = {'status': task.status}
if task.status == 'completed' and task.completion_datetime:
update_vals['completion_datetime'] = str(task.completion_datetime)
if task.completed_latitude and task.completed_longitude:
update_vals['completed_latitude'] = task.completed_latitude
update_vals['completed_longitude'] = task.completed_longitude
if task.action_latitude and task.action_longitude:
update_vals['action_latitude'] = task.action_latitude
update_vals['action_longitude'] = task.action_longitude
config._rpc(
'fusion.technician.task', 'write',
[[task.x_fc_sync_remote_id], update_vals], ctx)
_logger.info(
"Pushed status '%s' for shadow task %s back to %s (remote id %d)",
task.status, task.name, config.name, task.x_fc_sync_remote_id)
self._trigger_parent_notifications(config, task)
except Exception:
_logger.exception(
"Failed to push status for shadow task %s to %s",
task.name, config.name)
@api.model
def _push_technician_location(self, user_id, latitude, longitude, accuracy=0):
"""Push a technician's location update to all remote instances.
Called when a technician performs a task action (en_route, complete)
so the other instance immediately knows where the tech is, without
waiting for the next pull cron cycle.
"""
configs = self.sudo().search([('active', '=', True)])
if not configs:
return
local_map = configs[0]._get_local_tech_map()
sync_id = local_map.get(user_id)
if not sync_id:
return
for config in configs:
try:
remote_map = config._get_remote_tech_map()
remote_uid = remote_map.get(sync_id)
if not remote_uid:
continue
# Create location record on remote instance
config._rpc(
'fusion.technician.location', 'create',
[[{
'user_id': remote_uid,
'latitude': latitude,
'longitude': longitude,
'accuracy': accuracy,
'source': 'sync',
'sync_instance': configs[0]._get_local_instance_id(),
}]])
except Exception:
_logger.warning(
"Failed to push location for tech %s to %s",
user_id, config.name)
def _trigger_parent_notifications(self, config, task):
"""After pushing a shadow status, trigger appropriate emails and
notifications on the parent instance so the client gets notified
exactly once (from the originating instance only)."""
remote_id = task.x_fc_sync_remote_id
if task.status == 'completed':
for method in ('_notify_scheduler_on_completion',
'_send_task_completion_email'):
try:
config._rpc('fusion.technician.task', method, [[remote_id]])
except Exception:
_logger.warning(
"Could not call %s on remote for %s", method, task.name)
elif task.status == 'en_route':
try:
config._rpc(
'fusion.technician.task',
'_send_task_en_route_email', [[remote_id]])
except Exception:
_logger.warning(
"Could not trigger en-route email on remote for %s",
task.name)
elif task.status == 'cancelled':
try:
config._rpc(
'fusion.technician.task',
'_send_task_cancelled_email', [[remote_id]])
except Exception:
_logger.warning(
"Could not trigger cancel email on remote for %s",
task.name)
# ------------------------------------------------------------------
# PULL: cron-based full reconciliation
# ------------------------------------------------------------------
@api.model
def _cron_pull_remote_tasks(self):
"""Cron job: pull tasks and technician locations from all active remote instances."""
configs = self.sudo().search([('active', '=', True)])
for config in configs:
try:
config._pull_tasks_from_remote()
config._pull_technician_locations()
config.sudo().write({
'last_sync': fields.Datetime.now(),
'last_sync_error': False,
})
except Exception as e:
_logger.exception("Task sync pull from %s failed", config.name)
config.sudo().write({'last_sync_error': str(e)})
def _pull_tasks_from_remote(self):
"""Pull all active tasks for matched technicians from the remote instance.
After syncing, recalculates travel chains for all affected tech+date
combos so route planning accounts for both local and shadow tasks.
"""
self.ensure_one()
local_syncid_to_uid = self._get_local_syncid_to_uid()
if not local_syncid_to_uid:
return
remote_map = self._get_remote_tech_map()
if not remote_map:
return
matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
if not matched_sync_ids:
_logger.info("Task sync: no matched technicians between local and %s", self.name)
return
remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
remote_syncid_by_uid = {v: k for k, v in remote_map.items()}
cutoff = fields.Date.today() - timedelta(days=7)
remote_tasks = self._rpc(
'fusion.technician.task', 'search_read',
[[
'|',
('technician_id', 'in', remote_tech_ids),
('additional_technician_ids', 'in', remote_tech_ids),
('scheduled_date', '>=', str(cutoff)),
('x_fc_sync_source', '=', False),
]],
{'fields': SYNC_TASK_FIELDS + ['id']})
if remote_tasks is None:
return
Task = self.env['fusion.technician.task'].sudo().with_context(
skip_task_sync=True, skip_travel_recalc=True)
remote_uuids = set()
affected_combos = set()
for rt in remote_tasks:
sync_uuid = rt.get('x_fc_sync_uuid')
if not sync_uuid:
continue
remote_uuids.add(sync_uuid)
remote_tech_raw = rt['technician_id']
remote_uid = remote_tech_raw[0] if isinstance(remote_tech_raw, (list, tuple)) else remote_tech_raw
tech_sync_id = remote_syncid_by_uid.get(remote_uid)
local_uid = local_syncid_to_uid.get(tech_sync_id) if tech_sync_id else None
if not local_uid:
continue
partner_raw = rt.get('partner_id')
client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else ''
client_phone = rt.get('partner_phone', '') or ''
state_raw = rt.get('address_state_id')
state_name = ''
if isinstance(state_raw, (list, tuple)) and len(state_raw) > 1:
state_name = state_raw[1]
# Map additional technicians from remote to local
local_additional_ids = []
remote_add_raw = rt.get('additional_technician_ids', [])
if remote_add_raw and isinstance(remote_add_raw, list):
for add_uid in remote_add_raw:
add_sync_id = remote_syncid_by_uid.get(add_uid)
if add_sync_id:
local_add_uid = local_syncid_to_uid.get(add_sync_id)
if local_add_uid:
local_additional_ids.append(local_add_uid)
sched_date = rt.get('scheduled_date')
vals = {
'x_fc_sync_uuid': sync_uuid,
'x_fc_sync_source': self.instance_id,
'x_fc_sync_remote_id': rt['id'],
'name': f"[{self.instance_id.upper()}] {rt.get('name', '')}",
'technician_id': local_uid,
'additional_technician_ids': [(6, 0, local_additional_ids)],
'task_type': rt.get('task_type', 'delivery'),
'status': rt.get('status', 'scheduled'),
'scheduled_date': sched_date,
'time_start': rt.get('time_start', 9.0),
'time_end': rt.get('time_end', 10.0),
'duration_hours': rt.get('duration_hours', 1.0),
'address_street': rt.get('address_street', ''),
'address_street2': rt.get('address_street2', ''),
'address_city': rt.get('address_city', ''),
'address_zip': rt.get('address_zip', ''),
'address_buzz_code': rt.get('address_buzz_code', ''),
'address_lat': rt.get('address_lat', 0),
'address_lng': rt.get('address_lng', 0),
'priority': rt.get('priority', 'normal'),
'pod_required': rt.get('pod_required', False),
'description': rt.get('description', ''),
'x_fc_sync_client_name': client_name,
'x_fc_sync_client_phone': client_phone,
'travel_time_minutes': rt.get('travel_time_minutes', 0),
'travel_distance_km': rt.get('travel_distance_km', 0),
'travel_origin': rt.get('travel_origin', ''),
'completed_latitude': rt.get('completed_latitude', 0),
'completed_longitude': rt.get('completed_longitude', 0),
'action_latitude': rt.get('action_latitude', 0),
'action_longitude': rt.get('action_longitude', 0),
}
if rt.get('completion_datetime'):
vals['completion_datetime'] = rt['completion_datetime']
if state_name:
state_rec = self.env['res.country.state'].sudo().search(
[('name', '=', state_name)], limit=1)
if state_rec:
vals['address_state_id'] = state_rec.id
existing = Task.search([('x_fc_sync_uuid', '=', sync_uuid)], limit=1)
if existing:
if existing.status in TERMINAL_STATUSES:
vals.pop('status', None)
existing.write(vals)
else:
vals['sale_order_id'] = False
Task.create([vals])
if sched_date:
affected_combos.add((local_uid, sched_date))
for add_uid in local_additional_ids:
affected_combos.add((add_uid, sched_date))
stale_shadows = Task.search([
('x_fc_sync_source', '=', self.instance_id),
('x_fc_sync_uuid', 'not in', list(remote_uuids)),
('scheduled_date', '>=', str(cutoff)),
('active', '=', True),
])
if stale_shadows:
for st in stale_shadows:
if st.scheduled_date and st.technician_id:
affected_combos.add((st.technician_id.id, st.scheduled_date))
for tech in st.additional_technician_ids:
if st.scheduled_date:
affected_combos.add((tech.id, st.scheduled_date))
stale_shadows.write({'active': False, 'status': 'cancelled'})
_logger.info("Deactivated %d stale shadow tasks from %s",
len(stale_shadows), self.instance_id)
if affected_combos:
today = fields.Date.today()
today_str = str(today)
future_combos = set()
for tid, d in affected_combos:
if not d:
continue
d_str = str(d) if not isinstance(d, str) else d
if d_str >= today_str:
future_combos.add((tid, d_str))
if future_combos:
TaskModel = self.env['fusion.technician.task'].sudo()
try:
ungeocode = TaskModel.search([
('x_fc_sync_source', '=', self.instance_id),
('active', '=', True),
('scheduled_date', '>=', today_str),
('status', 'not in', ['cancelled']),
'|',
('address_lat', '=', 0), ('address_lat', '=', False),
])
geocoded = 0
for shadow in ungeocode:
if shadow.address_display:
if shadow.with_context(skip_travel_recalc=True)._geocode_address():
geocoded += 1
if geocoded:
_logger.info("Geocoded %d shadow tasks from %s",
geocoded, self.name)
except Exception:
_logger.exception(
"Shadow task geocoding after sync from %s failed", self.name)
try:
TaskModel._recalculate_combos_travel(future_combos)
_logger.info(
"Recalculated travel for %d tech+date combos after sync from %s",
len(future_combos), self.name)
except Exception:
_logger.exception(
"Travel recalculation after sync from %s failed", self.name)
# ------------------------------------------------------------------
# PULL: technician locations from remote instance
# ------------------------------------------------------------------
def _pull_technician_locations(self):
"""Pull latest GPS locations for matched technicians from the remote instance.
Creates local location records with source='sync' so the map view
shows technician positions from both instances. Only keeps the single
most recent synced location per technician (replaces older synced
records to avoid clutter).
"""
self.ensure_one()
local_syncid_to_uid = self._get_local_syncid_to_uid()
if not local_syncid_to_uid:
return
remote_map = self._get_remote_tech_map()
if not remote_map:
return
matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
if not matched_sync_ids:
return
remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
remote_syncid_by_uid = {v: k for k, v in remote_map.items()}
remote_locations = self._rpc(
'fusion.technician.location', 'search_read',
[[
('user_id', 'in', remote_tech_ids),
('logged_at', '>', str(fields.Datetime.subtract(
fields.Datetime.now(), hours=24))),
('source', '!=', 'sync'),
]],
{
'fields': ['user_id', 'latitude', 'longitude',
'accuracy', 'logged_at'],
'order': 'logged_at desc',
})
if not remote_locations:
return
Location = self.env['fusion.technician.location'].sudo()
seen_techs = set()
synced_count = 0
for rloc in remote_locations:
remote_uid_raw = rloc['user_id']
remote_uid = (remote_uid_raw[0]
if isinstance(remote_uid_raw, (list, tuple))
else remote_uid_raw)
if remote_uid in seen_techs:
continue
seen_techs.add(remote_uid)
sync_id = remote_syncid_by_uid.get(remote_uid)
local_uid = local_syncid_to_uid.get(sync_id) if sync_id else None
if not local_uid:
continue
lat = rloc.get('latitude', 0)
lng = rloc.get('longitude', 0)
if not lat or not lng:
continue
old_synced = Location.search([
('user_id', '=', local_uid),
('source', '=', 'sync'),
('sync_instance', '=', self.instance_id),
])
if old_synced:
old_synced.unlink()
Location.create({
'user_id': local_uid,
'latitude': lat,
'longitude': lng,
'accuracy': rloc.get('accuracy', 0),
'logged_at': rloc.get('logged_at', fields.Datetime.now()),
'source': 'sync',
'sync_instance': self.instance_id,
})
synced_count += 1
if synced_count:
_logger.info("Synced %d technician location(s) from %s",
synced_count, self.name)
# ------------------------------------------------------------------
# CLEANUP
# ------------------------------------------------------------------
@api.model
def _cron_cleanup_old_shadows(self):
"""Remove shadow tasks older than 30 days (completed/cancelled)."""
cutoff = fields.Date.today() - timedelta(days=30)
old_shadows = self.env['fusion.technician.task'].sudo().search([
('x_fc_sync_source', '!=', False),
('scheduled_date', '<', str(cutoff)),
('status', 'in', ['completed', 'cancelled']),
])
if old_shadows:
count = len(old_shadows)
old_shadows.unlink()
_logger.info("Cleaned up %d old shadow tasks", count)
# ------------------------------------------------------------------
# Manual trigger
# ------------------------------------------------------------------
def action_sync_now(self):
"""Manually trigger a full sync for this config."""
self.ensure_one()
self._pull_tasks_from_remote()
self._pull_technician_locations()
self.sudo().write({
'last_sync': fields.Datetime.now(),
'last_sync_error': False,
})
shadow_count = self.env['fusion.technician.task'].sudo().search_count([
('x_fc_sync_source', '=', self.instance_id),
])
loc_count = self.env['fusion.technician.location'].sudo().search_count([
('source', '=', 'sync'),
('sync_instance', '=', self.instance_id),
])
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Sync Complete',
'message': (f'Synced from {self.name}. '
f'{shadow_count} shadow task(s), '
f'{loc_count} technician location(s) visible.'),
'type': 'success',
'sticky': False,
},
}

View File

@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
Fusion Technician Location
GPS location logging for field technicians.
"""
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class FusionTechnicianLocation(models.Model):
_name = 'fusion.technician.location'
_description = 'Technician Location Log'
_order = 'logged_at desc'
user_id = fields.Many2one(
'res.users',
string='Technician',
required=True,
index=True,
ondelete='cascade',
)
latitude = fields.Float(
string='Latitude',
digits=(10, 7),
required=True,
)
longitude = fields.Float(
string='Longitude',
digits=(10, 7),
required=True,
)
accuracy = fields.Float(
string='Accuracy (m)',
help='GPS accuracy in meters',
)
logged_at = fields.Datetime(
string='Logged At',
default=fields.Datetime.now,
required=True,
index=True,
)
source = fields.Selection([
('portal', 'Portal'),
('app', 'Mobile App'),
('sync', 'Synced'),
], string='Source', default='portal')
sync_instance = fields.Char(
'Sync Instance', index=True,
help='Source instance ID if synced (e.g. westin, mobility)',
)
@api.model
def log_location(self, latitude, longitude, accuracy=None):
"""Log the current user's location. Called from portal JS."""
return self.sudo().create({
'user_id': self.env.user.id,
'latitude': latitude,
'longitude': longitude,
'accuracy': accuracy or 0,
'source': 'portal',
})
@api.model
def get_latest_locations(self):
"""Get the most recent location for each technician (for map view).
Includes both local GPS pings and synced locations from remote
instances, so the map shows all shared technicians regardless of
which Odoo instance they are clocked into.
"""
self.env.cr.execute("""
SELECT DISTINCT ON (user_id)
user_id, latitude, longitude, accuracy, logged_at,
COALESCE(sync_instance, '') AS sync_instance
FROM fusion_technician_location
WHERE logged_at > NOW() - INTERVAL '24 hours'
ORDER BY user_id, logged_at DESC
""")
rows = self.env.cr.dictfetchall()
local_id = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.sync_instance_id', '')
result = []
for row in rows:
user = self.env['res.users'].sudo().browse(row['user_id'])
src = row.get('sync_instance') or local_id
result.append({
'user_id': row['user_id'],
'name': user.name,
'latitude': row['latitude'],
'longitude': row['longitude'],
'accuracy': row['accuracy'],
'logged_at': str(row['logged_at']),
'sync_instance': src,
})
return result
@api.model
def _cron_cleanup_old_locations(self):
"""Remove location logs based on configurable retention setting.
Setting (fusion_claims.location_retention_days):
- Empty / not set => keep 30 days (default)
- "0" => delete at end of day (keep today only)
- "1" .. "N" => keep for N days
"""
ICP = self.env['ir.config_parameter'].sudo()
raw = (ICP.get_param('fusion_claims.location_retention_days') or '').strip()
if raw == '':
retention_days = 30 # default: 1 month
else:
try:
retention_days = max(int(raw), 0)
except (ValueError, TypeError):
retention_days = 30
cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=retention_days)
old_records = self.search([('logged_at', '<', cutoff)])
count = len(old_records)
if count:
old_records.unlink()
_logger.info(
"Cleaned up %d technician location records (retention=%d days)",
count, retention_days,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_technician_task_user,fusion.technician.task.user,model_fusion_technician_task,sales_team.group_sale_salesman,1,1,1,0
access_fusion_technician_task_manager,fusion.technician.task.manager,model_fusion_technician_task,sales_team.group_sale_manager,1,1,1,1
access_fusion_technician_task_technician,fusion.technician.task.technician,model_fusion_technician_task,fusion_tasks.group_field_technician,1,1,0,0
access_fusion_technician_task_portal,fusion.technician.task.portal,model_fusion_technician_task,base.group_portal,1,0,0,0
access_fusion_push_subscription_user,fusion.push.subscription.user,model_fusion_push_subscription,base.group_user,1,1,1,0
access_fusion_push_subscription_portal,fusion.push.subscription.portal,model_fusion_push_subscription,base.group_portal,1,1,1,0
access_fusion_technician_location_manager,fusion.technician.location.manager,model_fusion_technician_location,sales_team.group_sale_manager,1,1,1,1
access_fusion_technician_location_user,fusion.technician.location.user,model_fusion_technician_location,sales_team.group_sale_salesman,1,0,0,0
access_fusion_technician_location_portal,fusion.technician.location.portal,model_fusion_technician_location,base.group_portal,0,0,1,0
access_fusion_task_sync_config_manager,fusion.task.sync.config.manager,model_fusion_task_sync_config,sales_team.group_sale_manager,1,1,1,1
access_fusion_task_sync_config_user,fusion.task.sync.config.user,model_fusion_task_sync_config,sales_team.group_sale_salesman,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_technician_task_user fusion.technician.task.user model_fusion_technician_task sales_team.group_sale_salesman 1 1 1 0
3 access_fusion_technician_task_manager fusion.technician.task.manager model_fusion_technician_task sales_team.group_sale_manager 1 1 1 1
4 access_fusion_technician_task_technician fusion.technician.task.technician model_fusion_technician_task fusion_tasks.group_field_technician 1 1 0 0
5 access_fusion_technician_task_portal fusion.technician.task.portal model_fusion_technician_task base.group_portal 1 0 0 0
6 access_fusion_push_subscription_user fusion.push.subscription.user model_fusion_push_subscription base.group_user 1 1 1 0
7 access_fusion_push_subscription_portal fusion.push.subscription.portal model_fusion_push_subscription base.group_portal 1 1 1 0
8 access_fusion_technician_location_manager fusion.technician.location.manager model_fusion_technician_location sales_team.group_sale_manager 1 1 1 1
9 access_fusion_technician_location_user fusion.technician.location.user model_fusion_technician_location sales_team.group_sale_salesman 1 0 0 0
10 access_fusion_technician_location_portal fusion.technician.location.portal model_fusion_technician_location base.group_portal 0 0 1 0
11 access_fusion_task_sync_config_manager fusion.task.sync.config.manager model_fusion_task_sync_config sales_team.group_sale_manager 1 1 1 1
12 access_fusion_task_sync_config_user fusion.task.sync.config.user model_fusion_task_sync_config sales_team.group_sale_salesman 1 0 0 0

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- MODULE CATEGORY -->
<!-- ================================================================== -->
<record id="module_category_fusion_tasks" model="ir.module.category">
<field name="name">Fusion Tasks</field>
<field name="sequence">46</field>
</record>
<!-- ================================================================== -->
<!-- FUSION TASKS PRIVILEGE (Odoo 19 pattern) -->
<!-- ================================================================== -->
<record id="res_groups_privilege_fusion_tasks" model="res.groups.privilege">
<field name="name">Fusion Tasks</field>
<field name="sequence">46</field>
<field name="category_id" ref="module_category_fusion_tasks"/>
</record>
<!-- ================================================================== -->
<!-- FIELD TECHNICIAN GROUP -->
<!-- Standalone group safe for both portal and internal users. -->
<!-- Do NOT imply base.group_user — that chain conflicts with portal -->
<!-- users (share=True). -->
<!-- ================================================================== -->
<record id="group_field_technician" model="res.groups">
<field name="name">Field Technician</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_tasks"/>
</record>
<!-- ================================================================== -->
<!-- TECHNICIAN TASK RECORD RULES -->
<!-- ================================================================== -->
<!-- Managers: full access to all tasks -->
<record id="rule_technician_task_manager" model="ir.rule">
<field name="name">Technician Task: Manager Full Access</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Sales users: read/write all tasks, create tasks -->
<record id="rule_technician_task_sales_user" model="ir.rule">
<field name="name">Technician Task: Sales User Access</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Field Technicians (internal): own tasks only -->
<record id="rule_technician_task_technician" model="ir.rule">
<field name="name">Technician Task: Technician Own Tasks</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="domain_force">[('technician_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_field_technician'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Portal technicians: own tasks only, read + limited write -->
<record id="rule_technician_task_portal" model="ir.rule">
<field name="name">Technician Task: Portal Technician Access</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="domain_force">[('technician_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- ================================================================== -->
<!-- PUSH SUBSCRIPTION RECORD RULES -->
<!-- ================================================================== -->
<!-- Users: own subscriptions only -->
<record id="rule_push_subscription_user" model="ir.rule">
<field name="name">Push Subscription: Own Only</field>
<field name="model_id" ref="model_fusion_push_subscription"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Portal: own subscriptions only -->
<record id="rule_push_subscription_portal" model="ir.rule">
<field name="name">Push Subscription: Portal Own Only</field>
<field name="model_id" ref="model_fusion_push_subscription"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -0,0 +1,488 @@
// =====================================================================
// Fusion Task Map View - Sidebar + Google Maps
// Theme-aware: uses Odoo/Bootstrap variables for dark mode support
// =====================================================================
$sidebar-width: 340px;
$transition-speed: .25s;
.o_fusion_task_map_view {
height: 100%;
.o_content {
height: 100%;
display: flex;
flex-direction: column;
}
}
// ── Main wrapper: sidebar + map side by side ────────────────────────
.fc_map_wrapper {
display: flex;
flex-direction: row;
height: 100%;
min-height: 0;
overflow: hidden;
position: relative;
}
// ── Sidebar ─────────────────────────────────────────────────────────
.fc_sidebar {
width: $sidebar-width;
min-width: $sidebar-width;
max-width: $sidebar-width;
background: var(--o-view-background-color, $o-view-background-color);
border-right: 1px solid $border-color;
display: flex;
flex-direction: column;
transition: width $transition-speed ease, min-width $transition-speed ease,
max-width $transition-speed ease, opacity $transition-speed ease;
overflow: hidden;
&--collapsed {
width: 0;
min-width: 0;
max-width: 0;
opacity: 0;
border-right: none;
}
}
.fc_sidebar_header {
padding: 14px 16px 12px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
h6 {
font-size: 14px;
color: $headings-color;
}
}
.fc_sidebar_body {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
padding: 6px 0;
&::-webkit-scrollbar { width: 5px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: $border-color; border-radius: 4px; }
}
.fc_sidebar_footer {
padding: 10px 16px;
border-top: 1px solid $border-color;
flex-shrink: 0;
}
.fc_sidebar_empty {
text-align: center;
padding: 40px 20px;
color: $text-muted;
}
// ── Day filter chips ────────────────────────────────────────────────
.fc_day_filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.fc_day_chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
font-size: 11px;
font-weight: 600;
border: 1px solid $border-color;
border-radius: 12px;
background: transparent;
color: $text-muted;
cursor: pointer;
transition: all .15s;
line-height: 18px;
&:hover {
border-color: rgba($primary, .3);
color: $body-color;
}
&--active {
color: #fff !important;
border-color: transparent !important;
}
&--all {
color: $body-color;
font-weight: 500;
&:hover { background: rgba($primary, .1); }
}
}
.fc_day_chip_count {
font-size: 10px;
opacity: .8;
}
.fc_group_hidden_tag {
font-size: 9px;
text-transform: uppercase;
letter-spacing: .5px;
color: $text-muted;
background: rgba($secondary, .1);
padding: 0 5px;
border-radius: 3px;
margin-left: 4px;
font-weight: 500;
}
// ── Technician filter chips ─────────────────────────────────────────
.fc_tech_filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.fc_tech_chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px 3px 4px;
font-size: 11px;
font-weight: 600;
border: 1px solid $border-color;
border-radius: 14px;
background: transparent;
color: $text-muted;
cursor: pointer;
transition: all .15s;
line-height: 18px;
max-width: 100%;
overflow: hidden;
&:hover {
border-color: rgba($primary, .35);
color: $body-color;
background: rgba($primary, .06);
}
&--active {
background: $primary !important;
color: #fff !important;
border-color: $primary !important;
.fc_tech_chip_avatar {
background: rgba(#fff, .25);
color: #fff;
}
}
&--all {
padding: 3px 10px;
color: $body-color;
font-weight: 500;
&:hover { background: rgba($primary, .1); }
}
}
.fc_tech_chip_avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba($secondary, .15);
color: $body-color;
font-size: 9px;
font-weight: 700;
flex-shrink: 0;
}
.fc_tech_chip_name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Collapsed toggle button (floating)
.fc_sidebar_toggle_btn {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
z-index: 15;
background: var(--o-view-background-color, $o-view-background-color);
border: 1px solid $border-color;
border-left: none;
border-radius: 0 8px 8px 0;
padding: 12px 6px;
cursor: pointer;
box-shadow: 2px 0 6px rgba(0,0,0,.08);
color: $text-muted;
transition: background .15s;
&:hover {
background: $o-gray-100;
color: $body-color;
}
}
// ── Group headers ───────────────────────────────────────────────────
.fc_group_header {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
user-select: none;
font-weight: 600;
font-size: 12px;
color: $text-muted;
text-transform: uppercase;
letter-spacing: .5px;
background: rgba($secondary, .08);
border-bottom: 1px solid $border-color;
transition: background .15s;
&:hover {
background: rgba($secondary, .15);
}
.fa-caret-right,
.fa-caret-down {
width: 14px;
text-align: center;
font-size: 13px;
}
}
.fc_group_label {
flex: 1;
}
.fc_group_badge {
background: rgba($secondary, .2);
color: $body-color;
font-size: 10px;
font-weight: 700;
padding: 1px 7px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
// ── Task cards ──────────────────────────────────────────────────────
.fc_group_tasks {
padding: 4px 0;
}
.fc_task_card {
margin: 3px 10px;
padding: 10px 12px;
background: var(--o-view-background-color, $o-view-background-color);
border: 1px solid $border-color;
border-radius: 8px;
cursor: pointer;
transition: all .15s;
position: relative;
&:hover {
background: rgba($primary, .05);
border-color: rgba($primary, .2);
box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
&--active {
background: rgba($primary, .1) !important;
border-color: rgba($primary, .35) !important;
box-shadow: 0 0 0 2px rgba($primary, .15);
}
}
.fc_task_card_top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.fc_task_num {
display: inline-block;
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 1px 8px;
border-radius: 4px;
line-height: 18px;
}
.fc_task_status {
font-size: 11px;
font-weight: 600;
}
.fc_task_client {
font-size: 13px;
font-weight: 600;
color: $headings-color;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fc_task_meta {
display: flex;
gap: 12px;
font-size: 11px;
color: $body-color;
margin-bottom: 3px;
.fa { opacity: .5; }
}
.fc_task_date {
font-size: 11px;
color: #6366f1;
font-weight: 600;
margin-bottom: 3px;
.fa { opacity: .5; }
}
.fc_task_detail {
font-size: 11px;
color: $body-color;
margin-bottom: 2px;
.fa { opacity: .5; }
}
.fc_task_address {
font-size: 10px;
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.fc_task_bottom_row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
flex-wrap: wrap;
}
.fc_task_travel {
display: inline-flex;
align-items: center;
font-size: 10px;
color: $body-color;
background: rgba($secondary, .1);
padding: 1px 8px;
border-radius: 4px;
.fa { opacity: .5; }
}
.fc_task_source {
display: inline-flex;
align-items: center;
font-size: 10px;
color: #fff;
font-weight: 600;
padding: 1px 8px;
border-radius: 4px;
.fa { opacity: .8; }
}
.fc_task_edit_btn {
display: inline-flex;
align-items: center;
font-size: 10px;
font-weight: 600;
color: var(--btn-primary-color, #fff);
background: var(--btn-primary-bg, #{$primary});
padding: 2px 10px;
border-radius: 4px;
cursor: pointer;
margin-left: auto;
transition: all .15s;
&:hover {
opacity: .85;
filter: brightness(1.15);
}
}
// ── Map area ────────────────────────────────────────────────────────
.fc_map_area {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
}
.fc_map_legend_bar {
flex: 0 0 auto;
font-size: 12px;
min-height: 40px;
}
.fc_map_container {
flex: 1 1 auto;
position: relative;
min-height: 400px;
}
// ── Google Maps InfoWindow override ──────────────────────────────────
.gm-style-iw-d {
overflow: auto !important;
}
.gm-style .gm-style-iw-c {
padding: 0 !important;
border-radius: 10px !important;
overflow: hidden !important;
box-shadow: 0 4px 20px rgba(0,0,0,.15) !important;
}
.gm-style .gm-style-iw-tc {
display: none !important;
}
.gm-style .gm-ui-hover-effect {
display: none !important;
}
// ── Responsive ──────────────────────────────────────────────────────
@media (max-width: 768px) {
.fc_map_wrapper {
flex-direction: column;
}
.fc_sidebar {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
max-height: 40vh;
border-right: none;
border-bottom: 1px solid $border-color;
&--collapsed {
max-height: 0;
opacity: 0;
}
}
.fc_sidebar_toggle_btn {
top: auto;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
border-radius: 8px;
border: 1px solid $border-color;
padding: 8px 16px;
}
.fc_map_area {
flex: 1;
min-height: 300px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,255 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_tasks.FusionTaskMapView">
<div class="o_fusion_task_map_view">
<Layout display="display">
<t t-set-slot="control-panel-additional-actions">
<CogMenu/>
</t>
<t t-set-slot="layout-buttons">
<t t-call="{{ props.buttonTemplate }}"/>
</t>
<t t-set-slot="layout-actions">
<SearchBar toggler="searchBarToggler"/>
</t>
<t t-set-slot="control-panel-navigation-additional">
<t t-component="searchBarToggler.component" t-props="searchBarToggler.props"/>
</t>
<div class="fc_map_wrapper">
<!-- ========== SIDEBAR ========== -->
<div t-att-class="'fc_sidebar' + (state.sidebarOpen ? '' : ' fc_sidebar--collapsed')">
<!-- Sidebar header -->
<div class="fc_sidebar_header">
<div class="d-flex align-items-center justify-content-between">
<h6 class="mb-0 fw-bold">
<i class="fa fa-list-ul me-2"/>Deliveries
<span class="badge text-bg-primary ms-1" t-esc="state.taskCount"/>
</h6>
<button class="btn btn-sm btn-link text-muted p-0" t-on-click="toggleSidebar"
title="Toggle sidebar">
<i t-att-class="'fa ' + (state.sidebarOpen ? 'fa-chevron-left' : 'fa-chevron-right')"/>
</button>
</div>
<!-- New task button -->
<button class="btn btn-primary btn-sm w-100 mt-2" t-on-click="createNewTask">
<i class="fa fa-plus me-1"/>New Delivery Task
</button>
<!-- Day filter chips -->
<div class="fc_day_filters mt-2">
<t t-foreach="state.groups" t-as="group" t-key="group.key + '_filter'">
<button t-att-class="'fc_day_chip' + (isGroupVisible(group.key) ? ' fc_day_chip--active' : '')"
t-att-style="isGroupVisible(group.key) ? 'background:' + group.dayColor + ';color:#fff;border-color:' + group.dayColor : ''"
t-on-click="() => this.toggleDayFilter(group.key)">
<t t-esc="group.label"/>
<span class="fc_day_chip_count" t-esc="group.count"/>
</button>
</t>
<button class="fc_day_chip fc_day_chip--all" t-on-click="showAllDays"
title="Show all">All</button>
</div>
<!-- Technician filter -->
<t t-if="state.allTechnicians.length > 1">
<div class="fc_tech_filters mt-2">
<t t-foreach="state.allTechnicians" t-as="tech" t-key="tech.id">
<button t-att-class="'fc_tech_chip' + (isTechVisible(tech.id) ? ' fc_tech_chip--active' : '')"
t-on-click="() => this.toggleTechFilter(tech.id)"
t-att-title="tech.name">
<span class="fc_tech_chip_avatar" t-esc="tech.initials"/>
<span class="fc_tech_chip_name" t-esc="tech.name"/>
</button>
</t>
<button class="fc_tech_chip fc_tech_chip--all" t-on-click="showAllTechs"
title="Show all technicians">All</button>
</div>
</t>
</div>
<!-- Sidebar body: grouped task list -->
<div class="fc_sidebar_body">
<t t-foreach="state.groups" t-as="group" t-key="group.key">
<!-- Group header (collapsible) with day color -->
<div class="fc_group_header" t-on-click="() => this.toggleGroup(group.key)">
<i t-att-class="'fa me-1 ' + (isGroupCollapsed(group.key) ? 'fa-caret-right' : 'fa-caret-down')"/>
<i class="fa fa-circle me-1" style="font-size:8px;"
t-att-style="'color:' + group.dayColor"/>
<span class="fc_group_label" t-esc="group.label"/>
<span t-if="!isGroupVisible(group.key)" class="fc_group_hidden_tag">hidden</span>
<span class="fc_group_badge" t-esc="group.count"/>
</div>
<!-- Group tasks -->
<div t-if="!isGroupCollapsed(group.key)" class="fc_group_tasks">
<t t-foreach="group.tasks" t-as="task" t-key="task.id">
<div t-att-class="'fc_task_card' + (state.activeTaskId === task.id ? ' fc_task_card--active' : '')"
t-on-click="() => this.focusTask(task.id)">
<!-- Card top row: number + status -->
<div class="fc_task_card_top">
<span class="fc_task_num" t-att-style="'background:' + task._dayColor">
<t t-esc="'#' + task._scheduleNum"/>
</span>
<span class="fc_task_status" t-att-style="'color:' + task._statusColor">
<i t-att-class="'fa ' + task._statusIcon" style="margin-right:3px;"/>
<t t-esc="task._statusLabel"/>
</span>
</div>
<!-- Client name -->
<div class="fc_task_client" t-esc="task._clientName"/>
<!-- Type + time -->
<div class="fc_task_meta">
<span><i class="fa fa-tag me-1"/><t t-esc="task._typeLbl"/></span>
<span><i class="fa fa-clock-o me-1"/><t t-esc="task._timeRange"/></span>
</div>
<!-- Date -->
<div class="fc_task_date">
<i class="fa fa-calendar me-1"/><t t-esc="task._dateLabel"/>
</div>
<!-- Technician + address -->
<div class="fc_task_detail">
<span><i class="fa fa-user me-1"/><t t-esc="task._techName"/></span>
</div>
<div t-if="task.address_display" class="fc_task_address">
<i class="fa fa-map-marker me-1"/>
<t t-esc="task.address_display"/>
</div>
<!-- Travel + source -->
<div class="fc_task_bottom_row">
<span t-if="task.travel_time_minutes" class="fc_task_travel">
<i class="fa fa-car me-1"/>
<t t-esc="task.travel_time_minutes"/> min travel
</span>
<span t-if="task._sourceLabel" class="fc_task_source"
t-att-style="'background:' + task._sourceColor">
<i class="fa fa-building-o me-1"/>
<t t-esc="task._sourceLabel"/>
</span>
<span class="fc_task_edit_btn"
t-on-click.stop="() => this.openTask(task.id)"
title="Edit task">
<i class="fa fa-pencil me-1"/>Edit
</span>
</div>
</div>
</t>
</div>
</t>
<!-- Empty state -->
<div t-if="state.groups.length === 0 and !state.loading" class="fc_sidebar_empty">
<i class="fa fa-inbox fa-2x text-muted d-block mb-2"/>
<span class="text-muted">No tasks found</span>
</div>
</div>
<!-- Sidebar footer: technician count -->
<div class="fc_sidebar_footer">
<div class="d-flex align-items-center gap-2">
<svg width="14" height="14" viewBox="0 0 48 48">
<rect x="2" y="2" width="44" height="44" rx="12" ry="12" fill="#1d4ed8" stroke="#fff" stroke-width="3"/>
<text x="24" y="30" text-anchor="middle" fill="#fff" font-size="17" font-family="Arial,sans-serif" font-weight="bold">T</text>
</svg>
<small class="text-muted">
<t t-esc="state.techCount"/> technician(s) online
</small>
</div>
</div>
</div>
<!-- Collapsed sidebar toggle -->
<button t-if="!state.sidebarOpen"
class="fc_sidebar_toggle_btn" t-on-click="toggleSidebar"
title="Open sidebar">
<i class="fa fa-chevron-right"/>
</button>
<!-- ========== MAP AREA ========== -->
<div class="fc_map_area">
<!-- Legend bar -->
<div class="fc_map_legend_bar d-flex align-items-center gap-3 px-3 py-2 border-bottom bg-view flex-wrap">
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showTasks ? 'btn-primary' : 'btn-outline-secondary'"
t-on-click="toggleTasks">
<i class="fa fa-map-marker"/>Tasks <t t-esc="state.taskCount"/>
</button>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showTechnicians ? 'btn-primary' : 'btn-outline-secondary'"
t-on-click="toggleTechnicians">
<i class="fa fa-user"/>Techs <t t-esc="state.techCount"/>
</button>
<span class="border-start mx-1" style="height:20px;"/>
<span class="text-muted fw-bold" style="font-size:11px;">Pins:</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#f59e0b;"/>Pending</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#ef4444;"/>Today</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#3b82f6;"/>Tomorrow</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#10b981;"/>This Week</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#a855f7;"/>Upcoming</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#9ca3af;"/>Yesterday</span>
<span class="flex-grow-1"/>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showRoute ? 'btn-info' : 'btn-outline-secondary'"
t-on-click="toggleRoute" title="Toggle route animation">
<i class="fa fa-road"/>Route
</button>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showTraffic ? 'btn-warning' : 'btn-outline-secondary'"
t-on-click="toggleTraffic" title="Toggle traffic layer">
<i class="fa fa-car"/>Traffic
</button>
<button class="btn btn-outline-secondary btn-sm" t-on-click="onRefresh" title="Refresh">
<i class="fa fa-refresh" t-att-class="{'fa-spin': state.loading}"/>
</button>
</div>
<!-- Map container -->
<div class="fc_map_container">
<div t-ref="mapContainer" style="position:absolute;top:0;left:0;right:0;bottom:0;"/>
<!-- Loading -->
<div t-if="state.loading"
class="position-absolute top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center"
style="z-index:10;background:rgba(255,255,255,.92);">
<div class="text-center">
<i class="fa fa-spinner fa-spin fa-3x text-primary mb-3 d-block"/>
<span class="text-muted">Loading Google Maps...</span>
</div>
</div>
<!-- Error -->
<div t-if="state.error"
class="position-absolute top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center"
style="z-index:10;background:rgba(255,255,255,.92);">
<div class="alert alert-danger m-4" role="alert">
<i class="fa fa-exclamation-triangle me-2"/><t t-esc="state.error"/>
</div>
</div>
<!-- Empty -->
<div t-if="!state.loading and !state.error and state.taskCount === 0 and state.techCount === 0"
class="position-absolute top-50 start-50 translate-middle text-center" style="z-index:5;">
<div class="bg-white rounded-3 shadow p-4">
<i class="fa fa-map-marker fa-3x text-muted mb-3 d-block"/>
<h5>No locations to show</h5>
<p class="text-muted mb-0">Try adjusting the filters or date range.</p>
</div>
</div>
</div>
</div>
</div>
</Layout>
</div>
</t>
<t t-name="fusion_tasks.FusionTaskMapView.Buttons"/>
</templates>

View File

@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Fusion Tasks Settings as a new app block -->
<record id="res_config_settings_view_form_fusion_tasks" model="ir.ui.view">
<field name="name">res.config.settings.view.form.fusion.tasks</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Tasks" string="Fusion Tasks" name="fusion_tasks"
groups="fusion_tasks.group_field_technician">
<h2>Technician Management</h2>
<div class="row mt-4 o_settings_container">
<!-- Google Maps API Key -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Google Maps API</span>
<div class="text-muted">
API key for Google Maps Places autocomplete in address fields and Distance Matrix travel calculations.
</div>
<div class="mt-2">
<field name="fc_google_maps_api_key" placeholder="Enter your Google Maps API Key" password="True"/>
</div>
<div class="alert alert-info mt-2" role="alert">
<i class="fa fa-info-circle"/> Enable the "Places API" and "Distance Matrix API" in your Google Cloud Console.
</div>
</div>
</div>
<!-- Google Business Review URL -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Google Business Review URL</span>
<div class="text-muted">
Link to your Google Business Profile review page.
Sent to clients after service completion (when "Request Google Review" is enabled on the task).
</div>
<div class="mt-2">
<field name="fc_google_review_url" placeholder="https://g.page/r/your-business/review"/>
</div>
</div>
</div>
<!-- Store Hours -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Store / Scheduling Hours</span>
<div class="text-muted">
Operating hours for technician task scheduling. Tasks can only be booked
within these hours. Calendar view is also restricted to this range.
</div>
<div class="mt-2 d-flex align-items-center gap-2">
<field name="fc_store_open_hour" widget="float_time" style="max-width: 100px;"/>
<span>to</span>
<field name="fc_store_close_hour" widget="float_time" style="max-width: 100px;"/>
</div>
</div>
</div>
<!-- Distance Matrix Toggle -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fc_google_distance_matrix_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="fc_google_distance_matrix_enabled"/>
<div class="text-muted">
Calculate travel time between technician tasks using Google Distance Matrix API.
Requires Google Maps API key above with Distance Matrix API enabled.
</div>
</div>
</div>
<!-- Start Address (Company Default / Fallback) -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Default HQ / Fallback Address</span>
<div class="text-muted">
Company default start location used when a technician has no personal
start address set. Each technician can set their own start location
in their user profile or from the portal.
</div>
<div class="mt-2">
<field name="fc_technician_start_address" placeholder="e.g. 123 Main St, Brampton, ON"/>
</div>
</div>
</div>
<!-- Location History Retention -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Location History Retention</span>
<div class="text-muted">
How many days to keep technician GPS location history before automatic cleanup.
</div>
<div class="mt-2 d-flex align-items-center gap-2">
<field name="fc_location_retention_days" placeholder="30" style="max-width: 80px;"/>
<span class="text-muted">days</span>
</div>
<div class="text-muted small mt-1">
Leave empty = 30 days. Enter 0 = delete at end of each day. 1+ = keep that many days.
</div>
</div>
</div>
</div>
<h2>Push Notifications</h2>
<div class="row mt-4 o_settings_container">
<!-- Push Enable -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fc_push_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="fc_push_enabled"/>
<div class="text-muted">
Send web push notifications to technicians about upcoming tasks.
Requires VAPID keys (auto-generated on first save if empty).
</div>
</div>
</div>
<!-- Advance Minutes -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Notification Advance Time</span>
<div class="text-muted">
Send push notification this many minutes before a scheduled task.
</div>
<div class="mt-2">
<field name="fc_push_advance_minutes"/> minutes
</div>
</div>
</div>
<!-- VAPID Public Key -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">VAPID Public Key</span>
<div class="mt-2">
<field name="fc_vapid_public_key" placeholder="Auto-generated"/>
</div>
</div>
</div>
<!-- VAPID Private Key -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">VAPID Private Key</span>
<div class="mt-2">
<field name="fc_vapid_private_key" password="True" placeholder="Auto-generated"/>
</div>
</div>
</div>
</div>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- SYNC CONFIG - FORM VIEW -->
<!-- ================================================================== -->
<record id="view_task_sync_config_form" model="ir.ui.view">
<field name="name">fusion.task.sync.config.form</field>
<field name="model">fusion.task.sync.config</field>
<field name="arch" type="xml">
<form string="Task Sync Configuration">
<header>
<button name="action_test_connection" type="object"
string="Test Connection" class="btn-secondary" icon="fa-plug"/>
<button name="action_sync_now" type="object"
string="Sync Now" class="btn-success" icon="fa-sync"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Westin Healthcare"/></h1>
</div>
<group>
<group string="Connection">
<field name="instance_id" placeholder="e.g. westin"/>
<field name="url" placeholder="http://192.168.1.40:8069"/>
<field name="database" placeholder="e.g. westin-v19"/>
<field name="username" placeholder="e.g. admin"/>
<field name="api_key" password="True"/>
<field name="active"/>
</group>
<group string="Status">
<field name="last_sync"/>
<field name="last_sync_error" readonly="1"/>
</group>
</group>
<div class="alert alert-info mt-3">
<i class="fa fa-info-circle"/>
Technicians are matched across instances by their
<strong>Tech Sync ID</strong> field (Settings &gt; Users).
Set the same ID (e.g. "gordy") on both instances for each shared technician.
</div>
</sheet>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- SYNC CONFIG - LIST VIEW -->
<!-- ================================================================== -->
<record id="view_task_sync_config_list" model="ir.ui.view">
<field name="name">fusion.task.sync.config.list</field>
<field name="model">fusion.task.sync.config</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="instance_id"/>
<field name="url"/>
<field name="database"/>
<field name="active"/>
<field name="last_sync"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- SYNC CONFIG - ACTION + MENU -->
<!-- ================================================================== -->
<record id="action_task_sync_config" model="ir.actions.act_window">
<field name="name">Task Sync Instances</field>
<field name="res_model">fusion.task.sync.config</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_task_sync_config"
name="Task Sync"
parent="menu_technician_config"
action="action_task_sync_config"
sequence="10"/>
</odoo>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- LIST VIEW -->
<!-- ================================================================== -->
<record id="view_technician_location_list" model="ir.ui.view">
<field name="name">fusion.technician.location.list</field>
<field name="model">fusion.technician.location</field>
<field name="arch" type="xml">
<list string="Technician Locations" create="0" edit="0"
default_order="logged_at desc">
<field name="user_id" widget="many2one_avatar_user"/>
<field name="logged_at" string="Time"/>
<field name="latitude" optional="hide"/>
<field name="longitude" optional="hide"/>
<field name="accuracy" string="Accuracy (m)" optional="hide"/>
<field name="source"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- FORM VIEW (read-only) -->
<!-- ================================================================== -->
<record id="view_technician_location_form" model="ir.ui.view">
<field name="name">fusion.technician.location.form</field>
<field name="model">fusion.technician.location</field>
<field name="arch" type="xml">
<form string="Location Log" create="0" edit="0">
<sheet>
<group>
<group>
<field name="user_id"/>
<field name="logged_at"/>
<field name="source"/>
</group>
<group>
<field name="latitude"/>
<field name="longitude"/>
<field name="accuracy"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- SEARCH VIEW -->
<!-- ================================================================== -->
<record id="view_technician_location_search" model="ir.ui.view">
<field name="name">fusion.technician.location.search</field>
<field name="model">fusion.technician.location</field>
<field name="arch" type="xml">
<search string="Search Location Logs">
<field name="user_id" string="Technician"/>
<separator/>
<filter string="Today" name="filter_today"
domain="[('logged_at', '>=', context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Last 7 Days" name="filter_7d"
domain="[('logged_at', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<filter string="Last 30 Days" name="filter_30d"
domain="[('logged_at', '>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
<separator/>
<filter string="Technician" name="group_user" context="{'group_by': 'user_id'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'logged_at:day'}"/>
<filter string="Source" name="group_source" context="{'group_by': 'source'}"/>
</search>
</field>
</record>
<!-- ================================================================== -->
<!-- ACTION -->
<!-- ================================================================== -->
<record id="action_technician_locations" model="ir.actions.act_window">
<field name="name">Location History</field>
<field name="res_model">fusion.technician.location</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_technician_location_search"/>
<field name="context">{
'search_default_filter_today': 1,
'search_default_group_user': 1,
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No location data logged yet.
</p>
<p>Technician locations are automatically logged when they use the portal.</p>
</field>
</record>
<!-- ================================================================== -->
<!-- MENU ITEMS (under Configuration) -->
<!-- ================================================================== -->
<menuitem id="menu_technician_locations"
name="Location History"
parent="menu_technician_config"
action="action_technician_locations"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,507 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- SEQUENCE -->
<!-- ================================================================== -->
<record id="seq_technician_task" model="ir.sequence">
<field name="name">Technician Task</field>
<field name="code">fusion.technician.task</field>
<field name="prefix">TASK-</field>
<field name="padding">5</field>
<field name="number_increment">1</field>
</record>
<!-- ================================================================== -->
<!-- RES.USERS FORM EXTENSION - Field Staff toggle -->
<!-- ================================================================== -->
<record id="view_users_form_field_staff" model="ir.ui.view">
<field name="name">res.users.form.field.staff</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='login']" position="after">
<field name="x_fc_is_field_staff"/>
<field name="x_fc_start_address"
invisible="not x_fc_is_field_staff"
placeholder="e.g. 123 Main St, Brampton, ON"/>
<field name="x_fc_tech_sync_id"
invisible="not x_fc_is_field_staff"
placeholder="e.g. gordy, manpreet"/>
</xpath>
</field>
</record>
<!-- ================================================================== -->
<!-- SEARCH VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_search" model="ir.ui.view">
<field name="name">fusion.technician.task.search</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<search string="Search Tasks">
<field name="technician_id" string="Technician"/>
<field name="partner_id" string="Client"/>
<field name="name" string="Task"/>
<separator/>
<!-- Quick Filters -->
<filter string="Today" name="filter_today"
domain="[('scheduled_date', '=', context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Tomorrow" name="filter_tomorrow"
domain="[('scheduled_date', '=', (context_today() + datetime.timedelta(days=1)).strftime('%Y-%m-%d'))]"/>
<filter string="This Week" name="filter_this_week"
domain="[('scheduled_date', '>=', (context_today() - datetime.timedelta(days=context_today().weekday())).strftime('%Y-%m-%d')),
('scheduled_date', '&lt;=', (context_today() + datetime.timedelta(days=6-context_today().weekday())).strftime('%Y-%m-%d'))]"/>
<separator/>
<filter string="Pending" name="filter_pending" domain="[('status', '=', 'pending')]"/>
<filter string="Scheduled" name="filter_scheduled" domain="[('status', '=', 'scheduled')]"/>
<filter string="En Route" name="filter_en_route" domain="[('status', '=', 'en_route')]"/>
<filter string="In Progress" name="filter_in_progress" domain="[('status', '=', 'in_progress')]"/>
<filter string="Completed" name="filter_completed" domain="[('status', '=', 'completed')]"/>
<filter string="Active" name="filter_active" domain="[('status', 'not in', ['cancelled', 'completed'])]"/>
<separator/>
<filter string="My Tasks" name="filter_my_tasks"
domain="['|', ('technician_id', '=', uid), ('additional_technician_ids', 'in', [uid])]"/>
<filter string="Deliveries" name="filter_deliveries" domain="[('task_type', '=', 'delivery')]"/>
<filter string="Repairs" name="filter_repairs" domain="[('task_type', '=', 'repair')]"/>
<filter string="POD Required" name="filter_pod" domain="[('pod_required', '=', True)]"/>
<separator/>
<filter string="Local Tasks" name="filter_local"
domain="[('x_fc_sync_source', '=', False)]"/>
<filter string="Synced Tasks" name="filter_synced"
domain="[('x_fc_sync_source', '!=', False)]"/>
<separator/>
<!-- Group By -->
<filter string="Technician" name="group_technician" context="{'group_by': 'technician_id'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'scheduled_date'}"/>
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
<filter string="Task Type" name="group_type" context="{'group_by': 'task_type'}"/>
<filter string="Client" name="group_client" context="{'group_by': 'partner_id'}"/>
</search>
</field>
</record>
<!-- ================================================================== -->
<!-- FORM VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_form" model="ir.ui.view">
<field name="name">fusion.technician.task.form</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<form string="Technician Task">
<field name="x_fc_is_shadow" invisible="1"/>
<field name="x_fc_sync_source" invisible="1"/>
<header>
<button name="action_start_en_route" type="object" string="En Route"
class="btn-primary" invisible="status != 'scheduled' or x_fc_is_shadow"/>
<button name="action_start_task" type="object" string="Start Task"
class="btn-primary" invisible="status not in ('scheduled', 'en_route') or x_fc_is_shadow"/>
<button name="action_complete_task" type="object" string="Complete"
class="btn-success" invisible="status not in ('in_progress', 'en_route') or x_fc_is_shadow"/>
<button name="action_reschedule" type="object" string="Reschedule"
class="btn-warning" invisible="status not in ('scheduled', 'en_route') or x_fc_is_shadow"/>
<button name="action_cancel_task" type="object" string="Cancel"
class="btn-danger" invisible="status in ('completed', 'cancelled') or x_fc_is_shadow"
confirm="Are you sure you want to cancel this task?"/>
<button name="action_reset_to_scheduled" type="object" string="Reset to Scheduled"
invisible="status not in ('cancelled', 'rescheduled') or x_fc_is_shadow"/>
<button string="Calculate Travel"
class="btn-secondary o_fc_calculate_travel" icon="fa-car"
invisible="x_fc_is_shadow"/>
<field name="status" widget="statusbar"
statusbar_visible="pending,scheduled,en_route,in_progress,completed"/>
</header>
<sheet>
<!-- Shadow task banner -->
<div class="alert alert-info text-center" role="alert"
invisible="not x_fc_is_shadow">
<strong><i class="fa fa-link"/> This task is synced from
<field name="x_fc_sync_source" readonly="1" nolabel="1" class="d-inline"/>
— view only.</strong>
</div>
<div class="oe_button_box" name="button_box">
</div>
<widget name="web_ribbon" title="Completed" bg_color="text-bg-success"
invisible="status != 'completed'"/>
<widget name="web_ribbon" title="Cancelled" bg_color="text-bg-danger"
invisible="status != 'cancelled'"/>
<widget name="web_ribbon" title="Synced" bg_color="text-bg-info"
invisible="not x_fc_is_shadow or status in ('completed', 'cancelled')"/>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Schedule Info Banner -->
<field name="schedule_info_html" nolabel="1" colspan="2"
invisible="not technician_id or not scheduled_date"/>
<!-- Previous Task / Travel Warning Banner -->
<field name="prev_task_summary_html" nolabel="1" colspan="2"
invisible="not technician_id or not scheduled_date"/>
<!-- Hidden fields for calendar sync and legacy -->
<field name="datetime_start" invisible="1"/>
<field name="datetime_end" invisible="1"/>
<field name="time_start_12h" invisible="1"/>
<field name="time_end_12h" invisible="1"/>
<group>
<group string="Assignment">
<field name="technician_id"
domain="[('x_fc_is_field_staff', '=', True)]"/>
<field name="additional_technician_ids"
widget="many2many_tags_avatar"
domain="[('x_fc_is_field_staff', '=', True), ('id', '!=', technician_id)]"
options="{'color_field': 'color'}"/>
<field name="task_type"/>
<field name="priority" widget="priority"/>
</group>
<group string="Schedule">
<field name="scheduled_date"/>
<field name="time_start" widget="float_time"
string="Start Time"/>
<field name="duration_hours" widget="float_time"
string="Duration"/>
<field name="time_end" widget="float_time"
string="End Time" readonly="1"
force_save="1"/>
</group>
</group>
<group>
<group string="Client">
<field name="partner_id"/>
<field name="partner_phone" widget="phone"/>
</group>
<group string="Location">
<field name="is_in_store"/>
<field name="address_partner_id" invisible="is_in_store"/>
<field name="address_street" readonly="is_in_store"/>
<field name="address_street2" string="Unit/Suite #" invisible="is_in_store"/>
<field name="address_buzz_code" invisible="is_in_store"/>
<field name="address_city" invisible="1"/>
<field name="address_state_id" invisible="1"/>
<field name="address_zip" invisible="1"/>
<field name="address_lat" invisible="1"/>
<field name="address_lng" invisible="1"/>
</group>
</group>
<group>
<group string="Travel (Auto-Calculated)">
<field name="travel_time_minutes" readonly="1"/>
<field name="travel_distance_km" readonly="1"/>
<field name="travel_origin" readonly="1"/>
<field name="previous_task_id" readonly="1"/>
</group>
<group string="Options">
<field name="pod_required"/>
<field name="x_fc_send_client_updates"/>
<field name="x_fc_ask_google_review"/>
<field name="active" invisible="1"/>
</group>
</group>
<notebook>
<page string="Description" name="description">
<group>
<field name="description" placeholder="What needs to be done..."/>
</group>
<group>
<field name="equipment_needed" placeholder="Tools, parts, materials..."/>
</group>
</page>
<page string="Completion" name="completion">
<group>
<field name="completion_datetime"/>
<field name="completion_notes"/>
</group>
<group>
<field name="voice_note_transcription"/>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- LIST VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_list" model="ir.ui.view">
<field name="name">fusion.technician.task.list</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<list string="Technician Tasks" decoration-success="status == 'completed'"
decoration-warning="status == 'in_progress'"
decoration-info="status == 'en_route'"
decoration-danger="status == 'cancelled'"
decoration-muted="status == 'rescheduled'"
default_order="scheduled_date, sequence, time_start">
<field name="name"/>
<field name="technician_id" widget="many2one_avatar_user"/>
<field name="additional_technician_ids" widget="many2many_tags_avatar"
optional="show" string="+ Techs"/>
<field name="task_type" decoration-bf="1"/>
<field name="scheduled_date"/>
<field name="time_start_display" string="Start"/>
<field name="time_end_display" string="End"/>
<field name="partner_id"/>
<field name="address_city"/>
<field name="travel_time_minutes" string="Travel (min)" optional="show"/>
<field name="status" widget="badge"
decoration-success="status == 'completed'"
decoration-warning="status == 'in_progress'"
decoration-info="status in ('scheduled', 'en_route')"
decoration-danger="status == 'cancelled'"/>
<field name="priority" widget="priority" optional="hide"/>
<field name="pod_required" optional="hide"/>
<field name="x_fc_source_label" string="Source" optional="show"
widget="badge" decoration-info="x_fc_is_shadow"
decoration-success="not x_fc_is_shadow"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- KANBAN VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_kanban" model="ir.ui.view">
<field name="name">fusion.technician.task.kanban</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<kanban default_group_by="status" class="o_kanban_small_column"
records_draggable="1" group_create="0">
<field name="color"/>
<field name="priority"/>
<field name="technician_id"/>
<field name="additional_technician_ids"/>
<field name="additional_tech_count"/>
<field name="partner_id"/>
<field name="task_type"/>
<field name="scheduled_date"/>
<field name="time_start_display"/>
<field name="address_city"/>
<field name="travel_time_minutes"/>
<field name="status"/>
<field name="x_fc_is_shadow"/>
<field name="x_fc_sync_client_name"/>
<templates>
<t t-name="card">
<div t-attf-class="oe_kanban_color_#{record.color.raw_value} oe_kanban_card oe_kanban_global_click">
<div class="oe_kanban_content">
<div class="o_kanban_record_top mb-1">
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title">
<field name="name"/>
</strong>
</div>
<field name="priority" widget="priority"/>
</div>
<div class="mb-1">
<span class="badge bg-primary me-1"><field name="task_type"/></span>
<span class="text-muted"><field name="scheduled_date"/> - <field name="time_start_display"/></span>
</div>
<div class="mb-1">
<i class="fa fa-user me-1"/>
<t t-if="record.x_fc_is_shadow.raw_value">
<span t-out="record.x_fc_sync_client_name.value"/>
</t>
<t t-else="">
<field name="partner_id"/>
</t>
</div>
<div class="text-muted small" t-if="record.address_city.raw_value">
<i class="fa fa-map-marker me-1"/><field name="address_city"/>
<t t-if="record.travel_time_minutes.raw_value">
<span class="ms-2"><i class="fa fa-car me-1"/><field name="travel_time_minutes"/> min</span>
</t>
</div>
<div t-if="record.additional_tech_count.raw_value > 0" class="text-muted small mb-1">
<i class="fa fa-users me-1"/>
<span>+<field name="additional_tech_count"/> technician(s)</span>
</div>
<div class="o_kanban_record_bottom mt-2">
<div class="oe_kanban_bottom_left">
<field name="activity_ids" widget="kanban_activity"/>
</div>
<div class="oe_kanban_bottom_right">
<field name="technician_id" widget="many2one_avatar_user"/>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ================================================================== -->
<!-- CALENDAR VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_calendar" model="ir.ui.view">
<field name="name">fusion.technician.task.calendar</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<calendar string="Technician Schedule"
date_start="datetime_start" date_stop="datetime_end"
color="technician_id" mode="week" event_open_popup="1"
quick_create="0">
<!-- Displayed on the calendar card -->
<field name="partner_id"/>
<field name="x_fc_sync_client_name"/>
<field name="task_type"/>
<field name="time_start_display" string="Start"/>
<field name="time_end_display" string="End"/>
<!-- Popover (hover/click) details -->
<field name="name"/>
<field name="technician_id" avatar_field="image_128"/>
<field name="address_display" string="Address"/>
<field name="travel_time_minutes" string="Travel (min)"/>
<field name="status"/>
<field name="duration_hours" widget="float_time" string="Duration"/>
</calendar>
</field>
</record>
<!-- ================================================================== -->
<!-- MAP VIEW (Enterprise web_map) -->
<!-- ================================================================== -->
<record id="view_technician_task_map" model="ir.ui.view">
<field name="name">fusion.technician.task.map</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<map res_partner="address_partner_id" default_order="time_start"
routing="1" js_class="fusion_task_map">
<field name="partner_id" string="Client"/>
<field name="task_type" string="Type"/>
<field name="technician_id" string="Technician"/>
<field name="time_start_display" string="Start"/>
<field name="time_end_display" string="End"/>
<field name="status" string="Status"/>
<field name="travel_time_minutes" string="Travel (min)"/>
</map>
</field>
</record>
<!-- ================================================================== -->
<!-- ACTIONS -->
<!-- ================================================================== -->
<!-- Main Tasks Action (List/Kanban) -->
<record id="action_technician_tasks" model="ir.actions.act_window">
<field name="name">Technician Tasks</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">list,kanban,form,calendar,map</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first technician task
</p>
<p>Schedule deliveries, repairs, and other field tasks for your technicians.</p>
</field>
</record>
<!-- Schedule Action (Map default) -->
<record id="action_technician_schedule" model="ir.actions.act_window">
<field name="name">Schedule</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">map,calendar,list,kanban,form</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
</record>
<!-- Map View Action (for app landing page) -->
<record id="action_technician_map_view" model="ir.actions.act_window">
<field name="name">Task Map</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">map,list,kanban,form,calendar</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
</record>
<!-- Today's Tasks Action -->
<record id="action_technician_tasks_today" model="ir.actions.act_window">
<field name="name">Today's Tasks</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">kanban,list,form,map</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_today': 1, 'search_default_filter_active': 1}</field>
</record>
<!-- My Tasks Action -->
<record id="action_technician_my_tasks" model="ir.actions.act_window">
<field name="name">My Tasks</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">list,kanban,form,calendar,map</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_my_tasks': 1, 'search_default_filter_active': 1}</field>
</record>
<!-- Pending Tasks Action -->
<record id="action_technician_tasks_pending" model="ir.actions.act_window">
<field name="name">Pending Tasks</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_pending': 1}</field>
</record>
<!-- Calendar Action -->
<record id="action_technician_calendar" model="ir.actions.act_window">
<field name="name">Task Calendar</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">calendar,list,kanban,form,map</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
</record>
<!-- ================================================================== -->
<!-- MENU ITEMS - Standalone Field Service App -->
<!-- ================================================================== -->
<!-- Root app menu -->
<menuitem id="menu_field_service_root"
name="Field Service"
web_icon="fusion_tasks,static/description/icon.png"
groups="fusion_tasks.group_field_technician"
sequence="45"/>
<!-- Map View - first item = default landing view -->
<menuitem id="menu_technician_map"
name="Map View"
parent="menu_field_service_root"
action="action_technician_map_view"
sequence="5"
groups="fusion_tasks.group_field_technician"/>
<!-- Tasks -->
<menuitem id="menu_technician_tasks"
name="Tasks"
parent="menu_field_service_root"
action="action_technician_tasks"
sequence="10"
groups="fusion_tasks.group_field_technician"/>
<!-- Calendar -->
<menuitem id="menu_technician_calendar"
name="Calendar"
parent="menu_field_service_root"
action="action_technician_calendar"
sequence="30"
groups="fusion_tasks.group_field_technician"/>
<!-- Task Sync (submenu) -->
<menuitem id="menu_technician_config"
name="Configuration"
parent="menu_field_service_root"
sequence="90"
groups="fusion_tasks.group_field_technician"/>
</odoo>

BIN
at_accounting-18.0.1.7.zip Normal file

Binary file not shown.

129
batch3_models.sql Normal file
View File

@@ -0,0 +1,129 @@
-- ============================================================
-- BATCH 3: Reconciliation Models for Westin Healthcare
-- Database: westin-v19 | Date: 2026-04-03
-- ============================================================
-- Tax IDs: 20 = HST PURCHASE (13%), 32 = NO TAX PURCHASE (0%)
-- ============================================================
BEGIN;
-- Helper function to create writeoff models in one shot
CREATE OR REPLACE FUNCTION _tmp_create_writeoff(
p_name text, p_seq int, p_match text,
p_account_id int, p_tax_id int, p_label text
) RETURNS void AS $$
DECLARE
v_model_id int;
v_line_id int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, false, 2, 2, NOW(), NOW())
RETURNING id INTO v_model_id;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
VALUES (v_model_id, 1, 10, p_account_id, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW())
RETURNING id INTO v_line_id;
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (v_line_id, p_tax_id);
END;
$$ LANGUAGE plpgsql;
-- Helper function for partner-mapping models
CREATE OR REPLACE FUNCTION _tmp_create_partner_map(
p_name text, p_seq int, p_match text, p_partner_id int
) RETURNS void AS $$
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, mapped_partner_id, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, p_partner_id, true, false, 2, 2, NOW(), NOW());
END;
$$ LANGUAGE plpgsql;
-- ============================================================
-- PART 1: WRITEOFF MODELS (36 models)
-- ============================================================
-- Acct 495=Computer/IT, 496=Advertising, 497=Car/Van, 499=Bank Charges
-- Acct 501=Dues/Subs, 506=Meals, 507=Office, 518=Shipping
-- Acct 523=Telephone, 526=Utilities, 552=Gas, 557=Security
-- Rideshare / Transportation
SELECT _tmp_create_writeoff('Uber Rides', 200, 'uber', 497, 20, 'Uber Rideshare');
SELECT _tmp_create_writeoff('Lyft Rides', 201, 'Lyft', 497, 20, 'Lyft Rideshare');
SELECT _tmp_create_writeoff('407 ETR Highway Tolls', 202, '407 ETR', 497, 20, '407 ETR Highway Tolls');
SELECT _tmp_create_writeoff('Klassic Car Wash', 203, 'KLASSIC CAR WASH', 497, 20, 'Klassic Car Wash');
-- Web Hosting / IT (NO TAX - foreign companies)
SELECT _tmp_create_writeoff('Cloud Clusters Hosting', 210, 'CLOUD CLUSTERS', 495, 32, 'Cloud Clusters Web Hosting');
SELECT _tmp_create_writeoff('Siteground Web Hosting', 211, 'SITEGROUND', 495, 32, 'Siteground Web Hosting');
SELECT _tmp_create_writeoff('WP Media / Imagify Plugin',212, 'WP MEDIA', 495, 32, 'WP Media Imagify Image Optimization');
SELECT _tmp_create_writeoff('Railway.app Cloud Hosting',213, 'RAILWAY', 495, 32, 'Railway.app Cloud Hosting');
SELECT _tmp_create_writeoff('Fiverr Freelance Services',214, 'FIVERR', 495, 32, 'Fiverr Freelance Services');
-- IT Services (HST - Canadian)
SELECT _tmp_create_writeoff('Microsoft 365 Subscription',215, 'Microsoft', 495, 20, 'Microsoft 365 Subscription');
SELECT _tmp_create_writeoff('Webware Website Platform', 216, 'Webware', 495, 20, 'Webware Website Platform');
SELECT _tmp_create_writeoff('Google Workspace', 217, 'WORKSPACE', 495, 20, 'Google Workspace Subscription');
-- Advertising (NO TAX - foreign companies)
SELECT _tmp_create_writeoff('Yelp Advertising', 220, 'YELP', 496, 32, 'Yelp Online Advertising');
SELECT _tmp_create_writeoff('ClickCease Ad Protection', 221, 'CLICKCEASE', 496, 32, 'ClickCease Ad Fraud Protection');
SELECT _tmp_create_writeoff('Kliken / SiteWit Ads', 222, 'KLIKEN', 496, 32, 'Kliken SiteWit Online Advertising');
SELECT _tmp_create_writeoff('Constant Contact Email', 223, 'CONSTANT CONTACT', 496, 32, 'Constant Contact Email Marketing');
-- Advertising (HST - Canadian)
SELECT _tmp_create_writeoff('Yellow Pages Advertising', 224, 'YELLOW PAGES', 496, 20, 'Yellow Pages Directory Advertising');
SELECT _tmp_create_writeoff('Microsoft Advertising', 225, 'MICROSOFT*ADVERTISING', 496, 20, 'Microsoft Bing Advertising');
-- Telephone / Communications
SELECT _tmp_create_writeoff('Bell Canada Telecom', 230, 'BELL CANADA', 523, 20, 'Bell Canada Telephone & Internet');
SELECT _tmp_create_writeoff('eFax Online Fax Service', 231, 'EFAX', 523, 32, 'eFax Online Fax Service');
SELECT _tmp_create_writeoff('Faxdeck Online Fax', 232, 'FAXDECK', 523, 32, 'Faxdeck Online Fax Service');
SELECT _tmp_create_writeoff('RingCentral Phone', 233, 'RINGCENTRAL', 523, 32, 'RingCentral Cloud Phone Service');
-- Subscriptions / Dues
SELECT _tmp_create_writeoff('Scribd Medical Reference', 240, 'SCRIBD', 501, 32, 'Scribd Medical Reference Subscription');
SELECT _tmp_create_writeoff('Amazon Channels', 241, 'Amazon Channel', 501, 20, 'Amazon Channels Subscription');
SELECT _tmp_create_writeoff('Dominion Insurance', 242, 'DOMINION PREM', 501, 32, 'Dominion Insurance Premium');
-- Meals & Entertainment
SELECT _tmp_create_writeoff('Tim Hortons - Meals', 250, 'Tim Horton', 506, 20, 'Tim Hortons Meals');
SELECT _tmp_create_writeoff('Malton Best Restaurant', 251, 'malton best', 506, 20, 'Malton Best Restaurant Meals');
-- Office / Supplies
SELECT _tmp_create_writeoff('Princess Auto - Supplies', 260, 'Princess Auto', 507, 20, 'Princess Auto Supplies');
SELECT _tmp_create_writeoff('Canadian Tire - Supplies', 261, 'CANADIAN TIRE', 507, 20, 'Canadian Tire Office/Shop Supplies');
SELECT _tmp_create_writeoff('Staples Office Supplies', 262, 'STAPLES', 507, 20, 'Staples Office Supplies');
SELECT _tmp_create_writeoff('MGS Business Registration',263, 'MGS-BUSINESS', 507, 20, 'MGS Ontario Business Registration');
-- Shipping
SELECT _tmp_create_writeoff('DHL Express Shipping', 270, 'DHL', 518, 20, 'DHL Express Shipping');
SELECT _tmp_create_writeoff('FedEx Shipping', 271, 'Fedex', 518, 20, 'FedEx Shipping & Delivery');
-- Bank Fees
SELECT _tmp_create_writeoff('Scotia Service Charge', 280, 'Service Charge', 499, 32, 'Scotia Bank Service Charge');
-- Security / Building
SELECT _tmp_create_writeoff('ADT Canada Security', 290, 'ADT CANADA', 557, 20, 'ADT Canada Security Monitoring');
SELECT _tmp_create_writeoff('Seccan Security', 291, 'seccan', 557, 20, 'Seccan Security Services');
-- Utilities
SELECT _tmp_create_writeoff('Alectra Utilities - Hydro',292, 'ALECTRA', 526, 20, 'Alectra Utilities Hydro Payment');
-- ============================================================
-- PART 2: PARTNER-MAPPING MODELS (9 models)
-- ============================================================
SELECT _tmp_create_partner_map('VGM Canada', 300, 'VGM Canada', 5024);
SELECT _tmp_create_partner_map('Medical Mart', 301, 'Medical Mart', 4991);
SELECT _tmp_create_partner_map('AMG Medical', 302, 'AMG medical', 4934);
SELECT _tmp_create_partner_map('HoMedics Group Canada', 303, 'HOMEDICS', 4975);
SELECT _tmp_create_partner_map('Stevens Company Limited', 304, 'Stevens Company', 5017);
SELECT _tmp_create_partner_map('Ki Mobility Canada', 305, 'Ki Mobility', 4981);
SELECT _tmp_create_partner_map('R82 Inc', 306, 'R82', 5009);
SELECT _tmp_create_partner_map('Harmony Group / Products',307, 'HARMONY', 6216);
SELECT _tmp_create_partner_map('Continent Globe Freight', 308, 'CONTINENT GLOBE', NULL);
-- Cleanup temp functions
DROP FUNCTION _tmp_create_writeoff(text, int, text, int, int, text);
DROP FUNCTION _tmp_create_partner_map(text, int, text, int);
COMMIT;

53
batch4_models.sql Normal file
View File

@@ -0,0 +1,53 @@
BEGIN;
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
DECLARE v_mid int; v_lid int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
END; $$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION _tmp_pm(p_name text, p_seq int, p_match text, p_pid int) RETURNS void AS $$
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, mapped_partner_id, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, p_pid, true, true, 2, 2, NOW(), NOW());
END; $$ LANGUAGE plpgsql;
SELECT _tmp_wo('UPS Shipping', 400, 'UPS', 518, 20, 'UPS Shipping & Delivery');
SELECT _tmp_wo('Shopify Subscription', 401, 'SHOPIFY', 495, 20, 'Shopify E-Commerce Platform');
SELECT _tmp_wo('Canva Design', 402, 'CANVA', 495, 32, 'Canva Design Subscription');
SELECT _tmp_wo('Massive.com / Polygon.io', 403, 'MASSIVE.COM', 495, 32, 'Polygon.io Stock Data API');
SELECT _tmp_wo('Air Canada Travel', 404, 'AIR CAN', 525, 20, 'Air Canada Travel');
SELECT _tmp_wo('Enterprise Rent-A-Car', 405, 'ENTERPRISE RENT', 497, 20, 'Enterprise Car Rental');
SELECT _tmp_wo('Walmart Purchases', 406, 'WALMART', 507, 20, 'Walmart Office/Shop Supplies');
SELECT _tmp_wo('FlightHub Travel', 407, 'FLIGHTHUB', 525, 20, 'FlightHub Travel Booking');
SELECT _tmp_wo('G2A Software', 408, 'G2A.COM', 495, 32, 'G2A Software Licenses');
SELECT _tmp_wo('Ubiquiti Network Equipment', 409, 'UBIQUITI', 495, 20, 'Ubiquiti Network Hardware');
SELECT _tmp_wo('Facebook Ads (FACEBK)', 410, 'FACEBK', 496, 20, 'Facebook/Meta Advertising');
SELECT _tmp_wo('Eventbrite Events', 411, 'eventbrite', 496, 20, 'Eventbrite Event Registration');
SELECT _tmp_wo('WP Mail SMTP Plugin', 412, 'WPMAILSMTP', 495, 32, 'WP Mail SMTP Plugin');
SELECT _tmp_wo('Synthesia AI Video', 413, 'SYNTHESIA', 495, 32, 'Synthesia AI Video Platform');
SELECT _tmp_wo('E2PDF WordPress Plugin', 414, 'E2PDF', 495, 32, 'E2PDF WordPress Plugin');
SELECT _tmp_wo('Plugins For WP', 415, 'PLUGINSFORWP', 495, 32, 'WordPress Plugins');
SELECT _tmp_wo('Google Cloud Platform', 416, 'GOOGLE*CLOUD', 495, 20, 'Google Cloud Platform');
SELECT _tmp_wo('Best Buy Retail', 417, 'BEST BUY', 507, 20, 'Best Buy Electronics/Supplies');
SELECT _tmp_wo('Scotia Visa Annual Fee', 418, 'annual fee', 499, 32, 'Scotia Visa Annual Fee');
SELECT _tmp_wo('Corp Canada Registration', 419, 'CORP CANADA', 507, 20, 'Corporation Canada Registration');
SELECT _tmp_wo('NUANS Name Search', 420, 'NUANS', 507, 20, 'NUANS Business Name Search');
SELECT _tmp_wo('Wisprflow AI', 421, 'WISPRFLOW', 495, 32, 'Wisprflow AI Platform');
SELECT _tmp_wo('LawDepot Legal Docs', 422, 'lawdepot', 507, 20, 'LawDepot Legal Documents');
SELECT _tmp_wo('eBay Purchases', 423, 'eBay', 507, 20, 'eBay Online Purchases');
SELECT _tmp_wo('Ooma VoIP Phone', 424, 'OOMA', 523, 20, 'Ooma VoIP Phone Service');
SELECT _tmp_wo('Paddle / Synergy App', 425, 'PADDLE.NET', 495, 32, 'Paddle Software Subscription');
SELECT _tmp_pm('Power Plus Mobility', 500, 'POWER PLUS', 35);
SELECT _tmp_pm('Best Buy Medical Supplies', 501, 'Best Buy Medical', 4939);
SELECT _tmp_pm('Cheelcare Canada', 502, 'cheelcare', 11955);
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
DROP FUNCTION _tmp_pm(text, int, text, int);
COMMIT;

188
batch5_models.sql Normal file
View File

@@ -0,0 +1,188 @@
BEGIN;
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
DECLARE v_mid int; v_lid int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
END; $$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION _tmp_pm(p_name text, p_seq int, p_match text, p_pid int) RETURNS void AS $$
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, mapped_partner_id, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, p_pid, true, true, 2, 2, NOW(), NOW());
END; $$ LANGUAGE plpgsql;
-- MJR Capital = collections payments (Office Expense, HST)
SELECT _tmp_wo('MJR Capital Services - Collections', 600, 'mjr capital', 507, 20, 'MJR Capital Collections Payment');
-- Landry & Jacobs = legal/collections (Office Expense, NO TAX - US company in AZ)
SELECT _tmp_wo('Landry & Jacobs - Collections', 601, 'landry', 507, 32, 'Landry & Jacobs Collections');
-- Micro Center = US electronics retailer (Computer/IT, NO TAX - US)
SELECT _tmp_wo('Micro Center Electronics', 602, 'MICRO CENTER', 495, 32, 'Micro Center Electronics Purchase');
-- Maravi Canada = medical supplies vendor (partner mapping)
-- Need partner ID first - create as writeoff to Office for now
SELECT _tmp_wo('Maravi Canada Medical', 603, 'MARAVI', 507, 20, 'Maravi Canada Medical Supplies');
-- Google Turbo AI Note (SaaS, HST Canadian)
SELECT _tmp_wo('Google Turbo AI Note', 604, 'TURBO AI NOTE', 495, 20, 'Google Turbo AI Note');
-- FUSION NEXASYSTEMS = own company test charges (Office Expense, HST)
SELECT _tmp_wo('Fusion NexaSystems Test', 605, 'NEXASYSTEMS', 507, 20, 'NexaSystems Test Charge');
-- VPS IT NEXASYSTEMS = own company VPS hosting (Computer/IT, HST)
-- already covered by NEXASYSTEMS match above
-- Sunnybrook / St Josephs = hospital parking (Meals & Ent or Office, HST)
SELECT _tmp_wo('Hospital Parking - Sunnybrook', 606, 'sunnybrook', 506, 20, 'Sunnybrook Hospital Parking/Meals');
SELECT _tmp_wo('Hospital Parking - St Josephs', 607, 'st josephs', 506, 20, 'St Josephs Hospital Parking');
-- Canada Post (CPC SCP) - already exists but let's check
-- Model 49 matches "CPC SCP" - should work
-- Bolts Plus Inc = hardware supplies (Office Expense, HST)
SELECT _tmp_wo('Bolts Plus Hardware', 608, 'BOLTS PLUS', 507, 20, 'Bolts Plus Hardware Supplies');
-- Durafast Label Company = labels/printing (Office Expense, HST)
SELECT _tmp_wo('Durafast Label Company', 609, 'durafast', 507, 20, 'Durafast Label Printing');
-- Better Business Bureau = membership (Dues & Subs, HST)
SELECT _tmp_wo('Better Business Bureau', 610, 'better business bureau', 501, 20, 'Better Business Bureau Membership');
-- AmySystems = software (Computer/IT, HST Canadian - QC)
SELECT _tmp_wo('AmySystems Software', 611, 'AMYSYSTEMS', 495, 20, 'AmySystems Software');
-- Thermor Limited = medical equipment vendor
SELECT _tmp_pm('Thermor Limited', 612, 'thermor', NULL);
-- Aqua Creek Products = pool/medical equipment (US vendor)
-- Large amounts ($21K) - this is a PO vendor
SELECT _tmp_pm('Aqua Creek Products', 613, 'aqua creek', NULL);
-- Rogers (line 20184 with ******4596) - existing model should match
-- 407 ETR (line 20131) - existing model matches "407 ETR" but this says "407ETR (WEB)"
SELECT _tmp_wo('407 ETR Web Payment', 614, '407ETR', 497, 20, '407 ETR Web Highway Tolls');
-- 7 Spice Bistro / The Kebob / Momo2Go = restaurants (Meals, HST)
SELECT _tmp_wo('7 Spice Bistro', 615, '7 SPICE', 506, 20, '7 Spice Bistro Meals');
SELECT _tmp_wo('The Kebob Restaurant', 616, 'KEBOB', 506, 20, 'The Kebob Restaurant Meals');
SELECT _tmp_wo('Momo2Go Restaurant', 617, 'MOMO2GO', 506, 20, 'Momo2Go Restaurant Meals');
-- Jay Cee Sales & Rivet = hardware/industrial (Office, NO TAX - US in MI)
SELECT _tmp_wo('Jay Cee Sales & Rivet', 618, 'jay cee sales', 507, 32, 'Jay Cee Sales Industrial Supplies');
-- Kickstarter / Eufymake = crowdfunding purchase (Computer/IT, NO TAX - US)
SELECT _tmp_wo('Kickstarter Purchase', 619, 'kickstarter', 495, 32, 'Kickstarter Crowdfunding Purchase');
-- Bambu Lab = 3D printer (Computer/IT, NO TAX - Hong Kong)
SELECT _tmp_wo('Bambu Lab 3D Printer', 620, 'bambulab', 495, 32, 'Bambu Lab 3D Printer');
-- Dhillon Video Karo = video production (Advertising, HST)
SELECT _tmp_wo('Dhillon Video Karo', 621, 'dhillon video', 496, 20, 'Dhillon Video Production');
-- Cansew = sewing/upholstery supplies (Office Expense, HST)
SELECT _tmp_wo('Cansew Supplies', 622, 'cansew', 507, 20, 'Cansew Sewing/Upholstery Supplies');
-- NuthutVancouver = food/snacks (Meals, HST)
SELECT _tmp_wo('SP Nuthut', 623, 'NUTHUT', 506, 20, 'Nuthut Food/Snacks');
-- Flywire = payment processing for education (Office, HST)
SELECT _tmp_wo('Flywire Payment', 624, 'flywire', 507, 20, 'Flywire Education Payment');
-- IELTS Humber = education/testing (Office, HST)
SELECT _tmp_wo('IELTS Humber College', 625, 'IELTS', 507, 20, 'IELTS Testing Fee');
-- York University = education (Office, HST)
SELECT _tmp_wo('York University', 626, 'york u', 507, 20, 'York University Application Fee');
-- ESW US Direct = e-commerce (Office, NO TAX - US)
SELECT _tmp_wo('ESW US Direct E-Commerce', 627, 'ESW U.S.', 507, 32, 'ESW US Direct E-Commerce');
-- Corp Canada = already created (419), skip
-- NextDigitalKeys = software keys (Computer/IT, NO TAX - UK)
SELECT _tmp_wo('NextDigitalKeys Software', 628, 'nextdigitalkeys', 495, 32, 'NextDigitalKeys Software License');
-- StenoKeyboards = keyboard hardware (Computer/IT, NO TAX - foreign)
SELECT _tmp_wo('StenoKeyboards', 629, 'stenokeyboards', 495, 32, 'StenoKeyboards Hardware');
-- Global Technologies of Barrie = IT services vendor
SELECT _tmp_wo('Global Technologies Barrie', 630, 'global technologies', 495, 20, 'Global Technologies IT Services');
-- Milutin Vuicin = contractor/consultant (Computer/IT, NO TAX - US TX)
SELECT _tmp_wo('Milutin Vuicin Consulting', 631, 'milutin vuicin', 495, 32, 'Milutin Vuicin Consulting');
-- Maple Leaf Wheelchair = PO vendor
SELECT _tmp_pm('Maple Leaf Wheelchair', 632, 'maple leaf wheelchair', NULL);
-- Distributions GNX = distribution vendor (QC)
SELECT _tmp_wo('Distributions GNX', 633, 'distributions gnx', 507, 20, 'Distributions GNX');
-- ParkWhiz / ParkLink = parking (Car/Van, HST)
SELECT _tmp_wo('ParkWhiz / ParkLink Parking', 634, 'park', 497, 20, 'Parking Fee');
-- Actually 'park' is too broad, skip that. Use specific ones:
-- delete that last one, too generic
DELETE FROM account_reconcile_model WHERE name::text LIKE '%ParkWhiz%';
-- Re-do with specific matches
SELECT _tmp_wo('ParkWhiz Parking', 635, 'ParkWhiz', 497, 20, 'ParkWhiz Parking Fee');
SELECT _tmp_wo('Precise ParkLink', 636, 'parklink', 497, 20, 'Precise ParkLink Parking');
-- Span Medical Products = PO vendor
SELECT _tmp_pm('Span Medical Products', 637, 'SPAN MEDICAL', NULL);
-- NSC Medical = PO vendor
SELECT _tmp_pm('NSC Medical', 638, 'nsc medical', NULL);
-- WOW Mobile Boutique = phone accessories (Office, HST)
SELECT _tmp_wo('WOW Mobile Boutique', 639, 'MOBILE BOUTIQ', 507, 20, 'WOW Mobile Boutique');
-- Triumph Mobility = PO vendor
SELECT _tmp_pm('Triumph Mobility', 640, 'triumph mobility', NULL);
-- Home Healthcare Store = PO vendor
SELECT _tmp_pm('Home Healthcare Store', 641, 'home healthcare store', NULL);
-- Ubiquiti already created (409)
-- Anthropic already matched by model 138 (ANTHROPIC)
-- Royalmount Town = travel/accommodation (Travel, HST QC)
SELECT _tmp_wo('Royalmount Town Hotel', 642, 'royalmount', 525, 20, 'Royalmount Town Accommodation');
-- Westin Healthcare own charges = test transactions
SELECT _tmp_wo('Westin Healthcare Test', 643, 'WESTIN HEALTHCARE', 507, 20, 'Westin Healthcare Test Charge');
-- XTool Canada = laser cutter/tools (Computer/IT, HST - Canadian store)
SELECT _tmp_wo('XTool Canada', 644, 'xtool', 495, 20, 'XTool Canada Equipment');
-- Providence Healthcare = hospital parking (Meals, HST)
SELECT _tmp_wo('Providence Healthcare', 645, 'providence healthcare', 506, 20, 'Providence Healthcare Parking');
-- Glentel Wirelesswave = phone accessory (Office, HST)
SELECT _tmp_wo('Glentel Wirelesswave', 646, 'wirelesswave', 507, 20, 'Glentel Wirelesswave Phone');
-- 3DMouse = computer peripheral (Computer/IT, HST)
SELECT _tmp_wo('3DMouse Input Device', 647, '3dmouse', 495, 20, '3DMouse Input Device');
-- LawDepot already created (422)
-- Best Buy Medical already created as partner_map (501)
-- Catherwood & Vittoria = restaurant (Meals, HST)
SELECT _tmp_wo('Catherwood & Vittoria', 648, 'catherwood', 506, 20, 'Catherwood & Vittoria Restaurant');
-- SB M Wing = hospital cafeteria (Meals, HST)
SELECT _tmp_wo('Sunnybrook M Wing Cafe', 649, 'sb m wing', 506, 20, 'Sunnybrook M Wing Cafeteria');
-- Canada/Ottawa lines = government fees/parking
SELECT _tmp_wo('Canada Ottawa Govt Fee', 650, 'canada-Ottawa', 507, 20, 'Ottawa Government Fee');
SELECT _tmp_wo('Canada Ottawa Fee 2', 651, 'canada ottawa on', 507, 20, 'Ottawa Government Fee');
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
DROP FUNCTION _tmp_pm(text, int, text, int);
COMMIT;

22
batch6_transfers.sql Normal file
View File

@@ -0,0 +1,22 @@
BEGIN;
CREATE OR REPLACE FUNCTION _tmp_wo_transfer(p_name text, p_seq int, p_match text, p_acct int, p_label text) RETURNS void AS $$
DECLARE v_mid int; v_lid int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, partner_id, create_uid, write_uid, create_date, write_date)
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 1, 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
-- No tax on internal transfers
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, 32);
END; $$ LANGUAGE plpgsql;
-- These models post PAYMENT FROM lines directly to Outstanding Receipts (493)
-- This handles cases where the source side was already reconciled
SELECT _tmp_wo_transfer('Scotia Visa - Payment From Current (7814)', 50, 'PAYMENT FROM', 493, 'CC Payment from Scotia Current');
SELECT _tmp_wo_transfer('Scotia Visa - Transfer From Current', 51, 'from - *****', 493, 'CC Payment from Scotia Current');
SELECT _tmp_wo_transfer('Scotia Visa - Payment From (X0)', 52, 'payment from -', 493, 'CC Payment from Scotia Current');
DROP FUNCTION _tmp_wo_transfer(text, int, text, int, text);
COMMIT;

124
batch7_rbc.sql Normal file
View File

@@ -0,0 +1,124 @@
BEGIN;
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
DECLARE v_mid int; v_lid int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
END; $$ LANGUAGE plpgsql;
-- ============================================================
-- GOVERNMENT CUSTOMER PAYMENTS → Outstanding Receipts (493)
-- These are payments FROM government agencies TO Westin for equipment/services
-- No tax on government transfer payments
-- ============================================================
-- ODSP = Ontario Disability Support Program (already partially matched by other models)
-- Check: model already exists? No - "Misc Payment ODSP" has no model
SELECT _tmp_wo('ODSP Government Payment', 700, 'ODSP', 493, 32, 'ODSP Customer Payment');
-- MODC = March of Dimes Canada (Expense Payment MODC = incoming govt payment)
SELECT _tmp_wo('MODC - March of Dimes Payment', 701, 'MODC', 493, 32, 'March of Dimes Customer Payment');
-- Revera Long Term Care payments
SELECT _tmp_wo('Revera LTC Payment', 702, 'Revera', 493, 32, 'Revera Long-Term Care Payment');
-- Medavie Blue Cross insurance payments
SELECT _tmp_wo('Medavie Blue Cross Payment', 703, 'MEDAVIE', 493, 32, 'Medavie Blue Cross Insurance Payment');
-- OMOD (Ontario March of Dimes variant)
SELECT _tmp_wo('OMOD Payment', 704, 'OMOD', 493, 32, 'Ontario March of Dimes Payment');
-- Peel Region payroll deposits (home care worker funding)
SELECT _tmp_wo('Peel Region North Deposit', 705, 'PEEL NORTH', 493, 32, 'Region of Peel North Payment');
SELECT _tmp_wo('Peel Region South Deposit', 706, 'PEEL SOUTH', 493, 32, 'Region of Peel South Payment');
SELECT _tmp_wo('Peel Region CMSM Deposit', 707, 'PEEL CMSM', 493, 32, 'Region of Peel CMSM Payment');
-- WSIB payments
SELECT _tmp_wo('WSIB Payment', 708, 'WSIB', 493, 32, 'WSIB Workers Compensation Payment');
-- GST Refund from CRA
SELECT _tmp_wo('CRA GST Refund', 709, 'GSTCANADA', 493, 32, 'CRA GST/HST Refund');
-- Affinity Health bill payments (incoming)
SELECT _tmp_wo('Affinity Health Payment', 710, 'Affinity Health', 493, 32, 'Affinity Health Customer Payment');
-- Amica Senior Living AP payments
SELECT _tmp_wo('Amica Senior Living Payment', 711, 'AMICA', 493, 32, 'Amica Senior Living Payment');
-- PCHS = Peel Community Health Services
SELECT _tmp_wo('PCHS Payment', 712, 'PCHS', 493, 32, 'PCHS Community Health Payment');
-- Run Care Canada
SELECT _tmp_wo('Run Care Canada Payment', 713, 'RUN CARE', 493, 32, 'Run Care Canada Payment');
-- Teskie International
SELECT _tmp_wo('Teskie International Payment', 714, 'TESKIE', 493, 32, 'Teskie International Payment');
-- ============================================================
-- STRIPE DEPOSITS → Outstanding Receipts (493)
-- Online payment gateway deposits
-- ============================================================
SELECT _tmp_wo('Stripe Payment Deposit', 720, 'STRIPE', 493, 32, 'Stripe Online Payment Deposit');
-- ============================================================
-- DEPOSITS / CHEQUE DEPOSITS → Outstanding Receipts (493)
-- Customer payments received
-- ============================================================
SELECT _tmp_wo('Mobile Cheque Deposit', 730, 'Mobile cheque deposit', 493, 32, 'Customer Cheque Deposit');
SELECT _tmp_wo('ATM Deposit', 731, 'ATM deposit', 493, 32, 'Customer ATM Cash/Cheque Deposit');
-- ============================================================
-- NSF RETURNS → Outstanding Receipts (493)
-- Bounced cheques — need to reverse original payment
-- ============================================================
SELECT _tmp_wo('Item Returned NSF', 740, 'Item returned NSF', 493, 32, 'NSF Item Return');
SELECT _tmp_wo('Cheque Returned NSF', 741, 'Cheque returned NSF', 493, 32, 'NSF Cheque Return');
-- ============================================================
-- OUTGOING PAYMENTS / BILLS
-- ============================================================
-- Personal Loan SPL (already has model 80 but checking)
-- Wawanesa Insurance (already model 28 — partner_map, needs bills)
-- Bill Payment Telus (already model for Telus)
-- Bill Payment BuildingStack
SELECT _tmp_wo('BuildingStack Rent Payment', 750, 'BUILDING_STACK', 560, 20, 'BuildingStack Building Rent');
-- Commercial Taxes
SELECT _tmp_wo('Commercial Property Tax', 751, 'COMMERCIAL TAXES', 507, 20, 'Commercial Property Tax Payment');
-- HMS Auto Service
SELECT _tmp_wo('HMS Auto Service', 752, 'HMS AUTO', 497, 20, 'HMS Auto Service Vehicle Repair');
-- Dixie Tailoring (alterations)
SELECT _tmp_wo('Dixie Tailoring', 753, 'DIXIE TAILORIN', 507, 20, 'Dixie Tailoring Services');
-- Hardware Agency
SELECT _tmp_wo('Hardware Agency', 754, 'HARDWARE AGENC', 507, 20, 'Hardware Agency Supplies');
-- Desi Haveli / Bamiyan Kabob (meals)
SELECT _tmp_wo('Desi Haveli Restaurant', 755, 'DESI HAVELI', 506, 20, 'Desi Haveli Restaurant Meals');
SELECT _tmp_wo('Bamiyan Kabob Restaurant', 756, 'BAMIYAN KABOB', 506, 20, 'Bamiyan Kabob Restaurant Meals');
-- Intuit/ADP payroll verification
SELECT _tmp_wo('Intuit Payroll Verification', 757, 'INTUITCANADAULC', 507, 32, 'Intuit Canada Payroll Verification');
-- ============================================================
-- BRANCH TRANSFERS → Outstanding Receipts (493)
-- Internal RBC account transfers
-- ============================================================
SELECT _tmp_wo('RBC Branch Transfer 1306', 760, 'BR TO BR - 1306', 493, 32, 'RBC Branch Transfer 1306');
SELECT _tmp_wo('RBC Branch Transfer 9970', 761, 'BR TO BR - 9970', 493, 32, 'RBC Branch Transfer 9970');
-- ============================================================
-- GENERIC DEPOSIT → Outstanding Receipts
-- ============================================================
SELECT _tmp_wo('Generic Bank Deposit', 770, 'Deposit', 493, 32, 'Bank Deposit');
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
COMMIT;

91
batch8_fixes.sql Normal file
View File

@@ -0,0 +1,91 @@
BEGIN;
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
DECLARE v_mid int; v_lid int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, create_uid, write_uid, create_date, write_date)
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
END; $$ LANGUAGE plpgsql;
-- ============================================================
-- FIX 1: Wawanesa (model 28) — convert from partner_map to writeoff
-- Insurance → Car Insurance (548), NO TAX (insurance is exempt)
-- ============================================================
-- Deactivate old partner_map model
UPDATE account_reconcile_model SET active = false WHERE id = 28;
-- Create new writeoff model for Wawanesa
SELECT _tmp_wo('Wawanesa Insurance Premium', 800, 'WAWANESA', 548, 32, 'Wawanesa Car Insurance Premium');
-- ============================================================
-- FIX 2: Personal Loan SPL (model 80) — fix match param
-- "Personal Loan SPL" doesn't match "Personal Loan : SPL"
-- ============================================================
UPDATE account_reconcile_model SET match_label_param = 'Personal Loan' WHERE id = 80;
-- ============================================================
-- FIX 3: IFS Insurance (model 23) — same issue, convert from partner_map
-- ============================================================
-- Check if model 23 has writeoff line
-- Model 23: match "IFS PREMIUM", mapped_partner_id=7291, no writeoff line
UPDATE account_reconcile_model SET active = false WHERE id = 23;
SELECT _tmp_wo('IFS Insurance Premium', 801, 'IFS PREMIUM', 550, 32, 'IFS Commercial Insurance Premium');
-- ============================================================
-- NEW MODELS for remaining repeated patterns
-- ============================================================
-- Telus Bill Payment (523 = Telephone, HST)
SELECT _tmp_wo('Telus Bill Payment', 802, 'Telus Comm', 523, 20, 'Telus Communications Bill Payment');
-- e-Transfer fee (already model 5 but check if matching)
-- Model 5 matches "e-Transfer fee" — should work
-- Account Payable Pmt HOME (LTC home customer payments → Outstanding Receipts)
SELECT _tmp_wo('HOME LTC Customer Payment', 803, 'Account Payable PmtHOME', 493, 32, 'HOME Long-Term Care Payment');
SELECT _tmp_wo('HOME LTC Customer Payment 2', 804, 'Account Payable Pmt HOME', 493, 32, 'HOME Long-Term Care Payment');
SELECT _tmp_wo('HOME LTC Customer Payment 3', 805, 'Account Payable Pmt-HOME', 493, 32, 'HOME Long-Term Care Payment');
-- R & M Health Supplies payment
SELECT _tmp_wo('R&M Health Supplies Payment', 806, 'R & M HEALTH', 493, 32, 'R&M Health Supplies Customer Payment');
-- BCCL payment
SELECT _tmp_wo('BCCL Customer Payment', 807, 'Account Payable Pmt-BCCL', 493, 32, 'BCCL Customer Payment');
-- CARE payment
SELECT _tmp_wo('CARE Customer Payment', 808, 'Account Payable PmtCARE', 493, 32, 'CARE Customer Payment');
-- Amica Senior Life (different spelling from earlier model)
SELECT _tmp_wo('Amica Senior Life Payment', 809, 'AMICASENIORLIFE', 493, 32, 'Amica Senior Life Customer Payment');
-- Cash withdrawal (no tax, Office Expense)
SELECT _tmp_wo('Cash Withdrawal', 810, 'Cash withdrawal', 507, 32, 'Cash Withdrawal');
-- ATM/Mobile adjustment
SELECT _tmp_wo('ATM Mobile Adjustment Credit', 811, 'ATM/Mobile adjustment credit', 499, 32, 'ATM Mobile Adjustment Credit');
SELECT _tmp_wo('ATM Mobile Adjustment Debit', 812, 'ATM/Mobile adjustment debit', 499, 32, 'ATM Mobile Adjustment Debit');
-- e-Transfer cancel (returned funds → Outstanding Receipts)
SELECT _tmp_wo('e-Transfer Cancellation', 813, 'e-Transfer cancel', 493, 32, 'e-Transfer Cancelled Return');
-- OnRoute (highway rest stop meals)
SELECT _tmp_wo('OnRoute Highway Meals', 814, 'ONROUTE', 506, 20, 'OnRoute Highway Rest Stop');
-- Opening Balance
SELECT _tmp_wo('Opening Balance', 815, 'Opening Balance', 493, 32, 'Opening Balance Entry');
-- Foreign Exchange withdrawal
SELECT _tmp_wo('Royal Foreign Exchange', 816, 'Royal Foreign Exchange', 525, 32, 'Royal Foreign Exchange Withdrawal');
-- Online Banking wire payment
SELECT _tmp_wo('Online Banking Wire Payment', 817, 'Online Banking wire', 494, 32, 'Online Banking Wire Payment');
-- Henrys camera store refund
SELECT _tmp_wo('Henrys Camera Refund', 818, 'Henry', 507, 20, 'Henrys Camera Store');
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
COMMIT;

18
batch9_rbc_visa.sql Normal file
View File

@@ -0,0 +1,18 @@
BEGIN;
CREATE OR REPLACE FUNCTION _tmp_wo(p_name text, p_seq int, p_match text, p_acct int, p_tax int, p_label text) RETURNS void AS $$
DECLARE v_mid int; v_lid int;
BEGIN
INSERT INTO account_reconcile_model (name, sequence, company_id, trigger, match_label, match_label_param, active, can_be_proposed, create_uid, write_uid, create_date, write_date)
VALUES (jsonb_build_object('en_US', p_name), p_seq, 1, 'auto_reconcile', 'contains', p_match, true, true, 2, 2, NOW(), NOW()) RETURNING id INTO v_mid;
INSERT INTO account_reconcile_model_line (model_id, company_id, sequence, account_id, amount_type, amount, amount_string, label, partner_id, create_uid, write_uid, create_date, write_date)
VALUES (v_mid, 1, 10, p_acct, 'percentage', 100, '100', jsonb_build_object('en_US', p_label), 1, 2, 2, NOW(), NOW()) RETURNING id INTO v_lid;
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id) VALUES (v_lid, p_tax);
END; $$ LANGUAGE plpgsql;
SELECT _tmp_wo('RBC Visa - CC Payment Received', 900, 'PAYMENT - THANK YOU', 77, 32, 'RBC CC Payment from Chequing');
SELECT _tmp_wo('RBC Visa - Credit Card Payment', 901, 'credit card payment', 77, 32, 'RBC CC Payment from Chequing');
SELECT _tmp_wo('RBC Visa - RBC CC Payment', 902, 'RBC credit card', 77, 32, 'RBC CC Payment from Chequing');
SELECT _tmp_wo('RBC Visa - Payment to CC', 903, 'payment to credit card', 77, 32, 'RBC CC Payment from Chequing');
DROP FUNCTION _tmp_wo(text, int, text, int, int, text);
COMMIT;

30
batch_reconcile.py Normal file
View File

@@ -0,0 +1,30 @@
import logging
RecModel = env['account.reconcile.model']
StLine = env['account.bank.statement.line']
models = RecModel.search([('trigger', '=', 'auto_reconcile'), ('can_be_proposed', '=', True)])
print(f'Auto-reconcile models: {len(models)}', flush=True)
# Run on ALL 4 journals
for jid, name in [(53, 'RBC Chequing'), (28, 'RBC Visa'), (50, 'Scotia Current'), (51, 'Scotia Passport Visa')]:
lines = StLine.search([('journal_id', '=', jid), ('is_reconciled', '=', False)])
count_before = len(lines)
if not count_before:
continue
batch_size = 100
for i in range(0, count_before, batch_size):
batch = lines[i:i+batch_size]
try:
models._apply_reconcile_models(batch)
except Exception as e:
print(f' Error: {e}', flush=True)
env.cr.commit()
remaining = StLine.search_count([('journal_id', '=', jid), ('is_reconciled', '=', False)])
reconciled = count_before - remaining
if reconciled > 0:
print(f'{name}: reconciled {reconciled}/{count_before}, remaining {remaining}', flush=True)
else:
print(f'{name}: no new matches ({count_before} remaining)', flush=True)

73
cleanup_duplicates.py Normal file
View File

@@ -0,0 +1,73 @@
import logging
_logger = logging.getLogger('cleanup_duplicates')
BSL = env['account.bank.statement.line'].sudo()
AML = env['account.move.line'].sudo()
AM = env['account.move'].sudo()
# All 64 duplicate statement line IDs (the second import set, 18703-18767)
dupe_ids = [
18703, 18704, 18705, 18706, 18707, 18708, 18709, 18710, 18711, 18712,
18713, 18714, 18715, 18716, 18717, 18718, 18719, 18720, 18721, 18722,
18723, 18724, 18725, 18726, 18727, 18728, 18729, 18730, 18731, 18732,
18733, 18734, 18735, 18736, 18737, 18738, 18739, 18740, 18741, 18742,
18743, 18744, 18745, 18746, 18747, 18748, 18749, 18750, 18751, 18752,
18753, 18754, 18755, 18756, 18757, 18758, 18759, 18760, 18761, 18762,
18763, 18764, 18766, 18767,
]
dupes = BSL.browse(dupe_ids)
print(f'Processing {len(dupes)} duplicate statement lines', flush=True)
reconciled_count = 0
unreconciled_count = 0
error_count = 0
for line in dupes:
move = line.move_id
if line.is_reconciled:
# Step 1: Un-reconcile — remove partial reconcile entries
# Find the statement line's AML and its partial reconciliations
st_aml = move.line_ids.filtered(lambda l: l.statement_line_id == line)
if st_aml:
# Find and remove partial reconcile entries
partials = env['account.partial.reconcile'].sudo().search([
'|',
('debit_move_id', 'in', st_aml.ids),
('credit_move_id', 'in', st_aml.ids),
])
if partials:
partials.unlink()
# Also check full reconcile
full_recs = st_aml.mapped('full_reconcile_id')
if full_recs:
full_recs.unlink()
reconciled_count += 1
# Step 2: Reset move to draft so we can delete it
try:
if move.state == 'posted':
move.button_draft()
# Step 3: Cancel and delete the move (which deletes the statement line too)
move.button_cancel()
move.with_context(force_delete=True).unlink()
unreconciled_count += 1
except Exception as e:
print(f' Error on line {line.id}: {e}', flush=True)
error_count += 1
env.cr.rollback()
continue
if unreconciled_count % 20 == 0:
env.cr.commit()
print(f' Progress: {unreconciled_count} deleted...', flush=True)
env.cr.commit()
print(f'DONE: {unreconciled_count} deleted, {reconciled_count} were reconciled, {error_count} errors', flush=True)
# Verify
remaining = BSL.search_count([('id', 'in', dupe_ids)])
print(f'Verification: {remaining} duplicate lines still exist (should be 0)', flush=True)

63
debug_reconcile.py Normal file
View File

@@ -0,0 +1,63 @@
from odoo.tools import SQL
lines = env['account.bank.statement.line'].browse([20262])
models = env['account.reconcile.model'].search([('trigger', '=', 'auto_reconcile'), ('can_be_proposed', '=', True)])
env['account.reconcile.model'].flush_model()
lines.flush_recordset()
# Run a simplified version of the _apply_reconcile_models SQL
env.cr.execute("""
WITH matching_journal_ids AS (
SELECT account_reconcile_model_id, ARRAY_AGG(account_journal_id) AS ids
FROM account_journal_account_reconcile_model_rel
GROUP BY account_reconcile_model_id
),
matching_partner_ids AS (
SELECT account_reconcile_model_id, ARRAY_AGG(res_partner_id) AS ids
FROM account_reconcile_model_res_partner_rel
GROUP BY account_reconcile_model_id
)
SELECT st_line.id AS st_line_id,
reco_model.id AS reco_model_id,
reco_model.trigger
FROM account_bank_statement_line st_line
JOIN account_move move ON st_line.move_id = move.id
LEFT JOIN LATERAL (
SELECT reco_model.id, reco_model.trigger
FROM account_reconcile_model reco_model
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
AND (reco_model.match_label IS NULL OR (
reco_model.match_label = 'contains'
AND (st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
OR move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%')
))
AND reco_model.id IN %s
AND reco_model.can_be_proposed IS TRUE
AND reco_model.company_id = st_line.company_id
ORDER BY reco_model.sequence ASC, reco_model.id ASC
LIMIT 1
) AS reco_model ON TRUE
WHERE st_line.id IN %s
""", (tuple(models.ids), tuple(lines.ids)))
results = env.cr.fetchall()
print(f'SQL results: {results}', flush=True)
# Now check what the full _apply_reconcile_models method SQL has that's different
# The key is that the method joins with model_fees and account_reconcile_model_line
# Let me check if the model 47 has an account_reconcile_model_line with account_id set
model47 = env['account.reconcile.model'].browse(47)
print(f'Model 47 lines: {[(l.id, l.account_id.id, l.account_id.name) for l in model47.line_ids]}', flush=True)
# Check the full method result
print('Calling _apply_reconcile_models...', flush=True)
lines2 = env['account.bank.statement.line'].browse([20266]) # FACEBK line
print(f'Line 20266 before: reconciled={lines2.is_reconciled}', flush=True)
models._apply_reconcile_models(lines2)
env.cr.commit()
lines2.invalidate_recordset()
print(f'Line 20266 after: reconciled={lines2.is_reconciled}', flush=True)

View File

@@ -0,0 +1,96 @@
# Interactive Tables for Fusion AI Chat
**Date:** 2026-04-03
**Module:** fusion_accounting
**Status:** Approved for implementation
## Problem
AI tool results render as plain Markdown tables in the chat. Users cannot annotate, act on, or provide feedback on individual rows. For actionable reports (missing ITCs, duplicate bills, overdue invoices), users need per-row input and bulk actions.
## Solution
A `fusion-table` structured data block that the AI returns instead of Markdown tables for actionable results. The frontend parses these blocks and renders an interactive table widget with: AI recommendations per row, user input fields, checkboxes, and a bulk action bar.
## AI Output Format
The AI wraps structured data in a fenced code block with language `fusion-table`:
```fusion-table
{
"mode": "interactive",
"title": "Missing ITC Bills",
"columns": ["Date", "Vendor", "Amount", "ITC Risk"],
"rows": [
{
"id": 123,
"cells": ["2024-01-10", "Ki Mobility LLC", "-$14,917.95", "HST ITC?"],
"recommendation": {"action": "dismiss", "reason": "US vendor, no HST applies"}
}
],
"actions": ["dismiss", "flag", "create_rule"],
"source_tool": "find_missing_itc_bills"
}
```
- `mode`: `"interactive"` (full widget) or `"readonly"` (styled table, no inputs)
- `columns`: header labels for the data columns
- `rows[].id`: Odoo record ID (e.g., account.move ID)
- `rows[].cells`: display values matching columns
- `rows[].recommendation`: AI's suggested action + reasoning (optional)
- `actions`: which bulk action buttons to show
- `source_tool`: which tool produced this data
## Frontend Components
### 1. mdToHtml() Enhancement (chat_panel.js)
Detect `fusion-table` fenced blocks during Markdown parsing. Extract the JSON payload and render a placeholder `<div class="fusion_interactive_table" data-table-idx="N"/>` that the OWL component will mount into.
### 2. FusionInteractiveTable (new OWL component)
Renders inside the chat message area. Structure:
- **Header row**: Select-all checkbox + data columns + "AI Recommendation" + "Your Input"
- **Body rows**: Per-row checkbox + data cells + recommendation badge (colour-coded: green=dismiss, amber=flag, blue=create_rule) + text input
- **Action bar** (bottom): "Apply Recommendations", "Flag Selected", "Create Rules", "Dismiss Selected", "Submit All Notes to AI"
### 3. Action Flow
Button clicks collect `{rowIds, notes, action}` and call `this.props.onTableAction(payload)`. The chat panel formats this into a structured user message and sends it via the existing `/fusion_accounting/chat` endpoint:
```
[TABLE_ACTION] source=find_missing_itc_bills action=dismiss
Rows: #123 (note: "Confirmed, no ITC needed"), #125 (note: "Need to check PO")
```
The AI processes this through its normal tool-calling flow — dismissing, flagging, creating rules, etc.
## Styling
All colours via Odoo CSS variables and Bootstrap utilities:
- Dismiss badge: `bg-success-subtle` / `text-success`
- Flag badge: `bg-warning-subtle` / `text-warning`
- Create Rule badge: `bg-info-subtle` / `text-info`
- Input fields: Odoo form control classes
- Action bar: `bg-view` with `border-top`
- No hardcoded colours — dark/light mode handled by Odoo theme
## Files Changed
| File | Change |
|---|---|
| `static/src/components/chat/chat_panel.js` | Parse fusion-table blocks in mdToHtml(), mount interactive tables, wire action handler |
| `static/src/components/chat/chat_panel.xml` | Add template slot for interactive tables |
| `static/src/components/chat/interactive_table.js` | New OWL component |
| `static/src/components/chat/interactive_table.xml` | New template |
| `static/src/scss/chat.scss` | Interactive table styles (CSS variables only) |
| `services/prompts/system_prompt.py` | Add fusion-table format instructions to system prompt |
## What Does NOT Change
- Backend tools (same return data)
- AI adapters/orchestrator
- Tier 3 approval cards (separate flow)
- Controller endpoints
- Regular Markdown rendering for non-table content

View File

@@ -0,0 +1,668 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fusion Claims — Workflow Explorer</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"></script>
<script src="./workflows.js"></script>
<style>
:root {
--bg: #0f1115;
--panel: #161a22;
--panel-2: #1d2330;
--border: #2a3040;
--text: #e6e9ef;
--muted: #8a93a8;
--accent: #4f8cff;
--accent-soft: rgba(79,140,255,.15);
--ok: #3fbf7f;
--warn: #f4b400;
--err: #ff5c6c;
--entry: #9b5de5;
--terminal: #5f6b80;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; height: 100%; }
body { display: flex; min-height: 100vh; }
aside {
width: 280px; flex-shrink: 0;
background: var(--panel);
border-right: 1px solid var(--border);
padding: 20px 0;
overflow-y: auto;
}
aside h1 {
font-size: 13px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .08em;
margin: 0 20px 12px;
}
aside .subtitle {
font-size: 11px;
color: var(--muted);
margin: 0 20px 20px;
line-height: 1.4;
}
.wf-list { list-style: none; padding: 0; margin: 0; }
.wf-list li {
padding: 12px 20px;
cursor: pointer;
border-left: 3px solid transparent;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
transition: background .15s;
}
.wf-list li:hover { background: var(--panel-2); }
.wf-list li.active {
background: var(--accent-soft);
border-left-color: var(--accent);
}
.wf-list li .name { font-weight: 500; }
.wf-list li .gap-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
background: var(--err);
color: #fff;
min-width: 20px;
text-align: center;
}
.wf-list li .gap-badge.zero { background: var(--ok); }
main {
flex: 1;
padding: 32px 40px;
overflow-y: auto;
max-width: calc(100vw - 280px);
}
h2 {
margin: 0 0 4px;
font-size: 24px;
font-weight: 700;
}
.field-name {
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
margin-bottom: 20px;
}
.stats {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.stat {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
}
.stat .label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted);
}
.stat .value {
font-size: 22px;
font-weight: 700;
margin-top: 4px;
}
.stat.ok .value { color: var(--ok); }
.stat.err .value { color: var(--err); }
.stat.warn .value { color: var(--warn); }
.tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}
.tabs button {
background: transparent;
border: none;
color: var(--muted);
font: inherit;
padding: 10px 16px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-weight: 500;
}
.tabs button:hover { color: var(--text); }
.tabs button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.mermaid-wrap {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
overflow-x: auto;
}
.mermaid-wrap svg { max-width: 100%; height: auto; }
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
th, td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
th {
background: var(--panel-2);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--muted);
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--panel-2); }
tbody tr.has-issue td:first-child { border-left: 3px solid var(--err); }
code.state-key {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
background: var(--panel-2);
padding: 2px 6px;
border-radius: 4px;
color: var(--accent);
}
.pill {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .03em;
}
.pill.ok { background: rgba(63,191,127,.2); color: var(--ok); }
.pill.warn { background: rgba(244,180,0,.2); color: var(--warn); }
.pill.err { background: rgba(255,92,108,.2); color: var(--err); }
.pill.entry { background: rgba(155,93,229,.2); color: var(--entry); }
.pill.terminal { background: rgba(95,107,128,.35); color: #c2c9d9; }
.count {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: var(--muted);
}
.tr-list { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 8px; }
.tr-row {
display: grid;
grid-template-columns: 200px 30px 200px 1fr 140px;
gap: 12px;
padding: 10px 12px;
align-items: center;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.tr-row:last-child { border-bottom: none; }
.tr-row:hover { background: var(--panel-2); }
.tr-arrow { color: var(--muted); text-align: center; }
.tr-trigger { color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
.tr-trigger .file { color: #555; display: block; margin-top: 2px; }
.tr-kind {
text-align: right;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .04em;
}
.kind-wizard { color: #4f8cff; }
.kind-action_method { color: #9b5de5; }
.kind-cron { color: #f4b400; }
.kind-auto_write { color: #3fbf7f; }
.kind-ui_button { color: #ff5c6c; }
.gaps {
background: var(--panel);
border: 1px solid var(--border);
border-left: 4px solid var(--err);
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 20px;
}
.gaps.zero { border-left-color: var(--ok); }
.gaps h3 {
margin: 0 0 12px;
font-size: 15px;
display: flex;
align-items: center;
gap: 8px;
}
.gaps p { color: var(--muted); margin: 0; }
.gaps ul { margin: 0; padding-left: 20px; }
.gaps li { padding: 4px 0; font-size: 13px; }
.gaps li code {
font-family: ui-monospace, monospace;
background: var(--panel-2);
padding: 1px 5px;
border-radius: 3px;
color: var(--accent);
font-size: 12px;
}
.gap-kind {
display: inline-block;
font-size: 10px;
text-transform: uppercase;
font-weight: 700;
padding: 1px 6px;
border-radius: 3px;
margin-right: 6px;
letter-spacing: .05em;
}
.gap-kind.unreachable { background: rgba(244,180,0,.25); color: var(--warn); }
.gap-kind.dead-end { background: rgba(255,92,108,.25); color: var(--err); }
.gap-kind.missing-path { background: rgba(79,140,255,.25); color: var(--accent); }
.gap-kind.hold-loss { background: rgba(155,93,229,.25); color: var(--entry); }
.legend {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 12px;
color: var(--muted);
margin-bottom: 8px;
}
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend .swatch { width: 10px; height: 10px; border-radius: 2px; }
</style>
</head>
<body>
<aside>
<h1>Fusion Claims</h1>
<p class="subtitle">Workflow Explorer — 5 parallel state machines on <code style="color:var(--accent)">sale.order</code>. Click a workflow to inspect.</p>
<ul class="wf-list" id="wf-list"></ul>
</aside>
<main id="main">
<div id="wf-content"></div>
</main>
<script>
// ============================================================
// DOM helpers — no innerHTML, all createElement / textContent
// ============================================================
function el(tag, opts, children) {
const node = document.createElement(tag);
if (opts) {
if (opts.class) node.className = opts.class;
if (opts.id) node.id = opts.id;
if (opts.text != null) node.textContent = opts.text;
if (opts.style) Object.assign(node.style, opts.style);
if (opts.data) Object.entries(opts.data).forEach(([k,v]) => node.dataset[k] = v);
if (opts.on) Object.entries(opts.on).forEach(([evt,fn]) => node.addEventListener(evt, fn));
}
if (children) {
(Array.isArray(children) ? children : [children]).forEach(c => {
if (c == null) return;
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
}
return node;
}
// Render a string that may contain <code>...</code> spans safely.
// Splits on our own markers and builds real DOM nodes.
function renderSafeInline(parent, text) {
// Only recognise <code>...</code> — everything else is literal text.
const parts = text.split(/(<code>[^<]*<\/code>)/);
parts.forEach(part => {
if (part.startsWith('<code>') && part.endsWith('</code>')) {
const codeText = part.slice(6, -7);
parent.appendChild(el('code', {text: codeText}));
} else if (part) {
parent.appendChild(document.createTextNode(part));
}
});
}
// ============================================================
// Gap analysis
// ============================================================
function analyseWorkflow(wf) {
const stateKeys = wf.states.map(s => s.key);
const inbound = new Map();
const outbound = new Map();
stateKeys.forEach(k => { inbound.set(k, []); outbound.set(k, []); });
wf.transitions.forEach(t => {
if (t.to && inbound.has(t.to)) inbound.get(t.to).push(t);
if (t.from && t.from !== '*' && outbound.has(t.from)) outbound.get(t.from).push(t);
});
const wildcardInTo = new Set();
wf.transitions.forEach(t => { if (t.from === '*') wildcardInTo.add(t.to); });
const terminal = new Set(wf.terminal || []);
const gaps = [];
const stateStatus = {};
stateKeys.forEach(key => {
const label = wf.states.find(s => s.key === key).label;
const isDefault = key === wf.default;
const isTerminal = terminal.has(key);
const hasInbound = inbound.get(key).length > 0 || wildcardInTo.has(key);
const hasOutbound = outbound.get(key).length > 0 || wf.transitions.some(t => t.from === key);
let status = 'ok';
const issues = [];
if (!isDefault && !hasInbound) {
status = 'err';
issues.push({kind: 'unreachable', msg: 'No code path sets this state. It will never be reached via normal workflow — only via manual DB edit or stale ORM context.'});
}
if (!isTerminal && !hasOutbound && !isDefault) {
status = 'err';
issues.push({kind: 'dead-end', msg: 'Once an order lands here, there is no action method or wizard to transition it out. Users will have to edit the record directly.'});
}
stateStatus[key] = {
status, issues, isDefault, isTerminal,
inbound: inbound.get(key),
outbound: wf.transitions.filter(t => t.from === key)
};
issues.forEach(iss => gaps.push({state: key, label, ...iss}));
});
// Workflow-specific heuristics
if (wf.field === 'x_fc_adp_application_status') {
if (!wf.transitions.some(t => t.to === 'rejected')) {
gaps.push({kind: 'missing-path', state: 'rejected', label: 'Rejected by ADP',
msg: 'No transition writes <code>rejected</code>. The state is declared but nothing reaches it. An ADP rejection has nowhere to land.'});
}
if (!wf.transitions.some(t => t.from === 'rejected')) {
gaps.push({kind: 'missing-path', state: 'rejected', label: 'Rejected by ADP',
msg: 'No <code>action_resubmit_from_rejected</code> exists (only <code>action_resubmit_from_withdrawn</code>). A rejected application cannot be brought back into the workflow.'});
}
if (!wf.transitions.some(t => t.to === 'denied')) {
gaps.push({kind: 'missing-path', state: 'denied', label: 'Application Denied',
msg: 'No code path sets <code>denied</code>. Declared as a selection value but has no action method to assign it.'});
}
if (!wf.transitions.some(t => t.to === 'expired')) {
gaps.push({kind: 'missing-path', state: 'expired', label: 'Application Expired',
msg: 'No cron or method sets <code>expired</code>. Declared but unreachable — the ADP expiry logic was never implemented.'});
}
if (!wf.transitions.some(t => t.to === 'cancelled')) {
gaps.push({kind: 'missing-path', state: 'cancelled', label: 'Cancelled',
msg: 'No action method writes <code>cancelled</code> on the ADP workflow.'});
}
if (!wf.transitions.some(t => t.to === 'withdrawn')) {
gaps.push({kind: 'missing-path', state: 'withdrawn', label: 'Withdrawn',
msg: '<code>action_resubmit_from_withdrawn</code> exists (line 3667) but no method WRITES <code>withdrawn</code> in the first place. Dead end on entry.'});
}
if (!wf.transitions.some(t => t.to === 'needs_correction')) {
gaps.push({kind: 'missing-path', state: 'needs_correction', label: 'Needs Correction',
msg: 'The write() override at line 6017 handles <code>needs_correction</code> document-clearing logic, but no code path sets the state TO <code>needs_correction</code>. Only reachable via manual edit.'});
}
}
if (wf.field === 'x_fc_mod_status') {
if (!wf.transitions.some(t => t.from === 'funding_denied')) {
gaps.push({kind: 'dead-end', state: 'funding_denied', label: 'Denied',
msg: 'No way to revive a denied MOD case. No resubmit, no cancellation path. Once denied, the order is stuck unless someone edits <code>x_fc_mod_status</code> directly.'});
}
}
if (['x_fc_sa_status', 'x_fc_odsp_std_status', 'x_fc_ow_status'].includes(wf.field)) {
const resume = wf.transitions.find(t => t.from === 'on_hold');
if (resume && resume.to === 'quotation') {
gaps.push({kind: 'hold-loss', state: 'on_hold', label: 'On Hold',
msg: '<code>action_odsp_resume</code> always resumes to <code>quotation</code>, losing all progress regardless of where the order was put on hold. An order held at <code>ready_delivery</code> is reset to the start.'});
}
if (!wf.transitions.some(t => t.from === 'denied')) {
gaps.push({kind: 'dead-end', state: 'denied', label: 'Denied',
msg: 'No path out of <code>denied</code>. Once set, the case is stuck.'});
}
}
return {gaps, stateStatus};
}
// ============================================================
// Mermaid flowchart builder — produces plain text, Mermaid parses it.
// ============================================================
function buildMermaid(wf, stateStatus) {
const lines = ['flowchart LR'];
wf.states.forEach(s => {
const st = stateStatus[s.key];
const safeLabel = s.label.replace(/"/g, '&quot;');
const shape = st.isTerminal ? `(("${safeLabel}"))` :
st.isDefault ? `(["${safeLabel}"])` :
`["${safeLabel}"]`;
lines.push(` ${s.key}${shape}`);
});
const seen = new Set();
wf.transitions.forEach(t => {
if (t.from === '*') return;
const key = `${t.from}->${t.to}`;
if (seen.has(key)) return;
seen.add(key);
lines.push(` ${t.from} --> ${t.to}`);
});
wf.states.forEach(s => {
const st = stateStatus[s.key];
let cls = 'ok';
if (st.status === 'err') {
if (st.issues.some(i => i.kind === 'unreachable')) cls = 'unreachable';
else cls = 'deadend';
} else if (st.isDefault) cls = 'entry';
else if (st.isTerminal) cls = 'terminal';
lines.push(` class ${s.key} ${cls}`);
});
lines.push(' classDef ok fill:#1d2330,stroke:#3fbf7f,color:#e6e9ef,stroke-width:1.5px');
lines.push(' classDef entry fill:#2b1d40,stroke:#9b5de5,color:#e6e9ef,stroke-width:2.5px');
lines.push(' classDef terminal fill:#1a2030,stroke:#5f6b80,color:#c2c9d9,stroke-width:1.5px');
lines.push(' classDef unreachable fill:#2a2418,stroke:#f4b400,color:#f4b400,stroke-width:2px,stroke-dasharray:5 3');
lines.push(' classDef deadend fill:#2a1820,stroke:#ff5c6c,color:#ff5c6c,stroke-width:2px');
return lines.join('\n');
}
// ============================================================
// Renderer — DOM-based, no innerHTML
// ============================================================
const wfData = window.WORKFLOWS_DATA;
const wfKeys = Object.keys(wfData);
let activeWf = wfKeys[0];
let activeTab = 'flow';
function renderSidebar() {
const list = document.getElementById('wf-list');
while (list.firstChild) list.removeChild(list.firstChild);
wfKeys.forEach(k => {
const wf = wfData[k];
const {gaps} = analyseWorkflow(wf);
const li = el('li', {
class: k === activeWf ? 'active' : '',
on: {click: () => { activeWf = k; activeTab = 'flow'; renderSidebar(); renderContent(); }}
}, [
el('span', {class: 'name', text: wf.label}),
el('span', {class: 'gap-badge' + (gaps.length === 0 ? ' zero' : ''), text: String(gaps.length)})
]);
list.appendChild(li);
});
}
function makeStat(label, value, cls) {
return el('div', {class: 'stat' + (cls ? ' ' + cls : '')}, [
el('div', {class: 'label', text: label}),
el('div', {class: 'value', text: String(value)})
]);
}
function makeGapListItem(g) {
const li = el('li');
const kind = el('span', {class: 'gap-kind ' + g.kind, text: g.kind.replace('-', ' ')});
li.appendChild(kind);
const strong = el('strong', {text: g.label});
li.appendChild(strong);
li.appendChild(document.createTextNode(' — '));
renderSafeInline(li, g.msg);
return li;
}
function renderContent() {
const wf = wfData[activeWf];
const {gaps, stateStatus} = analyseWorkflow(wf);
const container = document.getElementById('wf-content');
while (container.firstChild) container.removeChild(container.firstChild);
const wizardCount = wf.transitions.filter(t => t.kind === 'wizard').length;
const cronCount = wf.transitions.filter(t => t.kind === 'cron').length;
const autoCount = wf.transitions.filter(t => t.kind === 'auto_write').length;
container.appendChild(el('h2', {text: wf.label}));
const fn = el('div', {class: 'field-name'});
fn.appendChild(document.createTextNode(wf.field + ' · default: '));
fn.appendChild(el('code', {text: wf.default}));
container.appendChild(fn);
const stats = el('div', {class: 'stats'}, [
makeStat('States', wf.states.length),
makeStat('Transitions', wf.transitions.length),
makeStat('Gaps', gaps.length, gaps.length === 0 ? 'ok' : 'err'),
makeStat('Wizards', wizardCount),
makeStat('Crons / Auto', cronCount + autoCount)
]);
container.appendChild(stats);
// Gaps panel
const gapsBox = el('div', {class: 'gaps' + (gaps.length === 0 ? ' zero' : '')});
gapsBox.appendChild(el('h3', {text: gaps.length === 0
? '\u2713 No gaps detected'
: '\u26A0 ' + gaps.length + ' gap' + (gaps.length === 1 ? '' : 's') + ' detected'}));
if (gaps.length === 0) {
gapsBox.appendChild(el('p', {text: 'This workflow has full coverage: every declared state is reachable, every non-terminal state has an exit, and all transitions are backed by code paths.'}));
} else {
const ul = el('ul');
gaps.forEach(g => ul.appendChild(makeGapListItem(g)));
gapsBox.appendChild(ul);
}
container.appendChild(gapsBox);
// Tabs
const tabs = el('div', {class: 'tabs'});
const tabDefs = [
{key: 'flow', label: 'Flowchart'},
{key: 'states', label: 'States (' + wf.states.length + ')'},
{key: 'transitions', label: 'Transitions (' + wf.transitions.length + ')'}
];
tabDefs.forEach(t => {
tabs.appendChild(el('button', {
class: activeTab === t.key ? 'active' : '',
text: t.label,
on: {click: () => { activeTab = t.key; renderContent(); }}
}));
});
container.appendChild(tabs);
// Flow tab
if (activeTab === 'flow') {
const legend = el('div', {class: 'legend'}, [
el('span', null, [el('span', {class: 'swatch', style: {background: '#9b5de5'}}), 'Entry state']),
el('span', null, [el('span', {class: 'swatch', style: {background: '#3fbf7f'}}), 'Healthy']),
el('span', null, [el('span', {class: 'swatch', style: {background: '#f4b400'}}), 'Unreachable']),
el('span', null, [el('span', {class: 'swatch', style: {background: '#ff5c6c'}}), 'Dead-end']),
el('span', null, [el('span', {class: 'swatch', style: {background: '#5f6b80'}}), 'Terminal'])
]);
container.appendChild(legend);
const wrap = el('div', {class: 'mermaid-wrap'});
const mm = el('div', {class: 'mermaid', id: 'mermaid-' + activeWf});
mm.textContent = buildMermaid(wf, stateStatus);
wrap.appendChild(mm);
container.appendChild(wrap);
// Render mermaid async
mermaid.initialize({startOnLoad: false, theme: 'base', securityLevel: 'strict', themeVariables: {
background: '#161a22', primaryColor: '#1d2330', primaryTextColor: '#e6e9ef',
primaryBorderColor: '#3fbf7f', lineColor: '#4f8cff'
}});
const src = mm.textContent;
const renderId = 'mm-svg-' + activeWf + '-' + Date.now();
mermaid.render(renderId, src).then(result => {
while (mm.firstChild) mm.removeChild(mm.firstChild);
// mermaid.render returns an SVG string — parse via DOMParser, no innerHTML
const doc = new DOMParser().parseFromString(result.svg, 'image/svg+xml');
const svgNode = doc.documentElement;
mm.appendChild(document.importNode(svgNode, true));
}).catch(err => {
while (mm.firstChild) mm.removeChild(mm.firstChild);
const pre = el('pre', {style: {color: 'var(--err)', whiteSpace: 'pre-wrap'}});
pre.textContent = 'Mermaid error: ' + err.message + '\n\n' + src;
mm.appendChild(pre);
});
}
// States tab
if (activeTab === 'states') {
const table = el('table');
const thead = el('thead');
const headRow = el('tr');
['State', 'Key', 'Status', 'In', 'Out'].forEach(h => headRow.appendChild(el('th', {text: h})));
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = el('tbody');
wf.states.forEach(s => {
const st = stateStatus[s.key];
let pillClass = 'ok', pillLabel = 'Healthy';
if (st.isDefault) { pillClass = 'entry'; pillLabel = 'Entry'; }
else if (st.isTerminal) { pillClass = 'terminal'; pillLabel = 'Terminal'; }
if (st.status === 'err') {
if (st.issues.some(i => i.kind === 'unreachable')) { pillClass = 'warn'; pillLabel = 'Unreachable'; }
else { pillClass = 'err'; pillLabel = 'Dead-end'; }
}
const tr = el('tr', {class: st.status === 'err' ? 'has-issue' : ''});
tr.appendChild(el('td', null, [el('strong', {text: s.label})]));
tr.appendChild(el('td', null, [el('code', {class: 'state-key', text: s.key})]));
tr.appendChild(el('td', null, [el('span', {class: 'pill ' + pillClass, text: pillLabel})]));
tr.appendChild(el('td', {class: 'count', text: String(st.inbound.length)}));
tr.appendChild(el('td', {class: 'count', text: String(st.outbound.length)}));
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
}
// Transitions tab
if (activeTab === 'transitions') {
const list = el('div', {class: 'tr-list'});
wf.transitions.forEach(t => {
const row = el('div', {class: 'tr-row'});
row.appendChild(el('div', null, [el('code', {class: 'state-key', text: t.from})]));
row.appendChild(el('div', {class: 'tr-arrow', text: '\u2192'}));
row.appendChild(el('div', null, [el('code', {class: 'state-key', text: t.to})]));
const trig = el('div', {class: 'tr-trigger'});
trig.appendChild(document.createTextNode(t.trigger));
const fileLine = el('span', {class: 'file', text: t.file + (t.line ? ':' + t.line : '')});
trig.appendChild(fileLine);
row.appendChild(trig);
const kind = el('div', {class: 'tr-kind'});
kind.appendChild(el('span', {class: 'kind-' + t.kind, text: t.kind.replace('_', ' ')}));
row.appendChild(kind);
list.appendChild(row);
});
container.appendChild(list);
}
}
renderSidebar();
renderContent();
</script>
</body>
</html>

View File

@@ -0,0 +1,197 @@
// Workflow data extracted from fusion_claims/models/sale_order.py and wizard/*.py
// Generated 2026-04-08. If the code changes, regenerate this file.
window.WORKFLOWS_DATA = {
"adp_application": {
"field": "x_fc_adp_application_status",
"label": "ADP Application",
"default": "quotation",
"terminal": ["case_closed", "cancelled"],
"states": [
{"key": "quotation", "label": "Quotation Stage"},
{"key": "assessment_scheduled", "label": "Assessment Scheduled"},
{"key": "assessment_completed", "label": "Assessment Completed"},
{"key": "waiting_for_application", "label": "Waiting for Application"},
{"key": "application_received", "label": "Application Received"},
{"key": "ready_submission", "label": "Ready for Submission"},
{"key": "submitted", "label": "Application Submitted"},
{"key": "accepted", "label": "Accepted by ADP"},
{"key": "rejected", "label": "Rejected by ADP"},
{"key": "resubmitted", "label": "Application Resubmitted"},
{"key": "needs_correction", "label": "Needs Correction"},
{"key": "approved", "label": "Application Approved"},
{"key": "approved_deduction", "label": "Approved with Deduction"},
{"key": "ready_delivery", "label": "Ready for Delivery"},
{"key": "denied", "label": "Application Denied"},
{"key": "withdrawn", "label": "Application Withdrawn"},
{"key": "ready_bill", "label": "Ready to Bill"},
{"key": "billed", "label": "Billed to ADP"},
{"key": "case_closed", "label": "Case Closed"},
{"key": "on_hold", "label": "On Hold"},
{"key": "cancelled", "label": "Cancelled"},
{"key": "expired", "label": "Application Expired"}
],
"transitions": [
{"from": "quotation", "to": "assessment_scheduled", "trigger": "schedule_assessment_wizard.action_schedule", "file": "wizard/schedule_assessment_wizard.py", "line": 118, "kind": "wizard"},
{"from": "assessment_scheduled", "to": "assessment_completed", "trigger": "assessment_completed_wizard.action_confirm", "file": "wizard/assessment_completed_wizard.py", "line": 105, "kind": "wizard"},
{"from": "assessment_completed", "to": "waiting_for_application", "trigger": "auto-transition on status_email write()", "file": "models/sale_order.py", "line": 6017, "kind": "auto_write"},
{"from": "assessment_completed", "to": "application_received", "trigger": "application_received_wizard.action_confirm", "file": "wizard/application_received_wizard.py", "line": 136, "kind": "wizard"},
{"from": "waiting_for_application", "to": "application_received", "trigger": "application_received_wizard.action_confirm", "file": "wizard/application_received_wizard.py", "line": 136, "kind": "wizard"},
{"from": "application_received", "to": "ready_submission", "trigger": "ready_for_submission_wizard.action_confirm", "file": "wizard/ready_for_submission_wizard.py", "line": 159, "kind": "wizard"},
{"from": "ready_submission", "to": "submitted", "trigger": "submission_verification_wizard.action_confirm_submission", "file": "wizard/submission_verification_wizard.py", "line": 288, "kind": "wizard"},
{"from": "needs_correction", "to": "resubmitted", "trigger": "submission_verification_wizard.action_confirm_submission", "file": "wizard/submission_verification_wizard.py", "line": 288, "kind": "wizard"},
{"from": "submitted", "to": "accepted", "trigger": "action_mark_accepted", "file": "models/sale_order.py", "line": 3563, "kind": "action_method"},
{"from": "resubmitted", "to": "accepted", "trigger": "action_mark_accepted", "file": "models/sale_order.py", "line": 3563, "kind": "action_method"},
{"from": "submitted", "to": "approved", "trigger": "device_approval_wizard.action_confirm", "file": "wizard/device_approval_wizard.py", "line": 290, "kind": "wizard"},
{"from": "resubmitted", "to": "approved", "trigger": "device_approval_wizard.action_confirm", "file": "wizard/device_approval_wizard.py", "line": 290, "kind": "wizard"},
{"from": "accepted", "to": "approved", "trigger": "device_approval_wizard.action_confirm", "file": "wizard/device_approval_wizard.py", "line": 290, "kind": "wizard"},
{"from": "submitted", "to": "approved_deduction", "trigger": "device_approval_wizard.action_confirm", "file": "wizard/device_approval_wizard.py", "line": 290, "kind": "wizard"},
{"from": "resubmitted", "to": "approved_deduction", "trigger": "device_approval_wizard.action_confirm", "file": "wizard/device_approval_wizard.py", "line": 290, "kind": "wizard"},
{"from": "accepted", "to": "approved_deduction", "trigger": "device_approval_wizard.action_confirm", "file": "wizard/device_approval_wizard.py", "line": 290, "kind": "wizard"},
{"from": "approved", "to": "ready_delivery", "trigger": "ready_for_delivery_wizard.action_confirm", "file": "wizard/ready_for_delivery_wizard.py", "line": 108, "kind": "wizard"},
{"from": "approved_deduction", "to": "ready_delivery", "trigger": "ready_for_delivery_wizard.action_confirm", "file": "wizard/ready_for_delivery_wizard.py", "line": 108, "kind": "wizard"},
{"from": "*", "to": "ready_delivery", "trigger": "technician_task complete", "file": "models/technician_task.py", "line": 228, "kind": "auto_write"},
{"from": "ready_delivery", "to": "approved", "trigger": "technician_task cancel", "file": "models/technician_task.py", "line": 327, "kind": "auto_write"},
{"from": "ready_delivery", "to": "ready_bill", "trigger": "ready_to_bill_wizard.action_confirm", "file": "wizard/ready_to_bill_wizard.py", "line": null, "kind": "wizard"},
{"from": "ready_bill", "to": "billed", "trigger": "adp_export_wizard.action_export", "file": "wizard/adp_export_wizard.py", "line": null, "kind": "wizard"},
{"from": "billed", "to": "case_closed", "trigger": "_cron_auto_close_billed_cases", "file": "models/sale_order.py", "line": 6852, "kind": "cron"},
{"from": "withdrawn", "to": "ready_submission", "trigger": "action_resubmit_from_withdrawn", "file": "models/sale_order.py", "line": 3667, "kind": "action_method"}
]
},
"mod": {
"field": "x_fc_mod_status",
"label": "March of Dimes",
"default": "need_to_schedule",
"terminal": ["case_closed", "cancelled"],
"states": [
{"key": "need_to_schedule", "label": "Schedule Assessment"},
{"key": "assessment_scheduled", "label": "Assessment Booked"},
{"key": "assessment_completed", "label": "Assessment Done"},
{"key": "processing_drawings", "label": "Processing Drawing"},
{"key": "quote_submitted", "label": "Quote Sent"},
{"key": "awaiting_funding", "label": "Awaiting Funding"},
{"key": "funding_approved", "label": "Approved"},
{"key": "funding_denied", "label": "Denied"},
{"key": "contract_received", "label": "PCA Received"},
{"key": "in_production", "label": "In Production"},
{"key": "project_complete", "label": "Complete"},
{"key": "pod_submitted", "label": "POD Sent"},
{"key": "case_closed", "label": "Closed"},
{"key": "on_hold", "label": "On Hold"},
{"key": "cancelled", "label": "Cancelled"}
],
"transitions": [
{"from": "need_to_schedule", "to": "assessment_scheduled", "trigger": "action_mod_schedule_assessment", "file": "models/sale_order.py", "line": 7018, "kind": "action_method"},
{"from": "assessment_scheduled", "to": "assessment_completed", "trigger": "action_mod_complete_assessment", "file": "models/sale_order.py", "line": 7025, "kind": "action_method"},
{"from": "assessment_completed", "to": "processing_drawings", "trigger": "action_mod_processing_drawing", "file": "models/sale_order.py", "line": 7035, "kind": "action_method"},
{"from": "processing_drawings", "to": "quote_submitted", "trigger": "send_to_mod_wizard.action_send (quote)", "file": "wizard/send_to_mod_wizard.py", "line": 203, "kind": "wizard"},
{"from": "quote_submitted", "to": "awaiting_funding", "trigger": "mod_awaiting_funding_wizard.action_confirm", "file": "wizard/mod_awaiting_funding_wizard.py", "line": 34, "kind": "wizard"},
{"from": "awaiting_funding", "to": "funding_approved", "trigger": "mod_funding_approved_wizard.action_confirm", "file": "wizard/mod_funding_approved_wizard.py", "line": 48, "kind": "wizard"},
{"from": "awaiting_funding", "to": "funding_denied", "trigger": "action_mod_funding_denied", "file": "models/sale_order.py", "line": 7076, "kind": "action_method"},
{"from": "funding_approved", "to": "contract_received", "trigger": "mod_pca_received_wizard.action_confirm", "file": "wizard/mod_pca_received_wizard.py", "line": 143, "kind": "wizard"},
{"from": "contract_received", "to": "in_production", "trigger": "action_mod_in_production", "file": "models/sale_order.py", "line": 7093, "kind": "action_method"},
{"from": "in_production", "to": "project_complete", "trigger": "action_mod_project_complete", "file": "models/sale_order.py", "line": 7100, "kind": "action_method"},
{"from": "project_complete", "to": "pod_submitted", "trigger": "send_to_mod_wizard.action_send (pod)", "file": "wizard/send_to_mod_wizard.py", "line": 221, "kind": "wizard"},
{"from": "pod_submitted", "to": "case_closed", "trigger": "action_mod_close_case", "file": "models/sale_order.py", "line": 7123, "kind": "action_method"},
{"from": "*", "to": "on_hold", "trigger": "action_mod_on_hold", "file": "models/sale_order.py", "line": 7129, "kind": "action_method"},
{"from": "on_hold", "to": "in_production", "trigger": "action_mod_resume", "file": "models/sale_order.py", "line": 7134, "kind": "action_method"},
{"from": "*", "to": "cancelled", "trigger": "action_cancel", "file": "models/sale_order.py", "line": 7142, "kind": "action_method"}
]
},
"sa_mobility": {
"field": "x_fc_sa_status",
"label": "SA Mobility",
"default": "quotation",
"terminal": ["case_closed", "cancelled"],
"states": [
{"key": "quotation", "label": "Quotation"},
{"key": "form_ready", "label": "SA Form Ready"},
{"key": "submitted_to_sa", "label": "Submitted to SA Mobility"},
{"key": "pre_approved", "label": "Pre-Approved"},
{"key": "ready_delivery", "label": "Ready for Delivery"},
{"key": "delivered", "label": "Delivered"},
{"key": "pod_submitted", "label": "POD Submitted"},
{"key": "payment_received", "label": "Payment Received"},
{"key": "case_closed", "label": "Case Closed"},
{"key": "on_hold", "label": "On Hold"},
{"key": "cancelled", "label": "Cancelled"},
{"key": "denied", "label": "Denied"}
],
"transitions": [
{"from": "quotation", "to": "form_ready", "trigger": "odsp_sa_mobility_wizard.action_confirm", "file": "wizard/odsp_sa_mobility_wizard.py", "line": null, "kind": "wizard"},
{"from": "form_ready", "to": "submitted_to_sa", "trigger": "odsp_submit_to_odsp_wizard.action_confirm", "file": "wizard/odsp_submit_to_odsp_wizard.py", "line": 105, "kind": "wizard"},
{"from": "submitted_to_sa", "to": "pre_approved", "trigger": "odsp_pre_approved_wizard.action_confirm", "file": "wizard/odsp_pre_approved_wizard.py", "line": 68, "kind": "wizard"},
{"from": "pre_approved", "to": "ready_delivery", "trigger": "odsp_ready_delivery_wizard.action_confirm", "file": "wizard/odsp_ready_delivery_wizard.py", "line": 170, "kind": "wizard"},
{"from": "ready_delivery", "to": "delivered", "trigger": "_odsp_advance_status('delivered')", "file": "models/sale_order.py", "line": 1212, "kind": "auto_write"},
{"from": "delivered", "to": "pod_submitted", "trigger": "_odsp_advance_status('pod_submitted')", "file": "models/sale_order.py", "line": 1225, "kind": "auto_write"},
{"from": "pod_submitted", "to": "payment_received", "trigger": "invoice payment posted", "file": "models/account_move.py", "line": 59, "kind": "auto_write"},
{"from": "payment_received", "to": "case_closed", "trigger": "_cron_auto_close_odsp_paid_cases", "file": "models/sale_order.py", "line": 6899, "kind": "cron"},
{"from": "*", "to": "on_hold", "trigger": "action_odsp_on_hold", "file": "models/sale_order.py", "line": 1396, "kind": "action_method"},
{"from": "on_hold", "to": "quotation", "trigger": "action_odsp_resume", "file": "models/sale_order.py", "line": 1401, "kind": "action_method"},
{"from": "*", "to": "denied", "trigger": "action_odsp_denied", "file": "models/sale_order.py", "line": 1405, "kind": "action_method"},
{"from": "*", "to": "cancelled", "trigger": "action_cancel", "file": "models/sale_order.py", "line": 1141, "kind": "action_method"}
]
},
"odsp_standard": {
"field": "x_fc_odsp_std_status",
"label": "ODSP Standard",
"default": "quotation",
"terminal": ["case_closed", "cancelled"],
"states": [
{"key": "quotation", "label": "Quotation"},
{"key": "submitted_to_odsp", "label": "Submitted to ODSP"},
{"key": "pre_approved", "label": "Pre-Approved"},
{"key": "ready_delivery", "label": "Ready for Delivery"},
{"key": "delivered", "label": "Delivered"},
{"key": "pod_submitted", "label": "POD Submitted"},
{"key": "payment_received", "label": "Payment Received"},
{"key": "case_closed", "label": "Case Closed"},
{"key": "on_hold", "label": "On Hold"},
{"key": "cancelled", "label": "Cancelled"},
{"key": "denied", "label": "Denied"}
],
"transitions": [
{"from": "quotation", "to": "submitted_to_odsp", "trigger": "odsp_submit_to_odsp_wizard.action_confirm", "file": "wizard/odsp_submit_to_odsp_wizard.py", "line": 105, "kind": "wizard"},
{"from": "submitted_to_odsp", "to": "pre_approved", "trigger": "odsp_pre_approved_wizard.action_confirm", "file": "wizard/odsp_pre_approved_wizard.py", "line": 68, "kind": "wizard"},
{"from": "pre_approved", "to": "ready_delivery", "trigger": "odsp_ready_delivery_wizard.action_confirm", "file": "wizard/odsp_ready_delivery_wizard.py", "line": 170, "kind": "wizard"},
{"from": "ready_delivery", "to": "delivered", "trigger": "_odsp_advance_status('delivered')", "file": "models/sale_order.py", "line": 1215, "kind": "auto_write"},
{"from": "delivered", "to": "pod_submitted", "trigger": "_odsp_advance_status('pod_submitted')", "file": "models/sale_order.py", "line": 1225, "kind": "auto_write"},
{"from": "pod_submitted", "to": "payment_received", "trigger": "invoice payment posted", "file": "models/account_move.py", "line": 59, "kind": "auto_write"},
{"from": "payment_received", "to": "case_closed", "trigger": "_cron_auto_close_odsp_paid_cases", "file": "models/sale_order.py", "line": 6899, "kind": "cron"},
{"from": "*", "to": "on_hold", "trigger": "action_odsp_on_hold", "file": "models/sale_order.py", "line": 1396, "kind": "action_method"},
{"from": "on_hold", "to": "quotation", "trigger": "action_odsp_resume", "file": "models/sale_order.py", "line": 1401, "kind": "action_method"},
{"from": "*", "to": "denied", "trigger": "action_odsp_denied", "file": "models/sale_order.py", "line": 1405, "kind": "action_method"},
{"from": "*", "to": "cancelled", "trigger": "action_cancel", "file": "models/sale_order.py", "line": 1141, "kind": "action_method"}
]
},
"ontario_works": {
"field": "x_fc_ow_status",
"label": "Ontario Works",
"default": "quotation",
"terminal": ["case_closed", "cancelled"],
"states": [
{"key": "quotation", "label": "Quotation"},
{"key": "documents_ready", "label": "Documents Ready"},
{"key": "submitted_to_ow", "label": "Submitted to Ontario Works"},
{"key": "payment_received", "label": "Payment Received"},
{"key": "ready_delivery", "label": "Ready for Delivery"},
{"key": "delivered", "label": "Delivered"},
{"key": "case_closed", "label": "Case Closed"},
{"key": "on_hold", "label": "On Hold"},
{"key": "cancelled", "label": "Cancelled"},
{"key": "denied", "label": "Denied"}
],
"transitions": [
{"from": "quotation", "to": "documents_ready", "trigger": "odsp_discretionary_wizard.action_confirm (docs)", "file": "wizard/odsp_discretionary_wizard.py", "line": 245, "kind": "wizard"},
{"from": "documents_ready", "to": "submitted_to_ow", "trigger": "odsp_discretionary_wizard.action_confirm (submit)", "file": "wizard/odsp_discretionary_wizard.py", "line": 260, "kind": "wizard"},
{"from": "submitted_to_ow", "to": "payment_received", "trigger": "invoice payment posted", "file": "models/account_move.py", "line": 59, "kind": "auto_write"},
{"from": "payment_received", "to": "ready_delivery", "trigger": "odsp_ready_delivery_wizard.action_confirm", "file": "wizard/odsp_ready_delivery_wizard.py", "line": 170, "kind": "wizard"},
{"from": "ready_delivery", "to": "delivered", "trigger": "_odsp_advance_status('delivered')", "file": "models/sale_order.py", "line": 1217, "kind": "auto_write"},
{"from": "delivered", "to": "case_closed", "trigger": "_cron_auto_close_odsp_paid_cases", "file": "models/sale_order.py", "line": 6899, "kind": "cron"},
{"from": "*", "to": "on_hold", "trigger": "action_odsp_on_hold", "file": "models/sale_order.py", "line": 1396, "kind": "action_method"},
{"from": "on_hold", "to": "quotation", "trigger": "action_odsp_resume", "file": "models/sale_order.py", "line": 1401, "kind": "action_method"},
{"from": "*", "to": "denied", "trigger": "action_odsp_denied", "file": "models/sale_order.py", "line": 1405, "kind": "action_method"},
{"from": "*", "to": "cancelled", "trigger": "action_cancel", "file": "models/sale_order.py", "line": 1141, "kind": "action_method"}
]
}
};

1197
entech-website-design.html Normal file

File diff suppressed because it is too large Load Diff

145
fix_elavon.py Normal file
View File

@@ -0,0 +1,145 @@
import logging
_logger = logging.getLogger('fix_elavon')
AML = env['account.move.line'].sudo()
BSL = env['account.bank.statement.line'].sudo()
# ============================================================
# PART 1: Fix 144 incoming Elavon payments (Bank Charges -> Outstanding Receipts)
# ============================================================
print('=== PART 1: Fix incoming Elavon payments ===', flush=True)
incoming_bad_amls = AML.search([
('account_id', '=', 499), # Bank Charges
('statement_line_id', '!=', False),
('statement_line_id.payment_ref', 'ilike', 'elavon'),
('credit', '>', 0), # Credit to Bank Charges = incoming payment writeoff
])
# Filter to only those where the statement line amount > 0 (incoming)
incoming_ids = []
for aml in incoming_bad_amls:
if aml.statement_line_id.amount > 0:
incoming_ids.append(aml.id)
print(f'Found {len(incoming_ids)} incoming Elavon writeoff lines to fix', flush=True)
if incoming_ids:
# Direct SQL update - change account from 499 to 493
env.cr.execute("""
UPDATE account_move_line
SET account_id = 493
WHERE id IN %s
""", (tuple(incoming_ids),))
env.cr.commit()
print(f'Changed {len(incoming_ids)} lines: Bank Charges (499) -> Outstanding Receipts (493)', flush=True)
# ============================================================
# PART 2: Fix 6 round-number refund Business PADs
# ============================================================
print('\n=== PART 2: Fix round-number customer refunds ===', flush=True)
refund_bad_amls = AML.search([
('account_id', '=', 499), # Bank Charges
('statement_line_id', '!=', False),
('statement_line_id.payment_ref', 'ilike', 'elavon'),
('debit', '>', 0), # Debit to Bank Charges = outgoing writeoff
])
refund_ids = []
for aml in refund_bad_amls:
st_line = aml.statement_line_id
if st_line.amount < 0 and st_line.amount == round(st_line.amount, 0):
refund_ids.append(aml.id)
print(f' Refund: line {st_line.id}, ${st_line.amount}, {st_line.move_id.date}', flush=True)
print(f'Found {len(refund_ids)} round-number refund lines to fix', flush=True)
if refund_ids:
env.cr.execute("""
UPDATE account_move_line
SET account_id = 493
WHERE id IN %s
""", (tuple(refund_ids),))
env.cr.commit()
print(f'Changed {len(refund_ids)} lines: Bank Charges (499) -> Outstanding Receipts (493)', flush=True)
# ============================================================
# PART 3: Fix reconcile model 96 - should ONLY match fees (Business PAD)
# and create new model for incoming Elavon payments
# ============================================================
print('\n=== PART 3: Update reconcile models ===', flush=True)
# Model 96 currently matches "Elavon Mrch Svc" which catches EVERYTHING
# Change it to only match "Business PAD" (the fees)
model96 = env['account.reconcile.model'].sudo().browse(96)
print(f'Model 96 before: match="{model96.match_label_param}", account={model96.line_ids.account_id.name}', flush=True)
model96.write({'match_label_param': 'Business PAD'})
# Keep account 499 (Bank Charges) for the fees - that's correct
print(f'Model 96 after: match="{model96.match_label_param}" (now only matches fees)', flush=True)
# Model 85 matches "MRCH" which also catches Elavon payments on RBC Chequing
# Leave it for now - those are the RBC monthly MRCH fee lines, different pattern
# Create new model for incoming Elavon payments -> Outstanding Receipts (493)
existing = env['account.reconcile.model'].sudo().search([
('match_label_param', '=', 'Elavon Mrch Svc : Miscellaneous'),
])
if not existing:
new_model = env['account.reconcile.model'].sudo().create({
'name': 'Elavon Customer Payment Deposit',
'sequence': 55,
'company_id': 1,
'trigger': 'auto_reconcile',
'match_label': 'contains',
'match_label_param': 'Elavon Mrch Svc : Miscellaneous',
'can_be_proposed': True,
})
new_line = env['account.reconcile.model.line'].sudo().create({
'model_id': new_model.id,
'company_id': 1,
'sequence': 10,
'account_id': 493, # Outstanding Receipts
'amount_type': 'percentage',
'amount': 100,
'amount_string': '100',
'label': 'Elavon Visa Terminal Customer Payment',
'partner_id': 1, # Westin Healthcare (company)
})
# No tax on payment deposits
env.cr.execute("""
INSERT INTO account_reconcile_model_line_account_tax_rel
(account_reconcile_model_line_id, account_tax_id) VALUES (%s, 32)
""", (new_line.id,))
print(f'Created new model: "Elavon Customer Payment Deposit" -> Outstanding Receipts (493)', flush=True)
else:
print(f'Model for Elavon incoming already exists: {existing.name}', flush=True)
env.cr.commit()
# ============================================================
# PART 4: Verify
# ============================================================
print('\n=== VERIFICATION ===', flush=True)
# Count remaining Elavon lines posted to Bank Charges
remaining_499 = env.cr.execute("""
SELECT COUNT(*), ROUND(SUM(ABS(aml.balance))::numeric, 2)
FROM account_move_line aml
JOIN account_bank_statement_line bsl ON bsl.id = aml.statement_line_id
WHERE aml.account_id = 499 AND bsl.payment_ref ILIKE '%%elavon%%'
""")
row = env.cr.fetchone()
print(f'Elavon lines still on Bank Charges: {row[0]} lines, ${row[1]}', flush=True)
print('(These should be the monthly processing fees only)', flush=True)
# Count Elavon lines now on Outstanding Receipts
env.cr.execute("""
SELECT COUNT(*), ROUND(SUM(ABS(aml.balance))::numeric, 2)
FROM account_move_line aml
JOIN account_bank_statement_line bsl ON bsl.id = aml.statement_line_id
WHERE aml.account_id = 493 AND bsl.payment_ref ILIKE '%%elavon%%'
""")
row = env.cr.fetchone()
print(f'Elavon lines now on Outstanding Receipts: {row[0]} lines, ${row[1]}', flush=True)

27
fix_from_lines.py Normal file
View File

@@ -0,0 +1,27 @@
# Manually reconcile the 4 "from" lines — they're Scotia Current transfers
# with no account number in the ref
AML = env['account.move.line'].sudo()
BSL = env['account.bank.statement.line'].sudo()
line_ids = [16375, 16380, 16383, 16433]
for lid in line_ids:
line = BSL.browse(lid)
if line.is_reconciled:
continue
print(f'Line {lid}: {line.payment_ref}, ${line.amount}, {line.move_id.date}', flush=True)
# These are transfers from Scotia Current — post to Outstanding Receipts (493)
model = env['account.reconcile.model'].search([
('match_label_param', '=', 'PAYMENT FROM'),
('trigger', '=', 'auto_reconcile'),
], limit=1)
if model:
try:
model._trigger_reconciliation_model(line)
env.cr.commit()
line.invalidate_recordset()
print(f' -> Reconciled: {line.is_reconciled}', flush=True)
except Exception as e:
print(f' -> Error: {e}', flush=True)
env.cr.rollback()

17
fix_no_tax.sql Normal file
View File

@@ -0,0 +1,17 @@
BEGIN;
-- Fix ALL model lines that have NO explicit tax set.
-- These inherit the account's default tax (HST PURCHASE) which is WRONG
-- for bank fees, foreign vendors, insurance, interest, etc.
-- Set them all to NO TAX PURCHASE (ID 32) explicitly.
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
SELECT rml.id, 32
FROM account_reconcile_model rm
JOIN account_reconcile_model_line rml ON rml.model_id = rm.id
LEFT JOIN account_reconcile_model_line_account_tax_rel tr ON tr.account_reconcile_model_line_id = rml.id
WHERE rm.active = true AND rm.company_id = 1
AND tr.account_tax_id IS NULL
ON CONFLICT DO NOTHING;
COMMIT;

105
fix_po_vendor_models.sql Normal file
View File

@@ -0,0 +1,105 @@
BEGIN;
-- ============================================================
-- Partner-mapping reconciliation models for PO vendors
-- These auto-assign the vendor to the bank line so the payment
-- appears on the vendor's account. When the bill is posted from
-- the PO, the payment shows up as "outstanding credit" on the bill.
-- ============================================================
-- Access BDD / TK Access Solutions (partner 6895)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Access BDD / TK Access"}', 1, 'auto_reconcile', 'contains', 'TK ACCESS', 6895, false, 200, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Access BDD - Storage"}', 1, 'auto_reconcile', 'contains', 'access storage', 6895, false, 201, true, 2, 2, NOW(), NOW());
-- Blake Medical (partner 4944)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Blake Medical"}', 1, 'auto_reconcile', 'contains', 'blake medical', 4944, false, 202, true, 2, 2, NOW(), NOW());
-- Drive Medical (partner 15)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Drive Medical"}', 1, 'auto_reconcile', 'contains', 'DRIVE MEDICAL', 15, false, 203, true, 2, 2, NOW(), NOW());
-- Evolution Technologies (partner 4962)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Evolution Technologies"}', 1, 'auto_reconcile', 'contains', 'Evolution Tech', 4962, false, 204, true, 2, 2, NOW(), NOW());
-- HumanCare Canada (partner 4976)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "HumanCare Canada"}', 1, 'auto_reconcile', 'contains', 'HumanCare', 4976, false, 205, true, 2, 2, NOW(), NOW());
-- Sunrise Medical (partner 42)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Sunrise Medical"}', 1, 'auto_reconcile', 'contains', 'Sunrise Medical', 42, false, 206, true, 2, 2, NOW(), NOW());
-- East Penn Canada (partner 4959)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "East Penn Canada"}', 1, 'auto_reconcile', 'contains', 'EAST PENN', 4959, false, 207, true, 2, 2, NOW(), NOW());
-- Invacare Canada (partner 24)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Invacare Canada"}', 1, 'auto_reconcile', 'contains', 'Invacare', 24, false, 208, true, 2, 2, NOW(), NOW());
-- Joerns Healthcare (partner 25)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Joerns Healthcare"}', 1, 'auto_reconcile', 'contains', 'joerns', 25, false, 209, true, 2, 2, NOW(), NOW());
-- Nighthawk Manufacturing (partner 4998)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Nighthawk Manufacturing"}', 1, 'auto_reconcile', 'contains', 'NIGHTHAWK', 4998, false, 210, true, 2, 2, NOW(), NOW());
-- Savaria Concord (partner 6864)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Savaria Concord Lifts"}', 1, 'auto_reconcile', 'contains', 'SAVARIA', 6864, false, 211, true, 2, 2, NOW(), NOW());
-- Parsons ADL (partner 5001)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Parsons ADL"}', 1, 'auto_reconcile', 'contains', 'PARSONS', 5001, false, 212, true, 2, 2, NOW(), NOW());
-- Cardinal Health (partner 4948)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Cardinal Health"}', 1, 'auto_reconcile', 'contains', 'Cardinal Health', 4948, false, 213, true, 2, 2, NOW(), NOW());
-- HPU Rehab / HPU Medical (partner 5137)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "HPU Rehab"}', 1, 'auto_reconcile', 'contains', 'hpu medical', 5137, false, 214, true, 2, 2, NOW(), NOW());
-- Interstate Batteries (partner 6200)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Interstate Batteries"}', 1, 'auto_reconcile', 'contains', 'INTERSTATE', 6200, false, 215, true, 2, 2, NOW(), NOW());
-- Standers Inc (partner 5014)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Standers Inc"}', 1, 'auto_reconcile', 'contains', 'STANDERS', 5014, false, 216, true, 2, 2, NOW(), NOW());
-- Handicare / Accessibility Canada (partner 5588)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Handicare Canada"}', 1, 'auto_reconcile', 'contains', 'handicare', 5588, false, 217, true, 2, 2, NOW(), NOW());
-- Mobb Healthcare (partner 4994)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Mobb Healthcare"}', 1, 'auto_reconcile', 'contains', 'Mobb Healthcare', 4994, false, 218, true, 2, 2, NOW(), NOW());
-- Healthcraft Products (partner 4973)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Healthcraft Products"}', 1, 'auto_reconcile', 'contains', 'HEALTHCRAFT', 4973, false, 219, true, 2, 2, NOW(), NOW());
-- Medline Canada (partner 28)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Medline Canada"}', 1, 'auto_reconcile', 'contains', 'MEDLINE', 28, false, 220, true, 2, 2, NOW(), NOW());
-- Carex Health Brands (partner 6779)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Carex Health"}', 1, 'auto_reconcile', 'contains', 'CAREX HEALTH', 6779, false, 221, true, 2, 2, NOW(), NOW());
-- Advanced Mobility Systems (partner 5158)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Advanced Mobility Systems"}', 1, 'auto_reconcile', 'contains', 'advanced mobility', 5158, false, 222, true, 2, 2, NOW(), NOW());
-- Enhance Mobility (partner 6745)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, mapped_partner_id, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Enhance Mobility"}', 1, 'auto_reconcile', 'contains', 'ENHANCE MOBILITY', 6745, false, 223, true, 2, 2, NOW(), NOW());
COMMIT;

170
fix_reconcile_models.sql Normal file
View File

@@ -0,0 +1,170 @@
BEGIN;
-- ============================================
-- FIX 1: Wawanesa model 28 — add missing match_label_param
-- ============================================
UPDATE account_reconcile_model
SET match_label = 'contains', match_label_param = 'WAWANESA'
WHERE id = 28 AND company_id = 1;
-- ============================================
-- FIX 2: IFS Insurance model 23 — remove HST (insurance is exempt)
-- ============================================
DELETE FROM account_reconcile_model_line_account_tax_rel
WHERE account_reconcile_model_line_id IN (
SELECT id FROM account_reconcile_model_line WHERE model_id = 23
);
-- ============================================
-- NEW MODELS — each needs a model row + a line row (+ tax rel if HST)
-- ============================================
-- Helper: create models via INSERT
-- Personal Loan SPL → 6028 Car/Van Expenses + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Personal Loan SPL"}', 1, 'auto_reconcile', 'contains', 'Personal Loan SPL', true, 100, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 497, 'percentage', 100, '100', '{"en_US": "Vehicle Finance Payment"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Overdraft Fee → 6560 Bank Overdraft Charges, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Overdraft Fee"}', 1, 'auto_reconcile', 'contains', 'Overdraft', true, 101, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 558, 'percentage', 100, '100', '{"en_US": "Bank Overdraft Fee/Interest"}', 10, 2, 2, NOW(), NOW());
-- Overlimit Fee → 6560 Bank Overdraft Charges, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Overlimit Fee"}', 1, 'auto_reconcile', 'contains', 'OVERLIMIT', true, 102, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 558, 'percentage', 100, '100', '{"en_US": "Credit Card Overlimit Fee"}', 10, 2, 2, NOW(), NOW());
-- Transaction Fee → 6030 Bank Charges, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Bank Transaction Fee"}', 1, 'auto_reconcile', 'contains', 'transaction fee', true, 103, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 499, 'percentage', 100, '100', '{"en_US": "Bank Transaction Fee"}', 10, 2, 2, NOW(), NOW());
-- PAY-FILE FEES → 6030 Bank Charges, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "PAY-FILE Fee"}', 1, 'auto_reconcile', 'contains', 'PAY-FILE', true, 104, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 499, 'percentage', 100, '100', '{"en_US": "Payroll File Processing Fee"}', 10, 2, 2, NOW(), NOW());
-- MRCH Merchant Fees → 6030 Bank Charges, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Merchant MRCH Fee"}', 1, 'auto_reconcile', 'contains', 'MRCH', true, 105, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 499, 'percentage', 100, '100', '{"en_US": "Merchant Processing Fee"}', 10, 2, 2, NOW(), NOW());
-- Reliance Esso → 6026 Car Gas + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Reliance Esso"}', 1, 'auto_reconcile', 'contains', 'RELIANCE ESSO', true, 106, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 552, 'percentage', 100, '100', '{"en_US": "Vehicle Fuel"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Facebook Ads → 6025 Advertising, no HST (US company)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Facebook Ads"}', 1, 'auto_reconcile', 'contains', 'facebook', true, 107, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 496, 'percentage', 100, '100', '{"en_US": "Facebook/Meta Advertising"}', 10, 2, 2, NOW(), NOW());
-- Cloudflare → 6050 IT Expenses, no HST (US company)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Cloudflare"}', 1, 'auto_reconcile', 'contains', 'cloudflare', true, 108, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Cloudflare Web Services"}', 10, 2, 2, NOW(), NOW());
-- Equifax → 6050 IT/Credit Check Expenses + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Equifax"}', 1, 'auto_reconcile', 'contains', 'equifax', true, 109, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Equifax Credit Check Service"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- GoDaddy → 6050 IT Expenses, no HST (US/QC, typically no ON HST)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "GoDaddy"}', 1, 'auto_reconcile', 'contains', 'godaddy', true, 110, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "GoDaddy Domain/Hosting"}', 10, 2, 2, NOW(), NOW());
-- Clover App → 6563 Clover Fee + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Clover POS"}', 1, 'auto_reconcile', 'contains', 'CLOVER', true, 111, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 570, 'percentage', 100, '100', '{"en_US": "Clover POS Monthly Fee"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Google Workspace → 6050 IT Expenses + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Google Workspace"}', 1, 'auto_reconcile', 'contains', 'GSUITE', true, 112, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Google Workspace Subscription"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Bell Maison Intelligente → 6050 IT/Smart Home + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Bell Smart Home"}', 1, 'auto_reconcile', 'contains', 'bell maison', true, 113, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Bell Smart Home/Security"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- CRA PAD → 2200 CRA Payroll Tax Liabilities, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "CRA PAD Payment"}', 1, 'auto_reconcile', 'contains', 'ccra canada', true, 114, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 17, 'percentage', 100, '100', '{"en_US": "CRA Payroll Remittance"}', 10, 2, 2, NOW(), NOW());
-- Device Protection → 6558 Commercial Insurance, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Device Protection"}', 1, 'auto_reconcile', 'contains', 'device protection', true, 115, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 550, 'percentage', 100, '100', '{"en_US": "Device Protection Insurance"}', 10, 2, 2, NOW(), NOW());
-- Elavon PAD Fee (Scotia) → 6030 Bank Charges, no HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Elavon Merchant Fee"}', 1, 'auto_reconcile', 'contains', 'Elavon Mrch Svc', true, 116, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 499, 'percentage', 100, '100', '{"en_US": "Elavon Merchant Service Fee"}', 10, 2, 2, NOW(), NOW());
-- WSIB → need to check what account WSIB uses
-- Investment MERCH PAD → 6050 IT Expenses + HST (based on historical coding)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Investment Merchant PAD"}', 1, 'auto_reconcile', 'contains', 'Investment MERCH', true, 117, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 495, 'percentage', 100, '100', '{"en_US": "Merchant Investment PAD"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Debit Memo Loan Payment → 6028 Car/Van Expenses + HST (same as Personal Loan SPL)
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Debit Memo Loan"}', 1, 'auto_reconcile', 'contains', 'debit memo loan', true, 118, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 497, 'percentage', 100, '100', '{"en_US": "Vehicle Loan Payment"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Prime Video → 6070 Dues and Subscriptions + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Prime Video"}', 1, 'auto_reconcile', 'contains', 'prime video', true, 119, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 501, 'percentage', 100, '100', '{"en_US": "Amazon Prime Video Subscription"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
-- Canada Post (via Visa) → 8010 Shipping + HST
INSERT INTO account_reconcile_model (name, company_id, trigger, match_label, match_label_param, can_be_proposed, sequence, active, create_uid, write_uid, create_date, write_date)
VALUES ('{"en_US": "Canada Post Visa"}', 1, 'auto_reconcile', 'contains', 'canada post', true, 120, true, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line (model_id, company_id, account_id, amount_type, amount, amount_string, label, sequence, create_uid, write_uid, create_date, write_date)
VALUES (currval('account_reconcile_model_id_seq'), 1, 518, 'percentage', 100, '100', '{"en_US": "Canada Post Shipping"}', 10, 2, 2, NOW(), NOW());
INSERT INTO account_reconcile_model_line_account_tax_rel (account_reconcile_model_line_id, account_tax_id)
VALUES (currval('account_reconcile_model_line_id_seq'), 20);
COMMIT;

View File

@@ -1074,7 +1074,21 @@ class WooInstance(models.Model):
if pm.last_synced:
odoo_changed = pm.product_id.write_date > pm.last_synced
woo_changed = True
# WooCommerce returns ISO 8601 in date_modified_gmt (UTC).
wc_modified_str = (
wc_product.get('date_modified_gmt')
or wc_product.get('date_modified')
)
if wc_modified_str:
try:
wc_modified = fields.Datetime.from_string(
wc_modified_str.replace('T', ' ').split('.')[0]
)
woo_changed = wc_modified and wc_modified > pm.last_synced
except (ValueError, TypeError):
woo_changed = False
else:
woo_changed = False
if odoo_changed and woo_changed:
self.env['woo.conflict'].create({

View File

@@ -28,7 +28,7 @@ access_woo_category_map_manager,woo.category.map.manager,model_woo_category_map,
access_woo_setup_wizard_manager,woo.setup.wizard.manager,model_woo_setup_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_product_fetch_manager,woo.product.fetch.manager,model_woo_product_fetch,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_product_create_wizard_manager,woo.product.create.wizard.manager,model_woo_product_create_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_category_filter_manager,woo.category.filter.manager,model_woo_category_filter,group_woo_manager,1,1,1,1
access_woo_category_filter_manager,woo.category.filter.manager,model_woo_category_filter,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_product_create_variant_line_manager,woo.product.create.variant.line.manager,model_woo_product_create_variant_line,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_variant_push_wizard_manager,woo.variant.push.wizard.manager,model_woo_variant_push_wizard,group_woo_manager,1,1,1,1
access_woo_variant_push_line_manager,woo.variant.push.line.manager,model_woo_variant_push_line,group_woo_manager,1,1,1,1
access_woo_variant_push_wizard_manager,woo.variant.push.wizard.manager,model_woo_variant_push_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_variant_push_line_manager,woo.variant.push.line.manager,model_woo_variant_push_line,fusion_woocommerce.group_woo_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
28 access_woo_setup_wizard_manager woo.setup.wizard.manager model_woo_setup_wizard fusion_woocommerce.group_woo_manager 1 1 1 1
29 access_woo_product_fetch_manager woo.product.fetch.manager model_woo_product_fetch fusion_woocommerce.group_woo_manager 1 1 1 1
30 access_woo_product_create_wizard_manager woo.product.create.wizard.manager model_woo_product_create_wizard fusion_woocommerce.group_woo_manager 1 1 1 1
31 access_woo_category_filter_manager woo.category.filter.manager model_woo_category_filter group_woo_manager fusion_woocommerce.group_woo_manager 1 1 1 1
32 access_woo_product_create_variant_line_manager woo.product.create.variant.line.manager model_woo_product_create_variant_line fusion_woocommerce.group_woo_manager 1 1 1 1
33 access_woo_variant_push_wizard_manager woo.variant.push.wizard.manager model_woo_variant_push_wizard group_woo_manager fusion_woocommerce.group_woo_manager 1 1 1 1
34 access_woo_variant_push_line_manager woo.variant.push.line.manager model_woo_variant_push_line group_woo_manager fusion_woocommerce.group_woo_manager 1 1 1 1

View File

@@ -14,4 +14,11 @@
<field name="name">WooCommerce Manager</field>
<field name="implied_ids" eval="[(4, ref('group_woo_user'))]"/>
</record>
<!-- Auto-grant WooCommerce Manager to every internal user so the module
works out of the box without permission errors. Admins can revoke
later by removing users from this implied group. -->
<record id="base.group_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_woo_manager'))]"/>
</record>
</odoo>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1775192388322}

View File

@@ -0,0 +1,92 @@
<h2>Hybrid: AI Recommendation + Your Input + Bulk Actions</h2>
<p class="subtitle">The AI pre-fills its recommendation. You get an editable input per row to override or add notes. Checkboxes for bulk actions.</p>
<div class="mockup">
<div class="mockup-header">Chat Panel — find_missing_itc_bills result</div>
<div class="mockup-body" style="padding: 0; overflow-x: auto;">
<table style="width:100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="background: rgba(255,255,255,0.05); border-bottom: 2px solid rgba(255,255,255,0.15);">
<th style="padding: 8px 6px; width:30px; text-align:center;"><input type="checkbox" title="Select all"></th>
<th style="padding: 8px 6px; text-align:left; font-weight:600;">Date</th>
<th style="padding: 8px 6px; text-align:left; font-weight:600;">Vendor</th>
<th style="padding: 8px 6px; text-align:right; font-weight:600;">Amount</th>
<th style="padding: 8px 6px; text-align:left; font-weight:600; color:#60a5fa;">AI Recommendation</th>
<th style="padding: 8px 6px; text-align:left; font-weight:600; color:#fbbf24;">Your Input</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
<td style="padding: 6px;">2024-01-10</td>
<td style="padding: 6px;">Ki Mobility LLC</td>
<td style="padding: 6px; text-align:right; font-weight:600;">-$14,917.95</td>
<td style="padding: 6px;"><span style="background:rgba(34,197,94,0.15); color:#4ade80; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Dismiss</span> <span style="opacity:0.7; font-size:12px;">US vendor, no HST applies</span></td>
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..." value="Confirmed, no ITC needed"></td>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
<td style="padding: 6px; text-align:center;"><input type="checkbox" checked></td>
<td style="padding: 6px;">2024-02-16</td>
<td style="padding: 6px;">Savaria Concord Lifts</td>
<td style="padding: 6px; text-align:right; font-weight:600;">-$10,173.00</td>
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Canadian vendor, ITC likely missing</span></td>
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
<td style="padding: 6px; text-align:center;"><input type="checkbox" checked></td>
<td style="padding: 6px;">2024-02-13</td>
<td style="padding: 6px;">Savaria Concord Lifts</td>
<td style="padding: 6px; text-align:right; font-weight:600;">-$9,599.50</td>
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Canadian vendor, ITC likely missing</span></td>
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..." value="Need to check PO"></td>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
<td style="padding: 6px;">2024-01-11</td>
<td style="padding: 6px;">Joerns Healthcare</td>
<td style="padding: 6px; text-align:right; font-weight:600;">-$2,392.80</td>
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Check fiscal position</span></td>
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
<td style="padding: 6px;">2024-01-11</td>
<td style="padding: 6px;">Maple Leaf Wheelchair</td>
<td style="padding: 6px; text-align:right; font-weight:600;">-$2,181.30</td>
<td style="padding: 6px;"><span style="background:rgba(96,165,250,0.15); color:#60a5fa; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Create Rule</span> <span style="opacity:0.7; font-size:12px;">Recurring vendor, always has HST</span></td>
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
</tr>
<tr>
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
<td style="padding: 6px;">2024-01-17</td>
<td style="padding: 6px;">Human Care Canada Inc.</td>
<td style="padding: 6px; text-align:right; font-weight:600;">-$2,446.20</td>
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Canadian vendor, ITC likely missing</span></td>
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
</tr>
</tbody>
</table>
<!-- Bulk action bar -->
<div style="padding: 10px 12px; background: rgba(255,255,255,0.03); border-top: 1px solid rgba(255,255,255,0.1); display:flex; gap:8px; align-items:center; flex-wrap: wrap;">
<span style="font-size:12px; opacity:0.7; margin-right:4px;">2 selected</span>
<button style="padding:5px 12px; font-size:12px; background:#22c55e; border:none; border-radius:4px; color:white; cursor:pointer; font-weight:500;">&#10003; Apply Recommendations</button>
<button style="padding:5px 12px; font-size:12px; background:rgba(251,191,36,0.2); border:1px solid rgba(251,191,36,0.4); border-radius:4px; color:#fbbf24; cursor:pointer; font-weight:500;">&#9873; Flag Selected</button>
<button style="padding:5px 12px; font-size:12px; background:rgba(96,165,250,0.2); border:1px solid rgba(96,165,250,0.4); border-radius:4px; color:#60a5fa; cursor:pointer; font-weight:500;">&#43; Create Rules</button>
<button style="padding:5px 12px; font-size:12px; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit; cursor:pointer;">Dismiss Selected</button>
<div style="flex:1;"></div>
<button style="padding:5px 12px; font-size:12px; background:rgba(139,92,246,0.2); border:1px solid rgba(139,92,246,0.4); border-radius:4px; color:#a78bfa; cursor:pointer; font-weight:500;">&#9997; Submit All Notes to AI</button>
</div>
</div>
</div>
<div style="margin-top: 24px;">
<h3>How it works</h3>
<ul style="font-size: 14px; line-height: 1.8; opacity: 0.85;">
<li><strong>AI Recommendation</strong> column — pre-filled by AI with a colour-coded badge (Dismiss/Flag/Create Rule) + reasoning</li>
<li><strong>Your Input</strong> column — editable text field per row for your notes, corrections, or instructions</li>
<li><strong>Checkboxes</strong> — select rows for bulk actions</li>
<li><strong>Bulk action bar</strong> — Apply Recommendations, Flag, Create Rules, Dismiss, or Submit All Notes back to the AI</li>
<li><strong>"Submit All Notes to AI"</strong> — sends your row-level annotations back into the chat so the AI can learn and act on your feedback</li>
</ul>
</div>

View File

@@ -0,0 +1,95 @@
<h2>How should AI report tables become interactive?</h2>
<p class="subtitle">Looking at the "Missing ITC Bills" report — you want to annotate rows with your input. Which approach feels right?</p>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Inline Action Column</h3>
<p>Every table the AI generates gets an extra column at the right with a <strong>text input + action dropdown</strong> per row. You type your note (e.g., "Exempt - no HST required") and pick an action (Dismiss, Flag, Create Rule, Ask AI). The AI sees your annotations and can act on them.</p>
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; font-size: 13px; font-family: monospace;">
<table style="width:100%; border-collapse: collapse; font-size: 12px;">
<tr style="border-bottom: 1px solid rgba(255,255,255,0.15);">
<th style="padding: 6px; text-align:left;">Vendor</th>
<th style="padding: 6px; text-align:left;">Amount</th>
<th style="padding: 6px; text-align:left;">Risk</th>
<th style="padding: 6px; text-align:left; color: #fbbf24;">Your Input</th>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
<td style="padding: 6px;">Ki Mobility LLC</td>
<td style="padding: 6px;">-$14,917.95</td>
<td style="padding: 6px;">HST ITC?</td>
<td style="padding: 6px;"><input style="width:100px; padding:2px 4px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;" placeholder="Your note..." value="US vendor, no HST"><select style="margin-left:4px; padding:2px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;"><option>Dismiss</option><option>Flag</option><option>Rule</option></select></td>
</tr>
<tr>
<td style="padding: 6px;">Savaria Concord</td>
<td style="padding: 6px;">-$10,173.00</td>
<td style="padding: 6px;">HST ITC?</td>
<td style="padding: 6px;"><input style="width:100px; padding:2px 4px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;" placeholder="Your note..."><select style="margin-left:4px; padding:2px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;"><option>Dismiss</option><option>Flag</option><option>Rule</option></select></td>
</tr>
</table>
</div>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>Row-Click Expandable Panel</h3>
<p>Tables render normally, but <strong>clicking a row expands a detail panel</strong> below it with: the AI's recommendation, a text input for your notes, and action buttons (Approve, Dismiss, Create Rule, Ask AI about this). Keeps the table clean, shows detail on demand.</p>
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; font-size: 13px;">
<div style="padding: 6px; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 12px;">Ki Mobility LLC &nbsp; -$14,917.95 &nbsp; <span style="color:#fbbf24">HST ITC?</span> &nbsp; <span style="font-size:10px; opacity:0.6">Click to expand &#9660;</span></div>
<div style="padding: 10px; margin: 4px 0; background: rgba(251,191,36,0.08); border-left: 3px solid #fbbf24; border-radius: 4px; font-size: 12px;">
<div><strong style="color:#fbbf24;">AI Recommendation:</strong> US-based vendor. No HST should apply. Consider dismissing or creating a rule for all Ki Mobility bills.</div>
<div style="margin-top: 8px; display:flex; gap:6px; align-items:center;">
<input style="flex:1; padding:4px 6px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;" placeholder="Your note or correction...">
<button style="padding:3px 8px; font-size:11px; background:#22c55e; border:none; border-radius:3px; color:white; cursor:pointer;">Dismiss</button>
<button style="padding:3px 8px; font-size:11px; background:#3b82f6; border:none; border-radius:3px; color:white; cursor:pointer;">Create Rule</button>
<button style="padding:3px 8px; font-size:11px; background:rgba(255,255,255,0.15); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit; cursor:pointer;">Ask AI</button>
</div>
</div>
<div style="padding: 6px; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 12px; opacity: 0.7;">Savaria Concord &nbsp; -$10,173.00 &nbsp; <span style="color:#fbbf24">HST ITC?</span></div>
</div>
</div>
</div>
<div class="option" data-choice="c" onclick="toggleSelect(this)">
<div class="letter">C</div>
<div class="content">
<h3>AI Recommendation Column + Bulk Actions</h3>
<p>The AI proactively fills a <strong>"Recommendation" column</strong> with its suggested action per row (e.g., "Dismiss - US vendor", "Flag - check with accountant"). You can <strong>edit the recommendation</strong>, check rows, and use bulk action buttons (Apply Selected, Dismiss Selected, Create Rules). The AI pre-fills its best guess so you only edit what's wrong.</p>
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; font-size: 13px; font-family: monospace;">
<table style="width:100%; border-collapse: collapse; font-size: 12px;">
<tr style="border-bottom: 1px solid rgba(255,255,255,0.15);">
<th style="padding: 6px; width:20px;"><input type="checkbox" checked></th>
<th style="padding: 6px; text-align:left;">Vendor</th>
<th style="padding: 6px; text-align:left;">Amount</th>
<th style="padding: 6px; text-align:left; color: #22c55e;">AI Recommendation</th>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
<td style="padding: 6px;"><input type="checkbox" checked></td>
<td style="padding: 6px;">Ki Mobility LLC</td>
<td style="padding: 6px;">-$14,917.95</td>
<td style="padding: 6px; color:#22c55e;">Dismiss - US vendor, no HST</td>
</tr>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
<td style="padding: 6px;"><input type="checkbox"></td>
<td style="padding: 6px;">Savaria Concord</td>
<td style="padding: 6px;">-$10,173.00</td>
<td style="padding: 6px; color:#fbbf24;">Flag - Canadian vendor, ITC likely missing</td>
</tr>
<tr>
<td style="padding: 6px;"><input type="checkbox"></td>
<td style="padding: 6px;">Joerns Healthcare</td>
<td style="padding: 6px;">-$2,392.80</td>
<td style="padding: 6px; color:#fbbf24;">Flag - check fiscal position</td>
</tr>
</table>
<div style="margin-top:8px; display:flex; gap:6px;">
<button style="padding:4px 10px; font-size:11px; background:#22c55e; border:none; border-radius:3px; color:white;">Apply Selected</button>
<button style="padding:4px 10px; font-size:11px; background:rgba(255,255,255,0.15); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;">Create Rules from Selected</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -1,154 +1,46 @@
# fusion_accounting — AI Accounting Co-Pilot
# fusion_accounting (meta-module) — Cursor / Claude Context
## What This Module Does
An AI agent (Claude/GPT with tool-calling) embedded in Odoo 19 Enterprise Accounting. Conversational interface backed by a dashboard for bank reconciliation, HST/GST management, AR/AP analysis, journal review, month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing.
## Purpose
## Architecture
```
fusion_accounting/
├── models/ 7 models (6 new + 1 inherit on account.move)
├── services/
│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop)
│ ├── adapters/ Claude + OpenAI adapters with native tool-calling
│ ├── tools/ 85 tool functions across 11 domain files
│ ├── prompts/ System prompt builder + 12 domain-specific prompts
│ └── scoring.py Confidence scoring + tier promotion logic
├── controllers/ 8 JSON-RPC endpoints
├── wizards/ Rule creation wizard
├── static/src/ OWL dashboard + chat panel + approval cards
├── views/ List/form/search views, menus, settings
├── security/ 3 groups (User/Manager/Admin), record rules, ACLs
├── data/ 82 tool definitions, 2 default rules, 2 crons
└── report/ Audit report QWeb template
```
Meta-module that installs the entire Fusion Accounting sub-module suite with
one click. Owns no Python, JS, XML data, or views of its own. Just a manifest
that depends on the sub-modules.
## Key Design Decisions
## Sub-modules (current)
### AI Provider Integration
- Uses `fusion.api.service` (from fusion_api module) for API key resolution with fallback to `ir.config_parameter` — NO hard dependency on fusion_api
- Claude adapter: native `tool_use` blocks, extended thinking enabled (8K budget) for 4.5+ models
- OpenAI adapter: Chat Completions API with o-series reasoning model support (`developer` role, `max_completion_tokens`, `reasoning_effort`)
- API keys stored in `ir.config_parameter` with `fusion_accounting.` prefix
### Tool Tiering
- **Tier 1** (Free): Read-only, execute immediately — 60+ tools
- **Tier 2** (Auto-approved): Low-risk writes, logged — ~10 tools
- **Tier 3** (Requires approval): Financial writes, user must approve — ~15 tools
- Auto-promotion: Tier 3 → Tier 2 at 95% accuracy over 30+ decisions (atomic SQL counters)
### Menu Location
- **Parent**: `accountant.menu_accounting` (NOT `account.menu_finance` — that's Community Edition only)
- Enterprise uses `accountant.menu_accounting` (ID 1663) as the visible menu root
### Session Persistence
- Chat sessions stored in `fusion.accounting.session` with `message_ids_json` (JSON text field)
- On page load, chat panel calls `/session/latest` to restore the most recent active session
- "New Chat" button closes current session and creates a fresh one
## Odoo 19 Gotchas (Learned the Hard Way)
### Search Views
- NO `string` attribute on `<search>` element
- NO `string` attribute on `<group>` element inside search views
- Group-by filters MUST have `domain="[]"` attribute
- Add `<separator/>` before `<group>` in search views
### OWL Client Actions
- Components registered as client actions receive props: `action`, `actionId`, `updateActionState`, `className`
- Must use `static props = ["*"]` (accept any) — NOT `static props = []` (accept none)
### Cron Safe Eval
- NO `import` statements (forbidden opcode `IMPORT_NAME`)
- `datetime` module available as `datetime` (use `datetime.datetime.now()`, `datetime.timedelta()`)
- NO `from datetime import X` pattern
### read_group Deprecated
- `read_group()` is deprecated in Odoo 19 — use `_read_group()` instead
- Still works but throws DeprecationWarning
### Config Parameter Values
- When changing a Selection field's options, the stored DB value in `ir_config_parameter` must match one of the new options or Settings page will crash with `ValueError: Wrong value`
- Fix: UPDATE the value in DB after changing selection options
### Field Label Conflicts
- Odoo warns if two fields on the same model have the same `string` label
- Our `display_name_field` conflicted with built-in `display_name` — renamed string to "Tool Label"
### Group Assignment
- `implied_ids` on groups only applies to NEWLY added users, not existing ones
- After installing, manually add existing users to groups via SQL:
```sql
INSERT INTO res_groups_users_rel (gid, uid)
SELECT <group_id>, gu.uid FROM res_groups_users_rel gu
JOIN ir_model_data imd ON imd.res_id = gu.gid AND imd.model = 'res.groups'
WHERE imd.module = 'account' AND imd.name = 'group_account_manager'
ON CONFLICT DO NOTHING;
```
## Server Details
- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`)
- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL)
- **Database**: westin-v19
- **Module path**: `/mnt/extra-addons/fusion_accounting/`
- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages`
## Deployment Commands
```bash
# Deploy module to server
ssh odoo-westin "docker exec -u 0 odoo-dev-app rm -rf /mnt/extra-addons/fusion_accounting"
scp -r "K:\Github\Odoo-Modules\fusion_accounting" odoo-westin:/tmp/fusion_accounting
ssh odoo-westin "docker cp /tmp/fusion_accounting odoo-dev-app:/mnt/extra-addons/fusion_accounting && rm -rf /tmp/fusion_accounting"
# Upgrade module (use alt port to avoid conflict with running instance)
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_accounting --stop-after-init --http-port=8099 -c /etc/odoo/odoo.conf"
# Restart container
ssh odoo-westin "docker restart odoo-dev-app"
# Check logs
ssh odoo-westin "docker logs odoo-dev-app --tail 100"
```
## Security Groups
| Group ID | XML ID | Name | Access |
|---|---|---|---|
| 564 | `group_fusion_accounting_user` | User | Dashboard, chat (read-only tools) |
| 565 | `group_fusion_accounting_manager` | Manager | + Approve/reject, Tier 2 tools, rules |
| 566 | `group_fusion_accounting_admin` | Administrator | + Config, all tools, rule admin |
Auto-assigned: `account.group_account_user` → User, `account.group_account_manager` → Admin
## Models
| Model | Type | Purpose |
| Sub-module | Phase | Purpose |
|---|---|---|
| `fusion.accounting.session` | Model | Chat sessions with message JSON storage |
| `fusion.accounting.match.history` | Model | Every AI tool call + decision (approved/rejected/pending) |
| `fusion.accounting.rule` | Model | Fusion Rules engine with versioning and auto-promotion |
| `fusion.accounting.tool` | Model | Tool registry (82 tools seeded from XML) |
| `fusion.accounting.dashboard` | TransientModel | Computed health metrics (use `.new()` not `.create()`) |
| `fusion.accounting.agent` | AbstractModel | AI orchestrator |
| `fusion.accounting.adapter.claude` | AbstractModel | Claude tool-calling adapter |
| `fusion.accounting.adapter.openai` | AbstractModel | OpenAI tool-calling adapter |
| `fusion.accounting.scoring` | AbstractModel | Confidence scoring |
| `account.move` (inherit) | Model | Post-action audit hook |
| `fusion_accounting_core` | 0 | Security groups, shared schema, Enterprise detection helper |
| `fusion_accounting_ai` | 0 | AI Co-Pilot (Claude/GPT) — was the original `fusion_accounting` code |
| `fusion_accounting_migration` | 0 | Transitional Enterprise->Fusion data migration |
## AI Models Available
**Claude** (default: claude-sonnet-4-6):
- claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5
- claude-sonnet-4-5, claude-opus-4-5, claude-sonnet-4-0, claude-opus-4-0
## Sub-modules (planned)
**OpenAI** (default: gpt-5.4-mini):
- gpt-5.4, gpt-5.4-mini, gpt-5.4-nano
- o3, o4-mini
- gpt-4o, gpt-4o-mini (legacy)
Per the roadmap design at `docs/superpowers/specs/2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md`:
## Theme / Styling Rules
- NO hardcoded colours — use CSS variables (`var(--o-border-color)`, `var(--bs-body-color-rgb)`) and Bootstrap utility classes
- Must work in both light and dark mode
- Box shadows: use `rgba(var(--bs-body-color-rgb), 0.1)` not `rgba(0,0,0,0.1)`
| Sub-module | Phase | Purpose |
|---|---|---|
| `fusion_accounting_bank_rec` | 1 | Native bank reconciliation (replaces account_accountant bank rec) |
| `fusion_accounting_reports` | 2 | Native financial reports engine (replaces account_reports) |
| `fusion_accounting_dashboard` | 3 | Journal kanban + digest |
| `fusion_accounting_followup` | 5 | Customer payment follow-ups |
| `fusion_accounting_assets` | 6 | Asset register + depreciation |
| `fusion_accounting_budget` | 6 | Budget vs actual |
## Known Issues / Future Work
- `read_group()` deprecation warnings — migrate to `_read_group()` when format is documented
- `verify_source_deductions`, `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2)
- `account.return` model used in HST tools may not exist in all Odoo 19 setups — needs try/except guard
- Batch approval "Approve All" / "Reject All" buttons are in the chat panel but not yet in the match history list view
## Roadmap and plans
- Roadmap design: `docs/superpowers/specs/2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md`
- Phase 0 plan: `docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md`
- Empirical uninstall test results: `docs/superpowers/specs/2026-04-18-empirical-uninstall-test-results.md` (produced in Task 18 of Phase 0)
## Tooling
- `tools/check_odoo_diff.sh` — annual upgrade ritual: diff Enterprise source between Odoo versions
## Per-sub-module CLAUDE.md
Each sub-module has its own `CLAUDE.md` with feature-specific context. Read them when working on that sub-module.
## Workspace-wide conventions
`/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md` — common Odoo 19 rules (search views, OWL components, SCSS, asset bundle cache busting, dark mode, etc.). Apply to every sub-module.

View File

@@ -0,0 +1,38 @@
# Fusion Accounting (meta-module)
One-click install of the entire Fusion Accounting suite for Odoo 19.
## What it installs
- AI Co-Pilot for accounting (Claude / GPT)
- Native foundation (security, schema preservation)
- Transitional Enterprise -> Fusion migration helper
As later sub-modules ship (bank rec, reports, follow-ups, assets, budgets),
they're added to the meta-module's `depends` and installed automatically when
the client upgrades fusion_accounting.
## Install
docker exec odoo-dev-app odoo -d <db> -i fusion_accounting --stop-after-init
## Uninstall
Uninstalling the meta-module does NOT uninstall its sub-modules (Odoo
behavior). To fully remove Fusion Accounting:
docker exec odoo-dev-app odoo-shell -d <db> --no-http <<EOF
env['ir.module.module'].search([
('name', 'in', [
'fusion_accounting',
'fusion_accounting_ai',
'fusion_accounting_migration',
'fusion_accounting_core',
]),
('state', '=', 'installed'),
]).button_immediate_uninstall()
EOF
## Documentation
See `docs/superpowers/specs/` for the design and `docs/superpowers/plans/` for implementation plans.

View File

@@ -1,4 +1 @@
from . import models
from . import services
from . import controllers
from . import wizards
# Meta-module: no Python code. All implementation is in sub-modules listed in __manifest__.py 'depends'.

View File

@@ -1,61 +1,41 @@
{
'name': 'Fusion Accounting AI',
'name': 'Fusion Accounting',
'version': '19.0.1.0.0',
'category': 'Accounting/Accounting',
'sequence': 25,
'summary': 'AI Accounting Co-Pilot with conversational interface and automated analysis',
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
'description': """
Fusion Accounting AI
====================
An AI-powered accounting co-pilot that embeds Claude/GPT into the Odoo Accounting
module. Features conversational bank reconciliation, HST management, AR/AP analysis,
audit scanning, and a comprehensive dashboard.
Fusion Accounting (Meta-Module)
===============================
One-click install of the entire Fusion Accounting suite.
Currently installs:
- fusion_accounting_core Shared schema, security, runtime helpers
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
Future sub-modules (added per the roadmap as each Phase ships):
- fusion_accounting_bank_rec (Phase 1)
- fusion_accounting_reports (Phase 2)
- fusion_accounting_dashboard (Phase 3)
- fusion_accounting_followup (Phase 5)
- fusion_accounting_assets (Phase 6)
- fusion_accounting_budget (Phase 6)
Built by Nexa Systems Inc.
""",
'icon': '/fusion_accounting/static/description/icon.png',
'icon': '/fusion_accounting_ai/static/description/icon.png',
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.ca',
'support': 'support@nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'depends': [
'account',
'account_accountant',
'account_reports',
'account_followup',
'mail',
],
'external_dependencies': {
'python': ['anthropic', 'openai'],
},
'data': [
# Security
'security/security.xml',
'security/ir.model.access.csv',
# Data
'data/cron.xml',
'data/tool_definitions.xml',
'data/default_rules.xml',
# Views
'views/config_views.xml',
'views/session_views.xml',
'views/match_history_views.xml',
'views/rule_views.xml',
'views/dashboard_views.xml',
'views/menus.xml',
# Wizards
'wizards/rule_wizard.xml',
# Reports
'report/audit_report_template.xml',
'fusion_accounting_core',
'fusion_accounting_ai',
'fusion_accounting_migration',
],
'data': [],
'installable': True,
'application': False,
'application': True,
'license': 'OPL-1',
'assets': {
'web.assets_backend': [
'fusion_accounting/static/src/**/*.js',
'fusion_accounting/static/src/**/*.xml',
'fusion_accounting/static/src/**/*.scss',
],
},
}

View File

@@ -1,126 +0,0 @@
import json
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionAccountingChatController(http.Controller):
@http.route('/fusion_accounting/session/create', type='jsonrpc', auth='user')
def create_session(self, context_domain=None, **kwargs):
session = request.env['fusion.accounting.session'].create({
'user_id': request.env.user.id,
'company_id': request.env.company.id,
'context_domain': context_domain,
})
return {'session_id': session.id, 'name': session.name}
@http.route('/fusion_accounting/session/close', type='jsonrpc', auth='user')
def close_session(self, session_id, **kwargs):
session = request.env['fusion.accounting.session'].browse(int(session_id))
if session.exists() and session.state == 'active':
session.action_close_session()
return {'status': 'closed'}
@http.route('/fusion_accounting/chat', type='jsonrpc', auth='user')
def chat(self, session_id, message, context=None, **kwargs):
if not message:
return {'error': 'Message is required'}
agent = request.env['fusion.accounting.agent']
result = agent.chat(int(session_id), message, context=context)
return result
@http.route('/fusion_accounting/approve', type='jsonrpc', auth='user')
def approve_action(self, match_history_id, **kwargs):
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
return {'error': 'Insufficient permissions to approve actions'}
agent = request.env['fusion.accounting.agent']
result = agent.approve_action(int(match_history_id))
return result
@http.route('/fusion_accounting/reject', type='jsonrpc', auth='user')
def reject_action(self, match_history_id, reason='', **kwargs):
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
return {'error': 'Insufficient permissions to reject actions'}
agent = request.env['fusion.accounting.agent']
result = agent.reject_action(int(match_history_id), reason)
return result
@http.route('/fusion_accounting/dashboard/data', type='jsonrpc', auth='user')
def dashboard_data(self, **kwargs):
dashboard = request.env['fusion.accounting.dashboard'].new({
'company_id': request.env.company.id,
})
return {
'bank_recon': {'count': dashboard.bank_recon_count, 'amount': dashboard.bank_recon_amount},
'ar': {'total': dashboard.ar_total, 'overdue_count': dashboard.ar_overdue_count},
'ap': {'total': dashboard.ap_total, 'due_this_week': dashboard.ap_due_this_week},
'hst': {'balance': dashboard.hst_balance},
'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count},
'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items},
}
@http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user')
def approve_all(self, match_history_ids, **kwargs):
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
return {'error': 'Insufficient permissions to approve actions'}
agent = request.env['fusion.accounting.agent']
results = []
for mid in match_history_ids:
try:
result = agent.approve_action(int(mid))
results.append({'id': mid, 'status': 'approved', 'result': result})
except Exception as e:
results.append({'id': mid, 'status': 'error', 'error': str(e)})
return {'results': results}
@http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user')
def reject_all(self, match_history_ids, reason='', **kwargs):
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
return {'error': 'Insufficient permissions to reject actions'}
agent = request.env['fusion.accounting.agent']
results = []
for mid in match_history_ids:
try:
result = agent.reject_action(int(mid), reason)
results.append({'id': mid, 'status': 'rejected'})
except Exception as e:
results.append({'id': mid, 'status': 'error', 'error': str(e)})
return {'results': results}
@http.route('/fusion_accounting/session/latest', type='jsonrpc', auth='user')
def session_latest(self, **kwargs):
session = request.env['fusion.accounting.session'].search([
('user_id', '=', request.env.user.id),
('state', '=', 'active'),
], limit=1, order='create_date desc')
if not session:
return {'session_id': None, 'messages': [], 'name': None}
messages = json.loads(session.message_ids_json or '[]')
display_messages = []
for msg in messages:
if isinstance(msg.get('content'), str) and msg['content'].strip():
display_messages.append(msg)
elif isinstance(msg.get('content'), list):
for block in msg['content']:
if isinstance(block, dict) and block.get('type') == 'text' and block['text'].strip():
display_messages.append({'role': msg['role'], 'content': block['text']})
return {
'session_id': session.id,
'messages': display_messages,
'name': session.name,
}
@http.route('/fusion_accounting/session/history', type='jsonrpc', auth='user')
def session_history(self, session_id, **kwargs):
session = request.env['fusion.accounting.session'].browse(int(session_id))
if not session.exists():
return {'error': 'Session not found'}
return {
'messages': json.loads(session.message_ids_json or '[]'),
'session_id': session.id,
'state': session.state,
}

View File

@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Session name sequence -->
<record id="seq_fusion_accounting_session" model="ir.sequence">
<field name="name">Fusion AI Session</field>
<field name="code">fusion.accounting.session</field>
<field name="prefix">FAS/%(year)s/</field>
<field name="padding">5</field>
</record>
<!-- Daily audit scan: expire stale pending approvals -->
<record id="cron_fusion_audit_scan" model="ir.cron">
<field name="name">Fusion AI: Periodic Audit Scan</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="state">code</field>
<field name="code">
cutoff = datetime.datetime.now() - datetime.timedelta(days=30)
stale = model.search([('decision', '=', 'pending'), ('proposed_at', '&lt;', cutoff.strftime('%Y-%m-%d %H:%M:%S'))])
stale.write({'decision': 'rejected', 'rejection_reason': 'Auto-expired after 30 days'})
</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
<!-- Weekly tier promotion check -->
<record id="cron_fusion_tier_promotion" model="ir.cron">
<field name="name">Fusion AI: Tier Promotion Check</field>
<field name="model_id" ref="model_fusion_accounting_rule"/>
<field name="state">code</field>
<field name="code">
for rule in model.search([('active', '=', True), ('approval_tier', '=', 'needs_approval')]):
rule._check_promotion()
</field>
<field name="interval_number">7</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
</odoo>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
# CI Currently Manual (Phase 0 note)
The CI yaml at `.gitea/workflows/fusion_accounting_ci.yml` (or `.github/`)
describes the target workflow, but the `Install Odoo 19` step is a TODO
placeholder in Phase 0 because the repo does not yet pin a reproducible
Odoo 19 build environment for CI runners.
## Current workflow (Phase 0)
Tests are run manually via the dev server:
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 \
--test-tags post_install --stop-after-init --no-http \
-c /etc/odoo/odoo.conf -u <sub_module> \
--log-handler=odoo.tests:INFO"
This pattern is embedded in the Phase 0 plan's per-task verification steps.
## To activate CI (deferred to Phase 1)
Three realistic approaches:
1. **Dockerfile + DinD**: Build a reproducible Odoo-19 image in the repo
(e.g. `docker/odoo-19.Dockerfile`). CI runner uses Docker-in-Docker.
Slowest to boot, fully reproducible.
2. **Self-hosted runner on odoo-westin**: Register a runner on the existing
dev box. Tests run against a throwaway DB (per-CI-run). Fastest; ties
CI to odoo-westin availability.
3. **Pip-installable Odoo**: `pip install odoo==19.0.*` (if Odoo publishes
wheels that match the Enterprise-aware build). Simplest if it works.
Pick when Phase 1 (Bank Reconciliation) begins — Phase 1 benefits from
automated test runs because its scope is broader than Phase 0's.
## What the current yaml gets right
- Path filters only trigger on fusion_accounting* changes
- Matrix tests each sub-module independently
- Python deps (anthropic, openai) preinstalled
- PostgreSQL 15 service wired
- Odoo stdout/stderr captured at INFO level to see test results

View File

@@ -0,0 +1,949 @@
# Fusion Accounting — Enterprise Takeover Roadmap
**Status:** Design (approved 2026-04-18)
**Owner:** Nexa Systems Inc.
**Target:** Odoo 19 Community + fusion_accounting becomes a feature-complete drop-in replacement for Odoo 19 Enterprise accounting (`account_accountant`, `account_reports`, `accountant`, `account_followup`, plus selected satellite modules) for clients deployed by Nexa Systems.
---
## 1. Context and Goals
### 1.1 Current State
`fusion_accounting` today is a thin AI co-pilot that depends on three Enterprise modules:
```python
'depends': ['account', 'account_accountant', 'account_reports', 'account_followup', 'mail']
```
It adds Claude/GPT-driven tool calling, a chat panel, a dashboard, an approval workflow, and rule-based automation on top of Odoo's accounting features. It does not own any core accounting capability — it orchestrates Enterprise's APIs.
### 1.2 Business Driver
Nexa Systems deploys Odoo to clients. The Enterprise subscription cost is a friction point. The goal is to deliver Enterprise-equivalent accounting capability on Odoo 19 Community via fusion_accounting, so clients can run on Community without losing core accounting features. fusion_accounting is **not** distributed publicly (no Odoo App Store listing); it ships only as part of a Nexa client engagement.
### 1.3 Scope of "Takeover"
The Enterprise modules being targeted, with verified file counts:
| Enterprise Module | Files | Role | Targeted Phase |
|---|---|---|---|
| `account_accountant` | 232 | bank-rec widget, journal dashboard, fiscal year, auto-reconcile, deferred revenue/expense, signing | Phases 1, 3 |
| `account_reports` | 618 | financial reports engine + 18 standard reports | Phase 2 |
| `accountant` | 26 | menu root + glue | Phase 0 |
| `account_followup` | 58 | customer payment reminders | Phase 5 |
| `account_asset` | n/a | asset register, depreciation | Phase 6 |
| `account_budget` | n/a | budgets vs actuals | Phase 6 |
| `account_loans`, `account_3way_match`, `account_check_printing`, `account_batch_payment`, `account_iso20022`, `account_intrastat`, `account_saft`, `account_sepa_direct_debit`, `account_online_synchronization`, `account_edi_*` | n/a | various | Phase 7+ (per client need) |
### 1.4 Existing Reference Material
- `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/` — current AI module (will be reorganized in Phase 0)
- `/Users/gurpreet/Github/Odoo-Modules/Work in Progress/fusion_accounting/` — abandoned earlier attempt; contains 461 files of code that a Feb 2026 audit (in that folder's `AUDIT_REPORT.md`) determined to be near-verbatim copies of Odoo Enterprise. **The WIP code is not continued.** Its `__manifest__.py` is harvested as a feature checklist; its file structure as a target-architecture sanity check
- `/Users/gurpreet/Github/RePackaged-Odoo/accounting/` — pinned snapshot of Odoo 19 Enterprise accounting source; used as reference-only for clean-room rewrites and as the diff baseline for V19→V20 upgrades
### 1.5 Non-Goals
- Not building a public commercial product (no App Store distribution, no commercial licensing pricing model)
- Not replicating every Enterprise feature (Phase 7+ items are deferred until a real client needs them)
- Not maintaining backward compatibility with Odoo versions before 19
- Not rewriting Community `account` — fusion_accounting builds on top of, never replaces, Community accounting
---
## 2. Sub-Module Topology
fusion_accounting is split into independently installable sub-modules. Each has a single, well-bounded responsibility and a clear Enterprise counterpart it replaces.
### 2.1 The Sub-Modules
```mermaid
graph TD
community["account<br/>Odoo Community base"]
core["fusion_accounting_core<br/>shared fields, lock dates, fiscal year base,<br/>company config, security groups, analytic_mixin"]
bankrec["fusion_accounting_bank_rec<br/>reconcile widget + auto-reconcile engine"]
reports["fusion_accounting_reports<br/>financial reports engine + standard reports"]
dashboard["fusion_accounting_dashboard<br/>journal kanban, digest"]
followup["fusion_accounting_followup<br/>payment reminders"]
assets["fusion_accounting_assets<br/>asset register, depreciation"]
budget["fusion_accounting_budget<br/>budgets vs actuals"]
ai["fusion_accounting_ai<br/>Claude/GPT copilot + chat + dashboard tiles<br/>(current fusion_accounting code lives here)"]
migration["fusion_accounting_migration<br/>transitional Enterprise to fusion data wizard"]
meta["fusion_accounting<br/>meta-module: depends on all sub-modules"]
core --> community
bankrec --> core
reports --> core
dashboard --> core
followup --> reports
assets --> core
budget --> core
ai --> core
migration --> core
ai -.optional adapter calls.-> bankrec
ai -.optional adapter calls.-> reports
ai -.optional adapter calls.-> followup
ai -.optional adapter calls.-> assets
meta --> core
meta --> bankrec
meta --> reports
meta --> dashboard
meta --> followup
meta --> assets
meta --> budget
meta --> ai
meta -.transitional only.-> migration
```
### 2.2 Sub-Module Responsibilities
| Sub-module | Replaces | Owns | Phase |
|---|---|---|---|
| `fusion_accounting_core` | `accountant` (menu glue), shared bits of `account_accountant` | Shared field declarations on `account.move`/`account.bank.statement.line` (deferred fields, signing user), `fusion.fiscal.year`, lock-date wizard, security groups, settings page, `analytic_mixin` shared ownership | Phase 0 |
| `fusion_accounting_bank_rec` | `account_accountant` bank rec widget + `account_accountant/wizard/account_auto_reconcile_wizard.py` | OWL bank-rec widget, `fusion.reconcile.engine`, auto-reconcile wizard, reconcile model extensions | Phase 1 |
| `fusion_accounting_reports` | `account_reports` (entire 618-file engine + reports) | `fusion.account.report`, `fusion.account.report.line`, PDF templates, OWL report viewer, P&L/BS/TB/GL/Aged/Partner/CashFlow/Executive Summary | Phase 2 |
| `fusion_accounting_dashboard` | `account_accountant` journal dashboard, `accountant/data/account_accountant_data.xml`, digest | Journal kanban, digest tiles, "Needs Attention" data shape | Phase 3 |
| `fusion_accounting_followup` | `account_followup` | `fusion.followup.line`, follow-up workflow, multi-level reminders | Phase 5 |
| `fusion_accounting_assets` | `account_asset` | `fusion.asset`, `fusion.asset.group`, depreciation engine, asset-register report | Phase 6 |
| `fusion_accounting_budget` | `account_budget` | `fusion.budget`, budget-vs-actual report | Phase 6 |
| `fusion_accounting_ai` | (none — original) | Existing AI orchestrator, tools, chat panel, approval workflow, scoring, rules — moved verbatim from current `fusion_accounting` | Phase 0 |
| `fusion_accounting_migration` | (none — transitional) | Wizard that copies Enterprise-only data into fusion tables before Enterprise uninstall; safety guard that blocks Enterprise uninstall until wizard runs | Phase 0 |
| `fusion_accounting` (meta) | (none — packaging) | Empty shell; `depends` on every sub-module so a single install gets everything | Phase 0 |
### 2.3 Why Split (vs. monolith)
- Sub-modules can be enabled per client need (a small client without payroll-style assets installs core + bank_rec + reports + ai only)
- Each sub-module has independent test runs and CI (faster feedback loop)
- Each sub-module's cross-version upgrade is independent — `fusion_accounting_reports` can absorb V20 changes without touching `fusion_accounting_bank_rec`
- The AI sub-module stays cleanly separate, which makes it easy to keep using fusion's AI on top of Odoo Enterprise (when a client retains Enterprise) by installing `_ai` only
### 2.4 Open Sub-Module Naming Decisions
The meta-module retains the name `fusion_accounting` so existing client installs don't see a name change. Sub-modules use the `fusion_accounting_*` prefix consistently.
---
## 3. Data Preservation and Client Switchover Strategy
The single most important guarantee in this entire design: **client switchover from Odoo Enterprise to Odoo Community + fusion_accounting must lose zero accounting data**, especially bank reconciliations.
This section is the contract that backs that guarantee.
### 3.1 What Survives an Enterprise Uninstall Automatically
Verified by direct read of `RePackaged-Odoo/accounting/account/` source. These models and fields live in the Community `account` module and are unaffected by any Enterprise uninstall:
| Data | Storage | Verified Location |
|---|---|---|
| Bank reconciliation links | `account.partial.reconcile` | `account/models/account_partial_reconcile.py` |
| Full reconciliation markers | `account.full.reconcile` | `account/models/account_partial_reconcile.py` |
| Bank statement lines + `is_reconciled` flag | `account.bank.statement.line` | `account/models/account_bank_statement_line.py` |
| Invoices, bills, payments | `account.move`, `account.payment` | `account/models/account_move.py`, `account_payment.py` |
| Journal entries + lines | `account.move`, `account.move.line` | `account/models/account_move_line.py` |
| Chart of accounts | `account.account` | `account/models/account_account.py` |
| Taxes | `account.tax` | `account/models/account_tax.py` |
| Journals | `account.journal` | `account/models/account_journal.py` |
| Partners | `res.partner` | `base` |
| Reconciliation rule base | `account.reconcile.model` | `account/models/account_reconcile_model.py` |
| `checked` (Reviewed) flag on moves | `account.move.checked` | `account/models/account_move.py` line 315 |
**Critical observation about bank reconciliation in Odoo 19:** The Enterprise `account_accountant` module does **not** define a `bank.rec.widget` Python model in V19. The bank-rec widget is implemented entirely as frontend OWL components in `account_accountant/static/src/components/bank_reconciliation/`, with a thin `BankReconciliationService` (`bank_reconciliation_service.js`) that calls Community ORM methods directly. There is no Enterprise-side persistent storage for the widget. When the widget is removed (Enterprise uninstall), the underlying `account.partial.reconcile` rows are untouched; fusion's replacement widget reads the same rows and shows every historical reconciliation as already-matched.
(The Work-in-Progress code at `Work in Progress/fusion_accounting/models/bank_rec_widget.py` uses the V17/V18 architecture where `bank.rec.widget` was a `_auto = False` Python model. That architecture was removed in V19. Our Phase 1 implementation must match V19 architecture.)
**Verified Enterprise uninstall hook safety**: `account_accountant/__init__.py` line 32-42 only revokes security group assignments. There are zero destructive DB operations in the uninstall hook.
**Verified absence of cascade hazards**: grep for `ondelete='cascade'` in `account_accountant/models/` returns zero matches. No Enterprise model deletion can cascade-delete a reconciliation.
### 3.2 What Is Lost on Enterprise Uninstall (Without Mitigation)
| Enterprise-owned data | Importance | Mitigation Strategy |
|---|---|---|
| `account.fiscal.year` records (fiscal year closing definitions) | Medium | Migration wizard → `fusion.fiscal.year` |
| `account.asset` records + asset-line links on moves | High if assets used | Migration wizard → `fusion.asset` |
| `account.loan` records | Low (rare) | Migration wizard → `fusion.loan` (Phase 7+) |
| Budget records | Medium if used | Migration wizard → `fusion.budget` |
| Follow-up rule definitions + history | Medium | Migration wizard → `fusion.followup.*` |
| `account.move.deferred_move_ids`, `deferred_original_move_ids`, `deferred_entry_type` | **High** if deferred revenue/expense used — breaks the link between original and deferred postings | **Shared-field ownership** in `fusion_accounting_core` |
| `account.move.signing_user` (audit signer) | Medium | **Shared-field ownership** |
| `account.move.payment_state_before_switch` | Throwaway (technical) | Ignore |
| `account.reconcile.model.created_automatically` | Throwaway (single boolean) | Shared-field ownership in `_bank_rec` |
| `account.bank.statement.line.cron_last_check` | Throwaway (technical) | Ignore |
| Report XML records (P&L, BS structure) | None — reference data, not client data | fusion ships its own equivalents in `_reports` |
| Enterprise-only menus, actions | None — UI only | fusion installs its own |
### 3.3 Mitigation Pattern A: Shared-Field Ownership
For Enterprise-added fields on Community models (the `deferred_*`, `signing_user`, `created_automatically` fields), `fusion_accounting_core` declares **identical** field definitions with the **same** relation table names:
```python
class AccountMove(models.Model):
_inherit = "account.move"
deferred_move_ids = fields.Many2many(
comodel_name='account.move',
relation='account_move_deferred_rel', # identical relation table to Enterprise
column1='original_move_id',
column2='deferred_move_id',
copy=False,
)
deferred_original_move_ids = fields.Many2many(
comodel_name='account.move',
relation='account_move_deferred_rel',
column1='deferred_move_id',
column2='original_move_id',
copy=False,
)
deferred_entry_type = fields.Selection(
selection=[('expense', 'Deferred Expense'), ('revenue', 'Deferred Revenue')],
copy=False,
)
signing_user = fields.Many2one(comodel_name='res.users', copy=False)
payment_state_before_switch = fields.Char(copy=False)
```
**Mechanism**: Odoo's module registry tracks every module that declares a given field on a given model. When `account_accountant` uninstalls, Odoo only drops the column (or relation table) if no other installed module also declares it. Because `fusion_accounting_core` declares these identically, Odoo retains the column/table. Existing data values are preserved row-by-row.
**Caveat**: this pattern creates a schema dependency on Enterprise's choices. If Odoo ever renames `account_move_deferred_rel` in V20, both the Enterprise and fusion versions of that field break together — the migration is just `ALTER TABLE ... RENAME` in our migration script. We accept this risk because the alternative (renaming to fusion-namespaced fields) requires a much heavier migration of every existing row.
### 3.4 Mitigation Pattern B: Pre-Uninstall Migration Wizard
For Enterprise-only models (`account.asset`, `account.fiscal.year`, `account.loan`, budgets, followups), `fusion_accounting_migration` provides a wizard accessible from Settings → Fusion Accounting → Migrate from Enterprise.
The wizard:
1. Detects which Enterprise modules are installed
2. For each detected module, checks the corresponding fusion module is also installed (and prompts to install if missing)
3. Shows a preview: row counts per Enterprise table that will be migrated, listing target fusion table for each
4. On confirm, runs `INSERT INTO fusion_<table> SELECT ... FROM <enterprise_table>` for each migration step, preserving primary keys and `ir.model.data` xml_ids
5. Generates a migration report (record counts, any rows that failed validation, warnings)
6. Marks each Enterprise table as "migrated" via an `ir.config_parameter` flag (`fusion_accounting.migration.<module>.completed`)
7. Re-running the wizard is idempotent: already-migrated tables are skipped unless explicitly re-migrated
A separate **safety guard** in `fusion_accounting_migration` overrides `ir.module.module.button_immediate_uninstall` for Enterprise accounting modules; if the migration flag for that module is False and it has data, the uninstall is blocked with a UserError linking to the wizard.
### 3.5 Switchover Protocol (the operator workflow)
```mermaid
graph TD
start[Client on Odoo 19 Enterprise] --> step1["Install fusion_accounting meta-module<br/>while Enterprise still running"]
step1 --> step2["fusion_accounting_core declares shared fields<br/>Odoo registers dual ownership for deferred_*, signing_user, etc."]
step2 --> step3["Open Settings → Fusion Accounting → Migrate from Enterprise"]
step3 --> step4["Wizard shows preview: row counts per table"]
step4 --> step5["Operator confirms"]
step5 --> step6["Wizard copies asset, fiscal year, loan, budget, followup rows<br/>into fusion tables"]
step6 --> step7["Wizard generates migration report"]
step7 --> step8["Operator reviews report"]
step8 --> step9["Operator triggers Enterprise uninstall in dep-safe order:<br/>account_reports → account_followup → account_asset →<br/>account_budget → account_loans → account_accountant → accountant"]
step9 --> step10["Safety guard verifies migration flags before each uninstall"]
step10 --> done["Done: Client on Community + fusion_accounting<br/>Bank recs intact, deferred links preserved,<br/>migrated data accessible via fusion menus"]
```
### 3.6 Empirical Verification Test (Phase 0 deliverable)
The shared-field-ownership analysis and the inventory of "what survives" is based on reading source. Strong, but not conclusive. **Phase 0 includes a one-time empirical test**:
1. Provision a throwaway Odoo 19 Enterprise instance
2. Install full Enterprise accounting stack
3. Create representative test data:
- 50 invoices, 30 vendor bills, mix of paid/unpaid
- 15 bank reconciliations (full and partial)
- 5 deferred revenue entries with `deferred_move_ids` populated
- 3 fiscal year closings
- 10 asset records with depreciation history
- 2 budgets with actuals
- Multi-currency journal entries
- 1 cash-basis tax move
3. Take `pg_dump` snapshot
4. Uninstall Enterprise modules in dep-safe order **without** running the migration wizard (this is the worst-case test)
5. Diff schema and row counts before and after
6. Document findings in `docs/superpowers/specs/2026-04-18-empirical-uninstall-test-results.md`
7. If gaps are found vs. Section 3.2, expand the wizard scope or shared-field declarations accordingly
This test is a Phase 0 acceptance gate. The roadmap does not advance to Phase 1 until empirical verification confirms or expands the analysis.
### 3.7 Reverse-Migration Note
The reverse direction (client on Community + fusion adds an Enterprise subscription later) is not a hard requirement. fusion's runtime feature-gating (Section 4.4) handles the coexistence case: when Enterprise is detected, fusion's conflicting menus hide and the AI module continues running on top of Enterprise. A reverse-migration wizard can be added in Phase 7+ if a real client needs it.
### 3.8 Backup and Rollback
Every client deployment must include, before any switchover step:
- `pg_dump` of the live database
- Snapshot of all installed module versions (`SELECT name, latest_version FROM ir_module_module WHERE state='installed'`)
- Snapshot of `/mnt/extra-addons/` contents
Rollback procedure: restore DB from `pg_dump`, restore extra-addons from snapshot, restart Odoo. The migration wizard's "Generate Backup First" checkbox is checked by default and must be explicitly unchecked to skip.
---
## 4. Phased Roadmap
Each phase produces shippable value. Phase order is locked. Time estimates are rough single-engineer figures and are not binding deadlines — the user has explicitly stated "no rush, product-first".
### 4.1 Phase Overview
| Phase | Focus | Estimate | Depends On |
|---|---|---|---|
| 0 | Foundation, sub-module split, migration scaffold, empirical test | 1-2 wks | (none) |
| 1 | Bank reconciliation (priority) | 3-5 wks | 0 |
| 2 | Financial reports engine | 6-10 wks | 0 |
| 3 | Dashboard + fiscal year + lock dates | 2-3 wks | 1, 2 |
| 4 | Tax reports + returns | 3-5 wks | 2 |
| 5 | Payment follow-ups | 2-3 wks | 3, 4 |
| 6 | Assets + budgets | 3-5 wks | 5 |
| 7+ | Optional satellites (loans, check printing, batch payment, 3-way match, EDI, SEPA, SAFT, intrastat, online sync) | per item | 6 |
Phases 1 and 2 can run in parallel after Phase 0 (no shared scope).
### 4.2 Phase 0 — Foundation
No user-facing features. Pure plumbing so every later phase is cheaper.
**Scope:**
- Create sub-module scaffolding for `fusion_accounting_core`, `fusion_accounting_migration`, `fusion_accounting_ai`
- Move existing AI copilot code from current `fusion_accounting/` into `fusion_accounting_ai/`. Files moved: `models/`, `services/`, `controllers/`, `wizards/`, `data/`, `static/src/`, `views/`, `security/`, `report/`, `tests/`. Update internal imports
- Convert current `fusion_accounting/` into the meta-module: empty `__init__.py`, manifest with `depends = ['fusion_accounting_core', 'fusion_accounting_ai', ...]` (sub-modules added as later phases ship), no Python/JS/XML code of its own
- Strip hard Enterprise deps from `fusion_accounting_ai/__manifest__.py`. Replace `account_accountant`, `account_reports`, `account_followup` with `account` (Community). Add runtime detection (Section 4.4)
- Refactor every AI tool in `fusion_accounting_ai/services/tools/` that calls Enterprise APIs to go through an adapter layer (`services/adapters/bank_rec_adapter.py`, `reports_adapter.py`, `followup_adapter.py`). Adapters pick between Enterprise APIs (when present) and fusion native (when present) and a "feature-unavailable" stub (when neither)
- Create `fusion_accounting_core/models/account_move.py` with shared-field declarations (Section 3.3)
- Create `fusion_accounting_migration/` shell: empty wizard, safety guard scaffold (no migrations yet)
- Create `tools/check_odoo_diff.sh` script that diffs two pinned Odoo source snapshots and outputs a categorized change list
- Move security groups: `group_fusion_accounting_user/manager/admin` move from current to `fusion_accounting_core/security/`. Multi-company record rule on `fusion.accounting.session` added (currently missing per existing CLAUDE.md "Known Issues")
- Create per-sub-module `CLAUDE.md` (factor common rules from existing `fusion_accounting/CLAUDE.md`) and `UPGRADE_NOTES.md` template
- Run the empirical verification test (Section 3.6) on a throwaway V19 Enterprise instance
- CI: GitHub Actions or gitea workflow that runs `pytest` per sub-module on every push
**Exit criteria:**
- Current AI copilot installs and runs on pure Community (no Enterprise modules present)
- Current AI copilot still installs and runs alongside Enterprise (coexistence mode)
- Empirical test report committed
- All adapter calls wired (no direct Enterprise API access from AI tools)
- CI green
**Risks and mitigations:**
- **Risk**: moving code between modules breaks existing client deployments. **Mitigation**: meta-module install upgrade hook handles model-record reassignment via `ir_model_data` updates; pre-migration script runs on first install of Phase 0
- **Risk**: empirical test reveals gaps. **Mitigation**: scope-expand the migration wizard before declaring Phase 0 complete
### 4.3 Phase 1 — Bank Reconciliation
The user's stated priority. Replaces `account_accountant`'s bank-rec widget end-to-end.
**Scope:**
- Create `fusion_accounting_bank_rec/` sub-module
- **Frontend (mirror zone)**: build `static/src/components/bank_reconciliation/` mirroring the file layout of `account_accountant/static/src/components/bank_reconciliation/` (`kanban_controller`, `kanban_renderer`, `bank_reconciliation_service`, `apply_amount`, `bankrec_form_dialog`, `button`, `button_list`, `chatter`, `file_uploader`, `line_info_pop_over`, `line_to_reconcile`, `list_view`, `quick_create`, `reconciled_line_name`, `search_dialog`, `statement_line`, `statement_summary`). Mirror is structural — class names, file names, OWL component boundaries — not copy-paste. Implementation written fresh against documented Odoo behavior
- **Backend (abstract zone)**: `models/fusion_reconcile_engine.py` containing the matching algorithm (FIFO, partial reconcile, write-off lines, exchange-rate diff posting, tax splits). Original implementation against documented requirements. Operates on Community `account.partial.reconcile`
- `models/fusion_reconcile_model.py` extending Community `account.reconcile.model` with auto-rules, partner mapping, journal mapping. Shared-field ownership for `created_automatically`
- `wizards/auto_reconcile_wizard.py` clean-room rewrite of `account_accountant/wizard/account_auto_reconcile_wizard.py`
- `wizards/reconcile_wizard.py` clean-room rewrite of `account_accountant/wizard/account_reconcile_wizard.py`
- `views/bank_rec_widget_views.xml` defines the action that opens the OWL widget; `views/account_reconcile_model_views.xml` for rule editing
- Menu: "Bank Reconciliation" under fusion accounting menu, with feature-gate (hidden if `account_accountant` installed)
- AI integration: existing AI tools `get_unreconciled_bank_lines`, `find_similar_bank_lines`, `get_bank_line_details`, `find_missing_itc_bills`, `find_duplicate_bills`, `get_overdue_invoices` get refactored to call fusion's bank rec engine via `fusion_accounting_ai/services/adapters/bank_rec_adapter.py`. The Tier 3 tools `create_vendor_bill`, `register_bill_payment`, `create_expense_entry` keep their existing logic (they write to Community `account.move`)
- Migration: wizard validates `account.partial.reconcile` row count is preserved across switchover (read-only check, no migration needed)
- Tests:
- Unit (engine): matching correctness with fixtures (single partner, multi-partner, multi-currency, partial, exchange diff, write-off, tax split)
- Integration: install + create statement + reconcile via UI + assert `account.partial.reconcile` rows
- Tour (JS): smoke through the full bank rec workflow
- Migration: install Enterprise, create 10 reconciliations, install fusion, uninstall Enterprise, assert reconciliations visible in fusion widget
**Exit criteria:**
- Community + fusion_accounting user can reconcile bank statements with feature parity to Enterprise
- All Phase 1 tests passing
- Migration round-trip (Enterprise → fusion) preserves every reconciliation
- AI tools work against fusion bank rec engine
### 4.4 Phase 2 — Financial Reports Engine
The largest phase. Replaces `account_reports` (618 files).
**Scope:**
- Create `fusion_accounting_reports/` sub-module
- **Backend (abstract zone)**: `models/fusion_account_report.py` defining `fusion.account.report` and `fusion.account.report.line`. Generic engine that takes a report definition (sections, filters, computation rules) and produces report rows from `account.move.line` data. Original computation kernel — does not copy `account_reports`'s `account_report.py`
- **Backend (mirror zone)**: report definition records mirror Odoo's data files. Files: `data/balance_sheet.xml`, `data/profit_and_loss.xml`, `data/cash_flow_report.xml`, `data/general_ledger.xml`, `data/trial_balance.xml`, `data/aged_partner_balance.xml`, `data/partner_ledger.xml`, `data/executive_summary.xml`, `data/sales_report.xml`, `data/multicurrency_revaluation_report.xml`, `data/bank_reconciliation_report.xml`, `data/deferred_reports.xml`, `data/journal_report.xml`, `data/customer_statement.xml`. XML structure follows Odoo's so V20 ports are diff-and-apply
- **Frontend (mirror zone)**: `static/src/components/` mirrors `account_reports/static/src/components/` — filters bar, comparison toggle, drill-down, foldable sections, footnotes
- **PDF export**: QWeb templates in `report/` mirror Odoo's `data/pdf_export_templates.xml` and `data/customer_reports_pdf_export_templates.xml`. Asset bundle `fusion_accounting_reports.assets_pdf_export` defined in manifest
- Performance: denormalized read paths for trial balance and general ledger (materialized aggregations refreshed on `account.move` post). Drill-down lazy-loads line detail. Per-(company, period, filter_hash) cache invalidated on `account.move.line` write
- Multi-company, multi-currency, cash-basis toggle — all handled by the engine
- AI integration: tools `get_profit_loss`, `get_balance_sheet`, `get_trial_balance`, `get_aged_receivables`, `get_aged_payables`, `get_partner_ledger`, `answer_financial_question` refactored via `reports_adapter.py`
- Migration: report XML records are reference data, not client data. fusion ships its own equivalent records; no migration of report definitions needed. Existing journal entry data (which the reports compute from) is in Community `account` and untouched
- Tests:
- Unit (engine): SQL-fixture comparisons (compute report → compare against hand-rolled SQL) for every standard report, every filter combination
- Integration: install + post entries + open report + assert numbers
- Multi-currency: single + multi + revaluation period
- Performance: 1k / 10k / 100k journal lines, assert P95 latency under 5s
- PDF: render every report to PDF, assert no QWeb errors
- Tour: smoke through report viewer with filters
**Exit criteria:**
- All 14 standard reports rendering correctly (numerical match against SQL fixtures)
- PDF export working for every report
- Performance targets met
- AI tools backed by fusion reports
### 4.5 Phase 3 — Dashboard + Fiscal Year + Lock Dates
**Scope:**
- Create `fusion_accounting_dashboard/` sub-module
- **Journal kanban dashboard**: mirror layout of `account_accountant/views/account_journal_dashboard_views.xml`. Computed metrics in `models/account_journal.py` extending Community `account.journal` with kanban-state fields (counts, totals, action buttons). Original computation; mirror UI
- `models/fusion_fiscal_year.py` defining `fusion.fiscal.year` (replaces `account.fiscal.year`)
- Fiscal year wizard: closing workflow, period locks, initial-balance carry-forward
- Lock date wizard: clean-room rewrite of `account_accountant/wizard/account_change_lock_date.py`. Operates on Community `account.lock_exception` model (verified at `account/models/account_lock_exception.py`)
- Digest tile contributions: extend `mail.digest` with fusion accounting metrics (revenue, expense, AR, AP)
- "Needs Attention" panel — connect data already returned by current AI dashboard endpoint to a frontend rendering. Dashboard endpoint (currently in `fusion_accounting_ai/controllers/`) moves to `fusion_accounting_dashboard/controllers/`; AI module's dashboard tiles call dashboard's endpoint via adapter
- Tests:
- Journal dashboard kanban metrics match expected values for fixtures
- Fiscal year close locks subsequent edits
- Lock date wizard prevents posting before lock date
- Digest renders without errors
**Exit criteria:**
- Journal dashboard at parity with Enterprise
- Fiscal year management functional
- Lock dates enforced
- Digest emails delivering
### 4.6 Phase 4 — Tax Reports + Returns
**Scope:**
- Build on Phase 2 reports engine; tax reports are specialized `fusion.account.report` records
- Generic tax report (`data/generic_tax_report.xml`) with country-specific overrides
- Canadian HST: unify the existing HST workflow in `fusion_accounting_ai` (currently in `services/prompts/domain_prompts.py` and tool functions) with the new tax report engine. The existing `find_missing_itc_bills`, `get_overdue_invoices`, etc. tools call into the tax report
- `fusion.account.return` model (replaces `account.return` from `account_reports`) tracking tax return drafts, submitted state, payment status
- Return creation wizard, return submission wizard, return generic payment wizard — clean-room rewrites of the corresponding `account_reports` wizards
- Tax closing entries (move generation on tax period close)
- Tests:
- Tax report numbers match SQL fixtures
- Return workflow: draft → review → submitted → paid
- HST 4-phase workflow (per existing CLAUDE.md) end-to-end
**Exit criteria:**
- Generic tax report functional
- Canadian HST workflow runs through fusion (no Enterprise dependency)
- Return tracking working
### 4.7 Phase 5 — Payment Follow-ups
**Scope:**
- Create `fusion_accounting_followup/` sub-module
- `models/fusion_followup_line.py` (replaces `account_followup.followup.line`)
- `models/res_partner.py` extends `res.partner` with follow-up level, last reminder date, dunning history
- `models/account_move.py` extends `account.move` with follow-up state (overdue days, last reminder)
- Multi-level reminder workflow: each level has email template, days delay, optional SMS, optional `mail.activity`
- `wizards/followup_send_wizard.py` for manual sends; cron for automatic
- Follow-up report (PDF): clean-room template
- AI integration: `fusion_accounting_ai` adds tools `draft_followup_message_for_partner`, `send_followup_to_overdue_partners` calling the followup engine via adapter
- Migration: wizard copies `account_followup.followup.line` and partner-level follow-up state into `fusion.followup.line` and shared-field-owned partner fields
- Tests:
- Multi-level escalation
- Email template rendering
- SMS delivery (mock)
- AI-drafted message quality (snapshot tests)
**Exit criteria:**
- Multi-level dunning working
- Migration from `account_followup` preserves history
### 4.8 Phase 6 — Assets + Budgets
**Scope:**
- Create `fusion_accounting_assets/` sub-module
- `models/fusion_asset.py` (replaces `account.asset`)
- `models/fusion_asset_group.py` (replaces `account.asset.group`)
- Depreciation engine: linear, declining, custom schedules. Original implementation
- `wizards/asset_modify.py` for revaluation, sale, disposal — clean-room rewrite
- Asset register report integrates with Phase 2 reports engine
- Migration wizard copies `account.asset` rows + line links on moves
- Create `fusion_accounting_budget/` sub-module
- `models/fusion_budget.py` (replaces `budget.analytic`)
- Budget vs actual report integrates with Phase 2 reports engine
- Migration wizard copies budget records
- Tests for both
**Exit criteria:**
- Asset depreciation schedules computed correctly
- Disposal generates correct GL entries
- Budget variance report functional
### 4.9 Phase 7+ — Optional Satellites
Not scheduled. Each is its own brainstorming → spec → plan → implementation cycle when a real client needs it. Candidate satellite modules:
- `fusion_accounting_loans` — loan amortization
- `fusion_accounting_check_printing` — check printing
- `fusion_accounting_batch_payment` — batch payments
- `fusion_accounting_3way_match` — purchase 3-way match
- `fusion_accounting_edi` — UBL/CII e-invoicing
- `fusion_accounting_sepa` — SEPA direct debit + credit transfer
- `fusion_accounting_saft` — SAFT export
- `fusion_accounting_intrastat` — intrastat report
- `fusion_accounting_iso20022` — ISO 20022 payment files
- `fusion_accounting_online_sync` — online bank sync (Yodlee/Plaid integration)
### 4.10 Per-Phase Deliverables (uniform)
Each phase produces:
1. A separate **design document** in `docs/superpowers/specs/YYYY-MM-DD-fusion-accounting-phase-N-*-design.md` (brainstormed in its own session)
2. A separate **implementation plan** via the `writing-plans` skill
3. Working code with passing tests
4. Entry in the sub-module's `UPGRADE_NOTES.md` listing Odoo source files referenced and intentional deltas
5. Coverage in `fusion_accounting_migration` if the phase replaces an Enterprise data-bearing model
6. Manual QA checklist (install, migrate, smoke, uninstall) committed to the sub-module
7. Update to the meta-module `__manifest__.py` adding the new sub-module to its `depends`
---
## 5. Architecture Rules
These rules apply to every sub-module and every phase. They are the discipline that keeps V19→V20 upgrades mechanical and prevents the WIP-style descent into copied code with stale architecture.
### 5.1 The Hybrid Split
Every sub-module has two zones with different rules:
**Mirror zone** (follows Odoo structure 1:1):
- XML view definitions and xpath targets
- Frontend OWL component file layout, service registration, widget props
- PDF/QWeb templates: structure, CSS class names
- Wizard flows: step order, field names where they appear in views
- Asset bundle declarations in manifests
**Locations**: `views/`, `static/src/components/`, `report/` QWeb templates, `wizards/*_views.xml`, `__manifest__.py` asset bundles
**Abstract zone** (our own design, insulated from Odoo internals):
- Core algorithms: matching, aggregation, computation, depreciation
- Data access helpers
- Business validation, approval flows
- AI integration adapters
- Engine classes (e.g. `fusion_reconcile_engine.py`)
**Locations**: `models/fusion_*_engine.py`, `services/`, `controllers/` (business logic only — request routing is mirror-zone)
**Rule of thumb**: if Odoo refactors it every release, mirror it. If it's been stable for a decade (FIFO matching, accrual rules, depreciation math), abstract it.
### 5.2 Naming Conventions
| Thing | Convention | Example |
|---|---|---|
| Model `_name` | `fusion.*` prefix always | `fusion.bank.rec.widget`, `fusion.account.report`, `fusion.fiscal.year` |
| Model `_inherit` on Community | Keep `account.*` (no rename) | `class AccountMove(models.Model): _inherit = 'account.move'` |
| Model `_inherit` on Enterprise | **Forbidden** — duplicate fields via shared-field-ownership instead | n/a |
| Python class names | `Fusion` prefix for new models | `FusionBankRecWidget`, `FusionAccountReport` |
| Table names (auto-derived) | Follows model prefix | `fusion_bank_rec_widget`, `fusion_account_report` |
| XML record IDs | `fusion_*` prefix | `<record id="fusion_view_bank_rec_form">` |
| Menu IDs | `fusion_menu_*` prefix | Avoids collision with `account_menu_*` |
| Action IDs | `fusion_action_*` | Same |
| Controller routes | `/fusion_accounting/*` | Already in use; carries forward |
| Security groups | `group_fusion_*` | Already in use |
| Field names on inherited Community models | Identical to Enterprise if shared-field-owned; otherwise `x_fusion_*` prefix | `deferred_move_ids` (shared), `x_fusion_ai_confidence` (our own) |
| CSS/SCSS classes | `.fusion_*` or `.o_fusion_*` | Avoids Bootstrap/Odoo collision |
| `ir.config_parameter` keys | `fusion_accounting.*` | Already in use |
### 5.3 Coexistence Detection
Every sub-module that replaces an Enterprise feature must detect Enterprise at install time and at runtime, and feature-gate accordingly.
**Helper function** (lives in `fusion_accounting_core/models/ir_module_module.py`):
```python
class IrModuleModule(models.Model):
_inherit = "ir.module.module"
@api.model
def _fusion_is_enterprise_accounting_installed(self):
return bool(self.sudo().search_count([
('name', 'in', ['account_accountant', 'account_reports', 'accountant']),
('state', '=', 'installed'),
]))
```
**Three coexistence modes per sub-module**, configurable in Settings → Fusion Accounting → Integration Mode:
1. **Replace** (default when Enterprise absent): fusion menus visible, fusion views primary, fusion workflows active
2. **Augment** (default when Enterprise present): fusion menus hidden, fusion widgets disabled, fusion AI module continues to call Enterprise APIs via adapters
3. **Force-replace** (manual): fusion menus visible alongside Enterprise (operator's choice — risk of confusion, used during migration)
Menu visibility achieved via `groups` attribute referencing a dynamically-computed group (`group_fusion_show_menus_when_enterprise_absent`), implemented as a `@api.depends` computed field on `res.users` that recomputes membership when modules change state.
### 5.4 Zero Hard Enterprise Dependencies
After Phase 0:
- `fusion_accounting_core/__manifest__.py`: `depends = ['account', 'mail', 'web_tour']`
- `fusion_accounting_ai/__manifest__.py`: `depends = ['fusion_accounting_core']` plus `external_dependencies` for `anthropic`, `openai`
- Every other `fusion_accounting_*/__manifest__.py`: `depends = ['fusion_accounting_core']` plus fusion siblings as needed (e.g., `_followup` depends on `_reports`)
**No `fusion_accounting_*` module may have `account_accountant`, `account_reports`, `accountant`, `account_followup`, `account_asset`, `account_budget`, `account_loans`, `account_3way_match`, `account_check_printing`, `account_batch_payment`, `account_iso20022`, `account_intrastat`, `account_saft`, `account_sepa_direct_debit`, `account_online_synchronization`, or any `account_edi_*` in its `depends`.**
Runtime detection (Section 5.3) replaces compile-time dependency.
### 5.5 Canonical Sub-Module Directory Layout
```
fusion_accounting_<feature>/
├── __manifest__.py
├── __init__.py
├── CLAUDE.md # module-specific context for Cursor agent
├── UPGRADE_NOTES.md # Odoo version deltas absorbed
├── README.md # operator-facing install/configure/troubleshoot
├── docs/
│ └── odoo_diff/ # snapshots of relevant Odoo source for diffing
│ └── v19/
│ └── account_accountant__bank_reconciliation_service.js
├── controllers/
│ └── __init__.py
├── data/
├── demo/
├── i18n/
├── models/
│ ├── __init__.py
│ ├── fusion_<feature>_engine.py # abstract zone: core algorithm
│ ├── account_<x>.py # mirror zone: inherits Community model
│ └── fusion_<y>.py # mirror zone: our own models
├── report/
├── security/
│ ├── ir.model.access.csv
│ └── <feature>_security.xml
├── services/ # AI / heavy business logic
├── static/
│ ├── description/
│ │ ├── icon.png
│ │ └── index.html
│ └── src/
│ ├── components/ # mirror zone: OWL components
│ ├── scss/
│ ├── services/ # frontend services
│ └── views/
├── tests/
│ ├── __init__.py
│ ├── test_<feature>_engine.py # abstract zone unit tests
│ ├── test_<feature>_integration.py # full-stack integration tests
│ ├── test_migration.py # Enterprise → fusion round-trip
│ └── tours/
├── views/
├── wizards/
└── migrations/ # Odoo version migration scripts (XX.0.x.y.z)
└── 19.0.1.0.0/
├── pre-migration.py
└── post-migration.py
```
### 5.6 Odoo 19 Gotchas (carried forward, factored across CLAUDE.md files)
The current `fusion_accounting/CLAUDE.md` documents Odoo 19-specific traps that have already cost time. All carry forward:
- Search views: no `string` attribute on `<search>` or `<group>`; group-by filters need `domain="[]"`; `<separator/>` before `<group>`
- OWL client actions: `static props = ["*"]` (accept any), not `static props = []` (accept none)
- OWL rich HTML: `markup()` and `t-out` unreliable in Odoo 19; use `onMounted` + `onPatched` + direct `innerHTML`
- Cron `safe_eval`: no `import` statements; use `datetime.datetime.now()` not `from datetime import datetime`
- `read_group()` deprecated → use `_read_group()`
- `ir_config_parameter` Selection field migrations: stored DB value must match new options or Settings page crashes
- `implied_ids` on groups only applies to newly-added users — existing users need SQL backfill
- `TransientModel` in controllers: use `.new({...})` not `.create({...})`
- HTTP routes: `type="jsonrpc"`, not `type="json"` (deprecated)
- `res.config.settings`: only boolean/integer/float/char/selection/many2one/datetime; no Date fields
- `res.groups`: no `users` field, no `category_id` field
- Search views: no `group expand="0"` syntax
- SCSS imports: `@import "./partial"` is forbidden in Odoo 19 custom SCSS; register every SCSS file as a separate entry in `web.assets_backend`
- Card styling: don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)`; use Odoo's kanban explicit-hex pattern with custom-property tokens
- Dark mode: branch on `$o-webclient-color-scheme` at SCSS compile time, not runtime DOM class
- Asset bundle cache busting: bump manifest version + `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'` if needed
These rules belong in each sub-module's `CLAUDE.md` (the relevant subset) plus the workspace-root `CLAUDE.md` (common rules).
### 5.7 Manifest Versioning and Branch Strategy
- Per-sub-module manifest: `'version': 'XX.0.x.y.z'` where XX is the Odoo version (e.g., `19.0.1.0.0` for V19, first release)
- Bump `XX` on Odoo version change (V19 → V20 → V21)
- Bump `x` on major feature additions within an Odoo version
- Bump `y` on minor features and bug fixes
- Bump `z` on hotfixes
- Git branches: `main-v19`, `main-v20`, etc. Each client deployment is pinned to one branch
- Release tags: `<sub-module>/v19.0.1.0.0` per sub-module per release
---
## 6. Cross-Version Upgrade Workflow
This section is the user's stated top concern: how to keep porting Enterprise changes forward each year without it becoming a rewrite project.
### 6.1 Snapshot Discipline
Maintain one pinned snapshot of the relevant Odoo source per Odoo version:
```
/Users/gurpreet/Github/RePackaged-Odoo/
├── accounting-v19/ # current snapshot (already in place at accounting/)
├── accounting-v20/ # added when V20 ships
├── accounting-v21/ # added when V21 ships
```
Older snapshots are never deleted — they are the diff source for upgrade work.
### 6.2 Annual Upgrade Ritual
When Odoo V<N+1> ships:
1. Add the snapshot folder
2. For each fusion sub-module:
- Run `tools/check_odoo_diff.sh <enterprise_module> v<N> v<N+1> > reports/v<N+1>_<module>_diff.md`
- Manually classify each change in the diff:
- `[MIRROR]` — apply the same hunk to fusion's mirror-zone files (mechanical)
- `[ABSTRACT]` — verify the Odoo public API surface our adapter uses still works; update the adapter if signatures changed
- `[NEW FEATURE]` — decide port or defer
- `[BUG FIX]` — port (usually cheap)
- `[REMOVED]` — clean up our equivalent
- Apply mirror-zone hunks (these are usually direct `patch -p1` operations)
- Write Odoo version migration scripts in `migrations/<N+1>.0.0.0.0/` for any data-shape changes
- Update `UPGRADE_NOTES.md`
- Run all tests
3. Tag releases on `main-v<N+1>` branch
4. Pilot upgrade on one client first; ratchet outward
### 6.3 `UPGRADE_NOTES.md` Template
```markdown
# UPGRADE_NOTES — fusion_accounting_bank_rec
## V19.0.1.0.0 (initial)
- Ported from: account_accountant V19 (snapshot date 2026-04-18)
- Mirror sources:
- account_accountant/static/src/components/bank_reconciliation/* → fusion_accounting_bank_rec/static/src/components/bank_reconciliation/*
- account_accountant/wizard/account_auto_reconcile_wizard.py → fusion_accounting_bank_rec/wizards/auto_reconcile_wizard.py (clean-room)
- Abstract zone:
- models/fusion_reconcile_engine.py — original implementation
- Intentional deltas from Odoo:
- AI hook in reconcile step (calls fusion_accounting_ai.suggest_match adapter)
- Different default colour palette (SCSS var overrides)
## V20.0.x.y.z (planned, not yet shipped)
- Odoo changes account_accountant V19 → V20 absorbed:
- [MIRROR] kanban_renderer.js: column layout changed, applied identical hunk
- [ABSTRACT] account.reconcile.model._apply_lines_for_bank_widget signature changed — updated adapter
- [NEW FEATURE] batch-reconcile-across-journals — deferred to V20.1
- Migration scripts:
- migrations/20.0.0.0.0/pre-migration.py: rename column foo → bar
```
### 6.4 `tools/check_odoo_diff.sh` Specification
The script lives at `fusion_accounting/tools/check_odoo_diff.sh` (workspace root, shared across sub-modules). Usage:
```bash
tools/check_odoo_diff.sh <enterprise_module> <from_version> <to_version> [<output_file>]
```
Behavior:
- Runs `diff -ruN /Users/gurpreet/Github/RePackaged-Odoo/accounting-<from>/<module> /Users/gurpreet/Github/RePackaged-Odoo/accounting-<to>/<module>`
- Splits output into per-file sections
- For each file, classifies based on file path: `views/` and `static/src/components/` and `report/``[MIRROR]` candidate; `models/*_engine.py`-like → `[ABSTRACT]` review; new files → `[NEW FEATURE]` review
- Outputs a markdown report with per-file sections and classification suggestions
- Exit code: 0 if no changes, non-zero if changes (CI can use to flag annual upgrades)
### 6.5 Pinning and Rollback
- Git: `main-v19`, `main-v20`, etc. branches in fusion repo. Each client stays on their pinned Odoo version
- Manifest version pinned per sub-module per Odoo version
- Client deployment: never auto-upgrade. Upgrade is a deliberate, tested, per-client migration
- Rollback: restore DB from `pg_dump` taken before upgrade, restore `fusion_accounting_*` checkout from git tag, restart Odoo
### 6.6 Cross-Version Migration Scripts
Odoo's standard migration mechanism applies. Each sub-module has a `migrations/` folder with subfolders named after manifest versions. Scripts run automatically when the manifest version bumps in the database vs. on disk.
```python
# fusion_accounting_assets/migrations/20.0.0.0.0/pre-migration.py
def migrate(cr, version):
# V20 renamed fusion_asset.original_value to fusion_asset.acquisition_cost
cr.execute("ALTER TABLE fusion_asset RENAME COLUMN original_value TO acquisition_cost")
```
---
## 7. AI Integration, Testing, Documentation
### 7.1 AI Integration
The AI copilot (existing `fusion_accounting/services/`, `fusion_accounting/static/src/`, `fusion_accounting/controllers/` etc.) moves to `fusion_accounting_ai/` in Phase 0 and stays original code. What changes:
**Adapter pattern**: every AI tool that today calls Enterprise APIs gets routed through an adapter:
```
fusion_accounting_ai/services/adapters/
├── bank_rec_adapter.py
├── reports_adapter.py
├── followup_adapter.py
├── assets_adapter.py
└── _registry.py
```
Adapter behavior (uniform pattern across all adapters):
```python
class BankRecAdapter:
def __init__(self, env):
self.env = env
def list_unreconciled_lines(self, journal_id, limit=100):
# Prefer fusion native if installed
if 'fusion.bank.rec.widget' in self.env.registry:
return self.env['fusion.bank.rec.widget'].sudo().get_unreconciled(journal_id, limit)
# Fall back to Enterprise if installed
elif self.env['ir.module.module']._fusion_is_module_installed('account_accountant'):
return self._enterprise_unreconciled_lines(journal_id, limit)
# Last resort: pure Community search
else:
return self.env['account.bank.statement.line'].sudo().search([
('journal_id', '=', journal_id),
('is_reconciled', '=', False),
], limit=limit)
```
This pattern means `fusion_accounting_ai` always works, regardless of which other modules are installed. The AI tool functions in `fusion_accounting_ai/services/tools/` get refactored once in Phase 0 to call adapters; subsequent phases just enrich the adapters.
**New AI capabilities unlocked by native implementations**: each native phase exposes engine internals to AI tools that Enterprise didn't expose cleanly. Examples:
- Phase 1: AI gets access to fusion's match-confidence scores
- Phase 2: AI can request a report computation with custom comparison periods on the fly
- Phase 4: AI has direct access to tax-grid-by-account decomposition
- Phase 5: AI drafts follow-up messages with full payment history context
**Existing AI patterns carry forward unchanged**:
- Tool tiering (Tier 1 / 2 / 3 with auto-promotion)
- Provider pinning per session (Claude vs OpenAI consistency within a session)
- Tier 3 approval flow with `pending_approval` placeholder swap on approve/reject
- Rich-text chat output via `mdToHtml()` and `innerHTML` injection
- Interactive `fusion-table` blocks for actionable results
- Session ownership / multi-company record rules (the `fusion.accounting.session` rule that's currently missing gets added in Phase 0)
### 7.2 Testing Strategy
Every phase must pass these test categories before exit:
| Category | Scope | Where it lives |
|---|---|---|
| **Unit (engine)** | Pure-Python; no Odoo DB. Algorithm correctness with fixtures | `tests/test_<feature>_engine.py` |
| **Integration (Odoo TestCase)** | Full Odoo DB; install + create data + exercise workflow + assert state | `tests/test_<feature>_integration.py` |
| **Migration round-trip** | Install Enterprise, create Enterprise-only data, install fusion, run wizard, uninstall Enterprise, assert data integrity | `tests/test_migration.py` |
| **Tour (JS)** | End-to-end widget UI smoke | `tests/tours/<feature>_tour.js` |
| **Performance** | Phase 2 reports especially; assert P95 latency at 1k/10k/100k rows | `tests/test_<feature>_performance.py` |
| **Multi-matrix** | Single-company, multi-company, multi-currency, cash-basis on/off | parameterized within other tests |
CI runs all tests on every push. A nightly job runs migration tests against a fixture Enterprise DB.
### 7.3 Documentation Deliverables
Per sub-module:
- `CLAUDE.md` — module-specific context for Cursor/AI assistance
- `UPGRADE_NOTES.md` — Odoo version porting log
- `README.md` — operator-facing: install, configure, troubleshoot, common gotchas
- One screencast or animated GIF per major user workflow, in `static/description/`
- Per-feature feature flag documentation in `CLAUDE.md` if applicable
Workspace-root documentation:
- `/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md` — common Odoo 19 conventions (already substantial; carries forward)
- `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/CLAUDE.md` — meta-module overview pointing at sub-modules
- `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/docs/superpowers/specs/` — design and plan docs (this doc and future ones)
### 7.4 Security
- Three groups carry forward from existing module: `group_fusion_accounting_user/manager/admin`. Move from current location to `fusion_accounting_core/security/security.xml` in Phase 0
- Auto-assignments from Community accounting groups: `account.group_account_user` → fusion User; `account.group_account_manager` → fusion Admin (already in place)
- Multi-company record rules on every fusion model with `company_id`. Add the missing rule on `fusion.accounting.session` in Phase 0
- ACLs in `security/ir.model.access.csv` per sub-module, scoped to that sub-module's models only
- Approve/reject endpoints continue to use `auth='user'` with imperative `has_group()` check inside the handler (Odoo has no built-in `auth='manager'`)
### 7.5 Performance Considerations (Phase 2 in particular)
Odoo Enterprise reports have known performance issues on large databases. The Phase 2 design doc must lock in:
- Denormalized read paths for trial balance and general ledger (materialized aggregations refreshed on `account.move` post)
- Lazy-load line detail (drill-down fetches separately, not all at once)
- Cache report runs per `(company_id, period, filter_hash)` with invalidation on `account.move.line` write/post/cancel
- Parallel computation across companies in multi-company reports
- SQL query review (no Python aggregation of large result sets)
### 7.6 Multi-Company, Multi-Currency, Analytic
Not a separate phase. Woven into every phase's exit criteria:
- Every fusion model with company-scoped data has `company_id` field and a multi-company record rule
- Every monetary field pairs with `currency_id`
- `analytic_mixin` (currently in `account_accountant/models/analytic_mixin.py`): declared in `fusion_accounting_core` via shared-field-ownership pattern so analytic tags survive Enterprise uninstall
### 7.7 Localization
Canadian HST is built into the existing AI module (`fusion_accounting_ai/services/prompts/domain_prompts.py`) and carries forward. Other localizations are deferred:
- Each country-specific tax report becomes a `fusion.account.report` record in `fusion_accounting_reports/data/<country>_<report>.xml`
- Country-specific chart of accounts: continue to use Odoo's `account.chart.template` mechanism (Community)
- New countries are added on demand, per client engagement
### 7.8 Hosting and Deployment
Out of scope for this design doc; covered in workspace-root operational docs. fusion_accounting deploys to the existing Nexa Odoo infrastructure (per existing `fusion_accounting/CLAUDE.md`: `odoo-westin` for Westin Healthcare, equivalents for other clients). Deploy commands in CLAUDE.md carry forward.
---
## 8. Acceptance Criteria for This Roadmap
This roadmap is considered "done" (and ready for the first writing-plans session for Phase 0) when:
- The user has reviewed this document and signed off
- No unresolved ambiguity remains in any of the locked decisions (sub-module topology, data preservation, phase order, architecture rules, upgrade workflow)
- The empirical verification test (Section 3.6) is scheduled as part of Phase 0 and not deferred
The next session's deliverable will be the Phase 0 implementation plan (via the `writing-plans` skill), which will turn Section 4.2 into actionable, testable tasks.
---
## 9. Open Questions Deferred to Future Sessions
Items consciously left open here, to be resolved in their respective phase brainstorming sessions:
- Phase 1: exact UI deltas from Odoo's bank rec widget (colour palette, AI confidence badge placement, keyboard shortcuts)
- Phase 2: report definition data format (XML mirroring Odoo vs. our own simpler format)
- Phase 2: caching layer implementation (in-memory vs. Redis vs. PostgreSQL materialized views)
- Phase 4: which non-Canadian tax jurisdictions to seed
- Phase 5: SMS provider integration (Twilio? `mail.sms` Odoo built-in?)
- Phase 6: depreciation methods to support beyond linear/declining (sum-of-years-digits, units-of-production)
- Phase 7+: which satellites have actual client demand right now
---
## 10. References
- Workspace root: `/Users/gurpreet/Github/Odoo-Modules/`
- Current AI module: `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/`
- Current AI module conventions: `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/CLAUDE.md`
- Workspace conventions: `/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md`
- WIP code (not continued): `/Users/gurpreet/Github/Odoo-Modules/Work in Progress/fusion_accounting/`
- WIP audit report: `/Users/gurpreet/Github/Odoo-Modules/Work in Progress/fusion_accounting/AUDIT_REPORT.md`
- Pinned Odoo source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/`
- Plan file (this session): `/Users/gurpreet/.cursor/plans/fusion_accounting_takeover_roadmap_c851fdb4.plan.md`

View File

@@ -1,81 +0,0 @@
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionAccountingMatchHistory(models.Model):
_name = 'fusion.accounting.match.history'
_description = 'Fusion Accounting Match History'
_order = 'proposed_at desc'
session_id = fields.Many2one(
'fusion.accounting.session', string='Session',
index=True, ondelete='cascade',
)
tool_name = fields.Char(string='Tool Name', required=True, index=True)
tool_params = fields.Text(string='Tool Parameters (JSON)')
tool_result = fields.Text(string='Tool Result (JSON)')
ai_reasoning = fields.Text(string='AI Reasoning')
ai_confidence = fields.Float(string='AI Confidence', digits=(3, 2))
rule_id = fields.Many2one(
'fusion.accounting.rule', string='Applied Rule',
ondelete='set null',
)
proposed_at = fields.Datetime(
string='Proposed At',
default=fields.Datetime.now,
required=True,
)
decision = fields.Selection(
selection=[
('approved', 'Approved'),
('rejected', 'Rejected'),
('pending', 'Pending'),
('auto', 'Auto-Executed'),
],
string='Decision',
default='pending',
index=True,
)
decided_at = fields.Datetime(string='Decided At')
decided_by = fields.Many2one('res.users', string='Decided By')
rejection_reason = fields.Text(string='Rejection Reason')
correct_action = fields.Text(string='Correct Action (JSON)')
bank_statement_line_id = fields.Many2one(
'account.bank.statement.line', string='Bank Statement Line',
ondelete='set null',
)
move_line_ids = fields.Many2many(
'account.move.line', string='Journal Items',
)
amount = fields.Monetary(string='Amount', currency_field='currency_id')
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
partner_id = fields.Many2one('res.partner', string='Partner')
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
def action_approve(self):
self.write({
'decision': 'approved',
'decided_at': fields.Datetime.now(),
'decided_by': self.env.user.id,
})
for rec in self:
if rec.rule_id:
rec.rule_id._record_decision(approved=True)
def action_reject(self):
self.write({
'decision': 'rejected',
'decided_at': fields.Datetime.now(),
'decided_by': self.env.user.id,
})
for rec in self:
if rec.rule_id:
rec.rule_id._record_decision(approved=False)

View File

@@ -1,13 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_session_user,fusion.accounting.session.user,model_fusion_accounting_session,group_fusion_accounting_user,1,1,1,0
access_fusion_session_admin,fusion.accounting.session.admin,model_fusion_accounting_session,group_fusion_accounting_admin,1,1,1,1
access_fusion_history_user,fusion.accounting.match.history.user,model_fusion_accounting_match_history,group_fusion_accounting_user,1,0,0,0
access_fusion_history_manager,fusion.accounting.match.history.manager,model_fusion_accounting_match_history,group_fusion_accounting_manager,1,1,1,0
access_fusion_history_admin,fusion.accounting.match.history.admin,model_fusion_accounting_match_history,group_fusion_accounting_admin,1,1,1,1
access_fusion_rule_user,fusion.accounting.rule.user,model_fusion_accounting_rule,group_fusion_accounting_user,1,0,0,0
access_fusion_rule_manager,fusion.accounting.rule.manager,model_fusion_accounting_rule,group_fusion_accounting_manager,1,1,1,0
access_fusion_rule_admin,fusion.accounting.rule.admin,model_fusion_accounting_rule,group_fusion_accounting_admin,1,1,1,1
access_fusion_tool_user,fusion.accounting.tool.user,model_fusion_accounting_tool,group_fusion_accounting_user,1,0,0,0
access_fusion_tool_admin,fusion.accounting.tool.admin,model_fusion_accounting_tool,group_fusion_accounting_admin,1,1,1,1
access_fusion_dashboard_user,fusion.accounting.dashboard.user,model_fusion_accounting_dashboard,group_fusion_accounting_user,1,1,1,1
access_fusion_rule_wizard_manager,fusion.accounting.rule.wizard.manager,model_fusion_accounting_rule_wizard,group_fusion_accounting_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_session_user fusion.accounting.session.user model_fusion_accounting_session group_fusion_accounting_user 1 1 1 0
3 access_fusion_session_admin fusion.accounting.session.admin model_fusion_accounting_session group_fusion_accounting_admin 1 1 1 1
4 access_fusion_history_user fusion.accounting.match.history.user model_fusion_accounting_match_history group_fusion_accounting_user 1 0 0 0
5 access_fusion_history_manager fusion.accounting.match.history.manager model_fusion_accounting_match_history group_fusion_accounting_manager 1 1 1 0
6 access_fusion_history_admin fusion.accounting.match.history.admin model_fusion_accounting_match_history group_fusion_accounting_admin 1 1 1 1
7 access_fusion_rule_user fusion.accounting.rule.user model_fusion_accounting_rule group_fusion_accounting_user 1 0 0 0
8 access_fusion_rule_manager fusion.accounting.rule.manager model_fusion_accounting_rule group_fusion_accounting_manager 1 1 1 0
9 access_fusion_rule_admin fusion.accounting.rule.admin model_fusion_accounting_rule group_fusion_accounting_admin 1 1 1 1
10 access_fusion_tool_user fusion.accounting.tool.user model_fusion_accounting_tool group_fusion_accounting_user 1 0 0 0
11 access_fusion_tool_admin fusion.accounting.tool.admin model_fusion_accounting_tool group_fusion_accounting_admin 1 1 1 1
12 access_fusion_dashboard_user fusion.accounting.dashboard.user model_fusion_accounting_dashboard group_fusion_accounting_user 1 1 1 1
13 access_fusion_rule_wizard_manager fusion.accounting.rule.wizard.manager model_fusion_accounting_rule_wizard group_fusion_accounting_manager 1 1 1 1

View File

@@ -1,94 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Module Category -->
<record id="module_category_fusion_accounting" model="ir.module.category">
<field name="name">Fusion Accounting AI</field>
<field name="sequence">25</field>
</record>
<!-- Groups Privilege -->
<record id="res_groups_privilege_fusion_accounting" model="res.groups.privilege">
<field name="name">Fusion Accounting AI</field>
<field name="category_id" ref="module_category_fusion_accounting"/>
</record>
<!-- User Group (Staff) -->
<record id="group_fusion_accounting_user" model="res.groups">
<field name="name">User</field>
<field name="sequence">10</field>
<field name="implied_ids" eval="[(4, ref('account.group_account_user'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
</record>
<!-- Manager Group -->
<record id="group_fusion_accounting_manager" model="res.groups">
<field name="name">Manager</field>
<field name="sequence">20</field>
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_user'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
</record>
<!-- Admin Group -->
<record id="group_fusion_accounting_admin" model="res.groups">
<field name="name">Administrator</field>
<field name="sequence">30</field>
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
</record>
<!-- Auto-assign: Accounting users get Fusion AI User, Advisers get Admin -->
<record id="account.group_account_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_user'))]"/>
</record>
<record id="account.group_account_manager" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_admin'))]"/>
</record>
<!-- Record Rules -->
<record id="rule_fusion_session_user" model="ir.rule">
<field name="name">Fusion Session: Own Sessions</field>
<field name="model_id" ref="model_fusion_accounting_session"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_accounting_user'))]"/>
</record>
<record id="rule_fusion_session_manager" model="ir.rule">
<field name="name">Fusion Session: All Sessions</field>
<field name="model_id" ref="model_fusion_accounting_session"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
</record>
<record id="rule_fusion_history_user" model="ir.rule">
<field name="name">Fusion History: Own History</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="domain_force">[('session_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_accounting_user'))]"/>
</record>
<record id="rule_fusion_history_manager" model="ir.rule">
<field name="name">Fusion History: All History</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
</record>
<!-- Multi-company rules -->
<record id="rule_fusion_tool_company" model="ir.rule">
<field name="name">Fusion Tool: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_tool"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<record id="rule_fusion_rule_company" model="ir.rule">
<field name="name">Fusion Rule: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_rule"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<record id="rule_fusion_history_company" model="ir.rule">
<field name="name">Fusion History: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
</odoo>

View File

@@ -1,315 +0,0 @@
import json
import logging
import time
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionAccountingAgent(models.AbstractModel):
_name = 'fusion.accounting.agent'
_description = 'Fusion Accounting AI Agent Orchestrator'
def _get_config(self, key, default=None):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param(f'fusion_accounting.{key}', default)
def _get_adapter(self):
provider = self._get_config('ai_provider', 'claude')
if provider == 'claude':
return self.env['fusion.accounting.adapter.claude']
return self.env['fusion.accounting.adapter.openai']
def _get_tool_registry(self):
return self.env['fusion.accounting.tool'].search([('active', '=', True)])
def _get_tools_for_user(self, user=None):
user = user or self.env.user
tools = self._get_tool_registry()
filtered = self.env['fusion.accounting.tool']
for tool in tools:
if not tool.required_groups:
filtered |= tool
continue
group_xmlids = [g.strip() for g in tool.required_groups.split(',') if g.strip()]
if all(user.has_group(g) for g in group_xmlids):
filtered |= tool
return filtered
def _build_tool_definitions(self, tools):
definitions = []
for tool in tools:
defn = {
'name': tool.name,
'description': tool.description,
}
if tool.parameters_schema:
try:
defn['parameters'] = json.loads(tool.parameters_schema)
except json.JSONDecodeError:
defn['parameters'] = {'type': 'object', 'properties': {}}
else:
defn['parameters'] = {'type': 'object', 'properties': {}}
definitions.append(defn)
return definitions
def _load_rules(self, domain=None):
rule_domain = [('active', '=', True), ('company_id', '=', self.env.company.id)]
if domain:
rule_domain.append(('rule_type', '=', domain))
rules = self.env['fusion.accounting.rule'].search(rule_domain, order='sequence')
admin_rules = rules.filtered(lambda r: r.created_by == 'admin')
ai_auto = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'auto')
ai_pending = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'needs_approval')
return admin_rules | ai_auto | ai_pending
def _load_match_history(self, domain=None, limit=None):
limit = limit or int(self._get_config('history_in_prompt', '50'))
history_domain = [('company_id', '=', self.env.company.id)]
if domain:
history_domain.append(('tool_name', 'ilike', domain))
return self.env['fusion.accounting.match.history'].search(
history_domain, limit=limit, order='proposed_at desc',
)
def _build_system_prompt(self, rules, history, context=None, domain=None):
from .prompts.system_prompt import build_system_prompt
from .prompts.domain_prompts import get_domain_prompt
base = build_system_prompt(rules, history, context)
if domain:
domain_prompt = get_domain_prompt(domain)
if domain_prompt:
base = f"{base}\n\n{domain_prompt}"
return base
def _execute_tool(self, tool_name, params, session_id=None):
from .tools import TOOL_DISPATCH
if tool_name not in TOOL_DISPATCH:
return {'error': f'Unknown tool: {tool_name}'}
try:
result = TOOL_DISPATCH[tool_name](self.env, params)
return result
except Exception as e:
_logger.error("Tool execution error (%s): %s", tool_name, e)
return {'error': str(e)}
def _log_match_history(self, session, tool_name, params, result, reasoning='',
confidence=0.0, rule=None, tier='1'):
vals = {
'session_id': session.id if session else False,
'tool_name': tool_name,
'tool_params': json.dumps(params) if params else '{}',
'tool_result': json.dumps(result) if result else '{}',
'ai_reasoning': reasoning,
'ai_confidence': confidence,
'rule_id': rule.id if rule else False,
'proposed_at': fields.Datetime.now(),
'decision': 'auto' if tier in ('1', '2') else 'pending',
'company_id': self.env.company.id,
}
return self.env['fusion.accounting.match.history'].sudo().create(vals)
def chat(self, session_id, user_message, context=None):
session = self.env['fusion.accounting.session'].browse(session_id)
if not session.exists():
raise UserError(_("Session not found."))
adapter = self._get_adapter()
tools = self._get_tools_for_user()
tool_definitions = self._build_tool_definitions(tools)
rules = self._load_rules()
history = self._load_match_history()
system_prompt = self._build_system_prompt(
rules, history, context, domain=session.context_domain,
)
messages_json = json.loads(session.message_ids_json or '[]')
messages_json.append({'role': 'user', 'content': user_message})
max_turns = max(int(self._get_config('max_tool_calls', '20')), 1)
total_tokens_in = 0
total_tokens_out = 0
response = {'text': '', 'tool_calls': None}
for turn in range(max_turns):
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=tool_definitions,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
if response.get('tool_calls'):
tool_results = []
for tc in response['tool_calls']:
tool_name = tc['name']
tool_params = tc.get('arguments', {})
tool_rec = tools.filtered(lambda t: t.name == tool_name)
tier = tool_rec.tier if tool_rec else '1'
if tier == '3':
history_rec = self._log_match_history(
session, tool_name, tool_params, None,
reasoning=tc.get('reasoning', ''),
confidence=tc.get('confidence', 0.0),
tier='3',
)
tool_results.append({
'tool_call_id': tc.get('id', ''),
'result': json.dumps({
'status': 'pending_approval',
'match_history_id': history_rec.id,
'message': f'Action requires user approval. Match history ID: {history_rec.id}',
}),
})
else:
result = self._execute_tool(tool_name, tool_params, session.id)
self._log_match_history(
session, tool_name, tool_params, result,
reasoning=tc.get('reasoning', ''),
tier=tier,
)
tool_results.append({
'tool_call_id': tc.get('id', ''),
'result': json.dumps(result) if not isinstance(result, str) else result,
})
try:
self._check_rule_proposal(tool_name, tool_params, session)
except Exception:
_logger.exception("Error in _check_rule_proposal for tool %s", tool_name)
messages_json = adapter.append_tool_results(
messages_json, response, tool_results,
)
session.tool_call_count += len(tool_results)
else:
assistant_text = response.get('text', '')
messages_json.append({'role': 'assistant', 'content': assistant_text})
break
else:
# Loop exhausted — force a final text response without tools
try:
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=[],
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
messages_json.append({
'role': 'assistant',
'content': response.get('text', 'I reached the maximum number of steps. Please continue the conversation.'),
})
except Exception:
_logger.warning("Failed to get final summary after max tool calls")
session.write({
'message_ids_json': json.dumps(messages_json),
'token_count_in': session.token_count_in + total_tokens_in,
'token_count_out': session.token_count_out + total_tokens_out,
'ai_provider': self._get_config('ai_provider', 'claude'),
'ai_model': adapter._get_model_name(),
})
pending = self.env['fusion.accounting.match.history'].search([
('session_id', '=', session.id),
('decision', '=', 'pending'),
])
return {
'text': response.get('text', ''),
'pending_approvals': [{
'id': p.id,
'tool_name': p.tool_name,
'params': p.tool_params,
'reasoning': p.ai_reasoning,
'confidence': p.ai_confidence,
'amount': p.amount,
} for p in pending],
'session_id': session.id,
}
def approve_action(self, match_history_id):
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
if not history.exists() or history.decision != 'pending':
raise UserError(_("Action not found or already decided."))
params = json.loads(history.tool_params or '{}')
result = self._execute_tool(history.tool_name, params, history.session_id.id)
history.write({
'decision': 'approved',
'decided_at': fields.Datetime.now(),
'decided_by': self.env.user.id,
'tool_result': json.dumps(result) if not isinstance(result, str) else result,
})
if history.rule_id:
history.rule_id.sudo()._record_decision(approved=True)
return result
def _check_rule_proposal(self, tool_name, params, session):
"""Detect repeated patterns and propose new rules when 3+ identical matches."""
recent = self.env['fusion.accounting.match.history'].search([
('tool_name', '=', tool_name),
('decision', 'in', ('approved', 'auto')),
('company_id', '=', self.env.company.id),
], limit=20, order='proposed_at desc')
if len(recent) < 3:
return
from collections import Counter
param_keys = []
for h in recent:
try:
p = json.loads(h.tool_params or '{}')
key_parts = []
for k in sorted(p.keys()):
if k not in ('limit', 'date_from', 'date_to'):
key_parts.append(f'{k}={json.dumps(p[k], sort_keys=True)}')
if key_parts:
param_keys.append('|'.join(key_parts))
except json.JSONDecodeError:
continue
counts = Counter(param_keys)
for pattern, count in counts.most_common(3):
if count < 3:
break
existing = self.env['fusion.accounting.rule'].search([
('match_logic', 'ilike', pattern[:50]),
('company_id', '=', self.env.company.id),
], limit=1)
if existing:
continue
self.env['fusion.accounting.rule'].create({
'name': f'AI Pattern: {tool_name} ({pattern[:40]})',
'rule_type': 'match',
'description': f'Automatically detected pattern from {count} approved uses of {tool_name}.',
'match_logic': f'When using {tool_name} with parameters matching: {pattern}',
'created_by': 'ai',
'approval_tier': 'needs_approval',
'company_id': self.env.company.id,
})
_logger.info("AI proposed rule for pattern: %s (%d matches)", tool_name, count)
def reject_action(self, match_history_id, reason=''):
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
if not history.exists() or history.decision != 'pending':
raise UserError(_("Action not found or already decided."))
history.write({
'decision': 'rejected',
'decided_at': fields.Datetime.now(),
'decided_by': self.env.user.id,
'rejection_reason': reason,
})
if history.rule_id:
history.rule_id.sudo()._record_decision(approved=False)
return {'status': 'rejected', 'reason': reason}

View File

@@ -1,109 +0,0 @@
DOMAIN_PROMPTS = {
'bank_reconciliation': """
BANK RECONCILIATION CONTEXT:
You are helping with bank statement reconciliation. Key concepts:
- Bank statement lines (account.bank.statement.line) represent transactions from the bank feed.
- Each line needs to be matched to one or more journal items (account.move.line).
- Matching is done via set_line_bank_statement_line(move_line_ids).
- Fee differences (e.g., Elavon card processing fees) should be allocated to the fee account.
- Weekend batches may combine multiple days of card payments.
- Always verify amounts before proposing a match.
""",
'hst_management': """
HST/GST MANAGEMENT CONTEXT:
You are helping with Canadian HST/GST tax management.
- HST Collected is tracked on account 2005 (credit balance = liability).
- Input Tax Credits (ITCs) are on account 2006 (debit balance = asset).
- Net HST = Collected - ITCs. Positive means owing to CRA.
- Quarterly filing periods. Check for missing tax on invoices/bills.
- All vendor bills should have ITCs unless explicitly exempt.
""",
'accounts_receivable': """
ACCOUNTS RECEIVABLE CONTEXT:
- AR aging: current, 1-30, 31-60, 61-90, 90+ days overdue.
- Follow-up actions escalate by aging bucket.
- Payments should be matched to specific invoices.
- Unmatched payments sit on the Outstanding Receipts account (1122).
""",
'accounts_payable': """
ACCOUNTS PAYABLE CONTEXT:
- AP aging mirrors AR: current through 90+ days.
- Watch for duplicate bills (same vendor + amount + date).
- Bills should match purchase orders when applicable.
- Tax on bills should match the vendor's fiscal position.
""",
'journal_review': """
JOURNAL REVIEW CONTEXT:
- Check for wrong-direction balances (e.g., expense account with credit balance).
- Detect duplicate entries (same partner + amount + date + journal).
- Flag entries on unlikely accounts (revenue on a tax account, etc.).
- Sequence gaps may indicate deleted entries.
- Draft entries older than 30 days should be reviewed.
""",
'month_end': """
MONTH-END CLOSE CONTEXT:
- Aggregate all domain checks into a close checklist.
- Verify all bank reconciliations are current.
- Check accrual account balances (vacation, sick leave, etc.).
- Verify no entries exist after lock date.
- Run hash integrity check.
- Produce period trial balance summary.
""",
'payroll_verification': """
PAYROLL VERIFICATION CONTEXT:
- Cross-reference payroll journal entries to bank statement cheques.
- Verify CPP, EI, and income tax deductions against CRA rate tables.
- Check CRA remittance account balance vs payments made.
""",
'inventory': """
INVENTORY & COGS CONTEXT:
- Stock In Hand tracked on account 1069.
- Price differences on account 5010 (PO price vs bill price).
- COGS ratio by product category helps spot anomalies.
- Large inventory adjustments need review.
""",
'adp': """
ADP RECONCILIATION CONTEXT:
- ADP Receivable tracked on account 1101.
- ADP invoices have customer portion + ADP portion = total.
- Government deposits should match ADP invoices.
""",
'reporting': """
FINANCIAL REPORTING CONTEXT:
- Reports use Odoo's account.report engine.
- Available: P&L, Balance Sheet, Trial Balance, Cash Flow.
- Period comparison available for trend analysis.
- Export to PDF or XLSX for external distribution.
""",
'audit': """
AUDIT & INTEGRITY CONTEXT:
- Run comprehensive checks on posted entries.
- Verify hash chain integrity on journals.
- Check sequence continuity.
- Flag entries with chatter messages for review tracking.
- Audit status per account: todo / reviewed / supervised / anomaly.
""",
'payroll_management': """
PAYROLL MANAGEMENT CONTEXT:
- Parse pasted payroll summaries from QBO or fusion_payroll.
- Create payroll journal entries with proper debit/credit lines.
- Match payroll cheques to bank statement lines.
- Calculate CRA obligations (CPP employer + employee, EI, income tax).
- Prepare CRA remittance payment entries.
""",
}
def get_domain_prompt(domain):
return DOMAIN_PROMPTS.get(domain, '')

View File

@@ -1,98 +0,0 @@
import json
def build_system_prompt(rules, history, context=None):
parts = [
CORE_SYSTEM_PROMPT,
_build_rules_section(rules),
_build_history_section(history),
]
if context:
parts.append(_build_context_section(context))
return '\n\n'.join(p for p in parts if p)
CORE_SYSTEM_PROMPT = """You are Fusion AI, an expert accounting co-pilot embedded in Odoo 19.
You assist with bank reconciliation, HST/GST management, AR/AP analysis, journal review,
month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing.
BEHAVIOUR:
- Use tools to query and act on Odoo data. Never invent financial figures.
- For Tier 1 tools: execute immediately and report results.
- For Tier 2 tools: execute and log. Inform the user what was done.
- For Tier 3 tools: propose the action with clear reasoning. The user must approve.
- When proposing a Tier 3 action, explain: what you want to do, why, the amounts involved, and your confidence level.
- Apply Fusion Rules (below) before general reasoning.
- Reference match history for patterns the user has approved/rejected before.
- Use Canadian English. Format monetary amounts with $ and two decimals.
- When you encounter ambiguity, ask clarifying questions rather than guessing.
RESPONSE FORMATTING:
- Use rich Markdown formatting in your responses. The chat renders Markdown as HTML.
- Use **bold** for account names, amounts, and key terms.
- Use ## and ### headers to organize sections in longer responses.
- Use Markdown tables for tabular data (| col1 | col2 | format).
- Use bullet lists (- item) for findings, issues, and action items.
- Use numbered lists (1. item) for sequential steps or ranked items.
- Use `code` for account codes, reference numbers, and technical IDs.
- Use --- horizontal rules to separate sections in long reports.
LINKING TO ODOO RECORDS:
- When referencing specific records, include clickable Odoo links.
- Journal entries: [INV/2026/00123](/odoo/accounting/123) where 123 is the move ID.
- Partners: [Customer Name](/odoo/contacts/456) where 456 is the partner ID.
- Accounts: reference by code in bold, e.g. **1001 - Cash**.
- Bank statement lines: mention the date, reference, and amount clearly.
- When tool results include record IDs, always link them.
TOOL CALLING:
- Call tools by name with the required parameters.
- You may call multiple tools in sequence to gather data before proposing an action.
- Do not exceed the maximum tool calls per turn.
- When presenting tool results, format them richly with tables, bold amounts, and links.
"""
def _build_rules_section(rules):
if not rules:
return ''
lines = ['ACTIVE FUSION RULES:']
for rule in rules:
priority = 'ADMIN' if rule.created_by == 'admin' else 'AI'
tier = 'auto' if rule.approval_tier == 'auto' else 'needs-approval'
lines.append(
f'- [{priority}/{tier}] {rule.name} ({rule.rule_type}): '
f'{rule.description or rule.match_logic or "No description"}'
)
if rule.match_logic:
lines.append(f' Match logic: {rule.match_logic}')
return '\n'.join(lines)
def _build_history_section(history):
if not history:
return ''
lines = ['RECENT MATCH HISTORY (learn from these patterns):']
for h in history[:50]:
status = h.decision
reason = ''
if h.rejection_reason:
reason = f' (reason: {h.rejection_reason})'
lines.append(
f'- {h.tool_name}: {status}{reason} '
f'[confidence={h.ai_confidence:.0%}]'
)
if h.ai_reasoning:
lines.append(f' Reasoning: {h.ai_reasoning[:200]}')
return '\n'.join(lines)
def _build_context_section(context):
if not context:
return ''
if isinstance(context, dict):
parts = ['CURRENT CONTEXT:']
for k, v in context.items():
parts.append(f'- {k}: {v}')
return '\n'.join(parts)
return f'CURRENT CONTEXT: {context}'

View File

@@ -1,150 +0,0 @@
import logging
from odoo import fields
from datetime import timedelta
_logger = logging.getLogger(__name__)
def get_ap_aging(env, params):
today = fields.Date.today()
domain = [
('account_id.account_type', '=', 'liability_payable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
]
amls = env['account.move.line'].search(domain)
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
for aml in amls:
amt = abs(aml.amount_residual)
if not aml.date_maturity or aml.date_maturity >= today:
buckets['current'] += amt
else:
days = (today - aml.date_maturity).days
if days <= 30:
buckets['1_30'] += amt
elif days <= 60:
buckets['31_60'] += amt
elif days <= 90:
buckets['61_90'] += amt
else:
buckets['90_plus'] += amt
return {'total': sum(buckets.values()), 'buckets': buckets, 'line_count': len(amls)}
def find_duplicate_bills(env, params):
window_days = int(params.get('window_days', 7))
bills = env['account.move'].search([
('move_type', 'in', ('in_invoice', 'in_refund')),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
], order='partner_id, amount_total, date')
duplicates = []
prev = None
for bill in bills:
if prev and (
prev.partner_id == bill.partner_id
and abs(prev.amount_total - bill.amount_total) < 0.01
and abs((prev.date - bill.date).days) <= window_days
):
duplicates.append({
'bill_1': {'id': prev.id, 'name': prev.name, 'date': str(prev.date)},
'bill_2': {'id': bill.id, 'name': bill.name, 'date': str(bill.date)},
'partner': bill.partner_id.name,
'amount': bill.amount_total,
})
prev = bill
return {'count': len(duplicates), 'duplicates': duplicates[:20]}
def match_bill_to_po(env, params):
bill_id = int(params['bill_id'])
bill = env['account.move'].browse(bill_id)
if not bill.exists():
return {'error': 'Bill not found'}
matches = []
for line in bill.invoice_line_ids:
if line.purchase_line_id:
matches.append({
'bill_line': line.name or '',
'po': line.purchase_line_id.order_id.name,
'po_line': line.purchase_line_id.name,
'po_qty': line.purchase_line_id.product_qty,
'bill_qty': line.quantity,
'match': abs(line.quantity - line.purchase_line_id.product_qty) < 0.01,
})
return {'bill': bill.name, 'matches': matches, 'unmatched_lines': len(bill.invoice_line_ids) - len(matches)}
def get_unpaid_bills(env, params):
domain = [
('move_type', 'in', ('in_invoice', 'in_refund')),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('company_id', '=', env.company.id),
]
if params.get('partner_id'):
domain.append(('partner_id', '=', int(params['partner_id'])))
bills = env['account.move'].search(domain, order='invoice_date_due asc', limit=int(params.get('limit', 50)))
return {
'count': len(bills),
'total': sum(b.amount_residual for b in bills),
'bills': [{
'id': b.id, 'name': b.name,
'partner': b.partner_id.name if b.partner_id else '',
'amount_total': b.amount_total,
'amount_residual': b.amount_residual,
'date_due': str(b.invoice_date_due) if b.invoice_date_due else '',
} for b in bills],
}
def verify_bill_taxes(env, params):
bill_id = int(params['bill_id'])
bill = env['account.move'].browse(bill_id)
if not bill.exists():
return {'error': 'Bill not found'}
issues = []
for line in bill.invoice_line_ids:
if line.product_id and not line.tax_ids:
issues.append({
'line': line.name or line.product_id.name,
'issue': 'No tax applied to product line',
})
return {'bill': bill.name, 'issues': issues, 'clean': len(issues) == 0}
def get_payment_schedule(env, params):
days_ahead = int(params.get('days_ahead', 30))
cutoff = fields.Date.today() + timedelta(days=days_ahead)
bills = env['account.move'].search([
('move_type', '=', 'in_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('invoice_date_due', '<=', cutoff),
('company_id', '=', env.company.id),
], order='invoice_date_due asc')
return {
'period': f'Next {days_ahead} days',
'total': sum(b.amount_residual for b in bills),
'bills': [{
'id': b.id, 'name': b.name,
'partner': b.partner_id.name if b.partner_id else '',
'amount_residual': b.amount_residual,
'date_due': str(b.invoice_date_due) if b.invoice_date_due else '',
} for b in bills[:50]],
}
TOOLS = {
'get_ap_aging': get_ap_aging,
'find_duplicate_bills': find_duplicate_bills,
'match_bill_to_po': match_bill_to_po,
'get_unpaid_bills': get_unpaid_bills,
'verify_bill_taxes': verify_bill_taxes,
'get_payment_schedule': get_payment_schedule,
}

View File

@@ -1,165 +0,0 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def get_ar_aging(env, params):
today = fields.Date.today()
domain = [
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
]
amls = env['account.move.line'].search(domain)
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
for aml in amls:
if not aml.date_maturity or aml.date_maturity >= today:
buckets['current'] += aml.amount_residual
else:
days = (today - aml.date_maturity).days
if days <= 30:
buckets['1_30'] += aml.amount_residual
elif days <= 60:
buckets['31_60'] += aml.amount_residual
elif days <= 90:
buckets['61_90'] += aml.amount_residual
else:
buckets['90_plus'] += aml.amount_residual
return {
'total': sum(buckets.values()),
'buckets': buckets,
'line_count': len(amls),
}
def get_overdue_invoices(env, params):
today = fields.Date.today()
days_overdue = int(params.get('min_days_overdue', 1))
from datetime import timedelta
cutoff = today - timedelta(days=days_overdue)
invoices = env['account.move'].search([
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('invoice_date_due', '<', cutoff),
('company_id', '=', env.company.id),
], order='invoice_date_due asc', limit=int(params.get('limit', 50)))
return {
'count': len(invoices),
'invoices': [{
'id': inv.id,
'name': inv.name,
'partner': inv.partner_id.name if inv.partner_id else '',
'email': inv.partner_id.email or '' if inv.partner_id else '',
'phone': inv.partner_id.phone or '' if inv.partner_id else '',
'amount_total': inv.amount_total,
'amount_residual': inv.amount_residual,
'date_due': str(inv.invoice_date_due),
'days_overdue': (today - inv.invoice_date_due).days,
} for inv in invoices],
}
def get_partner_balance(env, params):
partner_id = int(params['partner_id'])
partner = env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': 'Partner not found'}
amls = env['account.move.line'].search([
('partner_id', '=', partner_id),
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
])
return {
'partner': partner.name,
'balance': sum(aml.amount_residual for aml in amls),
'open_items': [{
'id': aml.id,
'ref': aml.ref or aml.move_id.name,
'date': str(aml.date),
'amount_residual': aml.amount_residual,
'date_maturity': str(aml.date_maturity) if aml.date_maturity else '',
} for aml in amls],
}
def send_followup(env, params):
partner_id = int(params['partner_id'])
partner = env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': 'Partner not found'}
options = {
'partner_id': partner_id,
'email': params.get('send_email', False),
'print': params.get('print_letter', False),
'sms': False,
}
if params.get('email_subject'):
options['email_subject'] = params['email_subject']
if params.get('body'):
options['body'] = params['body']
result = partner.execute_followup(options)
return {'status': 'sent', 'partner': partner.name, 'result': str(result) if result else 'done'}
def get_followup_report(env, params):
partner_id = int(params['partner_id'])
partner = env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': 'Partner not found'}
try:
report = env['account.followup.report']
html = report._get_followup_report_html(partner)
return {'partner': partner.name, 'html': html}
except Exception as e:
return {'error': str(e)}
def reconcile_payment_to_invoice(env, params):
move_line_ids = [int(x) for x in params['move_line_ids']]
amls = env['account.move.line'].browse(move_line_ids)
if len(amls) < 2:
return {'error': 'Need at least 2 journal items to reconcile'}
amls.reconcile()
return {
'status': 'reconciled',
'move_line_ids': move_line_ids,
}
def get_unmatched_payments(env, params):
domain = [
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('move_id.payment_id', '!=', False),
('company_id', '=', env.company.id),
]
amls = env['account.move.line'].search(domain, order='date desc')
return {
'count': len(amls),
'payments': [{
'id': aml.id,
'date': str(aml.date),
'ref': aml.ref or aml.move_id.name,
'partner': aml.partner_id.name if aml.partner_id else '',
'amount': abs(aml.amount_residual),
} for aml in amls[:50]],
}
TOOLS = {
'get_ar_aging': get_ar_aging,
'get_overdue_invoices': get_overdue_invoices,
'get_partner_balance': get_partner_balance,
'send_followup': send_followup,
'get_followup_report': get_followup_report,
'reconcile_payment_to_invoice': reconcile_payment_to_invoice,
'get_unmatched_payments': get_unmatched_payments,
}

View File

@@ -1,111 +0,0 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def get_adp_receivable_aging(env, params):
accounts = env['account.account'].search([
('code', '=like', '1101%'),
('company_id', '=', env.company.id),
])
today = fields.Date.today()
amls = env['account.move.line'].search([
('account_id', 'in', accounts.ids),
('reconciled', '=', False),
('parent_state', '=', 'posted'),
])
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
for aml in amls:
amt = abs(aml.amount_residual)
if not aml.date_maturity or aml.date_maturity >= today:
buckets['current'] += amt
else:
days = (today - aml.date_maturity).days
if days <= 30:
buckets['1_30'] += amt
elif days <= 60:
buckets['31_60'] += amt
elif days <= 90:
buckets['61_90'] += amt
else:
buckets['90_plus'] += amt
return {'total': sum(buckets.values()), 'buckets': buckets}
def match_adp_payment_to_invoice(env, params):
move_line_ids = [int(x) for x in params['move_line_ids']]
amls = env['account.move.line'].browse(move_line_ids).exists()
if len(amls) < 2:
return {'error': 'Need at least 2 existing journal items to reconcile'}
amls.reconcile()
return {'status': 'matched', 'move_line_ids': amls.ids}
def verify_adp_split(env, params):
invoice_id = int(params['invoice_id'])
invoice = env['account.move'].browse(invoice_id)
if not invoice.exists():
return {'error': 'Invoice not found'}
lines = invoice.invoice_line_ids
total = invoice.amount_untaxed
return {
'invoice': invoice.name,
'total_untaxed': total,
'total_with_tax': invoice.amount_total,
'lines': [{'name': l.name, 'subtotal': l.price_subtotal, 'total': l.price_total} for l in lines],
'balanced': abs(sum(l.price_subtotal for l in lines) - total) < 0.01,
}
def find_adp_without_payment(env, params):
adp_partner = env['res.partner'].search([('name', 'ilike', 'ADP')], limit=1)
if not adp_partner:
return {'status': 'info', 'message': 'No ADP partner found in the system.'}
invoices = env['account.move'].search([
('partner_id', '=', adp_partner.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
])
return {
'count': len(invoices),
'invoices': [{
'id': inv.id, 'name': inv.name,
'amount': inv.amount_residual, 'date': str(inv.date),
} for inv in invoices[:20]],
}
def get_adp_summary(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
accounts = env['account.account'].search([
('code', '=like', '1101%'), ('company_id', '=', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
('parent_state', '=', 'posted'),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
lines = env['account.move.line'].search(domain)
total_debit = sum(l.debit for l in lines)
total_credit = sum(l.credit for l in lines)
return {
'period': f'{date_from or "all"} to {date_to or "now"}',
'billed': total_debit,
'collected': total_credit,
'outstanding': total_debit - total_credit,
}
TOOLS = {
'get_adp_receivable_aging': get_adp_receivable_aging,
'match_adp_payment_to_invoice': match_adp_payment_to_invoice,
'verify_adp_split': verify_adp_split,
'find_adp_without_payment': find_adp_without_payment,
'get_adp_summary': get_adp_summary,
}

View File

@@ -1,177 +0,0 @@
import logging
from datetime import datetime
_logger = logging.getLogger(__name__)
def get_unreconciled_bank_lines(env, params):
domain = [('is_reconciled', '=', False), ('company_id', '=', env.company.id)]
if params.get('journal_id'):
domain.append(('journal_id', '=', int(params['journal_id'])))
if params.get('date_from'):
domain.append(('date', '>=', params['date_from']))
if params.get('date_to'):
domain.append(('date', '<=', params['date_to']))
if params.get('min_amount'):
domain.append(('amount', '>=', float(params['min_amount'])))
limit = int(params.get('limit', 50))
lines = env['account.bank.statement.line'].search(domain, limit=limit, order='date desc')
return {
'count': len(lines),
'total_amount': sum(abs(l.amount) for l in lines),
'lines': [{
'id': l.id,
'date': str(l.date),
'payment_ref': l.payment_ref or '',
'partner_name': l.partner_name or (l.partner_id.name if l.partner_id else ''),
'amount': l.amount,
'journal': l.journal_id.name,
} for l in lines],
}
def get_unreconciled_receipts(env, params):
account_code = params.get('account_code', '1122')
accounts = env['account.account'].search([
('code', '=like', f'{account_code}%'),
('company_id', '=', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
('reconciled', '=', False),
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
lines = env['account.move.line'].search(domain, order='date desc')
return {
'count': len(lines),
'total_amount': sum(abs(l.amount_residual) for l in lines),
'lines': [{
'id': l.id,
'date': str(l.date),
'ref': l.ref or l.move_id.name,
'partner': l.partner_id.name if l.partner_id else '',
'amount_residual': l.amount_residual,
} for l in lines],
}
def match_bank_line_to_payments(env, params):
st_line_id = int(params['statement_line_id'])
move_line_ids = [int(x) for x in params['move_line_ids']]
st_line = env['account.bank.statement.line'].browse(st_line_id)
if not st_line.exists():
return {'error': 'Statement line not found'}
st_line.set_line_bank_statement_line(move_line_ids)
return {
'status': 'matched',
'statement_line_id': st_line_id,
'matched_move_lines': move_line_ids,
'is_reconciled': st_line.is_reconciled,
}
def auto_reconcile_bank_lines(env, params):
company_id = params.get('company_id', env.company.id)
lines = env['account.bank.statement.line'].search([
('is_reconciled', '=', False),
('company_id', '=', int(company_id)),
])
before_count = len(lines)
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
still_unreconciled = env['account.bank.statement.line'].search([
('is_reconciled', '=', False),
('company_id', '=', int(company_id)),
])
reconciled_count = before_count - len(still_unreconciled)
return {
'status': 'completed',
'lines_before': before_count,
'lines_reconciled': reconciled_count,
'lines_remaining': len(still_unreconciled),
}
def apply_reconcile_model(env, params):
model_id = int(params['model_id'])
st_line_id = int(params['statement_line_id'])
reco_model = env['account.reconcile.model'].browse(model_id)
st_line = env['account.bank.statement.line'].browse(st_line_id)
if not reco_model.exists() or not st_line.exists():
return {'error': 'Model or statement line not found'}
_liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines()
residual = sum(l.amount_residual for l in suspense_lines) if suspense_lines else st_line.amount
write_off_vals = reco_model._get_write_off_move_lines_dict(st_line, residual)
if write_off_vals:
line_ids_create_command = [(0, 0, vals) for vals in write_off_vals]
st_line.move_id.write({'line_ids': line_ids_create_command})
return {
'status': 'applied',
'model': reco_model.name,
'write_off_lines': len(write_off_vals) if write_off_vals else 0,
}
def unmatch_bank_line(env, params):
st_line_id = int(params['statement_line_id'])
st_line = env['account.bank.statement.line'].browse(st_line_id)
if not st_line.exists():
return {'error': 'Statement line not found'}
st_line.action_unreconcile_entry()
return {'status': 'unmatched', 'statement_line_id': st_line_id}
def get_reconcile_suggestions(env, params):
st_line_id = int(params['statement_line_id'])
st_line = env['account.bank.statement.line'].browse(st_line_id)
if not st_line.exists():
return {'error': 'Statement line not found'}
models = env['account.reconcile.model'].search([
('company_id', '=', env.company.id),
])
return {
'models': [{
'id': m.id,
'name': m.name,
'trigger': m.trigger if hasattr(m, 'trigger') else 'manual',
} for m in models],
}
def sum_payments_by_date(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
if not date_from or not date_to:
return {'error': 'date_from and date_to are required'}
journal_ids = params.get('journal_ids', [])
domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
('date', '>=', date_from),
('date', '<=', date_to),
]
if journal_ids:
domain.append(('journal_id', 'in', [int(j) for j in journal_ids]))
lines = env['account.move.line'].search(domain)
total_debit = sum(l.debit for l in lines)
total_credit = sum(l.credit for l in lines)
return {
'date_from': date_from,
'date_to': date_to,
'total_debit': total_debit,
'total_credit': total_credit,
'net': total_debit - total_credit,
'line_count': len(lines),
}
TOOLS = {
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
'get_unreconciled_receipts': get_unreconciled_receipts,
'match_bank_line_to_payments': match_bank_line_to_payments,
'auto_reconcile_bank_lines': auto_reconcile_bank_lines,
'apply_reconcile_model': apply_reconcile_model,
'unmatch_bank_line': unmatch_bank_line,
'get_reconcile_suggestions': get_reconcile_suggestions,
'sum_payments_by_date': sum_payments_by_date,
}

View File

@@ -1,171 +0,0 @@
import logging
_logger = logging.getLogger(__name__)
def calculate_hst_balance(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
base_domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
base_domain.append(('date', '>=', date_from))
if date_to:
base_domain.append(('date', '<=', date_to))
collected_accounts = env['account.account'].search([
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
])
itc_accounts = env['account.account'].search([
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
])
collected_lines = env['account.move.line'].search(
base_domain + [('account_id', 'in', collected_accounts.ids)]
)
itc_lines = env['account.move.line'].search(
base_domain + [('account_id', 'in', itc_accounts.ids)]
)
hst_collected = abs(sum(l.balance for l in collected_lines))
itcs = abs(sum(l.balance for l in itc_lines))
return {
'hst_collected': hst_collected,
'input_tax_credits': itcs,
'net_hst': hst_collected - itcs,
'status': 'owing' if (hst_collected - itcs) > 0 else 'refund',
'period': f'{date_from or "all"} to {date_to or "now"}',
}
def get_tax_report(env, params):
report_ref = params.get('report_ref', 'account.generic_tax_report')
try:
report = env.ref(report_ref)
except Exception:
return {'error': f'Report not found: {report_ref}'}
options = report.get_options({
'date': {
'date_from': params.get('date_from', ''),
'date_to': params.get('date_to', ''),
}
})
lines = report._get_lines(options)
return {
'report_name': report.name,
'lines': [{
'name': l.get('name', ''),
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
} for l in lines[:50]],
}
def find_missing_tax_invoices(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('move_type', 'in', ('out_invoice', 'out_refund')),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
invoices = env['account.move'].search(domain)
missing = invoices.filtered(
lambda inv: not any(line.tax_ids for line in inv.invoice_line_ids)
)
return {
'total_invoices': len(invoices),
'missing_tax_count': len(missing),
'invoices': [{
'id': inv.id,
'name': inv.name,
'partner': inv.partner_id.name if inv.partner_id else '',
'amount_total': inv.amount_total,
'date': str(inv.date),
} for inv in missing[:30]],
}
def find_missing_itc_bills(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('move_type', 'in', ('in_invoice', 'in_refund')),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
bills = env['account.move'].search(domain)
missing = bills.filtered(
lambda b: not any(line.tax_ids for line in b.invoice_line_ids)
)
return {
'total_bills': len(bills),
'missing_itc_count': len(missing),
'bills': [{
'id': b.id,
'name': b.name,
'partner': b.partner_id.name if b.partner_id else '',
'amount_total': b.amount_total,
'date': str(b.date),
} for b in missing[:30]],
}
def get_tax_return_status(env, params):
returns = env['account.return'].search([
('company_id', '=', env.company.id),
], order='date_start desc', limit=10)
return {
'returns': [{
'id': r.id,
'name': r.display_name,
'date_start': str(r.date_start) if hasattr(r, 'date_start') else '',
'date_end': str(r.date_end) if hasattr(r, 'date_end') else '',
'state': r.state if hasattr(r, 'state') else '',
} for r in returns],
}
def generate_tax_return(env, params):
try:
env['account.return']._generate_or_refresh_all_returns(
company=env.company
)
return {'status': 'generated', 'message': 'Tax returns refreshed successfully.'}
except Exception as e:
return {'error': str(e)}
def validate_tax_return(env, params):
return_id = int(params['return_id'])
tax_return = env['account.return'].browse(return_id)
if not tax_return.exists():
return {'error': 'Tax return not found'}
try:
tax_return.action_validate()
return {'status': 'validated', 'return_id': return_id}
except Exception as e:
return {'error': str(e)}
TOOLS = {
'calculate_hst_balance': calculate_hst_balance,
'get_tax_report': get_tax_report,
'find_missing_tax_invoices': find_missing_tax_invoices,
'find_missing_itc_bills': find_missing_itc_bills,
'get_tax_return_status': get_tax_return_status,
'generate_tax_return': generate_tax_return,
'validate_tax_return': validate_tax_return,
}

View File

@@ -1,117 +0,0 @@
import logging
import base64
_logger = logging.getLogger(__name__)
def _get_report(env, ref_id):
try:
return env.ref(ref_id)
except Exception:
return None
def _run_report(env, report_ref, params):
report = _get_report(env, report_ref)
if not report:
return {'error': f'Report {report_ref} not found'}
date_opts = {}
if params.get('date_from'):
date_opts['date_from'] = params['date_from']
if params.get('date_to'):
date_opts['date_to'] = params['date_to']
options = report.get_options({'date': date_opts} if date_opts else {})
lines = report._get_lines(options)
return {
'report_name': report.name,
'lines': [{
'name': l.get('name', ''),
'level': l.get('level', 0),
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
} for l in lines[:100]],
}
def get_profit_loss(env, params):
return _run_report(env, 'account_reports.profit_and_loss', params)
def get_balance_sheet(env, params):
return _run_report(env, 'account_reports.balance_sheet', params)
def get_trial_balance(env, params):
return _run_report(env, 'account_reports.trial_balance_report', params)
def get_cash_flow(env, params):
return _run_report(env, 'account_reports.cash_flow_statement', params)
def compare_periods(env, params):
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
report = _get_report(env, report_ref)
if not report:
return {'error': f'Report {report_ref} not found'}
period1 = _run_report(env, report_ref, {
'date_from': params.get('period1_from'),
'date_to': params.get('period1_to'),
})
period2 = _run_report(env, report_ref, {
'date_from': params.get('period2_from'),
'date_to': params.get('period2_to'),
})
return {'period_1': period1, 'period_2': period2}
def answer_financial_question(env, params):
question = params.get('question', '')
sql_query = params.get('sql_query')
if sql_query:
return {'error': 'Direct SQL not permitted. Use report tools instead.'}
return {'status': 'info', 'message': f'Use specific report tools to answer: {question}'}
def export_report(env, params):
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
fmt = params.get('format', 'pdf')
report = _get_report(env, report_ref)
if not report:
return {'error': f'Report {report_ref} not found'}
date_opts = {}
if params.get('date_from'):
date_opts['date_from'] = params['date_from']
if params.get('date_to'):
date_opts['date_to'] = params['date_to']
options = report.get_options({'date': date_opts} if date_opts else {})
try:
if fmt == 'xlsx':
result = report.dispatch_report_action(options, 'export_to_xlsx')
else:
result = report.dispatch_report_action(options, 'export_to_pdf')
if isinstance(result, dict) and result.get('file_content'):
return {
'file_name': result.get('file_name', f'report.{fmt}'),
'file_type': result.get('file_type', fmt),
'file_content_b64': base64.b64encode(result['file_content']).decode(),
}
return {
'status': 'generated',
'message': f'Report exported as {fmt}. Use the Odoo UI to download.',
}
except Exception as e:
return {'error': f'Export failed: {str(e)}'}
TOOLS = {
'get_profit_loss': get_profit_loss,
'get_balance_sheet': get_balance_sheet,
'get_trial_balance': get_trial_balance,
'get_cash_flow': get_cash_flow,
'compare_periods': compare_periods,
'answer_financial_question': answer_financial_question,
'export_report': export_report,
}

View File

@@ -1,20 +0,0 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class FusionApprovalCard extends Component {
static template = "fusion_accounting.ApprovalCard";
static props = ["approval", "onApprove", "onReject"];
get confidencePercent() {
return Math.round((this.props.approval.confidence || 0) * 100);
}
approve() {
this.props.onApprove(this.props.approval.id);
}
reject() {
this.props.onReject(this.props.approval.id);
}
}

View File

@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.ApprovalCard">
<div class="fusion_approval_card card border-warning mb-2">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-start mb-1">
<strong t-esc="props.approval.tool_name"/>
<span class="badge bg-warning text-dark">
<t t-esc="confidencePercent"/>% conf
</span>
</div>
<p class="small mb-1 text-muted" t-esc="props.approval.reasoning"/>
<t t-if="props.approval.amount">
<p class="small mb-1">
Amount: <strong>$<t t-esc="(props.approval.amount || 0).toFixed(2)"/></strong>
</p>
</t>
<div class="d-flex gap-2">
<button class="btn btn-success btn-sm flex-grow-1" t-on-click="approve">
<i class="fa fa-check"/> Approve
</button>
<button class="btn btn-outline-danger btn-sm flex-grow-1" t-on-click="reject">
<i class="fa fa-times"/> Reject
</button>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -1,304 +0,0 @@
/** @odoo-module **/
import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { FusionApprovalCard } from "./approval_card";
function mdToHtml(text) {
if (!text) return "";
// Split into lines for block-level processing
const lines = text.split("\n");
const output = [];
let inTable = false;
let tableHeader = false;
let inList = false;
let listType = null;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
const trimmed = line.trim();
// Close list if we're not in a list item anymore
if (inList && !trimmed.match(/^[-*]\s/) && !trimmed.match(/^\d+\.\s/) && trimmed !== "") {
output.push(listType === "ul" ? "</ul>" : "</ol>");
inList = false;
listType = null;
}
// Table row detection (line has at least 2 pipes)
const pipeCount = (trimmed.match(/\|/g) || []).length;
if (pipeCount >= 2 && trimmed.includes("|")) {
// Separator row (|---|---|)
if (/^[\s|:\-]+$/.test(trimmed)) {
tableHeader = true;
continue;
}
// Split cells
let cells = trimmed.split("|").map(c => c.trim());
// Remove empty first/last from leading/trailing pipes
if (cells[0] === "") cells.shift();
if (cells.length > 0 && cells[cells.length - 1] === "") cells.pop();
if (!inTable) {
output.push('<div class="table-responsive my-2"><table class="table table-sm table-bordered align-middle">');
inTable = true;
// First row is header
output.push("<thead><tr>");
cells.forEach(c => output.push(`<th class="px-2 py-1 fw-semibold">${inlineFormat(c)}</th>`));
output.push("</tr></thead><tbody>");
continue;
}
// Body row
output.push("<tr>");
cells.forEach(c => output.push(`<td class="px-2 py-1">${inlineFormat(c)}</td>`));
output.push("</tr>");
continue;
}
// Close table if we were in one
if (inTable) {
output.push("</tbody></table></div>");
inTable = false;
tableHeader = false;
}
// Empty line
if (trimmed === "") {
output.push("");
continue;
}
// Headers
const headerMatch = trimmed.match(/^(#{1,5})\s+(.+)$/);
if (headerMatch) {
const level = Math.min(headerMatch[1].length + 2, 6); // ## -> h4, ### -> h5
output.push(`<h${level} class="mt-3 mb-1">${inlineFormat(headerMatch[2])}</h${level}>`);
continue;
}
// Horizontal rule
if (/^[-*_]{3,}$/.test(trimmed)) {
output.push('<hr class="my-2"/>');
continue;
}
// Unordered list
const ulMatch = trimmed.match(/^[-*]\s+(.+)$/);
if (ulMatch) {
if (!inList || listType !== "ul") {
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
output.push('<ul class="mb-1">');
inList = true;
listType = "ul";
}
output.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
continue;
}
// Ordered list
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
if (!inList || listType !== "ol") {
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
output.push('<ol class="mb-1">');
inList = true;
listType = "ol";
}
output.push(`<li>${inlineFormat(olMatch[1])}</li>`);
continue;
}
// Regular paragraph
output.push(`<p class="mb-1">${inlineFormat(trimmed)}</p>`);
}
// Close open elements
if (inTable) output.push("</tbody></table></div>");
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
return output.join("\n");
}
function inlineFormat(text) {
if (!text) return "";
return text
// Escape HTML entities
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// Bold + italic
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Links [text](url)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
// Odoo record links #model,id
.replace(/#([\w.]+),(\d+)/g, '<a href="/odoo/$1/$2" class="badge text-bg-primary text-decoration-none">$1#$2</a>');
}
export class FusionChatPanel extends Component {
static template = "fusion_accounting.ChatPanel";
static components = { FusionApprovalCard };
static props = ["sessionId?"];
setup() {
this.inputRef = useRef("chatInput");
this.messagesRef = useRef("messages");
this.state = useState({
messages: [],
pendingApprovals: [],
inputText: "",
sending: false,
loading: true,
internalSessionId: null,
sessionName: null,
});
onWillStart(async () => {
await this.loadLatestSession();
});
onMounted(() => {
this._renderRichMessages();
});
onPatched(() => {
this._renderRichMessages();
});
}
_renderRichMessages() {
const container = this.messagesRef.el;
if (!container) return;
const richDivs = container.querySelectorAll(".fusion_rich_slot[data-idx]");
for (const div of richDivs) {
const idx = parseInt(div.dataset.idx);
const msg = this.state.messages[idx];
if (msg && msg.role === "assistant" && msg.content) {
const html = mdToHtml(msg.content);
if (div.innerHTML !== html) {
div.innerHTML = html;
}
}
}
}
get sessionId() {
return this.state.internalSessionId || this.props.sessionId;
}
async loadLatestSession() {
this.state.loading = true;
try {
const data = await rpc("/fusion_accounting/session/latest", {});
if (data.session_id) {
this.state.internalSessionId = data.session_id;
this.state.messages = data.messages || [];
this.state.sessionName = data.name;
}
} catch (e) {
console.error("Failed to load session:", e);
}
this.state.loading = false;
this.scrollToBottom();
}
async onNewChat() {
if (this.sessionId) {
try {
await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
} catch (e) { /* not critical */ }
}
const session = await rpc("/fusion_accounting/session/create", {});
this.state.internalSessionId = session.session_id;
this.state.sessionName = session.name;
this.state.messages = [];
this.state.pendingApprovals = [];
}
async sendMessage() {
const text = this.state.inputText.trim();
if (!text || this.state.sending) return;
if (!this.sessionId) {
const session = await rpc("/fusion_accounting/session/create", {});
this.state.internalSessionId = session.session_id;
this.state.sessionName = session.name;
}
this.state.messages.push({ role: "user", content: text });
this.state.inputText = "";
this.state.sending = true;
this.scrollToBottom();
try {
const result = await rpc("/fusion_accounting/chat", {
session_id: this.sessionId,
message: text,
});
if (result.text) {
this.state.messages.push({ role: "assistant", content: result.text });
}
if (result.pending_approvals) {
this.state.pendingApprovals = result.pending_approvals;
}
} catch (e) {
this.state.messages.push({
role: "assistant",
content: `Error: ${e.message || "Something went wrong."}`,
});
}
this.state.sending = false;
this.scrollToBottom();
}
onKeyDown(ev) {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
this.sendMessage();
}
}
scrollToBottom() {
const el = this.messagesRef.el;
if (el) {
setTimeout(() => { el.scrollTop = el.scrollHeight; }, 100);
}
}
async onApprove(matchHistoryId) {
await rpc("/fusion_accounting/approve", { match_history_id: matchHistoryId });
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);
this.state.messages.push({ role: "assistant", content: "Action approved and executed." });
}
async onReject(matchHistoryId) {
await rpc("/fusion_accounting/reject", { match_history_id: matchHistoryId, reason: "User rejected" });
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);
this.state.messages.push({ role: "assistant", content: "Action rejected." });
}
async onApproveAll() {
const ids = this.state.pendingApprovals.map(a => a.id);
if (!ids.length) return;
await rpc("/fusion_accounting/approve_all", { match_history_ids: ids });
const count = this.state.pendingApprovals.length;
this.state.pendingApprovals = [];
this.state.messages.push({ role: "assistant", content: `${count} actions approved and executed.` });
}
async onRejectAll() {
const ids = this.state.pendingApprovals.map(a => a.id);
if (!ids.length) return;
await rpc("/fusion_accounting/reject_all", { match_history_ids: ids, reason: "Batch rejected" });
const count = this.state.pendingApprovals.length;
this.state.pendingApprovals = [];
this.state.messages.push({ role: "assistant", content: `${count} actions rejected.` });
}
}

View File

@@ -1,103 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.ChatPanel">
<div class="fusion_chat_panel card h-100 d-flex flex-column">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<div>
<h5 class="mb-0 d-inline"><i class="fa fa-comments-o me-2"/>Fusion AI</h5>
<small class="text-muted ms-2" t-if="state.sessionName" t-esc="state.sessionName"/>
</div>
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
title="Start a new conversation">
<i class="fa fa-plus me-1"/>New Chat
</button>
</div>
<!-- Messages -->
<div class="fusion_chat_messages flex-grow-1 overflow-auto p-3" t-ref="messages">
<t t-if="state.loading">
<div class="text-center text-muted py-4">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2">Loading conversation...</p>
</div>
</t>
<t t-elif="state.messages.length === 0">
<div class="text-center text-muted py-4">
<i class="fa fa-robot fa-3x mb-3 d-block"/>
<p>Ask me about your accounting data.<br/>
I can help with bank reconciliation, tax analysis, AR/AP, auditing, and more.</p>
</div>
</t>
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
<!-- User message -->
<t t-if="msg.role === 'user'">
<div class="fusion_chat_msg mb-2 p-2 rounded bg-primary-subtle ms-4">
<small class="text-muted d-block mb-1">
<i class="fa fa-user me-1"/>You
</small>
<span style="white-space: pre-wrap;" t-esc="msg.content"/>
</div>
</t>
<!-- AI message — rich HTML rendered via onPatched -->
<t t-else="">
<div class="fusion_chat_msg fusion_ai_msg mb-3 p-3 rounded me-4">
<small class="text-muted d-block mb-2">
<i class="fa fa-robot me-1"/>Fusion AI
</small>
<div class="fusion_rich_content fusion_rich_slot"
t-att-data-idx="msg_index"/>
</div>
</t>
</t>
<t t-if="state.sending">
<div class="fusion_ai_msg rounded p-3 me-4 mb-2">
<small class="text-muted d-block mb-1">
<i class="fa fa-robot me-1"/>Fusion AI
</small>
<i class="fa fa-spinner fa-spin me-1"/> Thinking...
</div>
</t>
</div>
<!-- Pending Approvals -->
<t t-if="state.pendingApprovals.length > 0">
<div class="border-top p-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">Pending Approvals (<t t-esc="state.pendingApprovals.length"/>):</small>
<div class="d-flex gap-1" t-if="state.pendingApprovals.length > 1">
<button class="btn btn-success btn-sm" t-on-click="onApproveAll">
<i class="fa fa-check-double"/> Approve All
</button>
<button class="btn btn-outline-danger btn-sm" t-on-click="onRejectAll">
Reject All
</button>
</div>
</div>
<t t-foreach="state.pendingApprovals" t-as="approval" t-key="approval.id">
<FusionApprovalCard
approval="approval"
onApprove.bind="onApprove"
onReject.bind="onReject"/>
</t>
</div>
</t>
<!-- Input -->
<div class="fusion_chat_input border-top p-2">
<div class="input-group">
<textarea
t-ref="chatInput"
class="form-control form-control-sm"
placeholder="Ask Fusion AI..."
rows="2"
t-model="state.inputText"
t-on-keydown="onKeyDown"/>
<button class="btn btn-primary btn-sm" t-on-click="sendMessage"
t-att-disabled="state.sending">
<i class="fa fa-paper-plane"/>
</button>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.Dashboard">
<div class="o_action fusion_accounting_dashboard">
<div class="fusion_dashboard_header d-flex justify-content-between align-items-center p-3">
<h2 class="mb-0">Fusion AI Dashboard</h2>
<button class="btn btn-outline-primary btn-sm" t-on-click="loadDashboard">
<i class="fa fa-refresh"/> Refresh
</button>
</div>
<t t-if="state.loading">
<div class="text-center p-5">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2">Loading dashboard...</p>
</div>
</t>
<t t-else="">
<!-- Health Cards -->
<div class="fusion_health_cards d-flex flex-wrap gap-3 p-3">
<t t-foreach="cards" t-as="card" t-key="card.domain">
<FusionHealthCard
title="card.title"
metric="card.metric"
subtext="card.subtext"
status="card.status"
domain="card.domain"
onCardClick.bind="onCardClick"/>
</t>
</div>
<!-- Action Centre + Chat -->
<div class="d-flex gap-3 p-3" style="min-height: 500px;">
<!-- Action Centre -->
<div class="flex-grow-1">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">Needs Attention</h5>
</div>
<div class="card-body overflow-auto">
<p class="text-muted">AI-prioritised items will appear here after the first audit scan.</p>
</div>
</div>
</div>
<!-- Chat Panel (720px = original 400 + 80%) -->
<div style="width: 720px; min-width: 600px;">
<FusionChatPanel sessionId="state.chatSessionId"/>
</div>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -1,22 +0,0 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class FusionHealthCard extends Component {
static template = "fusion_accounting.HealthCard";
static props = ["title", "metric", "subtext", "status", "domain", "onCardClick"];
get statusClass() {
const map = {
green: "bg-success-subtle border-success",
yellow: "bg-warning-subtle border-warning",
red: "bg-danger-subtle border-danger",
blue: "bg-info-subtle border-info",
};
return map[this.props.status] || "bg-light";
}
onClick() {
this.props.onCardClick(this.props.domain);
}
}

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.HealthCard">
<div class="fusion_health_card card border-2 cursor-pointer"
t-attf-class="{{statusClass}}"
style="min-width: 180px; flex: 1;"
t-on-click="onClick">
<div class="card-body text-center p-3">
<h6 class="card-title text-muted mb-1" t-esc="props.title"/>
<h3 class="mb-1" t-esc="props.metric"/>
<small class="text-muted" t-esc="props.subtext"/>
</div>
</div>
</t>
</templates>

View File

@@ -1,72 +0,0 @@
.fusion_chat_panel {
.fusion_chat_messages {
max-height: 500px;
min-height: 300px;
}
.fusion_chat_msg {
word-break: break-word;
}
.fusion_ai_msg {
background: var(--o-view-background-color);
border: 1px solid var(--o-border-color);
}
.fusion_rich_content {
font-size: 0.9rem;
line-height: 1.5;
h3, h4, h5 {
font-weight: 600;
color: var(--o-main-color-5, inherit);
}
table {
font-size: 0.85rem;
}
a {
color: var(--o-action-color, var(--bs-link-color));
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
a.badge {
font-size: 0.75rem;
cursor: pointer;
}
code {
font-size: 0.85em;
}
hr {
border-color: var(--o-border-color);
}
ul, ol {
margin-bottom: 0.25rem;
}
li {
margin-bottom: 0.15rem;
}
strong {
font-weight: 600;
}
}
.fusion_chat_input {
textarea {
resize: none;
}
}
.fusion_approval_card {
border-left: 3px solid var(--bs-warning);
}
}

View File

@@ -1,16 +0,0 @@
.fusion_accounting_dashboard {
.fusion_dashboard_header {
border-bottom: 1px solid var(--o-border-color);
background: var(--o-view-background-color);
}
.fusion_health_cards {
.fusion_health_card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(var(--bs-body-color-rgb), 0.1);
}
}
}
}

View File

@@ -0,0 +1,37 @@
# Fusion Accounting Tooling
## check_odoo_diff.sh
Diff a single Odoo Enterprise accounting module across two pinned snapshots
in `RePackaged-Odoo/` and produce a categorized change report (markdown).
### Usage
tools/check_odoo_diff.sh <module> <from_version> <to_version> [<output_md>]
### Example
# When Odoo 20 ships, get a full report on what changed in account_accountant
tools/check_odoo_diff.sh account_accountant v19 v20 > reports/v20_accountant.md
### Classification tags
- `[MIRROR]` — mechanical port required (view XML, OWL component, PDF template, wizard view)
- `[ABSTRACT]` — verify our adapter still aligns; update if Odoo's public API surface changed
- `[MANIFEST]` — manifest changes (deps, asset bundles, version, hooks)
- `[TEST]` — Odoo's tests changed; check if our equivalents need updates
- `[REVIEW]` — uncategorized; manual review needed
### Snapshot conventions
Snapshots live at `$REPACKAGED_ODOO_ROOT/accounting-<version>/<module>` (default
root: `/Users/gurpreet/Github/RePackaged-Odoo`). Override the root with the
`REPACKAGED_ODOO_ROOT` env var.
The current workspace has only the V19 snapshot at
`/Users/gurpreet/Github/RePackaged-Odoo/accounting/` (unversioned). When
Odoo 20 ships:
1. Rename the current snapshot: `mv accounting accounting-v19`
2. Drop the new V20 source at `accounting-v20/`
3. Run `tools/check_odoo_diff.sh account_accountant v19 v20` per sub-module

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# check_odoo_diff.sh
#
# Diff a single Odoo Enterprise accounting module across two pinned snapshots
# and produce a categorized change report.
#
# Usage:
# tools/check_odoo_diff.sh <module> <from_version> <to_version> [<output_md>]
#
# Example:
# tools/check_odoo_diff.sh account_accountant v19 v20 reports/v20_accountant_diff.md
set -euo pipefail
MODULE="${1:?Usage: check_odoo_diff.sh <module> <from_version> <to_version> [<output_md>]}"
FROM="${2:?from_version required (e.g. v19)}"
TO="${3:?to_version required (e.g. v20)}"
OUT="${4:-/dev/stdout}"
ROOT="${REPACKAGED_ODOO_ROOT:-/Users/gurpreet/Github/RePackaged-Odoo}"
FROM_DIR="$ROOT/accounting-$FROM/$MODULE"
TO_DIR="$ROOT/accounting-$TO/$MODULE"
if [ ! -d "$FROM_DIR" ]; then
echo "ERROR: $FROM_DIR does not exist. Snapshot $FROM not yet present?" >&2
exit 1
fi
if [ ! -d "$TO_DIR" ]; then
echo "ERROR: $TO_DIR does not exist. Snapshot $TO not yet present?" >&2
exit 1
fi
classify() {
local f="$1"
case "$f" in
*/views/*|*/static/src/components/*|*/report/*|*/wizard/*_views.xml|*/wizards/*_views.xml)
echo "[MIRROR]" ;;
*/models/*_engine.py|*/services/*)
echo "[ABSTRACT]" ;;
*/__manifest__.py)
echo "[MANIFEST]" ;;
*/tests/*)
echo "[TEST]" ;;
*)
echo "[REVIEW]" ;;
esac
}
{
echo "# Diff Report: $MODULE ($FROM -> $TO)"
echo ""
echo "Generated: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
echo "## Changed Files (with classification suggestion)"
echo ""
diff -ruN --brief "$FROM_DIR" "$TO_DIR" | while read -r line; do
case "$line" in
"Files "*" and "*" differ")
file=$(echo "$line" | sed -E 's/^Files (.+) and .+ differ$/\1/' | sed "s|$FROM_DIR/||")
tag=$(classify "$file")
echo "- $tag \`$file\`"
;;
"Only in $TO_DIR"*)
file=$(echo "$line" | sed -E "s|Only in $TO_DIR(.*): (.+)|\1/\2|" | sed "s|^/||")
tag=$(classify "$file")
echo "- $tag NEW: \`$file\`"
;;
"Only in $FROM_DIR"*)
file=$(echo "$line" | sed -E "s|Only in $FROM_DIR(.*): (.+)|\1/\2|" | sed "s|^/||")
tag=$(classify "$file")
echo "- $tag REMOVED: \`$file\`"
;;
esac
done
echo ""
echo "## Full Diff (truncated to first 2000 lines)"
echo ""
echo '```diff'
diff -ruN "$FROM_DIR" "$TO_DIR" | head -2000
echo '```'
} > "$OUT"
echo "Diff report written to: $OUT" >&2

View File

@@ -1,97 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_history_list" model="ir.ui.view">
<field name="name">fusion.accounting.match.history.list</field>
<field name="model">fusion.accounting.match.history</field>
<field name="arch" type="xml">
<list string="Match History">
<field name="proposed_at"/>
<field name="tool_name"/>
<field name="decision" widget="badge"
decoration-success="decision == 'approved'"
decoration-danger="decision == 'rejected'"
decoration-warning="decision == 'pending'"
decoration-info="decision == 'auto'"/>
<field name="ai_confidence" widget="progressbar"/>
<field name="amount"/>
<field name="partner_id"/>
<field name="decided_by"/>
<field name="decided_at"/>
</list>
</field>
</record>
<record id="view_fusion_history_form" model="ir.ui.view">
<field name="name">fusion.accounting.match.history.form</field>
<field name="model">fusion.accounting.match.history</field>
<field name="arch" type="xml">
<form string="Match History">
<header>
<button name="action_approve" string="Approve" type="object"
class="btn-primary" invisible="decision != 'pending'"
groups="fusion_accounting.group_fusion_accounting_manager"/>
<button name="action_reject" string="Reject" type="object"
class="btn-danger" invisible="decision != 'pending'"
groups="fusion_accounting.group_fusion_accounting_manager"/>
</header>
<sheet>
<group>
<group>
<field name="tool_name"/>
<field name="decision"/>
<field name="ai_confidence"/>
<field name="amount"/>
<field name="partner_id"/>
</group>
<group>
<field name="session_id"/>
<field name="rule_id"/>
<field name="proposed_at"/>
<field name="decided_at"/>
<field name="decided_by"/>
</group>
</group>
<group string="AI Details">
<field name="ai_reasoning"/>
<field name="tool_params"/>
<field name="tool_result"/>
</group>
<group string="Correction" invisible="decision != 'rejected'">
<field name="rejection_reason"/>
<field name="correct_action"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_fusion_history_search" model="ir.ui.view">
<field name="name">fusion.accounting.match.history.search</field>
<field name="model">fusion.accounting.match.history</field>
<field name="arch" type="xml">
<search>
<field name="tool_name"/>
<field name="partner_id"/>
<filter name="pending" string="Pending" domain="[('decision', '=', 'pending')]"/>
<filter name="approved" string="Approved" domain="[('decision', '=', 'approved')]"/>
<filter name="rejected" string="Rejected" domain="[('decision', '=', 'rejected')]"/>
<separator/>
<group>
<filter name="group_tool" string="Tool" domain="[]" context="{'group_by': 'tool_name'}"/>
<filter name="group_decision" string="Decision" domain="[]" context="{'group_by': 'decision'}"/>
</group>
</search>
</field>
</record>
<record id="action_fusion_history" model="ir.actions.act_window">
<field name="name">Match History</field>
<field name="res_model">fusion.accounting.match.history</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_history_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">No match history yet</p>
<p>AI tool calls and their outcomes will appear here.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,272 @@
# fusion_accounting_ai — Cursor / Claude Context
## Purpose
Conversational AI co-pilot for Odoo Accounting using Claude or GPT with native
tool-calling. Embeds in any Odoo install via the data-adapter pattern (works on
Community-only, Community + fusion native sub-modules, or Community + Enterprise).
## Sub-module relationships
- `fusion_accounting_core`: hard dep, provides security groups + Enterprise detection
- `fusion_accounting_bank_rec` (Phase 1): adapter routes to it when present
- `fusion_accounting_reports` (Phase 2): same
- `fusion_accounting_followup` (Phase 5): same
- Odoo Enterprise modules: detected at runtime, AI tools route through them via adapters
## Data-adapter pattern (Phase 0 addition)
- `services/data_adapters/base.py``DataAdapter` + `AdapterMode`
- `services/data_adapters/_registry.py``get_adapter(env, name)` + `register_adapter`
- One adapter file per domain: `bank_rec.py`, `reports.py`, `followup.py`, `assets.py`
- Each adapter implements `<method>_via_fusion`, `<method>_via_enterprise`, `<method>_via_community`
- Adapter `_select_mode()` picks fusion if model loaded, else enterprise if module installed, else community
---
## Architecture
```
fusion_accounting_ai/
├── models/ 7 files (5 new models + 2 inherits: account.move, res.config.settings)
├── services/
│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop)
│ ├── adapters/ Claude + OpenAI adapters with native tool-calling
│ ├── data_adapters/ Tri-mode domain routers (fusion / enterprise / community)
│ ├── tools/ 93 tool functions across 11 domain files
│ ├── prompts/ System prompt builder + 12 domain-specific prompts
│ └── scoring.py Confidence scoring + tier promotion logic
├── controllers/ 10 JSON-RPC endpoints
├── wizards/ Rule creation wizard
├── static/src/ OWL dashboard + chat panel + approval cards
├── views/ List/form/search views, menus, settings
├── security/ ACLs + record rules (groups themselves live in fusion_accounting_core)
├── data/ 88 tool definitions, 2 default rules, 2 crons, 1 sequence
├── tests/ API integration tests
└── report/ Audit report QWeb template
```
## Key Design Decisions
### AI Provider Integration
- Uses `fusion.api.service` (from fusion_api module) for API key resolution with fallback to `ir.config_parameter` — NO hard dependency on fusion_api
- Claude adapter: native `tool_use` blocks, extended thinking enabled (8K budget) for all Claude 4.x models
- OpenAI adapter: Chat Completions API with o-series reasoning model support (`developer` role, `max_completion_tokens`, `reasoning_effort`)
- API keys stored in `ir.config_parameter` with `fusion_accounting.` prefix
- API key fields in Settings use `password="True"` widget — labels include "(Fusion AI)" suffix to avoid conflicts with other modules' key fields
- **Provider pinning**: Sessions remember which provider was used. If the global provider changes mid-session, the session continues with its original provider to prevent cross-adapter message format contamination.
### Tool Tiering
- **Tier 1** (Free): Read-only, execute immediately — 60+ tools
- **Tier 2** (Auto-approved): Low-risk writes, logged — ~10 tools
- **Tier 3** (Requires approval): Financial writes, user must approve — ~15 tools
- Auto-promotion: Tier 3 → Tier 2 at 95% accuracy over 30+ decisions (atomic SQL counters on `fusion.accounting.rule._record_decision`)
- Tool descriptions include tier labels (e.g., `[Tier 3: Requires user approval]`) so the AI knows which tools need approval
- When a Tier 3 tool is encountered during the chat loop, the loop short-circuits: a final text response is forced so the AI can present approval cards to the user
### Tier 3 Approval Flow
- When a Tier 3 action is approved/rejected, the session's `message_ids_json` is updated to replace the `pending_approval` placeholder with the actual tool result — this prevents dangling `tool_use` blocks that would cause API errors on the next chat turn
- After approval, `scoring.check_promotions()` is called to check if any rules should be promoted
### Menu Location
- **Parent**: `accountant.menu_accounting` (NOT `account.menu_finance` — that's Community Edition only)
- Enterprise uses `accountant.menu_accounting` (ID 1663) as the visible menu root
- `account.menu_finance` (ID 180) exists but has NO visible children in Enterprise — it's the Community root
### Session Persistence
- Chat sessions stored in `fusion.accounting.session` with `message_ids_json` (JSON text field)
- On page load, chat panel calls `/session/latest` to restore the most recent active session
- Empty assistant messages (tool-call-only responses with no text) are filtered out by the controller
- "New Chat" button closes current session and creates a fresh one
- Session name (e.g., FAS/2026/00001) shown in the chat header
- **Session ownership**: Controllers verify the current user owns the session (managers can access any session)
### Rich Text Chat Output
- AI responses are rendered as rich HTML, not plain text
- Markdown-to-HTML conversion happens client-side in `chat_panel.js` via `mdToHtml()` function
- HTML is injected via `innerHTML` on `onMounted` + `onPatched` (NOT via OWL's `markup()` / `t-out` — those proved unreliable in Odoo 19)
- The `_renderRichMessages()` method finds `.fusion_rich_slot[data-idx]` divs and sets their innerHTML
- Supported: headers (# through #####), **bold**, *italic*, `code`, tables, bullet/numbered lists, horizontal rules, [links](url)
- System prompt instructs AI to use markdown formatting and include Odoo record links like `[INV/2026/00123](/odoo/accounting/123)`
### Interactive Tables (fusion-table)
- AI can return `fusion-table` fenced code blocks instead of Markdown tables for actionable results
- `mdToHtml()` detects these blocks, extracts JSON, and renders `FusionInteractiveTable` OWL components via `mount()`
- **Interactive mode**: checkbox column + data columns + AI Recommendation column (colour-coded badge) + Your Input column (text field per row) + bottom bulk action bar
- **Read-only mode**: styled table, no inputs/actions
- Actions: Apply Recommendations, Flag Selected, Create Rules, Dismiss Selected, Submit All Notes to AI
- Action button clicks format a `[TABLE_ACTION]` structured message and send it back through the chat endpoint
- The AI decides per-response whether to use interactive or Markdown tables based on whether the data is actionable
- Used for: `find_missing_itc_bills`, `find_duplicate_bills`, `get_overdue_invoices`, `find_draft_entries`, `get_unreconciled_bank_lines`, etc.
- NOT used for: `get_profit_loss`, `get_balance_sheet`, `get_trial_balance` (informational, read-only)
- All styles use Odoo CSS variables — dark/light mode handled automatically
### Dashboard Layout
- Health cards row at top (6 cards: Bank Recon, AR, AP, HST, Audit Score, Month-End)
- Below: side-by-side layout — "Needs Attention" panel (flex-grow) + Chat panel (720px fixed width)
- Chat panel is 720px (80% larger than original 400px design)
- Dashboard endpoint returns `needs_attention` and `recent_activity` JSON arrays alongside health card metrics
### HST Filing Workflow (4-Phase AI-Driven)
- Phase 1: AI runs all HST reports (tax report, missing ITCs, compliance audit, HST balance)
- Phase 2: AI sweeps ALL bank accounts for unreconciled expense payments
- Phase 3: Per-line processing — check for existing bills, check history for coding patterns, ask about HST, create bills, register payments
- Phase 4: Re-run reports to verify updated HST position
- New tools added: `search_partners` (Tier 1), `find_similar_bank_lines` (Tier 1), `get_bank_line_details` (Tier 1), `create_vendor_bill` (Tier 3), `register_bill_payment` (Tier 3), `create_expense_entry` (Tier 3)
- Two paths for recording expenses: (a) formal vendor bill + payment, or (b) direct GL entry in MISC journal with optional HST split
- The `create_expense_entry` tool posts directly to the Miscellaneous Operations journal — debit expense + debit HST ITC (2006) + credit bank
- Domain prompt (`hst_management` in domain_prompts.py) includes bank journal IDs and the full 4-phase workflow instructions
## Odoo 19 Gotchas (Learned the Hard Way)
### Search Views
- NO `string` attribute on `<search>` element
- NO `string` attribute on `<group>` element inside search views
- Group-by filters MUST have `domain="[]"` attribute
- Add `<separator/>` before `<group>` in search views
### OWL Client Actions
- Components registered as client actions receive props: `action`, `actionId`, `updateActionState`, `className`
- Must use `static props = ["*"]` (accept any) — NOT `static props = []` (accept none)
### OWL Rich HTML Rendering
- `markup()` from `@odoo/owl` + `t-out` is UNRELIABLE in Odoo 19 for rendering HTML in OWL components
- Use `onMounted` + `onPatched` hooks to find DOM elements and set `innerHTML` directly
- Pattern: render a placeholder `<div class="slot" t-att-data-idx="index"/>`, then in the hook find it and set `.innerHTML`
- Always use BOTH `onMounted` AND `onPatched``onPatched` alone misses the first render
### Cron Safe Eval
- NO `import` statements (forbidden opcode `IMPORT_NAME`)
- `datetime` module available as `datetime` (use `datetime.datetime.now()`, `datetime.timedelta()`)
- NO `from datetime import X` pattern
### read_group Deprecated
- `read_group()` is deprecated in Odoo 19 — use `_read_group()` instead
- Still works but throws DeprecationWarning
- Dashboard `accounting_dashboard.py` still uses `read_group()` — migrate to `_read_group()` when the new API is stable
### Config Parameter Values
- When changing a Selection field's options, the stored DB value in `ir_config_parameter` must match one of the new options or Settings page will crash with `ValueError: Wrong value`
- Fix: UPDATE the value in DB after changing selection options:
```sql
UPDATE ir_config_parameter SET value = 'new_value' WHERE key = 'fusion_accounting.field_name';
```
### Field Label Conflicts
- Odoo warns if two fields on the same model have the same `string` label
- Our `display_name_field` conflicted with built-in `display_name` — renamed string to "Tool Label"
- API key fields use "(Fusion AI)" suffix to avoid label conflicts with other modules
- Tool model uses `domain` (not `domain_name`) and `parameters_schema` (not `parameters`) as field names
### Group Assignment
- `implied_ids` on groups only applies to NEWLY added users, not existing ones
- After installing, manually add existing users to groups via SQL:
```sql
INSERT INTO res_groups_users_rel (gid, uid)
SELECT <group_id>, gu.uid FROM res_groups_users_rel gu
JOIN ir_model_data imd ON imd.res_id = gu.gid AND imd.model = 'res.groups'
WHERE imd.module = 'account' AND imd.name = 'group_account_manager'
ON CONFLICT DO NOTHING;
```
### TransientModel in Controllers
- Use `.new({...})` NOT `.create({...})` for TransientModels in controller endpoints
- `.create()` writes a DB row on every request; `.new()` is in-memory only
- Dashboard controller uses `.new()` to compute health metrics without DB writes
## Server Details
- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`)
- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL)
- **Database**: westin-v19
- **Module path**: `/mnt/extra-addons/fusion_accounting_ai/`
- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages`
- **URL**: erp.westinhealthcare.ca
## Deployment Commands
```bash
# Full deploy cycle (clean + copy + upgrade + restart)
ssh odoo-westin "docker exec -u 0 odoo-dev-app rm -rf /mnt/extra-addons/fusion_accounting_ai"
scp -r "K:\Github\Odoo-Modules\fusion_accounting_ai" odoo-westin:/tmp/fusion_accounting_ai
ssh odoo-westin "docker cp /tmp/fusion_accounting_ai odoo-dev-app:/mnt/extra-addons/fusion_accounting_ai && rm -rf /tmp/fusion_accounting_ai"
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_accounting_ai --stop-after-init --http-port=8099 -c /etc/odoo/odoo.conf"
ssh odoo-westin "docker restart odoo-dev-app"
# Check logs
ssh odoo-westin "docker logs odoo-dev-app --tail 100"
# Quick DB queries
ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -t -c \"<SQL>\""
# Check module state
ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -t -c \"SELECT name, state, latest_version FROM ir_module_module WHERE name = 'fusion_accounting_ai';\""
```
## Security Groups
(The three groups themselves are now defined in `fusion_accounting_core`. This
module's `security/ir.model.access.csv` grants access on AI-specific models
using those group XML-ids.)
| XML ID (in fusion_accounting_core) | Name | Access in AI module |
|---|---|---|
| `group_fusion_accounting_user` | User | Dashboard, chat (read-only tools) |
| `group_fusion_accounting_manager` | Manager | + Approve/reject, Tier 2 tools, rules |
| `group_fusion_accounting_admin` | Administrator | + Config, all tools, rule admin |
Auto-assigned (configured in _core): `account.group_account_user` → User,
`account.group_account_manager` → Admin
## Controller Endpoints
| Route | Auth | Purpose |
|---|---|---|
| `/fusion_accounting/session/create` | user | Create new chat session |
| `/fusion_accounting/session/close` | user (ownership check) | Close active session |
| `/fusion_accounting/session/latest` | user (own sessions only) | Load most recent active session + messages |
| `/fusion_accounting/session/history` | user (ownership check, managers see all) | Load specific session messages |
| `/fusion_accounting/chat` | user (ownership check) | Send message, get AI response |
| `/fusion_accounting/approve` | user + manager group check | Approve single Tier 3 action |
| `/fusion_accounting/reject` | user + manager group check | Reject single Tier 3 action |
| `/fusion_accounting/approve_all` | user + manager group check | Batch approve multiple actions |
| `/fusion_accounting/reject_all` | user + manager group check | Batch reject multiple actions |
| `/fusion_accounting/dashboard/data` | user | Get dashboard health card metrics + needs_attention + recent_activity |
Note: Approve/reject endpoints use `auth='user'` at the decorator level with an imperative `has_group()` check inside the handler (Odoo has no built-in `auth='manager'`).
## Models
| Model | Type | Location | Purpose |
|---|---|---|---|
| `fusion.accounting.session` | Model | models/ | Chat sessions with message JSON storage |
| `fusion.accounting.match.history` | Model | models/ | Every AI tool call + decision (approved/rejected/pending) |
| `fusion.accounting.rule` | Model | models/ | Fusion Rules engine with versioning and auto-promotion |
| `fusion.accounting.tool` | Model | models/ | Tool registry (82 tools seeded from XML) |
| `fusion.accounting.dashboard` | TransientModel | models/ | Computed health metrics (use `.new()` not `.create()`) |
| `res.config.settings` (inherit) | TransientModel | models/ | Settings page (API keys, thresholds, toggles) |
| `account.move` (inherit) | Model | models/ | Post-action audit hook |
| `fusion.accounting.agent` | AbstractModel | services/ | AI orchestrator |
| `fusion.accounting.adapter.claude` | AbstractModel | services/ | Claude tool-calling adapter |
| `fusion.accounting.adapter.openai` | AbstractModel | services/ | OpenAI tool-calling adapter |
| `fusion.accounting.scoring` | AbstractModel | services/ | Confidence scoring |
| `fusion.accounting.rule.wizard` | TransientModel | wizards/ | Quick-create rule from chat suggestion |
## AI Models Available
**Claude** (default: claude-sonnet-4-6):
- claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5
- claude-sonnet-4-5, claude-opus-4-5, claude-sonnet-4-0, claude-opus-4-0
**OpenAI** (default: gpt-5.4-mini):
- gpt-5.4, gpt-5.4-mini, gpt-5.4-nano
- o3, o4-mini
- gpt-4o, gpt-4o-mini (legacy)
## Theme / Styling Rules
- NO hardcoded colours — use CSS variables (`var(--o-border-color)`, `var(--bs-body-color-rgb)`) and Bootstrap utility classes
- Must work in both light and dark mode
- Box shadows: use `rgba(var(--bs-body-color-rgb), 0.1)` not `rgba(0,0,0,0.1)`
- AI messages use `var(--o-view-background-color)` background + `var(--o-border-color)` border
- Links use `var(--o-action-color)` for theme awareness
## Known Issues / Future Work
- `read_group()` deprecation warnings in `accounting_dashboard.py` — migrate to `_read_group()` when the new API format is stable
- `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2)
- `get_payroll_schedule`, `verify_source_deductions`, `verify_payroll_deductions` are stubs (Phase 2 — fusion_payroll integration)
- `answer_financial_question` is a stub (returns message to use other tools instead)
- Batch approval "Approve All" / "Reject All" buttons are in the chat panel but not yet in the match history list view
- "Needs Attention" panel shows placeholder text in the dashboard — the data is computed and returned by the API but the frontend rendering needs to be connected
- Consider switching OpenAI adapter from Chat Completions API to Responses API for better tool handling with newer models
- `o1` model does not support tool calling — no guard in place (o3/o4-mini do support it)
- Multi-company record rule on `fusion.accounting.session` — added in Phase 0 split-out (see UPGRADE_NOTES.md)

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