diff --git a/web_view_monetary_format/README.rst b/web_view_monetary_format/README.rst new file mode 100644 index 00000000000..6c89ff1397c --- /dev/null +++ b/web_view_monetary_format/README.rst @@ -0,0 +1,110 @@ +======================== +Web View Monetary Format +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:34a379bb5c1b0ae0a35bcca3779218eefe0504261d8c6a1b99de0b78e19de893 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/18.0/web_view_monetary_format + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-18-0/web-18-0-web_view_monetary_format + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +In standard Odoo, pivot views format all monetary fields with the same +number of decimal places, regardless of the currency associated with +each record. For example, Japanese Yen (JPY) values that should display +as whole numbers (e.g. "1,000") are shown with unnecessary decimals +(e.g. "1,000.00"). + +This module patches the pivot view to respect the ``currency_digits`` +attribute of monetary fields so that each cell is formatted according to +its currency's rounding precision. + +Note: This module does not need to be migrated to Odoo 19, as the core +now covers this functionality. + +**Table of contents** + +.. contents:: + :local: + +Known issues / Roadmap +====================== + +- Mixed-currency aggregation (e.g. group totals spanning JPY and USD + rows) is out of scope. The module formats each cell individually but + does not attempt to handle or warn about summing values across + different currencies. +- Graph views are not yet covered. Currently only pivot views are + supported. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Quartile + +Contributors +------------ + +- `Quartile `__ + + - Yoshi Tashiro + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-yostashiro| image:: https://github.com/yostashiro.png?size=40px + :target: https://github.com/yostashiro + :alt: yostashiro +.. |maintainer-AungKoKoLin1997| image:: https://github.com/AungKoKoLin1997.png?size=40px + :target: https://github.com/AungKoKoLin1997 + :alt: AungKoKoLin1997 + +Current `maintainers `__: + +|maintainer-yostashiro| |maintainer-AungKoKoLin1997| + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_view_monetary_format/__init__.py b/web_view_monetary_format/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web_view_monetary_format/__manifest__.py b/web_view_monetary_format/__manifest__.py new file mode 100644 index 00000000000..92412357a58 --- /dev/null +++ b/web_view_monetary_format/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Web View Monetary Format", + "version": "18.0.1.0.0", + "category": "Hidden", + "summary": "Currency-aware decimal formatting in aggregated views", + "author": "Quartile, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "license": "AGPL-3", + "depends": ["web"], + "assets": { + "web.assets_backend_lazy": [ + "web_view_monetary_format/static/src/views/pivot/**/*", + ], + "web.assets_tests": [ + "web_view_monetary_format/static/src/test/**/*", + ], + }, + "installable": True, + "maintainers": ["yostashiro", "AungKoKoLin1997"], +} diff --git a/web_view_monetary_format/pyproject.toml b/web_view_monetary_format/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/web_view_monetary_format/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_view_monetary_format/readme/CONTRIBUTORS.md b/web_view_monetary_format/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..d2b2daa57a4 --- /dev/null +++ b/web_view_monetary_format/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Quartile](https://www.quartile.co) + - Yoshi Tashiro diff --git a/web_view_monetary_format/readme/DESCRIPTION.md b/web_view_monetary_format/readme/DESCRIPTION.md new file mode 100644 index 00000000000..0d1dd6bb0be --- /dev/null +++ b/web_view_monetary_format/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +In standard Odoo, pivot views format all monetary fields with the same number of +decimal places, regardless of the currency associated with each record. For example, +Japanese Yen (JPY) values that should display as whole numbers (e.g. "1,000") are shown +with unnecessary decimals (e.g. "1,000.00"). + +This module patches the pivot view to respect the `currency_digits` attribute of +monetary fields so that each cell is formatted according to its currency's rounding +precision. + +Note: This module does not need to be migrated to Odoo 19, as the core now covers this +functionality. diff --git a/web_view_monetary_format/readme/ROADMAP.md b/web_view_monetary_format/readme/ROADMAP.md new file mode 100644 index 00000000000..91083d6b63b --- /dev/null +++ b/web_view_monetary_format/readme/ROADMAP.md @@ -0,0 +1,4 @@ +- Mixed-currency aggregation (e.g. group totals spanning JPY and USD rows) is out of + scope. The module formats each cell individually but does not attempt to handle or + warn about summing values across different currencies. +- Graph views are not yet covered. Currently only pivot views are supported. diff --git a/web_view_monetary_format/static/description/index.html b/web_view_monetary_format/static/description/index.html new file mode 100644 index 00000000000..0d4bf871a31 --- /dev/null +++ b/web_view_monetary_format/static/description/index.html @@ -0,0 +1,446 @@ + + + + + +Web View Monetary Format + + + +
+

