From 75eb08468757eff0c6a2ec49b197404f93f5745a Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 21:22:01 -0400 Subject: [PATCH 01/33] feat(fusion_accounting_core): add empty sub-module skeleton Made-with: Cursor --- fusion_accounting_core/__init__.py | 1 + fusion_accounting_core/__manifest__.py | 32 +++++++++++++++++++ fusion_accounting_core/models/__init__.py | 1 + .../security/ir.model.access.csv | 1 + fusion_accounting_core/tests/__init__.py | 1 + 5 files changed, 36 insertions(+) create mode 100644 fusion_accounting_core/__init__.py create mode 100644 fusion_accounting_core/__manifest__.py create mode 100644 fusion_accounting_core/models/__init__.py create mode 100644 fusion_accounting_core/security/ir.model.access.csv create mode 100644 fusion_accounting_core/tests/__init__.py diff --git a/fusion_accounting_core/__init__.py b/fusion_accounting_core/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/fusion_accounting_core/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fusion_accounting_core/__manifest__.py b/fusion_accounting_core/__manifest__.py new file mode 100644 index 00000000..1d1f9de0 --- /dev/null +++ b/fusion_accounting_core/__manifest__.py @@ -0,0 +1,32 @@ +{ + 'name': 'Fusion Accounting Core', + 'version': '19.0.1.0.0', + 'category': 'Accounting/Accounting', + 'sequence': 24, + 'summary': 'Shared base for the Fusion Accounting sub-module suite (security, shared schema, runtime helpers).', + 'description': """ +Fusion Accounting Core +====================== +Foundation for the Fusion Accounting sub-modules. Owns: +- Three security groups (User, Manager, Admin) shared across all fusion sub-modules +- Shared-field declarations on Community account models so deferred-revenue, + signing-user, and similar Enterprise-extension fields survive Enterprise uninstall +- Runtime helper for detecting Odoo Enterprise accounting modules + +This module never works alone. Install fusion_accounting (the meta-module) +or one of fusion_accounting_ai, fusion_accounting_bank_rec, etc. + +Built by Nexa Systems Inc. + """, + 'author': 'Nexa Systems Inc.', + 'website': 'https://nexasystems.ca', + 'support': 'support@nexasystems.ca', + 'maintainer': 'Nexa Systems Inc.', + 'depends': ['account', 'mail'], + 'data': [ + 'security/ir.model.access.csv', + ], + 'installable': True, + 'application': False, + 'license': 'OPL-1', +} diff --git a/fusion_accounting_core/models/__init__.py b/fusion_accounting_core/models/__init__.py new file mode 100644 index 00000000..154f21c2 --- /dev/null +++ b/fusion_accounting_core/models/__init__.py @@ -0,0 +1 @@ +# Models populated in Tasks 8-12 (shared-field-ownership, helpers) diff --git a/fusion_accounting_core/security/ir.model.access.csv b/fusion_accounting_core/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/fusion_accounting_core/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/fusion_accounting_core/tests/__init__.py b/fusion_accounting_core/tests/__init__.py new file mode 100644 index 00000000..55610b70 --- /dev/null +++ b/fusion_accounting_core/tests/__init__.py @@ -0,0 +1 @@ +# Tests populated in Tasks 8-12 From c6d1008810f7f47d266a623eeebb2b005a1b1ae9 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 21:27:55 -0400 Subject: [PATCH 02/33] feat(fusion_accounting_ai): add empty sub-module skeleton Made-with: Cursor --- fusion_accounting_ai/__init__.py | 4 ++ fusion_accounting_ai/__manifest__.py | 41 +++++++++++++++++++ fusion_accounting_ai/controllers/__init__.py | 0 fusion_accounting_ai/models/__init__.py | 0 .../security/ir.model.access.csv | 1 + fusion_accounting_ai/services/__init__.py | 0 .../services/adapters/__init__.py | 0 .../services/data_adapters/__init__.py | 0 .../services/prompts/__init__.py | 0 .../services/tools/__init__.py | 0 fusion_accounting_ai/tests/__init__.py | 0 fusion_accounting_ai/wizards/__init__.py | 0 12 files changed, 46 insertions(+) create mode 100644 fusion_accounting_ai/__init__.py create mode 100644 fusion_accounting_ai/__manifest__.py create mode 100644 fusion_accounting_ai/controllers/__init__.py create mode 100644 fusion_accounting_ai/models/__init__.py create mode 100644 fusion_accounting_ai/security/ir.model.access.csv create mode 100644 fusion_accounting_ai/services/__init__.py create mode 100644 fusion_accounting_ai/services/adapters/__init__.py create mode 100644 fusion_accounting_ai/services/data_adapters/__init__.py create mode 100644 fusion_accounting_ai/services/prompts/__init__.py create mode 100644 fusion_accounting_ai/services/tools/__init__.py create mode 100644 fusion_accounting_ai/tests/__init__.py create mode 100644 fusion_accounting_ai/wizards/__init__.py diff --git a/fusion_accounting_ai/__init__.py b/fusion_accounting_ai/__init__.py new file mode 100644 index 00000000..6311ca4b --- /dev/null +++ b/fusion_accounting_ai/__init__.py @@ -0,0 +1,4 @@ +from . import models +from . import controllers +from . import services +from . import wizards diff --git a/fusion_accounting_ai/__manifest__.py b/fusion_accounting_ai/__manifest__.py new file mode 100644 index 00000000..d1b473ca --- /dev/null +++ b/fusion_accounting_ai/__manifest__.py @@ -0,0 +1,41 @@ +{ + 'name': 'Fusion Accounting AI', + 'version': '19.0.1.0.0', + 'category': 'Accounting/Accounting', + 'sequence': 26, + 'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.', + 'description': """ +Fusion Accounting AI +==================== +Conversational AI co-pilot for Odoo Accounting. Embeds Claude/GPT with +native tool-calling for bank reconciliation, HST management, AR/AP analysis, +journal review, month-end close, payroll, ADP reconciliation, financial +reporting, and auditing. + +Works on three install profiles via the data-adapter pattern: +1. Pure Odoo Community + fusion_accounting_ai +2. Odoo Community + fusion_accounting_ai + fusion native sub-modules (bank_rec, reports, ...) +3. Odoo Enterprise + fusion_accounting_ai (legacy mode) + +Built by Nexa Systems Inc. + """, + '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': ['fusion_accounting_core'], + 'external_dependencies': { + 'python': ['anthropic', 'openai'], + }, + 'data': [ + # Populated as files move in (Tasks 5, 7, 11) + 'security/ir.model.access.csv', + ], + 'installable': True, + 'application': True, + 'license': 'OPL-1', + 'assets': { + # Populated as static moves in (Task 5) + }, +} diff --git a/fusion_accounting_ai/controllers/__init__.py b/fusion_accounting_ai/controllers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_ai/models/__init__.py b/fusion_accounting_ai/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_ai/security/ir.model.access.csv b/fusion_accounting_ai/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/fusion_accounting_ai/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/fusion_accounting_ai/services/__init__.py b/fusion_accounting_ai/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_ai/services/adapters/__init__.py b/fusion_accounting_ai/services/adapters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_ai/services/data_adapters/__init__.py b/fusion_accounting_ai/services/data_adapters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_ai/services/prompts/__init__.py b/fusion_accounting_ai/services/prompts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_ai/services/tools/__init__.py b/fusion_accounting_ai/services/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_ai/tests/__init__.py b/fusion_accounting_ai/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_ai/wizards/__init__.py b/fusion_accounting_ai/wizards/__init__.py new file mode 100644 index 00000000..e69de29b From b7483d5177e7c3d60ebf1ebae63799e3afa1f6cd Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 21:33:53 -0400 Subject: [PATCH 03/33] feat(fusion_accounting_migration): add empty sub-module skeleton Made-with: Cursor --- fusion_accounting_migration/__init__.py | 2 ++ fusion_accounting_migration/__manifest__.py | 36 +++++++++++++++++++ .../models/__init__.py | 0 .../security/ir.model.access.csv | 1 + fusion_accounting_migration/tests/__init__.py | 0 .../wizards/__init__.py | 0 .../wizards/migration_wizard_views.xml | 4 +++ 7 files changed, 43 insertions(+) create mode 100644 fusion_accounting_migration/__init__.py create mode 100644 fusion_accounting_migration/__manifest__.py create mode 100644 fusion_accounting_migration/models/__init__.py create mode 100644 fusion_accounting_migration/security/ir.model.access.csv create mode 100644 fusion_accounting_migration/tests/__init__.py create mode 100644 fusion_accounting_migration/wizards/__init__.py create mode 100644 fusion_accounting_migration/wizards/migration_wizard_views.xml diff --git a/fusion_accounting_migration/__init__.py b/fusion_accounting_migration/__init__.py new file mode 100644 index 00000000..aee8895e --- /dev/null +++ b/fusion_accounting_migration/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/fusion_accounting_migration/__manifest__.py b/fusion_accounting_migration/__manifest__.py new file mode 100644 index 00000000..e6086d8d --- /dev/null +++ b/fusion_accounting_migration/__manifest__.py @@ -0,0 +1,36 @@ +{ + 'name': 'Fusion Accounting Migration', + 'version': '19.0.1.0.0', + 'category': 'Accounting/Accounting', + 'sequence': 27, + 'summary': 'Transitional module: migrates Odoo Enterprise accounting data to Fusion Accounting tables before Enterprise uninstall.', + 'description': """ +Fusion Accounting Migration +=========================== +Transitional helper that lives only during Enterprise-to-Fusion switchover. + +Provides: +- A safety guard that blocks uninstall of Odoo Enterprise accounting modules + (account_accountant, account_reports, account_followup, account_asset, + account_budget, account_loans) until the Fusion migration wizard has run +- A guided migration wizard accessible at Settings -> Fusion Accounting -> + Migrate from Enterprise (the wizard's per-feature migrations are added + by each Fusion sub-module that replaces an Enterprise feature) + +Once the switchover is complete, this module can safely be uninstalled. + +Built by Nexa Systems Inc. + """, + 'author': 'Nexa Systems Inc.', + 'website': 'https://nexasystems.ca', + 'support': 'support@nexasystems.ca', + 'maintainer': 'Nexa Systems Inc.', + 'depends': ['fusion_accounting_core'], + 'data': [ + 'security/ir.model.access.csv', + 'wizards/migration_wizard_views.xml', + ], + 'installable': True, + 'application': False, + 'license': 'OPL-1', +} diff --git a/fusion_accounting_migration/models/__init__.py b/fusion_accounting_migration/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_migration/security/ir.model.access.csv b/fusion_accounting_migration/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/fusion_accounting_migration/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/fusion_accounting_migration/tests/__init__.py b/fusion_accounting_migration/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_migration/wizards/__init__.py b/fusion_accounting_migration/wizards/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_migration/wizards/migration_wizard_views.xml b/fusion_accounting_migration/wizards/migration_wizard_views.xml new file mode 100644 index 00000000..ccd0b26f --- /dev/null +++ b/fusion_accounting_migration/wizards/migration_wizard_views.xml @@ -0,0 +1,4 @@ + + + + From 6c72f2ab497c1c7d9a6bb0875e26b517a441f3f6 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 21:45:06 -0400 Subject: [PATCH 04/33] refactor(fusion_accounting): move AI module code into fusion_accounting_ai sub-module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fusion_accounting/controllers/__init__.py | 1 - fusion_accounting/models/__init__.py | 9 -------- .../security/ir.model.access.csv | 19 ----------------- fusion_accounting/services/__init__.py | 5 ----- .../services/adapters/__init__.py | 2 -- .../services/prompts/__init__.py | 2 -- fusion_accounting/services/tools/__init__.py | 19 ----------------- fusion_accounting/wizards/__init__.py | 1 - fusion_accounting_ai/__manifest__.py | 20 ++++++++++++++++-- fusion_accounting_ai/controllers/__init__.py | 1 + .../controllers/chat_controller.py | 0 .../data/cron.xml | 0 .../data/default_rules.xml | 0 .../data/tool_definitions.xml | 0 fusion_accounting_ai/models/__init__.py | 9 ++++++++ .../models/account_move_hook.py | 0 .../models/accounting_config.py | 0 .../models/accounting_dashboard.py | 0 .../models/accounting_match_history.py | 0 .../models/accounting_rule.py | 0 .../models/accounting_session.py | 0 .../models/accounting_tool.py | 0 .../models/recurring_pattern.py | 0 .../models/vendor_tax_profile.py | 0 .../report/audit_report_template.xml | 0 .../security/ir.model.access.csv | 18 ++++++++++++++++ fusion_accounting_ai/services/__init__.py | 5 +++++ .../services/adapters/__init__.py | 2 ++ .../services/adapters/claude.py | 0 .../services/adapters/openai_adapter.py | 0 .../services/agent.py | 0 .../services/data_adapters/__init__.py | 1 + .../services/prompts/__init__.py | 2 ++ .../services/prompts/domain_prompts.py | 0 .../services/prompts/system_prompt.py | 0 .../services/scoring.py | 0 .../services/tools/__init__.py | 19 +++++++++++++++++ .../services/tools/accounts_payable.py | 0 .../services/tools/accounts_receivable.py | 0 .../services/tools/adp.py | 0 .../services/tools/audit.py | 0 .../services/tools/bank_reconciliation.py | 0 .../services/tools/hst_management.py | 0 .../services/tools/inventory.py | 0 .../services/tools/journal_review.py | 0 .../services/tools/month_end.py | 0 .../services/tools/payroll.py | 0 .../services/tools/reporting.py | 0 .../static/description/icon.png | Bin .../src/components/chat/approval_card.js | 0 .../src/components/chat/approval_card.xml | 0 .../static/src/components/chat/chat_panel.js | 0 .../static/src/components/chat/chat_panel.xml | 0 .../src/components/chat/interactive_table.js | 0 .../src/components/chat/interactive_table.xml | 0 .../components/dashboard/fusion_dashboard.js | 0 .../components/dashboard/fusion_dashboard.xml | 0 .../src/components/dashboard/health_card.js | 0 .../src/components/dashboard/health_card.xml | 0 .../static/src/scss/chat.scss | 0 .../static/src/scss/dashboard.scss | 0 .../tests/test_api_live.py | 0 .../tests/test_claude_api.py | 0 .../views/config_views.xml | 0 .../views/dashboard_views.xml | 0 .../views/match_history_views.xml | 0 .../views/menus.xml | 0 .../views/recurring_pattern_views.xml | 0 .../views/rule_views.xml | 0 .../views/session_views.xml | 0 .../views/vendor_tax_profile_views.xml | 0 fusion_accounting_ai/wizards/__init__.py | 1 + .../wizards/rule_wizard.py | 0 .../wizards/rule_wizard.xml | 0 74 files changed, 76 insertions(+), 60 deletions(-) delete mode 100644 fusion_accounting/controllers/__init__.py delete mode 100644 fusion_accounting/models/__init__.py delete mode 100644 fusion_accounting/security/ir.model.access.csv delete mode 100644 fusion_accounting/services/__init__.py delete mode 100644 fusion_accounting/services/adapters/__init__.py delete mode 100644 fusion_accounting/services/prompts/__init__.py delete mode 100644 fusion_accounting/services/tools/__init__.py delete mode 100644 fusion_accounting/wizards/__init__.py rename {fusion_accounting => fusion_accounting_ai}/controllers/chat_controller.py (100%) rename {fusion_accounting => fusion_accounting_ai}/data/cron.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/data/default_rules.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/data/tool_definitions.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/models/account_move_hook.py (100%) rename {fusion_accounting => fusion_accounting_ai}/models/accounting_config.py (100%) rename {fusion_accounting => fusion_accounting_ai}/models/accounting_dashboard.py (100%) rename {fusion_accounting => fusion_accounting_ai}/models/accounting_match_history.py (100%) rename {fusion_accounting => fusion_accounting_ai}/models/accounting_rule.py (100%) rename {fusion_accounting => fusion_accounting_ai}/models/accounting_session.py (100%) rename {fusion_accounting => fusion_accounting_ai}/models/accounting_tool.py (100%) rename {fusion_accounting => fusion_accounting_ai}/models/recurring_pattern.py (100%) rename {fusion_accounting => fusion_accounting_ai}/models/vendor_tax_profile.py (100%) rename {fusion_accounting => fusion_accounting_ai}/report/audit_report_template.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/services/adapters/claude.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/adapters/openai_adapter.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/agent.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/prompts/domain_prompts.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/prompts/system_prompt.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/scoring.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/tools/accounts_payable.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/tools/accounts_receivable.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/tools/adp.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/tools/audit.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/tools/bank_reconciliation.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/tools/hst_management.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/tools/inventory.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/tools/journal_review.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/tools/month_end.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/tools/payroll.py (100%) rename {fusion_accounting => fusion_accounting_ai}/services/tools/reporting.py (100%) rename {fusion_accounting => fusion_accounting_ai}/static/description/icon.png (100%) rename {fusion_accounting => fusion_accounting_ai}/static/src/components/chat/approval_card.js (100%) rename {fusion_accounting => fusion_accounting_ai}/static/src/components/chat/approval_card.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/static/src/components/chat/chat_panel.js (100%) rename {fusion_accounting => fusion_accounting_ai}/static/src/components/chat/chat_panel.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/static/src/components/chat/interactive_table.js (100%) rename {fusion_accounting => fusion_accounting_ai}/static/src/components/chat/interactive_table.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/static/src/components/dashboard/fusion_dashboard.js (100%) rename {fusion_accounting => fusion_accounting_ai}/static/src/components/dashboard/fusion_dashboard.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/static/src/components/dashboard/health_card.js (100%) rename {fusion_accounting => fusion_accounting_ai}/static/src/components/dashboard/health_card.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/static/src/scss/chat.scss (100%) rename {fusion_accounting => fusion_accounting_ai}/static/src/scss/dashboard.scss (100%) rename {fusion_accounting => fusion_accounting_ai}/tests/test_api_live.py (100%) rename {fusion_accounting => fusion_accounting_ai}/tests/test_claude_api.py (100%) rename {fusion_accounting => fusion_accounting_ai}/views/config_views.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/views/dashboard_views.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/views/match_history_views.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/views/menus.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/views/recurring_pattern_views.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/views/rule_views.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/views/session_views.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/views/vendor_tax_profile_views.xml (100%) rename {fusion_accounting => fusion_accounting_ai}/wizards/rule_wizard.py (100%) rename {fusion_accounting => fusion_accounting_ai}/wizards/rule_wizard.xml (100%) diff --git a/fusion_accounting/controllers/__init__.py b/fusion_accounting/controllers/__init__.py deleted file mode 100644 index aac8675a..00000000 --- a/fusion_accounting/controllers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import chat_controller diff --git a/fusion_accounting/models/__init__.py b/fusion_accounting/models/__init__.py deleted file mode 100644 index e9f03309..00000000 --- a/fusion_accounting/models/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from . import accounting_config -from . import accounting_tool -from . import accounting_session -from . import accounting_match_history -from . import accounting_rule -from . import accounting_dashboard -from . import account_move_hook -from . import vendor_tax_profile -from . import recurring_pattern diff --git a/fusion_accounting/security/ir.model.access.csv b/fusion_accounting/security/ir.model.access.csv deleted file mode 100644 index 81cbe5d6..00000000 --- a/fusion_accounting/security/ir.model.access.csv +++ /dev/null @@ -1,19 +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 -access_fusion_recurring_pattern_user,fusion.recurring.pattern.user,model_fusion_recurring_pattern,group_fusion_accounting_user,1,0,0,0 -access_fusion_recurring_pattern_manager,fusion.recurring.pattern.manager,model_fusion_recurring_pattern,group_fusion_accounting_manager,1,1,1,0 -access_fusion_recurring_pattern_admin,fusion.recurring.pattern.admin,model_fusion_recurring_pattern,group_fusion_accounting_admin,1,1,1,1 -access_fusion_vendor_profile_user,fusion.vendor.tax.profile.user,model_fusion_vendor_tax_profile,group_fusion_accounting_user,1,0,0,0 -access_fusion_vendor_profile_manager,fusion.vendor.tax.profile.manager,model_fusion_vendor_tax_profile,group_fusion_accounting_manager,1,1,1,0 -access_fusion_vendor_profile_admin,fusion.vendor.tax.profile.admin,model_fusion_vendor_tax_profile,group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting/services/__init__.py b/fusion_accounting/services/__init__.py deleted file mode 100644 index f25b2789..00000000 --- a/fusion_accounting/services/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from . import adapters -from . import tools -from . import prompts -from . import agent -from . import scoring diff --git a/fusion_accounting/services/adapters/__init__.py b/fusion_accounting/services/adapters/__init__.py deleted file mode 100644 index 26807733..00000000 --- a/fusion_accounting/services/adapters/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from . import claude -from . import openai_adapter diff --git a/fusion_accounting/services/prompts/__init__.py b/fusion_accounting/services/prompts/__init__.py deleted file mode 100644 index ff7682de..00000000 --- a/fusion_accounting/services/prompts/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from . import system_prompt -from . import domain_prompts diff --git a/fusion_accounting/services/tools/__init__.py b/fusion_accounting/services/tools/__init__.py deleted file mode 100644 index b97b6963..00000000 --- a/fusion_accounting/services/tools/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from .bank_reconciliation import TOOLS as BANK_RECON_TOOLS -from .hst_management import TOOLS as HST_TOOLS -from .accounts_receivable import TOOLS as AR_TOOLS -from .accounts_payable import TOOLS as AP_TOOLS -from .journal_review import TOOLS as JOURNAL_TOOLS -from .month_end import TOOLS as MONTH_END_TOOLS -from .payroll import TOOLS as PAYROLL_TOOLS -from .inventory import TOOLS as INVENTORY_TOOLS -from .adp import TOOLS as ADP_TOOLS -from .reporting import TOOLS as REPORTING_TOOLS -from .audit import TOOLS as AUDIT_TOOLS - -TOOL_DISPATCH = {} -for tools_dict in [ - BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS, - MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS, - REPORTING_TOOLS, AUDIT_TOOLS, -]: - TOOL_DISPATCH.update(tools_dict) diff --git a/fusion_accounting/wizards/__init__.py b/fusion_accounting/wizards/__init__.py deleted file mode 100644 index a4a503f9..00000000 --- a/fusion_accounting/wizards/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import rule_wizard diff --git a/fusion_accounting_ai/__manifest__.py b/fusion_accounting_ai/__manifest__.py index d1b473ca..e524cb74 100644 --- a/fusion_accounting_ai/__manifest__.py +++ b/fusion_accounting_ai/__manifest__.py @@ -29,13 +29,29 @@ Built by Nexa Systems Inc. 'python': ['anthropic', 'openai'], }, 'data': [ - # Populated as files move in (Tasks 5, 7, 11) 'security/ir.model.access.csv', + 'data/cron.xml', + 'data/tool_definitions.xml', + 'data/default_rules.xml', + 'views/config_views.xml', + 'views/session_views.xml', + 'views/match_history_views.xml', + 'views/rule_views.xml', + 'views/dashboard_views.xml', + 'views/vendor_tax_profile_views.xml', + 'views/recurring_pattern_views.xml', + 'views/menus.xml', + 'wizards/rule_wizard.xml', + 'report/audit_report_template.xml', ], 'installable': True, 'application': True, 'license': 'OPL-1', 'assets': { - # Populated as static moves in (Task 5) + 'web.assets_backend': [ + 'fusion_accounting_ai/static/src/**/*.js', + 'fusion_accounting_ai/static/src/**/*.xml', + 'fusion_accounting_ai/static/src/**/*.scss', + ], }, } diff --git a/fusion_accounting_ai/controllers/__init__.py b/fusion_accounting_ai/controllers/__init__.py index e69de29b..aac8675a 100644 --- a/fusion_accounting_ai/controllers/__init__.py +++ b/fusion_accounting_ai/controllers/__init__.py @@ -0,0 +1 @@ +from . import chat_controller diff --git a/fusion_accounting/controllers/chat_controller.py b/fusion_accounting_ai/controllers/chat_controller.py similarity index 100% rename from fusion_accounting/controllers/chat_controller.py rename to fusion_accounting_ai/controllers/chat_controller.py diff --git a/fusion_accounting/data/cron.xml b/fusion_accounting_ai/data/cron.xml similarity index 100% rename from fusion_accounting/data/cron.xml rename to fusion_accounting_ai/data/cron.xml diff --git a/fusion_accounting/data/default_rules.xml b/fusion_accounting_ai/data/default_rules.xml similarity index 100% rename from fusion_accounting/data/default_rules.xml rename to fusion_accounting_ai/data/default_rules.xml diff --git a/fusion_accounting/data/tool_definitions.xml b/fusion_accounting_ai/data/tool_definitions.xml similarity index 100% rename from fusion_accounting/data/tool_definitions.xml rename to fusion_accounting_ai/data/tool_definitions.xml diff --git a/fusion_accounting_ai/models/__init__.py b/fusion_accounting_ai/models/__init__.py index e69de29b..e9f03309 100644 --- a/fusion_accounting_ai/models/__init__.py +++ b/fusion_accounting_ai/models/__init__.py @@ -0,0 +1,9 @@ +from . import accounting_config +from . import accounting_tool +from . import accounting_session +from . import accounting_match_history +from . import accounting_rule +from . import accounting_dashboard +from . import account_move_hook +from . import vendor_tax_profile +from . import recurring_pattern diff --git a/fusion_accounting/models/account_move_hook.py b/fusion_accounting_ai/models/account_move_hook.py similarity index 100% rename from fusion_accounting/models/account_move_hook.py rename to fusion_accounting_ai/models/account_move_hook.py diff --git a/fusion_accounting/models/accounting_config.py b/fusion_accounting_ai/models/accounting_config.py similarity index 100% rename from fusion_accounting/models/accounting_config.py rename to fusion_accounting_ai/models/accounting_config.py diff --git a/fusion_accounting/models/accounting_dashboard.py b/fusion_accounting_ai/models/accounting_dashboard.py similarity index 100% rename from fusion_accounting/models/accounting_dashboard.py rename to fusion_accounting_ai/models/accounting_dashboard.py diff --git a/fusion_accounting/models/accounting_match_history.py b/fusion_accounting_ai/models/accounting_match_history.py similarity index 100% rename from fusion_accounting/models/accounting_match_history.py rename to fusion_accounting_ai/models/accounting_match_history.py diff --git a/fusion_accounting/models/accounting_rule.py b/fusion_accounting_ai/models/accounting_rule.py similarity index 100% rename from fusion_accounting/models/accounting_rule.py rename to fusion_accounting_ai/models/accounting_rule.py diff --git a/fusion_accounting/models/accounting_session.py b/fusion_accounting_ai/models/accounting_session.py similarity index 100% rename from fusion_accounting/models/accounting_session.py rename to fusion_accounting_ai/models/accounting_session.py diff --git a/fusion_accounting/models/accounting_tool.py b/fusion_accounting_ai/models/accounting_tool.py similarity index 100% rename from fusion_accounting/models/accounting_tool.py rename to fusion_accounting_ai/models/accounting_tool.py diff --git a/fusion_accounting/models/recurring_pattern.py b/fusion_accounting_ai/models/recurring_pattern.py similarity index 100% rename from fusion_accounting/models/recurring_pattern.py rename to fusion_accounting_ai/models/recurring_pattern.py diff --git a/fusion_accounting/models/vendor_tax_profile.py b/fusion_accounting_ai/models/vendor_tax_profile.py similarity index 100% rename from fusion_accounting/models/vendor_tax_profile.py rename to fusion_accounting_ai/models/vendor_tax_profile.py diff --git a/fusion_accounting/report/audit_report_template.xml b/fusion_accounting_ai/report/audit_report_template.xml similarity index 100% rename from fusion_accounting/report/audit_report_template.xml rename to fusion_accounting_ai/report/audit_report_template.xml diff --git a/fusion_accounting_ai/security/ir.model.access.csv b/fusion_accounting_ai/security/ir.model.access.csv index 97dd8b91..81cbe5d6 100644 --- a/fusion_accounting_ai/security/ir.model.access.csv +++ b/fusion_accounting_ai/security/ir.model.access.csv @@ -1 +1,19 @@ 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 +access_fusion_recurring_pattern_user,fusion.recurring.pattern.user,model_fusion_recurring_pattern,group_fusion_accounting_user,1,0,0,0 +access_fusion_recurring_pattern_manager,fusion.recurring.pattern.manager,model_fusion_recurring_pattern,group_fusion_accounting_manager,1,1,1,0 +access_fusion_recurring_pattern_admin,fusion.recurring.pattern.admin,model_fusion_recurring_pattern,group_fusion_accounting_admin,1,1,1,1 +access_fusion_vendor_profile_user,fusion.vendor.tax.profile.user,model_fusion_vendor_tax_profile,group_fusion_accounting_user,1,0,0,0 +access_fusion_vendor_profile_manager,fusion.vendor.tax.profile.manager,model_fusion_vendor_tax_profile,group_fusion_accounting_manager,1,1,1,0 +access_fusion_vendor_profile_admin,fusion.vendor.tax.profile.admin,model_fusion_vendor_tax_profile,group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting_ai/services/__init__.py b/fusion_accounting_ai/services/__init__.py index e69de29b..f25b2789 100644 --- a/fusion_accounting_ai/services/__init__.py +++ b/fusion_accounting_ai/services/__init__.py @@ -0,0 +1,5 @@ +from . import adapters +from . import tools +from . import prompts +from . import agent +from . import scoring diff --git a/fusion_accounting_ai/services/adapters/__init__.py b/fusion_accounting_ai/services/adapters/__init__.py index e69de29b..26807733 100644 --- a/fusion_accounting_ai/services/adapters/__init__.py +++ b/fusion_accounting_ai/services/adapters/__init__.py @@ -0,0 +1,2 @@ +from . import claude +from . import openai_adapter diff --git a/fusion_accounting/services/adapters/claude.py b/fusion_accounting_ai/services/adapters/claude.py similarity index 100% rename from fusion_accounting/services/adapters/claude.py rename to fusion_accounting_ai/services/adapters/claude.py diff --git a/fusion_accounting/services/adapters/openai_adapter.py b/fusion_accounting_ai/services/adapters/openai_adapter.py similarity index 100% rename from fusion_accounting/services/adapters/openai_adapter.py rename to fusion_accounting_ai/services/adapters/openai_adapter.py diff --git a/fusion_accounting/services/agent.py b/fusion_accounting_ai/services/agent.py similarity index 100% rename from fusion_accounting/services/agent.py rename to fusion_accounting_ai/services/agent.py diff --git a/fusion_accounting_ai/services/data_adapters/__init__.py b/fusion_accounting_ai/services/data_adapters/__init__.py index e69de29b..8b137891 100644 --- a/fusion_accounting_ai/services/data_adapters/__init__.py +++ b/fusion_accounting_ai/services/data_adapters/__init__.py @@ -0,0 +1 @@ + diff --git a/fusion_accounting_ai/services/prompts/__init__.py b/fusion_accounting_ai/services/prompts/__init__.py index e69de29b..ff7682de 100644 --- a/fusion_accounting_ai/services/prompts/__init__.py +++ b/fusion_accounting_ai/services/prompts/__init__.py @@ -0,0 +1,2 @@ +from . import system_prompt +from . import domain_prompts diff --git a/fusion_accounting/services/prompts/domain_prompts.py b/fusion_accounting_ai/services/prompts/domain_prompts.py similarity index 100% rename from fusion_accounting/services/prompts/domain_prompts.py rename to fusion_accounting_ai/services/prompts/domain_prompts.py diff --git a/fusion_accounting/services/prompts/system_prompt.py b/fusion_accounting_ai/services/prompts/system_prompt.py similarity index 100% rename from fusion_accounting/services/prompts/system_prompt.py rename to fusion_accounting_ai/services/prompts/system_prompt.py diff --git a/fusion_accounting/services/scoring.py b/fusion_accounting_ai/services/scoring.py similarity index 100% rename from fusion_accounting/services/scoring.py rename to fusion_accounting_ai/services/scoring.py diff --git a/fusion_accounting_ai/services/tools/__init__.py b/fusion_accounting_ai/services/tools/__init__.py index e69de29b..b97b6963 100644 --- a/fusion_accounting_ai/services/tools/__init__.py +++ b/fusion_accounting_ai/services/tools/__init__.py @@ -0,0 +1,19 @@ +from .bank_reconciliation import TOOLS as BANK_RECON_TOOLS +from .hst_management import TOOLS as HST_TOOLS +from .accounts_receivable import TOOLS as AR_TOOLS +from .accounts_payable import TOOLS as AP_TOOLS +from .journal_review import TOOLS as JOURNAL_TOOLS +from .month_end import TOOLS as MONTH_END_TOOLS +from .payroll import TOOLS as PAYROLL_TOOLS +from .inventory import TOOLS as INVENTORY_TOOLS +from .adp import TOOLS as ADP_TOOLS +from .reporting import TOOLS as REPORTING_TOOLS +from .audit import TOOLS as AUDIT_TOOLS + +TOOL_DISPATCH = {} +for tools_dict in [ + BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS, + MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS, + REPORTING_TOOLS, AUDIT_TOOLS, +]: + TOOL_DISPATCH.update(tools_dict) diff --git a/fusion_accounting/services/tools/accounts_payable.py b/fusion_accounting_ai/services/tools/accounts_payable.py similarity index 100% rename from fusion_accounting/services/tools/accounts_payable.py rename to fusion_accounting_ai/services/tools/accounts_payable.py diff --git a/fusion_accounting/services/tools/accounts_receivable.py b/fusion_accounting_ai/services/tools/accounts_receivable.py similarity index 100% rename from fusion_accounting/services/tools/accounts_receivable.py rename to fusion_accounting_ai/services/tools/accounts_receivable.py diff --git a/fusion_accounting/services/tools/adp.py b/fusion_accounting_ai/services/tools/adp.py similarity index 100% rename from fusion_accounting/services/tools/adp.py rename to fusion_accounting_ai/services/tools/adp.py diff --git a/fusion_accounting/services/tools/audit.py b/fusion_accounting_ai/services/tools/audit.py similarity index 100% rename from fusion_accounting/services/tools/audit.py rename to fusion_accounting_ai/services/tools/audit.py diff --git a/fusion_accounting/services/tools/bank_reconciliation.py b/fusion_accounting_ai/services/tools/bank_reconciliation.py similarity index 100% rename from fusion_accounting/services/tools/bank_reconciliation.py rename to fusion_accounting_ai/services/tools/bank_reconciliation.py diff --git a/fusion_accounting/services/tools/hst_management.py b/fusion_accounting_ai/services/tools/hst_management.py similarity index 100% rename from fusion_accounting/services/tools/hst_management.py rename to fusion_accounting_ai/services/tools/hst_management.py diff --git a/fusion_accounting/services/tools/inventory.py b/fusion_accounting_ai/services/tools/inventory.py similarity index 100% rename from fusion_accounting/services/tools/inventory.py rename to fusion_accounting_ai/services/tools/inventory.py diff --git a/fusion_accounting/services/tools/journal_review.py b/fusion_accounting_ai/services/tools/journal_review.py similarity index 100% rename from fusion_accounting/services/tools/journal_review.py rename to fusion_accounting_ai/services/tools/journal_review.py diff --git a/fusion_accounting/services/tools/month_end.py b/fusion_accounting_ai/services/tools/month_end.py similarity index 100% rename from fusion_accounting/services/tools/month_end.py rename to fusion_accounting_ai/services/tools/month_end.py diff --git a/fusion_accounting/services/tools/payroll.py b/fusion_accounting_ai/services/tools/payroll.py similarity index 100% rename from fusion_accounting/services/tools/payroll.py rename to fusion_accounting_ai/services/tools/payroll.py diff --git a/fusion_accounting/services/tools/reporting.py b/fusion_accounting_ai/services/tools/reporting.py similarity index 100% rename from fusion_accounting/services/tools/reporting.py rename to fusion_accounting_ai/services/tools/reporting.py diff --git a/fusion_accounting/static/description/icon.png b/fusion_accounting_ai/static/description/icon.png similarity index 100% rename from fusion_accounting/static/description/icon.png rename to fusion_accounting_ai/static/description/icon.png diff --git a/fusion_accounting/static/src/components/chat/approval_card.js b/fusion_accounting_ai/static/src/components/chat/approval_card.js similarity index 100% rename from fusion_accounting/static/src/components/chat/approval_card.js rename to fusion_accounting_ai/static/src/components/chat/approval_card.js diff --git a/fusion_accounting/static/src/components/chat/approval_card.xml b/fusion_accounting_ai/static/src/components/chat/approval_card.xml similarity index 100% rename from fusion_accounting/static/src/components/chat/approval_card.xml rename to fusion_accounting_ai/static/src/components/chat/approval_card.xml diff --git a/fusion_accounting/static/src/components/chat/chat_panel.js b/fusion_accounting_ai/static/src/components/chat/chat_panel.js similarity index 100% rename from fusion_accounting/static/src/components/chat/chat_panel.js rename to fusion_accounting_ai/static/src/components/chat/chat_panel.js diff --git a/fusion_accounting/static/src/components/chat/chat_panel.xml b/fusion_accounting_ai/static/src/components/chat/chat_panel.xml similarity index 100% rename from fusion_accounting/static/src/components/chat/chat_panel.xml rename to fusion_accounting_ai/static/src/components/chat/chat_panel.xml diff --git a/fusion_accounting/static/src/components/chat/interactive_table.js b/fusion_accounting_ai/static/src/components/chat/interactive_table.js similarity index 100% rename from fusion_accounting/static/src/components/chat/interactive_table.js rename to fusion_accounting_ai/static/src/components/chat/interactive_table.js diff --git a/fusion_accounting/static/src/components/chat/interactive_table.xml b/fusion_accounting_ai/static/src/components/chat/interactive_table.xml similarity index 100% rename from fusion_accounting/static/src/components/chat/interactive_table.xml rename to fusion_accounting_ai/static/src/components/chat/interactive_table.xml diff --git a/fusion_accounting/static/src/components/dashboard/fusion_dashboard.js b/fusion_accounting_ai/static/src/components/dashboard/fusion_dashboard.js similarity index 100% rename from fusion_accounting/static/src/components/dashboard/fusion_dashboard.js rename to fusion_accounting_ai/static/src/components/dashboard/fusion_dashboard.js diff --git a/fusion_accounting/static/src/components/dashboard/fusion_dashboard.xml b/fusion_accounting_ai/static/src/components/dashboard/fusion_dashboard.xml similarity index 100% rename from fusion_accounting/static/src/components/dashboard/fusion_dashboard.xml rename to fusion_accounting_ai/static/src/components/dashboard/fusion_dashboard.xml diff --git a/fusion_accounting/static/src/components/dashboard/health_card.js b/fusion_accounting_ai/static/src/components/dashboard/health_card.js similarity index 100% rename from fusion_accounting/static/src/components/dashboard/health_card.js rename to fusion_accounting_ai/static/src/components/dashboard/health_card.js diff --git a/fusion_accounting/static/src/components/dashboard/health_card.xml b/fusion_accounting_ai/static/src/components/dashboard/health_card.xml similarity index 100% rename from fusion_accounting/static/src/components/dashboard/health_card.xml rename to fusion_accounting_ai/static/src/components/dashboard/health_card.xml diff --git a/fusion_accounting/static/src/scss/chat.scss b/fusion_accounting_ai/static/src/scss/chat.scss similarity index 100% rename from fusion_accounting/static/src/scss/chat.scss rename to fusion_accounting_ai/static/src/scss/chat.scss diff --git a/fusion_accounting/static/src/scss/dashboard.scss b/fusion_accounting_ai/static/src/scss/dashboard.scss similarity index 100% rename from fusion_accounting/static/src/scss/dashboard.scss rename to fusion_accounting_ai/static/src/scss/dashboard.scss diff --git a/fusion_accounting/tests/test_api_live.py b/fusion_accounting_ai/tests/test_api_live.py similarity index 100% rename from fusion_accounting/tests/test_api_live.py rename to fusion_accounting_ai/tests/test_api_live.py diff --git a/fusion_accounting/tests/test_claude_api.py b/fusion_accounting_ai/tests/test_claude_api.py similarity index 100% rename from fusion_accounting/tests/test_claude_api.py rename to fusion_accounting_ai/tests/test_claude_api.py diff --git a/fusion_accounting/views/config_views.xml b/fusion_accounting_ai/views/config_views.xml similarity index 100% rename from fusion_accounting/views/config_views.xml rename to fusion_accounting_ai/views/config_views.xml diff --git a/fusion_accounting/views/dashboard_views.xml b/fusion_accounting_ai/views/dashboard_views.xml similarity index 100% rename from fusion_accounting/views/dashboard_views.xml rename to fusion_accounting_ai/views/dashboard_views.xml diff --git a/fusion_accounting/views/match_history_views.xml b/fusion_accounting_ai/views/match_history_views.xml similarity index 100% rename from fusion_accounting/views/match_history_views.xml rename to fusion_accounting_ai/views/match_history_views.xml diff --git a/fusion_accounting/views/menus.xml b/fusion_accounting_ai/views/menus.xml similarity index 100% rename from fusion_accounting/views/menus.xml rename to fusion_accounting_ai/views/menus.xml diff --git a/fusion_accounting/views/recurring_pattern_views.xml b/fusion_accounting_ai/views/recurring_pattern_views.xml similarity index 100% rename from fusion_accounting/views/recurring_pattern_views.xml rename to fusion_accounting_ai/views/recurring_pattern_views.xml diff --git a/fusion_accounting/views/rule_views.xml b/fusion_accounting_ai/views/rule_views.xml similarity index 100% rename from fusion_accounting/views/rule_views.xml rename to fusion_accounting_ai/views/rule_views.xml diff --git a/fusion_accounting/views/session_views.xml b/fusion_accounting_ai/views/session_views.xml similarity index 100% rename from fusion_accounting/views/session_views.xml rename to fusion_accounting_ai/views/session_views.xml diff --git a/fusion_accounting/views/vendor_tax_profile_views.xml b/fusion_accounting_ai/views/vendor_tax_profile_views.xml similarity index 100% rename from fusion_accounting/views/vendor_tax_profile_views.xml rename to fusion_accounting_ai/views/vendor_tax_profile_views.xml diff --git a/fusion_accounting_ai/wizards/__init__.py b/fusion_accounting_ai/wizards/__init__.py index e69de29b..a4a503f9 100644 --- a/fusion_accounting_ai/wizards/__init__.py +++ b/fusion_accounting_ai/wizards/__init__.py @@ -0,0 +1 @@ +from . import rule_wizard diff --git a/fusion_accounting/wizards/rule_wizard.py b/fusion_accounting_ai/wizards/rule_wizard.py similarity index 100% rename from fusion_accounting/wizards/rule_wizard.py rename to fusion_accounting_ai/wizards/rule_wizard.py diff --git a/fusion_accounting/wizards/rule_wizard.xml b/fusion_accounting_ai/wizards/rule_wizard.xml similarity index 100% rename from fusion_accounting/wizards/rule_wizard.xml rename to fusion_accounting_ai/wizards/rule_wizard.xml From 1c44f458ad1c61c6724b1cbdf5ae2eac8004cbd5 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 22:10:26 -0400 Subject: [PATCH 05/33] refactor(fusion_accounting): convert to meta-module that depends on sub-modules Made-with: Cursor --- fusion_accounting/__init__.py | 5 +-- fusion_accounting/__manifest__.py | 70 +++++++++++-------------------- 2 files changed, 25 insertions(+), 50 deletions(-) diff --git a/fusion_accounting/__init__.py b/fusion_accounting/__init__.py index e8f90eb7..99265dfe 100644 --- a/fusion_accounting/__init__.py +++ b/fusion_accounting/__init__.py @@ -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'. diff --git a/fusion_accounting/__manifest__.py b/fusion_accounting/__manifest__.py index 2788b74e..17d42474 100644 --- a/fusion_accounting/__manifest__.py +++ b/fusion_accounting/__manifest__.py @@ -1,63 +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/vendor_tax_profile_views.xml', - 'views/recurring_pattern_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', - ], - }, } From 8f1cb3abd255ef2a1679aea394c2c2b62f1a786a Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 22:22:23 -0400 Subject: [PATCH 06/33] =?UTF-8?q?feat(plating):=20demo=20stage-filler=20?= =?UTF-8?q?=E2=80=94=20every=20workflow=20step=20now=20has=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../scripts/fp_demo_stage_filler.py | 546 ++++++++++++++++++ 1 file changed, 546 insertions(+) create mode 100644 fusion_plating/scripts/fp_demo_stage_filler.py diff --git a/fusion_plating/scripts/fp_demo_stage_filler.py b/fusion_plating/scripts/fp_demo_stage_filler.py new file mode 100644 index 00000000..db9b0a9c --- /dev/null +++ b/fusion_plating/scripts/fp_demo_stage_filler.py @@ -0,0 +1,546 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""Demo stage-filler — fills the gaps left after fp_demo_seed.py. + +The base seeder gives us customers, SOs, MOs, WOs, deliveries, invoices +and payments. After the team-skills + timer-audit + presence-aware +Manager Desk work landed (commit 0d12902) the demo needs: + + 1. Timer audit fields (x_fc_started_*, x_fc_finished_*) backfilled + on done WOs so the new "Timer Audit" group has values + 2. WO role tags filled in on any leftover WOs (auto-promotion needs + a role on the WO to credit the operator) + 3. A diverse team — Marie / James / Priya / Diego / Aisha / Carlos + covering different role combinations including lead hands + 4. Proficiency records seeded from completed WOs so the "Task + Proficiency" list on the employee form has rich data and a few + auto-promotions are already on record + 5. Three employees clocked in right now via hr.attendance so the + Manager Desk "Present X / Y" chip has data and the worker + dropdown shows the bucket cues working + 6. Two extra quality holds + one paused WO so every stage of the + workflow is represented somewhere in the demo + +Run via odoo-shell: + + su - odoo -s /bin/bash -c "odoo shell -c /etc/odoo/odoo.conf -d admin \\ + --no-http --stop-after-init < fp_demo_stage_filler.py" + +Idempotent — re-runs are safe. +""" +import logging +from datetime import timedelta + +from odoo import fields + +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# 1. Backfill x_fc_work_role_id on WOs that don't have one yet +# --------------------------------------------------------------------------- +# Simple keyword map: WO/workcenter name → role code. Anything that +# doesn't match falls back to plating_op (the most common role). +ROLE_KEYWORDS = [ + ('mask', 'masking'), + ('rack', 'racking'), + ('demask', 'demask'), + ('derack', 'derack'), + ('plat', 'plating_op'), + ('bake', 'oven'), + ('oven', 'oven'), + ('inspect','inspection'), + ('rework', 'rework'), +] + + +def _fill_wo_roles(env): + Role = env['fp.work.role'] + role_by_code = {r.code: r for r in Role.search([])} + fallback = role_by_code.get('plating_op') + fixed = 0 + wos = env['mrp.workorder'].search([('x_fc_work_role_id', '=', False)]) + for wo in wos: + haystack = ((wo.name or '') + ' ' + (wo.workcenter_id.name or '')).lower() + match = next( + (role_by_code[code] for kw, code in ROLE_KEYWORDS + if kw in haystack and code in role_by_code), + fallback, + ) + if match: + wo.x_fc_work_role_id = match.id + fixed += 1 + print(f"[1] Filled work_role on {fixed} WOs (fallback = plating_op)") + + +# --------------------------------------------------------------------------- +# 2. Backfill timer audit on done WOs +# --------------------------------------------------------------------------- +def _backfill_timer_audit(env): + """Stamp started_at/finished_at on done WOs from their time_ids. + + Where time_ids isn't populated (some demo WOs were created without + going through the productivity flow) we synthesise the timestamps + from create_date + duration so the WO header isn't empty. + """ + fixed = 0 + wos = env['mrp.workorder'].search([ + ('state', '=', 'done'), + ('x_fc_started_at', '=', False), + ]) + for wo in wos: + # Prefer real time_ids data — that's what the live override would + # have captured if the WO had been finished after the upgrade. + if wo.time_ids: + first = wo.time_ids.sorted('date_start')[:1] + last = wo.time_ids.sorted('date_end', reverse=True)[:1] + start_dt = first.date_start if first else False + end_dt = last.date_end if last else False + start_uid = first.user_id.id if first and first.user_id else False + end_uid = last.user_id.id if last and last.user_id else False + else: + start_dt = wo.create_date + dur = wo.duration or 0 + end_dt = ( + wo.create_date + timedelta(minutes=dur) + if wo.create_date else False + ) + uid = ( + wo.x_fc_assigned_user_id.id + if wo.x_fc_assigned_user_id else env.uid + ) + start_uid = end_uid = uid + # Always falls back to the assigned worker if we couldn't pull a + # user from time_ids — that's the operator who SHOULD get credit. + if not start_uid: + start_uid = ( + wo.x_fc_assigned_user_id.id + if wo.x_fc_assigned_user_id else env.uid + ) + if not end_uid: + end_uid = start_uid + wo.write({ + 'x_fc_started_at': start_dt, + 'x_fc_started_by_user_id': start_uid, + 'x_fc_finished_at': end_dt, + 'x_fc_finished_by_user_id': end_uid, + }) + fixed += 1 + print(f"[2] Backfilled timer audit on {fixed} done WOs") + + +# --------------------------------------------------------------------------- +# 3. Add five diverse operators + a senior lead hand +# --------------------------------------------------------------------------- +TEAM = [ + { + 'name': 'Marie Dubois', + 'work_email': 'marie.dubois@entech.demo', + 'job_title': 'Masking Specialist', + 'role_codes': ['masking', 'racking'], + 'lead_codes': ['masking'], + }, + { + 'name': "James O'Connor", + 'work_email': 'james.oconnor@entech.demo', + 'job_title': 'Senior Plating Operator', + 'role_codes': ['plating_op', 'demask'], + 'lead_codes': ['plating_op'], + }, + { + 'name': 'Priya Sharma', + 'work_email': 'priya.sharma@entech.demo', + 'job_title': 'Quality Inspector', + 'role_codes': ['oven', 'inspection'], + 'lead_codes': ['oven', 'inspection'], + }, + { + # Diego is the "still in training" employee — racks and plates + # but isn't qualified for masking yet. The proficiency tracker + # will promote him once he's finished N masking WOs. + 'name': 'Diego Ramirez', + 'work_email': 'diego.ramirez@entech.demo', + 'job_title': 'Plating Operator', + 'role_codes': ['racking', 'plating_op'], + 'lead_codes': [], + }, + { + 'name': 'Aisha Khan', + 'work_email': 'aisha.khan@entech.demo', + 'job_title': 'Inspection & Rework', + 'role_codes': ['inspection', 'rework'], + 'lead_codes': [], + }, + { + # Carlos is the senior — lead hand for everything, can cover + # any shift. Manager Desk surfaces him on every dropdown. + 'name': 'Carlos Silva', + 'work_email': 'carlos.silva@entech.demo', + 'job_title': 'Shift Supervisor', + 'role_codes': ['masking', 'racking', 'plating_op', 'demask', + 'oven', 'derack', 'inspection', 'rework'], + 'lead_codes': ['masking', 'racking', 'plating_op', 'demask', + 'oven', 'derack', 'inspection', 'rework'], + }, +] + + +def _seed_team(env): + Role = env['fp.work.role'] + Emp = env['hr.employee'] + Users = env['res.users'] + role_by_code = {r.code: r for r in Role.search([])} + operator_group = env.ref( + 'fusion_plating.group_fusion_plating_operator', + raise_if_not_found=False, + ) + created = 0 + updated = 0 + for spec in TEAM: + emp = Emp.search([('name', '=', spec['name'])], limit=1) + role_ids = [ + role_by_code[c].id for c in spec['role_codes'] + if c in role_by_code + ] + lead_ids = [ + role_by_code[c].id for c in spec['lead_codes'] + if c in role_by_code + ] + vals = { + 'name': spec['name'], + 'work_email': spec['work_email'], + 'job_title': spec['job_title'], + 'x_fc_work_role_ids': [(6, 0, role_ids)], + 'x_fc_lead_hand_role_ids': [(6, 0, lead_ids)], + } + if emp: + emp.write(vals) + updated += 1 + else: + emp = Emp.create(vals) + created += 1 + + # Make sure each employee has a backing res.users so the Manager + # Desk dropdown can pick them. Without a user the WO can't be + # assigned (x_fc_assigned_user_id targets res.users). + if not emp.user_id: + user = Users.search([('login', '=', spec['work_email'])], limit=1) + if not user: + # Odoo 19 renamed groups_id → group_ids on res.users. + user_vals = { + 'name': spec['name'], + 'login': spec['work_email'], + 'email': spec['work_email'], + } + if operator_group: + user_vals['group_ids'] = [(4, operator_group.id)] + user = Users.create(user_vals) + emp.user_id = user.id + print(f"[3] Seeded team: {created} new, {updated} updated") + + +# --------------------------------------------------------------------------- +# 4. Backfill proficiency records from completed WOs +# --------------------------------------------------------------------------- +def _redistribute_completed_wos(env): + """Re-assign a slice of historical done WOs across the new team. + + Without this, all the existing 82 done WOs stay credited to the + two original users (Administrator + Andrew) and the new team + members (Marie/James/Priya/etc.) look like blank slates with no + completion history. Spread the credit so the demo shows realistic + proficiency variance — Marie has 8 masking jobs done, James has + 12 plating_op, Carlos has touched everything, Diego is mid-training. + """ + Emp = env['hr.employee'] + Role = env['fp.work.role'] + role_by_code = {r.code: r for r in Role.search([])} + + # Plan: each (operator, role) gets a target completion count. + # Diego stays at 2 masking on purpose (still in training). + plan = { + 'Marie Dubois': {'masking': 8, 'racking': 5}, + "James O'Connor": {'plating_op': 12, 'demask': 4}, + 'Priya Sharma': {'oven': 6, 'inspection': 9}, + 'Aisha Khan': {'inspection': 4, 'rework': 3}, + 'Carlos Silva': {'masking': 3, 'racking': 3, 'plating_op': 4, 'oven': 2, + 'demask': 2, 'derack': 2, 'inspection': 3, 'rework': 1}, + } + + moved = 0 + for emp_name, role_targets in plan.items(): + emp = Emp.search([('name', '=', emp_name)], limit=1) + if not emp or not emp.user_id: + continue + for role_code, target_count in role_targets.items(): + role = role_by_code.get(role_code) + if not role: + continue + # Find done WOs of this role that aren't already on this user + wos = env['mrp.workorder'].search([ + ('state', '=', 'done'), + ('x_fc_work_role_id', '=', role.id), + ('x_fc_assigned_user_id', '!=', emp.user_id.id), + ], limit=target_count) + for wo in wos: + wo.x_fc_assigned_user_id = emp.user_id.id + # Re-stamp the audit so finished_by reflects the new owner. + wo.x_fc_started_by_user_id = emp.user_id.id + wo.x_fc_finished_by_user_id = emp.user_id.id + moved += 1 + print(f"[3b] Re-credited {moved} historical WOs to the new team") + + +def _seed_proficiency(env): + """Walk every done WO and credit the assigned worker for the role. + + This rebuilds the proficiency tally as if every historical WO had + gone through the new button_finish override. Auto-promotion fires + naturally on the way: if Marie has 8 completed masking WOs and + masking.mastery_required is 3, she'll get promoted (which is a + no-op since she's already qualified) but the proficiency row will + show promoted=True with a real promoted_at timestamp. + """ + Prof = env['fp.operator.proficiency'] + Prof.search([]).unlink() # reset so re-runs are deterministic + + counts = {} # (employee_id, role_id) → count + first_seen = {} + last_seen = {} + done_wos = env['mrp.workorder'].search([ + ('state', '=', 'done'), + ('x_fc_assigned_user_id', '!=', False), + ('x_fc_work_role_id', '!=', False), + ]) + for wo in done_wos: + emp = wo.x_fc_assigned_user_id.employee_id + if not emp: + continue + key = (emp.id, wo.x_fc_work_role_id.id) + counts[key] = counts.get(key, 0) + 1 + ts = wo.x_fc_finished_at or wo.write_date + if key not in first_seen or ts < first_seen[key]: + first_seen[key] = ts + if key not in last_seen or ts > last_seen[key]: + last_seen[key] = ts + + created = 0 + promoted = 0 + for (emp_id, role_id), count in counts.items(): + rec = Prof.create({ + 'employee_id': emp_id, + 'role_id': role_id, + 'completed_count': count, + 'first_completed_at': first_seen[(emp_id, role_id)], + 'last_completed_at': last_seen[(emp_id, role_id)], + }) + rec._maybe_promote() # fires promotion + chatter when applicable + created += 1 + if rec.promoted: + promoted += 1 + + # Give Diego a partial-mastery row on masking so we can SEE the + # progress label "2 / 3" and the not-yet-promoted state — that's + # the most interesting demo case. + diego = env['hr.employee'].search([('name', '=', 'Diego Ramirez')], limit=1) + masking = env['fp.work.role'].search([('code', '=', 'masking')], limit=1) + if diego and masking and not Prof.search_count([ + ('employee_id', '=', diego.id), ('role_id', '=', masking.id) + ]): + Prof.create({ + 'employee_id': diego.id, + 'role_id': masking.id, + 'completed_count': max(masking.mastery_required - 1, 1), + 'first_completed_at': fields.Datetime.now() - timedelta(days=10), + 'last_completed_at': fields.Datetime.now() - timedelta(days=1), + }) + print(f" + Diego seeded with {max(masking.mastery_required - 1, 1)} " + f"masking completions (one more away from promotion)") + print(f"[4] Seeded {created} proficiency rows ({promoted} auto-promoted)") + + +# --------------------------------------------------------------------------- +# 5. Clock three employees in right now (hr.attendance open record) +# --------------------------------------------------------------------------- +def _clock_in_team(env): + Att = env['hr.attendance'] + # Wipe any stale open records before creating fresh ones. We unlink + # rather than close because earlier script runs may have left + # check_in timestamps in the future (a .replace(hour=12) bug); the + # validator then refuses any check_out and the close fails. + Att.search([('check_out', '=', False)]).sudo().unlink() + + targets = ['Marie Dubois', 'James O\'Connor', 'Carlos Silva'] + started = [] + # Always 4 hours ago — guaranteed before "now", regardless of when + # the close-stale-records step ran. The earlier .replace() approach + # could land in the future if the script ran after noon UTC, which + # the validator rejected. + check_in = fields.Datetime.now() - timedelta(hours=4) + for name in targets: + emp = env['hr.employee'].search([('name', '=', name)], limit=1) + if not emp: + continue + Att.create({ + 'employee_id': emp.id, + 'check_in': check_in, + }) + started.append(name) + print(f"[5] Clocked in: {', '.join(started) or '(no targets found)'}") + + +# --------------------------------------------------------------------------- +# 6. Top up extra demo records — quality holds + paused WO + RFQs +# --------------------------------------------------------------------------- +def _add_quality_holds(env): + Hold = env['fusion.plating.quality.hold'] + if Hold.search_count([]) >= 3: + print("[6a] Quality holds already populated, skipping") + return + wos = env['mrp.workorder'].search([ + ('state', '=', 'done'), + ], limit=2) + # Valid hold_reason values: damaged / out_of_spec / contamination / + # customer_complaint / process_deviation / other. + reasons = ['damaged', 'out_of_spec'] + descriptions = [ + 'Light scratch on the masked face — flagging for re-inspection.', + 'Thickness reading 0.4 mils, target 0.5 ± 0.05. Out of spec.', + ] + created = 0 + for wo, reason, desc in zip(wos, reasons, descriptions): + Hold.create({ + 'workorder_id': wo.id, + 'production_id': wo.production_id.id, + 'part_ref': wo.production_id.product_id.default_code or 'PART-X', + 'qty_on_hold': 2, + 'qty_original': int(wo.production_id.product_qty or 5), + 'hold_reason': reason, + 'description': desc, + 'state': 'on_hold', + }) + created += 1 + print(f"[6a] Added {created} quality holds") + + +def _add_paused_wo(env): + """Show one WO mid-flight with pause/resume history. + + The audit fields show started_by but no finished_at — exactly the + "in progress, paused for lunch" state a manager would see live. + """ + progress = env['mrp.workorder'].search([('state', '=', 'progress')], limit=1) + if progress and progress.x_fc_started_at: + print("[6b] Already have a progress WO with audit, skipping") + return + if not progress: + # Promote a ready WO to progress so the demo has at least one. + ready = env['mrp.workorder'].search([ + ('state', 'in', ('ready', 'waiting', 'pending')), + ], limit=1) + if not ready: + print("[6b] No ready WO to promote to progress") + return + progress = ready + user = progress.x_fc_assigned_user_id or env.user + progress.write({ + 'x_fc_started_at': fields.Datetime.now() - timedelta(hours=2), + 'x_fc_started_by_user_id': user.id, + }) + print(f"[6b] Paused-WO marker set on {progress.display_name}") + + +def _mark_quote_sent(env): + """Bump one draft SO into the 'sent' state so the funnel has data + in every workflow column. + + Without this, the dashboard "Sent" stage is always empty — the + seeder jumps straight from draft to confirmed. + """ + sent = env['sale.order'].search([('state', '=', 'sent')], limit=1) + if sent: + print("[6d] Already have a sent quote, skipping") + return + draft = env['sale.order'].search( + [('state', '=', 'draft'), ('order_line', '!=', False)], limit=1, + ) + if not draft: + print("[6d] No draft SO with lines available") + return + # action_quotation_send opens a wizard — skip the wizard and just + # flip the state directly with a chatter line, which is what the + # wizard would do anyway after the email is sent. + draft.write({'state': 'sent'}) + draft.message_post(body='Quotation marked as sent (demo data).') + print(f"[6d] Marked {draft.name} as sent") + + +def _add_quote_requests(env): + QR = env.get('fusion.plating.quote.request') + if QR is None: + print("[6c] Portal module not installed — skipping RFQ seed") + return + if QR.search_count([]) >= 3: + print("[6c] Quote requests already populated, skipping") + return + customers = env['res.partner'].search( + [('customer_rank', '>', 0)], limit=3, + ) + # State selection on fusion.plating.quote.request: validate against + # the live model so this works even if the workflow gets renamed. + valid_states = {v for v, _ in QR._fields['state'].selection} + candidate_states = [ + s for s in ('new', 'under_review', 'quoted', 'accepted', 'rejected') + if s in valid_states + ][:3] + + notes = [ + '

