Compare commits

...

4 Commits

Author SHA1 Message Date
gsinghpal
c9ac4c64fb feat(fusion_accounting_bank_rec): mirror Enterprise OWL batch 4 (auxiliary components)
Mirrors 3 OWL components from account_accountant for Phase 1
structural parity:

- quick_create/ (BankRecQuickCreate + BankRecQuickCreateController
  for inline missing-record creation)
- chatter/ (BankRecChatter — extends @mail Chatter with a
  reloadParentView hook for the bound statement line)
- file_uploader/ (BankRecFileUploader — extends @account
  DocumentFileUploader to inject statement_line_id into the
  upload context, targeting account.bank.statement.line)

Renames applied per spec; CSS class
`o_bank_reconciliation_quick_create` ->
`o_fusion_bank_reconciliation_quick_create`.

Manifest version bumped to 19.0.1.0.15.

Module upgrade succeeds, 134 logical tests still pass — completing
the Phase 1 OWL component mirror (Tasks 30-33). All 14 components
across 4 batches are now bundled.

Made-with: Cursor
2026-04-19 12:55:20 -04:00
gsinghpal
b06e01babb feat(fusion_accounting_bank_rec): mirror Enterprise OWL batch 3 (dialog components)
Mirrors 2 OWL components (3 files each) from account_accountant
for Phase 1 structural parity:

- bankrec_form_dialog/ (full-form dialog for advanced editing,
  including BankRecEditLineFormController with the To-Review
  hotkey button)
- search_dialog/ (BankRecSelectCreateDialog for finding additional
  matches, plus the bank_rec_dialog_list view registration)

Renames applied per spec.

Notes:
- View registry IDs prefixed: `fusion_bankrec_edit_line`,
  `fusion_bank_rec_dialog_list`.
- Button template renamed
  `accountant.BankRecFormDialog.buttons` ->
  `fusion_accounting_bank_rec.BankRecFormDialog.buttons`.

Manifest version bumped to 19.0.1.0.14.

Module upgrade succeeds, 134 logical tests still pass.

Made-with: Cursor
2026-04-19 12:54:11 -04:00
gsinghpal
9e4de89269 feat(fusion_accounting_bank_rec): mirror Enterprise OWL batch 2 (action + edit components)
Mirrors 5 OWL components from account_accountant for Phase 1
structural parity:

- button/ (single action button)
- button_list/ (toolbar of buttons + dropdown + hotkeys)
- line_to_reconcile/ (editable matched-line editor)
- list_view/ (list view + many2one multi-edit field)
- apply_amount/ (amount application html field)

Renames applied per spec (template names, module IDs, CSS classes).

Notes / deferred to fusion-only Tasks 34-36:
- list_view extends @web ListController instead of Enterprise's
  AttachmentPreviewListController; setSelectedRecord is a no-op
  pending the previewer pane mirror.
- View/field registry IDs prefixed with `fusion_` to coexist with
  Enterprise's account_accountant when both modules are installed
  (`fusion_bank_rec_list`, `fusion_bank_rec_dialog_list`,
  `fusion_apply_amount_html`, `fusion_bank_rec_list_many2one_multi_id`,
  `fusion_bankrec_edit_line`).
- button_list still references Enterprise view_refs in dialog
  contexts (`account_accountant.view_account_list_bank_rec_widget`
  etc.) for parity; the `set_*` ORM methods on
  account.bank.statement.line are Enterprise-only too. These call
  sites only fire when the mirrored components are actually
  rendered, which Phase 1 does not exercise.

Manifest version bumped to 19.0.1.0.13.

Module upgrade succeeds, 134 logical tests still pass.

Made-with: Cursor
2026-04-19 12:53:02 -04:00
gsinghpal
1634ecd4f6 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
2026-04-19 12:51:38 -04:00
32 changed files with 2390 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting — Bank Reconciliation',
'version': '19.0.1.0.11',
'version': '19.0.1.0.15',
'category': 'Accounting/Accounting',
'sequence': 28,
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
@@ -43,6 +43,43 @@ 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',
# Batch 2 (Task 31) — action + edit components
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.xml',
# Batch 3 (Task 32) — dialog components
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog_list.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog_list.xml',
# Batch 4 (Task 33) — auxiliary components
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/quick_create/quick_create.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/quick_create/quick_create.xml',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/chatter/chatter.js',
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/file_uploader/file_uploader.js',
],
},
'installable': True,

View File

@@ -0,0 +1,82 @@
/** @odoo-module **/
/**
* Mirrored from `account_accountant/.../apply_amount/apply_amount.js`.
* Phase 1 structural parity.
*/
import { Component } from "@odoo/owl";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
class BankRecWidgetApplyAmountHtmlField extends Component {
static props = standardFieldProps;
static template = "fusion_accounting_bank_rec.BankRecWidgetApplyAmountHtmlField";
setup() {
this.action = useService("action");
this.orm = useService("orm");
}
get value() {
return this.props.record.data[this.props.name];
}
async switchApplyAmount(ev) {
const root = this.env.model.root;
const fetchReconciledLines = async (fields = []) => {
return await this.orm.searchRead(
"account.move.line",
[
[
"id",
"in",
...root.data.reconciled_lines_excluding_exchange_diff_ids._currentIds,
],
],
fields
);
};
const fetchStatementLines = async (fields = []) => {
return await this.orm.searchRead(
"account.move.line",
[["move_id", "=", root.data.move_id.id]],
fields
);
};
if (ev.target.attributes.name?.value === "action_redirect_to_move") {
const [line] = await fetchReconciledLines(["amount_currency", "balance", "move_id"]);
await this.openMove(line.move_id[0]);
} else if (ev.target.attributes.name?.value === "apply_full_amount") {
const [line] = await fetchReconciledLines(["amount_currency", "balance"]);
await root.update({
balance: -line.balance,
amount_currency: -line.amount_currency,
});
} else if (ev.target.attributes.name?.value === "apply_partial_amount") {
const lines = await fetchStatementLines(["amount_currency", "balance"]);
// We have all the lines of the entry, we want the suspense line.
await root.update({
balance: lines.at(-1).balance,
amount_currency: lines.at(-1).amount_currency,
});
}
}
openMove(moveId) {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "account.move",
res_id: moveId,
views: [[false, "form"]],
target: "current",
});
}
}
const fusionBankRecWidgetApplyAmountHtmlField = { component: BankRecWidgetApplyAmountHtmlField };
registry.category("fields").add("fusion_apply_amount_html", fusionBankRecWidgetApplyAmountHtmlField);

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BankRecWidgetApplyAmountHtmlField">
<div t-out="value" t-on-click="switchApplyAmount"/>
</t>
</templates>