Web View Monetary Format

+ + +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

In standard Odoo, pivot views format all monetary fields with the same +number of decimal places, regardless of the currency associated with +each record. For example, Japanese Yen (JPY) values that should display +as whole numbers (e.g. “1,000”) are shown with unnecessary decimals +(e.g. “1,000.00”).

+

This module patches the pivot view to respect the currency_digits +attribute of monetary fields so that each cell is formatted according to +its currency’s rounding precision.

+

Note: This module does not need to be migrated to Odoo 19, as the core +now covers this functionality.

+

Table of contents

+ +
+

Known issues / Roadmap

+
    +
  • Mixed-currency aggregation (e.g. group totals spanning JPY and USD +rows) is out of scope. The module formats each cell individually but +does not attempt to handle or warn about summing values across +different currencies.
  • +
  • Graph views are not yet covered. Currently only pivot views are +supported.
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Quartile
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

yostashiro AungKoKoLin1997

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/web_view_monetary_format/static/src/test/tour.esm.js b/web_view_monetary_format/static/src/test/tour.esm.js new file mode 100644 index 00000000000..47336680cba --- /dev/null +++ b/web_view_monetary_format/static/src/test/tour.esm.js @@ -0,0 +1,33 @@ +/* Copyright 2026 Quartile (https://www.quartile.co) + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {registry} from "@web/core/registry"; + +registry.category("web_tour.tours").add("web_view_monetary_format_tour", { + test: true, + steps: () => [ + { + trigger: ".o_pivot_view", + run() { + const values = [ + ...document.querySelectorAll(".o_pivot_cell_value div"), + ].map((el) => el.textContent.trim()); + if (!values.some((v) => v === "3,000")) { + throw new Error( + `Expected JPY value "3,000" (0 decimals) not found in: ${values.join(", ")}` + ); + } + if (values.some((v) => v === "3,000.00")) { + throw new Error( + 'JPY value should not have decimal places, but found "3,000.00"' + ); + } + if (!values.some((v) => v === "50.50")) { + throw new Error( + `Expected USD value "50.50" (2 decimals) not found in: ${values.join(", ")}` + ); + } + }, + }, + ], +}); diff --git a/web_view_monetary_format/static/src/views/pivot/pivot_model.esm.js b/web_view_monetary_format/static/src/views/pivot/pivot_model.esm.js new file mode 100644 index 00000000000..1e7c4279284 --- /dev/null +++ b/web_view_monetary_format/static/src/views/pivot/pivot_model.esm.js @@ -0,0 +1,62 @@ +/* Copyright 2026 Quartile (https://www.quartile.co) + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {PivotModel} from "@web/views/pivot/pivot_model"; +import {patch} from "@web/core/utils/patch"; + +patch(PivotModel.prototype, { + _getMeasureSpecs(config) { + const specs = super._getMeasureSpecs(config); + const {metaData} = config; + const currencyFields = new Set(); + for (const measure of metaData.activeMeasures) { + if (measure === "__count") { + continue; + } + const field = metaData.fields[measure]; + if ( + field.type === "monetary" && + field.currency_field && + !metaData.activeMeasures.includes(field.currency_field) + ) { + currencyFields.add(field.currency_field); + } + } + for (const currencyField of currencyFields) { + specs.push(`${currencyField}:array_agg`); + } + return specs; + }, + + _getMeasurements(group, config) { + const measurements = super._getMeasurements(group, config); + const {metaData} = config; + for (const measure of metaData.activeMeasures) { + if (measure === "__count") { + continue; + } + const field = metaData.fields[measure]; + if (field.type === "monetary" && field.currency_field) { + const rawValue = group[field.currency_field]; + if (Array.isArray(rawValue)) { + if ( + rawValue.length === 2 && + typeof rawValue[0] === "number" && + typeof rawValue[1] === "string" + ) { + measurements[`__currency__${measure}`] = rawValue[0]; + } else { + const uniqueIds = [ + ...new Set( + rawValue.filter((id) => id !== false && id !== null) + ), + ]; + measurements[`__currency__${measure}`] = + uniqueIds.length === 1 ? uniqueIds[0] : false; + } + } + } + } + return measurements; + }, +}); diff --git a/web_view_monetary_format/static/src/views/pivot/pivot_renderer.esm.js b/web_view_monetary_format/static/src/views/pivot/pivot_renderer.esm.js new file mode 100644 index 00000000000..706e3a74079 --- /dev/null +++ b/web_view_monetary_format/static/src/views/pivot/pivot_renderer.esm.js @@ -0,0 +1,44 @@ +/* Copyright 2026 Quartile (https://www.quartile.co) + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {PivotRenderer} from "@web/views/pivot/pivot_renderer"; +import {patch} from "@web/core/utils/patch"; +import {registry} from "@web/core/registry"; + +const formatters = registry.category("formatters"); + +patch(PivotRenderer.prototype, { + getFormattedValue(cell) { + const field = this.model.metaData.measures[cell.measure]; + let formatType = this.model.metaData.widgets[cell.measure]; + if (!formatType) { + const fieldType = field.type; + formatType = ["many2one", "reference"].includes(fieldType) + ? "integer" + : fieldType; + } + const formatter = formatters.get(formatType); + if (field.type === "monetary") { + const currencyId = this._getCellCurrencyId(cell); + if (currencyId) { + return formatter(cell.value, { + ...field, + currencyId, + noSymbol: true, + }); + } + } + return formatter(cell.value, field); + }, + + _getCellCurrencyId(cell) { + const key = JSON.stringify(cell.groupId); + const measurements = this.model.data.measurements[key]; + if (!measurements) { + return false; + } + const originIndex = cell.originIndexes ? cell.originIndexes[0] : 0; + const originData = measurements[originIndex]; + return originData ? originData[`__currency__${cell.measure}`] : false; + }, +}); diff --git a/web_view_monetary_format/tests/__init__.py b/web_view_monetary_format/tests/__init__.py new file mode 100644 index 00000000000..5965967b0fa --- /dev/null +++ b/web_view_monetary_format/tests/__init__.py @@ -0,0 +1 @@ +from . import test_view_monetary_format diff --git a/web_view_monetary_format/tests/test_view_monetary_format.py b/web_view_monetary_format/tests/test_view_monetary_format.py new file mode 100644 index 00000000000..b418dd7ffd1 --- /dev/null +++ b/web_view_monetary_format/tests/test_view_monetary_format.py @@ -0,0 +1,70 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestPivotMonetaryFormat(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + jpy = cls.env.ref("base.JPY") + jpy.write({"active": True, "rounding": 1}) + usd = cls.env.ref("base.USD") + usd.write({"active": True, "rounding": 0.01}) + partner_model = cls.env["ir.model"]._get("res.partner") + cls.env["ir.model.fields"].create( + [ + { + "model_id": partner_model.id, + "name": "x_test_currency_id", + "ttype": "many2one", + "relation": "res.currency", + "field_description": "Test Currency", + }, + { + "model_id": partner_model.id, + "name": "x_test_amount", + "ttype": "monetary", + "field_description": "Test Amount", + "currency_field": "x_test_currency_id", + }, + ] + ) + cls.env["res.partner"].create( + [ + {"name": "JPY 1", "x_test_currency_id": jpy.id, "x_test_amount": 1000}, + {"name": "JPY 2", "x_test_currency_id": jpy.id, "x_test_amount": 2000}, + {"name": "USD 1", "x_test_currency_id": usd.id, "x_test_amount": 50.50}, + ] + ) + cls.pivot_view = cls.env["ir.ui.view"].create( + { + "name": "test.partner.monetary.pivot", + "model": "res.partner", + "type": "pivot", + "arch": """ + + + + + """, + } + ) + cls.action = cls.env["ir.actions.act_window"].create( + { + "name": "Test Monetary Pivot", + "res_model": "res.partner", + "view_mode": "pivot", + "view_id": cls.pivot_view.id, + "domain": [("x_test_currency_id", "!=", False)], + } + ) + + def test_pivot_monetary_format(self): + self.start_tour( + f"/odoo/action-{self.action.id}", + "web_view_monetary_format_tour", + login="admin", + )