diff --git a/addons/spreadsheet_account/__init__.py b/addons/spreadsheet_account/__init__.py
new file mode 100644
index 00000000000..0650744f6bc
--- /dev/null
+++ b/addons/spreadsheet_account/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/addons/spreadsheet_account/__manifest__.py b/addons/spreadsheet_account/__manifest__.py
new file mode 100644
index 00000000000..8abbeb69ded
--- /dev/null
+++ b/addons/spreadsheet_account/__manifest__.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+{
+ 'name': "Spreadsheet Accounting Formulas",
+ 'version': '1.0',
+ 'category': 'Accounting',
+ 'summary': 'Spreadsheet Accounting formulas',
+ 'description': 'Spreadsheet Accounting formulas',
+ 'depends': ['spreadsheet', 'account'],
+ 'data': [],
+ 'installable': True,
+ 'auto_install': True,
+ 'license': 'LGPL-3',
+ 'assets': {
+ 'spreadsheet.o_spreadsheet': [
+ (
+ 'after',
+ 'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js',
+ 'spreadsheet_account/static/src/**/*.js'
+ ),
+ ],
+ 'web.qunit_suite_tests': [
+ 'spreadsheet_account/static/tests/**/*.js',
+ ],
+ }
+}
diff --git a/addons/spreadsheet_account/models/__init__.py b/addons/spreadsheet_account/models/__init__.py
new file mode 100644
index 00000000000..3b54bd6a98d
--- /dev/null
+++ b/addons/spreadsheet_account/models/__init__.py
@@ -0,0 +1,2 @@
+from . import account
+from . import res_company
diff --git a/addons/spreadsheet_account/models/account.py b/addons/spreadsheet_account/models/account.py
new file mode 100644
index 00000000000..68d975235a1
--- /dev/null
+++ b/addons/spreadsheet_account/models/account.py
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import date
+from dateutil.relativedelta import relativedelta
+
+from odoo import models, api, _
+from odoo.osv import expression
+from odoo.tools import date_utils
+
+
+class AccountMove(models.Model):
+ _inherit = "account.account"
+
+ @api.model
+ def _get_date_period_boundaries(self, date_period, company):
+ period_type = date_period["range_type"]
+ year = date_period.get("year")
+ month = date_period.get("month")
+ quarter = date_period.get("quarter")
+ day = date_period.get("day")
+ if period_type == "year":
+ fiscal_day = company.fiscalyear_last_day
+ fiscal_month = int(company.fiscalyear_last_month)
+ if not (fiscal_day == 31 and fiscal_month == 12):
+ year += 1
+ current = date(year, fiscal_month, fiscal_day)
+ start, end = date_utils.get_fiscal_year(current, fiscal_day, fiscal_month)
+ elif period_type == "month":
+ start = date(year, month, 1)
+ end = start + relativedelta(months=1, days=-1)
+ elif period_type == "quarter":
+ first_month = quarter * 3 - 2
+ start = date(year, first_month, 1)
+ end = start + relativedelta(months=3, days=-1)
+ elif period_type == "day":
+ fiscal_day = company.fiscalyear_last_day
+ fiscal_month = int(company.fiscalyear_last_month)
+ end = date(year, month, day)
+ start, _ = date_utils.get_fiscal_year(end, fiscal_day, fiscal_month)
+ return start, end
+
+ def _build_spreadsheet_formula_domain(self, formula_params):
+ code = formula_params["code"]
+ if not code:
+ return expression.FALSE_DOMAIN
+ company_id = formula_params["company_id"] or self.env.company.id
+ company = self.env["res.company"].browse(company_id)
+ start, end = self._get_date_period_boundaries(
+ formula_params["date_range"], company
+ )
+
+ base_domain = [
+ ("account_id.code", "=like", f"{code}%"),
+ ("company_id", "=", company_id),
+ ]
+ if formula_params["include_unposted"]:
+ base_domain = expression.AND(
+ [base_domain, [("move_id.state", "!=", "cancel")]]
+ )
+ else:
+ base_domain = expression.AND(
+ [base_domain, [("move_id.state", "=", "posted")]]
+ )
+
+ balance_domain = expression.AND(
+ [
+ base_domain,
+ [
+ ("account_id.include_initial_balance", "=", True),
+ ("date", "<=", end),
+ ],
+ ]
+ )
+ result_domain = expression.AND(
+ [
+ base_domain,
+ [
+ ("account_id.include_initial_balance", "=", False),
+ ("date", ">=", start),
+ ("date", "<=", end),
+ ],
+ ]
+ )
+ return expression.OR([balance_domain, result_domain])
+
+ @api.model
+ def spreadsheet_move_line_action(self, args):
+ domain = self._build_spreadsheet_formula_domain(args)
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": "account.move.line",
+ "view_mode": "list",
+ "views": [[False, "list"]],
+ "target": "current",
+ "domain": domain,
+ "name": _("Journal items for account prefix %s", args["code"]),
+ }
+
+ @api.model
+ def spreadsheet_fetch_debit_credit(self, args_list):
+ """Fetch data for ODOO.CREDIT, ODOO.DEBIT and ODOO.BALANCE formulas
+ The input list looks like this:
+ [{
+ date_range: {
+ range_type: "year"
+ year: int
+ },
+ company_id: int
+ code: str
+ include_unposted: bool
+ }]
+ """
+ results = []
+ for args in args_list:
+ company_id = args["company_id"] or self.env.company.id
+ domain = self._build_spreadsheet_formula_domain(args)
+ # remove this when _search always returns a Query object
+ if domain == expression.FALSE_DOMAIN:
+ results.append({"credit": 0, "debit": 0})
+ continue
+ MoveLines = self.env["account.move.line"].with_company(company_id)
+ query = MoveLines._search(domain)
+ query.order = None
+ query_str, params = query.select(
+ "SUM(debit) AS debit", "SUM(credit) AS credit"
+ )
+ self.env.cr.execute(query_str, params)
+ line_values = self.env.cr.dictfetchone()
+ results.append(
+ {
+ "credit": line_values["credit"] or 0,
+ "debit": line_values["debit"] or 0,
+ }
+ )
+
+ return results
+
+ @api.model
+ def get_account_group(self, account_types):
+ data = self._read_group(
+ [("account_type", "in", account_types), ("company_id", "=", self.env.company.id)],
+ ["code:array_agg"],
+ ["account_type"],
+ )
+ mapped = {group["account_type"]: group["code"] for group in data}
+ return [mapped.get(account_type, []) for account_type in account_types]
diff --git a/addons/spreadsheet_account/models/res_company.py b/addons/spreadsheet_account/models/res_company.py
new file mode 100644
index 00000000000..cd7d049208e
--- /dev/null
+++ b/addons/spreadsheet_account/models/res_company.py
@@ -0,0 +1,29 @@
+from odoo import models, api, fields
+
+from odoo.tools import date_utils
+
+
+class ResCompany(models.Model):
+ _inherit = "res.company"
+
+ @api.model
+ def get_fiscal_dates(self, payload):
+ companies = self.env["res.company"].browse(
+ data["company_id"] or self.env.company.id for data in payload
+ )
+ existing_companies = companies.exists()
+ # prefetch both fields
+ existing_companies.read(["fiscalyear_last_day", "fiscalyear_last_month"])
+ results = []
+
+ for data, company in zip(payload, companies):
+ if company not in existing_companies:
+ results.append(False)
+ continue
+ start, end = date_utils.get_fiscal_year(
+ fields.Date.to_date(data["date"]),
+ day=company.fiscalyear_last_day,
+ month=int(company.fiscalyear_last_month),
+ )
+ results.append({"start": start, "end": end})
+ return results
diff --git a/addons/spreadsheet_account/static/src/accounting_datasource.js b/addons/spreadsheet_account/static/src/accounting_datasource.js
new file mode 100644
index 00000000000..4f3200d6534
--- /dev/null
+++ b/addons/spreadsheet_account/static/src/accounting_datasource.js
@@ -0,0 +1,135 @@
+/** @odoo-module */
+import { camelToSnakeObject, sum, toServerDateString } from "@spreadsheet/helpers/helpers";
+import { _t } from "@web/core/l10n/translation";
+import { sprintf } from "@web/core/utils/strings";
+
+import { ServerData } from "@spreadsheet/data_sources/server_data";
+
+/**
+ * @typedef {import("./accounting_functions").DateRange} DateRange
+ */
+
+export class AccountingDataSource {
+ constructor(services) {
+ this.serverData = new ServerData(services.orm, {
+ whenDataIsFetched: () => services.notify(),
+ });
+ }
+
+ /**
+ * Gets the total credit for a given account code prefix
+ * @param {string[]} codes prefixes of the accounts codes
+ * @param {DateRange} dateRange start date of the period to look
+ * @param {number} offset end date of the period to look
+ * @param {number} companyId specific company to target
+ * @param {boolean} includeUnposted wether or not select unposted entries
+ * @returns {number | undefined}
+ */
+ getCredit(codes, dateRange, offset, companyId, includeUnposted) {
+ const data = this._fetchAccountData(codes, dateRange, offset, companyId, includeUnposted);
+ return data.credit;
+ }
+
+ /**
+ * Gets the total debit for a given account code prefix
+ * @param {string[]} codes prefixes of the accounts codes
+ * @param {DateRange} dateRange start date of the period to look
+ * @param {number} offset end date of the period to look
+ * @param {number} companyId specific company to target
+ * @param {boolean} includeUnposted wether or not select unposted entries
+ * @returns {number | undefined}
+ */
+ getDebit(codes, dateRange, offset, companyId, includeUnposted) {
+ const data = this._fetchAccountData(codes, dateRange, offset, companyId, includeUnposted);
+ return data.debit;
+ }
+
+ /**
+ * @param {Date} date
+ * @param {number | null} companyId
+ * @returns {string}
+ */
+ getFiscalStartDate(date, companyId) {
+ return this._fetchCompanyData(date, companyId).start;
+ }
+
+ /**
+ * @param {Date} date
+ * @param {number | null} companyId
+ * @returns {string}
+ */
+ getFiscalEndDate(date, companyId) {
+ return this._fetchCompanyData(date, companyId).end;
+ }
+
+ /**
+ * @param {string} accountType
+ * @returns {string[]}
+ */
+ getAccountGroupCodes(accountType) {
+ return this.serverData.batch.get("account.account", "get_account_group", accountType);
+ }
+
+ /**
+ * Fetch the account information (credit/debit) for a given account code
+ * @private
+ * @param {string[]} codes prefix of the accounts' codes
+ * @param {DateRange} dateRange start date of the period to look
+ * @param {number} offset end date of the period to look
+ * @param {number | null} companyId specific companyId to target
+ * @param {boolean} includeUnposted wether or not select unposted entries
+ * @returns {{ debit: number, credit: number }}
+ */
+ _fetchAccountData(codes, dateRange, offset, companyId, includeUnposted) {
+ dateRange.year += offset;
+ // Excel dates start at 1899-12-30, we should not support date ranges
+ // that do not cover dates prior to it.
+ // Unfortunately, this check needs to be done right before the server
+ // call as a date to low (year <= 1) can raise an error server side.
+ if (dateRange.year < 1900) {
+ throw new Error(sprintf(_t("%s is not a valid year."), dateRange.year));
+ }
+ const results = [];
+ let error = undefined;
+ // If some payload were to raise an error, we still need to process the others
+ // to make sure they are part of the next batch call.
+ for (const code of codes) {
+ try {
+ const result = this.serverData.batch.get(
+ "account.account",
+ "spreadsheet_fetch_debit_credit",
+ camelToSnakeObject({ dateRange, code, companyId, includeUnposted })
+ );
+ results.push(result);
+ } catch (err) {
+ error = error || err;
+ }
+ }
+ if (error) {
+ throw error;
+ }
+ return {
+ debit: sum(results.map((values) => values.debit)),
+ credit: sum(results.map((values) => values.credit)),
+ };
+ }
+
+ /**
+ * Fetch the start and end date of the fiscal year enclosing a given date
+ * Defaults on the current user company if not provided
+ * @private
+ * @param {Date} date
+ * @param {number | null} companyId
+ * @returns {{start: string, end: string}}
+ */
+ _fetchCompanyData(date, companyId) {
+ const result = this.serverData.batch.get("res.company", "get_fiscal_dates", {
+ date: toServerDateString(date),
+ company_id: companyId,
+ });
+ if (result === false) {
+ throw new Error(_t("The company fiscal year could not be found."));
+ }
+ return result;
+ }
+}
diff --git a/addons/spreadsheet_account/static/src/accounting_functions.js b/addons/spreadsheet_account/static/src/accounting_functions.js
new file mode 100644
index 00000000000..6bac2e25176
--- /dev/null
+++ b/addons/spreadsheet_account/static/src/accounting_functions.js
@@ -0,0 +1,288 @@
+/** @odoo-module **/
+
+import { _t } from "@web/core/l10n/translation";
+import { sprintf } from "@web/core/utils/strings";
+
+import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
+const { functionRegistry } = spreadsheet.registries;
+const { args, toBoolean, toString, toNumber, toJsDate } = spreadsheet.helpers;
+
+const QuarterRegexp = /^q([1-4])\/(\d{4})$/i;
+const MonthRegexp = /^0?([1-9]|1[0-2])\/(\d{4})$/i;
+
+/**
+ * @typedef {Object} YearDateRange
+ * @property {"year"} rangeType
+ * @property {number} year
+ */
+
+/**
+ * @typedef {Object} QuarterDateRange
+ * @property {"quarter"} rangeType
+ * @property {number} year
+ * @property {number} quarter
+ */
+
+/**
+ * @typedef {Object} MonthDateRange
+ * @property {"month"} rangeType
+ * @property {number} year
+ * @property {number} month
+ */
+
+/**
+ * @typedef {Object} DayDateRange
+ * @property {"day"} rangeType
+ * @property {number} year
+ * @property {number} month
+ * @property {number} day
+ */
+
+/**
+ * @typedef {YearDateRange | QuarterDateRange | MonthDateRange | DayDateRange} DateRange
+ */
+
+/**
+ * @param {string} dateRange
+ * @returns {QuarterDateRange | undefined}
+ */
+function parseAccountingQuarter(dateRange) {
+ const found = dateRange.match(QuarterRegexp);
+ return found
+ ? {
+ rangeType: "quarter",
+ year: toNumber(found[2]),
+ quarter: toNumber(found[1]),
+ }
+ : undefined;
+}
+
+/**
+ * @param {string} dateRange
+ * @returns {MonthDateRange | undefined}
+ */
+function parseAccountingMonth(dateRange) {
+ const found = dateRange.match(MonthRegexp);
+ return found
+ ? {
+ rangeType: "month",
+ year: toNumber(found[2]),
+ month: toNumber(found[1]),
+ }
+ : undefined;
+}
+
+/**
+ * @param {string} dateRange
+ * @returns {YearDateRange | undefined}
+ */
+function parseAccountingYear(dateRange) {
+ const dateNumber = toNumber(dateRange);
+ // This allows a bit of flexibility for the user if they were to input a
+ // numeric value instead of a year.
+ // Users won't need to fetch accounting info for year 3000 before a long time
+ // And the numeric value 3000 corresponds to 18th march 1908, so it's not an
+ //issue to prevent them from fetching accounting data prior to that date.
+ if (dateNumber < 3000) {
+ return { rangeType: "year", year: dateNumber };
+ }
+ return undefined;
+}
+
+/**
+ * @param {string} dateRange
+ * @returns {DayDateRange}
+ */
+function parseAccountingDay(dateRange) {
+ const dateNumber = toNumber(dateRange);
+ return {
+ rangeType: "day",
+ year: functionRegistry.get("YEAR").compute(dateNumber),
+ month: functionRegistry.get("MONTH").compute(dateNumber),
+ day: functionRegistry.get("DAY").compute(dateNumber),
+ };
+}
+
+/**
+ * @param {string | number} dateRange
+ * @returns {DateRange}
+ */
+export function parseAccountingDate(dateRange) {
+ try {
+ dateRange = toString(dateRange).trim();
+ return (
+ parseAccountingQuarter(dateRange) ||
+ parseAccountingMonth(dateRange) ||
+ parseAccountingYear(dateRange) ||
+ parseAccountingDay(dateRange)
+ );
+ } catch (_) {
+ throw new Error(sprintf(_t(`'%s' is not a valid period. Supported formats are "21/12/2022", "Q1/2022", "12/2022", and "2022".`), dateRange));
+ }
+}
+
+const ODOO_FIN_ARGS = `
+ account_codes (string) ${_t("The prefix of the accounts.")}
+ date_range (string, date) ${_t(`The date range. Supported formats are "21/12/2022", "Q1/2022", "12/2022", and "2022".`)}
+ offset (number, default=0) ${_t("Year offset applied to date_range.")}
+ company_id (number, optional) ${_t("The company to target (Advanced).")}
+ include_unposted (boolean, default=TRUE) ${_t("Set to TRUE to include unposted entries.")}
+`
+
+functionRegistry.add("ODOO.CREDIT", {
+ description: _t("Get the total credit for the specified account(s) and period."),
+ args: args(ODOO_FIN_ARGS),
+ returns: ["NUMBER"],
+ compute: function (
+ accountCodes,
+ dateRange,
+ offset = 0,
+ companyId = null,
+ includeUnposted = true
+ ) {
+ accountCodes = toString(accountCodes).split(",").sort();
+ offset = toNumber(offset);
+ dateRange = parseAccountingDate(dateRange);
+ includeUnposted = toBoolean(includeUnposted);
+ return this.getters.getAccountPrefixCredit(
+ accountCodes,
+ dateRange,
+ offset,
+ companyId,
+ includeUnposted
+ );
+ },
+ computeFormat: function (
+ accountCodes,
+ dateRange,
+ offset = 0,
+ companyId = null,
+ includeUnposted = true
+ ) {
+ return this.getters.getCompanyCurrencyFormat(companyId && companyId.value) || "#,##0.00";
+ },
+});
+
+functionRegistry.add("ODOO.DEBIT", {
+ description: _t("Get the total debit for the specified account(s) and period."),
+ args: args(ODOO_FIN_ARGS),
+ returns: ["NUMBER"],
+ compute: function (
+ accountCodes,
+ dateRange,
+ offset = 0,
+ companyId = null,
+ includeUnposted = true
+ ) {
+ accountCodes = toString(accountCodes).split(",").sort();
+ offset = toNumber(offset);
+ dateRange = parseAccountingDate(dateRange);
+ includeUnposted = toBoolean(includeUnposted);
+ return this.getters.getAccountPrefixDebit(
+ accountCodes,
+ dateRange,
+ offset,
+ companyId,
+ includeUnposted
+ );
+ },
+ computeFormat: function (
+ accountCodes,
+ dateRange,
+ offset = 0,
+ companyId = null,
+ includeUnposted = true
+ ) {
+ return this.getters.getCompanyCurrencyFormat(companyId && companyId.value) || "#,##0.00";
+ },
+});
+
+functionRegistry.add("ODOO.BALANCE", {
+ description: _t("Get the total balance for the specified account(s) and period."),
+ args: args(ODOO_FIN_ARGS),
+ returns: ["NUMBER"],
+ compute: function (
+ accountCodes,
+ dateRange,
+ offset = 0,
+ companyId = null,
+ includeUnposted = true
+ ) {
+ accountCodes = toString(accountCodes).split(",").sort();
+ offset = toNumber(offset);
+ dateRange = parseAccountingDate(dateRange);
+ includeUnposted = toBoolean(includeUnposted);
+ return (
+ this.getters.getAccountPrefixDebit(
+ accountCodes,
+ dateRange,
+ offset,
+ companyId,
+ includeUnposted
+ ) -
+ this.getters.getAccountPrefixCredit(
+ accountCodes,
+ dateRange,
+ offset,
+ companyId,
+ includeUnposted
+ )
+ );
+ },
+ computeFormat: function (
+ accountCodes,
+ dateRange,
+ offset = 0,
+ companyId = null,
+ includeUnposted = true
+ ) {
+ return this.getters.getCompanyCurrencyFormat(companyId && companyId.value) || "#,##0.00";
+ },
+});
+
+functionRegistry.add("ODOO.FISCALYEAR.START", {
+ description: _t("Returns the starting date of the fiscal year encompassing the provided date."),
+ args: args(`
+ date (date) ${_t("Reference date.")}
+ company_id (number, optional) ${_t("The company.")}
+ `),
+ returns: ["NUMBER"],
+ computeFormat: () => "m/d/yyyy",
+ compute: function (date, companyId = null) {
+ const startDate = this.getters.getFiscalStartDate(
+ toJsDate(date),
+ companyId === null ? null : toNumber(companyId)
+ );
+ return toNumber(startDate);
+ },
+});
+
+functionRegistry.add("ODOO.FISCALYEAR.END", {
+ description: _t("Returns the ending date of the fiscal year encompassing the provided date."),
+ args: args(`
+ date (date) ${_t("Reference date.")}
+ company_id (number, optional) ${_t("The company.")}
+ `),
+ returns: ["NUMBER"],
+ computeFormat: () => "m/d/yyyy",
+ compute: function (date, companyId = null) {
+ const endDate = this.getters.getFiscalEndDate(
+ toJsDate(date),
+ companyId === null ? null : toNumber(companyId)
+ );
+ return toNumber(endDate);
+ },
+});
+
+functionRegistry.add("ODOO.ACCOUNT.GROUP", {
+ description: _t("Returns the account ids of a given group."),
+ args: args(`
+ type (string) ${_t("Account type.")}
+ `),
+ returns: ["NUMBER"],
+ computeFormat: () => "m/d/yyyy",
+ compute: function (accountType) {
+ const accountTypes = this.getters.getAccountGroupCodes(toString(accountType));
+ return accountTypes.join(",");
+ },
+});
diff --git a/addons/spreadsheet_account/static/src/index.js b/addons/spreadsheet_account/static/src/index.js
new file mode 100644
index 00000000000..307e69aef85
--- /dev/null
+++ b/addons/spreadsheet_account/static/src/index.js
@@ -0,0 +1,45 @@
+/** @odoo-module */
+
+import { _lt } from "@web/core/l10n/translation";
+import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
+import AccountingPlugin from "./plugins/accounting_plugin";
+import { getFirstAccountFunction, getNumberOfAccountFormulas } from "./utils";
+import { parseAccountingDate } from "./accounting_functions";
+import { camelToSnakeObject } from "@spreadsheet/helpers/helpers";
+
+const { cellMenuRegistry, uiPluginRegistry } = spreadsheet.registries;
+const { astToFormula } = spreadsheet;
+const { toString, toBoolean } = spreadsheet.helpers;
+
+uiPluginRegistry.add("odooAccountingAggregates", AccountingPlugin);
+
+cellMenuRegistry.add("move_lines_see_records", {
+ name: _lt("See records"),
+ sequence: 176,
+ async action(env) {
+ const cell = env.model.getters.getActiveCell();
+ const { args } = getFirstAccountFunction(cell.content);
+ let [code, date_range, offset, companyId, includeUnposted] = args
+ .map(astToFormula)
+ .map((arg) => env.model.getters.evaluateFormula(arg));
+ code = toString(code);
+ const dateRange = parseAccountingDate(date_range);
+ dateRange.year += offset || 0;
+ companyId = companyId || null;
+ includeUnposted = toBoolean(includeUnposted);
+
+ const action = await env.services.orm.call(
+ "account.account",
+ "spreadsheet_move_line_action",
+ [camelToSnakeObject({ dateRange, companyId, code, includeUnposted })]
+ );
+ await env.services.action.doAction(action);
+ },
+ isVisible: (env) => {
+ const cell = env.model.getters.getActiveCell();
+ return (
+ cell && !cell.evaluated.error &&
+ cell.evaluated.value !== "" && getNumberOfAccountFormulas(cell.content) === 1
+ );
+ },
+});
diff --git a/addons/spreadsheet_account/static/src/plugins/accounting_plugin.js b/addons/spreadsheet_account/static/src/plugins/accounting_plugin.js
new file mode 100644
index 00000000000..aa689a0a3c1
--- /dev/null
+++ b/addons/spreadsheet_account/static/src/plugins/accounting_plugin.js
@@ -0,0 +1,102 @@
+/** @odoo-module */
+
+import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
+import { AccountingDataSource } from "../accounting_datasource";
+const DATA_SOURCE_ID = "ACCOUNTING_AGGREGATES";
+
+/**
+ * @typedef {import("../accounting_functions").DateRange} DateRange
+ */
+
+export default class AccountingPlugin extends spreadsheet.UIPlugin {
+ constructor(getters, history, dispatch, config) {
+ super(getters, history, dispatch, config);
+ this.dataSources = config.dataSources;
+ if (this.dataSources) {
+ this.dataSources.add(DATA_SOURCE_ID, AccountingDataSource);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Getters
+ // -------------------------------------------------------------------------
+
+ /**
+ * Gets the total balance for given account code prefix
+ * @param {string[]} codes prefixes of the accounts' codes
+ * @param {DateRange} dateRange start date of the period to look
+ * @param {number} offset end date of the period to look
+ * @param {number | null} companyId specific company to target
+ * @param {boolean} includeUnposted wether or not select unposted entries
+ * @returns {number}
+ */
+ getAccountPrefixCredit(codes, dateRange, offset, companyId, includeUnposted) {
+ return (
+ this.dataSources &&
+ this.dataSources
+ .get(DATA_SOURCE_ID)
+ .getCredit(codes, dateRange, offset, companyId, includeUnposted)
+ );
+ }
+
+ /**
+ * Gets the total balance for a given account code prefix
+ * @param {string[]} codes prefixes of the accounts codes
+ * @param {DateRange} dateRange start date of the period to look
+ * @param {number} offset end date of the period to look
+ * @param {number | null} companyId specific company to target
+ * @param {boolean} includeUnposted wether or not select unposted entries
+ * @returns {number}
+ */
+ getAccountPrefixDebit(codes, dateRange, offset, companyId, includeUnposted) {
+ return (
+ this.dataSources &&
+ this.dataSources
+ .get(DATA_SOURCE_ID)
+ .getDebit(codes, dateRange, offset, companyId, includeUnposted)
+ );
+ }
+
+ /**
+ * @param {Date} date Date included in the fiscal year
+ * @param {number | null} companyId specific company to target
+ * @returns {string | undefined}
+ */
+ getFiscalStartDate(date, companyId) {
+ return (
+ this.dataSources &&
+ this.dataSources.get(DATA_SOURCE_ID).getFiscalStartDate(date, companyId)
+ );
+ }
+
+ /**
+ * @param {Date} date Date included in the fiscal year
+ * @param {number | undefined} companyId specific company to target
+ * @returns {string | undefined}
+ */
+ getFiscalEndDate(date, companyId) {
+ return (
+ this.dataSources &&
+ this.dataSources.get(DATA_SOURCE_ID).getFiscalEndDate(date, companyId)
+ );
+ }
+
+ /**
+ * @param {string} accountType
+ * @returns {string[]}
+ */
+ getAccountGroupCodes(accountType) {
+ return (
+ this.dataSources &&
+ this.dataSources.get(DATA_SOURCE_ID).getAccountGroupCodes(accountType)
+ );
+ }
+}
+
+AccountingPlugin.getters = [
+ "getAccountPrefixCredit",
+ "getAccountPrefixDebit",
+ "getAccountGroupCodes",
+ "getFiscalStartDate",
+ "getFiscalEndDate",
+];
diff --git a/addons/spreadsheet_account/static/src/utils.js b/addons/spreadsheet_account/static/src/utils.js
new file mode 100644
index 00000000000..52cf00c34df
--- /dev/null
+++ b/addons/spreadsheet_account/static/src/utils.js
@@ -0,0 +1,26 @@
+/** @odoo-module **/
+import { getOdooFunctions } from "@spreadsheet/helpers/odoo_functions_helpers";
+
+/** @typedef {import("@spreadsheet/helpers/odoo_functions_helpers").OdooFunctionDescription} OdooFunctionDescription*/
+
+/**
+ * @param {string} formula
+ * @returns {number}
+ */
+export function getNumberOfAccountFormulas(formula) {
+ return getOdooFunctions(formula, (functionName) =>
+ ["ODOO.BALANCE", "ODOO.CREDIT", "ODOO.DEBIT"].includes(functionName)
+ ).filter((fn) => fn.isMatched).length;
+}
+
+/**
+ * Get the first Account function description of the given formula.
+ *
+ * @param {string} formula
+ * @returns {OdooFunctionDescription | undefined}
+ */
+export function getFirstAccountFunction(formula) {
+ return getOdooFunctions(formula, (functionName) =>
+ ["ODOO.BALANCE", "ODOO.CREDIT", "ODOO.DEBIT"].includes(functionName)
+ ).find((fn) => fn.isMatched);
+}
diff --git a/addons/spreadsheet_account/static/tests/accounting_test_data.js b/addons/spreadsheet_account/static/tests/accounting_test_data.js
new file mode 100644
index 00000000000..d1a6d2b6c51
--- /dev/null
+++ b/addons/spreadsheet_account/static/tests/accounting_test_data.js
@@ -0,0 +1,42 @@
+/** @odoo-module */
+
+import { getBasicData } from "@spreadsheet/../tests/utils/data";
+
+export function getAccountingData() {
+ return {
+ models: {
+ ...getBasicData(),
+ "account.move.line": {
+ fields: {
+ account_id: { type: "many2one", relation: "account.account" },
+ date: { string: "Date", type: "date" },
+ },
+ records: [
+ { id: 1, name: "line1", account_id: 1, date: "2022-06-01" },
+ { id: 2, name: "line2", account_id: 2, date: "2022-06-23" },
+ ],
+ },
+ "account.account": {
+ fields: {
+ code: { string: "Code", type: "string" },
+ account_type: { string: "Account type", type: "string" },
+ },
+ records: [
+ { id: 1, code: "100104", account_type: "income" },
+ { id: 2, code: "100105", account_type: "income_other" },
+ { id: 3, code: "200104", account_type: "income" },
+ ],
+ },
+ },
+ views: {
+ "account.move.line,false,list": /* xml */ `
+
+
+
+
+
+ `,
+ "account.move.line,false,search": /* xml */ ``,
+ },
+ };
+}
diff --git a/addons/spreadsheet_account/static/tests/mock_server.js b/addons/spreadsheet_account/static/tests/mock_server.js
new file mode 100644
index 00000000000..5f96d9fd362
--- /dev/null
+++ b/addons/spreadsheet_account/static/tests/mock_server.js
@@ -0,0 +1,20 @@
+/** @odoo-module */
+
+import { registry } from "@web/core/registry";
+
+registry
+ .category("mock_server")
+ .add("account.account/spreadsheet_fetch_debit_credit", function (route, args) {
+ return new Array(args.args[0].length).fill({ credit: 0, debit: 0 });
+ })
+ .add("account.account/get_account_group", function (route, args, performRPC) {
+ const accountTypes = args.args[0];
+ const data = accountTypes.map((accountType) => {
+ const records = this.mockSearchRead("account.account", [
+ [["account_type", "=", accountType]],
+ ["code"],
+ ], {});
+ return records.map((record) => record.code);
+ });
+ return data;
+ });
diff --git a/addons/spreadsheet_account/static/tests/model/account_groups_tests.js b/addons/spreadsheet_account/static/tests/model/account_groups_tests.js
new file mode 100644
index 00000000000..fff24e5a220
--- /dev/null
+++ b/addons/spreadsheet_account/static/tests/model/account_groups_tests.js
@@ -0,0 +1,36 @@
+/** @odoo-module */
+
+import { nextTick } from "@web/../tests/helpers/utils";
+import { setCellContent } from "@spreadsheet/../tests/utils/commands";
+import { createModelWithDataSource } from "@spreadsheet/../tests/utils/model";
+import { getCellValue } from "@spreadsheet/../tests/utils/getters";
+import { getAccountingData } from "../accounting_test_data";
+
+let serverData;
+
+function beforeEach() {
+ serverData = getAccountingData();
+}
+
+QUnit.module("spreadsheet_account > account groups", { beforeEach }, () => {
+ QUnit.test("get no account", async (assert) => {
+ const model = await createModelWithDataSource({ serverData });
+ setCellContent(model, "A1", `=ODOO.ACCOUNT.GROUP("test")`);
+ await nextTick();
+ assert.equal(getCellValue(model, "A1"), "");
+ });
+
+ QUnit.test("get one account", async (assert) => {
+ const model = await createModelWithDataSource({ serverData });
+ setCellContent(model, "A1", `=ODOO.ACCOUNT.GROUP("income_other")`);
+ await nextTick();
+ assert.equal(getCellValue(model, "A1"), "100105");
+ });
+
+ QUnit.test("get multiple accounts", async (assert) => {
+ const model = await createModelWithDataSource({ serverData });
+ setCellContent(model, "A1", `=ODOO.ACCOUNT.GROUP("income")`);
+ await nextTick();
+ assert.equal(getCellValue(model, "A1"), "100104,200104");
+ });
+});
diff --git a/addons/spreadsheet_account/static/tests/model/accounting_tests.js b/addons/spreadsheet_account/static/tests/model/accounting_tests.js
new file mode 100644
index 00000000000..efa96bf2da4
--- /dev/null
+++ b/addons/spreadsheet_account/static/tests/model/accounting_tests.js
@@ -0,0 +1,385 @@
+/** @odoo-module */
+
+import { nextTick } from "@web/../tests/helpers/utils";
+import { setCellContent } from "@spreadsheet/../tests/utils/commands";
+import { createModelWithDataSource } from "@spreadsheet/../tests/utils/model";
+import { parseAccountingDate } from "../../src/accounting_functions";
+import { getCellValue, getCell } from "@spreadsheet/../tests/utils/getters";
+import { getAccountingData } from "../accounting_test_data";
+import { camelToSnakeObject } from "@spreadsheet/helpers/helpers";
+import { sprintf } from "@web/core/utils/strings";
+
+let serverData;
+
+function beforeEach() {
+ serverData = getAccountingData();
+}
+
+QUnit.module("spreadsheet_account > Accounting", { beforeEach }, () => {
+ QUnit.module("Formulas");
+ QUnit.test("Basic evaluation", async (assert) => {
+ const model = await createModelWithDataSource({
+ mockRPC: async function (route, args) {
+ if (args.method === "spreadsheet_fetch_debit_credit") {
+ assert.step("spreadsheet_fetch_debit_credit");
+ return [{ debit: 42, credit: 16 }];
+ }
+ },
+ });
+ setCellContent(model, "A1", `=ODOO.CREDIT("100", "2022")`);
+ setCellContent(model, "A2", `=ODOO.DEBIT("100", "2022")`);
+ setCellContent(model, "A3", `=ODOO.BALANCE("100", "2022")`);
+ await nextTick();
+ assert.equal(getCellValue(model, "A1"), 16);
+ assert.equal(getCellValue(model, "A2"), 42);
+ assert.equal(getCellValue(model, "A3"), 26);
+ assert.verifySteps(["spreadsheet_fetch_debit_credit"]);
+ });
+
+ QUnit.test("Functions are correctly formatted", async (assert) => {
+ const model = await createModelWithDataSource();
+ setCellContent(model, "A1", `=ODOO.CREDIT("100", "2022")`);
+ setCellContent(model, "A2", `=ODOO.DEBIT("100", "2022")`);
+ setCellContent(model, "A3", `=ODOO.BALANCE("100", "2022")`);
+ await nextTick();
+ assert.strictEqual(getCell(model, "A1").evaluated.format, "#,##0.00[$€]");
+ assert.strictEqual(getCell(model, "A2").evaluated.format, "#,##0.00[$€]");
+ assert.strictEqual(getCell(model, "A3").evaluated.format, "#,##0.00[$€]");
+ });
+
+ QUnit.test("Functions with a wrong company id is correctly in error", async (assert) => {
+ const model = await createModelWithDataSource({
+ mockRPC: async function (route, args) {
+ if (args.method === "get_company_currency_for_spreadsheet") {
+ return false;
+ }
+ },
+ });
+ setCellContent(model, "A1", `=ODOO.CREDIT("100", "2022", 0, 123456)`);
+ await nextTick();
+ assert.strictEqual(
+ getCell(model, "A1").evaluated.error.message,
+ "Currency not available for this company."
+ );
+ });
+
+ QUnit.test("formula with invalid date", async (assert) => {
+ const model = await createModelWithDataSource();
+ setCellContent(model, "A1", `=ODOO.CREDIT("100",)`);
+ setCellContent(model, "A2", `=ODOO.DEBIT("100", 0)`);
+ setCellContent(model, "A3", `=ODOO.BALANCE("100", -1)`);
+ setCellContent(model, "A4", `=ODOO.BALANCE("100", "not a valid period")`);
+ setCellContent(model, "A5", `=ODOO.BALANCE("100", 1900)`); // this should be ok
+ setCellContent(model, "A6", `=ODOO.BALANCE("100", 1900, -1)`);
+ setCellContent(model, "A7", `=ODOO.DEBIT("100", 1899)`);
+ await nextTick();
+ const errorMessage = `'%s' is not a valid period. Supported formats are "21/12/2022", "Q1/2022", "12/2022", and "2022".`
+ assert.equal(getCell(model, "A1").evaluated.error.message, "0 is not a valid year.");
+ assert.equal(getCell(model, "A2").evaluated.error.message, "0 is not a valid year.");
+ assert.equal(getCell(model, "A3").evaluated.error.message, "-1 is not a valid year.");
+ assert.equal(getCell(model, "A4").evaluated.error.message, sprintf(errorMessage, "not a valid period"));
+ assert.equal(getCell(model, "A5").evaluated.value, 0);
+ assert.equal(getCell(model, "A6").evaluated.error.message, "1899 is not a valid year.");
+ assert.equal(getCell(model, "A7").evaluated.error.message, "1899 is not a valid year.");
+ });
+
+ QUnit.test("Evaluation with multiple account codes", async (assert) => {
+ const model = await createModelWithDataSource({
+ mockRPC: async function (route, args) {
+ if (args.method === "spreadsheet_fetch_debit_credit") {
+ assert.step("spreadsheet_fetch_debit_credit");
+ return [
+ { debit: 42, credit: 16 },
+ { debit: 100, credit: 10 },
+ ];
+ }
+ },
+ });
+ setCellContent(model, "A1", `=ODOO.CREDIT("100,200", "2022")`);
+ setCellContent(model, "A2", `=ODOO.DEBIT("100,200", "2022")`);
+ setCellContent(model, "A3", `=ODOO.BALANCE("100,200", "2022")`);
+ await nextTick();
+ assert.equal(getCellValue(model, "A1"), 26);
+ assert.equal(getCellValue(model, "A2"), 142);
+ assert.equal(getCellValue(model, "A3"), 116);
+ assert.verifySteps(["spreadsheet_fetch_debit_credit"]);
+ });
+
+ QUnit.test("Handle error evaluation", async (assert) => {
+ const model = await createModelWithDataSource({
+ mockRPC: async function (route, args) {
+ if (args.method === "spreadsheet_fetch_debit_credit") {
+ throw new Error("a nasty error");
+ }
+ },
+ });
+ setCellContent(model, "A1", `=ODOO.CREDIT("100", "2022")`);
+ await nextTick();
+ const cell = getCell(model, "A1");
+ assert.equal(cell.evaluated.value, "#ERROR");
+ assert.equal(cell.evaluated.error.message, "a nasty error");
+ });
+
+ QUnit.test("Server requests", async (assert) => {
+ const model = await createModelWithDataSource({
+ mockRPC: async function (route, args) {
+ if (args.method === "spreadsheet_fetch_debit_credit") {
+ const blobs = args.args[0];
+ for (let blob of blobs) {
+ assert.step(JSON.stringify(blob));
+ }
+ }
+ return [];
+ },
+ });
+ setCellContent(model, "A1", `=ODOO.BALANCE("100", "2022")`);
+ setCellContent(model, "A2", `=ODOO.CREDIT("100", "01/2022")`);
+ setCellContent(model, "A3", `=ODOO.DEBIT("100","Q2/2022")`);
+ setCellContent(model, "A4", `=ODOO.BALANCE("10", "2021")`);
+ setCellContent(model, "A5", `=ODOO.CREDIT("10", "2022", -1)`); // same payload as A4: should only be called once
+ setCellContent(model, "A6", `=ODOO.DEBIT("5", "2021", 0, 2)`);
+ setCellContent(model, "A7", `=ODOO.DEBIT("5", "05/04/2021", 1)`);
+ setCellContent(model, "A8", `=ODOO.BALANCE("5", "2022",,,FALSE)`);
+ setCellContent(model, "A9", `=ODOO.BALANCE("100", "05/05/2022",,,TRUE)`);
+ await nextTick();
+
+ assert.verifySteps([
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("2022"),
+ code: "100",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("01/2022"),
+ code: "100",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("Q2/2022"),
+ code: "100",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("2021"),
+ code: "10",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("2021"),
+ code: "5",
+ companyId: 2,
+ includeUnposted: true,
+ })
+ ),
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("05/04/2022"),
+ code: "5",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("2022"),
+ code: "5",
+ companyId: null,
+ includeUnposted: false,
+ })
+ ),
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("05/05/2022"),
+ code: "100",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ ]);
+ });
+
+ QUnit.test("Server requests with multiple account codes", async (assert) => {
+ const model = await createModelWithDataSource({
+ mockRPC: async function (route, args) {
+ if (args.method === "spreadsheet_fetch_debit_credit") {
+ assert.step("spreadsheet_fetch_debit_credit");
+ const blobs = args.args[0];
+ for (let blob of blobs) {
+ assert.step(JSON.stringify(blob));
+ }
+ }
+ },
+ });
+ setCellContent(model, "A1", `=ODOO.BALANCE("100,200", "2022")`);
+ setCellContent(model, "A2", `=ODOO.CREDIT("100,200", "2022")`);
+ setCellContent(model, "A3", `=ODOO.DEBIT("100,200","2022")`);
+ await nextTick();
+
+ assert.verifySteps([
+ "spreadsheet_fetch_debit_credit",
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("2022"),
+ code: "100",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("2022"),
+ code: "200",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ ]);
+ });
+
+ QUnit.test("account group formula as input to balance formula", async (assert) => {
+ const model = await createModelWithDataSource({
+ serverData,
+ mockRPC: async function (route, args) {
+ if (args.method === "spreadsheet_fetch_debit_credit") {
+ assert.step("spreadsheet_fetch_debit_credit");
+ const blobs = args.args[0];
+ for (let blob of blobs) {
+ assert.step(JSON.stringify(blob));
+ }
+ }
+ },
+ });
+ setCellContent(model, "A1", `=ODOO.ACCOUNT.GROUP("income")`);
+ setCellContent(model, "A2", `=ODOO.BALANCE(A1, 2022)`);
+ assert.equal(getCellValue(model, "A1"), "Loading...");
+ assert.equal(getCellValue(model, "A2"), "Loading...");
+ await nextTick();
+ assert.equal(getCellValue(model, "A1"), "100104,200104");
+ assert.equal(getCellValue(model, "A2"), 0);
+ assert.verifySteps([
+ "spreadsheet_fetch_debit_credit",
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("2022"),
+ code: "100104",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("2022"),
+ code: "200104",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ ]);
+ });
+
+ QUnit.test("two concurrent requests on different accounts", async (assert) => {
+ const model = await createModelWithDataSource({
+ serverData,
+ mockRPC: async function (route, args) {
+ if (args.method === "spreadsheet_fetch_debit_credit") {
+ assert.step("spreadsheet_fetch_debit_credit");
+ const blobs = args.args[0];
+ for (let blob of blobs) {
+ assert.step(JSON.stringify(blob));
+ }
+ }
+ },
+ });
+ setCellContent(model, "A1", `=ODOO.ACCOUNT.GROUP("income")`);
+ setCellContent(model, "A2", `=ODOO.BALANCE(A1, 2022)`); // batched only when A1 resolves
+ setCellContent(model, "A3", `=ODOO.BALANCE("100", 2022)`); // batched directly
+ assert.equal(getCellValue(model, "A1"), "Loading...");
+ assert.equal(getCellValue(model, "A2"), "Loading...");
+ assert.equal(getCellValue(model, "A3"), "Loading...");
+ // A lot happens within the next tick.
+ // Because cells are evaluated given their order in the sheet,
+ // A1's request is done first, meaning it's also resolved first, which add A2 to the next batch (synchronously)
+ // Only then A3 is resolved. => A2 is batched while A3 is pending
+ await nextTick();
+ assert.equal(getCellValue(model, "A1"), "100104,200104");
+ assert.equal(getCellValue(model, "A2"), 0);
+ assert.equal(getCellValue(model, "A3"), 0);
+ assert.verifySteps([
+ "spreadsheet_fetch_debit_credit",
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("2022"),
+ code: "100",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ "spreadsheet_fetch_debit_credit",
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("2022"),
+ code: "100104",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ JSON.stringify(
+ camelToSnakeObject({
+ dateRange: parseAccountingDate("2022"),
+ code: "200104",
+ companyId: null,
+ includeUnposted: true,
+ })
+ ),
+ ]);
+ });
+
+ QUnit.test("parseAccountingDate", (assert) => {
+ assert.deepEqual(parseAccountingDate("2022"), {
+ rangeType: "year",
+ year: 2022,
+ });
+ assert.deepEqual(parseAccountingDate("11/10/2022"), {
+ rangeType: "day",
+ year: 2022,
+ month: 11,
+ day: 10,
+ });
+ assert.deepEqual(parseAccountingDate("10/2022"), {
+ rangeType: "month",
+ year: 2022,
+ month: 10,
+ });
+ assert.deepEqual(parseAccountingDate("Q1/2022"), {
+ rangeType: "quarter",
+ year: 2022,
+ quarter: 1,
+ });
+ assert.deepEqual(parseAccountingDate("q4/2022"), {
+ rangeType: "quarter",
+ year: 2022,
+ quarter: 4,
+ });
+ // A number below 3000 is interpreted as a year.
+ // It's interpreted as a regular spreadsheet date otherwise
+ assert.deepEqual(parseAccountingDate("3005"), {
+ rangeType: "day",
+ year: 1908,
+ month: 3,
+ day: 23,
+ });
+ });
+});
diff --git a/addons/spreadsheet_account/static/tests/model/fiscal_year_tests.js b/addons/spreadsheet_account/static/tests/model/fiscal_year_tests.js
new file mode 100644
index 00000000000..6ed8d9c3c98
--- /dev/null
+++ b/addons/spreadsheet_account/static/tests/model/fiscal_year_tests.js
@@ -0,0 +1,103 @@
+/** @odoo-module */
+import { nextTick } from "@web/../tests/helpers/utils";
+import { setCellContent } from "@spreadsheet/../tests/utils/commands";
+import { createModelWithDataSource } from "@spreadsheet/../tests/utils/model";
+import { getCell } from "@spreadsheet/../tests/utils/getters";
+import "@spreadsheet_account/index";
+
+QUnit.module("spreadsheet_account > fiscal year", {}, () => {
+ QUnit.test("Basic evaluation", async (assert) => {
+ const model = await createModelWithDataSource({
+ mockRPC: async function (route, args) {
+ if (args.method === "get_fiscal_dates") {
+ assert.step("get_fiscal_dates");
+ assert.deepEqual(args.args, [
+ [
+ {
+ date: "2020-11-11",
+ company_id: null,
+ },
+ ],
+ ]);
+ return [{ start: "2020-01-01", end: "2020-12-31" }];
+ }
+ },
+ });
+ setCellContent(model, "A1", `=ODOO.FISCALYEAR.START("11/11/2020")`);
+ setCellContent(model, "A2", `=ODOO.FISCALYEAR.END("11/11/2020")`);
+ await nextTick();
+ assert.verifySteps(["get_fiscal_dates"]);
+ assert.equal(getCell(model, "A1").formattedValue, "1/1/2020");
+ assert.equal(getCell(model, "A2").formattedValue, "12/31/2020");
+ });
+
+ QUnit.test("with a given company id", async (assert) => {
+ const model = await createModelWithDataSource({
+ mockRPC: async function (route, args) {
+ if (args.method === "get_fiscal_dates") {
+ assert.step("get_fiscal_dates");
+ assert.deepEqual(args.args, [
+ [
+ {
+ date: "2020-11-11",
+ company_id: 1,
+ },
+ ],
+ ]);
+ return [{ start: "2020-01-01", end: "2020-12-31" }];
+ }
+ },
+ });
+ setCellContent(model, "A1", `=ODOO.FISCALYEAR.START("11/11/2020", 1)`);
+ setCellContent(model, "A2", `=ODOO.FISCALYEAR.END("11/11/2020", 1)`);
+ await nextTick();
+ assert.verifySteps(["get_fiscal_dates"]);
+ assert.equal(getCell(model, "A1").formattedValue, "1/1/2020");
+ assert.equal(getCell(model, "A2").formattedValue, "12/31/2020");
+ });
+
+ QUnit.test("with a wrong company id", async (assert) => {
+ const model = await createModelWithDataSource({
+ mockRPC: async function (route, args) {
+ if (args.method === "get_fiscal_dates") {
+ assert.step("get_fiscal_dates");
+ assert.deepEqual(args.args, [
+ [
+ {
+ date: "2020-11-11",
+ company_id: 100,
+ },
+ ],
+ ]);
+ return [false];
+ }
+ },
+ });
+ setCellContent(model, "A1", `=ODOO.FISCALYEAR.START("11/11/2020", 100)`);
+ setCellContent(model, "A2", `=ODOO.FISCALYEAR.END("11/11/2020", 100)`);
+ await nextTick();
+ assert.verifySteps(["get_fiscal_dates"]);
+ assert.equal(
+ getCell(model, "A1").evaluated.error.message,
+ "The company fiscal year could not be found."
+ );
+ assert.equal(
+ getCell(model, "A2").evaluated.error.message,
+ "The company fiscal year could not be found."
+ );
+ });
+
+ QUnit.test("with wrong input arguments", async (assert) => {
+ const model = await createModelWithDataSource();
+ setCellContent(model, "A1", `=ODOO.FISCALYEAR.START("not a number")`);
+ setCellContent(model, "A2", `=ODOO.FISCALYEAR.END("11/11/2020", "not a number")`);
+ assert.equal(
+ getCell(model, "A1").evaluated.error.message,
+ "The function ODOO.FISCALYEAR.START expects a number value, but 'not a number' is a string, and cannot be coerced to a number."
+ );
+ assert.equal(
+ getCell(model, "A2").evaluated.error.message,
+ "The function ODOO.FISCALYEAR.END expects a number value, but 'not a number' is a string, and cannot be coerced to a number."
+ );
+ });
+});
diff --git a/addons/spreadsheet_account/static/tests/ui/accounting_drilldown_tests.js b/addons/spreadsheet_account/static/tests/ui/accounting_drilldown_tests.js
new file mode 100644
index 00000000000..d9d9d0f513e
--- /dev/null
+++ b/addons/spreadsheet_account/static/tests/ui/accounting_drilldown_tests.js
@@ -0,0 +1,76 @@
+/** @odoo-module */
+
+import { nextTick } from "@web/../tests/helpers/utils";
+import { selectCell, setCellContent } from "@spreadsheet/../tests/utils/commands";
+import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
+import { getAccountingData } from "../accounting_test_data";
+import { createModelWithDataSource } from "@spreadsheet/../tests/utils/model";
+import { registry } from "@web/core/registry";
+
+const { cellMenuRegistry } = spreadsheet.registries;
+
+let serverData;
+
+function beforeEach() {
+ serverData = getAccountingData();
+}
+
+QUnit.module("spreadsheet_account > Accounting Drill down", { beforeEach }, () => {
+ QUnit.test("Create drill down domain", async (assert) => {
+ const drillDownAction = {
+ type: "ir.actions.act_window",
+ res_model: "account.move.line",
+ view_mode: "list",
+ views: [[false, "list"]],
+ target: "current",
+ domain: [["account_id", "in", [1, 2]]],
+ name: "my awesome action",
+ };
+ const fakeActionService = {
+ name: "action",
+ start() {
+ return {
+ async doAction(action, options) {
+ assert.step("drill down action");
+ assert.deepEqual(action, drillDownAction);
+ assert.equal(options, undefined);
+ return true;
+ },
+ };
+ },
+ };
+ registry.category("services").add("action", fakeActionService, { force: true });
+
+ const model = await createModelWithDataSource({
+ serverData,
+ mockRPC: async function (route, args) {
+ if (args.method === "spreadsheet_move_line_action") {
+ assert.deepEqual(args.args, [
+ {
+ code: "100",
+ company_id: null,
+ include_unposted: false,
+ date_range: {
+ range_type: "year",
+ year: 2020,
+ },
+ },
+ ]);
+ return drillDownAction;
+ }
+ },
+ });
+ const env = model.config.evalContext.env;
+ env.model = model;
+ setCellContent(model, "A1", `=ODOO.BALANCE("100", 2020)`);
+ setCellContent(model, "A2", `=ODOO.BALANCE("100", 0)`);
+ await nextTick();
+ selectCell(model, "A1");
+ const root = cellMenuRegistry.getAll().find((item) => item.id === "move_lines_see_records");
+ assert.equal(root.isVisible(env), true);
+ await root.action(env);
+ assert.verifySteps(["drill down action"]);
+ selectCell(model, "A2");
+ assert.equal(root.isVisible(env), false);
+ });
+});
diff --git a/addons/spreadsheet_account/tests/__init__.py b/addons/spreadsheet_account/tests/__init__.py
new file mode 100644
index 00000000000..64396d3ac64
--- /dev/null
+++ b/addons/spreadsheet_account/tests/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import test_account_group
+from . import test_debit_credit
+from . import test_company_fiscal_year
diff --git a/addons/spreadsheet_account/tests/test_account_group.py b/addons/spreadsheet_account/tests/test_account_group.py
new file mode 100644
index 00000000000..098bbcaf37a
--- /dev/null
+++ b/addons/spreadsheet_account/tests/test_account_group.py
@@ -0,0 +1,87 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests.common import TransactionCase
+
+
+class SpreadsheetAccountGroupTest(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.env["account.account"].create(
+ {
+ "company_id": cls.env.user.company_id.id,
+ "name": "spreadsheet revenue Company 1",
+ "account_type": "income",
+ "code": "123",
+ }
+ )
+
+ cls.env["account.account"].create(
+ {
+ "company_id": cls.env.user.company_id.id,
+ "name": "spreadsheet expense Company 1",
+ "account_type": "income_other",
+ "code": "456",
+ }
+ )
+
+ cls.env["account.account"].create(
+ {
+ "company_id": cls.env.user.company_id.id,
+ "name": "spreadsheet revenue Company 2",
+ "account_type": "income",
+ "code": "789",
+ }
+ )
+
+ def _unlink_default_accounts(self, account_ids):
+ used_account_id = self.env["account.account"].search([("code", '=', '123')])
+ aml_ids = self.env["account.move.line"].search([("account_id", "in", account_ids.ids), ("company_id", "=", self.env.company.id)])
+ journal_ids = self.env["account.journal"].search([("default_account_id", "in", account_ids.ids), ("company_id", "=", self.env.company.id)])
+ for aml_id in aml_ids:
+ aml_id.account_id = used_account_id
+ for journal_id in journal_ids:
+ journal_id.default_account_id = used_account_id
+ for account_id in account_ids:
+ self.env["ir.property"]._set_default("property_account_income_categ_id", "product.category", used_account_id, self.env.company)
+ account_id.unlink()
+
+ def test_fetch_account_no_group(self):
+ self.assertEqual(self.env["account.account"].get_account_group([]), [])
+
+ def test_fetch_account_one_group(self):
+ account_ids = self.env["account.account"].search([("account_type", "=", "income_other"), ("company_id", "=", self.env.company.id), ("code", "!=", "456")])
+ self._unlink_default_accounts(account_ids)
+ self.assertEqual(
+ self.env["account.account"].get_account_group(["income_other"]),
+ [["456"]],
+ )
+
+ def test_group_with_no_account(self):
+ account_ids = self.env["account.account"].search([("account_type", "=", "income_other"), ("company_id", "=", self.env.company.id)])
+ self._unlink_default_accounts(account_ids)
+ self.assertEqual(
+ self.env["account.account"].get_account_group(["income_other"]), [[]]
+ )
+
+ def test_with_wrong_account_type_id(self):
+ self.assertEqual(self.env["account.account"].get_account_group([999999]), [[]])
+
+ def test_group_with_multiple_accounts(self):
+ account_ids = self.env["account.account"].search([("account_type", "=", "income"), ("company_id", "=", self.env.company.id), ("code", "not in", ["123", "789"])])
+ self._unlink_default_accounts(account_ids)
+ self.assertEqual(
+ self.env["account.account"].get_account_group(["income"]),
+ [["123", "789"]],
+ )
+
+ def test_response_is_ordered(self):
+ o1_codes_1, o1_codes_2 = self.env["account.account"].get_account_group(
+ ["income", "income_other"]
+ )
+ o2_codes_2, o2_codes_1 = self.env["account.account"].get_account_group(
+ ["income_other", "income"]
+ )
+ self.assertEqual(o1_codes_1, o2_codes_1)
+ self.assertEqual(o1_codes_2, o2_codes_2)
diff --git a/addons/spreadsheet_account/tests/test_company_fiscal_year.py b/addons/spreadsheet_account/tests/test_company_fiscal_year.py
new file mode 100644
index 00000000000..04d373ec44b
--- /dev/null
+++ b/addons/spreadsheet_account/tests/test_company_fiscal_year.py
@@ -0,0 +1,98 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests.common import TransactionCase
+
+from datetime import date
+
+
+class SpreadsheetFiscalYearTest(TransactionCase):
+ def test_fiscal_year_reference(self):
+ self.env.company.fiscalyear_last_day = 3
+ self.env.company.fiscalyear_last_month = "2"
+
+ self.assertEqual(
+ self.env["res.company"].get_fiscal_dates(
+ [{"company_id": None, "date": "2020-03-05"}]
+ ),
+ [
+ {"start": date(2020, 2, 4), "end": date(2021, 2, 3)},
+ ],
+ )
+
+ def test_fiscal_year_reference_last_day(self):
+ self.env.company.fiscalyear_last_day = 3
+ self.env.company.fiscalyear_last_month = "2"
+
+ self.assertEqual(
+ self.env["res.company"].get_fiscal_dates(
+ [{"company_id": None, "date": "2020-02-03"}]
+ ),
+ [
+ {"start": date(2019, 2, 4), "end": date(2020, 2, 3)},
+ ],
+ )
+
+ def test_fiscal_year_reference_first_day(self):
+ self.env.company.fiscalyear_last_day = 3
+ self.env.company.fiscalyear_last_month = "2"
+
+ self.assertEqual(
+ self.env["res.company"].get_fiscal_dates(
+ [{"company_id": None, "date": "2020-02-04"}]
+ ),
+ [{"start": date(2020, 2, 4), "end": date(2021, 2, 3)}],
+ )
+
+ def test_fiscal_year_with_company_id(self):
+ company = self.env["res.company"].create(
+ {
+ "name": "test company",
+ "fiscalyear_last_day": 3,
+ "fiscalyear_last_month": "2",
+ }
+ )
+ self.assertEqual(
+ self.env["res.company"].get_fiscal_dates(
+ [
+ {"company_id": company.id, "date": "2020-02-04"},
+ {"company_id": None, "date": "2020-02-04"},
+ ]
+ ),
+ [
+ {"start": date(2020, 2, 4), "end": date(2021, 2, 3)},
+ {"start": date(2020, 1, 1), "end": date(2020, 12, 31)},
+ ],
+ )
+
+ def test_result_order(self):
+ company = self.env["res.company"].create(
+ {
+ "name": "test company",
+ "fiscalyear_last_day": 3,
+ "fiscalyear_last_month": "2",
+ }
+ )
+ request1 = {"company_id": company.id, "date": "2020-02-04"}
+ request2 = {"company_id": None, "date": "2020-02-04"}
+ [o1_request1, o1_request2] = self.env["res.company"].get_fiscal_dates(
+ [request1, request2]
+ )
+ [o2_request2, o2_request1] = self.env["res.company"].get_fiscal_dates(
+ [request2, request1]
+ )
+ self.assertEqual(o1_request1, o2_request1)
+ self.assertEqual(o1_request2, o2_request2)
+
+ def test_fiscal_year_with_wrong_company_id(self):
+ self.assertEqual(
+ self.env["res.company"].get_fiscal_dates(
+ [
+ {"company_id": 999, "date": "2020-02-04"},
+ {"company_id": None, "date": "2020-02-04"},
+ ]
+ ),
+ [
+ False,
+ {"start": date(2020, 1, 1), "end": date(2020, 12, 31)},
+ ],
+ )
diff --git a/addons/spreadsheet_account/tests/test_debit_credit.py b/addons/spreadsheet_account/tests/test_debit_credit.py
new file mode 100644
index 00000000000..72e01ab78b3
--- /dev/null
+++ b/addons/spreadsheet_account/tests/test_debit_credit.py
@@ -0,0 +1,866 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import date
+
+from odoo import Command
+from odoo.tests import tagged
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+
+
+@tagged("post_install", "-at_install")
+class SpreadsheetAccountingFunctionsTest(AccountTestInvoicingCommon):
+ @classmethod
+ def setUpClass(cls, chart_template_ref=None):
+ super().setUpClass(chart_template_ref=chart_template_ref)
+
+ cls.account_revenue_c1 = cls.env["account.account"].create(
+ {
+ "company_id": cls.company_data["company"].id,
+ "name": "spreadsheet revenue Company 1",
+ "account_type": "income",
+ "code": "sp1234566",
+ }
+ )
+
+ cls.account_expense_c1 = cls.env["account.account"].create(
+ {
+ "company_id": cls.company_data["company"].id,
+ "name": "spreadsheet expense Company 1",
+ "account_type": "expense",
+ "code": "sp1234577",
+ }
+ )
+
+ cls.account_revenue_c2 = cls.env["account.account"].create(
+ {
+ "company_id": cls.company_data_2["company"].id,
+ "name": "spreadsheet revenue Company 2",
+ "account_type": "income",
+ "code": "sp99887755",
+ }
+ )
+
+ cls.account_expense_c2 = cls.env["account.account"].create(
+ {
+ "company_id": cls.company_data_2["company"].id,
+ "name": "spreadsheet expense Company 2",
+ "account_type": "expense",
+ "code": "sp99887766",
+ }
+ )
+
+ cls.env["account.move"].create(
+ {
+ "company_id": cls.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2022-04-02",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": cls.account_revenue_c1.id,
+ "debit": 500,
+ }
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": cls.account_expense_c1.id,
+ "credit": 500,
+ },
+ ),
+ ],
+ }
+ )
+
+ cls.env["account.move"].with_company(cls.company_data_2["company"]).create(
+ {
+ "company_id": cls.company_data_2["company"].id,
+ "move_type": "entry",
+ "date": "2022-02-02",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c2",
+ "account_id": cls.account_revenue_c2.id,
+ "debit": 1500,
+ "company_id": cls.company_data_2["company"].id,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c2",
+ "account_id": cls.account_expense_c2.id,
+ "credit": 1500,
+ "company_id": cls.company_data_2["company"].id,
+ },
+ ),
+ ],
+ }
+ )
+
+ def test_empty_payload(self):
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit([]), []
+ )
+
+ def test_exact_code(self):
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2022,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [{"credit": 0.0, "debit": 500.0}],
+ )
+
+ def test_response_order(self):
+ request_1 = {
+ "date_range": {
+ "range_type": "year",
+ "year": 2022,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ request_2 = {
+ "date_range": {
+ "range_type": "year",
+ "year": 2020,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ [o1_res1, o1_res2] = self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [request_1, request_2]
+ )
+ [o2_res2, o2_res1] = self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [request_2, request_1]
+ )
+ self.assertEqual(o1_res1, o2_res1)
+ self.assertEqual(o1_res2, o2_res2)
+
+ def test_prefix_code(self):
+ code = "sp1234"
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2022,
+ },
+ "code": code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [
+ {"credit": 500.0, "debit": 500.0},
+ ],
+ )
+
+ def test_do_not_count_future_years(self):
+ self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2022-04-02",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 1000,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_expense_c1.id,
+ "credit": 1000,
+ },
+ ),
+ ],
+ }
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2021,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [
+ {"credit": 0, "debit": 0.0},
+ ],
+ )
+
+ def test_year_date_period(self):
+ self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2020-04-02",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 1000,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_expense_c1.id,
+ "credit": 1000,
+ },
+ ),
+ ],
+ }
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2021,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [
+ {"credit": 0, "debit": 0.0},
+ ],
+ )
+
+ def test_shifted_fiscal_year_date_period(self):
+ self.company_data["company"].fiscalyear_last_day = 3
+ self.company_data["company"].fiscalyear_last_month = "2"
+ self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2023-01-02",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 1000,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_expense_c1.id,
+ "credit": 1000,
+ },
+ ),
+ ],
+ }
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2022,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [{"credit": 0, "debit": 1500.0}],
+ )
+
+ def test_quarter_date_period(self):
+ self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2022-07-02",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 777,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "credit": 777,
+ },
+ ),
+ ],
+ }
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "quarter",
+ "year": 2022,
+ "quarter": 3,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [{"credit": 777, "debit": 777}],
+ )
+
+ def test_month_date_period(self):
+ self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2022-07-02",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 666,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "credit": 666,
+ },
+ ),
+ ],
+ }
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {"range_type": "month", "year": 2022, "month": 7},
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [{"credit": 666, "debit": 666}],
+ )
+
+ def test_day_date_period(self):
+ self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2022-07-02",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 555,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "credit": 555,
+ },
+ ),
+ ],
+ }
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "day",
+ "year": 2022,
+ "month": 7,
+ "day": 2,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [{"credit": 555, "debit": 1055}],
+ )
+
+ def test_first_fiscal_day_date_period(self):
+ self.company_data["company"].fiscalyear_last_day = 3
+ self.company_data["company"].fiscalyear_last_month = "2"
+ self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2022-02-03",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 111,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "credit": 111,
+ },
+ ),
+ ],
+ }
+ )
+ self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2022-02-04",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 423,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "credit": 423,
+ },
+ ),
+ ],
+ }
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "day",
+ "year": 2022,
+ "month": 2,
+ "day": 4,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [
+ {"credit": 423, "debit": 423},
+ ],
+ )
+
+ def test_balance_account_by_year(self):
+ # On balance accounts, we sum the lines from the creation up to the last dat of date_range
+ self.account_revenue_c1.sudo().include_initial_balance = True
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2025,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [
+ {"credit": 0, "debit": 500},
+ ],
+ )
+
+ def test_balance_quarter_date_period(self):
+ self.account_revenue_c1.sudo().include_initial_balance = True
+ self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2022-07-02",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 777,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "credit": 777,
+ },
+ ),
+ ],
+ }
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "quarter",
+ "year": 2022,
+ "quarter": 3,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [
+ {"credit": 777, "debit": 1277},
+ ],
+ )
+
+ def test_balance_month_date_period(self):
+ self.account_revenue_c1.sudo().include_initial_balance = True
+ self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2022-07-02",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 777,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "credit": 777,
+ },
+ ),
+ ],
+ }
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {"range_type": "month", "year": 2022, "month": 7},
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [
+ {"credit": 777, "debit": 1277},
+ ],
+ )
+
+ def test_balance_day_date_period(self):
+ self.account_revenue_c1.sudo().include_initial_balance = True
+ self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2022-07-02",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 777,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "credit": 777,
+ },
+ ),
+ ],
+ }
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "day",
+ "year": 2022,
+ "month": 7,
+ "day": 2,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [
+ {"credit": 777, "debit": 1277},
+ ],
+ )
+
+ def test_move_state_ignore_cancel(self):
+ self.account_revenue_c1.sudo().include_initial_balance = True
+ self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2022-04-02",
+ "state": "cancel",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 10000000,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "credit": 10000000,
+ },
+ ),
+ ],
+ }
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2022,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": False,
+ }
+ ]
+ ),
+ [
+ {"credit": 0, "debit": 0},
+ ],
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2022,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [
+ {"credit": 0, "debit": 500},
+ ],
+ )
+
+ def test_move_state_unposted(self):
+ self.account_revenue_c1.sudo().include_initial_balance = True
+ move = self.env["account.move"].create(
+ {
+ "company_id": self.company_data["company"].id,
+ "move_type": "entry",
+ "date": "2022-04-02",
+ "line_ids": [
+ Command.create(
+ {
+ "name": "line_debit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "debit": 888,
+ },
+ ),
+ Command.create(
+ {
+ "name": "line_credit_c1",
+ "account_id": self.account_revenue_c1.id,
+ "credit": 888,
+ },
+ ),
+ ],
+ }
+ )
+ move._post()
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2022,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": False,
+ }
+ ]
+ ),
+ [
+ {"credit": 888, "debit": 888},
+ ],
+ )
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2022,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": None,
+ "include_unposted": True,
+ }
+ ]
+ ),
+ [
+ {"credit": 888, "debit": 1388},
+ ],
+ )
+
+ def test_no_code(self):
+ self.assertEqual(
+ self.env["account.account"].spreadsheet_fetch_debit_credit(
+ [
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2022,
+ },
+ "code": "",
+ "company_id": None,
+ "include_unposted": False,
+ }
+ ]
+ ),
+ [
+ {"credit": 0, "debit": 0},
+ ],
+ )
+
+ def test_see_records_action(self):
+ action = self.env["account.account"].spreadsheet_move_line_action(
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2022,
+ },
+ "code": self.account_revenue_c1.code,
+ "company_id": self.account_revenue_c1.company_id.id,
+ "include_unposted": True,
+ }
+ )
+ self.assertEqual(
+ action,
+ {
+ "type": "ir.actions.act_window",
+ "res_model": "account.move.line",
+ "view_mode": "list",
+ "views": [[False, "list"]],
+ "target": "current",
+ "domain": [
+ "|",
+ "&",
+ "&",
+ "&",
+ ("account_id.code", "=like", "sp1234566%"),
+ ("company_id", "=", self.account_revenue_c1.company_id.id),
+ ("move_id.state", "!=", "cancel"),
+ "&",
+ ("account_id.include_initial_balance", "=", True),
+ ("date", "<=", date(2022, 12, 31)),
+ "&",
+ "&",
+ "&",
+ ("account_id.code", "=like", "sp1234566%"),
+ ("company_id", "=", self.account_revenue_c1.company_id.id),
+ ("move_id.state", "!=", "cancel"),
+ "&",
+ "&",
+ ("account_id.include_initial_balance", "=", False),
+ ("date", ">=", date(2022, 1, 1)),
+ ("date", "<=", date(2022, 12, 31)),
+ ],
+ "name": "Journal items for account prefix sp1234566",
+ },
+ )
+
+ def test_see_records_action_no_code(self):
+ action = self.env["account.account"].spreadsheet_move_line_action(
+ {
+ "date_range": {
+ "range_type": "year",
+ "year": 2022,
+ },
+ "code": "",
+ "company_id": None,
+ "include_unposted": True,
+ }
+ )
+ self.assertEqual(
+ action,
+ {
+ "type": "ir.actions.act_window",
+ "res_model": "account.move.line",
+ "view_mode": "list",
+ "views": [[False, "list"]],
+ "target": "current",
+ "domain": [(0, "=", 1)],
+ "name": "Journal items for account prefix ",
+ },
+ )
diff --git a/addons/web/tooling/_eslintignore b/addons/web/tooling/_eslintignore
index 38620c5f287..e0e746f5a5a 100644
--- a/addons/web/tooling/_eslintignore
+++ b/addons/web/tooling/_eslintignore
@@ -91,8 +91,8 @@ addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js
!spreadsheet_edition/**/*
# Whitelist spreadsheet_account
-!spreadsheet_account
-!spreadsheet_account/**/*
+!addons/spreadsheet_account
+!addons/spreadsheet_account/**/*
!addons/auth_password_policy
!addons/auth_password_policy/**/*
diff --git a/addons/web/tooling/_prettierignore b/addons/web/tooling/_prettierignore
index 3d0447e2b5e..67f0b466d5f 100644
--- a/addons/web/tooling/_prettierignore
+++ b/addons/web/tooling/_prettierignore
@@ -90,9 +90,13 @@ addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js
!spreadsheet_edition
!spreadsheet_edition/**/*
+# Whitelist spreadsheet_edition
+!spreadsheet_edition
+!spreadsheet_edition/**/*
+
# Whitelist spreadsheet_account
-!spreadsheet_account
-!spreadsheet_account/**/*
+!addons/spreadsheet_account
+!addons/spreadsheet_account/**/*
!addons/auth_password_policy
!addons/auth_password_policy/**/*