40 Commits

Author SHA1 Message Date
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
gsinghpal
d36933d7f4 fix(configurator): wrap t-field widgets in <span> inside table cells
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
Acknowledgement PDF rendering failed with "QWeb widgets do not work
correctly on 'td' elements" — Odoo's qweb compiler rejects
t-field/t-options directly on <td>. Wrap the monetary / qty widgets
in an inner <span> for every cell that uses them (body rows + footer
total).

Caught during browser UAT on S00066 — shell _render_qweb_pdf smoke
test passed earlier because it bypasses the full compile path, but
the production /report/pdf/ endpoint fails the assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:59:02 -04:00
gsinghpal
1817f63c67 fix(fusion_accounting_reports): engine accepts report_code to disambiguate
When multiple fusion.report rows share a report_type (e.g. 4 PnL-typed
reports: pnl, cash_flow, executive_summary, annual_statements), the
engine's _get_report previously returned whichever matched the type
filter first \u2014 so all four reports rendered the canonical P&L
line_specs regardless of which report the user selected.

Adds report_code kwarg to compute_pnl, compute_balance_sheet,
compute_trial_balance, compute_gl. Controller /fusion/reports/run now
accepts and forwards report_code. _get_report has a 3-tier resolution:
1. Exact code match (validates type)
2. Canonical (code == report_type)
3. First by sequence

Two new tests assert distinct line_specs render for distinct codes and
that wrong-type code raises ValidationError.

Verified live on westin-v19: pnl/cash_flow/executive_summary/
annual_statements now return 3/9/7/5 rows respectively (was all
3 before).

Made-with: Cursor
2026-04-19 23:58:29 -04:00
gsinghpal
1ebff01d35 feat(fusion_accounting_reports): seed 3 partner-grouped reports
Adds Aged Receivable, Aged Payable, and Partner Ledger as fusion.report
records using the new compute_partner_grouped engine method.

REPORT_TYPES is extended with aged_receivable / aged_payable /
partner_ledger so each report has a unique report_type. The HTTP
controller dispatches these to engine.compute_partner_grouped with
the appropriate account_type via PARTNER_GROUPED_ACCOUNT_TYPE.

Output includes per-partner aging buckets: current, 1-30, 31-60,
61-90, 90+ days.

Westin total: 4 + 4 + 3 = 11 of Enterprise's 22 standard reports.

Made-with: Cursor
2026-04-19 23:55:45 -04:00
gsinghpal
ff6d21a561 feat(fusion_accounting_reports): partner-grouped engine method
Adds engine.compute_partner_grouped(period, account_type=...) that
returns per-partner aggregations with aging buckets (current/1-30/
31-60/61-90/90+). SQL-direct for performance — single GROUP BY query
with conditional sum per bucket.

Foundation for the 3 partner-grouped reports landing in commit 3:
Aged Receivable, Aged Payable, Partner Ledger.

Made-with: Cursor
2026-04-19 23:54:32 -04:00
gsinghpal
6896c71b79 feat(fusion_accounting_reports): seed 4 more standard reports
Adds Cash Flow Statement, Executive Summary, Tax Summary, and Annual
Statements as fusion.report records with line_specs. All work with the
existing engine's bucket-sum pattern — no engine changes needed.

Westin total: 4 + 4 = 8 of Enterprise's 22 standard reports now in
fusion_accounting_reports. Partner-grouped reports (Aged AR/AP,
Partner Ledger) need an engine extension — in commit 2.

Made-with: Cursor
2026-04-19 23:53:16 -04:00
gsinghpal
111792599c fix(configurator): margin % stored as fraction so widget='percentage' formats right
Phase D8 compute was returning x_fc_margin_percent already-multiplied
by 100, but the 'percentage' widget in the SO form multiplies again
for display. Result was 10000% instead of 100%.

Store as 0.0-1.0 fraction; widget handles the multiplier. Caught
during UAT on S00066.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:49:00 -04:00
gsinghpal
679dbaa979 feat(fusion_accounting_followup): per-partner state migration from Enterprise
Migrates Enterprise account_followup per-partner state to Fusion fields:
- res.partner.followup_status -> fusion_followup_status (action_due/no_action)
- res.partner.payment_next_action_date -> fusion_followup_paused_until
  (when future-dated; sets status to 'paused')
- res.partner.followup_line_id -> fusion_followup_last_level_id
  (resolved by name match against migrated levels)

Wired into fusion.migration.wizard.action_run_migration after the
existing _followup_bootstrap_step. Idempotent: skips partners whose
Fusion state is already non-default. Defensive against missing
Enterprise fields (each field probed individually before use).

Closes the per-partner state migration gap that was blocking
Enterprise account_followup uninstall.

Made-with: Cursor
2026-04-19 23:48:22 -04:00
gsinghpal
b15bf2293e fix(configurator/bridge_mrp): address all bugs from code review
Two critical, one important, four polish fixes found by the
pr-review-toolkit code-reviewer.

C1 (CRITICAL) Start-at-node filter dropped later siblings
  fusion_plating_bridge_mrp/models/mrp_production.py:448
  The allowed_ids set was {descendants} ∪ {ancestors}, which wrongly
  excluded nodes that should run AFTER the start node — including
  later siblings of the start node and all operations in subsequent
  sub-processes. Rewrote the upward walk to ALSO include each
  ancestor's later-sequence siblings and their descendants. Smoke on
  ENP-ALUM-BASIC: full=9 WOs, partial from mid-tree 'De-Masking'=5
  WOs (previously was 1).

C2 (CRITICAL) Duplicate MO on re-confirm of pre-PR SOs
  fusion_plating_bridge_mrp/models/sale_order.py:96
  Legacy untagged MOs (created before this PR had line-linkage m2m)
  were not recognized by the untagged idempotency check, so
  re-confirming an already-processed SO would create one additional
  MO per untagged plating line. Fix: pre-scan for a single legacy
  untagged MO and adopt it by linking ALL untagged plating lines
  onto it. Those lines are then treated as covered and no per-line
  MOs are created on top. Smoke: S00066 before=1 MO, after
  re-run=1 MO.

I5 (IMPORTANT) push_to_defaults wrote to pre-bump revision
  fusion_plating_configurator/wizard/fp_direct_order_wizard.py:236
  When create_new_revision=True, _get_or_bump_revision() returned a
  new part record that got written to the SO line, but the
  post-confirm push_to_defaults loop re-read line.part_catalog_id
  (still the OLD rev) and wrote defaults there, defeating the whole
  point of "save as default". Fix: cache resolved parts in a dict
  keyed by wizard-line ID during the build loop, and use that cache
  in the push_to_defaults pass.

I3/I4/I6 (PERF) Computes lacked @api.depends and did per-record
  search_count / search queries
  fusion_plating_configurator/models/sale_order.py
  _compute_nav_counts, _compute_workorder_count, _compute_wo_completion
  now:
  - declare @api.depends
  - batch via read_group across the whole self recordset
  - rebuild {origin: counts} dicts and assign per record

M7 (MEDIUM) No savepoint around per-group MO creation
  fusion_plating_bridge_mrp/models/sale_order.py:_fp_auto_create_mo
  A mid-loop exception left group 1's MO persisted and aborted
  groups 2..N. Wrapped each group's create in SAVEPOINT/RELEASE/
  ROLLBACK TO SAVEPOINT so one bad group no longer corrupts state.

