2 Commits

Author SHA1 Message Date
gsinghpal
3e48bab087 feat(fusion_accounting_bank_rec): kanban controller + renderer for OWL widget
Top-level OWL component (BankRecKanbanController) hosts the bank
reconciliation widget. Reads journal_id + company_id from action context,
initializes the fusion_bank_reconciliation service, and renders the
layout: header (stats), left column (line cards via BankRecLineCard
renderer), right column (detail panel with AI suggestions).

Custom view type 'fusion_bank_rec_kanban' registered so window actions
can use <field name="view_mode">fusion_bank_rec_kanban</field>.

Made-with: Cursor
2026-04-19 12:33:57 -04:00
gsinghpal
a4a9692888 fix(fusion_accounting_bank_rec): acceptSuggestion double-decrement count
Optimistic remove was decrementing unreconciledCount before assigning
the authoritative server count, leading to off-by-one. Order swapped:
remove first, then overwrite with server count.

Caught by Task 28 subagent self-review.

Made-with: Cursor
2026-04-19 12:28:34 -04:00
6 changed files with 269 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting — Bank Reconciliation',
'version': '19.0.1.0.10',
'version': '19.0.1.0.11',
'category': 'Accounting/Accounting',
'sequence': 28,
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
@@ -39,6 +39,10 @@ Built by Nexa Systems Inc.
'fusion_accounting_bank_rec/static/src/scss/ai_suggestion.scss',
'fusion_accounting_bank_rec/static/src/scss/dark_mode.scss',
'fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js',
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_controller.js',
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_renderer.js',
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_view.js',
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban.xml',
],
},
'installable': True,

View File