View File

@@ -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";

View File

@@ -0,0 +1,48 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../bankrec_form_dialog/bankrec_form_dialog.js`.
* Phase 1 structural parity.
*/
import { FormController } from "@web/views/form/form_controller";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { formView } from "@web/views/form/form_view";
import { onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
export class BankRecFormDialog extends FormViewDialog {
setup() {
super.setup();
Object.assign(this.viewProps, {
buttonTemplate: "fusion_accounting_bank_rec.BankRecFormDialog.buttons",
});
}
}
export class BankRecEditLineFormController extends FormController {
setup() {
super.setup();
this.isReviewed = this.props.context.is_reviewed;
onWillStart(async () => {
this.userCanReview = await user.hasGroup("account.group_account_user");
});
}
async toReviewButtonClicked(params = {}) {
await this.orm.call("account.move", "set_moves_checked", [
this.model.root.data.move_id.id,
false,
]);
return this.saveButtonClicked(params);
}
}
export const bankRecEditLineFormController = {
...formView,
Controller: BankRecEditLineFormController,
};
registry.category("views").add("fusion_bankrec_edit_line", bankRecEditLineFormController);

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BankRecFormDialog.buttons" t-inherit="web.FormViewDialog.ToOne.buttons" t-inherit-mode="primary">
<xpath expr="//button[hasclass('o_form_button_save')]" position="after">
<button
t-if="userCanReview and this.isReviewed"
class="btn btn-info"
t-on-click.stop="() => this.toReviewButtonClicked({closable: true})"
data-hotkey="q">
<span>To Review</span>
</button>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,29 @@
/** @odoo-module **/
/**
* Mirrored from `account_accountant/.../button/button.js`.
* Phase 1 structural parity.
*/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class BankRecButton extends Component {
static template = "fusion_accounting_bank_rec.BankRecButton";
static props = {
label: { type: String, optional: true },
action: { type: Function, optional: true },
count: { type: [Number, { value: null }], optional: true },
primary: { type: Boolean, optional: true },
toReview: { type: Boolean, optional: true },
classes: { type: String, optional: true },
};
static defaultProps = {
primary: false,
classes: "",
};
setup() {
this.ui = useService("ui");
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="fusion_accounting_bank_rec.BankRecButton">
<button
t-attf-class="d-flex gap-1 btn text-nowrap {{ props.classes }}"
t-att-class="{'btn-sm': !ui.isSmall, 'btn-primary': props.primary, 'btn-info': props.toReview, 'btn-secondary': !props.primary}"
t-on-click.stop="() => props?.action()"
>
<span t-esc="props?.label" class="m-auto text-truncate"/>
<span class="rounded-pill px-2 o_bg-black-10" t-if="props?.count">
<t t-esc="props.count"/>
</span>
</button>
</t>
</templates>

View File

@@ -0,0 +1,603 @@
/** @odoo-module **/
/**
* Mirrored from `account_accountant/.../button_list/button_list.js`.
* Phase 1 structural parity. Behaviour delegates to the
* Enterprise-compat surface in our `fusion_bank_reconciliation` service.
*/
import { BankRecButton } from "@fusion_accounting_bank_rec/components/bank_reconciliation/button/button";
import { BankRecFileUploader } from "@fusion_accounting_bank_rec/components/bank_reconciliation/file_uploader/file_uploader";
import { Component } from "@odoo/owl";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
import { BankRecSelectCreateDialog } from "@fusion_accounting_bank_rec/components/bank_reconciliation/search_dialog/search_dialog";
import { _t } from "@web/core/l10n/translation";
import { getCurrency } from "@web/core/currency";
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service";
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
export class BankRecButtonList extends Component {
static template = "fusion_accounting_bank_rec.BankRecButtonList";
static components = {
Dropdown,
DropdownItem,
BankRecButton,
BankRecFileUploader,
};
static props = {
statementLineRootRef: { type: Object },
statementLine: { type: Object },
suspenseAccountLine: { type: Object, optional: true },
reconcileLineCount: { type: [Number, { value: null }], optional: true },
reconcileModels: Array,
preSelectedReconciliationModel: { type: Object, optional: true },
};
static defaultProps = {
reconcileLineCount: 0,
};
setup() {
this.action = useService("action");
this.ui = useService("ui");
this.orm = useService("orm");
this.addDialog = useOwnedDialogs();
this.currencyDigits = getCurrency(this.statementLineData.currency_id.id)?.digits || 2;
this.bankReconciliation = useBankReconciliation();
this.registerHotkeys();
}
restoreFocus() {
if (this.isLineSelected) {
this.props.statementLineRootRef.el.focus();
}
}
/**
* Displays a search dialog (no create option) for selecting a `res.partner` record.
*/
setPartnerOnReconcileLine() {
this.addDialog(
SelectCreateDialog,
{
title: _t("Search: Partner"),
noCreate: false,
multiSelect: false,
resModel: "res.partner",
context: { default_name: this.statementLineData.partner_name },
onSelected: async (partner) => {
await this.orm.call(
"account.bank.statement.line",
"set_partner_bank_statement_line",
[this.statementLineData.id, partner[0]]
);
const recordsToLoad = [];
if (this.statementLineData.partner_name) {
// Reload all impacted statement lines if we have a partner_name
recordsToLoad.push(
...this.env.model.root.records.filter(
(record) =>
record.data.partner_name === this.statementLineData.partner_name
)
);
} else {
recordsToLoad.push(this.props.statementLine);
}
await this.bankReconciliation.reloadRecords(recordsToLoad);
await this.bankReconciliation.computeReconcileLineCountPerPartnerId(
this.env.model.root.records
);
this.bankReconciliation.reloadChatter();
this.restoreFocus();
},
},
{
onClose: () => {
this.restoreFocus();
},
}
);
}
/**
* Opens a dialog to select an account and assigns it to the current reconcile line.
*/
setAccountOnReconcileLine() {
const context = {
list_view_ref: "account_accountant.view_account_list_bank_rec_widget",
search_view_ref: "account_accountant.view_account_search_bank_rec_widget",
...(this.statementLineData.amount > 0
? { preferred_account_type: "income" }
: { preferred_account_type: "expense" }),
};
this.addDialog(
SelectCreateDialog,
{
title: _t("Search: Account"),
noCreate: true,
multiSelect: false,
domain: [
[
"id",
"not in",
[
this.statementLineData.journal_id.suspense_account_id.id,
this.statementLineData.journal_id.default_account_id.id,
],
],
],
context: context,
resModel: "account.account",
onSelected: async (account) => {
const linesToLoad = await this._setAccountOnReconcileLine(
this.lastAccountMoveLine.data.id,
account[0],
{ context: { account_default_taxes: true } }
);
const recordsToLoad = [
...this.env.model.root.records.filter((record) =>
linesToLoad.includes(record.data.id)
),
this.props.statementLine,
];
await this.bankReconciliation.reloadRecords(recordsToLoad);
this.bankReconciliation.reloadChatter();
this.restoreFocus();
},
},
{
onClose: () => {
this.restoreFocus();
},
}
);
}
async _setAccountOnReconcileLine(amlId, accountId, context = {}) {
return await this.orm.call(
"account.bank.statement.line",
"set_account_bank_statement_line",
[this.statementLineData.id, amlId, accountId],
context
);
}
async setAccountReceivableOnReconcileLine() {
let accountId;
if (this.statementLineData.partner_id.property_account_receivable_id.id) {
accountId = this.statementLineData.partner_id.property_account_receivable_id.id;
} else {
accountId = await this.orm.webSearchRead("account.account", [
["account_type", "=", "asset_receivable"],
]);
}
await this._setAccountOnReconcileLine(this.lastAccountMoveLine.data.id, accountId);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
async setAccountPayableOnReconcileLine() {
let accountId;
if (this.statementLineData.partner_id.property_account_payable_id.id) {
accountId = this.statementLineData.partner_id.property_account_payable_id.id;
} else {
accountId = await this.orm.webSearchRead("account.account", [
["account_type", "=", "liability_payable"],
]);
}
await this._setAccountOnReconcileLine(this.lastAccountMoveLine.data.id, accountId);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
/**
* Opens a dialog to search and select journal items to reconcile with the current bank statement line.
*/
reconcileOnReconcileLine() {
const context = {
list_view_ref: "account_accountant.view_account_move_line_list_bank_rec_widget",
search_view_ref: "account_accountant.view_account_move_line_search_bank_rec_widget",
preferred_aml_value: -this.props.suspenseAccountLine.amount_currency,
preferred_aml_currency_id: this.props.suspenseAccountLine.currency_id.id,
...(this.statementLineData.partner_id
? { search_default_partner_id: this.statementLineData.partner_id.id }
: { search_default_posted: 1 }),
};
this.addDialog(
BankRecSelectCreateDialog,
{
title: _t("Search: Journal Items to Match"),
noCreate: true,
domain: this.getReconcileButtonDomain(),
resModel: "account.move.line",
size: "xl",
context: context,
onSelected: async (moveLines) => {
await this.orm.call(
"account.bank.statement.line",
"set_line_bank_statement_line",
[this.statementLineData.id, moveLines]
);
await this.bankReconciliation.computeReconcileLineCountPerPartnerId(
this.env.model.root.records
);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
this.restoreFocus();
},
suspenseAccountLine: this.props.suspenseAccountLine,
reference: this.statementLineData.payment_ref,
date: this.statementLineData.date,
},
{
onClose: () => {
this.restoreFocus();
},
}
);
}
getReconcileButtonDomain() {
return [
["parent_state", "in", ["draft", "posted"]],
["company_id", "child_of", this.statementLineData.company_id.id],
["search_account_id.reconcile", "=", true],
["display_type", "not in", ["line_section", "line_note"]],
["reconciled", "=", false],
"|",
["search_account_id.account_type", "not in", ["asset_receivable", "liability_payable"]],
["payment_id", "=", false],
["statement_line_id", "!=", this.statementLineData.id],
];
}
/**
* Deletes the current bank statement line.
*/
async deleteTransaction() {
this.addDialog(ConfirmationDialog, {
body: _t("Are you sure you want to delete this statement line?"),
confirm: async () => {
await this.orm.unlink("account.bank.statement.line", [this.statementLineData.id]);
this.env.model.load();
},
cancel: () => {},
});
}
/**
* Set the move of the statement line as to check
*/
async setStatementLineAsReviewed() {
await this.orm.call("account.move", "set_moves_checked", [
this.statementLineData.move_id.id,
]);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
// -----------------------------------------------------------------------------
// Reconciliation Model
// -----------------------------------------------------------------------------
async triggerReconciliationModel(reconciliationModelId) {
await this.orm.call("account.reconcile.model", "trigger_reconciliation_model", [
reconciliationModelId,
this.statementLineData.id,
]);
await this.bankReconciliation.computeReconcileLineCountPerPartnerId(
this.env.model.root.records
);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
getKeyAction(key) {
const keyActions = {
1: {
condition:
this.props.statementLineRootRef.el.querySelector(".set-partner-btn") &&
this.isLineSelected,
action: async () => this.setPartnerOnReconcileLine(),
buttonElement: this.props.statementLineRootRef.el.querySelector(".set-partner-btn"),
},
2: {
condition:
this.props.statementLineRootRef.el.querySelector(".reconcile-btn") &&
this.isLineSelected,
action: async () => this.reconcileOnReconcileLine(),
buttonElement: this.props.statementLineRootRef.el.querySelector(".reconcile-btn"),
},
3: {
condition:
this.props.statementLineRootRef.el.querySelector(".set-account-btn") &&
this.isLineSelected,
action: () => this.setAccountOnReconcileLine(),
buttonElement: this.props.statementLineRootRef.el.querySelector(".set-account-btn"),
},
4: {
condition:
this.props.statementLineRootRef.el.querySelector(".set-payable-btn") &&
this.isLineSelected,
action: () => this.setAccountPayableOnReconcileLine(),
buttonElement: this.props.statementLineRootRef.el.querySelector(".set-payable-btn"),
},
5: {
condition:
this.props.statementLineRootRef.el.querySelector(".set-receivable-btn") &&
this.isLineSelected,
action: () => this.setAccountReceivableOnReconcileLine(),
buttonElement:
this.props.statementLineRootRef.el.querySelector(".set-receivable-btn"),
},
6: {
condition:
this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-0"
) && this.isLineSelected,
action: () => {
const buttonElement = this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-0"
);
if (buttonElement) {
buttonElement.click();
}
},
buttonElement: this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-0"
),
},
7: {
condition:
this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-1"
) && this.isLineSelected,
action: () => {
const buttonElement = this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-1"
);
if (buttonElement) {
buttonElement.click();
}
},
buttonElement: this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-1"
),
},
8: {
condition:
this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-2"
) && this.isLineSelected,
action: () => {
const buttonElement = this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-2"
);
if (buttonElement) {
buttonElement.click();
}
},
buttonElement: this.props.statementLineRootRef.el.querySelector(
".reconciliation-model-btn-2"
),
},
Enter: {
condition:
this.props.statementLineRootRef.el.querySelector(".btn-primary") &&
this.isLineSelected,
action: () => {
const primaryButtons =
this.props.statementLineRootRef.el.querySelectorAll(".btn-primary");
if (primaryButtons.length > 0) {
primaryButtons[0].click();
}
},
buttonElement: this.props.statementLineRootRef.el.querySelector(".btn-primary"),
},
};
return keyActions[key];
}
registerHotkeys() {
const hotkeyConfigs = [
{ key: "1", trigger: "alt+shift+1" },
{ key: "2", trigger: "alt+shift+2" },
{ key: "3", trigger: "alt+shift+3" },
{ key: "4", trigger: "alt+shift+4" },
{ key: "5", trigger: "alt+shift+5" },
{ key: "6", trigger: "alt+shift+6" },
{ key: "7", trigger: "alt+shift+7" },
{ key: "8", trigger: "alt+shift+8" },
{ key: "Enter", trigger: "alt+shift+enter" },
];
hotkeyConfigs.forEach(({ key, trigger }) => {
useHotkey(
trigger,
({ target }) => {
const { condition, action } = this.getKeyAction(key);
if (condition) {
action();
}
},
{
area: () => this.props.statementLineRootRef.el.parentElement,
withOverlay: () => {
const { buttonElement, condition } = this.getKeyAction(key);
return condition ? buttonElement : null;
},
isAvailable: () => {
const { condition } = this.getKeyAction(key);
return condition;
},
}
);
});
}
// -----------------------------------------------------------------------------
// File Uploader
// -----------------------------------------------------------------------------
get bankRecFileUploaderRecord() {
return {
statementLineId: this.statementLineData.id,
};
}
// -----------------------------------------------------------------------------
// ACTION
// -----------------------------------------------------------------------------
actionViewRecoModels() {
return this.action.doAction("account.action_account_reconcile_model");
}
// -----------------------------------------------------------------------------
// GETTER
// -----------------------------------------------------------------------------
get statementLineData() {
return this.props.statementLine.data;
}
get isLineSelected() {
return this.statementLineData.id === this.bankReconciliation.statementLine?.data.id;
}
get lastAccountMoveLine() {
return this.statementLineData.line_ids.records.at(-1);
}
get isCustomerRankHigher() {
return (
this.statementLineData.partner_id.customer_rank >
this.statementLineData.partner_id.supplier_rank
);
}
get isSetPartnerButtonShown() {
return !this.statementLineData.partner_id;
}
get isSetAccountButtonShown() {
return !this.statementLineData.account_id;
}
get isSetReceivableButtonShown() {
return (
!this.isSetPartnerButtonShown &&
((this.statementLineData.partner_id.customer_rank && this.isCustomerRankHigher) ||
this.statementLineData.amount > 0)
);
}
get isSetPayableButtonShown() {
return (
!this.isSetPartnerButtonShown &&
((this.statementLineData.partner_id.supplier_rank && !this.isCustomerRankHigher) ||
this.statementLineData.amount < 0)
);
}
get isReconcileButtonShown() {
return this.props.reconcileLineCount === null || this.props.reconcileLineCount;
}
get reconcileModelsInDropdown() {
if (this.ui.isSmall) {
return this.props.reconcileModels;
}
return this.props.reconcileModels.filter(
(model) => model.id !== this.props?.preSelectedReconciliationModel?.id
);
}
get buttons() {
const buttonsToDisplay = {};
if (this.isSetPartnerButtonShown) {
buttonsToDisplay.partner = {
label: _t("Set Partner"),
action: this.setPartnerOnReconcileLine.bind(this),
classes: "set-partner-btn",
};
} else {
buttonsToDisplay.receivable = {
label: _t("Receivable"),
action: this.setAccountReceivableOnReconcileLine.bind(this),
classes: "set-receivable-btn",
};
buttonsToDisplay.payable = {
label: _t("Payable"),
action: this.setAccountPayableOnReconcileLine.bind(this),
classes: "set-payable-btn",
};
}
if (this.isReconcileButtonShown) {
buttonsToDisplay.reconcile = {
label: _t("Reconcile"),
action: this.reconcileOnReconcileLine.bind(this),
count: this.props.reconcileLineCount,
classes: "reconcile-btn",
};
}
if (this.isSetAccountButtonShown) {
buttonsToDisplay.account = {
label: _t("Set Account"),
action: this.setAccountOnReconcileLine.bind(this),
classes: "set-account-btn",
};
}
if (this.statementLineData.is_reconciled && !this.statementLineData.checked) {
buttonsToDisplay.toReview = {
label: _t("Reviewed"),
action: this.setStatementLineAsReviewed.bind(this),
toReview: true,
};
}
return buttonsToDisplay;
}
get buttonsToDisplay() {
const buttons = this.buttons || {};
let primaryButtonKeys = [];
let secondaryButtonKeys = [];
if (buttons?.partner && buttons?.account) {
primaryButtonKeys = ["partner", "account"];
} else if (buttons?.reconcile && !!buttons.reconcile?.count) {
primaryButtonKeys = ["reconcile"];
if (this.isSetReceivableButtonShown) {
secondaryButtonKeys = ["receivable"];
} else {
secondaryButtonKeys = ["payable"];
}
} else if (this.isSetReceivableButtonShown) {
primaryButtonKeys = ["receivable"];
} else if (this.isSetPayableButtonShown) {
primaryButtonKeys = ["payable"];
}
return [
...primaryButtonKeys.map((key) => ({ ...buttons[key], primary: true })),
...secondaryButtonKeys.map((key) => ({ ...buttons[key] })),
];
}
get buttonsInDropdown() {
const buttons = this.buttons || {};
if (this.props.preSelectedReconciliationModel) {
return Object.values(buttons);
}
const buttonToDisplayClasses = this.buttonsToDisplay.map((button) => button.classes) || [];
return Object.values(buttons).filter(
(button) => !buttonToDisplayClasses.includes(button.classes)
);
}
}

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="fusion_accounting_bank_rec.BankRecButtonList">
<div class="d-flex flex-wrap gap-1">
<t t-if="props.preSelectedReconciliationModel and !statementLineData.is_reconciled">
<BankRecButton
label="props.preSelectedReconciliationModel.display_name"
primary="true"
action.bind="() => this.triggerReconciliationModel(props.preSelectedReconciliationModel.id)"
/>
</t>
<t t-elif="buttons?.toReview">
<BankRecButton t-props="buttons.toReview"/>
</t>
<t t-else="">
<t t-foreach="buttonsToDisplay" t-as="button" t-key="button_index">
<BankRecButton t-props="button"/>
</t>
</t>
<Dropdown t-if="!statementLineData.is_reconciled">
<button class="btn btn-secondary" t-att-class="{'btn-sm': !ui.isSmall}">
<i class="oi oi-ellipsis-v"/>
</button>
<t t-set-slot="content">
<t t-foreach="buttonsInDropdown" t-as="button" t-key="button_index">
<DropdownItem class="'btn btn-link'" onSelected.bind="button.action">
<t t-esc="button.label"/>
</DropdownItem>
</t>
<BankRecFileUploader record="bankRecFileUploaderRecord">
<t t-set-slot="toggler">
<span class="dropdown-item dropdown-item o-navigable btn btn-link">
Upload Bills
</span>
</t>
</BankRecFileUploader>
<div class="dropdown-divider"/>
<t t-foreach="reconcileModelsInDropdown" t-as="model" t-key="model.id">
<DropdownItem class="'btn btn-link'" onSelected.bind="() => this.triggerReconciliationModel(model.id)">
<t t-esc="model.display_name"/>
</DropdownItem>
</t>
<div t-if="reconcileModelsInDropdown.length" class="dropdown-divider"/>
<DropdownItem class="'btn btn-link'" onSelected.bind="actionViewRecoModels">
Manage Models
</DropdownItem>
<DropdownItem class="'btn btn-link'" onSelected.bind="deleteTransaction">
Delete Transaction
</DropdownItem>
</t>
</Dropdown>
</div>
</t>
</templates>

View File

@@ -0,0 +1,16 @@
/** @odoo-module **/
/**
* Mirrored from `account_accountant/.../chatter/chatter.js`.
* Phase 1 structural parity.
*/
import { Chatter } from "@mail/chatter/web_portal/chatter";
export class BankRecChatter extends Chatter {
static props = [...Chatter.props, "statementLine?"];
async reloadParentView() {
await this.props.statementLine?.load();
}
}

View File

@@ -0,0 +1,29 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../file_uploader/file_uploader.js`.
* Phase 1 structural parity.
*/
import { DocumentFileUploader } from "@account/components/document_file_uploader/document_file_uploader";
export class BankRecFileUploader extends DocumentFileUploader {
/**
* Extends `DocumentFileUploader.getExtraContext` to add the
* `statement_line_id` to the context, used by
* `account.bank.statement.line.create_document_from_attachment` to link
* the uploaded bill back to the originating statement line.
*/
getExtraContext() {
const extraContext = super.getExtraContext();
return {
...extraContext,
statement_line_id: this.props.record.statementLineId,
};
}
getResModel() {
return "account.bank.statement.line";
}
}

