diff --git a/.cursor/rules/fusion-api-integration.mdc b/.cursor/rules/fusion-api-integration.mdc new file mode 100644 index 00000000..b044414d --- /dev/null +++ b/.cursor/rules/fusion-api-integration.mdc @@ -0,0 +1,135 @@ +--- +description: Use the Fusion API module for all API calls (OpenAI, Anthropic, Google Maps, Twilio, OAuth) in any Fusion module +globs: fusion_*/models/**/*.py, fusion_*/services/**/*.py +alwaysApply: false +--- + +# Fusion API Integration Guide + +When any Fusion module needs to call an external API (OpenAI, Anthropic, Google Maps, Twilio, Google/Microsoft OAuth), it MUST use the centralized `fusion.api.service` instead of managing its own keys. + +The service lives at `fusion_api/models/api_service.py` and is an AbstractModel accessible via `self.env['fusion.api.service']`. + +## Available Methods + +### 1. `call_openai()` -- AI text generation via OpenAI + +```python +result = self.env['fusion.api.service'].call_openai( + consumer='fusion_clock_ai', # your module's technical name + feature='timesheet_summary', # descriptive feature name for tracking + messages=[ + {'role': 'system', 'content': 'You are a helpful assistant.'}, + {'role': 'user', 'content': 'Summarize this timesheet...'}, + ], + model='gpt-4o-mini', # optional, defaults to gpt-4o-mini + max_tokens=1024, # optional, defaults to 1024 + user=self.env.user, # optional, defaults to current user +) +# Returns: str (the text response) +``` + +### 2. `call_anthropic()` -- AI text generation via Claude + +```python +result = self.env['fusion.api.service'].call_anthropic( + consumer='fusion_notes', + feature='voice_transcription', + messages=[ + {'role': 'user', 'content': 'Transcribe this text...'}, + ], + system='You are a medical transcription assistant.', # optional system prompt + model='claude-sonnet-4-20250514', # optional, defaults to claude-sonnet-4-20250514 + max_tokens=1024, # optional +) +# Returns: str (the text response) +``` + +### 3. `get_api_key()` -- raw API key for non-AI services + +```python +api_key = self.env['fusion.api.service'].get_api_key( + provider_type='google_maps', # one of: google_maps, twilio, custom + consumer='fusion_shipping', # your module's technical name + feature='geocoding', # optional feature name +) +# Returns: str (the raw API key to use in your own HTTP calls) +``` + +### 4. `get_oauth_credentials()` -- OAuth tokens for Google/Microsoft + +```python +creds = self.env['fusion.api.service'].get_oauth_credentials( + provider_type='google_oauth', # google_oauth or microsoft_oauth + consumer='fusion_schedule', + feature='calendar_sync', +) +# Returns: dict with keys: +# client_id, client_secret, access_token, refresh_token, token_expiry, redirect_uri +``` + +## Provider Types + +| Type | Use For | +|------|---------| +| `openai` | GPT-4o, GPT-4o-mini, o1, etc. | +| `anthropic` | Claude Sonnet, Haiku, Opus | +| `google_maps` | Geocoding, Places, Distance Matrix | +| `google_oauth` | Google Calendar, Drive OAuth | +| `microsoft_oauth` | Microsoft Calendar, Graph OAuth | +| `twilio` | SMS, Voice | +| `custom` | Any other API provider | + +## What Happens Automatically + +- The calling module is auto-registered as a consumer in Fusion API +- Every call is logged with: tokens, cost, response time, user, feature, model +- Budget caps (monthly/daily) are enforced before each call +- Rate limits (RPM/RPD) are enforced before each call +- Per-user limits are checked if configured +- On limit exceeded, a `UserError` is raised with a clear message + +## Phased Migration Pattern + +Do NOT add `fusion_api` as a hard dependency. Use a try/fallback pattern so the module works with or without Fusion API installed: + +```python +def _call_ai(self, messages, feature='general'): + """Call OpenAI through Fusion API, falling back to own key.""" + try: + return self.env['fusion.api.service'].call_openai( + consumer='fusion_clock_ai', + feature=feature, + messages=messages, + ) + except Exception: + api_key = self.env['ir.config_parameter'].sudo().get_param( + 'fusion_clock_ai.openai_api_key' + ) + if not api_key: + raise + from openai import OpenAI + client = OpenAI(api_key=api_key) + response = client.chat.completions.create( + model='gpt-4o-mini', messages=messages, + ) + return response.choices[0].message.content +``` + +## Rules + +- NEVER hardcode API keys in Python code +- NEVER store API keys in `ir.config_parameter` for new modules -- use Fusion API +- ALWAYS pass the module's technical name (e.g. `fusion_clock_ai`) as the `consumer` parameter +- ALWAYS pass a descriptive `feature` string for usage tracking granularity +- NEVER catch and silently swallow errors from `fusion.api.service` -- let UserError propagate +- DO NOT add `fusion_api` to `depends` in `__manifest__.py` -- use the fallback pattern above +- The `consumer` string should match the module's technical name exactly (folder name) + +## Odoo 19 Conventions + +- This project targets Odoo 19 (Community/Enterprise) +- No hardcoded colors in views or SCSS -- let Odoo handle dark/light theming +- Use `res.groups.privilege` for security groups (not `category_id`) +- Do not use deprecated `ir.cron` fields (`numbercall`, `doall`) +- Settings views: use `name=` attribute on `` tags, not `data-key=` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..5efc26c1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,40 @@ +# Odoo Modules — Claude Code Instructions + +## Project +27 custom Odoo 19 modules for Fusion Central (Westin Healthcare + NEXA Systems). + +## Critical Rules — Odoo 19 +1. **NEVER code from memory** — Always read a reference file from Docker first: + ```bash + docker exec odoo-dev-app cat /usr/lib/python3/dist-packages/odoo/addons//static/src/ + ``` +2. **Frontend JS**: Use `Interaction` class from `@web/public/interaction`, registered via `registry.category("public.interactions")`. NOT IIFE/DOMContentLoaded. +3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`. +4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated). +5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields. +6. **res.groups**: NO `users` field, NO `category_id` field. +7. **Search views**: NO `group expand="0"` syntax. + +## Naming +- New fields: `x_fc_*` prefix +- Legacy fields: `x_studio_*` +- Canadian English for all user-facing text +- Currency: `$` sign with Monetary fields + currency_id + +## Cursor-Managed Modules +- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state + +## Workflow +- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u --stop-after-init` +- Local URL: http://localhost:8069 +- Test before deploying. Edit existing files — don't create unnecessary new ones. + +## Supabase Knowledge Base +Before starting unfamiliar work, check Supabase for context: +```bash +PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U postgres -d postgres +``` +- `fusionapps.decisions` — past architecture decisions +- `fusionapps.issues` — known issues and fixes +- `fusionapps.code_snippets` — reference code +- `fusionapps.quick_commands` — deployment and admin commands diff --git a/docs/plans/2026-02-26-fusion-clock-ai-design.md b/docs/plans/2026-02-26-fusion-clock-ai-design.md new file mode 100644 index 00000000..1f887d23 --- /dev/null +++ b/docs/plans/2026-02-26-fusion-clock-ai-design.md @@ -0,0 +1,2258 @@ +# Fusion Clock AI - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a standalone Odoo 19 module (`fusion_clock_ai`) that adds OpenAI-powered intelligence to the existing `fusion_clock` attendance system -- natural language queries, narrative reports, anomaly detection, employee chatbot, predictive alerts, and smart configuration. + +**Architecture:** Companion module depending on `fusion_clock`. All AI calls route through a central service model (`fusion.clock.ai.service`) that handles API key management, token budgeting, response caching, and prompt templates. Frontend uses OWL chat components in both backend (manager) and portal (employee). The module inherits existing models to inject AI summaries without modifying `fusion_clock` code. + +**Tech Stack:** Odoo 19, Python 3.12, OpenAI Python SDK (`openai`), OWL 2 (Odoo Web Library), SCSS, XML views. + +--- + +## Module Structure + +``` +fusion_clock_ai/ + __init__.py + __manifest__.py + models/ + __init__.py + ai_service.py # Core OpenAI wrapper (AbstractModel) + ai_prompt.py # Prompt template storage + ai_conversation.py # Chat history per user + ai_usage.py # Token/cost tracking per day + ai_cache.py # Response cache with TTL + res_config_settings.py # AI-specific settings + hr_employee_ai.py # Inherit hr.employee for AI summary + hr_attendance_ai.py # Inherit hr.attendance for anomaly hooks + clock_report_ai.py # Inherit fusion.clock.report for AI narratives + clock_correction_ai.py # Inherit fusion.clock.correction for AI advisor + controllers/ + __init__.py + ai_api.py # Backend JSON-RPC endpoints + portal_ai.py # Portal chatbot endpoints + security/ + security.xml # Groups and record rules + ir.model.access.csv # Model access rights + data/ + ir_config_parameter_data.xml + ir_cron_data.xml # Weekly AI report, daily anomaly scan + ai_prompt_data.xml # Default prompt templates + views/ + res_config_settings_views.xml + ai_conversation_views.xml + ai_usage_views.xml + ai_prompt_views.xml + hr_employee_views.xml # AI Insights tab on employee + clock_report_views.xml # AI narrative on reports + clock_correction_views.xml # AI advisor on corrections + ai_menus.xml + portal_ai_templates.xml # Portal chatbot UI + static/ + src/ + js/ + ai_chat_backend.js # OWL: Manager chat panel + ai_chat_portal.js # Portal: Employee chatbot (Interaction) + xml/ + ai_chat_backend.xml # OWL template + scss/ + fusion_clock_ai.scss # All AI-specific styles + css/ + portal_ai.css # Portal chatbot styles +``` + +--- + +## Phase 1: Foundation + Quick Wins + +### Task 1: Module Scaffold and Manifest + +**Files:** +- Create: `fusion_clock_ai/__init__.py` +- Create: `fusion_clock_ai/__manifest__.py` +- Create: `fusion_clock_ai/models/__init__.py` +- Create: `fusion_clock_ai/controllers/__init__.py` + +**Step 1: Create module entry point** + +```python +# fusion_clock_ai/__init__.py +from . import models +from . import controllers +``` + +**Step 2: Create manifest** + +```python +# fusion_clock_ai/__manifest__.py +{ + 'name': 'Fusion Clock AI', + 'version': '19.0.1.0.0', + 'category': 'Human Resources/Attendances', + 'summary': 'AI-Powered Intelligence for Fusion Clock - GPT Reports, Anomaly Detection, Natural Language Queries & Employee Assistant', + 'description': """ +Fusion Clock AI - Intelligent Attendance Analytics +==================================================== + +AI enhancement module for Fusion Clock. Requires OpenAI API key. + +* **Natural Language Dashboard** - Ask questions about attendance in plain English +* **AI Narrative Reports** - Human-readable weekly/monthly summaries replacing raw numbers +* **Payroll Anomaly Detection** - GPT flags suspicious patterns before payroll approval +* **Employee AI Assistant** - Portal chatbot for hours, leave requests, schedule queries +* **Attendance Coach** - Personalized weekly tips per employee +* **Correction Review Advisor** - AI context for approval/rejection decisions +* **Predictive Understaffing** - Forecast absence likelihood by day +* **Shift Optimization** - Data-driven shift reassignment suggestions +* **Compliance Checks** - Labor law violation alerts +* **Smart Configuration** - Describe policies in English, AI maps to settings +* **Geofence Tuning** - AI suggests radius adjustments from clock-in data +* **Intelligent Incident Logs** - Auto-generated explanations for every activity log + +Requires: fusion_clock module and an OpenAI API key. + """, + 'author': 'Nexa Systems Inc.', + 'website': 'https://nexasystems.io', + 'license': 'OPL-1', + 'depends': [ + 'fusion_clock', + 'mail', + ], + 'external_dependencies': { + 'python': ['openai'], + }, + 'data': [ + # Security + 'security/security.xml', + 'security/ir.model.access.csv', + # Data + 'data/ir_config_parameter_data.xml', + 'data/ir_cron_data.xml', + 'data/ai_prompt_data.xml', + # Views + 'views/res_config_settings_views.xml', + 'views/ai_conversation_views.xml', + 'views/ai_usage_views.xml', + 'views/ai_prompt_views.xml', + 'views/hr_employee_views.xml', + 'views/clock_report_views.xml', + 'views/clock_correction_views.xml', + 'views/ai_menus.xml', + # Portal + 'views/portal_ai_templates.xml', + ], + 'assets': { + 'web.assets_frontend': [ + 'fusion_clock_ai/static/src/css/portal_ai.css', + 'fusion_clock_ai/static/src/js/ai_chat_portal.js', + ], + 'web.assets_backend': [ + 'fusion_clock_ai/static/src/scss/fusion_clock_ai.scss', + 'fusion_clock_ai/static/src/js/ai_chat_backend.js', + 'fusion_clock_ai/static/src/xml/ai_chat_backend.xml', + ], + }, + 'installable': True, + 'auto_install': False, + 'application': False, +} +``` + +**Step 3: Create models init** + +```python +# fusion_clock_ai/models/__init__.py +from . import ai_service +from . import ai_prompt +from . import ai_conversation +from . import ai_usage +from . import ai_cache +from . import res_config_settings +from . import hr_employee_ai +from . import hr_attendance_ai +from . import clock_report_ai +from . import clock_correction_ai +``` + +**Step 4: Create controllers init** + +```python +# fusion_clock_ai/controllers/__init__.py +from . import ai_api +from . import portal_ai +``` + +--- + +### Task 2: Core AI Service Model + +**Files:** +- Create: `fusion_clock_ai/models/ai_service.py` + +This is the central brain. Every AI feature calls methods on this model. It handles: +- OpenAI API initialization with the stored API key +- Token counting and budget enforcement +- Response caching (via `fusion.clock.ai.cache`) +- Usage logging (via `fusion.clock.ai.usage`) +- System prompt assembly from templates + +**Step 1: Implement the service** + +```python +# fusion_clock_ai/models/ai_service.py +import json +import hashlib +import logging +from datetime import datetime, timedelta, date +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +try: + from openai import OpenAI +except ImportError: + OpenAI = None + _logger.warning("openai package not installed. Fusion Clock AI features will be unavailable.") + + +class FusionClockAIService(models.AbstractModel): + _name = 'fusion.clock.ai.service' + _description = 'Fusion Clock AI Service' + + def _get_client(self): + if OpenAI is None: + raise UserError(_("The 'openai' Python package is not installed. Run: pip install openai")) + ICP = self.env['ir.config_parameter'].sudo() + api_key = ICP.get_param('fusion_clock_ai.openai_api_key', '') + if not api_key: + raise UserError(_("OpenAI API key not configured. Go to Fusion Clock > Configuration > AI Settings.")) + return OpenAI(api_key=api_key) + + def _get_model(self): + ICP = self.env['ir.config_parameter'].sudo() + return ICP.get_param('fusion_clock_ai.openai_model', 'gpt-4o-mini') + + def _get_max_tokens(self): + ICP = self.env['ir.config_parameter'].sudo() + return int(ICP.get_param('fusion_clock_ai.max_response_tokens', '1024')) + + def _check_budget(self): + ICP = self.env['ir.config_parameter'].sudo() + monthly_budget = float(ICP.get_param('fusion_clock_ai.monthly_budget_usd', '50.0')) + if monthly_budget <= 0: + return True + today = date.today() + month_start = today.replace(day=1) + usage_records = self.env['fusion.clock.ai.usage'].sudo().search([ + ('date', '>=', month_start), + ('date', '<=', today), + ]) + total_cost = sum(r.estimated_cost_usd for r in usage_records) + if total_cost >= monthly_budget: + raise UserError(_( + "Monthly AI budget of $%(budget).2f has been reached ($%(spent).2f spent). " + "Increase the budget in AI Settings or wait until next month.", + budget=monthly_budget, spent=total_cost, + )) + return True + + def _get_cache_key(self, prompt_key, context_hash): + return hashlib.sha256(f"{prompt_key}:{context_hash}".encode()).hexdigest() + + def _check_cache(self, cache_key, ttl_minutes=15): + cutoff = datetime.now() - timedelta(minutes=ttl_minutes) + cached = self.env['fusion.clock.ai.cache'].sudo().search([ + ('cache_key', '=', cache_key), + ('created_at', '>=', cutoff), + ], limit=1) + if cached: + return cached.response_text + return False + + def _store_cache(self, cache_key, response_text, prompt_key=''): + self.env['fusion.clock.ai.cache'].sudo().create({ + 'cache_key': cache_key, + 'prompt_key': prompt_key, + 'response_text': response_text, + }) + + def _log_usage(self, feature, model_name, prompt_tokens, completion_tokens): + cost_per_1k_input = { + 'gpt-4o': 0.0025, 'gpt-4o-mini': 0.00015, 'gpt-3.5-turbo': 0.0005, + } + cost_per_1k_output = { + 'gpt-4o': 0.01, 'gpt-4o-mini': 0.0006, 'gpt-3.5-turbo': 0.0015, + } + input_cost = (prompt_tokens / 1000) * cost_per_1k_input.get(model_name, 0.001) + output_cost = (completion_tokens / 1000) * cost_per_1k_output.get(model_name, 0.002) + today = date.today() + existing = self.env['fusion.clock.ai.usage'].sudo().search([ + ('date', '=', today), + ('feature', '=', feature), + ('model_name', '=', model_name), + ], limit=1) + if existing: + existing.write({ + 'prompt_tokens': existing.prompt_tokens + prompt_tokens, + 'completion_tokens': existing.completion_tokens + completion_tokens, + 'estimated_cost_usd': existing.estimated_cost_usd + input_cost + output_cost, + 'request_count': existing.request_count + 1, + }) + else: + self.env['fusion.clock.ai.usage'].sudo().create({ + 'date': today, + 'feature': feature, + 'model_name': model_name, + 'prompt_tokens': prompt_tokens, + 'completion_tokens': completion_tokens, + 'estimated_cost_usd': input_cost + output_cost, + 'request_count': 1, + }) + + def _get_system_prompt(self, prompt_key): + prompt = self.env['fusion.clock.ai.prompt'].sudo().search([ + ('key', '=', prompt_key), + ('active', '=', True), + ], limit=1) + if not prompt: + return "You are a helpful attendance management assistant." + return prompt.content + + def chat_completion(self, messages, feature='general', cache_key=None, cache_ttl=15): + if cache_key: + cached = self._check_cache(cache_key, cache_ttl) + if cached: + return cached + self._check_budget() + client = self._get_client() + model_name = self._get_model() + max_tokens = self._get_max_tokens() + try: + response = client.chat.completions.create( + model=model_name, + messages=messages, + max_tokens=max_tokens, + temperature=0.3, + ) + except Exception as e: + _logger.error("OpenAI API error: %s", str(e)) + raise UserError(_("AI service error: %s", str(e))) + + result = response.choices[0].message.content + usage = response.usage + self._log_usage(feature, model_name, usage.prompt_tokens, usage.completion_tokens) + + if cache_key: + self._store_cache(cache_key, result, feature) + + return result + + # ---------------------------------------------------------------- + # Data aggregation helpers -- collect attendance data as structured + # context strings that fit within token limits + # ---------------------------------------------------------------- + + def _build_employee_context(self, employee, date_from=None, date_to=None): + if not date_from: + date_from = date.today() - timedelta(days=7) + if not date_to: + date_to = date.today() + + Attendance = self.env['hr.attendance'].sudo() + attendances = Attendance.search([ + ('employee_id', '=', employee.id), + ('check_in', '>=', datetime.combine(date_from, datetime.min.time())), + ('check_in', '<=', datetime.combine(date_to, datetime.max.time())), + ], order='check_in asc') + + shift = employee.x_fclk_shift_id + shift_info = f"Shift: {shift.name} ({shift.start_time:.1f}-{shift.end_time:.1f})" if shift else "No assigned shift" + + lines = [ + f"Employee: {employee.name}", + f"Department: {employee.department_id.name or 'N/A'}", + shift_info, + f"On-time streak: {employee.x_fclk_ontime_streak}", + f"Absences this month: {employee.x_fclk_absences_this_month}", + f"Absences this year: {employee.x_fclk_absences_this_year}", + f"Overtime this week: {employee.x_fclk_overtime_this_week:.1f}h", + f"Overtime this month: {employee.x_fclk_overtime_this_month:.1f}h", + f"", + f"Attendance records ({date_from} to {date_to}):", + ] + + for att in attendances: + check_out_str = att.check_out.strftime('%Y-%m-%d %H:%M') if att.check_out else 'MISSING' + lines.append( + f" {att.check_in.strftime('%Y-%m-%d %H:%M')} -> {check_out_str} | " + f"Net: {att.x_fclk_net_hours:.1f}h | Break: {att.x_fclk_break_minutes:.0f}m | " + f"OT: {att.x_fclk_overtime_hours:.1f}h | Source: {att.x_fclk_clock_source or 'N/A'}" + ) + + Log = self.env['fusion.clock.activity.log'].sudo() + logs = Log.search([ + ('employee_id', '=', employee.id), + ('log_date', '>=', datetime.combine(date_from, datetime.min.time())), + ('log_date', '<=', datetime.combine(date_to, datetime.max.time())), + ('log_type', 'in', ['late_clock_in', 'early_clock_out', 'auto_clock_out', + 'missed_clock_out', 'absent', 'outside_geofence']), + ], order='log_date asc') + + if logs: + lines.append("") + lines.append("Incidents:") + for log in logs: + lines.append(f" [{log.log_type}] {log.log_date.strftime('%Y-%m-%d %H:%M')}: {log.description or ''}") + + Penalty = self.env['fusion.clock.penalty'].sudo() + penalties = Penalty.search([ + ('employee_id', '=', employee.id), + ('date', '>=', date_from), + ('date', '<=', date_to), + ]) + if penalties: + lines.append("") + lines.append("Penalties:") + for p in penalties: + lines.append(f" [{p.penalty_type}] {p.date}: {p.penalty_minutes:.0f}m deducted") + + return "\n".join(lines) + + def _build_team_context(self, employees, date_from=None, date_to=None): + if not date_from: + date_from = date.today() - timedelta(days=7) + if not date_to: + date_to = date.today() + + lines = [f"Team Attendance Summary ({date_from} to {date_to})", ""] + + for emp in employees: + Attendance = self.env['hr.attendance'].sudo() + attendances = Attendance.search([ + ('employee_id', '=', emp.id), + ('check_in', '>=', datetime.combine(date_from, datetime.min.time())), + ('check_in', '<=', datetime.combine(date_to, datetime.max.time())), + ]) + total_net = sum(a.x_fclk_net_hours for a in attendances) + total_ot = sum(a.x_fclk_overtime_hours for a in attendances) + days_worked = len(set(a.check_in.date() for a in attendances)) + + Penalty = self.env['fusion.clock.penalty'].sudo() + penalty_count = Penalty.search_count([ + ('employee_id', '=', emp.id), + ('date', '>=', date_from), + ('date', '<=', date_to), + ]) + + Log = self.env['fusion.clock.activity.log'].sudo() + absence_count = Log.search_count([ + ('employee_id', '=', emp.id), + ('log_type', '=', 'absent'), + ('log_date', '>=', datetime.combine(date_from, datetime.min.time())), + ('log_date', '<=', datetime.combine(date_to, datetime.max.time())), + ]) + late_count = Log.search_count([ + ('employee_id', '=', emp.id), + ('log_type', '=', 'late_clock_in'), + ('log_date', '>=', datetime.combine(date_from, datetime.min.time())), + ('log_date', '<=', datetime.combine(date_to, datetime.max.time())), + ]) + + shift_name = emp.x_fclk_shift_id.name if emp.x_fclk_shift_id else 'No shift' + lines.append( + f"- {emp.name} ({emp.department_id.name or 'N/A'}, {shift_name}): " + f"{days_worked} days, {total_net:.1f}h net, {total_ot:.1f}h OT, " + f"{penalty_count} penalties, {late_count} late, {absence_count} absences, " + f"streak: {emp.x_fclk_ontime_streak}" + ) + + return "\n".join(lines) + + def _build_payroll_context(self, report): + lines = [ + f"Payroll Report: {report.name}", + f"Period: {report.date_start} to {report.date_end}", + f"Employee: {report.employee_id.name if report.employee_id else 'Batch (all employees)'}", + f"Total hours: {report.total_hours:.1f}", + f"Net hours: {report.net_hours:.1f}", + f"Total breaks: {report.total_breaks:.0f} minutes", + f"Total penalties: {report.total_penalties}", + f"Days worked: {report.days_worked}", + "", + "Daily breakdown:", + ] + for att in report.attendance_ids: + check_out_str = att.check_out.strftime('%H:%M') if att.check_out else 'MISSING' + auto = ' [AUTO-CLOCKED-OUT]' if att.x_fclk_auto_clocked_out else '' + lines.append( + f" {att.check_in.strftime('%Y-%m-%d')} | " + f"{att.check_in.strftime('%H:%M')}-{check_out_str} | " + f"Net: {att.x_fclk_net_hours:.1f}h | OT: {att.x_fclk_overtime_hours:.1f}h{auto}" + ) + return "\n".join(lines) +``` + +--- + +### Task 3: Prompt Template Model + +**Files:** +- Create: `fusion_clock_ai/models/ai_prompt.py` +- Create: `fusion_clock_ai/data/ai_prompt_data.xml` + +Stores editable prompt templates for each AI feature. Managers can tweak prompts without code changes. + +**Step 1: Create the model** + +```python +# fusion_clock_ai/models/ai_prompt.py +from odoo import models, fields + + +class FusionClockAIPrompt(models.Model): + _name = 'fusion.clock.ai.prompt' + _description = 'AI Prompt Template' + _order = 'key' + + key = fields.Char(required=True, index=True) + name = fields.Char(required=True) + content = fields.Text(required=True) + description = fields.Text(help="Explains when this prompt is used and what variables are available.") + active = fields.Boolean(default=True) + feature_category = fields.Selection([ + ('manager_query', 'Manager Queries'), + ('employee_chat', 'Employee Chatbot'), + ('report', 'Report Narratives'), + ('anomaly', 'Anomaly Detection'), + ('coach', 'Attendance Coach'), + ('correction', 'Correction Advisor'), + ('prediction', 'Predictions'), + ('shift', 'Shift Optimization'), + ('compliance', 'Compliance'), + ('config', 'Configuration'), + ('geofence', 'Geofence Tuning'), + ('incident', 'Incident Explanation'), + ], required=True) +``` + +**Step 2: Create default prompts data file** + +```xml + + + + + + manager_query + Manager Natural Language Query + manager_query + System prompt for the manager chat. The team attendance data will be injected as context. + You are an attendance analytics assistant for a company using Fusion Clock. +You answer questions about employee attendance, overtime, absences, penalties, and schedules. + +Rules: +- Answer based ONLY on the provided data. Never invent data. +- If the data doesn't contain the answer, say so clearly. +- Format responses with clear structure: bullet points, tables when comparing employees. +- Use hours and minutes (e.g., "8h 30m"), not decimal hours. +- When mentioning dates, use the format "Mon, Mar 15". +- Be concise but thorough. Highlight concerning patterns. +- If asked about payroll impact, calculate net hours minus penalties. +- Currency amounts are NOT in the data -- never guess pay rates. + + + + employee_chat + Employee Clock Assistant + employee_chat + You are a friendly attendance assistant for an employee using Fusion Clock. +You help them understand their hours, schedule, and attendance history. + +Rules: +- Only share information about THIS employee. Never reference other employees. +- Be encouraging but honest about attendance issues. +- Format times in 12-hour format (e.g., "9:05 AM"). +- If they ask about submitting leave, guide them to use the Leave Request form on the portal. +- If they ask about corrections, guide them to the Correction Request feature. +- Never make up data. If something isn't in the context, say you don't have that information. +- Keep responses concise and friendly. + + + + weekly_narrative + Weekly Summary Narrative + report + Generate a concise, professional weekly attendance summary for the employee. + +Structure: +1. Opening line with overall assessment (strong week / needs attention / concerning patterns) +2. Key metrics in a sentence (total hours, overtime, on-time streak) +3. Notable events (if any: penalties, absences, auto-clock-outs, late arrivals) +4. Positive note or actionable suggestion + +Keep it to 3-5 sentences. Professional but warm tone. No bullet points -- write as a short paragraph. + + + + anomaly_detection + Payroll Anomaly Detection + anomaly + You are a payroll auditor reviewing attendance data before payroll processing. + +Flag these anomalies: +- Missing clock-outs without leave requests +- Extremely short shifts (under 2 hours) that might be accidental +- Overtime spikes vs previous periods +- Employees with zero hours but no absence/leave record +- Consecutive auto-clock-outs (employee may not know how to clock out) +- Clock-in/out times that are suspiciously identical day after day (possible buddy punching) +- Weekend/holiday work without overtime classification + +For each anomaly found, output: +ANOMALY: [employee name] - [type] - [details] - [recommended action] + +If no anomalies found, say "No anomalies detected. Payroll data looks clean." + + + + attendance_coach + Personal Attendance Coach + coach + You are a supportive attendance coach writing a brief personalized tip for an employee. + +Based on their recent attendance patterns, write ONE actionable tip (1-2 sentences). +Be specific to their data -- don't give generic advice. + +Examples of good tips: +- "You've been arriving 5-10 minutes late on Mondays for 3 weeks. Setting your Monday alarm 15 minutes earlier could protect your 12-day streak." +- "Great consistency this week -- 5 days on time! You're 3 days away from a 20-day streak milestone." +- "You've been auto-clocked-out 3 times this week. Remember to clock out before leaving to avoid needing corrections." + +Keep it positive and actionable. Never be punitive. + + + + correction_advisor + Correction Review Advisor + correction + You are an HR advisor reviewing a timesheet correction request. + +Provide context to help the manager decide: +1. Is this a one-time request or a recurring pattern? +2. Does the requested time seem reasonable given the employee's typical schedule? +3. Any red flags (e.g., adding hours on a day already marked absent)? + +End with: "Recommendation: APPROVE / REVIEW FURTHER / DISCUSS WITH EMPLOYEE" + +Be neutral and fact-based. 2-3 sentences max. + + + + understaffing_prediction + Understaffing Prediction + prediction + Based on historical attendance patterns, predict the likelihood of understaffing for the upcoming week. + +Consider: +- Day-of-week absence patterns (e.g., Mondays/Fridays higher) +- Seasonal patterns if data spans multiple months +- Recent absence trends (increasing/decreasing) +- Approved upcoming leaves + +For each day of the upcoming week, output: +[Day]: [Risk Level: Low/Medium/High] - [Expected attendance count] / [Total employees] - [Reasoning] + +End with one overall recommendation for the manager. + + + + shift_optimization + Shift Optimization + shift + Analyze employee attendance patterns and suggest shift reassignments. + +Look for: +- Employees consistently arriving early/late for their assigned shift +- Employees with high penalties who might fit a different shift +- Unbalanced shift coverage (too many on one shift, too few on another) + +For each suggestion: +SUGGESTION: Move [employee] from [current shift] to [suggested shift] -- [reasoning based on their clock-in pattern] + +Only suggest changes with strong data support. If no changes needed, say so. + + + + compliance_check + Labor Compliance Check + compliance + Review attendance records for potential labor law compliance issues. + +Check for: +- Shifts exceeding maximum allowed consecutive hours (flag if > 12h) +- Missing mandatory breaks (shifts > 5h without break deduction) +- Insufficient rest between shifts (less than 8 hours between clock-out and next clock-in) +- Excessive weekly hours (> 48h in a week) +- Minors working outside permitted hours (if age data available) + +For each violation: +VIOLATION: [employee] - [type] - [date] - [details] - [regulation reference if known] + +If compliant, say "All records are within standard labor compliance thresholds." + + + + smart_config + Natural Language Configuration + config + You translate natural language policy descriptions into Fusion Clock configuration parameters. + +Available parameters: +- fusion_clock.default_clock_in_time (float, 24h format, e.g. 9.0) +- fusion_clock.default_clock_out_time (float, 24h format) +- fusion_clock.default_break_minutes (float, minutes) +- fusion_clock.grace_period_minutes (float) +- fusion_clock.penalty_grace_minutes (float) +- fusion_clock.penalty_deduction_minutes (float) +- fusion_clock.very_late_threshold_minutes (float) +- fusion_clock.max_monthly_absences (integer) +- fusion_clock.daily_overtime_threshold (float, hours) +- fusion_clock.weekly_overtime_threshold (float, hours) +- fusion_clock.max_shift_hours (float, hours) +- fusion_clock.break_threshold_hours (float, hours) + +For each change, output JSON: +{"parameter": "key", "value": "new_value", "explanation": "why"} + +If the request is ambiguous, ask for clarification instead of guessing. + + + + geofence_tuning + Geofence Tuning Suggestions + geofence + Analyze clock-in/out distance data for geofenced locations and suggest radius adjustments. + +Look for: +- High percentage of clock-ins just outside the radius (radius too tight) +- All clock-ins very close to center (radius could be tightened for security) +- Specific employees consistently outside (individual issue vs location issue) +- Different patterns by time of day + +For each location: +[Location Name] (current radius: Xm): +- Clock-ins inside: X%, average distance: Xm +- Clock-ins outside (blocked): X%, average overshoot: Xm +- SUGGESTION: [Keep/Increase to Xm/Decrease to Xm] -- [reasoning] + + + + incident_explanation + Incident Auto-Explanation + incident + Generate a brief, human-readable explanation for an attendance incident. + +Given the incident type and context, write 1 sentence explaining what happened and why. + +Examples: +- auto_clock_out: "Automatically clocked out at 5:15 PM after the 15-minute grace period expired without a manual clock-out." +- late_clock_in: "Arrived at 9:23 AM, 23 minutes after the 9:00 AM scheduled start. 15-minute penalty applied after 5-minute grace." +- outside_geofence: "Attempted to clock in from 850m away from the Mississauga office (allowed radius: 200m)." + +Be factual. Include specific times and numbers from the data. + + + + leave_reason_writer + Leave Reason Writer + employee_chat + Rewrite the employee's rough leave/absence explanation into a professional, clear message suitable for their manager. + +Rules: +- Keep the original meaning exactly. Do not add or change facts. +- Fix grammar, spelling, and formatting. +- Keep it concise (1-3 sentences). +- Professional but natural tone. +- If the original is already professional, return it unchanged. + + + + activity_log_summary + Activity Log Summarizer + report + Summarize an employee's activity logs for a given period into a narrative overview. + +Structure: +1. Overall pattern (consistent, improving, declining, irregular) +2. Key stats in prose (X clock-ins, Y on-time, Z incidents) +3. Notable patterns (e.g., "Late arrivals concentrated on Mondays") +4. Comparison to previous period if data available + +Write 3-5 sentences. Professional, concise, fact-based. + + + +``` + +--- + +### Task 4: Conversation, Usage, and Cache Models + +**Files:** +- Create: `fusion_clock_ai/models/ai_conversation.py` +- Create: `fusion_clock_ai/models/ai_usage.py` +- Create: `fusion_clock_ai/models/ai_cache.py` + +**Step 1: Conversation model (chat history)** + +```python +# fusion_clock_ai/models/ai_conversation.py +from odoo import models, fields + + +class FusionClockAIConversation(models.Model): + _name = 'fusion.clock.ai.conversation' + _description = 'AI Chat Conversation' + _order = 'create_date desc' + + user_id = fields.Many2one('res.users', required=True, index=True, ondelete='cascade') + conversation_type = fields.Selection([ + ('manager_query', 'Manager Query'), + ('employee_chat', 'Employee Chat'), + ('config', 'Configuration'), + ], required=True) + message_ids = fields.One2many('fusion.clock.ai.message', 'conversation_id') + title = fields.Char(compute='_compute_title', store=True) + company_id = fields.Many2one('res.company', default=lambda self: self.env.company) + + def _compute_title(self): + for rec in self: + first_msg = rec.message_ids.filtered(lambda m: m.role == 'user')[:1] + if first_msg: + text = first_msg.content[:60] + rec.title = text + ('...' if len(first_msg.content) > 60 else '') + else: + rec.title = f"Conversation #{rec.id}" + + +class FusionClockAIMessage(models.Model): + _name = 'fusion.clock.ai.message' + _description = 'AI Chat Message' + _order = 'create_date asc' + + conversation_id = fields.Many2one('fusion.clock.ai.conversation', required=True, + ondelete='cascade', index=True) + role = fields.Selection([ + ('system', 'System'), + ('user', 'User'), + ('assistant', 'Assistant'), + ], required=True) + content = fields.Text(required=True) + token_count = fields.Integer() +``` + +**Step 2: Usage tracking model** + +```python +# fusion_clock_ai/models/ai_usage.py +from odoo import models, fields + + +class FusionClockAIUsage(models.Model): + _name = 'fusion.clock.ai.usage' + _description = 'AI Usage Tracking' + _order = 'date desc' + + date = fields.Date(required=True, index=True) + feature = fields.Char(required=True, index=True) + model_name = fields.Char(required=True) + prompt_tokens = fields.Integer() + completion_tokens = fields.Integer() + total_tokens = fields.Integer(compute='_compute_total') + estimated_cost_usd = fields.Float(digits=(10, 6)) + request_count = fields.Integer(default=1) + company_id = fields.Many2one('res.company', default=lambda self: self.env.company) + + def _compute_total(self): + for rec in self: + rec.total_tokens = rec.prompt_tokens + rec.completion_tokens +``` + +**Step 3: Response cache model** + +```python +# fusion_clock_ai/models/ai_cache.py +from odoo import models, fields + + +class FusionClockAICache(models.Model): + _name = 'fusion.clock.ai.cache' + _description = 'AI Response Cache' + _order = 'created_at desc' + + cache_key = fields.Char(required=True, index=True) + prompt_key = fields.Char(index=True) + response_text = fields.Text(required=True) + created_at = fields.Datetime(default=fields.Datetime.now, required=True) + + def _gc_expired_cache(self): + """Cron: delete cache entries older than 24 hours.""" + from datetime import datetime, timedelta + cutoff = datetime.now() - timedelta(hours=24) + self.search([('created_at', '<', cutoff)]).unlink() +``` + +--- + +### Task 5: Settings Model and Views + +**Files:** +- Create: `fusion_clock_ai/models/res_config_settings.py` +- Create: `fusion_clock_ai/views/res_config_settings_views.xml` +- Create: `fusion_clock_ai/data/ir_config_parameter_data.xml` + +**Step 1: Settings model** + +```python +# fusion_clock_ai/models/res_config_settings.py +from odoo import models, fields + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + fclk_ai_openai_api_key = fields.Char( + string='OpenAI API Key', + config_parameter='fusion_clock_ai.openai_api_key', + ) + fclk_ai_openai_model = fields.Selection([ + ('gpt-4o', 'GPT-4o (Best quality, higher cost)'), + ('gpt-4o-mini', 'GPT-4o Mini (Good quality, low cost)'), + ('gpt-3.5-turbo', 'GPT-3.5 Turbo (Basic, lowest cost)'), + ], string='AI Model', + config_parameter='fusion_clock_ai.openai_model', + default='gpt-4o-mini', + ) + fclk_ai_max_response_tokens = fields.Integer( + string='Max Response Tokens', + config_parameter='fusion_clock_ai.max_response_tokens', + default=1024, + ) + fclk_ai_monthly_budget_usd = fields.Float( + string='Monthly Budget (USD)', + config_parameter='fusion_clock_ai.monthly_budget_usd', + default=50.0, + help="Set to 0 for unlimited. AI requests stop when budget is reached.", + ) + fclk_ai_cache_ttl_minutes = fields.Integer( + string='Cache TTL (minutes)', + config_parameter='fusion_clock_ai.cache_ttl_minutes', + default=15, + ) + fclk_ai_enable_manager_chat = fields.Boolean( + string='Enable Manager AI Chat', + config_parameter='fusion_clock_ai.enable_manager_chat', + default=True, + ) + fclk_ai_enable_employee_chat = fields.Boolean( + string='Enable Employee AI Assistant', + config_parameter='fusion_clock_ai.enable_employee_chat', + default=True, + ) + fclk_ai_enable_narrative_reports = fields.Boolean( + string='Enable AI Narrative Reports', + config_parameter='fusion_clock_ai.enable_narrative_reports', + default=True, + ) + fclk_ai_enable_anomaly_detection = fields.Boolean( + string='Enable Payroll Anomaly Detection', + config_parameter='fusion_clock_ai.enable_anomaly_detection', + default=True, + ) + fclk_ai_enable_coach = fields.Boolean( + string='Enable Attendance Coach', + config_parameter='fusion_clock_ai.enable_coach', + default=True, + ) + fclk_ai_enable_correction_advisor = fields.Boolean( + string='Enable Correction Review Advisor', + config_parameter='fusion_clock_ai.enable_correction_advisor', + default=True, + ) + fclk_ai_enable_predictions = fields.Boolean( + string='Enable Predictive Alerts', + config_parameter='fusion_clock_ai.enable_predictions', + default=False, + ) + fclk_ai_enable_shift_suggestions = fields.Boolean( + string='Enable Shift Optimization', + config_parameter='fusion_clock_ai.enable_shift_suggestions', + default=False, + ) + fclk_ai_enable_compliance = fields.Boolean( + string='Enable Compliance Checks', + config_parameter='fusion_clock_ai.enable_compliance', + default=False, + ) + fclk_ai_enable_smart_config = fields.Boolean( + string='Enable Natural Language Config', + config_parameter='fusion_clock_ai.enable_smart_config', + default=False, + ) + fclk_ai_enable_geofence_tuning = fields.Boolean( + string='Enable Geofence Tuning', + config_parameter='fusion_clock_ai.enable_geofence_tuning', + default=False, + ) + fclk_ai_enable_incident_explain = fields.Boolean( + string='Enable Incident Auto-Explain', + config_parameter='fusion_clock_ai.enable_incident_explain', + default=True, + ) +``` + +**Step 2: Settings view (inherits fusion_clock settings)** + +```xml + + + + + res.config.settings.form.fusion.clock.ai + res.config.settings + + + + +

