116 Commits

Author SHA1 Message Date
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
3491069f48 docs(fusion_accounting_followup): CLAUDE.md, UPGRADE_NOTES.md, README.md
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Made-with: Cursor
2026-04-19 21:41:41 -04:00
gsinghpal
fbc1ac38f8 feat(fusion_accounting): meta-module now installs followup sub-module
Made-with: Cursor
2026-04-19 21:40:10 -04:00
gsinghpal
aeb5461ad0 test(fusion_accounting_followup): local LLM follow-up text smoke (skips without LLM)
Made-with: Cursor
2026-04-19 21:39:50 -04:00
gsinghpal
e1f94d5202 test(fusion_accounting_followup): 5 OWL tour tests
Made-with: Cursor
2026-04-19 21:39:08 -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
8eb4b8dc6c fix(fusion_accounting_followup): seeded levels + migration idempotency
- test_create_minimal/negative_delay used sequence=1, which now collides
  with the seeded Friendly Reminder level. Use sequences 901/902.
- migration backfill: search by name (not raw seq) for idempotency,
  allocate sequence as max(existing)+1 to avoid both seed clashes and
  within-batch collisions when Enterprise has duplicate sequence values.

Made-with: Cursor
2026-04-19 21:33:26 -04:00
gsinghpal
d0a912b1da test(fusion_accounting_followup): coexistence behavior
Made-with: Cursor
2026-04-19 21:30:26 -04:00
gsinghpal
8ef88da94a feat(fusion_accounting_followup): menu + window actions with coexistence group filter
Made-with: Cursor
2026-04-19 21:30:06 -04:00
gsinghpal
38a2684782 feat(fusion_accounting_followup): migration wizard backfill from account_followup
Made-with: Cursor
2026-04-19 21:29:38 -04:00
gsinghpal
2ec90a50b0 feat(fusion_accounting_followup): batch send follow-ups wizard
Made-with: Cursor
2026-04-19 21:28:58 -04:00
gsinghpal
4ee261e189 feat(fusion_accounting_followup): default mail templates for 3 escalation levels
Made-with: Cursor
2026-04-19 21:27:59 -04:00
gsinghpal
ab3fcc56db feat(fusion_accounting_followup): seed 3 default follow-up levels
Made-with: Cursor
2026-04-19 21:27:33 -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
474485f963 feat(fusion_accounting_followup): ai_text_panel + followup_history_table components
Made-with: Cursor
2026-04-19 21:20:51 -04:00
gsinghpal
da746698c5 feat(fusion_accounting_followup): partner_card + aging_bucket_strip + risk_badge components
Made-with: Cursor
2026-04-19 21:19:52 -04:00
gsinghpal
21f6171162 feat(fusion_accounting_followup): top-level followup_dashboard component
Made-with: Cursor
2026-04-19 21:18:59 -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
86bead48e1 feat(fusion_accounting_followup): followup_service.js reactive frontend service
Made-with: Cursor
2026-04-19 21:17:57 -04:00
gsinghpal
99e4f8e17f feat(fusion_accounting_followup): SCSS foundation for OWL widget
Made-with: Cursor
2026-04-19 21:17:18 -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
f45d66c465 test(fusion_accounting_followup): performance benchmarks with P95 targets
Made-with: Cursor
2026-04-19 21:10:02 -04:00
gsinghpal
f64b8f373c test(fusion_accounting_followup): full follow-up flow integration test
Made-with: Cursor
2026-04-19 21:09:17 -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
d51a2b104e test(fusion_accounting_followup): Hypothesis property-based invariants
Made-with: Cursor
2026-04-19 21:08:35 -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
042dcf8067 feat(fusion_accounting_followup): 2 cron jobs (daily scan + weekly risk refresh)
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
- fusion.followup.cron AbstractModel with two handlers
- cron_fusion_followup_daily_scan: walks every overdue partner and
  delegates to engine.send_followup_email
- cron_fusion_followup_risk_refresh: weekly refresh of
  fusion_followup_risk_score / risk_band on res.partner
- V19 ir.cron records (no numbercall field)
- 2 smoke tests added (80 total)

Made-with: Cursor
2026-04-19 21:04:37 -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
52becd176a feat(fusion_accounting_ai): 5 new customer follow-up AI tools
Adds Task 17 tool layer:
- fusion_list_overdue
- fusion_get_partner_followup_detail
- fusion_generate_followup_text
- fusion_send_followup
- fusion_get_partner_risk_score

Tools register through TOOL_DISPATCH and degrade with a clear
error message when fusion_accounting_followup is not installed.
5 TransactionCase tests added (78 total).

Made-with: Cursor
2026-04-19 21:03:30 -04:00
gsinghpal
993df3a14a feat(fusion_accounting_ai): wire FollowupAdapter fusion paths to engine
- Switch FUSION_MODEL to fusion.followup.engine so adapter mode
  selection matches the new module
- Add list_overdue() with fusion/enterprise/community variants
- Re-route send_followup_via_fusion to engine.send_followup_email
- 4 new TransactionCase tests (73 total)

Existing aging / overdue_invoices adapter methods continue to fall
back to the community implementation.

Made-with: Cursor
2026-04-19 21:02:17 -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
d455016c27 feat(fusion_accounting_followup): 6 JSON-RPC endpoints for OWL widget
Adds Task 15 controller layer:
- /fusion/followup/list_overdue
- /fusion/followup/get_partner_detail
- /fusion/followup/generate_text
- /fusion/followup/send
- /fusion/followup/pause
- /fusion/followup/reset

All endpoints use V19 type='jsonrpc' and route through
fusion.followup.engine. 6 HttpCase tests added (69 total).

Made-with: Cursor
2026-04-19 21:00:07 -04:00
gsinghpal
9b6d6b3895 test(fusion_accounting_followup): engine integration tests for full lifecycle
End-to-end flows over a real posted receivable line: aging discovery,
level resolution, send-with-cache reuse, pause+force override, and
audit history growth. Adds ignore_pause kwarg to compute_followup_level
so force=True in send_followup_email reaches level resolution.

Made-with: Cursor
2026-04-19 20:54:13 -04:00
gsinghpal
6802d60e44 feat(fusion_accounting_followup): fusion.followup.engine 7-method API
The orchestrator AbstractModel for follow-up lifecycle.
get_overdue_for_partner, compute_followup_level, send_followup_email,
escalate_to_next_level, pause_followup, reset_followup, snapshot_followup_history.

All controllers, AI tools, wizards, cron must route through these
methods; no direct ORM writes to fusion.followup.run from anywhere else.

Made-with: Cursor
2026-04-19 20:52:27 -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
06dafc31c1 feat(fusion_accounting_followup): inherit account.move.line for level tracking
Made-with: Cursor
2026-04-19 20:47:37 -04:00
gsinghpal
2ddc600d65 feat(fusion_accounting_followup): inherit res.partner with follow-up state
Made-with: Cursor
2026-04-19 20:46:08 -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
207c857e6b feat(fusion_accounting_followup): LLM text cache model
Made-with: Cursor
2026-04-19 20:45:27 -04:00
gsinghpal
05de855cea feat(fusion_accounting_followup): fusion.followup.run audit model
Made-with: Cursor
2026-04-19 20:44:39 -04:00
gsinghpal
9ae9161892 feat(fusion_accounting_followup): fusion.followup.level definition model
Made-with: Cursor
2026-04-19 20:43:51 -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
1829f0584f feat(fusion_accounting_followup): AI follow-up text generator + prompt
Made-with: Cursor
2026-04-19 20:40:26 -04:00
gsinghpal
63f3e0ec14 feat(fusion_accounting_followup): tone_selector service
Made-with: Cursor
2026-04-19 20:39:17 -04:00
gsinghpal
397fb238c5 feat(fusion_accounting_followup): risk_scorer service
Made-with: Cursor
2026-04-19 20:38: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
d4ef19858d feat(fusion_accounting_followup): level_resolver service
Made-with: Cursor
2026-04-19 20:38:02 -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
4ce0edc698 feat(fusion_accounting_followup): overdue_aging service with 6 buckets
Made-with: Cursor
2026-04-19 20:35:39 -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
gsinghpal
ea2f44287f feat(fusion_accounting_followup): Phase 4 skeleton + plan
35-task plan to replace Enterprise account_followup module:
- Multi-level dunning (gentle reminder -> firm warning -> legal)
- AI augmentation: contextual follow-up text generation + payment risk scoring + tone selection
- HYBRID engine: shared primitives + persisted level/run/cache models
- Per-partner state: current level, paused-until, history
- Coexists with Enterprise (group_fusion_show_when_enterprise_absent)
- Same V19 conventions + test pyramid + perf-budget discipline as Phases 1-3

Made-with: Cursor
2026-04-19 20:31:07 -04:00
gsinghpal
b4558a223c feat(configurator): stub fp.direct.order.line model for multi-line direct order wizard
Task A1 of the direct-order-wizard rewrite. Adds the transient line
model that will hold per-part detail (part, coating, qty, price) when
the wizard moves from single-line to header+lines architecture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:29:52 -04:00
gsinghpal
7a53012f09 Merge Phase 3: AI-augmented asset management
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
50 tasks shipped on fusion_accounting/phase-3-assets:
- fusion.asset.engine (7-method API: compute_schedule, post, dispose, partial_sale, pause, resume, reverse)
- 3 depreciation methods (straight-line, declining-balance, units-of-production)
- 6 persisted models + materialized view for portfolio queries
- AI: anomaly detection + LLM-suggested useful life with templated fallback
- 8 JSON-RPC controller endpoints + reactive frontend service
- 6 OWL components + SCSS tokens + dark mode
- 4 wizards (creation w/ AI suggest, disposal, partial sale, depreciation run)
- Migration wizard backfill from account.asset (verified live: 2 records, idempotent)
- Audit PDF report
- 2 cron jobs (daily depreciation post + monthly anomaly scan)
- 5 AI chat tools
- Coexists with Enterprise (group_fusion_show_when_enterprise_absent)
- 141 tests passing (unit, integration, property-based, controller, MV, wizards, coexistence, perf, LLM compat, OWL tours)
- All 5 P95 perf metrics within 1x of budget (8x-500x headroom)
2026-04-19 20:29:40 -04:00
gsinghpal
43e1f3d6f5 docs(fusion_accounting_assets): CLAUDE.md, UPGRADE_NOTES.md, README.md
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
Mirrors Phase 1 + 2 doc layout. CLAUDE.md captures architecture, the
7-method engine API, persisted models, controllers, OWL frontend,
performance baselines (Tasks 23 + 41 numbers), test counts (140), and
Phase 3.5 backlog. UPGRADE_NOTES.md anchors the Odoo 19 reference and
records V19 deprecations applied. README.md is the user-facing intro.

Made-with: Cursor
2026-04-19 20:25:16 -04:00
gsinghpal
69453bd8ae feat(fusion_accounting): meta-module now installs assets sub-module
Adds fusion_accounting_assets to the meta-module 'depends' so a single
install of fusion_accounting brings up the full Phase 1 + 2 + 3 stack.
Bumps version 19.0.1.0.2 -> 19.0.1.0.3.

Made-with: Cursor
2026-04-19 20:23:47 -04:00
gsinghpal
7e2c31e371 test(fusion_accounting_assets): local LLM useful-life smoke (skips without LLM)
Auto-detects LM Studio (:1234) or Ollama (:11434) on
host.docker.internal / localhost; skips silently when no server is
reachable so CI stays green. When a server is present it exercises the
full predict_useful_life path through the OpenAI-compatible adapter,
catching prompt / JSON-parsing regressions that mocked LLMs hide.

Tagged 'local_llm' so it can be selected explicitly when an LLM is
known-available.

Made-with: Cursor
2026-04-19 20:23:30 -04:00
gsinghpal
6344a75150 test(fusion_accounting_assets): controller perf benchmark
Adds JSON-RPC controller benchmark to complement Task 23's engine-level
benchmarks: end-to-end /fusion/assets/get_detail timing through the HTTP
dispatch layer.

Captured locally on westin-v19:
  controller.get_detail: median=2ms p95=40ms (target <500ms, 12x headroom)

Tagged 'benchmark' so it stays out of fast unit runs.

Made-with: Cursor
2026-04-19 20:22:50 -04:00
gsinghpal
59ecc9fc5b test(fusion_accounting_assets): 5 OWL tour tests
Mirrors Phase 1 + 2 tour pattern: HttpCase.start_tour wrappers tagged
'tour' so they skip cleanly when websocket-client is absent. Tours cover
smoke (/odoo loads), the asset list / category list / anomaly list views,
and the depreciation-run wizard form. Bundle is wired via
web.assets_tests.

Verified locally: 5 tests registered, all skip with
"websocket-client module is not installed" (expected — no chromium in
the dev container).

Made-with: Cursor
2026-04-19 20:22:13 -04:00
gsinghpal
2ee341316c test(fusion_accounting_assets): coexistence behavior
Made-with: Cursor
2026-04-19 20:16:30 -04:00
gsinghpal
02885108f2 feat(fusion_accounting_assets): menu + window actions with coexistence group filter
Made-with: Cursor
2026-04-19 20:15:38 -04:00
gsinghpal
af8c72a3b1 feat(fusion_accounting_assets): migration audit PDF report
Made-with: Cursor
2026-04-19 20:14:50 -04:00
gsinghpal
1491f455fe feat(fusion_accounting_assets): migration wizard backfill from account.asset
Made-with: Cursor
2026-04-19 20:13:30 -04:00
gsinghpal
3efef7efc7 feat(fusion_accounting_assets): depreciation run wizard
Made-with: Cursor
2026-04-19 20:06:25 -04:00
gsinghpal
92f445eb8f feat(fusion_accounting_assets): partial sale wizard
Made-with: Cursor
2026-04-19 20:05:17 -04:00
gsinghpal
892c37e2b0 feat(fusion_accounting_assets): disposal wizard
Made-with: Cursor
2026-04-19 20:04:03 -04:00
gsinghpal
a6ef7e0c2a feat(fusion_accounting_assets): asset creation wizard with AI useful-life suggest
Made-with: Cursor
2026-04-19 20:02:46 -04:00
gsinghpal
9794970429 feat(fusion_accounting_assets): ai_useful_life_panel + anomaly_strip components
Made-with: Cursor
2026-04-19 17:39:56 -04:00
gsinghpal
c0b8cc4159 feat(fusion_accounting_assets): disposal_dialog component
Made-with: Cursor
2026-04-19 17:39:17 -04:00
gsinghpal
51bff01f13 feat(fusion_accounting_assets): depreciation_board component
Made-with: Cursor
2026-04-19 17:38:50 -04:00
gsinghpal
7ba15c65aa feat(fusion_accounting_assets): asset_detail_panel component
Made-with: Cursor
2026-04-19 17:38:28 -04:00
gsinghpal
bf8689716c feat(fusion_accounting_assets): asset_card component
Made-with: Cursor
2026-04-19 17:37:57 -04:00
gsinghpal
bddd22cabd feat(fusion_accounting_assets): top-level asset_dashboard component
Made-with: Cursor
2026-04-19 17:37:34 -04:00
gsinghpal
6051ef22a0 feat(fusion_accounting_assets): assets_service.js reactive frontend service
Made-with: Cursor
2026-04-19 17:36:52 -04:00
gsinghpal
24f8a5857e feat(fusion_accounting_assets): SCSS foundation for OWL widget
Made-with: Cursor
2026-04-19 17:36:11 -04:00
gsinghpal
475d17c1aa test(fusion_accounting_assets): performance benchmarks with P95 targets
Made-with: Cursor
2026-04-19 17:26:01 -04:00
gsinghpal
fec1c12246 feat(fusion_accounting_assets): MV for per-asset book value snapshot
Made-with: Cursor
2026-04-19 17:25:14 -04:00
gsinghpal
c939b83812 test(fusion_accounting_assets): integration tests for all 3 depreciation methods
Made-with: Cursor
2026-04-19 17:23:41 -04:00
gsinghpal
1e70b8d5c0 test(fusion_accounting_assets): Hypothesis property-based depreciation invariants
Made-with: Cursor
2026-04-19 17:22:55 -04:00
gsinghpal
de6d8fda3e feat(fusion_accounting_assets): 2 cron jobs (depreciation post + anomaly scan)
Some checks failed
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
Made-with: Cursor
2026-04-19 17:17:21 -04:00
gsinghpal
9092a78be2 feat(fusion_accounting_ai): 5 new asset management AI tools
Made-with: Cursor
2026-04-19 17:16:22 -04:00
gsinghpal
79cd0216ff feat(fusion_accounting_ai): wire AssetsAdapter fusion paths to engine
Made-with: Cursor
2026-04-19 17:15:24 -04:00
gsinghpal
3e8b7b1e82 feat(fusion_accounting_assets): 8 JSON-RPC endpoints for OWL widget
Made-with: Cursor
2026-04-19 17:14:22 -04:00
gsinghpal
345c971d59 test(fusion_accounting_assets): engine integration tests for full lifecycle
Made-with: Cursor
2026-04-19 17:06:55 -04:00
gsinghpal
54922a0b32 feat(fusion_accounting_assets): fusion.asset.engine 7-method API
The orchestrator AbstractModel for asset depreciation lifecycle.
compute_depreciation_schedule, post_depreciation_entry, dispose_asset,
partial_sale, pause_asset, resume_asset, reverse_disposal.

All controllers, AI tools, wizards, and cron must route through these
methods; no direct ORM writes to fusion.asset.depreciation.line or
account.move from anywhere else.

Made-with: Cursor
2026-04-19 17:06:12 -04:00
gsinghpal
38a6e375e6 feat(fusion_accounting_assets): inherit account.move.line for asset linkage
- fusion_asset_id Many2one on account.move.line (ondelete='set null':
  invoice line preserved if asset is removed)
- fusion_asset_count compute (smart-button friendly)
- action_open_fusion_asset() returns a window action to jump to the asset
- 3 new tests (66 total)

Made-with: Cursor
2026-04-19 16:59:44 -04:00
gsinghpal
8659f51935 feat(fusion_accounting_assets): asset anomaly persisted model
- 3 anomaly types: behind_schedule, ahead_of_schedule, low_utilization
- 3 severity levels: low, medium, high
- expected / actual / variance_pct (mirrors anomaly_detection service output)
- 4-state lifecycle: new -> acknowledged -> resolved (or dismissed)
- action_acknowledge / action_dismiss / action_resolve transitions
- ondelete='cascade' on asset_id (anomalies follow the asset)
- 4 new tests (63 total)

Made-with: Cursor
2026-04-19 16:58:56 -04:00
gsinghpal
5c89763191 feat(fusion_accounting_assets): asset disposal record model
- 4 disposal types: sale, scrap, donation, lost
- mail.thread tracking on type / date / sale amount / partner
- gain_loss_amount computed:
    - sale: sale_amount - book_value_at_disposal
    - scrap / donation / lost: -book_value_at_disposal (full loss)
- ondelete='restrict' on asset_id (cannot delete an asset with disposal)
- move_id placeholder for engine-created journal entry
- 4 new tests (59 total)

Made-with: Cursor
2026-04-19 16:58:12 -04:00
gsinghpal
b68d1b1c66 feat(fusion_accounting_assets): asset category template model
- defaults applied to new assets (method, useful_life, declining rate,
  salvage %, prorate convention)
- GL account hooks: asset_account_id, depreciation_account_id,
  expense_account_id (domain-filtered to relevant account types)
- computed asset_count for kanban / list views
- 3 new tests (55 total)

Made-with: Cursor
2026-04-19 16:57:25 -04:00
gsinghpal
0439d81675 feat(fusion_accounting_assets): depreciation board line model
- period_index, scheduled_date, amount, accumulated, book_value_at_end
- is_posted / posted_date / move_id (set when engine posts the entry)
- action_post() marks the line as posted (idempotent)
- UNIQUE(asset_id, period_index) constraint via models.Constraint
- 5 new tests (52 total)

Made-with: Cursor
2026-04-19 16:56:47 -04:00
gsinghpal
70e4404d9b feat(fusion_accounting_assets): main fusion.asset model with state machine
- fusion.asset: lifecycle (draft -> running -> paused -> disposed)
- mail.thread + mail.activity.mixin tracking
- 3 depreciation methods + 3 prorate conventions selections
- monetary cost / salvage with check constraints (models.Constraint)
- computed book_value, total_depreciated, last_posted_date
- action_set_running / pause / resume / set_draft transitions
- minimal stubs for fusion.asset.category and
  fusion.asset.depreciation.line so the One2many / Many2one comodels
  resolve at registry build time; expanded in Tasks 9 + 10
- 7 new tests (47 total)

Made-with: Cursor
2026-04-19 16:55:59 -04:00
gsinghpal
bc7ba27d77 feat(fusion_accounting_assets): AI useful life predictor + prompt
Made-with: Cursor
2026-04-19 16:50:01 -04:00
gsinghpal
19cbed5b37 feat(fusion_accounting_assets): asset anomaly detection service
Made-with: Cursor
2026-04-19 16:49:02 -04:00
gsinghpal
b7c171f983 feat(fusion_accounting_assets): salvage_value service
Made-with: Cursor
2026-04-19 16:48:18 -04:00
gsinghpal
bece120ee3 feat(fusion_accounting_assets): prorate service for partial-period depreciation
Made-with: Cursor
2026-04-19 16:47:31 -04:00
gsinghpal
3e73ca0eb7 feat(fusion_accounting_assets): 3 depreciation methods (straight, declining, units)
Made-with: Cursor
2026-04-19 16:46:54 -04:00
gsinghpal
99b6990dd6 feat(fusion_accounting_assets): Phase 3 skeleton + plan
50-task plan to replace Enterprise account_asset module:
- CORE scope: 3 depreciation methods (straight-line, declining-balance, units-of-production)
- HYBRID engine: shared primitives + persisted asset/category/disposal/anomaly models
- AI augmentation: utilization anomaly detection + LLM-suggested useful life
- Full lifecycle: draft -> running -> paused -> disposed
- Coexists with Enterprise (group_fusion_show_when_enterprise_absent)
- Same V19 conventions + test pyramid + perf-budget discipline as Phases 1-2

Skeleton: empty manifest + dirs + icon. Tasks 3-50 add the substance.
Made-with: Cursor
2026-04-19 16:43:06 -04:00
gsinghpal
fdfaf7e779 Merge Phase 2: AI-augmented financial reports
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
46 tasks shipped on fusion_accounting/phase-2-reports:
- fusion.report.engine (5-method API: compute_pnl/balance_sheet/trial_balance/gl/drill_down)
- 4 CORE reports seeded (P&L, balance sheet, trial balance, general ledger)
- AI layer: anomaly detection + LLM commentary generator
- 8 JSON-RPC controller endpoints + reactive frontend service
- 8 OWL components + SCSS tokens (light + dark)
- Materialized view + 2 cron jobs (anomaly scan + MV refresh)
- 3 wizards (XLSX export, period picker, migration bootstrap)
- PDF export via QWeb
- 130 tests passing (engine, integration, property-based, controller, MV, wizards, coexistence, perf, LLM compat, OWL tours)
- All 6 P95 perf metrics within 1x of budget (37x-250x headroom)
2026-04-19 16:41:17 -04:00
221 changed files with 13338 additions and 388 deletions

View File

@@ -0,0 +1,165 @@
# Phase 3 — Fusion Accounting Assets Implementation Plan
**Module:** `fusion_accounting_assets`
**Branch:** `fusion_accounting/phase-3-assets`
**Pre-phase tag:** `fusion_accounting/pre-phase-3`
**Estimated tasks:** ~50
**Reference:** `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_asset/` (~2258 LOC Python)
## Goal
Replace Odoo Enterprise's `account_asset` module — asset management with depreciation schedules, disposal, partial sale, and reporting. CORE scope: 3 depreciation methods (straight-line, declining balance, units of production), full asset lifecycle, depreciation board, disposal/sale wizards. AI augmentation: utilization anomaly detection + AI-suggested useful life from invoice context. Coexists with Enterprise.
## Architecture (HYBRID engine, Phase 1+2 pattern)
```
fusion.asset.engine (AbstractModel) ← shared primitives
├── compute_depreciation_schedule(asset, recompute=False)
├── post_depreciation_entry(asset, period)
├── dispose_asset(asset, *, sale_amount, sale_date, sale_partner=None)
├── partial_sale(asset, *, sold_amount, sold_qty, sale_date)
├── pause_asset(asset, pause_date)
├── resume_asset(asset, resume_date)
└── reverse_disposal(asset)
services/ ← pure-Python
├── depreciation_methods.py → straight_line, declining_balance, units_of_production
├── prorate.py → first/last period prorating (calendar/365/etc.)
├── salvage_value.py → end-of-life value math
├── anomaly_detection.py → utilization variance vs expected
├── useful_life_predictor.py → LLM-suggested useful life from invoice description
└── useful_life_prompt.py → provider-agnostic LLM prompt
models/
├── fusion_asset.py → main fusion.asset model
├── fusion_asset_depreciation_line.py → depreciation board lines
├── fusion_asset_category.py → categories with default settings
├── fusion_asset_disposal.py → disposal records
├── fusion_asset_anomaly.py → flagged utilization issues
├── fusion_asset_engine.py → AbstractModel orchestrator
└── account_move.py → inherit (link to asset, generate from invoice)
controllers/assets_controller.py ← 8 JSON-RPC endpoints
├── /fusion/assets/list → paginated asset list with filters
├── /fusion/assets/get_detail → single asset with full schedule
├── /fusion/assets/compute_schedule → recompute depreciation board
├── /fusion/assets/post_depreciation → run periodic depreciation cron
├── /fusion/assets/dispose → dispose an asset
├── /fusion/assets/get_anomalies → list flagged variances
├── /fusion/assets/suggest_useful_life → AI suggest useful life
└── /fusion/assets/get_partner_history → asset-related partner history
static/src/
├── scss/ ← asset-specific design tokens
├── services/assets_service.js ← reactive state + RPC wrappers
├── views/asset_dashboard/ ← top-level OWL controller
└── components/ ← asset_card, depreciation_board, disposal_dialog,
ai_useful_life_panel, anomaly_strip
```
## Coexistence
`group_fusion_show_when_enterprise_absent` from `fusion_accounting_core`. Asset menu only visible when `account_asset` NOT installed. Engine + AI tools always available.
## Tasks (50 total)
### Group 1: Foundation (1-2)
1. Safety net (DONE)
2. Plan doc + module skeleton
### Group 2: Pure-Python services TDD (3-7)
3. `services/depreciation_methods.py` — straight_line + declining_balance + units_of_production (TDD)
4. `services/prorate.py` — first/last period prorating
5. `services/salvage_value.py` — end-of-life math
6. `services/anomaly_detection.py` — utilization variance
7. `services/useful_life_predictor.py` + `useful_life_prompt.py` — LLM integration
### Group 3: Persisted models (8-13)
8. `models/fusion_asset.py` — main asset model with state machine
9. `models/fusion_asset_depreciation_line.py` — depreciation board lines
10. `models/fusion_asset_category.py` — categories with defaults
11. `models/fusion_asset_disposal.py` — disposal records
12. `models/fusion_asset_anomaly.py` — flagged anomalies
13. `models/account_move.py` (inherit) — link asset to invoice
### Group 4: Engine (14-15)
14. `models/fusion_asset_engine.py` — 7-method API
15. Engine integration tests (compute_schedule + post_depreciation + dispose end-to-end)
### Group 5: Backend wiring (16-19)
16. JSON-RPC controller (8 endpoints)
17. AssetsAdapter wiring `_via_fusion` paths
18. 5 new AI tools
19. Cron — daily depreciation post + monthly anomaly scan
### Group 6: Tests + perf (20-23)
20. Property-based tests (Hypothesis: schedule sums == cost - salvage)
21. Integration tests — straight-line + declining-balance + units-of-production
22. Materialized view for asset book values (perf)
23. Performance benchmarks
### Group 7: Frontend OWL (24-31)
24. SCSS tokens + main asset stylesheet (light + dark)
25. `assets_service.js` (reactive state + RPC wrappers)
26. `asset_dashboard` (top-level kanban + summary)
27. `asset_card` (one asset summary card)
28. `asset_detail_panel` (right-side: schedule, history, AI suggestions)
29. `depreciation_board` (table view of schedule with edit chevrons)
30. `disposal_dialog` (sale/scrap wizard)
31. Fusion-only: `ai_useful_life_panel` + `anomaly_strip`
### Group 8: Wizards (32-35)
32. Asset creation wizard (from invoice line)
33. Disposal wizard (sale, scrap, donation)
34. Partial sale wizard
35. Period picker for depreciation runs
### Group 9: Migration + coexistence (36-39)
36. Migration wizard inheritance — backfill from account.asset rows
37. Audit report PDF (per-company asset count, total NBV, etc.)
38. Menu + window action with coexistence group filter
39. Coexistence test
### Group 10: Final tests + polish (40-50)
40. 5 OWL tour tests
41. Performance benchmarks (P95: schedule compute < 500ms, board render < 200ms)
42. Optimize if benchmarks fail (conditional)
43. Local LLM compat test for useful_life_predictor
44. Update meta-module manifest
45. CLAUDE.md, UPGRADE_NOTES.md, README.md
46. End-to-end smoke + tag phase-3-complete + push
47-50. Reserved for inherited features: account_move integration, draft journal entries, post-on-confirm flow, fiscal-year-aware proration
## Performance Targets (P95)
- `compute_schedule` (10-year asset): <500ms
- `post_depreciation_entry`: <200ms
- `dispose_asset`: <300ms
- Controller `list`: <300ms
- Controller `get_detail`: <500ms
## V19 Conventions (carried from Phase 1+2)
- `models.Constraint` not `_sql_constraints`
- No `@api.depends('id')` on stored compute fields
- `@route(type='jsonrpc')` not `type='json'`
- `ir.cron` has no `numbercall` field
- `res.groups.user_ids` not `users`
- `ir.ui.menu.group_ids` not `groups_id`
- `models.Constraint` for unique-keys
- `env.flush_all()` before MV REFRESH
- REFRESH MATERIALIZED VIEW CONCURRENTLY needs autocommit cursor
## Test Targets
Match Phase 1+2 test pyramid:
- Unit (pure-Python services)
- Integration (engine end-to-end)
- Property-based (Hypothesis: schedule total invariants)
- Controller (HttpCase JSON-RPC)
- MV correctness
- Performance benchmarks (tagged 'benchmark')
- OWL tours (tagged 'tour')
- Local LLM smoke (tagged 'local_llm')
Phase 1+2 final: 287 tests. Phase 3 target: ~140-180 additional → ~430-470 total.