Need a quote for 200 brass fittings — Type II passivation, urgent.

', + '

Recurring customer, standard EN on 50 housings, 0.5 mil target.

', + '

Aerospace job, AS9100 + Nadcap CoC required, 12 turbine vanes.

', + ] + currency = env.company.currency_id + created = 0 + for partner, state, note in zip(customers, candidate_states, notes): + QR.create({ + 'name': f'RFQ-DEMO-{partner.id}', + 'partner_id': partner.id, + 'state': state, + 'currency_id': currency.id, + 'part_description': note, + 'quantity': 50, + }) + created += 1 + print(f"[6c] Added {created} quote requests (states: {', '.join(candidate_states)})") + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- +def _safe(label, fn): + """Run a step, log + swallow any error so partial success persists.""" + try: + fn(env) + env.cr.commit() + except Exception as exc: + env.cr.rollback() + print(f"!! {label} FAILED: {exc!r}") + + +print("\n=========================================================") +print("FUSION PLATING — DEMO STAGE FILLER") +print("=========================================================") +_safe('1. fill WO roles', _fill_wo_roles) +_safe('3. seed team', _seed_team) +_safe('2. backfill timer audit', _backfill_timer_audit) +_safe('3b. redistribute WOs', _redistribute_completed_wos) +_safe('4. seed proficiency', _seed_proficiency) +_safe('5. clock in team', _clock_in_team) +_safe('6a. add quality holds', _add_quality_holds) +_safe('6b. mark paused WO', _add_paused_wo) +_safe('6c. add quote requests', _add_quote_requests) +_safe('6d. mark one quote sent', _mark_quote_sent) +print("=========================================================") +print("Done. Re-run anytime — script is idempotent.") +print("=========================================================\n") From f8dfff5ce69df796e5f8fa55fe6342036da18bfd Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 22:32:53 -0400 Subject: [PATCH 07/33] fix(manager-desk): include 'blocked' WOs + populate empty columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../controllers/manager_controller.py | 16 +++- .../scripts/fp_demo_stage_filler.py | 89 +++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py index d3243fd8..37c1390d 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py @@ -59,8 +59,14 @@ class FpManagerDashboardController(http.Controller): has_assign = 'x_fc_assigned_user_id' in MrpWO._fields # ---- Column 1: Unassigned (no worker on an active WO) ---------- + # 'not in (done, cancel)' rather than an explicit allow-list so + # we catch every active state Odoo emits — including 'blocked' + # (predecessor not done yet). The previous allow-list missed + # 'blocked' and left the column empty for entire MO routings + # whose first WO was still running. + ACTIVE_NEG_STATES = ('done', 'cancel') domain_unassigned = [ - ('state', 'in', ('pending', 'waiting', 'ready', 'progress')), + ('state', 'not in', ACTIVE_NEG_STATES), ] if has_assign: domain_unassigned.append(('x_fc_assigned_user_id', '=', False)) @@ -126,8 +132,12 @@ class FpManagerDashboardController(http.Controller): unassigned_cards.append(_mo_card(mo, wos)) # ---- Column 2: In Progress (MOs with at least one active WO) ---- + # Same widening as the unassigned domain — capture every active + # state. Without 'blocked' in the set, an MO whose only running + # WO is currently blocked-waiting-on-predecessor disappears from + # the column even though the assigned worker is still on point. domain_active = [ - ('state', 'in', ('ready', 'progress')), + ('state', 'not in', ACTIVE_NEG_STATES), ] if has_assign: domain_active.append(('x_fc_assigned_user_id', '!=', False)) @@ -149,7 +159,7 @@ class FpManagerDashboardController(http.Controller): for user in operator_group.user_ids.sorted('name'): open_wos = MrpWO.search([ ('x_fc_assigned_user_id', '=', user.id), - ('state', 'in', ('ready', 'progress', 'waiting')), + ('state', 'not in', ACTIVE_NEG_STATES), ]) team.append({ 'user_id': user.id, diff --git a/fusion_plating/scripts/fp_demo_stage_filler.py b/fusion_plating/scripts/fp_demo_stage_filler.py index db9b0a9c..6a762c2c 100644 --- a/fusion_plating/scripts/fp_demo_stage_filler.py +++ b/fusion_plating/scripts/fp_demo_stage_filler.py @@ -451,6 +451,93 @@ def _add_paused_wo(env): print(f"[6b] Paused-WO marker set on {progress.display_name}") +def _populate_active_wos(env): + """Make sure the Manager Desk's three columns all have visible data. + + Walks the in-flight MO's routing in sequence order and explicitly + assigns each downstream WO to a specific operator (with two + deliberately left unassigned so the "Needs a Worker" column has + cards to pick from). Earlier keyword-based fuzzy matching missed + a few WOs whose names didn't contain the expected substring, so + this rewrite uses a positional plan instead — less clever, more + predictable. + + Manager Desk's WO domain was widened to include 'blocked' state in + the same patch, so WOs sitting waiting on a predecessor finally + show up. + """ + Emp = env['hr.employee'] + mo = env['mrp.production'].search([('state', '=', 'progress')], limit=1) + if not mo: + print("[6e] No in-progress MO available — skipping") + return + + # Sequence-aligned plan: the Nth downstream WO (skipping done + + # progress) goes to the Nth operator. None means leave unassigned. + PLAN = [ + 'Diego Ramirez', # the training operator gets the first prep step + 'Carlos Silva', # senior owns the critical plating step + "James O'Connor", # lead hand for plating_op also covers demask + 'Priya Sharma', # lead hand for oven + None, # de-rack — left empty for "Needs a Worker" + None, # post-bake — left empty for "Needs a Worker" + 'Aisha Khan', # final inspection + ] + + # Skip done / cancel / progress (the latter is the live one we + # don't want to disturb mid-flight). + pending = mo.workorder_ids.sorted('sequence').filtered( + lambda w: w.state not in ('done', 'cancel', 'progress') + ) + moved = cleared = 0 + for wo, name in zip(pending, PLAN): + if name is None: + wo.x_fc_assigned_user_id = False + cleared += 1 + continue + emp = Emp.search([('name', '=', name)], limit=1) + if emp and emp.user_id: + wo.x_fc_assigned_user_id = emp.user_id.id + moved += 1 + print(f"[6e] Active WOs: redistributed {moved} to new team, " + f"left {cleared} unassigned") + + +def _mark_so_awaiting_manager(env): + """Push two confirmed SOs to "inspected, no manager assigned" so the + Manager Desk's "Awaiting Assignment" KPI has a non-zero value. + + The KPI's domain on the controller side is: + state == 'sale' + x_fc_receiving_status == 'inspected' + x_fc_assigned_manager_id is False + Set those three on a couple of existing confirmed SOs. + """ + SO = env['sale.order'] + if not ('x_fc_receiving_status' in SO._fields + and 'x_fc_assigned_manager_id' in SO._fields): + print("[6f] receiving_status/assigned_manager fields not present") + return + already = SO.search_count([ + ('state', '=', 'sale'), + ('x_fc_receiving_status', '=', 'inspected'), + ('x_fc_assigned_manager_id', '=', False), + ]) + if already >= 2: + print(f"[6f] Awaiting-assignment SOs already populated ({already})") + return + sos = SO.search([ + ('state', '=', 'sale'), + ('x_fc_receiving_status', '!=', 'inspected'), + ], limit=2) + for so in sos: + so.write({ + 'x_fc_receiving_status': 'inspected', + 'x_fc_assigned_manager_id': False, + }) + print(f"[6f] Marked {len(sos)} SOs as awaiting-manager-assignment") + + def _mark_quote_sent(env): """Bump one draft SO into the 'sent' state so the funnel has data in every workflow column. @@ -541,6 +628,8 @@ _safe('6a. add quality holds', _add_quality_holds) _safe('6b. mark paused WO', _add_paused_wo) _safe('6c. add quote requests', _add_quote_requests) _safe('6d. mark one quote sent', _mark_quote_sent) +_safe('6e. populate active WOs', _populate_active_wos) +_safe('6f. SO awaiting-manager', _mark_so_awaiting_manager) print("=========================================================") print("Done. Re-run anytime — script is idempotent.") print("=========================================================\n") From 6a775db444c121748a1b25d57f8467654e744dd6 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 22:42:50 -0400 Subject: [PATCH 08/33] 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 --- .../migrations/19.0.1.0.0/post-migration.py | 97 +++++++++++++++++++ fusion_accounting_ai/tests/__init__.py | 1 + .../tests/test_post_migration.py | 34 +++++++ 3 files changed, 132 insertions(+) create mode 100644 fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py create mode 100644 fusion_accounting_ai/tests/test_post_migration.py diff --git a/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py b/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py new file mode 100644 index 00000000..409d3ac8 --- /dev/null +++ b/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py @@ -0,0 +1,97 @@ +"""Reassign ir_model_data ownership from fusion_accounting to fusion_accounting_ai. + +Pre-Phase-0, all fusion code lived in module='fusion_accounting'. Post-Phase-0, +fusion_accounting is the meta-module and the AI code lives in +'fusion_accounting_ai'. Odoo loads the Python from the new location, but +existing ir_model_data rows still record the old module name. This script +rewrites them. + +Special case: if the data-load phase of this very upgrade already created a +new row in module='fusion_accounting_ai' with the same `name` as an old +orphan (because the orphan lived under the old module name when data-load +looked for it, missed it, and re-created the record), the UPDATE below would +violate the unique constraint on (module, name). For those conflicts we +delete the old orphan — the newly-created row is the one that records and +the runtime will actually use going forward. + +Idempotent: running it a second time does nothing because the WHERE clauses +find no matches. +""" + +import logging + +_logger = logging.getLogger(__name__) + +# Exact xml-id names (model_ prefix, one per fusion.* model) that belonged to +# the AI module. Each corresponds to a auto-created +# by Odoo when the model class loads. +AI_MODEL_PREFIXES = ( + 'model_fusion_accounting_session', + 'model_fusion_accounting_match_history', + 'model_fusion_accounting_rule', + 'model_fusion_accounting_tool', + 'model_fusion_accounting_dashboard', + 'model_fusion_accounting_recurring_pattern', + 'model_fusion_accounting_vendor_tax_profile', + 'model_fusion_accounting_rule_wizard', +) + +# XML-id name patterns for views/data/security/wizard/etc. that belong to +# the AI sub-module. These cover every xml-id the AI module declares in its +# data files (cron.xml, default_rules.xml, tool_definitions.xml, views/*.xml, +# wizards/*.xml, report/*.xml) plus the ACL entries in ir.model.access.csv. +# +# Patterns use SQL LIKE syntax; '%' matches anything. These are broad on +# purpose: we want to catch every past and present xml-id declared by the AI +# data files, including Odoo-auto-generated companions (e.g. ir.cron auto- +# creates an ir.actions.server with xml-id '_ir_actions_server'). +AI_NAME_LIKE = ( + 'view_fusion_%', + 'action_fusion_%', + 'menu_fusion_%', + 'fusion_tool_%', + 'fusion_rule_%', + 'cron_fusion_%', + 'seq_fusion_%', + 'access_fusion_%', + 'rule_fusion_%', + 'paperformat_fusion_%', + 'report_fusion_%', + 'audit_report_template', +) + + +def migrate(cr, version): + # Step 1: Delete orphan rows that conflict with an already-existing row in + # fusion_accounting_ai (data-load artifact). The new row is the survivor. + cr.execute(""" + DELETE FROM ir_model_data AS old + WHERE old.module = 'fusion_accounting' + AND (old.name = ANY(%s) OR old.name LIKE ANY(%s)) + AND EXISTS ( + SELECT 1 FROM ir_model_data AS new + WHERE new.module = 'fusion_accounting_ai' + AND new.name = old.name + ) + """, (list(AI_MODEL_PREFIXES), list(AI_NAME_LIKE))) + deleted_conflicts = cr.rowcount + + # Step 2: Reassign the non-conflicting orphans. + cr.execute(""" + UPDATE ir_model_data + SET module = 'fusion_accounting_ai' + WHERE module = 'fusion_accounting' + AND ( + name = ANY(%s) + OR name LIKE ANY(%s) + ) + """, (list(AI_MODEL_PREFIXES), list(AI_NAME_LIKE))) + moved = cr.rowcount + + _logger.info( + "fusion_accounting_ai post-migration: deleted %d conflicting orphans, " + "reassigned %d ir_model_data rows from module='fusion_accounting' " + "to module='fusion_accounting_ai'", + deleted_conflicts, + moved, + ) diff --git a/fusion_accounting_ai/tests/__init__.py b/fusion_accounting_ai/tests/__init__.py index e69de29b..839e3144 100644 --- a/fusion_accounting_ai/tests/__init__.py +++ b/fusion_accounting_ai/tests/__init__.py @@ -0,0 +1 @@ +from . import test_post_migration diff --git a/fusion_accounting_ai/tests/test_post_migration.py b/fusion_accounting_ai/tests/test_post_migration.py new file mode 100644 index 00000000..e9a62709 --- /dev/null +++ b/fusion_accounting_ai/tests/test_post_migration.py @@ -0,0 +1,34 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestPostMigration(TransactionCase): + """Verify ir_model_data ownership transferred from fusion_accounting to fusion_accounting_ai.""" + + def test_no_orphan_ir_model_data_in_old_module(self): + """No fusion-related model/view/data record should still claim module='fusion_accounting'. + + After Phase 0, fusion_accounting is the meta-module and owns no records. + Every fusion.* model/view/data record should be owned by a sub-module + (fusion_accounting_ai, fusion_accounting_core, fusion_accounting_migration). + """ + orphans = self.env['ir.model.data'].search([ + ('module', '=', 'fusion_accounting'), + ('name', 'like', '%'), + ]) + # The meta-module legitimately may own zero records. Anything found here + # is an orphan from the pre-Phase-0 layout. + self.assertFalse( + orphans, + f"Found {len(orphans)} ir_model_data rows still owned by fusion_accounting " + f"(should be owned by sub-modules). Examples: " + f"{[(r.module, r.name) for r in orphans[:5]]}" + ) + + def test_known_xml_ids_resolve_via_new_module(self): + """Spot-check that key xml-ids are reachable under the new module name.""" + # Sessions model + ref = self.env.ref('fusion_accounting_ai.model_fusion_accounting_session', raise_if_not_found=False) + self.assertTrue(ref, "fusion_accounting_ai.model_fusion_accounting_session should resolve") + # Security group + # (this lives in _core after Task 12 — adapt assertion when Task 12 completes) From 7025f6210799b2d883a524460dd9789434a400ba Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 22:59:47 -0400 Subject: [PATCH 09/33] feat(fusion_accounting_ai): add DataAdapter base + registry Made-with: Cursor --- .../services/data_adapters/__init__.py | 3 + .../services/data_adapters/_registry.py | 25 ++++++ .../services/data_adapters/base.py | 79 +++++++++++++++++++ fusion_accounting_ai/tests/__init__.py | 1 + .../tests/test_data_adapters.py | 27 +++++++ 5 files changed, 135 insertions(+) create mode 100644 fusion_accounting_ai/services/data_adapters/_registry.py create mode 100644 fusion_accounting_ai/services/data_adapters/base.py create mode 100644 fusion_accounting_ai/tests/test_data_adapters.py diff --git a/fusion_accounting_ai/services/data_adapters/__init__.py b/fusion_accounting_ai/services/data_adapters/__init__.py index 8b137891..8926891d 100644 --- a/fusion_accounting_ai/services/data_adapters/__init__.py +++ b/fusion_accounting_ai/services/data_adapters/__init__.py @@ -1 +1,4 @@ +from .base import DataAdapter, AdapterMode +from ._registry import get_adapter, register_adapter +__all__ = ['DataAdapter', 'AdapterMode', 'get_adapter', 'register_adapter'] diff --git a/fusion_accounting_ai/services/data_adapters/_registry.py b/fusion_accounting_ai/services/data_adapters/_registry.py new file mode 100644 index 00000000..fda309a6 --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/_registry.py @@ -0,0 +1,25 @@ +"""Registry: lazy-loads data adapter instances per env.""" + +from .base import DataAdapter + + +def get_adapter(env, name: str) -> DataAdapter: + """Return a data adapter by short name. Cached per request via env.context.""" + cache = env.context.get('_fusion_data_adapter_cache') + if cache is None: + cache = {} + if name not in cache: + cls = _ADAPTERS.get(name) + if cls is None: + raise KeyError(f"Unknown data adapter: {name!r}. Known: {list(_ADAPTERS)}") + cache[name] = cls(env) + return cache[name] + + +# Populated as adapter classes are added (Tasks 9, 10, 11). +_ADAPTERS: dict[str, type[DataAdapter]] = {} + + +def register_adapter(name: str, cls: type[DataAdapter]) -> None: + """Register an adapter class. Call from each adapter module at import time.""" + _ADAPTERS[name] = cls diff --git a/fusion_accounting_ai/services/data_adapters/base.py b/fusion_accounting_ai/services/data_adapters/base.py new file mode 100644 index 00000000..ecf9296c --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/base.py @@ -0,0 +1,79 @@ +"""Data-adapter base class: routes data lookups across three backends. + +The fusion_accounting_ai sub-module's tools (e.g. get_unreconciled_bank_lines) +must work in any of three install profiles: + +1. FUSION mode — a fusion native sub-module (e.g. fusion_accounting_bank_rec) + is installed; route to its model. +2. ENTERPRISE mode — Odoo Enterprise (e.g. account_accountant) is installed; + route to Enterprise APIs. +3. COMMUNITY mode — neither; fall back to a pure Odoo Community search/read. + +Subclasses implement the three backend methods and define which fusion model +and which Enterprise module they probe. +""" + +import enum +import logging +from typing import Any + +_logger = logging.getLogger(__name__) + + +class AdapterMode(enum.Enum): + FUSION = "fusion" + ENTERPRISE = "enterprise" + COMMUNITY = "community" + + +class DataAdapter: + """Base class. Subclasses set FUSION_MODEL and ENTERPRISE_MODULE class attrs + and implement _via_fusion(...), _via_enterprise(...), _via_community(...).""" + + # Override in subclasses. + FUSION_MODEL: str = "" + ENTERPRISE_MODULE: str = "" + + def __init__(self, env): + self.env = env + + def _select_mode( + self, + fusion_native_model: str | None = None, + enterprise_module: str | None = None, + ) -> AdapterMode: + """Pick FUSION if the model is loaded, else ENTERPRISE if the module + is installed, else COMMUNITY.""" + fusion_model = fusion_native_model or self.FUSION_MODEL + ent_module = enterprise_module or self.ENTERPRISE_MODULE + + if fusion_model and fusion_model in self.env: + return AdapterMode.FUSION + + if ent_module: + installed = self.env['ir.module.module'].sudo().search_count([ + ('name', '=', ent_module), + ('state', '=', 'installed'), + ]) + if installed: + return AdapterMode.ENTERPRISE + + return AdapterMode.COMMUNITY + + def _dispatch(self, method_name: str, *args, **kwargs) -> Any: + """Look up _via_ on self and call it. + + E.g. method_name='list_unreconciled', mode=FUSION calls + self.list_unreconciled_via_fusion(*args, **kwargs). + """ + mode = self._select_mode() + attr = f"{method_name}_via_{mode.value}" + impl = getattr(self, attr, None) + if impl is None: + _logger.warning( + "DataAdapter %s has no implementation for %s in mode %s; " + "returning empty result", + type(self).__name__, method_name, mode.value, + ) + return [] + return impl(*args, **kwargs) diff --git a/fusion_accounting_ai/tests/__init__.py b/fusion_accounting_ai/tests/__init__.py index 839e3144..e3410185 100644 --- a/fusion_accounting_ai/tests/__init__.py +++ b/fusion_accounting_ai/tests/__init__.py @@ -1 +1,2 @@ from . import test_post_migration +from . import test_data_adapters diff --git a/fusion_accounting_ai/tests/test_data_adapters.py b/fusion_accounting_ai/tests/test_data_adapters.py new file mode 100644 index 00000000..34689058 --- /dev/null +++ b/fusion_accounting_ai/tests/test_data_adapters.py @@ -0,0 +1,27 @@ +from odoo.tests.common import TransactionCase, tagged +from odoo.addons.fusion_accounting_ai.services.data_adapters.base import ( + DataAdapter, AdapterMode, +) + + +@tagged('post_install', '-at_install') +class TestDataAdapterBase(TransactionCase): + """Verify the data adapter base class chooses the correct backend.""" + + def test_adapter_mode_pure_community(self): + """With no fusion native and no Enterprise, adapter selects COMMUNITY.""" + adapter = DataAdapter(self.env) + mode = adapter._select_mode( + fusion_native_model='fusion.bank.rec.widget', + enterprise_module='account_accountant', + ) + self.assertIn(mode, (AdapterMode.FUSION, AdapterMode.ENTERPRISE, AdapterMode.COMMUNITY)) + + def test_adapter_falls_back_when_fusion_model_missing(self): + """Adapter must not error when the fusion native model isn't loaded.""" + adapter = DataAdapter(self.env) + mode = adapter._select_mode( + fusion_native_model='fusion.never.exists', + enterprise_module='also_does_not_exist', + ) + self.assertEqual(mode, AdapterMode.COMMUNITY) From a2efc9f2d40aa74643afe39b40f626fa3d8cd117 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:04:22 -0400 Subject: [PATCH 10/33] fix(employee): handle Odoo 19 'in' operator + empty-list sentinel in clocked-in search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../models/hr_employee.py | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py b/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py index bf5d4acc..e5b94ff6 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py @@ -22,3 +22,136 @@ class HrEmployee(models.Model): help='Which shop roles this employee performs. Used by the ' 'Manager Desk and auto-assignment on WO generation.', ) +<<<<<<< Updated upstream +======= + # Per-role lead-hand list. Sarah might be a lead hand for masking + + # racking but not for plating; Mike might cover everything during + # a graveyard shift. Stored on a separate relation table so the + # primary "Shop Roles" list stays distinct from the cover-anything + # authority. + x_fc_lead_hand_role_ids = fields.Many2many( + 'fp.work.role', 'fp_employee_lead_hand_role_rel', + 'employee_id', 'role_id', string='Lead Hand For', + help='Roles where this employee is authorised to lead or cover ' + 'for an absent operator. Lead hands are surfaced first in ' + 'the Manager Desk worker picker for these roles.', + ) + + x_fc_proficiency_ids = fields.One2many( + 'fp.operator.proficiency', 'employee_id', + string='Task Proficiency', + help='Per-role completion tally. Workers earn one count per WO ' + 'they finish on a given role. Once the count crosses the ' + "role's mastery threshold the role is added to their " + 'Shop Roles list automatically.', + ) + + # ------------------------------------------------------------------ + # Attendance helpers — used by the Manager Desk to show who is + # currently clocked in. Works with vanilla hr_attendance or the + # full fusion_clock module — both store an open record (no + # check_out) for as long as the employee is on shift. + # ------------------------------------------------------------------ + x_fc_is_clocked_in = fields.Boolean( + string='Clocked In', + compute='_compute_x_fc_is_clocked_in', + search='_search_x_fc_is_clocked_in', + help='True if this employee currently has an open hr.attendance ' + 'record (clocked in but not clocked out).', + ) + + def _compute_x_fc_is_clocked_in(self): + """Compute attendance status from hr.attendance. + + Batched so the manager dashboard doesn't issue one query per + employee — important when the shop has dozens of operators. + """ + if not self: + return + Att = self.env.get('hr.attendance') + if Att is None: + for emp in self: + emp.x_fc_is_clocked_in = False + return + # One read for the whole recordset. + open_emp_ids = set(Att.sudo().search([ + ('employee_id', 'in', self.ids), + ('check_out', '=', False), + ]).mapped('employee_id').ids) + for emp in self: + emp.x_fc_is_clocked_in = emp.id in open_emp_ids + + def _search_x_fc_is_clocked_in(self, *args): + """Lets `[('x_fc_is_clocked_in', '=', True)]` work as a domain. + + Odoo 19 normalises the equality term ``('=', True)`` into + ``('in', OrderedSet([True]))`` before calling this method, so + we have to handle ``in`` / ``not in`` as well as the bare + ``=`` / ``!=``. The signature is also variadic so a future + Odoo refactor that prepends a ``records`` argument doesn't + break us. + + Strategy: + 1. Reduce the caller's intent to a *match_set* of booleans + — which values of ``x_fc_is_clocked_in`` should match. + 2. Negative operators flip that set. + 3. Translate the set into an ``id IN`` (or ``NOT IN``) term + on the cached open-attendance employee ids. + """ + # Variable signature — Odoo 19 may pass (records, op, val). + if len(args) == 3: + _records, operator, value = args + elif len(args) == 2: + operator, value = args + else: + return [('id', '=', False)] + + Att = self.env.get('hr.attendance') + if Att is None: + return [('id', '=', False)] + + # Build the set of bool values the caller wants. + if operator in ('=', '!='): + match_set = {bool(value)} + elif operator in ('in', 'not in'): + # value is a (possibly OrderedSet) iterable of bools. + match_set = set(map(bool, value)) + else: + return [('id', '=', False)] + + # Negated operators flip the match set so we can reason in + # purely positive terms below. + if operator in ('!=', 'not in'): + match_set = {True, False} - match_set + + if not match_set: + return [('id', '=', False)] + if match_set == {True, False}: + # No filter at all — every employee matches. + return [] + + open_emp_ids = Att.sudo().search( + [('check_out', '=', False)] + ).employee_id.ids + # Sentinel guards against an empty list — Odoo treats + # `('id', 'in', [])` as no constraint in some versions and + # ends up matching every row. + ids_term = open_emp_ids or [0] + return [('id', 'in' if True in match_set else 'not in', ids_term)] + + @api.model + def _fp_clocked_in_user_ids(self): + """Return the set of res.users.ids whose linked employee is on shift. + + Used by the Manager Desk controller to short-circuit the worker + dropdown to "present today" without an N+1 attendance query + per worker. + """ + Att = self.env.get('hr.attendance') + if Att is None: + return set() + emps = Att.sudo().search([ + ('check_out', '=', False), + ]).mapped('employee_id') + return set(emps.user_id.ids) +>>>>>>> Stashed changes From 6d02389b80384a2c515820b4368fa9a4b382aa6f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:06:27 -0400 Subject: [PATCH 11/33] fix(bridge_mrp): revert malformed hr_employee.py from conflict-marker commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../models/hr_employee.py | 133 ------------------ 1 file changed, 133 deletions(-) diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py b/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py index e5b94ff6..bf5d4acc 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py @@ -22,136 +22,3 @@ class HrEmployee(models.Model): help='Which shop roles this employee performs. Used by the ' 'Manager Desk and auto-assignment on WO generation.', ) -<<<<<<< Updated upstream -======= - # Per-role lead-hand list. Sarah might be a lead hand for masking + - # racking but not for plating; Mike might cover everything during - # a graveyard shift. Stored on a separate relation table so the - # primary "Shop Roles" list stays distinct from the cover-anything - # authority. - x_fc_lead_hand_role_ids = fields.Many2many( - 'fp.work.role', 'fp_employee_lead_hand_role_rel', - 'employee_id', 'role_id', string='Lead Hand For', - help='Roles where this employee is authorised to lead or cover ' - 'for an absent operator. Lead hands are surfaced first in ' - 'the Manager Desk worker picker for these roles.', - ) - - x_fc_proficiency_ids = fields.One2many( - 'fp.operator.proficiency', 'employee_id', - string='Task Proficiency', - help='Per-role completion tally. Workers earn one count per WO ' - 'they finish on a given role. Once the count crosses the ' - "role's mastery threshold the role is added to their " - 'Shop Roles list automatically.', - ) - - # ------------------------------------------------------------------ - # Attendance helpers — used by the Manager Desk to show who is - # currently clocked in. Works with vanilla hr_attendance or the - # full fusion_clock module — both store an open record (no - # check_out) for as long as the employee is on shift. - # ------------------------------------------------------------------ - x_fc_is_clocked_in = fields.Boolean( - string='Clocked In', - compute='_compute_x_fc_is_clocked_in', - search='_search_x_fc_is_clocked_in', - help='True if this employee currently has an open hr.attendance ' - 'record (clocked in but not clocked out).', - ) - - def _compute_x_fc_is_clocked_in(self): - """Compute attendance status from hr.attendance. - - Batched so the manager dashboard doesn't issue one query per - employee — important when the shop has dozens of operators. - """ - if not self: - return - Att = self.env.get('hr.attendance') - if Att is None: - for emp in self: - emp.x_fc_is_clocked_in = False - return - # One read for the whole recordset. - open_emp_ids = set(Att.sudo().search([ - ('employee_id', 'in', self.ids), - ('check_out', '=', False), - ]).mapped('employee_id').ids) - for emp in self: - emp.x_fc_is_clocked_in = emp.id in open_emp_ids - - def _search_x_fc_is_clocked_in(self, *args): - """Lets `[('x_fc_is_clocked_in', '=', True)]` work as a domain. - - Odoo 19 normalises the equality term ``('=', True)`` into - ``('in', OrderedSet([True]))`` before calling this method, so - we have to handle ``in`` / ``not in`` as well as the bare - ``=`` / ``!=``. The signature is also variadic so a future - Odoo refactor that prepends a ``records`` argument doesn't - break us. - - Strategy: - 1. Reduce the caller's intent to a *match_set* of booleans - — which values of ``x_fc_is_clocked_in`` should match. - 2. Negative operators flip that set. - 3. Translate the set into an ``id IN`` (or ``NOT IN``) term - on the cached open-attendance employee ids. - """ - # Variable signature — Odoo 19 may pass (records, op, val). - if len(args) == 3: - _records, operator, value = args - elif len(args) == 2: - operator, value = args - else: - return [('id', '=', False)] - - Att = self.env.get('hr.attendance') - if Att is None: - return [('id', '=', False)] - - # Build the set of bool values the caller wants. - if operator in ('=', '!='): - match_set = {bool(value)} - elif operator in ('in', 'not in'): - # value is a (possibly OrderedSet) iterable of bools. - match_set = set(map(bool, value)) - else: - return [('id', '=', False)] - - # Negated operators flip the match set so we can reason in - # purely positive terms below. - if operator in ('!=', 'not in'): - match_set = {True, False} - match_set - - if not match_set: - return [('id', '=', False)] - if match_set == {True, False}: - # No filter at all — every employee matches. - return [] - - open_emp_ids = Att.sudo().search( - [('check_out', '=', False)] - ).employee_id.ids - # Sentinel guards against an empty list — Odoo treats - # `('id', 'in', [])` as no constraint in some versions and - # ends up matching every row. - ids_term = open_emp_ids or [0] - return [('id', 'in' if True in match_set else 'not in', ids_term)] - - @api.model - def _fp_clocked_in_user_ids(self): - """Return the set of res.users.ids whose linked employee is on shift. - - Used by the Manager Desk controller to short-circuit the worker - dropdown to "present today" without an N+1 attendance query - per worker. - """ - Att = self.env.get('hr.attendance') - if Att is None: - return set() - emps = Att.sudo().search([ - ('check_out', '=', False), - ]).mapped('employee_id') - return set(emps.user_id.ids) ->>>>>>> Stashed changes From d331dc5fa6ddb98629d98246f71dfaa109e3533e Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:08:53 -0400 Subject: [PATCH 12/33] feat(fusion_accounting_ai): add BankRecAdapter for tri-mode bank-rec lookups Made-with: Cursor --- .../services/data_adapters/__init__.py | 3 ++ .../services/data_adapters/bank_rec.py | 53 +++++++++++++++++++ .../tests/test_data_adapters.py | 33 ++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 fusion_accounting_ai/services/data_adapters/bank_rec.py diff --git a/fusion_accounting_ai/services/data_adapters/__init__.py b/fusion_accounting_ai/services/data_adapters/__init__.py index 8926891d..b6cdbfcb 100644 --- a/fusion_accounting_ai/services/data_adapters/__init__.py +++ b/fusion_accounting_ai/services/data_adapters/__init__.py @@ -1,4 +1,7 @@ from .base import DataAdapter, AdapterMode from ._registry import get_adapter, register_adapter +# Side-effect imports: each adapter module calls register_adapter at module load. +from . import bank_rec # noqa: F401 + __all__ = ['DataAdapter', 'AdapterMode', 'get_adapter', 'register_adapter'] diff --git a/fusion_accounting_ai/services/data_adapters/bank_rec.py b/fusion_accounting_ai/services/data_adapters/bank_rec.py new file mode 100644 index 00000000..727e9c3e --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/bank_rec.py @@ -0,0 +1,53 @@ +"""Bank reconciliation data adapter. + +Routes bank-rec data lookups across: +- FUSION: fusion.bank.rec.widget (added by fusion_accounting_bank_rec, Phase 1) +- ENTERPRISE: account_accountant's bank_rec_widget JS service +- COMMUNITY: pure search on account.bank.statement.line +""" + +from .base import DataAdapter +from ._registry import register_adapter + + +class BankRecAdapter(DataAdapter): + FUSION_MODEL = 'fusion.bank.rec.widget' + ENTERPRISE_MODULE = 'account_accountant' + + def list_unreconciled(self, journal_id, limit=100): + """Return unreconciled bank statement lines for a journal.""" + return self._dispatch('list_unreconciled', journal_id=journal_id, limit=limit) + + def list_unreconciled_via_fusion(self, journal_id, limit=100): + # Phase 1 will add fusion.bank.rec.widget; this method becomes the primary path. + # For now: even when the model exists, delegate to community read shape. + return self.list_unreconciled_via_community(journal_id=journal_id, limit=limit) + + def list_unreconciled_via_enterprise(self, journal_id, limit=100): + # Enterprise's bank rec uses a JS-side service; from Python the cleanest + # backend access is the same Community search (the data lives in + # account.bank.statement.line either way). This adapter's purpose is + # to expose a stable shape to AI tools regardless of which UI the user has. + return self.list_unreconciled_via_community(journal_id=journal_id, limit=limit) + + def list_unreconciled_via_community(self, journal_id, limit=100): + Line = self.env['account.bank.statement.line'].sudo() + records = Line.search([ + ('journal_id', '=', journal_id), + ('is_reconciled', '=', False), + ], limit=limit, order='date desc, id desc') + return [ + { + 'id': r.id, + 'date': r.date, + 'payment_ref': r.payment_ref, + 'amount': r.amount, + 'partner_id': r.partner_id.id if r.partner_id else None, + 'partner_name': r.partner_id.name if r.partner_id else None, + 'currency_id': r.currency_id.id if r.currency_id else None, + } + for r in records + ] + + +register_adapter('bank_rec', BankRecAdapter) diff --git a/fusion_accounting_ai/tests/test_data_adapters.py b/fusion_accounting_ai/tests/test_data_adapters.py index 34689058..a22be0db 100644 --- a/fusion_accounting_ai/tests/test_data_adapters.py +++ b/fusion_accounting_ai/tests/test_data_adapters.py @@ -2,6 +2,7 @@ from odoo.tests.common import TransactionCase, tagged from odoo.addons.fusion_accounting_ai.services.data_adapters.base import ( DataAdapter, AdapterMode, ) +from odoo.addons.fusion_accounting_ai.services.data_adapters import get_adapter @tagged('post_install', '-at_install') @@ -25,3 +26,35 @@ class TestDataAdapterBase(TransactionCase): enterprise_module='also_does_not_exist', ) self.assertEqual(mode, AdapterMode.COMMUNITY) + + +@tagged('post_install', '-at_install') +class TestBankRecAdapter(TransactionCase): + """Verify the bank-rec adapter returns rows in any install profile.""" + + def setUp(self): + super().setUp() + self.journal = self.env['account.journal'].create({ + 'name': 'Test Bank', + 'type': 'bank', + 'code': 'TBNK', + }) + self.statement = self.env['account.bank.statement'].create({ + 'name': 'Test Statement', + 'journal_id': self.journal.id, + }) + self.line = self.env['account.bank.statement.line'].create({ + 'statement_id': self.statement.id, + 'journal_id': self.journal.id, + 'date': '2026-04-18', + 'payment_ref': 'Test Payment', + 'amount': 100.0, + }) + + def test_list_unreconciled_returns_our_test_line(self): + """The adapter should find the unreconciled line we just created.""" + adapter = get_adapter(self.env, 'bank_rec') + rows = adapter.list_unreconciled(journal_id=self.journal.id, limit=10) + ids = [r['id'] for r in rows] + self.assertIn(self.line.id, ids, + f"Expected line {self.line.id} in unreconciled list, got: {ids}") From 086b24ab36f72cb7f05a90779c8d25cf91dbf184 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:14:41 -0400 Subject: [PATCH 13/33] feat(fusion_accounting_ai): add ReportsAdapter with trial_balance Made-with: Cursor --- .../services/data_adapters/__init__.py | 4 +- .../services/data_adapters/reports.py | 56 +++++++++++++++++++ .../tests/test_data_adapters.py | 16 ++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 fusion_accounting_ai/services/data_adapters/reports.py diff --git a/fusion_accounting_ai/services/data_adapters/__init__.py b/fusion_accounting_ai/services/data_adapters/__init__.py index b6cdbfcb..df70bf1d 100644 --- a/fusion_accounting_ai/services/data_adapters/__init__.py +++ b/fusion_accounting_ai/services/data_adapters/__init__.py @@ -1,7 +1,7 @@ from .base import DataAdapter, AdapterMode from ._registry import get_adapter, register_adapter -# Side-effect imports: each adapter module calls register_adapter at module load. -from . import bank_rec # noqa: F401 +from . import bank_rec # noqa: F401 +from . import reports # noqa: F401 __all__ = ['DataAdapter', 'AdapterMode', 'get_adapter', 'register_adapter'] diff --git a/fusion_accounting_ai/services/data_adapters/reports.py b/fusion_accounting_ai/services/data_adapters/reports.py new file mode 100644 index 00000000..37e71d40 --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/reports.py @@ -0,0 +1,56 @@ +"""Reports data adapter. + +Routes report-data lookups across: +- FUSION: fusion.account.report (added by fusion_accounting_reports, Phase 2) +- ENTERPRISE: account.report from account_reports +- COMMUNITY: raw aggregations on account.move.line +""" + +from .base import DataAdapter +from ._registry import register_adapter + + +class ReportsAdapter(DataAdapter): + FUSION_MODEL = 'fusion.account.report' + ENTERPRISE_MODULE = 'account_reports' + + def trial_balance(self, date_to=None, company_ids=None): + return self._dispatch('trial_balance', date_to=date_to, company_ids=company_ids) + + def trial_balance_via_fusion(self, date_to=None, company_ids=None): + # Phase 2 will implement; for now defer to community. + return self.trial_balance_via_community(date_to=date_to, company_ids=company_ids) + + def trial_balance_via_enterprise(self, date_to=None, company_ids=None): + # Enterprise account_reports has rich filters; for AI-tool consumption, + # the community shape suffices and avoids brittle coupling to Odoo's + # report-line internals. + return self.trial_balance_via_community(date_to=date_to, company_ids=company_ids) + + def trial_balance_via_community(self, date_to=None, company_ids=None): + domain = [('parent_state', '=', 'posted')] + if date_to: + domain.append(('date', '<=', date_to)) + if company_ids: + domain.append(('company_id', 'in', list(company_ids))) + + Line = self.env['account.move.line'].sudo() + groups = Line._read_group( + domain=domain, + groupby=['account_id'], + aggregates=['debit:sum', 'credit:sum'], + ) + return [ + { + 'account_id': account.id, + 'account_code': account.code, + 'account_name': account.name, + 'debit': debit_sum, + 'credit': credit_sum, + 'balance': debit_sum - credit_sum, + } + for account, debit_sum, credit_sum in groups + ] + + +register_adapter('reports', ReportsAdapter) diff --git a/fusion_accounting_ai/tests/test_data_adapters.py b/fusion_accounting_ai/tests/test_data_adapters.py index a22be0db..4bff52aa 100644 --- a/fusion_accounting_ai/tests/test_data_adapters.py +++ b/fusion_accounting_ai/tests/test_data_adapters.py @@ -58,3 +58,19 @@ class TestBankRecAdapter(TransactionCase): ids = [r['id'] for r in rows] self.assertIn(self.line.id, ids, f"Expected line {self.line.id} in unreconciled list, got: {ids}") + + +@tagged('post_install', '-at_install') +class TestReportsAdapter(TransactionCase): + """Verify the reports adapter computes a trial-balance-shaped result.""" + + def test_trial_balance_returns_rows_in_pure_community(self): + adapter = get_adapter(self.env, 'reports') + # Compute an empty-filter trial balance for the current company. Should + # return a list (possibly empty in a fresh test DB) without errors. + result = adapter.trial_balance() + self.assertIsInstance(result, list) + # Each row should have account_id and balance keys + for row in result: + self.assertIn('account_id', row) + self.assertIn('balance', row) From f8b97211ab9679d60d4b543cc09812a74ee17f8c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:21:14 -0400 Subject: [PATCH 14/33] feat(fusion_accounting_ai): add Followup and Assets data adapters Made-with: Cursor --- .../services/data_adapters/__init__.py | 2 + .../services/data_adapters/assets.py | 42 +++++++++++++++++ .../services/data_adapters/followup.py | 47 +++++++++++++++++++ .../tests/test_data_adapters.py | 16 +++++++ 4 files changed, 107 insertions(+) create mode 100644 fusion_accounting_ai/services/data_adapters/assets.py create mode 100644 fusion_accounting_ai/services/data_adapters/followup.py diff --git a/fusion_accounting_ai/services/data_adapters/__init__.py b/fusion_accounting_ai/services/data_adapters/__init__.py index df70bf1d..1f69704c 100644 --- a/fusion_accounting_ai/services/data_adapters/__init__.py +++ b/fusion_accounting_ai/services/data_adapters/__init__.py @@ -3,5 +3,7 @@ from ._registry import get_adapter, register_adapter from . import bank_rec # noqa: F401 from . import reports # noqa: F401 +from . import followup # noqa: F401 +from . import assets # noqa: F401 __all__ = ['DataAdapter', 'AdapterMode', 'get_adapter', 'register_adapter'] diff --git a/fusion_accounting_ai/services/data_adapters/assets.py b/fusion_accounting_ai/services/data_adapters/assets.py new file mode 100644 index 00000000..df7eaca6 --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/assets.py @@ -0,0 +1,42 @@ +"""Assets data adapter.""" + +from .base import DataAdapter +from ._registry import register_adapter + + +class AssetsAdapter(DataAdapter): + FUSION_MODEL = 'fusion.asset' + ENTERPRISE_MODULE = 'account_asset' + + def list_assets(self, state=None): + return self._dispatch('list_assets', state=state) + + def list_assets_via_fusion(self, state=None): + return self._read_fusion('fusion.asset', state=state) + + def list_assets_via_enterprise(self, state=None): + return self._read_fusion('account.asset', state=state) + + def list_assets_via_community(self, state=None): + # No assets feature in pure Community — return empty list with a hint. + return [] + + def _read_fusion(self, model_name, state=None): + """Shared shape between fusion and enterprise (both use account.asset-like API).""" + Model = self.env[model_name].sudo() + domain = [] + if state: + domain.append(('state', '=', state)) + records = Model.search(domain, limit=200) + out = [] + for r in records: + out.append({ + 'id': r.id, + 'name': getattr(r, 'name', None), + 'state': getattr(r, 'state', None), + 'value': getattr(r, 'original_value', None) or getattr(r, 'acquisition_cost', None), + }) + return out + + +register_adapter('assets', AssetsAdapter) diff --git a/fusion_accounting_ai/services/data_adapters/followup.py b/fusion_accounting_ai/services/data_adapters/followup.py new file mode 100644 index 00000000..c6d98a76 --- /dev/null +++ b/fusion_accounting_ai/services/data_adapters/followup.py @@ -0,0 +1,47 @@ +"""Follow-up data adapter.""" + +from datetime import date, timedelta +from .base import DataAdapter +from ._registry import register_adapter + + +class FollowupAdapter(DataAdapter): + FUSION_MODEL = 'fusion.followup.line' + ENTERPRISE_MODULE = 'account_followup' + + def overdue_invoices(self, days_overdue=30, partner_id=None): + return self._dispatch('overdue_invoices', days_overdue=days_overdue, partner_id=partner_id) + + def overdue_invoices_via_fusion(self, days_overdue=30, partner_id=None): + return self.overdue_invoices_via_community(days_overdue=days_overdue, partner_id=partner_id) + + def overdue_invoices_via_enterprise(self, days_overdue=30, partner_id=None): + return self.overdue_invoices_via_community(days_overdue=days_overdue, partner_id=partner_id) + + def overdue_invoices_via_community(self, days_overdue=30, partner_id=None): + cutoff = date.today() - timedelta(days=days_overdue) + domain = [ + ('move_type', 'in', ('out_invoice', 'out_refund')), + ('state', '=', 'posted'), + ('payment_state', 'in', ('not_paid', 'partial')), + ('invoice_date_due', '<=', cutoff), + ] + if partner_id: + domain.append(('partner_id', '=', partner_id)) + moves = self.env['account.move'].sudo().search(domain, limit=200, order='invoice_date_due asc') + return [ + { + 'id': m.id, + 'name': m.name, + 'partner_id': m.partner_id.id, + 'partner_name': m.partner_id.name, + 'invoice_date_due': m.invoice_date_due, + 'amount_residual': m.amount_residual, + 'currency_id': m.currency_id.id, + 'days_overdue': (date.today() - m.invoice_date_due).days, + } + for m in moves + ] + + +register_adapter('followup', FollowupAdapter) diff --git a/fusion_accounting_ai/tests/test_data_adapters.py b/fusion_accounting_ai/tests/test_data_adapters.py index 4bff52aa..0d6de1a8 100644 --- a/fusion_accounting_ai/tests/test_data_adapters.py +++ b/fusion_accounting_ai/tests/test_data_adapters.py @@ -74,3 +74,19 @@ class TestReportsAdapter(TransactionCase): for row in result: self.assertIn('account_id', row) self.assertIn('balance', row) + + +@tagged('post_install', '-at_install') +class TestFollowupAdapter(TransactionCase): + def test_overdue_invoices_returns_list(self): + adapter = get_adapter(self.env, 'followup') + rows = adapter.overdue_invoices(days_overdue=30) + self.assertIsInstance(rows, list) + + +@tagged('post_install', '-at_install') +class TestAssetsAdapter(TransactionCase): + def test_list_assets_returns_list(self): + adapter = get_adapter(self.env, 'assets') + rows = adapter.list_assets() + self.assertIsInstance(rows, list) From 2a41f48123df399c62c599679a75c80852dc0a1f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:26:47 -0400 Subject: [PATCH 15/33] 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 --- .../services/data_adapters/bank_rec.py | 60 +++++++++++++++---- .../services/tools/bank_reconciliation.py | 44 +++++++------- 2 files changed, 71 insertions(+), 33 deletions(-) diff --git a/fusion_accounting_ai/services/data_adapters/bank_rec.py b/fusion_accounting_ai/services/data_adapters/bank_rec.py index 727e9c3e..2f48be80 100644 --- a/fusion_accounting_ai/services/data_adapters/bank_rec.py +++ b/fusion_accounting_ai/services/data_adapters/bank_rec.py @@ -14,28 +14,60 @@ class BankRecAdapter(DataAdapter): FUSION_MODEL = 'fusion.bank.rec.widget' ENTERPRISE_MODULE = 'account_accountant' - def list_unreconciled(self, journal_id, limit=100): - """Return unreconciled bank statement lines for a journal.""" - return self._dispatch('list_unreconciled', journal_id=journal_id, limit=limit) + def list_unreconciled(self, journal_id=None, limit=100, date_from=None, + date_to=None, min_amount=None, company_id=None): + """Return unreconciled bank statement lines. - def list_unreconciled_via_fusion(self, journal_id, limit=100): + All filter params are optional; pass company_id to restrict results to + a single company (the AI tools always do this). + """ + return self._dispatch( + 'list_unreconciled', + journal_id=journal_id, limit=limit, + date_from=date_from, date_to=date_to, + min_amount=min_amount, company_id=company_id, + ) + + def list_unreconciled_via_fusion(self, journal_id=None, limit=100, + date_from=None, date_to=None, + min_amount=None, company_id=None): # Phase 1 will add fusion.bank.rec.widget; this method becomes the primary path. # For now: even when the model exists, delegate to community read shape. - return self.list_unreconciled_via_community(journal_id=journal_id, limit=limit) + return self.list_unreconciled_via_community( + journal_id=journal_id, limit=limit, + date_from=date_from, date_to=date_to, + min_amount=min_amount, company_id=company_id, + ) - def list_unreconciled_via_enterprise(self, journal_id, limit=100): + def list_unreconciled_via_enterprise(self, journal_id=None, limit=100, + date_from=None, date_to=None, + min_amount=None, company_id=None): # Enterprise's bank rec uses a JS-side service; from Python the cleanest # backend access is the same Community search (the data lives in # account.bank.statement.line either way). This adapter's purpose is # to expose a stable shape to AI tools regardless of which UI the user has. - return self.list_unreconciled_via_community(journal_id=journal_id, limit=limit) + return self.list_unreconciled_via_community( + journal_id=journal_id, limit=limit, + date_from=date_from, date_to=date_to, + min_amount=min_amount, company_id=company_id, + ) - def list_unreconciled_via_community(self, journal_id, limit=100): + def list_unreconciled_via_community(self, journal_id=None, limit=100, + date_from=None, date_to=None, + min_amount=None, company_id=None): Line = self.env['account.bank.statement.line'].sudo() - records = Line.search([ - ('journal_id', '=', journal_id), - ('is_reconciled', '=', False), - ], limit=limit, order='date desc, id desc') + domain = [('is_reconciled', '=', False)] + if journal_id is not None: + domain.append(('journal_id', '=', journal_id)) + if company_id is not None: + domain.append(('company_id', '=', company_id)) + if date_from: + domain.append(('date', '>=', date_from)) + if date_to: + domain.append(('date', '<=', date_to)) + if min_amount is not None: + domain.append(('amount', '>=', min_amount)) + records = Line.search(domain, limit=limit, order='date desc, id desc') return [ { 'id': r.id, @@ -43,8 +75,10 @@ class BankRecAdapter(DataAdapter): 'payment_ref': r.payment_ref, 'amount': r.amount, 'partner_id': r.partner_id.id if r.partner_id else None, - 'partner_name': r.partner_id.name if r.partner_id else None, + 'partner_name': r.partner_name or (r.partner_id.name if r.partner_id else None), 'currency_id': r.currency_id.id if r.currency_id else None, + 'journal_id': r.journal_id.id, + 'journal_name': r.journal_id.name, } for r in records ] diff --git a/fusion_accounting_ai/services/tools/bank_reconciliation.py b/fusion_accounting_ai/services/tools/bank_reconciliation.py index 82cc2e8d..7c1b0a5b 100644 --- a/fusion_accounting_ai/services/tools/bank_reconciliation.py +++ b/fusion_accounting_ai/services/tools/bank_reconciliation.py @@ -6,28 +6,32 @@ _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 unreconciled bank lines for a journal/company. + + Routed through the bank_rec data adapter so the result shape is identical + whether the install profile is fusion-native, Enterprise, or pure Community. + """ + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'bank_rec') + rows = adapter.list_unreconciled( + journal_id=int(params['journal_id']) if params.get('journal_id') else None, + limit=int(params.get('limit', 50)), + date_from=params.get('date_from'), + date_to=params.get('date_to'), + min_amount=float(params['min_amount']) if params.get('min_amount') else None, + company_id=env.company.id, + ) return { - 'count': len(lines), - 'total_amount': sum(abs(l.amount) for l in lines), + 'count': len(rows), + 'total_amount': sum(abs(r['amount']) for r in rows), '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], + 'id': r['id'], + 'date': str(r['date']) if r['date'] else '', + 'payment_ref': r['payment_ref'] or '', + 'partner_name': r['partner_name'] or '', + 'amount': r['amount'], + 'journal': r['journal_name'], + } for r in rows], } From 6791246def44a49d32005628ccc58196c2e41680 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:30:20 -0400 Subject: [PATCH 16/33] refactor(fusion_accounting_ai): route accounts_receivable tools through FollowupAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../services/data_adapters/followup.py | 183 +++++++++++++++++- .../services/tools/accounts_receivable.py | 97 +++------- .../tests/test_data_adapters.py | 32 +++ 3 files changed, 236 insertions(+), 76 deletions(-) diff --git a/fusion_accounting_ai/services/data_adapters/followup.py b/fusion_accounting_ai/services/data_adapters/followup.py index c6d98a76..067011f2 100644 --- a/fusion_accounting_ai/services/data_adapters/followup.py +++ b/fusion_accounting_ai/services/data_adapters/followup.py @@ -1,24 +1,56 @@ -"""Follow-up data adapter.""" +"""Follow-up data adapter. + +Routes follow-up / aged-balance / collections data lookups across: +- FUSION: fusion.followup.line (added by future fusion_accounting_followup, Phase 2) +- ENTERPRISE: account_followup's account.followup.line + account.followup.report +- COMMUNITY: aggregations on account.move / account.move.line +""" from datetime import date, timedelta from .base import DataAdapter from ._registry import register_adapter +# Default aging bucket edges used for both AR and AP. +_AGING_BUCKETS = ('current', '1_30', '31_60', '61_90', '90_plus') + + +def _bucket_for_days(days): + if days <= 0: + return 'current' + if days <= 30: + return '1_30' + if days <= 60: + return '31_60' + if days <= 90: + return '61_90' + return '90_plus' + + class FollowupAdapter(DataAdapter): FUSION_MODEL = 'fusion.followup.line' ENTERPRISE_MODULE = 'account_followup' - def overdue_invoices(self, days_overdue=30, partner_id=None): - return self._dispatch('overdue_invoices', days_overdue=days_overdue, partner_id=partner_id) + # ------------------------------------------------------------------ + # overdue_invoices + # ------------------------------------------------------------------ + def overdue_invoices(self, days_overdue=30, partner_id=None, limit=200): + return self._dispatch( + 'overdue_invoices', + days_overdue=days_overdue, partner_id=partner_id, limit=limit, + ) - def overdue_invoices_via_fusion(self, days_overdue=30, partner_id=None): - return self.overdue_invoices_via_community(days_overdue=days_overdue, partner_id=partner_id) + def overdue_invoices_via_fusion(self, days_overdue=30, partner_id=None, limit=200): + return self.overdue_invoices_via_community( + days_overdue=days_overdue, partner_id=partner_id, limit=limit, + ) - def overdue_invoices_via_enterprise(self, days_overdue=30, partner_id=None): - return self.overdue_invoices_via_community(days_overdue=days_overdue, partner_id=partner_id) + def overdue_invoices_via_enterprise(self, days_overdue=30, partner_id=None, limit=200): + return self.overdue_invoices_via_community( + days_overdue=days_overdue, partner_id=partner_id, limit=limit, + ) - def overdue_invoices_via_community(self, days_overdue=30, partner_id=None): + def overdue_invoices_via_community(self, days_overdue=30, partner_id=None, limit=200): cutoff = date.today() - timedelta(days=days_overdue) domain = [ ('move_type', 'in', ('out_invoice', 'out_refund')), @@ -28,20 +60,151 @@ class FollowupAdapter(DataAdapter): ] if partner_id: domain.append(('partner_id', '=', partner_id)) - moves = self.env['account.move'].sudo().search(domain, limit=200, order='invoice_date_due asc') + moves = self.env['account.move'].sudo().search( + domain, limit=limit, order='invoice_date_due asc', + ) + today = date.today() return [ { 'id': m.id, 'name': m.name, 'partner_id': m.partner_id.id, 'partner_name': m.partner_id.name, + 'partner_email': m.partner_id.email or '', + 'partner_phone': m.partner_id.phone or '', 'invoice_date_due': m.invoice_date_due, + 'amount_total': m.amount_total, 'amount_residual': m.amount_residual, 'currency_id': m.currency_id.id, - 'days_overdue': (date.today() - m.invoice_date_due).days, + 'days_overdue': (today - m.invoice_date_due).days if m.invoice_date_due else 0, } for m in moves ] + # ------------------------------------------------------------------ + # aged_receivables + # ------------------------------------------------------------------ + def aged_receivables(self, company_id=None): + return self._dispatch('aged_receivables', company_id=company_id) + + def aged_receivables_via_fusion(self, company_id=None): + return self.aged_receivables_via_community(company_id=company_id) + + def aged_receivables_via_enterprise(self, company_id=None): + return self.aged_receivables_via_community(company_id=company_id) + + def aged_receivables_via_community(self, company_id=None): + return self._aged_buckets( + account_type='asset_receivable', + company_id=company_id, + sign=1, + ) + + # ------------------------------------------------------------------ + # aged_payables + # ------------------------------------------------------------------ + def aged_payables(self, company_id=None): + return self._dispatch('aged_payables', company_id=company_id) + + def aged_payables_via_fusion(self, company_id=None): + return self.aged_payables_via_community(company_id=company_id) + + def aged_payables_via_enterprise(self, company_id=None): + return self.aged_payables_via_community(company_id=company_id) + + def aged_payables_via_community(self, company_id=None): + return self._aged_buckets( + account_type='liability_payable', + company_id=company_id, + sign=-1, # AP residuals are negative; report as positive amounts + ) + + def _aged_buckets(self, account_type, company_id=None, sign=1): + """Shared aging-bucket implementation for receivable/payable accounts. + + Returns a dict: {'total': ..., 'buckets': {...}, 'line_count': N}. + `sign=-1` flips the sign so payables report as positive owed amounts. + """ + today = date.today() + domain = [ + ('account_id.account_type', '=', account_type), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ] + if company_id is not None: + domain.append(('company_id', '=', company_id)) + amls = self.env['account.move.line'].sudo().search(domain) + + buckets = {k: 0.0 for k in _AGING_BUCKETS} + for aml in amls: + amt = aml.amount_residual + if sign < 0: + amt = abs(amt) + if not aml.date_maturity or aml.date_maturity >= today: + buckets['current'] += amt + else: + days = (today - aml.date_maturity).days + buckets[_bucket_for_days(days)] += amt + + return { + 'total': sum(buckets.values()), + 'buckets': buckets, + 'line_count': len(amls), + } + + # ------------------------------------------------------------------ + # followup_report_html — Enterprise-only artifact + # ------------------------------------------------------------------ + def followup_report_html(self, partner_id): + return self._dispatch('followup_report_html', partner_id=partner_id) + + def followup_report_html_via_fusion(self, partner_id): + # Phase 2 will implement a native version. + return self.followup_report_html_via_community(partner_id=partner_id) + + def followup_report_html_via_enterprise(self, partner_id): + partner = self.env['res.partner'].browse(partner_id) + if not partner.exists(): + return {'error': 'Partner not found'} + report = self.env['account.followup.report'] + html = report._get_followup_report_html(partner) + return {'partner': partner.name, 'html': html} + + def followup_report_html_via_community(self, partner_id): + return { + 'error': ( + 'Follow-up report is only available when account_followup ' + '(Enterprise) or a fusion follow-up module is installed.' + ), + } + + # ------------------------------------------------------------------ + # send_followup — Enterprise-only action + # ------------------------------------------------------------------ + def send_followup(self, partner_id, options=None): + return self._dispatch('send_followup', partner_id=partner_id, options=options) + + def send_followup_via_fusion(self, partner_id, options=None): + return self.send_followup_via_community(partner_id=partner_id, options=options) + + def send_followup_via_enterprise(self, partner_id, options=None): + partner = self.env['res.partner'].browse(partner_id) + if not partner.exists(): + return {'error': 'Partner not found'} + result = partner.execute_followup(options or {'partner_id': partner_id}) + return { + 'status': 'sent', + 'partner': partner.name, + 'result': str(result) if result else 'done', + } + + def send_followup_via_community(self, partner_id, options=None): + return { + 'error': ( + 'Sending follow-ups is only available when account_followup ' + '(Enterprise) or a fusion follow-up module is installed.' + ), + } + register_adapter('followup', FollowupAdapter) diff --git a/fusion_accounting_ai/services/tools/accounts_receivable.py b/fusion_accounting_ai/services/tools/accounts_receivable.py index 1f7dc518..0e1f2c49 100644 --- a/fusion_accounting_ai/services/tools/accounts_receivable.py +++ b/fusion_accounting_ai/services/tools/accounts_receivable.py @@ -1,66 +1,36 @@ 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), - } + """Return AR aging buckets. Routed through FollowupAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'followup') + return adapter.aged_receivables(company_id=env.company.id) 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 overdue customer invoices. Routed through FollowupAdapter.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'followup') + rows = adapter.overdue_invoices( + days_overdue=int(params.get('min_days_overdue', 1)), + limit=int(params.get('limit', 50)), + ) return { - 'count': len(invoices), + 'count': len(rows), '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], + 'id': r['id'], + 'name': r['name'], + 'partner': r['partner_name'] or '', + 'email': r['partner_email'], + 'phone': r['partner_phone'], + 'amount_total': r['amount_total'], + 'amount_residual': r['amount_residual'], + 'date_due': str(r['invoice_date_due']) if r['invoice_date_due'] else '', + 'days_overdue': r['days_overdue'], + } for r in rows], } @@ -119,10 +89,10 @@ def get_partner_balance(env, params): def send_followup(env, params): + """Send a follow-up to a partner. Routed through FollowupAdapter so the + Enterprise-only execute_followup path is isolated behind the adapter.""" + from ..data_adapters import get_adapter 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), @@ -133,21 +103,16 @@ def send_followup(env, params): 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'} + adapter = get_adapter(env, 'followup') + return adapter.send_followup(partner_id=partner_id, options=options) def get_followup_report(env, params): + """Return the follow-up report HTML for a partner. Routed through FollowupAdapter.""" + from ..data_adapters import get_adapter 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)} + adapter = get_adapter(env, 'followup') + return adapter.followup_report_html(partner_id=partner_id) def reconcile_payment_to_invoice(env, params): diff --git a/fusion_accounting_ai/tests/test_data_adapters.py b/fusion_accounting_ai/tests/test_data_adapters.py index 0d6de1a8..c5f71b2f 100644 --- a/fusion_accounting_ai/tests/test_data_adapters.py +++ b/fusion_accounting_ai/tests/test_data_adapters.py @@ -83,6 +83,38 @@ class TestFollowupAdapter(TransactionCase): rows = adapter.overdue_invoices(days_overdue=30) self.assertIsInstance(rows, list) + def test_overdue_invoices_row_has_contact_fields(self): + """The enriched shape must include email, phone, and amount_total so + the accounts_receivable tool wrapper can render them.""" + adapter = get_adapter(self.env, 'followup') + rows = adapter.overdue_invoices(days_overdue=30, limit=5) + for row in rows: + for key in ( + 'id', 'name', 'partner_id', 'partner_name', + 'partner_email', 'partner_phone', + 'invoice_date_due', 'amount_total', 'amount_residual', + 'days_overdue', + ): + self.assertIn(key, row, f"Missing key {key!r} in overdue row") + + def test_aged_receivables_returns_bucket_shape(self): + adapter = get_adapter(self.env, 'followup') + result = adapter.aged_receivables(company_id=self.env.company.id) + self.assertIn('total', result) + self.assertIn('buckets', result) + self.assertIn('line_count', result) + for bucket in ('current', '1_30', '31_60', '61_90', '90_plus'): + self.assertIn(bucket, result['buckets']) + + def test_aged_payables_returns_bucket_shape(self): + adapter = get_adapter(self.env, 'followup') + result = adapter.aged_payables(company_id=self.env.company.id) + self.assertIn('total', result) + self.assertIn('buckets', result) + self.assertIn('line_count', result) + for bucket in ('current', '1_30', '31_60', '61_90', '90_plus'): + self.assertIn(bucket, result['buckets']) + @tagged('post_install', '-at_install') class TestAssetsAdapter(TransactionCase): From 2ead351c30176a8afb4956f569ac5eeb71bf361c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:31:19 -0400 Subject: [PATCH 17/33] refactor(fusion_accounting_ai): route accounts_payable aged balances through FollowupAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../services/tools/accounts_payable.py | 30 +++---------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/fusion_accounting_ai/services/tools/accounts_payable.py b/fusion_accounting_ai/services/tools/accounts_payable.py index ffc2d35d..57073c9c 100644 --- a/fusion_accounting_ai/services/tools/accounts_payable.py +++ b/fusion_accounting_ai/services/tools/accounts_payable.py @@ -6,32 +6,10 @@ _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)} + """Return AP aging buckets. Routed through FollowupAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'followup') + return adapter.aged_payables(company_id=env.company.id) def find_duplicate_bills(env, params): From e983a370aa1890d53b6b8d2cf9bc41d6e90420d8 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:33:54 -0400 Subject: [PATCH 18/33] refactor(fusion_accounting_ai): route reporting tools through ReportsAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../services/data_adapters/reports.py | 114 +++++++++++++ .../services/tools/reporting.py | 159 +++++++++--------- .../tests/test_data_adapters.py | 28 ++- 3 files changed, 222 insertions(+), 79 deletions(-) diff --git a/fusion_accounting_ai/services/data_adapters/reports.py b/fusion_accounting_ai/services/data_adapters/reports.py index 37e71d40..f73730a2 100644 --- a/fusion_accounting_ai/services/data_adapters/reports.py +++ b/fusion_accounting_ai/services/data_adapters/reports.py @@ -6,14 +6,22 @@ Routes report-data lookups across: - COMMUNITY: raw aggregations on account.move.line """ +import base64 +import logging + from .base import DataAdapter from ._registry import register_adapter +_logger = logging.getLogger(__name__) + class ReportsAdapter(DataAdapter): FUSION_MODEL = 'fusion.account.report' ENTERPRISE_MODULE = 'account_reports' + # ------------------------------------------------------------------ + # trial_balance (Community-computable from account.move.line) + # ------------------------------------------------------------------ def trial_balance(self, date_to=None, company_ids=None): return self._dispatch('trial_balance', date_to=date_to, company_ids=company_ids) @@ -52,5 +60,111 @@ class ReportsAdapter(DataAdapter): for account, debit_sum, credit_sum in groups ] + # ------------------------------------------------------------------ + # run_report — generic Enterprise account.report wrapper + # + # Returns either {'report_name', 'lines'} or {'error': ...}. + # Used by profit_loss / balance_sheet / cash_flow / trial_balance_lines + # tool wrappers that want Enterprise's hierarchical report shape when + # available. + # ------------------------------------------------------------------ + def run_report(self, ref_id, date_from=None, date_to=None, limit=100): + return self._dispatch( + 'run_report', + ref_id=ref_id, date_from=date_from, date_to=date_to, limit=limit, + ) + + def run_report_via_fusion(self, ref_id, date_from=None, date_to=None, limit=100): + # Phase 2: fusion.account.report will implement equivalent rendering. + return self.run_report_via_community( + ref_id=ref_id, date_from=date_from, date_to=date_to, limit=limit, + ) + + def run_report_via_enterprise(self, ref_id, date_from=None, date_to=None, limit=100): + try: + report = self.env.ref(ref_id, raise_if_not_found=False) + except Exception: + report = None + if not report: + return {'error': f'Report {ref_id} not found'} + date_opts = {} + if date_from: + date_opts['date_from'] = date_from + if date_to: + date_opts['date_to'] = 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': line.get('name', ''), + 'level': line.get('level', 0), + 'columns': [c.get('no_format', c.get('name', '')) for c in line.get('columns', [])], + } for line in lines[:limit]], + } + + def run_report_via_community(self, ref_id, date_from=None, date_to=None, limit=100): + return { + 'error': ( + f'Report {ref_id!r} is only available when account_reports (Enterprise) ' + 'or a fusion reports module is installed. For pure Community installs, ' + 'use the raw trial_balance() adapter method or the tools that aggregate ' + 'account.move.line directly.' + ), + } + + # ------------------------------------------------------------------ + # export_report — Enterprise-only PDF/XLSX export + # ------------------------------------------------------------------ + def export_report(self, ref_id, fmt='pdf', date_from=None, date_to=None): + return self._dispatch( + 'export_report', + ref_id=ref_id, fmt=fmt, date_from=date_from, date_to=date_to, + ) + + def export_report_via_fusion(self, ref_id, fmt='pdf', date_from=None, date_to=None): + return self.export_report_via_community( + ref_id=ref_id, fmt=fmt, date_from=date_from, date_to=date_to, + ) + + def export_report_via_enterprise(self, ref_id, fmt='pdf', date_from=None, date_to=None): + try: + report = self.env.ref(ref_id, raise_if_not_found=False) + except Exception: + report = None + if not report: + return {'error': f'Report {ref_id} not found'} + date_opts = {} + if date_from: + date_opts['date_from'] = date_from + if date_to: + date_opts['date_to'] = 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)}'} + + def export_report_via_community(self, ref_id, fmt='pdf', date_from=None, date_to=None): + return { + 'error': ( + f'Exporting report {ref_id!r} is only available with Enterprise ' + 'account_reports installed.' + ), + } + register_adapter('reports', ReportsAdapter) diff --git a/fusion_accounting_ai/services/tools/reporting.py b/fusion_accounting_ai/services/tools/reporting.py index dad430bb..4753f1cb 100644 --- a/fusion_accounting_ai/services/tools/reporting.py +++ b/fusion_accounting_ai/services/tools/reporting.py @@ -1,67 +1,91 @@ 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]], - } - +# --------------------------------------------------------------------------- +# Enterprise account.report wrappers — all routed through ReportsAdapter. +# --------------------------------------------------------------------------- def get_profit_loss(env, params): - return _run_report(env, 'account_reports.profit_and_loss', params) + """Route through ReportsAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') + return adapter.run_report( + ref_id='account_reports.profit_and_loss', + date_from=params.get('date_from'), + date_to=params.get('date_to'), + ) def get_balance_sheet(env, params): - return _run_report(env, 'account_reports.balance_sheet', params) + """Route through ReportsAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') + return adapter.run_report( + ref_id='account_reports.balance_sheet', + date_from=params.get('date_from'), + date_to=params.get('date_to'), + ) def get_trial_balance(env, params): - return _run_report(env, 'account_reports.trial_balance_report', params) + """Route through ReportsAdapter for tri-mode consistency. + + In Enterprise mode returns the hierarchical report lines. In Community + mode falls back to the adapter's trial_balance() aggregation so the tool + continues to return useful data with a compatible shape. + """ + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') + result = adapter.run_report( + ref_id='account_reports.trial_balance_report', + date_from=params.get('date_from'), + date_to=params.get('date_to'), + ) + if isinstance(result, dict) and result.get('error'): + rows = adapter.trial_balance( + date_to=params.get('date_to'), + company_ids=[env.company.id], + ) + return { + 'report_name': 'Trial Balance (Community aggregation)', + 'lines': [{ + 'name': f"{r['account_code']} {r['account_name']}", + 'level': 2, + 'columns': [r['debit'], r['credit'], r['balance']], + } for r in rows], + } + return result def get_cash_flow(env, params): - return _run_report(env, 'account_reports.cash_flow_statement', params) + """Route through ReportsAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') + return adapter.run_report( + ref_id='account_reports.cash_flow_statement', + date_from=params.get('date_from'), + date_to=params.get('date_to'), + ) def compare_periods(env, params): + """Run the same report over two periods and return both results. Routes + both runs through ReportsAdapter.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') 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'), - }) + period1 = adapter.run_report( + ref_id=report_ref, + date_from=params.get('period1_from'), + date_to=params.get('period1_to'), + ) + period2 = adapter.run_report( + ref_id=report_ref, + date_from=params.get('period2_from'), + date_to=params.get('period2_to'), + ) return {'period_1': period1, 'period_2': period2} @@ -74,42 +98,27 @@ def answer_financial_question(env, params): 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 {}) + """Route through ReportsAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') + return adapter.export_report( + ref_id=params.get('report_ref', 'account_reports.profit_and_loss'), + fmt=params.get('format', 'pdf'), + date_from=params.get('date_from'), + date_to=params.get('date_to'), + ) - 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)}'} +# --------------------------------------------------------------------------- +# Pure-Community tools — search account.move / account.payment directly. +# These are tri-mode safe (the data lives in the same tables regardless of +# install profile) so they don't need adapter routing. +# --------------------------------------------------------------------------- def get_invoicing_summary(env, params): """Get invoicing summary — total invoiced by month, by partner, or for a date range. Supports: monthly breakdown for a year, current month totals, or filtered by partner.""" - from datetime import date, timedelta + from datetime import date import calendar year = int(params.get('year', date.today().year)) @@ -145,7 +154,6 @@ def get_invoicing_summary(env, params): } for inv in invoices[:30]], } - # Monthly breakdown for the year months = [] grand_total = 0 for month in range(1, 13): @@ -209,7 +217,6 @@ def get_billing_summary(env, params): } for b in bills[:30]], } - # Monthly breakdown months = [] grand_total = 0 for month in range(1, 13): diff --git a/fusion_accounting_ai/tests/test_data_adapters.py b/fusion_accounting_ai/tests/test_data_adapters.py index c5f71b2f..f07d346c 100644 --- a/fusion_accounting_ai/tests/test_data_adapters.py +++ b/fusion_accounting_ai/tests/test_data_adapters.py @@ -66,15 +66,37 @@ class TestReportsAdapter(TransactionCase): def test_trial_balance_returns_rows_in_pure_community(self): adapter = get_adapter(self.env, 'reports') - # Compute an empty-filter trial balance for the current company. Should - # return a list (possibly empty in a fresh test DB) without errors. result = adapter.trial_balance() self.assertIsInstance(result, list) - # Each row should have account_id and balance keys for row in result: self.assertIn('account_id', row) self.assertIn('balance', row) + def test_run_report_returns_lines_or_error_dict(self): + """run_report() must always return either an Enterprise-shaped + {'report_name', 'lines'} dict or an {'error': ...} dict — never raise.""" + adapter = get_adapter(self.env, 'reports') + result = adapter.run_report(ref_id='account_reports.profit_and_loss') + self.assertIsInstance(result, dict) + # Either a report_name+lines response or an error — both valid + self.assertTrue( + ('lines' in result and 'report_name' in result) or 'error' in result, + f"Unexpected result shape: {result!r}", + ) + + def test_run_report_with_unknown_ref_returns_error(self): + adapter = get_adapter(self.env, 'reports') + result = adapter.run_report(ref_id='nonexistent.report.xml_id') + self.assertIsInstance(result, dict) + self.assertIn('error', result) + + def test_export_report_returns_dict(self): + adapter = get_adapter(self.env, 'reports') + result = adapter.export_report( + ref_id='account_reports.profit_and_loss', fmt='pdf', + ) + self.assertIsInstance(result, dict) + @tagged('post_install', '-at_install') class TestFollowupAdapter(TransactionCase): From 484314625eb0f5c75d181e192e9402192fc9c0b4 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:38:19 -0400 Subject: [PATCH 19/33] feat(shopfloor): match Bake Windows + First-Piece Gates kanbans to Plant Overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../static/src/scss/fp_kanbans.scss | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 fusion_plating/fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss new file mode 100644 index 00000000..60dae323 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss @@ -0,0 +1,187 @@ +// ============================================================================= +// Fusion Plating — Shared kanban card style for menu pages +// Copyright 2026 Nexa Systems Inc. · License OPL-1 +// +// This file styles the standalone Bake Windows and First-Piece Gates kanban +// pages so they match the Plant Overview client action's card look. Plant +// Overview is a full custom OWL component and has its own .o_fp_po_card +// styles (see plant_overview.scss); we deliberately copy the visual +// language here rather than reuse those classes so the OWL component and +// the standard Odoo kanbans stay loosely coupled. +// +// Design recipe (matches plant_overview.scss): +// - white surface ($fp-card), 1px $fp-border, $fp-radius-md corners +// - soft elevation, hover lifts subtly +// - 4px state stripe on the left, clipped to corners via overflow:hidden +// - status colours pulled from $fp-ok / $fp-warn / $fp-bad / $fp-info +// ============================================================================= + + +// Generic kanban card shared by Bake Windows + First-Piece Gates. +// Per-page tweaks live in the .o_fp_bw_kanban and .o_fp_fpg_kanban +// blocks below. +.o_fp_kcard { + position: relative; + display: flex; + flex-direction: column; + gap: $fp-space-2; + background-color: $fp-card; + color: $fp-ink; + border: 1px solid #{$fp-border}; + border-radius: $fp-radius-md; + // Clip the ::before stripe to the rounded corners. Shadows render + // outside the box so they're unaffected. + overflow: hidden; + padding: $fp-space-3 $fp-space-4; + box-shadow: $fp-elev-1; + transition: transform $fp-dur-fast $fp-ease, + box-shadow $fp-dur $fp-ease, + border-color $fp-dur $fp-ease; + + @include fp-hover-only { + &:hover { + transform: translateY(-1px); + box-shadow: $fp-elev-2; + border-color: color-mix(in srgb, #{$fp-accent} 45%, #{$fp-border}); + } + } + + // Left state stripe — driven by data-state / data-result attribute on + // the card. Default is the muted ink-faint colour; specific states + // override below. + &::before { + content: ""; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 4px; + background-color: $fp-ink-faint; + } + + // -- Title row ----------------------------------------------------- + .o_fp_kcard_title { + font-size: $fp-text-base; + font-weight: $fp-weight-semibold; + letter-spacing: -0.01em; + color: $fp-ink; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + + // -- Subtitle (part ref / lot / customer one-liner) ---------------- + .o_fp_kcard_sub { + font-size: $fp-text-xs; + color: $fp-ink-mute; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + + // -- Big metric (time remaining etc.) ------------------------------ + // Used when the card has one number that matters more than the rest + // (bake countdown, qty pending). Stays compact — this is a kanban, + // not a billboard. + .o_fp_kcard_metric { + display: inline-flex; align-items: baseline; gap: $fp-space-1; + margin-top: $fp-space-1; + font-variant-numeric: tabular-nums; + + .o_fp_kcard_metric_value { + font-size: $fp-text-md; + font-weight: $fp-weight-bold; + color: $fp-ink; + letter-spacing: -0.01em; + } + .o_fp_kcard_metric_label { + font-size: $fp-text-xs; + color: $fp-ink-mute; + } + } + + // -- Meta line — small key/value pairs separated by mid-dots ------- + .o_fp_kcard_meta { + font-size: $fp-text-xs; + color: $fp-ink-mute; + display: flex; flex-wrap: wrap; gap: $fp-space-1; + + .o_fp_kcard_meta_sep { + color: $fp-ink-faint; + } + } + + // -- Footer — chip + secondary tags -------------------------------- + .o_fp_kcard_footer { + display: flex; justify-content: space-between; align-items: center; + gap: $fp-space-2; + margin-top: auto; // sticks footer to the bottom even at varying heights + } + + // Inline chip for state / result. Mirrors the Plant Overview chip + // styling but locally scoped to keep the surface independent. + .o_fp_kcard_chip { + display: inline-flex; align-items: center; + padding: 2px 10px; + border-radius: $fp-radius-pill; + font-size: 0.7rem; + font-weight: $fp-weight-bold; + text-transform: uppercase; + letter-spacing: 0.06em; + + &.tone-info { @include fp-pill(--bs-info); } + &.tone-success { @include fp-pill(--bs-success); } + &.tone-warning { @include fp-pill(--bs-warning); } + &.tone-danger { @include fp-pill(--bs-danger); } + &.tone-muted { background-color: $fp-card-soft; color: $fp-ink-mute; } + } +} + + +// ============================================================================= +// Bake Windows — state-driven stripe + soft danger wash on missed jobs +// ============================================================================= +.o_fp_bw_kanban { + .o_fp_kcard { + &[data-state="awaiting_bake"] { &::before { background-color: $fp-warn; } } + &[data-state="bake_in_progress"] { &::before { background-color: $fp-info; } } + &[data-state="baked"] { &::before { background-color: $fp-ok; } } + &[data-state="missed_window"] { + &::before { background-color: $fp-bad; } + // Missed windows are an exception state — softly tint the + // whole card so it stands out in a sea of normal ones. + background-color: fp-wash(--bs-danger, 6%); + border-color: color-mix(in srgb, #{$fp-bad} 35%, #{$fp-border}); + } + &[data-state="scrapped"] { + &::before { background-color: $fp-ink-faint; } + opacity: 0.65; + } + } +} + + +// ============================================================================= +// First-Piece Gates — result-driven stripe (pending = warn, fail = bad) +// ============================================================================= +.o_fp_fpg_kanban { + .o_fp_kcard { + &[data-result="pending"] { &::before { background-color: $fp-warn; } } + &[data-result="pass"] { &::before { background-color: $fp-ok; } } + &[data-result="fail"] { + &::before { background-color: $fp-bad; } + background-color: fp-wash(--bs-danger, 6%); + border-color: color-mix(in srgb, #{$fp-bad} 35%, #{$fp-border}); + } + } + + // Subtle "released" badge — visible only when the lot has been + // released after a passing first-piece. Sits next to the result chip. + .o_fp_fpg_released { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 8px; + border-radius: $fp-radius-pill; + font-size: 0.65rem; + font-weight: $fp-weight-bold; + text-transform: uppercase; + letter-spacing: 0.06em; + background-color: color-mix(in srgb, #{$fp-ok} 14%, transparent); + color: $fp-ok; + + .fa { font-size: 0.7rem; } + } +} From f18afe7380f0b14a5bbcd49c95aab56da86d6ee7 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:40:27 -0400 Subject: [PATCH 20/33] refactor(fusion_accounting_ai): route month_end + hst_management report tools through ReportsAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../services/tools/hst_management.py | 29 ++++++---------- .../services/tools/month_end.py | 33 ++++++++++++------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/fusion_accounting_ai/services/tools/hst_management.py b/fusion_accounting_ai/services/tools/hst_management.py index 63f4e71c..6caf3287 100644 --- a/fusion_accounting_ai/services/tools/hst_management.py +++ b/fusion_accounting_ai/services/tools/hst_management.py @@ -52,25 +52,16 @@ def calculate_hst_balance(env, params): 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]], - } + """Route through ReportsAdapter for tri-mode consistency. The Community + fallback returns an error dict explaining the report is Enterprise-only.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') + return adapter.run_report( + ref_id=params.get('report_ref', 'account.generic_tax_report'), + date_from=params.get('date_from'), + date_to=params.get('date_to'), + limit=50, + ) def find_missing_tax_invoices(env, params): diff --git a/fusion_accounting_ai/services/tools/month_end.py b/fusion_accounting_ai/services/tools/month_end.py index e35fc7cc..4a941b0b 100644 --- a/fusion_accounting_ai/services/tools/month_end.py +++ b/fusion_accounting_ai/services/tools/month_end.py @@ -101,22 +101,31 @@ def run_hash_integrity_check(env, params): def get_period_summary(env, params): + """Period summary via trial-balance. Routed through ReportsAdapter so the + Enterprise-only account_reports.trial_balance_report path is isolated; + Community installs fall back to the adapter's trial_balance() aggregation.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'reports') date_from = params.get('date_from') date_to = params.get('date_to') - try: - report = env.ref('account_reports.trial_balance_report') - except Exception: - report = env.ref('account.trial_balance_report', raise_if_not_found=False) - if not report: - return {'error': 'Trial balance report not found'} - options = report.get_options({'date': {'date_from': date_from, 'date_to': date_to}}) - lines = report._get_lines(options) + result = adapter.run_report( + ref_id='account_reports.trial_balance_report', + date_from=date_from, date_to=date_to, + ) + if isinstance(result, dict) and result.get('error'): + rows = adapter.trial_balance( + date_to=date_to, company_ids=[env.company.id], + ) + return { + 'period': f'{date_from} to {date_to}', + 'lines': [{ + 'name': f"{r['account_code']} {r['account_name']}", + 'columns': [r['debit'], r['credit'], r['balance']], + } for r in rows[:100]], + } return { 'period': f'{date_from} to {date_to}', - 'lines': [{ - 'name': l.get('name', ''), - 'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])], - } for l in lines[:100]], + 'lines': result.get('lines', []), } From 182978606d35f392725bc97b0ab593b595e7432a Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:41:27 -0400 Subject: [PATCH 21/33] 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). --- .../fusion_plating_shopfloor/__manifest__.py | 3 +- .../views/fp_bake_window_views.xml | 62 +++++++++++++++---- .../views/fp_first_piece_gate_views.xml | 59 +++++++++++++++--- 3 files changed, 100 insertions(+), 24 deletions(-) diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index 5a15b971..bce54c4a 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.9.0.0', + 'version': '19.0.12.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', @@ -65,6 +65,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'fusion_plating_shopfloor/static/src/scss/plant_overview.scss', 'fusion_plating_shopfloor/static/src/scss/process_tree.scss', 'fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss', + 'fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss', 'fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml', 'fusion_plating_shopfloor/static/src/xml/plant_overview.xml', 'fusion_plating_shopfloor/static/src/xml/process_tree.xml', diff --git a/fusion_plating/fusion_plating_shopfloor/views/fp_bake_window_views.xml b/fusion_plating/fusion_plating_shopfloor/views/fp_bake_window_views.xml index 128fd61c..bca377e0 100644 --- a/fusion_plating/fusion_plating_shopfloor/views/fp_bake_window_views.xml +++ b/fusion_plating/fusion_plating_shopfloor/views/fp_bake_window_views.xml @@ -111,34 +111,70 @@ + fp.bake.window.kanban fusion.plating.bake.window - + + + - -
-
-
- -
-
- +
+
+
-
-
-
Lot
+
+ +
+
+ + + + + remaining +
+
+ + Lot + + · + + + + · + + Qty + +
+
diff --git a/fusion_plating/fusion_plating_shopfloor/views/fp_first_piece_gate_views.xml b/fusion_plating/fusion_plating_shopfloor/views/fp_first_piece_gate_views.xml index 8aeff7de..2035ecf1 100644 --- a/fusion_plating/fusion_plating_shopfloor/views/fp_first_piece_gate_views.xml +++ b/fusion_plating/fusion_plating_shopfloor/views/fp_first_piece_gate_views.xml @@ -76,29 +76,68 @@ + fp.first.piece.gate.kanban fusion.plating.first.piece.gate - + + + + - -
-
-
- -
-
+
+
+
-
- +
+ +
+
+ + + + · + + + +
+
+ + + + · + + + +
+
From b637723c6a6df134cf1140f7c8f400a1d2004dc6 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:46:44 -0400 Subject: [PATCH 22/33] feat(fusion_accounting_core): add _fusion_is_enterprise_accounting_installed helper Made-with: Cursor --- fusion_accounting_core/models/__init__.py | 2 +- .../models/ir_module_module.py | 32 +++++++++++++++++++ fusion_accounting_core/tests/__init__.py | 2 +- .../tests/test_enterprise_detection.py | 20 ++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 fusion_accounting_core/models/ir_module_module.py create mode 100644 fusion_accounting_core/tests/test_enterprise_detection.py diff --git a/fusion_accounting_core/models/__init__.py b/fusion_accounting_core/models/__init__.py index 154f21c2..8e2d2d1b 100644 --- a/fusion_accounting_core/models/__init__.py +++ b/fusion_accounting_core/models/__init__.py @@ -1 +1 @@ -# Models populated in Tasks 8-12 (shared-field-ownership, helpers) +from . import ir_module_module diff --git a/fusion_accounting_core/models/ir_module_module.py b/fusion_accounting_core/models/ir_module_module.py new file mode 100644 index 00000000..27e02076 --- /dev/null +++ b/fusion_accounting_core/models/ir_module_module.py @@ -0,0 +1,32 @@ +from odoo import api, models + + +# Modules considered "Odoo Enterprise accounting" for the purpose of feature gating. +# A client is "on Enterprise" if any of these are installed; fusion_accounting_* +# replacement modules will hide their menus when Enterprise is present (replace mode +# vs. augment mode is configurable in Settings). +ENTERPRISE_ACCOUNTING_MODULES = ( + 'account_accountant', + 'account_reports', + 'accountant', +) + + +class IrModuleModule(models.Model): + _inherit = "ir.module.module" + + @api.model + def _fusion_is_enterprise_accounting_installed(self): + """True if any Odoo Enterprise accounting module is installed in this DB.""" + return bool(self.sudo().search_count([ + ('name', 'in', list(ENTERPRISE_ACCOUNTING_MODULES)), + ('state', '=', 'installed'), + ])) + + @api.model + def _fusion_is_module_installed(self, module_name): + """True if a specific module is installed.""" + return bool(self.sudo().search_count([ + ('name', '=', module_name), + ('state', '=', 'installed'), + ])) diff --git a/fusion_accounting_core/tests/__init__.py b/fusion_accounting_core/tests/__init__.py index 55610b70..f1a2b5c5 100644 --- a/fusion_accounting_core/tests/__init__.py +++ b/fusion_accounting_core/tests/__init__.py @@ -1 +1 @@ -# Tests populated in Tasks 8-12 +from . import test_enterprise_detection diff --git a/fusion_accounting_core/tests/test_enterprise_detection.py b/fusion_accounting_core/tests/test_enterprise_detection.py new file mode 100644 index 00000000..4f2a72c7 --- /dev/null +++ b/fusion_accounting_core/tests/test_enterprise_detection.py @@ -0,0 +1,20 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestEnterpriseDetection(TransactionCase): + """Verify the helper that detects Odoo Enterprise accounting installs.""" + + def test_helper_returns_bool(self): + result = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed() + self.assertIsInstance(result, bool) + + def test_helper_matches_actual_state(self): + """Helper should return True iff one of the known Enterprise modules is installed.""" + installed = self.env['ir.module.module'].sudo().search_count([ + ('name', 'in', ['account_accountant', 'account_reports', 'accountant']), + ('state', '=', 'installed'), + ]) + expected = bool(installed) + actual = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed() + self.assertEqual(actual, expected) From e79f11f5f094a6494c1a3503078b4d55e8282655 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:48:26 -0400 Subject: [PATCH 23/33] fix(shopfloor): suppress Odoo .o_kanban_record chrome inside fp kanbans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fusion_plating_shopfloor/__manifest__.py | 2 +- .../static/src/scss/fp_kanbans.scss | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index bce54c4a..5a431f9b 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.12.0.0', + 'version': '19.0.13.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss index 60dae323..83fd495c 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss @@ -17,6 +17,36 @@ // ============================================================================= +// ----------------------------------------------------------------------------- +// Suppress Odoo's default .o_kanban_record chrome inside our kanbans. +// The default wrapper paints its own background, border, padding, and +// (annoyingly) box-shadow with sharper corners than our inner card — +// which makes a faint square ghost visible behind every card. By making +// the wrapper transparent / borderless, only our .o_fp_kcard surface is +// visible, so corner radii stay consistent across every state. +// ----------------------------------------------------------------------------- +.o_fp_bw_kanban, +.o_fp_fpg_kanban { + .o_kanban_record { + background: transparent !important; + border: 0 !important; + box-shadow: none !important; + padding: 0 !important; + margin: 0 0 $fp-space-2 0; + // Match the inner card radius so any focus / selected outline + // Odoo paints on top still curves with the card. + border-radius: $fp-radius-md; + overflow: visible; // let our shadow paint outside the wrapper + } + + // Same treatment for the kanban group container — Odoo gives it a + // subtle bg that looks misaligned next to the card surfaces. + .o_kanban_group { + background: transparent; + } +} + + // Generic kanban card shared by Bake Windows + First-Piece Gates. // Per-page tweaks live in the .o_fp_bw_kanban and .o_fp_fpg_kanban // blocks below. From 10140a6968b7ff1eb57896328a67d468d5b34c4b Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:55:32 -0400 Subject: [PATCH 24/33] feat(fusion_accounting_core): shared-field-ownership for deferred fields, signing_user, created_automatically Made-with: Cursor --- fusion_accounting_core/models/__init__.py | 2 + fusion_accounting_core/models/account_move.py | 56 +++++++++++++++++++ .../models/account_reconcile_model.py | 17 ++++++ fusion_accounting_core/tests/__init__.py | 1 + .../tests/test_shared_field_ownership.py | 32 +++++++++++ 5 files changed, 108 insertions(+) create mode 100644 fusion_accounting_core/models/account_move.py create mode 100644 fusion_accounting_core/models/account_reconcile_model.py create mode 100644 fusion_accounting_core/tests/test_shared_field_ownership.py diff --git a/fusion_accounting_core/models/__init__.py b/fusion_accounting_core/models/__init__.py index 8e2d2d1b..bcc7bba4 100644 --- a/fusion_accounting_core/models/__init__.py +++ b/fusion_accounting_core/models/__init__.py @@ -1 +1,3 @@ from . import ir_module_module +from . import account_move +from . import account_reconcile_model diff --git a/fusion_accounting_core/models/account_move.py b/fusion_accounting_core/models/account_move.py new file mode 100644 index 00000000..e4dd1c94 --- /dev/null +++ b/fusion_accounting_core/models/account_move.py @@ -0,0 +1,56 @@ +"""Shared-field-ownership declarations for account.move. + +Per the roadmap (Section 3.3), these fields exist in Odoo Enterprise's +account_accountant module. By declaring them here with identical schemas +and identical relation tables, fusion_accounting_core becomes a co-owner. +When Enterprise uninstalls, Odoo's module registry sees fusion still owns +the fields and preserves the columns / relation tables, so the data +(deferred revenue links, signing user, etc.) survives uninstall. + +The fields here have NO compute methods, NO defaults beyond what Enterprise +provides, NO views. They're pure schema-preservation declarations. Any +business logic that operates on these fields lives in Enterprise (when +present) or in a future fusion sub-module that opts to own that behavior. +""" + +from odoo import fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + deferred_move_ids = fields.Many2many( + comodel_name='account.move', + relation='account_move_deferred_rel', + column1='original_move_id', + column2='deferred_move_id', + copy=False, + string="Deferred Entries", + ) + 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, + string="Original Invoices", + ) + deferred_entry_type = fields.Selection( + selection=[ + ('expense', 'Deferred Expense'), + ('revenue', 'Deferred Revenue'), + ], + copy=False, + string="Deferred Entry Type", + ) + + signing_user = fields.Many2one( + comodel_name='res.users', + copy=False, + string="Signing User", + ) + + payment_state_before_switch = fields.Char( + copy=False, + string="Payment State Before Switch", + ) diff --git a/fusion_accounting_core/models/account_reconcile_model.py b/fusion_accounting_core/models/account_reconcile_model.py new file mode 100644 index 00000000..4f35c837 --- /dev/null +++ b/fusion_accounting_core/models/account_reconcile_model.py @@ -0,0 +1,17 @@ +"""Shared-field-ownership for account.reconcile.model. + +Mirrors the single field Enterprise's account_accountant adds to the +Community account.reconcile.model: created_automatically. +""" + +from odoo import fields, models + + +class AccountReconcileModel(models.Model): + _inherit = "account.reconcile.model" + + created_automatically = fields.Boolean( + default=False, + copy=False, + string="Created Automatically", + ) diff --git a/fusion_accounting_core/tests/__init__.py b/fusion_accounting_core/tests/__init__.py index f1a2b5c5..10b90050 100644 --- a/fusion_accounting_core/tests/__init__.py +++ b/fusion_accounting_core/tests/__init__.py @@ -1 +1,2 @@ from . import test_enterprise_detection +from . import test_shared_field_ownership diff --git a/fusion_accounting_core/tests/test_shared_field_ownership.py b/fusion_accounting_core/tests/test_shared_field_ownership.py new file mode 100644 index 00000000..82fb24e1 --- /dev/null +++ b/fusion_accounting_core/tests/test_shared_field_ownership.py @@ -0,0 +1,32 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestSharedFieldOwnership(TransactionCase): + """Verify fusion_accounting_core declares the Enterprise extension fields + on account.move and account.reconcile.model, so they survive Enterprise uninstall.""" + + def test_account_move_deferred_fields_exist(self): + Move = self.env['account.move'] + for fname in ('deferred_move_ids', 'deferred_original_move_ids', 'deferred_entry_type'): + self.assertIn(fname, Move._fields, f"{fname!r} must exist on account.move") + + def test_account_move_signing_user_exists(self): + Move = self.env['account.move'] + self.assertIn('signing_user', Move._fields) + + def test_account_move_payment_state_before_switch_exists(self): + Move = self.env['account.move'] + self.assertIn('payment_state_before_switch', Move._fields) + + def test_account_reconcile_model_created_automatically_exists(self): + Model = self.env['account.reconcile.model'] + self.assertIn('created_automatically', Model._fields) + + def test_deferred_relation_table_name_matches_enterprise(self): + """The shared M2M relation table must be named identically to Enterprise's + so dual ownership works (Enterprise drops field => fusion preserves table).""" + f = self.env['account.move']._fields['deferred_move_ids'] + self.assertEqual(f.relation, 'account_move_deferred_rel') + self.assertEqual(f.column1, 'original_move_id') + self.assertEqual(f.column2, 'deferred_move_id') From 7ac01991e571fb7e0e8a5125703b429d843ccc9c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 00:14:36 -0400 Subject: [PATCH 25/33] refactor(fusion_accounting): move security groups to _core, add multi-company session rule Made-with: Cursor --- fusion_accounting/security/security.xml | 94 ------------------- fusion_accounting_ai/__manifest__.py | 1 + .../controllers/chat_controller.py | 10 +- .../data/tool_definitions.xml | 38 ++++---- .../migrations/19.0.1.0.0/post-migration.py | 38 ++++++-- .../fusion_accounting_ai_security.xml | 58 ++++++++++++ .../security/ir.model.access.csv | 36 +++---- .../views/match_history_views.xml | 4 +- fusion_accounting_ai/views/menus.xml | 10 +- fusion_accounting_ai/views/rule_views.xml | 4 +- fusion_accounting_core/__manifest__.py | 1 + .../migrations/19.0.1.0.0/post-migration.py | 48 ++++++++++ .../security/fusion_accounting_security.xml | 46 +++++++++ 13 files changed, 237 insertions(+), 151 deletions(-) delete mode 100644 fusion_accounting/security/security.xml create mode 100644 fusion_accounting_ai/security/fusion_accounting_ai_security.xml create mode 100644 fusion_accounting_core/migrations/19.0.1.0.0/post-migration.py create mode 100644 fusion_accounting_core/security/fusion_accounting_security.xml diff --git a/fusion_accounting/security/security.xml b/fusion_accounting/security/security.xml deleted file mode 100644 index 25c3c297..00000000 --- a/fusion_accounting/security/security.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - - Fusion Accounting AI - 25 - - - - - Fusion Accounting AI - - - - - - User - 10 - - - - - - - Manager - 20 - - - - - - - Administrator - 30 - - - - - - - - - - - - - - - Fusion Session: Own Sessions - - [('user_id', '=', user.id)] - - - - - Fusion Session: All Sessions - - [(1, '=', 1)] - - - - - Fusion History: Own History - - [('session_id.user_id', '=', user.id)] - - - - - Fusion History: All History - - [(1, '=', 1)] - - - - - - Fusion Tool: Multi-Company - - ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] - - - - Fusion Rule: Multi-Company - - ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] - - - - Fusion History: Multi-Company - - ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] - - diff --git a/fusion_accounting_ai/__manifest__.py b/fusion_accounting_ai/__manifest__.py index e524cb74..ab54bffa 100644 --- a/fusion_accounting_ai/__manifest__.py +++ b/fusion_accounting_ai/__manifest__.py @@ -30,6 +30,7 @@ Built by Nexa Systems Inc. }, 'data': [ 'security/ir.model.access.csv', + 'security/fusion_accounting_ai_security.xml', 'data/cron.xml', 'data/tool_definitions.xml', 'data/default_rules.xml', diff --git a/fusion_accounting_ai/controllers/chat_controller.py b/fusion_accounting_ai/controllers/chat_controller.py index a3c6214c..b1acb10b 100644 --- a/fusion_accounting_ai/controllers/chat_controller.py +++ b/fusion_accounting_ai/controllers/chat_controller.py @@ -13,7 +13,7 @@ class FusionAccountingChatController(http.Controller): """S1-S3: Verify the current user owns the session.""" if session.user_id.id != request.env.user.id: # Allow managers to access any session - if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'): + if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Access denied: you do not own this session'} return None @@ -55,7 +55,7 @@ class FusionAccountingChatController(http.Controller): @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'): + if not request.env.user.has_group('fusion_accounting_core.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)) @@ -63,7 +63,7 @@ class FusionAccountingChatController(http.Controller): @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'): + if not request.env.user.has_group('fusion_accounting_core.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) @@ -103,7 +103,7 @@ class FusionAccountingChatController(http.Controller): @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'): + if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Insufficient permissions to approve actions'} agent = request.env['fusion.accounting.agent'] results = [] @@ -119,7 +119,7 @@ class FusionAccountingChatController(http.Controller): @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'): + if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Insufficient permissions to reject actions'} agent = request.env['fusion.accounting.agent'] results = [] diff --git a/fusion_accounting_ai/data/tool_definitions.xml b/fusion_accounting_ai/data/tool_definitions.xml index 55a5cad9..ec81f941 100644 --- a/fusion_accounting_ai/data/tool_definitions.xml +++ b/fusion_accounting_ai/data/tool_definitions.xml @@ -25,7 +25,7 @@ bank_reconciliation 3 {"type": "object", "properties": {"statement_line_id": {"type": "integer", "description": "Bank statement line ID"}, "move_line_ids": {"type": "array", "items": {"type": "integer"}, "description": "Journal item IDs to match"}}, "required": ["statement_line_id", "move_line_ids"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager auto_reconcile_bank_lines @@ -34,7 +34,7 @@ bank_reconciliation 3 {"type": "object", "properties": {"company_id": {"type": "integer"}}} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager apply_reconcile_model @@ -43,7 +43,7 @@ bank_reconciliation 3 {"type": "object", "properties": {"model_id": {"type": "integer"}, "statement_line_id": {"type": "integer"}}, "required": ["model_id", "statement_line_id"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager unmatch_bank_line @@ -52,7 +52,7 @@ bank_reconciliation 3 {"type": "object", "properties": {"statement_line_id": {"type": "integer"}}, "required": ["statement_line_id"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager get_reconcile_suggestions @@ -119,7 +119,7 @@ hst_management 2 {"type": "object", "properties": {}} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager validate_tax_return @@ -128,7 +128,7 @@ hst_management 3 {"type": "object", "properties": {"return_id": {"type": "integer"}}, "required": ["return_id"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager @@ -163,7 +163,7 @@ accounts_receivable 2 {"type": "object", "properties": {"partner_id": {"type": "integer"}, "send_email": {"type": "boolean"}, "print_letter": {"type": "boolean"}, "email_subject": {"type": "string"}, "body": {"type": "string"}}, "required": ["partner_id"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager get_followup_report @@ -180,7 +180,7 @@ accounts_receivable 3 {"type": "object", "properties": {"move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["move_line_ids"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager get_unmatched_payments @@ -449,7 +449,7 @@ adp 3 {"type": "object", "properties": {"move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["move_line_ids"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager verify_adp_split @@ -483,7 +483,7 @@ adp 3 {"type": "object", "properties": {"invoices": {"type": "array", "items": {"type": "object", "properties": {"invoice_number": {"type": "string"}, "amount": {"type": "number"}}, "required": ["invoice_number", "amount"]}, "description": "List of invoices with number and payment amount"}, "payment_date": {"type": "string", "description": "Payment date from remittance (YYYY-MM-DD)"}, "journal_id": {"type": "integer", "description": "Bank journal ID (default 50 = Scotia Current)"}}, "required": ["invoices", "payment_date"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager @@ -542,7 +542,7 @@ reporting 2 {"type": "object", "properties": {"report_ref": {"type": "string"}, "format": {"type": "string", "enum": ["pdf", "xlsx"]}, "date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["report_ref"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager @@ -626,7 +626,7 @@ audit 2 {"type": "object", "properties": {"move_id": {"type": "integer"}, "flag": {"type": "string"}, "recommendation": {"type": "string"}}, "required": ["move_id"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager get_audit_status @@ -643,7 +643,7 @@ audit 2 {"type": "object", "properties": {"status_id": {"type": "integer"}, "status": {"type": "string", "enum": ["todo", "reviewed", "supervised", "anomaly"]}}, "required": ["status_id", "status"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager get_audit_trail @@ -686,7 +686,7 @@ payroll_management 3 {"type": "object", "properties": {"journal_id": {"type": "integer"}, "date": {"type": "string"}, "ref": {"type": "string"}, "lines": {"type": "array", "items": {"type": "object", "properties": {"account_id": {"type": "integer"}, "name": {"type": "string"}, "debit": {"type": "number"}, "credit": {"type": "number"}, "partner_id": {"type": "integer"}}}}}, "required": ["journal_id", "date", "lines"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager match_payroll_cheques @@ -695,7 +695,7 @@ payroll_management 3 {"type": "object", "properties": {"statement_line_id": {"type": "integer"}, "move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["statement_line_id", "move_line_ids"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager prepare_cra_payment @@ -704,7 +704,7 @@ payroll_management 3 {"type": "object", "properties": {"journal_id": {"type": "integer"}, "date": {"type": "string"}, "lines": {"type": "array"}}, "required": ["journal_id", "date", "lines"]} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager generate_t4 @@ -713,7 +713,7 @@ payroll_management 2 {"type": "object", "properties": {}} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager generate_roe @@ -722,7 +722,7 @@ payroll_management 2 {"type": "object", "properties": {}} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager get_payroll_cost_report @@ -823,7 +823,7 @@ bank_reconciliation 3 {"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Bank journal ID (default 50)"}, "line_ids": {"type": "array", "items": {"type": "integer"}, "description": "Optional: specific bank line IDs to reconcile. If empty, reconciles all matching payroll cheques."}}} - fusion_accounting.group_fusion_accounting_manager + fusion_accounting_core.group_fusion_accounting_manager diff --git a/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py b/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py index 409d3ac8..8386592f 100644 --- a/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py +++ b/fusion_accounting_ai/migrations/19.0.1.0.0/post-migration.py @@ -61,7 +61,31 @@ AI_NAME_LIKE = ( ) +# Group/category/privilege xml-ids that moved from 'fusion_accounting' to +# 'fusion_accounting_core' in Phase 0 (Task 16). Both _core and _ai +# post-migrations run this same UPDATE — whichever runs first wins, the other +# is a no-op. We reassign these here too so that if _ai happens to upgrade +# first (before _core's own post-migration has had a chance to run) the groups +# are still rehomed correctly. +CORE_SECURITY_NAMES = ( + 'module_category_fusion_accounting', + 'res_groups_privilege_fusion_accounting', + 'group_fusion_accounting_user', + 'group_fusion_accounting_manager', + 'group_fusion_accounting_admin', +) + + def migrate(cr, version): + # Step 0: Reassign security groups/category/privilege to fusion_accounting_core. + cr.execute(""" + UPDATE ir_model_data + SET module = 'fusion_accounting_core' + WHERE module = 'fusion_accounting' + AND name = ANY(%s) + """, (list(CORE_SECURITY_NAMES),)) + moved_to_core = cr.rowcount + # Step 1: Delete orphan rows that conflict with an already-existing row in # fusion_accounting_ai (data-load artifact). The new row is the survivor. cr.execute(""" @@ -76,7 +100,7 @@ def migrate(cr, version): """, (list(AI_MODEL_PREFIXES), list(AI_NAME_LIKE))) deleted_conflicts = cr.rowcount - # Step 2: Reassign the non-conflicting orphans. + # Step 2: Reassign the non-conflicting orphans to fusion_accounting_ai. cr.execute(""" UPDATE ir_model_data SET module = 'fusion_accounting_ai' @@ -86,12 +110,14 @@ def migrate(cr, version): OR name LIKE ANY(%s) ) """, (list(AI_MODEL_PREFIXES), list(AI_NAME_LIKE))) - moved = cr.rowcount + moved_to_ai = cr.rowcount _logger.info( - "fusion_accounting_ai post-migration: deleted %d conflicting orphans, " - "reassigned %d ir_model_data rows from module='fusion_accounting' " - "to module='fusion_accounting_ai'", + "fusion_accounting_ai post-migration: reassigned %d security rows to " + "fusion_accounting_core, deleted %d conflicting AI orphans, reassigned " + "%d ir_model_data rows from module='fusion_accounting' to " + "module='fusion_accounting_ai'", + moved_to_core, deleted_conflicts, - moved, + moved_to_ai, ) diff --git a/fusion_accounting_ai/security/fusion_accounting_ai_security.xml b/fusion_accounting_ai/security/fusion_accounting_ai_security.xml new file mode 100644 index 00000000..e3cc66cc --- /dev/null +++ b/fusion_accounting_ai/security/fusion_accounting_ai_security.xml @@ -0,0 +1,58 @@ + + + + + Fusion Session: Own Sessions + + [('user_id', '=', user.id)] + + + + + Fusion Session: All Sessions + + [(1, '=', 1)] + + + + + Fusion History: Own History + + [('session_id.user_id', '=', user.id)] + + + + + Fusion History: All History + + [(1, '=', 1)] + + + + + + Fusion Tool: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + Fusion Rule: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + Fusion History: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + + Fusion Session: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + diff --git a/fusion_accounting_ai/security/ir.model.access.csv b/fusion_accounting_ai/security/ir.model.access.csv index 81cbe5d6..441b9863 100644 --- a/fusion_accounting_ai/security/ir.model.access.csv +++ b/fusion_accounting_ai/security/ir.model.access.csv @@ -1,19 +1,19 @@ 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 -access_fusion_recurring_pattern_user,fusion.recurring.pattern.user,model_fusion_recurring_pattern,group_fusion_accounting_user,1,0,0,0 -access_fusion_recurring_pattern_manager,fusion.recurring.pattern.manager,model_fusion_recurring_pattern,group_fusion_accounting_manager,1,1,1,0 -access_fusion_recurring_pattern_admin,fusion.recurring.pattern.admin,model_fusion_recurring_pattern,group_fusion_accounting_admin,1,1,1,1 -access_fusion_vendor_profile_user,fusion.vendor.tax.profile.user,model_fusion_vendor_tax_profile,group_fusion_accounting_user,1,0,0,0 -access_fusion_vendor_profile_manager,fusion.vendor.tax.profile.manager,model_fusion_vendor_tax_profile,group_fusion_accounting_manager,1,1,1,0 -access_fusion_vendor_profile_admin,fusion.vendor.tax.profile.admin,model_fusion_vendor_tax_profile,group_fusion_accounting_admin,1,1,1,1 +access_fusion_session_user,fusion.accounting.session.user,model_fusion_accounting_session,fusion_accounting_core.group_fusion_accounting_user,1,1,1,0 +access_fusion_session_admin,fusion.accounting.session.admin,model_fusion_accounting_session,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_history_user,fusion.accounting.match.history.user,model_fusion_accounting_match_history,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 +access_fusion_history_manager,fusion.accounting.match.history.manager,model_fusion_accounting_match_history,fusion_accounting_core.group_fusion_accounting_manager,1,1,1,0 +access_fusion_history_admin,fusion.accounting.match.history.admin,model_fusion_accounting_match_history,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_rule_user,fusion.accounting.rule.user,model_fusion_accounting_rule,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 +access_fusion_rule_manager,fusion.accounting.rule.manager,model_fusion_accounting_rule,fusion_accounting_core.group_fusion_accounting_manager,1,1,1,0 +access_fusion_rule_admin,fusion.accounting.rule.admin,model_fusion_accounting_rule,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_tool_user,fusion.accounting.tool.user,model_fusion_accounting_tool,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 +access_fusion_tool_admin,fusion.accounting.tool.admin,model_fusion_accounting_tool,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_dashboard_user,fusion.accounting.dashboard.user,model_fusion_accounting_dashboard,fusion_accounting_core.group_fusion_accounting_user,1,1,1,1 +access_fusion_rule_wizard_manager,fusion.accounting.rule.wizard.manager,model_fusion_accounting_rule_wizard,fusion_accounting_core.group_fusion_accounting_manager,1,1,1,1 +access_fusion_recurring_pattern_user,fusion.recurring.pattern.user,model_fusion_recurring_pattern,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 +access_fusion_recurring_pattern_manager,fusion.recurring.pattern.manager,model_fusion_recurring_pattern,fusion_accounting_core.group_fusion_accounting_manager,1,1,1,0 +access_fusion_recurring_pattern_admin,fusion.recurring.pattern.admin,model_fusion_recurring_pattern,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_vendor_profile_user,fusion.vendor.tax.profile.user,model_fusion_vendor_tax_profile,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 +access_fusion_vendor_profile_manager,fusion.vendor.tax.profile.manager,model_fusion_vendor_tax_profile,fusion_accounting_core.group_fusion_accounting_manager,1,1,1,0 +access_fusion_vendor_profile_admin,fusion.vendor.tax.profile.admin,model_fusion_vendor_tax_profile,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting_ai/views/match_history_views.xml b/fusion_accounting_ai/views/match_history_views.xml index e8e52ae1..a1c38b2e 100644 --- a/fusion_accounting_ai/views/match_history_views.xml +++ b/fusion_accounting_ai/views/match_history_views.xml @@ -31,10 +31,10 @@
diff --git a/fusion_accounting_ai/views/menus.xml b/fusion_accounting_ai/views/menus.xml index 15cb292f..7a1dc508 100644 --- a/fusion_accounting_ai/views/menus.xml +++ b/fusion_accounting_ai/views/menus.xml @@ -5,7 +5,7 @@ name="Fusion AI" parent="accountant.menu_accounting" sequence="8" - groups="group_fusion_accounting_user"/> + groups="fusion_accounting_core.group_fusion_accounting_user"/> + groups="fusion_accounting_core.group_fusion_accounting_manager"/> + groups="fusion_accounting_core.group_fusion_accounting_manager"/> + groups="fusion_accounting_core.group_fusion_accounting_manager"/> + groups="fusion_accounting_core.group_fusion_accounting_admin"/> diff --git a/fusion_accounting_ai/views/rule_views.xml b/fusion_accounting_ai/views/rule_views.xml index 85af9d0e..60139a29 100644 --- a/fusion_accounting_ai/views/rule_views.xml +++ b/fusion_accounting_ai/views/rule_views.xml @@ -27,10 +27,10 @@
diff --git a/fusion_accounting_core/__manifest__.py b/fusion_accounting_core/__manifest__.py index 1d1f9de0..95479c45 100644 --- a/fusion_accounting_core/__manifest__.py +++ b/fusion_accounting_core/__manifest__.py @@ -24,6 +24,7 @@ Built by Nexa Systems Inc. 'maintainer': 'Nexa Systems Inc.', 'depends': ['account', 'mail'], 'data': [ + 'security/fusion_accounting_security.xml', 'security/ir.model.access.csv', ], 'installable': True, diff --git a/fusion_accounting_core/migrations/19.0.1.0.0/post-migration.py b/fusion_accounting_core/migrations/19.0.1.0.0/post-migration.py new file mode 100644 index 00000000..5564954a --- /dev/null +++ b/fusion_accounting_core/migrations/19.0.1.0.0/post-migration.py @@ -0,0 +1,48 @@ +"""Reassign security group/category/privilege xml-ids from the old module name. + +Pre-Phase-0, the three fusion security groups (user, manager, admin), the +module category and the privilege all lived in module='fusion_accounting'. +Post-Phase-0 (Task 16) they moved into module='fusion_accounting_core'. + +Odoo loads the XML from the new location on upgrade, but the existing +ir_model_data rows still reference the old module. This script rewrites them. + +Both fusion_accounting_core and fusion_accounting_ai ship an equivalent +UPDATE — whichever post-migration runs first wins the rehoming, the other +is a no-op. This redundancy protects the common case where the two modules +are upgraded in either order (as well as the case where only one is +installed in a given database). + +Idempotent: running it a second time matches zero rows. +""" + +import logging + +_logger = logging.getLogger(__name__) + + +CORE_SECURITY_NAMES = ( + 'module_category_fusion_accounting', + 'res_groups_privilege_fusion_accounting', + 'group_fusion_accounting_user', + 'group_fusion_accounting_manager', + 'group_fusion_accounting_admin', +) + + +def migrate(cr, version): + cr.execute( + """ + UPDATE ir_model_data + SET module = 'fusion_accounting_core' + WHERE module = 'fusion_accounting' + AND name = ANY(%s) + """, + (list(CORE_SECURITY_NAMES),), + ) + moved = cr.rowcount + _logger.info( + "fusion_accounting_core post-migration: reassigned %d security rows " + "from module='fusion_accounting' to module='fusion_accounting_core'", + moved, + ) diff --git a/fusion_accounting_core/security/fusion_accounting_security.xml b/fusion_accounting_core/security/fusion_accounting_security.xml new file mode 100644 index 00000000..59953d9c --- /dev/null +++ b/fusion_accounting_core/security/fusion_accounting_security.xml @@ -0,0 +1,46 @@ + + + + + Fusion Accounting + 25 + + + + + Fusion Accounting + + + + + + User + 10 + + + + + + + Manager + 20 + + + + + + + Administrator + 30 + + + + + + + + + + + + From 512467788b12d66a5eba4a4fac231c1141e135f1 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 00:29:33 -0400 Subject: [PATCH 26/33] fix(fusion_accounting_core): add pre-migration for security group rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migrations/19.0.1.0.0/post-migration.py | 29 +++++---- .../migrations/19.0.1.0.0/pre-migration.py | 62 +++++++++++++++++++ 2 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 fusion_accounting_core/migrations/19.0.1.0.0/pre-migration.py diff --git a/fusion_accounting_core/migrations/19.0.1.0.0/post-migration.py b/fusion_accounting_core/migrations/19.0.1.0.0/post-migration.py index 5564954a..9240b7c0 100644 --- a/fusion_accounting_core/migrations/19.0.1.0.0/post-migration.py +++ b/fusion_accounting_core/migrations/19.0.1.0.0/post-migration.py @@ -1,17 +1,15 @@ -"""Reassign security group/category/privilege xml-ids from the old module name. +"""Safety-net reassignment of security xml-ids to fusion_accounting_core. -Pre-Phase-0, the three fusion security groups (user, manager, admin), the -module category and the privilege all lived in module='fusion_accounting'. -Post-Phase-0 (Task 16) they moved into module='fusion_accounting_core'. +The actual rename lives in pre-migration.py — it MUST run before data-load +to avoid creating duplicate res.groups records and hitting the (module, +name) unique constraint on ir_model_data. This post-migration is a +belt-and-suspenders no-op for the common case: if pre-migration already +ran, this UPDATE matches zero rows. -Odoo loads the XML from the new location on upgrade, but the existing -ir_model_data rows still reference the old module. This script rewrites them. - -Both fusion_accounting_core and fusion_accounting_ai ship an equivalent -UPDATE — whichever post-migration runs first wins the rehoming, the other -is a no-op. This redundancy protects the common case where the two modules -are upgraded in either order (as well as the case where only one is -installed in a given database). +It also catches a rare edge case: fusion_accounting_ai.post-migration.py +runs an identical UPDATE to cover cross-module upgrade ordering, so both +modules redundantly ensure the rows land in the right module regardless +of which upgrade runs first. Idempotent: running it a second time matches zero rows. """ @@ -21,7 +19,7 @@ import logging _logger = logging.getLogger(__name__) -CORE_SECURITY_NAMES = ( +SECURITY_NAMES = ( 'module_category_fusion_accounting', 'res_groups_privilege_fusion_accounting', 'group_fusion_accounting_user', @@ -38,11 +36,12 @@ def migrate(cr, version): WHERE module = 'fusion_accounting' AND name = ANY(%s) """, - (list(CORE_SECURITY_NAMES),), + (list(SECURITY_NAMES),), ) moved = cr.rowcount _logger.info( "fusion_accounting_core post-migration: reassigned %d security rows " - "from module='fusion_accounting' to module='fusion_accounting_core'", + "from module='fusion_accounting' to module='fusion_accounting_core' " + "(usually zero; pre-migration already handled the rename)", moved, ) diff --git a/fusion_accounting_core/migrations/19.0.1.0.0/pre-migration.py b/fusion_accounting_core/migrations/19.0.1.0.0/pre-migration.py new file mode 100644 index 00000000..9970b7ae --- /dev/null +++ b/fusion_accounting_core/migrations/19.0.1.0.0/pre-migration.py @@ -0,0 +1,62 @@ +"""Rehome the fusion security xml-ids to fusion_accounting_core BEFORE data-load. + +Pre-Phase-0, the three fusion security groups (user, manager, admin), the +module category and the privilege all lived in module='fusion_accounting'. +Post-Phase-0 (Task 16) they moved into module='fusion_accounting_core'. + +Running this rename in pre-migration (rather than post-migration) is +essential: Odoo's XML data-load looks up records by (module, name) in +ir_model_data. If the old row still has module='fusion_accounting' when +data-load runs, Odoo will not find a match for +'fusion_accounting_core.group_fusion_accounting_user' and will CREATE a +brand-new res.groups record plus a new ir_model_data row. That leaves the +database with two groups per name: + +1. The ORIGINAL group (still tagged module='fusion_accounting') that every + existing user is linked to via res_groups_users_rel. +2. A FRESH empty group (newly tagged module='fusion_accounting_core'). + +The subsequent post-migration UPDATE...SET module='fusion_accounting_core' +would then violate the (module, name) unique constraint on ir_model_data +and the upgrade transaction would roll back. + +By renaming ir_model_data rows BEFORE data-load, Odoo finds the existing +row (now tagged fusion_accounting_core.*), UPDATEs the res.groups record +in place with the XML-defined values, and the user-group links are +preserved untouched. + +Idempotent: running this a second time matches zero rows. +""" + +import logging + +_logger = logging.getLogger(__name__) + + +SECURITY_NAMES = ( + 'module_category_fusion_accounting', + 'res_groups_privilege_fusion_accounting', + 'group_fusion_accounting_user', + 'group_fusion_accounting_manager', + 'group_fusion_accounting_admin', +) + + +def migrate(cr, version): + cr.execute( + """ + UPDATE ir_model_data + SET module = 'fusion_accounting_core' + WHERE module = 'fusion_accounting' + AND name = ANY(%s) + """, + (list(SECURITY_NAMES),), + ) + moved = cr.rowcount + _logger.info( + "fusion_accounting_core pre-migration: renamed %d security rows " + "from module='fusion_accounting' to module='fusion_accounting_core' " + "before data-load (idempotent; non-zero only on first upgrade from " + "pre-Phase-0)", + moved, + ) From db90b1ad5bd220f872fee3a578efe0bbf802c099 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 00:36:09 -0400 Subject: [PATCH 27/33] 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..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 --- .../models/__init__.py | 1 + .../models/ir_module_module.py | 72 +++++++++++++++++++ .../security/ir.model.access.csv | 1 + fusion_accounting_migration/tests/__init__.py | 1 + .../tests/test_safety_guard.py | 31 ++++++++ .../wizards/__init__.py | 1 + .../wizards/migration_wizard.py | 65 +++++++++++++++++ .../wizards/migration_wizard_views.xml | 26 ++++++- 8 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_migration/models/ir_module_module.py create mode 100644 fusion_accounting_migration/tests/test_safety_guard.py create mode 100644 fusion_accounting_migration/wizards/migration_wizard.py diff --git a/fusion_accounting_migration/models/__init__.py b/fusion_accounting_migration/models/__init__.py index e69de29b..8e2d2d1b 100644 --- a/fusion_accounting_migration/models/__init__.py +++ b/fusion_accounting_migration/models/__init__.py @@ -0,0 +1 @@ +from . import ir_module_module diff --git a/fusion_accounting_migration/models/ir_module_module.py b/fusion_accounting_migration/models/ir_module_module.py new file mode 100644 index 00000000..b5fccddf --- /dev/null +++ b/fusion_accounting_migration/models/ir_module_module.py @@ -0,0 +1,72 @@ +"""Safety guard: blocks Odoo Enterprise accounting uninstall until migration runs. + +For each Enterprise accounting module the user attempts to uninstall, the +guard checks an ir.config_parameter flag named: + + fusion_accounting.migration..completed + +If the flag is False/unset and the module is currently installed, the guard +raises UserError pointing the user to Settings -> Fusion Accounting -> +Migrate from Enterprise. + +The migration wizard sets the flag to True after a successful migration run +for that module. +""" + +from odoo import _, api, models +from odoo.exceptions import UserError + + +GUARDED_MODULES = ( + 'account_accountant', + 'account_reports', + 'accountant', + 'account_followup', + 'account_asset', + 'account_budget', + 'account_loans', +) + + +class IrModuleModule(models.Model): + _inherit = "ir.module.module" + + @api.model + def _fusion_check_uninstall_guard(self, module_names): + """Verify it's safe to uninstall the given modules. + + Returns True if all checks pass; raises UserError otherwise. + """ + Param = self.env['ir.config_parameter'].sudo() + for name in module_names: + if name not in GUARDED_MODULES: + continue + installed = self.sudo().search_count([ + ('name', '=', name), ('state', '=', 'installed'), + ]) + if not installed: + continue + flag_key = f'fusion_accounting.migration.{name}.completed' + if Param.get_param(flag_key, default='False').lower() != 'true': + raise UserError(_( + "Cannot uninstall %s: the Fusion Accounting migration " + "for this module has not run yet. Please open\n" + " Settings -> Fusion Accounting -> Migrate from Enterprise\n" + "and run the migration before uninstalling. Once the " + "migration has completed, the safety guard will allow " + "uninstall.\n\n" + "If you genuinely want to uninstall WITHOUT migrating " + "(data will be lost), set the parameter %s to True manually.", + name, flag_key, + )) + return True + + def button_immediate_uninstall(self): + """Override to invoke the safety guard before allowing uninstall.""" + self._fusion_check_uninstall_guard(self.mapped('name')) + return super().button_immediate_uninstall() + + def module_uninstall(self): + """Override the lower-level uninstall path too (CLI / API uninstall).""" + self._fusion_check_uninstall_guard(self.mapped('name')) + return super().module_uninstall() diff --git a/fusion_accounting_migration/security/ir.model.access.csv b/fusion_accounting_migration/security/ir.model.access.csv index 97dd8b91..420f6bb6 100644 --- a/fusion_accounting_migration/security/ir.model.access.csv +++ b/fusion_accounting_migration/security/ir.model.access.csv @@ -1 +1,2 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_migration_wizard_admin,fusion.migration.wizard admin,model_fusion_migration_wizard,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting_migration/tests/__init__.py b/fusion_accounting_migration/tests/__init__.py index e69de29b..184d2b90 100644 --- a/fusion_accounting_migration/tests/__init__.py +++ b/fusion_accounting_migration/tests/__init__.py @@ -0,0 +1 @@ +from . import test_safety_guard diff --git a/fusion_accounting_migration/tests/test_safety_guard.py b/fusion_accounting_migration/tests/test_safety_guard.py new file mode 100644 index 00000000..67532c33 --- /dev/null +++ b/fusion_accounting_migration/tests/test_safety_guard.py @@ -0,0 +1,31 @@ +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestSafetyGuard(TransactionCase): + """Verify the safety guard blocks Enterprise uninstall when migration hasn't run.""" + + def test_uninstall_not_blocked_when_migration_completed(self): + """If the per-module migration flag is set, uninstall is allowed.""" + self.env['ir.config_parameter'].sudo().set_param( + 'fusion_accounting.migration.account_accountant.completed', 'True' + ) + guard = self.env['ir.module.module']._fusion_check_uninstall_guard(['account_accountant']) + self.assertTrue(guard, "Guard should pass when migration flag is set") + + def test_uninstall_blocked_when_migration_pending(self): + """If account_accountant is installed and migration not run, raise.""" + self.env['ir.config_parameter'].sudo().set_param( + 'fusion_accounting.migration.account_accountant.completed', 'False' + ) + Module = self.env['ir.module.module'].sudo() + installed = Module.search_count([ + ('name', '=', 'account_accountant'), + ('state', '=', 'installed'), + ]) + if not installed: + self.skipTest("account_accountant not installed in this DB") + with self.assertRaises(UserError) as ctx: + Module._fusion_check_uninstall_guard(['account_accountant']) + self.assertIn('migration', str(ctx.exception).lower()) diff --git a/fusion_accounting_migration/wizards/__init__.py b/fusion_accounting_migration/wizards/__init__.py index e69de29b..0e67a0b3 100644 --- a/fusion_accounting_migration/wizards/__init__.py +++ b/fusion_accounting_migration/wizards/__init__.py @@ -0,0 +1 @@ +from . import migration_wizard diff --git a/fusion_accounting_migration/wizards/migration_wizard.py b/fusion_accounting_migration/wizards/migration_wizard.py new file mode 100644 index 00000000..cdde55da --- /dev/null +++ b/fusion_accounting_migration/wizards/migration_wizard.py @@ -0,0 +1,65 @@ +"""Migration wizard skeleton. + +Per-feature migration logic (account.asset -> fusion.asset, etc.) is added +by each fusion sub-module that replaces an Enterprise feature, by extending +this wizard via _inherit. + +Phase 0 ships the wizard with no migrations registered. Phase 1 will add +the bank-rec verification check. Phase 6 will add asset migration, etc. +""" + +from odoo import _, api, fields, models + + +class FusionMigrationWizard(models.TransientModel): + _name = "fusion.migration.wizard" + _description = "Migrate from Odoo Enterprise to Fusion Accounting" + + enterprise_modules_detected = fields.Char( + compute='_compute_detected', + string="Enterprise Modules Detected", + ) + notes = fields.Text(default=lambda self: self._default_notes()) + + def _default_notes(self): + return _( + "This wizard migrates data from Odoo Enterprise accounting modules " + "to Fusion Accounting tables. Run before uninstalling Enterprise. " + "After a successful run, each migrated module is marked complete " + "and the Enterprise uninstall safety guard will allow uninstall.\n\n" + "Phase 0 of the roadmap ships this wizard as a shell. As Phase 1, " + "Phase 5, Phase 6, etc. ship, each adds its own migration step here." + ) + + @api.depends_context('uid') + def _compute_detected(self): + Mod = self.env['ir.module.module'].sudo() + from ..models.ir_module_module import GUARDED_MODULES + installed = Mod.search([ + ('name', 'in', list(GUARDED_MODULES)), + ('state', '=', 'installed'), + ]) + for w in self: + w.enterprise_modules_detected = ', '.join(installed.mapped('name')) or _("None") + + def action_run_migration(self): + """Stub: Phase 0 has no migrations to run. + + Sub-modules extend this method to perform their per-module migration, + then set the corresponding fusion_accounting.migration..completed + config param to True. + """ + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'info', + 'title': _("Nothing to migrate (yet)"), + 'message': _( + "Phase 0 ships the migration framework but no per-feature " + "migrations are registered yet. Each fusion sub-module that " + "replaces an Enterprise feature (Phase 1+) will register its " + "own migration step here." + ), + }, + } diff --git a/fusion_accounting_migration/wizards/migration_wizard_views.xml b/fusion_accounting_migration/wizards/migration_wizard_views.xml index ccd0b26f..dd27259b 100644 --- a/fusion_accounting_migration/wizards/migration_wizard_views.xml +++ b/fusion_accounting_migration/wizards/migration_wizard_views.xml @@ -1,4 +1,28 @@ - + + fusion.migration.wizard.form + fusion.migration.wizard + +
+ + + + + + +
+
+
+
+
+ + + Migrate from Enterprise + fusion.migration.wizard + form + new +
From de71a61a8b4825cecb882a63324725e817ac6d40 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 00:51:32 -0400 Subject: [PATCH 28/33] 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 --- .../models/ir_module_module.py | 22 ++++++++++++++----- .../tests/test_safety_guard.py | 15 +++++++++++-- .../wizards/migration_wizard.py | 3 ++- .../wizards/migration_wizard_views.xml | 18 +++++++++++++++ 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/fusion_accounting_migration/models/ir_module_module.py b/fusion_accounting_migration/models/ir_module_module.py index b5fccddf..28968435 100644 --- a/fusion_accounting_migration/models/ir_module_module.py +++ b/fusion_accounting_migration/models/ir_module_module.py @@ -6,8 +6,8 @@ guard checks an ir.config_parameter flag named: fusion_accounting.migration..completed If the flag is False/unset and the module is currently installed, the guard -raises UserError pointing the user to Settings -> Fusion Accounting -> -Migrate from Enterprise. +raises UserError pointing the user to the top-level +Fusion Accounting -> Migrate from Enterprise menu. The migration wizard sets the flag to True after a successful migration run for that module. @@ -51,7 +51,7 @@ class IrModuleModule(models.Model): raise UserError(_( "Cannot uninstall %s: the Fusion Accounting migration " "for this module has not run yet. Please open\n" - " Settings -> Fusion Accounting -> Migrate from Enterprise\n" + " Fusion Accounting -> Migrate from Enterprise\n" "and run the migration before uninstalling. Once the " "migration has completed, the safety guard will allow " "uninstall.\n\n" @@ -62,11 +62,23 @@ class IrModuleModule(models.Model): return True def button_immediate_uninstall(self): - """Override to invoke the safety guard before allowing uninstall.""" + """Override to invoke the safety guard before allowing uninstall. + + Both this and ``module_uninstall`` below can fire in a single UI + uninstall call (button_immediate_uninstall -> module_uninstall). The + guard is a pure read + raise, so re-running it is idempotent: on the + happy path it just re-confirms; on the blocked path the first call + already raised and the second is never reached. + """ self._fusion_check_uninstall_guard(self.mapped('name')) return super().button_immediate_uninstall() def module_uninstall(self): - """Override the lower-level uninstall path too (CLI / API uninstall).""" + """Override the lower-level uninstall path too (CLI / API uninstall). + + See ``button_immediate_uninstall`` above -- both overrides may run in + the same UI uninstall; the guard is idempotent so double-invocation + is safe. + """ self._fusion_check_uninstall_guard(self.mapped('name')) return super().module_uninstall() diff --git a/fusion_accounting_migration/tests/test_safety_guard.py b/fusion_accounting_migration/tests/test_safety_guard.py index 67532c33..5f301664 100644 --- a/fusion_accounting_migration/tests/test_safety_guard.py +++ b/fusion_accounting_migration/tests/test_safety_guard.py @@ -7,11 +7,22 @@ class TestSafetyGuard(TransactionCase): """Verify the safety guard blocks Enterprise uninstall when migration hasn't run.""" def test_uninstall_not_blocked_when_migration_completed(self): - """If the per-module migration flag is set, uninstall is allowed.""" + """If the per-module migration flag is set, uninstall is allowed. + + Skip if account_accountant isn't installed -- otherwise the guard's + `if not installed: continue` short-circuit would make this test pass + vacuously without exercising the flag-check branch. + """ + Module = self.env['ir.module.module'].sudo() + if not Module.search_count([ + ('name', '=', 'account_accountant'), + ('state', '=', 'installed'), + ]): + self.skipTest("account_accountant not installed in this DB") self.env['ir.config_parameter'].sudo().set_param( 'fusion_accounting.migration.account_accountant.completed', 'True' ) - guard = self.env['ir.module.module']._fusion_check_uninstall_guard(['account_accountant']) + guard = Module._fusion_check_uninstall_guard(['account_accountant']) self.assertTrue(guard, "Guard should pass when migration flag is set") def test_uninstall_blocked_when_migration_pending(self): diff --git a/fusion_accounting_migration/wizards/migration_wizard.py b/fusion_accounting_migration/wizards/migration_wizard.py index cdde55da..7ca98363 100644 --- a/fusion_accounting_migration/wizards/migration_wizard.py +++ b/fusion_accounting_migration/wizards/migration_wizard.py @@ -10,6 +10,8 @@ the bank-rec verification check. Phase 6 will add asset migration, etc. from odoo import _, api, fields, models +from ..models.ir_module_module import GUARDED_MODULES + class FusionMigrationWizard(models.TransientModel): _name = "fusion.migration.wizard" @@ -34,7 +36,6 @@ class FusionMigrationWizard(models.TransientModel): @api.depends_context('uid') def _compute_detected(self): Mod = self.env['ir.module.module'].sudo() - from ..models.ir_module_module import GUARDED_MODULES installed = Mod.search([ ('name', 'in', list(GUARDED_MODULES)), ('state', '=', 'installed'), diff --git a/fusion_accounting_migration/wizards/migration_wizard_views.xml b/fusion_accounting_migration/wizards/migration_wizard_views.xml index dd27259b..17446e9e 100644 --- a/fusion_accounting_migration/wizards/migration_wizard_views.xml +++ b/fusion_accounting_migration/wizards/migration_wizard_views.xml @@ -25,4 +25,22 @@ form new + + + + From 6731260cde4fdc3812efa9766bbd8626440afd37 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 00:56:49 -0400 Subject: [PATCH 29/33] feat(fusion_accounting): add check_odoo_diff.sh for cross-version upgrade ritual Made-with: Cursor --- fusion_accounting/tools/README.md | 37 ++++++++++ fusion_accounting/tools/check_odoo_diff.sh | 83 ++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 fusion_accounting/tools/README.md create mode 100755 fusion_accounting/tools/check_odoo_diff.sh diff --git a/fusion_accounting/tools/README.md b/fusion_accounting/tools/README.md new file mode 100644 index 00000000..6f34c68d --- /dev/null +++ b/fusion_accounting/tools/README.md @@ -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 [] + +### 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-/` (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 diff --git a/fusion_accounting/tools/check_odoo_diff.sh b/fusion_accounting/tools/check_odoo_diff.sh new file mode 100755 index 00000000..06eb5a9f --- /dev/null +++ b/fusion_accounting/tools/check_odoo_diff.sh @@ -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 [] +# +# 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 []}" +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 From 51b26838b92cf27f64baf07776e46d6f4d0afc57 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 01:09:42 -0400 Subject: [PATCH 30/33] 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 --- fusion_accounting/CLAUDE.md | 270 +++--------------- fusion_accounting/README.md | 38 +++ fusion_accounting_ai/CLAUDE.md | 272 +++++++++++++++++++ fusion_accounting_ai/README.md | 31 +++ fusion_accounting_ai/UPGRADE_NOTES.md | 22 ++ fusion_accounting_core/CLAUDE.md | 25 ++ fusion_accounting_core/README.md | 39 +++ fusion_accounting_core/UPGRADE_NOTES.md | 28 ++ fusion_accounting_migration/CLAUDE.md | 20 ++ fusion_accounting_migration/README.md | 22 ++ fusion_accounting_migration/UPGRADE_NOTES.md | 10 + 11 files changed, 541 insertions(+), 236 deletions(-) create mode 100644 fusion_accounting/README.md create mode 100644 fusion_accounting_ai/CLAUDE.md create mode 100644 fusion_accounting_ai/README.md create mode 100644 fusion_accounting_ai/UPGRADE_NOTES.md create mode 100644 fusion_accounting_core/CLAUDE.md create mode 100644 fusion_accounting_core/README.md create mode 100644 fusion_accounting_core/UPGRADE_NOTES.md create mode 100644 fusion_accounting_migration/CLAUDE.md create mode 100644 fusion_accounting_migration/README.md create mode 100644 fusion_accounting_migration/UPGRADE_NOTES.md diff --git a/fusion_accounting/CLAUDE.md b/fusion_accounting/CLAUDE.md index 04826be6..8b2834c7 100644 --- a/fusion_accounting/CLAUDE.md +++ b/fusion_accounting/CLAUDE.md @@ -1,248 +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 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 -│ ├── 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/ 3 groups (User/Manager/Admin), record rules, ACLs -├── data/ 88 tool definitions, 2 default rules, 2 crons, 1 sequence -├── tests/ API integration tests -└── 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 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 - -## Odoo 19 Gotchas (Learned the Hard Way) - -### Search Views -- NO `string` attribute on `` element -- NO `string` attribute on `` element inside search views -- Group-by filters MUST have `domain="[]"` attribute -- Add `` before `` 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 `
`, 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 , 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/` -- **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" -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" -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" -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 \"\"" - -# 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';\"" -``` - -## 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 - -## Controller Endpoints -| Route | Auth | Purpose | +| Sub-module | Phase | 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 | +| `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 | -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'`). +## Sub-modules (planned) -## 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 | +Per the roadmap design at `docs/superpowers/specs/2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md`: -## 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-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 | -**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) +## Roadmap and plans -## 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 +- 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) -### 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 +## Tooling -## 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 missing on `fusion.accounting.session` — add if multi-company usage is needed +- `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. diff --git a/fusion_accounting/README.md b/fusion_accounting/README.md new file mode 100644 index 00000000..ff69a1de --- /dev/null +++ b/fusion_accounting/README.md @@ -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 -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 --no-http <_via_fusion`, `_via_enterprise`, `_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 `` element +- NO `string` attribute on `` element inside search views +- Group-by filters MUST have `domain="[]"` attribute +- Add `` before `` 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 `
`, 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 , 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 \"\"" + +# 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) diff --git a/fusion_accounting_ai/README.md b/fusion_accounting_ai/README.md new file mode 100644 index 00000000..9b5e6f24 --- /dev/null +++ b/fusion_accounting_ai/README.md @@ -0,0 +1,31 @@ +# Fusion Accounting AI + +Conversational AI co-pilot for Odoo Accounting using Claude or GPT. + +## What it does + +Embeds an AI agent in the Odoo Accounting menu. Users chat with the AI, which +calls into Odoo via tool-functions (read journal entries, find unreconciled +bank lines, draft follow-ups, generate audit reports, etc.). Tier 3 actions +(financial writes) require user approval via in-chat approval cards. + +## Install profiles + +This module works on three install profiles: + +1. **Pure Community + this module** — AI uses pure Community searches via the + data-adapter `_via_community` paths. Reduced functionality (no rich reports, + no Enterprise bank-rec features) but all read tools work. +2. **Community + this module + fusion native sub-modules** (recommended target) — + adapters route to fusion bank rec / fusion reports / etc. Full functionality. +3. **Community + Enterprise + this module** (legacy) — adapters route to Enterprise + APIs. Most functionality available; some Enterprise-specific UI integration + (e.g. live cursor in bank-rec widget) not supported. + +## Configuration + +Settings -> Fusion Accounting AI -> set API keys for Claude (default) and/or OpenAI. + +## Troubleshooting + +See `CLAUDE.md` in this module for known Odoo 19 gotchas. diff --git a/fusion_accounting_ai/UPGRADE_NOTES.md b/fusion_accounting_ai/UPGRADE_NOTES.md new file mode 100644 index 00000000..ef6f6500 --- /dev/null +++ b/fusion_accounting_ai/UPGRADE_NOTES.md @@ -0,0 +1,22 @@ +# UPGRADE_NOTES — fusion_accounting_ai + +## V19.0.1.0.0 (initial — Phase 0 split-out) + +### Origin +Code originally lived in `fusion_accounting/` (the original AI module). Split out +into this sub-module during Phase 0 of the Enterprise Takeover Roadmap. + +### Additions in this version +- `services/data_adapters/` — DataAdapter base + 4 adapters (bank_rec, reports, followup, assets) +- `services/tools/*.py` — every tool that called Enterprise-specific APIs refactored through adapters +- `migrations/19.0.1.0.0/post-migration.py` — reassigns ir_model_data ownership from old module name +- Multi-company record rule on `fusion.accounting.session` (was missing pre-Phase-0 per CLAUDE.md Known Issues) + +### Removed from manifest deps +- `account_accountant` (was hard dep) +- `account_reports` (was hard dep) +- `account_followup` (was hard dep) +- `mail` (now inherited via `fusion_accounting_core`) + +Replaced with: `fusion_accounting_core` (Community-only). Runtime detection of +Enterprise modules via the data adapter pattern. diff --git a/fusion_accounting_core/CLAUDE.md b/fusion_accounting_core/CLAUDE.md new file mode 100644 index 00000000..be2c8e44 --- /dev/null +++ b/fusion_accounting_core/CLAUDE.md @@ -0,0 +1,25 @@ +# fusion_accounting_core — Cursor / Claude Context + +## Purpose +Foundation for the Fusion Accounting sub-module suite. Owns: +- Three security groups (User / Manager / Admin) shared across all sub-modules +- Shared-field-ownership declarations on `account.move` and `account.reconcile.model` +- Runtime Enterprise-detection helper: `env['ir.module.module']._fusion_is_enterprise_accounting_installed()` + +## What lives here +- `models/account_move.py` — declares Enterprise-extension fields with identical + schemas / relation tables. Pure schema-preservation; no business logic. +- `models/account_reconcile_model.py` — same pattern for `created_automatically` +- `models/ir_module_module.py` — Enterprise-detection helpers +- `security/fusion_accounting_security.xml` — privilege + 3 groups + auto-assignment + +## Critical rules +- NEVER add business logic to the shared-field models (account_move.py here). + Logic belongs in the feature sub-module that owns it (e.g. fusion_accounting_bank_rec). +- NEVER rename the relation tables for shared M2Ms. They must match Enterprise verbatim + for the dual-ownership pattern to work. +- Shared fields here have NO defaults beyond what Enterprise sets. The point is preservation. + +## Cross-references +- Parent design: `fusion_accounting/docs/superpowers/specs/2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md` (Section 3) +- Workspace conventions: `/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md` diff --git a/fusion_accounting_core/README.md b/fusion_accounting_core/README.md new file mode 100644 index 00000000..d58ad06a --- /dev/null +++ b/fusion_accounting_core/README.md @@ -0,0 +1,39 @@ +# Fusion Accounting Core + +Foundation module for the Fusion Accounting suite. + +## What it does + +- Defines three security groups: Fusion Accounting User / Manager / Administrator +- Auto-promotes Odoo `account.group_account_user` -> Fusion User and + `account.group_account_manager` -> Fusion Admin +- Declares schema-preservation fields on `account.move` and `account.reconcile.model` + so that Enterprise extension fields (deferred revenue links, signing user, etc.) + survive an Enterprise uninstall +- Exposes the helper `env['ir.module.module']._fusion_is_enterprise_accounting_installed()` + +## Install + +This module never installs alone. Install `fusion_accounting` (the meta-module) +or any of the feature sub-modules — they all depend on `fusion_accounting_core`. + +## Uninstall + +Uninstalling `fusion_accounting_core` will remove the security groups and the +schema-preservation fields. If Enterprise is also installed, uninstalling +`fusion_accounting_core` will cause Odoo to consider the deferred / signing +fields owned only by Enterprise — which is the original Enterprise-only state +(no data loss, just back to Enterprise-controlled schema). + +## Troubleshooting + +If users are missing the "Fusion Accounting" privilege section in user settings +after install, the `implied_ids` mechanism only fires for newly-added users. +Backfill existing users via SQL: + + INSERT INTO res_groups_users_rel (gid, uid) + SELECT g.res_id, gu.uid + FROM res_groups_users_rel gu + JOIN ir_model_data g ON g.module = 'fusion_accounting_core' AND g.name = 'group_fusion_accounting_user' + JOIN ir_model_data ag ON ag.module = 'account' AND ag.name = 'group_account_user' AND gu.gid = ag.res_id + ON CONFLICT DO NOTHING; diff --git a/fusion_accounting_core/UPGRADE_NOTES.md b/fusion_accounting_core/UPGRADE_NOTES.md new file mode 100644 index 00000000..d761c8ec --- /dev/null +++ b/fusion_accounting_core/UPGRADE_NOTES.md @@ -0,0 +1,28 @@ +# UPGRADE_NOTES — fusion_accounting_core + +## V19.0.1.0.0 (initial — Phase 0) + +### Reference sources +- `RePackaged-Odoo/accounting/account_accountant/models/account_move.py` (Enterprise extension fields read for schema match) +- `RePackaged-Odoo/accounting/account_accountant/models/account_reconcile_model.py` (same) + +### Mirror-zone files (none in _core — _core has no Mirror zone) + +### Abstract-zone files (all of _core is abstract) +- `models/account_move.py` +- `models/account_reconcile_model.py` +- `models/ir_module_module.py` + +### Intentional deltas from Odoo +- Shared-field declarations have NO compute methods, NO @api decorators beyond + basic field types. Enterprise's account_move.py adds compute methods and + business logic; we deliberately do not duplicate them. When Enterprise is + installed, its compute methods run; when it's not, the fields are simply + unused (until a fusion sub-module decides to own that behavior). + +### Migrations +- `migrations/19.0.1.0.0/pre-migration.py` — rehome fusion security xml-ids + from module='fusion_accounting' to module='fusion_accounting_core' BEFORE + data-load (avoids unique-constraint crash on upgrade from pre-Phase-0) +- `migrations/19.0.1.0.0/post-migration.py` — idempotent safety-net for the + same rehome (zero-op if pre-migration already ran) diff --git a/fusion_accounting_migration/CLAUDE.md b/fusion_accounting_migration/CLAUDE.md new file mode 100644 index 00000000..069e3b04 --- /dev/null +++ b/fusion_accounting_migration/CLAUDE.md @@ -0,0 +1,20 @@ +# fusion_accounting_migration — Cursor / Claude Context + +## Purpose +Transitional sub-module that helps clients move from Odoo Enterprise accounting +to Odoo Community + Fusion Accounting without losing data. + +## What it does +- Safety guard: blocks uninstall of Enterprise accounting modules until the + migration wizard has run (per-module flag in ir.config_parameter) +- Migration wizard: shell that other fusion sub-modules extend with per-feature + migration logic (Phase 0 ships only the shell) + +## Critical +- The safety guard overrides `button_immediate_uninstall` AND `module_uninstall` + on `ir.module.module`. Both paths must be guarded — UI uninstall, CLI uninstall, + and API uninstall all go through one or the other. +- Each fusion feature sub-module that replaces an Enterprise feature MUST extend + the migration wizard's `action_run_migration` to add its own migration step + AND set the corresponding `fusion_accounting.migration..completed` + flag to True after running. diff --git a/fusion_accounting_migration/README.md b/fusion_accounting_migration/README.md new file mode 100644 index 00000000..3cd02237 --- /dev/null +++ b/fusion_accounting_migration/README.md @@ -0,0 +1,22 @@ +# Fusion Accounting Migration + +Transitional helper for moving clients from Odoo Enterprise to Community + Fusion. + +## When to use + +Install this module ONCE per client during the Enterprise->Fusion switchover. +After the switchover is complete and the client is comfortable on Community, +this module can be uninstalled. + +## How it works + +1. Install fusion_accounting (the meta-module) — pulls in this module +2. Open Fusion Accounting -> Migrate from Enterprise (top-level menu) +3. Wizard shows which Enterprise modules are detected and what migrations are available +4. Run migration; wizard reports counts and warnings +5. Uninstall Enterprise modules in dep-safe order (the safety guard prevents premature uninstall) + +## Override the safety guard + +If you need to uninstall an Enterprise module WITHOUT migrating (data will be lost), +set `fusion_accounting.migration..completed` to True in System Parameters. diff --git a/fusion_accounting_migration/UPGRADE_NOTES.md b/fusion_accounting_migration/UPGRADE_NOTES.md new file mode 100644 index 00000000..eafcafa7 --- /dev/null +++ b/fusion_accounting_migration/UPGRADE_NOTES.md @@ -0,0 +1,10 @@ +# UPGRADE_NOTES — fusion_accounting_migration + +## V19.0.1.0.0 (initial — Phase 0) + +Skeleton: safety guard + wizard shell. No per-feature migration logic registered yet. + +Added by future phases: +- Phase 1: bank-rec migration (verifies account.partial.reconcile rows are intact; sets `account_accountant.completed` flag) +- Phase 5: account_followup migration +- Phase 6: account_asset, account_budget migration From f0577c1788ebe42be0c9cd7c42b431686222407e Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 01:18:36 -0400 Subject: [PATCH 31/33] ci(fusion_accounting): add CI workflow scaffold + Phase 0 deferral note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/fusion_accounting_ci.yml | 79 +++++++++++++++++++ .../specs/2026-04-18-ci-deferred.md | 41 ++++++++++ 2 files changed, 120 insertions(+) create mode 100644 .gitea/workflows/fusion_accounting_ci.yml create mode 100644 fusion_accounting/docs/superpowers/specs/2026-04-18-ci-deferred.md diff --git a/.gitea/workflows/fusion_accounting_ci.yml b/.gitea/workflows/fusion_accounting_ci.yml new file mode 100644 index 00000000..6b77aa97 --- /dev/null +++ b/.gitea/workflows/fusion_accounting_ci.yml @@ -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 /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 diff --git a/fusion_accounting/docs/superpowers/specs/2026-04-18-ci-deferred.md b/fusion_accounting/docs/superpowers/specs/2026-04-18-ci-deferred.md new file mode 100644 index 00000000..1785d590 --- /dev/null +++ b/fusion_accounting/docs/superpowers/specs/2026-04-18-ci-deferred.md @@ -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 \ + --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 From 92f93de47b84b75d1db9e91a90a661636cd3d942 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 01:26:02 -0400 Subject: [PATCH 32/33] chore(receiving): port received_qty auto-prefill from live entech to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../models/fp_receiving.py | 4 + fusion_plating/scripts/fp_e2e_full.py | 323 +++++++++++++++++ fusion_plating/scripts/fp_e2e_human.py | 327 ++++++++++++++++++ 3 files changed, 654 insertions(+) create mode 100644 fusion_plating/scripts/fp_e2e_full.py create mode 100644 fusion_plating/scripts/fp_e2e_human.py diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py index 4350feed..ba877e1b 100644 --- a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py +++ b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py @@ -88,6 +88,10 @@ class FpReceiving(models.Model): for vals in vals_list: if vals.get('name', 'New') == 'New': vals['name'] = self.env['ir.sequence'].next_by_code('fp.receiving') or 'New' + # Prefill received_qty from expected_qty so the operator only + # has to confirm or correct — the common case is qty matches. + if vals.get('expected_qty') and not vals.get('received_qty'): + vals['received_qty'] = vals['expected_qty'] return super().create(vals_list) # ------------------------------------------------------------------------- diff --git a/fusion_plating/scripts/fp_e2e_full.py b/fusion_plating/scripts/fp_e2e_full.py new file mode 100644 index 00000000..299685cd --- /dev/null +++ b/fusion_plating/scripts/fp_e2e_full.py @@ -0,0 +1,323 @@ +# -*- coding: utf-8 -*- +# Full quote→payment E2E. Run via: odoo shell --no-http -d admin +# < scripts/fp_e2e_full.py +# +# Hits every workflow step and prints PASS/FAIL per check. + +from datetime import datetime, timedelta +import base64 +import sys + +env = env # noqa injected by odoo shell + + +def banner(txt): + print(f"\n{'='*72}\n {txt}\n{'='*72}") + + +def check(label, ok, detail=''): + flag = 'PASS' if ok else 'FAIL' + print(f" [{flag}] {label}{(' — ' + detail) if detail else ''}") + return ok + + +RESULTS = [] + + +def expect(label, ok, detail=''): + RESULTS.append((label, ok)) + check(label, ok, detail) + + +# ===================================================================== +banner('PHASE 1 — Quote configurator + PO → client_order_ref') +# ===================================================================== + +# Fresh customer per run so we can verify automations on a clean slate. +stamp = datetime.now().strftime('%y%m%d-%H%M%S') +cust_name = f'E2E Customer {stamp}' +customer = env['res.partner'].create({ + 'name': cust_name, + 'company_type': 'company', + 'email': f'e2e-{stamp}@example.com', + 'street': '100 King St W', + 'city': 'Toronto', 'zip': 'M5X 1A1', + 'country_id': env.ref('base.ca').id, +}) +expect('customer created', bool(customer)) + +# Use first available coating + part catalog. The seeder has plenty. +coating = env['fp.coating.config'].search([], limit=1) +part_cat = env['fp.part.catalog'].search([], limit=1) +expect('coating config available', bool(coating)) +expect('part catalog available', bool(part_cat)) + +po_number = f'PO-E2E-{stamp}' +quote = env['fp.quote.configurator'].create({ + 'partner_id': customer.id, + 'part_catalog_id': part_cat.id, + 'coating_config_id': coating.id, + 'quantity': 25, + 'po_number_preliminary': po_number, +}) +expect('quote configurator created', bool(quote), quote.name or '') + +# Trigger price calc + create the SO. +quote.action_calculate_price() +expect('price calculated', quote.unit_price > 0, + f'unit ${quote.unit_price:.2f}') +result = quote.action_create_quotation() +so_id = result.get('res_id') if isinstance(result, dict) else False +so = env['sale.order'].browse(so_id) +expect('SO created from quote', bool(so), so.name or '') +expect('client_order_ref carries PO', so.client_order_ref == po_number, + f'got "{so.client_order_ref}"') + +# ===================================================================== +banner('PHASE 2 — SO confirm → MO + portal job + WOs') +# ===================================================================== + +so.action_confirm() +expect('SO confirmed', so.state == 'sale') + +# Auto-MO from our hook (FP-SERVICE bypasses native sale_mrp routing). +mo = env['mrp.production'].search([('origin', '=', so.name)], limit=1) +expect('MO auto-created from SO', bool(mo), mo.name or '') + +# Portal job auto-created on MO confirm. +if mo.state == 'draft': + mo.action_confirm() +job = env['fusion.plating.portal.job'].search( + [('production_id', '=', mo.id)], limit=1, +) +expect('portal job auto-created', bool(job), job.name or '') +expect('portal job linked back to MO', + mo.x_fc_portal_job_id.id == job.id if job else False) + +# Recipe + WOs +recipe = env['fusion.plating.process.node'].search( + [('node_type', '=', 'recipe')], limit=1) +if recipe and not mo.x_fc_recipe_id: + mo.x_fc_recipe_id = recipe.id + mo._generate_workorders_from_recipe() +expect('recipe assigned', bool(mo.x_fc_recipe_id), + mo.x_fc_recipe_id.name if mo.x_fc_recipe_id else '') +expect('work orders generated', len(mo.workorder_ids) > 0, + f'{len(mo.workorder_ids)} WOs') + +# ===================================================================== +banner('PHASE 3 — Receiving with auto-prefilled qty') +# ===================================================================== + +Receiving = env['fp.receiving'] +recv = Receiving.create({ + 'partner_id': customer.id, + 'sale_order_id': so.id, + 'received_date': fields.Datetime.now(), + 'line_ids': [(0, 0, { + 'description': 'E2E test parts', + 'expected_qty': 25, + })], +}) +expect('receiving record created', bool(recv), recv.name or '') +line = recv.line_ids[:1] +expect('received_qty auto-prefilled from expected_qty', + line.received_qty == line.expected_qty, + f'expected={line.expected_qty} received={line.received_qty}') +if recv.state == 'draft': + try: + recv.action_confirm() + except Exception as e: + print(f" [info] receiving confirm: {e}") + +# ===================================================================== +banner('PHASE 4 — Execute work orders + audit timing') +# ===================================================================== + +# Use the first operator we can find. Auto-promotion test only needs +# one completion for verification — N is configurable per role. +operator = env['hr.employee'].search([('active', '=', True)], limit=1) +expect('operator available', bool(operator), operator.name or '') + +ok_starts = ok_finishes = 0 +for wo in mo.workorder_ids: + try: + if wo.state in ('pending', 'waiting', 'ready'): + wo.button_start() + ok_starts += 1 + if wo.state == 'progress': + wo.button_finish() + ok_finishes += 1 + except Exception as e: + print(f" [info] WO {wo.name}: {e}") +expect('WOs started', ok_starts > 0, f'{ok_starts} starts') +expect('WOs finished', ok_finishes > 0, f'{ok_finishes} finishes') + +# Timer audit fields populated? +audited = mo.workorder_ids.filtered( + lambda w: getattr(w, 'x_fc_started_at_utc', False) + and getattr(w, 'x_fc_finished_at_utc', False) +) +expect('timer audit fields populated', + len(audited) > 0, f'{len(audited)}/{len(mo.workorder_ids)}') + +# Mark MO done. Triggers cert + delivery automations. +try: + mo.button_mark_done() +except Exception as e: + print(f" [info] mark_done: {e}") + # Best-effort: bypass produced-qty wizard + try: + mo.qty_producing = mo.product_qty + mo._action_done() + except Exception as e2: + print(f" [info] _action_done: {e2}") +expect('MO state = done', mo.state == 'done', mo.state) + +# ===================================================================== +banner('PHASE 5 — Certificates (CoC issued + rich PDF, no thickness dup)') +# ===================================================================== + +certs = env['fp.certificate'].search([('production_id', '=', mo.id)]) +coc = certs.filtered(lambda c: c.certificate_type == 'coc')[:1] +thickness = certs.filtered(lambda c: c.certificate_type == 'thickness_report') +expect('CoC certificate created', bool(coc), coc.name if coc else '') +expect('thickness cert SKIPPED (CoC includes thickness)', + len(thickness) == 0, f'thickness count={len(thickness)}') +if coc: + expect('CoC auto-issued (state != draft)', + coc.state != 'draft', f'state={coc.state}') + expect('CoC has attachment', bool(coc.attachment_id), + coc.attachment_id.name if coc.attachment_id else 'none') + if coc.attachment_id: + size_kb = len(base64.b64decode(coc.attachment_id.datas)) / 1024 + # Bare-header PDF is ~30KB, rich PDF is 200KB+. + expect('CoC PDF is rich (>= 100KB, not bare header)', + size_kb >= 100, f'{size_kb:.1f}KB') + expect('CoC filename uses customer slug', + cust_name.replace(' ', '_') in coc.attachment_id.name + or 'CoC' in coc.attachment_id.name, + coc.attachment_id.name) + +# ===================================================================== +banner('PHASE 6 — Delivery auto-prefilled + chain of custody') +# ===================================================================== + +dlv = env['fusion.plating.delivery'].search( + [('partner_id', '=', customer.id)], order='id desc', limit=1) +expect('delivery auto-created', bool(dlv), dlv.name or '') +if dlv: + expect('scheduled_date prefilled', bool(dlv.scheduled_date), + str(dlv.scheduled_date or 'none')) + # CoC cross-link from MO done hook. + expect('CoC attached to delivery', + bool(dlv.coc_attachment_id), + dlv.coc_attachment_id.name if dlv.coc_attachment_id else 'none') + # Walk through delivery states. + try: + if dlv.state == 'draft': + dlv.action_schedule() + if dlv.state == 'scheduled': + dlv.action_start_delivery() + if dlv.state == 'en_route': + dlv.action_mark_delivered() + except Exception as e: + print(f" [info] delivery transitions: {e}") + expect('delivery delivered', dlv.state == 'delivered', dlv.state) + coc_logs = env['fusion.plating.chain.of.custody'].search( + [('delivery_id', '=', dlv.id)]) + expect('chain of custody logged', len(coc_logs) > 0, + f'{len(coc_logs)} entries') + +# Portal job should now show shipped. +job = env['fusion.plating.portal.job'].browse(job.id) +expect('portal job state advanced to shipped/complete', + job.state in ('shipped', 'complete'), job.state) + +# ===================================================================== +banner('PHASE 7 — Invoice creation + post + body has lines') +# ===================================================================== + +# Let Odoo's standard invoice flow handle it. +try: + inv_act = so._create_invoices() + inv = inv_act if hasattr(inv_act, '_name') else env['account.move'].browse( + inv_act.get('res_id') if isinstance(inv_act, dict) else inv_act + ) +except Exception as e: + print(f" [info] _create_invoices: {e}") + inv = env['account.move'].search( + [('invoice_origin', '=', so.name)], limit=1) +expect('invoice created', bool(inv), inv.name or '') + +if inv: + inv.invoice_date = fields.Date.today() + try: + inv.action_post() + except Exception as e: + print(f" [info] action_post: {e}") + expect('invoice posted', inv.state == 'posted', inv.state) + # Render the new invoice PDF and confirm body has product lines + # (Odoo 19 display_type='product' fix). + report = env.ref('fusion_plating_reports.action_report_fp_invoice_portrait', + raise_if_not_found=False) + if report: + try: + pdf, _ext = report.with_context(force_report_rendering=True + )._render_qweb_pdf(report.report_name, [inv.id]) + kb = len(pdf) / 1024 + expect('invoice PDF body is non-empty (>= 50KB)', + kb >= 50, f'{kb:.1f}KB') + except Exception as e: + print(f" [info] invoice render: {e}") + expect('invoice PDF rendered', False, str(e)) + + # Portal job should now be complete. + expect('portal job state = complete', + job.state == 'complete', job.state) + +# ===================================================================== +banner('PHASE 8 — Notification log + email attachments') +# ===================================================================== + +logs = env['fp.notification.log'].search( + [('sale_order_id', '=', so.id)]) +events = logs.mapped('trigger_event') +expect('notification log entries written', + len(logs) > 0, f'{len(logs)} entries: {events}') +expect('SO confirmed notification fired', + 'so_confirmed' in events, + 'present' if 'so_confirmed' in events else 'missing') +# Shipping email should carry the CoC attachment we generated. +shipping_logs = logs.filtered(lambda l: l.trigger_event == 'shipped') +if shipping_logs: + sl = shipping_logs[:1] + expect('shipping email logged', + sl.status == 'sent', sl.status) + if sl.attachment_names: + expect('shipping email has CoC attachment', + 'CoC' in (sl.attachment_names or '') + or '.pdf' in (sl.attachment_names or '').lower(), + sl.attachment_names) + +# ===================================================================== +banner('SUMMARY') +# ===================================================================== + +passed = sum(1 for _, ok in RESULTS if ok) +failed = sum(1 for _, ok in RESULTS if not ok) +print(f'\n {passed} PASS / {failed} FAIL out of {len(RESULTS)} checks') +print(f' customer: {customer.name}') +print(f' SO: {so.name}') +print(f' MO: {mo.name}') +print(f' job: {job.name}') +print(f' delivery: {dlv.name if dlv else "(none)"}') +print(f' invoice: {inv.name if inv else "(none)"}') +print(f' CoC cert: {coc.name if coc else "(none)"}') +if failed: + print('\n FAILURES:') + for label, ok in RESULTS: + if not ok: + print(f' - {label}') + +env.cr.commit() diff --git a/fusion_plating/scripts/fp_e2e_human.py b/fusion_plating/scripts/fp_e2e_human.py new file mode 100644 index 00000000..505df683 --- /dev/null +++ b/fusion_plating/scripts/fp_e2e_human.py @@ -0,0 +1,327 @@ +# -*- coding: utf-8 -*- +# Human-style E2E walkthrough. Run via: +# cat fp_e2e_human.py | odoo shell -c /etc/odoo/odoo.conf -d admin --no-http +# Each STEP is narrated as a person clicking through the UI. + +from datetime import datetime, timedelta +import base64 + +env = env # noqa injected by odoo shell +from odoo import fields # noqa + + +def step(num, who, action): + bar = '═' * 70 + print(f'\n{bar}\n STEP {num} — {who}\n → {action}\n{bar}') + + +def show(label, value): + print(f' {label:<28} {value}') + + +def hr(): + print(' ' + '─' * 64) + + +# ───────────────────────────────────────────────────────────────────── +# Setup: pick a generic existing customer? No — fresh customer so we +# can prove the full chain of automations on a clean slate. +# ───────────────────────────────────────────────────────────────────── +stamp = datetime.now().strftime('%y%m%d-%H%M%S') + +# ===================================================================== +step(1, 'CUSTOMER (portal)', 'Submits an RFQ via the customer portal') +# ===================================================================== + +# A new contact appears the moment a portal user fills the RFQ form. +# In production a portal session would do this; for this walkthrough +# we'll create the partner + RFQ as if the portal accepted it. +customer = env['res.partner'].create({ + 'name': f'Acme Aerospace {stamp}', + 'company_type': 'company', + 'email': f'orders-{stamp}@acmeaero.example', + 'phone': '+1-416-555-0142', + 'street': '88 Queen St E', + 'city': 'Toronto', 'zip': 'M5C 1A8', + 'country_id': env.ref('base.ca').id, +}) +show('new contact', f'{customer.name} (id={customer.id})') + +rfq = env['fusion.plating.quote.request'].create({ + 'partner_id': customer.id, + 'contact_name': 'Sandra Kim', + 'contact_email': customer.email, + 'company_name': customer.name, + 'part_description': '

25 stainless brackets, AMS 2404, ~50µin ENP.

', + 'quantity': 25, + 'state': 'new', +}) +show('RFQ created', f'{rfq.name} (state={rfq.state})') +show('inbox alert', 'Sales sees a new RFQ on Plating > Sales > Quote Requests') + +# ===================================================================== +step(2, 'SALES (estimator)', + 'Reviews RFQ + builds a configurator quote with PO# + customer price') +# ===================================================================== + +coating = env['fp.coating.config'].search([], limit=1) +part_cat = env['fp.part.catalog'].search([], limit=1) +show('coating template', coating.name if coating else '(none)') +show('part catalog', part_cat.name if part_cat else '(none)') + +po_number = f'PO-ACME-{stamp}' +quote = env['fp.quote.configurator'].create({ + 'partner_id': customer.id, + 'part_catalog_id': part_cat.id if part_cat else False, + 'coating_config_id': coating.id if coating else False, + 'quantity': 25, + 'po_number_preliminary': po_number, + 'estimator_override_price': 1875.00, +}) +show('quote session', f'{quote.name} (state={quote.state})') +show('estimator override', f'${quote.estimator_override_price:,.2f}') +show('calculated price', f'${quote.calculated_price:,.2f} ({quote.currency_id.name})') +show('PO# entered', po_number) + +# Sales clicks "Create Quotation" — this is the SO. +result = quote.action_create_quotation() +so = env['sale.order'].browse(result.get('res_id')) +show('SO drafted', f'{so.name} (state={so.state})') +show('SO total', f'${so.amount_total:,.2f}') +show('SO has PO# (client_order_ref)', so.client_order_ref or '(empty)') +show('SO links back to RFQ origin', so.origin or '(empty)') + +# ===================================================================== +step(3, 'CUSTOMER', + 'Reviews the quote PDF, signs / accepts → SO confirmed') +# ===================================================================== + +# Render the quote PDF the customer would receive. +quote_report = env.ref( + 'fusion_plating_reports.action_report_fp_sale_portrait', + raise_if_not_found=False) +if quote_report: + pdf, _e = quote_report.with_context(force_report_rendering=True + )._render_qweb_pdf(quote_report.report_name, [so.id]) + show('quote PDF size', f'{len(pdf)/1024:.1f} KB (body must be non-empty)') + +so.action_confirm() +show('SO confirmed', f'state={so.state}') + +# Auto-MO from our SO-confirm hook. +mo = env['mrp.production'].search([('origin', '=', so.name)], limit=1) +show('MO auto-created', f'{mo.name} (state={mo.state})' if mo else '(MISSING)') + +if mo and mo.state == 'draft': + mo.action_confirm() + show('MO confirmed', f'state={mo.state}') + +job = mo.x_fc_portal_job_id if mo else env['fusion.plating.portal.job'] +show('portal job auto-created', + f'{job.name} (state={job.state})' if job else '(MISSING)') + +# ===================================================================== +step(4, 'RECEIVING (warehouse)', + 'Customer parts arrive — count + log + auto-prefill') +# ===================================================================== + +recv = env['fp.receiving'].create({ + 'partner_id': customer.id, + 'sale_order_id': so.id, + 'received_date': fields.Datetime.now(), + 'expected_qty': 25, + 'line_ids': [(0, 0, { + 'description': '25 stainless brackets, in 2 kraft boxes', + 'expected_qty': 25, + 'received_qty': 25, + })], +}) +show('receiving record', f'{recv.name} (state={recv.state})') +show('expected qty (header)', str(recv.expected_qty)) +show('received qty (header)', f'{recv.received_qty} (auto-prefilled from expected_qty)') +show('matches expected', 'YES' if recv.received_qty == recv.expected_qty else 'NO') + +# Walk through the inspection lifecycle as the receiver would. +try: + recv.action_start_inspection() + show(' → inspection started', f'state={recv.state}') + recv.action_accept() + show(' → parts accepted', f'state={recv.state}') +except Exception as e: + print(f' [info] inspection: {e}') + +# ===================================================================== +step(5, 'PLANNER', + 'Assigns recipe + generates work orders from process tree') +# ===================================================================== + +recipe = env['fusion.plating.process.node'].search( + [('node_type', '=', 'recipe')], limit=1) +show('recipe template', recipe.name if recipe else '(none)') + +if mo and recipe and not mo.x_fc_recipe_id: + mo.x_fc_recipe_id = recipe.id + mo._generate_workorders_from_recipe() + +show('WOs generated', f'{len(mo.workorder_ids)} work orders') +for i, wo in enumerate(mo.workorder_ids[:5], 1): + show(f' WO {i}', f'{wo.name} @ {wo.workcenter_id.name}') +if len(mo.workorder_ids) > 5: + show(' …', f'and {len(mo.workorder_ids) - 5} more') + +# ===================================================================== +step(6, 'OPERATOR (shop floor)', + 'Clocks in, starts each WO, finishes when the bath is done') +# ===================================================================== + +ok_starts = ok_finishes = 0 +for wo in mo.workorder_ids: + try: + if wo.state in ('pending', 'waiting', 'ready'): + wo.button_start() + ok_starts += 1 + if wo.state == 'progress': + wo.button_finish() + ok_finishes += 1 + except Exception as e: + print(f' [info] WO {wo.name}: {e}') + +show('WOs started', ok_starts) +show('WOs finished', ok_finishes) +total_dur = sum(mo.workorder_ids.mapped('duration')) +show('total time logged on WOs', f'{total_dur:.1f} min (Odoo native time tracking)') + +# ===================================================================== +step(7, 'MANAGER', + 'Marks MO done → triggers CoC cert + delivery auto-create') +# ===================================================================== + +try: + mo.button_mark_done() +except Exception as e: + print(f' [info] mark_done: {e} — falling back to _action_done') + try: + mo.qty_producing = mo.product_qty + mo._action_done() + except Exception as e2: + print(f' [info] _action_done: {e2}') + +show('MO state', mo.state) +show('parts location', mo.x_fc_current_location) + +certs = env['fp.certificate'].search([('production_id', '=', mo.id)]) +coc = certs.filtered(lambda c: c.certificate_type == 'coc')[:1] +thickness = certs.filtered(lambda c: c.certificate_type == 'thickness_report') +show('CoC cert', f'{coc.name} (state={coc.state})' if coc else '(MISSING)') +show('thickness cert', f'count={len(thickness)} (expected 0 — CoC includes it)') +if coc and coc.attachment_id: + kb = len(base64.b64decode(coc.attachment_id.datas)) / 1024 + show('CoC PDF', f'{coc.attachment_id.name} → {kb:.1f} KB') + show('PDF is rich (>=100 KB, not bare header)', + 'YES' if kb >= 100 else 'NO') + +# ===================================================================== +step(8, 'SHIPPER (dispatcher + driver)', + 'Schedules → driver picks up → marks delivered') +# ===================================================================== + +dlv = env['fusion.plating.delivery'].search( + [('partner_id', '=', customer.id)], order='id desc', limit=1) +show('delivery auto-created', f'{dlv.name} (state={dlv.state})' if dlv else '(MISSING)') +if dlv: + show(' scheduled date prefilled', str(dlv.scheduled_date or '(empty)')) + show(' driver prefilled', + dlv.assigned_driver_id.name if dlv.assigned_driver_id else '(none)') + show(' CoC cross-linked to delivery', + dlv.coc_attachment_id.name if dlv.coc_attachment_id else '(none)') + + # Walk through the lifecycle as the driver / dispatcher would. + try: + if dlv.state == 'draft': dlv.action_schedule(); show(' → scheduled', dlv.state) + if dlv.state == 'scheduled': dlv.action_start_route(); show(' → en route', dlv.state) + if dlv.state == 'en_route': dlv.action_mark_delivered(); show(' → delivered', dlv.state) + except Exception as e: + print(f' [info] delivery transitions: {e}') + + coc_logs = env['fusion.plating.chain.of.custody'].search( + [('delivery_id', '=', dlv.id)]) + show('chain-of-custody entries', len(coc_logs)) + +# Portal job should have moved to shipped. +job = env['fusion.plating.portal.job'].browse(job.id) +show('portal job state', job.state) + +# ===================================================================== +step(9, 'ACCOUNTING', + 'Creates + posts invoice (does NOT register payment — ' + 'real customer pays through bank/Stripe)') +# ===================================================================== + +try: + inv_act = so._create_invoices() + inv = inv_act if hasattr(inv_act, '_name') else env['account.move'].browse( + inv_act.get('res_id') if isinstance(inv_act, dict) else inv_act) +except Exception as e: + print(f' [info] _create_invoices: {e}') + inv = env['account.move'].search( + [('invoice_origin', '=', so.name)], limit=1) + +show('invoice created', f'{inv.name} (state={inv.state})' if inv else '(MISSING)') +if inv: + inv.invoice_date = fields.Date.today() + try: + inv.action_post() + except Exception as e: + print(f' [info] action_post: {e}') + show('invoice posted', f'state={inv.state}, payment_state={inv.payment_state}') + show('total', f'${inv.amount_total:,.2f}') + show('payment_state explanation', + '"not_paid" is correct — accountant has not yet registered any payment') + + inv_report = env.ref( + 'fusion_plating_reports.action_report_fp_invoice_portrait', + raise_if_not_found=False) + if inv_report: + try: + pdf, _e = inv_report.with_context(force_report_rendering=True + )._render_qweb_pdf(inv_report.report_name, [inv.id]) + kb = len(pdf) / 1024 + show('invoice PDF size', f'{kb:.1f} KB') + show('PDF body has line items (>=50 KB, not empty)', + 'YES' if kb >= 50 else 'NO') + except Exception as e: + print(f' [info] invoice render: {e}') + +show('portal job state (after invoice)', + env['fusion.plating.portal.job'].browse(job.id).state) + +# ===================================================================== +step(10, 'AUDIT', + 'What the system logged for this customer') +# ===================================================================== + +logs = env['fp.notification.log'].search( + [('sale_order_id', '=', so.id)], order='create_date') +show('notification log entries', len(logs)) +for l in logs: + show(f' • {l.trigger_event}', + f'{l.status} | to {l.recipient_email or "(no email)"} | ' + f'attached: {l.attachment_names or "(none)"}') + +print('\n ════════════════════════════════════════════════════════════════════') +print(' END-TO-END SUMMARY') +print(' ════════════════════════════════════════════════════════════════════') +show('customer', customer.name) +show('RFQ', rfq.name) +show('SO', f'{so.name} (PO: {so.client_order_ref})') +show('MO', f'{mo.name} → {mo.state}') +show('receiving', recv.name) +show('CoC cert', coc.name if coc else '(none)') +show('delivery', f'{dlv.name} → {dlv.state}' if dlv else '(none)') +show('invoice', f'{inv.name} → posted={inv.state == "posted"}, ' + f'paid={inv.payment_state == "paid"}' if inv else '(none)') +show('portal job', f'{job.name} → final state: ' + f'{env["fusion.plating.portal.job"].browse(job.id).state}') + +env.cr.commit() +print('\n Changes committed. Order completed end-to-end.\n') From d7cc334c98608f83e1afc4008bfab8eee448ccfd Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 01:29:22 -0400 Subject: [PATCH 33/33] docs(fusion_accounting): record Phase 0 smoke test results Made-with: Cursor --- .../2026-04-18-phase-0-foundation-plan.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/fusion_accounting/docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md b/fusion_accounting/docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md index 74526a77..4f1ff2a5 100644 --- a/fusion_accounting/docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md +++ b/fusion_accounting/docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md @@ -3734,3 +3734,41 @@ Expected: both tags listed (`fusion_accounting/pre-phase-0` and `fusion_accounti ## What Comes After Phase 0 Phase 1 — Bank Reconciliation. Brainstorm in a new session, produce its own design doc and implementation plan. The Phase 0 BankRecAdapter `_via_fusion` path becomes meaningful when Phase 1 ships `fusion.bank.rec.widget`. + +--- + +## Phase 0 Smoke Test Results — 2026-04-18 + +Host: `odoo-westin` (container `odoo-dev-app`, DB `westin-v19`, Odoo 19, Enterprise installed alongside). + +### Deploy +- Clean redeploy: removed and re-copied all four modules (`fusion_accounting`, `fusion_accounting_core`, `fusion_accounting_ai`, `fusion_accounting_migration`) into `/mnt/extra-addons/` on the container. +- Meta-module upgrade (`odoo -u fusion_accounting --stop-after-init --no-http`): exit 0, all four modules `installed` in `ir_module_module`. Only pre-existing unrelated warnings (studio, fusion_claims label collisions, docutils, `_sql_constraints` deprecations on third-party modules). + +### Test suite results +- Command: `odoo --test-tags post_install --stop-after-init --no-http -u fusion_accounting_core,fusion_accounting_ai,fusion_accounting_migration` +- Exit code: **0** +- Per-test `Starting …` lines observed (odoo.tests INFO handler): **23 tests** + - `fusion_accounting_core` — 7 tests: `TestEnterpriseDetection` ×2, `TestSharedFieldOwnership` ×5 + - `fusion_accounting_ai` — 14 tests: `TestDataAdapterBase` ×2, `TestBankRecAdapter` ×1, `TestReportsAdapter` ×4, `TestFollowupAdapter` ×4, `TestAssetsAdapter` ×1, `TestPostMigration` ×2 + - `fusion_accounting_migration` — 2 tests: `TestSafetyGuard` ×2 +- Result: **23 PASS, 0 FAIL, 0 ERROR, 0 SKIP** +- No `AssertionError` / `Traceback` / `FAILED` lines in the log. +- Odoo's `odoo.tests.stats` reports slightly higher per-module counts (ai: 26, core: 11, migration: 4) because Odoo also counts its own implicit per-module sanity checks (XML validation, etc.) beyond our explicit `TestCase` methods; all non-explicit tests also passed since exit code is 0 and no failure lines appear. + +### Verification spot-checks +- **Migration wizard menu (6a)**: present — `ir_ui_menu` contains both `Fusion Accounting` (id 2802, root) and `Migrate from Enterprise` (id 2803, child of 2802). Ten total fusion menus registered across `fusion_accounting_ai` (8) and `fusion_accounting_migration` (2). +- **AI module actions (6b)**: 8 actions registered under `module='fusion_accounting_ai'` — `action_fusion_session`, `action_fusion_history`, `action_fusion_rule`, `action_fusion_dashboard`, `action_vendor_tax_profiles`, `action_recurring_patterns`, `action_fusion_rule_wizard`, `action_report_fusion_audit`. +- **Security groups (6c)**: three groups present in `fusion_accounting_core` — `Administrator`, `Manager`, `User`, each with `0` users (expected for a fresh install with no user assignments yet). +- **Shared-field columns on `account_move` (6d)**: + - `signing_user` (integer, FK to `res_users`) — physically present, owned by `fusion_accounting_core` ✓ + - `payment_state_before_switch` (character varying) — physically present, owned by `fusion_accounting_core` ✓ + - `deferred_move_ids` / `deferred_original_move_ids` — both present via m2m relation table `account_move_deferred_rel` with columns `original_move_id` / `deferred_move_id` (matches Enterprise's table name; test `test_deferred_relation_table_name_matches_enterprise` passes) ✓ + - `deferred_entry_type` — exists in the ORM (`ir_model_fields.store='f'`) but no local column, because Enterprise's `account_asset` (installed on this DB: `account_accountant`, `account_asset`, `account_reports` all `installed`) currently owns the physical storage. This is the intended dual-ownership design from Task 17 — fusion_accounting_core declares a stub so the field survives Enterprise uninstall; the `TestSharedFieldOwnership.test_account_move_deferred_fields_exist` test passed and confirmed the field is in `Move._fields`. + +### Deferred +- **Task 18** (empirical Enterprise-uninstall verification test): deferred pending environment provisioning decision. Requires a dedicated scratch DB where we can actually uninstall Enterprise without disturbing the productive westin-v19 tenant. Tracked in `fusion_accounting/docs/superpowers/plans/2026-04-18-ci-deferred.md` (or equivalent follow-up note). The shared-field design is validated in principle by Tasks 17+21 and the `TestSharedFieldOwnership` suite; Task 18 adds the "actually uninstall, confirm nothing collapses" live check. + +### Phase 0 Status: **COMPLETE** (pending Task 18 empirical test) + +Ready to proceed to Phase 1 (Bank Reconciliation) — brainstorming session + its own design doc + implementation plan.