diff --git a/l10n_jp_hr_employee_name_kana/README.rst b/l10n_jp_hr_employee_name_kana/README.rst new file mode 100644 index 0000000..4202e00 --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/README.rst @@ -0,0 +1,104 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=========================== +Japanese Employee Kana Name +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:fb9d60e8c0d8d0f765a12bb1134d820f4d78ea1ec4c4f8fc4addce4bc4169943 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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%2Fl10n--japan-lightgray.png?logo=github + :target: https://github.com/OCA/l10n-japan/tree/19.0/l10n_jp_hr_employee_name_kana + :alt: OCA/l10n-japan +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/l10n-japan-19-0/l10n-japan-19-0-l10n_jp_hr_employee_name_kana + :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/l10n-japan&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Adds a normalized kana reading to employees. The reading is also exposed +on the public employee model so internal users can search the employee +directory by pronunciation. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Enter the phonetic reading in **Name (Kana)** on the employee. It is +also exposed on the public employee record, so users without HR access +can find colleagues by pronunciation. + +Employees follow the kana format configured in **Japanese Kana Name**. +To keep employee furigana in hiragana while contacts and products stay +in katakana, set the ``l10n_jp_name_kana.format.hr.employee`` system +parameter — see that module's usage notes. + +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 `__: + + - Aung Ko Ko Lin + +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-AungKoKoLin1997| image:: https://github.com/AungKoKoLin1997.png?size=40px + :target: https://github.com/AungKoKoLin1997 + :alt: AungKoKoLin1997 + +Current `maintainer `__: + +|maintainer-AungKoKoLin1997| + +This module is part of the `OCA/l10n-japan `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/l10n_jp_hr_employee_name_kana/__init__.py b/l10n_jp_hr_employee_name_kana/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/l10n_jp_hr_employee_name_kana/__manifest__.py b/l10n_jp_hr_employee_name_kana/__manifest__.py new file mode 100644 index 0000000..826f323 --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Japanese Employee Kana Name", + "summary": "Add a normalized kana name to employees", + "version": "19.0.1.0.0", + "category": "Localization/Japan", + "author": "Quartile, Odoo Community Association (OCA)", + "maintainers": ["AungKoKoLin1997"], + "website": "https://github.com/OCA/l10n-japan", + "license": "AGPL-3", + "depends": ["hr", "l10n_jp_name_kana"], + "data": ["views/hr_employee_views.xml"], + "auto_install": True, + "installable": True, +} diff --git a/l10n_jp_hr_employee_name_kana/models/__init__.py b/l10n_jp_hr_employee_name_kana/models/__init__.py new file mode 100644 index 0000000..4bf3ed8 --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/models/__init__.py @@ -0,0 +1,2 @@ +from . import hr_employee +from . import hr_employee_public diff --git a/l10n_jp_hr_employee_name_kana/models/hr_employee.py b/l10n_jp_hr_employee_name_kana/models/hr_employee.py new file mode 100644 index 0000000..6eb1496 --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/models/hr_employee.py @@ -0,0 +1,9 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class HrEmployee(models.Model): + _name = "hr.employee" + _inherit = ["hr.employee", "name.kana.mixin"] diff --git a/l10n_jp_hr_employee_name_kana/models/hr_employee_public.py b/l10n_jp_hr_employee_name_kana/models/hr_employee_public.py new file mode 100644 index 0000000..879222c --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/models/hr_employee_public.py @@ -0,0 +1,22 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class HrEmployeePublic(models.Model): + _name = "hr.employee.public" + _inherit = ["hr.employee.public", "name.kana.mixin"] + + name_kana = fields.Char(readonly=True) + + @api.model + def _get_kana_format(self): + """Follow the employee setting rather than resolving one of our own. + + This model is a read-only SQL view: the reading it exposes is the one + stored on hr.employee, so a search term has to be normalized to that + format. A setting of its own could disagree, and searching the employee + directory would then match nothing. + """ + return self.env["hr.employee"]._get_kana_format() diff --git a/l10n_jp_hr_employee_name_kana/pyproject.toml b/l10n_jp_hr_employee_name_kana/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/l10n_jp_hr_employee_name_kana/readme/CONTRIBUTORS.md b/l10n_jp_hr_employee_name_kana/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..faae328 --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Quartile](https://www.quartile.co): + - Aung Ko Ko Lin diff --git a/l10n_jp_hr_employee_name_kana/readme/DESCRIPTION.md b/l10n_jp_hr_employee_name_kana/readme/DESCRIPTION.md new file mode 100644 index 0000000..594d3ec --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +Adds a normalized kana reading to employees. The reading is also exposed on +the public employee model so internal users can search the employee directory +by pronunciation. diff --git a/l10n_jp_hr_employee_name_kana/readme/USAGE.md b/l10n_jp_hr_employee_name_kana/readme/USAGE.md new file mode 100644 index 0000000..2b2a46f --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/readme/USAGE.md @@ -0,0 +1,8 @@ +Enter the phonetic reading in **Name (Kana)** on the employee. It is also +exposed on the public employee record, so users without HR access can find +colleagues by pronunciation. + +Employees follow the kana format configured in **Japanese Kana Name**. To keep +employee furigana in hiragana while contacts and products stay in katakana, set +the `l10n_jp_name_kana.format.hr.employee` system parameter — see that module's +usage notes. diff --git a/l10n_jp_hr_employee_name_kana/static/description/index.html b/l10n_jp_hr_employee_name_kana/static/description/index.html new file mode 100644 index 0000000..12399f2 --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/static/description/index.html @@ -0,0 +1,447 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Japanese Employee Kana Name

+ +

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

+

Adds a normalized kana reading to employees. The reading is also exposed +on the public employee model so internal users can search the employee +directory by pronunciation.

+

Table of contents

+ +
+

Usage

+

Enter the phonetic reading in Name (Kana) on the employee. It is +also exposed on the public employee record, so users without HR access +can find colleagues by pronunciation.

+

Employees follow the kana format configured in Japanese Kana Name. +To keep employee furigana in hiragana while contacts and products stay +in katakana, set the l10n_jp_name_kana.format.hr.employee system +parameter — see that module’s usage notes.

+
+
+

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 maintainer:

+

AungKoKoLin1997

+

This module is part of the OCA/l10n-japan project on GitHub.

+

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

