6 Commits

Author SHA1 Message Date
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
22 changed files with 978 additions and 0 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 @@
from . import services

View File

@@ -0,0 +1,44 @@
{
'name': 'Fusion Accounting Assets',
'version': '19.0.1.0.5',
'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',
'account',
],
'data': [
'security/ir.model.access.csv',
],
'assets': {
'web.assets_backend': [
],
},
'installable': True,
'auto_install': False,
'application': False,
'icon': '/fusion_accounting_assets/static/description/icon.png',
}

View File

@@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink

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

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,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,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)