9 Commits

Author SHA1 Message Date
gsinghpal
8217bb0ff6 fix(fusion_accounting_reports): expose dynamic OWL reports as menu items
User reported that after Enterprise uninstall, clicking 'Reports' opened
PDF statements instead of the dynamic Fusion report viewer. Root cause:
the OWL ReportViewer (registered as view_type='fusion_reports') was only
reachable via the period-picker WIZARD; no menu items used the OWL view
directly. Plus the JS service ignored report_code, so even within the
viewer, all PnL-typed reports rendered the canonical P&L line_specs.

Changes:

JS layer
- reports_service.js: runReport now accepts and forwards reportCode;
  state tracks currentReportCode so re-runs after period/comparison
  changes preserve the variant.
- report_viewer.js: reads default_report_code (and default_comparison)
  from the action context.
- period_filter.js: passes the cached reportCode on date changes;
  clears it when the user picks a different report_type.

Backend
- New fusion_accounting_reports/views/report_actions.xml with 11
  dedicated ir.actions.act_window records, one per built-in report
  (P&L, Balance Sheet, Trial Balance, GL, Cash Flow, Executive Summary,
  Annual Statements, Aged Receivable, Aged Payable, Partner Ledger,
  Tax Summary). Each opens view_mode='fusion_reports' with the
  appropriate default_report_type + default_report_code context.
- views/menu_views.xml: each report now gets its own menu item
  directly under Accounting > Reporting (sequence 10-40), matching
  Enterprise's flat structure. Custom Period wizard, XLSX export and
  Anomaly browser collected under a 'Tools' sub-group at the bottom.
- fusion_accounting_l10n_ca: adds menu items for 'Profit and Loss
  (Canada)' and 'Balance Sheet (Canada)' as siblings, plus a 'Tax
  Returns (CA)' configuration menu.

Verified live on westin-v19:
- pnl rendering 3 rows, cash_flow 9, executive_summary 7,
  annual_statements 5, ca_profit_loss 9 \u2014 each report now renders
  its own line_specs correctly.
- Reporting menu shows 14 Fusion report entries + Tools group.
- 136/136 reports + l10n_ca tests pass.

Version bumps: reports 19.0.1.1.1, l10n_ca 19.0.1.1.0.

Made-with: Cursor
2026-04-20 01:11:48 -04:00
gsinghpal
867b5f71a1 fix(fusion_accounting): unified Accounting menu under one root, hide migration when Enterprise gone
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
User reported two UX problems after the Enterprise uninstall:
1. Each Fusion sub-module showed up as its own standalone app in the
   launcher (Bank Reconciliation, Financial Reports, Asset Management,
   Customer Follow-ups, Fusion AI). Should look like ONE Accounting app.
2. Clicking the 'Fusion Accounting' app still opened the migration
   wizard even though Enterprise had been uninstalled and there was
   nothing to migrate.

Fix:
- Move all Fusion sub-module roots under the Community account.menu_finance
  hierarchy:
    * Bank Reconciliation \u2192 Accounting > Bank Reconciliation
    * Asset Management    \u2192 Accounting > Asset Management
    * Financial Reports   \u2192 Reporting > Financial Reports
    * Follow-ups          \u2192 Customers > Follow-ups
    * Fusion AI           \u2192 Configuration > Fusion AI
    * Migrate from Ent.   \u2192 Configuration > Migrate from Enterprise
- Rename Community's 'Invoicing' top-level menu to 'Accounting' (what
  Enterprise's accountant module did). Set the Fusion icon on it. This
  rename lives in the meta-module so it only fires when the full suite
  is installed.
- Add second computed group 'group_fusion_show_when_enterprise_present'
  (inverse of the existing 'absent' group). Migration menus are gated
  by this group, so they auto-hide once Enterprise is uninstalled.
- _fusion_recompute_coexistence_group now maintains both groups in lockstep.
- Meta-module now also depends on l10n_ca, hr_payroll, ocr, documents
  (the Phase 6/7 sub-modules) for one-click full-suite install.
- Fusion AI menu's old parent ('accountant.menu_accounting') was deleted
  with the Enterprise uninstall \u2014 reparented under Configuration.

Result: single 'Accounting' top-level menu containing the standard
V19 Community structure (Dashboard / Customers / Vendors / Accounting /
Reporting / Configuration), with all Fusion features slotted into the
appropriate sub-section. Verified live on westin-v19: 6 separate
Fusion top-level menus collapsed to 1; coexistence groups recomputed
(absent=10 users, present=0 users); 604/604 tests pass.

Version bump: all touched modules \u2192 19.0.1.1.0.

Made-with: Cursor
2026-04-20 01:04:49 -04:00
gsinghpal
bee5ba4d3f fix(plating): UAT-caught UX annoyances + lurking bugs
Five fixes from the end-to-end UAT debrief:

1. Menu discoverability (HIGH)
   Added a prominent "+ New Direct Order" button in the Sale Orders
   list header toolbar (class=btn-primary, display=always). The
   existing menuitem at Plating > Sales > New Direct Order was
   buried in a submenu that didn't always expand; the toolbar
   button is a guaranteed entry point from the most common screen.

2. Escape/X destroys wizard state (HIGH)
   Added a prominent info banner at the top of the wizard form:
   "Changes are not saved until you click Create & Confirm Order.
   Closing this window (Esc or X) discards your entries." The
   Cancel button now has confirm="Discard this order? All header
   data and line items will be lost." so the intentional-cancel
   path also prompts.

3. Shell/cron crash in _fp_auto_create_mo (MEDIUM)
   bridge_mrp/models/sale_order.py:232-264 used _() inside list
   comprehensions to format the internal chatter summary of newly
   created / adopted MOs. _() resolves language from env.context,
   which is empty in odoo-shell and cron contexts — triggering a
   translate.get_text_alias crash AFTER the MOs had been created.
   These strings are internal audit log text, not user-facing UI;
   dropped the _() wrappers so the message builds safely from any
   context. Same for the per-group error-message on savepoint
   rollback.

4. Misleading "100%" margin (MEDIUM)
   x_fc_margin_percent displayed 100% on every SO because the cost
   rollup from fp.coating.config.unit_cost isn't populated yet.
   Added x_fc_margin_available Boolean (True only when at least
   one line's coating has a non-zero unit_cost). The SO Plating
   tab now hides the margin numbers when margin_available=False
   and shows an inline muted note: "Margin n/a — coating cost
   rollup not yet populated on any line's treatment."

5. Account Hold banner too loud (LOW)
   fusion_plating_invoicing was injecting a full-height danger
   alert above every SO header. Slimmed it to a one-line compact
   alert with icon: "Account Hold — SO confirmation, invoicing
   and shipping are blocked for non-managers." Half the vertical
   footprint, less visual competition with the Plating chip bar.

Verified via UAT on S00071.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:03:26 -04:00
gsinghpal
068a654c2b fix(fusion_accounting_bank_rec): test factory adapts to V19 Community semantics
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
After Enterprise's account_accountant is uninstalled,
account.bank.statement.journal_id reverts to its V19 Community definition
\u2014 a read-only computed field derived from line_ids.journal_id. Direct
writes are silently dropped (which is what was happening: 55 tests
errored with 'null value in column journal_id' because the test's
statement had no journal, and the line factory was reading
statement.journal_id (False) and passing that to the line create).

Fix:
- make_bank_statement now bootstraps the statement with one zero-amount
  line carrying journal_id, so the computed journal_id resolves correctly.
- make_bank_line no longer routes journal through the statement \u2014
  journal_id is set directly on the line (which is V19 Community's
  intended path; lines can exist standalone without a statement).

This is a test-only change; runtime behaviour is unchanged. Real users
creating bank lines via the UI already use the correct path.

Made-with: Cursor
2026-04-20 00:52:02 -04:00
gsinghpal
71f39c8d33 feat(fusion_accounting_documents): Documents app <-> invoice bridge
Replaces Enterprise's documents_account with a Fusion-native bridge.
When a PDF/image lands in the Documents app, users can convert it
into a draft vendor bill via a wizard that copies the document's
binary onto the new account.move and posts a chatter note linking
back to the source document.

Adds:
- documents.document.move_id (Many2one to the linked invoice)
- documents.document.is_invoice_candidate (computed; True for
  unlinked PDF/image binaries)
- documents.document.action_create_invoice() opens the wizard
- account.move.source_document_ids reverse linkage + statinfo button
- fusion.create.invoice.from.document.wizard (TransientModel + form)
- ir.actions.server bound to documents.document so the workflow
  appears in the kanban/list Actions menu (the Documents app has
  no regular form view to inherit from in v19)

The wizard:
- defaults to the company's first purchase journal
- supports vendor bill or vendor credit note
- copies the source attachment onto the new move
- posts a chatter note linking back
- marks the document linked so it stops appearing as a candidate

Auto-installs when documents + fusion_accounting_core are both
present. 8 unit tests cover the candidate flag, wizard happy path,
attachment copy, reverse linkage, already-linked guard, non-PDF
guard, and credit-note creation.

Made-with: Cursor
2026-04-20 00:34:50 -04:00
gsinghpal
125f48377a feat(fusion_accounting_ocr): pluggable OCR for vendor bills
Replaces Enterprise's account_invoice_extract with a Fusion-native pipeline:

Stage 1 (text extraction): Tesseract OCRs the bill attachment via
pytesseract + pdf2image. Pluggable OCRProvider adapter pattern allows
future Mindee / Google Document AI / Ollama-vision backends.

Stage 2 (field parsing): The fusion_accounting_ai LLMProvider reads the
raw OCR text and returns structured invoice fields (vendor, invoice
number, dates, amounts, line items) as JSON.

Draft invoice fields are auto-populated for empty-only fields (never
overwriting user-entered data). Vendor matching by name against
res.partner with supplier_rank > 0.

Adds:
- account.move.ocr_state (selection: not_requested/pending/processing/
  done/failed/manual)
- account.move.ocr_raw_text, ocr_extracted_data (Json), ocr_backend,
  ocr_confidence
- fusion.ocr.log (audit trail per OCR run)
- res.company.fusion_ocr_enabled / fusion_ocr_default_backend / auto_run
- /fusion/ocr/request_for_invoice JSON-RPC endpoint

Backend availability detected at runtime via OCRProvider.is_available()
classmethods. Tesseract 5.3.4 + pytesseract 0.3.13 + pdf2image 1.17.0
are installed in the container.

Tests: 13 (TesseractAdapter availability + image OCR; flow tests for
draft autofill, no-attachment guard, customer-invoice guard, ref-not-
overwritten; field parser empty/clean-json/markdown-fence/bad-JSON/
provider-exception). All pass on westin-v19 OrbStack VM.

Made-with: Cursor
2026-04-20 00:32:50 -04:00
gsinghpal
a730942d24 feat(fusion_accounting_hr_payroll): payroll -> GL bridge
Replaces Enterprise's hr_payroll_account with a Fusion-native bridge:
- Adds account_debit / account_credit / fusion_analytic_account_id /
  not_computed_in_net to hr.salary.rule (company-dependent GL mapping)
- Adds move_id + move_state + journal_id + _fusion_create_account_move
  to hr.payslip (validated payslip -> balanced account.move)
- Adds move_id + move_state + action_open_move to hr.payslip.run
- Adds journal_id (company-dependent) to hr.payroll.structure
- Adds is_payroll_journal flag to account.journal
- Adds payslip_ids / payslip_count + action_open_payslip on account.move
- Adds payslip_id reverse link on account.move.line
- Adds move_line_id reverse link on hr.payslip.line
- Adds fusion_payroll_journal_id + fusion_payroll_auto_post to res.company
  (with res.config.settings exposure)

Coexistence: detects Enterprise hr_payroll_account at runtime via
ir.module.module and yields move creation to it while both modules are
installed, so payslips do not get duplicate entries. Once the Enterprise
module is uninstalled, this module owns the bridge.

Auto-installs whenever both hr_payroll and fusion_accounting_core are
present on the database.

10 smoke tests verifying field surface + bridge entrypoints all pass on
westin-v19. Full payslip-to-move integration test deferred (needs
seeded payroll structure).

Removes Westin's last payroll-accounting dependency on Enterprise's
accountant umbrella module (Phase 6b of the Fusion Accounting suite).

Made-with: Cursor
2026-04-20 00:18:08 -04:00
gsinghpal
aab4b5e958 feat(fusion_accounting_l10n_ca): Canadian reports + tax return tracking
Replaces Enterprise's l10n_ca_reports with Fusion-native equivalents:
- ca_balance_sheet, ca_profit_loss as fusion.report definitions
- fusion.tax.return model for GST/HST/PST/T4/T5018 filing tracking
- Auto-installs when l10n_ca + fusion_accounting_reports both present

Removes Westin's last Canadian-compliance dependency on Enterprise's
account_reports.

Made-with: Cursor
2026-04-20 00:12:59 -04:00
gsinghpal
c8ca37099b refactor(reports): move SO Acknowledgement into fusion_plating_reports with house style
D7 template was originally in fusion_plating_configurator with a
Bootstrap-only look-and-feel that didn't match the other Fusion
Plating reports. Re-styled and relocated:

- Moved to fusion_plating_reports/report/report_fp_so_acknowledgement.xml
  alongside sale / work-order / job-traveller / invoice templates.
- Uses fp_portrait_styles (company primary colour for headers, .bordered
  tables, .info-header row, .totals-table, .highlight-box, .sig-box /
  .sig-line / .small-muted).
- Layout now mirrors report_fp_sale.xml: Billing / Shipping address
  pair, references row (Customer PO / Customer Job / Order Date /
  Salesperson), scheduling row (Planned Start / Internal / Customer
  Deadline / Ship Via), blanket-order callout, order line table
  (PART / DESCRIPTION / TREATMENT / QTY / UNIT PRICE / SUBTOTAL),
  totals table with subtotal / taxes / grand total, and a two-column
  signature block.

fusion_plating_configurator no longer ships report/ files — it
depends on fusion_plating_reports transitively via installed modules
order. Report XML ID changed from
'fusion_plating_configurator.report_fp_so_acknowledgement_doc' to
'fusion_plating_reports.report_fp_so_acknowledgement_doc'.

UAT on S00066: PDF renders cleanly with ENTECH branding, contact
footer, subtotal \$3,025 / taxes \$393.25 / grand total \$3,418.25,
signature lines — visually identical to the Quotation/Sales Order
report.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 00:10:33 -04:00
104 changed files with 3324 additions and 274 deletions

View File

@@ -1,26 +1,30 @@
{
'name': 'Fusion Accounting',
'version': '19.0.1.0.4',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'sequence': 25,
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
'summary': 'Meta-module that installs the full Fusion Accounting suite as a Community-edition replacement for Odoo Enterprise accounting.',
'description': """
Fusion Accounting (Meta-Module)
===============================
One-click install of the entire Fusion Accounting suite.
One-click install of the entire Fusion Accounting suite \u2014 a Community-edition
replacement for Odoo Enterprise's accounting modules.
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
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
- fusion_accounting_reports AI-augmented financial reports (Phase 2)
- fusion_accounting_assets AI-augmented asset management (Phase 3)
- fusion_accounting_followup AI-augmented customer follow-ups (Phase 4)
Sub-modules installed:
- fusion_accounting_core Shared schema, security, runtime helpers
- fusion_accounting_ai AI Co-Pilot (Claude/GPT/local LLM)
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
- fusion_accounting_bank_rec AI-assisted bank reconciliation
- fusion_accounting_reports AI-augmented financial reports
- fusion_accounting_assets AI-augmented asset management
- fusion_accounting_followup AI-augmented customer follow-ups
- fusion_accounting_l10n_ca Canadian reports + tax return tracking
- fusion_accounting_hr_payroll Payroll \u2192 GL bridge (replaces hr_payroll_account)
- fusion_accounting_ocr Tesseract + LLM invoice OCR
- fusion_accounting_documents Documents app \u2194 invoice bridge
Future sub-modules (added per the roadmap as each Phase ships):
- fusion_accounting_dashboard (Phase 5)
- fusion_accounting_budget (Phase 6)
Renames the Community "Invoicing" top-level menu to "Accounting" and slots
all Fusion sub-features as sub-menus, mirroring the Odoo Enterprise UX.
Built by Nexa Systems Inc.
""",
@@ -37,8 +41,14 @@ Built by Nexa Systems Inc.
'fusion_accounting_reports',
'fusion_accounting_assets',
'fusion_accounting_followup',
'fusion_accounting_l10n_ca',
'fusion_accounting_hr_payroll',
'fusion_accounting_ocr',
'fusion_accounting_documents',
],
'data': [
'data/menu_overrides.xml',
],
'data': [],
'installable': True,
'application': True,
'license': 'OPL-1',

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="0">
<!--
Top-level "Invoicing" menu rename + visual rebrand.
V19 Community ships this menu as "Invoicing" with the standard
accounting icon. Odoo Enterprise's `accountant` module renames it
to "Accounting" and swaps the icon. We do the same here so that
once Enterprise is uninstalled, the unified menu still presents
as "Accounting" (not "Invoicing") to users.
This file lives in the meta-module so the rename only takes effect
when the full Fusion suite is installed; sub-modules installed
a-la-carte don't change the menu's name.
-->
<record id="account.menu_finance" model="ir.ui.menu">
<field name="name">Accounting</field>
<field name="web_icon">fusion_accounting,static/description/icon.png</field>
<field name="sequence">25</field>
</record>
</odoo>

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting AI',
'version': '19.0.1.0.1',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'sequence': 26,
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',

View File

@@ -1,10 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Root menu under Accounting (account_accountant uses accountant.menu_accounting) -->
<!-- Lives under Community Accounting "Configuration" sub-menu so the
AI co-pilot is reachable regardless of whether Enterprise is
installed. (Was previously parented to `accountant.menu_accounting`
which doesn't exist after the Enterprise uninstall.) -->
<menuitem id="menu_fusion_accounting_root"
name="Fusion AI"
parent="accountant.menu_accounting"
sequence="8"
parent="account.menu_finance_configuration"
sequence="55"
groups="fusion_accounting_core.group_fusion_accounting_user"/>
<!-- Dashboard -->

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Assets',
'version': '19.0.1.0.36',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented asset management with depreciation schedules.',
'description': """

View File

@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top-level menu (visible only when account_asset Enterprise NOT installed) -->
<!-- Lives under Community Accounting "Accounting" sub-menu. Only visible
when Enterprise's account_asset is absent. -->
<menuitem id="menu_fusion_assets_root"
name="Asset Management"
sequence="60"
web_icon="fusion_accounting_assets,static/description/icon.png"
parent="account.menu_finance_entries"
sequence="25"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Asset list/form -->

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting — Bank Reconciliation',
'version': '19.0.1.0.26',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'sequence': 28,
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',

View File

@@ -29,30 +29,50 @@ def make_bank_journal(env, *, name='Test Bank', code=None):
def make_bank_statement(env, *, journal=None, name='Test Statement', date_=None):
"""Create a bank statement. Auto-creates a bank journal if not provided."""
"""Create a bank statement.
NOTE: in V19 Community, ``account.bank.statement.journal_id`` is a
read-only computed field derived from ``line_ids.journal_id`` — direct
writes are silently dropped. Enterprise's ``account_accountant`` used to
override this to make it writable; without Enterprise we have to derive
the journal from a line. We attach a single token line at create time
(later removed/replaced by the test) to bootstrap the journal.
"""
journal = journal or make_bank_journal(env)
return env['account.bank.statement'].create({
'name': name,
'journal_id': journal.id,
'date': date_ or date.today(),
'line_ids': [(0, 0, {
'journal_id': journal.id,
'date': date_ or date.today(),
'payment_ref': 'Statement bootstrap line',
'amount': 0.0,
})],
})
def make_bank_line(env, *, journal=None, statement=None, amount=100.00,
partner=None, memo='Test line', date_=None):
"""Create a bank statement line. Creates statement if not provided.
"""Create a bank statement line. Creates a journal (and optionally a
statement) if not provided.
Most-common factory in tests. Defaults give a $100 line with no partner."""
if not statement:
statement = make_bank_statement(env, journal=journal, date_=date_)
return env['account.bank.statement.line'].create({
'statement_id': statement.id,
'journal_id': statement.journal_id.id,
In V19 Community, lines can exist standalone — a statement is not
required. We create one only if the test explicitly passes ``statement=``.
"""
if statement and not journal:
journal = statement.journal_id
if not journal:
journal = make_bank_journal(env)
vals = {
'journal_id': journal.id,
'date': date_ or date.today(),
'payment_ref': memo,
'amount': amount,
'partner_id': partner.id if partner else False,
})
}
if statement:
vals['statement_id'] = statement.id
return env['account.bank.statement.line'].create(vals)
# ============================================================

View File

@@ -20,11 +20,14 @@
</field>
</record>
<!-- Top-level menu — only visible when Enterprise's account_accountant is absent -->
<!-- Container menu lives under the Community Accounting "Accounting"
sub-menu (account.menu_finance_entries). Only visible when
Enterprise's account_accountant is absent (Enterprise's reconcile
widget owns the same UI surface). -->
<menuitem id="menu_fusion_bank_rec_root"
name="Bank Reconciliation"
sequence="40"
web_icon="fusion_accounting_bank_rec,static/description/icon.png"
parent="account.menu_finance_entries"
sequence="15"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_bank_rec_main"
@@ -34,9 +37,8 @@
sequence="10"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Sub-menu for the auto-reconcile wizard -->
<menuitem id="menu_fusion_auto_reconcile_wizard"
name="Auto-Reconcile"
name="Auto-Reconcile\u2026"
parent="menu_fusion_bank_rec_root"
action="action_fusion_auto_reconcile_wizard"
sequence="20"

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Core',
'version': '19.0.1.0.2',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'sequence': 24,
'summary': 'Shared base for the Fusion Accounting sub-module suite (security, shared schema, runtime helpers).',

View File

@@ -8,20 +8,46 @@ class ResUsers(models.Model):
@api.model
def _fusion_recompute_coexistence_group(self):
"""Set group membership = all internal users iff Enterprise absent.
"""Maintain the two coexistence groups based on Enterprise presence.
- ``group_fusion_show_when_enterprise_absent``: members = all internal
users when NO Enterprise accounting module is installed. Used to
unhide Fusion menus that would conflict with Enterprise UIs.
- ``group_fusion_show_when_enterprise_present``: members = all internal
users when AT LEAST ONE Enterprise accounting module IS installed.
Used to hide migration/transitional UIs once Enterprise has been
uninstalled (so the user doesn't see "Migrate from Enterprise" with
nothing to migrate).
The two groups are mutually exclusive at any moment in time, but a
user can transition between them as Enterprise modules are installed
or uninstalled. Idempotent; safe to call multiple times.
Called from ir.module.module.button_immediate_install / uninstall
overrides. Idempotent; safe to call multiple times.
overrides.
"""
group = self.env.ref(
absent_group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
raise_if_not_found=False,
)
if not group:
present_group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_present',
raise_if_not_found=False,
)
if not absent_group and not present_group:
return
enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed()
all_internal = self.sudo().search([('share', '=', False)])
if enterprise_installed:
group.sudo().write({'user_ids': [(5, 0, 0)]})
if absent_group:
absent_group.sudo().write({'user_ids': [(5, 0, 0)]})
if present_group:
present_group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]})
else:
all_internal = self.sudo().search([('share', '=', False)])
group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]})
if absent_group:
absent_group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]})
if present_group:
present_group.sudo().write({'user_ids': [(5, 0, 0)]})

