From 70c951a7ef40bb43a6d339014c613f4ad2193148 Mon Sep 17 00:00:00 2001 From: tocmo0nlord Date: Fri, 29 May 2026 00:26:00 +0000 Subject: [PATCH] Add timesheet / AI-audit time tracking (wraps account.analytic.line) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- activeblue_familylaw/__manifest__.py | 2 + activeblue_familylaw/models/__init__.py | 1 + .../models/fl_attorney_agent.py | 19 +++ activeblue_familylaw/models/fl_case.py | 38 ++++++ .../models/fl_paralegal_agent.py | 2 +- activeblue_familylaw/models/fl_timesheet.py | 129 ++++++++++++++++++ .../security/ir.model.access.csv | 6 + activeblue_familylaw/views/fl_case_views.xml | 28 ++++ .../views/fl_timesheet_views.xml | 84 ++++++++++++ activeblue_familylaw/views/menu_views.xml | 7 + 10 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 activeblue_familylaw/models/fl_timesheet.py create mode 100644 activeblue_familylaw/views/fl_timesheet_views.xml diff --git a/activeblue_familylaw/__manifest__.py b/activeblue_familylaw/__manifest__.py index 9a24f90..097d930 100644 --- a/activeblue_familylaw/__manifest__.py +++ b/activeblue_familylaw/__manifest__.py @@ -23,6 +23,7 @@ 'crm', 'account', 'hr_expense', + 'analytic', # 'sign', # Odoo Sign — enable when confirmed installed # 'queue_job', # OCA queue_job — install from https://github.com/OCA/queue ], @@ -56,6 +57,7 @@ 'views/fl_wizard_views.xml', 'views/fl_discovery_suggest_views.xml', 'views/fl_conflict_check_views.xml', + 'views/fl_timesheet_views.xml', 'views/menu_views.xml', # Phase 4 — QWeb PDF Reports 'report/report_financial_affidavit_short.xml', diff --git a/activeblue_familylaw/models/__init__.py b/activeblue_familylaw/models/__init__.py index 6c20caa..6b60232 100644 --- a/activeblue_familylaw/models/__init__.py +++ b/activeblue_familylaw/models/__init__.py @@ -18,3 +18,4 @@ from . import fl_case from . import fl_conflict_check from . import fl_paralegal_agent from . import fl_attorney_agent +from . import fl_timesheet diff --git a/activeblue_familylaw/models/fl_attorney_agent.py b/activeblue_familylaw/models/fl_attorney_agent.py index a07e911..c3b40e7 100644 --- a/activeblue_familylaw/models/fl_attorney_agent.py +++ b/activeblue_familylaw/models/fl_attorney_agent.py @@ -58,6 +58,7 @@ class FlAttorneyAgent(models.AbstractModel): statutes = self._candidate_statutes(case) caselaw = self._candidate_caselaw(case) + ai_used = False try: context = self._build_context(case, statutes, caselaw) result = self.env['fl.ai.engine'].call_claude_json( @@ -69,13 +70,31 @@ class FlAttorneyAgent(models.AbstractModel): max_tokens=3000, ) self._store_memo(case, analysis, result, statutes, caselaw) + ai_used = True except Exception as exc: _logger.error("Attorney agent failed for case %s: %s", case.id, exc, exc_info=True) self._store_fallback(case, analysis, statutes, caselaw, str(exc)) + self._log_ai_time(case, 'Attorney strategy memo', ai_used) return analysis + def _log_ai_time(self, case, note, ai_used): + """Log a non-billable AI audit entry. No-op until fl.timesheet exists.""" + if 'fl.timesheet' not in self.env: + return + try: + self.env['fl.timesheet'].sudo().create({ + 'case_id': case.id, + 'name': note, + 'is_billable': False, + 'ai_agent': 'attorney', + 'duration_hours': 0.1 if ai_used else 0.02, + }) + except Exception as exc: # never block on audit logging + _logger.warning("Attorney AI-time logging skipped for case %s: %s", + case.id, exc) + # ────────────────────────────────────────────────────────────────────── # Candidate library (grounds the model in real records) # ────────────────────────────────────────────────────────────────────── diff --git a/activeblue_familylaw/models/fl_case.py b/activeblue_familylaw/models/fl_case.py index 2f2ab4c..f5fdf07 100644 --- a/activeblue_familylaw/models/fl_case.py +++ b/activeblue_familylaw/models/fl_case.py @@ -472,6 +472,28 @@ class FlCase(models.Model): compute='_compute_total_expenses', store=True ) + # ══════════════════════════════════════════════════════════════════════ + # TIME & BILLING + # ══════════════════════════════════════════════════════════════════════ + + currency_id = fields.Many2one( + 'res.currency', string='Currency', + default=lambda self: self.env.company.currency_id + ) + timesheet_ids = fields.One2many( + 'fl.timesheet', 'case_id', string='Timesheet Entries' + ) + total_billable_hours = fields.Float( + string='Billable Hours', compute='_compute_timesheet_totals' + ) + total_billable_amount = fields.Monetary( + string='Billable Amount', compute='_compute_timesheet_totals', + currency_field='currency_id' + ) + total_ai_audit_hours = fields.Float( + string='AI Audit Hours', compute='_compute_timesheet_totals' + ) + # ══════════════════════════════════════════════════════════════════════ # POST-ORDER # ══════════════════════════════════════════════════════════════════════ @@ -804,6 +826,22 @@ class FlCase(models.Model): for rec in self: rec.total_expenses = sum(rec.expense_ids.mapped('total_amount')) + @api.depends( + 'timesheet_ids.duration_hours', + 'timesheet_ids.is_billable', + 'timesheet_ids.billable_amount', + 'timesheet_ids.ai_agent', + ) + def _compute_timesheet_totals(self): + for rec in self: + billable = rec.timesheet_ids.filtered(lambda t: t.is_billable) + rec.total_billable_hours = sum(billable.mapped('duration_hours')) + rec.total_billable_amount = sum(billable.mapped('billable_amount')) + rec.total_ai_audit_hours = sum( + rec.timesheet_ids.filtered(lambda t: t.ai_agent) + .mapped('duration_hours') + ) + @api.depends('filing_date') def _compute_retroactivity_date(self): """ diff --git a/activeblue_familylaw/models/fl_paralegal_agent.py b/activeblue_familylaw/models/fl_paralegal_agent.py index f6a8963..a3954a2 100644 --- a/activeblue_familylaw/models/fl_paralegal_agent.py +++ b/activeblue_familylaw/models/fl_paralegal_agent.py @@ -311,7 +311,7 @@ class FlParalegalAgent(models.AbstractModel): if 'fl.timesheet' not in self.env: return try: - self.env['fl.timesheet'].create({ + self.env['fl.timesheet'].sudo().create({ 'case_id': case.id, 'name': note, 'is_billable': False, diff --git a/activeblue_familylaw/models/fl_timesheet.py b/activeblue_familylaw/models/fl_timesheet.py new file mode 100644 index 0000000..eecaec8 --- /dev/null +++ b/activeblue_familylaw/models/fl_timesheet.py @@ -0,0 +1,129 @@ +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 diff --git a/activeblue_familylaw/security/ir.model.access.csv b/activeblue_familylaw/security/ir.model.access.csv index 358f772..195438e 100644 --- a/activeblue_familylaw/security/ir.model.access.csv +++ b/activeblue_familylaw/security/ir.model.access.csv @@ -81,6 +81,12 @@ access_fl_income_withholding_petitioner,fl.income.withholding petitioner,model_f # ── fl.conflict.check ──────────────────────────────────────────────────────── access_fl_conflict_check_admin,fl.conflict.check admin,model_fl_conflict_check,group_admin,1,1,1,1 access_fl_conflict_check_paralegal,fl.conflict.check paralegal,model_fl_conflict_check,group_paralegal,1,1,1,0 +# ── fl.timesheet ───────────────────────────────────────────────────────────── +access_fl_timesheet_admin,fl.timesheet admin,model_fl_timesheet,group_admin,1,1,1,1 +access_fl_timesheet_paralegal,fl.timesheet paralegal,model_fl_timesheet,group_paralegal,1,1,1,0 +# ── account.analytic.line (timesheet wraps it — ensure non-admins can post) ─── +access_account_analytic_line_fl_admin,account.analytic.line fl admin,analytic.model_account_analytic_line,group_admin,1,1,1,1 +access_account_analytic_line_fl_paralegal,account.analytic.line fl paralegal,analytic.model_account_analytic_line,group_paralegal,1,1,1,0 # ── fl.intake.wizard ───────────────────────────────────────────────────────── access_fl_intake_wizard_admin,fl.intake.wizard admin,model_fl_intake_wizard,group_admin,1,1,1,1 access_fl_intake_wizard_paralegal,fl.intake.wizard paralegal,model_fl_intake_wizard,group_paralegal,1,1,1,1 diff --git a/activeblue_familylaw/views/fl_case_views.xml b/activeblue_familylaw/views/fl_case_views.xml index 2e97793..268f3a7 100644 --- a/activeblue_familylaw/views/fl_case_views.xml +++ b/activeblue_familylaw/views/fl_case_views.xml @@ -310,6 +310,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/activeblue_familylaw/views/fl_timesheet_views.xml b/activeblue_familylaw/views/fl_timesheet_views.xml new file mode 100644 index 0000000..dbba120 --- /dev/null +++ b/activeblue_familylaw/views/fl_timesheet_views.xml @@ -0,0 +1,84 @@ + + + + + + fl.timesheet.tree + fl.timesheet + + + + + + + + + + + + + + + + + + fl.timesheet.form + fl.timesheet + +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + fl.timesheet.search + fl.timesheet + + + + + + + + + + + + + + + + + Timesheets + fl.timesheet + tree,form + + {'search_default_billable': 1} + + +
+
diff --git a/activeblue_familylaw/views/menu_views.xml b/activeblue_familylaw/views/menu_views.xml index 21adf65..f5c8d1a 100644 --- a/activeblue_familylaw/views/menu_views.xml +++ b/activeblue_familylaw/views/menu_views.xml @@ -94,6 +94,13 @@ action="action_fl_conflict_check_list" sequence="70"/> + +