Initial commit: Accounting packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:47 +02:00
commit 4ef34c2317
2661 changed files with 1709616 additions and 0 deletions

View file

@ -0,0 +1,119 @@
/** @odoo-module */
import { camelToSnakeObject, toServerDateString } from "@spreadsheet/helpers/helpers";
import { _t } from "@web/core/l10n/translation";
import { sprintf } from "@web/core/utils/strings";
import { deepCopy } from "@web/core/utils/objects";
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 = deepCopy(dateRange);
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));
}
return this.serverData.batch.get(
"account.account",
"spreadsheet_fetch_debit_credit",
camelToSnakeObject({ dateRange, codes, companyId, includeUnposted })
);
}
/**
* 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;
}
}

View file

@ -0,0 +1,306 @@
/** @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=FALSE) ${_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 = false
) {
accountCodes = toString(accountCodes)
.split(",")
.map((code) => code.trim())
.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 = false
) {
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 = false
) {
accountCodes = toString(accountCodes)
.split(",")
.map((code) => code.trim())
.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 = false
) {
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 = false
) {
accountCodes = toString(accountCodes)
.split(",")
.map((code) => code.trim())
.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 = false
) {
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(`
day (date) ${_t("The day from which to extract the fiscal year start.")}
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(`
day (date) ${_t("The day from which to extract the fiscal year end.")}
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("The account type (income, expense, asset_current,...).")}
`),
returns: ["NUMBER"],
computeFormat: () => "m/d/yyyy",
compute: function (accountType) {
const accountTypes = this.getters.getAccountGroupCodes(toString(accountType));
return accountTypes.join(",");
},
});

View file

@ -0,0 +1,52 @@
/** @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 [codes, date_range, offset, companyId, includeUnposted] = args
.map(astToFormula)
.map((arg) => env.model.getters.evaluateFormula(arg));
codes = toString(codes).split(",");
const dateRange = parseAccountingDate(date_range);
offset = parseInt(offset) || 0;
dateRange.year += offset || 0;
companyId = parseInt(companyId) || null;
try {
includeUnposted = toBoolean(includeUnposted);
} catch {
includeUnposted = false;
}
const action = await env.services.orm.call(
"account.account",
"spreadsheet_move_line_action",
[camelToSnakeObject({ dateRange, companyId, codes, 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
);
},
});

View file

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

View file

@ -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, ["ODOO.BALANCE", "ODOO.CREDIT", "ODOO.DEBIT"]).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, ["ODOO.BALANCE", "ODOO.CREDIT", "ODOO.DEBIT"]).find(
(fn) => fn.isMatched
);
}

View file

@ -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 */ `
<tree string="Move Lines">
<field name="id"/>
<field name="account_id"/>
<field name="date"/>
</tree>
`,
"account.move.line,false,search": /* xml */ `<search/>`,
},
};
}

View file

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

View file

@ -0,0 +1,38 @@
/** @odoo-module */
import { setCellContent } from "@spreadsheet/../tests/utils/commands";
import {
createModelWithDataSource,
waitForDataSourcesLoaded,
} 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 waitForDataSourcesLoaded(model);
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 waitForDataSourcesLoaded(model);
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 waitForDataSourcesLoaded(model);
assert.equal(getCellValue(model, "A1"), "100104,200104");
});
});

View file