View File

@@ -49,4 +49,12 @@
<field name="name">Fusion: Show menus when Enterprise absent</field>
<field name="comment">Computed group. Membership: all internal users when no Enterprise accounting module is installed. Used to hide fusion sub-module menus that would conflict with Enterprise UIs.</field>
</record>
<!-- Phase 8: inverse coexistence group \u2014 visible only when Enterprise IS present.
Used to hide migration/transitional UIs once the migration is complete and
Enterprise has been uninstalled. -->
<record id="group_fusion_show_when_enterprise_present" model="res.groups">
<field name="name">Fusion: Show menus when Enterprise present</field>
<field name="comment">Computed group. Membership: all internal users WHEN at least one Enterprise accounting module is installed. Used to hide migration/transitional UIs that are irrelevant once Enterprise has been uninstalled.</field>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import models
from . import wizards

View File

@@ -0,0 +1,48 @@
{
'name': 'Fusion Accounting — Documents Bridge',
'version': '19.0.1.0.0',
'category': 'Accounting/Accounting',
'summary': 'Bridges the Documents app to Accounting: route scanned bills into vendor invoices.',
'description': """
Fusion Accounting — Documents Bridge
====================================
A Fusion-native replacement for Enterprise's ``documents_account`` module.
Adds:
- ``documents.document.move_id`` — Many2one to the linked accounting move.
- ``documents.document.is_invoice_candidate`` — computed flag for PDFs/images
not yet linked to a move.
- ``documents.document.action_create_invoice()`` — opens a wizard that
creates a draft vendor bill and copies the document's binary as an
attachment on the new ``account.move``.
- ``account.move.source_document_ids`` — reverse linkage with a stat button
on the invoice form.
- A ``fusion.create.invoice.from.document.wizard`` model + form view.
- A server action bound to ``documents.document`` so the workflow is
reachable from the Documents Actions menu (the Documents app uses
kanban/list views without a regular form view to inherit from).
Auto-installs when ``documents`` and ``fusion_accounting_core`` are both
present.
""",
'author': 'Nexa Systems Inc.',
'license': 'LGPL-3',
'depends': [
'fusion_accounting_core',
'account',
'documents',
],
'data': [
'security/ir.model.access.csv',
'wizards/create_invoice_from_document_views.xml',
'views/documents_document_views.xml',
'views/account_move_views.xml',
'data/server_actions_data.xml',
],
'auto_install': ['documents', 'fusion_accounting_core'],
'installable': True,
'application': False,
'icon': '/fusion_accounting_documents/static/description/icon.png',
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Server action bound to documents.document so the
"Create Vendor Invoice" workflow appears in the cog/Actions
menu of the Documents kanban + list views.
We dispatch through ``action_create_invoice`` so the same
validation runs whether the user clicks the action or calls
the method programmatically.
-->
<record id="action_create_invoice_from_document" model="ir.actions.server">
<field name="name">Create Vendor Invoice (Fusion)</field>
<field name="model_id" ref="documents.model_documents_document"/>
<field name="binding_model_id" ref="documents.model_documents_document"/>
<field name="binding_view_types">list,kanban</field>
<field name="state">code</field>
<field name="code">
if records and len(records) == 1:
action = records.action_create_invoice()
else:
raise UserError(_("Select exactly one document to convert into a vendor invoice."))
</field>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import documents_document
from . import account_move

View File

@@ -0,0 +1,33 @@
"""Reverse linkage from account.move back to source documents."""
from odoo import _, fields, models
class AccountMove(models.Model):
_inherit = 'account.move'
source_document_ids = fields.One2many(
'documents.document',
'move_id',
string='Source Documents',
readonly=True,
help="Documents in the Documents app that were used to create this move.",
)
source_document_count = fields.Integer(
string='Source Document Count',
compute='_compute_source_document_count',
)
def _compute_source_document_count(self):
for m in self:
m.source_document_count = len(m.source_document_ids)
def action_open_source_documents(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Source Documents'),
'res_model': 'documents.document',
'view_mode': 'kanban,list',
'domain': [('move_id', '=', self.id)],
}

View File

@@ -0,0 +1,71 @@
"""Bridge documents.document to accounting moves.
Adds a Many2one link to the created invoice/move, a computed
``is_invoice_candidate`` flag for PDFs/images that have not yet been
turned into a vendor bill, and the ``action_create_invoice`` entry
point used by both the form button and the server action.
"""
from odoo import _, api, fields, models
from odoo.exceptions import UserError
INVOICE_CANDIDATE_MIMETYPES = (
'application/pdf',
'image/png',
'image/jpeg',
'image/jpg',
)
class DocumentsDocument(models.Model):
_inherit = 'documents.document'
move_id = fields.Many2one(
'account.move',
string='Linked Invoice/Move',
copy=False,
ondelete='set null',
help="The accounting move this document was used to create.",
)
is_invoice_candidate = fields.Boolean(
string='Is Invoice Candidate',
compute='_compute_is_invoice_candidate',
store=True,
help="True when this document looks like a vendor bill "
"(PDF/image binary) and has not yet been linked to a move.",
)
@api.depends('mimetype', 'type', 'move_id')
def _compute_is_invoice_candidate(self):
for d in self:
d.is_invoice_candidate = (
d.type == 'binary'
and (d.mimetype or '') in INVOICE_CANDIDATE_MIMETYPES
and not d.move_id
)
def action_create_invoice(self):
"""Open the wizard to create a vendor invoice from this document."""
self.ensure_one()
if self.move_id:
raise UserError(_(
"This document is already linked to invoice %s.",
self.move_id.display_name,
))
if self.type == 'folder':
raise UserError(_(
"Folders cannot be turned into invoices."
))
if (self.mimetype or '') not in INVOICE_CANDIDATE_MIMETYPES:
raise UserError(_(
"Only PDF or image documents can be turned into invoices."
))
return {
'type': 'ir.actions.act_window',
'name': _('Create Invoice from Document'),
'res_model': 'fusion.create.invoice.from.document.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_document_id': self.id},
}

View File

@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_create_invoice_wizard_user,fusion.create.invoice.wizard.user,model_fusion_create_invoice_from_document_wizard,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_create_invoice_wizard_user fusion.create.invoice.wizard.user model_fusion_create_invoice_from_document_wizard base.group_user 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1 @@
from . import test_document_to_invoice

View File