View File

@@ -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,
});
}
}

View File

@@ -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>

View File

@@ -0,0 +1,204 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../line_to_reconcile/line_to_reconcile.js`.
* Phase 1 structural parity.
*/
import { Component, useRef } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { formatMonetary } from "@web/views/fields/formatters";
import { useService } from "@web/core/utils/hooks";
import { useBankReconciliation } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bank_reconciliation_service";
import { usePopover } from "@web/core/popover/popover_hook";
import { BankRecFormDialog } from "@fusion_accounting_bank_rec/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog";
import { BankRecLineInfoPopOver } from "@fusion_accounting_bank_rec/components/bank_reconciliation/line_info_pop_over/line_info_pop_over";
import { x2ManyCommands } from "@web/core/orm_service";
export class BankRecLineToReconcile extends Component {
static template = "fusion_accounting_bank_rec.BankRecLineToReconcile";
static props = {
line: Object,
statementLine: Object,
};
setup() {
this.action = useService("action");
this.orm = useService("orm");
this.dialogService = useService("dialog");
this.ui = useService("ui");
this.bankReconciliation = useBankReconciliation();
this.lineInfoRef = useRef("line-info-ref");
this.lineInfoPopOver = usePopover(BankRecLineInfoPopOver, {
position: "left",
closeOnClickAway: true,
});
}
onClickLine() {
if (this.ui.isSmall) {
this.toggleEditLine();
}
}
toggleEditLine() {
this.dialogService.add(BankRecFormDialog, {
title: _t("Edit Line"),
resModel: "account.move.line",
resId: this.lineData.id,
context: {
form_view_ref: "account_accountant.view_bank_rec_edit_line",
is_reviewed: this.lineData.move_id.checked,
},
onRecordSave: async (record) => {
await this.orm.call("account.bank.statement.line", "edit_reconcile_line", [
this.statementLineData.id,
this.lineData.id,
await record.getChanges(),
]);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
return true;
},
});
}
async deleteLine() {
await this.orm.call("account.bank.statement.line", "delete_reconciled_line", [
this.statementLineData.id,
this.lineData.id,
]);
if (this.lineData.reconciled_lines_ids.records.length) {
// Only update the line count per partner if we delete
// a line which is reconciled to another move line
this.bankReconciliation.computeReconcileLineCountPerPartnerId(
this.env.model.root.records
);
}
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
// -----------------------------------------------------------------------------
// ACTION
// -----------------------------------------------------------------------------
openMove() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "account.move",
res_id: this.moveData.id,
views: [[false, "form"]],
target: "current",
});
}
openPartner() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "res.partner",
res_id: this.lineData.partner_id.id,
views: [[false, "form"]],
target: "current",
});
}
openLineInfoPopOver() {
if (this.lineInfoPopOver.isOpen || !this.showLineInfo) {
this.lineInfoPopOver.close();
} else {
this.lineInfoPopOver.open(this.lineInfoRef.el, {
statementLineData: this.statementLineData,
lineData: this.lineData,
exchangeMove: this.exchangeMove,
isPartiallyReconciled: this.isPartiallyReconciled,
});
}
}
async deleteTax(taxIndex) {
const taxChanged = this.lineDataTaxIds[taxIndex];
await this.orm.call("account.bank.statement.line", "edit_reconcile_line", [
this.statementLineData.id,
this.lineData.id,
{ tax_ids: [[x2ManyCommands.UNLINK, taxChanged.data.id]] },
]);
this.props.statementLine.load();
this.bankReconciliation.reloadChatter();
}
// -----------------------------------------------------------------------------
// GETTER
// -----------------------------------------------------------------------------
get statementLineData() {
return this.props.statementLine.data;
}
get lineData() {
return this.props.line;
}
get reconciledLineId() {
return this.lineData.reconciled_lines_ids.records.length === 1
? this.lineData.reconciled_lines_ids.records[0].data
: null;
}
get reconciledLineExcludingExchangeDiffId() {
return this.lineData.reconciled_lines_excluding_exchange_diff_ids.records.length === 1
? this.lineData.reconciled_lines_excluding_exchange_diff_ids.records[0].data
: null;
}
get moveData() {
return (
this.reconciledLineId?.move_id ||
this.reconciledLineExcludingExchangeDiffId?.move_id ||
this.lineData.move_id
);
}
get isPartiallyReconciled() {
if (!this.reconciledLineId) {
return false;
}
return !this.reconciledLineId.full_reconcile_id?.id;
}
get hasDifferentCurrencies() {
return this.lineData.currency_id.id !== this.statementLineData.currency_id.id;
}
get formattedAmountCurrencyOfLine() {
return formatMonetary(this.lineData.amount_currency, {
currencyId: this.lineData.currency_id.id,
});
}
get formattedAmountCurrencyOfStatementLine() {
return formatMonetary(this.lineData.amount_currency, {
currencyId: this.statementLineData.currency_id.id,
});
}
get exchangeMove() {
return (
this.lineData.matched_debit_ids.records[0]?.data.exchange_move_id ||
this.lineData.matched_credit_ids.records[0]?.data.exchange_move_id
);
}
get showLineInfo() {
return this.isPartiallyReconciled || this.exchangeMove?.id;
}
get isTaxLine() {
return this.lineData.tax_line_id;
}
get lineDataTaxIds() {
return this.lineData.tax_ids.records;
}
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="fusion_accounting_bank_rec.BankRecLineToReconcile">
<div class="o_row" t-on-click.stop="onClickLine">
<div class="o_line_name d-flex align-items-center gap-1 text-truncate">
<a href="#" class="text-truncate fw-bold" t-esc="lineData.partner_id.display_name" t-on-click.stop="openPartner" role="button" t-att-title="lineData.partner_id.display_name" t-if="lineData.partner_id"/>
<span t-esc="lineData.account_id.display_name" class="text-truncate" t-att-class="lineData.partner_id ? 'ms-1' : undefined"/>
<div class="d-flex gap-2">
<t t-foreach="lineDataTaxIds" 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">
<span class="o_tag_badge_text text-truncate" t-esc="tax_id.data.display_name"/>
<i t-on-click.stop="() => this.deleteTax(tax_id_index)" class="ps-1 opacity-100-hover opacity-75 oi oi-close"/>
</div>
</t>
</div>
</div>
<span t-if="!!moveData.display_name and moveData.id !== statementLineData.move_id.id" class="d-none d-md-inline">
<a t-on-click.stop="openMove" href="#">
<t t-esc="moveData.display_name"/>
</a>
</span>
<div class="o_line_amount d-flex align-items-center justify-content-between">
<span class="text-muted w-50 text-end" t-if="hasDifferentCurrencies">
<span t-att-class="{'btn btn-link p-0' : showLineInfo}" t-ref="line-info-ref" t-on-click.stop="openLineInfoPopOver">
<i t-if="showLineInfo" class="fa fa-info-circle me-2"/>
<t t-out="formattedAmountCurrencyOfLine"/>
</span>
</span>
<span class="text-end w-100" t-if="!hasDifferentCurrencies">
<span t-att-class="{'btn btn-link p-0' : showLineInfo}" t-ref="line-info-ref" t-on-click.stop="openLineInfoPopOver">
<i t-if="showLineInfo" class="fa fa-info-circle me-2"/>
<t t-out="formattedAmountCurrencyOfStatementLine"/>
</span>
</span>
</div>
<div class="o_line_to_reconcile_button d-none d-md-flex justify-content-end gap-2">
<button t-if="lineData.has_invalid_analytics" class="btn btn-link p-0 text-600" t-on-click.stop="toggleEditLine">
<i class="fa fa-exclamation-triangle text-warning" data-tooltip="This line has invalid analytic distribution"/>
</button>
<button t-if="!lineData.has_invalid_analytics" class="btn btn-link p-0 text-600" t-on-click.stop="toggleEditLine">
<i class="fa fa-pencil"/>
</button>
<button class="btn btn-link p-0 text-600" t-on-click.stop="deleteLine" t-if="!isTaxLine">
<i class="fa fa-trash"/>
</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,88 @@
/** @odoo-module **/
/**
* Mirrored from `account_accountant/.../list_view/list.js`.
* Phase 1 structural parity.
*
* NOTE: Enterprise extends `AttachmentPreviewListController` from
* `account_accountant/static/src/components/attachment_preview_list_view/...`.
* That helper isn't part of Phase 1 scope; we extend the base
* `ListController` directly and TODO-flag the methods that depend on
* the previewer state. Behaviour will be wired up in fusion-only
* Tasks 34-36 alongside the right-pane preview integration.
*/
import { ListController } from "@web/views/list/list_controller";
import { ListRenderer } from "@web/views/list/list_renderer";
import { registry } from "@web/core/registry";
import { listView } from "@web/views/list/list_view";
import { useChildSubEnv } from "@odoo/owl";
import { makeActiveField } from "@web/model/relational_model/utils";
export class BankRecListController extends ListController {
setup() {
super.setup(...arguments);
this.skipKanbanRestore = {};
useChildSubEnv({
skipKanbanRestoreNeeded: (stLineId) => this.skipKanbanRestore[stLineId],
});
}
/**
* Don't allow bank_rec_form to be restored with previous values since
* the statement line has changed.
*/
async onRecordSaved(record) {
this.skipKanbanRestore[record.resId] = true;
return super.onRecordSaved(...arguments);
}
get previewerStorageKey() {
return "fusion.statement_line_pdf_previewer_hidden";
}
get modelParams() {
const params = super.modelParams;
params.config.activeFields.bank_statement_attachment_ids = makeActiveField();
params.config.activeFields.bank_statement_attachment_ids.related = {
fields: {
mimetype: { name: "mimetype", type: "char" },
},
activeFields: {
mimetype: makeActiveField(),
},
};
params.config.activeFields.attachment_ids = makeActiveField();
params.config.activeFields.attachment_ids.related = {
fields: {
mimetype: { name: "mimetype", type: "char" },
},
activeFields: {
mimetype: makeActiveField(),
},
};
return params;
}
/**
* TODO(fusion task 34-36): wire up attachment preview pane.
* Enterprise sets `this.attachmentPreviewState.selectedRecord` and
* calls `this.setThread(...)` on the AttachmentPreviewListController.
* Until that helper is mirrored, this is a no-op.
*/
async setSelectedRecord(/* accountBankStatementLineData */) {
return;
}
}
export class BankRecListRenderer extends ListRenderer {}
export const bankRecListView = {
...listView,
Controller: BankRecListController,
Renderer: BankRecListRenderer,
};
registry.category("views").add("fusion_bank_rec_list", bankRecListView);

View File

@@ -0,0 +1,30 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../list_view/list_view_many2one_multi_edit.js`.
* Phase 1 structural parity.
*/
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
import { buildM2OFieldDescription, Many2OneField } from "@web/views/fields/many2one/many2one_field";
export class BankRecMany2OneMultiID extends Component {
static template = "fusion_accounting_bank_rec.BankRecMany2OneMultiID";
static components = { Many2One };
static props = { ...Many2OneField.props };
get m2oProps() {
const props = computeM2OProps(this.props);
if (this.props.record.selected && this.props.record.model.multiEdit) {
props.context.active_ids = this.env.model.root.selection.map((r) => r.resId);
}
return props;
}
}
registry.category("fields").add("fusion_bank_rec_list_many2one_multi_id", {
...buildM2OFieldDescription(BankRecMany2OneMultiID),
});

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="fusion_accounting_bank_rec.BankRecMany2OneMultiID">
<Many2One t-props="m2oProps"/>
</t>
</templates>

