feat(fusion_accounting_bank_rec): OWL bank reconciliation service
Central data layer + reactive state for the OWL widget. Wraps the 10
JSON-RPC endpoints from the bank_rec_controller (get_state,
list_unreconciled, get_line_detail, suggest_matches, accept_suggestion,
reconcile_manual, unreconcile, write_off, bulk_reconcile,
get_partner_history). Components inject via useService("fusion_bank_reconciliation").
State held in OWL's reactive() so components auto-rerender on
selection / pagination / reconcile-success changes.
Verified: web.assets_backend bundle includes
/fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js;
134/134 module tests pass.
Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting — Bank Reconciliation',
|
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||||
'version': '19.0.1.0.9',
|
'version': '19.0.1.0.10',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 28,
|
'sequence': 28,
|
||||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||||
@@ -38,6 +38,7 @@ Built by Nexa Systems Inc.
|
|||||||
'fusion_accounting_bank_rec/static/src/scss/bank_reconciliation.scss',
|
'fusion_accounting_bank_rec/static/src/scss/bank_reconciliation.scss',
|
||||||
'fusion_accounting_bank_rec/static/src/scss/ai_suggestion.scss',
|
'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/scss/dark_mode.scss',
|
||||||
|
'fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank reconciliation service.
|
||||||
|
*
|
||||||
|
* Central data layer + reactive state for the OWL bank-rec widget.
|
||||||
|
* Components inject this service via useService("fusion_bank_reconciliation")
|
||||||
|
* and read/write state through its methods.
|
||||||
|
*
|
||||||
|
* Wraps the 10 JSON-RPC endpoints from controllers/bank_rec_controller.py.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { reactive } from "@odoo/owl";
|
||||||
|
|
||||||
|
const ENDPOINT_BASE = "/fusion/bank_rec";
|
||||||
|
|
||||||
|
export class BankReconciliationService {
|
||||||
|
constructor(env, services) {
|
||||||
|
this.env = env;
|
||||||
|
this.rpc = services.rpc;
|
||||||
|
this.notification = services.notification;
|
||||||
|
|
||||||
|
// Reactive state — components depend on it via useState/reactive
|
||||||
|
this.state = reactive({
|
||||||
|
journalId: null,
|
||||||
|
companyId: null,
|
||||||
|
unreconciledCount: 0,
|
||||||
|
totalPendingAmount: 0,
|
||||||
|
lines: [],
|
||||||
|
lineCache: {}, // {lineId: {detail, suggestions, attachments}}
|
||||||
|
selectedLineId: null,
|
||||||
|
isLoading: false,
|
||||||
|
isReconciling: false,
|
||||||
|
offset: 0,
|
||||||
|
limit: 50,
|
||||||
|
filters: {},
|
||||||
|
// Cache of recently-applied actions for optimistic UI
|
||||||
|
recentActions: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Initialization
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async initForJournal(journalId, companyId) {
|
||||||
|
this.state.journalId = journalId;
|
||||||
|
this.state.companyId = companyId;
|
||||||
|
this.state.isLoading = true;
|
||||||
|
try {
|
||||||
|
const stateInfo = await this.rpc(`${ENDPOINT_BASE}/get_state`, {
|
||||||
|
journal_id: journalId, company_id: companyId,
|
||||||
|
});
|
||||||
|
this.state.unreconciledCount = stateInfo.unreconciled_count;
|
||||||
|
this.state.totalPendingAmount = stateInfo.total_pending_amount;
|
||||||
|
await this.loadLines({ reset: true });
|
||||||
|
} finally {
|
||||||
|
this.state.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// List + pagination
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async loadLines({ reset = false } = {}) {
|
||||||
|
if (reset) {
|
||||||
|
this.state.offset = 0;
|
||||||
|
this.state.lines = [];
|
||||||
|
}
|
||||||
|
this.state.isLoading = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/list_unreconciled`, {
|
||||||
|
journal_id: this.state.journalId,
|
||||||
|
company_id: this.state.companyId,
|
||||||
|
limit: this.state.limit,
|
||||||
|
offset: this.state.offset,
|
||||||
|
...this.state.filters,
|
||||||
|
});
|
||||||
|
if (reset) {
|
||||||
|
this.state.lines = result.lines;
|
||||||
|
} else {
|
||||||
|
this.state.lines = [...this.state.lines, ...result.lines];
|
||||||
|
}
|
||||||
|
this.state.unreconciledCount = result.total;
|
||||||
|
} finally {
|
||||||
|
this.state.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMore() {
|
||||||
|
this.state.offset += this.state.limit;
|
||||||
|
await this.loadLines({ reset: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(key, value) {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
delete this.state.filters[key];
|
||||||
|
} else {
|
||||||
|
this.state.filters[key] = value;
|
||||||
|
}
|
||||||
|
this.loadLines({ reset: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Line detail + suggestions
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async selectLine(lineId) {
|
||||||
|
this.state.selectedLineId = lineId;
|
||||||
|
if (!this.state.lineCache[lineId]) {
|
||||||
|
await this.loadLineDetail(lineId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadLineDetail(lineId) {
|
||||||
|
const detail = await this.rpc(`${ENDPOINT_BASE}/get_line_detail`, {
|
||||||
|
statement_line_id: lineId,
|
||||||
|
});
|
||||||
|
this.state.lineCache[lineId] = detail;
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshLineDetail(lineId) {
|
||||||
|
delete this.state.lineCache[lineId];
|
||||||
|
return await this.loadLineDetail(lineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async suggestMatches(lineIds, limitPerLine = 3) {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/suggest_matches`, {
|
||||||
|
statement_line_ids: lineIds,
|
||||||
|
limit_per_line: limitPerLine,
|
||||||
|
});
|
||||||
|
// Refresh cache for each line
|
||||||
|
for (const lineId of lineIds) {
|
||||||
|
await this.refreshLineDetail(lineId);
|
||||||
|
}
|
||||||
|
return result.suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Reconciliation actions
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async acceptSuggestion(suggestionId) {
|
||||||
|
this.state.isReconciling = true;
|
||||||
|
try {
|
||||||
|
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.notification.add("Reconciliation accepted", { type: "success" });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Accept failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.state.isReconciling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcileManual(statementLineId, againstMoveLineIds) {
|
||||||
|
this.state.isReconciling = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/reconcile_manual`, {
|
||||||
|
statement_line_id: statementLineId,
|
||||||
|
against_move_line_ids: againstMoveLineIds,
|
||||||
|
});
|
||||||
|
this._removeReconciledLineFromState(statementLineId);
|
||||||
|
this.notification.add("Reconciled", { type: "success" });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Reconcile failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.state.isReconciling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async unreconcile(partialReconcileIds) {
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/unreconcile`, {
|
||||||
|
partial_reconcile_ids: partialReconcileIds,
|
||||||
|
});
|
||||||
|
// Reload list since unreconciled lines come back
|
||||||
|
await this.loadLines({ reset: true });
|
||||||
|
this.notification.add("Unreconciled", { type: "info" });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Unreconcile failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeOff({ statementLineId, accountId, amount, label, taxId = null }) {
|
||||||
|
this.state.isReconciling = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/write_off`, {
|
||||||
|
statement_line_id: statementLineId,
|
||||||
|
account_id: accountId,
|
||||||
|
amount: amount,
|
||||||
|
label: label,
|
||||||
|
tax_id: taxId,
|
||||||
|
});
|
||||||
|
this._removeReconciledLineFromState(statementLineId);
|
||||||
|
this.notification.add("Write-off applied", { type: "success" });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Write-off failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.state.isReconciling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkReconcile(statementLineIds, strategy = "auto") {
|
||||||
|
this.state.isReconciling = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/bulk_reconcile`, {
|
||||||
|
statement_line_ids: statementLineIds,
|
||||||
|
strategy: strategy,
|
||||||
|
});
|
||||||
|
await this.loadLines({ reset: true });
|
||||||
|
const msg = `${result.reconciled_count} reconciled, ${result.skipped} skipped`;
|
||||||
|
this.notification.add(msg, { type: "success" });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Bulk failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.state.isReconciling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Partner history (right-side panel)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async getPartnerHistory(partnerId, limit = 20) {
|
||||||
|
return await this.rpc(`${ENDPOINT_BASE}/get_partner_history`, {
|
||||||
|
partner_id: partnerId,
|
||||||
|
limit: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
_removeReconciledLineFromState(lineId) {
|
||||||
|
if (!lineId) return;
|
||||||
|
this.state.lines = this.state.lines.filter((l) => l.id !== lineId);
|
||||||
|
if (this.state.selectedLineId === lineId) {
|
||||||
|
this.state.selectedLineId = null;
|
||||||
|
}
|
||||||
|
delete this.state.lineCache[lineId];
|
||||||
|
if (this.state.unreconciledCount > 0) {
|
||||||
|
this.state.unreconciledCount -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confidence band helper for templates
|
||||||
|
getBandClass(line) {
|
||||||
|
return `band-${line.fusion_confidence_band || "none"}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bankReconciliationService = {
|
||||||
|
dependencies: ["rpc", "notification"],
|
||||||
|
start(env, services) {
|
||||||
|
return new BankReconciliationService(env, services);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("services").add("fusion_bank_reconciliation", bankReconciliationService);
|
||||||
Reference in New Issue
Block a user