@@ -0,0 +1,140 @@
"""Tests for the documents.document <-> account.move bridge."""
import base64
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install', 'fusion_accounting_documents')
class TestDocumentToInvoice(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.vendor = cls.env['res.partner'].create({
'name': 'Test Doc Vendor',
'supplier_rank': 1,
})
cls.purchase_journal = cls.env['account.journal'].search(
[('type', '=', 'purchase'),
('company_id', '=', cls.env.company.id)],
limit=1,
)
def _make_document(self, name='Test Bill PDF',
mimetype='application/pdf',
payload=b'%PDF-fake-bill-content'):
attachment = self.env['ir.attachment'].create({
'name': name,
'datas': base64.b64encode(payload),
'mimetype': mimetype,
})
Document = self.env['documents.document']
doc_vals = {
'name': name,
'attachment_id': attachment.id,
'mimetype': mimetype,
'type': 'binary',
}
if 'folder_id' in Document._fields:
folder = Document.search(
[('type', '=', 'folder')], limit=1,
)
if folder:
doc_vals['folder_id'] = folder.id
return Document.create(doc_vals)
def test_invoice_candidate_flag_pdf(self):
doc = self._make_document()
self.assertTrue(doc.is_invoice_candidate)
def test_invoice_candidate_flag_image(self):
doc = self._make_document(
name='scan.png',
mimetype='image/png',
payload=b'\x89PNG\r\n\x1a\nfake',
)
self.assertTrue(doc.is_invoice_candidate)
def test_invoice_candidate_flag_text_excluded(self):
doc = self._make_document(
name='note.txt',
mimetype='text/plain',
payload=b'just a note',
)
self.assertFalse(doc.is_invoice_candidate)
def test_action_create_invoice_opens_wizard(self):
doc = self._make_document()
action = doc.action_create_invoice()
self.assertEqual(action['type'], 'ir.actions.act_window')
self.assertEqual(
action['res_model'],
'fusion.create.invoice.from.document.wizard',
)
self.assertEqual(action['target'], 'new')
self.assertEqual(action['context']['default_document_id'], doc.id)
def test_wizard_creates_invoice_and_links(self):
doc = self._make_document()
wizard = self.env['fusion.create.invoice.from.document.wizard'].create({
'document_id': doc.id,
'partner_id': self.vendor.id,
'move_type': 'in_invoice',
})
self.assertTrue(wizard.journal_id, "Default journal should resolve")
action = wizard.action_create_invoice()
self.assertEqual(action['res_model'], 'account.move')
move = self.env['account.move'].browse(action['res_id'])
self.assertEqual(move.move_type, 'in_invoice')
self.assertEqual(move.partner_id, self.vendor)
self.assertEqual(doc.move_id, move)
self.assertFalse(doc.is_invoice_candidate,
"Linked docs should no longer be candidates")
self.assertEqual(move.source_document_count, 1)
self.assertIn(doc, move.source_document_ids)
attachments = self.env['ir.attachment'].search([
('res_model', '=', 'account.move'),
('res_id', '=', move.id),
])
self.assertTrue(
attachments,
"An attachment copy should land on the new move",
)
def test_action_create_invoice_already_linked_raises(self):
doc = self._make_document()
existing_move = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.vendor.id,
})
doc.move_id = existing_move.id
with self.assertRaises(UserError):
doc.action_create_invoice()
def test_action_create_invoice_non_candidate_raises(self):
doc = self._make_document(
name='note.txt',
mimetype='text/plain',
payload=b'hello',
)
with self.assertRaises(UserError):
doc.action_create_invoice()
def test_wizard_creates_credit_note(self):
doc = self._make_document(name='credit-note.pdf')
wizard = self.env['fusion.create.invoice.from.document.wizard'].create({
'document_id': doc.id,
'partner_id': self.vendor.id,
'move_type': 'in_refund',
})
action = wizard.action_create_invoice()
move = self.env['account.move'].browse(action['res_id'])
self.assertEqual(move.move_type, 'in_refund')
self.assertEqual(doc.move_id, move)

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_move_form_inherit_fusion_documents" model="ir.ui.view">
<field name="name">account.move.form.inherit.fusion.documents</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button"
type="object"
name="action_open_source_documents"
icon="fa-file-text-o"
invisible="source_document_count == 0">
<field name="source_document_count"
widget="statinfo"
string="Source Docs"/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
The Documents app does not ship a regular form view for
documents.document; editing happens in the side panel of the
kanban/list views. We therefore add the new fields to the
kanban + list views and rely on a server action (defined in
data/server_actions_data.xml) to expose the "Create Invoice"
workflow from the Actions menu.
-->
<record id="view_documents_document_kanban_inherit_fusion_acc"
model="ir.ui.view">
<field name="name">documents.document.kanban.inherit.fusion.acc</field>
<field name="model">documents.document</field>
<field name="inherit_id" ref="documents.document_view_kanban"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="is_invoice_candidate"/>
<field name="move_id"/>
</xpath>
</field>
</record>
<record id="view_documents_document_list_inherit_fusion_acc"
model="ir.ui.view">
<field name="name">documents.document.list.inherit.fusion.acc</field>
<field name="model">documents.document</field>
<field name="inherit_id" ref="documents.documents_view_list_main"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="is_invoice_candidate" optional="hide"/>
<field name="move_id"
string="Linked Invoice"
optional="hide"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import create_invoice_from_document

View File

@@ -0,0 +1,132 @@
"""Wizard to create a vendor invoice from a Documents document.
The wizard creates an empty draft ``account.move`` of the chosen
move type, copies the document's binary attachment onto the new
move, posts a chatter note linking back to the source document,
and finally stores the move on ``documents.document.move_id`` so
the source no longer appears as an invoice candidate.
"""
from odoo import _, api, fields, models
from odoo.exceptions import UserError
MOVE_TYPE_LABELS = {
'in_invoice': _('Vendor Bill'),
'in_refund': _('Vendor Credit Note'),
}
class CreateInvoiceFromDocumentWizard(models.TransientModel):
_name = 'fusion.create.invoice.from.document.wizard'
_description = 'Create Vendor Invoice from Document'
document_id = fields.Many2one(
'documents.document',
string='Source Document',
required=True,
readonly=True,
ondelete='cascade',
)
document_name = fields.Char(related='document_id.name', readonly=True)
document_mimetype = fields.Char(related='document_id.mimetype', readonly=True)
partner_id = fields.Many2one(
'res.partner',
string='Vendor',
domain="[('supplier_rank', '>', 0)]",
)
move_type = fields.Selection(
[
('in_invoice', 'Vendor Bill'),
('in_refund', 'Vendor Credit Note'),
],
string='Type',
default='in_invoice',
required=True,
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
required=True,
)
journal_id = fields.Many2one(
'account.journal',
string='Journal',
domain="[('type', '=', 'purchase'), ('company_id', '=', company_id)]",
default=lambda self: self._default_journal(),
)
@api.model
def _default_journal(self):
return self.env['account.journal'].search(
[('type', '=', 'purchase'),
('company_id', '=', self.env.company.id)],
limit=1,
)
@api.onchange('company_id')
def _onchange_company_id(self):
if self.journal_id and self.journal_id.company_id != self.company_id:
self.journal_id = self.env['account.journal'].search(
[('type', '=', 'purchase'),
('company_id', '=', self.company_id.id)],
limit=1,
)
def action_create_invoice(self):
self.ensure_one()
if not self.document_id:
raise UserError(_("No document selected."))
if self.document_id.move_id:
raise UserError(_(
"Document %(doc)s is already linked to invoice %(inv)s.",
doc=self.document_id.display_name,
inv=self.document_id.move_id.display_name,
))
if not self.journal_id:
raise UserError(_(
"No purchase journal configured for company %s.",
self.company_id.display_name,
))
move_vals = {
'move_type': self.move_type,
'journal_id': self.journal_id.id,
'company_id': self.company_id.id,
}
if self.partner_id:
move_vals['partner_id'] = self.partner_id.id
move = self.env['account.move'].create(move_vals)
attachment = self.document_id.attachment_id
if attachment:
attachment_copy = attachment.copy({
'res_model': 'account.move',
'res_id': move.id,
})
move.message_post(
body=_(
"Created from Documents source: <strong>%s</strong>",
self.document_id.name,
),
attachment_ids=[attachment_copy.id],
)
else:
move.message_post(body=_(
"Created from Documents source: <strong>%s</strong> "
"(no attachment to copy).",
self.document_id.name,
))
self.document_id.move_id = move.id
return {
'type': 'ir.actions.act_window',
'name': MOVE_TYPE_LABELS.get(self.move_type, _('Invoice')),
'res_model': 'account.move',
'view_mode': 'form',
'res_id': move.id,
}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_create_invoice_from_document_wizard_form" model="ir.ui.view">
<field name="name">fusion.create.invoice.from.document.wizard.form</field>
<field name="model">fusion.create.invoice.from.document.wizard</field>
<field name="arch" type="xml">
<form string="Create Invoice from Document">
<sheet>
<group>
<field name="document_id" invisible="1"/>
<field name="document_name" readonly="1"/>
<field name="document_mimetype" readonly="1"/>
</group>
<group>
<field name="move_type"/>
<field name="partner_id" options="{'no_create': True}"/>
<field name="company_id"
groups="base.group_multi_company"
options="{'no_create': True}"/>
<field name="journal_id" options="{'no_create': True}"/>
</group>
</sheet>
<footer>
<button name="action_create_invoice"
string="Create Invoice"
type="object"
class="btn-primary"
data-hotkey="q"/>
<button string="Cancel"
class="btn-secondary"
special="cancel"
data-hotkey="x"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Follow-up',
'version': '19.0.1.0.30',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.',
'description': """

View File

@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top-level menu (visible only when account_followup Enterprise NOT installed) -->
<!-- Lives under Community Accounting "Customers" sub-menu. Only visible
when Enterprise's account_followup is absent. -->
<menuitem id="menu_fusion_followup_root"
name="Customer Follow-ups"
sequence="70"
web_icon="fusion_accounting_followup,static/description/icon.png"
name="Follow-ups"
parent="account.menu_finance_receivables"
sequence="50"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Partners list (gated to overdue) -->

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,59 @@
{
'name': 'Fusion Accounting - HR Payroll Bridge',
'version': '19.0.1.0.0',
'category': 'Human Resources/Payroll',
'summary': 'Bridges payroll (hr_payroll) to accounting via account.move creation when payslips are validated.',
'description': """
Fusion Accounting - HR Payroll Bridge
=====================================
A Fusion-native replacement for Odoo Enterprise's ``hr_payroll_account``
module. Removes Westin's last payroll-accounting dependency on the
Enterprise ``accountant`` umbrella.
Scope
-----
- Adds ``account_debit`` / ``account_credit`` / ``analytic_distribution`` to
``hr.salary.rule`` (company-dependent GL mapping per rule).
- Adds ``move_id`` + ``journal_id`` + ``_fusion_create_account_move`` to
``hr.payslip``: when a payslip is validated, generates a balanced
``account.move`` from the salary rule mapping.
- Adds ``fusion_payroll_journal_id`` + ``fusion_payroll_auto_post`` to
``res.company`` (fallback journal + auto-post toggle).
- Reverse links ``payslip_ids`` / ``payslip_count`` on ``account.move``
for traceability and reporting.
Coexistence
-----------
When Odoo Enterprise's ``hr_payroll_account`` is also installed, this
module yields move-creation to it (detected at runtime via
``ir.module.module``) so payslips don't get duplicate entries. After
``hr_payroll_account`` is uninstalled, this module owns the bridge.
Auto-install
------------
Auto-installs whenever both ``hr_payroll`` and ``fusion_accounting_core``
are present.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.ca',
'license': 'LGPL-3',
'depends': [
'fusion_accounting_core',
'account',
'hr_payroll',
'base_iban',
],
'data': [
'data/hr_salary_rule_data.xml',
'views/hr_salary_rule_views.xml',
'views/hr_payslip_views.xml',
'views/hr_payroll_structure_views.xml',
'views/res_config_settings_views.xml',
'views/account_move_views.xml',
],
'auto_install': ['hr_payroll', 'fusion_accounting_core'],
'installable': True,
'application': False,
'icon': '/fusion_accounting_hr_payroll/static/description/icon.png',
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Bridge defaults from the Enterprise hr_payroll_account module.
Wrapped in noupdate="1" so re-running -u does not overwrite a
customer's account mapping on these rules.
Each <record> uses xmlid_lookup="ignore" through optional `forcecreate="0"`
semantics so that the load is silently skipped when the referenced
upstream rule is not present (e.g. on a database without the
Enterprise default payroll structures).
-->
<data noupdate="1">
<record id="hr_payroll.default_deduction_salary_rule" model="hr.salary.rule" forcecreate="0">
<field name="not_computed_in_net" eval="True"/>
</record>
<record id="hr_payroll.default_attachment_of_salary_rule" model="hr.salary.rule" forcecreate="0">
<field name="not_computed_in_net" eval="True"/>
</record>
<record id="hr_payroll.default_assignment_of_salary_rule" model="hr.salary.rule" forcecreate="0">
<field name="not_computed_in_net" eval="True"/>
</record>
<record id="hr_payroll.default_child_support" model="hr.salary.rule" forcecreate="0">
<field name="not_computed_in_net" eval="True"/>
</record>
<record id="hr_payroll.default_reimbursement_salary_rule" model="hr.salary.rule" forcecreate="0">
<field name="not_computed_in_net" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,10 @@
from . import hr_salary_rule
from . import hr_payslip
from . import hr_payslip_line
from . import hr_payslip_run
from . import hr_payroll_structure
from . import account_journal
from . import account_move
from . import account_move_line
from . import res_company
from . import res_config_settings

View File

@@ -0,0 +1,12 @@
from odoo import fields, models
class AccountJournal(models.Model):
_inherit = 'account.journal'
is_payroll_journal = fields.Boolean(
string='Used for Payroll',
help="Marks this journal as the salary / payroll posting journal "
"for the company. Informational; the actual fallback is set "
"on res.company.fusion_payroll_journal_id.",
)

View File

@@ -0,0 +1,41 @@
from odoo import _, fields, models
class AccountMove(models.Model):
_inherit = 'account.move'
payslip_ids = fields.One2many(
comodel_name='hr.payslip',
inverse_name='move_id',
string='Payslips',
readonly=True,
copy=False,
)
payslip_count = fields.Integer(
string='# of Payslips',
compute='_compute_payslip_count',
compute_sudo=True,
)
def _compute_payslip_count(self):
for move in self:
move.payslip_count = len(move.payslip_ids)
def action_open_payslip(self):
self.ensure_one()
action = {
'name': _('Payslips'),
'type': 'ir.actions.act_window',
'res_model': 'hr.payslip',
}
if self.payslip_count == 1:
action.update({
'view_mode': 'form',
'res_id': self.payslip_ids.id,
})
else:
action.update({
'view_mode': 'list,form',
'domain': [('id', 'in', self.payslip_ids.ids)],
})
return action

View File

@@ -0,0 +1,16 @@
from odoo import fields, models
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
payslip_id = fields.Many2one(
'hr.payslip',
string='Source Payslip',
readonly=True,
copy=False,
ondelete='set null',
index='btree_not_null',
help="Payslip this journal item was generated from "
"(populated by the Fusion payroll bridge for reporting).",
)

View File

@@ -0,0 +1,26 @@
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class HrPayrollStructure(models.Model):
_inherit = 'hr.payroll.structure'
journal_id = fields.Many2one(
'account.journal',
string='Salary Journal',
company_dependent=True,
domain="[('type', '=', 'general')]",
help="Default journal used when generating payroll accounting "
"entries for payslips that follow this structure.",
)
@api.constrains('journal_id')
def _check_journal_currency(self):
for record in self.sudo():
journal = record.journal_id
if journal and journal.currency_id and journal.company_id \
and journal.currency_id != journal.company_id.currency_id:
raise ValidationError(_(
"The salary journal must be in the same currency as "
"the company.",
))

