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',
|
||||
'version': '19.0.1.0.9',
|
||||
'version': '19.0.1.0.10',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 28,
|
||||
'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/ai_suggestion.scss',
|
||||
'fusion_accounting_bank_rec/static/src/scss/dark_mode.scss',
|
||||
'fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js',
|
||||
],
|
||||
},
|
||||
'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