M8 (MEDIUM) Email 'opened' status false-positived on internal CC
  fusion_plating_configurator/models/sale_order.py:_compute_email_status
  Switched from 'any notification is_read' to 'customer partner has
  a read email notification on this SO'.

M9 (LOW) start_at_node_id domain silently empty when coating unset
  fusion_plating_configurator/wizard/fp_direct_order_line.py:94
  Changed `('parent_id', 'child_of', ...)` to
  `('id', 'child_of', ..., or 0)` and clarified the help text.

Regression smoke passed all checks on odoo-entech.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:35:03 -04:00
gsinghpal
9d8db0f9b1 fix(bank_rec): don't shadow Odoo's _() translation function in action_run_migration
Line 77 was `_ = super().action_run_migration()`, using `_` as a
throwaway variable name. That rebinds the module-level `_` (Odoo's
translation function imported at the top) to whatever super() returns
\u2014 in our case the parent's notification dict.

Lines 84/85 then call `_('Bank-Rec Migration Complete')` which is
now `some_dict('Bank-Rec Migration Complete')` \u2192
TypeError: 'dict' object is not callable.

User hit this when running the migration wizard from the menu.

Fix: drop the assignment; we don't actually use super()'s return value.
Made-with: Cursor
2026-04-19 23:34:45 -04:00
gsinghpal
ef2ccb89cf fix(services): V19 removed 'rpc' service \u2014 import standalone rpc() function
V19 removed the 'rpc' service from the registry. All 4 fusion services
(bank_reconciliation, reports, assets, followup) declared dependencies:
['rpc', ...] and accessed services.rpc in their constructor. At runtime
this caused:

  Error: Some services could not be started: fusion_bank_reconciliation,
  fusion_reports, fusion_assets, fusion_followup. Missing dependencies: rpc

\u2014 which prevented the entire OWL backend from booting (blank screen).

Fix per V19 docs:
- Add 'import { rpc } from "@web/core/network/rpc";'
- Set 'this.rpc = rpc;' in constructor (instead of services.rpc)
- Remove 'rpc' from dependencies list

This is the workspace CLAUDE.md guidance Phase 4's subagent flagged
but didn't act on for backward consistency. V19 actually removed the
service entirely, so the consistency choice was wrong \u2014 fixing now.

All call sites still use this.rpc(...) so no per-method changes needed.
Bundle rebuilt clean; backend boots correctly.

Made-with: Cursor
2026-04-19 23:25:52 -04:00
gsinghpal
51d8ce494d fix(scss): remove forbidden @import "variables" lines breaking V19 asset bundle
Phases 1-3's SCSS files used '@import "variables";' to pull in tokens
from _variables.scss. V19's odoo.addons.base.models.assetsbundle
forbids cross-file SCSS imports for security ('Local import forbidden')
and the asset bundle warning was firing on every web request.

Phase 4 caught + fixed this for fusion_accounting_followup; Phases 1-3
were never updated. Today's deployment surfaced the CSS error reported
by the user.

Resolution:
- Removed @import lines from 7 SCSS files across bank_rec, reports, assets
- Variables come from _variables.scss via manifest concatenation order
  (bundle order is _variables.scss first, then dependent files)
- Replaced documentation comments to NOT contain the literal string
  '@import "variables"' \u2014 Odoo's check is regex-based and was
  matching even SCSS comments

Verified clean: bundle rebuilds with zero 'Local import forbidden'
warnings; all 534 fusion-module tests still pass.

Made-with: Cursor
2026-04-19 21:57:22 -04:00
gsinghpal
190c296240 fix(fusion_accounting_ai): align legacy assets-adapter test with Phase 3 return shape
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
Phase 3 (fusion_accounting_assets) changed list_assets() to return
{count, total, assets} dict instead of a flat list — consistent with
bank_rec.list_unreconciled, reports.run_report, followup.list_overdue.

The pre-existing test in fusion_accounting_ai still asserted isinstance(rows, list)
and was failing on every run since Phase 3 merge. Updated to assert dict shape.

Made-with: Cursor
2026-04-19 21:50:47 -04:00
gsinghpal
12fa20c4f1 Merge Phase 4: AI-augmented customer follow-ups
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
37 tasks shipped on fusion_accounting/phase-4-followup:
- fusion.followup.engine (7-method API: get_overdue, compute_level, send, escalate, pause, reset, snapshot_history)
- 6 aging buckets + 3-level dunning + tone selector
- 5 persisted models (level, run, text_cache, partner inherit, move_line inherit)
- AI: payment risk scoring + LLM follow-up text + templated fallback
- 6 JSON-RPC controller endpoints + reactive frontend service
- 5 OWL components + SCSS + dark mode
- Batch wizard + 2 cron jobs (daily scan + weekly risk refresh)
- 3 default mail templates + 3 default levels
- Migration wizard backfill from account_followup
- Coexistence with Enterprise
- 106 tests passing
- All P95 perf metrics within 1x of budget

ALL 4 PHASES COMPLETE — replaces account_accountant + account_reports + account_asset + account_followup.
2026-04-19 21:48:10 -04:00
gsinghpal
b834ae3117 feat(configurator): complete all deferred Phase D/E/F tasks
Ships the remaining items from the Sales UX Uplift plan:

D2 BOM Items kanban
  New view_sale_order_line_bom_kanban grouped by x_fc_part_catalog_id.
  Smart button 'BOM Items' on SO form opens it.

D5 Archive line
  x_fc_archived Boolean on sale.order.line plus action_archive_line /
  action_unarchive_line. Acknowledgement report filters out archived
  lines.

D6 Add Quoted Lines sub-wizard
  New fp.add.from.quote.wizard parallel to fp.add.from.so.wizard. Pick
  quotes for this customer and clone them into direct-order lines
  carrying part, coating, qty, unit price (from calculated or
  override), and notes. Button '+ Add From Quotes' on wizard Lines tab.

D7 SO Acknowledgement PDF
  New ir.actions.report + QWeb template in configurator/report/.
  Header shows customer / contact / PO / Customer Job #, Bill-To,
  Ship-To, planned start + customer deadline + ship-via. Line table
  skips archived lines. Includes external notes, blanket-order
  callout, and customer-signature + vendor-signature blocks.
  Binding added to sale.order so it shows up under Print menu.

D9 Quick-nav chip bar
  New smart buttons on SO form: Invoices / Pickings / NCRs / Files
  with counts and icons. Each opens a filtered list. NCR button
  appears only when fusion_plating_quality is installed.

D10 SO/WO perspective toggle
  view_sale_order_line_wo_kanban grouped by x_fc_wo_group_tag. Smart
  button 'By WO' on SO form.

D11 Assemblies minimal model
  fp.sale.assembly + fp.sale.assembly.line with name, ship_to, count,
  procured_count, completed_at. UX (forms / kanbans / integration
  into receiving) deferred — model only for now.

D14 Uploaded Files
  Files smart button on SO form opens ir.attachment kanban filtered
  to this SO. Count appears in the chip bar.