View File

@@ -0,0 +1,41 @@
/** @odoo-module **/
/**
* Mirrored from `account_accountant/.../quick_create/quick_create.js`.
* Phase 1 structural parity.
*/
import {
KanbanRecordQuickCreate,
KanbanQuickCreateController,
} from "@web/views/kanban/kanban_record_quick_create";
export class BankRecQuickCreateController extends KanbanQuickCreateController {
static template = "fusion_accounting_bank_rec.BankRecQuickCreateController";
}
export class BankRecQuickCreate extends KanbanRecordQuickCreate {
static template = "fusion_accounting_bank_rec.BankRecQuickCreate";
static props = {
...KanbanRecordQuickCreate.props,
resModel: { type: String },
context: { type: Object },
group: { type: Object, optional: true },
};
static components = { BankRecQuickCreateController };
/**
* Overridden — quick-create flow always works against a synthetic group
* built from the resModel + context props (rather than relying on a
* caller-provided group), matching Enterprise behaviour.
*/
async getQuickCreateProps(props) {
await super.getQuickCreateProps({
...props,
group: {
resModel: props.resModel,
context: props.context,
},
});
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BankRecQuickCreate">
<BankRecQuickCreateController t-if="state.isLoaded" t-props="quickCreateProps"/>
</t>
<t t-name="fusion_accounting_bank_rec.BankRecQuickCreateController">
<div class="o_fusion_bank_reconciliation_quick_create o_kanban_record" t-ref="root">
<t t-component="props.Renderer" record="model.root" Compiler="props.Compiler" archInfo="props.archInfo"/>
<div class="d-flex gap-1 button_group p-2">
<button class="btn btn-primary o_kanban_add" t-on-click="() => this.validate('add')" data-hotkey="s">
Add &amp; New
</button>
<button class="btn btn-secondary o_kanban_edit" t-on-click="() => this.validate('add_close')" data-hotkey="shift+s">
Add &amp; Close
</button>
<button class="btn btn-secondary o_kanban_cancel" t-on-click="() => this.cancel(true)" data-hotkey="d">
Discard
</button>
</div>
</div>
</t>
</templates>

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -0,0 +1,90 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../search_dialog/search_dialog.js`.
* Phase 1 structural parity.
*/
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
import { formatMonetary } from "@web/views/fields/formatters";
import { useService } from "@web/core/utils/hooks";
const { DateTime } = luxon;
export class BankRecSelectCreateDialog extends SelectCreateDialog {
static template = "fusion_accounting_bank_rec.BankRecSelectCreateDialog";
static props = {
...SelectCreateDialog.props,
suspenseAccountLine: Object,
reference: String,
date: DateTime,
size: { type: String, optional: true },
};
static defaultProps = {
...SelectCreateDialog.defaultProps,
size: "lg",
};
setup() {
super.setup();
this.orm = useService("orm");
this.ui = useService("ui");
this.state.remainingAmount = this.suspenseAccountLine.amount_currency;
this.state.hideRemainingAmount = false;
this.baseViewProps.onSelectionChanged = (resIds, selectedLines) => {
this.state.resIds = resIds;
this.changeInSelectedMoveLine(selectedLines);
};
}
async changeInSelectedMoveLine(selectedLines) {
if (!selectedLines?.length) {
this.state.remainingAmount = this.suspenseAccountLine.amount_currency;
return;
}
let selectedLinesSum = 0;
this.state.hideRemainingAmount = false;
if (
this.suspenseAccountLine.currency_id.id !==
this.suspenseAccountLine.company_currency_id.id
) {
const selectedLineCurrencies = selectedLines.map((line) => line.currency_id);
if (
selectedLineCurrencies.length !== 1 ||
(selectedLineCurrencies.length === 1 &&
selectedLineCurrencies[0] !== this.suspenseAccountLine.currency_id.id)
) {
this.state.hideRemainingAmount = true;
return;
} else {
selectedLinesSum = selectedLines.reduce((sum, line) => {
return sum + line.amount_residual_currency;
}, 0);
}
} else {
selectedLinesSum = selectedLines.reduce((sum, line) => {
return sum + line.amount_residual;
}, 0);
}
this.state.remainingAmount = this.suspenseAccountLine.amount_currency + selectedLinesSum;
}
get suspenseAccountLine() {
return this.props?.suspenseAccountLine;
}
get remainingAmountFormatted() {
return formatMonetary(this.state.remainingAmount, {
currencyId: this.suspenseAccountLine.currency_id.id,
});
}
get formattedStatementLineDate() {
return this.props.date?.toLocaleString();
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BankRecSelectCreateDialog" t-inherit="web.SelectCreateDialog" t-inherit-mode="primary">
<xpath expr="//Dialog" position="attributes">
<attribute name="size">props.size</attribute>
</xpath>
<xpath expr="//button[hasclass('o_form_button_cancel')]" position="after">
<div t-if="!this.ui.isSmall" class="d-flex align-items-center flex-grow-1 flex-shrink-1 flex-basis-0 gap-2 min-w-0 justify-content-between" name="bank_reconciliation_info">
<span t-esc="formattedStatementLineDate"/>
<div class="text-truncate" t-esc="props.reference"/>
<div class="text-nowrap text-end" name="remaining_amount">
<span class="text-muted">Balance: </span>
<t t-if="!this.state.hideRemainingAmount" t-esc="remainingAmountFormatted"/>
<t t-else=""> / </t>
</div>
</div>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,77 @@
/** @odoo-module **/
/**
* Mirrored from
* `account_accountant/.../search_dialog/search_dialog_list.js`.
* Phase 1 structural parity.
*/
import { ListController } from "@web/views/list/list_controller";
import { ListRenderer } from "@web/views/list/list_renderer";
import { listView } from "@web/views/list/list_view";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class BankRecReconcileDialogListController extends ListController {
setup() {
super.setup();
this.orm = useService("orm");
}
async onSelectionChanged() {
const resIds = await this.model.root.getResIds(true);
if (!resIds.length) {
this.props.onSelectionChanged(resIds, []);
}
let selectedLines;
// When being in the list view with more elements than the limit and
// doing a select all, the user can select more elements than the
// limit. In this case the isDomainSelected is True.
if (this.isDomainSelected) {
const { resModel, context } = this.model.root._config;
selectedLines = await this.orm.read(
resModel,
resIds,
["amount_residual", "amount_residual_currency", "currency_id"],
{ context }
);
} else {
selectedLines = Object.values(this.model.root.records)
.filter((record) => resIds.includes(record._config.resId))
.map((record) => {
const data = record.data;
return {
amount_residual: data.amount_residual,
amount_residual_currency: data.amount_residual_currency,
currency_id: data.currency_id.id,
};
});
}
this.props.onSelectionChanged(resIds, selectedLines);
}
}
export class BankRecReconcileDialogListRenderer extends ListRenderer {
static template = "fusion_accounting_bank_rec.BankRecReconcileDialogListRenderer";
static recordRowTemplate =
"fusion_accounting_bank_rec.BankRecReconcileDialogListRenderer.RecordRow";
async openMoveView(record) {
this.env.services.action.doAction({
type: "ir.actions.act_window",
res_model: "account.move",
res_id: record.data.move_id.id,
views: [[false, "form"]],
target: "current",
});
}
}
export const bankRecReconcileDialogListRenderer = {
...listView,
Renderer: BankRecReconcileDialogListRenderer,
Controller: BankRecReconcileDialogListController,
};
registry.category("views").add("fusion_bank_rec_dialog_list", bankRecReconcileDialogListRenderer);

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_bank_rec.BankRecReconcileDialogListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
<xpath expr="//th[@t-if='hasOpenFormViewColumn']" position="replace">
<th class="o_list_open_form_view w-print-0 p-print-0"/>
</xpath>
</t>
<t t-name="fusion_accounting_bank_rec.BankRecReconcileDialogListRenderer.RecordRow" t-inherit="web.ListRenderer.RecordRow" t-inherit-mode="primary">
<xpath expr="//t[@t-if='hasOpenFormViewColumn']" position="replace">
<td class="o_list_record_open_form_view w-print-0 p-print-0 text-center"
t-custom-click.stop="() => this.openMoveView(record)"
>
<button class="btn btn-link align-top text-end"
name="Open in form view"
aria-label="Open in form view"
>View</button>
</td>
</xpath>
</t>
</templates>

View File

@@ -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;
}
}

View File

@@ -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 &lt; 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>

View File

@@ -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);
}
}
}

View File

@@ -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>

View File

@@ -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"));
}