+
+
+
+
+ + diff --git a/l10n_jp_hr_employee_name_kana/tests/__init__.py b/l10n_jp_hr_employee_name_kana/tests/__init__.py new file mode 100644 index 0000000..a64b9a5 --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/tests/__init__.py @@ -0,0 +1 @@ +from . import test_hr_employee diff --git a/l10n_jp_hr_employee_name_kana/tests/test_hr_employee.py b/l10n_jp_hr_employee_name_kana/tests/test_hr_employee.py new file mode 100644 index 0000000..60f0d8c --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/tests/test_hr_employee.py @@ -0,0 +1,66 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + +from odoo.addons.l10n_jp_name_kana.models.name_kana_mixin import KANA_FORMAT_PARAM + + +class TestHrEmployeeNameKana(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.employee_model = cls.env["hr.employee"] + cls.public_model = cls.env["hr.employee.public"] + cls.param = cls.env["ir.config_parameter"].sudo() + + def setUp(self): + super().setUp() + # ir.config_parameter is ormcached and that cache is only cleared + # between test classes, so a rolled-back parameter would leak here. + self.env.registry.clear_cache("stable") + + def _search_public_ids(self, term): + return { + employee_id + for employee_id, _display_name in self.public_model.name_search(term) + } + + def test_public_employee_exposes_kana_and_searches_it(self): + employee = self.employee_model.create( + {"name": "Kana Employee", "name_kana": "ヤマダ タロウ"} + ) + self.assertEqual(employee.name_kana, "ヤマダ タロウ") + self.assertEqual( + self.public_model.browse(employee.id).name_kana, "ヤマダ タロウ" + ) + self.assertIn(employee.id, self._search_public_ids("やまだ たろう")) + + def test_search_view_field_normalizes_the_term(self): + """Both employee search views filter on name_kana_search. + + A search view compares the term to the column as typed, so a filter on + name_kana itself would only match a term typed in the stored form. + """ + employee = self.employee_model.create( + {"name": "Kana Employee", "name_kana": "ヤマダ タロウ"} + ) + for model in (self.employee_model, self.public_model): + for term in ("ヤマダ タロウ", "やまだ たろう", "ヤマダ タロウ"): + with self.subTest(model=model._name, term=term): + found = model.search([("name_kana_search", "ilike", term)]) + self.assertIn(employee.id, found.ids) + + def test_public_employee_follows_the_employee_format(self): + """The SQL view stores nothing, so it must not resolve a format of its own. + + With a setting of its own it would fall back to the global one here and + normalize the search term to katakana, matching nothing. + """ + self.param.set_param(KANA_FORMAT_PARAM, "full_width_katakana") + self.param.set_param(f"{KANA_FORMAT_PARAM}.hr.employee", "hiragana") + employee = self.employee_model.create( + {"name": "Hiragana Employee", "name_kana": "ヤマダ タロウ"} + ) + self.assertEqual(employee.name_kana, "やまだ たろう") + self.assertIn(employee.id, self._search_public_ids("ヤマダ タロウ")) diff --git a/l10n_jp_hr_employee_name_kana/views/hr_employee_views.xml b/l10n_jp_hr_employee_name_kana/views/hr_employee_views.xml new file mode 100644 index 0000000..c46bba5 --- /dev/null +++ b/l10n_jp_hr_employee_name_kana/views/hr_employee_views.xml @@ -0,0 +1,65 @@ + + + + + hr.employee.form (kana name) + hr.employee + + + + + + + + + hr.employee.list (kana name) + hr.employee + + + + + + + + + hr.employee.search (kana name) + hr.employee + + + + + + + + + hr.employee.public.form (kana name) + hr.employee.public + + + + + + + + + hr.employee.public.list (kana name) + hr.employee.public + + + + + + + + + hr.employee.public.search (kana name) + hr.employee.public + + + + + + + + diff --git a/l10n_jp_name_kana/README.rst b/l10n_jp_name_kana/README.rst new file mode 100644 index 0000000..9161b1d --- /dev/null +++ b/l10n_jp_name_kana/README.rst @@ -0,0 +1,151 @@ +================== +Japanese Kana Name +================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:09cdd6164ee63892862c774a17fee5725a57b2633afb0964cd49a6759eba0dff + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fl10n--japan-lightgray.png?logo=github + :target: https://github.com/OCA/l10n-japan/tree/19.0/l10n_jp_name_kana + :alt: OCA/l10n-japan +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/l10n-japan-19-0/l10n-japan-19-0-l10n_jp_name_kana + :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/l10n-japan&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Adds a phonetic reading (カナ / ふりがな) to contacts, and provides the +abstract model that carries the same behaviour to other models. + +A reading is the sort key and the lookup key for a name written in +kanji, since kanji has no usable collation order and staff search by +pronunciation. It is only useful if the same pronunciation always +produces the same stored string, so kana values are normalized on write. +Administrators choose the format: a global one, overridable per model. +Full-width katakana is used when nothing is configured. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Enter the phonetic reading in **Name (Kana)** on the contact. The field +is available as an optional list column, and a reading typed either into +the **Name (Kana)** search filter or into a contact lookup finds the +record whichever kana form it is typed in. + +Administrators select the kana format under **Settings > General +Settings > Japanese Kana Names**. Full-width katakana is the default; +half-width katakana and hiragana are the alternatives. + +A single model can depart from that, for the case where personal +furigana is kept in hiragana while contacts and products stay in +katakana. This is rare, so it has no setting of its own: add a system +parameter under **Settings > Technical > System Parameters** whose key +is the model name appended to the global one, and whose value is one of +``full_width_katakana``, ``half_width_katakana`` or ``hiragana``. + ++-----------------------------------------------+-----------------------------+ +| Key | Applies to | ++===============================================+=============================+ +| ``l10n_jp_name_kana.format`` | every model with a reading | ++-----------------------------------------------+-----------------------------+ +| ``l10n_jp_name_kana.format.res.partner`` | contacts | ++-----------------------------------------------+-----------------------------+ +| ``l10n_jp_name_kana.format.hr.employee`` | employees | ++-----------------------------------------------+-----------------------------+ +| ``l10n_jp_name_kana.format.product.template`` | products and their variants | ++-----------------------------------------------+-----------------------------+ + +An empty or absent per-model key means the model follows the global +format. + +Changing any of these applies to the readings saved from then on; see +the known issues for the ones already stored. + +Known issues / Roadmap +====================== + +Changing a kana format does not rewrite the readings that are already +stored. Only readings saved after the change follow the new format; the +older ones keep the format they were saved in, and because a search term +is normalized to the current format, they stop being found by it until +they are saved again. + +Rewriting them from the request that changes the setting is what this +module deliberately does not do: readings are close to unique, so there +is nothing to group the updates by, and one UPDATE per row on a table of +any size outlasts the request, rolls it back, and leaves the format +unchangeable with no diagnosable error. Doing it safely needs a batched +background job, which is left for a future version. + +Until then, pick the format when the module is installed. If it has to +change afterwards, re-save the affected readings separately -- writing a +reading back through the ORM normalizes it to the current format. + +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 `__: + + - Aung Ko Ko Lin + +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-AungKoKoLin1997| image:: https://github.com/AungKoKoLin1997.png?size=40px + :target: https://github.com/AungKoKoLin1997 + :alt: AungKoKoLin1997 + +Current `maintainer `__: + +|maintainer-AungKoKoLin1997| + +This module is part of the `OCA/l10n-japan `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/l10n_jp_name_kana/__init__.py b/l10n_jp_name_kana/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/l10n_jp_name_kana/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/l10n_jp_name_kana/__manifest__.py b/l10n_jp_name_kana/__manifest__.py new file mode 100644 index 0000000..e5e9336 --- /dev/null +++ b/l10n_jp_name_kana/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Japanese Kana Name", + "summary": "Add a normalized kana name to contacts, and a mixin to reuse it", + "version": "19.0.1.0.0", + "category": "Localization/Japan", + "author": "Quartile, Odoo Community Association (OCA)", + "maintainers": ["AungKoKoLin1997"], + "website": "https://github.com/OCA/l10n-japan", + "license": "AGPL-3", + "depends": ["base_setup"], + "data": [ + "views/res_partner_views.xml", + "views/res_config_settings_views.xml", + ], + "external_dependencies": {"python": ["jaconv"]}, + "installable": True, +} diff --git a/l10n_jp_name_kana/models/__init__.py b/l10n_jp_name_kana/models/__init__.py new file mode 100644 index 0000000..7b8b037 --- /dev/null +++ b/l10n_jp_name_kana/models/__init__.py @@ -0,0 +1,4 @@ +from . import name_kana_mixin +from . import ir_config_parameter +from . import res_partner +from . import res_config_settings diff --git a/l10n_jp_name_kana/models/ir_config_parameter.py b/l10n_jp_name_kana/models/ir_config_parameter.py new file mode 100644 index 0000000..327f666 --- /dev/null +++ b/l10n_jp_name_kana/models/ir_config_parameter.py @@ -0,0 +1,44 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + +from .name_kana_mixin import KANA_FORMAT_PARAM + + +class IrConfigParameter(models.Model): + _inherit = "ir.config_parameter" + + def _is_kana_format_key(self, key): + # The global key, or a per-model one below it. Matching on the bare + # prefix would also claim an unrelated key that merely starts with the + # same text, and refuse it as an invalid kana format. + return bool(key) and ( + key == KANA_FORMAT_PARAM or key.startswith(f"{KANA_FORMAT_PARAM}.") + ) + + def _check_kana_format_value(self, value): + """Refuse an unusable format at configuration time. + + Hooked here rather than on res.config.settings so that it covers the + settings page, a hand edit under Technical > System Parameters and a + programmatic set_param alike. The per-model formats have no settings + field, so they are typed by hand into a free-text parameter; without + this the typo would only surface later, when someone saves a record. + """ + if value: + self.env["name.kana.mixin"]._validate_kana_format(value) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if self._is_kana_format_key(vals.get("key")): + self._check_kana_format_value(vals.get("value")) + return super().create(vals_list) + + def write(self, vals): + if "value" in vals: + keys = set(self.mapped("key")) | {vals.get("key")} + if any(self._is_kana_format_key(key) for key in keys): + self._check_kana_format_value(vals["value"]) + return super().write(vals) diff --git a/l10n_jp_name_kana/models/name_kana_mixin.py b/l10n_jp_name_kana/models/name_kana_mixin.py new file mode 100644 index 0000000..053022e --- /dev/null +++ b/l10n_jp_name_kana/models/name_kana_mixin.py @@ -0,0 +1,161 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections.abc import Set as AbstractSet + +import jaconv + +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.fields import Domain + +DEFAULT_KANA_FORMAT = "full_width_katakana" + +# Global key. A model-specific key is this one suffixed with the model name, +# e.g. "l10n_jp_name_kana.format.res.partner". +KANA_FORMAT_PARAM = "l10n_jp_name_kana.format" + +KANA_FORMAT_SELECTION = [ + ("full_width_katakana", "Full-width Katakana"), + ("half_width_katakana", "Half-width Katakana"), + ("hiragana", "Hiragana"), +] + +SUPPORTED_KANA_FORMATS = dict(KANA_FORMAT_SELECTION) + + +class NameKanaMixin(models.AbstractModel): + _name = "name.kana.mixin" + _description = "Kana Name Mixin" + + name_kana = fields.Char(string="Name (Kana)", index="trigram") + name_kana_search = fields.Char( + string="Name (Kana) Search", + compute="_compute_name_kana_search", + search="_search_name_kana", + ) + + @api.depends("name_kana") + def _compute_name_kana_search(self): + for record in self: + record.name_kana_search = record.name_kana + + @api.model + def _search_name_kana(self, operator, value): + """Search the stored readings with the term put in their format first. + + Backs the name_kana_search field, so that the search view and the + contact lookup agree: both find a record whichever kana form the term + is typed in. + """ + return Domain( + "name_kana", operator, self._normalize_name_kana_search_value(value) + ) + + @api.model + def _search_display_name(self, operator, value): + domain = super()._search_display_name(operator, value) + kana_domain = self._search_name_kana(operator, value) + if operator in Domain.NEGATIVE_OPERATORS: + return Domain.AND([domain, kana_domain]) + return Domain.OR([domain, kana_domain]) + + @api.model + def _get_kana_format(self): + """Return the format this model stores its readings in. + + A model-specific setting wins over the global one. Both are system + parameters rather than company fields: res.partner and product.template + rows are shared between companies by default, so a per-company format + would let two users produce two spellings of the same row. + """ + get_param = self.env["ir.config_parameter"].sudo().get_param + return ( + get_param(f"{KANA_FORMAT_PARAM}.{self._name}") + or get_param(KANA_FORMAT_PARAM) + or DEFAULT_KANA_FORMAT + ) + + @api.model + def _validate_kana_format(self, kana_format): + """Raise unless the format is one this module knows how to produce. + + Checked when the parameter is written, so a typo is refused at + configuration time, and again here, because the parameter is free text + and could also have been set outside the ORM. + """ + if kana_format in SUPPORTED_KANA_FORMATS: + return + raise UserError( + self.env._( + "%(format)s is not a valid kana format. Check the " + "%(parameter)s system parameters; expected one of: " + "%(supported)s.", + format=kana_format, + parameter=f"{KANA_FORMAT_PARAM}*", + supported=", ".join(SUPPORTED_KANA_FORMATS), + ) + ) + + @api.model + def _normalize_name_kana(self, value): + """Return the canonical representation of a kana name.""" + if not isinstance(value, str) or not value.strip(): + return value + kana_format = self._get_kana_format() + self._validate_kana_format(kana_format) + value = " ".join(value.split()) + value = jaconv.h2z(value, kana=True, digit=False, ascii=False) + if kana_format == "hiragana": + return jaconv.kata2hira(value) + value = jaconv.hira2kata(value) + if kana_format == "half_width_katakana": + return jaconv.z2h(value, kana=True, digit=False, ascii=False) + return value + + @api.model + def _normalize_name_kana_write_value(self, value): + """Normalize a value on its way into the column. + + A blank reading is stored as no reading at all. A whitespace-only + string is truthy, so it would otherwise count as a reading everywhere: + it would pass a "has a reading" filter and sort among the readings. The + search counterpart below deliberately does the opposite and keeps a + blank as typed, because a blank term must not turn into a search for an + empty reading. + """ + if isinstance(value, str) and not value.strip(): + return False + return self._normalize_name_kana(value) + + @api.model + def _normalize_name_kana_search_value(self, value): + """Normalize a search term, which may be a single value or a collection. + + `in` and `not in` carry a collection, and the ORM rewrites `=` into `in` + with an ``OrderedSet``, so this has to accept any set as well as a list + or a tuple -- the same types core treats as a collection here. Checking + only list and tuple lets the set through unnormalized, and the kana + condition then searches for the term as typed and matches nothing. + """ + if isinstance(value, (list, tuple, AbstractSet)): + return [self._normalize_name_kana(item) for item in value] + return self._normalize_name_kana(value) + + @api.model_create_multi + def create(self, vals_list): + normalized_vals_list = [] + for vals in vals_list: + vals = dict(vals) + if "name_kana" in vals: + vals["name_kana"] = self._normalize_name_kana_write_value( + vals["name_kana"] + ) + normalized_vals_list.append(vals) + return super().create(normalized_vals_list) + + def write(self, vals): + if "name_kana" in vals: + vals = dict(vals) + vals["name_kana"] = self._normalize_name_kana_write_value(vals["name_kana"]) + return super().write(vals) diff --git a/l10n_jp_name_kana/models/res_config_settings.py b/l10n_jp_name_kana/models/res_config_settings.py new file mode 100644 index 0000000..e72482d --- /dev/null +++ b/l10n_jp_name_kana/models/res_config_settings.py @@ -0,0 +1,20 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +from .name_kana_mixin import ( + DEFAULT_KANA_FORMAT, + KANA_FORMAT_PARAM, + KANA_FORMAT_SELECTION, +) + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + kana_format = fields.Selection( + selection=KANA_FORMAT_SELECTION, + config_parameter=KANA_FORMAT_PARAM, + default=DEFAULT_KANA_FORMAT, + ) diff --git a/l10n_jp_name_kana/models/res_partner.py b/l10n_jp_name_kana/models/res_partner.py new file mode 100644 index 0000000..5b8fe91 --- /dev/null +++ b/l10n_jp_name_kana/models/res_partner.py @@ -0,0 +1,9 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ResPartner(models.Model): + _name = "res.partner" + _inherit = ["res.partner", "name.kana.mixin"] diff --git a/l10n_jp_name_kana/pyproject.toml b/l10n_jp_name_kana/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/l10n_jp_name_kana/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/l10n_jp_name_kana/readme/CONTRIBUTORS.md b/l10n_jp_name_kana/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..faae328 --- /dev/null +++ b/l10n_jp_name_kana/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Quartile](https://www.quartile.co): + - Aung Ko Ko Lin diff --git a/l10n_jp_name_kana/readme/DESCRIPTION.md b/l10n_jp_name_kana/readme/DESCRIPTION.md new file mode 100644 index 0000000..e8eb276 --- /dev/null +++ b/l10n_jp_name_kana/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +Adds a phonetic reading (カナ / ふりがな) to contacts, and provides the abstract +model that carries the same behaviour to other models. + +A reading is the sort key and the lookup key for a name written in kanji, since +kanji has no usable collation order and staff search by pronunciation. It is +only useful if the same pronunciation always produces the same stored string, so +kana values are normalized on write. Administrators choose the format: a global +one, overridable per model. Full-width katakana is used when nothing is +configured. diff --git a/l10n_jp_name_kana/readme/ROADMAP.md b/l10n_jp_name_kana/readme/ROADMAP.md new file mode 100644 index 0000000..16178d0 --- /dev/null +++ b/l10n_jp_name_kana/readme/ROADMAP.md @@ -0,0 +1,15 @@ +Changing a kana format does not rewrite the readings that are already stored. +Only readings saved after the change follow the new format; the older ones keep +the format they were saved in, and because a search term is normalized to the +current format, they stop being found by it until they are saved again. + +Rewriting them from the request that changes the setting is what this module +deliberately does not do: readings are close to unique, so there is nothing to +group the updates by, and one UPDATE per row on a table of any size outlasts the +request, rolls it back, and leaves the format unchangeable with no diagnosable +error. Doing it safely needs a batched background job, which is left for a +future version. + +Until then, pick the format when the module is installed. If it has to change +afterwards, re-save the affected readings separately -- writing a reading back +through the ORM normalizes it to the current format. diff --git a/l10n_jp_name_kana/readme/USAGE.md b/l10n_jp_name_kana/readme/USAGE.md new file mode 100644 index 0000000..e9e3da6 --- /dev/null +++ b/l10n_jp_name_kana/readme/USAGE.md @@ -0,0 +1,27 @@ +Enter the phonetic reading in **Name (Kana)** on the contact. The field is +available as an optional list column, and a reading typed either into the +**Name (Kana)** search filter or into a contact lookup finds the record +whichever kana form it is typed in. + +Administrators select the kana format under **Settings > General Settings > +Japanese Kana Names**. Full-width katakana is the default; half-width katakana +and hiragana are the alternatives. + +A single model can depart from that, for the case where personal furigana is +kept in hiragana while contacts and products stay in katakana. This is rare, so +it has no setting of its own: add a system parameter under **Settings > +Technical > System Parameters** whose key is the model name appended to the +global one, and whose value is one of `full_width_katakana`, +`half_width_katakana` or `hiragana`. + +| Key | Applies to | +|---|---| +| `l10n_jp_name_kana.format` | every model with a reading | +| `l10n_jp_name_kana.format.res.partner` | contacts | +| `l10n_jp_name_kana.format.hr.employee` | employees | +| `l10n_jp_name_kana.format.product.template` | products and their variants | + +An empty or absent per-model key means the model follows the global format. + +Changing any of these applies to the readings saved from then on; see the known +issues for the ones already stored. diff --git a/l10n_jp_name_kana/static/description/index.html b/l10n_jp_name_kana/static/description/index.html new file mode 100644 index 0000000..c3ec02f --- /dev/null +++ b/l10n_jp_name_kana/static/description/index.html @@ -0,0 +1,496 @@ + + + + + +Japanese Kana Name + + + +
+

Japanese Kana Name

+ + +

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

+

Adds a phonetic reading (カナ / ふりがな) to contacts, and provides the +abstract model that carries the same behaviour to other models.

+

A reading is the sort key and the lookup key for a name written in +kanji, since kanji has no usable collation order and staff search by +pronunciation. It is only useful if the same pronunciation always +produces the same stored string, so kana values are normalized on write. +Administrators choose the format: a global one, overridable per model. +Full-width katakana is used when nothing is configured.

+

Table of contents

+ +
+

Usage

+

Enter the phonetic reading in Name (Kana) on the contact. The field +is available as an optional list column, and a reading typed either into +the Name (Kana) search filter or into a contact lookup finds the +record whichever kana form it is typed in.

+

Administrators select the kana format under Settings > General +Settings > Japanese Kana Names. Full-width katakana is the default; +half-width katakana and hiragana are the alternatives.

+

A single model can depart from that, for the case where personal +furigana is kept in hiragana while contacts and products stay in +katakana. This is rare, so it has no setting of its own: add a system +parameter under Settings > Technical > System Parameters whose key +is the model name appended to the global one, and whose value is one of +full_width_katakana, half_width_katakana or hiragana.

+ ++++ + + + + + + + + + + + + + + + + + + + +
KeyApplies to
l10n_jp_name_kana.formatevery model with a reading
l10n_jp_name_kana.format.res.partnercontacts
l10n_jp_name_kana.format.hr.employeeemployees
l10n_jp_name_kana.format.product.templateproducts and their variants
+

An empty or absent per-model key means the model follows the global +format.

+

Changing any of these applies to the readings saved from then on; see +the known issues for the ones already stored.

+
+
+

Known issues / Roadmap

+

Changing a kana format does not rewrite the readings that are already +stored. Only readings saved after the change follow the new format; the +older ones keep the format they were saved in, and because a search term +is normalized to the current format, they stop being found by it until +they are saved again.

+

Rewriting them from the request that changes the setting is what this +module deliberately does not do: readings are close to unique, so there +is nothing to group the updates by, and one UPDATE per row on a table of +any size outlasts the request, rolls it back, and leaves the format +unchangeable with no diagnosable error. Doing it safely needs a batched +background job, which is left for a future version.

+

Until then, pick the format when the module is installed. If it has to +change afterwards, re-save the affected readings separately – writing a +reading back through the ORM normalizes it to the current format.

+
+
+

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 maintainer:

+

AungKoKoLin1997

+

This module is part of the OCA/l10n-japan project on GitHub.

+

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

+
+
+
+ + diff --git a/l10n_jp_name_kana/tests/__init__.py b/l10n_jp_name_kana/tests/__init__.py new file mode 100644 index 0000000..19deaa6 --- /dev/null +++ b/l10n_jp_name_kana/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_name_kana_mixin +from . import test_res_partner diff --git a/l10n_jp_name_kana/tests/common.py b/l10n_jp_name_kana/tests/common.py new file mode 100644 index 0000000..67d0cf0 --- /dev/null +++ b/l10n_jp_name_kana/tests/common.py @@ -0,0 +1,18 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +# Reference cases for the canonical (katakana) form, shared between the mixin's +# own tests and the res.partner ones, so that the same table is exercised both +# directly against the converter and through create() and write() on a model +# that actually has a table. +KANA_CASES = [ + # (input, expected) -- note + ("ヤマダショウジ", "ヤマダショウジ"), # half-width -> full-width + ("やまだしょうじ", "ヤマダショウジ"), # hiragana -> katakana + ("ガ", "ガ"), # half-width voiced pair composes + ("コーヒー", "コーヒー"), # long-vowel mark survives + ("ヤマダ タロウ", "ヤマダ タロウ"), # separation is preserved + ("ヤマダ タロウ", "ヤマダ タロウ"), # IME full-width space (U+3000) is canonical + ("ヤマダ タロウ", "ヤマダ タロウ"), # a repeated separator collapses + (" ヤマダ タロウ ", "ヤマダ タロウ"), # the edges are trimmed +] diff --git a/l10n_jp_name_kana/tests/test_name_kana_mixin.py b/l10n_jp_name_kana/tests/test_name_kana_mixin.py new file mode 100644 index 0000000..427262a --- /dev/null +++ b/l10n_jp_name_kana/tests/test_name_kana_mixin.py @@ -0,0 +1,106 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + +from ..models.name_kana_mixin import KANA_FORMAT_PARAM +from .common import KANA_CASES + + +class TestNameKanaMixin(TransactionCase): + """The mixin has no table, so only the converter and the resolution of the + format setting are covered here. The ORM paths live in the consumer modules. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.mixin = cls.env["name.kana.mixin"] + cls.param = cls.env["ir.config_parameter"].sudo() + + def setUp(self): + super().setUp() + # ir.config_parameter is ormcached and that cache is only cleared + # between test classes, so a rolled-back parameter would leak here. + self.env.registry.clear_cache("stable") + + def _set_format(self, value, model=None): + key = f"{KANA_FORMAT_PARAM}.{model}" if model else KANA_FORMAT_PARAM + self.param.set_param(key, value) + + def test_full_width_katakana(self): + self._set_format("full_width_katakana") + for value, expected in KANA_CASES: + with self.subTest(value=value): + self.assertEqual(self.mixin._normalize_name_kana(value), expected) + + def test_half_width_katakana(self): + self._set_format("half_width_katakana") + self.assertEqual(self.mixin._normalize_name_kana("ヤマダショウジ"), "ヤマダショウジ") + self.assertEqual(self.mixin._normalize_name_kana("やまだしょうじ"), "ヤマダショウジ") + self.assertEqual(self.mixin._normalize_name_kana("ヤマダショウジ"), "ヤマダショウジ") + + def test_hiragana(self): + self._set_format("hiragana") + self.assertEqual(self.mixin._normalize_name_kana("ヤマダショウジ"), "やまだしょうじ") + self.assertEqual( + self.mixin._normalize_name_kana("ヤマダショウジ"), "やまだしょうじ" + ) + + def test_full_width_katakana_is_the_default(self): + self.assertEqual( + self.mixin._normalize_name_kana("やまだしょうじ"), "ヤマダショウジ" + ) + + def test_model_setting_wins_over_global_setting(self): + self._set_format("hiragana") + self._set_format("half_width_katakana", model=self.mixin._name) + self.assertEqual(self.mixin._normalize_name_kana("やまだ"), "ヤマダ") + # Clearing it falls back to the global setting. + self._set_format(False, model=self.mixin._name) + self.assertEqual(self.mixin._normalize_name_kana("ヤマダ"), "やまだ") + + def test_invalid_format_parameter_is_refused(self): + """A typo is caught when the parameter is written, not on the next save.""" + with self.assertRaises(UserError): + self._set_format("romaji") + with self.assertRaises(UserError): + self._set_format("romaji", model="res.partner") + + def test_unsupported_format_is_not_converted(self): + """Defence in depth: the parameter could be set outside the ORM.""" + with patch.object( + self.env.registry["name.kana.mixin"], + "_get_kana_format", + return_value="romaji", + ): + with self.assertRaises(UserError): + self.mixin._normalize_name_kana("ヤマダ") + + def test_blank_values_are_returned_unchanged(self): + self.assertFalse(self.mixin._normalize_name_kana(False)) + self.assertEqual(self.mixin._normalize_name_kana(""), "") + self.assertEqual(self.mixin._normalize_name_kana(" "), " ") + + def test_the_two_callers_treat_a_blank_differently(self): + """A blank is no reading on the way in, but stays as typed on the way out. + + Storing " " would leave a truthy value that counts as a reading + everywhere; normalizing a blank *term* to False would instead turn it + into a search for an empty reading. + """ + for value in (" ", " ", ""): + with self.subTest(value=repr(value)): + self.assertFalse(self.mixin._normalize_name_kana_write_value(value)) + self.assertEqual( + self.mixin._normalize_name_kana_search_value(value), value + ) + + def test_settings_field_stores_the_parameter(self): + settings = self.env["res.config.settings"].create({}) + settings.kana_format = "hiragana" + settings.set_values() + self.assertEqual(self.param.get_param(KANA_FORMAT_PARAM), "hiragana") diff --git a/l10n_jp_name_kana/tests/test_res_partner.py b/l10n_jp_name_kana/tests/test_res_partner.py new file mode 100644 index 0000000..5ecb2ab --- /dev/null +++ b/l10n_jp_name_kana/tests/test_res_partner.py @@ -0,0 +1,217 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + +from ..models.name_kana_mixin import KANA_FORMAT_PARAM +from .common import KANA_CASES + + +class TestResPartnerNameKana(TransactionCase): + """The mixin has no table of its own, so its write path is covered here, + on a real consumer model. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner_model = cls.env["res.partner"] + cls.param = cls.env["ir.config_parameter"].sudo() + + def setUp(self): + super().setUp() + # ir.config_parameter is ormcached and that cache is only cleared + # between test classes, so a rolled-back parameter would leak here. + self.env.registry.clear_cache("stable") + + def _set_format(self, value, model=None): + key = f"{KANA_FORMAT_PARAM}.{model}" if model else KANA_FORMAT_PARAM + self.param.set_param(key, value) + + def test_normalized_on_create(self): + partners = self.partner_model.create( + [ + {"name": f"Partner {index}", "name_kana": value} + for index, (value, _expected) in enumerate(KANA_CASES) + ] + ) + self.assertEqual( + partners.mapped("name_kana"), [expected for _value, expected in KANA_CASES] + ) + + def test_normalized_on_write(self): + partner = self.partner_model.create({"name": "Partner"}) + for value, expected in KANA_CASES: + with self.subTest(value=value): + partner.name_kana = value + self.assertEqual(partner.name_kana, expected) + + def test_blank_value_is_stored_as_no_reading(self): + """A whitespace-only reading is truthy, so it would count as a reading.""" + partner = self.partner_model.create({"name": "Partner", "name_kana": False}) + self.assertFalse(partner.name_kana) + for value in (" ", " "): # U+0020 and the IME's U+3000 + with self.subTest(value=repr(value)): + partner.name_kana = value + self.assertFalse(partner.name_kana) + + def test_model_setting_overrides_global_setting(self): + self._set_format("hiragana") + self._set_format("half_width_katakana", model="res.partner") + partner = self.partner_model.create( + {"name": "Partner", "name_kana": "やまだしょうじ"} + ) + self.assertEqual(partner.name_kana, "ヤマダショウジ") + + def test_model_setting_falls_back_to_global_setting(self): + self._set_format("hiragana") + partner = self.partner_model.create({"name": "Partner", "name_kana": "ヤマダショウジ"}) + self.assertEqual(partner.name_kana, "やまだしょうじ") + + def test_format_change_leaves_stored_readings_as_they_are(self): + """A format change applies to what is saved after it, not before it. + + Rewriting the rows already stored costs one UPDATE per row -- readings + are close to unique, so there is nothing to group them by -- which on a + table of any size outlasts the request that changes the setting and + rolls it back, leaving the format unchangeable. See the ROADMAP. + """ + self._set_format("full_width_katakana") + partner = self.partner_model.create( + {"name": "Partner", "name_kana": "やまだしょうじ"} + ) + self.assertEqual(partner.name_kana, "ヤマダショウジ") + settings = self.env["res.config.settings"].create({}) + settings.kana_format = "hiragana" + settings.set_values() + self.assertEqual(partner.name_kana, "ヤマダショウジ") + # Saving the reading again is what moves it to the new format. + partner.name_kana = "ヤマダショウジ" + self.assertEqual(partner.name_kana, "やまだしょうじ") + + def test_display_name_collection_operators_normalize_every_member(self): + """The ORM rewrites `=` into `in` carrying an OrderedSet. + + So this is not an exotic hand-written domain: every equality search on + display_name takes the collection branch. Normalizing only list and + tuple leaves the term as typed, and the kana condition matches nothing. + """ + partner = self.partner_model.create({"name": "Partner", "name_kana": "ヤマダショウジ"}) + term = "やまだしょうじ" + for operator, value in [("=", term), ("in", [term]), ("in", (term,))]: + with self.subTest(operator=operator, value=type(value).__name__): + found = self.partner_model.search([("display_name", operator, value)]) + self.assertIn(partner, found) + + def test_whitespace_does_not_partition_the_column(self): + """A Japanese IME emits U+3000 between name parts; imports emit U+0020. + + jaconv touches neither, nor a doubled or trailing space, so without + canonicalizing the separator the column holds one spelling per way of + typing the gap -- and each is only found by the term that repeats it. + """ + ime = self.partner_model.create({"name": "IME", "name_kana": "ヤマダ タロウ"}) + imported = self.partner_model.create( + {"name": "Imported", "name_kana": "ヤマダ タロウ"} + ) + padded = self.partner_model.create( + {"name": "Padded", "name_kana": " ヤマダ タロウ "} + ) + self.assertEqual(ime.name_kana, imported.name_kana) + self.assertEqual(ime.name_kana, padded.name_kana) + for term in ( + "ヤマダ タロウ", + "ヤマダ タロウ", + "ヤマダ タロウ", + "やまだ たろう", + "ヤマダ タロウ", + "ヤマダ タロウ ", + ): + with self.subTest(term=term): + found = { + record_id + for record_id, _name in self.partner_model.name_search(term) + } + self.assertIn(ime.id, found) + self.assertIn(imported.id, found) + self.assertIn(padded.id, found) + + def test_search_view_field_normalizes_the_term(self): + """The search view filters on name_kana_search, not on name_kana. + + A search view compares the term to the column as typed, so a filter on + name_kana itself finds nothing whenever the two are in different kana + forms -- while the contact lookup, which normalizes, finds the record. + """ + partner = self.partner_model.create({"name": "Partner", "name_kana": "ヤマダショウジ"}) + other = self.partner_model.create({"name": "Other", "name_kana": "スズキ"}) + # The trailing space is what an IME leaves behind after committing a + # conversion, so a term carrying one has to find the record too. + for term in ( + "ヤマダショウジ", + "やまだしょうじ", + "ヤマダショウジ", + "ヤマダ", + "ヤマダ ", + ): + with self.subTest(term=term): + found = self.partner_model.search([("name_kana_search", "ilike", term)]) + self.assertIn(partner, found) + self.assertNotIn(other, found) + + def test_search_view_field_is_a_readonly_mirror(self): + """It must not look like an input the reading can be entered through. + + A bare search hook with no compute reads as False and is reported + writable, which is enough for the import wizard to offer it as a + column -- one the ORM then accepts and stores nowhere. + """ + partner = self.partner_model.create({"name": "Partner", "name_kana": "ヤマダ"}) + self.assertEqual(partner.name_kana_search, partner.name_kana) + description = self.partner_model.fields_get(["name_kana_search"]) + self.assertTrue(description["name_kana_search"]["readonly"]) + # Readonly is what keeps it out of the import field list; the ORM + # itself never refuses a write to a non-stored field, so check that one + # cannot reach the stored reading. + partner.write({"name_kana_search": "スズキ"}) + partner.invalidate_recordset() + self.assertEqual(partner.name_kana, "ヤマダ") + self.assertEqual(partner.name_kana_search, "ヤマダ") + + def test_display_name_negative_operator_excludes_the_kana_match(self): + """A negative operator has to AND the kana condition, not OR it. + + OR-ing it would match everything: a record whose reading matches still + satisfies the negative condition on the other search keys, so the union + would be the whole table. + """ + matching = self.partner_model.create({"name": "Alpha", "name_kana": "ヤマダ"}) + other = self.partner_model.create({"name": "Beta", "name_kana": "スズキ"}) + found = self.partner_model.search([("display_name", "not ilike", "やまだ")]) + self.assertNotIn(matching, found) + self.assertIn(other, found) + + def test_name_search_normalizes_input_and_keeps_core_keys(self): + partner = self.partner_model.create( + { + "name": "Kana Search Partner", + "name_kana": "ヤマダショウジ", + "email": "kana-search@example.com", + "ref": "KANA-REF", + } + ) + + def search_ids(term): + return { + record_id + for record_id, _display_name in self.partner_model.name_search(term) + } + + # The reading is found however the term itself is typed. + for term in ("ヤマダショウジ", "やまだしょうじ", "ヤマダショウジ"): + with self.subTest(term=term): + self.assertIn(partner.id, search_ids(term)) + # The core search keys still work. + for term in ("kana-search@example.com", "KANA-REF"): + with self.subTest(term=term): + self.assertIn(partner.id, search_ids(term)) diff --git a/l10n_jp_name_kana/views/res_config_settings_views.xml b/l10n_jp_name_kana/views/res_config_settings_views.xml new file mode 100644 index 0000000..3e2147d --- /dev/null +++ b/l10n_jp_name_kana/views/res_config_settings_views.xml @@ -0,0 +1,23 @@ + + + + + res.config.settings.view.form.inherit.name.kana + res.config.settings + + + + + + + + + + + + diff --git a/l10n_jp_name_kana/views/res_partner_views.xml b/l10n_jp_name_kana/views/res_partner_views.xml new file mode 100644 index 0000000..4945efa --- /dev/null +++ b/l10n_jp_name_kana/views/res_partner_views.xml @@ -0,0 +1,45 @@ + + + + + res.partner.form (kana name) + res.partner + + + + + + + + + res.partner.simple.form (kana name) + res.partner + + + + + + + + + res.partner.list (kana name) + res.partner + + + + + + + + + res.partner.search (kana name) + res.partner + + + + + + + + diff --git a/l10n_jp_product_name_kana/README.rst b/l10n_jp_product_name_kana/README.rst new file mode 100644 index 0000000..9990b5c --- /dev/null +++ b/l10n_jp_product_name_kana/README.rst @@ -0,0 +1,103 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========================== +Japanese Product Kana Name +========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:db33b0e097ce83789397dfd880def0a7038c52af17ba5a9f4780a9f623982ebb + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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%2Fl10n--japan-lightgray.png?logo=github + :target: https://github.com/OCA/l10n-japan/tree/19.0/l10n_jp_product_name_kana + :alt: OCA/l10n-japan +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/l10n-japan-19-0/l10n-japan-19-0-l10n_jp_product_name_kana + :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/l10n-japan&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Adds a normalized kana reading to product templates and makes product +templates and variants searchable by pronunciation. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Enter the phonetic reading in **Name (Kana)** on the product template. +Variants inherit it, and a reading typed into a product lookup on an +order line finds the product. + +Products follow the kana format configured in **Japanese Kana Name**. To +give them a format of their own, set the +``l10n_jp_name_kana.format.product.template`` system parameter — see +that module's usage notes. + +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 `__: + + - Aung Ko Ko Lin + +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-AungKoKoLin1997| image:: https://github.com/AungKoKoLin1997.png?size=40px + :target: https://github.com/AungKoKoLin1997 + :alt: AungKoKoLin1997 + +Current `maintainer `__: + +|maintainer-AungKoKoLin1997| + +This module is part of the `OCA/l10n-japan `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/l10n_jp_product_name_kana/__init__.py b/l10n_jp_product_name_kana/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/l10n_jp_product_name_kana/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/l10n_jp_product_name_kana/__manifest__.py b/l10n_jp_product_name_kana/__manifest__.py new file mode 100644 index 0000000..8367a6f --- /dev/null +++ b/l10n_jp_product_name_kana/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Japanese Product Kana Name", + "summary": "Add a normalized kana name to products", + "version": "19.0.1.0.0", + "category": "Localization/Japan", + "author": "Quartile, Odoo Community Association (OCA)", + "maintainers": ["AungKoKoLin1997"], + "website": "https://github.com/OCA/l10n-japan", + "license": "AGPL-3", + "depends": ["l10n_jp_name_kana", "product"], + "data": ["views/product_views.xml"], + "auto_install": True, + "installable": True, +} diff --git a/l10n_jp_product_name_kana/models/__init__.py b/l10n_jp_product_name_kana/models/__init__.py new file mode 100644 index 0000000..18b37e8 --- /dev/null +++ b/l10n_jp_product_name_kana/models/__init__.py @@ -0,0 +1,2 @@ +from . import product_product +from . import product_template diff --git a/l10n_jp_product_name_kana/models/product_product.py b/l10n_jp_product_name_kana/models/product_product.py new file mode 100644 index 0000000..e789284 --- /dev/null +++ b/l10n_jp_product_name_kana/models/product_product.py @@ -0,0 +1,45 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models +from odoo.fields import Domain + + +class ProductProduct(models.Model): + _inherit = "product.product" + + @api.model + def name_search(self, name="", domain=None, operator="ilike", limit=100): + template_model = self.env["product.template"] + normalized_name = template_model._normalize_name_kana_search_value(name) + kana_domain = Domain("product_tmpl_id.name_kana", operator, normalized_name) + domain = Domain(domain or Domain.TRUE) + if operator in Domain.NEGATIVE_OPERATORS: + return super().name_search(name, domain & kana_domain, operator, limit) + results = super().name_search(name, domain, operator, limit) + remaining = limit and max(limit - len(results), 0) + if limit and not remaining: + return results + matched_ids = [record_id for record_id, _display_name in results] + kana_products = self.search_fetch( + domain & Domain("id", "not in", matched_ids) & kana_domain, + ["display_name"], + limit=remaining, + ) + return [ + *results, + *((product.id, product.display_name) for product in kana_products.sudo()), + ] + + @api.model + def _search_display_name(self, operator, value): + domain = super()._search_display_name(operator, value) + template_model = self.env["product.template"] + kana_domain = Domain( + "product_tmpl_id.name_kana", + operator, + template_model._normalize_name_kana_search_value(value), + ) + if operator in Domain.NEGATIVE_OPERATORS: + return Domain.AND([domain, kana_domain]) + return Domain.OR([domain, kana_domain]) diff --git a/l10n_jp_product_name_kana/models/product_template.py b/l10n_jp_product_name_kana/models/product_template.py new file mode 100644 index 0000000..57cc14f --- /dev/null +++ b/l10n_jp_product_name_kana/models/product_template.py @@ -0,0 +1,9 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProductTemplate(models.Model): + _name = "product.template" + _inherit = ["product.template", "name.kana.mixin"] diff --git a/l10n_jp_product_name_kana/pyproject.toml b/l10n_jp_product_name_kana/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/l10n_jp_product_name_kana/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/l10n_jp_product_name_kana/readme/CONTRIBUTORS.md b/l10n_jp_product_name_kana/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..faae328 --- /dev/null +++ b/l10n_jp_product_name_kana/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Quartile](https://www.quartile.co): + - Aung Ko Ko Lin diff --git a/l10n_jp_product_name_kana/readme/DESCRIPTION.md b/l10n_jp_product_name_kana/readme/DESCRIPTION.md new file mode 100644 index 0000000..c583d6f --- /dev/null +++ b/l10n_jp_product_name_kana/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +Adds a normalized kana reading to product templates and makes product templates +and variants searchable by pronunciation. diff --git a/l10n_jp_product_name_kana/readme/USAGE.md b/l10n_jp_product_name_kana/readme/USAGE.md new file mode 100644 index 0000000..52b9401 --- /dev/null +++ b/l10n_jp_product_name_kana/readme/USAGE.md @@ -0,0 +1,8 @@ +Enter the phonetic reading in **Name (Kana)** on the product template. Variants +inherit it, and a reading typed into a product lookup on an order line finds the +product. + +Products follow the kana format configured in **Japanese Kana Name**. To give +them a format of their own, set the +`l10n_jp_name_kana.format.product.template` system parameter — see that module's +usage notes. diff --git a/l10n_jp_product_name_kana/static/description/index.html b/l10n_jp_product_name_kana/static/description/index.html new file mode 100644 index 0000000..3feb075 --- /dev/null +++ b/l10n_jp_product_name_kana/static/description/index.html @@ -0,0 +1,446 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Japanese Product Kana Name

+ +

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

+

Adds a normalized kana reading to product templates and makes product +templates and variants searchable by pronunciation.

+

Table of contents

+ +
+

Usage

+

Enter the phonetic reading in Name (Kana) on the product template. +Variants inherit it, and a reading typed into a product lookup on an +order line finds the product.

+

Products follow the kana format configured in Japanese Kana Name. To +give them a format of their own, set the +l10n_jp_name_kana.format.product.template system parameter — see +that module’s usage notes.

+
+
+

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 maintainer:

+

AungKoKoLin1997

+

This module is part of the OCA/l10n-japan project on GitHub.

+

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

+
+
+
+
+ + diff --git a/l10n_jp_product_name_kana/tests/__init__.py b/l10n_jp_product_name_kana/tests/__init__.py new file mode 100644 index 0000000..626580e --- /dev/null +++ b/l10n_jp_product_name_kana/tests/__init__.py @@ -0,0 +1 @@ +from . import test_product diff --git a/l10n_jp_product_name_kana/tests/test_product.py b/l10n_jp_product_name_kana/tests/test_product.py new file mode 100644 index 0000000..cc7c6e0 --- /dev/null +++ b/l10n_jp_product_name_kana/tests/test_product.py @@ -0,0 +1,45 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + + +class TestProductNameKana(TransactionCase): + def test_template_and_variant_name_search(self): + template = self.env["product.template"].create( + {"name": "Kana Product", "name_kana": "オフィスチェア"} + ) + self.assertEqual(template.name_kana, "オフィスチェア") + template_ids = { + record_id + for record_id, _display_name in self.env["product.template"].name_search( + "おふぃすちぇあ" + ) + } + self.assertIn(template.id, template_ids) + product_ids = { + record_id + for record_id, _display_name in self.env["product.product"].name_search( + "オフィスチェア" + ) + } + self.assertIn(template.product_variant_id.id, product_ids) + + def test_search_view_field_normalizes_the_term(self): + """Both product search views filter on name_kana_search. + + product.product has no reading of its own -- it reaches the field + through the product_tmpl_id delegation, so the search has to resolve + across it. + """ + template = self.env["product.template"].create( + {"name": "Kana Product", "name_kana": "オフィスチェア"} + ) + for model, record in ( + (self.env["product.template"], template), + (self.env["product.product"], template.product_variant_id), + ): + for term in ("オフィスチェア", "おふぃすちぇあ", "オフィスチェア"): + with self.subTest(model=model._name, term=term): + found = model.search([("name_kana_search", "ilike", term)]) + self.assertIn(record.id, found.ids) diff --git a/l10n_jp_product_name_kana/views/product_views.xml b/l10n_jp_product_name_kana/views/product_views.xml new file mode 100644 index 0000000..e8be5b3 --- /dev/null +++ b/l10n_jp_product_name_kana/views/product_views.xml @@ -0,0 +1,45 @@ + + + + + product.template.form (kana name) + product.template + + + + + + + + + product.template.list (kana name) + product.template + + + + + + + + + product.template.search (kana name) + product.template + + + + + + + + + product.product.search (kana name) + product.product + + + + + + + + diff --git a/requirements.txt b/requirements.txt index b638f7a..31d1eff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ # generated from manifests external_dependencies PyYAML +jaconv jsonschema