View File

@@ -0,0 +1,242 @@
import logging
from collections import defaultdict
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
move_id = fields.Many2one(
'account.move',
string='Accounting Entry',
readonly=True,
copy=False,
index='btree_not_null',
)
move_state = fields.Selection(
related='move_id.state',
string='Move State',
export_string_translation=False,
)
journal_id = fields.Many2one(
'account.journal',
string='Salary Journal',
domain="[('type', '=', 'general')]",
)
@api.model
def _fusion_enterprise_bridge_active(self):
"""Return True when the Enterprise hr_payroll_account module is the
authoritative payslip - GL bridge on this database. Used to avoid
duplicate move creation while both modules coexist."""
module = self.env['ir.module.module'].sudo().search(
[('name', '=', 'hr_payroll_account')], limit=1,
)
return bool(module) and module.state == 'installed'
def _fusion_resolve_journal(self):
"""Pick the journal for this payslip's bridge move."""
self.ensure_one()
if self.journal_id:
return self.journal_id
struct = self.struct_id
if struct and 'journal_id' in struct._fields and struct.journal_id:
return struct.journal_id
company = self.company_id or self.env.company
return company.fusion_payroll_journal_id or False
def _fusion_resolve_partner(self):
"""Pick the best partner reference for the move lines of this payslip."""
self.ensure_one()
employee = self.employee_id
if not employee:
return False
if 'work_contact_id' in employee._fields and employee.work_contact_id:
return employee.work_contact_id.id
if 'address_home_id' in employee._fields and employee.address_home_id:
return employee.address_home_id.id
return False
def _fusion_get_line_amount(self, line):
"""Hook so a localisation can override which payslip-line value is
posted. Defaults to ``line.total``."""
return line.total or 0.0
def action_payslip_done(self):
res = super().action_payslip_done()
if self._fusion_enterprise_bridge_active():
return res
for slip in self:
if slip.move_id:
continue
journal = slip._fusion_resolve_journal()
if not journal:
continue
try:
slip._fusion_create_account_move(journal=journal)
except UserError as err:
_logger.warning(
"Fusion payroll bridge: GL move skipped for slip %s: %s",
slip.id, err,
)
slip.message_post(body=_(
"Fusion Payroll bridge could not create the journal "
"entry: %s",
) % err)
except Exception:
_logger.exception(
"Fusion payroll bridge: unexpected failure for slip %s",
slip.id,
)
return res
def action_payslip_cancel(self):
if hasattr(super(), 'action_payslip_cancel'):
res = super().action_payslip_cancel()
else:
res = True
if self._fusion_enterprise_bridge_active():
return res
for slip in self:
move = slip.move_id
if not move:
continue
try:
if move.state == 'posted':
move.button_draft()
move.with_context(force_delete=True).unlink()
except Exception:
_logger.exception(
"Fusion payroll bridge: cannot reverse move %s for slip %s",
move.id, slip.id,
)
return res
def _fusion_create_account_move(self, journal=None):
"""Build a balanced ``account.move`` from this payslip using the
``account_debit`` / ``account_credit`` mapping on each salary rule.
Returns the created move (or False if there is nothing to post)."""
self.ensure_one()
if not self.line_ids:
return False
journal = journal or self._fusion_resolve_journal()
if not journal:
raise UserError(_(
"No salary journal configured for company %s. "
"Set a fallback journal under Accounting Settings - "
"Fusion Payroll Bridge.",
) % (self.company_id.display_name if self.company_id else ''))
debit_per_account = defaultdict(float)
credit_per_account = defaultdict(float)
analytic_per_account = {}
for line in self.line_ids:
rule = line.salary_rule_id
amount = self._fusion_get_line_amount(line)
if not amount:
continue
debit_account = rule.account_debit
credit_account = rule.account_credit
analytic = (
rule.fusion_analytic_account_id
if 'fusion_analytic_account_id' in rule._fields
else False
)
if amount > 0:
if debit_account:
debit_per_account[debit_account.id] += amount
if credit_account:
credit_per_account[credit_account.id] += amount
else:
pos = -amount
if debit_account:
credit_per_account[debit_account.id] += pos
if credit_account:
debit_per_account[credit_account.id] += pos
if analytic:
for acc in (debit_account, credit_account):
if acc and acc.id not in analytic_per_account:
analytic_per_account[acc.id] = analytic.id
partner_id = self._fusion_resolve_partner()
line_label = self.display_name or self.number or _('Payslip')
move_lines = []
all_accounts = set(debit_per_account) | set(credit_per_account)
for account_id in all_accounts:
net = (
debit_per_account.get(account_id, 0.0)
- credit_per_account.get(account_id, 0.0)
)
if abs(net) < 0.005:
continue
vals = {
'account_id': account_id,
'name': line_label,
'partner_id': partner_id,
}
if net > 0:
vals['debit'] = round(net, 2)
vals['credit'] = 0.0
else:
vals['debit'] = 0.0
vals['credit'] = round(-net, 2)
analytic_id = analytic_per_account.get(account_id)
if analytic_id:
vals['analytic_distribution'] = {str(analytic_id): 100.0}
move_lines.append((0, 0, vals))
if not move_lines:
return False
total_debit = sum(vals[2]['debit'] for vals in move_lines)
total_credit = sum(vals[2]['credit'] for vals in move_lines)
if abs(total_debit - total_credit) > 0.01:
raise UserError(_(
"Payroll move not balanced: debit=%(d).2f, credit=%(c).2f. "
"Check the account_debit / account_credit mapping on the "
"salary rules of payslip %(name)s.",
) % {
'd': total_debit,
'c': total_credit,
'name': self.display_name,
})
move_vals = {
'journal_id': journal.id,
'date': self.date_to or fields.Date.context_today(self),
'ref': self.number or self.display_name,
'line_ids': move_lines,
'move_type': 'entry',
}
move = self.env['account.move'].sudo().create(move_vals)
if self.company_id and self.company_id.fusion_payroll_auto_post:
try:
move.action_post()
except Exception:
_logger.exception(
"Fusion payroll bridge: auto-post failed for move %s; "
"leaving in draft.",
move.id,
)
self.move_id = move.id
return move
def action_open_move(self):
self.ensure_one()
if not self.move_id:
return False
return {
'type': 'ir.actions.act_window',
'name': _('Journal Entry'),
'res_model': 'account.move',
'view_mode': 'form',
'res_id': self.move_id.id,
}

View File

@@ -0,0 +1,16 @@
from odoo import fields, models
class HrPayslipLine(models.Model):
_inherit = 'hr.payslip.line'
move_line_id = fields.Many2one(
'account.move.line',
string='Journal Item',
readonly=True,
copy=False,
ondelete='set null',
index='btree_not_null',
help="Account move line this payslip line was rolled up into "
"(set by the Fusion payroll bridge for traceability).",
)

View File

@@ -0,0 +1,29 @@
from odoo import _, fields, models
class HrPayslipRun(models.Model):
_inherit = 'hr.payslip.run'
move_id = fields.Many2one(
'account.move',
string='Batch Accounting Entry',
readonly=True,
copy=False,
ondelete='set null',
)
move_state = fields.Selection(
related='move_id.state',
string='Move State',
)
def action_open_move(self):
self.ensure_one()
if not self.move_id:
return False
return {
'type': 'ir.actions.act_window',
'name': _('Journal Entry'),
'res_model': 'account.move',
'view_mode': 'form',
'res_id': self.move_id.id,
}

View File

@@ -0,0 +1,35 @@
from odoo import fields, models
class HrSalaryRule(models.Model):
_inherit = 'hr.salary.rule'
account_debit = fields.Many2one(
'account.account',
string='Debit Account',
company_dependent=True,
ondelete='restrict',
help="GL account debited when this rule's amount is posted "
"(typically expense or asset).",
)
account_credit = fields.Many2one(
'account.account',
string='Credit Account',
company_dependent=True,
ondelete='restrict',
help="GL account credited when this rule's amount is posted "
"(typically liability).",
)
fusion_analytic_account_id = fields.Many2one(
'account.analytic.account',
string='Analytic Account',
company_dependent=True,
help="Optional analytic account applied to both legs of the move.",
)
not_computed_in_net = fields.Boolean(
string="Excluded from Net",
default=False,
help="If checked, the result of this rule is excluded from the "
"Net salary line in the journal entry. Set a dedicated "
"debit/credit account so the amount is posted independently.",
)

View File

@@ -0,0 +1,19 @@
from odoo import fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
fusion_payroll_journal_id = fields.Many2one(
'account.journal',
string='Default Payroll Journal',
domain="[('type', '=', 'general'), ('company_id', '=', id)]",
help="Fallback journal used by the Fusion payroll bridge when a "
"payslip's structure does not define one.",
)
fusion_payroll_auto_post = fields.Boolean(
string='Auto-post Payroll Entries',
default=False,
help="When enabled, payroll-generated journal entries are posted "
"immediately. Otherwise they remain in draft for review.",
)

View File