View File

@@ -0,0 +1,140 @@
# Phase 4 — Fusion Accounting Follow-up Implementation Plan
**Module:** `fusion_accounting_followup`
**Branch:** `fusion_accounting/phase-4-followup`
**Pre-phase tag:** `fusion_accounting/pre-phase-4`
**Estimated tasks:** ~35
**Reference:** `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_followup/` (~1318 LOC Python)
## Goal
Replace Enterprise's `account_followup` module — multi-level dunning sequences for unpaid invoices, with AI augmentation: contextually-appropriate follow-up text generation + payment-risk scoring + tone adjustment based on customer history. Coexists with Enterprise.
## Architecture (HYBRID engine, Phases 1-3 pattern)
```
fusion.followup.engine (AbstractModel) ← shared primitives
├── compute_followup_level(partner)
├── get_overdue_for_partner(partner)
├── send_followup_email(partner, level=None)
├── escalate_to_next_level(partner)
├── pause_followup(partner, until_date)
├── reset_followup(partner)
└── snapshot_followup_history(partner) ← audit/history
services/ ← pure-Python
├── overdue_aging.py → bucket overdue lines (current/30/60/90/120+)
├── level_resolver.py → match aging buckets to follow-up levels
├── risk_scorer.py → payment-history risk score (0-100)
├── tone_selector.py → gentle/firm/legal based on level + risk
├── followup_text_generator.py → LLM-generated follow-up text
└── followup_text_prompt.py → provider-agnostic LLM prompt
models/
├── fusion_followup_level.py → level definition (delay days, template, action)
├── fusion_followup_run.py → execution record (per-partner per-level)
├── fusion_followup_text_cache.py → LLM-generated text cache (cost-saving)
├── fusion_followup_engine.py → AbstractModel orchestrator
├── res_partner.py (inherit) → fusion_followup_status, fusion_followup_paused_until
└── account_move_line.py (inherit) → followup_level_id (which level last contacted at)
controllers/followup_controller.py ← 6 JSON-RPC endpoints
├── /fusion/followup/list_overdue → list partners with overdue
├── /fusion/followup/get_partner_detail → single partner with aging + history
├── /fusion/followup/generate_text → AI-generate follow-up text
├── /fusion/followup/send → send a follow-up email
├── /fusion/followup/pause → pause follow-ups for a partner
└── /fusion/followup/reset → reset follow-up state
static/src/
├── scss/ ← follow-up design tokens
├── services/followup_service.js ← reactive state + RPC wrappers
├── views/followup_dashboard/ ← top-level OWL controller
└── components/ ← partner_card, aging_bucket_strip, ai_text_panel,
followup_history_table, risk_badge
```
## Coexistence
`group_fusion_show_when_enterprise_absent`. Follow-up menu visible only when `account_followup` NOT installed.
## Tasks (~35 total)
### Group 1: Foundation (1-2)
1. Safety net (DONE)
2. Plan doc + module skeleton
### Group 2: Pure-Python services TDD (3-7)
3. `services/overdue_aging.py` (TDD: bucket lines into 0/30/60/90/120+)
4. `services/level_resolver.py` (TDD: match aging to level)
5. `services/risk_scorer.py` (TDD: payment-history risk 0-100)
6. `services/tone_selector.py` (TDD: gentle/firm/legal)
7. `services/followup_text_generator.py` + `followup_text_prompt.py` (LLM)
### Group 3: Persisted models (8-12)
8. `models/fusion_followup_level.py` (level definition)
9. `models/fusion_followup_run.py` (execution record)
10. `models/fusion_followup_text_cache.py` (LLM cache)
11. `models/res_partner.py` (inherit: fusion_followup_status, paused_until)
12. `models/account_move_line.py` (inherit: followup_level_id)
### Group 4: Engine + integration tests (13-14)
13. `models/fusion_followup_engine.py` (7-method API)
14. Engine integration tests
### Group 5: Backend wiring (15-18)
15. JSON-RPC controller (6 endpoints)
16. FollowupAdapter wiring `_via_fusion` paths
17. 4 new AI tools (list_overdue, generate_text, send_followup, get_risk_score)
18. Cron — daily scan + escalate
### Group 6: Tests + perf (19-21)
19. Property-based tests (Hypothesis: aging buckets sum to total)
20. Integration tests (full follow-up flow: scan → escalate → send → reset)
21. Performance benchmarks (P95: scan < 500ms, generate_text < 5s incl. LLM)
### Group 7: Frontend (22-26)
22. SCSS tokens + main stylesheet
23. `followup_service.js`
24. `followup_dashboard` (top-level)
25. `partner_card` + `aging_bucket_strip` + `risk_badge`
26. `ai_text_panel` (Fusion-only) + `followup_history_table`
### Group 8: Wizards + data (27-29)
27. Default follow-up levels XML data (7-day reminder, 30-day, 60-day, legal)
28. Default mail templates XML data (3 escalation levels)
29. "Send batch follow-ups" wizard
### Group 9: Migration + coexistence (30-32)
30. Migration wizard inheritance — backfill from account_followup tables
31. Menu + window action with coexistence group filter
32. Coexistence test
### Group 10: Final tests + polish (33-37)
33. 5 OWL tour tests
34. Local LLM compat test for text_generator
35. Update meta-module manifest
36. CLAUDE.md, UPGRADE_NOTES.md, README.md
37. End-to-end smoke + tag phase-4-complete + push
## Performance Targets (P95)
- `compute_followup_level`: <50ms
- `get_overdue_for_partner`: <100ms
- `send_followup_email` (no LLM): <200ms
- `generate_text` (with LLM): <5s
- Controller `list_overdue` (50 partners): <500ms
## V19 Conventions (Phases 1-3 lessons)
- `models.Constraint` not `_sql_constraints`
- No `@api.depends('id')` on stored compute fields
- `@route(type='jsonrpc')` not `type='json'`
- `ir.cron` no `numbercall` field
- `res.groups.user_ids` not `users`
- `ir.ui.menu.group_ids` not `groups_id`
- `from odoo.exceptions import UserError, ValidationError` (NOT `self.env['ir.exceptions'].UserError`)
## Test Targets
Match Phases 1-3 test pyramid. Phase 4 target: ~80-100 additional tests → ~510-530 total project tests.

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting',
'version': '19.0.1.0.2',
'version': '19.0.1.0.4',
'category': 'Accounting/Accounting',
'sequence': 25,
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
@@ -15,11 +15,11 @@ Currently installs:
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
- fusion_accounting_reports AI-augmented financial reports (Phase 2)
- fusion_accounting_assets AI-augmented asset management (Phase 3)
- fusion_accounting_followup AI-augmented customer follow-ups (Phase 4)
Future sub-modules (added per the roadmap as each Phase ships):
- fusion_accounting_dashboard (Phase 3)
- fusion_accounting_followup (Phase 5)
- fusion_accounting_assets (Phase 6)
- fusion_accounting_dashboard (Phase 5)
- fusion_accounting_budget (Phase 6)
Built by Nexa Systems Inc.
@@ -35,6 +35,8 @@ Built by Nexa Systems Inc.
'fusion_accounting_migration',
'fusion_accounting_bank_rec',
'fusion_accounting_reports',
'fusion_accounting_assets',
'fusion_accounting_followup',
],
'data': [],
'installable': True,

View File

@@ -1,42 +1,98 @@
"""Assets data adapter."""
"""Assets data adapter — routes asset queries through fusion engine if installed."""
from .base import DataAdapter
from ._registry import register_adapter
class AssetsAdapter(DataAdapter):
FUSION_MODEL = 'fusion.asset'
FUSION_MODEL = 'fusion.asset.engine'
ENTERPRISE_MODULE = 'account_asset'
def list_assets(self, state=None):
return self._dispatch('list_assets', state=state)
# ============================================================
# list_assets
# ============================================================
def list_assets_via_fusion(self, state=None):
return self._read_fusion('fusion.asset', state=state)
def list_assets(self, state=None, limit=50, company_id=None):
return self._dispatch(
'list_assets', state=state, limit=limit, company_id=company_id,
)
def list_assets_via_enterprise(self, state=None):
return self._read_fusion('account.asset', state=state)
def list_assets_via_fusion(self, **kwargs):
if 'fusion.asset.engine' not in self.env.registry:
return {'assets': [], 'count': 0, 'total': 0}
Asset = self.env['fusion.asset'].sudo()
domain = [('company_id', '=', kwargs.get('company_id') or self.env.company.id)]
if kwargs.get('state'):
domain.append(('state', '=', kwargs['state']))
total = Asset.search_count(domain)
assets = Asset.search(
domain, limit=int(kwargs.get('limit', 50)),
order='acquisition_date desc',
)
return {
'count': len(assets), 'total': total,
'assets': [{
'id': a.id, 'name': a.name, 'state': a.state,
'cost': a.cost, 'book_value': a.book_value,
'method': a.method,
'category_name': a.category_id.name if a.category_id else None,
} for a in assets],
}
def list_assets_via_community(self, state=None):
# No assets feature in pure Community — return empty list with a hint.
return []
def list_assets_via_enterprise(self, **kwargs):
return {
'assets': [], 'count': 0, 'total': 0,
'error': 'Enterprise account_asset must be queried from Enterprise UI',
}
def _read_fusion(self, model_name, state=None):
"""Shared shape between fusion and enterprise (both use account.asset-like API)."""
Model = self.env[model_name].sudo()
domain = []
if state:
domain.append(('state', '=', state))
records = Model.search(domain, limit=200)
out = []
for r in records:
out.append({
'id': r.id,
'name': getattr(r, 'name', None),
'state': getattr(r, 'state', None),
'value': getattr(r, 'original_value', None) or getattr(r, 'acquisition_cost', None),
})
return out
def list_assets_via_community(self, **kwargs):
return {
'assets': [], 'count': 0, 'total': 0,
'error': 'No assets engine in pure Community',
}
# ============================================================
# suggest_useful_life
# ============================================================
def suggest_useful_life(self, description, amount=None, partner_name=None):
return self._dispatch(
'suggest_useful_life',
description=description, amount=amount, partner_name=partner_name,
)
def suggest_useful_life_via_fusion(self, **kwargs):
if 'fusion.asset.engine' not in self.env.registry:
return {'error': 'fusion_accounting_assets not installed'}
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
predict_useful_life,
)
return predict_useful_life(self.env, **kwargs)
def suggest_useful_life_via_enterprise(self, **kwargs):
return {'error': 'AI useful-life suggestion is fusion-only'}
def suggest_useful_life_via_community(self, **kwargs):
return {'error': 'AI useful-life suggestion is fusion-only'}
# ============================================================
# dispose_asset
# ============================================================
def dispose_asset(self, asset_id, **kwargs):
return self._dispatch('dispose_asset', asset_id=asset_id, **kwargs)
def dispose_asset_via_fusion(self, asset_id, **kwargs):
if 'fusion.asset.engine' not in self.env.registry:
return {'error': 'fusion_accounting_assets not installed'}
asset = self.env['fusion.asset'].sudo().browse(int(asset_id))
return self.env['fusion.asset.engine'].sudo().dispose_asset(asset, **kwargs)
def dispose_asset_via_enterprise(self, asset_id, **kwargs):
return {'error': 'Enterprise asset disposal must use Enterprise UI'}
def dispose_asset_via_community(self, asset_id, **kwargs):
return {'error': 'Community has no asset disposal flow'}
register_adapter('assets', AssetsAdapter)

View File

@@ -28,7 +28,7 @@ def _bucket_for_days(days):
class FollowupAdapter(DataAdapter):
FUSION_MODEL = 'fusion.followup.line'
FUSION_MODEL = 'fusion.followup.engine'
ENTERPRISE_MODULE = 'account_followup'
# ------------------------------------------------------------------
@@ -179,15 +179,29 @@ class FollowupAdapter(DataAdapter):
}
# ------------------------------------------------------------------
# send_followup — Enterprise-only action
# send_followup — routes to fusion engine when available
# ------------------------------------------------------------------
def send_followup(self, partner_id, options=None):
return self._dispatch('send_followup', partner_id=partner_id, options=options)
def send_followup(self, partner_id, level_id=None, force=False, options=None):
return self._dispatch(
'send_followup',
partner_id=partner_id, level_id=level_id,
force=force, options=options,
)
def send_followup_via_fusion(self, partner_id, options=None):
return self.send_followup_via_community(partner_id=partner_id, options=options)
def send_followup_via_fusion(self, partner_id, level_id=None,
force=False, options=None):
if 'fusion.followup.engine' not in self.env.registry:
return {'error': 'fusion_accounting_followup not installed'}
partner = self.env['res.partner'].browse(int(partner_id))
level = None
if level_id:
level = self.env['fusion.followup.level'].browse(int(level_id))
return self.env['fusion.followup.engine'].send_followup_email(
partner, level=level, force=bool(force),
)
def send_followup_via_enterprise(self, partner_id, options=None):
def send_followup_via_enterprise(self, partner_id, level_id=None,
force=False, options=None):
partner = self.env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': 'Partner not found'}
@@ -198,7 +212,8 @@ class FollowupAdapter(DataAdapter):
'result': str(result) if result else 'done',
}
def send_followup_via_community(self, partner_id, options=None):
def send_followup_via_community(self, partner_id, level_id=None,
force=False, options=None):
return {
'error': (
'Sending follow-ups is only available when account_followup '
@@ -206,5 +221,61 @@ class FollowupAdapter(DataAdapter):
),
}
# ------------------------------------------------------------------
# list_overdue — partner-centric overdue rollup (fusion engine)
# ------------------------------------------------------------------
def list_overdue(self, status=None, limit=50, company_id=None):
return self._dispatch(
'list_overdue',
status=status, limit=limit, company_id=company_id,
)
def list_overdue_via_fusion(self, status=None, limit=50, company_id=None):
if 'fusion.followup.engine' not in self.env.registry:
return {'partners': [], 'count': 0, 'total': 0}
company_id = company_id or self.env.company.id
Line = self.env['account.move.line'].sudo()
partner_ids = Line.search([
('parent_state', '=', 'posted'),
('account_id.account_type', '=', 'asset_receivable'),
('reconciled', '=', False),
('amount_residual', '>', 0),
('date_maturity', '<', date.today()),
('company_id', '=', company_id),
]).mapped('partner_id').ids
Partner = self.env['res.partner'].sudo()
domain = [('id', 'in', partner_ids)]
if status:
domain.append(('fusion_followup_status', '=', status))
partners = Partner.search(domain, limit=int(limit))
engine = self.env['fusion.followup.engine']
rows = []
for p in partners:
try:
overdue = engine.get_overdue_for_partner(p)
rows.append({
'partner_id': p.id,
'partner_name': p.name,
'overdue_amount': overdue['aging']['total_overdue_amount'],
'risk_score': overdue['risk']['score'],
'risk_band': overdue['risk']['band'],
'status': p.fusion_followup_status,
})
except Exception:
pass
return {'count': len(rows), 'total': len(partner_ids), 'partners': rows}
def list_overdue_via_enterprise(self, status=None, limit=50, company_id=None):
return {
'partners': [], 'count': 0, 'total': 0,
'error': 'Enterprise account_followup must be used from its UI',
}
def list_overdue_via_community(self, status=None, limit=50, company_id=None):
return {
'partners': [], 'count': 0, 'total': 0,
'error': 'No follow-up engine in pure Community',
}
register_adapter('followup', FollowupAdapter)

View File

@@ -10,11 +10,14 @@ from .adp import TOOLS as ADP_TOOLS
from .reporting import TOOLS as REPORTING_TOOLS
from .audit import TOOLS as AUDIT_TOOLS
from .financial_reports import TOOLS as FINANCIAL_REPORTS_TOOLS
from .asset_management import TOOLS as ASSET_MANAGEMENT_TOOLS
from .customer_followup import TOOLS as CUSTOMER_FOLLOWUP_TOOLS
TOOL_DISPATCH = {}
for tools_dict in [
BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS,
MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS,
REPORTING_TOOLS, AUDIT_TOOLS, FINANCIAL_REPORTS_TOOLS,
ASSET_MANAGEMENT_TOOLS, CUSTOMER_FOLLOWUP_TOOLS,
]:
TOOL_DISPATCH.update(tools_dict)

View File

