feat(fusion_accounting_bank_rec): mirror Enterprise OWL batch 1 (display components)
Mirrors 4 OWL components from account_accountant for Phase 1
structural parity:
- statement_line/ (display + interactivity for one bank line)
- statement_summary/ (header summary card per statement)
- line_info_pop_over/ (popover with extra info on hover)
- reconciled_line_name/ (label for already-reconciled lines)
Plus the Enterprise-compat surface added to
fusion_bank_reconciliation service:
- useBankReconciliation() hook export
- chatterState reactive (visible, statementLine)
- reconcileCountPerPartnerId / reconcileModelPerStatementLineId
- selectStatementLine, openChatter, toggleChatter, reloadChatter
- computeReconcileLineCountPerPartnerId (no-op stub)
- computeAvailableReconcileModels (no-op stub)
- updateAvailableReconcileModels (no-op stub)
- reloadRecords helper
- statementLine{,MoveId,Move,Id} getters
Service now also depends on `orm`. A
components/bank_reconciliation/bank_reconciliation_service.js
re-export shim lets mirrored components keep their relative
`../bank_reconciliation_service` imports verbatim.
Renames applied per spec:
- account_accountant.* -> fusion_accounting_bank_rec.* (template names)
- @account_accountant/... -> @fusion_accounting_bank_rec/... (module IDs)
- useService("bank_reconciliation_service")
-> useService("fusion_bank_reconciliation")
Forward imports to batch 2 components (button_list,
line_to_reconcile) resolve lazily — files are on disk and bundled
in subsequent batches. Phase 1 prioritizes structural parity;
behaviour wired up in fusion-only Tasks 34-36.
Manifest version bumped to 19.0.1.0.12.
Module upgrade succeeds, 134 logical tests still pass.
Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||
'version': '19.0.1.0.11',
|
||||
'version': '19.0.1.0.12',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 28,
|
||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||
@@ -43,6 +43,19 @@ Built by Nexa Systems Inc.
|
||||
'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',
|
||||
# OWL component mirror — Enterprise account_accountant bank-rec.
|
||||
# Re-export shim so mirrored components can use the relative
|
||||
# `../bank_reconciliation_service` import unchanged.
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_reconciliation_service.js',
|
||||
# Batch 1 (Task 30) — display components
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_line/statement_line.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_line/statement_line.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_summary/statement_summary.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_summary/statement_summary.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_info_pop_over/line_info_pop_over.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_info_pop_over/line_info_pop_over.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconciled_line_name/reconciled_line_name.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconciled_line_name/reconciled_line_name.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* Re-export shim so mirrored Enterprise components can use the relative
|
||||
* import `../bank_reconciliation_service` unchanged. The real
|
||||
* implementation lives in
|
||||
* `@fusion_accounting_bank_rec/services/bank_reconciliation_service`.
|
||||
*/
|
||||
|
||||
export {
|
||||
BankReconciliationService,
|
||||
bankReconciliationService,
|
||||
useBankReconciliation,
|
||||
} from "@fusion_accounting_bank_rec/services/bank_reconciliation_service";
|
||||
@@ -0,0 +1,80 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* Mirrored from
|
||||
* `account_accountant/.../line_info_pop_over/line_info_pop_over.js`.
|
||||
* Phase 1 structural parity.
|
||||
*/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { formatMonetary } from "@web/views/fields/formatters";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class BankRecLineInfoPopOver extends Component {
|
||||
static template = "fusion_accounting_bank_rec.BankRecLineInfoPopOver";
|
||||
static props = {
|
||||
lineData: { type: Object, optional: true },
|
||||
statementLineData: { type: Object, optional: true },
|
||||
exchangeMove: { type: Object, optional: true },
|
||||
isPartiallyReconciled: { type: Boolean, optional: true },
|
||||
close: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
}
|
||||
|
||||
openExchangeMove() {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "account.move",
|
||||
res_id: this.props.exchangeMove.id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
openReconciledMove() {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "account.move",
|
||||
res_id: this.reconciledLineData.move_id.id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
get reconciledMoveName() {
|
||||
return this.reconciledLineData.move_name;
|
||||
}
|
||||
|
||||
get formattedReconciledMoveAmountCurrency() {
|
||||
return formatMonetary(this.reconciledLineData.amount_currency, {
|
||||
currencyId: this.reconciledLineData.currency_id.id,
|
||||
});
|
||||
}
|
||||
|
||||
get reconciledLineData() {
|
||||
return this.props.lineData.reconciled_lines_ids.records[0].data;
|
||||
}
|
||||
|
||||
get formattedLineDataAmountCurrency() {
|
||||
return formatMonetary(this.props.lineData.amount_currency, {
|
||||
currencyId: this.props.lineData.currency_id.id,
|
||||
});
|
||||
}
|
||||
|
||||
get exchangeDiffMoveName() {
|
||||
return this.props.exchangeMove.display_name;
|
||||
}
|
||||
|
||||
get exchangeMoveBalance() {
|
||||
return this.props.exchangeMove.line_ids[0].balance;
|
||||
}
|
||||
|
||||
get formattedExchangeMoveBalance() {
|
||||
return formatMonetary(this.exchangeMoveBalance, {
|
||||
currencyId: this.props.statementLineData.company_id.currency_id?.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting_bank_rec.BankRecLineInfoPopOver">
|
||||
<table class="table table-hover m-0">
|
||||
<tbody>
|
||||
<tr t-if="props.exchangeMove">
|
||||
<td t-on-click="openExchangeMove" class="cursor-pointer">
|
||||
<span class="btn btn-link p-0" t-esc="exchangeDiffMoveName"/>
|
||||
</td>
|
||||
<td class="align-middle text-end" t-esc="formattedExchangeMoveBalance"/>
|
||||
</tr>
|
||||
<tr t-if="props.isPartiallyReconciled">
|
||||
<td t-on-click="openReconciledMove" class="cursor-pointer">
|
||||
<span class="btn btn-link p-0" t-esc="reconciledMoveName"/>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<span class="text-decoration-line-through me-2" t-esc="formattedReconciledMoveAmountCurrency"/>
|
||||
<span t-esc="formattedLineDataAmountCurrency"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,40 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* Mirrored from
|
||||
* `account_accountant/.../reconciled_line_name/reconciled_line_name.js`.
|
||||
* Phase 1 structural parity.
|
||||
*/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { x2ManyCommands } from "@web/core/orm_service";
|
||||
|
||||
export class BankRecReconciledLineName extends Component {
|
||||
static template = "fusion_accounting_bank_rec.BankRecReconciledLineName";
|
||||
static props = {
|
||||
statementLine: { type: Object },
|
||||
linesToReconcile: { type: Object },
|
||||
moveLineId: { type: String },
|
||||
valueToDisplay: { type: Object },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.bankReconciliation = useBankReconciliation();
|
||||
}
|
||||
|
||||
async deleteTax(lineId, taxChanged) {
|
||||
const lineData = this.props.linesToReconcile.filter((line) => {
|
||||
return line.id === parseInt(lineId);
|
||||
})[0];
|
||||
await this.orm.call("account.bank.statement.line", "edit_reconcile_line", [
|
||||
this.props.statementLine.data.id,
|
||||
lineData.id,
|
||||
{ tax_ids: [[x2ManyCommands.UNLINK, taxChanged.data.id]] },
|
||||
]);
|
||||
this.props.statementLine.load();
|
||||
this.bankReconciliation.reloadChatter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting_bank_rec.BankRecReconciledLineName">
|
||||
<div name="reconciled_line_name" class="text-start text-truncate text-muted">
|
||||
<t t-if="props.valueToDisplay?.tax">
|
||||
<t t-foreach="props.valueToDisplay.tax" t-as="tax_id" t-key="tax_id_index">
|
||||
<div class="o_tag d-inline-flex align-items-center badge rounded-pill o_tag_color_0 flex-shrink-0" t-att-class="!tax_id_last ? 'me-1': ''">
|
||||
<span class="o_tag_badge_text text-truncate" t-esc="tax_id.data.display_name"/>
|
||||
<i t-on-click.stop="() => this.deleteTax(props.moveLineId, tax_id)" class="ps-1 opacity-100-hover opacity-75 oi oi-close"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="" t-out="props.valueToDisplay.move or props.valueToDisplay.account"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,305 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* Mirrored from
|
||||
* `account_accountant/static/src/components/bank_reconciliation/statement_line/statement_line.js`
|
||||
*
|
||||
* Phase 1 structural parity. Module IDs / template names / CSS classes
|
||||
* rebranded to `fusion_accounting_bank_rec`. Behaviour delegates to the
|
||||
* Enterprise-compat surface in our `fusion_bank_reconciliation` service.
|
||||
*/
|
||||
|
||||
import { BankRecButtonList } from "@fusion_accounting_bank_rec/components/bank_reconciliation/button_list/button_list";
|
||||
import { BankRecLineToReconcile } from "@fusion_accounting_bank_rec/components/bank_reconciliation/line_to_reconcile/line_to_reconcile";
|
||||
import { BankRecReconciledLineName } from "@fusion_accounting_bank_rec/components/bank_reconciliation/reconciled_line_name/reconciled_line_name";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { formatMonetary } from "@web/views/fields/formatters";
|
||||
import { KanbanRecord } from "@web/views/kanban/kanban_record";
|
||||
import { user } from "@web/core/user";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { onWillStart, useState, useRef } from "@odoo/owl";
|
||||
import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service";
|
||||
|
||||
export class BankRecStatementLine extends KanbanRecord {
|
||||
static template = "fusion_accounting_bank_rec.BankRecStatementLine";
|
||||
static components = {
|
||||
BankRecLineToReconcile,
|
||||
BankRecButtonList,
|
||||
DropdownItem,
|
||||
BankRecReconciledLineName,
|
||||
};
|
||||
static props = [...KanbanRecord.props];
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.ui = useService("ui");
|
||||
this.bankReconciliation = useBankReconciliation();
|
||||
this.state = useState({
|
||||
isUnfolded: false,
|
||||
});
|
||||
this.statementLineRootRef = useRef("root");
|
||||
if (this.env.model.config.context?.default_st_line_id === this.props.record.resId) {
|
||||
this.state.isUnfolded = true;
|
||||
this.bankReconciliation.selectStatementLine(this.props.record);
|
||||
}
|
||||
onWillStart(async () => {
|
||||
this.userCanReview = await user.hasGroup("account.group_account_user");
|
||||
});
|
||||
}
|
||||
|
||||
getRecordClasses() {
|
||||
let classes = super.getRecordClasses();
|
||||
if (this.hasStatementLine === 1) {
|
||||
classes += " mt-3";
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ACTION
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
openStatementCreate() {
|
||||
this.action.doAction("account_accountant.action_bank_statement_form_bank_rec_widget", {
|
||||
additionalContext: {
|
||||
split_line_id: this.recordData.id,
|
||||
default_journal_id: this.recordData.journal_id.id,
|
||||
},
|
||||
onClose: async () => {
|
||||
this.env.model.load();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openPartner() {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "res.partner",
|
||||
res_id: this.partner.id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
async removePartner() {
|
||||
await this.orm.write("account.bank.statement.line", [this.recordData.id], {
|
||||
partner_id: false,
|
||||
});
|
||||
this.record.load();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// HELPER
|
||||
// -----------------------------------------------------------------------------
|
||||
get reconciledLineName() {
|
||||
const reconciledLine = {};
|
||||
for (const line of this.linesToReconcile) {
|
||||
if (
|
||||
line.reconciled_lines_excluding_exchange_diff_ids.records.length === 1 &&
|
||||
line.reconciled_lines_excluding_exchange_diff_ids.records[0].data.move_name
|
||||
) {
|
||||
reconciledLine[line.id] = {
|
||||
move: line.reconciled_lines_excluding_exchange_diff_ids.records[0].data
|
||||
.move_name,
|
||||
};
|
||||
} else if (line.tax_ids.count) {
|
||||
reconciledLine[line.id] = { tax: line.tax_ids.records };
|
||||
} else {
|
||||
reconciledLine[line.id] = { account: line.account_id.display_name };
|
||||
}
|
||||
}
|
||||
return reconciledLine;
|
||||
}
|
||||
|
||||
get record() {
|
||||
return this.props.record;
|
||||
}
|
||||
|
||||
get recordData() {
|
||||
return this.props.record.data;
|
||||
}
|
||||
|
||||
fold() {
|
||||
if (this.state.isUnfolded) {
|
||||
this.toggleUnfold();
|
||||
}
|
||||
this.selectStatementLine();
|
||||
}
|
||||
|
||||
unfold() {
|
||||
if (!this.state.isUnfolded) {
|
||||
this.toggleUnfold();
|
||||
}
|
||||
this.selectStatementLine();
|
||||
}
|
||||
|
||||
toggleUnfold() {
|
||||
this.state.isUnfolded = !this.isUnfolded;
|
||||
this.selectStatementLine();
|
||||
}
|
||||
|
||||
selectStatementLine() {
|
||||
// Update the chatter with the last selected element
|
||||
this.bankReconciliation.selectStatementLine(this.record);
|
||||
}
|
||||
|
||||
openChatter() {
|
||||
this.selectStatementLine();
|
||||
this.bankReconciliation.openChatter();
|
||||
}
|
||||
|
||||
get hasInvalidAnalytics() {
|
||||
return this.linesToReconcile.some((line) => line.has_invalid_analytics);
|
||||
}
|
||||
|
||||
get isUnfolded() {
|
||||
return this.state.isUnfolded;
|
||||
}
|
||||
|
||||
get hasStatementLine() {
|
||||
return this.env.model.root.count;
|
||||
}
|
||||
|
||||
get formattedAmount() {
|
||||
return formatMonetary(this.recordData.amount, {
|
||||
currencyId: this.recordData.currency_id.id,
|
||||
});
|
||||
}
|
||||
|
||||
get formattedDate() {
|
||||
return this.recordData.date.toLocaleString({
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
get formattedFullDate() {
|
||||
return this.recordData.date.toLocaleString({
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
get partner() {
|
||||
return this.recordData.partner_id;
|
||||
}
|
||||
|
||||
get linesToReconcile() {
|
||||
return this.accountMoveLines.filter((line) => {
|
||||
return (
|
||||
line.account_id.id !== this.recordData.journal_id?.suspense_account_id.id &&
|
||||
line.account_id.id !== this.recordData.journal_id?.default_account_id.id
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
get suspenseAccountLine() {
|
||||
return this.accountMoveLines.filter((line) => {
|
||||
return line.account_id.id === this.recordData.journal_id.suspense_account_id.id;
|
||||
})?.[0];
|
||||
}
|
||||
|
||||
get accountMoveLines() {
|
||||
return [...this.recordData.line_ids.records.map((line) => line.data)];
|
||||
}
|
||||
|
||||
get hasForeignCurrencyAndSameCurrencyForAllLines() {
|
||||
return (
|
||||
this.recordData.foreign_currency_id &&
|
||||
this.linesToReconcile &&
|
||||
this.linesToReconcile.filter((line) => {
|
||||
return line.currency_id.id !== this.recordData.foreign_currency_id.id;
|
||||
}).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
get suspenseAccountLineFormattedAmount() {
|
||||
return formatMonetary(this.suspenseAccountLine.amount_currency, {
|
||||
currencyId: this.suspenseAccountLine?.currency_id.id,
|
||||
});
|
||||
}
|
||||
|
||||
get activityNumber() {
|
||||
return this.recordData.activity_ids.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there is at least one attachment associated with the bank
|
||||
* statement line or its related records. Aggregates attachment counts from
|
||||
* the move, the related move lines, and the lines reconciled with them.
|
||||
*
|
||||
* @returns {number} Total attachments. > 0 indicates presence.
|
||||
*/
|
||||
get hasAttachment() {
|
||||
const statementAttachment = this.recordData.bank_statement_attachment_ids.records.map(
|
||||
(attachment) => attachment.data.id
|
||||
);
|
||||
|
||||
return (
|
||||
this.recordData.attachment_ids.records.length +
|
||||
this.linesToReconcile
|
||||
.flatMap((line) => line.reconciled_lines_ids.records)
|
||||
.filter((line) => line.data.move_attachment_ids?.count)
|
||||
.reduce(
|
||||
(accumulator, line) =>
|
||||
parseInt(accumulator) + parseInt(line.data.move_attachment_ids.count),
|
||||
0
|
||||
) +
|
||||
this.linesToReconcile
|
||||
.filter(
|
||||
(line) =>
|
||||
line.move_attachment_ids?.count &&
|
||||
!line.move_attachment_ids.records
|
||||
.map((attachment) => attachment.data.id)
|
||||
.every((id) => statementAttachment.includes(id))
|
||||
)
|
||||
.reduce(
|
||||
(accumulator, line) =>
|
||||
parseInt(accumulator) + parseInt(line.move_attachment_ids.count),
|
||||
0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
get amountClasses() {
|
||||
const classes = this.recordData.foreign_currency_id ? "w-50" : "w-100";
|
||||
if (this.recordData.amount > 0) {
|
||||
return `${classes} fw-bold`;
|
||||
}
|
||||
if (this.recordData.amount < 0) {
|
||||
return `${classes} text-danger fw-bold`;
|
||||
}
|
||||
return `${classes} text-secondary`;
|
||||
}
|
||||
|
||||
get buttonListProps() {
|
||||
return {
|
||||
statementLineRootRef: this.statementLineRootRef,
|
||||
statementLine: this.record,
|
||||
reconcileLineCount:
|
||||
this.bankReconciliation.reconcileCountPerPartnerId[this.recordData.partner_id.id] ??
|
||||
null,
|
||||
reconcileModels:
|
||||
this.bankReconciliation.reconcileModelPerStatementLineId[this.recordData.id] ?? [],
|
||||
preSelectedReconciliationModel: this.accountMoveLines
|
||||
.filter((line) => line.reconcile_model_id.id)
|
||||
.map((line) => line.reconcile_model_id)?.[0],
|
||||
};
|
||||
}
|
||||
|
||||
get formattedAmountCurrencyInForeign() {
|
||||
return formatMonetary(this.recordData.amount_currency, {
|
||||
currencyId: this.recordData.foreign_currency_id.id,
|
||||
});
|
||||
}
|
||||
|
||||
get isSelected() {
|
||||
return this.recordData.move_id.id === this.bankReconciliation.statementLineMoveId;
|
||||
}
|
||||
|
||||
get isChatterOpen() {
|
||||
return this.bankReconciliation.chatterState.visible;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting_bank_rec.BankRecStatementLine" t-inherit="web.KanbanRecord" t-inherit-mode="primary">
|
||||
<xpath expr="//article" position="replace">
|
||||
<article
|
||||
t-att-class="getRecordClasses()"
|
||||
t-att-data-id="record.id"
|
||||
t-att-tabindex="record.model.useSampleModel ? -1 : 0"
|
||||
t-custom-click="onGlobalClick"
|
||||
t-on-touchstart="onTouchStart"
|
||||
t-on-touchmove="onTouchMoveOrCancel"
|
||||
t-on-touchcancel="onTouchMoveOrCancel"
|
||||
t-on-touchend="onTouchEnd"
|
||||
t-ref="root">
|
||||
<div name="bank_statement_line" class="o_statement_line w-100 p-2" t-on-click="selectStatementLine" t-att-class="{'o_selected_statement_line': isSelected}">
|
||||
<button t-if="!recordData.statement_id" type="button" class="o_statement_btn d-none d-md-block position-absolute top-0 end-0 btn btn-sm btn-secondary" t-on-click.stop="openStatementCreate">
|
||||
Statement
|
||||
</button>
|
||||
<div class="o_grid_container">
|
||||
<div class="o_row">
|
||||
<div class="d-flex gap-3">
|
||||
<div t-att-data-tooltip="formattedFullDate">
|
||||
<t t-esc="formattedDate"/>
|
||||
</div>
|
||||
<div t-on-click.stop="openChatter" t-if="!ui.isSmall" class="o_chatter_icon btn-link text-action" t-att-class="{'visible': activityNumber or hasAttachment}">
|
||||
<div t-if="activityNumber" class="activity-container position-relative">
|
||||
<i class="fa fa-lg fa-clock-o" role="img" aria-label="Activities"/>
|
||||
<span class="activity-badge badge rounded-pill" t-esc="activityNumber"/>
|
||||
</div>
|
||||
<i t-elif="hasAttachment"
|
||||
class="fa fa-lg fa-paperclip"
|
||||
role="img"
|
||||
aria-label="Attachment"
|
||||
/>
|
||||
<i t-elif="!isChatterOpen"
|
||||
class="fa fa-lg fa-comments-o"
|
||||
role="img"
|
||||
aria-label="Journal Entry"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_payment_ref user-select-text d-none d-md-block"
|
||||
t-att-class="isUnfolded ? 'overflow-wrap' : 'text-truncate'">
|
||||
<span class="d-inline">
|
||||
<t t-if="partner">
|
||||
<a class="fw-bold" href="#" t-on-click.prevent.stop="openPartner">
|
||||
<span t-esc="partner.display_name" name="statement_line_partner_name"/>
|
||||
</a>
|
||||
<button class="btn btn-link oi oi-close p-0 align-baseline" t-on-click.stop="removePartner" t-if="!linesToReconcile.length"/>
|
||||
</t>
|
||||
<t t-elif="recordData.partner_name">
|
||||
<span class="fw-bold" t-esc="recordData.partner_name" name="statement_line_partner_name"/>
|
||||
</t>
|
||||
<span t-att-class="partner or recordData.partner_name ? 'ms-1' : undefined"
|
||||
t-esc="recordData.payment_ref"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Only available on large screen -->
|
||||
<div class="o_button_line d-none d-md-flex align-items-start text-truncate">
|
||||
<BankRecButtonList t-props="buttonListProps" suspenseAccountLine="suspenseAccountLine" t-if="!recordData.is_reconciled or (userCanReview and !recordData.checked)"/>
|
||||
<span class="badge rounded-pill py-1 ps-1" t-att-class="{ 'pe-1': !isUnfolded, 'text-success bg-success-subtle': !hasInvalidAnalytics, 'text-warning bg-warning-subtle': hasInvalidAnalytics}" t-if="recordData.is_reconciled">
|
||||
<i t-if="hasInvalidAnalytics" class="fa fa-exclamation-triangle" data-tooltip="Some lines have invalid analytic distribution"/>
|
||||
<i t-if="!hasInvalidAnalytics" class="fa fa-check"/>
|
||||
<span t-if="isUnfolded" class="ms-1">
|
||||
Reconciled
|
||||
</span>
|
||||
</span>
|
||||
<t t-if="recordData.is_reconciled and !isUnfolded">
|
||||
<t t-foreach="Object.entries(reconciledLineName)" t-as="line" t-key="line_index">
|
||||
<BankRecReconciledLineName statementLine="record" linesToReconcile="linesToReconcile" moveLineId="line[0]" valueToDisplay="line[1]"/>
|
||||
<t t-if="line_index < Object.keys(reconciledLineName).length - 1">, </t>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
<div class="d-flex align-items-start justify-content-between o_line_amount">
|
||||
<span class="text-muted w-50 text-end text-nowrap" t-if="recordData.foreign_currency_id">
|
||||
<t t-esc="formattedAmountCurrencyInForeign"/>
|
||||
</span>
|
||||
<span t-att-class="amountClasses" class="text-end text-nowrap" t-esc="formattedAmount"/>
|
||||
</div>
|
||||
<div class="d-none d-md-block text-end" t-on-click="toggleUnfold" t-if="recordData.is_reconciled">
|
||||
<i class="oi" t-att-class="{'oi-chevron-up': isUnfolded, 'oi-chevron-down': !isUnfolded}"/>
|
||||
</div>
|
||||
<div class="d-none d-md-block" t-else=""/> <!-- To keep empty space if no chevron -->
|
||||
</div>
|
||||
|
||||
<!-- Only available on small screen -->
|
||||
<div class="o_row d-md-none">
|
||||
<span class="text-truncate o_payment_ref"
|
||||
t-esc="recordData.payment_ref"
|
||||
/>
|
||||
</div>
|
||||
<t t-if="isUnfolded or !recordData.is_reconciled">
|
||||
<t t-foreach="linesToReconcile" t-as="line" t-key="line_index">
|
||||
<BankRecLineToReconcile statementLine="record" line="line"/>
|
||||
</t>
|
||||
<div class="o_row" t-if="linesToReconcile.length">
|
||||
<div t-if="suspenseAccountLine" class="d-none d-md-flex fw-bold text-muted align-items-center justify-content-end o_line_amount" t-att-class="hasForeignCurrencyAndSameCurrencyForAllLines ? 'w-50' : 'w-100'">
|
||||
<t t-esc="suspenseAccountLineFormattedAmount"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<div class="o_row d-md-none">
|
||||
<div class="o_button_line">
|
||||
<BankRecButtonList t-props="buttonListProps" suspenseAccountLine="suspenseAccountLine" t-if="!recordData.is_reconciled or (userCanReview and !recordData.checked)"/>
|
||||
<span t-if="recordData.is_reconciled and !isUnfolded" class="text-start text-muted" t-esc="reconciledLineName"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,42 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* Mirrored from
|
||||
* `account_accountant/.../statement_summary/statement_summary.js`.
|
||||
* Phase 1 structural parity.
|
||||
*/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class BankRecStatementSummary extends Component {
|
||||
static template = "fusion_accounting_bank_rec.BankRecStatementSummary";
|
||||
|
||||
static props = {
|
||||
label: { type: String },
|
||||
amount: { type: String, optional: true },
|
||||
action: { type: Function },
|
||||
journalId: { type: Number, optional: true },
|
||||
isValid: { type: Boolean, optional: true },
|
||||
journalIsInvalid: { type: Boolean, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
isValid: true,
|
||||
};
|
||||
|
||||
actionApplyInvalidStatement() {
|
||||
const facets = this.env.searchModel.facets;
|
||||
const searchItems = this.env.searchModel.searchItems;
|
||||
const invalidStatementFilter = Object.values(searchItems).find(
|
||||
(i) => i.name == "invalid_statement"
|
||||
);
|
||||
const invalidStatementFacet = facets.filter(
|
||||
(i) => i.groupId == invalidStatementFilter.groupId
|
||||
);
|
||||
if (
|
||||
invalidStatementFacet.length == 0 ||
|
||||
!invalidStatementFacet[0].values.includes(invalidStatementFilter.description)
|
||||
) {
|
||||
this.env.searchModel.toggleSearchItem(invalidStatementFilter.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
<t t-name="fusion_accounting_bank_rec.BankRecStatementSummary">
|
||||
<div class="o_statement_summary d-flex justify-content-between align-items-center w-100 p-2">
|
||||
<div name="label_statement_summary" class="d-flex gap-2 align-items-center">
|
||||
<h4 t-esc="props.label"
|
||||
t-on-click="props.action"
|
||||
class="m-0"
|
||||
t-att-class="{'text-danger': !props.isValid}"/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="m-0"
|
||||
t-if="props.journalIsInvalid"
|
||||
t-on-click="actionApplyInvalidStatement">
|
||||
Invalid Statement(s)
|
||||
</h4>
|
||||
</div>
|
||||
<div t-if="props.amount"
|
||||
class="btn btn-link p-0 fw-bold fs-4"
|
||||
t-on-click="props.action"
|
||||
t-esc="props.amount"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -11,7 +11,9 @@
|
||||
*/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { reactive, useState, EventBus } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
const ENDPOINT_BASE = "/fusion/bank_rec";
|
||||
|
||||
@@ -20,6 +22,22 @@ export class BankReconciliationService {
|
||||
this.env = env;
|
||||
this.rpc = services.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({
|
||||
@@ -265,13 +283,138 @@ export class BankReconciliationService {
|
||||
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: ["rpc", "notification"],
|
||||
dependencies: ["rpc", "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