changes
This commit is contained in:
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user