@@ -0,0 +1,77 @@
"""Fusion-engine-routed AI tools for asset management."""
import logging
_logger = logging.getLogger(__name__)
def fusion_list_assets(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'assets')
return adapter.list_assets(
state=params.get('state'),
limit=int(params.get('limit', 50)),
company_id=int(params['company_id']) if params.get('company_id') else env.company.id,
)
def fusion_get_asset_detail(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
Asset = env['fusion.asset']
asset = Asset.browse(int(params['asset_id']))
if not asset.exists():
return {'error': 'Asset not found'}
return {
'asset': {
'id': asset.id, 'name': asset.name, 'state': asset.state,
'cost': asset.cost, 'book_value': asset.book_value,
'total_depreciated': asset.total_depreciated,
'method': asset.method, 'useful_life_years': asset.useful_life_years,
},
'depreciation_count': len(asset.depreciation_line_ids),
}
def fusion_compute_asset_schedule(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
asset = env['fusion.asset'].browse(int(params['asset_id']))
return env['fusion.asset.engine'].compute_depreciation_schedule(
asset, recompute=bool(params.get('recompute', False)),
)
def fusion_dispose_asset(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'assets')
return adapter.dispose_asset(
asset_id=int(params['asset_id']),
sale_amount=float(params.get('sale_amount', 0)),
disposal_type=params.get('disposal_type', 'sale'),
)
def fusion_suggest_asset_useful_life(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'assets')
return adapter.suggest_useful_life(
description=params.get('description', ''),
amount=float(params['amount']) if params.get('amount') else None,
partner_name=params.get('partner_name'),
)
TOOLS = {
'fusion_list_assets': fusion_list_assets,
'fusion_get_asset_detail': fusion_get_asset_detail,
'fusion_compute_asset_schedule': fusion_compute_asset_schedule,
'fusion_dispose_asset': fusion_dispose_asset,
'fusion_suggest_asset_useful_life': fusion_suggest_asset_useful_life,
}

View File

@@ -0,0 +1,98 @@
"""Fusion-engine-routed AI tools for customer follow-ups.
These tools are exposed through TOOL_DISPATCH and let the assistant query
the customer follow-up engine via natural language. All tools degrade
gracefully when fusion_accounting_followup is not installed.
"""
import logging
_logger = logging.getLogger(__name__)
def fusion_list_overdue(env, params):
"""List partners with overdue invoices, sorted by risk."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'followup')
return adapter.list_overdue(
status=params.get('status'),
limit=int(params.get('limit', 50)),
company_id=int(params['company_id'])
if params.get('company_id') else env.company.id,
)
def fusion_get_partner_followup_detail(env, params):
"""Detailed follow-up state for a single partner: aging, risk, history."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
Partner = env['res.partner']
partner = Partner.browse(int(params['partner_id']))
if not partner.exists():
return {'error': 'Partner not found'}
engine = env['fusion.followup.engine']
overdue = engine.get_overdue_for_partner(partner)
history = engine.snapshot_followup_history(partner, limit=10)
return {
'partner_id': partner.id,
'partner_name': partner.name,
'overdue': overdue,
'history': history,
}
def fusion_generate_followup_text(env, params):
"""Generate (or fall back to template) follow-up subject + body."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
generate_followup_text,
)
return generate_followup_text(
env,
partner_name=params.get('partner_name', ''),
total_overdue=float(params.get('total_overdue', 0)),
currency_code=params.get('currency_code', 'USD'),
longest_overdue_days=int(params.get('longest_overdue_days', 0)),
tone=params.get('tone', 'gentle'),
invoice_count=int(params.get('invoice_count', 0)),
)
def fusion_send_followup(env, params):
"""Send a follow-up email via the engine (creates a fusion.followup.run)."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'followup')
return adapter.send_followup(
partner_id=int(params['partner_id']),
level_id=int(params['level_id']) if params.get('level_id') else None,
force=bool(params.get('force', False)),
)
def fusion_get_partner_risk_score(env, params):
"""Compute and return the payment-risk score + drivers for a partner."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
partner = env['res.partner'].browse(int(params['partner_id']))
if not partner.exists():
return {'error': 'Partner not found'}
overdue = env['fusion.followup.engine'].get_overdue_for_partner(partner)
return {
'partner_id': partner.id,
'partner_name': partner.name,
'risk': overdue['risk'],
}
TOOLS = {
'fusion_list_overdue': fusion_list_overdue,
'fusion_get_partner_followup_detail': fusion_get_partner_followup_detail,
'fusion_generate_followup_text': fusion_generate_followup_text,
'fusion_send_followup': fusion_send_followup,
'fusion_get_partner_risk_score': fusion_get_partner_risk_score,
}

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

@@ -0,0 +1,130 @@
# fusion_accounting_assets — Cursor / Claude Context
## Purpose
AI-augmented fixed asset management with depreciation schedules — a
Fusion-native replacement for (and coexisting with) Odoo Enterprise's
`account_asset` module. Ships in Phase 3 of the fusion_accounting roadmap.
## Architecture
Hybrid: the engine (`fusion.asset.engine`, AbstractModel) is the SINGLE
write surface for the asset lifecycle. Everything else (controllers, OWL
widget, AI tools, wizards, cron) routes through the engine's 7-method
public API:
- `compute_depreciation_schedule(asset, recompute=False)`
- `post_depreciation_entry(asset, period_date=None)`
- `dispose_asset(asset, sale_amount=0, sale_date=None, sale_partner=None, disposal_type='sale')`
- `partial_sale(asset, sold_amount, sold_qty=None, sale_date=None, sale_partner=None)`
- `pause_asset(asset, pause_date=None)`
- `resume_asset(asset, resume_date=None)`
- `reverse_disposal(asset)`
Pure-Python services live in `services/`:
- `depreciation_methods` — straight_line, declining_balance, units_of_production
- `prorate` — first/last-period prorating: full_month, days_365, days_period
- `salvage_value` — % of cost, fixed amount, zero
- `anomaly_detection` — variance vs expected schedule, low utilization
- `useful_life_predictor` + `useful_life_prompt` — LLM-suggested useful life with templated fallback
Persisted models in `models/`:
- `fusion.asset` — main model, state machine: draft → running → paused → disposed
- `fusion.asset.depreciation.line` — board lines
- `fusion.asset.category` — templates
- `fusion.asset.disposal` — disposal records
- `fusion.asset.anomaly` — flagged variances
- `fusion.asset.book.values.mv` — pre-aggregated materialized view
- `fusion.asset.engine` — AbstractModel (the API)
- `fusion.assets.cron` — cron handlers (post depreciations, MV refresh, anomaly scan)
- `account.move.line` (inherits) — adds `fusion_asset_id` linkage
- `fusion.migration.wizard` (inherits in `models/`) — adds asset backfill step
Wizards (TransientModel) in `wizards/`:
- `fusion.create.asset.wizard` — assisted creation with AI useful-life suggestion
- `fusion.disposal.wizard` — full disposal flow
- `fusion.partial.sale.wizard` — partial-quantity disposal
- `fusion.depreciation.run.wizard` — period close runner
Controller: `controllers/assets_controller.py` exposes 8 JSON-RPC
endpoints under `/fusion/assets/*` (list, get_detail, compute_schedule,
post_depreciation, dispose, get_anomalies, suggest_useful_life,
get_partner_history). All calls route through the engine.
OWL frontend: `static/src/`
- `services/assets_service.js` — central reactive state + RPC wrappers
- `views/asset_dashboard/*` — top-level dashboard view
- `components/asset_card`, `asset_detail_panel`, `depreciation_board`,
`disposal_dialog`, `ai_useful_life_panel`, `anomaly_strip` — 6 components
- `scss/_variables.scss` + `assets.scss` + `dark_mode.scss`
- `tours/assets_tours.js` — 5 OWL tour smoke tests
## Coexistence
When `account_asset` is installed the Asset Management menu hides via
`fusion_accounting_core.group_fusion_show_when_enterprise_absent` (a
computed group). The engine + AI tools remain available for the chat.
The migration wizard backfills `fusion.asset` from existing
`account.asset` records (verified live: 2 records, Task 35).
## Conventions
- **V19 deprecations to avoid:** `_sql_constraints` (use
`models.Constraint`), `@api.depends('id')` (raises
`NotImplementedError`), `@route(type='json')` (use `type='jsonrpc'`),
`numbercall` field on `ir.cron` (removed), `groups_id` on `res.users`
(use `all_group_ids` for searching), `users` field on `res.groups`
(use `user_ids`), `groups_id` on `ir.ui.menu` (use `group_ids`).
- **Materialized view refresh:** `fusion.asset.book.values.mv` is
refreshed by cron (REFRESH CONCURRENTLY in an autocommit cursor since
it can't run inside a regular Odoo transaction).
- **Provider routing:** AI features look up
`fusion_accounting.provider.asset_useful_life`, falling back to
`fusion_accounting.provider.default`. When neither is set the
templated keyword fallback in `useful_life_predictor` keeps the
feature usable offline.
## Performance baseline (Tasks 23 + 41)
| Operation | P95 | Budget | Headroom |
|------------------------------------|-------|----------|----------|
| `engine.compute_schedule` (10yr SL)| 1ms | 500ms | 500x |
| `engine.post_depreciation_entry` | <1ms | 300ms | huge |
| `engine.dispose_asset` | 5ms | 300ms | 60x |
| `controller.list` (35 assets) | 42ms | 300ms | 7x |
| `controller.get_detail` | 40ms | 500ms | 12x |
All Phase 3 perf metrics are within 1x of budget; no optimization was
needed at ship (Task 42 skipped per the conditional rule).
## Test counts (Phase 3 ship)
- 140 logical tests total in fusion_accounting_assets
- 0 failures, 0 errors
- Coverage includes: 4 engine benchmarks + 1 controller benchmark
(tagged `benchmark`), 1 local LLM smoke (tagged `local_llm`, skips
when no LLM), 5 OWL tour tests (tagged `tour`, skip without
websocket-client), Hypothesis property tests on the engine,
integration tests on the public API, controller round-trip tests, MV
shape tests.
## Known concerns / Phase 3.5 backlog
- Sub-annual depreciation frequency (currently annual only)
- Units-of-production assumes even per-period units
- Disposal journal entry not yet created — `dispose_asset` writes the
`fusion.asset.disposal` record but not the cash / gain-loss move
- Multi-currency, allocation rules, and analytic tags for depreciation
moves are out of scope for Phase 3
- Partial-sale child asset is created with no own depreciation schedule
pre-disposal
- Migration wizard inheritance lives in `models/` rather than
`wizards/` (small inconsistency with the rest of the wizard layout —
intentional to keep ORM ordering simple)
- `useful_life_predictor` always returns a usable dict (templated
fallback when LLM absent), so callers can't distinguish "AI said so"
from "fallback fired"; the `confidence` key is the only signal

View File

@@ -0,0 +1,53 @@
# fusion_accounting_assets
AI-augmented fixed asset management for Odoo 19 Community — a
Fusion-native replacement for Enterprise's `account_asset` module.
## What it does
- Three depreciation methods: straight-line, declining balance, and
units-of-production
- Asset lifecycle state machine: draft → running → paused → disposed
- Editable depreciation board with full schedule recompute
- Disposal flow (sale, scrap, donation) plus partial-sale wizard
- Daily cron for posting periodic depreciation
- AI augmentation:
- **Anomaly detection** — variance vs expected schedule, low utilization
- **Useful-life suggestion** — LLM-driven from invoice context, with a
keyword-based templated fallback so the feature still works offline
- Coexists with Enterprise `account_asset` (Enterprise wins by default;
the Fusion menu only appears when Enterprise is uninstalled)
- Migration-aware: bootstrap step backfills `fusion.asset` from existing
`account.asset` rows so the AI has memory from day 1
## Quick start
```bash
# Install
odoo --addons-path=... -i fusion_accounting_assets
# Open the dashboard (when Enterprise's account_asset is NOT installed)
# Apps -> Asset Management -> Assets
# When Enterprise IS installed: use Enterprise's UI; the engine + AI tools
# are still available via the AI chat.
```
## Configuration
- Local LLM (LM Studio, Ollama):
- `fusion_accounting.openai_base_url` =
`http://host.docker.internal:1234/v1`
- `fusion_accounting.openai_model` = your local model name
- `fusion_accounting.openai_api_key` = `lm-studio` (anything non-empty)
- `fusion_accounting.provider.asset_useful_life` = `openai`
## Public API (engine)
`fusion.asset.engine` is the single write surface. See `CLAUDE.md` for
the full 7-method signature list.
## See also
- `CLAUDE.md` — agent context
- `UPGRADE_NOTES.md` — Odoo version anchoring

View File

@@ -0,0 +1,49 @@
# fusion_accounting_assets — Upgrade Notes
## Odoo Version Anchor
This module targets **Odoo 19.0** (community-base).
Reference snapshot of Enterprise code mirrored from:
- `account_asset` (Odoo 19.0.x)
- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_asset/`
## Cross-Version Diff Strategy
When a new Odoo version ships:
1. Run `check_odoo_diff.sh` (in repo root) against the new Enterprise version
2. Note any breaking changes in `account.asset` / `account.move.line` API
3. For mirrored OWL components, diff Enterprise's new versions against ours
and port material changes (signature renames, new behaviour we want to
inherit)
4. Re-run the full test suite + tour tests against the new Odoo version
5. Update this file with the new version anchor + any deviations
## V19 Migration Notes (already applied)
- `_sql_constraints``models.Constraint` (every persisted model)
- `@api.depends('id')` → removed (none introduced)
- `@route(type='json')``type='jsonrpc'` (all 8 endpoints in
`controllers/assets_controller.py`)
- `numbercall` removed from `ir.cron` (data/cron.xml)
- `res.groups.users``user_ids` and `ir.ui.menu.groups_id`
`group_ids` (security + menu_views.xml)
## Phase 3 → Phase 3.5 Migration
If we ship Phase 3.5 (sub-annual depreciation frequency, disposal journal
entries, multi-currency, allocation rules), changes will go in
incremental commits. No DB migration needed (Phase 3 schema is
forward-compatible — new columns will be nullable / default-valued).
## Coexistence with Enterprise `account_asset`
The migration step in `fusion.migration.wizard` backfills `fusion.asset`
records from existing `account.asset` rows. It is idempotent (skips rows
already linked via the `legacy_account_asset_id` column). Verified live
on westin-v19: 2 records migrated cleanly.
When `account_asset` is installed the Asset Management menu hides via
`fusion_accounting_core.group_fusion_show_when_enterprise_absent`. The
engine and AI tools remain available for chat-driven workflows.

View File

@@ -0,0 +1,5 @@
from . import models
from . import services
from . import controllers
from . import wizards
from . import reports

View File

@@ -0,0 +1,76 @@
{
'name': 'Fusion Accounting Assets',
'version': '19.0.1.0.36',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented asset management with depreciation schedules.',
'description': """
Fusion Accounting Assets
========================
A Fusion-native replacement for Odoo Enterprise's account_asset module.
CORE scope (Phase 3):
- 3 depreciation methods: straight-line, declining balance, units of production
- Asset lifecycle: draft -> running -> paused -> disposed
- Depreciation board with editable schedule
- Disposal (sale, scrap, donation) + partial sale wizards
- Daily cron for posting periodic depreciation
AI augmentation:
- Anomaly detection on utilization vs expected
- AI-suggested useful life from invoice context (LLM)
Coexists with Enterprise: when account_asset is installed, the Fusion
menu hides; the engine + AI tools remain available for the chat.
""",
'author': 'Fusion Accounting',
'license': 'LGPL-3',
'depends': [
'fusion_accounting_core',
'fusion_accounting_ai',
'fusion_accounting_migration',
'account',
'mail',
],
'data': [
'security/ir.model.access.csv',
'data/cron.xml',
'wizards/create_asset_wizard_views.xml',
'wizards/disposal_wizard_views.xml',
'wizards/partial_sale_wizard_views.xml',
'wizards/depreciation_run_wizard_views.xml',
'reports/migration_audit_report_views.xml',
'reports/migration_audit_report_action.xml',
'views/menu_views.xml',
],
'assets': {
'web.assets_backend': [
'fusion_accounting_assets/static/src/scss/_variables.scss',
'fusion_accounting_assets/static/src/scss/assets.scss',
'fusion_accounting_assets/static/src/scss/dark_mode.scss',
'fusion_accounting_assets/static/src/services/assets_service.js',
'fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.js',
'fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.xml',
'fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard_view.js',
'fusion_accounting_assets/static/src/components/asset_card/asset_card.js',
'fusion_accounting_assets/static/src/components/asset_card/asset_card.xml',
'fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.js',
'fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.xml',
'fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.js',
'fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.xml',
'fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.js',
'fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.xml',
'fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.js',
'fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.xml',
'fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.js',
'fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.xml',
],
'web.assets_tests': [
'fusion_accounting_assets/static/src/tours/assets_tours.js',
],
},
'installable': True,
'auto_install': False,
'application': False,
'icon': '/fusion_accounting_assets/static/description/icon.png',
}

View File

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

View File

@@ -0,0 +1,175 @@
"""HTTP controller: 8 JSON-RPC endpoints for the OWL asset dashboard.
All endpoints route through fusion.asset.engine. V19 type='jsonrpc'.
"""
import logging
from datetime import date, datetime
from odoo import _, http
from odoo.exceptions import ValidationError
from odoo.http import request
_logger = logging.getLogger(__name__)
def _parse_date(value):
if isinstance(value, date):
return value
if not value:
return None
return datetime.strptime(value, '%Y-%m-%d').date()
class FusionAssetsController(http.Controller):
@http.route('/fusion/assets/list', type='jsonrpc', auth='user')
def list_assets(self, state=None, category_id=None, limit=50, offset=0,
company_id=None):
company_id = int(company_id) if company_id else request.env.company.id
Asset = request.env['fusion.asset'].sudo()
domain = [('company_id', '=', company_id)]
if state:
domain.append(('state', '=', state))
if category_id:
domain.append(('category_id', '=', int(category_id)))
total = Asset.search_count(domain)
assets = Asset.search(domain, limit=int(limit), offset=int(offset),
order='acquisition_date desc')
return {
'count': len(assets),
'total': total,
'assets': [{
'id': a.id, 'name': a.name, 'code': a.code or '',
'state': a.state, 'cost': a.cost, 'salvage_value': a.salvage_value,
'book_value': a.book_value, 'total_depreciated': a.total_depreciated,
'method': a.method, 'useful_life_years': a.useful_life_years,
'acquisition_date': str(a.acquisition_date),
'in_service_date': str(a.in_service_date) if a.in_service_date else None,
'category_id': a.category_id.id if a.category_id else None,
'category_name': a.category_id.name if a.category_id else None,
'currency_code': a.currency_id.name,
} for a in assets],
}
@http.route('/fusion/assets/get_detail', type='jsonrpc', auth='user')
def get_detail(self, asset_id):
asset = request.env['fusion.asset'].browse(int(asset_id))
if not asset.exists():
raise ValidationError(_("Asset %s not found") % asset_id)
return {
'asset': {
'id': asset.id, 'name': asset.name, 'code': asset.code or '',
'state': asset.state, 'cost': asset.cost,
'salvage_value': asset.salvage_value,
'book_value': asset.book_value,
'total_depreciated': asset.total_depreciated,
'method': asset.method,
'useful_life_years': asset.useful_life_years,
'declining_rate_pct': asset.declining_rate_pct,
'total_units_expected': asset.total_units_expected,
'units_used_to_date': asset.units_used_to_date,
'prorate_convention': asset.prorate_convention,
'acquisition_date': str(asset.acquisition_date),
'in_service_date': str(asset.in_service_date) if asset.in_service_date else None,
'disposed_date': str(asset.disposed_date) if asset.disposed_date else None,
'category_id': asset.category_id.id if asset.category_id else None,
'category_name': asset.category_id.name if asset.category_id else None,
'currency_id': asset.currency_id.id,
'currency_code': asset.currency_id.name,
},
'depreciation_lines': [{
'id': l.id, 'period_index': l.period_index,
'scheduled_date': str(l.scheduled_date),
'amount': l.amount, 'accumulated': l.accumulated,
'book_value_at_end': l.book_value_at_end,
'is_posted': l.is_posted,
'posted_date': str(l.posted_date) if l.posted_date else None,
} for l in asset.depreciation_line_ids.sorted('period_index')],
'anomalies': [{
'id': a.id, 'anomaly_type': a.anomaly_type,
'severity': a.severity, 'detail': a.detail or '',
'state': a.state,
} for a in request.env['fusion.asset.anomaly'].search([
('asset_id', '=', asset.id), ('state', 'in', ('new', 'acknowledged'))
])],
}
@http.route('/fusion/assets/compute_schedule', type='jsonrpc', auth='user')
def compute_schedule(self, asset_id, recompute=False):
asset = request.env['fusion.asset'].sudo().browse(int(asset_id))
engine = request.env['fusion.asset.engine'].sudo()
return engine.compute_depreciation_schedule(asset, recompute=bool(recompute))
@http.route('/fusion/assets/post_depreciation', type='jsonrpc', auth='user')
def post_depreciation(self, asset_id, period_date=None):
asset = request.env['fusion.asset'].sudo().browse(int(asset_id))
engine = request.env['fusion.asset.engine'].sudo()
return engine.post_depreciation_entry(asset, period_date=_parse_date(period_date))
@http.route('/fusion/assets/dispose', type='jsonrpc', auth='user')
def dispose(self, asset_id, sale_amount=0, sale_date=None,
sale_partner_id=None, disposal_type='sale'):
asset = request.env['fusion.asset'].sudo().browse(int(asset_id))
engine = request.env['fusion.asset.engine'].sudo()
partner = None
if sale_partner_id:
partner = request.env['res.partner'].sudo().browse(int(sale_partner_id))
return engine.dispose_asset(
asset, sale_amount=float(sale_amount),
sale_date=_parse_date(sale_date),
sale_partner=partner, disposal_type=disposal_type,
)
@http.route('/fusion/assets/get_anomalies', type='jsonrpc', auth='user')
def get_anomalies(self, asset_id=None, severity=None, state='new', limit=50,
company_id=None):
company_id = int(company_id) if company_id else request.env.company.id
Anomaly = request.env['fusion.asset.anomaly'].sudo()
domain = [('company_id', '=', company_id)]
if asset_id:
domain.append(('asset_id', '=', int(asset_id)))
if severity:
domain.append(('severity', '=', severity))
if state:
domain.append(('state', '=', state))
anomalies = Anomaly.search(domain, limit=int(limit), order='detected_at desc')
return {
'count': len(anomalies),
'anomalies': [{
'id': a.id, 'asset_id': a.asset_id.id, 'asset_name': a.asset_id.name,
'anomaly_type': a.anomaly_type, 'severity': a.severity,
'expected': a.expected, 'actual': a.actual,
'variance_pct': a.variance_pct, 'detail': a.detail or '',
'state': a.state,
'detected_at': str(a.detected_at),
} for a in anomalies],
}
@http.route('/fusion/assets/suggest_useful_life', type='jsonrpc', auth='user')
def suggest_useful_life(self, description, amount=None, partner_name=None):
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
predict_useful_life,
)
return predict_useful_life(
request.env, description=description,
amount=float(amount) if amount is not None else None,
partner_name=partner_name,
)
@http.route('/fusion/assets/get_partner_history', type='jsonrpc', auth='user')
def get_partner_history(self, partner_id, limit=20):
Asset = request.env['fusion.asset'].sudo()
assets = Asset.search([
('source_invoice_line_id.partner_id', '=', int(partner_id)),
], limit=int(limit), order='acquisition_date desc')
return {
'partner_id': int(partner_id),
'count': len(assets),
'assets': [{
'id': a.id, 'name': a.name,
'cost': a.cost, 'book_value': a.book_value,
'state': a.state,
'acquisition_date': str(a.acquisition_date),
} for a in assets],
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_fusion_assets_post_depreciation" model="ir.cron">
<field name="name">Fusion Assets — Post Due Depreciation</field>
<field name="model_id" ref="model_fusion_assets_cron"/>
<field name="state">code</field>
<field name="code">model._cron_post_due_depreciation()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
<record id="cron_fusion_assets_refresh_book_values_mv" model="ir.cron">
<field name="name">Fusion Assets — Refresh Book Values MV</field>
<field name="model_id" ref="model_fusion_assets_cron"/>
<field name="state">code</field>
<field name="code">model._cron_refresh_book_values_mv()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active" eval="True"/>
</record>
<record id="cron_fusion_assets_anomaly_scan" model="ir.cron">
<field name="name">Fusion Assets — Monthly Anomaly Scan</field>
<field name="model_id" ref="model_fusion_assets_cron"/>
<field name="state">code</field>
<field name="code">model._cron_anomaly_scan()</field>
<field name="interval_number">30</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
-- Materialized view: per-asset book value snapshot.
-- Refreshed via cron. Used by the OWL dashboard for portfolio summaries.
CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_asset_book_values_mv AS
SELECT
a.id AS id,
a.id AS asset_id,
a.company_id,
a.category_id,
a.state,
a.cost,
a.salvage_value,
COALESCE(SUM(CASE WHEN l.is_posted THEN l.amount ELSE 0 END), 0) AS total_depreciated,
a.cost - COALESCE(SUM(CASE WHEN l.is_posted THEN l.amount ELSE 0 END), 0) AS book_value,
COUNT(l.id) FILTER (WHERE l.is_posted) AS posted_periods,
COUNT(l.id) FILTER (WHERE NOT l.is_posted) AS pending_periods,
a.acquisition_date,
a.in_service_date
FROM fusion_asset a
LEFT JOIN fusion_asset_depreciation_line l ON l.asset_id = a.id
GROUP BY a.id, a.company_id, a.category_id, a.state, a.cost, a.salvage_value,
a.acquisition_date, a.in_service_date;
CREATE UNIQUE INDEX IF NOT EXISTS fusion_asset_book_values_mv_pkey
ON fusion_asset_book_values_mv (id);
CREATE INDEX IF NOT EXISTS fusion_asset_book_values_mv_company_state
ON fusion_asset_book_values_mv (company_id, state);
CREATE INDEX IF NOT EXISTS fusion_asset_book_values_mv_category
ON fusion_asset_book_values_mv (category_id) WHERE category_id IS NOT NULL;

View File

@@ -0,0 +1,10 @@
from . import fusion_asset_category
from . import fusion_asset
from . import fusion_asset_depreciation_line
from . import fusion_asset_disposal
from . import fusion_asset_anomaly
from . import account_move
from . import fusion_asset_engine
from . import fusion_assets_cron
from . import fusion_asset_book_values_mv
from . import fusion_migration_wizard

View File

@@ -0,0 +1,34 @@
"""Inherit account.move.line to link to fusion.asset records.
Lets us trace assets back to their source invoice line.
"""
from odoo import fields, models
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
fusion_asset_id = fields.Many2one(
'fusion.asset', string='Created Asset',
copy=False, ondelete='set null',
help="Fusion asset record created from this invoice line.",
)
fusion_asset_count = fields.Integer(compute='_compute_fusion_asset_count')
def _compute_fusion_asset_count(self):
for line in self:
line.fusion_asset_count = 1 if line.fusion_asset_id else 0
def action_open_fusion_asset(self):
self.ensure_one()
if not self.fusion_asset_id:
return
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.asset',
'res_id': self.fusion_asset_id.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,164 @@
"""Fusion Asset model.
Lifecycle: draft -> running -> (paused -> running)* -> disposed.
- draft: created, not yet running depreciation
- running: depreciation board active, periodic posts happen
- paused: depreciation suspended (e.g. asset out for repair)
- disposed: sold/scrapped/donated; no further depreciation
"""
import logging
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
METHOD_SELECTION = [
('straight_line', 'Straight Line'),
('declining_balance', 'Declining Balance'),
('units_of_production', 'Units of Production'),
]
PRORATE_SELECTION = [
('full_month', 'Full Month'),
('days_365', 'Days / 365'),
('days_period', 'Days in Period'),
]
STATE_SELECTION = [
('draft', 'Draft'),
('running', 'Running'),
('paused', 'Paused'),
('disposed', 'Disposed'),
]
class FusionAsset(models.Model):
_name = "fusion.asset"
_description = "Fusion Fixed Asset"
_order = "acquisition_date desc, id desc"
_inherit = ['mail.thread', 'mail.activity.mixin']
name = fields.Char(required=True, tracking=True)
code = fields.Char(help="Internal asset code (e.g. tag number).")
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company,
)
category_id = fields.Many2one('fusion.asset.category', tracking=True)
state = fields.Selection(
STATE_SELECTION, default='draft', required=True, tracking=True,
)
cost = fields.Monetary(
required=True, tracking=True,
help="Original acquisition cost.",
)
salvage_value = fields.Monetary(
default=0.0, tracking=True,
help="Estimated end-of-life value.",
)
acquisition_date = fields.Date(
required=True, default=fields.Date.today, tracking=True,
)
in_service_date = fields.Date(
tracking=True,
help="Date depreciation actually begins.",
)
disposed_date = fields.Date(readonly=True, tracking=True)
currency_id = fields.Many2one(
'res.currency', required=True,
default=lambda self: self.env.company.currency_id,
)
method = fields.Selection(
METHOD_SELECTION, required=True, default='straight_line', tracking=True,
)
useful_life_years = fields.Integer(
default=5, tracking=True,
help="For straight_line / declining_balance.",
)
declining_rate_pct = fields.Float(
default=20.0,
help="For declining_balance method, e.g. 20.0 = 20%/year.",
)
total_units_expected = fields.Float(
help="For units_of_production method.",
)
units_used_to_date = fields.Float(
default=0.0,
help="For units_of_production: track usage.",
)
prorate_convention = fields.Selection(
PRORATE_SELECTION, default='days_period', required=True,
)
source_invoice_line_id = fields.Many2one(
'account.move.line', string='Source Invoice Line',
help="The invoice line that originated this asset.",
)
parent_id = fields.Many2one(
'fusion.asset', help='For partial-sale child assets.',
)
depreciation_line_ids = fields.One2many(
'fusion.asset.depreciation.line', 'asset_id',
string='Depreciation Lines',
)
book_value = fields.Monetary(compute='_compute_book_value', store=True)
total_depreciated = fields.Monetary(compute='_compute_book_value', store=True)
last_posted_date = fields.Date(compute='_compute_last_posted_date', store=True)
@api.depends('cost', 'depreciation_line_ids.amount', 'depreciation_line_ids.is_posted')
def _compute_book_value(self):
for asset in self:
posted = sum(l.amount for l in asset.depreciation_line_ids if l.is_posted)
asset.total_depreciated = posted
asset.book_value = asset.cost - posted
@api.depends('depreciation_line_ids.is_posted', 'depreciation_line_ids.scheduled_date')
def _compute_last_posted_date(self):
for asset in self:
posted_dates = [
l.scheduled_date for l in asset.depreciation_line_ids if l.is_posted
]
asset.last_posted_date = max(posted_dates) if posted_dates else False
def action_set_running(self):
for asset in self:
if asset.state != 'draft':
raise ValidationError(_("Only draft assets can be set running."))
if not asset.in_service_date:
asset.in_service_date = fields.Date.today()
asset.state = 'running'
def action_pause(self):
for asset in self:
if asset.state != 'running':
raise ValidationError(_("Only running assets can be paused."))
asset.state = 'paused'
def action_resume(self):
for asset in self:
if asset.state != 'paused':
raise ValidationError(_("Only paused assets can be resumed."))
asset.state = 'running'
def action_set_draft(self):
for asset in self:
if asset.state not in ('draft', 'paused'):
raise ValidationError(
_("Cannot reset to draft from %s.") % asset.state,
)
asset.state = 'draft'
_check_cost_positive = models.Constraint(
'CHECK(cost >= 0)',
'Asset cost must be non-negative.',
)
_check_salvage_lte_cost = models.Constraint(
'CHECK(salvage_value >= 0 AND salvage_value <= cost)',
'Salvage value must be between 0 and cost.',
)

View File

@@ -0,0 +1,42 @@
"""Persisted asset anomaly flags from the engine's variance detection."""
from odoo import fields, models
SEVERITY = [('low', 'Low'), ('medium', 'Medium'), ('high', 'High')]
ANOMALY_TYPES = [
('behind_schedule', 'Behind Schedule'),
('ahead_of_schedule', 'Ahead of Schedule'),
('low_utilization', 'Low Utilization'),
]
class FusionAssetAnomaly(models.Model):
_name = "fusion.asset.anomaly"
_description = "Flagged Asset Anomaly"
_order = "detected_at desc, severity desc"
asset_id = fields.Many2one('fusion.asset', required=True, ondelete='cascade')
company_id = fields.Many2one(related='asset_id.company_id', store=True)
anomaly_type = fields.Selection(ANOMALY_TYPES, required=True)
severity = fields.Selection(SEVERITY, required=True)
expected = fields.Float()
actual = fields.Float()
variance_pct = fields.Float()
detail = fields.Text()
detected_at = fields.Datetime(default=fields.Datetime.now, required=True)
state = fields.Selection([
('new', 'New'),
('acknowledged', 'Acknowledged'),
('resolved', 'Resolved'),
('dismissed', 'Dismissed'),
], default='new', required=True)
def action_acknowledge(self):
self.write({'state': 'acknowledged'})
def action_dismiss(self):
self.write({'state': 'dismissed'})
def action_resolve(self):
self.write({'state': 'resolved'})

View File

@@ -0,0 +1,59 @@
"""MV of per-asset book value snapshot. Refresh via cron or model._refresh()."""
import logging
import os
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionAssetBookValuesMV(models.Model):
_name = "fusion.asset.book.values.mv"
_description = "MV of asset book value snapshot"
_auto = False
_table = "fusion_asset_book_values_mv"
_order = "book_value desc"
asset_id = fields.Many2one('fusion.asset', readonly=True)
company_id = fields.Many2one('res.company', readonly=True)
category_id = fields.Many2one('fusion.asset.category', readonly=True)
state = fields.Char(readonly=True)
cost = fields.Float(readonly=True)
salvage_value = fields.Float(readonly=True)
total_depreciated = fields.Float(readonly=True)
book_value = fields.Float(readonly=True)
posted_periods = fields.Integer(readonly=True)
pending_periods = fields.Integer(readonly=True)
acquisition_date = fields.Date(readonly=True)
in_service_date = fields.Date(readonly=True)
def init(self):
sql_path = os.path.join(
os.path.dirname(__file__), '..', 'data', 'sql',
'create_mv_asset_book_values.sql',
)
with open(sql_path, 'r') as f:
self.env.cr.execute(f.read())
_logger.info("fusion_asset_book_values_mv: created/verified MV")
@api.model
def _refresh(self, *, concurrently=True):
# CONCURRENTLY requires a unique index (we have one) and that the MV
# has been populated at least once. Wrap the concurrent attempt in a
# savepoint so a failure (e.g. first-ever refresh before the MV is
# populated) does NOT poison the surrounding transaction; we then
# fall back to a plain REFRESH.
if concurrently:
try:
with self.env.cr.savepoint():
self.env.cr.execute(
"REFRESH MATERIALIZED VIEW CONCURRENTLY "
"fusion_asset_book_values_mv"
)
return
except Exception as e: # noqa: BLE001
_logger.warning("Concurrent MV refresh failed (%s); fallback", e)
self.env.cr.execute(
"REFRESH MATERIALIZED VIEW fusion_asset_book_values_mv"
)

View File

@@ -0,0 +1,53 @@
"""Asset categories with default settings (used as templates)."""
from odoo import api, fields, models
class FusionAssetCategory(models.Model):
_name = "fusion.asset.category"
_description = "Fusion Asset Category"
_order = "sequence, name"
name = fields.Char(required=True, translate=True)
sequence = fields.Integer(default=10)
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company,
)
method = fields.Selection([
('straight_line', 'Straight Line'),
('declining_balance', 'Declining Balance'),
('units_of_production', 'Units of Production'),
], default='straight_line', required=True)
useful_life_years = fields.Integer(default=5)
declining_rate_pct = fields.Float(default=20.0)
salvage_value_pct = fields.Float(
default=0.0,
help="% of cost (used for new assets in this category).",
)
prorate_convention = fields.Selection([
('full_month', 'Full Month'),
('days_365', 'Days / 365'),
('days_period', 'Days in Period'),
], default='days_period', required=True)
asset_account_id = fields.Many2one(
'account.account', string='Asset Account',
domain="[('account_type', 'in', ('asset_fixed', 'asset_non_current'))]",
)
depreciation_account_id = fields.Many2one(
'account.account', string='Depreciation Account',
domain="[('account_type', '=', 'asset_fixed')]",
)
expense_account_id = fields.Many2one(
'account.account', string='Expense Account',
domain="[('account_type', '=', 'expense_depreciation')]",
)
asset_count = fields.Integer(compute='_compute_asset_count')
def _compute_asset_count(self):
for cat in self:
cat.asset_count = self.env['fusion.asset'].search_count([
('category_id', '=', cat.id),
])

View File

@@ -0,0 +1,42 @@
"""Per-period depreciation board lines for an asset."""
from odoo import fields, models
class FusionAssetDepreciationLine(models.Model):
_name = "fusion.asset.depreciation.line"
_description = "Asset Depreciation Board Line"
_order = "asset_id, scheduled_date"
asset_id = fields.Many2one('fusion.asset', required=True, ondelete='cascade')
company_id = fields.Many2one(related='asset_id.company_id', store=True)
currency_id = fields.Many2one(related='asset_id.currency_id', store=True)
period_index = fields.Integer(required=True)
scheduled_date = fields.Date(required=True)
amount = fields.Monetary(required=True)
accumulated = fields.Monetary()
book_value_at_end = fields.Monetary()
is_posted = fields.Boolean(default=False, copy=False)
posted_date = fields.Date(copy=False)
move_id = fields.Many2one(
'account.move', copy=False,
help="Journal entry created when this line was posted.",
)
def action_post(self):
"""Mark this line as posted (without creating the journal entry yet —
engine method post_depreciation_entry handles the actual entry creation)."""
for line in self:
if line.is_posted:
continue
line.write({
'is_posted': True,
'posted_date': fields.Date.today(),
})
_unique_period_per_asset = models.Constraint(
'UNIQUE(asset_id, period_index)',
'A depreciation line for that period already exists.',
)

View File

@@ -0,0 +1,56 @@
"""Asset disposal records (sale, scrap, donation)."""
from odoo import api, fields, models
DISPOSAL_TYPES = [
('sale', 'Sale'),
('scrap', 'Scrap'),
('donation', 'Donation'),
('lost', 'Lost / Stolen'),
]
class FusionAssetDisposal(models.Model):
_name = "fusion.asset.disposal"
_description = "Asset Disposal Record"
_order = "disposal_date desc, id desc"
_inherit = ['mail.thread']
asset_id = fields.Many2one(
'fusion.asset', required=True, ondelete='restrict', tracking=True,
)
company_id = fields.Many2one(related='asset_id.company_id', store=True)
currency_id = fields.Many2one(related='asset_id.currency_id', store=True)
disposal_type = fields.Selection(
DISPOSAL_TYPES, required=True, default='sale', tracking=True,
)
disposal_date = fields.Date(
required=True, default=fields.Date.today, tracking=True,
)
sale_amount = fields.Monetary(
default=0.0, tracking=True,
help="Cash received (for sale disposal type).",
)
sale_partner_id = fields.Many2one('res.partner', tracking=True)
book_value_at_disposal = fields.Monetary(
readonly=True,
help="Asset book value at disposal date.",
)
gain_loss_amount = fields.Monetary(compute='_compute_gain_loss', store=True)
notes = fields.Text()
move_id = fields.Many2one(
'account.move', readonly=True, copy=False,
help="Journal entry created for this disposal.",
)
@api.depends('sale_amount', 'book_value_at_disposal', 'disposal_type')
def _compute_gain_loss(self):
for d in self:
if d.disposal_type == 'sale':
d.gain_loss_amount = d.sale_amount - d.book_value_at_disposal
else:
d.gain_loss_amount = -d.book_value_at_disposal

View File

@@ -0,0 +1,398 @@
"""The asset engine — orchestrator for all asset depreciation + lifecycle.
7-method public API. No direct ORM writes to fusion.asset.depreciation.line
or account.move from anywhere else; everything routes through here for
consistent validation, audit, and side-effect handling.
"""
import logging
from datetime import date, timedelta
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from ..services.depreciation_methods import (
straight_line,
declining_balance,
units_of_production,
)
_logger = logging.getLogger(__name__)
class FusionAssetEngine(models.AbstractModel):
_name = "fusion.asset.engine"
_description = "Fusion Asset Engine"
# ============================================================
# PUBLIC API (7 methods)
# ============================================================
@api.model
def compute_depreciation_schedule(self, asset, *, recompute: bool = False) -> dict:
"""Compute (or re-compute) the depreciation board for an asset.
If recompute=False and posted lines exist, ONLY un-posted future lines
are regenerated. If recompute=True, all unposted lines are wiped and
regenerated from scratch using current asset config.
"""
if not asset:
raise ValidationError(_("asset is required"))
asset.ensure_one()
self._validate_asset_for_schedule(asset)
Line = self.env['fusion.asset.depreciation.line'].sudo()
if recompute:
Line.search([
('asset_id', '=', asset.id),
('is_posted', '=', False),
]).unlink()
existing_posted = Line.search([
('asset_id', '=', asset.id),
('is_posted', '=', True),
], order='period_index')
start_period = max([l.period_index for l in existing_posted], default=-1) + 1
accumulated_so_far = sum(l.amount for l in existing_posted)
steps = self._compute_steps(asset)
new_steps = steps[start_period:]
base_date = asset.in_service_date or asset.acquisition_date
# Accumulated baseline at the boundary between posted and to-be-created
# lines: subtract the accumulated value the algorithm itself reports at
# that boundary, then re-add the actually-posted total. This keeps the
# board's accumulated column monotonic when picking up mid-life.
baseline_offset = 0.0
if start_period > 0 and start_period <= len(steps):
baseline_offset = steps[start_period - 1].accumulated_depreciation
line_vals = []
for s in new_steps:
scheduled_date = self._add_periods(base_date, s.period_index)
running_accumulated = round(
accumulated_so_far + s.accumulated_depreciation - baseline_offset, 2
)
line_vals.append({
'asset_id': asset.id,
'period_index': s.period_index,
'scheduled_date': scheduled_date,
'amount': s.period_amount,
'accumulated': running_accumulated,
'book_value_at_end': s.book_value_at_end,
'is_posted': False,
})
if line_vals:
Line.create(line_vals)
return {
'asset_id': asset.id,
'lines_created': len(line_vals),
'total_lines': len(asset.depreciation_line_ids),
'method': asset.method,
}
@api.model
def post_depreciation_entry(self, asset, *, period_date: date = None) -> dict:
"""Post the next-due un-posted depreciation line.
If period_date provided, post all lines whose scheduled_date <= period_date.
Otherwise, post the single next un-posted line (the earliest one).
"""
asset.ensure_one()
if asset.state != 'running':
raise ValidationError(
_("Cannot post depreciation for asset in state %s") % asset.state
)
Line = self.env['fusion.asset.depreciation.line'].sudo()
domain = [('asset_id', '=', asset.id), ('is_posted', '=', False)]
if period_date:
domain.append(('scheduled_date', '<=', period_date))
unposted = Line.search(domain, order='scheduled_date, period_index')
if not unposted:
return {'posted_count': 0, 'reason': 'no unposted lines due'}
if not period_date:
unposted = unposted[:1]
posted_ids = []
for line in unposted:
self._create_journal_entry(asset, line)
line.action_post()
posted_ids.append(line.id)
return {'posted_count': len(posted_ids), 'posted_line_ids': posted_ids}
@api.model
def dispose_asset(self, asset, *, sale_amount: float = 0.0,
sale_date: date = None, sale_partner=None,
disposal_type: str = 'sale') -> dict:
"""Dispose an asset (sale, scrap, donation, lost)."""
asset.ensure_one()
if asset.state == 'disposed':
raise ValidationError(_("Asset already disposed."))
sale_date = sale_date or fields.Date.today()
Line = self.env['fusion.asset.depreciation.line'].sudo()
future_unposted = Line.search([
('asset_id', '=', asset.id),
('is_posted', '=', False),
('scheduled_date', '>', sale_date),
])
future_unposted.unlink()
asset.invalidate_recordset(['book_value', 'total_depreciated'])
book_value = asset.book_value
Disposal = self.env['fusion.asset.disposal'].sudo()
partner_id = False
if sale_partner:
partner_id = sale_partner.id if hasattr(sale_partner, 'id') else sale_partner
disposal = Disposal.create({
'asset_id': asset.id,
'disposal_type': disposal_type,
'disposal_date': sale_date,
'sale_amount': sale_amount,
'sale_partner_id': partner_id,
'book_value_at_disposal': book_value,
})
asset.write({
'state': 'disposed',
'disposed_date': sale_date,
})
return {
'asset_id': asset.id,
'disposal_id': disposal.id,
'gain_loss_amount': disposal.gain_loss_amount,
'book_value_at_disposal': book_value,
}
@api.model
def partial_sale(self, asset, *, sold_amount: float, sold_qty: float = None,
sale_date: date = None, sale_partner=None) -> dict:
"""Partially dispose: split asset into two — sold child + remaining parent.
sold_amount is cash received for the sold portion.
sold_qty is the ratio of original cost to attribute to the sold portion (0..1).
If sold_qty is None, defaults to sold_amount / cost.
"""
asset.ensure_one()
if asset.state == 'disposed':
raise ValidationError(_("Cannot partially sell a disposed asset."))
if sold_qty is None:
sold_qty = sold_amount / asset.cost if asset.cost else 0
if not (0 < sold_qty < 1):
raise ValidationError(
_("sold_qty must be strictly between 0 and 1; got %s") % sold_qty
)
sale_date = sale_date or fields.Date.today()
Asset = self.env['fusion.asset'].sudo()
sold_cost = round(asset.cost * sold_qty, 2)
sold_salvage = round(asset.salvage_value * sold_qty, 2)
child_vals = {
'name': f"{asset.name} (sold portion)",
'parent_id': asset.id,
'cost': sold_cost,
'salvage_value': sold_salvage,
'acquisition_date': asset.acquisition_date,
'in_service_date': asset.in_service_date,
'method': asset.method,
'useful_life_years': asset.useful_life_years,
'declining_rate_pct': asset.declining_rate_pct,
'prorate_convention': asset.prorate_convention,
'company_id': asset.company_id.id,
'state': 'running',
}
if asset.category_id:
child_vals['category_id'] = asset.category_id.id
child = Asset.create(child_vals)
new_cost = round(asset.cost - sold_cost, 2)
new_salvage = round(asset.salvage_value - sold_salvage, 2)
asset.write({
'cost': new_cost,
'salvage_value': new_salvage,
})
self.compute_depreciation_schedule(asset, recompute=True)
result = self.dispose_asset(
child, sale_amount=sold_amount, sale_date=sale_date,
sale_partner=sale_partner, disposal_type='sale',
)
return {
'parent_asset_id': asset.id,
'child_asset_id': child.id,
'disposal_id': result['disposal_id'],
'gain_loss_amount': result['gain_loss_amount'],
}
@api.model
def pause_asset(self, asset, pause_date: date = None) -> dict:
"""Pause depreciation. Wraps asset.action_pause for API symmetry and
to log the pause date for downstream auditing."""
asset.ensure_one()
asset.action_pause()
return {
'asset_id': asset.id,
'pause_date': pause_date or fields.Date.today(),
'state': 'paused',
}
@api.model
def resume_asset(self, asset, resume_date: date = None) -> dict:
"""Resume a paused asset."""
asset.ensure_one()
asset.action_resume()
return {
'asset_id': asset.id,
'resume_date': resume_date or fields.Date.today(),
'state': 'running',
}
@api.model
def reverse_disposal(self, asset) -> dict:
"""Reverse a disposal (rare — recovery from accidental sale entry)."""
asset.ensure_one()
if asset.state != 'disposed':
raise ValidationError(_("Asset is not disposed."))
Disposal = self.env['fusion.asset.disposal'].sudo()
last_disposal = Disposal.search(
[('asset_id', '=', asset.id)],
order='disposal_date desc, id desc', limit=1,
)
if last_disposal and last_disposal.move_id:
try:
last_disposal.move_id.button_cancel()
except Exception as e: # noqa: BLE001
_logger.warning("Could not cancel disposal move: %s", e)
if last_disposal:
last_disposal.unlink()
asset.write({'state': 'running', 'disposed_date': False})
return {'asset_id': asset.id, 'state': 'running'}
# ============================================================
# PRIVATE HELPERS
# ============================================================
def _validate_asset_for_schedule(self, asset):
if asset.cost <= 0:
raise ValidationError(_("Asset cost must be > 0 to compute schedule."))
if asset.method == 'units_of_production' and not asset.total_units_expected:
raise ValidationError(_(
"Units of Production assets need total_units_expected set."
))
if asset.method in ('straight_line', 'declining_balance'):
if asset.useful_life_years < 1:
raise ValidationError(_("useful_life_years must be >= 1."))
if asset.salvage_value > asset.cost:
raise ValidationError(_("Salvage value cannot exceed cost."))
def _compute_steps(self, asset) -> list:
"""Dispatch to the appropriate depreciation method service."""
if asset.method == 'straight_line':
return straight_line(
cost=asset.cost,
salvage_value=asset.salvage_value,
n_periods=asset.useful_life_years,
)
if asset.method == 'declining_balance':
return declining_balance(
cost=asset.cost,
salvage_value=asset.salvage_value,
n_periods=asset.useful_life_years,
rate=asset.declining_rate_pct / 100.0,
)
if asset.method == 'units_of_production':
# Phase 3 simple: assume even per-period units. Phase 3.5 can read
# from a per-period usage table populated by maintenance/IoT data.
if asset.useful_life_years:
per_period = asset.total_units_expected / asset.useful_life_years
periods = asset.useful_life_years
else:
per_period = asset.total_units_expected
periods = 1
return units_of_production(
cost=asset.cost,
salvage_value=asset.salvage_value,
total_units_expected=asset.total_units_expected,
units_per_period=[per_period] * periods,
)
return []
def _add_periods(self, base_date: date, n_periods: int) -> date:
"""Add (n_periods + 1) yearly increments to base_date and step back one
day, giving the period-end date.
Phase 3.5 can split this into monthly/quarterly variants when the asset
carries a sub-annual frequency.
"""
try:
return base_date.replace(year=base_date.year + n_periods + 1) - timedelta(days=1)
except ValueError:
return base_date.replace(
year=base_date.year + n_periods + 1, day=28,
) - timedelta(days=1)
def _create_journal_entry(self, asset, line):
"""Create the journal entry for a depreciation line.
Phase 3 keeps this minimal: requires the category to have both
depreciation_account_id and expense_account_id wired up. Without that,
the line is still posted (is_posted flag) but no move is created.
Phase 3.5 will add multi-currency, allocation rules, and analytic tags.
"""
category = asset.category_id
if not category or not (category.depreciation_account_id and category.expense_account_id):
_logger.debug(
"No accounts on category for asset %s; skipping journal entry",
asset.id,
)
return None
Move = self.env['account.move'].sudo()
journal = self.env['account.journal'].search([
('type', '=', 'general'),
('company_id', '=', asset.company_id.id),
], limit=1)
if not journal:
_logger.warning(
"No general journal for company %s; skipping move creation",
asset.company_id.name,
)
return None
try:
move = Move.create({
'date': line.scheduled_date,
'journal_id': journal.id,
'ref': f"Depreciation: {asset.name} (P{line.period_index + 1})",
'line_ids': [
(0, 0, {
'name': f"Depreciation expense - {asset.name}",
'account_id': category.expense_account_id.id,
'debit': line.amount,
'credit': 0,
}),
(0, 0, {
'name': f"Accumulated depreciation - {asset.name}",
'account_id': category.depreciation_account_id.id,
'debit': 0,
'credit': line.amount,
}),
],
})
move.action_post()
line.write({'move_id': move.id})
return move
except Exception as e: # noqa: BLE001
_logger.warning(
"Failed to create depreciation move for asset %s line %s: %s",
asset.id, line.id, e,
)
return None

View File

@@ -0,0 +1,96 @@
"""Cron handlers for fusion_accounting_assets.
- _cron_post_due_depreciation: daily, post due depreciation lines for running assets
- _cron_anomaly_scan: monthly, scan for schedule variance and create anomaly records
"""
import logging
from odoo import api, fields, models
from ..services.anomaly_detection import detect_schedule_variance
_logger = logging.getLogger(__name__)
class FusionAssetsCron(models.AbstractModel):
_name = "fusion.assets.cron"
_description = "Fusion Assets Cron Handlers"
@api.model
def _cron_post_due_depreciation(self):
"""For each running asset, post any due un-posted depreciation lines."""
today = fields.Date.today()
engine = self.env['fusion.asset.engine']
Asset = self.env['fusion.asset']
running_assets = Asset.search([('state', '=', 'running')])
posted_total = 0
for asset in running_assets:
try:
with self.env.cr.savepoint():
result = engine.post_depreciation_entry(asset, period_date=today)
posted_total += result.get('posted_count', 0)
except Exception as e: # noqa: BLE001
_logger.warning("Cron post failed for asset %s: %s", asset.id, e)
_logger.info(
"Cron: posted depreciation on %d lines across %d running assets",
posted_total, len(running_assets),
)
# Keep the book-value MV in sync after posting so the dashboard
# reflects today's numbers without waiting for the dedicated MV cron.
try:
self.env['fusion.asset.book.values.mv']._refresh()
except Exception as e: # noqa: BLE001
_logger.warning("Post-cron MV refresh failed: %s", e)
@api.model
def _cron_refresh_book_values_mv(self):
"""Refresh the per-asset book value MV (hourly)."""
self.env['fusion.asset.book.values.mv']._refresh()
@api.model
def _cron_anomaly_scan(self):
"""For each running asset, compare expected accumulated depreciation
vs posted, and persist any variance flags."""
Asset = self.env['fusion.asset']
Anomaly = self.env['fusion.asset.anomaly']
running_assets = Asset.search([('state', '=', 'running')])
flagged = 0
today = fields.Date.today()
for asset in running_assets:
try:
expected = sum(
l.amount for l in asset.depreciation_line_ids
if l.scheduled_date and l.scheduled_date <= today
)
actual = asset.total_depreciated
anomaly = detect_schedule_variance(
asset_id=asset.id, asset_name=asset.name,
expected_accumulated=expected, actual_accumulated=actual,
)
if anomaly is None:
continue
anomaly_dict = anomaly.to_dict()
existing = Anomaly.search([
('asset_id', '=', asset.id),
('anomaly_type', '=', anomaly_dict['anomaly_type']),
('state', 'in', ('new', 'acknowledged')),
], limit=1)
if existing:
continue
Anomaly.create({
'asset_id': asset.id,
'anomaly_type': anomaly_dict['anomaly_type'],
'severity': anomaly_dict['severity'],
'expected': anomaly_dict['expected'],
'actual': anomaly_dict['actual'],
'variance_pct': anomaly_dict['variance_pct'],
'detail': anomaly_dict['detail'],
})
flagged += 1
except Exception as e: # noqa: BLE001
_logger.warning("Cron anomaly scan failed for asset %s: %s", asset.id, e)
_logger.info(
"Cron: scanned %d assets, flagged %d anomalies",
len(running_assets), flagged,
)

View File

@@ -0,0 +1,105 @@
"""Assets-specific migration step.
Backfills fusion.asset from existing account.asset rows (Enterprise) so users
get all their existing assets in the Fusion namespace after switchover."""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
# Map Enterprise method names to Fusion method names
ENTERPRISE_METHOD_MAP = {
'linear': 'straight_line',
'degressive': 'declining_balance',
'degressive_then_linear': 'declining_balance', # simplified
'manual': 'straight_line',
'unit_of_production': 'units_of_production',
'units_of_production': 'units_of_production',
}
class FusionMigrationWizard(models.TransientModel):
_inherit = "fusion.migration.wizard"
def _assets_bootstrap_step(self):
"""Backfill fusion.asset from account.asset (Enterprise) if it exists."""
result = {
'step': 'assets_bootstrap',
'enterprise_module_present': False,
'created': 0, 'skipped': 0, 'errors': [],
}
# Check if Enterprise account.asset exists
AccountAsset = self.env.get('account.asset')
if AccountAsset is None:
result['enterprise_module_present'] = False
return result
result['enterprise_module_present'] = True
FusionAsset = self.env['fusion.asset'].sudo()
# Iterate Enterprise records
company_id = self.company_id.id if 'company_id' in self._fields and self.company_id else None
domain = []
if company_id:
domain.append(('company_id', '=', company_id))
try:
ea_records = AccountAsset.sudo().search(domain, limit=10000)
except Exception as e:
result['errors'].append(f"Enterprise search failed: {e}")
return result
for ea in ea_records:
try:
# Idempotent: skip if a fusion asset with same source name exists
existing = FusionAsset.search([
('name', '=', ea.name),
('cost', '=', getattr(ea, 'original_value', 0) or 0),
('company_id', '=', ea.company_id.id),
], limit=1)
if existing:
result['skipped'] += 1
continue
# Map state — Enterprise has 'draft', 'open' (running), 'paused', 'close' (disposed)
ea_state = getattr(ea, 'state', 'draft')
state_map = {'draft': 'draft', 'open': 'running',
'paused': 'paused', 'close': 'disposed',
'model': 'draft'}
state = state_map.get(ea_state, 'draft')
method = ENTERPRISE_METHOD_MAP.get(
getattr(ea, 'method', 'linear'), 'straight_line')
FusionAsset.create({
'name': ea.name,
'cost': getattr(ea, 'original_value', 0) or 0,
'salvage_value': getattr(ea, 'salvage_value', 0) or 0,
'acquisition_date': getattr(ea, 'acquisition_date', False) or fields.Date.today(),
'in_service_date': getattr(ea, 'prorata_date', False) or False,
'method': method,
'useful_life_years': getattr(ea, 'method_number', 5) or 5,
'declining_rate_pct': getattr(ea, 'method_progress_factor', 0.2) * 100 if hasattr(ea, 'method_progress_factor') else 20.0,
'company_id': ea.company_id.id,
'state': state,
})
result['created'] += 1
except Exception as e:
result['errors'].append(f"Asset {ea.id}: {e}")
_logger.info(
"fusion_accounting_assets migration: %d created, %d skipped, %d errors",
result['created'], result['skipped'], len(result['errors']))
return result
def action_run_migration(self):
"""Override to add assets-bootstrap step."""
result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None
try:
self._assets_bootstrap_step()
except Exception as e:
_logger.warning("assets_bootstrap_step failed: %s", e)
return result

View File

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

View File

@@ -0,0 +1,36 @@
"""QWeb PDF: migration audit report for fusion_accounting_assets."""
from odoo import api, models
class FusionAssetsMigrationAuditReport(models.AbstractModel):
_name = "report.fusion_accounting_assets.migration_audit_template"
_description = "Fusion Assets Migration Audit"
@api.model
def _get_report_values(self, docids, data=None):
wizards = self.env['fusion.migration.wizard'].browse(docids) if docids else self.env['fusion.migration.wizard']
Asset = self.env['fusion.asset']
company_stats = []
for company in self.env['res.company'].search([]):
assets = Asset.search([('company_id', '=', company.id)])
by_state = {}
for state in ('draft', 'running', 'paused', 'disposed'):
by_state[state] = sum(1 for a in assets if a.state == state)
total_cost = sum(a.cost for a in assets)
total_book = sum(a.book_value for a in assets)
total_dep = sum(a.total_depreciated for a in assets)
company_stats.append({
'company': company,
'count': len(assets),
'by_state': by_state,
'total_cost': total_cost,
'total_book_value': total_book,
'total_depreciated': total_dep,
})
return {
'doc_ids': docids,
'doc_model': 'fusion.migration.wizard',
'docs': wizards,
'company_stats': company_stats,
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_report_assets_migration_audit" model="ir.actions.report">
<field name="name">Assets Migration Audit</field>
<field name="model">fusion.migration.wizard</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_accounting_assets.migration_audit_template</field>
<field name="report_file">fusion_accounting_assets.migration_audit_template</field>
<field name="binding_model_id" ref="fusion_accounting_migration.model_fusion_migration_wizard"/>
</record>
</odoo>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="migration_audit_template">
<t t-call="web.html_container">
<t t-call="web.external_layout">
<div class="page">
<h2>Fusion Assets Migration Audit</h2>
<p>
<span t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/>
</p>
<h3>Per-Company Summary</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Company</th>
<th class="text-end">Total Assets</th>
<th class="text-end">Draft</th>
<th class="text-end">Running</th>
<th class="text-end">Paused</th>
<th class="text-end">Disposed</th>
<th class="text-end">Total Cost</th>
<th class="text-end">Total NBV</th>
<th class="text-end">Total Depreciated</th>
</tr>
</thead>
<tbody>
<tr t-foreach="company_stats" t-as="cs">
<td><span t-field="cs['company'].name"/></td>
<td class="text-end"><span t-esc="cs['count']"/></td>
<td class="text-end"><span t-esc="cs['by_state']['draft']"/></td>
<td class="text-end"><span t-esc="cs['by_state']['running']"/></td>
<td class="text-end"><span t-esc="cs['by_state']['paused']"/></td>
<td class="text-end"><span t-esc="cs['by_state']['disposed']"/></td>
<td class="text-end"><span t-esc="'{:,.2f}'.format(cs['total_cost'])"/></td>
<td class="text-end"><span t-esc="'{:,.2f}'.format(cs['total_book_value'])"/></td>
<td class="text-end"><span t-esc="'{:,.2f}'.format(cs['total_depreciated'])"/></td>
</tr>
</tbody>
</table>
<p class="text-muted small">
Generated by Fusion Accounting Assets
</p>
</div>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,15 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_asset_user,fusion.asset.user,model_fusion_asset,base.group_user,1,0,0,0
access_fusion_asset_admin,fusion.asset.admin,model_fusion_asset,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_asset_depreciation_line_user,fusion.asset.depreciation.line.user,model_fusion_asset_depreciation_line,base.group_user,1,0,0,0
access_fusion_asset_depreciation_line_admin,fusion.asset.depreciation.line.admin,model_fusion_asset_depreciation_line,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_asset_category_user,fusion.asset.category.user,model_fusion_asset_category,base.group_user,1,0,0,0
access_fusion_asset_category_admin,fusion.asset.category.admin,model_fusion_asset_category,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_asset_disposal_user,fusion.asset.disposal.user,model_fusion_asset_disposal,base.group_user,1,0,0,0
access_fusion_asset_disposal_admin,fusion.asset.disposal.admin,model_fusion_asset_disposal,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_asset_anomaly_user,fusion.asset.anomaly.user,model_fusion_asset_anomaly,base.group_user,1,0,0,0
access_fusion_asset_anomaly_admin,fusion.asset.anomaly.admin,model_fusion_asset_anomaly,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_create_asset_wizard_user,fusion.create.asset.wizard.user,model_fusion_create_asset_wizard,base.group_user,1,1,1,0
access_fusion_disposal_wizard_user,fusion.disposal.wizard.user,model_fusion_disposal_wizard,base.group_user,1,1,1,0
access_fusion_partial_sale_wizard_user,fusion.partial.sale.wizard.user,model_fusion_partial_sale_wizard,base.group_user,1,1,1,0
access_fusion_depreciation_run_wizard_user,fusion.depreciation.run.wizard.user,model_fusion_depreciation_run_wizard,base.group_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_asset_user fusion.asset.user model_fusion_asset base.group_user 1 0 0 0
3 access_fusion_asset_admin fusion.asset.admin model_fusion_asset fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
4 access_fusion_asset_depreciation_line_user fusion.asset.depreciation.line.user model_fusion_asset_depreciation_line base.group_user 1 0 0 0
5 access_fusion_asset_depreciation_line_admin fusion.asset.depreciation.line.admin model_fusion_asset_depreciation_line fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
6 access_fusion_asset_category_user fusion.asset.category.user model_fusion_asset_category base.group_user 1 0 0 0
7 access_fusion_asset_category_admin fusion.asset.category.admin model_fusion_asset_category fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
8 access_fusion_asset_disposal_user fusion.asset.disposal.user model_fusion_asset_disposal base.group_user 1 0 0 0
9 access_fusion_asset_disposal_admin fusion.asset.disposal.admin model_fusion_asset_disposal fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
10 access_fusion_asset_anomaly_user fusion.asset.anomaly.user model_fusion_asset_anomaly base.group_user 1 0 0 0
11 access_fusion_asset_anomaly_admin fusion.asset.anomaly.admin model_fusion_asset_anomaly fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
12 access_fusion_create_asset_wizard_user fusion.create.asset.wizard.user model_fusion_create_asset_wizard base.group_user 1 1 1 0
13 access_fusion_disposal_wizard_user fusion.disposal.wizard.user model_fusion_disposal_wizard base.group_user 1 1 1 0
14 access_fusion_partial_sale_wizard_user fusion.partial.sale.wizard.user model_fusion_partial_sale_wizard base.group_user 1 1 1 0
15 access_fusion_depreciation_run_wizard_user fusion.depreciation.run.wizard.user model_fusion_depreciation_run_wizard base.group_user 1 1 1 0

View File

@@ -0,0 +1,6 @@
from . import depreciation_methods
from . import prorate
from . import salvage_value
from . import anomaly_detection
from . import useful_life_prompt
from . import useful_life_predictor

View File

@@ -0,0 +1,96 @@
"""Asset utilization anomaly detection.
Flags assets where actual usage / posted depreciation deviates significantly
from the expected schedule. Three signal types:
- behind_schedule: actual depreciation < expected by > threshold pct
- ahead_of_schedule: actual > expected (over-depreciated; scrap or recompute)
- low_utilization: units_used < expected_units_per_period (waste alert)
"""
from dataclasses import dataclass
@dataclass
class AssetAnomaly:
asset_id: int
asset_name: str
anomaly_type: str
severity: str
expected: float
actual: float
variance_pct: float
detail: str
def to_dict(self):
return {
'asset_id': self.asset_id,
'asset_name': self.asset_name,
'anomaly_type': self.anomaly_type,
'severity': self.severity,
'expected': self.expected,
'actual': self.actual,
'variance_pct': self.variance_pct,
'detail': self.detail,
}
DEFAULT_LOW_THRESHOLD_PCT = 10.0
DEFAULT_MEDIUM_THRESHOLD_PCT = 25.0
DEFAULT_HIGH_THRESHOLD_PCT = 50.0
def detect_schedule_variance(*, asset_id: int, asset_name: str,
expected_accumulated: float,
actual_accumulated: float) -> AssetAnomaly | None:
"""Compare expected accumulated depreciation vs actual posted."""
if expected_accumulated <= 0:
return None
variance_amt = actual_accumulated - expected_accumulated
variance_pct = abs(variance_amt) / expected_accumulated * 100
if variance_pct < DEFAULT_LOW_THRESHOLD_PCT:
return None
direction = 'ahead_of_schedule' if variance_amt > 0 else 'behind_schedule'
if variance_pct >= DEFAULT_HIGH_THRESHOLD_PCT:
severity = 'high'
elif variance_pct >= DEFAULT_MEDIUM_THRESHOLD_PCT:
severity = 'medium'
else:
severity = 'low'
detail = f"Posted ${actual_accumulated:,.2f} vs expected ${expected_accumulated:,.2f}"
return AssetAnomaly(
asset_id=asset_id,
asset_name=asset_name,
anomaly_type=direction,
severity=severity,
expected=expected_accumulated,
actual=actual_accumulated,
variance_pct=round(variance_pct, 1),
detail=detail,
)
def detect_low_utilization(*, asset_id: int, asset_name: str,
expected_units: float,
actual_units: float) -> AssetAnomaly | None:
"""For units-of-production assets: flag low actual usage."""
if expected_units <= 0:
return None
if actual_units >= expected_units * 0.9:
return None
deficit_pct = (expected_units - actual_units) / expected_units * 100
if deficit_pct >= 50:
severity = 'high'
elif deficit_pct >= 25:
severity = 'medium'
else:
severity = 'low'
return AssetAnomaly(
asset_id=asset_id,
asset_name=asset_name,
anomaly_type='low_utilization',
severity=severity,
expected=expected_units,
actual=actual_units,
variance_pct=round(deficit_pct, 1),
detail=f"Used {actual_units:.0f} of expected {expected_units:.0f} units",
)

View File

@@ -0,0 +1,116 @@
"""Depreciation method primitives.
Three methods supported:
- straight_line: equal periodic charge over useful_life
- declining_balance: % per period of remaining book value
- units_of_production: charge proportional to units used / total units expected
All return a list of DepreciationStep dataclasses (period_index, period_amount,
accumulated_depreciation, book_value_at_end). Total depreciation always
sums to (cost - salvage_value), within 1-cent rounding tolerance.
"""
from dataclasses import dataclass
from typing import Literal
Method = Literal['straight_line', 'declining_balance', 'units_of_production']
@dataclass
class DepreciationStep:
period_index: int
period_amount: float
accumulated_depreciation: float
book_value_at_end: float
def straight_line(*, cost: float, salvage_value: float = 0.0,
n_periods: int) -> list[DepreciationStep]:
"""Equal charge per period: (cost - salvage) / n_periods.
Last period absorbs rounding so total == cost - salvage exactly.
"""
if n_periods < 1:
return []
depreciable = cost - salvage_value
per_period = round(depreciable / n_periods, 2)
steps = []
accumulated = 0.0
for i in range(n_periods):
if i == n_periods - 1:
amount = round(depreciable - accumulated, 2)
else:
amount = per_period
accumulated = round(accumulated + amount, 2)
book = round(cost - accumulated, 2)
steps.append(DepreciationStep(
period_index=i,
period_amount=amount,
accumulated_depreciation=accumulated,
book_value_at_end=book,
))
return steps
def declining_balance(*, cost: float, salvage_value: float = 0.0,
n_periods: int, rate: float) -> list[DepreciationStep]:
"""Apply `rate` (e.g. 0.20 = 20%) to remaining book each period.
Switches to straight-line when straight-line would deplete remaining book
faster (typical Odoo behavior). Last step caps at salvage_value.
"""
if n_periods < 1 or rate <= 0:
return []
if rate >= 1:
# Pathological: 100%+ rate. Charge full depreciable amount in period 0.
depreciable = round(cost - salvage_value, 2)
return [DepreciationStep(0, depreciable, depreciable, round(salvage_value, 2))]
steps = []
book = cost
accumulated = 0.0
for i in range(n_periods):
remaining_periods = n_periods - i
db_amount = round(book * rate, 2)
sl_amount = round((book - salvage_value) / remaining_periods, 2) if remaining_periods else 0.0
amount = max(db_amount, sl_amount)
if book - amount < salvage_value:
amount = round(book - salvage_value, 2)
accumulated = round(accumulated + amount, 2)
book = round(book - amount, 2)
steps.append(DepreciationStep(
period_index=i,
period_amount=amount,
accumulated_depreciation=accumulated,
book_value_at_end=book,
))
if book <= salvage_value:
break
return steps
def units_of_production(*, cost: float, salvage_value: float = 0.0,
total_units_expected: float,
units_per_period: list[float]) -> list[DepreciationStep]:
"""Charge per period = (units_used / total_expected) * (cost - salvage)."""
if total_units_expected <= 0:
return []
depreciable = cost - salvage_value
per_unit = depreciable / total_units_expected
steps = []
accumulated = 0.0
for i, units in enumerate(units_per_period):
amount = round(units * per_unit, 2)
if accumulated + amount > depreciable:
amount = round(depreciable - accumulated, 2)
accumulated = round(accumulated + amount, 2)
book = round(cost - accumulated, 2)
steps.append(DepreciationStep(
period_index=i,
period_amount=amount,
accumulated_depreciation=accumulated,
book_value_at_end=book,
))
if accumulated >= depreciable:
break
return steps

View File

@@ -0,0 +1,34 @@
"""Prorating helpers for first-period and last-period depreciation.
When an asset starts mid-month, the first period charges only a fraction
of the full period_amount. Three conventions:
- 'full_month': always charge full month (no proration)
- 'days_365': pro-rate by actual days / 365
- 'days_period': pro-rate by actual days in period / total days in period
"""
from datetime import date
from typing import Literal
ProrateConvention = Literal['full_month', 'days_365', 'days_period']
def prorate_factor(*, period_start: date, period_end: date,
asset_start: date,
convention: ProrateConvention = 'days_period') -> float:
"""Return a 0..1 factor for how much of `period`'s depreciation
applies to an asset that started on `asset_start`."""
if convention == 'full_month':
return 1.0
if asset_start <= period_start:
return 1.0
if asset_start > period_end:
return 0.0
actual_days = (period_end - asset_start).days + 1
if convention == 'days_365':
return actual_days / 365.0
if convention == 'days_period':
period_days = (period_end - period_start).days + 1
return actual_days / period_days
raise ValueError(f"Unknown convention: {convention}")

View File

@@ -0,0 +1,38 @@
"""Salvage value (scrap value) calculation helpers.
Most clients use straight % of cost; some use fixed dollar amounts.
"""
from dataclasses import dataclass
from typing import Literal
SalvageMethod = Literal['percentage', 'fixed', 'zero']
@dataclass
class SalvageConfig:
method: SalvageMethod
value: float = 0.0
def compute_salvage_value(*, cost: float, config: SalvageConfig) -> float:
"""Compute end-of-life salvage value."""
if config.method == 'zero':
return 0.0
if config.method == 'percentage':
return round(cost * config.value / 100, 2)
if config.method == 'fixed':
return round(config.value, 2)
raise ValueError(f"Unknown salvage method: {config.method}")
def remaining_useful_life_value(*, current_book: float, salvage: float,
periods_used: int, total_periods: int) -> float:
"""Estimate remaining value if asset is sold/scrapped now."""
if total_periods <= 0:
return current_book
if periods_used >= total_periods:
return salvage
remaining_pct = (total_periods - periods_used) / total_periods
return round(salvage + (current_book - salvage) * remaining_pct, 2)

View File

@@ -0,0 +1,94 @@
"""AI-suggested useful life from invoice context.
Wraps useful_life_prompt + an LLMProvider. Returns a dict per the prompt's
output contract. Templated fallback when no provider configured.
"""
import json
import logging
import re
_logger = logging.getLogger(__name__)
# Templated fallback rules: (regex, years, method, rationale)
FALLBACK_RULES = [
(r'\b(computer|laptop|monitor|server|workstation)\b', 4, 'straight_line', 'Computer hardware'),
(r'\b(furniture|desk|chair|cabinet)\b', 7, 'straight_line', 'Furniture'),
(r'\b(vehicle|truck|car|van)\b', 5, 'declining_balance', 'Vehicle (CRA Class 10)'),
(r'\b(building|warehouse)\b', 30, 'straight_line', 'Building'),
(r'\b(software|license)\b', 4, 'straight_line', 'Software license'),
(r'\b(equipment|machinery|machine)\b', 10, 'straight_line', 'Manufacturing equipment'),
(r'\b(leasehold improvement)\b', 5, 'straight_line', 'Leasehold improvements'),
]
FALLBACK_DEFAULT = (5, 'straight_line', 'Generic fixed asset (default)')
def predict_useful_life(env, *, description: str, amount: float = None,
partner_name: str = None, provider=None) -> dict:
"""Suggest useful life + method via LLM, with templated fallback."""
if provider is None:
provider = _get_provider(env)
if provider is None:
return _templated_fallback(description)
try:
from .useful_life_prompt import build_prompt
system, user = build_prompt(
description=description, amount=amount, partner_name=partner_name,
)
response = provider.complete(
system=system,
messages=[{'role': 'user', 'content': user}],
max_tokens=400, temperature=0.1,
)
content = response.get('content') if isinstance(response, dict) else response
parsed = json.loads(content)
for key in ('useful_life_years', 'depreciation_method', 'rationale'):
if key not in parsed:
raise ValueError(f"Missing key: {key}")
parsed.setdefault('confidence', 0.7)
return parsed
except Exception as e:
_logger.warning("Useful life LLM prediction failed (%s); falling back", e)
return _templated_fallback(description)
def _templated_fallback(description: str) -> dict:
"""Pattern-match keyword rules. Always returns a usable dict."""
desc_lower = description.lower() if description else ''
for pattern, years, method, rationale in FALLBACK_RULES:
if re.search(pattern, desc_lower):
return {
'useful_life_years': years,
'depreciation_method': method,
'rationale': rationale,
'confidence': 0.5,
}
years, method, rationale = FALLBACK_DEFAULT
return {
'useful_life_years': years,
'depreciation_method': method,
'rationale': rationale,
'confidence': 0.3,
}
def _get_provider(env):
"""Look up provider for 'asset_useful_life' feature."""
param = env['ir.config_parameter'].sudo()
name = param.get_param('fusion_accounting.provider.asset_useful_life')
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
if name.startswith('openai'):
return OpenAIAdapter(env)
elif name.startswith('claude'):
return ClaudeAdapter(env)
return None

View File

@@ -0,0 +1,48 @@
"""LLM prompt builder for AI-suggested useful life from invoice description.
Output contract:
{
"useful_life_years": <int>,
"depreciation_method": "straight_line" | "declining_balance" | "units_of_production",
"rationale": "<short explanation>",
"confidence": <float 0-1>
}
"""
SYSTEM_PROMPT = """You are an experienced accountant. Given an invoice line
description for a fixed asset, suggest the appropriate useful life in years
and depreciation method based on common accounting standards (IFRS / GAAP / CRA).
Respond ONLY with valid JSON of this exact shape:
{
"useful_life_years": <integer>,
"depreciation_method": "straight_line" | "declining_balance" | "units_of_production",
"rationale": "<one or two sentence explanation>",
"confidence": <float between 0 and 1>
}
Common useful-life conventions:
- Furniture: 7 years, straight-line
- Office equipment: 5 years, straight-line
- Computers: 3-4 years, straight-line or declining
- Vehicles: 5 years, declining-balance (CRA Class 10 30%)
- Buildings: 25-40 years, straight-line
- Manufacturing equipment: 10-15 years, units of production if measurable
- Software (licenses): 3-5 years, straight-line
- Leasehold improvements: lesser of lease term or useful life
Do NOT include markdown code fences. Do NOT include any prose outside the JSON."""
def build_prompt(*, description: str, amount: float = None,
partner_name: str = None) -> tuple[str, str]:
"""Return (system, user) prompt tuple."""
parts = [f"INVOICE LINE: {description}"]
if amount is not None:
parts.append(f"AMOUNT: ${amount:,.2f}")
if partner_name:
parts.append(f"VENDOR: {partner_name}")
parts.append("")
parts.append("Suggest the useful life and depreciation method per the system prompt.")
return (SYSTEM_PROMPT, "\n".join(parts))

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,41 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class AiUsefulLifePanel extends Component {
static template = "fusion_accounting_assets.AiUsefulLifePanel";
static props = {
description: { type: String, optional: true },
amount: { type: Number, optional: true },
onSelect: { type: Function, optional: true },
};
setup() {
this.assets = useService("fusion_assets");
this.state = useState({
suggestion: null,
isLoading: false,
descInput: this.props.description || '',
amountInput: this.props.amount || '',
});
}
async onSuggest() {
this.state.isLoading = true;
try {
this.state.suggestion = await this.assets.suggestUsefulLife(
this.state.descInput,
parseFloat(this.state.amountInput) || null,
);
} finally {
this.state.isLoading = false;
}
}
onUseSuggestion() {
if (this.state.suggestion && this.props.onSelect) {
this.props.onSelect(this.state.suggestion);
}
}
}

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.AiUsefulLifePanel">
<div style="background: white; padding: 1rem; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
<h5>AI Suggest Useful Life</h5>
<div class="mb-2">
<label>Description</label>
<input class="form-control" t-att-value="state.descInput"
t-on-input="(ev) => state.descInput = ev.target.value"/>
</div>
<div class="mb-2">
<label>Amount</label>
<input type="number" class="form-control" t-att-value="state.amountInput"
t-on-input="(ev) => state.amountInput = ev.target.value"/>
</div>
<button class="btn_asset primary" t-on-click="onSuggest"
t-att-disabled="state.isLoading">
<t t-if="state.isLoading">Asking AI...</t>
<t t-else="">Suggest</t>
</button>
<div t-if="state.suggestion" class="mt-3 p-2"
style="background: #eff6ff; border-radius: 0.25rem;">
<div><strong>Suggested life:</strong> <t t-esc="state.suggestion.useful_life_years"/> years</div>
<div><strong>Method:</strong> <t t-esc="state.suggestion.depreciation_method"/></div>
<div class="text-muted small">
<em><t t-esc="state.suggestion.rationale"/></em>
(confidence: <t t-esc="(state.suggestion.confidence * 100).toFixed(0)"/>%)
</div>
<button class="btn_asset mt-2" t-if="props.onSelect" t-on-click="onUseSuggestion">
Use This
</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,17 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class AnomalyStrip extends Component {
static template = "fusion_accounting_assets.AnomalyStrip";
static props = {
anomaly: { type: Object },
};
formatNumber(n) {
if (n === null || n === undefined) return "";
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: 0, maximumFractionDigits: 1,
}).format(n);
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.AnomalyStrip">
<div class="o_fusion_anomaly_strip" t-att-data-severity="props.anomaly.severity">
<strong>
<t t-esc="props.anomaly.asset_name || 'Asset'"/>
</strong>
<span class="ms-2">
<t t-esc="props.anomaly.anomaly_type.replace('_', ' ')"/>:
<t t-esc="formatNumber(props.anomaly.variance_pct)"/>%
</span>
<span class="ms-3 text-muted">
<t t-esc="props.anomaly.detail"/>
</span>
</div>
</t>
</templates>

View File

@@ -0,0 +1,13 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class AssetCard extends Component {
static template = "fusion_accounting_assets.AssetCard";
static props = {
asset: { type: Object },
selected: { type: Boolean, optional: true },
onSelect: { type: Function },
formatCurrency: { type: Function },
};
}

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.AssetCard">
<div class="o_fusion_assets_card"
t-att-class="props.selected ? 'selected' : ''"
t-on-click="props.onSelect">
<div class="o_fusion_assets_card_header">
<div class="asset-name">
<t t-esc="props.asset.name"/>
<span t-if="props.asset.code" class="text-muted ms-2">
[<t t-esc="props.asset.code"/>]
</span>
</div>
<div class="asset-state-badge" t-att-data-state="props.asset.state">
<t t-esc="props.asset.state"/>
</div>
</div>
<div class="asset-numbers">
<div>
<span class="label">Cost:</span>
<span class="value">$<t t-esc="props.formatCurrency(props.asset.cost)"/></span>
</div>
<div>
<span class="label">Book Value:</span>
<span class="value">$<t t-esc="props.formatCurrency(props.asset.book_value)"/></span>
</div>
<div>
<span class="label">Method:</span>
<span class="value"><t t-esc="props.asset.method"/></span>
</div>
<div t-if="props.asset.category_name">
<span class="label">Category:</span>
<span class="value"><t t-esc="props.asset.category_name"/></span>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,36 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { DepreciationBoard } from "../depreciation_board/depreciation_board";
export class AssetDetailPanel extends Component {
static template = "fusion_accounting_assets.AssetDetailPanel";
static props = {
detail: { type: Object },
formatCurrency: { type: Function },
};
static components = { DepreciationBoard };
setup() {
this.assets = useService("fusion_assets");
}
async onComputeSchedule() {
await this.assets.computeSchedule(this.props.detail.asset.id, false);
}
async onRecomputeSchedule() {
await this.assets.computeSchedule(this.props.detail.asset.id, true);
}
async onPostDepreciation() {
await this.assets.postDepreciation(this.props.detail.asset.id);
}
async onDispose() {
const saleAmount = parseFloat(prompt("Sale amount (0 for scrap)?", "0"));
if (isNaN(saleAmount)) return;
await this.assets.disposeAsset(this.props.detail.asset.id, { saleAmount });
}
}

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.AssetDetailPanel">
<div style="background: white; padding: 1rem; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
<h3><t t-esc="props.detail.asset.name"/></h3>
<div class="text-muted" t-if="props.detail.asset.code">
[<t t-esc="props.detail.asset.code"/>]
</div>
<div class="mt-3">
<div><strong>State:</strong> <t t-esc="props.detail.asset.state"/></div>
<div><strong>Cost:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.cost)"/></div>
<div><strong>Salvage:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.salvage_value)"/></div>
<div><strong>Book Value:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.book_value)"/></div>
<div><strong>Total Depreciated:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.total_depreciated)"/></div>
<div><strong>Method:</strong> <t t-esc="props.detail.asset.method"/></div>
<div><strong>Useful Life:</strong> <t t-esc="props.detail.asset.useful_life_years"/> years</div>
</div>
<div class="d-flex mt-3" style="gap: 0.5rem; flex-wrap: wrap;">
<button class="btn_asset" t-on-click="onComputeSchedule">Compute Schedule</button>
<button class="btn_asset" t-on-click="onRecomputeSchedule">Recompute</button>
<button class="btn_asset primary"
t-if="props.detail.asset.state === 'running'"
t-on-click="onPostDepreciation">Post Next</button>
<button class="btn_asset danger"
t-if="props.detail.asset.state !== 'disposed'"
t-on-click="onDispose">Dispose</button>
</div>
<h4 class="mt-4">Depreciation Schedule</h4>
<DepreciationBoard t-if="props.detail.depreciation_lines"
lines="props.detail.depreciation_lines"
formatCurrency="props.formatCurrency"/>
<div t-if="props.detail.anomalies and props.detail.anomalies.length" class="mt-3">
<h4>Active Anomalies</h4>
<div t-foreach="props.detail.anomalies" t-as="a" t-key="a.id"
class="o_fusion_anomaly_strip" t-att-data-severity="a.severity">
<strong><t t-esc="a.anomaly_type"/></strong>: <t t-esc="a.detail"/>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,16 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class DepreciationBoard extends Component {
static template = "fusion_accounting_assets.DepreciationBoard";
static props = {
lines: { type: Array },
formatCurrency: { type: Function },
};
rowClass(line) {
if (line.is_posted) return "posted";
return "";
}
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.DepreciationBoard">
<div class="o_fusion_assets_table">
<table>
<thead>
<tr>
<th>#</th>
<th>Date</th>
<th class="text-end">Amount</th>
<th class="text-end">Accumulated</th>
<th class="text-end">Book Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr t-foreach="props.lines" t-as="line" t-key="line.id"
t-att-class="rowClass(line)">
<td><t t-esc="line.period_index + 1"/></td>
<td><t t-esc="line.scheduled_date"/></td>
<td class="text-end">$<t t-esc="props.formatCurrency(line.amount)"/></td>
<td class="text-end">$<t t-esc="props.formatCurrency(line.accumulated)"/></td>
<td class="text-end">$<t t-esc="props.formatCurrency(line.book_value_at_end)"/></td>
<td>
<t t-if="line.is_posted">Posted</t>
<t t-else="">Pending</t>
</td>
</tr>
</tbody>
</table>
</div>
</t>
</templates>

View File

@@ -0,0 +1,34 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class DisposalDialog extends Component {
static template = "fusion_accounting_assets.DisposalDialog";
static props = {
assetId: { type: Number },
onClose: { type: Function },
};
setup() {
this.assets = useService("fusion_assets");
this.state = useState({
disposalType: 'sale',
saleAmount: 0,
saleDate: new Date().toISOString().slice(0, 10),
});
}
async onConfirm() {
try {
await this.assets.disposeAsset(this.props.assetId, {
disposalType: this.state.disposalType,
saleAmount: parseFloat(this.state.saleAmount) || 0,
saleDate: this.state.saleDate,
});
this.props.onClose();
} catch (e) {
// Error already shown by service
}
}
}

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.DisposalDialog">
<div class="modal" style="display: block; background: rgba(0,0,0,0.5); position: fixed; top:0; left:0; right:0; bottom:0; z-index: 1050;">
<div class="modal-dialog" style="margin: 5vh auto; max-width: 500px;">
<div class="modal-content">
<div class="modal-header">
<h5>Dispose Asset</h5>
<button class="btn-close" t-on-click="props.onClose">&#215;</button>
</div>
<div class="modal-body">
<div class="mb-3">
<label>Disposal Type</label>
<select class="form-select"
t-on-change="(ev) => state.disposalType = ev.target.value">
<option value="sale" selected="state.disposalType === 'sale'">Sale</option>
<option value="scrap" selected="state.disposalType === 'scrap'">Scrap</option>
<option value="donation" selected="state.disposalType === 'donation'">Donation</option>
<option value="lost" selected="state.disposalType === 'lost'">Lost</option>
</select>
</div>
<div class="mb-3" t-if="state.disposalType === 'sale'">
<label>Sale Amount ($)</label>
<input type="number" class="form-control"
t-att-value="state.saleAmount"
t-on-change="(ev) => state.saleAmount = ev.target.value"/>
</div>
<div class="mb-3">
<label>Date</label>
<input type="date" class="form-control"
t-att-value="state.saleDate"
t-on-change="(ev) => state.saleDate = ev.target.value"/>
</div>
</div>
<div class="modal-footer">
<button class="btn_asset" t-on-click="props.onClose">Cancel</button>
<button class="btn_asset primary" t-on-click="onConfirm">Confirm Disposal</button>
</div>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,40 @@
// Fusion assets design tokens (extends Phase 1+2's tokens for consistency).
$asset-bg-primary: #ffffff;
$asset-bg-secondary: #f9fafb;
$asset-bg-tertiary: #f3f4f6;
$asset-border: #e5e7eb;
$asset-text-primary: #111827;
$asset-text-secondary: #6b7280;
$asset-text-muted: #9ca3af;
$asset-accent: #3b82f6;
$asset-accent-bg: #eff6ff;
// State colors
$asset-state-draft: #6b7280;
$asset-state-running: #10b981;
$asset-state-paused: #f59e0b;
$asset-state-disposed: #ef4444;
// Severity colors (mirrors phase 2)
$asset-severity-high: #ef4444;
$asset-severity-high-bg: #fef2f2;
$asset-severity-medium: #f59e0b;
$asset-severity-medium-bg: #fffbeb;
$asset-severity-low: #10b981;
$asset-severity-low-bg: #ecfdf5;
$asset-space-1: 0.25rem;
$asset-space-2: 0.5rem;
$asset-space-3: 0.75rem;
$asset-space-4: 1rem;
$asset-space-6: 1.5rem;
$asset-font-size-xs: 0.75rem;
$asset-font-size-sm: 0.875rem;
$asset-font-size-base: 1rem;
$asset-font-size-lg: 1.125rem;
$asset-font-size-xl: 1.25rem;
$asset-border-radius: 0.375rem;
$asset-border-radius-md: 0.5rem;

View File

@@ -0,0 +1,158 @@
// 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;
min-height: 100vh;
&_header {
background: $asset-bg-primary;
border-bottom: 1px solid $asset-border;
padding: $asset-space-4 $asset-space-6;
display: flex;
justify-content: space-between;
align-items: center;
h1 { font-size: $asset-font-size-xl; margin: 0; }
.o_fusion_assets_summary {
display: flex;
gap: $asset-space-6;
font-size: $asset-font-size-sm;
color: $asset-text-secondary;
.summary-value {
font-weight: 600;
color: $asset-text-primary;
margin-left: $asset-space-1;
}
}
}
&_card {
background: $asset-bg-primary;
border: 1px solid $asset-border;
border-radius: $asset-border-radius-md;
padding: $asset-space-4;
margin-bottom: $asset-space-3;
cursor: pointer;
transition: all 200ms ease-in-out;
&:hover {
border-color: $asset-accent;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: $asset-accent;
background: $asset-accent-bg;
}
&_header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $asset-space-2;
}
.asset-name {
font-weight: 600;
font-size: $asset-font-size-base;
}
.asset-state-badge {
padding: $asset-space-1 $asset-space-2;
border-radius: $asset-border-radius;
font-size: $asset-font-size-xs;
font-weight: 500;
text-transform: uppercase;
&[data-state="draft"] { background: lighten($asset-state-draft, 40%); color: $asset-state-draft; }
&[data-state="running"] { background: lighten($asset-state-running, 45%); color: $asset-state-running; }
&[data-state="paused"] { background: lighten($asset-state-paused, 35%); color: $asset-state-paused; }
&[data-state="disposed"] { background: lighten($asset-state-disposed, 35%); color: $asset-state-disposed; }
}
.asset-numbers {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $asset-space-2;
font-size: $asset-font-size-sm;
color: $asset-text-secondary;
.label {
font-weight: 500;
margin-right: $asset-space-2;
}
.value {
color: $asset-text-primary;
font-weight: 500;
}
}
}
&_table {
background: $asset-bg-primary;
border-radius: $asset-border-radius-md;
overflow: hidden;
font-size: $asset-font-size-sm;
table { width: 100%; border-collapse: collapse; }
th {
background: $asset-bg-tertiary;
padding: $asset-space-3;
text-align: left;
font-weight: 600;
color: $asset-text-secondary;
border-bottom: 1px solid $asset-border;
}
td {
padding: $asset-space-2 $asset-space-3;
border-bottom: 1px solid lighten($asset-border, 5%);
}
tr.posted { background: $asset-bg-secondary; }
tr.due-now { background: $asset-severity-medium-bg; }
.text-end { text-align: right; }
}
.btn_asset {
padding: $asset-space-2 $asset-space-4;
border-radius: $asset-border-radius;
background: $asset-bg-primary;
border: 1px solid $asset-border;
color: $asset-text-primary;
font-size: $asset-font-size-sm;
cursor: pointer;
&:hover { background: $asset-bg-tertiary; }
&.primary {
background: $asset-accent;
border-color: $asset-accent;
color: white;
&:hover { background: darken($asset-accent, 8%); }
}
&.danger {
background: $asset-severity-high;
border-color: $asset-severity-high;
color: white;
}
}
}
.o_fusion_anomaly_strip {
margin: $asset-space-3 0;
padding: $asset-space-3;
border-radius: $asset-border-radius;
border: 1px solid;
font-size: $asset-font-size-sm;
&[data-severity="high"] { background: $asset-severity-high-bg; border-color: $asset-severity-high; }
&[data-severity="medium"] { background: $asset-severity-medium-bg; border-color: $asset-severity-medium; }
&[data-severity="low"] { background: $asset-severity-low-bg; border-color: $asset-severity-low; }
}