AI Settings

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +

Feature Toggles

+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ + + + + +``` + +**Step 3: Default config parameters** + +```xml + + + + + fusion_clock_ai.openai_model + gpt-4o-mini + + + fusion_clock_ai.max_response_tokens + 1024 + + + fusion_clock_ai.monthly_budget_usd + 50.0 + + + fusion_clock_ai.cache_ttl_minutes + 15 + + +``` + +--- + +### Task 6: Security (Groups, Access Rights, Record Rules) + +**Files:** +- Create: `fusion_clock_ai/security/security.xml` +- Create: `fusion_clock_ai/security/ir.model.access.csv` + +**Step 1: Security groups and record rules** + +```xml + + + + + + AI Conversation: User sees own + + [('user_id', '=', user.id)] + + + + + + + + + AI Conversation: Manager sees all + + [(1, '=', 1)] + + + + + AI Message: User sees own conversation messages + + [('conversation_id.user_id', '=', user.id)] + + + + + + + + + AI Message: Manager sees all + + [(1, '=', 1)] + + + + + + AI Usage: Manager only + + [(1, '=', 1)] + + + + + + AI Prompts: Manager only + + [(1, '=', 1)] + + + + + + AI Conversation: Portal sees own + + [('user_id', '=', user.id)] + + + + + + + + + AI Message: Portal sees own + + [('conversation_id.user_id', '=', user.id)] + + + + + + + +``` + +**Step 2: Access rights CSV** + +```csv +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ai_prompt_manager,fusion.clock.ai.prompt.manager,model_fusion_clock_ai_prompt,fusion_clock.group_fusion_clock_manager,1,1,1,1 +access_ai_conversation_user,fusion.clock.ai.conversation.user,model_fusion_clock_ai_conversation,fusion_clock.group_fusion_clock_user,1,1,1,1 +access_ai_conversation_manager,fusion.clock.ai.conversation.manager,model_fusion_clock_ai_conversation,fusion_clock.group_fusion_clock_manager,1,1,1,1 +access_ai_message_user,fusion.clock.ai.message.user,model_fusion_clock_ai_message,fusion_clock.group_fusion_clock_user,1,1,1,0 +access_ai_message_manager,fusion.clock.ai.message.manager,model_fusion_clock_ai_message,fusion_clock.group_fusion_clock_manager,1,1,1,1 +access_ai_usage_manager,fusion.clock.ai.usage.manager,model_fusion_clock_ai_usage,fusion_clock.group_fusion_clock_manager,1,1,1,1 +access_ai_cache_manager,fusion.clock.ai.cache.manager,model_fusion_clock_ai_cache,fusion_clock.group_fusion_clock_manager,1,1,1,1 +access_ai_conversation_portal,fusion.clock.ai.conversation.portal,model_fusion_clock_ai_conversation,base.group_portal,1,0,1,0 +access_ai_message_portal,fusion.clock.ai.message.portal,model_fusion_clock_ai_message,base.group_portal,1,0,1,0 +``` + +--- + +### Task 7: Backend Manager Chat (Controller + OWL Component) + +**Files:** +- Create: `fusion_clock_ai/controllers/ai_api.py` +- Create: `fusion_clock_ai/static/src/js/ai_chat_backend.js` +- Create: `fusion_clock_ai/static/src/xml/ai_chat_backend.xml` +- Create: `fusion_clock_ai/static/src/scss/fusion_clock_ai.scss` + +This is the manager's natural language dashboard. An OWL chat panel (similar to the existing systray FAB) where managers type questions like "Who had the most overtime this week?" and get AI-powered answers. + +**Step 1: Backend API controller** + +```python +# fusion_clock_ai/controllers/ai_api.py +import json +import hashlib +import logging +from datetime import datetime, timedelta, date +from odoo import http, fields as odoo_fields, _ +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class FusionClockAIAPI(http.Controller): + + def _get_employee(self): + return request.env['hr.employee'].sudo().search([ + ('user_id', '=', request.env.uid), + ], limit=1) + + @http.route('/fusion_clock_ai/manager_chat', type='json', auth='user') + def manager_chat(self, message, conversation_id=None, date_from=None, date_to=None): + user = request.env.user + if not user.has_group('fusion_clock.group_fusion_clock_manager'): + return {'error': 'Access denied. Manager role required.'} + + ICP = request.env['ir.config_parameter'].sudo() + if ICP.get_param('fusion_clock_ai.enable_manager_chat', 'True') != 'True': + return {'error': 'Manager AI Chat is disabled in settings.'} + + AI = request.env['fusion.clock.ai.service'].sudo() + + if not date_from: + date_from = (date.today() - timedelta(days=7)).isoformat() + if not date_to: + date_to = date.today().isoformat() + + d_from = date.fromisoformat(date_from) + d_to = date.fromisoformat(date_to) + + employees = request.env['hr.employee'].sudo().search([ + ('x_fclk_enable_clock', '=', True), + ('company_id', '=', request.env.company.id), + ]) + team_context = AI._build_team_context(employees, d_from, d_to) + + Conversation = request.env['fusion.clock.ai.conversation'].sudo() + if conversation_id: + conv = Conversation.browse(int(conversation_id)) + if not conv.exists() or conv.user_id.id != user.id: + conv = Conversation.create({ + 'user_id': user.id, + 'conversation_type': 'manager_query', + }) + else: + conv = Conversation.create({ + 'user_id': user.id, + 'conversation_type': 'manager_query', + }) + + Message = request.env['fusion.clock.ai.message'].sudo() + Message.create({ + 'conversation_id': conv.id, + 'role': 'user', + 'content': message, + }) + + system_prompt = AI._get_system_prompt('manager_query') + messages = [ + {'role': 'system', 'content': f"{system_prompt}\n\n--- ATTENDANCE DATA ---\n{team_context}"}, + ] + for msg in conv.message_ids: + messages.append({'role': msg.role, 'content': msg.content}) + + try: + response = AI.chat_completion(messages, feature='manager_query') + except Exception as e: + return {'error': str(e)} + + Message.create({ + 'conversation_id': conv.id, + 'role': 'assistant', + 'content': response, + }) + + return { + 'response': response, + 'conversation_id': conv.id, + } + + @http.route('/fusion_clock_ai/run_analysis', type='json', auth='user') + def run_analysis(self, analysis_type, date_from=None, date_to=None, employee_id=None): + user = request.env.user + if not user.has_group('fusion_clock.group_fusion_clock_manager'): + return {'error': 'Access denied.'} + + AI = request.env['fusion.clock.ai.service'].sudo() + + if not date_from: + date_from = (date.today() - timedelta(days=7)).isoformat() + if not date_to: + date_to = date.today().isoformat() + d_from = date.fromisoformat(date_from) + d_to = date.fromisoformat(date_to) + + prompt_map = { + 'anomaly': 'anomaly_detection', + 'understaffing': 'understaffing_prediction', + 'shift': 'shift_optimization', + 'compliance': 'compliance_check', + 'geofence': 'geofence_tuning', + } + prompt_key = prompt_map.get(analysis_type) + if not prompt_key: + return {'error': f'Unknown analysis type: {analysis_type}'} + + employees = request.env['hr.employee'].sudo().search([ + ('x_fclk_enable_clock', '=', True), + ('company_id', '=', request.env.company.id), + ]) + context_data = AI._build_team_context(employees, d_from, d_to) + + if analysis_type == 'geofence': + context_data += "\n\n" + self._build_geofence_context(d_from, d_to) + + system_prompt = AI._get_system_prompt(prompt_key) + + cache_key = AI._get_cache_key(prompt_key, hashlib.sha256( + f"{d_from}:{d_to}:{request.env.company.id}".encode() + ).hexdigest()) + + try: + response = AI.chat_completion( + [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': context_data}, + ], + feature=analysis_type, + cache_key=cache_key, + ) + except Exception as e: + return {'error': str(e)} + + return {'response': response} + + def _build_geofence_context(self, date_from, date_to): + Log = request.env['fusion.clock.activity.log'].sudo() + locations = request.env['fusion.clock.location'].sudo().search([ + ('active', '=', True), + ('company_id', '=', request.env.company.id), + ]) + lines = ["Geofence Data:"] + for loc in locations: + clock_ins = Log.search([ + ('location_id', '=', loc.id), + ('log_type', '=', 'clock_in'), + ('log_date', '>=', datetime.combine(date_from, datetime.min.time())), + ('log_date', '<=', datetime.combine(date_to, datetime.max.time())), + ]) + outside = Log.search([ + ('location_id', '=', loc.id), + ('log_type', '=', 'outside_geofence'), + ('log_date', '>=', datetime.combine(date_from, datetime.min.time())), + ('log_date', '<=', datetime.combine(date_to, datetime.max.time())), + ]) + distances = [l.distance for l in clock_ins if l.distance] + avg_dist = sum(distances) / len(distances) if distances else 0 + outside_distances = [l.distance for l in outside if l.distance] + avg_overshoot = sum(outside_distances) / len(outside_distances) if outside_distances else 0 + + lines.append( + f"\n{loc.name} (radius: {loc.radius}m):\n" + f" Successful clock-ins: {len(clock_ins)}, avg distance: {avg_dist:.0f}m\n" + f" Blocked (outside): {len(outside)}, avg overshoot: {avg_overshoot:.0f}m" + ) + return "\n".join(lines) + + @http.route('/fusion_clock_ai/smart_config', type='json', auth='user') + def smart_config(self, description): + user = request.env.user + if not user.has_group('fusion_clock.group_fusion_clock_manager'): + return {'error': 'Access denied.'} + + AI = request.env['fusion.clock.ai.service'].sudo() + system_prompt = AI._get_system_prompt('smart_config') + + try: + response = AI.chat_completion( + [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': description}, + ], + feature='smart_config', + ) + except Exception as e: + return {'error': str(e)} + + return {'response': response} + + @http.route('/fusion_clock_ai/apply_config', type='json', auth='user') + def apply_config(self, changes): + user = request.env.user + if not user.has_group('fusion_clock.group_fusion_clock_manager'): + return {'error': 'Access denied.'} + + ICP = request.env['ir.config_parameter'].sudo() + applied = [] + for change in changes: + param = change.get('parameter', '') + value = change.get('value', '') + if param.startswith('fusion_clock.'): + ICP.set_param(param, str(value)) + applied.append(param) + + return {'applied': applied, 'count': len(applied)} +``` + +**Step 2: OWL chat component (JS)** + +```javascript +/* fusion_clock_ai/static/src/js/ai_chat_backend.js */ +/** @odoo-module **/ +import { Component, useState, useRef, onMounted } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { rpc } from "@web/core/network/rpc"; + +class FusionClockAIChat extends Component { + static template = "fusion_clock_ai.AIChat"; + + setup() { + this.notification = useService("notification"); + this.chatBody = useRef("chatBody"); + this.state = useState({ + open: false, + messages: [], + input: "", + loading: false, + conversationId: null, + activeTab: "chat", + analysisResult: "", + analysisLoading: false, + }); + } + + togglePanel() { + this.state.open = !this.state.open; + } + + async sendMessage() { + const text = this.state.input.trim(); + if (!text || this.state.loading) return; + + this.state.messages.push({ role: "user", content: text }); + this.state.input = ""; + this.state.loading = true; + this._scrollToBottom(); + + try { + const result = await rpc("/fusion_clock_ai/manager_chat", { + message: text, + conversation_id: this.state.conversationId, + }); + + if (result.error) { + this.state.messages.push({ role: "assistant", content: `Error: ${result.error}` }); + } else { + this.state.messages.push({ role: "assistant", content: result.response }); + this.state.conversationId = result.conversation_id; + } + } catch (e) { + this.state.messages.push({ role: "assistant", content: "Failed to reach AI service." }); + } + + this.state.loading = false; + this._scrollToBottom(); + } + + async runAnalysis(type) { + this.state.analysisLoading = true; + this.state.analysisResult = ""; + + try { + const result = await rpc("/fusion_clock_ai/run_analysis", { + analysis_type: type, + }); + this.state.analysisResult = result.error || result.response; + } catch { + this.state.analysisResult = "Failed to run analysis."; + } + + this.state.analysisLoading = false; + } + + onKeyDown(ev) { + if (ev.key === "Enter" && !ev.shiftKey) { + ev.preventDefault(); + this.sendMessage(); + } + } + + newConversation() { + this.state.messages = []; + this.state.conversationId = null; + } + + setTab(tab) { + this.state.activeTab = tab; + } + + _scrollToBottom() { + requestAnimationFrame(() => { + const el = this.chatBody.el; + if (el) el.scrollTop = el.scrollHeight; + }); + } +} + +registry.category("actions").add("fusion_clock_ai.AIChat", FusionClockAIChat); +registry.category("systray").add("fusion_clock_ai.AIChatSystray", { + Component: FusionClockAIChat, + isDisplayed: (env) => true, +}, { sequence: 5 }); +``` + +**Step 3: OWL template (XML)** + +```xml + + + + + +
+ + + + +
+
+ Fusion Clock AI +
+ + +
+ +
+ + +
+
+ +

Ask me anything about your team's attendance.

+
+ + + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+ +
+
+
+ + +
+
+ + + + + +
+ +
+ Running analysis... +
+
+
+                    
+
+ + +
+ + -
-
-
- - -
- - Cancel - - -
- -
- - - - - - - - - - diff --git a/fusion_authorizer_portal/views/portal_technician_templates.xml b/fusion_authorizer_portal/views/portal_technician_templates.xml index 58ce9bee..721d89bd 100644 --- a/fusion_authorizer_portal/views/portal_technician_templates.xml +++ b/fusion_authorizer_portal/views/portal_technician_templates.xml @@ -23,34 +23,79 @@
-
-
-
-
-
+ t-att-data-check-in-time="clock_check_in_time or ''" + t-att-data-today-hours="clock_today_hours or 0"> +
+
+
+ + Clocked In Not Clocked In -
-
00:00:00
+ +
+
00:00:00
+
+ Today:
- +
+
+ + + + +
+ + Clock Out + Clock In + +
+ + +