@@ -0,0 +1,16 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
fusion_payroll_journal_id = fields.Many2one(
related='company_id.fusion_payroll_journal_id',
string='Default Payroll Journal',
readonly=False,
)
fusion_payroll_auto_post = fields.Boolean(
related='company_id.fusion_payroll_auto_post',
string='Auto-post Payroll Entries',
readonly=False,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1 @@
from . import test_payslip_to_move

View File

@@ -0,0 +1,108 @@
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestFusionPayrollBridge(TransactionCase):
"""Smoke tests for the Fusion payroll bridge.
Verifies that the field surface required to replace Enterprise's
``hr_payroll_account`` is present after the module installs.
Full payslip-to-move integration is exercised in a separate
integration test that needs a seeded payroll structure.
"""
def test_module_installed(self):
module = self.env['ir.module.module'].sudo().search(
[('name', '=', 'fusion_accounting_hr_payroll')], limit=1,
)
self.assertTrue(module, "Module record must exist")
self.assertEqual(
module.state, 'installed',
"Module should be in 'installed' state for these tests to run",
)
def test_salary_rule_has_account_fields(self):
rule_model = self.env['hr.salary.rule']
for fname in (
'account_debit',
'account_credit',
'fusion_analytic_account_id',
'not_computed_in_net',
):
self.assertIn(
fname, rule_model._fields,
f"hr.salary.rule must expose '{fname}'",
)
def test_payslip_has_move_link(self):
slip_model = self.env['hr.payslip']
for fname in ('move_id', 'move_state', 'journal_id'):
self.assertIn(
fname, slip_model._fields,
f"hr.payslip must expose '{fname}'",
)
self.assertTrue(
hasattr(slip_model, '_fusion_create_account_move'),
"hr.payslip must expose the _fusion_create_account_move bridge",
)
self.assertTrue(
hasattr(slip_model, '_fusion_enterprise_bridge_active'),
"hr.payslip must expose the Enterprise-bridge detector",
)
def test_payslip_run_has_move_link(self):
run_model = self.env['hr.payslip.run']
for fname in ('move_id', 'move_state'):
self.assertIn(
fname, run_model._fields,
f"hr.payslip.run must expose '{fname}'",
)
def test_company_payroll_journal_field(self):
co_model = self.env['res.company']
for fname in ('fusion_payroll_journal_id', 'fusion_payroll_auto_post'):
self.assertIn(
fname, co_model._fields,
f"res.company must expose '{fname}'",
)
def test_account_move_back_links(self):
move_model = self.env['account.move']
for fname in ('payslip_ids', 'payslip_count'):
self.assertIn(
fname, move_model._fields,
f"account.move must expose '{fname}'",
)
line_model = self.env['account.move.line']
self.assertIn(
'payslip_id', line_model._fields,
"account.move.line must expose 'payslip_id'",
)
def test_payslip_line_has_move_line_link(self):
line_model = self.env['hr.payslip.line']
self.assertIn(
'move_line_id', line_model._fields,
"hr.payslip.line must expose 'move_line_id'",
)
def test_enterprise_bridge_detector_returns_bool(self):
slip_model = self.env['hr.payslip']
self.assertIsInstance(
slip_model._fusion_enterprise_bridge_active(), bool,
)
def test_account_journal_payroll_flag(self):
journal_model = self.env['account.journal']
self.assertIn(
'is_payroll_journal', journal_model._fields,
"account.journal must expose 'is_payroll_journal'",
)
def test_payroll_structure_journal_field(self):
struct_model = self.env['hr.payroll.structure']
self.assertIn(
'journal_id', struct_model._fields,
"hr.payroll.structure must expose 'journal_id'",
)

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="fusion_account_move_view_form" model="ir.ui.view">
<field name="name">account.move.form.fusion.payroll.bridge</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<field name="payslip_count" invisible="1"/>
<button class="oe_stat_button"
name="action_open_payslip"
type="object"
icon="fa-user"
invisible="not payslip_count"
groups="hr.group_hr_user">
<div class="o_stat_info">
<field name="payslip_count" class="o_stat_value"/>
<span class="o_stat_text">Payslips</span>
</div>
</button>
</div>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="fusion_hr_payroll_structure_view_form" model="ir.ui.view">
<field name="name">hr.payroll.structure.form.fusion.payroll.bridge</field>
<field name="model">hr.payroll.structure</field>
<field name="inherit_id" ref="hr_payroll.view_hr_employee_grade_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<group string="Fusion Accounting">
<field name="journal_id"/>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="fusion_hr_payslip_view_form" model="ir.ui.view">
<field name="name">hr.payslip.form.fusion.payroll.bridge</field>
<field name="model">hr.payslip</field>
<field name="inherit_id" ref="hr_payroll.view_hr_payslip_form"/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<field name="move_id" invisible="1"/>
<field name="move_state" invisible="1"/>
<button class="oe_stat_button"
name="action_open_move"
type="object"
icon="fa-bars"
invisible="not move_id"
groups="account.group_account_readonly">
<div class="o_stat_info">
<span class="o_stat_text" invisible="move_state != 'draft'">Journal Entry (Draft)</span>
<span class="o_stat_text" invisible="move_state != 'posted'">Journal Entry (Posted)</span>
<span class="o_stat_text" invisible="move_state != 'cancel'">Journal Entry (Canceled)</span>
</div>
</button>
</div>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="fusion_hr_salary_rule_view_form" model="ir.ui.view">
<field name="name">hr.salary.rule.form.fusion.payroll.bridge</field>
<field name="model">hr.salary.rule</field>
<field name="inherit_id" ref="hr_payroll.hr_salary_rule_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<notebook>
<page string="Fusion Accounting" name="fusion_accounting">
<group>
<group>
<field name="account_debit" placeholder="None"/>
<field name="account_credit" placeholder="None"/>
<field name="fusion_analytic_account_id"
groups="analytic.group_analytic_accounting"
placeholder="None"/>
<field name="not_computed_in_net"/>
</group>
</group>
</page>
</notebook>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="fusion_hr_payroll_res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.form.fusion.payroll.bridge</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[1]" position="before">
<block title="Fusion Payroll Bridge" id="fusion_payroll_bridge_block">
<setting id="fusion_payroll_journal_setting"
string="Default Payroll Journal"
help="Fallback journal used by the Fusion payroll bridge when a payslip's structure does not define one.">
<field name="fusion_payroll_journal_id"/>
</setting>
<setting id="fusion_payroll_auto_post_setting"
string="Auto-post Payroll Entries"
help="When enabled, payroll-generated journal entries are posted immediately. Otherwise they remain in draft for review.">
<field name="fusion_payroll_auto_post"/>
</setting>
</block>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,32 @@
{
'name': 'Fusion Accounting — Canadian Reports',
'version': '19.0.1.1.0',
'category': 'Accounting/Localizations/Reporting',
'summary': 'Canadian-specific report definitions and tax return templates for Fusion Accounting.',
'description': """
Replaces Enterprise's l10n_ca_reports module with Fusion-native equivalents:
- Canadian Balance Sheet (report definition for fusion_accounting_reports engine)
- Canadian Profit & Loss (report definition)
- Tax return tracking templates (GST/HST/PST periods)
Auto-installs when l10n_ca + fusion_accounting_reports are both present.
""",
'depends': [
'fusion_accounting_core',
'fusion_accounting_reports',
'l10n_ca',
],
'data': [
'security/ir.model.access.csv',
'data/fusion_tax_return_data.xml',
'data/report_ca_balance_sheet.xml',
'data/report_ca_profit_loss.xml',
'views/menu_views.xml',
],
'auto_install': ['l10n_ca', 'fusion_accounting_reports'],
'installable': True,
'application': False,
'license': 'LGPL-3',
'author': 'Westin / Fusion Suite',
'icon': '/fusion_accounting_l10n_ca/static/description/icon.png',
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="seq_fusion_tax_return" model="ir.sequence">
<field name="name">Fusion Tax Return</field>
<field name="code">fusion.tax.return</field>
<field name="prefix">TAX/%(year)s/</field>
<field name="padding">4</field>
</record>
</odoo>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_ca_balance_sheet" model="fusion.report">
<field name="name">Balance Sheet (Canada)</field>
<field name="code">ca_balance_sheet</field>
<field name="report_type">balance_sheet</field>
<field name="sequence">21</field>
<field name="default_comparison_mode">previous_period</field>
<field name="description">Canadian-formatted balance sheet aligned to GAAP/IFRS classifications used in Canada.</field>
<field name="line_specs" eval="[
{'label': 'ASSETS', 'level': 0},
{'label': 'Current Assets', 'level': 1},
{'label': 'Cash and Bank', 'account_type_prefix': 'asset_cash', 'sign': 1, 'level': 2},
{'label': 'Accounts Receivable', 'account_type_prefix': 'asset_receivable', 'sign': 1, 'level': 2},
{'label': 'Inventory', 'account_type_prefix': 'asset_current', 'sign': 1, 'level': 2},
{'label': 'Prepaid Expenses', 'account_type_prefix': 'asset_prepayments', 'sign': 1, 'level': 2},
{'label': 'Total Current Assets', 'compute': 'subtotal', 'above': 4, 'sign': 1, 'level': 1},
{'label': 'Non-Current Assets', 'level': 1},
{'label': 'Property, Plant and Equipment', 'account_type_prefix': 'asset_fixed', 'sign': 1, 'level': 2},
{'label': 'Intangible Assets', 'account_type_prefix': 'asset_non_current', 'sign': 1, 'level': 2},
{'label': 'Total Non-Current Assets', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 1},
{'label': 'TOTAL ASSETS', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'LIABILITIES', 'level': 0},
{'label': 'Current Liabilities', 'level': 1},
{'label': 'Accounts Payable', 'account_type_prefix': 'liability_payable', 'sign': -1, 'level': 2},
{'label': 'Tax Payable (GST/HST/PST)', 'account_type_prefix': 'liability_current', 'sign': -1, 'level': 2},
{'label': 'Total Current Liabilities', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 1},
{'label': 'Long-Term Liabilities', 'account_type_prefix': 'liability_non_current', 'sign': -1, 'level': 1},
{'label': 'TOTAL LIABILITIES', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'EQUITY', 'level': 0},
{'label': 'Share Capital', 'account_type_prefix': 'equity', 'sign': -1, 'level': 1},
{'label': 'Retained Earnings', 'account_type_prefix': 'equity_unaffected', 'sign': -1, 'level': 1},
{'label': 'TOTAL EQUITY', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'TOTAL LIABILITIES + EQUITY', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_ca_profit_loss" model="fusion.report">
<field name="name">Profit and Loss (Canada)</field>
<field name="code">ca_profit_loss</field>
<field name="report_type">pnl</field>
<field name="sequence">12</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Canadian-formatted income statement.</field>
<field name="line_specs" eval="[
{'label': 'OPERATING REVENUE', 'level': 0},
{'label': 'Sales Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 1},
{'label': 'Other Operating Revenue', 'account_type_prefix': 'income_other', 'sign': -1, 'level': 1},
{'label': 'Total Revenue', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'COST OF GOODS SOLD', 'level': 0},
{'label': 'Direct Costs', 'account_type_prefix': 'expense_direct_cost', 'sign': -1, 'level': 1},
{'label': 'GROSS PROFIT', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'OPERATING EXPENSES', 'level': 0},
{'label': 'Operating Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 1},
{'label': 'Depreciation', 'account_type_prefix': 'expense_depreciation', 'sign': -1, 'level': 1},
{'label': 'OPERATING INCOME', 'compute': 'subtotal', 'above': 3, 'sign': 1, 'level': 0},
{'label': 'NET INCOME BEFORE TAX', 'compute': 'subtotal', 'above': 1, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import fusion_tax_return

View File

@@ -0,0 +1,92 @@
"""Fusion-native tax return tracking.
A simpler replacement for Enterprise's `account.return` model: a tax
return is a (return_type, period_from, period_to, status) record. Filers
mark them filed once submitted to CRA / Revenu Quebec / provincial
authorities.
"""
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FusionTaxReturn(models.Model):
_name = "fusion.tax.return"
_inherit = ["mail.thread"]
_description = "Fusion Tax Return Filing"
_order = "date_to desc, id desc"
name = fields.Char(
string="Reference",
required=True,
copy=False,
index=True,
default=lambda self: _("New"),
)
return_type = fields.Selection(
[
("gst_hst", "GST/HST Return"),
("pst", "PST Return"),
("qst", "QST Return"),
("t4", "T4 Slip"),
("t5018", "T5018 Statement"),
("payroll_remittance", "Payroll Source Deductions"),
("other", "Other"),
],
required=True,
default="gst_hst",
tracking=True,
)
company_id = fields.Many2one(
"res.company",
required=True,
default=lambda self: self.env.company,
)
currency_id = fields.Many2one(related="company_id.currency_id")
date_from = fields.Date(string="Period Start", required=True)
date_to = fields.Date(string="Period End", required=True)
state = fields.Selection(
[
("draft", "Draft"),
("to_file", "To File"),
("filed", "Filed"),
("cancelled", "Cancelled"),
],
default="draft",
required=True,
tracking=True,
)
filing_date = fields.Date(string="Filed On")
filing_reference = fields.Char(string="Confirmation #")
notes = fields.Text()
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get("name", _("New")) == _("New"):
vals["name"] = self.env["ir.sequence"].next_by_code(
"fusion.tax.return"
) or _("New")
return super().create(vals_list)
@api.constrains("date_from", "date_to")
def _check_period(self):
for r in self:
if r.date_from and r.date_to and r.date_from > r.date_to:
raise UserError(_("Period start must precede period end."))
def action_mark_filed(self):
self.ensure_one()
if self.state != "to_file":
raise UserError(_("Can only mark 'To File' returns as filed."))
self.write(
{
"state": "filed",
"filing_date": fields.Date.context_today(self),
}
)
return True

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_tax_return_user,fusion.tax.return.user,model_fusion_tax_return,base.group_user,1,0,0,0
access_fusion_tax_return_manager,fusion.tax.return.manager,model_fusion_tax_return,account.group_account_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_tax_return_user fusion.tax.return.user model_fusion_tax_return base.group_user 1 0 0 0
3 access_fusion_tax_return_manager fusion.tax.return.manager model_fusion_tax_return account.group_account_manager 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1 @@
from . import test_l10n_ca

View File

@@ -0,0 +1,36 @@
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install")
class TestL10nCa(TransactionCase):
def test_canadian_reports_seeded(self):
Report = self.env["fusion.report"].sudo()
ca_bs = Report.search([("code", "=", "ca_balance_sheet")], limit=1)
ca_pl = Report.search([("code", "=", "ca_profit_loss")], limit=1)
self.assertTrue(ca_bs, "ca_balance_sheet not seeded")
self.assertTrue(ca_pl, "ca_profit_loss not seeded")
self.assertEqual(ca_bs.report_type, "balance_sheet")
self.assertEqual(ca_pl.report_type, "pnl")
def test_canadian_pnl_runs_via_engine(self):
from odoo.addons.fusion_accounting_reports.services.date_periods import Period
period = Period(date(2025, 1, 1), date(2025, 12, 31), "FY 2025")
result = self.env["fusion.report.engine"].compute_pnl(
period, report_code="ca_profit_loss",
)
self.assertEqual(result["report_name"], "Profit and Loss (Canada)")
self.assertGreater(len(result["rows"]), 0)
def test_tax_return_create(self):
ret = self.env["fusion.tax.return"].create({
"return_type": "gst_hst",
"date_from": date(2025, 1, 1),
"date_to": date(2025, 3, 31),
})
self.assertNotEqual(ret.name, "New")
self.assertEqual(ret.state, "draft")

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Canadian-flavored P&L + BS menus. Live alongside the rest of the
Fusion reports under Accounting > Reporting. Open the OWL
ReportViewer with the Canadian report_code so the line_specs
come from the Canadian definitions seeded in this module.
-->
<record id="action_fusion_report_ca_pnl" model="ir.actions.act_window">
<field name="name">Profit and Loss (Canada)</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'pnl', 'default_report_code': 'ca_profit_loss', 'default_comparison': 'previous_year'}</field>
</record>
<record id="action_fusion_report_ca_bs" model="ir.actions.act_window">
<field name="name">Balance Sheet (Canada)</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'balance_sheet', 'default_report_code': 'ca_balance_sheet', 'default_comparison': 'previous_period'}</field>
</record>
<menuitem id="menu_fusion_report_ca_pnl"
name="Profit and Loss (Canada)"
parent="account.menu_finance_reports"
action="action_fusion_report_ca_pnl"
sequence="15"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_report_ca_bs"
name="Balance Sheet (Canada)"
parent="account.menu_finance_reports"
action="action_fusion_report_ca_bs"
sequence="16"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Tax-return tracking list -->
<record id="action_fusion_tax_return" model="ir.actions.act_window">
<field name="name">Tax Returns</field>
<field name="res_model">fusion.tax.return</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">No tax returns recorded yet</p>
<p>Track GST/HST/PST/T4/T5018/payroll-remittance filings.
Each return covers a (date_from, date_to) window and moves
from draft \u2192 to-file \u2192 filed as you submit it.</p>
</field>
</record>
<menuitem id="menu_fusion_tax_return"
name="Tax Returns (CA)"
parent="account.menu_finance_configuration"
action="action_fusion_tax_return"
sequence="100"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
</odoo>

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Migration',
'version': '19.0.1.0.0',
'version': '19.0.1.1.0',
'category': 'Accounting/Accounting',
'sequence': 27,
'summary': 'Transitional module: migrates Odoo Enterprise accounting data to Fusion Accounting tables before Enterprise uninstall.',

View File

@@ -27,21 +27,29 @@
</record>
<!--
Top-level "Fusion Accounting" menu so the UserError guidance
("Fusion Accounting -> Migrate from Enterprise") is actually reachable.
Placed at top level (no parent) because the migration is a one-time
admin task; making it visible during switchover is the point.
`groups` hides the menu from non-admins (mirroring the ACL on the wizard).
Migration wizard lives under Accounting > Configuration, and is
ONLY visible while at least one Enterprise accounting module is
still installed. Once the operator has uninstalled Enterprise, the
wizard is hidden \u2014 there's nothing left to migrate.
Visibility is gated by the intersection of:
- group_fusion_accounting_admin (admin-only feature)
- group_fusion_show_when_enterprise_present (computed: members
iff at least one Enterprise accounting module is installed)
-->
<!-- Note: gating uses ONLY group_fusion_show_when_enterprise_present.
Admin-restriction is enforced via the model ACL
(ir.model.access.csv only grants access to group_fusion_accounting_admin).
Odoo `groups=` on menuitems uses OR semantics, so listing both groups
would let any admin see the menu even after Enterprise is uninstalled. -->
<menuitem id="menu_fusion_migration_root"
name="Fusion Accounting"
sequence="95"
web_icon="fusion_accounting_migration,static/description/icon.png"
groups="fusion_accounting_core.group_fusion_accounting_admin"/>
<menuitem id="menu_fusion_migration_wizard"
name="Migrate from Enterprise"
parent="account.menu_finance_configuration"
sequence="95"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_present"/>
<menuitem id="menu_fusion_migration_wizard"
name="Run Migration Wizard"
parent="menu_fusion_migration_root"
action="action_fusion_migration_wizard"
sequence="10"
groups="fusion_accounting_core.group_fusion_accounting_admin"/>
groups="fusion_accounting_core.group_fusion_show_when_enterprise_present"/>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@@ -0,0 +1,39 @@
{
'name': 'Fusion Accounting — Invoice OCR',
'version': '19.0.1.0.0',
'category': 'Accounting/Accounting',
'summary': 'OCR for vendor bills via tesseract + LLM-driven field extraction.',
'description': """
Fusion Accounting — Invoice OCR
================================
Replaces Enterprise's account_invoice_extract with a Fusion-native pipeline:
1. Tesseract OCRs the bill attachment (PDF or image) into raw text
2. The fusion_accounting_ai LLMProvider parses the raw text into structured
fields (vendor, invoice number, dates, amounts, line items)
3. Draft invoice fields are populated for the AP user to confirm
Pluggable backend architecture: future Mindee, Google Document AI, or
Ollama-vision adapters can be dropped in alongside the default tesseract
adapter.
""",
'icon': '/fusion_accounting_ocr/static/description/icon.png',
'author': 'Westin / Fusion Suite',
'depends': [
'fusion_accounting_core',
'fusion_accounting_ai',
'account',
],
'external_dependencies': {
'python': ['pytesseract', 'pdf2image', 'PIL'],
},
'data': [
'security/ir.model.access.csv',
'views/account_move_views.xml',
'views/res_config_settings_views.xml',
],
'auto_install': False,
'installable': True,
'application': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1 @@
from . import ocr_controller

View File

@@ -0,0 +1,21 @@
from odoo import http
from odoo.http import request
class FusionOcrController(http.Controller):
@http.route('/fusion/ocr/request_for_invoice', type='jsonrpc', auth='user')
def request_for_invoice(self, move_id):
move = request.env['account.move'].browse(int(move_id))
move.check_access('write')
try:
move.action_request_ocr()
return {
'status': 'ok',
'state': move.ocr_state,
'backend': move.ocr_backend,
'confidence': move.ocr_confidence,
'extracted': move.ocr_extracted_data,
}
except Exception as e:
return {'status': 'error', 'message': str(e)}

View File

@@ -0,0 +1,4 @@
from . import fusion_ocr_log
from . import res_company
from . import res_config_settings
from . import account_move

View File

@@ -0,0 +1,180 @@
"""account.move OCR extension.
Adds an OCR pipeline triggered manually (or, optionally, automatically when
a PDF/image is attached). Stage 1 is tesseract text extraction; stage 2 is
LLM field parsing through the existing fusion_accounting_ai adapter stack.
"""
import base64
import logging
from odoo import _, fields, models
from odoo.exceptions import UserError
from ..services.ocr_providers.tesseract_adapter import TesseractAdapter
from ..services.ocr_providers.manual_adapter import ManualAdapter
from ..services.invoice_field_parser import parse_invoice_fields
_logger = logging.getLogger(__name__)
SUPPORTED_MIMETYPES = (
'application/pdf', 'image/png', 'image/jpeg', 'image/jpg',
)
class AccountMove(models.Model):
_inherit = 'account.move'
ocr_state = fields.Selection(
[
('not_requested', 'Not Requested'),
('pending', 'Pending'),
('processing', 'Processing'),
('done', 'Done'),
('failed', 'Failed'),
('manual', 'Manual Entry'),
],
default='not_requested',
copy=False,
tracking=True,
)
ocr_raw_text = fields.Text(
string='OCR Raw Text', readonly=True, copy=False,
help="Raw text extracted by the OCR backend.",
)
ocr_extracted_data = fields.Json(
string='OCR Extracted Fields', readonly=True, copy=False,
help="Structured invoice fields parsed from the OCR text by the LLM.",
)
ocr_backend = fields.Char(string='OCR Backend Used', readonly=True, copy=False)
ocr_confidence = fields.Float(string='OCR Confidence', readonly=True, copy=False)
ocr_log_ids = fields.One2many('fusion.ocr.log', 'move_id', string='OCR Runs')
def action_request_ocr(self):
"""Run OCR on the most recent supported attachment of each move."""
for move in self:
if move.move_type not in ('in_invoice', 'in_refund'):
raise UserError(_("OCR currently supports vendor bills only."))
attachment = self.env['ir.attachment'].sudo().search(
[
('res_model', '=', 'account.move'),
('res_id', '=', move.id),
('mimetype', 'in', SUPPORTED_MIMETYPES),
],
order='create_date desc',
limit=1,
)
if not attachment:
raise UserError(
_("No PDF or image attachment found on %s") % (move.name or move.id)
)
move._fusion_run_ocr(attachment)
return True
def _fusion_run_ocr(self, attachment):
self.ensure_one()
self.ocr_state = 'processing'
backend_name = (
self.company_id.fusion_ocr_default_backend
if 'fusion_ocr_default_backend' in self.company_id._fields
else 'tesseract'
)
provider = self._fusion_get_ocr_provider(backend_name)
if not provider:
self.ocr_state = 'manual'
self.message_post(
body=_("No OCR backend available; falling back to manual entry.")
)
return False
try:
data = base64.b64decode(attachment.datas)
result = provider.extract(
data, mimetype=attachment.mimetype or 'application/pdf'
)
self.write({
'ocr_raw_text': result.raw_text,
'ocr_backend': result.backend,
'ocr_confidence': result.confidence,
})
self.env['fusion.ocr.log'].sudo().create({
'move_id': self.id,
'backend': result.backend,
'confidence': result.confidence,
'raw_text_length': len(result.raw_text or ''),
'pages': result.pages,
'error': result.error,
})
if not result.raw_text and result.error:
self.ocr_state = 'failed'
self.message_post(body=_("OCR failed: %s") % result.error)
return False
parsed = parse_invoice_fields(self.env, result.raw_text)
self.ocr_extracted_data = parsed
self.ocr_state = 'done'
self._fusion_apply_ocr_fields(parsed)
self.message_post(
body=_("OCR complete: %s confidence %.0f%%") % (
result.backend, (result.confidence or 0) * 100,
)
)
return True
except Exception as e:
_logger.exception("OCR run failed for move %s", self.id)
self.ocr_state = 'failed'
self.message_post(body=_("OCR error: %s") % e)
return False
def _fusion_get_ocr_provider(self, backend_name):
if backend_name == 'tesseract' and TesseractAdapter.is_available():
return TesseractAdapter()
if backend_name == 'manual':
return ManualAdapter()
# Future adapters (mindee, google_doc_ai, ollama_vision) plug in
# here. Fall back to whichever adapter is actually usable.
if TesseractAdapter.is_available():
return TesseractAdapter()
return ManualAdapter()
def _fusion_apply_ocr_fields(self, parsed):
"""Apply parsed fields to a draft invoice without overwriting any
user-entered data. No-op on posted/cancelled invoices."""
if self.state != 'draft':
return
vals = {}
if parsed.get('invoice_date') and not self.invoice_date:
try:
vals['invoice_date'] = parsed['invoice_date']
except Exception:
pass
if parsed.get('due_date') and not self.invoice_date_due:
try:
vals['invoice_date_due'] = parsed['due_date']
except Exception:
pass
if parsed.get('invoice_number') and not self.ref:
vals['ref'] = parsed['invoice_number']
# Vendor: best-effort name match against existing supplier partners.
# Never auto-create a partner; AP user confirms ambiguous matches.
if parsed.get('vendor_name') and not self.partner_id:
partner = self.env['res.partner'].sudo().search(
[
('name', '=ilike', parsed['vendor_name']),
('supplier_rank', '>', 0),
],
limit=1,
)
if partner:
vals['partner_id'] = partner.id
if vals:
self.write(vals)

View File

@@ -0,0 +1,17 @@
from odoo import fields, models
class FusionOcrLog(models.Model):
_name = 'fusion.ocr.log'
_description = 'Fusion OCR Run Log'
_order = 'create_date desc'
move_id = fields.Many2one(
'account.move', required=True, ondelete='cascade', index=True,
)
backend = fields.Char(required=True)
confidence = fields.Float()
raw_text_length = fields.Integer()
pages = fields.Integer()
error = fields.Text()
create_date = fields.Datetime(readonly=True)

View File

@@ -0,0 +1,26 @@
from odoo import fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
fusion_ocr_enabled = fields.Boolean(
string='Enable Invoice OCR',
default=False,
help="When enabled, vendor bill attachments can be OCR'd via the "
"configured backend.",
)
fusion_ocr_default_backend = fields.Selection(
[
('tesseract', 'Tesseract (local, free)'),
('manual', 'Manual entry only'),
],
default='tesseract',
string='Default OCR Backend',
)
fusion_ocr_auto_run = fields.Boolean(
string='Auto-run OCR on attachment',
default=False,
help="When enabled, OCR runs automatically when a PDF/image is "
"attached to a vendor bill.",
)

View File

@@ -0,0 +1,15 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
fusion_ocr_enabled = fields.Boolean(
related='company_id.fusion_ocr_enabled', readonly=False,
)
fusion_ocr_default_backend = fields.Selection(
related='company_id.fusion_ocr_default_backend', readonly=False,
)
fusion_ocr_auto_run = fields.Boolean(
related='company_id.fusion_ocr_auto_run', readonly=False,
)

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_ocr_log_user,fusion.ocr.log.user,model_fusion_ocr_log,base.group_user,1,0,0,0
access_fusion_ocr_log_manager,fusion.ocr.log.manager,model_fusion_ocr_log,account.group_account_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_ocr_log_user fusion.ocr.log.user model_fusion_ocr_log base.group_user 1 0 0 0
3 access_fusion_ocr_log_manager fusion.ocr.log.manager model_fusion_ocr_log account.group_account_manager 1 1 1 1

View File

@@ -0,0 +1,3 @@
from . import ocr_providers
from . import attachment_to_image
from . import invoice_field_parser

View File

@@ -0,0 +1,43 @@
"""Helper: turn an ir.attachment into a list of PIL.Image pages.
Kept separate from the adapters so future backends (Ollama-vision, Mindee)
that want PIL images directly don't have to re-implement the PDF rendering.
"""
import base64
import io
import logging
_logger = logging.getLogger(__name__)
def attachment_to_pages(attachment):
"""Decode an ir.attachment into a list of PIL.Image pages.
Returns ``[]`` on failure (caller should treat as no pages).
"""
try:
from PIL import Image
from pdf2image import convert_from_bytes
except ImportError as e:
_logger.warning("attachment_to_pages requires PIL + pdf2image: %s", e)
return []
if not attachment or not attachment.datas:
return []
try:
data = base64.b64decode(attachment.datas)
except Exception as e:
_logger.warning("Could not decode attachment %s: %s", attachment.id, e)
return []
mimetype = attachment.mimetype or ''
is_pdf = mimetype == 'application/pdf' or data[:4] == b'%PDF'
try:
if is_pdf:
return convert_from_bytes(data, dpi=200)
return [Image.open(io.BytesIO(data))]
except Exception as e:
_logger.warning("Could not render attachment %s: %s", attachment.id, e)
return []

View File

@@ -0,0 +1,150 @@
"""Stage-2 of the OCR pipeline: parse raw OCR text into structured invoice
fields via the configured LLM provider.
Mirrors the pattern in fusion_accounting_followup/services/followup_text_generator.py:
look up an adapter by ir.config_parameter, fall back gracefully when no
provider is configured, and never let an LLM hiccup nuke the OCR result.
"""
import json
import logging
_logger = logging.getLogger(__name__)
SYSTEM_PROMPT = (
"You are an invoice field extraction assistant. You read raw OCR text "
"from vendor bills and return a strict JSON object with the requested "
"fields. You never include commentary or markdown fences. When a field "
"cannot be determined from the text you return null for that field."
)
USER_PROMPT = """Given the raw OCR text of a vendor bill, return a JSON object
with these fields (use null when unclear):
{{
"vendor_name": <string, the seller/vendor company name>,
"invoice_number": <string, the bill or invoice reference number>,
"invoice_date": <string, ISO format YYYY-MM-DD>,
"due_date": <string or null, ISO format YYYY-MM-DD>,
"currency": <string, ISO 4217 code like CAD/USD/EUR>,
"subtotal": <number or null>,
"tax_total": <number or null>,
"total": <number, the grand total amount due>,
"line_items": [
{{"description": <string>, "quantity": <number or null>,
"unit_price": <number or null>, "amount": <number or null>}}
]
}}
Return ONLY valid JSON, no commentary, no markdown fences.
Raw OCR text:
---
{text}
---
"""
def parse_invoice_fields(env, raw_text: str, *, provider=None) -> dict:
"""Use the configured LLM provider to extract structured invoice fields.
Returns a dict with the schema above. On any failure (no provider, bad
JSON, network error, etc.) returns an all-null result so the OCR raw
text is still preserved for the AP user.
"""
if not raw_text or not raw_text.strip():
return _empty_result()
if provider is None:
provider = _get_provider(env)
if provider is None:
_logger.info(
"No LLM provider configured for OCR field parsing; "
"raw OCR text preserved, fields left empty."
)
return _empty_result()
try:
truncated = raw_text[:12000]
user = USER_PROMPT.format(text=truncated)
response = provider.complete(
system=SYSTEM_PROMPT,
messages=[{'role': 'user', 'content': user}],
max_tokens=1000,
temperature=0.1,
)
content = response.get('content') if isinstance(response, dict) else response
if not content:
return _empty_result()
# LLMs sometimes wrap JSON in ```json ... ``` despite instructions.
content = content.strip()
if content.startswith('```'):
content = content.split('```', 2)[1]
if content.startswith('json'):
content = content[4:]
content = content.rsplit('```', 1)[0]
parsed = json.loads(content.strip())
return {
'vendor_name': parsed.get('vendor_name'),
'invoice_number': parsed.get('invoice_number'),
'invoice_date': parsed.get('invoice_date'),
'due_date': parsed.get('due_date'),
'currency': parsed.get('currency'),
'subtotal': parsed.get('subtotal'),
'tax_total': parsed.get('tax_total'),
'total': parsed.get('total'),
'line_items': parsed.get('line_items') or [],
}
except json.JSONDecodeError as e:
_logger.warning("LLM returned non-JSON for OCR field parsing: %s", e)
return _empty_result()
except Exception as e:
_logger.warning("OCR field parsing failed: %s", e)
return _empty_result()
def _empty_result():
return {
'vendor_name': None,
'invoice_number': None,
'invoice_date': None,
'due_date': None,
'currency': None,
'subtotal': None,
'tax_total': None,
'total': None,
'line_items': [],
}
def _get_provider(env):
"""Look up the LLM adapter via ir.config_parameter.
Honours a feature-specific override
(``fusion_accounting.provider.ocr_field_parsing``) before falling back
to the suite-wide default (``fusion_accounting.provider.default``).
Returns None when no adapter is configured/importable.
"""
param = env['ir.config_parameter'].sudo()
name = param.get_param('fusion_accounting.provider.ocr_field_parsing')
if not name:
name = param.get_param('fusion_accounting.provider.default')
if not name:
return None
try:
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
except ImportError:
return None
try:
if name.startswith('openai'):
return OpenAIAdapter(env)
if name.startswith('claude'):
return ClaudeAdapter(env)
except Exception as e:
_logger.warning("OCR field parser could not instantiate %s: %s", name, e)
return None
return None

View File

@@ -0,0 +1,3 @@
from . import base
from . import tesseract_adapter
from . import manual_adapter

View File

@@ -0,0 +1,40 @@
"""OCRProvider contract - every backend must conform.
Mirrors the LLMProvider pattern in fusion_accounting_ai. Future adapters
(Mindee, Google Document AI, Ollama-vision) drop in alongside the default
tesseract adapter without touching account.move.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@dataclass
class OCRResult:
raw_text: str = ''
confidence: float = 0.0 # 0.01.0
pages: int = 0
backend: str = ''
error: str = ''
metadata: dict = field(default_factory=dict)
class OCRProvider(ABC):
"""Abstract OCR backend. Subclasses implement extract()."""
name: str = 'base'
@abstractmethod
def extract(self, image_or_pdf_bytes: bytes, *, mimetype: str = 'application/pdf') -> OCRResult:
"""Extract text from raw bytes.
``mimetype`` hints whether to PDF-render (poppler) or image-decode
(PIL) the bytes. Implementations should still inspect the byte
signature for safety.
"""
...
@classmethod
def is_available(cls) -> bool:
"""Return True if the backend's runtime deps are present."""
return True

View File

@@ -0,0 +1,13 @@
"""Manual fallback adapter - no real OCR, just marks the document as
'awaiting manual entry'. Used when no real OCR backend is available
or when the user explicitly disables OCR.
"""
from .base import OCRProvider, OCRResult
class ManualAdapter(OCRProvider):
name = 'manual'
def extract(self, image_or_pdf_bytes, *, mimetype='application/pdf'):
return OCRResult(raw_text='', confidence=0.0, pages=0, backend='manual')

View File

@@ -0,0 +1,71 @@
"""Tesseract OCR adapter.
Uses the system tesseract binary via pytesseract, with poppler-backed
PDF rendering via pdf2image. Inside the container these are pre-installed:
- tesseract-ocr 5.3.4
- pytesseract 0.3.13
- pdf2image 1.17.0
- poppler-utils
"""
import io
import logging
from .base import OCRProvider, OCRResult
_logger = logging.getLogger(__name__)
class TesseractAdapter(OCRProvider):
name = 'tesseract'
@classmethod
def is_available(cls) -> bool:
try:
import pytesseract
from pdf2image import convert_from_bytes # noqa: F401
from PIL import Image # noqa: F401
pytesseract.get_tesseract_version()
return True
except Exception as e:
_logger.debug("TesseractAdapter not available: %s", e)
return False
def extract(self, image_or_pdf_bytes, *, mimetype='application/pdf'):
import pytesseract
from pdf2image import convert_from_bytes
from PIL import Image
try:
is_pdf = (
mimetype == 'application/pdf'
or (image_or_pdf_bytes[:4] == b'%PDF')
)
if is_pdf:
pages = convert_from_bytes(image_or_pdf_bytes, dpi=200)
else:
img = Image.open(io.BytesIO(image_or_pdf_bytes))
pages = [img]
texts = []
for p in pages:
texts.append(pytesseract.image_to_string(p))
full_text = '\n\f\n'.join(texts)
# Heuristic confidence - tesseract has a per-word conf in
# image_to_data, but a length proxy is fine for routing
# decisions. Future: use pytesseract.image_to_data for a real
# average word-level confidence.
conf = min(1.0, len(full_text) / 1000.0)
return OCRResult(
raw_text=full_text,
confidence=conf,
pages=len(pages),
backend='tesseract',
)
except Exception as e:
_logger.warning("Tesseract OCR failed: %s", e)
return OCRResult(
raw_text='', confidence=0.0, pages=0,
backend='tesseract', error=str(e),
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,3 @@
from . import test_tesseract_adapter
from . import test_invoice_ocr_flow
from . import test_field_parser

View File

@@ -0,0 +1,74 @@
from unittest.mock import MagicMock
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.addons.fusion_accounting_ocr.services.invoice_field_parser import (
parse_invoice_fields,
)
@tagged('post_install', '-at_install')
class TestFieldParser(TransactionCase):
def test_parser_handles_empty_text(self):
result = parse_invoice_fields(self.env, '')
self.assertIsNone(result['total'])
self.assertEqual(result['line_items'], [])
def test_parser_handles_no_provider_gracefully(self):
# Without an LLM provider configured, parse should return an empty
# result dict rather than crashing.
result = parse_invoice_fields(self.env, 'INVOICE 12345 Total $100')
self.assertIn('total', result)
self.assertIn('line_items', result)
self.assertIsInstance(result['line_items'], list)
def test_parser_consumes_clean_json(self):
provider = MagicMock()
provider.complete.return_value = {
'content': (
'{"vendor_name": "Acme Co", "invoice_number": "INV-1",'
' "invoice_date": "2026-04-20", "due_date": null,'
' "currency": "CAD", "subtotal": 90.0, "tax_total": 10.0,'
' "total": 100.0, "line_items": ['
'{"description": "Widget", "quantity": 1, "unit_price": 90.0,'
' "amount": 90.0}]}'
),
}
result = parse_invoice_fields(self.env, 'raw text', provider=provider)
self.assertEqual(result['vendor_name'], 'Acme Co')
self.assertEqual(result['invoice_number'], 'INV-1')
self.assertEqual(result['total'], 100.0)
self.assertEqual(len(result['line_items']), 1)
self.assertEqual(result['line_items'][0]['description'], 'Widget')
def test_parser_strips_markdown_fences(self):
provider = MagicMock()
provider.complete.return_value = {
'content': (
'```json\n'
'{"vendor_name": "Beta Ltd", "invoice_number": "B-2",'
' "invoice_date": null, "due_date": null, "currency": null,'
' "subtotal": null, "tax_total": null, "total": 5.5,'
' "line_items": []}\n'
'```'
),
}
result = parse_invoice_fields(self.env, 'raw text', provider=provider)
self.assertEqual(result['vendor_name'], 'Beta Ltd')
self.assertEqual(result['total'], 5.5)
def test_parser_returns_empty_on_invalid_json(self):
provider = MagicMock()
provider.complete.return_value = {'content': 'not json at all'}
result = parse_invoice_fields(self.env, 'raw text', provider=provider)
self.assertIsNone(result['total'])
self.assertEqual(result['line_items'], [])
def test_parser_returns_empty_on_provider_exception(self):
provider = MagicMock()
provider.complete.side_effect = RuntimeError('boom')
result = parse_invoice_fields(self.env, 'raw text', provider=provider)
self.assertIsNone(result['total'])
self.assertEqual(result['line_items'], [])

View File

@@ -0,0 +1,117 @@
import base64
import io
from unittest.mock import patch
from PIL import Image, ImageDraw
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestInvoiceOcrFlow(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'Test Vendor',
'supplier_rank': 1,
})
self.move = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner.id,
})
def test_ocr_state_default(self):
self.assertEqual(self.move.ocr_state, 'not_requested')
def test_action_request_ocr_no_attachment_raises(self):
with self.assertRaises(UserError):
self.move.action_request_ocr()
def test_action_request_ocr_with_image(self):
img = Image.new('RGB', (800, 120), color='white')
draw = ImageDraw.Draw(img)
try:
from PIL import ImageFont
font = ImageFont.truetype(
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 36,
)
except Exception:
font = None
draw.text((20, 30), "TOTAL $50.00 INV-9999", fill='black', font=font)
buf = io.BytesIO()
img.save(buf, format='PNG')
self.env['ir.attachment'].create({
'name': 'test_invoice.png',
'datas': base64.b64encode(buf.getvalue()),
'res_model': 'account.move',
'res_id': self.move.id,
'mimetype': 'image/png',
})
# Mock the LLM call to avoid a real API roundtrip.
with patch(
'odoo.addons.fusion_accounting_ocr.models.account_move.parse_invoice_fields',
return_value={
'vendor_name': None,
'invoice_number': 'INV-9999',
'invoice_date': None,
'due_date': None,
'currency': None,
'subtotal': None,
'tax_total': None,
'total': 50.0,
'line_items': [],
},
):
self.move.action_request_ocr()
self.assertEqual(self.move.ocr_state, 'done')
self.assertEqual(self.move.ocr_backend, 'tesseract')
self.assertGreater(self.move.ocr_confidence, 0)
self.assertIsNotNone(self.move.ocr_extracted_data)
# Parsed invoice_number should land on the invoice's ref field.
self.assertEqual(self.move.ref, 'INV-9999')
# OCR log row was created.
self.assertEqual(len(self.move.ocr_log_ids), 1)
log = self.move.ocr_log_ids
self.assertEqual(log.backend, 'tesseract')
self.assertGreater(log.raw_text_length, 0)
def test_apply_does_not_overwrite_user_entered_ref(self):
self.move.ref = 'USER-SET-REF'
img = Image.new('RGB', (400, 80), color='white')
ImageDraw.Draw(img).text((10, 30), "INV-7777", fill='black')
buf = io.BytesIO()
img.save(buf, format='PNG')
self.env['ir.attachment'].create({
'name': 't.png',
'datas': base64.b64encode(buf.getvalue()),
'res_model': 'account.move',
'res_id': self.move.id,
'mimetype': 'image/png',
})
with patch(
'odoo.addons.fusion_accounting_ocr.models.account_move.parse_invoice_fields',
return_value={
'vendor_name': None, 'invoice_number': 'INV-7777',
'invoice_date': None, 'due_date': None, 'currency': None,
'subtotal': None, 'tax_total': None, 'total': None,
'line_items': [],
},
):
self.move.action_request_ocr()
# User-entered ref must not be overwritten.
self.assertEqual(self.move.ref, 'USER-SET-REF')
def test_only_vendor_bills_supported(self):
customer_invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner.id,
})
with self.assertRaises(UserError):
customer_invoice.action_request_ocr()

View File

@@ -0,0 +1,47 @@
import io
from PIL import Image, ImageDraw
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.addons.fusion_accounting_ocr.services.ocr_providers.tesseract_adapter import (
TesseractAdapter,
)
@tagged('post_install', '-at_install')
class TestTesseractAdapter(TransactionCase):
def test_is_available(self):
# In our container tesseract + pytesseract + pdf2image are pre-installed.
self.assertTrue(TesseractAdapter.is_available())
def test_extract_simple_text_image(self):
# Generate a tiny PNG with the text "INVOICE 12345 Total $100".
# Use a slightly larger image and try to load a TTF font for
# tesseract reliability; fall back to default bitmap font otherwise.
img = Image.new('RGB', (800, 120), color='white')
draw = ImageDraw.Draw(img)
try:
from PIL import ImageFont
font = ImageFont.truetype(
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 36,
)
except Exception:
font = None
draw.text((20, 30), "INVOICE 12345 Total $100", fill='black', font=font)
buf = io.BytesIO()
img.save(buf, format='PNG')
png_bytes = buf.getvalue()
adapter = TesseractAdapter()
result = adapter.extract(png_bytes, mimetype='image/png')
self.assertEqual(result.backend, 'tesseract')
self.assertEqual(result.error, '')
self.assertEqual(result.pages, 1)
self.assertGreater(len(result.raw_text), 0)
# Tesseract should pick up the digits at minimum.
self.assertIn('12345', result.raw_text.replace(' ', ''))

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_move_form_inherit_fusion_ocr" model="ir.ui.view">
<field name="name">account.move.form.inherit.fusion_ocr</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_request_ocr"
type="object"
string="Request OCR"
class="oe_highlight"
invisible="move_type not in ('in_invoice', 'in_refund') or ocr_state in ('processing', 'done')"/>
<button name="action_request_ocr"
type="object"
string="Re-run OCR"
invisible="move_type not in ('in_invoice', 'in_refund') or ocr_state not in ('done', 'failed', 'manual')"/>
</xpath>
<xpath expr="//sheet" position="inside">
<group string="Fusion OCR"
invisible="move_type not in ('in_invoice', 'in_refund') or ocr_state == 'not_requested'">
<group>
<field name="ocr_state" widget="badge"
decoration-success="ocr_state == 'done'"
decoration-info="ocr_state == 'processing'"
decoration-warning="ocr_state == 'manual'"
decoration-danger="ocr_state == 'failed'"/>
<field name="ocr_backend" readonly="1"/>
<field name="ocr_confidence" readonly="1" widget="percentage"/>
</group>
<group>
<field name="ocr_extracted_data" readonly="1" widget="text"/>
</group>
<field name="ocr_raw_text" readonly="1" nolabel="1"
placeholder="Raw OCR text..."/>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_inherit_fusion_ocr" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.fusion_ocr</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@id='account_vendor_bills']" position="after">
<block title="Fusion Invoice OCR" id="fusion_ocr_section">
<setting id="fusion_ocr_enabled_setting"
string="Enable Invoice OCR"
help="OCR vendor bill attachments via the configured backend.">
<field name="fusion_ocr_enabled"/>
<div class="content-group" invisible="not fusion_ocr_enabled">
<div class="mt16">
<label for="fusion_ocr_default_backend"
string="Default OCR Backend" class="o_light_label"/>
<field name="fusion_ocr_default_backend"/>
</div>
<div class="mt16">
<field name="fusion_ocr_auto_run"/>
<label for="fusion_ocr_auto_run"
string="Auto-run OCR on attachment"/>
</div>
</div>
</setting>
</block>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Reports',
'version': '19.0.1.0.38',
'version': '19.0.1.1.1',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
'description': """
@@ -47,6 +47,7 @@ menu hides; the engine and AI tools remain available for the chat.
'reports/report_pdf_template.xml',
'wizards/xlsx_export_wizard_views.xml',
'wizards/period_picker_wizard_views.xml',
'views/report_actions.xml',
'views/menu_views.xml',
],
'external_dependencies': {

View File

@@ -15,9 +15,11 @@ export class PeriodFilter extends Component {
async onReportTypeChange(ev) {
const reportType = ev.target.value;
if (reportType && this.state.dateFrom && this.state.dateTo) {
// Switching report type clears the report_code (user is picking
// a different category, not a variant).
await this.reports.runReport(
reportType, this.state.dateFrom, this.state.dateTo,
this.state.comparison);
this.state.comparison, null);
}
}
@@ -27,7 +29,8 @@ export class PeriodFilter extends Component {
await this.reports.runReport(
this.state.currentReportType,
this.state.dateFrom, this.state.dateTo,
this.state.comparison);
this.state.comparison,
this.state.currentReportCode);
}
}

View File

@@ -16,6 +16,7 @@ export class ReportsService {
this.state = reactive({
availableReports: [],
currentReportType: null,
currentReportCode: null,
currentResult: null,
currentAnomalies: [],
currentCommentary: null,
@@ -41,15 +42,17 @@ export class ReportsService {
}
}
async runReport(reportType, dateFrom, dateTo, comparison = 'none') {
async runReport(reportType, dateFrom, dateTo, comparison = 'none', reportCode = null) {
this.state.isLoading = true;
this.state.currentReportType = reportType;
this.state.currentReportCode = reportCode;
this.state.dateFrom = dateFrom;
this.state.dateTo = dateTo;
this.state.comparison = comparison;
try {
this.state.currentResult = await this.rpc(`${ENDPOINT_BASE}/run`, {
report_type: reportType,
report_code: reportCode,
date_from: dateFrom,
date_to: dateTo,
comparison: comparison,
@@ -136,7 +139,8 @@ export class ReportsService {
this.state.comparison = mode;
if (this.state.currentReportType) {
return this.runReport(this.state.currentReportType,
this.state.dateFrom, this.state.dateTo, mode);
this.state.dateFrom, this.state.dateTo, mode,
this.state.currentReportCode);
}
}
}

View File

@@ -22,6 +22,11 @@ export class ReportViewer extends Component {
const ctx = this.props.action?.context || {};
const reportType = ctx.default_report_type || 'pnl';
// default_report_code lets multiple reports of the same type
// (e.g. pnl, cash_flow, executive_summary, annual_statements all
// type='pnl') resolve to their own line_specs.
const reportCode = ctx.default_report_code || null;
const comparison = ctx.default_comparison || 'none';
const companyId = this.env.services.user?.context?.allowed_company_ids?.[0];
onWillStart(async () => {
@@ -29,7 +34,8 @@ export class ReportViewer extends Component {
const today = new Date();
const year = today.getFullYear();
await this.reports.runReport(
reportType, `${year}-01-01`, `${year}-12-31`, 'none');
reportType, `${year}-01-01`, `${year}-12-31`,
comparison, reportCode);
});
}

View File

@@ -1,21 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_fusion_reports_root"
name="Financial Reports"
sequence="50"
web_icon="fusion_accounting_reports,static/description/icon.png"
<!--
Fusion dynamic financial reports live as direct children of the
Community "Reporting" sub-menu (account.menu_finance_reports),
sitting alongside Community's PDF-based "Statement Reports" /
"Partner Reports" / "Taxes & Fiscal" / "Management" wrappers.
Each menu opens an act_window with view_mode='fusion_reports'
(the OWL ReportViewer). report_actions.xml defines the actions.
All gated to the coexistence group so they only appear when
Enterprise's account_reports is uninstalled.
-->
<!-- Top of the list \u2014 daily-driver statements -->
<menuitem id="menu_fusion_reports_pnl"
name="Profit and Loss"
parent="account.menu_finance_reports"
action="action_fusion_report_pnl"
sequence="10"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_bs"
name="Balance Sheet"
parent="account.menu_finance_reports"
action="action_fusion_report_balance_sheet"
sequence="11"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_executive_summary"
name="Executive Summary"
parent="account.menu_finance_reports"
action="action_fusion_report_executive_summary"
sequence="12"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_cash_flow"
name="Cash Flow Statement"
parent="account.menu_finance_reports"
action="action_fusion_report_cash_flow"
sequence="13"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_annual_statements"
name="Annual Statements"
parent="account.menu_finance_reports"
action="action_fusion_report_annual_statements"
sequence="14"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Audit / drill-down statements -->
<menuitem id="menu_fusion_reports_tb"
name="Trial Balance"
parent="account.menu_finance_reports"
action="action_fusion_report_trial_balance"
sequence="20"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_gl"
name="General Ledger"
parent="account.menu_finance_reports"
action="action_fusion_report_general_ledger"
sequence="21"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Partner-grouped -->
<menuitem id="menu_fusion_reports_aged_receivable"
name="Aged Receivable"
parent="account.menu_finance_reports"
action="action_fusion_report_aged_receivable"
sequence="30"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_aged_payable"
name="Aged Payable"
parent="account.menu_finance_reports"
action="action_fusion_report_aged_payable"
sequence="31"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_partner_ledger"
name="Partner Ledger"
parent="account.menu_finance_reports"
action="action_fusion_report_partner_ledger"
sequence="32"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Tax -->
<menuitem id="menu_fusion_reports_tax"
name="Tax Summary"
parent="account.menu_finance_reports"
action="action_fusion_report_tax_summary"
sequence="40"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!--
Tools group at the bottom: custom-period picker, XLSX export wizard,
anomaly browser. Less-frequently used; bundled together so they
don't clutter the report list.
-->
<menuitem id="menu_fusion_reports_tools_group"
name="Tools"
parent="account.menu_finance_reports"
sequence="90"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_open"
name="Open Report..."
parent="menu_fusion_reports_root"
name="Custom Period..."
parent="menu_fusion_reports_tools_group"
action="action_fusion_period_picker_wizard"
sequence="10"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_xlsx"
name="Export to XLSX..."
parent="menu_fusion_reports_root"
parent="menu_fusion_reports_tools_group"
action="action_fusion_xlsx_export_wizard"
sequence="20"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
@@ -28,7 +118,7 @@
<menuitem id="menu_fusion_reports_anomalies"
name="Anomalies"
parent="menu_fusion_reports_root"
parent="menu_fusion_reports_tools_group"
action="action_fusion_report_anomaly_list"
sequence="30"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
One ir.actions.act_window per built-in report, each opens the OWL
ReportViewer (view_mode='fusion_reports'). The viewer reads
``default_report_type`` and ``default_report_code`` from the action
context to pick which fusion.report to render.
New menus per-report live below; users no longer need to go through
the period-picker wizard for the standard reports.
-->
<!-- ============================================================
CORE REPORTS (one per Fusion engine method)
============================================================ -->
<record id="action_fusion_report_pnl" model="ir.actions.act_window">
<field name="name">Profit and Loss</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'pnl', 'default_report_code': 'pnl'}</field>
</record>
<record id="action_fusion_report_balance_sheet" model="ir.actions.act_window">
<field name="name">Balance Sheet</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'balance_sheet', 'default_report_code': 'balance_sheet'}</field>
</record>
<record id="action_fusion_report_trial_balance" model="ir.actions.act_window">
<field name="name">Trial Balance</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'trial_balance', 'default_report_code': 'trial_balance'}</field>
</record>
<record id="action_fusion_report_general_ledger" model="ir.actions.act_window">
<field name="name">General Ledger</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'general_ledger', 'default_report_code': 'general_ledger'}</field>
</record>
<!-- ============================================================
SECONDARY PnL VARIANTS (engine.compute_pnl with code)
============================================================ -->
<record id="action_fusion_report_cash_flow" model="ir.actions.act_window">
<field name="name">Cash Flow Statement</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'pnl', 'default_report_code': 'cash_flow', 'default_comparison': 'previous_year'}</field>
</record>
<record id="action_fusion_report_executive_summary" model="ir.actions.act_window">
<field name="name">Executive Summary</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'pnl', 'default_report_code': 'executive_summary', 'default_comparison': 'previous_year'}</field>
</record>
<record id="action_fusion_report_annual_statements" model="ir.actions.act_window">
<field name="name">Annual Statements</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'pnl', 'default_report_code': 'annual_statements', 'default_comparison': 'previous_year'}</field>
</record>
<record id="action_fusion_report_tax_summary" model="ir.actions.act_window">
<field name="name">Tax Summary</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'trial_balance', 'default_report_code': 'tax_summary'}</field>
</record>
<!-- ============================================================
PARTNER-GROUPED REPORTS
============================================================ -->
<record id="action_fusion_report_aged_receivable" model="ir.actions.act_window">
<field name="name">Aged Receivable</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'aged_receivable', 'default_report_code': 'aged_receivable'}</field>
</record>
<record id="action_fusion_report_aged_payable" model="ir.actions.act_window">
<field name="name">Aged Payable</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'aged_payable', 'default_report_code': 'aged_payable'}</field>
</record>
<record id="action_fusion_report_partner_ledger" model="ir.actions.act_window">
<field name="name">Partner Ledger</field>
<field name="res_model">fusion.report</field>
<field name="view_mode">fusion_reports</field>
<field name="context">{'default_report_type': 'partner_ledger', 'default_report_code': 'partner_ledger'}</field>
</record>
</odoo>

View File

@@ -229,35 +229,38 @@ class SaleOrder(models.Model):
self.env.cr.execute('RELEASE SAVEPOINT %s' % savepoint_name)
except Exception as exc:
self.env.cr.execute('ROLLBACK TO SAVEPOINT %s' % savepoint_name)
self.message_post(body=_(
self.message_post(body=(
'Auto-MO group %s failed: %s'
) % (tag or 'single-line', exc))
continue
if created or adopted:
# _() needs a lang in env.context; in shell/cron this may be
# unset. Compose the message with plain format strings — this
# text is an internal chatter log, not user-facing UI.
msg_parts = []
if created:
lines_html = '<br/>'.join([
_('MO <a href="/odoo/manufacturing/%s">%s</a> '
'(%s, %d source line%s)') % (
mo.id, mo.name, tag or 'untagged',
n, 's' if n != 1 else ''
)
'MO <a href="/odoo/manufacturing/%s">%s</a> '
'(%s, %d source line%s)' % (
mo.id, mo.name, tag or 'untagged',
n, 's' if n != 1 else ''
)
for mo, tag, n in created
])
msg_parts.append(
_('%d draft MO(s) auto-created:<br/>%s') % (
'%d draft MO(s) auto-created:<br/>%s' % (
len(created), lines_html,
)
)
if adopted:
adopted_html = '<br/>'.join([
_('MO <a href="/odoo/manufacturing/%s">%s</a> '
'(legacy, now line-linked)') % (mo.id, mo.name)
'MO <a href="/odoo/manufacturing/%s">%s</a> '
'(legacy, now line-linked)' % (mo.id, mo.name)
for mo in adopted
])
msg_parts.append(
_('%d legacy MO(s) adopted:<br/>%s') % (
'%d legacy MO(s) adopted:<br/>%s' % (
len(adopted), adopted_html,
)
)

View File

@@ -52,7 +52,6 @@ Provides:
'wizard/fp_direct_order_wizard_views.xml',
'wizard/fp_add_from_so_wizard_views.xml',
'wizard/fp_add_from_quote_wizard_views.xml',
'report/report_so_acknowledgement.xml',
'wizard/fp_part_catalog_import_wizard_views.xml',
'data/fp_sale_description_template_data.xml',
],

View File

@@ -113,6 +113,12 @@ class SaleOrder(models.Model):
string='Margin %',
compute='_compute_margin',
)
x_fc_margin_available = fields.Boolean(
string='Margin Available',
compute='_compute_margin',
help='False when no order line has a costed coating — the '
'margin fields should render "n/a" in the UI.',
)
x_fc_workorder_count = fields.Integer(
string='Active WOs',
@@ -486,24 +492,34 @@ class SaleOrder(models.Model):
@api.depends('order_line.price_subtotal', 'amount_untaxed')
def _compute_margin(self):
"""Simple margin: untaxed total minus rolled-up cost from coating configs.
"""Margin = untaxed total rolled-up cost from coating configs.
x_fc_margin_percent is stored as a fraction (0.0 - 1.0) so the
widget='percentage' formats it correctly (a 100% margin reads
as 100%, not 10000%).
widget='percentage' formats 100% as 100%, not 10000%.
x_fc_margin_available is False when NO line has a costed coating
(i.e. fp.coating.config.unit_cost isn't populated anywhere). The
UI should render margin fields as "n/a" in that case rather than
showing a misleading 100%.
"""
for rec in self:
has_cost_data = False
cost = 0.0
for line in rec.order_line:
if line.x_fc_coating_config_id:
cost_per_unit = getattr(
line.x_fc_coating_config_id, 'unit_cost', 0.0,
) or 0.0
cost += cost_per_unit * (line.product_uom_qty or 0)
cc = line.x_fc_coating_config_id
if not cc:
continue
if 'unit_cost' not in cc._fields:
continue
if cc.unit_cost:
has_cost_data = True
cost_per_unit = cc.unit_cost or 0.0
cost += cost_per_unit * (line.product_uom_qty or 0)
rec.x_fc_margin_available = has_cost_data
rec.x_fc_margin_amount = (rec.amount_untaxed or 0) - cost
rec.x_fc_margin_percent = (
(rec.x_fc_margin_amount / rec.amount_untaxed)
if rec.amount_untaxed else 0.0
if (rec.amount_untaxed and has_cost_data) else 0.0
)
@api.onchange('upload_rfq_file')

View File

@@ -1,169 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sales Order Acknowledgement PDF (Phase D7) — a customer-facing
confirmation sent shortly after action_confirm. Includes external
notes, deadlines, and a signature block.
-->
<odoo>
<record id="action_report_fp_so_acknowledgement" model="ir.actions.report">
<field name="name">Sales Order Acknowledgement</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_configurator.report_fp_so_acknowledgement_doc</field>
<field name="report_file">fusion_plating_configurator.report_fp_so_acknowledgement_doc</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="print_report_name">'Acknowledgement - %s' % object.name</field>
</record>
<template id="report_fp_so_acknowledgement_doc">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<div class="page">
<h2 class="mb-4">
<span>Sales Order Acknowledgement - </span>
<span t-field="doc.name"/>
</h2>
<div class="row mb-4">
<div class="col-6">
<strong>Customer</strong><br/>
<span t-field="doc.partner_id"/><br/>
<span t-if="doc.x_fc_contact_phone"
t-field="doc.x_fc_contact_phone"/>
</div>
<div class="col-6">
<strong>References</strong><br/>
<span>Customer PO: </span>
<span t-field="doc.x_fc_po_number"/><br/>
<t t-if="doc.x_fc_customer_job_number">
<span>Customer Job #: </span>
<span t-field="doc.x_fc_customer_job_number"/><br/>
</t>
</div>
</div>
<div class="row mb-4">
<div class="col-6">
<strong>Bill To</strong><br/>
<div t-field="doc.partner_invoice_id"
t-options='{"widget": "contact", "fields": ["address"], "no_marker": true}'/>
</div>
<div class="col-6">
<strong>Ship To</strong><br/>
<div t-field="doc.partner_shipping_id"
t-options='{"widget": "contact", "fields": ["address"], "no_marker": true}'/>
</div>
</div>
<div class="row mb-4">
<div class="col-4">
<strong>Planned Start:</strong>
<span t-field="doc.x_fc_planned_start_date"/>
</div>
<div class="col-4">
<strong>Customer Deadline:</strong>
<span t-field="doc.commitment_date"/>
</div>
<div class="col-4">
<strong>Ship Via:</strong>
<span t-field="doc.x_fc_ship_via"/>
</div>
</div>
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Part</th>
<th>Treatment</th>
<th class="text-end">Qty</th>
<th class="text-end">Unit Price</th>
<th class="text-end">Subtotal</th>
</tr>
</thead>
<tbody>
<tr t-foreach="doc.order_line.filtered(lambda l: not l.x_fc_archived)"
t-as="line">
<td>
<span t-field="line.x_fc_part_catalog_id.part_number"/>
<br/>
<small t-field="line.name"/>
</td>
<td>
<span t-field="line.x_fc_coating_config_id"/>
</td>
<td class="text-end">
<span t-field="line.product_uom_qty"/>
</td>
<td class="text-end">
<span t-field="line.price_unit"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="4" class="text-end">
<strong>Total</strong>
</td>
<td class="text-end">
<strong>
<span t-field="doc.amount_total"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong>
</td>
</tr>
</tfoot>
</table>
<div t-if="doc.x_fc_external_note" class="mt-4">
<strong>Notes</strong>
<div t-field="doc.x_fc_external_note"/>
</div>
<div t-if="doc.x_fc_is_blanket_order" class="alert alert-info mt-3">
<strong>Blanket Order.</strong>
Parts will be released in quantities over time.
<span t-if="doc.x_fc_block_partial_shipments">
Partial shipments are blocked; the order ships
as one complete batch.
</span>
</div>
<div class="mt-5">
<table class="table table-borderless">
<tr>
<td style="width: 50%;">
<strong>Customer Signature</strong><br/>
<div style="border-bottom: 1px solid #333; height: 40px;"/>
<small>Signed name / date</small>
</td>
<td style="width: 50%;">
<strong>Nexa Systems / EN Technologies</strong><br/>
<div style="border-bottom: 1px solid #333; height: 40px;"/>
<small>
<span t-field="doc.user_id"/>
</small>
</td>
</tr>
</table>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -148,11 +148,21 @@
</group>
<group>
<group string="Margin">
<div colspan="2"
invisible="x_fc_margin_available"
class="text-muted">
<i class="fa fa-info-circle me-1"/>
Margin n/a — coating cost rollup not yet
populated on any line's treatment.
</div>
<field name="x_fc_margin_amount"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
options="{'currency_field': 'currency_id'}"
invisible="not x_fc_margin_available"/>
<field name="x_fc_margin_percent"
widget="percentage"/>
widget="percentage"
invisible="not x_fc_margin_available"/>
<field name="x_fc_margin_available" invisible="1"/>
</group>
</group>
<group>
@@ -188,6 +198,13 @@
<field name="arch" type="xml">
<list string="Sale Orders" decoration-info="state == 'draft'"
decoration-muted="state == 'cancel'">
<header>
<button name="%(action_fp_direct_order_wizard)d"
type="action"
string="+ New Direct Order"
class="btn-primary"
display="always"/>
</header>
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_po_number"/>

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