Compare commits
6 Commits
fdfaf7e779
...
bc7ba27d77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc7ba27d77 | ||
|
|
19cbed5b37 | ||
|
|
b7c171f983 | ||
|
|
bece120ee3 | ||
|
|
3e73ca0eb7 | ||
|
|
99b6990dd6 |
165
fusion_accounting/PHASE_3_PLAN.md
Normal file
165
fusion_accounting/PHASE_3_PLAN.md
Normal 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.
|
||||
1
fusion_accounting_assets/__init__.py
Normal file
1
fusion_accounting_assets/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import services
|
||||
44
fusion_accounting_assets/__manifest__.py
Normal file
44
fusion_accounting_assets/__manifest__.py
Normal 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',
|
||||
}
|
||||
0
fusion_accounting_assets/controllers/__init__.py
Normal file
0
fusion_accounting_assets/controllers/__init__.py
Normal file
0
fusion_accounting_assets/models/__init__.py
Normal file
0
fusion_accounting_assets/models/__init__.py
Normal file
0
fusion_accounting_assets/reports/__init__.py
Normal file
0
fusion_accounting_assets/reports/__init__.py
Normal file
1
fusion_accounting_assets/security/ir.model.access.csv
Normal file
1
fusion_accounting_assets/security/ir.model.access.csv
Normal file
@@ -0,0 +1 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
6
fusion_accounting_assets/services/__init__.py
Normal file
6
fusion_accounting_assets/services/__init__.py
Normal 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
|
||||
96
fusion_accounting_assets/services/anomaly_detection.py
Normal file
96
fusion_accounting_assets/services/anomaly_detection.py
Normal 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",
|
||||
)
|
||||
116
fusion_accounting_assets/services/depreciation_methods.py
Normal file
116
fusion_accounting_assets/services/depreciation_methods.py
Normal 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
|
||||
34
fusion_accounting_assets/services/prorate.py
Normal file
34
fusion_accounting_assets/services/prorate.py
Normal 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}")
|
||||
38
fusion_accounting_assets/services/salvage_value.py
Normal file
38
fusion_accounting_assets/services/salvage_value.py
Normal 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)
|
||||
94
fusion_accounting_assets/services/useful_life_predictor.py
Normal file
94
fusion_accounting_assets/services/useful_life_predictor.py
Normal 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
|
||||
48
fusion_accounting_assets/services/useful_life_prompt.py
Normal file
48
fusion_accounting_assets/services/useful_life_prompt.py
Normal 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))
|
||||
BIN
fusion_accounting_assets/static/description/icon.png
Normal file
BIN
fusion_accounting_assets/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
5
fusion_accounting_assets/tests/__init__.py
Normal file
5
fusion_accounting_assets/tests/__init__.py
Normal 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
|
||||
@@ -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')
|
||||
88
fusion_accounting_assets/tests/test_depreciation_methods.py
Normal file
88
fusion_accounting_assets/tests/test_depreciation_methods.py
Normal 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)
|
||||
65
fusion_accounting_assets/tests/test_prorate.py
Normal file
65
fusion_accounting_assets/tests/test_prorate.py
Normal 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]
|
||||
)
|
||||
45
fusion_accounting_assets/tests/test_salvage_value.py
Normal file
45
fusion_accounting_assets/tests/test_salvage_value.py
Normal 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)
|
||||
61
fusion_accounting_assets/tests/test_useful_life_predictor.py
Normal file
61
fusion_accounting_assets/tests/test_useful_life_predictor.py
Normal 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)
|
||||
0
fusion_accounting_assets/wizards/__init__.py
Normal file
0
fusion_accounting_assets/wizards/__init__.py
Normal file
Reference in New Issue
Block a user