changes
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1775192388322}
|
||||
@@ -0,0 +1,92 @@
|
||||
<h2>Hybrid: AI Recommendation + Your Input + Bulk Actions</h2>
|
||||
<p class="subtitle">The AI pre-fills its recommendation. You get an editable input per row to override or add notes. Checkboxes for bulk actions.</p>
|
||||
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Chat Panel — find_missing_itc_bills result</div>
|
||||
<div class="mockup-body" style="padding: 0; overflow-x: auto;">
|
||||
<table style="width:100%; border-collapse: collapse; font-size: 13px;">
|
||||
<thead>
|
||||
<tr style="background: rgba(255,255,255,0.05); border-bottom: 2px solid rgba(255,255,255,0.15);">
|
||||
<th style="padding: 8px 6px; width:30px; text-align:center;"><input type="checkbox" title="Select all"></th>
|
||||
<th style="padding: 8px 6px; text-align:left; font-weight:600;">Date</th>
|
||||
<th style="padding: 8px 6px; text-align:left; font-weight:600;">Vendor</th>
|
||||
<th style="padding: 8px 6px; text-align:right; font-weight:600;">Amount</th>
|
||||
<th style="padding: 8px 6px; text-align:left; font-weight:600; color:#60a5fa;">AI Recommendation</th>
|
||||
<th style="padding: 8px 6px; text-align:left; font-weight:600; color:#fbbf24;">Your Input</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
|
||||
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
|
||||
<td style="padding: 6px;">2024-01-10</td>
|
||||
<td style="padding: 6px;">Ki Mobility LLC</td>
|
||||
<td style="padding: 6px; text-align:right; font-weight:600;">-$14,917.95</td>
|
||||
<td style="padding: 6px;"><span style="background:rgba(34,197,94,0.15); color:#4ade80; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Dismiss</span> <span style="opacity:0.7; font-size:12px;">US vendor, no HST applies</span></td>
|
||||
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..." value="Confirmed, no ITC needed"></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
|
||||
<td style="padding: 6px; text-align:center;"><input type="checkbox" checked></td>
|
||||
<td style="padding: 6px;">2024-02-16</td>
|
||||
<td style="padding: 6px;">Savaria Concord Lifts</td>
|
||||
<td style="padding: 6px; text-align:right; font-weight:600;">-$10,173.00</td>
|
||||
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Canadian vendor, ITC likely missing</span></td>
|
||||
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
|
||||
<td style="padding: 6px; text-align:center;"><input type="checkbox" checked></td>
|
||||
<td style="padding: 6px;">2024-02-13</td>
|
||||
<td style="padding: 6px;">Savaria Concord Lifts</td>
|
||||
<td style="padding: 6px; text-align:right; font-weight:600;">-$9,599.50</td>
|
||||
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Canadian vendor, ITC likely missing</span></td>
|
||||
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..." value="Need to check PO"></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
|
||||
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
|
||||
<td style="padding: 6px;">2024-01-11</td>
|
||||
<td style="padding: 6px;">Joerns Healthcare</td>
|
||||
<td style="padding: 6px; text-align:right; font-weight:600;">-$2,392.80</td>
|
||||
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Check fiscal position</span></td>
|
||||
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.08);">
|
||||
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
|
||||
<td style="padding: 6px;">2024-01-11</td>
|
||||
<td style="padding: 6px;">Maple Leaf Wheelchair</td>
|
||||
<td style="padding: 6px; text-align:right; font-weight:600;">-$2,181.30</td>
|
||||
<td style="padding: 6px;"><span style="background:rgba(96,165,250,0.15); color:#60a5fa; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Create Rule</span> <span style="opacity:0.7; font-size:12px;">Recurring vendor, always has HST</span></td>
|
||||
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 6px; text-align:center;"><input type="checkbox"></td>
|
||||
<td style="padding: 6px;">2024-01-17</td>
|
||||
<td style="padding: 6px;">Human Care Canada Inc.</td>
|
||||
<td style="padding: 6px; text-align:right; font-weight:600;">-$2,446.20</td>
|
||||
<td style="padding: 6px;"><span style="background:rgba(251,191,36,0.15); color:#fbbf24; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:500;">Flag</span> <span style="opacity:0.7; font-size:12px;">Canadian vendor, ITC likely missing</span></td>
|
||||
<td style="padding: 6px;"><input style="width:100%; padding:4px 8px; font-size:12px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit;" placeholder="Add your note..."></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Bulk action bar -->
|
||||
<div style="padding: 10px 12px; background: rgba(255,255,255,0.03); border-top: 1px solid rgba(255,255,255,0.1); display:flex; gap:8px; align-items:center; flex-wrap: wrap;">
|
||||
<span style="font-size:12px; opacity:0.7; margin-right:4px;">2 selected</span>
|
||||
<button style="padding:5px 12px; font-size:12px; background:#22c55e; border:none; border-radius:4px; color:white; cursor:pointer; font-weight:500;">✓ Apply Recommendations</button>
|
||||
<button style="padding:5px 12px; font-size:12px; background:rgba(251,191,36,0.2); border:1px solid rgba(251,191,36,0.4); border-radius:4px; color:#fbbf24; cursor:pointer; font-weight:500;">⚑ Flag Selected</button>
|
||||
<button style="padding:5px 12px; font-size:12px; background:rgba(96,165,250,0.2); border:1px solid rgba(96,165,250,0.4); border-radius:4px; color:#60a5fa; cursor:pointer; font-weight:500;">+ Create Rules</button>
|
||||
<button style="padding:5px 12px; font-size:12px; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); border-radius:4px; color:inherit; cursor:pointer;">Dismiss Selected</button>
|
||||
<div style="flex:1;"></div>
|
||||
<button style="padding:5px 12px; font-size:12px; background:rgba(139,92,246,0.2); border:1px solid rgba(139,92,246,0.4); border-radius:4px; color:#a78bfa; cursor:pointer; font-weight:500;">✍ Submit All Notes to AI</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<h3>How it works</h3>
|
||||
<ul style="font-size: 14px; line-height: 1.8; opacity: 0.85;">
|
||||
<li><strong>AI Recommendation</strong> column — pre-filled by AI with a colour-coded badge (Dismiss/Flag/Create Rule) + reasoning</li>
|
||||
<li><strong>Your Input</strong> column — editable text field per row for your notes, corrections, or instructions</li>
|
||||
<li><strong>Checkboxes</strong> — select rows for bulk actions</li>
|
||||
<li><strong>Bulk action bar</strong> — Apply Recommendations, Flag, Create Rules, Dismiss, or Submit All Notes back to the AI</li>
|
||||
<li><strong>"Submit All Notes to AI"</strong> — sends your row-level annotations back into the chat so the AI can learn and act on your feedback</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,95 @@
|
||||
<h2>How should AI report tables become interactive?</h2>
|
||||
<p class="subtitle">Looking at the "Missing ITC Bills" report — you want to annotate rows with your input. Which approach feels right?</p>
|
||||
|
||||
<div class="options">
|
||||
<div class="option" data-choice="a" onclick="toggleSelect(this)">
|
||||
<div class="letter">A</div>
|
||||
<div class="content">
|
||||
<h3>Inline Action Column</h3>
|
||||
<p>Every table the AI generates gets an extra column at the right with a <strong>text input + action dropdown</strong> per row. You type your note (e.g., "Exempt - no HST required") and pick an action (Dismiss, Flag, Create Rule, Ask AI). The AI sees your annotations and can act on them.</p>
|
||||
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; font-size: 13px; font-family: monospace;">
|
||||
<table style="width:100%; border-collapse: collapse; font-size: 12px;">
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.15);">
|
||||
<th style="padding: 6px; text-align:left;">Vendor</th>
|
||||
<th style="padding: 6px; text-align:left;">Amount</th>
|
||||
<th style="padding: 6px; text-align:left;">Risk</th>
|
||||
<th style="padding: 6px; text-align:left; color: #fbbf24;">Your Input</th>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<td style="padding: 6px;">Ki Mobility LLC</td>
|
||||
<td style="padding: 6px;">-$14,917.95</td>
|
||||
<td style="padding: 6px;">HST ITC?</td>
|
||||
<td style="padding: 6px;"><input style="width:100px; padding:2px 4px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;" placeholder="Your note..." value="US vendor, no HST"><select style="margin-left:4px; padding:2px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;"><option>Dismiss</option><option>Flag</option><option>Rule</option></select></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 6px;">Savaria Concord</td>
|
||||
<td style="padding: 6px;">-$10,173.00</td>
|
||||
<td style="padding: 6px;">HST ITC?</td>
|
||||
<td style="padding: 6px;"><input style="width:100px; padding:2px 4px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;" placeholder="Your note..."><select style="margin-left:4px; padding:2px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;"><option>Dismiss</option><option>Flag</option><option>Rule</option></select></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="b" onclick="toggleSelect(this)">
|
||||
<div class="letter">B</div>
|
||||
<div class="content">
|
||||
<h3>Row-Click Expandable Panel</h3>
|
||||
<p>Tables render normally, but <strong>clicking a row expands a detail panel</strong> below it with: the AI's recommendation, a text input for your notes, and action buttons (Approve, Dismiss, Create Rule, Ask AI about this). Keeps the table clean, shows detail on demand.</p>
|
||||
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; font-size: 13px;">
|
||||
<div style="padding: 6px; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 12px;">Ki Mobility LLC -$14,917.95 <span style="color:#fbbf24">HST ITC?</span> <span style="font-size:10px; opacity:0.6">Click to expand ▼</span></div>
|
||||
<div style="padding: 10px; margin: 4px 0; background: rgba(251,191,36,0.08); border-left: 3px solid #fbbf24; border-radius: 4px; font-size: 12px;">
|
||||
<div><strong style="color:#fbbf24;">AI Recommendation:</strong> US-based vendor. No HST should apply. Consider dismissing or creating a rule for all Ki Mobility bills.</div>
|
||||
<div style="margin-top: 8px; display:flex; gap:6px; align-items:center;">
|
||||
<input style="flex:1; padding:4px 6px; font-size:11px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;" placeholder="Your note or correction...">
|
||||
<button style="padding:3px 8px; font-size:11px; background:#22c55e; border:none; border-radius:3px; color:white; cursor:pointer;">Dismiss</button>
|
||||
<button style="padding:3px 8px; font-size:11px; background:#3b82f6; border:none; border-radius:3px; color:white; cursor:pointer;">Create Rule</button>
|
||||
<button style="padding:3px 8px; font-size:11px; background:rgba(255,255,255,0.15); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit; cursor:pointer;">Ask AI</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 6px; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 12px; opacity: 0.7;">Savaria Concord -$10,173.00 <span style="color:#fbbf24">HST ITC?</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="c" onclick="toggleSelect(this)">
|
||||
<div class="letter">C</div>
|
||||
<div class="content">
|
||||
<h3>AI Recommendation Column + Bulk Actions</h3>
|
||||
<p>The AI proactively fills a <strong>"Recommendation" column</strong> with its suggested action per row (e.g., "Dismiss - US vendor", "Flag - check with accountant"). You can <strong>edit the recommendation</strong>, check rows, and use bulk action buttons (Apply Selected, Dismiss Selected, Create Rules). The AI pre-fills its best guess so you only edit what's wrong.</p>
|
||||
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; font-size: 13px; font-family: monospace;">
|
||||
<table style="width:100%; border-collapse: collapse; font-size: 12px;">
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.15);">
|
||||
<th style="padding: 6px; width:20px;"><input type="checkbox" checked></th>
|
||||
<th style="padding: 6px; text-align:left;">Vendor</th>
|
||||
<th style="padding: 6px; text-align:left;">Amount</th>
|
||||
<th style="padding: 6px; text-align:left; color: #22c55e;">AI Recommendation</th>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<td style="padding: 6px;"><input type="checkbox" checked></td>
|
||||
<td style="padding: 6px;">Ki Mobility LLC</td>
|
||||
<td style="padding: 6px;">-$14,917.95</td>
|
||||
<td style="padding: 6px; color:#22c55e;">Dismiss - US vendor, no HST</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<td style="padding: 6px;"><input type="checkbox"></td>
|
||||
<td style="padding: 6px;">Savaria Concord</td>
|
||||
<td style="padding: 6px;">-$10,173.00</td>
|
||||
<td style="padding: 6px; color:#fbbf24;">Flag - Canadian vendor, ITC likely missing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 6px;"><input type="checkbox"></td>
|
||||
<td style="padding: 6px;">Joerns Healthcare</td>
|
||||
<td style="padding: 6px;">-$2,392.80</td>
|
||||
<td style="padding: 6px; color:#fbbf24;">Flag - check fiscal position</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="margin-top:8px; display:flex; gap:6px;">
|
||||
<button style="padding:4px 10px; font-size:11px; background:#22c55e; border:none; border-radius:3px; color:white;">Apply Selected</button>
|
||||
<button style="padding:4px 10px; font-size:11px; background:rgba(255,255,255,0.15); border:1px solid rgba(255,255,255,0.2); border-radius:3px; color:inherit;">Create Rules from Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Continuing in terminal...</p>
|
||||
</div>
|
||||
@@ -6,19 +6,20 @@ An AI agent (Claude/GPT with tool-calling) embedded in Odoo 19 Enterprise Accoun
|
||||
## Architecture
|
||||
```
|
||||
fusion_accounting/
|
||||
├── models/ 7 models (6 new + 1 inherit on account.move)
|
||||
├── models/ 7 files (5 new models + 2 inherits: account.move, res.config.settings)
|
||||
├── services/
|
||||
│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop)
|
||||
│ ├── adapters/ Claude + OpenAI adapters with native tool-calling
|
||||
│ ├── tools/ 85 tool functions across 11 domain files
|
||||
│ ├── tools/ 93 tool functions across 11 domain files
|
||||
│ ├── prompts/ System prompt builder + 12 domain-specific prompts
|
||||
│ └── scoring.py Confidence scoring + tier promotion logic
|
||||
├── controllers/ 8 JSON-RPC endpoints
|
||||
├── controllers/ 10 JSON-RPC endpoints
|
||||
├── wizards/ Rule creation wizard
|
||||
├── static/src/ OWL dashboard + chat panel + approval cards
|
||||
├── views/ List/form/search views, menus, settings
|
||||
├── security/ 3 groups (User/Manager/Admin), record rules, ACLs
|
||||
├── data/ 82 tool definitions, 2 default rules, 2 crons
|
||||
├── data/ 88 tool definitions, 2 default rules, 2 crons, 1 sequence
|
||||
├── tests/ API integration tests
|
||||
└── report/ Audit report QWeb template
|
||||
```
|
||||
|
||||
@@ -26,24 +27,62 @@ fusion_accounting/
|
||||
|
||||
### AI Provider Integration
|
||||
- Uses `fusion.api.service` (from fusion_api module) for API key resolution with fallback to `ir.config_parameter` — NO hard dependency on fusion_api
|
||||
- Claude adapter: native `tool_use` blocks, extended thinking enabled (8K budget) for 4.5+ models
|
||||
- Claude adapter: native `tool_use` blocks, extended thinking enabled (8K budget) for all Claude 4.x models
|
||||
- OpenAI adapter: Chat Completions API with o-series reasoning model support (`developer` role, `max_completion_tokens`, `reasoning_effort`)
|
||||
- API keys stored in `ir.config_parameter` with `fusion_accounting.` prefix
|
||||
- API key fields in Settings use `password="True"` widget — labels include "(Fusion AI)" suffix to avoid conflicts with other modules' key fields
|
||||
- **Provider pinning**: Sessions remember which provider was used. If the global provider changes mid-session, the session continues with its original provider to prevent cross-adapter message format contamination.
|
||||
|
||||
### Tool Tiering
|
||||
- **Tier 1** (Free): Read-only, execute immediately — 60+ tools
|
||||
- **Tier 2** (Auto-approved): Low-risk writes, logged — ~10 tools
|
||||
- **Tier 3** (Requires approval): Financial writes, user must approve — ~15 tools
|
||||
- Auto-promotion: Tier 3 → Tier 2 at 95% accuracy over 30+ decisions (atomic SQL counters)
|
||||
- Auto-promotion: Tier 3 → Tier 2 at 95% accuracy over 30+ decisions (atomic SQL counters on `fusion.accounting.rule._record_decision`)
|
||||
- Tool descriptions include tier labels (e.g., `[Tier 3: Requires user approval]`) so the AI knows which tools need approval
|
||||
- When a Tier 3 tool is encountered during the chat loop, the loop short-circuits: a final text response is forced so the AI can present approval cards to the user
|
||||
|
||||
### Tier 3 Approval Flow
|
||||
- When a Tier 3 action is approved/rejected, the session's `message_ids_json` is updated to replace the `pending_approval` placeholder with the actual tool result — this prevents dangling `tool_use` blocks that would cause API errors on the next chat turn
|
||||
- After approval, `scoring.check_promotions()` is called to check if any rules should be promoted
|
||||
|
||||
### Menu Location
|
||||
- **Parent**: `accountant.menu_accounting` (NOT `account.menu_finance` — that's Community Edition only)
|
||||
- Enterprise uses `accountant.menu_accounting` (ID 1663) as the visible menu root
|
||||
- `account.menu_finance` (ID 180) exists but has NO visible children in Enterprise — it's the Community root
|
||||
|
||||
### Session Persistence
|
||||
- Chat sessions stored in `fusion.accounting.session` with `message_ids_json` (JSON text field)
|
||||
- On page load, chat panel calls `/session/latest` to restore the most recent active session
|
||||
- Empty assistant messages (tool-call-only responses with no text) are filtered out by the controller
|
||||
- "New Chat" button closes current session and creates a fresh one
|
||||
- Session name (e.g., FAS/2026/00001) shown in the chat header
|
||||
- **Session ownership**: Controllers verify the current user owns the session (managers can access any session)
|
||||
|
||||
### Rich Text Chat Output
|
||||
- AI responses are rendered as rich HTML, not plain text
|
||||
- Markdown-to-HTML conversion happens client-side in `chat_panel.js` via `mdToHtml()` function
|
||||
- HTML is injected via `innerHTML` on `onMounted` + `onPatched` (NOT via OWL's `markup()` / `t-out` — those proved unreliable in Odoo 19)
|
||||
- The `_renderRichMessages()` method finds `.fusion_rich_slot[data-idx]` divs and sets their innerHTML
|
||||
- Supported: headers (# through #####), **bold**, *italic*, `code`, tables, bullet/numbered lists, horizontal rules, [links](url)
|
||||
- System prompt instructs AI to use markdown formatting and include Odoo record links like `[INV/2026/00123](/odoo/accounting/123)`
|
||||
|
||||
### Interactive Tables (fusion-table)
|
||||
- AI can return `fusion-table` fenced code blocks instead of Markdown tables for actionable results
|
||||
- `mdToHtml()` detects these blocks, extracts JSON, and renders `FusionInteractiveTable` OWL components via `mount()`
|
||||
- **Interactive mode**: checkbox column + data columns + AI Recommendation column (colour-coded badge) + Your Input column (text field per row) + bottom bulk action bar
|
||||
- **Read-only mode**: styled table, no inputs/actions
|
||||
- Actions: Apply Recommendations, Flag Selected, Create Rules, Dismiss Selected, Submit All Notes to AI
|
||||
- Action button clicks format a `[TABLE_ACTION]` structured message and send it back through the chat endpoint
|
||||
- The AI decides per-response whether to use interactive or Markdown tables based on whether the data is actionable
|
||||
- Used for: `find_missing_itc_bills`, `find_duplicate_bills`, `get_overdue_invoices`, `find_draft_entries`, `get_unreconciled_bank_lines`, etc.
|
||||
- NOT used for: `get_profit_loss`, `get_balance_sheet`, `get_trial_balance` (informational, read-only)
|
||||
- All styles use Odoo CSS variables — dark/light mode handled automatically
|
||||
|
||||
### Dashboard Layout
|
||||
- Health cards row at top (6 cards: Bank Recon, AR, AP, HST, Audit Score, Month-End)
|
||||
- Below: side-by-side layout — "Needs Attention" panel (flex-grow) + Chat panel (720px fixed width)
|
||||
- Chat panel is 720px (80% larger than original 400px design)
|
||||
- Dashboard endpoint returns `needs_attention` and `recent_activity` JSON arrays alongside health card metrics
|
||||
|
||||
## Odoo 19 Gotchas (Learned the Hard Way)
|
||||
|
||||
@@ -57,6 +96,12 @@ fusion_accounting/
|
||||
- Components registered as client actions receive props: `action`, `actionId`, `updateActionState`, `className`
|
||||
- Must use `static props = ["*"]` (accept any) — NOT `static props = []` (accept none)
|
||||
|
||||
### OWL Rich HTML Rendering
|
||||
- `markup()` from `@odoo/owl` + `t-out` is UNRELIABLE in Odoo 19 for rendering HTML in OWL components
|
||||
- Use `onMounted` + `onPatched` hooks to find DOM elements and set `innerHTML` directly
|
||||
- Pattern: render a placeholder `<div class="slot" t-att-data-idx="index"/>`, then in the hook find it and set `.innerHTML`
|
||||
- Always use BOTH `onMounted` AND `onPatched` — `onPatched` alone misses the first render
|
||||
|
||||
### Cron Safe Eval
|
||||
- NO `import` statements (forbidden opcode `IMPORT_NAME`)
|
||||
- `datetime` module available as `datetime` (use `datetime.datetime.now()`, `datetime.timedelta()`)
|
||||
@@ -65,14 +110,20 @@ fusion_accounting/
|
||||
### read_group Deprecated
|
||||
- `read_group()` is deprecated in Odoo 19 — use `_read_group()` instead
|
||||
- Still works but throws DeprecationWarning
|
||||
- Dashboard `accounting_dashboard.py` still uses `read_group()` — migrate to `_read_group()` when the new API is stable
|
||||
|
||||
### Config Parameter Values
|
||||
- When changing a Selection field's options, the stored DB value in `ir_config_parameter` must match one of the new options or Settings page will crash with `ValueError: Wrong value`
|
||||
- Fix: UPDATE the value in DB after changing selection options
|
||||
- Fix: UPDATE the value in DB after changing selection options:
|
||||
```sql
|
||||
UPDATE ir_config_parameter SET value = 'new_value' WHERE key = 'fusion_accounting.field_name';
|
||||
```
|
||||
|
||||
### Field Label Conflicts
|
||||
- Odoo warns if two fields on the same model have the same `string` label
|
||||
- Our `display_name_field` conflicted with built-in `display_name` — renamed string to "Tool Label"
|
||||
- API key fields use "(Fusion AI)" suffix to avoid label conflicts with other modules
|
||||
- Tool model uses `domain` (not `domain_name`) and `parameters_schema` (not `parameters`) as field names
|
||||
|
||||
### Group Assignment
|
||||
- `implied_ids` on groups only applies to NEWLY added users, not existing ones
|
||||
@@ -85,28 +136,36 @@ fusion_accounting/
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
### TransientModel in Controllers
|
||||
- Use `.new({...})` NOT `.create({...})` for TransientModels in controller endpoints
|
||||
- `.create()` writes a DB row on every request; `.new()` is in-memory only
|
||||
- Dashboard controller uses `.new()` to compute health metrics without DB writes
|
||||
|
||||
## Server Details
|
||||
- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`)
|
||||
- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL)
|
||||
- **Database**: westin-v19
|
||||
- **Module path**: `/mnt/extra-addons/fusion_accounting/`
|
||||
- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages`
|
||||
- **URL**: erp.westinhealthcare.ca
|
||||
|
||||
## Deployment Commands
|
||||
```bash
|
||||
# Deploy module to server
|
||||
# Full deploy cycle (clean + copy + upgrade + restart)
|
||||
ssh odoo-westin "docker exec -u 0 odoo-dev-app rm -rf /mnt/extra-addons/fusion_accounting"
|
||||
scp -r "K:\Github\Odoo-Modules\fusion_accounting" odoo-westin:/tmp/fusion_accounting
|
||||
ssh odoo-westin "docker cp /tmp/fusion_accounting odoo-dev-app:/mnt/extra-addons/fusion_accounting && rm -rf /tmp/fusion_accounting"
|
||||
|
||||
# Upgrade module (use alt port to avoid conflict with running instance)
|
||||
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_accounting --stop-after-init --http-port=8099 -c /etc/odoo/odoo.conf"
|
||||
|
||||
# Restart container
|
||||
ssh odoo-westin "docker restart odoo-dev-app"
|
||||
|
||||
# Check logs
|
||||
ssh odoo-westin "docker logs odoo-dev-app --tail 100"
|
||||
|
||||
# Quick DB queries
|
||||
ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -t -c \"<SQL>\""
|
||||
|
||||
# Check module state
|
||||
ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -t -c \"SELECT name, state, latest_version FROM ir_module_module WHERE name = 'fusion_accounting';\""
|
||||
```
|
||||
|
||||
## Security Groups
|
||||
@@ -118,19 +177,37 @@ ssh odoo-westin "docker logs odoo-dev-app --tail 100"
|
||||
|
||||
Auto-assigned: `account.group_account_user` → User, `account.group_account_manager` → Admin
|
||||
|
||||
## Models
|
||||
| Model | Type | Purpose |
|
||||
## Controller Endpoints
|
||||
| Route | Auth | Purpose |
|
||||
|---|---|---|
|
||||
| `fusion.accounting.session` | Model | Chat sessions with message JSON storage |
|
||||
| `fusion.accounting.match.history` | Model | Every AI tool call + decision (approved/rejected/pending) |
|
||||
| `fusion.accounting.rule` | Model | Fusion Rules engine with versioning and auto-promotion |
|
||||
| `fusion.accounting.tool` | Model | Tool registry (82 tools seeded from XML) |
|
||||
| `fusion.accounting.dashboard` | TransientModel | Computed health metrics (use `.new()` not `.create()`) |
|
||||
| `fusion.accounting.agent` | AbstractModel | AI orchestrator |
|
||||
| `fusion.accounting.adapter.claude` | AbstractModel | Claude tool-calling adapter |
|
||||
| `fusion.accounting.adapter.openai` | AbstractModel | OpenAI tool-calling adapter |
|
||||
| `fusion.accounting.scoring` | AbstractModel | Confidence scoring |
|
||||
| `account.move` (inherit) | Model | Post-action audit hook |
|
||||
| `/fusion_accounting/session/create` | user | Create new chat session |
|
||||
| `/fusion_accounting/session/close` | user (ownership check) | Close active session |
|
||||
| `/fusion_accounting/session/latest` | user (own sessions only) | Load most recent active session + messages |
|
||||
| `/fusion_accounting/session/history` | user (ownership check, managers see all) | Load specific session messages |
|
||||
| `/fusion_accounting/chat` | user (ownership check) | Send message, get AI response |
|
||||
| `/fusion_accounting/approve` | user + manager group check | Approve single Tier 3 action |
|
||||
| `/fusion_accounting/reject` | user + manager group check | Reject single Tier 3 action |
|
||||
| `/fusion_accounting/approve_all` | user + manager group check | Batch approve multiple actions |
|
||||
| `/fusion_accounting/reject_all` | user + manager group check | Batch reject multiple actions |
|
||||
| `/fusion_accounting/dashboard/data` | user | Get dashboard health card metrics + needs_attention + recent_activity |
|
||||
|
||||
Note: Approve/reject endpoints use `auth='user'` at the decorator level with an imperative `has_group()` check inside the handler (Odoo has no built-in `auth='manager'`).
|
||||
|
||||
## Models
|
||||
| Model | Type | Location | Purpose |
|
||||
|---|---|---|---|
|
||||
| `fusion.accounting.session` | Model | models/ | Chat sessions with message JSON storage |
|
||||
| `fusion.accounting.match.history` | Model | models/ | Every AI tool call + decision (approved/rejected/pending) |
|
||||
| `fusion.accounting.rule` | Model | models/ | Fusion Rules engine with versioning and auto-promotion |
|
||||
| `fusion.accounting.tool` | Model | models/ | Tool registry (82 tools seeded from XML) |
|
||||
| `fusion.accounting.dashboard` | TransientModel | models/ | Computed health metrics (use `.new()` not `.create()`) |
|
||||
| `res.config.settings` (inherit) | TransientModel | models/ | Settings page (API keys, thresholds, toggles) |
|
||||
| `account.move` (inherit) | Model | models/ | Post-action audit hook |
|
||||
| `fusion.accounting.agent` | AbstractModel | services/ | AI orchestrator |
|
||||
| `fusion.accounting.adapter.claude` | AbstractModel | services/ | Claude tool-calling adapter |
|
||||
| `fusion.accounting.adapter.openai` | AbstractModel | services/ | OpenAI tool-calling adapter |
|
||||
| `fusion.accounting.scoring` | AbstractModel | services/ | Confidence scoring |
|
||||
| `fusion.accounting.rule.wizard` | TransientModel | wizards/ | Quick-create rule from chat suggestion |
|
||||
|
||||
## AI Models Available
|
||||
**Claude** (default: claude-sonnet-4-6):
|
||||
@@ -146,9 +223,26 @@ Auto-assigned: `account.group_account_user` → User, `account.group_account_man
|
||||
- NO hardcoded colours — use CSS variables (`var(--o-border-color)`, `var(--bs-body-color-rgb)`) and Bootstrap utility classes
|
||||
- Must work in both light and dark mode
|
||||
- Box shadows: use `rgba(var(--bs-body-color-rgb), 0.1)` not `rgba(0,0,0,0.1)`
|
||||
- AI messages use `var(--o-view-background-color)` background + `var(--o-border-color)` border
|
||||
- Links use `var(--o-action-color)` for theme awareness
|
||||
|
||||
### HST Filing Workflow (4-Phase AI-Driven)
|
||||
- Phase 1: AI runs all HST reports (tax report, missing ITCs, compliance audit, HST balance)
|
||||
- Phase 2: AI sweeps ALL bank accounts for unreconciled expense payments
|
||||
- Phase 3: Per-line processing — check for existing bills, check history for coding patterns, ask about HST, create bills, register payments
|
||||
- Phase 4: Re-run reports to verify updated HST position
|
||||
- New tools added: `search_partners` (Tier 1), `find_similar_bank_lines` (Tier 1), `get_bank_line_details` (Tier 1), `create_vendor_bill` (Tier 3), `register_bill_payment` (Tier 3), `create_expense_entry` (Tier 3)
|
||||
- Two paths for recording expenses: (a) formal vendor bill + payment, or (b) direct GL entry in MISC journal with optional HST split
|
||||
- The `create_expense_entry` tool posts directly to the Miscellaneous Operations journal — debit expense + debit HST ITC (2006) + credit bank
|
||||
- Domain prompt (`hst_management` in domain_prompts.py) includes bank journal IDs and the full 4-phase workflow instructions
|
||||
|
||||
## Known Issues / Future Work
|
||||
- `read_group()` deprecation warnings — migrate to `_read_group()` when format is documented
|
||||
- `verify_source_deductions`, `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2)
|
||||
- `account.return` model used in HST tools may not exist in all Odoo 19 setups — needs try/except guard
|
||||
- `read_group()` deprecation warnings in `accounting_dashboard.py` — migrate to `_read_group()` when the new API format is stable
|
||||
- `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2)
|
||||
- `get_payroll_schedule`, `verify_source_deductions`, `verify_payroll_deductions` are stubs (Phase 2 — fusion_payroll integration)
|
||||
- `answer_financial_question` is a stub (returns message to use other tools instead)
|
||||
- Batch approval "Approve All" / "Reject All" buttons are in the chat panel but not yet in the match history list view
|
||||
- "Needs Attention" panel shows placeholder text in the dashboard — the data is computed and returned by the API but the frontend rendering needs to be connected
|
||||
- Consider switching OpenAI adapter from Chat Completions API to Responses API for better tool handling with newer models
|
||||
- `o1` model does not support tool calling — no guard in place (o3/o4-mini do support it)
|
||||
- Multi-company record rule missing on `fusion.accounting.session` — add if multi-company usage is needed
|
||||
|
||||
@@ -42,6 +42,8 @@ Built by Nexa Systems Inc.
|
||||
'views/match_history_views.xml',
|
||||
'views/rule_views.xml',
|
||||
'views/dashboard_views.xml',
|
||||
'views/vendor_tax_profile_views.xml',
|
||||
'views/recurring_pattern_views.xml',
|
||||
'views/menus.xml',
|
||||
# Wizards
|
||||
'wizards/rule_wizard.xml',
|
||||
|
||||
@@ -9,6 +9,14 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
class FusionAccountingChatController(http.Controller):
|
||||
|
||||
def _check_session_ownership(self, session):
|
||||
"""S1-S3: Verify the current user owns the session."""
|
||||
if session.user_id.id != request.env.user.id:
|
||||
# Allow managers to access any session
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Access denied: you do not own this session'}
|
||||
return None
|
||||
|
||||
@http.route('/fusion_accounting/session/create', type='jsonrpc', auth='user')
|
||||
def create_session(self, context_domain=None, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].create({
|
||||
@@ -21,7 +29,13 @@ class FusionAccountingChatController(http.Controller):
|
||||
@http.route('/fusion_accounting/session/close', type='jsonrpc', auth='user')
|
||||
def close_session(self, session_id, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if session.exists() and session.state == 'active':
|
||||
if not session.exists():
|
||||
return {'status': 'closed'}
|
||||
# S2: Ownership check
|
||||
error = self._check_session_ownership(session)
|
||||
if error:
|
||||
return error
|
||||
if session.state == 'active':
|
||||
session.action_close_session()
|
||||
return {'status': 'closed'}
|
||||
|
||||
@@ -29,6 +43,12 @@ class FusionAccountingChatController(http.Controller):
|
||||
def chat(self, session_id, message, context=None, **kwargs):
|
||||
if not message:
|
||||
return {'error': 'Message is required'}
|
||||
# S3: Ownership check
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if session.exists():
|
||||
error = self._check_session_ownership(session)
|
||||
if error:
|
||||
return error
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
result = agent.chat(int(session_id), message, context=context)
|
||||
return result
|
||||
@@ -51,17 +71,35 @@ class FusionAccountingChatController(http.Controller):
|
||||
|
||||
@http.route('/fusion_accounting/dashboard/data', type='jsonrpc', auth='user')
|
||||
def dashboard_data(self, **kwargs):
|
||||
dashboard = request.env['fusion.accounting.dashboard'].new({
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
return {
|
||||
'bank_recon': {'count': dashboard.bank_recon_count, 'amount': dashboard.bank_recon_amount},
|
||||
'ar': {'total': dashboard.ar_total, 'overdue_count': dashboard.ar_overdue_count},
|
||||
'ap': {'total': dashboard.ap_total, 'due_this_week': dashboard.ap_due_this_week},
|
||||
'hst': {'balance': dashboard.hst_balance},
|
||||
'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count},
|
||||
'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items},
|
||||
}
|
||||
# E2: Wrap in try/except so dashboard doesn't return 500
|
||||
try:
|
||||
dashboard = request.env['fusion.accounting.dashboard'].new({
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
return {
|
||||
'bank_recon': {'count': dashboard.bank_recon_count, 'amount': dashboard.bank_recon_amount},
|
||||
'ar': {'total': dashboard.ar_total, 'overdue_count': dashboard.ar_overdue_count},
|
||||
'ap': {'total': dashboard.ap_total, 'due_this_week': dashboard.ap_due_this_week},
|
||||
'hst': {'balance': dashboard.hst_balance},
|
||||
'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count},
|
||||
'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items},
|
||||
# E1: Include needs_attention and recent_activity
|
||||
'needs_attention': json.loads(dashboard.needs_attention_json or '[]'),
|
||||
'recent_activity': json.loads(dashboard.recent_activity_json or '[]'),
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.exception("Dashboard data computation failed")
|
||||
return {
|
||||
'error': 'Dashboard data could not be computed',
|
||||
'bank_recon': {'count': 0, 'amount': 0},
|
||||
'ar': {'total': 0, 'overdue_count': 0},
|
||||
'ap': {'total': 0, 'due_this_week': 0},
|
||||
'hst': {'balance': 0},
|
||||
'audit': {'score': 0, 'flags': 0},
|
||||
'month_end': {'status': 'Unknown', 'open_items': 0},
|
||||
'needs_attention': [],
|
||||
'recent_activity': [],
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user')
|
||||
def approve_all(self, match_history_ids, **kwargs):
|
||||
@@ -74,7 +112,9 @@ class FusionAccountingChatController(http.Controller):
|
||||
result = agent.approve_action(int(mid))
|
||||
results.append({'id': mid, 'status': 'approved', 'result': result})
|
||||
except Exception as e:
|
||||
results.append({'id': mid, 'status': 'error', 'error': str(e)})
|
||||
# S4: Sanitize exception — log full error, return generic message
|
||||
_logger.exception("Error approving match history %s", mid)
|
||||
results.append({'id': mid, 'status': 'error', 'error': 'Action could not be approved. Check server logs for details.'})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user')
|
||||
@@ -86,19 +126,58 @@ class FusionAccountingChatController(http.Controller):
|
||||
for mid in match_history_ids:
|
||||
try:
|
||||
result = agent.reject_action(int(mid), reason)
|
||||
results.append({'id': mid, 'status': 'rejected'})
|
||||
# E3: Consistent return shape with approve_all
|
||||
results.append({'id': mid, 'status': 'rejected', 'result': result})
|
||||
except Exception as e:
|
||||
results.append({'id': mid, 'status': 'error', 'error': str(e)})
|
||||
# S4: Sanitize exception
|
||||
_logger.exception("Error rejecting match history %s", mid)
|
||||
results.append({'id': mid, 'status': 'error', 'error': 'Action could not be rejected. Check server logs for details.'})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_accounting/session/list', type='jsonrpc', auth='user')
|
||||
def session_list(self, limit=20, **kwargs):
|
||||
"""List recent sessions for the session picker dropdown."""
|
||||
sessions = request.env['fusion.accounting.session'].search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
], order='write_date desc', limit=int(limit))
|
||||
return {
|
||||
'sessions': [{
|
||||
'id': s.id,
|
||||
'name': s.name,
|
||||
'state': s.state,
|
||||
'date': s.write_date.isoformat() if s.write_date else '',
|
||||
'message_count': len(json.loads(s.message_ids_json or '[]')),
|
||||
'ai_model': s.ai_model or '',
|
||||
} for s in sessions],
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/session/latest', type='jsonrpc', auth='user')
|
||||
def session_latest(self, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].search([
|
||||
# Find the most recent active session that has messages first,
|
||||
# fall back to any active session (including empty ones)
|
||||
sessions = request.env['fusion.accounting.session'].search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
('state', '=', 'active'),
|
||||
], limit=1, order='create_date desc')
|
||||
if not session:
|
||||
], order='write_date desc', limit=10)
|
||||
if not sessions:
|
||||
return {'session_id': None, 'messages': [], 'name': None}
|
||||
|
||||
# Prefer a session with actual messages
|
||||
session = None
|
||||
for s in sessions:
|
||||
msg_json = s.message_ids_json or '[]'
|
||||
if msg_json != '[]' and len(msg_json) > 5:
|
||||
session = s
|
||||
break
|
||||
# If no session has messages, use the newest one
|
||||
if not session:
|
||||
session = sessions[0]
|
||||
|
||||
# Clean up empty stale sessions (created but never used)
|
||||
for s in sessions:
|
||||
if s.id != session.id and (s.message_ids_json or '[]') == '[]':
|
||||
s.write({'state': 'closed'})
|
||||
|
||||
messages = json.loads(session.message_ids_json or '[]')
|
||||
display_messages = []
|
||||
for msg in messages:
|
||||
@@ -119,6 +198,10 @@ class FusionAccountingChatController(http.Controller):
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if not session.exists():
|
||||
return {'error': 'Session not found'}
|
||||
# S1: Ownership check
|
||||
error = self._check_session_ownership(session)
|
||||
if error:
|
||||
return error
|
||||
return {
|
||||
'messages': json.loads(session.message_ids_json or '[]'),
|
||||
'session_id': session.id,
|
||||
|
||||
@@ -36,4 +36,37 @@ for rule in model.search([('active', '=', True), ('approval_tier', '=', 'needs_a
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Weekly recurring pattern rebuild -->
|
||||
<record id="cron_fusion_recurring_patterns" model="ir.cron">
|
||||
<field name="name">Fusion AI: Rebuild Recurring Patterns</field>
|
||||
<field name="model_id" ref="model_fusion_recurring_pattern"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._rebuild_all_patterns(min_occurrences=3)</field>
|
||||
<field name="interval_number">7</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily auto-reconcile inter-account transfers (CC payments) -->
|
||||
<record id="cron_fusion_transfer_reconcile" model="ir.cron">
|
||||
<field name="name">Fusion AI: Auto-Reconcile Inter-Account Transfers</field>
|
||||
<field name="model_id" ref="model_fusion_accounting_agent"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_reconcile_transfers()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Weekly vendor tax profile rebuild -->
|
||||
<record id="cron_fusion_vendor_profiles" model="ir.cron">
|
||||
<field name="name">Fusion AI: Rebuild Vendor Tax Profiles</field>
|
||||
<field name="model_id" ref="model_fusion_vendor_tax_profile"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._rebuild_all_profiles(min_bills=3)</field>
|
||||
<field name="interval_number">7</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<record id="tool_sum_payments_by_date" model="fusion.accounting.tool">
|
||||
<field name="name">sum_payments_by_date</field>
|
||||
<field name="display_name_field">Sum Payments by Date</field>
|
||||
<field name="description">Sum payment journal items for a date range, useful for matching card batch deposits.</field>
|
||||
<field name="description">Sum payment journal items for a date range. IMPORTANT: You MUST pass journal_ids to filter to specific journals (e.g., the card/POS journal). Without journal_ids, returns totals across ALL company journals which will be misleadingly large. Use this to verify card batch deposit amounts against the card payment journal for the prior business day.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "journal_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["date_from", "date_to"]}</field>
|
||||
@@ -697,4 +697,78 @@
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
|
||||
</record>
|
||||
|
||||
<!-- HST Filing Workflow Tools (added 2026-04-03) -->
|
||||
|
||||
<record id="tool_search_partners" model="fusion.accounting.tool">
|
||||
<field name="name">search_partners</field>
|
||||
<field name="display_name_field">Search Partners</field>
|
||||
<field name="description">Search for vendors/contacts by name keyword. Use this to resolve bank line descriptions (e.g., "AMAZON") to the correct Odoo partner record before creating bills. Pass supplier_only=true to filter to vendors only.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"keyword": {"type": "string", "description": "Name keyword to search (min 2 chars)"}, "supplier_only": {"type": "boolean", "description": "Only return suppliers/vendors"}, "limit": {"type": "integer"}}, "required": ["keyword"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_find_similar_bank_lines" model="fusion.accounting.tool">
|
||||
<field name="name">find_similar_bank_lines</field>
|
||||
<field name="display_name_field">Find Similar Bank Lines</field>
|
||||
<field name="description">Search past RECONCILED bank lines with similar payment_ref descriptions. Returns the expense account, tax treatment, and partner used for each historical match. Use this to check how similar expenses were coded in the past before proposing a new bill.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"keyword": {"type": "string", "description": "Keyword from payment_ref to search (min 3 chars)"}, "limit": {"type": "integer"}}, "required": ["keyword"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_get_bank_line_details" model="fusion.accounting.tool">
|
||||
<field name="name">get_bank_line_details</field>
|
||||
<field name="display_name_field">Get Bank Line Details</field>
|
||||
<field name="description">Get full details of a single unreconciled bank statement line. Also searches for existing vendor bills matching the amount and date, and suggests a partner based on the payment description. Use this to check if a bill already exists before creating a new one.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"line_id": {"type": "integer", "description": "Bank statement line ID"}}, "required": ["line_id"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_create_vendor_bill" model="fusion.accounting.tool">
|
||||
<field name="name">create_vendor_bill</field>
|
||||
<field name="display_name_field">Create Vendor Bill</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Create a vendor bill (account.move in_invoice) with expense lines and tax. Use after confirming the expense details with the user. Pass post=true to auto-post the bill after creation.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer", "description": "Vendor partner ID"}, "invoice_date": {"type": "string", "description": "Bill date (YYYY-MM-DD)"}, "lines": {"type": "array", "items": {"type": "object", "properties": {"description": {"type": "string"}, "account_id": {"type": "integer"}, "price_unit": {"type": "number"}, "quantity": {"type": "number"}, "tax_ids": {"type": "array", "items": {"type": "integer"}}}}, "description": "Invoice line items"}, "post": {"type": "boolean", "description": "Auto-post the bill after creation"}}, "required": ["partner_id", "invoice_date", "lines"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_register_bill_payment" model="fusion.accounting.tool">
|
||||
<field name="name">register_bill_payment</field>
|
||||
<field name="display_name_field">Register Bill Payment</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Register a payment on a posted vendor bill from a specific bank journal. Optionally reconcile the payment to a bank statement line. Use after create_vendor_bill to complete the full bill+payment+reconciliation flow.</field>
|
||||
<field name="domain">accounts_payable</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"bill_id": {"type": "integer", "description": "Posted bill ID (account.move)"}, "journal_id": {"type": "integer", "description": "Bank journal ID for payment"}, "payment_date": {"type": "string", "description": "Payment date (YYYY-MM-DD)"}, "amount": {"type": "number", "description": "Payment amount (defaults to bill total)"}, "statement_line_id": {"type": "integer", "description": "Bank statement line ID to reconcile with"}}, "required": ["bill_id", "journal_id"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_check_recurring_pattern" model="fusion.accounting.tool">
|
||||
<field name="name">check_recurring_pattern</field>
|
||||
<field name="display_name_field">Check Recurring Pattern</field>
|
||||
<field name="description">Check if a bank line matches a known recurring payment pattern. Returns the historical account coding, HST treatment, partner, and reconciliation model if one exists. ALWAYS call this FIRST for every unreconciled bank line — if a recurring pattern exists, follow its instructions instead of asking the user. Pass line_id to auto-extract ref and amount.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">1</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"line_id": {"type": "integer", "description": "Bank statement line ID"}, "payment_ref": {"type": "string", "description": "Payment reference text (auto-extracted if line_id provided)"}, "amount": {"type": "number", "description": "Transaction amount (auto-extracted if line_id provided)"}}, "required": []}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_match_internal_transfers" model="fusion.accounting.tool">
|
||||
<field name="name">match_internal_transfers</field>
|
||||
<field name="display_name_field">Match Internal Transfers</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Find and match inter-account transfers between two bank journals (e.g., Scotia Current ↔ Scotia Visa). Matches EXACT amounts within 2 days. ONLY matches when there is exactly one candidate — skips ambiguous cases. First call with execute=false to preview pairs, then execute=true to reconcile. Scotia Current=50, Scotia Visa=51, RBC Chequing=53, RBC Visa=28.</field>
|
||||
<field name="domain">bank_reconciliation</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"journal_a_id": {"type": "integer", "description": "First bank journal ID"}, "journal_b_id": {"type": "integer", "description": "Second bank journal ID"}, "date_from": {"type": "string"}, "date_to": {"type": "string"}, "max_days_apart": {"type": "integer", "description": "Max days between matching lines (default 2)"}, "execute": {"type": "boolean", "description": "false=preview pairs only, true=actually reconcile"}}, "required": ["journal_a_id", "journal_b_id"]}</field>
|
||||
</record>
|
||||
|
||||
<record id="tool_create_expense_entry" model="fusion.accounting.tool">
|
||||
<field name="name">create_expense_entry</field>
|
||||
<field name="display_name_field">Create Direct GL Expense</field>
|
||||
<field name="description">[Tier 3: Requires user approval] Create a direct GL expense entry in the Miscellaneous Operations journal. Alternative to creating a vendor bill — posts immediately. If has_hst=true, automatically splits the amount into net expense + 13% HST ITC on the 2006 account. Use this for small expenses where a formal vendor bill is not needed.</field>
|
||||
<field name="domain">hst_management</field>
|
||||
<field name="tier">3</field>
|
||||
<field name="parameters_schema">{"type": "object", "properties": {"date": {"type": "string", "description": "Entry date (YYYY-MM-DD)"}, "description": {"type": "string", "description": "Expense description"}, "expense_account_id": {"type": "integer", "description": "GL expense account ID"}, "amount": {"type": "number", "description": "Total amount including HST if applicable"}, "has_hst": {"type": "boolean", "description": "Whether HST (13%) is included in the amount"}, "bank_journal_id": {"type": "integer", "description": "Bank journal for the credit side"}}, "required": ["date", "description", "expense_account_id", "amount"]}</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -5,3 +5,5 @@ from . import accounting_match_history
|
||||
from . import accounting_rule
|
||||
from . import accounting_dashboard
|
||||
from . import account_move_hook
|
||||
from . import vendor_tax_profile
|
||||
from . import recurring_pattern
|
||||
|
||||
@@ -34,9 +34,14 @@ class AccountMoveAuditHook(models.Model):
|
||||
for line in move.line_ids:
|
||||
if not line.account_id:
|
||||
issues.append(f'Line missing account: {line.name}')
|
||||
if line.product_id and not line.tax_ids:
|
||||
if move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'):
|
||||
issues.append(f'Missing tax on product line: {line.product_id.name}')
|
||||
# M6: Only flag missing tax when the product has taxes configured
|
||||
# (avoids false positives for HST-exempt healthcare services)
|
||||
if (line.product_id and not line.tax_ids
|
||||
and move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')):
|
||||
# Check if the product has default taxes configured
|
||||
product_taxes = line.product_id.taxes_id if move.move_type in ('out_invoice', 'out_refund') else line.product_id.supplier_taxes_id
|
||||
if product_taxes:
|
||||
issues.append(f'Missing tax on product line: {line.product_id.name} (product has taxes configured but line has none)')
|
||||
|
||||
if not move.line_ids:
|
||||
issues.append('Entry has no lines')
|
||||
|
||||
@@ -153,11 +153,15 @@ class FusionAccountingDashboard(models.TransientModel):
|
||||
if balance > 0.01:
|
||||
issues += 1
|
||||
|
||||
gaps = self.env['account.move'].search_count([
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
('made_sequence_gap', '=', True),
|
||||
])
|
||||
# M4: Guard against made_sequence_gap field not existing
|
||||
try:
|
||||
gaps = self.env['account.move'].search_count([
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
('made_sequence_gap', '=', True),
|
||||
])
|
||||
except (ValueError, KeyError):
|
||||
gaps = 0
|
||||
issues += gaps
|
||||
|
||||
pending_approvals = self.env['fusion.accounting.match.history'].search_count([
|
||||
@@ -267,7 +271,7 @@ class FusionAccountingDashboard(models.TransientModel):
|
||||
rec.recent_activity_json = json.dumps([{
|
||||
'tool': r.tool_name,
|
||||
'decision': r.decision,
|
||||
'date': str(r.proposed_at),
|
||||
'date': r.proposed_at.isoformat() if r.proposed_at else '',
|
||||
'amount': r.amount,
|
||||
} for r in recent])
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class FusionAccountingRule(models.Model):
|
||||
if (rec.approval_tier == 'needs_approval'
|
||||
and rec.total_uses >= rec.min_sample_size
|
||||
and rec.confidence_score >= rec.promotion_threshold):
|
||||
rec.approval_tier = 'auto'
|
||||
rec.write({'approval_tier': 'auto'})
|
||||
_logger.info(
|
||||
"Rule '%s' promoted to auto-approved (confidence=%.2f, uses=%d)",
|
||||
rec.name, rec.confidence_score, rec.total_uses,
|
||||
@@ -116,5 +116,6 @@ class FusionAccountingRule(models.Model):
|
||||
def action_rollback(self):
|
||||
for rec in self:
|
||||
if rec.parent_rule_id:
|
||||
rec.active = False
|
||||
rec.parent_rule_id.active = True
|
||||
# M5: Use write() to trigger tracking on tracked fields
|
||||
rec.write({'active': False})
|
||||
rec.parent_rule_id.write({'active': True})
|
||||
|
||||
216
fusion_accounting/models/recurring_pattern.py
Normal file
216
fusion_accounting/models/recurring_pattern.py
Normal file
@@ -0,0 +1,216 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionRecurringPattern(models.Model):
|
||||
_name = 'fusion.recurring.pattern'
|
||||
_description = 'Recurring Bank Transaction Pattern (AI Cache)'
|
||||
_order = 'occurrences desc'
|
||||
|
||||
name = fields.Char(string='Pattern Name', required=True)
|
||||
ref_keyword = fields.Char(
|
||||
string='Reference Keyword',
|
||||
help='The payment_ref substring that identifies this pattern.',
|
||||
index=True,
|
||||
)
|
||||
amount = fields.Float(string='Amount', digits=(12, 2))
|
||||
amount_is_fixed = fields.Boolean(
|
||||
string='Fixed Amount',
|
||||
help='True if the amount is always the same. False if it varies.',
|
||||
)
|
||||
journal_id = fields.Many2one('account.journal', string='Bank Journal')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# How this was coded historically
|
||||
expense_account_id = fields.Many2one(
|
||||
'account.account', string='Expense Account',
|
||||
)
|
||||
expense_account_code = fields.Char(
|
||||
related='expense_account_id.code', string='Account Code', store=True,
|
||||
)
|
||||
has_hst = fields.Boolean(string='Has HST')
|
||||
partner_id = fields.Many2one('res.partner', string='Partner')
|
||||
reconcile_model_id = fields.Many2one(
|
||||
'account.reconcile.model', string='Reconciliation Model',
|
||||
help='If this pattern was handled by a reconciliation model.',
|
||||
)
|
||||
|
||||
# AI-readable instructions
|
||||
action_note = fields.Text(
|
||||
string='Action (AI-Readable)',
|
||||
help='Plain English instructions for the AI on how to handle this pattern.',
|
||||
)
|
||||
|
||||
# Stats
|
||||
occurrences = fields.Integer(string='Times Seen')
|
||||
first_seen = fields.Date(string='First Seen')
|
||||
last_seen = fields.Date(string='Last Seen')
|
||||
last_computed = fields.Datetime(string='Last Computed')
|
||||
|
||||
_sql_constraints = [
|
||||
('pattern_uniq', 'unique(ref_keyword, amount, company_id)',
|
||||
'One pattern per keyword+amount per company'),
|
||||
]
|
||||
|
||||
def _rebuild_all_patterns(self, min_occurrences=3, since='2024-01-01'):
|
||||
"""Scan reconciled bank lines for recurring patterns and cache how they were coded."""
|
||||
_logger.info("Rebuilding recurring patterns (min=%d, since=%s)...", min_occurrences, since)
|
||||
companies = self.env['res.company'].search([])
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
|
||||
for company in companies:
|
||||
# Step 1: Find recurring ref+amount combinations
|
||||
self.env.cr.execute("""
|
||||
SELECT LEFT(bsl.payment_ref, 60) as ref_pattern,
|
||||
bsl.amount,
|
||||
count(*) as occurrences,
|
||||
MIN(am.date) as first_seen,
|
||||
MAX(am.date) as last_seen,
|
||||
MODE() WITHIN GROUP (ORDER BY am.journal_id) as journal_id
|
||||
FROM account_bank_statement_line bsl
|
||||
JOIN account_move am ON bsl.move_id = am.id
|
||||
WHERE bsl.is_reconciled = true
|
||||
AND am.company_id = %s
|
||||
AND am.date >= %s
|
||||
AND bsl.payment_ref IS NOT NULL
|
||||
AND bsl.payment_ref != ''
|
||||
GROUP BY LEFT(bsl.payment_ref, 60), bsl.amount
|
||||
HAVING count(*) >= %s
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 200
|
||||
""", (company.id, since, min_occurrences))
|
||||
patterns = self.env.cr.dictfetchall()
|
||||
|
||||
for pat in patterns:
|
||||
ref = pat['ref_pattern'].strip()
|
||||
if not ref or len(ref) < 3:
|
||||
continue
|
||||
|
||||
# Step 2: Trace how one instance was coded
|
||||
self.env.cr.execute("""
|
||||
SELECT aml.account_id, aml.tax_line_id, aml.partner_id
|
||||
FROM account_bank_statement_line bsl
|
||||
JOIN account_move am ON bsl.move_id = am.id
|
||||
JOIN account_move_line aml ON aml.move_id = am.id
|
||||
WHERE bsl.is_reconciled = true
|
||||
AND bsl.payment_ref ILIKE %s
|
||||
AND bsl.amount = %s
|
||||
AND am.company_id = %s
|
||||
AND aml.display_type NOT IN ('line_section', 'line_note')
|
||||
AND aml.account_id NOT IN (
|
||||
SELECT default_account_id FROM account_journal
|
||||
WHERE company_id = %s AND default_account_id IS NOT NULL
|
||||
)
|
||||
ORDER BY bsl.id DESC
|
||||
LIMIT 5
|
||||
""", (f'%{ref[:40]}%', pat['amount'], company.id, company.id))
|
||||
coded_lines = self.env.cr.dictfetchall()
|
||||
|
||||
expense_account_id = None
|
||||
has_hst = False
|
||||
partner_id = None
|
||||
|
||||
for cl in coded_lines:
|
||||
if cl['tax_line_id']:
|
||||
has_hst = True
|
||||
elif cl['account_id'] and not expense_account_id:
|
||||
acct = self.env['account.account'].browse(cl['account_id'])
|
||||
if acct.exists() and acct.account_type in (
|
||||
'expense', 'expense_direct_cost', 'expense_depreciation',
|
||||
'asset_non_current', 'liability_non_current',
|
||||
):
|
||||
expense_account_id = cl['account_id']
|
||||
if cl['partner_id'] and not partner_id:
|
||||
partner_id = cl['partner_id']
|
||||
|
||||
# Build a friendly name
|
||||
clean_ref = re.sub(r'[X*]{3,}[\w-]*', '', ref).strip()
|
||||
clean_ref = re.sub(r'\s{2,}', ' ', clean_ref)[:50]
|
||||
|
||||
# Build AI action note
|
||||
acct_name = ''
|
||||
if expense_account_id:
|
||||
acct = self.env['account.account'].browse(expense_account_id)
|
||||
acct_name = f'{acct.code} {acct.name}' if acct.exists() else ''
|
||||
|
||||
partner_name = ''
|
||||
if partner_id:
|
||||
p = self.env['res.partner'].browse(partner_id)
|
||||
partner_name = p.name if p.exists() else ''
|
||||
|
||||
action_parts = [f'RECURRING PAYMENT (seen {pat["occurrences"]} times).']
|
||||
if expense_account_id:
|
||||
action_parts.append(f'Post to account: {acct_name}.')
|
||||
if has_hst:
|
||||
action_parts.append('HST applies — split with 13% ITC.')
|
||||
else:
|
||||
action_parts.append('No HST — post without tax.')
|
||||
if partner_name:
|
||||
action_parts.append(f'Partner: {partner_name}.')
|
||||
action_parts.append('Apply same coding as previous occurrences — no user input needed.')
|
||||
|
||||
action_note = ' '.join(action_parts)
|
||||
|
||||
# Step 3: Check if a reconciliation model already handles this pattern
|
||||
reco_model_id = None
|
||||
try:
|
||||
reco_models = self.env['account.reconcile.model'].search([
|
||||
('company_id', '=', company.id),
|
||||
('active', '=', True),
|
||||
('match_label_param', '!=', False),
|
||||
])
|
||||
ref_lower = ref.lower()
|
||||
for rm in reco_models:
|
||||
if rm.match_label_param and rm.match_label_param.lower() in ref_lower:
|
||||
reco_model_id = rm.id
|
||||
action_parts.append(
|
||||
f'Reconciliation model "{rm.name}" (ID:{rm.id}) already handles this — '
|
||||
f'use apply_reconcile_model to apply it automatically.'
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Upsert
|
||||
existing = self.search([
|
||||
('ref_keyword', '=', ref),
|
||||
('amount', '=', pat['amount']),
|
||||
('company_id', '=', company.id),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'name': clean_ref,
|
||||
'ref_keyword': ref,
|
||||
'amount': pat['amount'],
|
||||
'amount_is_fixed': True,
|
||||
'journal_id': pat['journal_id'],
|
||||
'company_id': company.id,
|
||||
'expense_account_id': expense_account_id,
|
||||
'has_hst': has_hst,
|
||||
'partner_id': partner_id,
|
||||
'reconcile_model_id': reco_model_id,
|
||||
'action_note': action_note,
|
||||
'occurrences': pat['occurrences'],
|
||||
'first_seen': pat['first_seen'],
|
||||
'last_seen': pat['last_seen'],
|
||||
'last_computed': fields.Datetime.now(),
|
||||
}
|
||||
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
total_updated += 1
|
||||
else:
|
||||
self.create(vals)
|
||||
total_created += 1
|
||||
|
||||
_logger.info("Recurring patterns rebuilt: %d created, %d updated", total_created, total_updated)
|
||||
return {'created': total_created, 'updated': total_updated}
|
||||
221
fusion_accounting/models/vendor_tax_profile.py
Normal file
221
fusion_accounting/models/vendor_tax_profile.py
Normal file
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionVendorTaxProfile(models.Model):
|
||||
_name = 'fusion.vendor.tax.profile'
|
||||
_description = 'Vendor Tax Profile (AI Cache)'
|
||||
_order = 'total_bills desc'
|
||||
_rec_name = 'partner_id'
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Vendor', required=True, index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
total_bills = fields.Integer(string='Total Bills')
|
||||
bills_with_hst = fields.Integer(string='Bills with HST')
|
||||
bills_zero_rated = fields.Integer(string='Bills Zero-Rated')
|
||||
avg_tax_pct = fields.Float(string='Avg Tax %', digits=(5, 2))
|
||||
|
||||
# Classification
|
||||
tax_classification = fields.Selection([
|
||||
('always_hst', 'Always HST (13%)'),
|
||||
('mostly_hst', 'Mostly HST (>10%)'),
|
||||
('shipping_only', 'HST on Shipping Only (<2%)'),
|
||||
('never_hst', 'Never HST (0%)'),
|
||||
('mixed', 'Mixed / Inconsistent'),
|
||||
], string='Tax Classification')
|
||||
|
||||
# Most common expense account
|
||||
primary_account_id = fields.Many2one(
|
||||
'account.account', string='Primary Expense Account',
|
||||
)
|
||||
primary_account_code = fields.Char(
|
||||
related='primary_account_id.code', string='Account Code', store=True,
|
||||
)
|
||||
|
||||
# AI-readable note
|
||||
tax_note = fields.Text(
|
||||
string='Tax Note (AI-Readable)',
|
||||
help='Plain English note the AI reads to understand tax treatment.',
|
||||
)
|
||||
|
||||
# PO-tracked vendor — bills come from purchase orders, never from bank recon
|
||||
is_po_vendor = fields.Boolean(
|
||||
string='PO-Tracked Vendor',
|
||||
help='Bills for this vendor are created from Purchase Orders. '
|
||||
'Do NOT create bills during bank reconciliation — just match to existing bills.',
|
||||
)
|
||||
po_count = fields.Integer(string='Purchase Orders')
|
||||
|
||||
# Vendor details for matching
|
||||
is_foreign = fields.Boolean(string='Foreign Vendor')
|
||||
vendor_country = fields.Char(string='Vendor Country')
|
||||
|
||||
# Timestamps
|
||||
last_computed = fields.Datetime(string='Last Computed')
|
||||
|
||||
_sql_constraints = [
|
||||
('partner_company_uniq', 'unique(partner_id, company_id)',
|
||||
'One tax profile per vendor per company'),
|
||||
]
|
||||
|
||||
def _rebuild_all_profiles(self, min_bills=3):
|
||||
"""Rebuild all vendor tax profiles from posted bill history.
|
||||
Called by cron or manually."""
|
||||
_logger.info("Rebuilding vendor tax profiles (min_bills=%d)...", min_bills)
|
||||
companies = self.env['res.company'].search([])
|
||||
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
|
||||
for company in companies:
|
||||
# Find all vendors with enough bills
|
||||
self.env.cr.execute("""
|
||||
SELECT m.partner_id, count(*) as bill_count,
|
||||
SUM(CASE WHEN m.amount_tax > 0.01 THEN 1 ELSE 0 END) as with_tax,
|
||||
SUM(CASE WHEN m.amount_tax <= 0.01 THEN 1 ELSE 0 END) as no_tax,
|
||||
COALESCE(AVG(CASE WHEN m.amount_untaxed > 0
|
||||
THEN m.amount_tax / m.amount_untaxed * 100
|
||||
ELSE 0 END), 0) as avg_tax_pct
|
||||
FROM account_move m
|
||||
WHERE m.move_type = 'in_invoice'
|
||||
AND m.state = 'posted'
|
||||
AND m.company_id = %s
|
||||
AND m.partner_id IS NOT NULL
|
||||
GROUP BY m.partner_id
|
||||
HAVING count(*) >= %s
|
||||
""", (company.id, min_bills))
|
||||
vendor_stats = self.env.cr.dictfetchall()
|
||||
|
||||
for vs in vendor_stats:
|
||||
partner = self.env['res.partner'].browse(vs['partner_id'])
|
||||
if not partner.exists():
|
||||
continue
|
||||
|
||||
# Classify
|
||||
avg_pct = round(vs['avg_tax_pct'], 2)
|
||||
total = vs['bill_count']
|
||||
with_tax = vs['with_tax']
|
||||
no_tax = vs['no_tax']
|
||||
|
||||
if no_tax == total:
|
||||
classification = 'never_hst'
|
||||
note = f'{partner.name} NEVER charges HST. All {total} bills are zero-rated. Do NOT apply HST.'
|
||||
elif avg_pct >= 12.0:
|
||||
classification = 'always_hst'
|
||||
note = f'{partner.name} consistently charges HST at ~{avg_pct}%. Apply HST PURCHASE (13%) to all product lines.'
|
||||
elif avg_pct >= 10.0:
|
||||
classification = 'mostly_hst'
|
||||
note = f'{partner.name} usually charges HST (~{avg_pct}%). {no_tax} of {total} bills had no tax. Apply HST by default but verify zero-rated items.'
|
||||
elif avg_pct < 2.0 and with_tax > 0:
|
||||
classification = 'shipping_only'
|
||||
note = (
|
||||
f'{partner.name} products are zero-rated (avg tax only {avg_pct}% of subtotal). '
|
||||
f'HST applies ONLY to shipping/freight charges, NOT to product lines. '
|
||||
f'When creating a bill, use NO TAX PURCHASE on product lines and HST PURCHASE (13%) only on shipping lines.'
|
||||
)
|
||||
else:
|
||||
classification = 'mixed'
|
||||
note = (
|
||||
f'{partner.name} has mixed tax treatment ({with_tax} bills with HST, {no_tax} without, avg {avg_pct}%). '
|
||||
f'Check each bill individually — some items may be zero-rated while others have HST.'
|
||||
)
|
||||
|
||||
# Find primary expense account
|
||||
self.env.cr.execute("""
|
||||
SELECT aml.account_id, count(*) as cnt
|
||||
FROM account_move_line aml
|
||||
JOIN account_move m ON aml.move_id = m.id
|
||||
WHERE m.partner_id = %s
|
||||
AND m.move_type = 'in_invoice'
|
||||
AND m.state = 'posted'
|
||||
AND m.company_id = %s
|
||||
AND aml.display_type = 'product'
|
||||
GROUP BY aml.account_id
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 1
|
||||
""", (vs['partner_id'], company.id))
|
||||
acct_row = self.env.cr.fetchone()
|
||||
primary_account_id = acct_row[0] if acct_row else False
|
||||
|
||||
# Check if foreign vendor
|
||||
is_foreign = False
|
||||
country = ''
|
||||
if partner.country_id:
|
||||
country = partner.country_id.name
|
||||
is_foreign = partner.country_id.code != 'CA'
|
||||
elif partner.vat and not partner.vat.startswith('CA'):
|
||||
is_foreign = True
|
||||
|
||||
# Only override to never_hst if foreign AND bills actually confirm no tax
|
||||
# (Don't override if bill data shows they DO charge HST — e.g., Amazon Canada)
|
||||
if is_foreign and avg_pct < 1.0 and no_tax > with_tax:
|
||||
classification = 'never_hst'
|
||||
note = f'{partner.name} is a FOREIGN vendor ({country or "non-Canadian"}) and bills confirm no HST. Do NOT apply any Canadian tax.'
|
||||
|
||||
# Check if this is a PO-tracked vendor (has confirmed purchase orders)
|
||||
is_po_vendor = False
|
||||
vendor_po_count = 0
|
||||
try:
|
||||
self.env.cr.execute("""
|
||||
SELECT count(*) FROM purchase_order
|
||||
WHERE partner_id = %s AND state IN ('purchase', 'done')
|
||||
AND company_id = %s
|
||||
""", (vs['partner_id'], company.id))
|
||||
po_row = self.env.cr.fetchone()
|
||||
vendor_po_count = po_row[0] if po_row else 0
|
||||
is_po_vendor = vendor_po_count >= 3
|
||||
except Exception:
|
||||
pass # purchase module may not be installed
|
||||
|
||||
if is_po_vendor:
|
||||
note = (
|
||||
f'PO-TRACKED VENDOR ({vendor_po_count} purchase orders). '
|
||||
f'Bills are created from Purchase Orders — do NOT create bills during bank reconciliation. '
|
||||
f'Instead, find the existing unpaid bill and match the bank payment to it. '
|
||||
f'Tax treatment: {note}'
|
||||
)
|
||||
|
||||
# Upsert
|
||||
existing = self.search([
|
||||
('partner_id', '=', vs['partner_id']),
|
||||
('company_id', '=', company.id),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'partner_id': vs['partner_id'],
|
||||
'company_id': company.id,
|
||||
'total_bills': total,
|
||||
'bills_with_hst': with_tax,
|
||||
'bills_zero_rated': no_tax,
|
||||
'avg_tax_pct': avg_pct,
|
||||
'tax_classification': classification,
|
||||
'primary_account_id': primary_account_id,
|
||||
'tax_note': note,
|
||||
'is_po_vendor': is_po_vendor,
|
||||
'po_count': vendor_po_count,
|
||||
'is_foreign': is_foreign,
|
||||
'vendor_country': country,
|
||||
'last_computed': fields.Datetime.now(),
|
||||
}
|
||||
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
total_updated += 1
|
||||
else:
|
||||
self.create(vals)
|
||||
total_created += 1
|
||||
|
||||
_logger.info(
|
||||
"Vendor tax profiles rebuilt: %d created, %d updated",
|
||||
total_created, total_updated,
|
||||
)
|
||||
return {'created': total_created, 'updated': total_updated}
|
||||
@@ -11,3 +11,9 @@ access_fusion_tool_user,fusion.accounting.tool.user,model_fusion_accounting_tool
|
||||
access_fusion_tool_admin,fusion.accounting.tool.admin,model_fusion_accounting_tool,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_dashboard_user,fusion.accounting.dashboard.user,model_fusion_accounting_dashboard,group_fusion_accounting_user,1,1,1,1
|
||||
access_fusion_rule_wizard_manager,fusion.accounting.rule.wizard.manager,model_fusion_accounting_rule_wizard,group_fusion_accounting_manager,1,1,1,1
|
||||
access_fusion_recurring_pattern_user,fusion.recurring.pattern.user,model_fusion_recurring_pattern,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_recurring_pattern_manager,fusion.recurring.pattern.manager,model_fusion_recurring_pattern,group_fusion_accounting_manager,1,1,1,0
|
||||
access_fusion_recurring_pattern_admin,fusion.recurring.pattern.admin,model_fusion_recurring_pattern,group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_vendor_profile_user,fusion.vendor.tax.profile.user,model_fusion_vendor_tax_profile,group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_vendor_profile_manager,fusion.vendor.tax.profile.manager,model_fusion_vendor_tax_profile,group_fusion_accounting_manager,1,1,1,0
|
||||
access_fusion_vendor_profile_admin,fusion.vendor.tax.profile.admin,model_fusion_vendor_tax_profile,group_fusion_accounting_admin,1,1,1,1
|
||||
|
||||
|
@@ -1,12 +1,21 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Inter-account transfer pairs: (source_journal, cc_journal, cc_account_pattern)
|
||||
# Source sends "MB-CREDIT CARD" (outgoing), CC receives "PAYMENT FROM" (incoming)
|
||||
TRANSFER_PAIRS = [
|
||||
# (source_journal_id, cc_journal_id, outstanding_account_id)
|
||||
(50, 51, 493), # Scotia Current → Passport Visa, Outstanding Receipts - All Banks
|
||||
(53, 28, 493), # RBC Chequing → RBC Visa, Outstanding Receipts - All Banks
|
||||
]
|
||||
|
||||
|
||||
class FusionAccountingAgent(models.AbstractModel):
|
||||
_name = 'fusion.accounting.agent'
|
||||
@@ -41,9 +50,14 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
def _build_tool_definitions(self, tools):
|
||||
definitions = []
|
||||
for tool in tools:
|
||||
# A2: Include tier info in description so AI knows which tools need approval
|
||||
tier_label = {'1': 'Read-only', '2': 'Auto-approved', '3': 'Requires user approval'}.get(tool.tier, '')
|
||||
desc = tool.description or ''
|
||||
if tier_label:
|
||||
desc = f"[Tier {tool.tier}: {tier_label}] {desc}"
|
||||
defn = {
|
||||
'name': tool.name,
|
||||
'description': tool.description,
|
||||
'description': desc,
|
||||
}
|
||||
if tool.parameters_schema:
|
||||
try:
|
||||
@@ -117,6 +131,21 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
raise UserError(_("Session not found."))
|
||||
|
||||
adapter = self._get_adapter()
|
||||
provider = self._get_config('ai_provider', 'claude')
|
||||
|
||||
# Pin provider to session to prevent cross-adapter message contamination (C5)
|
||||
if session.ai_provider and session.ai_provider != provider:
|
||||
_logger.warning(
|
||||
"Session %s was started with %s but current provider is %s. "
|
||||
"Keeping original provider to avoid message format conflicts.",
|
||||
session.name, session.ai_provider, provider,
|
||||
)
|
||||
provider = session.ai_provider
|
||||
if provider == 'claude':
|
||||
adapter = self.env['fusion.accounting.adapter.claude']
|
||||
else:
|
||||
adapter = self.env['fusion.accounting.adapter.openai']
|
||||
|
||||
tools = self._get_tools_for_user()
|
||||
tool_definitions = self._build_tool_definitions(tools)
|
||||
rules = self._load_rules()
|
||||
@@ -132,6 +161,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
total_tokens_in = 0
|
||||
total_tokens_out = 0
|
||||
response = {'text': '', 'tool_calls': None}
|
||||
has_pending_tier3 = False
|
||||
|
||||
for turn in range(max_turns):
|
||||
response = adapter.call_with_tools(
|
||||
@@ -151,6 +181,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
tier = tool_rec.tier if tool_rec else '1'
|
||||
|
||||
if tier == '3':
|
||||
has_pending_tier3 = True
|
||||
history_rec = self._log_match_history(
|
||||
session, tool_name, tool_params, None,
|
||||
reasoning=tc.get('reasoning', ''),
|
||||
@@ -184,7 +215,29 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
messages_json = adapter.append_tool_results(
|
||||
messages_json, response, tool_results,
|
||||
)
|
||||
session.tool_call_count += len(tool_results)
|
||||
session.write({'tool_call_count': session.tool_call_count + len(tool_results)})
|
||||
|
||||
# C2: Short-circuit loop when Tier 3 actions are pending —
|
||||
# force a final text response so the AI can present approval cards
|
||||
if has_pending_tier3:
|
||||
try:
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=[],
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': response.get('text', 'I have proposed actions that require your approval.'),
|
||||
})
|
||||
except Exception:
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': 'I have proposed actions that require your approval. Please review the pending items above.',
|
||||
})
|
||||
break
|
||||
else:
|
||||
assistant_text = response.get('text', '')
|
||||
messages_json.append({'role': 'assistant', 'content': assistant_text})
|
||||
@@ -210,7 +263,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
'message_ids_json': json.dumps(messages_json),
|
||||
'token_count_in': session.token_count_in + total_tokens_in,
|
||||
'token_count_out': session.token_count_out + total_tokens_out,
|
||||
'ai_provider': self._get_config('ai_provider', 'claude'),
|
||||
'ai_provider': provider,
|
||||
'ai_model': adapter._get_model_name(),
|
||||
})
|
||||
|
||||
@@ -249,6 +302,15 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=True)
|
||||
|
||||
# C1: Update session messages_json so next chat turn has coherent history
|
||||
self._update_session_after_decision(history, result)
|
||||
|
||||
# M8: Trigger promotion check after approval
|
||||
try:
|
||||
self.env['fusion.accounting.scoring'].check_promotions()
|
||||
except Exception:
|
||||
_logger.exception("Error checking promotions after approval")
|
||||
|
||||
return result
|
||||
|
||||
def _check_rule_proposal(self, tool_name, params, session):
|
||||
@@ -312,4 +374,133 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=False)
|
||||
|
||||
return {'status': 'rejected', 'reason': reason}
|
||||
# C1: Update session messages_json so next chat turn has coherent history
|
||||
reject_result = {'status': 'rejected', 'reason': reason}
|
||||
self._update_session_after_decision(history, reject_result)
|
||||
|
||||
return reject_result
|
||||
|
||||
def _update_session_after_decision(self, history, result):
|
||||
"""Update session messages_json to replace pending_approval placeholder
|
||||
with actual tool result, preventing dangling tool_use blocks."""
|
||||
session = history.session_id
|
||||
if not session or not session.message_ids_json:
|
||||
return
|
||||
try:
|
||||
messages = json.loads(session.message_ids_json)
|
||||
result_str = json.dumps(result) if not isinstance(result, str) else result
|
||||
updated = False
|
||||
for msg in messages:
|
||||
if msg.get('role') != 'user':
|
||||
continue
|
||||
content = msg.get('content')
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if (isinstance(block, dict) and block.get('type') == 'tool_result'
|
||||
and 'pending_approval' in str(block.get('content', ''))):
|
||||
# Check if this is the matching tool_result block
|
||||
if str(history.id) in str(block.get('content', '')):
|
||||
block['content'] = result_str
|
||||
updated = True
|
||||
break
|
||||
if updated:
|
||||
break
|
||||
if updated:
|
||||
session.write({'message_ids_json': json.dumps(messages)})
|
||||
except Exception:
|
||||
_logger.warning("Failed to update session messages after decision for history %s", history.id)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Cron: Auto-Reconcile Inter-Account Transfers
|
||||
# ----------------------------------------------------------------
|
||||
@api.model
|
||||
def _cron_reconcile_transfers(self):
|
||||
"""Automatically reconcile inter-account credit card payments.
|
||||
|
||||
When a payment is made from a bank account (e.g. Scotia Current) to a
|
||||
credit card (e.g. Scotia Passport Visa), two bank statement lines appear:
|
||||
- Source side: "MB-CREDIT CARD" (negative) — reconciled by model 38/35
|
||||
- CC side: "PAYMENT FROM *7814" (positive) — needs matching
|
||||
|
||||
The source-side reconciliation creates outstanding entries on account 493.
|
||||
This cron matches the CC-side lines against those outstanding entries by
|
||||
exact amount and closest date (within 3 days).
|
||||
"""
|
||||
AML = self.env['account.move.line'].sudo()
|
||||
BSL = self.env['account.bank.statement.line'].sudo()
|
||||
company_partner_id = self.env.company.partner_id.id
|
||||
|
||||
total_reconciled = 0
|
||||
|
||||
for source_jid, cc_jid, outstanding_acct_id in TRANSFER_PAIRS:
|
||||
# Find all unreconciled INCOMING lines on the credit card journal
|
||||
cc_lines = BSL.search([
|
||||
('journal_id', '=', cc_jid),
|
||||
('is_reconciled', '=', False),
|
||||
('amount', '>', 0), # Incoming payments only
|
||||
('company_id', '=', self.env.company.id),
|
||||
])
|
||||
if not cc_lines:
|
||||
continue
|
||||
|
||||
journal_name = cc_lines[0].journal_id.name
|
||||
_logger.info(
|
||||
"Transfer reconcile: %s — %d incoming unreconciled lines",
|
||||
journal_name, len(cc_lines),
|
||||
)
|
||||
|
||||
reconciled = 0
|
||||
skipped = 0
|
||||
|
||||
for line in cc_lines:
|
||||
line_date = line.move_id.date
|
||||
amount = line.amount
|
||||
|
||||
# Find outstanding entries with exact matching amount
|
||||
candidates = AML.search([
|
||||
('account_id', '=', outstanding_acct_id),
|
||||
('partner_id', '=', company_partner_id),
|
||||
('reconciled', '=', False),
|
||||
('amount_residual', '=', amount),
|
||||
])
|
||||
|
||||
if not candidates:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Pick the candidate closest in date (within 3 days)
|
||||
best = None
|
||||
best_gap = 999
|
||||
for c in candidates:
|
||||
gap = abs((c.date - line_date).days)
|
||||
if gap < best_gap:
|
||||
best_gap = gap
|
||||
best = c
|
||||
|
||||
if best_gap > 7:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Set partner and reconcile
|
||||
try:
|
||||
line.partner_id = company_partner_id
|
||||
line.set_line_bank_statement_line(best.ids)
|
||||
reconciled += 1
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Transfer reconcile failed: line %s (%s, $%.2f): %s",
|
||||
line.id, line.payment_ref, amount, e,
|
||||
)
|
||||
|
||||
# Commit every 50 lines to avoid long transactions
|
||||
if reconciled % 50 == 0 and reconciled > 0:
|
||||
self.env.cr.commit()
|
||||
|
||||
self.env.cr.commit()
|
||||
total_reconciled += reconciled
|
||||
_logger.info(
|
||||
"Transfer reconcile: %s — reconciled %d, skipped %d",
|
||||
journal_name, reconciled, skipped,
|
||||
)
|
||||
|
||||
_logger.info("Transfer reconcile complete: %d total reconciled", total_reconciled)
|
||||
|
||||
@@ -18,6 +18,54 @@ You are helping with Canadian HST/GST tax management.
|
||||
- Net HST = Collected - ITCs. Positive means owing to CRA.
|
||||
- Quarterly filing periods. Check for missing tax on invoices/bills.
|
||||
- All vendor bills should have ITCs unless explicitly exempt.
|
||||
- HST Purchase tax ID is 20 (13%). No Tax Purchase ID is 32 (0%).
|
||||
|
||||
HST FILING WORKFLOW (4 phases — follow this order):
|
||||
|
||||
PHASE 1 — REPORTS: Run all at once:
|
||||
calculate_hst_balance, get_tax_report, find_missing_itc_bills,
|
||||
find_missing_tax_invoices, audit_tax_compliance.
|
||||
Present summary with HST position (owing vs refund).
|
||||
|
||||
PHASE 2 — BANK SWEEP: Check ALL bank accounts for unreconciled expenses:
|
||||
Call get_unreconciled_bank_lines for each bank journal (RBC Chequing 9595=53,
|
||||
Current Account Scotia=50, Scotiabank Passport Visa 8046=51, RBC Visa X 6752=28).
|
||||
Present ALL unreconciled expense lines (negative amounts) as a fusion-table
|
||||
with your recommendation per row.
|
||||
|
||||
PHASE 3 — PER-LINE PROCESSING: For each flagged expense line:
|
||||
0. FIRST: check_recurring_pattern(line_id=X) — if match found, follow action_note
|
||||
instructions EXACTLY (account, HST, partner, reconcile model). No user input needed
|
||||
for recurring payments. If a reconcile_model_id is returned, use apply_reconcile_model.
|
||||
1. get_bank_line_details — check if a vendor bill already exists for same amount/date
|
||||
2. find_similar_bank_lines — check history AND vendor_tax_pattern for coding/tax pattern
|
||||
3. CRITICAL: Check vendor_tax_pattern.is_po_vendor flag:
|
||||
- If is_po_vendor=true: This vendor's bills come from Purchase Orders. Do NOT create
|
||||
a new bill. Instead, use get_unpaid_bills to find the existing bill and propose
|
||||
match_bank_line_to_payments to match the bank payment to that bill.
|
||||
- If is_po_vendor=false: Proceed with bill creation workflow below.
|
||||
4. If bill already exists → propose match_bank_line_to_payments
|
||||
5. If no bill but history match → propose create_vendor_bill with same coding pattern
|
||||
6. If no bill and no history → ask user: "Does this expense include HST?"
|
||||
7. search_partners — find the vendor by keyword from the bank description
|
||||
8. Once confirmed → create_vendor_bill + register_bill_payment (Tier 3, needs approval)
|
||||
9. Alternative: user can choose "Direct GL" → create_expense_entry (Tier 3)
|
||||
For expenses that obviously have no HST (bank fees, interest charges, insurance),
|
||||
proactively recommend "No HST" and explain why.
|
||||
|
||||
PO-TRACKED VENDORS (do NOT create bills for these — bills come from Purchase Orders):
|
||||
When find_similar_bank_lines returns is_po_vendor=true or the vendor_tax_pattern
|
||||
note starts with "PO-TRACKED VENDOR", the bill already exists or will be created
|
||||
from a PO. Your job is ONLY to find the existing unpaid bill and match the bank
|
||||
payment to it. If no unpaid bill exists, flag it for the user: "This is a PO vendor
|
||||
but no matching bill was found — the PO may not have been billed yet."
|
||||
|
||||
PHASE 4 — VERIFICATION: Re-run calculate_hst_balance and get_tax_report
|
||||
to show the updated HST position after all expenses are recorded.
|
||||
|
||||
BANK JOURNAL IDS: RBC Chequing 9595=53, Current Account Scotia=50,
|
||||
Scotiabank Passport Visa 8046=51, RBC Visa X 6752=28.
|
||||
MISC JOURNAL: ID=3 (for direct GL expense entries).
|
||||
""",
|
||||
|
||||
'accounts_receivable': """
|
||||
@@ -105,5 +153,36 @@ PAYROLL MANAGEMENT CONTEXT:
|
||||
}
|
||||
|
||||
|
||||
# A3/A5: Aliases so common domain variations still match a prompt
|
||||
DOMAIN_ALIASES = {
|
||||
'bank': 'bank_reconciliation',
|
||||
'bank_recon': 'bank_reconciliation',
|
||||
'hst': 'hst_management',
|
||||
'gst': 'hst_management',
|
||||
'tax': 'hst_management',
|
||||
'ar': 'accounts_receivable',
|
||||
'receivable': 'accounts_receivable',
|
||||
'ap': 'accounts_payable',
|
||||
'payable': 'accounts_payable',
|
||||
'journal': 'journal_review',
|
||||
'close': 'month_end',
|
||||
'month_end_close': 'month_end',
|
||||
'payroll': 'payroll_management',
|
||||
'payroll_verify': 'payroll_verification',
|
||||
'stock': 'inventory',
|
||||
'cogs': 'inventory',
|
||||
'report': 'reporting',
|
||||
'reports': 'reporting',
|
||||
'financial': 'reporting',
|
||||
}
|
||||
|
||||
|
||||
def get_domain_prompt(domain):
|
||||
return DOMAIN_PROMPTS.get(domain, '')
|
||||
if not domain:
|
||||
return ''
|
||||
# Try exact match first, then aliases
|
||||
prompt = DOMAIN_PROMPTS.get(domain, '')
|
||||
if not prompt:
|
||||
resolved = DOMAIN_ALIASES.get(domain, domain)
|
||||
prompt = DOMAIN_PROMPTS.get(resolved, '')
|
||||
return prompt
|
||||
|
||||
@@ -31,12 +31,56 @@ RESPONSE FORMATTING:
|
||||
- Use rich Markdown formatting in your responses. The chat renders Markdown as HTML.
|
||||
- Use **bold** for account names, amounts, and key terms.
|
||||
- Use ## and ### headers to organize sections in longer responses.
|
||||
- Use Markdown tables for tabular data (| col1 | col2 | format).
|
||||
- Use bullet lists (- item) for findings, issues, and action items.
|
||||
- Use numbered lists (1. item) for sequential steps or ranked items.
|
||||
- Use `code` for account codes, reference numbers, and technical IDs.
|
||||
- Use --- horizontal rules to separate sections in long reports.
|
||||
|
||||
INTERACTIVE TABLES (fusion-table) — MANDATORY FOR ACTIONABLE DATA:
|
||||
IMPORTANT: When a tool returns a list of records that the user could act on, you MUST use
|
||||
a ```fusion-table block instead of a Markdown table. This is REQUIRED — never use plain
|
||||
Markdown tables for actionable data. The fusion-table renders an interactive widget with
|
||||
checkboxes, your AI recommendations per row, user input fields, and bulk action buttons.
|
||||
|
||||
YOU MUST USE fusion-table FOR: missing ITCs/tax (find_missing_itc_bills, find_missing_tax_invoices),
|
||||
duplicate entries (find_duplicate_bills, find_duplicate_entries), overdue invoices (get_overdue_invoices),
|
||||
unreconciled lines (get_unreconciled_bank_lines, get_unreconciled_receipts, get_unmatched_payments,
|
||||
find_unreconciled_suspense), draft entries (find_draft_entries), wrong balances
|
||||
(find_wrong_direction_balances), sequence gaps (find_sequence_gaps), wrong accounts
|
||||
(find_wrong_account_entries), unpaid bills (get_unpaid_bills), and any other list where
|
||||
the user needs to review, dismiss, flag, or create rules for individual rows.
|
||||
|
||||
USE REGULAR MARKDOWN TABLES ONLY FOR: P&L (get_profit_loss), balance sheet (get_balance_sheet),
|
||||
trial balance (get_trial_balance), cash flow (get_cash_flow), period summaries, tax reports,
|
||||
and any purely informational/read-only data where there is nothing to act on per row.
|
||||
|
||||
Format: wrap a JSON object in a ```fusion-table fenced code block:
|
||||
|
||||
```fusion-table
|
||||
{
|
||||
"mode": "interactive",
|
||||
"title": "Descriptive Title",
|
||||
"columns": ["Col1", "Col2", "Col3"],
|
||||
"rows": [
|
||||
{"id": 123, "cells": ["val1", "val2", "val3"], "recommendation": {"action": "dismiss", "reason": "Brief explanation"}},
|
||||
{"id": 456, "cells": ["val1", "val2", "val3"], "recommendation": {"action": "flag", "reason": "Brief explanation"}}
|
||||
],
|
||||
"actions": ["dismiss", "flag", "create_rule"],
|
||||
"source_tool": "tool_name_that_produced_this"
|
||||
}
|
||||
```
|
||||
|
||||
- "mode": "interactive" (actionable) or "readonly" (informational but structured)
|
||||
- "id": the Odoo record ID (account.move id, account.bank.statement.line id, etc.)
|
||||
- "recommendation.action": one of "dismiss", "flag", "create_rule"
|
||||
- "recommendation.reason": short explanation of why you recommend this action
|
||||
- "actions": which bulk action buttons to show
|
||||
- "source_tool": the tool name that produced the data
|
||||
- You MUST provide a recommendation for each row when using interactive mode.
|
||||
- Format monetary amounts as "$X,XXX.XX" in cells.
|
||||
- Always include the record ID so actions can target the correct Odoo record.
|
||||
- Add a brief text summary before or after the fusion-table block for context.
|
||||
|
||||
LINKING TO ODOO RECORDS:
|
||||
- When referencing specific records, include clickable Odoo links.
|
||||
- Journal entries: [INV/2026/00123](/odoo/accounting/123) where 123 is the move ID.
|
||||
@@ -60,12 +104,14 @@ def _build_rules_section(rules):
|
||||
for rule in rules:
|
||||
priority = 'ADMIN' if rule.created_by == 'admin' else 'AI'
|
||||
tier = 'auto' if rule.approval_tier == 'auto' else 'needs-approval'
|
||||
conf_str = f', confidence={rule.confidence_score:.0%}, uses={rule.total_uses}' if rule.total_uses > 0 else ''
|
||||
lines.append(
|
||||
f'- [{priority}/{tier}] {rule.name} ({rule.rule_type}): '
|
||||
f'- [{priority}/{tier}{conf_str}] {rule.name} ({rule.rule_type}): '
|
||||
f'{rule.description or rule.match_logic or "No description"}'
|
||||
)
|
||||
if rule.match_logic:
|
||||
lines.append(f' Match logic: {rule.match_logic}')
|
||||
logic_text = rule.match_logic[:500] # Prevent prompt bloat
|
||||
lines.append(f' Match logic: {logic_text}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
@@ -73,7 +119,9 @@ def _build_history_section(history):
|
||||
if not history:
|
||||
return ''
|
||||
lines = ['RECENT MATCH HISTORY (learn from these patterns):']
|
||||
for h in history[:50]:
|
||||
# A4: Don't hard-cap at 50 — the caller (_load_match_history) already
|
||||
# respects the history_in_prompt config setting
|
||||
for h in history:
|
||||
status = h.decision
|
||||
reason = ''
|
||||
if h.rejection_reason:
|
||||
|
||||
@@ -140,6 +140,258 @@ def get_payment_schedule(env, params):
|
||||
}
|
||||
|
||||
|
||||
def search_partners(env, params):
|
||||
"""Search for partners/vendors by name keyword."""
|
||||
keyword = params.get('keyword', '')
|
||||
if not keyword or len(keyword) < 2:
|
||||
return {'error': 'Keyword must be at least 2 characters'}
|
||||
domain = [('name', 'ilike', keyword), ('company_id', 'in', [env.company.id, False])]
|
||||
if params.get('supplier_only'):
|
||||
domain.append(('supplier_rank', '>', 0))
|
||||
partners = env['res.partner'].search(domain, limit=int(params.get('limit', 20)))
|
||||
return {
|
||||
'count': len(partners),
|
||||
'partners': [{
|
||||
'id': p.id,
|
||||
'name': p.name,
|
||||
'supplier_rank': p.supplier_rank,
|
||||
'customer_rank': p.customer_rank,
|
||||
'vat': p.vat or '',
|
||||
'email': p.email or '',
|
||||
'phone': p.phone or '',
|
||||
} for p in partners],
|
||||
}
|
||||
|
||||
|
||||
def find_similar_bank_lines(env, params):
|
||||
"""Find past reconciled bank lines with similar description to suggest coding patterns.
|
||||
Also checks vendor bill tax patterns if a partner is identified."""
|
||||
keyword = params.get('keyword', '')
|
||||
if not keyword or len(keyword) < 3:
|
||||
return {'error': 'Keyword must be at least 3 characters'}
|
||||
# Find reconciled bank lines with matching payment_ref
|
||||
lines = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', True),
|
||||
('payment_ref', 'ilike', keyword),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date desc', limit=int(params.get('limit', 10)))
|
||||
|
||||
matches = []
|
||||
found_partner_id = None
|
||||
for line in lines:
|
||||
move = line.move_id
|
||||
if not move:
|
||||
continue
|
||||
expense_info = {'account_code': '', 'account_name': '', 'tax_applied': False, 'tax_amount': 0.0}
|
||||
for ml in move.line_ids:
|
||||
if ml.account_id.account_type in ('expense', 'expense_direct_cost', 'expense_depreciation'):
|
||||
expense_info['account_code'] = ml.account_id.code
|
||||
expense_info['account_name'] = ml.account_id.name
|
||||
expense_info['tax_applied'] = bool(ml.tax_ids)
|
||||
expense_info['tax_amount'] = sum(t.amount for t in ml.tax_ids) if ml.tax_ids else 0.0
|
||||
break
|
||||
if line.partner_id and not found_partner_id:
|
||||
found_partner_id = line.partner_id.id
|
||||
matches.append({
|
||||
'id': line.id,
|
||||
'date': str(line.date),
|
||||
'payment_ref': line.payment_ref or '',
|
||||
'amount': line.amount,
|
||||
'partner': line.partner_id.name if line.partner_id else '',
|
||||
'partner_id': line.partner_id.id if line.partner_id else None,
|
||||
'expense_account': expense_info['account_code'],
|
||||
'expense_account_name': expense_info['account_name'],
|
||||
'tax_applied': expense_info['tax_applied'],
|
||||
'tax_rate': expense_info['tax_amount'],
|
||||
})
|
||||
|
||||
result = {
|
||||
'keyword': keyword,
|
||||
'count': len(matches),
|
||||
'matches': matches,
|
||||
'suggestion': matches[0] if matches else None,
|
||||
}
|
||||
|
||||
# Check vendor tax profile cache first (fast), fall back to live query
|
||||
partner_id = found_partner_id or (int(params['partner_id']) if params.get('partner_id') else None)
|
||||
if partner_id:
|
||||
profile = env['fusion.vendor.tax.profile'].search([
|
||||
('partner_id', '=', partner_id),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if profile:
|
||||
result['vendor_tax_pattern'] = {
|
||||
'source': 'cached_profile',
|
||||
'total_bills': profile.total_bills,
|
||||
'bills_with_tax': profile.bills_with_hst,
|
||||
'bills_no_tax': profile.bills_zero_rated,
|
||||
'avg_tax_pct': profile.avg_tax_pct,
|
||||
'tax_classification': profile.tax_classification,
|
||||
'tax_note': profile.tax_note,
|
||||
'primary_account_id': profile.primary_account_id.id if profile.primary_account_id else None,
|
||||
'primary_account_code': profile.primary_account_code or '',
|
||||
'is_foreign': profile.is_foreign,
|
||||
'is_po_vendor': profile.is_po_vendor,
|
||||
'po_count': profile.po_count,
|
||||
}
|
||||
else:
|
||||
# No cached profile — live query for new/small vendors
|
||||
bills = env['account.move'].search([
|
||||
('move_type', '=', 'in_invoice'), ('state', '=', 'posted'),
|
||||
('partner_id', '=', partner_id),
|
||||
], order='date desc', limit=10)
|
||||
tax_stats = {'source': 'live_query', 'total_bills': len(bills),
|
||||
'bills_with_tax': 0, 'bills_no_tax': 0,
|
||||
'avg_tax_pct': 0.0, 'tax_note': ''}
|
||||
tax_pcts = []
|
||||
for bill in bills:
|
||||
if bill.amount_tax > 0.01:
|
||||
tax_stats['bills_with_tax'] += 1
|
||||
if bill.amount_untaxed > 0:
|
||||
tax_pcts.append(round(bill.amount_tax / bill.amount_untaxed * 100, 2))
|
||||
else:
|
||||
tax_stats['bills_no_tax'] += 1
|
||||
if tax_pcts:
|
||||
tax_stats['avg_tax_pct'] = round(sum(tax_pcts) / len(tax_pcts), 2)
|
||||
if tax_stats['total_bills'] > 0:
|
||||
if tax_stats['bills_no_tax'] == tax_stats['total_bills']:
|
||||
tax_stats['tax_note'] = 'This vendor NEVER charges HST. All bills are zero-rated.'
|
||||
elif tax_stats['avg_tax_pct'] < 2.0 and tax_stats['bills_with_tax'] > 0:
|
||||
tax_stats['tax_note'] = (
|
||||
f'HST only on shipping (avg {tax_stats["avg_tax_pct"]}%). '
|
||||
f'Do NOT apply HST to full amount.'
|
||||
)
|
||||
elif tax_stats['avg_tax_pct'] >= 12.0:
|
||||
tax_stats['tax_note'] = f'Consistently charges HST at ~{tax_stats["avg_tax_pct"]}%.'
|
||||
result['vendor_tax_pattern'] = tax_stats
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def create_vendor_bill(env, params):
|
||||
"""[Tier 3] Create a vendor bill (account.move with move_type='in_invoice').
|
||||
Requires user approval before execution."""
|
||||
partner_id = int(params['partner_id'])
|
||||
invoice_date = params.get('invoice_date', str(fields.Date.today()))
|
||||
bill_lines = params.get('lines', [])
|
||||
if not bill_lines:
|
||||
return {'error': 'At least one invoice line is required'}
|
||||
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': f'Partner not found: {partner_id}'}
|
||||
|
||||
invoice_line_vals = []
|
||||
for line in bill_lines:
|
||||
line_vals = {
|
||||
'name': line.get('description', 'Expense'),
|
||||
'price_unit': float(line.get('price_unit', 0)),
|
||||
'quantity': float(line.get('quantity', 1)),
|
||||
}
|
||||
if line.get('account_id'):
|
||||
line_vals['account_id'] = int(line['account_id'])
|
||||
if line.get('tax_ids'):
|
||||
line_vals['tax_ids'] = [(6, 0, [int(t) for t in line['tax_ids']])]
|
||||
invoice_line_vals.append((0, 0, line_vals))
|
||||
|
||||
try:
|
||||
bill = env['account.move'].create({
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': partner_id,
|
||||
'invoice_date': invoice_date,
|
||||
'date': invoice_date,
|
||||
'invoice_line_ids': invoice_line_vals,
|
||||
'company_id': env.company.id,
|
||||
})
|
||||
|
||||
if params.get('post', False):
|
||||
bill.action_post()
|
||||
|
||||
return {
|
||||
'status': 'created',
|
||||
'bill_id': bill.id,
|
||||
'bill_name': bill.name,
|
||||
'partner': partner.name,
|
||||
'amount_total': bill.amount_total,
|
||||
'state': bill.state,
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.error("Failed to create vendor bill: %s", e)
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def register_bill_payment(env, params):
|
||||
"""[Tier 3] Register payment on a posted vendor bill and optionally reconcile to bank line.
|
||||
Requires user approval before execution."""
|
||||
bill_id = int(params['bill_id'])
|
||||
journal_id = int(params['journal_id'])
|
||||
bill = env['account.move'].browse(bill_id)
|
||||
if not bill.exists() or bill.state != 'posted':
|
||||
return {'error': 'Bill not found or not posted'}
|
||||
|
||||
payment_date = params.get('payment_date', str(fields.Date.today()))
|
||||
|
||||
try:
|
||||
# Use the payment register wizard
|
||||
ctx = {
|
||||
'active_model': 'account.move',
|
||||
'active_ids': [bill_id],
|
||||
}
|
||||
wizard = env['account.payment.register'].with_context(**ctx).create({
|
||||
'journal_id': journal_id,
|
||||
'payment_date': payment_date,
|
||||
})
|
||||
# Optionally set amount if provided (otherwise defaults to bill amount)
|
||||
if params.get('amount'):
|
||||
wizard.amount = float(params['amount'])
|
||||
|
||||
payments = wizard.action_create_payments()
|
||||
|
||||
# Find the created payment
|
||||
payment = None
|
||||
if isinstance(payments, dict) and payments.get('res_id'):
|
||||
payment = env['account.payment'].browse(payments['res_id'])
|
||||
elif isinstance(payments, dict) and payments.get('domain'):
|
||||
payment = env['account.payment'].search(payments['domain'], limit=1)
|
||||
else:
|
||||
# Fallback: find the latest payment for this bill
|
||||
payment = env['account.payment'].search([
|
||||
('partner_id', '=', bill.partner_id.id),
|
||||
], order='create_date desc', limit=1)
|
||||
|
||||
result = {
|
||||
'status': 'paid',
|
||||
'bill_id': bill_id,
|
||||
'bill_name': bill.name,
|
||||
'payment_state': bill.payment_state,
|
||||
}
|
||||
if payment:
|
||||
result['payment_id'] = payment.id
|
||||
result['payment_name'] = payment.name
|
||||
|
||||
# Optionally reconcile to a bank statement line
|
||||
if params.get('statement_line_id') and payment:
|
||||
try:
|
||||
st_line = env['account.bank.statement.line'].browse(int(params['statement_line_id']))
|
||||
if st_line.exists() and not st_line.is_reconciled:
|
||||
# Find the payment's move lines on the bank's outstanding account
|
||||
pay_move_lines = payment.move_id.line_ids.filtered(
|
||||
lambda l: l.account_id.reconcile and not l.reconciled
|
||||
)
|
||||
if pay_move_lines:
|
||||
st_line.set_line_bank_statement_line(pay_move_lines.ids)
|
||||
result['reconciled'] = True
|
||||
result['statement_line_id'] = st_line.id
|
||||
except Exception as e:
|
||||
_logger.warning("Payment created but bank reconciliation failed: %s", e)
|
||||
result['reconcile_error'] = str(e)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
_logger.error("Failed to register payment: %s", e)
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_ap_aging': get_ap_aging,
|
||||
'find_duplicate_bills': find_duplicate_bills,
|
||||
@@ -147,4 +399,8 @@ TOOLS = {
|
||||
'get_unpaid_bills': get_unpaid_bills,
|
||||
'verify_bill_taxes': verify_bill_taxes,
|
||||
'get_payment_schedule': get_payment_schedule,
|
||||
'search_partners': search_partners,
|
||||
'find_similar_bank_lines': find_similar_bank_lines,
|
||||
'create_vendor_bill': create_vendor_bill,
|
||||
'register_bill_payment': register_bill_payment,
|
||||
}
|
||||
|
||||
@@ -69,7 +69,11 @@ def flag_entry(env, params):
|
||||
|
||||
|
||||
def get_audit_status(env, params):
|
||||
statuses = env['account.audit.account.status'].search([])
|
||||
try:
|
||||
AuditStatus = env['account.audit.account.status']
|
||||
except KeyError:
|
||||
return {'error': 'Audit status model (account.audit.account.status) is not available. The account_audit Enterprise module may not be installed.'}
|
||||
statuses = AuditStatus.search([])
|
||||
return {
|
||||
'statuses': [{
|
||||
'id': s.id,
|
||||
@@ -81,9 +85,13 @@ def get_audit_status(env, params):
|
||||
|
||||
|
||||
def set_audit_status(env, params):
|
||||
try:
|
||||
AuditStatus = env['account.audit.account.status']
|
||||
except KeyError:
|
||||
return {'error': 'Audit status model (account.audit.account.status) is not available. The account_audit Enterprise module may not be installed.'}
|
||||
status_id = int(params['status_id'])
|
||||
new_status = params['status']
|
||||
rec = env['account.audit.account.status'].browse(status_id)
|
||||
rec = AuditStatus.browse(status_id)
|
||||
if not rec.exists():
|
||||
return {'error': 'Audit status record not found'}
|
||||
rec.status = new_status
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -139,6 +140,10 @@ def get_reconcile_suggestions(env, params):
|
||||
|
||||
|
||||
def sum_payments_by_date(env, params):
|
||||
"""Sum payment/journal activity for a date range.
|
||||
IMPORTANT: Always pass journal_ids to filter to specific journals.
|
||||
Without journal_ids, returns totals across ALL journals which is
|
||||
almost never what you want for reconciliation."""
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
if not date_from or not date_to:
|
||||
@@ -150,18 +155,332 @@ def sum_payments_by_date(env, params):
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
]
|
||||
scope = 'all journals'
|
||||
if journal_ids:
|
||||
domain.append(('journal_id', 'in', [int(j) for j in journal_ids]))
|
||||
jids = [int(j) for j in journal_ids]
|
||||
domain.append(('journal_id', 'in', jids))
|
||||
journals = env['account.journal'].browse(jids)
|
||||
scope = ', '.join(j.name for j in journals if j.exists())
|
||||
else:
|
||||
# Without journal filter, include a warning and break down by journal
|
||||
pass
|
||||
|
||||
lines = env['account.move.line'].search(domain)
|
||||
total_debit = sum(l.debit for l in lines)
|
||||
total_credit = sum(l.credit for l in lines)
|
||||
return {
|
||||
|
||||
result = {
|
||||
'date_from': date_from,
|
||||
'date_to': date_to,
|
||||
'total_debit': total_debit,
|
||||
'total_credit': total_credit,
|
||||
'net': total_debit - total_credit,
|
||||
'line_count': len(lines),
|
||||
'scope': scope,
|
||||
}
|
||||
|
||||
# If no journal filter, add per-journal breakdown so AI doesn't
|
||||
# mistake company-wide totals for a specific journal's activity
|
||||
if not journal_ids:
|
||||
result['warning'] = (
|
||||
'No journal_ids filter was provided. These totals are across ALL '
|
||||
'journals in the company. To get card payment totals, pass the '
|
||||
'specific card/POS journal IDs.'
|
||||
)
|
||||
journal_totals = {}
|
||||
for l in lines:
|
||||
jname = l.journal_id.name
|
||||
if jname not in journal_totals:
|
||||
journal_totals[jname] = {'debit': 0.0, 'credit': 0.0, 'count': 0}
|
||||
journal_totals[jname]['debit'] += l.debit
|
||||
journal_totals[jname]['credit'] += l.credit
|
||||
journal_totals[jname]['count'] += 1
|
||||
result['by_journal'] = [
|
||||
{'journal': jn, 'debit': v['debit'], 'credit': v['credit'], 'count': v['count']}
|
||||
for jn, v in sorted(journal_totals.items(), key=lambda x: -x[1]['debit'])
|
||||
][:15]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_bank_line_details(env, params):
|
||||
"""Get full details of a single bank statement line plus matching suggestions."""
|
||||
line_id = int(params['line_id'])
|
||||
line = env['account.bank.statement.line'].browse(line_id)
|
||||
if not line.exists():
|
||||
return {'error': 'Bank statement line not found'}
|
||||
|
||||
result = {
|
||||
'id': line.id,
|
||||
'date': str(line.date),
|
||||
'payment_ref': line.payment_ref or '',
|
||||
'partner_name': line.partner_name or (line.partner_id.name if line.partner_id else ''),
|
||||
'partner_id': line.partner_id.id if line.partner_id else None,
|
||||
'amount': line.amount,
|
||||
'journal': line.journal_id.name,
|
||||
'journal_id': line.journal_id.id,
|
||||
'is_reconciled': line.is_reconciled,
|
||||
'existing_bills': [],
|
||||
'suggested_partner': None,
|
||||
}
|
||||
|
||||
# Search for existing vendor bills matching amount ± $0.50 and date ± 3 days
|
||||
abs_amount = abs(line.amount)
|
||||
from datetime import timedelta as td
|
||||
date_from = line.date - td(days=3)
|
||||
date_to = line.date + td(days=3)
|
||||
matching_bills = env['account.move'].search([
|
||||
('move_type', '=', 'in_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('amount_total', '>=', abs_amount - 0.50),
|
||||
('amount_total', '<=', abs_amount + 0.50),
|
||||
('date', '>=', str(date_from)),
|
||||
('date', '<=', str(date_to)),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=5)
|
||||
for bill in matching_bills:
|
||||
result['existing_bills'].append({
|
||||
'id': bill.id,
|
||||
'name': bill.name,
|
||||
'partner': bill.partner_id.name if bill.partner_id else '',
|
||||
'amount_total': bill.amount_total,
|
||||
'date': str(bill.date),
|
||||
'payment_state': bill.payment_state,
|
||||
})
|
||||
|
||||
# Try to suggest a partner from payment_ref keyword
|
||||
if line.payment_ref and not line.partner_id:
|
||||
# Extract meaningful words from payment_ref (skip common banking terms)
|
||||
skip_words = {'misc', 'payment', 'online', 'banking', 'pad', 'business',
|
||||
'deposit', 'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit'}
|
||||
words = [w for w in line.payment_ref.split() if len(w) > 2 and w.lower() not in skip_words]
|
||||
for word in words[:3]:
|
||||
partners = env['res.partner'].search([
|
||||
('name', 'ilike', word),
|
||||
('supplier_rank', '>', 0),
|
||||
], limit=3)
|
||||
if partners:
|
||||
result['suggested_partner'] = {
|
||||
'id': partners[0].id,
|
||||
'name': partners[0].name,
|
||||
'match_word': word,
|
||||
}
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def check_recurring_pattern(env, params):
|
||||
"""Check if a bank line matches a known recurring payment pattern.
|
||||
Returns the historical coding (account, HST, partner, reconcile model) if found."""
|
||||
line_id = params.get('line_id')
|
||||
payment_ref = params.get('payment_ref', '')
|
||||
amount = params.get('amount')
|
||||
|
||||
# If line_id provided, get the ref and amount from the line
|
||||
if line_id:
|
||||
line = env['account.bank.statement.line'].browse(int(line_id))
|
||||
if line.exists():
|
||||
payment_ref = line.payment_ref or ''
|
||||
amount = line.amount
|
||||
|
||||
if not payment_ref:
|
||||
return {'match': False, 'reason': 'No payment reference to match'}
|
||||
|
||||
# Search cached patterns by keyword
|
||||
patterns = env['fusion.recurring.pattern'].search([
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
|
||||
best_match = None
|
||||
for pat in patterns:
|
||||
if not pat.ref_keyword:
|
||||
continue
|
||||
# Check if the pattern keyword appears in the payment_ref
|
||||
if pat.ref_keyword.lower()[:30] in payment_ref.lower():
|
||||
# If amount matches too, it's a strong match
|
||||
if amount and pat.amount_is_fixed and abs(pat.amount - amount) < 0.01:
|
||||
best_match = pat
|
||||
break
|
||||
# Keyword-only match (amount may vary)
|
||||
if not best_match or pat.occurrences > best_match.occurrences:
|
||||
best_match = pat
|
||||
|
||||
if not best_match:
|
||||
return {'match': False, 'payment_ref': payment_ref}
|
||||
|
||||
result = {
|
||||
'match': True,
|
||||
'pattern_id': best_match.id,
|
||||
'pattern_name': best_match.name,
|
||||
'occurrences': best_match.occurrences,
|
||||
'first_seen': str(best_match.first_seen) if best_match.first_seen else '',
|
||||
'last_seen': str(best_match.last_seen) if best_match.last_seen else '',
|
||||
'expense_account_id': best_match.expense_account_id.id if best_match.expense_account_id else None,
|
||||
'expense_account_code': best_match.expense_account_code or '',
|
||||
'expense_account_name': best_match.expense_account_id.name if best_match.expense_account_id else '',
|
||||
'has_hst': best_match.has_hst,
|
||||
'partner_id': best_match.partner_id.id if best_match.partner_id else None,
|
||||
'partner_name': best_match.partner_id.name if best_match.partner_id else '',
|
||||
'action_note': best_match.action_note or '',
|
||||
'amount_is_fixed': best_match.amount_is_fixed,
|
||||
}
|
||||
if best_match.reconcile_model_id:
|
||||
result['reconcile_model_id'] = best_match.reconcile_model_id.id
|
||||
result['reconcile_model_name'] = best_match.reconcile_model_id.name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def match_internal_transfers(env, params):
|
||||
"""[Tier 3] Find and match inter-account transfers between two bank journals.
|
||||
Matches exact amounts within a date window. Only matches when there is exactly
|
||||
ONE candidate on each side (no ambiguous matches). Requires user approval.
|
||||
|
||||
Typical use: Scotia Current Account ↔ Scotia Visa payments."""
|
||||
journal_a_id = int(params['journal_a_id']) # e.g., Scotia Current (50)
|
||||
journal_b_id = int(params['journal_b_id']) # e.g., Scotia Visa (51)
|
||||
date_from = params.get('date_from', '2025-01-01')
|
||||
date_to = params.get('date_to', '2025-03-31')
|
||||
max_days_apart = int(params.get('max_days_apart', 2))
|
||||
|
||||
# Get unreconciled positive lines from both journals
|
||||
# (transfers show as positive on the RECEIVING side)
|
||||
lines_a = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('journal_id', '=', journal_a_id),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
lines_a = lines_a.filtered(
|
||||
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
|
||||
and l.move_id.date <= fields.Date.from_string(date_to)
|
||||
and l.amount > 0 # money coming IN on this account
|
||||
)
|
||||
|
||||
lines_b = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('journal_id', '=', journal_b_id),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
lines_b = lines_b.filtered(
|
||||
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
|
||||
and l.move_id.date <= fields.Date.from_string(date_to)
|
||||
and l.amount > 0 # money coming IN on this account
|
||||
)
|
||||
|
||||
matched_pairs = []
|
||||
used_a = set()
|
||||
used_b = set()
|
||||
|
||||
# For each line in A, find exact-amount match in B within date window
|
||||
for la in sorted(lines_a, key=lambda l: l.move_id.date):
|
||||
if la.id in used_a:
|
||||
continue
|
||||
candidates = []
|
||||
for lb in lines_b:
|
||||
if lb.id in used_b:
|
||||
continue
|
||||
if abs(la.amount - lb.amount) < 0.01:
|
||||
days = abs((la.move_id.date - lb.move_id.date).days)
|
||||
if days <= max_days_apart:
|
||||
candidates.append(lb)
|
||||
# Only match if EXACTLY ONE candidate — skip ambiguous
|
||||
if len(candidates) == 1:
|
||||
lb = candidates[0]
|
||||
matched_pairs.append({
|
||||
'line_a_id': la.id,
|
||||
'line_a_date': str(la.move_id.date),
|
||||
'line_a_ref': la.payment_ref or '',
|
||||
'line_a_journal': la.journal_id.name,
|
||||
'line_b_id': lb.id,
|
||||
'line_b_date': str(lb.move_id.date),
|
||||
'line_b_ref': lb.payment_ref or '',
|
||||
'line_b_journal': lb.journal_id.name,
|
||||
'amount': la.amount,
|
||||
'days_apart': abs((la.move_id.date - lb.move_id.date).days),
|
||||
})
|
||||
used_a.add(la.id)
|
||||
used_b.add(lb.id)
|
||||
|
||||
if not matched_pairs:
|
||||
return {
|
||||
'status': 'no_matches',
|
||||
'message': 'No unambiguous transfer pairs found.',
|
||||
'lines_a_checked': len(lines_a),
|
||||
'lines_b_checked': len(lines_b),
|
||||
}
|
||||
|
||||
# If this is just a dry-run check (no execute flag), return the pairs for review
|
||||
if not params.get('execute', False):
|
||||
return {
|
||||
'status': 'pairs_found',
|
||||
'count': len(matched_pairs),
|
||||
'pairs': matched_pairs,
|
||||
'message': f'Found {len(matched_pairs)} unambiguous transfer pairs. Set execute=true to reconcile them.',
|
||||
}
|
||||
|
||||
# Execute: create internal transfer journal entries to reconcile both sides
|
||||
reconciled = []
|
||||
for pair in matched_pairs:
|
||||
try:
|
||||
line_a = env['account.bank.statement.line'].browse(pair['line_a_id'])
|
||||
line_b = env['account.bank.statement.line'].browse(pair['line_b_id'])
|
||||
|
||||
# Create an internal transfer payment
|
||||
payment = env['account.payment'].create({
|
||||
'payment_type': 'outbound',
|
||||
'partner_type': 'supplier',
|
||||
'partner_id': env.company.partner_id.id, # Self as partner for internal transfer
|
||||
'amount': pair['amount'],
|
||||
'journal_id': journal_a_id,
|
||||
'destination_journal_id': journal_b_id,
|
||||
'date': line_a.move_id.date,
|
||||
'ref': f'Internal Transfer: {pair["line_a_ref"]} ↔ {pair["line_b_ref"]}',
|
||||
'is_internal_transfer': True,
|
||||
})
|
||||
payment.action_post()
|
||||
|
||||
# Now match the payment's move lines to the bank statement lines
|
||||
# The payment creates lines on both journals' outstanding accounts
|
||||
for move_line in payment.move_id.line_ids:
|
||||
if move_line.journal_id.id == journal_a_id and not move_line.reconciled:
|
||||
try:
|
||||
line_a.set_line_bank_statement_line(move_line.ids)
|
||||
except Exception:
|
||||
pass
|
||||
# Check paired transfer for the other side
|
||||
if payment.paired_internal_transfer_payment_id:
|
||||
paired = payment.paired_internal_transfer_payment_id
|
||||
for move_line in paired.move_id.line_ids:
|
||||
if move_line.journal_id.id == journal_b_id and not move_line.reconciled:
|
||||
try:
|
||||
line_b.set_line_bank_statement_line(move_line.ids)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reconciled.append({
|
||||
'line_a_id': pair['line_a_id'],
|
||||
'line_b_id': pair['line_b_id'],
|
||||
'amount': pair['amount'],
|
||||
'payment_id': payment.id,
|
||||
'status': 'reconciled',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Failed to reconcile transfer pair %s: %s", pair, e)
|
||||
reconciled.append({
|
||||
'line_a_id': pair['line_a_id'],
|
||||
'line_b_id': pair['line_b_id'],
|
||||
'amount': pair['amount'],
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'executed',
|
||||
'total_pairs': len(matched_pairs),
|
||||
'reconciled': len([r for r in reconciled if r['status'] == 'reconciled']),
|
||||
'errors': len([r for r in reconciled if r['status'] == 'error']),
|
||||
'details': reconciled,
|
||||
}
|
||||
|
||||
|
||||
@@ -174,4 +493,7 @@ TOOLS = {
|
||||
'unmatch_bank_line': unmatch_bank_line,
|
||||
'get_reconcile_suggestions': get_reconcile_suggestions,
|
||||
'sum_payments_by_date': sum_payments_by_date,
|
||||
'get_bank_line_details': get_bank_line_details,
|
||||
'check_recurring_pattern': check_recurring_pattern,
|
||||
'match_internal_transfers': match_internal_transfers,
|
||||
}
|
||||
|
||||
@@ -15,12 +15,22 @@ def calculate_hst_balance(env, params):
|
||||
if date_to:
|
||||
base_domain.append(('date', '<=', date_to))
|
||||
|
||||
collected_accounts = env['account.account'].search([
|
||||
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
# Odoo 19 Enterprise: account.account may not have company_id field
|
||||
# (shared chart of accounts). Use try/except to handle both cases.
|
||||
try:
|
||||
collected_accounts = env['account.account'].search([
|
||||
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
except Exception:
|
||||
collected_accounts = env['account.account'].search([
|
||||
('code', '=like', '2005%'),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'),
|
||||
])
|
||||
|
||||
collected_lines = env['account.move.line'].search(
|
||||
base_domain + [('account_id', 'in', collected_accounts.ids)]
|
||||
@@ -124,7 +134,11 @@ def find_missing_itc_bills(env, params):
|
||||
|
||||
|
||||
def get_tax_return_status(env, params):
|
||||
returns = env['account.return'].search([
|
||||
try:
|
||||
AccountReturn = env['account.return']
|
||||
except KeyError:
|
||||
return {'error': 'Tax return model (account.return) is not available. The account_tax_report or related Enterprise module may not be installed.'}
|
||||
returns = AccountReturn.search([
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date_start desc', limit=10)
|
||||
return {
|
||||
@@ -140,7 +154,11 @@ def get_tax_return_status(env, params):
|
||||
|
||||
def generate_tax_return(env, params):
|
||||
try:
|
||||
env['account.return']._generate_or_refresh_all_returns(
|
||||
AccountReturn = env['account.return']
|
||||
except KeyError:
|
||||
return {'error': 'Tax return model (account.return) is not available.'}
|
||||
try:
|
||||
AccountReturn._generate_or_refresh_all_returns(
|
||||
company=env.company
|
||||
)
|
||||
return {'status': 'generated', 'message': 'Tax returns refreshed successfully.'}
|
||||
@@ -149,8 +167,12 @@ def generate_tax_return(env, params):
|
||||
|
||||
|
||||
def validate_tax_return(env, params):
|
||||
try:
|
||||
AccountReturn = env['account.return']
|
||||
except KeyError:
|
||||
return {'error': 'Tax return model (account.return) is not available.'}
|
||||
return_id = int(params['return_id'])
|
||||
tax_return = env['account.return'].browse(return_id)
|
||||
tax_return = AccountReturn.browse(return_id)
|
||||
if not tax_return.exists():
|
||||
return {'error': 'Tax return not found'}
|
||||
try:
|
||||
@@ -160,6 +182,111 @@ def validate_tax_return(env, params):
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def create_expense_entry(env, params):
|
||||
"""[Tier 3] Create a direct GL expense entry in the Misc journal with optional HST split.
|
||||
This is the 'old school' way of recording expenses without a formal vendor bill.
|
||||
Requires user approval before execution."""
|
||||
date = params.get('date', str(env['account.move']._fields['date'].default(env['account.move'])))
|
||||
description = params.get('description', 'Expense')
|
||||
expense_account_id = int(params['expense_account_id'])
|
||||
amount = abs(float(params['amount']))
|
||||
has_hst = params.get('has_hst', False)
|
||||
bank_journal_id = int(params.get('bank_journal_id', 0))
|
||||
|
||||
# Find the MISC journal
|
||||
misc_journal = env['account.journal'].search([
|
||||
('code', '=', 'MISC'), ('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if not misc_journal:
|
||||
return {'error': 'Miscellaneous Operations journal (MISC) not found'}
|
||||
|
||||
expense_account = env['account.account'].browse(expense_account_id)
|
||||
if not expense_account.exists():
|
||||
return {'error': f'Expense account not found: {expense_account_id}'}
|
||||
|
||||
# Determine credit account (bank outstanding or AP)
|
||||
credit_account = None
|
||||
if bank_journal_id:
|
||||
bank_journal = env['account.journal'].browse(bank_journal_id)
|
||||
if bank_journal.exists():
|
||||
# Use the bank journal's default debit/credit account
|
||||
credit_account = (bank_journal.default_account_id
|
||||
or bank_journal.company_id.account_journal_payment_credit_account_id)
|
||||
if not credit_account:
|
||||
# Fallback to AP account
|
||||
credit_account = env['account.account'].search([
|
||||
('account_type', '=', 'liability_payable'),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
|
||||
if not credit_account:
|
||||
return {'error': 'Could not determine credit account for the expense entry'}
|
||||
|
||||
line_ids = []
|
||||
if has_hst:
|
||||
# Split: net expense + 13% HST ITC
|
||||
hst_rate = 0.13
|
||||
net_amount = round(amount / (1 + hst_rate), 2)
|
||||
hst_amount = round(amount - net_amount, 2)
|
||||
|
||||
# Find HST ITC account (2006%)
|
||||
itc_account = env['account.account'].search([
|
||||
('code', '=like', '2006%'),
|
||||
], limit=1)
|
||||
if not itc_account:
|
||||
# Fallback: use the HST purchase tax account
|
||||
hst_tax = env['account.tax'].search([
|
||||
('type_tax_use', '=', 'purchase'), ('amount', '=', 13.0),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if hst_tax and hst_tax.invoice_repartition_line_ids:
|
||||
for rep in hst_tax.invoice_repartition_line_ids:
|
||||
if rep.repartition_type == 'tax' and rep.account_id:
|
||||
itc_account = rep.account_id
|
||||
break
|
||||
if not itc_account:
|
||||
return {'error': 'HST ITC account (2006) not found'}
|
||||
|
||||
line_ids = [
|
||||
(0, 0, {'name': description, 'account_id': expense_account_id,
|
||||
'debit': net_amount, 'credit': 0.0}),
|
||||
(0, 0, {'name': f'HST ITC - {description}', 'account_id': itc_account.id,
|
||||
'debit': hst_amount, 'credit': 0.0}),
|
||||
(0, 0, {'name': description, 'account_id': credit_account.id,
|
||||
'debit': 0.0, 'credit': amount}),
|
||||
]
|
||||
else:
|
||||
# Simple: debit expense / credit bank
|
||||
line_ids = [
|
||||
(0, 0, {'name': description, 'account_id': expense_account_id,
|
||||
'debit': amount, 'credit': 0.0}),
|
||||
(0, 0, {'name': description, 'account_id': credit_account.id,
|
||||
'debit': 0.0, 'credit': amount}),
|
||||
]
|
||||
|
||||
try:
|
||||
move = env['account.move'].create({
|
||||
'move_type': 'entry',
|
||||
'journal_id': misc_journal.id,
|
||||
'date': date,
|
||||
'ref': description,
|
||||
'line_ids': line_ids,
|
||||
'company_id': env.company.id,
|
||||
})
|
||||
move.action_post()
|
||||
return {
|
||||
'status': 'posted',
|
||||
'move_id': move.id,
|
||||
'move_name': move.name,
|
||||
'amount': amount,
|
||||
'has_hst': has_hst,
|
||||
'hst_amount': round(amount - amount / 1.13, 2) if has_hst else 0.0,
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.error("Failed to create expense entry: %s", e)
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'calculate_hst_balance': calculate_hst_balance,
|
||||
'get_tax_report': get_tax_report,
|
||||
@@ -168,4 +295,5 @@ TOOLS = {
|
||||
'get_tax_return_status': get_tax_return_status,
|
||||
'generate_tax_return': generate_tax_return,
|
||||
'validate_tax_return': validate_tax_return,
|
||||
'create_expense_entry': create_expense_entry,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,48 @@ import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { FusionApprovalCard } from "./approval_card";
|
||||
|
||||
/**
|
||||
* Parse a fusion-table JSON block from AI response.
|
||||
* Returns {json, placeholder} or null if not a fusion-table block.
|
||||
*/
|
||||
function parseFusionTableBlock(text) {
|
||||
// Match ```fusion-table ... ``` blocks
|
||||
const regex = /```fusion-table\s*\n([\s\S]*?)```/g;
|
||||
const tables = [];
|
||||
let lastIndex = 0;
|
||||
const parts = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Add text before the block
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: "md", content: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
// Parse the JSON
|
||||
try {
|
||||
const data = JSON.parse(match[1].trim());
|
||||
const tableIdx = tables.length;
|
||||
tables.push(data);
|
||||
parts.push({ type: "table", idx: tableIdx });
|
||||
} catch (e) {
|
||||
// If JSON parse fails, treat as regular code block
|
||||
parts.push({ type: "md", content: match[0] });
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Remaining text after last block
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: "md", content: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
if (tables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { parts, tables };
|
||||
}
|
||||
|
||||
|
||||
function mdToHtml(text) {
|
||||
if (!text) return "";
|
||||
|
||||
@@ -150,6 +192,8 @@ export class FusionChatPanel extends Component {
|
||||
setup() {
|
||||
this.inputRef = useRef("chatInput");
|
||||
this.messagesRef = useRef("messages");
|
||||
// Track parsed table data per message index for interactive tables
|
||||
this._parsedTables = {};
|
||||
this.state = useState({
|
||||
messages: [],
|
||||
pendingApprovals: [],
|
||||
@@ -158,6 +202,11 @@ export class FusionChatPanel extends Component {
|
||||
loading: true,
|
||||
internalSessionId: null,
|
||||
sessionName: null,
|
||||
// Interactive tables extracted from AI messages, keyed by msg index
|
||||
interactiveTables: {},
|
||||
// Session history picker
|
||||
showSessionPicker: false,
|
||||
sessionList: [],
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
@@ -181,14 +230,240 @@ export class FusionChatPanel extends Component {
|
||||
const idx = parseInt(div.dataset.idx);
|
||||
const msg = this.state.messages[idx];
|
||||
if (msg && msg.role === "assistant" && msg.content) {
|
||||
const html = mdToHtml(msg.content);
|
||||
if (div.innerHTML !== html) {
|
||||
div.innerHTML = html;
|
||||
// Check for fusion-table blocks
|
||||
const parsed = parseFusionTableBlock(msg.content);
|
||||
if (parsed) {
|
||||
// Build HTML with placeholders for interactive tables
|
||||
let html = "";
|
||||
for (const part of parsed.parts) {
|
||||
if (part.type === "md") {
|
||||
html += mdToHtml(part.content);
|
||||
} else if (part.type === "table") {
|
||||
const tableKey = `${idx}_${part.idx}`;
|
||||
html += `<div class="fusion_table_mount" data-table-key="${tableKey}"></div>`;
|
||||
// Store table data for OWL mounting
|
||||
this._parsedTables[tableKey] = parsed.tables[part.idx];
|
||||
}
|
||||
}
|
||||
if (div.innerHTML !== html) {
|
||||
div.innerHTML = html;
|
||||
}
|
||||
// Mount OWL interactive table components into placeholders
|
||||
this._mountInteractiveTables(div);
|
||||
} else {
|
||||
const html = mdToHtml(msg.content);
|
||||
if (div.innerHTML !== html) {
|
||||
div.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_mountInteractiveTables(container) {
|
||||
const mounts = container.querySelectorAll(".fusion_table_mount[data-table-key]");
|
||||
for (const el of mounts) {
|
||||
const key = el.dataset.tableKey;
|
||||
if (el.dataset.mounted === "true") continue;
|
||||
const tableData = this._parsedTables[key];
|
||||
if (!tableData) continue;
|
||||
|
||||
el.dataset.mounted = "true";
|
||||
el.innerHTML = this._buildInteractiveTableHtml(tableData, key);
|
||||
this._wireTableEvents(el, tableData, key);
|
||||
}
|
||||
}
|
||||
|
||||
_badgeClass(action) {
|
||||
switch (action) {
|
||||
case "dismiss": return "bg-success-subtle text-success";
|
||||
case "flag": return "bg-warning-subtle text-warning";
|
||||
case "create_rule": return "bg-info-subtle text-info";
|
||||
default: return "bg-secondary-subtle text-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
_badgeLabel(action) {
|
||||
switch (action) {
|
||||
case "dismiss": return "Dismiss";
|
||||
case "flag": return "Flag";
|
||||
case "create_rule": return "Create Rule";
|
||||
default: return action || "Review";
|
||||
}
|
||||
}
|
||||
|
||||
_esc(text) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = text;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
_buildInteractiveTableHtml(tableData, key) {
|
||||
const cols = tableData.columns || [];
|
||||
const rows = tableData.rows || [];
|
||||
const isInteractive = tableData.mode === "interactive";
|
||||
const actions = tableData.actions || [];
|
||||
const title = tableData.title || "";
|
||||
|
||||
let h = '<div class="fusion_interactive_table my-2">';
|
||||
|
||||
// Title
|
||||
if (title) {
|
||||
h += `<div class="d-flex align-items-center mb-2">`;
|
||||
h += `<i class="fa fa-table me-2 text-muted"></i>`;
|
||||
h += `<strong>${this._esc(title)}</strong>`;
|
||||
h += `<span class="badge bg-secondary-subtle text-secondary ms-2">${rows.length} rows</span>`;
|
||||
h += `</div>`;
|
||||
}
|
||||
|
||||
// Table
|
||||
h += '<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0"><thead><tr>';
|
||||
if (isInteractive) {
|
||||
h += `<th class="fit-content px-2"><input type="checkbox" class="form-check-input" data-action="select-all"/></th>`;
|
||||
}
|
||||
for (const col of cols) {
|
||||
h += `<th class="px-2 py-1">${this._esc(col)}</th>`;
|
||||
}
|
||||
if (isInteractive) {
|
||||
h += `<th class="px-2 py-1 text-info">AI Recommendation</th>`;
|
||||
h += `<th class="px-2 py-1 text-warning" style="min-width:180px;">Your Input</th>`;
|
||||
}
|
||||
h += '</tr></thead><tbody>';
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
h += `<tr data-row-idx="${i}">`;
|
||||
if (isInteractive) {
|
||||
h += `<td class="fit-content px-2"><input type="checkbox" class="form-check-input" data-action="select-row" data-idx="${i}"/></td>`;
|
||||
}
|
||||
for (const cell of (row.cells || [])) {
|
||||
h += `<td class="px-2 py-1">${this._esc(String(cell))}</td>`;
|
||||
}
|
||||
if (isInteractive) {
|
||||
// Recommendation
|
||||
h += `<td class="px-2 py-1">`;
|
||||
if (row.recommendation) {
|
||||
const rc = row.recommendation;
|
||||
h += `<span class="badge me-1 ${this._badgeClass(rc.action)}">${this._badgeLabel(rc.action)}</span>`;
|
||||
h += `<small class="text-muted">${this._esc(rc.reason || "")}</small>`;
|
||||
}
|
||||
h += `</td>`;
|
||||
// User input
|
||||
h += `<td class="px-2 py-1"><input type="text" class="form-control form-control-sm fusion_row_note" data-idx="${i}" placeholder="Add your note..."/></td>`;
|
||||
}
|
||||
h += '</tr>';
|
||||
}
|
||||
h += '</tbody></table></div>';
|
||||
|
||||
// Action bar
|
||||
if (isInteractive) {
|
||||
h += '<div class="fusion_table_action_bar d-flex flex-wrap align-items-center gap-2 p-2 border-top">';
|
||||
h += '<small class="text-muted me-1 fusion_selected_count">0 selected</small>';
|
||||
h += `<button class="btn btn-success btn-sm" data-action="apply_recommendations" disabled><i class="fa fa-check me-1"></i>Apply Recommendations</button>`;
|
||||
if (actions.includes("flag")) {
|
||||
h += `<button class="btn btn-outline-warning btn-sm" data-action="flag" disabled><i class="fa fa-flag me-1"></i>Flag Selected</button>`;
|
||||
}
|
||||
if (actions.includes("create_rule")) {
|
||||
h += `<button class="btn btn-outline-info btn-sm" data-action="create_rule" disabled><i class="fa fa-plus me-1"></i>Create Rules</button>`;
|
||||
}
|
||||
if (actions.includes("dismiss")) {
|
||||
h += `<button class="btn btn-outline-secondary btn-sm" data-action="dismiss" disabled>Dismiss Selected</button>`;
|
||||
}
|
||||
h += '<div class="flex-grow-1"></div>';
|
||||
h += `<button class="btn btn-outline-primary btn-sm" data-action="submit_notes"><i class="fa fa-pencil me-1"></i>Submit All Notes to AI</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
h += '</div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
_wireTableEvents(container, tableData, key) {
|
||||
const rows = tableData.rows || [];
|
||||
|
||||
// Select all checkbox
|
||||
const selectAll = container.querySelector('[data-action="select-all"]');
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener("change", () => {
|
||||
const cbs = container.querySelectorAll('[data-action="select-row"]');
|
||||
for (const cb of cbs) cb.checked = selectAll.checked;
|
||||
this._updateTableActionBar(container);
|
||||
});
|
||||
}
|
||||
|
||||
// Individual row checkboxes
|
||||
const rowCbs = container.querySelectorAll('[data-action="select-row"]');
|
||||
for (const cb of rowCbs) {
|
||||
cb.addEventListener("change", () => this._updateTableActionBar(container));
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
const actionBtns = container.querySelectorAll('.fusion_table_action_bar button[data-action]');
|
||||
for (const btn of actionBtns) {
|
||||
btn.addEventListener("click", () => {
|
||||
const action = btn.dataset.action;
|
||||
const selectedRows = this._collectTableRows(container, tableData, action === "submit_notes");
|
||||
if (selectedRows.length === 0) return;
|
||||
this.onTableAction({
|
||||
action,
|
||||
source_tool: tableData.source_tool,
|
||||
rows: selectedRows,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_updateTableActionBar(container) {
|
||||
const cbs = container.querySelectorAll('[data-action="select-row"]:checked');
|
||||
const count = cbs.length;
|
||||
const countEl = container.querySelector('.fusion_selected_count');
|
||||
if (countEl) countEl.textContent = `${count} selected`;
|
||||
// Enable/disable action buttons
|
||||
const btns = container.querySelectorAll('.fusion_table_action_bar button[data-action]');
|
||||
for (const btn of btns) {
|
||||
if (btn.dataset.action === "submit_notes") continue; // always enabled
|
||||
btn.disabled = (count === 0);
|
||||
}
|
||||
}
|
||||
|
||||
_collectTableRows(container, tableData, allNotes) {
|
||||
const rows = tableData.rows || [];
|
||||
const result = [];
|
||||
|
||||
if (allNotes) {
|
||||
// Collect all rows that have a note
|
||||
const inputs = container.querySelectorAll('.fusion_row_note');
|
||||
for (const inp of inputs) {
|
||||
const idx = parseInt(inp.dataset.idx);
|
||||
const note = inp.value.trim();
|
||||
if (note && rows[idx]) {
|
||||
result.push({
|
||||
id: rows[idx].id,
|
||||
cells: rows[idx].cells,
|
||||
recommendation: rows[idx].recommendation,
|
||||
userNote: note,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Collect checked rows
|
||||
const cbs = container.querySelectorAll('[data-action="select-row"]:checked');
|
||||
for (const cb of cbs) {
|
||||
const idx = parseInt(cb.dataset.idx);
|
||||
if (rows[idx]) {
|
||||
const noteInput = container.querySelector(`.fusion_row_note[data-idx="${idx}"]`);
|
||||
result.push({
|
||||
id: rows[idx].id,
|
||||
cells: rows[idx].cells,
|
||||
recommendation: rows[idx].recommendation,
|
||||
userNote: noteInput ? noteInput.value.trim() : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
get sessionId() {
|
||||
return this.state.internalSessionId || this.props.sessionId;
|
||||
}
|
||||
@@ -209,17 +484,87 @@ export class FusionChatPanel extends Component {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
async toggleSessionPicker() {
|
||||
if (this.state.showSessionPicker) {
|
||||
this.state.showSessionPicker = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await rpc("/fusion_accounting/session/list", { limit: 20 });
|
||||
this.state.sessionList = data.sessions || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load session list:", e);
|
||||
this.state.sessionList = [];
|
||||
}
|
||||
this.state.showSessionPicker = true;
|
||||
}
|
||||
|
||||
async loadSession(sessionId) {
|
||||
this.state.showSessionPicker = false;
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const data = await rpc("/fusion_accounting/session/history", { session_id: sessionId });
|
||||
if (data.messages) {
|
||||
this.state.internalSessionId = data.session_id;
|
||||
// Filter display messages same as session/latest
|
||||
const display = [];
|
||||
for (const msg of data.messages) {
|
||||
if (typeof msg.content === "string" && msg.content.trim()) {
|
||||
display.push(msg);
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block && block.type === "text" && block.text && block.text.trim()) {
|
||||
display.push({ role: msg.role, content: block.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.state.messages = display;
|
||||
// Find session name from the list
|
||||
const found = this.state.sessionList.find(s => s.id === sessionId);
|
||||
this.state.sessionName = found ? found.name : `Session #${sessionId}`;
|
||||
this.state.pendingApprovals = [];
|
||||
this._parsedTables = {};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load session:", e);
|
||||
}
|
||||
this.state.loading = false;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
formatSessionDate(isoDate) {
|
||||
if (!isoDate) return "";
|
||||
try {
|
||||
const d = new Date(isoDate);
|
||||
return d.toLocaleDateString("en-CA", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
} catch (e) {
|
||||
return isoDate;
|
||||
}
|
||||
}
|
||||
|
||||
async onNewChat() {
|
||||
// Close current session first — must succeed before creating new one
|
||||
if (this.sessionId) {
|
||||
try {
|
||||
await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
|
||||
} catch (e) { /* not critical */ }
|
||||
const closeResult = await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
|
||||
if (closeResult.error) {
|
||||
console.warn("Failed to close session:", closeResult.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Error closing session:", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const session = await rpc("/fusion_accounting/session/create", {});
|
||||
this.state.internalSessionId = session.session_id;
|
||||
this.state.sessionName = session.name;
|
||||
this.state.messages = [];
|
||||
this.state.pendingApprovals = [];
|
||||
this._parsedTables = {};
|
||||
} catch (e) {
|
||||
console.error("Failed to create new session:", e);
|
||||
}
|
||||
const session = await rpc("/fusion_accounting/session/create", {});
|
||||
this.state.internalSessionId = session.session_id;
|
||||
this.state.sessionName = session.name;
|
||||
this.state.messages = [];
|
||||
this.state.pendingApprovals = [];
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
@@ -258,6 +603,66 @@ export class FusionChatPanel extends Component {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle actions from interactive tables (Apply, Flag, Create Rule, Dismiss, Submit Notes).
|
||||
* Formats a structured message and sends it back through the chat.
|
||||
*/
|
||||
async onTableAction(payload) {
|
||||
const { action, source_tool, rows } = payload;
|
||||
const actionLabels = {
|
||||
apply_recommendations: "Apply Recommendations",
|
||||
flag: "Flag",
|
||||
create_rule: "Create Rules",
|
||||
dismiss: "Dismiss",
|
||||
submit_notes: "Submit Notes",
|
||||
};
|
||||
const label = actionLabels[action] || action;
|
||||
|
||||
// Build a structured message for the AI
|
||||
let parts = [`[TABLE_ACTION] source=${source_tool} action=${action}`];
|
||||
for (const row of rows) {
|
||||
const cellSummary = (row.cells || []).join(" | ");
|
||||
let line = `- Row #${row.id}: ${cellSummary}`;
|
||||
if (row.recommendation) {
|
||||
line += ` (AI suggested: ${row.recommendation.action} - ${row.recommendation.reason})`;
|
||||
}
|
||||
if (row.userNote) {
|
||||
line += ` [User note: ${row.userNote}]`;
|
||||
}
|
||||
parts.push(line);
|
||||
}
|
||||
|
||||
const message = parts.join("\n");
|
||||
|
||||
// Show user what we're sending
|
||||
this.state.messages.push({
|
||||
role: "user",
|
||||
content: `**${label}** on ${rows.length} row(s) from ${source_tool}`,
|
||||
});
|
||||
this.state.sending = true;
|
||||
this.scrollToBottom();
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_accounting/chat", {
|
||||
session_id: this.sessionId,
|
||||
message: message,
|
||||
});
|
||||
if (result.text) {
|
||||
this.state.messages.push({ role: "assistant", content: result.text });
|
||||
}
|
||||
if (result.pending_approvals) {
|
||||
this.state.pendingApprovals = result.pending_approvals;
|
||||
}
|
||||
} catch (e) {
|
||||
this.state.messages.push({
|
||||
role: "assistant",
|
||||
content: `Error processing table action: ${e.message || "Something went wrong."}`,
|
||||
});
|
||||
}
|
||||
this.state.sending = false;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
onKeyDown(ev) {
|
||||
if (ev.key === "Enter" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -3,16 +3,60 @@
|
||||
<t t-name="fusion_accounting.ChatPanel">
|
||||
<div class="fusion_chat_panel card h-100 d-flex flex-column">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||
<div>
|
||||
<div class="d-flex align-items-center">
|
||||
<h5 class="mb-0 d-inline"><i class="fa fa-comments-o me-2"/>Fusion AI</h5>
|
||||
<small class="text-muted ms-2" t-if="state.sessionName" t-esc="state.sessionName"/>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
|
||||
title="Start a new conversation">
|
||||
<i class="fa fa-plus me-1"/>New Chat
|
||||
</button>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<!-- Session history button -->
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
t-on-click="toggleSessionPicker"
|
||||
title="Load previous session">
|
||||
<i class="fa fa-history"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
|
||||
title="Start a new conversation">
|
||||
<i class="fa fa-plus me-1"/>New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Picker Dropdown -->
|
||||
<t t-if="state.showSessionPicker">
|
||||
<div class="fusion_session_picker border-bottom">
|
||||
<div class="p-2 bg-body-tertiary">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="fw-semibold text-muted">Recent Sessions</small>
|
||||
<button class="btn-close btn-close-sm" t-on-click="toggleSessionPicker"/>
|
||||
</div>
|
||||
<t t-if="state.sessionList.length === 0">
|
||||
<p class="text-muted small mb-0">No previous sessions found.</p>
|
||||
</t>
|
||||
<div class="fusion_session_list overflow-auto" style="max-height: 200px;">
|
||||
<t t-foreach="state.sessionList" t-as="sess" t-key="sess.id">
|
||||
<div class="fusion_session_item d-flex justify-content-between align-items-center p-2 rounded cursor-pointer"
|
||||
t-att-class="sess.id === state.internalSessionId ? 'bg-primary-subtle' : ''"
|
||||
t-on-click="() => this.loadSession(sess.id)">
|
||||
<div>
|
||||
<div class="small fw-semibold" t-esc="sess.name"/>
|
||||
<div class="text-muted" style="font-size: 0.72rem;">
|
||||
<t t-esc="formatSessionDate(sess.date)"/>
|
||||
<span class="ms-2" t-if="sess.message_count">
|
||||
<t t-esc="sess.message_count"/> msgs
|
||||
</span>
|
||||
<span class="ms-1 badge"
|
||||
t-att-class="sess.state === 'active' ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary'"
|
||||
t-esc="sess.state"/>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fa fa-chevron-right text-muted" style="font-size: 0.7rem;"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="fusion_chat_messages flex-grow-1 overflow-auto p-3" t-ref="messages">
|
||||
<t t-if="state.loading">
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
|
||||
export class FusionInteractiveTable extends Component {
|
||||
static template = "fusion_accounting.InteractiveTable";
|
||||
static props = ["tableData", "onTableAction"];
|
||||
|
||||
setup() {
|
||||
const rows = (this.props.tableData.rows || []).map((row) => ({
|
||||
...row,
|
||||
selected: false,
|
||||
userNote: "",
|
||||
}));
|
||||
this.state = useState({
|
||||
rows,
|
||||
selectAll: false,
|
||||
});
|
||||
}
|
||||
|
||||
get isInteractive() {
|
||||
return this.props.tableData.mode === "interactive";
|
||||
}
|
||||
|
||||
get columns() {
|
||||
return this.props.tableData.columns || [];
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.props.tableData.title || "";
|
||||
}
|
||||
|
||||
get actions() {
|
||||
return this.props.tableData.actions || [];
|
||||
}
|
||||
|
||||
get selectedCount() {
|
||||
return this.state.rows.filter((r) => r.selected).length;
|
||||
}
|
||||
|
||||
get hasAction() {
|
||||
return (action) => this.actions.includes(action);
|
||||
}
|
||||
|
||||
actionAvailable(action) {
|
||||
return this.actions.includes(action);
|
||||
}
|
||||
|
||||
recommendationClass(action) {
|
||||
switch (action) {
|
||||
case "dismiss":
|
||||
return "bg-success-subtle text-success";
|
||||
case "flag":
|
||||
return "bg-warning-subtle text-warning";
|
||||
case "create_rule":
|
||||
return "bg-info-subtle text-info";
|
||||
default:
|
||||
return "bg-secondary-subtle text-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
recommendationLabel(action) {
|
||||
switch (action) {
|
||||
case "dismiss":
|
||||
return "Dismiss";
|
||||
case "flag":
|
||||
return "Flag";
|
||||
case "create_rule":
|
||||
return "Create Rule";
|
||||
default:
|
||||
return action || "Review";
|
||||
}
|
||||
}
|
||||
|
||||
onToggleSelectAll() {
|
||||
const newVal = !this.state.selectAll;
|
||||
this.state.selectAll = newVal;
|
||||
for (const row of this.state.rows) {
|
||||
row.selected = newVal;
|
||||
}
|
||||
}
|
||||
|
||||
onToggleRow(rowIndex) {
|
||||
this.state.rows[rowIndex].selected = !this.state.rows[rowIndex].selected;
|
||||
this.state.selectAll = this.state.rows.every((r) => r.selected);
|
||||
}
|
||||
|
||||
onNoteInput(rowIndex, ev) {
|
||||
this.state.rows[rowIndex].userNote = ev.target.value;
|
||||
}
|
||||
|
||||
_collectSelected() {
|
||||
return this.state.rows
|
||||
.filter((r) => r.selected)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
cells: r.cells,
|
||||
recommendation: r.recommendation,
|
||||
userNote: r.userNote,
|
||||
}));
|
||||
}
|
||||
|
||||
_collectAllNotes() {
|
||||
return this.state.rows
|
||||
.filter((r) => r.userNote.trim())
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
cells: r.cells,
|
||||
recommendation: r.recommendation,
|
||||
userNote: r.userNote,
|
||||
}));
|
||||
}
|
||||
|
||||
onApplyRecommendations() {
|
||||
const selected = this._collectSelected();
|
||||
if (!selected.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "apply_recommendations",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: selected,
|
||||
});
|
||||
}
|
||||
|
||||
onFlagSelected() {
|
||||
const selected = this._collectSelected();
|
||||
if (!selected.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "flag",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: selected,
|
||||
});
|
||||
}
|
||||
|
||||
onCreateRules() {
|
||||
const selected = this._collectSelected();
|
||||
if (!selected.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "create_rule",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: selected,
|
||||
});
|
||||
}
|
||||
|
||||
onDismissSelected() {
|
||||
const selected = this._collectSelected();
|
||||
if (!selected.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "dismiss",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: selected,
|
||||
});
|
||||
}
|
||||
|
||||
onSubmitNotes() {
|
||||
const noted = this._collectAllNotes();
|
||||
if (!noted.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "submit_notes",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: noted,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.InteractiveTable">
|
||||
<div class="fusion_interactive_table my-2">
|
||||
<!-- Title -->
|
||||
<t t-if="title">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="fa fa-table me-2 text-muted"/>
|
||||
<strong t-esc="title"/>
|
||||
<span class="badge bg-secondary-subtle text-secondary ms-2"
|
||||
t-esc="state.rows.length + ' rows'"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- Checkbox column (interactive only) -->
|
||||
<t t-if="isInteractive">
|
||||
<th class="fit-content px-2">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
t-att-checked="state.selectAll"
|
||||
t-on-change="onToggleSelectAll"/>
|
||||
</th>
|
||||
</t>
|
||||
<!-- Data columns -->
|
||||
<t t-foreach="columns" t-as="col" t-key="col_index">
|
||||
<th class="px-2 py-1" t-esc="col"/>
|
||||
</t>
|
||||
<!-- AI Recommendation column (interactive only) -->
|
||||
<t t-if="isInteractive">
|
||||
<th class="px-2 py-1 text-info">AI Recommendation</th>
|
||||
<th class="px-2 py-1 text-warning" style="min-width: 180px;">Your Input</th>
|
||||
</t>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.rows" t-as="row" t-key="row_index">
|
||||
<tr t-att-class="row.selected ? 'table-active' : ''">
|
||||
<!-- Checkbox -->
|
||||
<t t-if="isInteractive">
|
||||
<td class="fit-content px-2">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
t-att-checked="row.selected"
|
||||
t-on-change="() => this.onToggleRow(row_index)"/>
|
||||
</td>
|
||||
</t>
|
||||
<!-- Data cells -->
|
||||
<t t-foreach="row.cells" t-as="cell" t-key="cell_index">
|
||||
<td class="px-2 py-1" t-esc="cell"/>
|
||||
</t>
|
||||
<!-- AI Recommendation -->
|
||||
<t t-if="isInteractive">
|
||||
<td class="px-2 py-1">
|
||||
<t t-if="row.recommendation">
|
||||
<span t-att-class="'badge me-1 ' + recommendationClass(row.recommendation.action)"
|
||||
t-esc="recommendationLabel(row.recommendation.action)"/>
|
||||
<small class="text-muted" t-esc="row.recommendation.reason"/>
|
||||
</t>
|
||||
</td>
|
||||
<!-- User input -->
|
||||
<td class="px-2 py-1">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm fusion_row_note"
|
||||
placeholder="Add your note..."
|
||||
t-att-value="row.userNote"
|
||||
t-on-input="(ev) => this.onNoteInput(row_index, ev)"/>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Action Bar (interactive only) -->
|
||||
<t t-if="isInteractive">
|
||||
<div class="fusion_table_action_bar d-flex flex-wrap align-items-center gap-2 p-2 border-top">
|
||||
<small class="text-muted me-1">
|
||||
<t t-esc="selectedCount"/> selected
|
||||
</small>
|
||||
<button class="btn btn-success btn-sm"
|
||||
t-att-disabled="selectedCount === 0"
|
||||
t-on-click="onApplyRecommendations">
|
||||
<i class="fa fa-check me-1"/>Apply Recommendations
|
||||
</button>
|
||||
<t t-if="actionAvailable('flag')">
|
||||
<button class="btn btn-outline-warning btn-sm"
|
||||
t-att-disabled="selectedCount === 0"
|
||||
t-on-click="onFlagSelected">
|
||||
<i class="fa fa-flag me-1"/>Flag Selected
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="actionAvailable('create_rule')">
|
||||
<button class="btn btn-outline-info btn-sm"
|
||||
t-att-disabled="selectedCount === 0"
|
||||
t-on-click="onCreateRules">
|
||||
<i class="fa fa-plus me-1"/>Create Rules
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="actionAvailable('dismiss')">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
t-att-disabled="selectedCount === 0"
|
||||
t-on-click="onDismissSelected">
|
||||
Dismiss Selected
|
||||
</button>
|
||||
</t>
|
||||
<div class="flex-grow-1"/>
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
t-on-click="onSubmitNotes">
|
||||
<i class="fa fa-pencil me-1"/>Submit All Notes to AI
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -17,35 +17,52 @@
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<!-- Health Cards -->
|
||||
<div class="fusion_health_cards d-flex flex-wrap gap-3 p-3">
|
||||
<t t-foreach="cards" t-as="card" t-key="card.domain">
|
||||
<FusionHealthCard
|
||||
title="card.title"
|
||||
metric="card.metric"
|
||||
subtext="card.subtext"
|
||||
status="card.status"
|
||||
domain="card.domain"
|
||||
onCardClick.bind="onCardClick"/>
|
||||
</t>
|
||||
</div>
|
||||
<!-- Main layout: Left panel (cards + needs attention) | Right panel (chat) -->
|
||||
<div class="fusion_main_layout d-flex">
|
||||
|
||||
<!-- Action Centre + Chat -->
|
||||
<div class="d-flex gap-3 p-3" style="min-height: 500px;">
|
||||
<!-- Action Centre -->
|
||||
<div class="flex-grow-1">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Needs Attention</h5>
|
||||
<!-- LEFT SIDE: Cards (2 rows of 3) + Needs Attention -->
|
||||
<div class="fusion_left_panel d-flex flex-column p-3 gap-3">
|
||||
|
||||
<!-- Health Cards: 2 rows x 3 cards -->
|
||||
<div class="fusion_health_cards d-flex flex-wrap gap-2">
|
||||
<t t-foreach="cards" t-as="card" t-key="card.domain">
|
||||
<FusionHealthCard
|
||||
title="card.title"
|
||||
metric="card.metric"
|
||||
subtext="card.subtext"
|
||||
status="card.status"
|
||||
domain="card.domain"
|
||||
onCardClick.bind="onCardClick"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Needs Attention Panel -->
|
||||
<div class="card fusion_attention_card">
|
||||
<div class="card-header py-2">
|
||||
<h5 class="mb-0"><i class="fa fa-exclamation-triangle me-2 text-warning"/>Needs Attention</h5>
|
||||
</div>
|
||||
<div class="card-body overflow-auto">
|
||||
<p class="text-muted">AI-prioritised items will appear here after the first audit scan.</p>
|
||||
<div class="card-body overflow-auto p-2">
|
||||
<t t-if="state.data and state.data.needs_attention and state.data.needs_attention.length">
|
||||
<t t-foreach="state.data.needs_attention" t-as="item" t-key="item_index">
|
||||
<div class="fusion_attention_item d-flex align-items-start gap-2 p-2 rounded mb-1 cursor-pointer"
|
||||
t-on-click="() => this.onCardClick(item.domain)">
|
||||
<i class="fa fa-circle-o text-warning mt-1" style="font-size: 0.6rem;"/>
|
||||
<div>
|
||||
<div class="fw-semibold small" t-esc="item.title"/>
|
||||
<div class="text-muted" style="font-size: 0.78rem;" t-esc="item.action"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="text-muted small mb-0">AI-prioritised items will appear here after the first audit scan.</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel (720px = original 400 + 80%) -->
|
||||
<div style="width: 720px; min-width: 600px;">
|
||||
<!-- RIGHT SIDE: Chat Panel (full height, input pinned to bottom) -->
|
||||
<div class="fusion_right_panel border-start">
|
||||
<FusionChatPanel sessionId="state.chatSessionId"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Session picker dropdown
|
||||
.fusion_session_picker {
|
||||
flex-shrink: 0;
|
||||
|
||||
.fusion_session_item {
|
||||
transition: background 0.15s ease;
|
||||
&:hover {
|
||||
background: rgba(var(--bs-body-color-rgb), 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fusion_chat_input {
|
||||
flex-shrink: 0;
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
@@ -69,4 +82,83 @@
|
||||
.fusion_approval_card {
|
||||
border-left: 3px solid var(--bs-warning);
|
||||
}
|
||||
|
||||
// Interactive table styles
|
||||
.fusion_interactive_table {
|
||||
border: 1px solid var(--o-border-color);
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
background: var(--o-view-background-color);
|
||||
|
||||
.table {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0;
|
||||
|
||||
thead th {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
background: rgba(var(--bs-body-color-rgb), 0.03);
|
||||
border-bottom: 2px solid var(--o-border-color);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--bs-body-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
&.table-active {
|
||||
background: rgba(var(--bs-primary-rgb), 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.fit-content {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Row note input
|
||||
.fusion_row_note {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o-border-color);
|
||||
color: inherit;
|
||||
|
||||
&:focus {
|
||||
background: var(--o-view-background-color);
|
||||
border-color: var(--o-action-color, var(--bs-primary));
|
||||
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
// Recommendation badges
|
||||
.badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 0.2em 0.5em;
|
||||
}
|
||||
|
||||
// Action bar at bottom
|
||||
.fusion_table_action_bar {
|
||||
background: rgba(var(--bs-body-color-rgb), 0.02);
|
||||
border-top: 1px solid var(--o-border-color);
|
||||
|
||||
.btn-sm {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
.fusion_accounting_dashboard {
|
||||
// Fill the available Odoo content area (below navbar + menu bar)
|
||||
// Use 100% of parent instead of 100vh to respect Odoo's own layout
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.fusion_dashboard_header {
|
||||
border-bottom: 1px solid var(--o-border-color);
|
||||
background: var(--o-view-background-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Main two-column layout — must fill remaining height
|
||||
.fusion_main_layout {
|
||||
flex: 1;
|
||||
// This is the key: prevent the flex container from growing beyond
|
||||
// the viewport, which would push the chat input off-screen
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Left panel: cards + needs attention (scrollable)
|
||||
.fusion_left_panel {
|
||||
width: 50%;
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Health cards: 3 per row
|
||||
.fusion_health_cards {
|
||||
flex-shrink: 0;
|
||||
|
||||
.fusion_health_card {
|
||||
flex: 0 0 calc(33.333% - 6px);
|
||||
min-width: 150px;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
@@ -13,4 +43,71 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Needs Attention: fill remaining left panel space
|
||||
.fusion_attention_card {
|
||||
flex: 1;
|
||||
min-height: 150px;
|
||||
overflow: hidden;
|
||||
|
||||
.card-body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Needs Attention items
|
||||
.fusion_attention_item {
|
||||
transition: background 0.15s ease;
|
||||
&:hover {
|
||||
background: rgba(var(--bs-body-color-rgb), 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
// Right panel: chat takes all remaining width and height
|
||||
.fusion_right_panel {
|
||||
flex: 1;
|
||||
min-width: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// Critical: prevent overflow so chat input stays visible
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
// Override chat panel to fill the container
|
||||
.fusion_chat_panel {
|
||||
// Fill the right panel completely
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
// Must not exceed container
|
||||
min-height: 0;
|
||||
height: auto !important;
|
||||
|
||||
.card-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fusion_chat_messages {
|
||||
// Override base chat.scss values that break flex layout
|
||||
max-height: none !important;
|
||||
min-height: 0 !important;
|
||||
// Grow to fill, but scrollable
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fusion_chat_input {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also ensure the Odoo action container gives us full height
|
||||
.o_action_manager {
|
||||
.o_action.fusion_accounting_dashboard {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,22 @@
|
||||
sequence="40"
|
||||
groups="group_fusion_accounting_manager"/>
|
||||
|
||||
<!-- Vendor Tax Profiles -->
|
||||
<menuitem id="menu_fusion_vendor_profiles"
|
||||
name="Vendor Tax Profiles"
|
||||
parent="menu_fusion_accounting_root"
|
||||
action="action_vendor_tax_profiles"
|
||||
sequence="50"
|
||||
groups="group_fusion_accounting_manager"/>
|
||||
|
||||
<!-- Recurring Patterns -->
|
||||
<menuitem id="menu_fusion_recurring_patterns"
|
||||
name="Recurring Patterns"
|
||||
parent="menu_fusion_accounting_root"
|
||||
action="action_recurring_patterns"
|
||||
sequence="55"
|
||||
groups="group_fusion_accounting_manager"/>
|
||||
|
||||
<!-- Configuration (link to settings) -->
|
||||
<menuitem id="menu_fusion_config"
|
||||
name="Configuration"
|
||||
|
||||
86
fusion_accounting/views/recurring_pattern_views.xml
Normal file
86
fusion_accounting/views/recurring_pattern_views.xml
Normal file
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_recurring_pattern_list" model="ir.ui.view">
|
||||
<field name="name">fusion.recurring.pattern.list</field>
|
||||
<field name="model">fusion.recurring.pattern</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="ref_keyword"/>
|
||||
<field name="amount"/>
|
||||
<field name="expense_account_code" string="Account"/>
|
||||
<field name="has_hst"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="reconcile_model_id"/>
|
||||
<field name="occurrences"/>
|
||||
<field name="last_seen"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_recurring_pattern_form" model="ir.ui.view">
|
||||
<field name="name">fusion.recurring.pattern.form</field>
|
||||
<field name="model">fusion.recurring.pattern</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Pattern">
|
||||
<field name="name"/>
|
||||
<field name="ref_keyword"/>
|
||||
<field name="amount"/>
|
||||
<field name="amount_is_fixed"/>
|
||||
<field name="journal_id"/>
|
||||
</group>
|
||||
<group string="Coding">
|
||||
<field name="expense_account_id"/>
|
||||
<field name="has_hst"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="reconcile_model_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Statistics">
|
||||
<group>
|
||||
<field name="occurrences"/>
|
||||
<field name="first_seen"/>
|
||||
<field name="last_seen"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="last_computed"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="AI Instructions">
|
||||
<field name="action_note" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_recurring_pattern_search" model="ir.ui.view">
|
||||
<field name="name">fusion.recurring.pattern.search</field>
|
||||
<field name="model">fusion.recurring.pattern</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="ref_keyword"/>
|
||||
<separator/>
|
||||
<filter name="has_hst" string="Has HST" domain="[('has_hst', '=', True)]"/>
|
||||
<filter name="no_hst" string="No HST" domain="[('has_hst', '=', False)]"/>
|
||||
<filter name="has_reco_model" string="Has Reco Model" domain="[('reconcile_model_id', '!=', False)]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_account" string="Account" domain="[]" context="{'group_by': 'expense_account_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_recurring_patterns" model="ir.actions.act_window">
|
||||
<field name="name">Recurring Patterns</field>
|
||||
<field name="res_model">fusion.recurring.pattern</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_recurring_pattern_search"/>
|
||||
</record>
|
||||
</odoo>
|
||||
95
fusion_accounting/views/vendor_tax_profile_views.xml
Normal file
95
fusion_accounting/views/vendor_tax_profile_views.xml
Normal file
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="view_vendor_tax_profile_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.vendor.tax.profile.tree</field>
|
||||
<field name="model">fusion.vendor.tax.profile</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="partner_id"/>
|
||||
<field name="tax_classification" widget="badge"
|
||||
decoration-success="tax_classification == 'always_hst'"
|
||||
decoration-warning="tax_classification in ('shipping_only', 'mixed')"
|
||||
decoration-danger="tax_classification == 'never_hst'"
|
||||
decoration-info="tax_classification == 'mostly_hst'"/>
|
||||
<field name="total_bills"/>
|
||||
<field name="bills_with_hst"/>
|
||||
<field name="bills_zero_rated"/>
|
||||
<field name="avg_tax_pct" string="Avg Tax %"/>
|
||||
<field name="primary_account_code" string="Primary Account"/>
|
||||
<field name="is_po_vendor"/>
|
||||
<field name="po_count" optional="hide"/>
|
||||
<field name="is_foreign"/>
|
||||
<field name="last_computed"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_vendor_tax_profile_form" model="ir.ui.view">
|
||||
<field name="name">fusion.vendor.tax.profile.form</field>
|
||||
<field name="model">fusion.vendor.tax.profile</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Vendor">
|
||||
<field name="partner_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="is_foreign"/>
|
||||
<field name="vendor_country"/>
|
||||
</group>
|
||||
<group string="Tax Classification">
|
||||
<field name="tax_classification"/>
|
||||
<field name="avg_tax_pct"/>
|
||||
<field name="primary_account_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Bill Statistics">
|
||||
<group>
|
||||
<field name="total_bills"/>
|
||||
<field name="bills_with_hst"/>
|
||||
<field name="bills_zero_rated"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="last_computed"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="AI Tax Note">
|
||||
<field name="tax_note" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_vendor_tax_profile_search" model="ir.ui.view">
|
||||
<field name="name">fusion.vendor.tax.profile.search</field>
|
||||
<field name="model">fusion.vendor.tax.profile</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="partner_id"/>
|
||||
<field name="tax_classification"/>
|
||||
<separator/>
|
||||
<filter name="always_hst" string="Always HST" domain="[('tax_classification', '=', 'always_hst')]"/>
|
||||
<filter name="never_hst" string="Never HST" domain="[('tax_classification', '=', 'never_hst')]"/>
|
||||
<filter name="shipping_only" string="Shipping Only" domain="[('tax_classification', '=', 'shipping_only')]"/>
|
||||
<filter name="mixed" string="Mixed" domain="[('tax_classification', 'in', ('mixed', 'mostly_hst'))]"/>
|
||||
<filter name="foreign" string="Foreign Vendors" domain="[('is_foreign', '=', True)]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_classification" string="Classification" domain="[]" context="{'group_by': 'tax_classification'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_vendor_tax_profiles" model="ir.actions.act_window">
|
||||
<field name="name">Vendor Tax Profiles</field>
|
||||
<field name="res_model">fusion.vendor.tax.profile</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="view_vendor_tax_profile_search"/>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user