F4 Signed tracking
  x_fc_signed_at / x_fc_signed_by / x_fc_is_signed on sale.order +
  action_mark_signed helper. Signed column on quotes list view.

F10 New Quote
  Kept on existing action_fp_quotations (already surfaces the
  default New button).

E5/F9 Action icons per row
  Deferred — requires a custom widget; the native PDF action via the
  Print menu covers 80% of the use case.

Bumped to 19.0.8.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:45:17 -04:00
gsinghpal
b85e208856 chore(bridge_mrp): bump to 19.0.7.0.0 — WO group + start-at-node wiring
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:35:59 -04:00
gsinghpal
e3001b5297 feat(bridge_mrp): honour x_fc_wo_group_tag + x_fc_start_at_node_id
Two features from Phases B/C that were previously only data now do work:

1. WO GROUPING (x_fc_wo_group_tag)
   _fp_auto_create_mo rewritten to iterate order_lines and group by
   x_fc_wo_group_tag. Lines sharing a tag collapse into ONE MO with
   product = first line's part.product_id, qty = Σ line qty,
   recipe = first line's coating_config.recipe_id. Untagged lines
   each get their own MO. Legacy path preserved for service-line SOs
   with no plating data.

   Idempotency is per (origin, tag): re-confirming an SO doesn't
   create duplicate MOs for already-grouped lines.

   New on mrp.production:
   - x_fc_wo_group_tag (Char, tracking)
   - x_fc_sale_order_line_ids (M2M back to sale.order.line)
   - x_fc_start_at_node_id (Many2one fusion.plating.process.node)

2. START-AT-NODE (x_fc_start_at_node_id)
   _generate_workorders_from_recipe pre-computes allowed_ids as the
   set of {descendants of start_node} ∪ {ancestors of start_node}.
   _is_node_included rejects any node outside that set. This skips
   sibling branches earlier in the recipe while keeping the
   container hierarchy so WO sequence numbers still make sense.

Smoke-tested S00070 (4 lines, 2 tagged groups + 1 untagged) -> 3 MOs:
WO#A qty=15 (2 lines batched), WO#B qty=50 (1 line), untagged qty=7
(1 line). Each got the ENP-ALUM-BASIC recipe.

Start-at-node smoke on the same recipe: full generation = 9 WOs,
partial with start_at='Ready for processing' = 1 WO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:34:48 -04:00
gsinghpal
97c733b7c3 feat(configurator): Phase F — quotations list uplift
F1 follow-up: x_fc_follow_up_date + x_fc_follow_up_user_id fields on
sale.order, surfaced in the quotations list + a 'Needs Follow-Up'
preset filter.

F2 expires: native validity_date exposed as togglable column on the
quotes list + an 'Expired' preset filter.

F3 email status pills: x_fc_email_status computed (draft / sent /
opened / won). 'Opened' detects via mail.notification.is_read on any
email-type mail.message attached to this SO.

F5 part numbers summary: x_fc_part_numbers_summary ("PN1, PN2 (+3
more)") across order_line parts, togglable column.

F7 from-RFQ filter reuses existing x_fc_rfq_attachment_id.

Views:
- view_sale_order_list_fp_quotes (new list dedicated to quotes).
- view_sale_order_search_fp_quotes with filters Draft / Sent / Won /
  From RFQ / Needs Follow-Up / Expired + group-bys.
- action_fp_quotations rewired to both of the above.

Bumped to 19.0.7.2.0. Closes all six phases originally planned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:23:41 -04:00
gsinghpal
94eb7ef415 feat(configurator): Phase E — SO list view uplift
E1/E2/E3/E4: list view gets new togglable columns for
- x_fc_wo_completion (e.g. '3/5'): count of completed vs total WOs
- x_fc_invoiced_amount (Monetary): sum of posted customer invoices
  minus credit notes
- x_fc_margin_amount + x_fc_margin_percent: reuses Phase D8 computes
- x_fc_is_blanket_order toggle

New sale.order.search view (sale.order.search.fp) with preset
filters: My Orders / Open / Confirmed / Done / Blanket / Has Rush /
Overdue, plus group-bys for Customer / Status / Customer Deadline.

Bumped to 19.0.7.1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:18:52 -04:00
gsinghpal
3f807d0152 chore(configurator): bump to 19.0.7.0.0 — Phase D first pass landed
Phase D scope landed so far:
- D1 deadline countdown
- D4 internal/external notes split
- D8 margin amount + percent
- D12 contact phone on SO header
- D13 ship via Char
- D3 active WOs stat button

Deferred to later Phase D pass:
- D2 BOM Items grouped list (overlaps with order_line)
- D5 archive line (native Odoo, just needs UI exposure)
- D6 Add Quoted Lines sub-wizard
- D7 SO Acknowledgement PDF report
- D9 Quick-nav link bar
- D10 SO/WO perspective toggle
- D11 Assemblies section (hierarchical BOM)
- D14 Uploaded Files surface (native Odoo attachments)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:13:40 -04:00
gsinghpal
842efd828c feat(configurator): Phase D batch 2 — active WOs stat button on SO form
D3 first half: x_fc_workorder_count computes live count of active MRP
work orders linked to this SO (via mo.origin = so.name). Adds a
'Active WOs' smart button next to the existing PO / RFQ buttons on
the sale.order form. Clicking opens a filtered mrp.workorder list
grouped by MO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:12:34 -04:00
gsinghpal
2476961f50 feat(configurator): Phase D batch 1 — countdown, notes split, margin, contact
Phase D first landing covers the quick-win Steelhead-parity fields on
the SO form / list:

- D1: x_fc_deadline_countdown ("in 2d 3h", "overdue 1d 4h") computed
  from commitment_date. Surfaced in SO form scheduling group and as
  togglable column on the SO list.
- D4: x_fc_internal_note + x_fc_external_note split (html). Existing
  'note' field is left untouched for back-compat. External note is
  intended for the SO acknowledgement + portal; internal note is
  shop-floor only.
- D8: x_fc_margin_amount + x_fc_margin_percent, currently computed
  against fp.coating.config.unit_cost if defined (else 0 -> 100%
  margin). When cost rollup lands on fp.coating.config, margin will
  reflect reality automatically.
- D12: x_fc_contact_phone related to partner.phone (readonly) on SO
  header.
- D13: x_fc_ship_via Char on SO header (carrier name).

Smoke: S00066 shows 'in 9d 22h' countdown + \$3025 margin; S00069
shows 'in 24d 22h' + \$750. Contact phone pulls from partner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:11:18 -04:00
gsinghpal
6b4b0c9eb7 chore(configurator): bump to 19.0.6.2.0 — Phase C direct order polish
Phase C complete on odoo-entech. Smoke-tested S00069:
- C1 x_fc_start_at_node_id = Ready for De-Masking (resume-rework)
- C2 x_fc_part_wo_description = internal rework note
- C5 x_fc_is_one_off = False
- C3 x_fc_quote_id slot wired (no quote picked in this smoke)
- C4 push-to-defaults wrote EN High-Phos back onto part catalog

