changes
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
/** @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, useState, EventBus } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
const ENDPOINT_BASE = "/fusion/bank_rec";
|
||||
|
||||
export class BankReconciliationService {
|
||||
constructor(env, services) {
|
||||
this.env = env;
|
||||
// V19: rpc is no longer a service — imported as a standalone function above.
|
||||
this.rpc = rpc;
|
||||
this.notification = services.notification;
|
||||
this.orm = services.orm;
|
||||
|
||||
// ============================================================
|
||||
// Enterprise-compat surface (mirrored OWL components rely on this)
|
||||
// ============================================================
|
||||
// Mirrored components from account_accountant expect these
|
||||
// attributes/methods on the service. Most are implemented as
|
||||
// stubs that no-op or return sensible defaults; structural
|
||||
// parity now, behaviour wired up in fusion-only Tasks 34-36.
|
||||
this.bus = new EventBus();
|
||||
this.chatterState = reactive({
|
||||
visible: this._readChatterPref(),
|
||||
statementLine: null,
|
||||
});
|
||||
this.reconcileCountPerPartnerId = reactive({});
|
||||
this.reconcileModelPerStatementLineId = reactive({});
|
||||
|
||||
// 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._removeReconciledLineFromState(this.state.selectedLineId);
|
||||
this.state.unreconciledCount = result.unreconciled_count_after;
|
||||
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"}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Enterprise-compat methods (stubs — wired up later)
|
||||
// ============================================================
|
||||
// The following surface is required by mirrored components from
|
||||
// account_accountant. They are primarily no-ops or thin wrappers
|
||||
// around the legacy/V19 ORM. Phase 1 prioritizes structural parity;
|
||||
// fusion-only Tasks 34-36 will replace these with native
|
||||
// implementations driven by our JSON-RPC endpoints.
|
||||
|
||||
_readChatterPref() {
|
||||
try {
|
||||
return (
|
||||
JSON.parse(
|
||||
browser.sessionStorage.getItem("isFusionBankRecChatterOpened")
|
||||
) ?? false
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
toggleChatter() {
|
||||
this.chatterState.visible = !this.chatterState.visible;
|
||||
try {
|
||||
browser.sessionStorage.setItem(
|
||||
"isFusionBankRecChatterOpened",
|
||||
this.chatterState.visible
|
||||
);
|
||||
} catch {
|
||||
// Session storage unavailable — non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
openChatter() {
|
||||
this.chatterState.visible = true;
|
||||
}
|
||||
|
||||
selectStatementLine(statementLine) {
|
||||
this.chatterState.statementLine = statementLine;
|
||||
}
|
||||
|
||||
reloadChatter() {
|
||||
this.bus.trigger("MAIL:RELOAD-THREAD", {
|
||||
model: "account.move",
|
||||
id: this.statementLineMoveId,
|
||||
});
|
||||
}
|
||||
|
||||
async computeReconcileLineCountPerPartnerId(records) {
|
||||
// Stub: real impl to be added in fusion-only task.
|
||||
// Components call this after partner edits to refresh the per-partner
|
||||
// count badge. Returning empty here keeps the badge silent.
|
||||
if (!this.orm) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const partnerIds = (records || [])
|
||||
.map((r) => r?.data?.partner_id?.id)
|
||||
.filter(Boolean);
|
||||
if (!partnerIds.length) {
|
||||
this.reconcileCountPerPartnerId = {};
|
||||
return;
|
||||
}
|
||||
// Best-effort: keep a zero map so templates don't blow up.
|
||||
const out = {};
|
||||
for (const pid of partnerIds) {
|
||||
out[pid] = this.reconcileCountPerPartnerId[pid] ?? 0;
|
||||
}
|
||||
this.reconcileCountPerPartnerId = out;
|
||||
} catch {
|
||||
// Non-fatal; templates fall back to defaults.
|
||||
}
|
||||
}
|
||||
|
||||
async computeAvailableReconcileModels(records) {
|
||||
// Stub: components show these as quick-action buttons. Empty for now.
|
||||
const out = {};
|
||||
for (const r of records || []) {
|
||||
const id = r?.data?.id;
|
||||
if (id) {
|
||||
out[id] = [];
|
||||
}
|
||||
}
|
||||
this.reconcileModelPerStatementLineId = out;
|
||||
}
|
||||
|
||||
async updateAvailableReconcileModels(recordId) {
|
||||
if (recordId) {
|
||||
this.reconcileModelPerStatementLineId[recordId] = [];
|
||||
}
|
||||
}
|
||||
|
||||
async reloadRecords(records) {
|
||||
await Promise.all(
|
||||
(records || []).map((record) => record?.load ? record.load() : null)
|
||||
);
|
||||
}
|
||||
|
||||
get statementLineMove() {
|
||||
return this.chatterState.statementLine?.data?.move_id;
|
||||
}
|
||||
|
||||
get statementLineMoveId() {
|
||||
return this.statementLineMove?.id;
|
||||
}
|
||||
|
||||
get statementLine() {
|
||||
return this.chatterState.statementLine;
|
||||
}
|
||||
|
||||
get statementLineId() {
|
||||
return this.statementLine?.data?.id;
|
||||
}
|
||||
}
|
||||
|
||||
export const bankReconciliationService = {
|
||||
dependencies: ["notification", "orm"],
|
||||
start(env, services) {
|
||||
return new BankReconciliationService(env, services);
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("fusion_bank_reconciliation", bankReconciliationService);
|
||||
|
||||
/**
|
||||
* Hook for OWL components mirrored from Enterprise.
|
||||
*
|
||||
* Enterprise's components import `useBankReconciliation` from
|
||||
* `../bank_reconciliation_service`; we expose the same hook here so
|
||||
* mirrored code works unmodified after the relative-import rewrite.
|
||||
*/
|
||||
export function useBankReconciliation() {
|
||||
return useState(useService("fusion_bank_reconciliation"));
|
||||
}
|
||||
Reference in New Issue
Block a user