@ -0,0 +1,381 @@
/** @odoo-module */
import { setCellContent } from "@spreadsheet/../tests/utils/commands";
import {
createModelWithDataSource,
waitForDataSourcesLoaded,
} 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 waitForDataSourcesLoaded(model);
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 waitForDataSourcesLoaded(model);
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 waitForDataSourcesLoaded(model);
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 waitForDataSourcesLoaded(model);
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: 142, credit: 26 }];
}
},
});
setCellContent(model, "A1", `=ODOO.CREDIT("100,200", "2022")`);
setCellContent(model, "A2", `=ODOO.DEBIT("100,200", "2022")`);
setCellContent(model, "A3", `=ODOO.BALANCE("100,200", "2022")`);
// with spaces
setCellContent(model, "B1", `=ODOO.CREDIT("100 , 200", "2022")`);
setCellContent(model, "B2", `=ODOO.DEBIT("100 , 200", "2022")`);
setCellContent(model, "B3", `=ODOO.BALANCE("100 , 200", "2022")`);
await waitForDataSourcesLoaded(model);
assert.equal(getCellValue(model, "A1"), 26);
assert.equal(getCellValue(model, "A2"), 142);
assert.equal(getCellValue(model, "A3"), 116);
assert.equal(getCellValue(model, "B1"), 26);
assert.equal(getCellValue(model, "B2"), 142);
assert.equal(getCellValue(model, "B3"), 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 waitForDataSourcesLoaded(model);
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 (const blob of blobs) {
assert.step(JSON.stringify(blob));
}
}
},
});
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)`);
setCellContent(model, "A10", `=ODOO.BALANCE(33,2021,-2)`);
await waitForDataSourcesLoaded(model);
assert.verifySteps([
JSON.stringify(
camelToSnakeObject({
dateRange: parseAccountingDate("2022"),
codes: ["100"],
companyId: null,
includeUnposted: false,
})
),
JSON.stringify(
camelToSnakeObject({
dateRange: parseAccountingDate("01/2022"),
codes: ["100"],
companyId: null,
includeUnposted: false,
})
),
JSON.stringify(
camelToSnakeObject({
dateRange: parseAccountingDate("Q2/2022"),
codes: ["100"],
companyId: null,
includeUnposted: false,
})
),
JSON.stringify(
camelToSnakeObject({
dateRange: parseAccountingDate("2021"),
codes: ["10"],
companyId: null,
includeUnposted: false,
})
),
JSON.stringify(
camelToSnakeObject({
dateRange: parseAccountingDate("2021"),
codes: ["5"],
companyId: 2,
includeUnposted: false,
})
),
JSON.stringify(
camelToSnakeObject({
dateRange: parseAccountingDate("05/04/2022"),
codes: ["5"],
companyId: null,
includeUnposted: false,
})
),
JSON.stringify(
camelToSnakeObject({
dateRange: parseAccountingDate("2022"),
codes: ["5"],
companyId: null,
includeUnposted: false,
})
),
JSON.stringify(
camelToSnakeObject({
dateRange: parseAccountingDate("05/05/2022"),
codes: ["100"],
companyId: null,
includeUnposted: true,
})
),
JSON.stringify(
camelToSnakeObject({
dateRange: parseAccountingDate("2019"),
codes: ["33"],
companyId: null,
includeUnposted: false,
})
),
]);
});
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 (const 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 waitForDataSourcesLoaded(model);
assert.verifySteps([
"spreadsheet_fetch_debit_credit",
JSON.stringify(
camelToSnakeObject({
dateRange: parseAccountingDate("2022"),
codes: ["100", "200"],
companyId: null,
includeUnposted: false,
})
),
]);
});
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 (const 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 waitForDataSourcesLoaded(model);
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"),
codes: ["100104", "200104"],
companyId: null,
includeUnposted: false,
})
),
]);
});
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 (const 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 waitForDataSourcesLoaded(model);
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"),
codes: ["100"],
companyId: null,
includeUnposted: false,
})
),
"spreadsheet_fetch_debit_credit",
JSON.stringify(
camelToSnakeObject({
dateRange: parseAccountingDate("2022"),
codes: ["100104", "200104"],
companyId: null,
includeUnposted: false,
})
),
]);
});
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,
});
});
});

View file

@ -0,0 +1,105 @@
/** @odoo-module */
import { setCellContent } from "@spreadsheet/../tests/utils/commands";
import {
createModelWithDataSource,
waitForDataSourcesLoaded,
} 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 waitForDataSourcesLoaded(model);
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 waitForDataSourcesLoaded(model);
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 waitForDataSourcesLoaded(model);
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."
);
});
});

View file

@ -0,0 +1,88 @@
/** @odoo-module */
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,
waitForDataSourcesLoaded,
} 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, [
{
codes: ["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)`);
setCellContent(model, "A3", `=ODOO.BALANCE("100", 2020, , , FALSE)`);
setCellContent(model, "A4", `=ODOO.BALANCE("100", 2020, , , )`);
await waitForDataSourcesLoaded(model);
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);
selectCell(model, "A3");
assert.equal(root.isVisible(env), true);
await root.action(env);
assert.verifySteps(["drill down action"]);
selectCell(model, "A4");
assert.equal(root.isVisible(env), true);
await root.action(env);
assert.verifySteps(["drill down action"]);
});
});