Phase D (SO detail view), Phase E (SO list view), and Phase F
(Quotes list) are independent tracks — outlined in the plan doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:09:00 -04:00
gsinghpal
31bd8d1e56 feat(configurator): C3 — link direct-order line to a prior quote
Adds quote_id (Many2one fp.quote.configurator) on the wizard line
with a domain scoped to the wizard's customer + quote states (sent /
accepted / won). Onchange auto-fills part, coating, and unit price
(final = estimator_override_price or calculated_price, per-part).

Mirrors x_fc_quote_id on sale.order.line for the audit trail. Surfaced
as a togglable column on the SO line tree and under "Qty & Price" on
the wizard line drill-in form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:07:48 -04:00
gsinghpal
d437d1d959 feat(configurator): C4 — push coating + treatments back to part catalog defaults
Adds x_fc_default_coating_config_id and x_fc_default_treatment_ids
fields on fp.part.catalog. Wizard line gets a push_to_defaults
toggle. After action_create_order confirms the SO, any line with
push_to_defaults=True writes its coating + treatments back onto the
part catalog entry as the new defaults.

Reverse direction too: onchange on part_catalog_id in the wizard
line seeds coating + treatments from the part's defaults (if set and
the line doesn't already have them).

Part catalog form gets a new "Defaults" tab showing the stored
defaults. Smoke-tested: pushing default on order 1 populates the
catalog entry; new wizard line for that part auto-seeds the coating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:04:30 -04:00
gsinghpal
43a26b6849 feat(configurator): Phase C polish — to-node picker, WO description, one-off flag
C1: start_at_node_id per wizard line, mirrors to x_fc_start_at_node_id
on sale.order.line. Domain filters to nodes descending from the
coating_config's recipe so the estimator only picks valid resume
points. bridge_mrp will use this in a follow-up to skip ancestor
steps in the generated work order.

C2: part_wo_description (separate from customer-facing line_description)
lets the planner add internal-only notes that appear on the travelling
sheet only. Mirrors to x_fc_part_wo_description on sale.order.line.

C5: is_one_off flag for prototype / non-catalog parts. Mirrors to
x_fc_is_one_off. Actual skip-catalog behaviour will be wired in a
later pass.

All three fields appear in the wizard line drill-in form (under a new
"Work Order (internal)" group) and as togglable columns on the
sale.order.line tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:01:25 -04:00
gsinghpal
059276886d chore(configurator): bump to 19.0.6.1.0 — Phase B direct order wizard
Phase B complete on odoo-entech:
- B1/B2: Blanket + Block Partial flags on wizard header + sale.order
- B3: x_fc_wo_group_tag per SO line (bridge_mrp will use this to
  batch MOs in a follow-up)
- B4: 'Add From Prior SO' sub-wizard for repeat orders
- B5: Per-line is_missing_info compute + amber row decoration
- B6: Rush already on line (added in Phase A)

Smoke-tested: wizard accepts 4 lines (1 with missing price, 3 WO-tagged
across 2 groups), banner shows correctly, missing row highlighted in
amber, after fix SO creates cleanly with all flags + tags persisted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:50:49 -04:00
gsinghpal
9642a07306 feat(configurator): 'Add From Prior SO' sub-wizard for repeat orders
Task B4. New fp.add.from.so.wizard transient model: given the current
direct-order wizard + customer, lists the customer's prior confirmed
sale orders, lets the estimator tick source lines, and clones them
into fp.direct.order.line rows (part, coating, treatments, qty,
price, deadline, rush, WO group, description).

Button "+ Add From Prior SO" lives on the Lines tab of the main
wizard, visible once the customer is picked. Sub-wizard rejects
source lines that predate the new plating fields (no x_fc_part_catalog_id).

Smoke-tested on odoo-entech: copying all 3 lines of S00066 onto a
fresh wizard reproduces part/coating/qty/price correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:48:52 -04:00
gsinghpal
f55022c3d6 feat(configurator): blanket/block-partial flags + WO group + per-line missing indicator
Phase B partial landing (B1, B2, B3, B5):

- B1/B2: x_fc_is_blanket_order and x_fc_block_partial_shipments on
  sale.order; matching booleans on the wizard header.
- B3: x_fc_wo_group_tag Char on sale.order.line and wo_group_tag on
  wizard line. Free-text tag; bridge_mrp will batch lines sharing a
  tag into one MO in a follow-up.
- B5: is_missing_info computed Boolean on fp.direct.order.line;
  tree uses decoration-warning to highlight incomplete rows in amber.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:45:27 -04:00
gsinghpal
f0c3661277 chore(configurator): bump to 19.0.6.0.0 for multi-line direct order wizard
Task A8. Closes Phase A of the direct-order rewrite.