@@ -149,9 +149,8 @@ export class BankReconciliationService {
const result = await this.rpc(`${ENDPOINT_BASE}/accept_suggestion`, {
suggestion_id: suggestionId,
});
this.state.unreconciledCount = result.unreconciled_count_after;
// Optimistic remove from list
this._removeReconciledLineFromState(this.state.selectedLineId);
this.state.unreconciledCount = result.unreconciled_count_after;
this.notification.add("Reconciliation accepted", { type: "success" });
return result;
} catch (err) {

View File

@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BankRecKanbanController">
<div class="o_fusion_bank_rec">
<div class="o_fusion_bank_rec_header">
<div>
<h1>Bank Reconciliation</h1>
<div t-if="state.journalId" class="text-muted">
Journal #<t t-esc="state.journalId"/>
</div>
</div>
<div class="o_fusion_stats">
<div>
Unreconciled:
<span class="stat-value"><t t-esc="state.unreconciledCount"/></span>
</div>
<div>
Total pending:
<span class="stat-value">
$<t t-esc="formatCurrency(state.totalPendingAmount)"/>
</span>
</div>
</div>
</div>
<div class="d-flex" style="gap: 1rem; padding: 1rem;">
<div style="flex: 1 1 60%; max-width: 60%;">
<div t-if="state.isLoading" class="text-center p-4 text-muted">
Loading…
</div>
<div t-elif="state.lines.length === 0" class="text-center p-4 text-muted">
Nothing to reconcile.
</div>
<div t-else="">
<BankRecLineCard
t-foreach="state.lines"
t-as="line"
t-key="line.id"
line="line"
selected="state.selectedLineId === line.id"
onSelect="() => onSelectLine(line.id)"
formatCurrency="formatCurrency.bind(this)"
/>
<div t-if="state.lines.length lt state.unreconciledCount"
class="text-center mt-3">
<button class="btn_fusion" t-on-click="onLoadMore">
Load more
</button>
</div>
</div>
</div>
<div style="flex: 1 1 40%; max-width: 40%;" class="o_fusion_bank_rec_detail">
<t t-if="state.selectedLineId">
<t t-set="detail" t-value="state.lineCache[state.selectedLineId]"/>
<div t-if="!detail" class="text-muted">Loading detail…</div>
<div t-else="">
<h2>
<t t-esc="detail.line.payment_ref || 'No reference'"/>
</h2>
<div class="text-muted mb-3">
<span><t t-esc="detail.line.date"/></span>
<span class="ms-2">
$<t t-esc="formatCurrency(detail.line.amount)"/>
</span>
<span t-if="detail.line.partner_name" class="ms-2">
· <t t-esc="detail.line.partner_name"/>
</span>
</div>
<div t-if="detail.suggestions.length === 0">
<button class="btn_fusion btn_fusion_primary"
t-on-click="() => onSuggestForLine(detail.line.id)">
Get AI suggestions
</button>
</div>
<div t-else="">
<h5>AI Suggestions</h5>
<div t-foreach="detail.suggestions" t-as="sug" t-key="sug.id"
class="o_fusion_ai_suggestion"
t-att-data-band="confidenceBandLabel(sug.confidence >= 0.85 ? 'high' : sug.confidence >= 0.6 ? 'medium' : sug.confidence > 0 ? 'low' : 'none').toLowerCase()">
<div class="o_fusion_confidence_badge">
<t t-esc="(sug.confidence * 100).toFixed(0)"/>%
</div>
<div class="o_fusion_suggestion_text">
<div><t t-esc="sug.reasoning"/></div>
</div>
<div class="o_fusion_suggestion_actions">
<button class="btn_fusion btn_fusion_primary"
t-on-click="() => onAcceptSuggestion(sug.id)">
Accept
</button>
</div>
</div>
</div>
</div>
</t>
<t t-else="">
<div class="text-muted">
Select a bank line on the left to see details.
</div>
</t>
</div>
</div>
</div>
</t>
<t t-name="fusion_accounting_bank_rec.BankRecLineCard">
<div class="o_fusion_bank_rec_line"
t-att-class="props.selected ? 'o_fusion_selected' : ''"
t-on-click="props.onSelect">
<div class="o_fusion_bank_rec_line_header">
<div class="o_fusion_amount" t-att-class="props.line.amount lt 0 ? 'negative' : ''">
$<t t-esc="props.formatCurrency(props.line.amount)"/>
</div>
<div class="o_fusion_date">
<t t-esc="props.line.date"/>
</div>
</div>
<div class="o_fusion_bank_rec_line_body">
<span t-if="props.line.partner_name" class="o_fusion_partner">
<t t-esc="props.line.partner_name"/>
</span>
<span class="o_fusion_memo">
<t t-esc="props.line.payment_ref || 'No memo'"/>
</span>
</div>
<div t-if="props.line.attachment_count" class="o_fusion_attachments_badge">
📎 <t t-esc="props.line.attachment_count"/>
</div>
<div t-if="props.line.fusion_confidence_band and props.line.fusion_confidence_band !== 'none'"
t-att-class="'o_fusion_ai_suggestion ' + 'band-' + props.line.fusion_confidence_band"
t-att-data-band="props.line.fusion_confidence_band">
<div class="o_fusion_confidence_badge">
AI Suggestion Available
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,81 @@
/** @odoo-module **/
/**
* Bank reconciliation kanban controller.
*
* Top-level OWL component for the fusion bank-rec widget. Hosts:
* - Header bar (journal name, unreconciled count, total pending amount)
* - Left column: list of unreconciled bank line cards
* - Right column: detail panel for the selected line
*
* Reads journal_id + company_id from action context. Wires up the
* fusion_bank_reconciliation service for all data + reactivity.
*/
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { BankRecLineCard } from "./bank_rec_kanban_renderer";
export class BankRecKanbanController extends Component {
static template = "fusion_accounting_bank_rec.BankRecKanbanController";
static components = { BankRecLineCard };
static props = {
action: { type: Object, optional: true },
actionId: { type: [Number, String], optional: true },
className: { type: String, optional: true },
"*": true,
};
setup() {
this.bankRec = useService("fusion_bank_reconciliation");
this.notification = useService("notification");
this.state = useState(this.bankRec.state);
const ctx = this.props.action?.context || {};
const journalId = ctx.default_journal_id || ctx.active_id;
const companyId = ctx.allowed_company_ids?.[0]
|| this.env.services.user?.context?.allowed_company_ids?.[0];
onWillStart(async () => {
if (journalId && companyId) {
await this.bankRec.initForJournal(journalId, companyId);
}
});
}
onSelectLine(lineId) {
this.bankRec.selectLine(lineId);
}
async onLoadMore() {
await this.bankRec.loadMore();
}
async onSuggestForLine(lineId) {
await this.bankRec.suggestMatches([lineId]);
}
async onAcceptSuggestion(suggestionId) {
await this.bankRec.acceptSuggestion(suggestionId);
}
async onUnreconcile(partialIds) {
await this.bankRec.unreconcile(partialIds);
}
formatCurrency(amount) {
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
confidenceBandLabel(band) {
return {
high: "High",
medium: "Medium",
low: "Low",
none: "None",
}[band] || "—";
}
}

View File

@@ -0,0 +1,20 @@
/** @odoo-module **/
/**
* Bank reconciliation line-card renderer.
*
* Renders one unreconciled bank line as a card in the kanban list.
* Owned by BankRecKanbanController; receives line + selected flag as props.
*/
import { Component } from "@odoo/owl";
export class BankRecLineCard extends Component {
static template = "fusion_accounting_bank_rec.BankRecLineCard";
static props = {
line: { type: Object },
selected: { type: Boolean, optional: true },
onSelect: { type: Function },
formatCurrency: { type: Function },
};
}

View File

@@ -0,0 +1,20 @@
/** @odoo-module **/
/**
* Custom view type "fusion_bank_rec_kanban" — registers the controller
* with the views registry so window actions can specify
* <field name="view_mode">fusion_bank_rec_kanban</field>.
*/
import { registry } from "@web/core/registry";
import { BankRecKanbanController } from "./bank_rec_kanban_controller";
export const fusionBankRecKanbanView = {
type: "fusion_bank_rec_kanban",
Controller: BankRecKanbanController,
display_name: "Bank Reconciliation",
icon: "fa-exchange",
multiRecord: true,
};
registry.category("views").add("fusion_bank_rec_kanban", fusionBankRecKanbanView);