- fl.timesheet via delegation inheritance on account.analytic.line so billable hours flow through standard Odoo Accounting; duration_hours maps to unit_amount - Fields: case_id, employee_id, is_billable, ai_agent, duration_hours, computed hourly_rate/billable_amount (rate from hr.employee.fl_hourly_rate, else firm default ir.config_parameter fl_timesheet.default_hourly_rate) - _resolve_analytic_account: prefers the case project's analytic account (version-agnostic field lookup), falls back to a cached firm account under any available analytic plan — handles the required account_id on the wrapped line - Add 'analytic' to manifest depends; ACL for fl.timesheet and account.analytic.line (admin + paralegal) so non-admins can post entries - fl.case: timesheet_ids + total_billable_hours/amount + total_ai_audit_hours + currency_id; new Time & Billing tab; Timesheets menu + standalone views - Both AI agents now log non-billable audit entries via sudo() (paralegal + attorney, ai_agent set); logging stays a guarded no-op if creation fails Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
130 lines
5.3 KiB
Python
130 lines
5.3 KiB
Python
from odoo import api, fields, models
|
|
|
|
|
|
class HrEmployee(models.Model):
|
|
_inherit = 'hr.employee'
|
|
|
|
fl_hourly_rate = fields.Float(
|
|
string='Family Law Billing Rate ($/hr)',
|
|
help='Hourly billing rate used for family-law case timesheets. '
|
|
'Falls back to the firm default (ir.config_parameter '
|
|
'fl_timesheet.default_hourly_rate) when unset.'
|
|
)
|
|
|
|
|
|
class FlTimesheet(models.Model):
|
|
"""
|
|
Family-law timesheet / AI audit entry.
|
|
|
|
Wraps account.analytic.line via delegation inheritance: each fl.timesheet
|
|
owns one analytic line, so billable hours flow through standard Odoo
|
|
Accounting. duration_hours maps to the analytic line's unit_amount.
|
|
AI agents create non-billable entries (is_billable=False, ai_agent set) for
|
|
an audit trail; people create billable entries from the case Timesheet tab.
|
|
"""
|
|
_name = 'fl.timesheet'
|
|
_description = 'Family Law Timesheet / AI Audit Entry'
|
|
_inherits = {'account.analytic.line': 'analytic_line_id'}
|
|
_order = 'date desc, id desc'
|
|
|
|
analytic_line_id = fields.Many2one(
|
|
'account.analytic.line', string='Analytic Line',
|
|
required=True, ondelete='cascade', auto_join=True, index=True
|
|
)
|
|
case_id = fields.Many2one(
|
|
'fl.case', string='Case', required=True, ondelete='cascade', index=True
|
|
)
|
|
employee_id = fields.Many2one('hr.employee', string='Employee')
|
|
is_billable = fields.Boolean(
|
|
string='Billable', default=True,
|
|
help='Uncheck for non-billable entries (e.g. AI audit time).'
|
|
)
|
|
ai_agent = fields.Selection([
|
|
('paralegal', 'Paralegal Agent'),
|
|
('attorney', 'Attorney Agent'),
|
|
], string='AI Agent',
|
|
help='Set when an AI agent auto-logged this entry for the audit trail.')
|
|
duration_hours = fields.Float(string='Duration (Hours)', default=0.0)
|
|
|
|
hourly_rate = fields.Float(
|
|
string='Rate ($/hr)', compute='_compute_billable_amount'
|
|
)
|
|
billable_amount = fields.Monetary(
|
|
string='Amount', compute='_compute_billable_amount',
|
|
currency_field='currency_id'
|
|
)
|
|
|
|
@api.depends('duration_hours', 'is_billable', 'employee_id')
|
|
def _compute_billable_amount(self):
|
|
default_rate = float(self.env['ir.config_parameter'].sudo().get_param(
|
|
'fl_timesheet.default_hourly_rate', 0.0) or 0.0)
|
|
for rec in self:
|
|
rate = rec.employee_id.fl_hourly_rate or default_rate
|
|
rec.hourly_rate = rate
|
|
rec.billable_amount = (
|
|
rec.duration_hours * rate if rec.is_billable else 0.0
|
|
)
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# CRUD — keep the wrapped analytic line in sync
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
for vals in vals_list:
|
|
self._prepare_line_vals(vals)
|
|
return super().create(vals_list)
|
|
|
|
def write(self, vals):
|
|
if 'duration_hours' in vals:
|
|
vals['unit_amount'] = vals['duration_hours']
|
|
return super().write(vals)
|
|
|
|
def _prepare_line_vals(self, vals):
|
|
"""Fill the analytic line's required fields before delegation create."""
|
|
case = self.env['fl.case'].browse(vals['case_id']) if vals.get('case_id') \
|
|
else self.env['fl.case']
|
|
if not vals.get('account_id'):
|
|
vals['account_id'] = self._resolve_analytic_account(case).id
|
|
if not vals.get('name'):
|
|
if vals.get('ai_agent'):
|
|
label = dict(self._fields['ai_agent'].selection).get(
|
|
vals['ai_agent'], vals['ai_agent'])
|
|
vals['name'] = f'AI audit: {label}'
|
|
else:
|
|
vals['name'] = case.name or 'Time entry'
|
|
if 'duration_hours' in vals:
|
|
vals['unit_amount'] = vals['duration_hours']
|
|
|
|
def _resolve_analytic_account(self, case):
|
|
"""
|
|
Resolve the analytic account for the wrapped line. Prefers the case
|
|
project's analytic account (field name varies by version), falling back
|
|
to a cached firm-wide account created under any available analytic plan.
|
|
"""
|
|
Account = self.env['account.analytic.account'].sudo()
|
|
|
|
project = case.project_id if case else self.env['project.project']
|
|
if project:
|
|
for fname in ('account_id', 'analytic_account_id'):
|
|
acc = getattr(project, fname, False)
|
|
if acc:
|
|
return acc
|
|
|
|
param = self.env['ir.config_parameter'].sudo()
|
|
acc_id = param.get_param('fl_timesheet.analytic_account_id')
|
|
if acc_id:
|
|
acc = Account.browse(int(acc_id))
|
|
if acc.exists():
|
|
return acc
|
|
|
|
Plan = self.env['account.analytic.plan'].sudo()
|
|
plan = Plan.search([], limit=1) or Plan.create({'name': 'Family Law'})
|
|
acc = Account.create({
|
|
'name': 'Family Law Cases',
|
|
'plan_id': plan.id,
|
|
'company_id': self.env.company.id,
|
|
})
|
|
param.set_param('fl_timesheet.analytic_account_id', acc.id)
|
|
return acc
|