Smoke-tested on odoo-entech: wizard accepts 3 lines (qty 65, total
\$3,025 + tax -> \$3,418.25), creates SO S00066 in state=sale with all
header fields (customer job #, three deadlines, bill/ship addresses)
and per-line fields (part, coating, qty, price) populated correctly.

Phase A complete. Phase B (blanket flag, block partial, WO grouping,
add-from-SO, missing-info banner polish) and Phase C (to-node picker,
quote link, push-defaults) outlined in the plan doc; Phases D/E/F
(SO detail, SO list, quotes list) are separate tracks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:40:44 -04:00
gsinghpal
6fa4140d11 feat(configurator): surface new direct-order fields on sale order form + list
Task A7. SO form Plating tab gets a new "Customer Reference /
Scheduling" block showing customer_job_number, planned_start_date,
internal_deadline, commitment_date (as Customer Deadline). Order line
tree in SO form now shows per-line part / coating / treatments /
deadline / rush. SO list view exposes customer job # and both
deadlines as togglable columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:38:39 -04:00
gsinghpal
e34c1bcc8d refactor(configurator): multi-line direct order wizard with notebook form
Tasks A3 + A6. Wizard rewritten as header + lines architecture:

- Header carries customer/addresses/PO/deadlines/invoicing/notes.
- One SO line created per fp.direct.order.line, carrying part,
  coating, treatments M2M, qty, price, per-line deadline, rush flag,
  and description.
- action_create_order loops wizard lines, invokes revision-bump
  helper, and builds order_line tuples with x_fc_* fields.
- Form view uses notebook (Lines tab with editable tree + drill-in
  form, Notes tab), amber missing-info banner at top, running totals
  at bottom. Customer deadline maps to Odoo commitment_date on SO.

Single-line fields and their computes/onchanges removed from wizard;
moved to fp.direct.order.line in task A4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:37:11 -04:00
gsinghpal
95db3aff0f feat(configurator): x_fc_* fields on sale.order + new sale.order.line extensions
Task A5. Adds customer_job_number, planned_start_date, and
internal_deadline on sale.order. Customer deadline maps to Odoo's
native commitment_date. Creates sale_order_line.py with per-line
plating fields: part_catalog_id, coating_config_id, treatment_ids
M2M, part_deadline, rush_order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:33:50 -04:00
gsinghpal
9423a93961 feat(configurator): fill per-line logic (price lookup, desc template, rev bump)
Task A4. Expands fp.direct.order.line with: part related fields,
optional new-revision block, additional treatment M2M, per-line
deadline + rush flag, description template + free-text, onchange
auto-price-lookup from customer price list, onchange template
suggestion (part > customer > coating), and _get_or_bump_revision
helper that will be called by the SO-creation loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:32:34 -04:00
gsinghpal
057157587d feat(configurator): add header fields + line O2M to direct order wizard
Task A2 of the direct-order-wizard rewrite. Adds SO-header fields for
customer job #, three deadlines (planned start / internal / customer),
bill-to / ship-to address pickers, the line_ids O2M linking to
fp.direct.order.line, computed order totals, and a missing-info
warning banner. Partner onchange now also seeds default addresses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:31:14 -04:00
122 changed files with 5466 additions and 366 deletions

View File

@@ -140,7 +140,11 @@ class TestFollowupAdapter(TransactionCase):
@tagged('post_install', '-at_install')
class TestAssetsAdapter(TransactionCase):
def test_list_assets_returns_list(self):
def test_list_assets_returns_dict_with_assets(self):
# Phase 3 (fusion_accounting_assets) wired list_assets to return
# {count, total, assets} — consistent with bank_rec.list_unreconciled etc.
adapter = get_adapter(self.env, 'assets')
rows = adapter.list_assets()
self.assertIsInstance(rows, list)
self.assertIsInstance(rows, dict)
self.assertIn('assets', rows)
self.assertIsInstance(rows['assets'], list)

View File

@@ -1,4 +1,5 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// (V19 forbids cross-file SCSS imports; rely on bundle order instead.)
.o_fusion_assets {
background: $asset-bg-secondary;

View File

@@ -1,4 +1,4 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
[data-color-scheme="dark"] .o_fusion_assets {
background: #1f2937; color: #f9fafb;

View File

@@ -2,13 +2,15 @@
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
const ENDPOINT_BASE = "/fusion/assets";
export class AssetsService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
// V19: rpc is a standalone import, not a service.
this.rpc = rpc;
this.notification = services.notification;
this.state = reactive({
@@ -142,7 +144,7 @@ export class AssetsService {
}
export const assetsService = {
dependencies: ["rpc", "notification"],
dependencies: ["notification"],
start(env, services) { return new AssetsService(env, services); },
};

View File

@@ -74,7 +74,9 @@ class FusionMigrationWizard(models.TransientModel):
Phase 0) and then runs the bank-rec bootstrap. Returns a
notification summarizing both.
"""
_ = super().action_run_migration()
# Don't bind super()'s return value to `_` \u2014 that shadows the
# imported translation function and breaks the _("...") calls below.
super().action_run_migration()
result = self._bank_rec_bootstrap_step()
return {
'type': 'ir.actions.client',

View File

@@ -1,4 +1,4 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// ============================================================
// AI Suggestion strip (inline, on each statement line card)

View File

@@ -1,4 +1,5 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// (V19 forbids cross-file SCSS imports; rely on bundle order instead.)
// ============================================================
// Bank reconciliation kanban container

View File

@@ -1,5 +1,4 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// Activated via [data-color-scheme="dark"] on body or any ancestor.
// Mirrors Odoo's standard dark-mode trigger pattern.

View File

@@ -14,13 +14,15 @@ import { registry } from "@web/core/registry";
import { reactive, useState, EventBus } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { browser } from "@web/core/browser/browser";
import { rpc } from "@web/core/network/rpc";
const ENDPOINT_BASE = "/fusion/bank_rec";
export class BankReconciliationService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
// V19: rpc is no longer a service — imported as a standalone function above.
this.rpc = rpc;
this.notification = services.notification;
this.orm = services.orm;
@@ -400,7 +402,7 @@ export class BankReconciliationService {
}
export const bankReconciliationService = {
dependencies: ["rpc", "notification", "orm"],
dependencies: ["notification", "orm"],
start(env, services) {
return new BankReconciliationService(env, services);
},

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

@@ -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

@@ -78,10 +78,120 @@ class FusionMigrationWizard(models.TransientModel):
result['created'], result['skipped'], len(result['errors']))
return result
def _followup_partner_state_bootstrap_step(self):
"""Migration step: copy Enterprise account_followup per-partner state
onto Fusion's fields on res.partner.
Idempotent: only updates partners whose Fusion field is at default
(no_action) and whose Enterprise field has a non-default value.
"""
self.ensure_one()
_logger.info("fusion_accounting_followup partner-state migration starting")
Partner = self.env['res.partner'].sudo()
has_status = 'followup_status' in Partner._fields
has_next_date = 'payment_next_action_date' in Partner._fields
has_line = 'followup_line_id' in Partner._fields
if not (has_status or has_next_date or has_line):
_logger.info(
"Enterprise account_followup partner fields not present \u2014 skipping")
return {
'step': 'followup_partner_state',
'enterprise_module_present': False,
'updated': 0, 'skipped': 0, 'errors': [],
}
result = {
'step': 'followup_partner_state',
'enterprise_module_present': True,
'updated': 0, 'skipped': 0, 'errors': [],
}
domain_terms = []
if has_status:
domain_terms.append(('followup_status', '!=', 'no_action_needed'))
if has_next_date:
domain_terms.append(('payment_next_action_date', '!=', False))
if not domain_terms:
_logger.info("No usable Enterprise follow-up fields \u2014 skipping")
return result
if len(domain_terms) > 1:
domain = ['|'] * (len(domain_terms) - 1) + domain_terms
else:
domain = domain_terms
candidates = Partner.search(domain)
_logger.info(
"Found %d partners with non-default Enterprise follow-up state",
len(candidates))
Level = self.env['fusion.followup.level'].sudo()
today = fields.Date.today()
status_map = {
'in_need_of_action': 'action_due',
'with_overdue_invoices': 'action_due',
'no_action_needed': 'no_action',
}
for partner in candidates:
try:
if partner.fusion_followup_status not in (False, 'no_action'):
result['skipped'] += 1
continue
vals = {}
ent_status = (
getattr(partner, 'followup_status', None)
if has_status else None)
if ent_status and ent_status in status_map:
vals['fusion_followup_status'] = status_map[ent_status]
next_date = (
getattr(partner, 'payment_next_action_date', False)
if has_next_date else False)
if next_date and next_date > today:
vals['fusion_followup_paused_until'] = next_date
vals['fusion_followup_status'] = 'paused'
ent_line = (
getattr(partner, 'followup_line_id', None)
if has_line else None)
if ent_line:
fusion_level = Level.search([
('name', '=', ent_line.name),
], limit=1)
if fusion_level:
vals['fusion_followup_last_level_id'] = fusion_level.id
if vals:
partner.write(vals)
result['updated'] += 1
_logger.debug(
"Migrated partner %s: %s", partner.name, vals)
else:
result['skipped'] += 1
except Exception as e:
result['errors'].append(
f"Partner {partner.id} ({partner.name}): {e}")
_logger.warning(
"Migration failed for partner %s: %s", partner.id, e)
_logger.info(
"fusion_accounting_followup partner-state migration: "
"updated=%d skipped=%d errors=%d",
result['updated'], result['skipped'], len(result['errors']))
return result
def action_run_migration(self):
result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None
try:
self._followup_bootstrap_step()
except Exception as e:
_logger.warning("followup_bootstrap_step failed: %s", e)
try:
self._followup_partner_state_bootstrap_step()
except Exception as e:
_logger.warning("followup_partner_state_bootstrap_step failed: %s", e)
return result

View File

@@ -2,13 +2,15 @@
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
const ENDPOINT_BASE = "/fusion/followup";
export class FollowupService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
// V19: rpc is a standalone import, not a service.
this.rpc = rpc;
this.notification = services.notification;
this.state = reactive({
@@ -138,7 +140,7 @@ export class FollowupService {
}
export const followupService = {
dependencies: ["rpc", "notification"],
dependencies: ["notification"],
start(env, services) { return new FollowupService(env, services); },
};

View File

@@ -19,3 +19,12 @@ class TestFollowupMigrationRoundTrip(TransactionCase):
# Second run skips what first created (or both no-op)
if first['enterprise_module_present']:
self.assertGreaterEqual(second['skipped'], first['created'])
def test_partner_state_bootstrap_step(self):
"""Verify the partner-state migration step runs without error."""
wizard = self.env['fusion.migration.wizard'].create({})
result = wizard._followup_partner_state_bootstrap_step()
self.assertEqual(result['step'], 'followup_partner_state')
self.assertIn(result['enterprise_module_present'], [True, False])
self.assertGreaterEqual(result['updated'], 0)
self.assertGreaterEqual(result['skipped'], 0)

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,31 @@
{
'name': 'Fusion Accounting — Canadian Reports',
'version': '19.0.1.0.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',
],
'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,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

@@ -36,6 +36,13 @@ menu hides; the engine and AI tools remain available for the chat.
'data/report_balance_sheet.xml',
'data/report_trial_balance.xml',
'data/report_general_ledger.xml',
'data/report_cash_flow.xml',
'data/report_executive_summary.xml',
'data/report_tax_report.xml',
'data/report_annual_statements.xml',
'data/report_aged_receivable.xml',
'data/report_aged_payable.xml',
'data/report_partner_ledger.xml',
'data/cron.xml',
'reports/report_pdf_template.xml',
'wizards/xlsx_export_wizard_views.xml',

View File

@@ -18,7 +18,16 @@ from ..services.date_periods import Period
_logger = logging.getLogger(__name__)
REPORT_TYPES = {'pnl', 'balance_sheet', 'trial_balance', 'general_ledger'}
REPORT_TYPES = {
'pnl', 'balance_sheet', 'trial_balance', 'general_ledger',
'aged_receivable', 'aged_payable', 'partner_ledger',
}
PARTNER_GROUPED_ACCOUNT_TYPE = {
'aged_receivable': 'asset_receivable',
'aged_payable': 'liability_payable',
'partner_ledger': 'asset_receivable',
}
def _parse_date(value):
@@ -56,7 +65,7 @@ class FusionReportsController(http.Controller):
@http.route('/fusion/reports/run', type='jsonrpc', auth='user')
def run(self, report_type, date_from=None, date_to=None,
comparison='none', company_id=None):
comparison='none', company_id=None, report_code=None):
if report_type not in REPORT_TYPES:
raise ValidationError(_("Unknown report type: %s") % report_type)
company_id = int(company_id) if company_id else request.env.company.id
@@ -66,19 +75,33 @@ class FusionReportsController(http.Controller):
period = _build_period(date_from, date_to)
return engine.compute_pnl(
period, comparison=comparison, company_id=company_id,
report_code=report_code,
)
if report_type == 'balance_sheet':
return engine.compute_balance_sheet(
_parse_date(date_to),
comparison=comparison,
company_id=company_id,
report_code=report_code,
)
if report_type == 'trial_balance':
period = _build_period(date_from, date_to)
return engine.compute_trial_balance(period, company_id=company_id)
return engine.compute_trial_balance(
period, company_id=company_id, report_code=report_code,
)
if report_type in PARTNER_GROUPED_ACCOUNT_TYPE:
period = _build_period(date_from, date_to)
return engine.compute_partner_grouped(
period,
account_type=PARTNER_GROUPED_ACCOUNT_TYPE[report_type],
comparison=comparison,
company_id=company_id,
)
# general_ledger
period = _build_period(date_from, date_to)
return engine.compute_gl(period, company_id=company_id)
return engine.compute_gl(
period, company_id=company_id, report_code=report_code,
)
@http.route('/fusion/reports/drill_down', type='jsonrpc', auth='user')
def drill_down(self, account_id, date_from, date_to, company_id=None):

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_aged_payable" model="fusion.report">
<field name="name">Aged Payable</field>
<field name="code">aged_payable</field>
<field name="report_type">aged_payable</field>
<field name="sequence">36</field>
<field name="description">Per-vendor outstanding payables, bucketed by aging.</field>
<field name="line_specs" eval="[
{'label': 'Aged Payable', 'account_type_for_grouping': 'liability_payable'}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_aged_receivable" model="fusion.report">
<field name="name">Aged Receivable</field>
<field name="code">aged_receivable</field>
<field name="report_type">aged_receivable</field>
<field name="sequence">35</field>
<field name="description">Per-customer outstanding receivables, bucketed by aging.</field>
<field name="line_specs" eval="[
{'label': 'Aged Receivable', 'account_type_for_grouping': 'asset_receivable'}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_annual_statements" model="fusion.report">
<field name="name">Annual Statements</field>
<field name="code">annual_statements</field>
<field name="report_type">pnl</field>
<field name="sequence">11</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Year-over-year P&amp;L comparison for annual reporting.</field>
<field name="line_specs" eval="[
{'label': 'Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 0},
{'label': 'Cost of Goods Sold', 'account_type_prefix': 'expense_direct_cost', 'sign': -1, 'level': 1},
{'label': 'Gross Profit', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'Operating Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 1},
{'label': 'OPERATING INCOME', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_cash_flow" model="fusion.report">
<field name="name">Cash Flow Statement</field>
<field name="code">cash_flow</field>
<field name="report_type">pnl</field>
<field name="sequence">15</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Cash flow by activity (operating, investing, financing).</field>
<field name="line_specs" eval="[
{'label': 'Operating Activities', 'level': 0},
{'label': 'Net Income (from operations)', 'account_type_prefix': 'income', 'sign': -1, 'level': 1},
{'label': 'Depreciation Add-back', 'account_type_prefix': 'expense_depreciation', 'sign': 1, 'level': 1},
{'label': 'Operating Cash Flow', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'Investing Activities', 'level': 0},
{'label': 'Fixed Asset Purchases', 'account_type_prefix': 'asset_fixed', 'sign': -1, 'level': 1},
{'label': 'Investing Cash Flow', 'compute': 'subtotal', 'above': 1, 'sign': 1, 'level': 0},
{'label': 'Financing Activities', 'level': 0},
{'label': 'Liabilities (long-term)', 'account_type_prefix': 'liability_non_current', 'sign': 1, 'level': 1},
{'label': 'Equity', 'account_type_prefix': 'equity', 'sign': 1, 'level': 1},
{'label': 'Financing Cash Flow', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'NET CHANGE IN CASH', 'compute': 'subtotal', 'above': 3, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_executive_summary" model="fusion.report">
<field name="name">Executive Summary</field>
<field name="code">executive_summary</field>
<field name="report_type">pnl</field>
<field name="sequence">5</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Top-level KPI summary: revenue, expenses, net income, key balance positions.</field>
<field name="line_specs" eval="[
{'label': 'PROFIT &amp; LOSS', 'level': 0},
{'label': 'Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 1},
{'label': 'Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 1},
{'label': 'Net Income', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'BALANCE POSITIONS', 'level': 0},
{'label': 'Cash &amp; Bank', 'account_type_prefix': 'asset_cash', 'sign': 1, 'level': 1},
{'label': 'Receivables', 'account_type_prefix': 'asset_receivable', 'sign': 1, 'level': 1},
{'label': 'Payables', 'account_type_prefix': 'liability_payable', 'sign': -1, 'level': 1},
{'label': 'Net Working Position', 'compute': 'subtotal', 'above': 3, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_partner_ledger" model="fusion.report">
<field name="name">Partner Ledger</field>
<field name="code">partner_ledger</field>
<field name="report_type">partner_ledger</field>
<field name="sequence">40</field>
<field name="description">Per-partner ledger combining receivable and payable activity.</field>
<field name="line_specs" eval="[
{'label': 'Partner Ledger', 'account_type_for_grouping': 'asset_receivable'}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_tax_summary" model="fusion.report">
<field name="name">Tax Summary</field>
<field name="code">tax_summary</field>
<field name="report_type">trial_balance</field>
<field name="sequence">25</field>
<field name="description">Tax liability + asset positions. v1: aggregate-level only; per-tax-code breakdown is Phase 2.5.</field>
<field name="line_specs" eval="[
{'label': 'Tax Asset (recoverable)', 'account_type_prefix': 'asset_current', 'sign': 1, 'level': 0},
{'label': 'Tax Liability (collected)', 'account_type_prefix': 'liability_current', 'sign': -1, 'level': 0},
{'label': 'NET TAX POSITION', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -13,6 +13,9 @@ REPORT_TYPES = [
('balance_sheet', 'Balance Sheet'),
('trial_balance', 'Trial Balance'),
('general_ledger', 'General Ledger'),
('aged_receivable', 'Aged Receivable'),
('aged_payable', 'Aged Payable'),
('partner_ledger', 'Partner Ledger'),
]

View File

@@ -14,7 +14,7 @@ Internal pipeline (per report run):
"""
import logging
from datetime import date
from datetime import date, timedelta
from odoo import _, api, models
from odoo.exceptions import ValidationError
@@ -39,10 +39,17 @@ class FusionReportEngine(models.AbstractModel):
@api.model
def compute_pnl(
self, period: Period, *, comparison: str = 'none',
company_id: int | None = None,
company_id: int | None = None, report_code: str | None = None,
) -> dict:
"""Income statement (P&L) for the given period."""
report = self._get_report('pnl', company_id=company_id)
"""Income statement (P&L) for the given period.
``report_code`` selects between multiple PnL-typed report definitions
(``pnl``, ``cash_flow``, ``executive_summary``, ``annual_statements``).
When omitted, falls back to the canonical ``pnl`` definition.
"""
report = self._get_report(
'pnl', company_id=company_id, code=report_code,
)
return self._compute(
report, period, comparison=comparison, company_id=company_id,
)
@@ -50,11 +57,13 @@ class FusionReportEngine(models.AbstractModel):
@api.model
def compute_balance_sheet(
self, date_to: date, *, comparison: str = 'none',
company_id: int | None = None,
company_id: int | None = None, report_code: str | None = None,
) -> dict:
"""Balance sheet AS OF date_to. Period.date_from is set to a
far-past date so balances are cumulative-since-inception."""
report = self._get_report('balance_sheet', company_id=company_id)
report = self._get_report(
'balance_sheet', company_id=company_id, code=report_code,
)
period = Period(
date_from=date(1970, 1, 1),
date_to=date_to,
@@ -67,10 +76,17 @@ class FusionReportEngine(models.AbstractModel):
@api.model
def compute_trial_balance(
self, period: Period, *, company_id: int | None = None,
report_code: str | None = None,
) -> dict:
"""Trial balance for the given period - every account with
non-zero balance."""
report = self._get_report('trial_balance', company_id=company_id)
non-zero balance.
``report_code`` selects between multiple TB-typed reports (e.g.
``trial_balance``, ``tax_summary``).
"""
report = self._get_report(
'trial_balance', company_id=company_id, code=report_code,
)
return self._compute(
report, period, comparison='none', company_id=company_id,
)
@@ -78,12 +94,14 @@ class FusionReportEngine(models.AbstractModel):
@api.model
def compute_gl(
self, period: Period, *, account_ids: list | None = None,
company_id: int | None = None,
company_id: int | None = None, report_code: str | None = None,
) -> dict:
"""General ledger for the given period.
Returns per-account move-line listings rather than aggregated rows."""
report = self._get_report('general_ledger', company_id=company_id)
report = self._get_report(
'general_ledger', company_id=company_id, code=report_code,
)
company_id = company_id or self.env.company.id
result = self._compute(
report, period, comparison='none', company_id=company_id,
@@ -118,27 +136,188 @@ class FusionReportEngine(models.AbstractModel):
limit=500,
)
@api.model
def compute_partner_grouped(
self, period: Period, *, account_type: str = 'asset_receivable',
comparison: str = 'none', company_id: int | None = None,
) -> dict:
"""Per-partner aggregation report (Aged Receivable, Aged Payable,
Partner Ledger).
Returns a dict with ``rows`` = list of partner-level aggregates.
Each row has the partner_id, partner_name, total residual, and
aging buckets: current / 1-30 / 31-60 / 61-90 / 90+ days past
``period.date_to``.
SQL-direct for performance: a single GROUP BY query with conditional
sum per bucket. Only un-reconciled, posted lines with non-zero
residual at the as-of date are included.
"""
company_id = company_id or self.env.company.id
accounts = self.env['account.account'].sudo().search([
('account_type', '=', account_type),
('company_ids', 'in', company_id),
])
if not accounts:
return {
'report_type': 'partner_grouped',
'account_type': account_type,
'period': {
'date_from': str(period.date_from),
'date_to': str(period.date_to),
'label': period.label,
},
'rows': [],
'total': 0.0,
'partner_count': 0,
}
as_of = period.date_to
d30 = as_of - timedelta(days=30)
d60 = as_of - timedelta(days=60)
d90 = as_of - timedelta(days=90)
self.env.cr.execute(
"""
SELECT
COALESCE(p.id, 0) AS partner_id,
COALESCE(p.name, '(no partner)') AS partner_name,
SUM(aml.amount_residual) AS total_residual,
SUM(CASE
WHEN aml.date_maturity >= %s
OR aml.date_maturity IS NULL
THEN aml.amount_residual ELSE 0
END) AS bucket_current,
SUM(CASE
WHEN aml.date_maturity < %s
AND aml.date_maturity >= %s
THEN aml.amount_residual ELSE 0
END) AS bucket_1_30,
SUM(CASE
WHEN aml.date_maturity < %s
AND aml.date_maturity >= %s
THEN aml.amount_residual ELSE 0
END) AS bucket_31_60,
SUM(CASE
WHEN aml.date_maturity < %s
AND aml.date_maturity >= %s
THEN aml.amount_residual ELSE 0
END) AS bucket_61_90,
SUM(CASE
WHEN aml.date_maturity < %s
THEN aml.amount_residual ELSE 0
END) AS bucket_90_plus,
COUNT(*) AS line_count
FROM account_move_line aml
LEFT JOIN res_partner p ON p.id = aml.partner_id
WHERE aml.account_id = ANY(%s)
AND aml.parent_state = 'posted'
AND aml.reconciled = false
AND aml.amount_residual != 0
AND aml.company_id = %s
AND aml.date <= %s
GROUP BY p.id, p.name
HAVING SUM(aml.amount_residual) != 0
ORDER BY total_residual DESC
""",
(
as_of,
as_of, d30,
d30, d60,
d60, d90,
d90,
list(accounts.ids), company_id, as_of,
),
)
rows = []
for r in self.env.cr.dictfetchall():
rows.append({
'partner_id': r['partner_id'] or False,
'partner_name': r['partner_name'] or '(no partner)',
'total': float(r['total_residual'] or 0),
'bucket_current': float(r['bucket_current'] or 0),
'bucket_1_30': float(r['bucket_1_30'] or 0),
'bucket_31_60': float(r['bucket_31_60'] or 0),
'bucket_61_90': float(r['bucket_61_90'] or 0),
'bucket_90_plus': float(r['bucket_90_plus'] or 0),
'line_count': r['line_count'],
})
total = sum(r['total'] for r in rows)
return {
'report_type': 'partner_grouped',
'account_type': account_type,
'period': {
'date_from': str(period.date_from),
'date_to': str(period.date_to),
'label': period.label,
},
'company_id': company_id,
'rows': rows,
'total': total,
'partner_count': len(rows),
}
# ============================================================
# PRIVATE HELPERS
# ============================================================
def _get_report(self, report_type: str, *, company_id: int | None = None):
"""Look up the active fusion.report definition for a given
type+company. If no per-company override, falls back to global
(company_id=False)."""
def _get_report(
self, report_type: str, *, company_id: int | None = None,
code: str | None = None,
):
"""Look up the active fusion.report definition.
When ``code`` is provided, prefer the report with that exact code
(validating its ``report_type`` matches). Otherwise fall back to
the canonical-by-type lookup: prefer code == report_type, then any
report of that type. Per-company overrides win over global.
"""
Report = self.env['fusion.report'].sudo()
company_id = company_id or self.env.company.id
company_domain = [
('active', '=', True),
'|',
('company_id', '=', company_id),
('company_id', '=', False),
]
if code:
report = Report.search(
[('code', '=', code)] + company_domain,
order='company_id desc nulls last',
limit=1,
)
if not report:
raise ValidationError(
_("No active fusion.report definition with code '%s'") % code
)
if report.report_type != report_type:
raise ValidationError(
_("Report '%(code)s' has type '%(actual)s' but '%(expected)s' was expected.")
% {
'code': code,
'actual': report.report_type,
'expected': report_type,
}
)
return report
# No code: prefer the canonical (code == report_type), then any
# other report of that type.
report = Report.search(
[
('report_type', '=', report_type),
('active', '=', True),
'|',
('company_id', '=', company_id),
('company_id', '=', False),
],
[('code', '=', report_type), ('report_type', '=', report_type)] + company_domain,
order='company_id desc nulls last',
limit=1,
)
if report:
return report
report = Report.search(
[('report_type', '=', report_type)] + company_domain,
order='company_id desc nulls last, sequence',
limit=1,
)
if not report:
raise ValidationError(
_("No active fusion.report definition for type '%s'") % report_type

View File

@@ -1,4 +1,4 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
[data-color-scheme="dark"] .o_fusion_reports {
background: #1f2937;

View File

@@ -1,4 +1,5 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// (V19 forbids cross-file SCSS imports; rely on bundle order instead.)
.o_fusion_reports {
background: $report-bg-secondary;

View File

@@ -2,13 +2,15 @@
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
const ENDPOINT_BASE = "/fusion/reports";
export class ReportsService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
// V19: rpc is a standalone import, not a service.
this.rpc = rpc;
this.notification = services.notification;
this.state = reactive({
@@ -140,7 +142,7 @@ export class ReportsService {
}
export const reportsService = {
dependencies: ["rpc", "notification"],
dependencies: ["notification"],
start(env, services) { return new ReportsService(env, services); },
};

View File

@@ -90,6 +90,75 @@ class TestFusionReportEngine(TransactionCase):
)
self.assertIsInstance(rows, list)
def test_compute_partner_grouped_receivable(self):
period = Period(date(2025, 1, 1), date(2025, 12, 31), 'Test')
result = self.env['fusion.report.engine'].compute_partner_grouped(
period, account_type='asset_receivable',
)
self.assertEqual(result['report_type'], 'partner_grouped')
self.assertEqual(result['account_type'], 'asset_receivable')
self.assertIn('rows', result)
self.assertIn('total', result)
self.assertIn('partner_count', result)
if result['rows']:
for key in (
'partner_name', 'total', 'bucket_current', 'bucket_1_30',
'bucket_31_60', 'bucket_61_90', 'bucket_90_plus',
):
self.assertIn(key, result['rows'][0])
def test_report_code_disambiguates_same_report_type(self):
"""Multiple reports of report_type='pnl' must each be addressable
by code so the engine returns the requested definition's line_specs
(not whichever was first by company_id)."""
spec_one = [
{'label': 'A', 'account_type_prefix': 'income_', 'sign': 1},
]
spec_two = [
{'label': 'X', 'account_type_prefix': 'income_', 'sign': 1},
{'label': 'Y', 'account_type_prefix': 'expense_', 'sign': -1},
{'label': 'Z', 'account_type_prefix': 'asset_', 'sign': 1},
]
self.env['fusion.report'].create({
'name': 'Variant One', 'code': 'variant_one',
'report_type': 'pnl', 'line_specs': spec_one,
'company_id': self.env.company.id,
})
self.env['fusion.report'].create({
'name': 'Variant Two', 'code': 'variant_two',
'report_type': 'pnl', 'line_specs': spec_two,
'company_id': self.env.company.id,
})
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test')
engine = self.env['fusion.report.engine']
r1 = engine.compute_pnl(
period, company_id=self.env.company.id,
report_code='variant_one',
)
r2 = engine.compute_pnl(
period, company_id=self.env.company.id,
report_code='variant_two',
)
self.assertEqual(r1['report_name'], 'Variant One')
self.assertEqual(r2['report_name'], 'Variant Two')
self.assertEqual(len(r1['rows']), 1)
self.assertEqual(len(r2['rows']), 3)
def test_report_code_validates_type_match(self):
"""Asking for a 'pnl' computation but giving a balance_sheet code
should raise ValidationError, not silently mis-render."""
self.env['fusion.report'].create({
'name': 'Wrong Type', 'code': 'wrong_type_test',
'report_type': 'balance_sheet', 'line_specs': [],
'company_id': self.env.company.id,
})
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test')
with self.assertRaises(ValidationError):
self.env['fusion.report.engine'].compute_pnl(
period, company_id=self.env.company.id,
report_code='wrong_type_test',
)
def test_no_report_raises_validation_error(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
# Inactivate any pre-existing GL definitions so the lookup

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