View File

@@ -0,0 +1,32 @@
// Variables come from _variables.scss via manifest concatenation order.
[data-color-scheme="dark"] .o_fusion_assets {
background: #1f2937; color: #f9fafb;
&_header, &_card, &_table { background: #111827; border-color: #374151; }
&_card {
&:hover { border-color: #60a5fa; }
&.selected { background: #1e3a8a; border-color: #60a5fa; }
.asset-numbers .label { color: #9ca3af; }
.asset-numbers .value { color: #f9fafb; }
}
&_table {
th { background: #1f2937; color: #d1d5db; }
td { border-color: #374151; }
tr.posted { background: #1f2937; }
}
.btn_asset {
background: #374151; border-color: #4b5563; color: #f9fafb;
&:hover { background: #4b5563; }
&.primary { background: #3b82f6; }
}
.o_fusion_anomaly_strip {
&[data-severity="high"] { background: rgba(239, 68, 68, 0.15); }
&[data-severity="medium"] { background: rgba(245, 158, 11, 0.15); }
&[data-severity="low"] { background: rgba(16, 185, 129, 0.15); }
}
}

View File

@@ -0,0 +1,151 @@
/** @odoo-module **/
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;
// V19: rpc is a standalone import, not a service.
this.rpc = rpc;
this.notification = services.notification;
this.state = reactive({
assets: [],
count: 0,
total: 0,
stateFilter: null,
categoryFilter: null,
isLoading: false,
isProcessing: false,
selectedAssetId: null,
selectedDetail: null,
companyId: null,
limit: 50,
offset: 0,
anomalies: [],
});
}
async loadAssets(companyId = null) {
this.state.companyId = companyId;
this.state.isLoading = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/list`, {
state: this.state.stateFilter,
category_id: this.state.categoryFilter,
limit: this.state.limit,
offset: this.state.offset,
company_id: companyId,
});
this.state.assets = result.assets;
this.state.count = result.count;
this.state.total = result.total;
} finally {
this.state.isLoading = false;
}
}
async selectAsset(assetId) {
this.state.selectedAssetId = assetId;
this.state.selectedDetail = null;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/get_detail`, {
asset_id: assetId,
});
this.state.selectedDetail = result;
} catch (err) {
this.notification.add(`Failed to load asset detail: ${err.message || err}`, { type: "danger" });
}
}
async computeSchedule(assetId, recompute = false) {
this.state.isProcessing = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/compute_schedule`, {
asset_id: assetId, recompute: recompute,
});
this.notification.add(`Schedule computed (${result.lines_created} lines)`, { type: "success" });
if (this.state.selectedAssetId === assetId) {
await this.selectAsset(assetId);
}
return result;
} catch (err) {
this.notification.add(`Compute failed: ${err.message || err}`, { type: "danger" });
throw err;
} finally {
this.state.isProcessing = false;
}
}
async postDepreciation(assetId) {
this.state.isProcessing = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/post_depreciation`, {
asset_id: assetId,
});
this.notification.add(`Posted ${result.posted_count} period(s)`, { type: "success" });
if (this.state.selectedAssetId === assetId) {
await this.selectAsset(assetId);
}
return result;
} catch (err) {
this.notification.add(`Post failed: ${err.message || err}`, { type: "danger" });
throw err;
} finally {
this.state.isProcessing = false;
}
}
async disposeAsset(assetId, { saleAmount = 0, saleDate = null, salePartnerId = null, disposalType = "sale" } = {}) {
this.state.isProcessing = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/dispose`, {
asset_id: assetId, sale_amount: saleAmount,
sale_date: saleDate, sale_partner_id: salePartnerId,
disposal_type: disposalType,
});
this.notification.add(`Asset disposed: gain/loss $${result.gain_loss_amount.toFixed(2)}`, { type: "success" });
await this.loadAssets(this.state.companyId);
return result;
} catch (err) {
this.notification.add(`Dispose failed: ${err.message || err}`, { type: "danger" });
throw err;
} finally {
this.state.isProcessing = false;
}
}
async fetchAnomalies(severity = null) {
try {
const result = await this.rpc(`${ENDPOINT_BASE}/get_anomalies`, {
severity: severity, company_id: this.state.companyId,
});
this.state.anomalies = result.anomalies || [];
} catch (err) {
this.state.anomalies = [];
}
}
async suggestUsefulLife(description, amount = null, partnerName = null) {
return await this.rpc(`${ENDPOINT_BASE}/suggest_useful_life`, {
description: description, amount: amount, partner_name: partnerName,
});
}
setStateFilter(state) {
this.state.stateFilter = state;
this.state.offset = 0;
this.loadAssets(this.state.companyId);
}
}
export const assetsService = {
dependencies: ["notification"],
start(env, services) { return new AssetsService(env, services); },
};
registry.category("services").add("fusion_assets", assetsService);

View File

@@ -0,0 +1,80 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
/**
* 5 OWL tours for fusion_accounting_assets smoke testing.
*
* Each tour scripts a user interaction and is invoked from Python via
* HttpCase.start_tour(). Useful for catching UI regressions that asset-bundle
* compilation alone won't catch.
*/
// Tour 1: smoke
registry.category("web_tour.tours").add("fusion_assets_smoke", {
test: true,
url: "/odoo",
steps: () => [
{
content: "Wait for app",
trigger: ".o_navbar",
},
],
});
// Tour 2: open asset list
registry.category("web_tour.tours").add("fusion_assets_list", {
test: true,
url: "/odoo/action-fusion_accounting_assets.action_fusion_asset_list",
steps: () => [
{
content: "List view loads",
trigger: ".o_list_view, .o_view_nocontent",
},
],
});
// Tour 3: open categories
registry.category("web_tour.tours").add("fusion_assets_categories", {
test: true,
url: "/odoo/action-fusion_accounting_assets.action_fusion_asset_category_list",
steps: () => [
{
content: "Categories view loads",
trigger: ".o_list_view, .o_view_nocontent",
},
],
});
// Tour 4: anomalies
registry.category("web_tour.tours").add("fusion_assets_anomalies", {
test: true,
url: "/odoo/action-fusion_accounting_assets.action_fusion_asset_anomaly_list",
steps: () => [
{
content: "Anomalies view loads",
trigger: ".o_list_view, .o_view_nocontent",
},
],
});
// Tour 5: depreciation run wizard
registry.category("web_tour.tours").add("fusion_assets_depreciation_wizard", {
test: true,
url: "/odoo/action-fusion_accounting_assets.action_fusion_depreciation_run_wizard",
steps: () => [
{
content: "Wizard form opens",
trigger: ".modal-dialog .o_form_view",
},
{
content: "Period date field exists",
trigger: ".modal-dialog [name='period_date']",
},
{
content: "Close wizard",
trigger: ".modal-dialog .btn-secondary",
run: "click",
},
],
});

View File

@@ -0,0 +1,47 @@
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { AssetCard } from "../../components/asset_card/asset_card";
import { AssetDetailPanel } from "../../components/asset_detail_panel/asset_detail_panel";
import { AnomalyStrip } from "../../components/anomaly_strip/anomaly_strip";
export class AssetDashboard extends Component {
static template = "fusion_accounting_assets.AssetDashboard";
static props = { "*": true };
static components = { AssetCard, AssetDetailPanel, AnomalyStrip };
setup() {
this.assets = useService("fusion_assets");
this.state = useState(this.assets.state);
const companyId = this.env.services.user?.context?.allowed_company_ids?.[0];
onWillStart(async () => {
await this.assets.loadAssets(companyId);
await this.assets.fetchAnomalies();
});
}
onSelectAsset(id) {
this.assets.selectAsset(id);
}
onStateFilter(state) {
this.assets.setStateFilter(state || null);
}
formatCurrency(amount) {
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2, maximumFractionDigits: 2,
}).format(amount || 0);
}
get totalCost() {
return this.state.assets.reduce((sum, a) => sum + a.cost, 0);
}
get totalBookValue() {
return this.state.assets.reduce((sum, a) => sum + a.book_value, 0);
}
}

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.AssetDashboard">
<div class="o_fusion_assets">
<div class="o_fusion_assets_header">
<div>
<h1>Asset Management</h1>
<div class="text-muted">
<t t-esc="state.count"/> of <t t-esc="state.total"/> assets
</div>
</div>
<div class="o_fusion_assets_summary">
<div>Cost: <span class="summary-value">$<t t-esc="formatCurrency(totalCost)"/></span></div>
<div>Book Value: <span class="summary-value">$<t t-esc="formatCurrency(totalBookValue)"/></span></div>
</div>
</div>
<div class="d-flex" style="gap: 0.5rem; padding: 0.75rem;">
<button class="btn_asset" t-on-click="() => onStateFilter(null)"
t-att-class="state.stateFilter === null ? 'primary' : ''">All</button>
<button class="btn_asset" t-on-click="() => onStateFilter('draft')"
t-att-class="state.stateFilter === 'draft' ? 'primary' : ''">Draft</button>
<button class="btn_asset" t-on-click="() => onStateFilter('running')"
t-att-class="state.stateFilter === 'running' ? 'primary' : ''">Running</button>
<button class="btn_asset" t-on-click="() => onStateFilter('paused')"
t-att-class="state.stateFilter === 'paused' ? 'primary' : ''">Paused</button>
<button class="btn_asset" t-on-click="() => onStateFilter('disposed')"
t-att-class="state.stateFilter === 'disposed' ? 'primary' : ''">Disposed</button>
</div>
<AnomalyStrip t-foreach="state.anomalies" t-as="anomaly"
t-key="anomaly.id" anomaly="anomaly"/>
<div class="d-flex" style="gap: 1rem; padding: 1rem;">
<div style="flex: 1 1 60%;">
<div t-if="state.isLoading" class="text-center p-4 text-muted">Loading...</div>
<div t-elif="state.assets.length === 0" class="text-center p-4 text-muted">No assets found.</div>
<div t-else="">
<AssetCard t-foreach="state.assets" t-as="asset" t-key="asset.id"
asset="asset" selected="state.selectedAssetId === asset.id"
onSelect="() => onSelectAsset(asset.id)"
formatCurrency="formatCurrency.bind(this)"/>
</div>
</div>
<div style="flex: 1 1 40%;">
<AssetDetailPanel t-if="state.selectedDetail"
detail="state.selectedDetail"
formatCurrency="formatCurrency.bind(this)"/>
<div t-else="" class="p-4 text-muted">Select an asset to see details.</div>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,14 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { AssetDashboard } from "./asset_dashboard";
export const fusionAssetDashboardView = {
type: "fusion_assets",
Controller: AssetDashboard,
display_name: "Fusion Asset Management",
icon: "fa-cubes",
multiRecord: true,
};
registry.category("views").add("fusion_assets", fusionAssetDashboardView);

View File

@@ -0,0 +1,31 @@
from . import test_depreciation_methods
from . import test_prorate
from . import test_salvage_value
from . import test_asset_anomaly_detection
from . import test_useful_life_predictor
from . import test_fusion_asset
from . import test_fusion_asset_depreciation_line
from . import test_fusion_asset_category
from . import test_fusion_asset_disposal
from . import test_fusion_asset_anomaly
from . import test_account_move_inherit
from . import test_fusion_asset_engine
from . import test_engine_integration
from . import test_assets_controller
from . import test_assets_adapter
from . import test_asset_tools
from . import test_assets_cron
from . import test_engine_property
from . import test_method_integration
from . import test_asset_book_values_mv
from . import test_performance_benchmarks
from . import test_create_asset_wizard
from . import test_disposal_wizard
from . import test_partial_sale_wizard
from . import test_depreciation_run_wizard
from . import test_migration_round_trip
from . import test_audit_report
from . import test_coexistence
from . import test_assets_tours
from . import test_perf_controller
from . import test_local_llm_compat

View File

@@ -0,0 +1,47 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountMoveLineFusionAsset(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Asset From Invoice',
'cost': 8000,
'acquisition_date': date(2026, 1, 1),
})
self.partner = self.env['res.partner'].create({'name': 'Vendor X'})
product = self.env['product.product'].create({'name': 'Test Asset Item'})
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner.id,
'invoice_date': date(2026, 1, 1),
'invoice_line_ids': [(0, 0, {
'product_id': product.id,
'name': 'Test asset purchase',
'quantity': 1,
'price_unit': 8000,
})],
})
self.invoice_line = bill.invoice_line_ids[0]
def test_line_starts_without_asset_link(self):
self.assertFalse(self.invoice_line.fusion_asset_id)
self.assertEqual(self.invoice_line.fusion_asset_count, 0)
def test_link_invoice_line_to_asset(self):
self.invoice_line.fusion_asset_id = self.asset
self.assertEqual(self.invoice_line.fusion_asset_id, self.asset)
self.invoice_line.invalidate_recordset(['fusion_asset_count'])
self.assertEqual(self.invoice_line.fusion_asset_count, 1)
def test_action_open_fusion_asset_returns_window_action(self):
self.invoice_line.fusion_asset_id = self.asset
action = self.invoice_line.action_open_fusion_asset()
self.assertEqual(action['res_model'], 'fusion.asset')
self.assertEqual(action['res_id'], self.asset.id)
self.assertEqual(action['view_mode'], 'form')

View File

@@ -0,0 +1,71 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_assets.services.anomaly_detection import (
detect_schedule_variance, detect_low_utilization, AssetAnomaly,
)
@tagged('post_install', '-at_install')
class TestAssetAnomalyDetection(TransactionCase):
def test_schedule_variance_within_threshold_returns_none(self):
# 5% variance < 10% threshold
result = detect_schedule_variance(
asset_id=1, asset_name='Truck', expected_accumulated=10000,
actual_accumulated=10500,
)
self.assertIsNone(result)
def test_schedule_variance_behind_schedule_low_severity(self):
# 15% behind: low severity, behind_schedule
result = detect_schedule_variance(
asset_id=1, asset_name='Truck', expected_accumulated=10000,
actual_accumulated=8500,
)
self.assertIsNotNone(result)
self.assertEqual(result.anomaly_type, 'behind_schedule')
self.assertEqual(result.severity, 'low')
def test_schedule_variance_ahead_high_severity(self):
# 60% ahead: high severity
result = detect_schedule_variance(
asset_id=2, asset_name='Server', expected_accumulated=10000,
actual_accumulated=16000,
)
self.assertIsNotNone(result)
self.assertEqual(result.anomaly_type, 'ahead_of_schedule')
self.assertEqual(result.severity, 'high')
def test_schedule_variance_zero_expected_returns_none(self):
result = detect_schedule_variance(
asset_id=1, asset_name='Truck', expected_accumulated=0,
actual_accumulated=500,
)
self.assertIsNone(result)
def test_low_utilization_flags_when_underused(self):
# 60% deficit -> high severity
result = detect_low_utilization(
asset_id=3, asset_name='Mill', expected_units=1000, actual_units=400,
)
self.assertIsNotNone(result)
self.assertEqual(result.anomaly_type, 'low_utilization')
self.assertEqual(result.severity, 'high')
def test_low_utilization_within_tolerance_returns_none(self):
# 95% used: within 10% tolerance
result = detect_low_utilization(
asset_id=3, asset_name='Mill', expected_units=1000, actual_units=950,
)
self.assertIsNone(result)
def test_anomaly_to_dict_round_trip(self):
anomaly = AssetAnomaly(
asset_id=1, asset_name='X', anomaly_type='behind_schedule',
severity='medium', expected=100.0, actual=70.0, variance_pct=30.0,
detail='example',
)
d = anomaly.to_dict()
self.assertEqual(d['asset_id'], 1)
self.assertEqual(d['anomaly_type'], 'behind_schedule')
self.assertEqual(d['severity'], 'medium')

View File

@@ -0,0 +1,29 @@
"""Tests for the per-asset book value MV."""
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAssetBookValuesMV(TransactionCase):
def test_mv_exists_and_is_queryable(self):
self.env['fusion.asset.book.values.mv']._refresh(concurrently=False)
rows = self.env['fusion.asset.book.values.mv'].search([], limit=10)
self.assertIsNotNone(rows)
def test_mv_includes_new_asset_after_refresh(self):
asset = self.env['fusion.asset'].create({
'name': 'MV Test', 'cost': 5000, 'salvage_value': 500,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 5,
})
self.env.flush_all()
self.env['fusion.asset.book.values.mv']._refresh(concurrently=False)
mv_row = self.env['fusion.asset.book.values.mv'].search([
('asset_id', '=', asset.id),
], limit=1)
self.assertTrue(mv_row)
self.assertAlmostEqual(mv_row.book_value, 5000, places=2)

View File

@@ -0,0 +1,56 @@
"""Tests for the 5 fusion-asset AI tools."""
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.addons.fusion_accounting_ai.services.tools import asset_management as tools
@tagged('post_install', '-at_install')
class TestFusionAssetTools(TransactionCase):
def test_fusion_list_assets(self):
self.env['fusion.asset'].create({
'name': 'Tool Test', 'cost': 1000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
result = tools.fusion_list_assets(self.env, {'company_id': self.env.company.id})
self.assertGreaterEqual(result.get('count', 0), 1)
def test_fusion_get_asset_detail(self):
asset = self.env['fusion.asset'].create({
'name': 'Detail Test', 'cost': 1500,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
result = tools.fusion_get_asset_detail(self.env, {'asset_id': asset.id})
self.assertEqual(result['asset']['name'], 'Detail Test')
def test_fusion_compute_schedule(self):
asset = self.env['fusion.asset'].create({
'name': 'Schedule Test', 'cost': 2000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
result = tools.fusion_compute_asset_schedule(self.env, {'asset_id': asset.id})
self.assertEqual(result['lines_created'], 4)
def test_fusion_suggest_useful_life(self):
self.env['ir.config_parameter'].sudo().search([
('key', 'in', ['fusion_accounting.provider.asset_useful_life',
'fusion_accounting.provider.default'])
]).unlink()
result = tools.fusion_suggest_asset_useful_life(self.env, {
'description': 'desk',
})
self.assertEqual(result['useful_life_years'], 7)
def test_tools_registered_in_dispatch(self):
from odoo.addons.fusion_accounting_ai.services.tools import TOOL_DISPATCH
for tool_name in ['fusion_list_assets', 'fusion_get_asset_detail',
'fusion_compute_asset_schedule', 'fusion_dispose_asset',
'fusion_suggest_asset_useful_life']:
self.assertIn(tool_name, TOOL_DISPATCH)

View File

@@ -0,0 +1,40 @@
"""AssetsAdapter wiring tests — fusion-mode dispatch."""
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.addons.fusion_accounting_ai.services.data_adapters.assets import (
AssetsAdapter,
)
@tagged('post_install', '-at_install')
class TestAssetsAdapter(TransactionCase):
def setUp(self):
super().setUp()
self.adapter = AssetsAdapter(self.env)
def test_list_assets_via_fusion(self):
self.env['fusion.asset'].create({
'name': 'Adapter Test', 'cost': 1000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
result = self.adapter.list_assets_via_fusion(company_id=self.env.company.id)
self.assertGreaterEqual(result['count'], 1)
def test_suggest_useful_life_via_fusion_uses_templated_fallback(self):
self.env['ir.config_parameter'].sudo().search([
('key', 'in', ['fusion_accounting.provider.asset_useful_life',
'fusion_accounting.provider.default'])
]).unlink()
result = self.adapter.suggest_useful_life_via_fusion(description='laptop')
self.assertEqual(result['useful_life_years'], 4)
self.assertEqual(result['depreciation_method'], 'straight_line')
def test_dispose_asset_via_community_returns_error(self):
result = self.adapter.dispose_asset_via_community(asset_id=1, sale_amount=100)
self.assertIn('error', result)

View File

@@ -0,0 +1,103 @@
"""Controller tests using HttpCase."""
import json
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import HttpCase, new_test_user
@tagged('post_install', '-at_install')
class TestAssetsController(HttpCase):
def setUp(self):
super().setUp()
self.user = new_test_user(
self.env, login='assets_test_user',
groups='base.group_user,account.group_account_invoice',
)
def _jsonrpc(self, endpoint, params):
self.authenticate('assets_test_user', 'assets_test_user')
url = f'/fusion/assets/{endpoint}'
body = {'jsonrpc': '2.0', 'method': 'call', 'params': params, 'id': 1}
response = self.url_open(
url, data=json.dumps(body),
headers={'Content-Type': 'application/json'},
)
self.assertEqual(
response.status_code, 200,
f"{endpoint} returned {response.status_code}: {response.text[:300]}",
)
result = response.json()
if 'error' in result:
self.fail(f"{endpoint} errored: {result['error']}")
return result.get('result', {})
def test_list_returns_dict(self):
result = self._jsonrpc('list', {'company_id': self.env.company.id})
self.assertIn('assets', result)
self.assertIn('total', result)
def test_get_detail_returns_asset(self):
asset = self.env['fusion.asset'].create({
'name': 'Ctrl Test Asset', 'cost': 5000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 5,
})
result = self._jsonrpc('get_detail', {'asset_id': asset.id})
self.assertEqual(result['asset']['id'], asset.id)
self.assertIn('depreciation_lines', result)
def test_compute_schedule_creates_lines(self):
asset = self.env['fusion.asset'].create({
'name': 'CompTest', 'cost': 4000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
result = self._jsonrpc('compute_schedule', {'asset_id': asset.id})
self.assertEqual(result['lines_created'], 4)
def test_post_depreciation_after_running(self):
asset = self.env['fusion.asset'].create({
'name': 'PostTest', 'cost': 3000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 3,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(asset)
asset.action_set_running()
result = self._jsonrpc('post_depreciation', {'asset_id': asset.id})
self.assertEqual(result['posted_count'], 1)
def test_dispose_marks_asset_disposed(self):
asset = self.env['fusion.asset'].create({
'name': 'DispTest', 'cost': 6000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 3,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(asset)
asset.action_set_running()
result = self._jsonrpc('dispose', {
'asset_id': asset.id, 'sale_amount': 4000,
'sale_date': '2027-06-01', 'disposal_type': 'sale',
})
self.assertIn('disposal_id', result)
asset.invalidate_recordset(['state'])
self.assertEqual(asset.state, 'disposed')
def test_get_anomalies_returns_list(self):
result = self._jsonrpc('get_anomalies', {'company_id': self.env.company.id})
self.assertIn('anomalies', result)
def test_suggest_useful_life_returns_dict(self):
result = self._jsonrpc('suggest_useful_life', {'description': 'Dell laptop'})
self.assertIn('useful_life_years', result)
self.assertIn('depreciation_method', result)
self.assertEqual(result['useful_life_years'], 4)
def test_get_partner_history(self):
partner = self.env['res.partner'].create({'name': 'History Test Partner'})
result = self._jsonrpc('get_partner_history', {'partner_id': partner.id})
self.assertEqual(result['partner_id'], partner.id)

View File

@@ -0,0 +1,28 @@
"""Cron handler smoke tests."""
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestFusionAssetsCron(TransactionCase):
def setUp(self):
super().setUp()
self.cron = self.env['fusion.assets.cron']
self.asset = self.env['fusion.asset'].create({
'name': 'Cron Test', 'cost': 4000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
def test_cron_post_due_depreciation_runs(self):
self.cron._cron_post_due_depreciation()
def test_cron_anomaly_scan_runs(self):
self.cron._cron_anomaly_scan()

View File

@@ -0,0 +1,28 @@
"""Python wrappers that run the OWL tours via HttpCase.start_tour.
Tours require an HTTP server + headless browser. They are tagged with
'tour' so they can be excluded from fast unit-test runs and selected
explicitly when CI has the right infra (chromium + xvfb / websocket-client).
"""
from odoo.tests.common import HttpCase
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'tour')
class TestAssetsTours(HttpCase):
def test_smoke_tour(self):
self.start_tour("/odoo", "fusion_assets_smoke", login="admin")
def test_list_tour(self):
self.start_tour("/odoo", "fusion_assets_list", login="admin")
def test_categories_tour(self):
self.start_tour("/odoo", "fusion_assets_categories", login="admin")
def test_anomalies_tour(self):
self.start_tour("/odoo", "fusion_assets_anomalies", login="admin")
def test_depreciation_wizard_tour(self):
self.start_tour("/odoo", "fusion_assets_depreciation_wizard", login="admin")

View File

@@ -0,0 +1,18 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAuditReport(TransactionCase):
def test_report_renders(self):
wizard = self.env['fusion.migration.wizard'].create({})
try:
pdf, content_type = self.env['ir.actions.report'].sudo()._render_qweb_pdf(
'fusion_accounting_assets.migration_audit_template',
res_ids=[wizard.id], data={},
)
# PDF or HTML both ok (wkhtmltopdf might be missing on dev VM)
self.assertGreater(len(pdf), 100)
except Exception as e:
self.skipTest(f"PDF render failed (likely wkhtmltopdf missing): {e}")

View File

@@ -0,0 +1,38 @@
"""Coexistence tests: fusion_accounting_assets menu only visible when
Enterprise account_asset is NOT installed."""
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAssetsCoexistence(TransactionCase):
def setUp(self):
super().setUp()
self.coex_group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
raise_if_not_found=False,
)
self.assertIsNotNone(self.coex_group, "Coexistence group must exist")
def test_engine_always_available(self):
"""Engine is registered regardless of Enterprise install state."""
self.assertIn('fusion.asset.engine', self.env.registry)
def test_menu_gated_by_coexistence_group(self):
menu = self.env.ref('fusion_accounting_assets.menu_fusion_assets_root',
raise_if_not_found=False)
if not menu:
self.skipTest("Menu not loaded")
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
self.assertIn(self.coex_group, menu_groups,
"Asset root menu must require the coexistence group")
def test_categories_menu_gated(self):
menu = self.env.ref('fusion_accounting_assets.menu_fusion_asset_categories',
raise_if_not_found=False)
if not menu:
self.skipTest("Menu not loaded")
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
self.assertIn(self.coex_group, menu_groups)

View File

@@ -0,0 +1,62 @@
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestCreateAssetWizard(TransactionCase):
def setUp(self):
super().setUp()
self.env['ir.config_parameter'].sudo().search([
('key', 'in', ['fusion_accounting.provider.asset_useful_life',
'fusion_accounting.provider.default'])
]).unlink()
def test_create_minimal_asset(self):
wizard = self.env['fusion.create.asset.wizard'].create({
'name': 'Test Asset',
'cost': 5000,
'method': 'straight_line',
'useful_life_years': 5,
'acquisition_date': date(2026, 1, 1),
'source_invoice_line_id': False,
})
action = wizard.action_create_asset()
self.assertEqual(action['res_model'], 'fusion.asset')
asset = self.env['fusion.asset'].browse(action['res_id'])
self.assertEqual(asset.name, 'Test Asset')
self.assertEqual(asset.cost, 5000)
def test_ai_suggest_fills_fields(self):
wizard = self.env['fusion.create.asset.wizard'].create({
'name': 'Dell laptop',
'cost': 2000,
'method': 'straight_line',
'useful_life_years': 5,
'acquisition_date': date(2026, 1, 1),
})
wizard.action_ai_suggest()
self.assertEqual(wizard.ai_suggested_years, 4)
self.assertEqual(wizard.useful_life_years, 4)
def test_category_onchange_pre_fills(self):
category = self.env['fusion.asset.category'].create({
'name': 'Test Category',
'method': 'declining_balance',
'useful_life_years': 7,
'declining_rate_pct': 25.0,
'salvage_value_pct': 10.0,
})
wizard = self.env['fusion.create.asset.wizard'].new({
'name': 'Test', 'cost': 10000,
'method': 'straight_line', 'useful_life_years': 5,
'acquisition_date': date(2026, 1, 1),
'category_id': category.id,
})
wizard._onchange_category_id()
self.assertEqual(wizard.method, 'declining_balance')
self.assertEqual(wizard.useful_life_years, 7)
self.assertEqual(wizard.declining_rate_pct, 25.0)
self.assertAlmostEqual(wizard.salvage_value, 1000, places=2)

View File

@@ -0,0 +1,88 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_assets.services.depreciation_methods import (
straight_line, declining_balance, units_of_production,
)
@tagged('post_install', '-at_install')
class TestStraightLine(TransactionCase):
def test_total_equals_cost_minus_salvage(self):
steps = straight_line(cost=10000, salvage_value=1000, n_periods=5)
total = sum(s.period_amount for s in steps)
self.assertAlmostEqual(total, 9000, places=2)
def test_per_period_equal_except_last(self):
steps = straight_line(cost=10000, salvage_value=0, n_periods=4)
self.assertEqual([s.period_amount for s in steps], [2500.0] * 4)
def test_last_period_absorbs_rounding(self):
steps = straight_line(cost=10000, salvage_value=0, n_periods=3)
total = sum(s.period_amount for s in steps)
self.assertAlmostEqual(total, 10000, places=2)
def test_zero_periods_returns_empty(self):
self.assertEqual(straight_line(cost=10000, n_periods=0), [])
def test_book_value_decreasing(self):
steps = straight_line(cost=10000, salvage_value=1000, n_periods=5)
for i in range(1, len(steps)):
self.assertLess(steps[i].book_value_at_end, steps[i - 1].book_value_at_end)
@tagged('post_install', '-at_install')
class TestDecliningBalance(TransactionCase):
def test_total_does_not_exceed_depreciable(self):
steps = declining_balance(cost=10000, salvage_value=1000, n_periods=10, rate=0.20)
total = sum(s.period_amount for s in steps)
self.assertLessEqual(total, 9000.01)
def test_does_not_go_below_salvage(self):
steps = declining_balance(cost=10000, salvage_value=1000, n_periods=10, rate=0.50)
for s in steps:
self.assertGreaterEqual(s.book_value_at_end, 999.99)
def test_zero_rate_returns_empty(self):
self.assertEqual(declining_balance(cost=10000, n_periods=5, rate=0), [])
def test_pathological_100pct_rate_one_period(self):
steps = declining_balance(cost=10000, salvage_value=500, n_periods=10, rate=1.0)
self.assertEqual(len(steps), 1)
self.assertAlmostEqual(steps[0].period_amount, 9500, places=2)
@tagged('post_install', '-at_install')
class TestUnitsOfProduction(TransactionCase):
def test_total_proportional_to_units_used(self):
steps = units_of_production(
cost=20000, salvage_value=2000,
total_units_expected=10000,
units_per_period=[1000, 2000, 3000, 4000],
)
total = sum(s.period_amount for s in steps)
self.assertAlmostEqual(total, 18000, places=1)
def test_partial_use_partial_depreciation(self):
steps = units_of_production(
cost=10000, salvage_value=0,
total_units_expected=1000,
units_per_period=[200],
)
self.assertAlmostEqual(steps[0].period_amount, 2000, places=2)
def test_zero_total_units_returns_empty(self):
self.assertEqual(
units_of_production(cost=10000, total_units_expected=0, units_per_period=[100]),
[],
)
def test_does_not_overshoot_salvage(self):
steps = units_of_production(
cost=10000, salvage_value=1000,
total_units_expected=1000,
units_per_period=[2000],
)
self.assertAlmostEqual(steps[0].period_amount, 9000, places=2)

View File

@@ -0,0 +1,43 @@
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestDepreciationRunWizard(TransactionCase):
def test_run_all_running_posts_due_periods(self):
for amt in [3000, 5000]:
asset = self.env['fusion.asset'].create({
'name': f'Run Test {amt}', 'cost': amt,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 3,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(asset)
asset.action_set_running()
wizard = self.env['fusion.depreciation.run.wizard'].create({
'period_date': date(2030, 12, 31),
'state_filter': 'all_running',
})
wizard.action_run()
self.assertEqual(wizard.state, 'done')
self.assertGreater(wizard.posted_count, 0)
def test_run_selected_posts_only_selected(self):
asset = self.env['fusion.asset'].create({
'name': 'Selected Test', 'cost': 1000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 3,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(asset)
asset.action_set_running()
wizard = self.env['fusion.depreciation.run.wizard'].create({
'period_date': date(2030, 12, 31),
'state_filter': 'selected',
'asset_ids': [(6, 0, [asset.id])],
})
wizard.action_run()
self.assertEqual(wizard.state, 'done')

View File

@@ -0,0 +1,50 @@
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestDisposalWizard(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Disposal Test Asset',
'cost': 6000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 3,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
def test_default_loads_active_asset(self):
wizard = self.env['fusion.disposal.wizard'].with_context(
active_model='fusion.asset', active_id=self.asset.id,
).create({})
self.assertEqual(wizard.asset_id, self.asset)
def test_action_dispose_marks_asset_disposed(self):
wizard = self.env['fusion.disposal.wizard'].create({
'asset_id': self.asset.id,
'disposal_type': 'sale',
'sale_amount': 4000,
'disposal_date': date(2026, 6, 1),
})
wizard.action_dispose()
self.asset.invalidate_recordset(['state'])
self.assertEqual(self.asset.state, 'disposed')
def test_compute_gain_loss_sale(self):
wizard = self.env['fusion.disposal.wizard'].create({
'asset_id': self.asset.id,
'disposal_type': 'sale',
'sale_amount': 7000,
})
wizard._compute_gain_loss()
self.assertAlmostEqual(
wizard.estimated_gain_loss,
7000 - self.asset.book_value,
places=2,
)

View File

@@ -0,0 +1,151 @@
"""End-to-end engine integration tests.
Each test creates a complete realistic asset (with category and accounts),
runs the engine through a full lifecycle, and asserts both the model state
and the journal entries (where category accounts are configured).
"""
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.exceptions import ValidationError
@tagged('post_install', '-at_install', 'integration')
class TestAssetEngineIntegration(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.asset.engine']
Account = self.env['account.account']
company_id = self.env.company.id
self.expense_account = Account.search([
('account_type', '=', 'expense_depreciation'),
('company_ids', 'in', company_id),
], limit=1)
if not self.expense_account:
self.expense_account = Account.create({
'name': 'Test Depreciation Expense',
'code': '7180',
'account_type': 'expense_depreciation',
'company_ids': [(6, 0, [company_id])],
})
self.dep_account = Account.search([
('account_type', '=', 'asset_fixed'),
('company_ids', 'in', company_id),
], limit=1)
if not self.dep_account:
self.dep_account = Account.create({
'name': 'Test Accumulated Depreciation',
'code': '1690',
'account_type': 'asset_fixed',
'company_ids': [(6, 0, [company_id])],
})
self.category = self.env['fusion.asset.category'].create({
'name': 'Test Category',
'method': 'straight_line',
'useful_life_years': 5,
'asset_account_id': self.dep_account.id,
'depreciation_account_id': self.dep_account.id,
'expense_account_id': self.expense_account.id,
})
def _make_asset(self, **kwargs):
defaults = {
'name': 'Integration Asset',
'cost': 12000,
'salvage_value': 0,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 4,
'category_id': self.category.id,
}
defaults.update(kwargs)
return self.env['fusion.asset'].create(defaults)
def test_full_lifecycle_straight_line(self):
asset = self._make_asset()
self.engine.compute_depreciation_schedule(asset)
self.assertEqual(len(asset.depreciation_line_ids), 4)
self.assertAlmostEqual(
sum(asset.depreciation_line_ids.mapped('amount')), 12000, places=2,
)
asset.action_set_running()
for _i in range(2):
result = self.engine.post_depreciation_entry(asset)
self.assertEqual(result['posted_count'], 1)
asset.invalidate_recordset(['book_value', 'total_depreciated'])
self.assertAlmostEqual(asset.total_depreciated, 6000, places=2)
def test_post_creates_journal_entry_when_accounts_configured(self):
asset = self._make_asset()
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
self.engine.post_depreciation_entry(asset)
first = asset.depreciation_line_ids.sorted('period_index')[0]
self.assertTrue(first.move_id, "Expected journal entry on posted line")
moves = first.move_id
self.assertAlmostEqual(
sum(moves.line_ids.mapped('debit')),
sum(moves.line_ids.mapped('credit')),
places=2,
)
def test_dispose_caps_future_lines(self):
asset = self._make_asset()
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
self.engine.post_depreciation_entry(asset)
self.engine.dispose_asset(
asset, sale_amount=5000, sale_date=date(2027, 6, 1),
)
self.assertEqual(asset.state, 'disposed')
unposted = asset.depreciation_line_ids.filtered(lambda l: not l.is_posted)
for line in unposted:
self.assertLessEqual(line.scheduled_date, date(2027, 6, 1))
def test_dispose_records_correct_book_value(self):
asset = self._make_asset()
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
for _i in range(2):
self.engine.post_depreciation_entry(asset)
result = self.engine.dispose_asset(
asset, sale_amount=8000, sale_date=date(2028, 6, 1),
)
# Book value at disposal = cost - accumulated = 12000 - 6000 = 6000.
self.assertAlmostEqual(result['book_value_at_disposal'], 6000, places=2)
# Gain = 8000 - 6000 = 2000.
self.assertAlmostEqual(result['gain_loss_amount'], 2000, places=2)
def test_partial_sale_30pct(self):
asset = self._make_asset(cost=10000, salvage_value=0)
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
result = self.engine.partial_sale(
asset, sold_amount=3500, sold_qty=0.3,
sale_date=date(2027, 1, 1),
)
asset.invalidate_recordset(['cost'])
self.assertAlmostEqual(asset.cost, 7000, places=2)
child = self.env['fusion.asset'].browse(result['child_asset_id'])
self.assertAlmostEqual(child.cost, 3000, places=2)
self.assertEqual(child.state, 'disposed')
# Child has no posted depreciation; book_value at disposal = 3000.
# Gain = 3500 - 3000 = 500.
self.assertAlmostEqual(result['gain_loss_amount'], 500, places=0)
def test_pause_then_resume_lifecycle(self):
asset = self._make_asset()
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
self.engine.post_depreciation_entry(asset)
self.engine.pause_asset(asset)
with self.assertRaises(ValidationError):
self.engine.post_depreciation_entry(asset)
self.engine.resume_asset(asset)
result = self.engine.post_depreciation_entry(asset)
self.assertEqual(result['posted_count'], 1)

View File

@@ -0,0 +1,101 @@
"""Property-based invariant tests for the asset engine.
Hypothesis generates random inputs; we assert mathematical invariants
that must hold regardless of input."""
from hypothesis import given, settings, strategies as st, HealthCheck
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_assets.services.depreciation_methods import (
straight_line, declining_balance, units_of_production,
)
@tagged('post_install', '-at_install', 'property_based')
class TestDepreciationInvariants(TransactionCase):
@given(
cost=st.floats(min_value=100.0, max_value=1000000.0,
allow_nan=False, allow_infinity=False),
salvage_pct=st.floats(min_value=0.0, max_value=0.5,
allow_nan=False, allow_infinity=False),
n_periods=st.integers(min_value=1, max_value=40),
)
@settings(max_examples=80, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_straight_line_total_equals_cost_minus_salvage(self, cost, salvage_pct, n_periods):
cost = round(cost, 2)
salvage = round(cost * salvage_pct, 2)
steps = straight_line(cost=cost, salvage_value=salvage, n_periods=n_periods)
total = sum(s.period_amount for s in steps)
# Within 1c rounding tolerance
self.assertAlmostEqual(
total, cost - salvage, places=1,
msg=f"cost={cost}, salvage={salvage}, n={n_periods}, total={total:.2f}",
)
@given(
cost=st.floats(min_value=100.0, max_value=1000000.0,
allow_nan=False, allow_infinity=False),
salvage_pct=st.floats(min_value=0.0, max_value=0.5,
allow_nan=False, allow_infinity=False),
n_periods=st.integers(min_value=1, max_value=20),
)
@settings(max_examples=50, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_straight_line_book_value_decreasing(self, cost, salvage_pct, n_periods):
cost = round(cost, 2)
salvage = round(cost * salvage_pct, 2)
steps = straight_line(cost=cost, salvage_value=salvage, n_periods=n_periods)
for i in range(1, len(steps)):
self.assertLessEqual(
steps[i].book_value_at_end,
steps[i - 1].book_value_at_end + 0.01,
)
@given(
cost=st.floats(min_value=1000.0, max_value=100000.0,
allow_nan=False, allow_infinity=False),
salvage_pct=st.floats(min_value=0.0, max_value=0.3,
allow_nan=False, allow_infinity=False),
n_periods=st.integers(min_value=2, max_value=20),
rate=st.floats(min_value=0.05, max_value=0.5,
allow_nan=False, allow_infinity=False),
)
@settings(max_examples=50, deadline=3000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_declining_balance_never_below_salvage(self, cost, salvage_pct, n_periods, rate):
cost = round(cost, 2)
salvage = round(cost * salvage_pct, 2)
steps = declining_balance(
cost=cost, salvage_value=salvage,
n_periods=n_periods, rate=rate,
)
for s in steps:
self.assertGreaterEqual(
s.book_value_at_end, salvage - 0.01,
msg=f"cost={cost}, salvage={salvage}, rate={rate}, step={s}",
)
@given(
cost=st.floats(min_value=1000.0, max_value=100000.0,
allow_nan=False, allow_infinity=False),
total_units=st.floats(min_value=100.0, max_value=10000.0,
allow_nan=False, allow_infinity=False),
n_periods=st.integers(min_value=1, max_value=10),
)
@settings(max_examples=30, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_units_of_production_total_at_full_use_equals_depreciable(self, cost, total_units, n_periods):
cost = round(cost, 2)
salvage = 0.0
# Distribute total_units evenly across periods
per_period = total_units / n_periods
steps = units_of_production(
cost=cost, salvage_value=salvage,
total_units_expected=total_units,
units_per_period=[per_period] * n_periods,
)
total = sum(s.period_amount for s in steps)
self.assertAlmostEqual(total, cost - salvage, places=1)

View File

@@ -0,0 +1,59 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.exceptions import ValidationError
@tagged('post_install', '-at_install')
class TestFusionAsset(TransactionCase):
def setUp(self):
super().setUp()
self.asset_vals = {
'name': 'Test Asset',
'cost': 10000,
'salvage_value': 1000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
}
def test_create_minimal(self):
a = self.env['fusion.asset'].create(self.asset_vals)
self.assertEqual(a.state, 'draft')
self.assertEqual(a.book_value, 10000)
def test_state_transitions_draft_to_running(self):
a = self.env['fusion.asset'].create(self.asset_vals)
a.action_set_running()
self.assertEqual(a.state, 'running')
self.assertTrue(a.in_service_date)
def test_pause_resume(self):
a = self.env['fusion.asset'].create(self.asset_vals)
a.action_set_running()
a.action_pause()
self.assertEqual(a.state, 'paused')
a.action_resume()
self.assertEqual(a.state, 'running')
def test_cannot_pause_from_draft(self):
a = self.env['fusion.asset'].create(self.asset_vals)
with self.assertRaises(ValidationError):
a.action_pause()
def test_negative_cost_rejected(self):
with self.assertRaises(Exception):
self.env['fusion.asset'].create({**self.asset_vals, 'cost': -100})
def test_salvage_exceeds_cost_rejected(self):
with self.assertRaises(Exception):
self.env['fusion.asset'].create(
{**self.asset_vals, 'cost': 1000, 'salvage_value': 5000},
)
def test_book_value_starts_at_cost(self):
a = self.env['fusion.asset'].create(self.asset_vals)
self.assertEqual(a.book_value, a.cost)
self.assertEqual(a.total_depreciated, 0)

View File

@@ -0,0 +1,49 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionAssetAnomaly(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Watched Asset',
'cost': 5000,
'acquisition_date': date(2026, 1, 1),
})
def _make_anomaly(self, **kw):
vals = {
'asset_id': self.asset.id,
'anomaly_type': 'behind_schedule',
'severity': 'medium',
'expected': 1000.0,
'actual': 700.0,
'variance_pct': -30.0,
}
vals.update(kw)
return self.env['fusion.asset.anomaly'].create(vals)
def test_create_defaults_state_new(self):
a = self._make_anomaly()
self.assertEqual(a.state, 'new')
self.assertTrue(a.detected_at)
self.assertEqual(a.company_id, self.asset.company_id)
def test_acknowledge_transitions(self):
a = self._make_anomaly()
a.action_acknowledge()
self.assertEqual(a.state, 'acknowledged')
def test_dismiss_transitions(self):
a = self._make_anomaly()
a.action_dismiss()
self.assertEqual(a.state, 'dismissed')
def test_resolve_transitions(self):
a = self._make_anomaly(anomaly_type='low_utilization', severity='high')
a.action_resolve()
self.assertEqual(a.state, 'resolved')

View File

@@ -0,0 +1,35 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionAssetCategory(TransactionCase):
def test_create_with_defaults(self):
cat = self.env['fusion.asset.category'].create({'name': 'Computers'})
self.assertEqual(cat.method, 'straight_line')
self.assertEqual(cat.useful_life_years, 5)
self.assertEqual(cat.prorate_convention, 'days_period')
self.assertEqual(cat.asset_count, 0)
def test_asset_count_reflects_linked_assets(self):
cat = self.env['fusion.asset.category'].create({'name': 'Vehicles'})
for i in range(3):
self.env['fusion.asset'].create({
'name': f'Truck {i}',
'cost': 50000,
'acquisition_date': date(2026, 1, 1),
'method': 'declining_balance',
'category_id': cat.id,
})
cat.invalidate_recordset(['asset_count'])
self.assertEqual(cat.asset_count, 3)
def test_method_must_be_in_selection(self):
with self.assertRaises(Exception):
self.env['fusion.asset.category'].create({
'name': 'Bogus',
'method': 'not_a_method',
})

View File

@@ -0,0 +1,62 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionAssetDepreciationLine(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Asset for Lines',
'cost': 12000,
'salvage_value': 0,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 1,
})
def _make_line(self, period_index, amount=1000.0, scheduled_date=None):
return self.env['fusion.asset.depreciation.line'].create({
'asset_id': self.asset.id,
'period_index': period_index,
'scheduled_date': scheduled_date or date(2026, period_index, 28),
'amount': amount,
})
def test_create_line_defaults_unposted(self):
line = self._make_line(1)
self.assertFalse(line.is_posted)
self.assertFalse(line.posted_date)
self.assertFalse(line.move_id)
self.assertEqual(line.company_id, self.asset.company_id)
self.assertEqual(line.currency_id, self.asset.currency_id)
def test_action_post_marks_line_posted(self):
line = self._make_line(2)
line.action_post()
self.assertTrue(line.is_posted)
self.assertTrue(line.posted_date)
def test_action_post_idempotent_keeps_first_date(self):
line = self._make_line(3)
line.action_post()
first_date = line.posted_date
line.action_post()
self.assertEqual(line.posted_date, first_date)
def test_unique_period_per_asset(self):
self._make_line(4)
with self.assertRaises(Exception):
self._make_line(4)
def test_book_value_reflects_posted_lines_only(self):
l1 = self._make_line(5, amount=1000)
self._make_line(6, amount=1500)
self.assertEqual(self.asset.book_value, 12000)
l1.action_post()
self.asset.invalidate_recordset(['book_value', 'total_depreciated'])
self.assertEqual(self.asset.total_depreciated, 1000)
self.assertEqual(self.asset.book_value, 11000)

View File

@@ -0,0 +1,56 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionAssetDisposal(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Disposable Asset',
'cost': 10000,
'salvage_value': 0,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
})
def test_create_minimal_sale(self):
d = self.env['fusion.asset.disposal'].create({
'asset_id': self.asset.id,
'disposal_type': 'sale',
'sale_amount': 7000,
'book_value_at_disposal': 6000,
})
self.assertEqual(d.gain_loss_amount, 1000)
self.assertEqual(d.company_id, self.asset.company_id)
def test_sale_at_loss(self):
d = self.env['fusion.asset.disposal'].create({
'asset_id': self.asset.id,
'disposal_type': 'sale',
'sale_amount': 4000,
'book_value_at_disposal': 6000,
})
self.assertEqual(d.gain_loss_amount, -2000)
def test_scrap_full_loss(self):
d = self.env['fusion.asset.disposal'].create({
'asset_id': self.asset.id,
'disposal_type': 'scrap',
'sale_amount': 0,
'book_value_at_disposal': 6000,
})
self.assertEqual(d.gain_loss_amount, -6000)
def test_donation_ignores_sale_amount(self):
d = self.env['fusion.asset.disposal'].create({
'asset_id': self.asset.id,
'disposal_type': 'donation',
'sale_amount': 999,
'book_value_at_disposal': 6000,
})
self.assertEqual(d.gain_loss_amount, -6000)

View File

@@ -0,0 +1,115 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.exceptions import ValidationError
@tagged('post_install', '-at_install')
class TestFusionAssetEngine(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.asset.engine']
self.asset = self.env['fusion.asset'].create({
'name': 'Test Engine Asset',
'cost': 10000,
'salvage_value': 1000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
})
def test_engine_model_exists(self):
self.assertIn('fusion.asset.engine', self.env.registry)
def test_compute_schedule_straight_line(self):
result = self.engine.compute_depreciation_schedule(self.asset)
self.assertEqual(result['lines_created'], 5)
lines = self.asset.depreciation_line_ids
self.assertEqual(len(lines), 5)
# Total depreciation should equal cost - salvage = 9000
total = sum(lines.mapped('amount'))
self.assertAlmostEqual(total, 9000, places=2)
def test_compute_schedule_declining_balance(self):
self.asset.write({'method': 'declining_balance', 'declining_rate_pct': 30.0})
self.engine.compute_depreciation_schedule(self.asset)
lines = self.asset.depreciation_line_ids
self.assertGreater(len(lines), 0)
# First-period amount should be cost * rate = 10000 * 0.3 = 3000
first = lines.sorted('period_index')[0]
self.assertAlmostEqual(first.amount, 3000, places=2)
def test_compute_schedule_recompute_wipes_unposted(self):
self.engine.compute_depreciation_schedule(self.asset)
self.asset.write({'useful_life_years': 8})
self.engine.compute_depreciation_schedule(self.asset, recompute=True)
self.assertEqual(len(self.asset.depreciation_line_ids), 8)
def test_compute_schedule_validates_zero_cost(self):
# Bypass DB constraint with sudo + the constraint allows cost >= 0,
# but engine validation requires cost > 0.
bad = self.env['fusion.asset'].create({
'name': 'Zero',
'cost': 0,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
})
with self.assertRaises(ValidationError):
self.engine.compute_depreciation_schedule(bad)
def test_post_depreciation_entry_marks_line_posted(self):
self.engine.compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
result = self.engine.post_depreciation_entry(self.asset)
self.assertEqual(result['posted_count'], 1)
first_line = self.asset.depreciation_line_ids.sorted('period_index')[0]
self.assertTrue(first_line.is_posted)
def test_post_depreciation_only_after_running(self):
self.engine.compute_depreciation_schedule(self.asset)
# asset is still in 'draft' state
with self.assertRaises(ValidationError):
self.engine.post_depreciation_entry(self.asset)
def test_dispose_asset_creates_disposal_record(self):
self.engine.compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
result = self.engine.dispose_asset(
self.asset, sale_amount=5000, sale_date=date(2027, 6, 1),
)
self.assertEqual(self.asset.state, 'disposed')
self.assertIn('disposal_id', result)
self.assertEqual(result['book_value_at_disposal'], self.asset.book_value)
def test_partial_sale_creates_child_and_disposes(self):
self.engine.compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
original_cost = self.asset.cost
result = self.engine.partial_sale(
self.asset, sold_amount=3000, sold_qty=0.3,
sale_date=date(2027, 6, 1),
)
self.assertIn('parent_asset_id', result)
self.assertIn('child_asset_id', result)
self.asset.invalidate_recordset(['cost'])
expected_remaining = round(original_cost * 0.7, 2)
self.assertAlmostEqual(self.asset.cost, expected_remaining, places=2)
def test_pause_resume_round_trip(self):
self.asset.action_set_running()
self.engine.pause_asset(self.asset)
self.assertEqual(self.asset.state, 'paused')
self.engine.resume_asset(self.asset)
self.assertEqual(self.asset.state, 'running')
def test_reverse_disposal_restores_running_state(self):
self.engine.compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
self.engine.dispose_asset(self.asset, sale_amount=5000)
self.assertEqual(self.asset.state, 'disposed')
self.engine.reverse_disposal(self.asset)
self.assertEqual(self.asset.state, 'running')

View File

@@ -0,0 +1,83 @@
"""Local LLM compat smoke test for the useful_life_predictor service.
Auto-detects an LM Studio (port 1234) or Ollama (port 11434) server on
host.docker.internal or localhost. Skips silently when no local LLM is
reachable, so CI runs stay green.
When a server is present, this exercises the real OpenAI-compatible
adapter end-to-end against a local model — i.e. it catches prompt /
JSON-parsing regressions that only show up with a non-mocked LLM.
"""
import socket
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
def _server_reachable(host, port, timeout=1.0):
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except (OSError, socket.timeout):
return False
def _detect_local_llm():
candidates = [
('host.docker.internal', 1234, 'local-model'),
('host.docker.internal', 11434, 'llama3.1:8b'),
('localhost', 1234, 'local-model'),
('localhost', 11434, 'llama3.1:8b'),
]
for host, port, default_model in candidates:
if _server_reachable(host, port, timeout=0.5):
return (f'http://{host}:{port}/v1', default_model)
return (None, None)
@tagged('post_install', '-at_install', 'local_llm')
class TestLocalLLMUsefulLife(TransactionCase):
def setUp(self):
super().setUp()
self.base_url, self.model = _detect_local_llm()
if not self.base_url:
self.skipTest("No local LLM server detected (LM Studio :1234 / Ollama :11434)")
def test_useful_life_with_local_llm(self):
params = self.env['ir.config_parameter'].sudo()
keys = [
'fusion_accounting.openai_base_url',
'fusion_accounting.openai_model',
'fusion_accounting.openai_api_key',
'fusion_accounting.provider.asset_useful_life',
]
prior = {k: params.get_param(k) for k in keys}
params.set_param('fusion_accounting.openai_base_url', self.base_url)
params.set_param('fusion_accounting.openai_model', self.model)
params.set_param('fusion_accounting.openai_api_key', 'lm-studio')
params.set_param('fusion_accounting.provider.asset_useful_life', 'openai')
try:
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
predict_useful_life,
)
result = predict_useful_life(
self.env,
description='Dell laptop',
amount=2500,
partner_name='Dell Canada',
)
self.assertIn('useful_life_years', result)
self.assertIn('depreciation_method', result)
self.assertIsInstance(result['useful_life_years'], (int, float))
self.assertIn(
result['depreciation_method'],
('straight_line', 'declining_balance', 'units_of_production'),
)
finally:
for k, v in prior.items():
if v is not None:
params.set_param(k, v)

View File

@@ -0,0 +1,112 @@
"""Integration tests verifying all 3 depreciation methods through the engine."""
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'integration')
class TestStraightLineIntegration(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.asset.engine']
def test_straight_line_5yr_no_salvage(self):
asset = self.env['fusion.asset'].create({
'name': 'SL Test', 'cost': 10000, 'salvage_value': 0,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 5,
})
self.engine.compute_depreciation_schedule(asset)
lines = asset.depreciation_line_ids.sorted('period_index')
self.assertEqual(len(lines), 5)
for line in lines:
self.assertAlmostEqual(line.amount, 2000, places=2)
def test_straight_line_10yr_with_salvage(self):
asset = self.env['fusion.asset'].create({
'name': 'SL10', 'cost': 50000, 'salvage_value': 5000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 10,
})
self.engine.compute_depreciation_schedule(asset)
lines = asset.depreciation_line_ids.sorted('period_index')
self.assertEqual(len(lines), 10)
# Each year = (50000-5000)/10 = 4500; total depreciable = 45000
self.assertAlmostEqual(sum(lines.mapped('amount')), 45000, places=2)
def test_straight_line_book_value_at_end_equals_salvage(self):
asset = self.env['fusion.asset'].create({
'name': 'SL', 'cost': 10000, 'salvage_value': 1000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 5,
})
self.engine.compute_depreciation_schedule(asset)
last = asset.depreciation_line_ids.sorted('period_index')[-1]
self.assertAlmostEqual(last.book_value_at_end, 1000, places=2)
@tagged('post_install', '-at_install', 'integration')
class TestDecliningBalanceIntegration(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.asset.engine']
def test_declining_balance_30pct(self):
asset = self.env['fusion.asset'].create({
'name': 'DB', 'cost': 10000, 'salvage_value': 1000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'declining_balance', 'useful_life_years': 5,
'declining_rate_pct': 30.0,
})
self.engine.compute_depreciation_schedule(asset)
lines = asset.depreciation_line_ids.sorted('period_index')
# First period: 10000 * 0.30 = 3000
self.assertAlmostEqual(lines[0].amount, 3000, places=2)
# Should not exceed salvage at end
self.assertGreaterEqual(lines[-1].book_value_at_end, 999.99)
def test_declining_balance_50pct_high_rate(self):
asset = self.env['fusion.asset'].create({
'name': 'DB50', 'cost': 8000, 'salvage_value': 500,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'declining_balance', 'useful_life_years': 5,
'declining_rate_pct': 50.0,
})
self.engine.compute_depreciation_schedule(asset)
# First period: 8000 * 0.50 = 4000
first = asset.depreciation_line_ids.sorted('period_index')[0]
self.assertAlmostEqual(first.amount, 4000, places=2)
@tagged('post_install', '-at_install', 'integration')
class TestUnitsOfProductionIntegration(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.asset.engine']
def test_units_of_production_5yr_even_distribution(self):
asset = self.env['fusion.asset'].create({
'name': 'UOP', 'cost': 50000, 'salvage_value': 0,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'units_of_production',
'total_units_expected': 100000,
'useful_life_years': 5,
})
self.engine.compute_depreciation_schedule(asset)
lines = asset.depreciation_line_ids.sorted('period_index')
# 5 periods, even distribution = 20000 units/period
# Each period: (20000/100000) * 50000 = 10000
self.assertEqual(len(lines), 5)
for line in lines:
self.assertAlmostEqual(line.amount, 10000, places=2)

View File

@@ -0,0 +1,24 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAssetsMigrationRoundTrip(TransactionCase):
def test_bootstrap_step_runs_without_enterprise(self):
"""When Enterprise account.asset is NOT installed, step is a no-op."""
wizard = self.env['fusion.migration.wizard'].create({})
result = wizard._assets_bootstrap_step()
self.assertEqual(result['step'], 'assets_bootstrap')
# In our local DB, Enterprise account.asset may or may not exist
# If absent: enterprise_module_present is False
# If present: created>=0
self.assertIn(result['enterprise_module_present'], [True, False])
def test_bootstrap_idempotent_on_re_run(self):
wizard = self.env['fusion.migration.wizard'].create({})
first = wizard._assets_bootstrap_step()
second = wizard._assets_bootstrap_step()
# Second run should skip what the first created (or both no-op)
if first['enterprise_module_present']:
self.assertGreaterEqual(second['skipped'], first['created'])

View File

@@ -0,0 +1,48 @@
from datetime import date
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestPartialSaleWizard(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Partial Sale Test',
'cost': 10000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 5,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
def test_partial_sell_30pct_creates_child(self):
wizard = self.env['fusion.partial.sale.wizard'].create({
'asset_id': self.asset.id,
'sold_pct': 30.0, 'sold_amount': 4000,
'sale_date': date(2026, 6, 1),
})
wizard.action_partial_sell()
self.asset.invalidate_recordset(['cost'])
self.assertAlmostEqual(self.asset.cost, 7000, places=2)
def test_invalid_pct_raises(self):
wizard = self.env['fusion.partial.sale.wizard'].create({
'asset_id': self.asset.id,
'sold_pct': 0, 'sold_amount': 100,
})
with self.assertRaises(UserError):
wizard.action_partial_sell()
def test_compute_estimated_gain_loss(self):
wizard = self.env['fusion.partial.sale.wizard'].new({
'asset_id': self.asset.id,
'sold_pct': 30.0, 'sold_amount': 4000,
})
wizard._compute_sold_cost()
self.assertAlmostEqual(wizard.estimated_sold_cost, 3000, places=2)
self.assertAlmostEqual(wizard.estimated_gain_loss, 1000, places=2)

View File

@@ -0,0 +1,58 @@
"""Controller perf benchmarks tagged 'benchmark'.
Engine-level benchmarks live in test_performance_benchmarks.py (Task 23).
This file targets the JSON-RPC controller surface end-to-end (HTTP request
→ Odoo dispatch → engine → response). It complements Task 23 by catching
regressions introduced by controller / serialization layers, not just the
underlying engine.
"""
import json
import statistics
import time
from datetime import date
from odoo.tests.common import HttpCase, new_test_user
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'benchmark')
class TestAssetsControllerBenchmarks(HttpCase):
def setUp(self):
super().setUp()
for i in range(15):
self.env['fusion.asset'].create({
'name': f'BenchAsset{i}',
'cost': 1000 + i * 100,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
})
def test_get_detail_endpoint_p95(self):
new_test_user(
self.env, login='asset_perf_ctrl',
groups='base.group_user,account.group_account_invoice',
)
asset = self.env['fusion.asset'].search([], limit=1)
self.authenticate('asset_perf_ctrl', 'asset_perf_ctrl')
timings = []
for _ in range(5):
start = time.perf_counter()
response = self.url_open(
'/fusion/assets/get_detail',
data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'id': 1,
'params': {'asset_id': asset.id},
}),
headers={'Content-Type': 'application/json'},
)
timings.append((time.perf_counter() - start) * 1000)
self.assertEqual(response.status_code, 200)
sorted_t = sorted(timings)
p95 = sorted_t[min(int(len(sorted_t) * 0.95), len(sorted_t) - 1)]
median = statistics.median(timings)
msg = f"controller.get_detail: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <500ms)")
self.assertLess(p95, 5000)

View File

@@ -0,0 +1,117 @@
"""Performance benchmarks tagged 'benchmark'."""
import json
import statistics
import time
from datetime import date
from odoo.tests.common import HttpCase, TransactionCase, new_test_user
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'benchmark')
class TestEngineBenchmarks(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.asset.engine']
def _percentile(self, samples, p):
if len(samples) <= 1:
return samples[0] if samples else 0
sorted_s = sorted(samples)
idx = int(len(sorted_s) * p / 100)
return sorted_s[min(idx, len(sorted_s) - 1)]
def test_compute_schedule_p95(self):
timings = []
for i in range(10):
asset = self.env['fusion.asset'].create({
'name': f'PerfAsset{i}', 'cost': 100000, 'salvage_value': 5000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 10,
})
start = time.perf_counter()
self.engine.compute_depreciation_schedule(asset)
timings.append((time.perf_counter() - start) * 1000)
p95 = self._percentile(timings, 95)
median = statistics.median(timings)
msg = f"compute_schedule(10yr): median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <500ms)")
self.assertLess(p95, 5000, f"way over budget: {msg}")
def test_post_depreciation_p95(self):
asset = self.env['fusion.asset'].create({
'name': 'PostPerf', 'cost': 50000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 10,
})
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
timings = []
for _ in range(5):
start = time.perf_counter()
self.engine.post_depreciation_entry(asset)
timings.append((time.perf_counter() - start) * 1000)
p95 = self._percentile(timings, 95)
median = statistics.median(timings)
msg = f"post_depreciation: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <300ms)")
self.assertLess(p95, 3000)
def test_dispose_asset_p95(self):
timings = []
for i in range(5):
asset = self.env['fusion.asset'].create({
'name': f'DispPerf{i}', 'cost': 10000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 5,
})
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
start = time.perf_counter()
self.engine.dispose_asset(asset, sale_amount=5000)
timings.append((time.perf_counter() - start) * 1000)
p95 = self._percentile(timings, 95)
median = statistics.median(timings)
msg = f"dispose_asset: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <300ms)")
self.assertLess(p95, 3000)
@tagged('post_install', '-at_install', 'benchmark')
class TestControllerBenchmarks(HttpCase):
def test_list_endpoint_p95(self):
new_test_user(
self.env, login='asset_perf',
groups='base.group_user,account.group_account_invoice',
)
for i in range(20):
self.env['fusion.asset'].create({
'name': f'ListPerf{i}', 'cost': 1000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
self.authenticate('asset_perf', 'asset_perf')
timings = []
for _ in range(5):
start = time.perf_counter()
response = self.url_open(
'/fusion/assets/list',
data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'id': 1,
'params': {'company_id': self.env.company.id},
}),
headers={'Content-Type': 'application/json'},
)
timings.append((time.perf_counter() - start) * 1000)
self.assertEqual(response.status_code, 200)
sorted_t = sorted(timings)
p95 = sorted_t[min(int(len(sorted_t) * 0.95), len(sorted_t) - 1)]
median = statistics.median(timings)
msg = f"controller.list: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <300ms)")
self.assertLess(p95, 3000)

View File

@@ -0,0 +1,65 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_assets.services.prorate import prorate_factor
@tagged('post_install', '-at_install')
class TestProrate(TransactionCase):
def test_full_month_convention_always_one(self):
f = prorate_factor(
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
asset_start=date(2026, 1, 15),
convention='full_month',
)
self.assertEqual(f, 1.0)
def test_asset_starts_before_period_full_factor(self):
f = prorate_factor(
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
asset_start=date(2025, 12, 1),
convention='days_period',
)
self.assertEqual(f, 1.0)
def test_asset_starts_after_period_zero_factor(self):
f = prorate_factor(
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
asset_start=date(2026, 2, 5),
convention='days_period',
)
self.assertEqual(f, 0.0)
def test_days_period_mid_month(self):
# Jan 16 -> Jan 31 inclusive = 16 days; period = 31 days
f = prorate_factor(
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
asset_start=date(2026, 1, 16),
convention='days_period',
)
self.assertAlmostEqual(f, 16 / 31, places=5)
def test_days_365_mid_month(self):
# 16 days / 365
f = prorate_factor(
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
asset_start=date(2026, 1, 16),
convention='days_365',
)
self.assertAlmostEqual(f, 16 / 365.0, places=5)
def test_unknown_convention_raises(self):
with self.assertRaises(ValueError):
prorate_factor(
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
asset_start=date(2026, 1, 15),
convention='bogus', # type: ignore[arg-type]
)

View File

@@ -0,0 +1,45 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_assets.services.salvage_value import (
SalvageConfig, compute_salvage_value, remaining_useful_life_value,
)
@tagged('post_install', '-at_install')
class TestSalvageValue(TransactionCase):
def test_zero_method_returns_zero(self):
v = compute_salvage_value(cost=10000, config=SalvageConfig(method='zero'))
self.assertEqual(v, 0.0)
def test_percentage_method(self):
v = compute_salvage_value(
cost=10000, config=SalvageConfig(method='percentage', value=10),
)
self.assertAlmostEqual(v, 1000.0, places=2)
def test_fixed_method(self):
v = compute_salvage_value(
cost=10000, config=SalvageConfig(method='fixed', value=750),
)
self.assertAlmostEqual(v, 750.0, places=2)
def test_unknown_method_raises(self):
with self.assertRaises(ValueError):
compute_salvage_value(
cost=10000,
config=SalvageConfig(method='bogus', value=0), # type: ignore[arg-type]
)
def test_remaining_useful_life_value_midway(self):
# Halfway through life; current book 6000, salvage 1000 -> 1000 + 5000*0.5 = 3500
v = remaining_useful_life_value(
current_book=6000, salvage=1000, periods_used=5, total_periods=10,
)
self.assertAlmostEqual(v, 3500.0, places=2)
def test_remaining_useful_life_value_at_end_returns_salvage(self):
v = remaining_useful_life_value(
current_book=1200, salvage=1000, periods_used=10, total_periods=10,
)
self.assertEqual(v, 1000.0)

View File

@@ -0,0 +1,61 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
predict_useful_life,
)
from odoo.addons.fusion_accounting_assets.services.useful_life_prompt import (
SYSTEM_PROMPT, build_prompt,
)
@tagged('post_install', '-at_install')
class TestUsefulLifePredictor(TransactionCase):
def setUp(self):
super().setUp()
# Ensure no provider configured for these fallback tests.
self.env['ir.config_parameter'].sudo().search([
('key', 'in', [
'fusion_accounting.provider.asset_useful_life',
'fusion_accounting.provider.default',
])
]).unlink()
def test_fallback_computer(self):
result = predict_useful_life(self.env, description="Dell laptop")
self.assertEqual(result['useful_life_years'], 4)
self.assertEqual(result['depreciation_method'], 'straight_line')
def test_fallback_furniture(self):
result = predict_useful_life(self.env, description="office desk")
self.assertEqual(result['useful_life_years'], 7)
def test_fallback_vehicle_uses_declining(self):
result = predict_useful_life(self.env, description="Ford F-150 truck")
self.assertEqual(result['useful_life_years'], 5)
self.assertEqual(result['depreciation_method'], 'declining_balance')
def test_fallback_default_for_unknown(self):
result = predict_useful_life(self.env, description="mystery widget")
self.assertEqual(result['useful_life_years'], 5)
self.assertEqual(result['confidence'], 0.3)
def test_returns_dict_with_required_keys(self):
result = predict_useful_life(self.env, description="server")
for key in ('useful_life_years', 'depreciation_method', 'rationale', 'confidence'):
self.assertIn(key, result)
@tagged('post_install', '-at_install')
class TestUsefulLifePrompt(TransactionCase):
def test_system_prompt_requires_json(self):
self.assertIn('JSON', SYSTEM_PROMPT)
def test_build_prompt_returns_tuple(self):
result = build_prompt(description='test')
self.assertEqual(len(result), 2)
def test_user_prompt_includes_amount(self):
_, user = build_prompt(description='laptop', amount=2000)
self.assertIn('2,000', user)

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top-level menu (visible only when account_asset Enterprise NOT installed) -->
<menuitem id="menu_fusion_assets_root"
name="Asset Management"
sequence="60"
web_icon="fusion_accounting_assets,static/description/icon.png"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Asset list/form -->
<record id="action_fusion_asset_list" model="ir.actions.act_window">
<field name="name">Assets</field>
<field name="res_model">fusion.asset</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Manage your fixed assets
</p>
<p>
Track depreciation, post periodic entries, dispose assets at end-of-life.
AI augmentation: anomaly detection + suggested useful life.
</p>
</field>
</record>
<menuitem id="menu_fusion_assets_list"
name="Assets"
parent="menu_fusion_assets_root"
action="action_fusion_asset_list"
sequence="10"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Categories -->
<record id="action_fusion_asset_category_list" model="ir.actions.act_window">
<field name="name">Asset Categories</field>
<field name="res_model">fusion.asset.category</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_asset_categories"
name="Categories"
parent="menu_fusion_assets_root"
action="action_fusion_asset_category_list"
sequence="20"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Anomalies -->
<record id="action_fusion_asset_anomaly_list" model="ir.actions.act_window">
<field name="name">Asset Anomalies</field>
<field name="res_model">fusion.asset.anomaly</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_asset_anomalies"
name="Anomalies"
parent="menu_fusion_assets_root"
action="action_fusion_asset_anomaly_list"
sequence="30"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Run depreciation -->
<menuitem id="menu_fusion_assets_run_depreciation"
name="Run Depreciation..."
parent="menu_fusion_assets_root"
action="action_fusion_depreciation_run_wizard"
sequence="40"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
</odoo>

View File

@@ -0,0 +1,4 @@
from . import create_asset_wizard
from . import disposal_wizard
from . import partial_sale_wizard
from . import depreciation_run_wizard

View File

@@ -0,0 +1,133 @@
"""Create-asset-from-invoice-line wizard.
Reads an account.move.line as the source, pre-fills name/cost/category,
and optionally calls the AI useful-life predictor for suggestions."""
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from ..services.useful_life_predictor import predict_useful_life
class FusionCreateAssetWizard(models.TransientModel):
_name = "fusion.create.asset.wizard"
_description = "Create Fusion Asset from Invoice Line"
source_invoice_line_id = fields.Many2one(
'account.move.line', string='Source Invoice Line',
default=lambda self: self._default_source_line(),
)
name = fields.Char(required=True)
cost = fields.Monetary(required=True)
salvage_value = fields.Monetary(default=0.0)
currency_id = fields.Many2one(
'res.currency', required=True,
default=lambda self: self.env.company.currency_id,
)
category_id = fields.Many2one('fusion.asset.category')
method = fields.Selection([
('straight_line', 'Straight Line'),
('declining_balance', 'Declining Balance'),
('units_of_production', 'Units of Production'),
], required=True, default='straight_line')
useful_life_years = fields.Integer(default=5)
declining_rate_pct = fields.Float(default=20.0)
acquisition_date = fields.Date(required=True, default=fields.Date.today)
in_service_date = fields.Date(default=fields.Date.today)
ai_suggested_years = fields.Integer(readonly=True)
ai_suggested_method = fields.Char(readonly=True)
ai_rationale = fields.Text(readonly=True)
ai_confidence = fields.Float(readonly=True)
@api.model
def _default_source_line(self):
ctx = self.env.context
if ctx.get('active_model') == 'account.move.line':
return ctx.get('active_id')
return False
@api.onchange('source_invoice_line_id')
def _onchange_source_invoice_line_id(self):
if not self.source_invoice_line_id:
return
line = self.source_invoice_line_id
if not self.name:
self.name = line.name or line.move_id.name or 'New Asset'
if not self.cost:
self.cost = abs(line.balance) if line.balance else (line.price_unit * line.quantity)
if line.currency_id and not self.currency_id:
self.currency_id = line.currency_id
@api.onchange('category_id')
def _onchange_category_id(self):
if self.category_id:
self.method = self.category_id.method
self.useful_life_years = self.category_id.useful_life_years
self.declining_rate_pct = self.category_id.declining_rate_pct
if self.category_id.salvage_value_pct and self.cost:
self.salvage_value = round(
self.cost * self.category_id.salvage_value_pct / 100, 2)
def action_ai_suggest(self):
"""Call AI useful-life predictor."""
self.ensure_one()
if not self.name and not self.source_invoice_line_id:
raise UserError(_("Need a name or source invoice line first."))
description = self.name
if self.source_invoice_line_id and self.source_invoice_line_id.name:
description = self.source_invoice_line_id.name
partner_name = None
if self.source_invoice_line_id and self.source_invoice_line_id.partner_id:
partner_name = self.source_invoice_line_id.partner_id.name
suggestion = predict_useful_life(
self.env, description=description,
amount=self.cost, partner_name=partner_name,
)
self.write({
'ai_suggested_years': suggestion.get('useful_life_years'),
'ai_suggested_method': suggestion.get('depreciation_method'),
'ai_rationale': suggestion.get('rationale'),
'ai_confidence': suggestion.get('confidence'),
'useful_life_years': suggestion.get('useful_life_years'),
'method': suggestion.get('depreciation_method'),
})
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_create_asset(self):
"""Create the fusion.asset record + link to source invoice line."""
self.ensure_one()
if not self.cost:
raise UserError(_("Cost is required."))
Asset = self.env['fusion.asset']
asset = Asset.create({
'name': self.name,
'cost': self.cost,
'salvage_value': self.salvage_value,
'currency_id': self.currency_id.id,
'category_id': self.category_id.id if self.category_id else False,
'method': self.method,
'useful_life_years': self.useful_life_years,
'declining_rate_pct': self.declining_rate_pct,
'acquisition_date': self.acquisition_date,
'in_service_date': self.in_service_date,
'source_invoice_line_id': self.source_invoice_line_id.id if self.source_invoice_line_id else False,
'company_id': self.env.company.id,
})
if self.source_invoice_line_id:
self.source_invoice_line_id.fusion_asset_id = asset.id
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.asset',
'res_id': asset.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_create_asset_wizard_form" model="ir.ui.view">
<field name="name">fusion.create.asset.wizard.form</field>
<field name="model">fusion.create.asset.wizard</field>
<field name="arch" type="xml">
<form string="Create Fixed Asset">
<group>
<group string="Basics">
<field name="name"/>
<field name="source_invoice_line_id"
options="{'no_create': True}"
readonly="source_invoice_line_id"/>
<field name="cost"/>
<field name="currency_id" invisible="1"/>
<field name="salvage_value"/>
<field name="category_id" options="{'no_create': True}"/>
</group>
<group string="Depreciation">
<field name="method"/>
<field name="useful_life_years"
invisible="method == 'units_of_production'"/>
<field name="declining_rate_pct"
invisible="method != 'declining_balance'"/>
<field name="acquisition_date"/>
<field name="in_service_date"/>
</group>
</group>
<group string="AI Suggestion" invisible="not ai_suggested_years">
<field name="ai_suggested_years" readonly="1"/>
<field name="ai_suggested_method" readonly="1"/>
<field name="ai_rationale" readonly="1"/>
<field name="ai_confidence" readonly="1"/>
</group>
<footer>
<button name="action_ai_suggest" type="object"
string="AI Suggest" class="btn-secondary"/>
<button name="action_create_asset" type="object"
string="Create Asset" class="btn-primary"/>
<button special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_create_asset_wizard" model="ir.actions.act_window">
<field name="name">Create Asset from Invoice</field>
<field name="res_model">fusion.create.asset.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="account.model_account_move_line"/>
<field name="binding_view_types">list</field>
</record>
</odoo>

View File

@@ -0,0 +1,72 @@
"""Manual depreciation run wizard.
Operator picks a period_date and the wizard posts all running assets'
unposted lines whose scheduled_date <= period_date."""
from odoo import fields, models
class FusionDepreciationRunWizard(models.TransientModel):
_name = "fusion.depreciation.run.wizard"
_description = "Manual Depreciation Run Wizard"
period_date = fields.Date(
required=True, default=fields.Date.today,
help="Post all unposted lines whose scheduled_date is on or before this date.",
)
state_filter = fields.Selection([
('all_running', 'All Running Assets'),
('selected', 'Selected Asset(s) Only'),
], default='all_running', required=True)
asset_ids = fields.Many2many(
'fusion.asset', domain=[('state', '=', 'running')],
)
state = fields.Selection(
[('draft', 'Draft'), ('done', 'Done')], default='draft',
)
posted_count = fields.Integer(readonly=True)
skipped_count = fields.Integer(readonly=True)
error_count = fields.Integer(readonly=True)
summary = fields.Text(readonly=True)
def action_run(self):
self.ensure_one()
if self.state_filter == 'all_running':
assets = self.env['fusion.asset'].search([
('state', '=', 'running'),
('company_id', '=', self.env.company.id),
])
else:
assets = self.asset_ids
engine = self.env['fusion.asset.engine']
posted = 0
skipped = 0
errors = []
for asset in assets:
try:
with self.env.cr.savepoint():
result = engine.post_depreciation_entry(
asset, period_date=self.period_date,
)
posted += result.get('posted_count', 0)
if result.get('posted_count', 0) == 0:
skipped += 1
except Exception as e: # noqa: BLE001
errors.append(f"{asset.name}: {e}")
self.write({
'state': 'done',
'posted_count': posted,
'skipped_count': skipped,
'error_count': len(errors),
'summary': '\n'.join(errors[:20]) if errors else False,
})
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_depreciation_run_wizard_form" model="ir.ui.view">
<field name="name">fusion.depreciation.run.wizard.form</field>
<field name="model">fusion.depreciation.run.wizard</field>
<field name="arch" type="xml">
<form string="Run Depreciation">
<group invisible="state == 'done'">
<field name="period_date"/>
<field name="state_filter" widget="radio"/>
<field name="asset_ids" widget="many2many_tags"
invisible="state_filter != 'selected'"/>
</group>
<group invisible="state != 'done'" string="Results">
<field name="posted_count"/>
<field name="skipped_count"/>
<field name="error_count"/>
<field name="summary"/>
</group>
<field name="state" invisible="1"/>
<footer>
<button name="action_run" type="object" string="Run"
class="btn-primary" invisible="state == 'done'"/>
<button special="cancel" string="Close"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_depreciation_run_wizard" model="ir.actions.act_window">
<field name="name">Run Depreciation</field>
<field name="res_model">fusion.depreciation.run.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

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