diff --git a/product_chemical/__manifest__.py b/product_chemical/__manifest__.py
index 0e2dc83..a4ab328 100644
--- a/product_chemical/__manifest__.py
+++ b/product_chemical/__manifest__.py
@@ -2,15 +2,22 @@
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
{
"name": "Product Chemical",
- "version": "18.0.1.0.0",
+ "version": "18.0.1.1.0",
"category": "Inventory/Inventory",
- "summary": "Add chemical flag to products",
+ "summary": "Manage chemical substance information on products",
"author": "Quartile",
"website": "https://www.quartile.co",
"license": "LGPL-3",
- "depends": ["product"],
+ "depends": ["product", "stock"],
"data": [
+ "security/ir.model.access.csv",
+ "views/product_chemical_law_views.xml",
+ "views/product_chemical_category_views.xml",
+ "views/product_chemical_substance_views.xml",
+ "views/product_chemical_location_amount_views.xml",
"views/product_template_views.xml",
+ "views/stock_location_views.xml",
+ "views/product_chemical_menu.xml",
],
"installable": True,
}
diff --git a/product_chemical/i18n/ja.po b/product_chemical/i18n/ja.po
index 2a1ba7f..f74ae97 100644
--- a/product_chemical/i18n/ja.po
+++ b/product_chemical/i18n/ja.po
@@ -4,10 +4,10 @@
#
msgid ""
msgstr ""
-"Project-Id-Version: Odoo Server 18.0+e\n"
+"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-02-18 03:15+0000\n"
-"PO-Revision-Date: 2026-02-18 03:15+0000\n"
+"POT-Creation-Date: 2026-05-26 00:00+0000\n"
+"PO-Revision-Date: 2026-05-26 00:00+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -18,16 +18,284 @@ msgstr ""
#. module: product_chemical
#: model:ir.model.fields,field_description:product_chemical.field_product_product__is_chemical
#: model:ir.model.fields,field_description:product_chemical.field_product_template__is_chemical
+#: model:ir.ui.menu,name:product_chemical.menu_product_chemical_root
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_template_form_view
#: model_terms:ir.ui.view,arch_db:product_chemical.product_template_search_view
msgid "Chemical"
msgstr "化学物質"
#. module: product_chemical
-#: model:ir.model,name:product_chemical.model_product_template
-msgid "Product"
+#: model:ir.actions.act_window,name:product_chemical.product_chemical_law_action
+#: model:ir.ui.menu,name:product_chemical.menu_product_chemical_law
+msgid "Chemical Laws"
+msgstr "化学物質法律"
+
+#. module: product_chemical
+#: model:ir.model,name:product_chemical.model_product_chemical_law
+msgid "Chemical Law"
+msgstr "化学物質法律"
+
+#. module: product_chemical
+#: model:ir.model,name:product_chemical.model_product_chemical_major_category
+msgid "Chemical Major Category"
+msgstr "化学物質大分類"
+
+#. module: product_chemical
+#: model:ir.model,name:product_chemical.model_product_chemical_minor_category
+msgid "Chemical Minor Category"
+msgstr "化学物質小分類"
+
+#. module: product_chemical
+#: model:ir.model,name:product_chemical.model_product_chemical_substance
+msgid "Chemical Substance"
+msgstr "化学物質"
+
+#. module: product_chemical
+#: model:ir.actions.act_window,name:product_chemical.product_chemical_substance_action
+#: model:ir.model.fields,field_description:product_chemical.field_product_template__chemical_substance_line_ids
+#: model:ir.ui.menu,name:product_chemical.menu_product_chemical_substance
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_template_form_view
+msgid "Chemical Substances"
+msgstr "化学物質マスタ"
+
+#. module: product_chemical
+#: model:ir.model,name:product_chemical.model_product_chemical_law_line
+msgid "Product Chemical Law"
+msgstr "プロダクト化学物質法律"
+
+#. module: product_chemical
+#: model:ir.model,name:product_chemical.model_product_chemical_substance_line
+msgid "Product Chemical Substance Line"
+msgstr "プロダクト化学物質明細"
+
+#. module: product_chemical
+#: model:ir.model,name:product_chemical.model_product_chemical_location_amount
+msgid "Product Chemical Amount by Location"
+msgstr "ロケーション別プロダクト化学物質成分量"
+
+#. module: product_chemical
+#: model:ir.actions.act_window,name:product_chemical.product_chemical_major_category_action
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_law__major_category_ids
+#: model:ir.ui.menu,name:product_chemical.menu_product_chemical_major_category
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_law_view_form
+msgid "Major Categories"
+msgstr "大分類"
+
+#. module: product_chemical
+#: model:ir.actions.act_window,name:product_chemical.product_chemical_minor_category_action
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_major_category__minor_category_ids
+#: model:ir.ui.menu,name:product_chemical.menu_product_chemical_minor_category
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_major_category_view_form
+msgid "Minor Categories"
+msgstr "小分類"
+
+#. module: product_chemical
+#: model:ir.actions.act_window,name:product_chemical.product_chemical_location_amount_action
+#: model:ir.ui.menu,name:product_chemical.menu_product_chemical_location_amount
+msgid "Chemicals by Location"
+msgstr "ロケーション別化学物質"
+
+#. module: product_chemical
+#: model_terms:ir.ui.view,arch_db:product_chemical.stock_location_view_form
+msgid "Chemicals"
+msgstr "化学物質"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_substance__cas_no
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_substance_line__cas_no
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_location_amount__cas_no
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_template_search_view
+msgid "CAS No."
+msgstr "CAS番号"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_substance__content_rate
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_substance_line__content_rate
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_location_amount__content_rate
+msgid "Content Rate (%)"
+msgstr "含有率 (%)"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_law__code
+msgid "Code"
+msgstr "コード"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_law__name
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_major_category__name
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_minor_category__name
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_substance__name
+msgid "Name"
+msgstr "名称"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_substance__active
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_law__active
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_major_category__active
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_minor_category__active
+msgid "Active"
+msgstr "有効"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_law__sequence
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_major_category__sequence
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_minor_category__sequence
+msgid "Sequence"
+msgstr "シーケンス"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_law__note
+msgid "Note"
+msgstr "メモ"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_substance_line__substance_id
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_location_amount__substance_id
+msgid "Substance"
+msgstr "化学物質"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_template__chemical_substance_ids
+msgid "Substances"
+msgstr "化学物質"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_law_line__major_category_id
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_major_category_view_form
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_minor_category_view_search
+msgid "Major Category"
+msgstr "大分類"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_law_line__minor_category_id
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_minor_category_view_form
+msgid "Minor Category"
+msgstr "小分類"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_law_line__law_id
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_major_category__law_id
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_minor_category__law_id
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_major_category_view_search
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_minor_category_view_search
+msgid "Law"
+msgstr "法律"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_law__product_count
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_law_view_form
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_law_view_list
+msgid "Products"
msgstr "プロダクト"
#. module: product_chemical
-#: model:ir.model.fields,field_description:product_chemical.field_product_template__smart_search
-msgid "Smart Search"
-msgstr ""
+#: model:ir.model.fields,field_description:product_chemical.field_product_template__chemical_law_line_ids
+msgid "Laws"
+msgstr "法律"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_template__risk_assessment_pdf
+msgid "Risk Assessment Sheet"
+msgstr "リスクアセスメント表"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_template__risk_assessment_pdf_filename
+msgid "Risk Assessment Sheet Filename"
+msgstr "リスクアセスメント表ファイル名"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_template__chemical_location_amount_ids
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_template_form_view
+msgid "Component Amount by Location"
+msgstr "ロケーション別成分量"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_location_amount__quantity
+msgid "On Hand Qty"
+msgstr "手持在庫数"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_location_amount__location_id
+msgid "Location"
+msgstr "ロケーション"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_product_chemical_location_amount__component_amount
+msgid "Component Amount"
+msgstr "成分量"
+
+#. module: product_chemical
+#: model:ir.model.fields,field_description:product_chemical.field_stock_location__chemical_amount_ids
+msgid "Chemical Components"
+msgstr "化学物質成分"
+
+#. module: product_chemical
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_template_form_view
+msgid "Applicable Laws"
+msgstr "適用法律"
+
+#. module: product_chemical
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_substance_view_form
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_substance_view_search
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_law_view_form
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_law_view_search
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_major_category_view_form
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_minor_category_view_form
+msgid "Archived"
+msgstr "アーカイブ済"
+
+#. module: product_chemical
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_law_view_form
+msgid "Categories"
+msgstr "カテゴリ"
+
+#. module: product_chemical
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_law_view_form
+msgid "Notes"
+msgstr "メモ"
+
+#. module: product_chemical
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_law_view_form
+msgid "Law Name"
+msgstr "法律名"
+
+#. module: product_chemical
+#: model_terms:ir.ui.view,arch_db:product_chemical.product_chemical_substance_view_form
+msgid "Substance Name"
+msgstr "化学物質名"
+
+#. module: product_chemical
+#: model:ir.model.constraint,message:product_chemical.constraint_product_chemical_law_name_uniq
+msgid "Law name must be unique."
+msgstr "法律名は一意である必要があります。"
+
+#. module: product_chemical
+#: model:ir.model.constraint,message:product_chemical.constraint_product_chemical_substance_cas_no_uniq
+msgid "CAS No. must be unique."
+msgstr "CAS番号は一意である必要があります。"
+
+#. module: product_chemical
+#: model:ir.model.constraint,message:product_chemical.constraint_product_chemical_major_category_name_law_uniq
+msgid "Major category name must be unique per law."
+msgstr "大分類名は法律ごとに一意である必要があります。"
+
+#. module: product_chemical
+#: model:ir.model.constraint,message:product_chemical.constraint_product_chemical_minor_category_name_major_uniq
+msgid "Minor category name must be unique per major category."
+msgstr "小分類名は大分類ごとに一意である必要があります。"
+
+#. module: product_chemical
+#: model:ir.model.constraint,message:product_chemical.constraint_product_chemical_law_line_product_law_uniq
+msgid "Each law can be added only once per product."
+msgstr "1プロダクトに対し各法律は1度のみ追加できます。"
+
+#. module: product_chemical
+#: model:ir.model.constraint,message:product_chemical.constraint_product_chemical_substance_line_product_substance_uniq
+msgid "Each substance can be added only once per product."
+msgstr "1プロダクトに対し各化学物質は1度のみ追加できます。"
+
+#. module: product_chemical
+#: model:ir.model.constraint,message:product_chemical.constraint_product_chemical_substance_line_content_rate_range
+#: model:ir.model.constraint,message:product_chemical.constraint_product_chemical_substance_content_rate_range
+msgid "Content rate must be between 0 and 100."
+msgstr "含有率は0以上100以下である必要があります。"
diff --git a/product_chemical/migrations/18.0.1.1.0/pre-migration.py b/product_chemical/migrations/18.0.1.1.0/pre-migration.py
new file mode 100644
index 0000000..b6675ba
--- /dev/null
+++ b/product_chemical/migrations/18.0.1.1.0/pre-migration.py
@@ -0,0 +1,45 @@
+# Copyright 2026 Quartile (https://www.quartile.co)
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
+
+# Replace the legacy product.chemical.cas master with the new
+# product.chemical.substance master. The new master has a separate name field
+# in addition to CAS No. and a default content rate, so we drop the legacy
+# tables and let Odoo recreate them. Any existing line data is discarded; the
+# previous schema was only used for early prototyping.
+
+
+def migrate(cr, version):
+ cr.execute("DROP TABLE IF EXISTS product_chemical_cas_line CASCADE")
+ cr.execute("DROP TABLE IF EXISTS product_chemical_cas CASCADE")
+ cr.execute(
+ """
+ DELETE FROM ir_model_data
+ WHERE module = 'product_chemical'
+ AND model IN (
+ 'product.chemical.cas',
+ 'product.chemical.cas.line'
+ )
+ """
+ )
+ cr.execute(
+ """
+ DELETE FROM ir_model_fields
+ WHERE model IN (
+ 'product.chemical.cas',
+ 'product.chemical.cas.line'
+ )
+ OR relation IN (
+ 'product.chemical.cas',
+ 'product.chemical.cas.line'
+ )
+ """
+ )
+ cr.execute(
+ """
+ DELETE FROM ir_model
+ WHERE model IN (
+ 'product.chemical.cas',
+ 'product.chemical.cas.line'
+ )
+ """
+ )
diff --git a/product_chemical/models/__init__.py b/product_chemical/models/__init__.py
index e8fa8f6..58fcf65 100644
--- a/product_chemical/models/__init__.py
+++ b/product_chemical/models/__init__.py
@@ -1 +1,9 @@
+from . import product_chemical_law
+from . import product_chemical_major_category
+from . import product_chemical_minor_category
+from . import product_chemical_substance
+from . import product_chemical_law_line
+from . import product_chemical_substance_line
+from . import product_chemical_location_amount
from . import product_template
+from . import stock_location
diff --git a/product_chemical/models/product_chemical_law.py b/product_chemical/models/product_chemical_law.py
new file mode 100644
index 0000000..43cf3ca
--- /dev/null
+++ b/product_chemical/models/product_chemical_law.py
@@ -0,0 +1,50 @@
+# Copyright 2026 Quartile (https://www.quartile.co)
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
+
+from odoo import fields, models
+
+
+class ProductChemicalLaw(models.Model):
+ _name = "product.chemical.law"
+ _description = "Chemical Law"
+ _order = "sequence, name"
+
+ name = fields.Char(required=True, translate=True)
+ code = fields.Char()
+ sequence = fields.Integer(default=10)
+ active = fields.Boolean(default=True)
+ note = fields.Text()
+ major_category_ids = fields.One2many(
+ "product.chemical.major.category",
+ "law_id",
+ string="Major Categories",
+ )
+ product_count = fields.Integer(compute="_compute_product_count")
+
+ _sql_constraints = [
+ ("name_uniq", "unique(name)", "Law name must be unique."),
+ ]
+
+ def _compute_product_count(self):
+ line_data = self.env["product.chemical.law.line"]._read_group(
+ [("law_id", "in", self.ids)],
+ groupby=["law_id"],
+ aggregates=["product_tmpl_id:count_distinct"],
+ )
+ counts = {law.id: count for law, count in line_data}
+ for rec in self:
+ rec.product_count = counts.get(rec.id, 0)
+
+ def action_view_products(self):
+ self.ensure_one()
+ product_ids = (
+ self.env["product.chemical.law.line"]
+ .search([("law_id", "=", self.id)])
+ .product_tmpl_id.ids
+ )
+ action = self.env["ir.actions.act_window"]._for_xml_id(
+ "product.product_template_action"
+ )
+ action["domain"] = [("id", "in", product_ids)]
+ action["context"] = {"default_is_chemical": True}
+ return action
diff --git a/product_chemical/models/product_chemical_law_line.py b/product_chemical/models/product_chemical_law_line.py
new file mode 100644
index 0000000..b88afd1
--- /dev/null
+++ b/product_chemical/models/product_chemical_law_line.py
@@ -0,0 +1,46 @@
+# Copyright 2026 Quartile (https://www.quartile.co)
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
+
+from odoo import api, fields, models
+
+
+class ProductChemicalLawLine(models.Model):
+ _name = "product.chemical.law.line"
+ _description = "Product Chemical Law"
+
+ product_tmpl_id = fields.Many2one(
+ "product.template",
+ required=True,
+ ondelete="cascade",
+ index=True,
+ )
+ law_id = fields.Many2one(
+ "product.chemical.law", required=True, ondelete="restrict"
+ )
+ major_category_id = fields.Many2one(
+ "product.chemical.major.category",
+ domain="[('law_id', '=', law_id)]",
+ ondelete="restrict",
+ )
+ minor_category_id = fields.Many2one(
+ "product.chemical.minor.category",
+ domain="[('major_category_id', '=', major_category_id)]",
+ ondelete="restrict",
+ )
+
+ _sql_constraints = [
+ (
+ "product_law_uniq",
+ "unique(product_tmpl_id, law_id)",
+ "Each law can be added only once per product.",
+ ),
+ ]
+
+ @api.onchange("law_id")
+ def _onchange_law_id(self):
+ self.major_category_id = False
+ self.minor_category_id = False
+
+ @api.onchange("major_category_id")
+ def _onchange_major_category_id(self):
+ self.minor_category_id = False
diff --git a/product_chemical/models/product_chemical_location_amount.py b/product_chemical/models/product_chemical_location_amount.py
new file mode 100644
index 0000000..32afb30
--- /dev/null
+++ b/product_chemical/models/product_chemical_location_amount.py
@@ -0,0 +1,68 @@
+# Copyright 2026 Quartile (https://www.quartile.co)
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
+
+from odoo import fields, models
+
+
+class ProductChemicalLocationAmount(models.Model):
+ _name = "product.chemical.location.amount"
+ _description = "Product Chemical Amount by Location"
+ _auto = False
+ _order = "product_tmpl_id, location_id, substance_id"
+ _depends = {
+ "product.template": ["is_chemical"],
+ "product.product": ["product_tmpl_id"],
+ "product.chemical.substance.line": [
+ "product_tmpl_id",
+ "substance_id",
+ "content_rate",
+ ],
+ "product.chemical.substance": ["cas_no"],
+ "stock.quant": ["product_id", "location_id", "quantity"],
+ "stock.location": ["usage"],
+ }
+
+ product_tmpl_id = fields.Many2one(
+ "product.template", string="Product", readonly=True
+ )
+ location_id = fields.Many2one(
+ "stock.location", string="Location", readonly=True
+ )
+ substance_id = fields.Many2one(
+ "product.chemical.substance", string="Substance", readonly=True
+ )
+ cas_no = fields.Char(string="CAS No.", readonly=True)
+ quantity = fields.Float(string="On Hand Qty", readonly=True)
+ content_rate = fields.Float(string="Content Rate (%)", readonly=True)
+ component_amount = fields.Float(string="Component Amount", readonly=True)
+
+ @property
+ def _table_query(self):
+ return """
+ SELECT
+ ROW_NUMBER() OVER () AS id,
+ pt.id AS product_tmpl_id,
+ sq.location_id AS location_id,
+ sub_line.substance_id AS substance_id,
+ sub.cas_no AS cas_no,
+ SUM(sq.quantity) AS quantity,
+ sub_line.content_rate AS content_rate,
+ SUM(sq.quantity) * sub_line.content_rate / 100.0
+ AS component_amount
+ FROM product_template pt
+ JOIN product_product pp ON pp.product_tmpl_id = pt.id
+ JOIN product_chemical_substance_line sub_line
+ ON sub_line.product_tmpl_id = pt.id
+ JOIN product_chemical_substance sub
+ ON sub.id = sub_line.substance_id
+ JOIN stock_quant sq ON sq.product_id = pp.id
+ JOIN stock_location sl
+ ON sl.id = sq.location_id AND sl.usage = 'internal'
+ WHERE pt.is_chemical = TRUE
+ GROUP BY
+ pt.id,
+ sq.location_id,
+ sub_line.substance_id,
+ sub.cas_no,
+ sub_line.content_rate
+ """
diff --git a/product_chemical/models/product_chemical_major_category.py b/product_chemical/models/product_chemical_major_category.py
new file mode 100644
index 0000000..8d5b87b
--- /dev/null
+++ b/product_chemical/models/product_chemical_major_category.py
@@ -0,0 +1,30 @@
+# Copyright 2026 Quartile (https://www.quartile.co)
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
+
+from odoo import fields, models
+
+
+class ProductChemicalMajorCategory(models.Model):
+ _name = "product.chemical.major.category"
+ _description = "Chemical Major Category"
+ _order = "law_id, sequence, name"
+
+ name = fields.Char(required=True, translate=True)
+ law_id = fields.Many2one(
+ "product.chemical.law", required=True, ondelete="cascade", index=True
+ )
+ sequence = fields.Integer(default=10)
+ active = fields.Boolean(default=True)
+ minor_category_ids = fields.One2many(
+ "product.chemical.minor.category",
+ "major_category_id",
+ string="Minor Categories",
+ )
+
+ _sql_constraints = [
+ (
+ "name_law_uniq",
+ "unique(law_id, name)",
+ "Major category name must be unique per law.",
+ ),
+ ]
diff --git a/product_chemical/models/product_chemical_minor_category.py b/product_chemical/models/product_chemical_minor_category.py
new file mode 100644
index 0000000..d8eb614
--- /dev/null
+++ b/product_chemical/models/product_chemical_minor_category.py
@@ -0,0 +1,31 @@
+# Copyright 2026 Quartile (https://www.quartile.co)
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
+
+from odoo import fields, models
+
+
+class ProductChemicalMinorCategory(models.Model):
+ _name = "product.chemical.minor.category"
+ _description = "Chemical Minor Category"
+ _order = "major_category_id, sequence, name"
+
+ name = fields.Char(required=True, translate=True)
+ major_category_id = fields.Many2one(
+ "product.chemical.major.category",
+ required=True,
+ ondelete="cascade",
+ index=True,
+ )
+ law_id = fields.Many2one(
+ related="major_category_id.law_id", store=True, index=True
+ )
+ sequence = fields.Integer(default=10)
+ active = fields.Boolean(default=True)
+
+ _sql_constraints = [
+ (
+ "name_major_uniq",
+ "unique(major_category_id, name)",
+ "Minor category name must be unique per major category.",
+ ),
+ ]
diff --git a/product_chemical/models/product_chemical_substance.py b/product_chemical/models/product_chemical_substance.py
new file mode 100644
index 0000000..dd010d6
--- /dev/null
+++ b/product_chemical/models/product_chemical_substance.py
@@ -0,0 +1,36 @@
+# Copyright 2026 Quartile (https://www.quartile.co)
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
+
+from odoo import api, fields, models
+
+
+class ProductChemicalSubstance(models.Model):
+ _name = "product.chemical.substance"
+ _description = "Chemical Substance"
+ _order = "name"
+
+ name = fields.Char(required=True, translate=True)
+ cas_no = fields.Char(string="CAS No.", required=True)
+ content_rate = fields.Float(
+ string="Content Rate (%)",
+ default=0.0,
+ help="Default content rate (%) used when adding this substance to a product.",
+ )
+ active = fields.Boolean(default=True)
+
+ _sql_constraints = [
+ ("cas_no_uniq", "unique(cas_no)", "CAS No. must be unique."),
+ (
+ "content_rate_range",
+ "CHECK(content_rate >= 0 AND content_rate <= 100)",
+ "Content rate must be between 0 and 100.",
+ ),
+ ]
+
+ @api.depends("name", "cas_no")
+ def _compute_display_name(self):
+ for rec in self:
+ if rec.name and rec.cas_no:
+ rec.display_name = f"[{rec.cas_no}] {rec.name}"
+ else:
+ rec.display_name = rec.name or rec.cas_no or ""
diff --git a/product_chemical/models/product_chemical_substance_line.py b/product_chemical/models/product_chemical_substance_line.py
new file mode 100644
index 0000000..f318782
--- /dev/null
+++ b/product_chemical/models/product_chemical_substance_line.py
@@ -0,0 +1,43 @@
+# Copyright 2026 Quartile (https://www.quartile.co)
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
+
+from odoo import api, fields, models
+
+
+class ProductChemicalSubstanceLine(models.Model):
+ _name = "product.chemical.substance.line"
+ _description = "Product Chemical Substance Line"
+
+ product_tmpl_id = fields.Many2one(
+ "product.template",
+ required=True,
+ ondelete="cascade",
+ index=True,
+ )
+ substance_id = fields.Many2one(
+ "product.chemical.substance", required=True, ondelete="restrict"
+ )
+ cas_no = fields.Char(related="substance_id.cas_no", store=True, string="CAS No.")
+ content_rate = fields.Float(
+ string="Content Rate (%)",
+ required=True,
+ default=0.0,
+ )
+
+ _sql_constraints = [
+ (
+ "product_substance_uniq",
+ "unique(product_tmpl_id, substance_id)",
+ "Each substance can be added only once per product.",
+ ),
+ (
+ "content_rate_range",
+ "CHECK(content_rate >= 0 AND content_rate <= 100)",
+ "Content rate must be between 0 and 100.",
+ ),
+ ]
+
+ @api.onchange("substance_id")
+ def _onchange_substance_id(self):
+ if self.substance_id:
+ self.content_rate = self.substance_id.content_rate
diff --git a/product_chemical/models/product_template.py b/product_chemical/models/product_template.py
index c87047c..e6c0065 100644
--- a/product_chemical/models/product_template.py
+++ b/product_chemical/models/product_template.py
@@ -1,10 +1,45 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
-from odoo import fields, models
+from odoo import api, fields, models
class ProductTemplate(models.Model):
_inherit = "product.template"
is_chemical = fields.Boolean(string="Chemical")
+ chemical_law_line_ids = fields.One2many(
+ "product.chemical.law.line", "product_tmpl_id", string="Laws"
+ )
+ chemical_substance_line_ids = fields.One2many(
+ "product.chemical.substance.line",
+ "product_tmpl_id",
+ string="Chemical Substances",
+ )
+ chemical_substance_ids = fields.Many2many(
+ "product.chemical.substance",
+ compute="_compute_chemical_substance_ids",
+ search="_search_chemical_substance_ids",
+ string="Substances",
+ )
+ risk_assessment_pdf = fields.Binary(string="Risk Assessment Sheet")
+ risk_assessment_pdf_filename = fields.Char(
+ string="Risk Assessment Sheet Filename"
+ )
+ chemical_location_amount_ids = fields.One2many(
+ "product.chemical.location.amount",
+ "product_tmpl_id",
+ string="Component Amount by Location",
+ readonly=True,
+ )
+
+ @api.depends("chemical_substance_line_ids.substance_id")
+ def _compute_chemical_substance_ids(self):
+ for rec in self:
+ rec.chemical_substance_ids = rec.chemical_substance_line_ids.substance_id
+
+ def _search_chemical_substance_ids(self, operator, value):
+ lines = self.env["product.chemical.substance.line"].search(
+ [("substance_id", operator, value)]
+ )
+ return [("id", "in", lines.product_tmpl_id.ids)]
diff --git a/product_chemical/models/stock_location.py b/product_chemical/models/stock_location.py
new file mode 100644
index 0000000..4eaa039
--- /dev/null
+++ b/product_chemical/models/stock_location.py
@@ -0,0 +1,15 @@
+# Copyright 2026 Quartile (https://www.quartile.co)
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
+
+from odoo import fields, models
+
+
+class StockLocation(models.Model):
+ _inherit = "stock.location"
+
+ chemical_amount_ids = fields.One2many(
+ "product.chemical.location.amount",
+ "location_id",
+ string="Chemical Components",
+ readonly=True,
+ )
diff --git a/product_chemical/readme/CONTRIBUTORS.md b/product_chemical/readme/CONTRIBUTORS.md
index c4020f4..097598f 100644
--- a/product_chemical/readme/CONTRIBUTORS.md
+++ b/product_chemical/readme/CONTRIBUTORS.md
@@ -1,2 +1,2 @@
- [Quartile](https://www.quartile.co):
- - Yoshi Tashiro
+ - Tatsuki Kanda
diff --git a/product_chemical/readme/DESCRIPTION.md b/product_chemical/readme/DESCRIPTION.md
index b7e2bd3..868380f 100644
--- a/product_chemical/readme/DESCRIPTION.md
+++ b/product_chemical/readme/DESCRIPTION.md
@@ -1,2 +1,18 @@
-This module adds a "Chemical" boolean field to product templates, allowing users to
-flag products as chemicals.
+This module adds chemical substance management to products. When the "Chemical"
+flag on a product is enabled, a "Chemical" tab is shown that allows users to:
+
+* Attach one or more applicable laws to the product (with optional major /
+ minor category per law).
+* Upload a risk assessment sheet (PDF).
+* Register CAS Numbers contained in the product together with their content
+ rate (%).
+* Review the component amount per stock location, calculated from on-hand
+ quantity multiplied by each CAS content rate.
+
+Master data managed by this module:
+
+* **Laws** — referenced by products; each law lists the products that use it.
+* **Major / Minor categories** — organised under their parent law.
+* **CAS Numbers** — reusable across products.
+
+Products can be searched by CAS Number from the product search view.
diff --git a/product_chemical/security/ir.model.access.csv b/product_chemical/security/ir.model.access.csv
new file mode 100644
index 0000000..86313e5
--- /dev/null
+++ b/product_chemical/security/ir.model.access.csv
@@ -0,0 +1,8 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_product_chemical_law_user,product.chemical.law user,model_product_chemical_law,base.group_user,1,1,1,1
+access_product_chemical_major_category_user,product.chemical.major.category user,model_product_chemical_major_category,base.group_user,1,1,1,1
+access_product_chemical_minor_category_user,product.chemical.minor.category user,model_product_chemical_minor_category,base.group_user,1,1,1,1
+access_product_chemical_substance_user,product.chemical.substance user,model_product_chemical_substance,base.group_user,1,1,1,1
+access_product_chemical_law_line_user,product.chemical.law.line user,model_product_chemical_law_line,base.group_user,1,1,1,1
+access_product_chemical_substance_line_user,product.chemical.substance.line user,model_product_chemical_substance_line,base.group_user,1,1,1,1
+access_product_chemical_location_amount_user,product.chemical.location.amount user,model_product_chemical_location_amount,base.group_user,1,0,0,0
diff --git a/product_chemical/views/product_chemical_category_views.xml b/product_chemical/views/product_chemical_category_views.xml
new file mode 100644
index 0000000..616bc2a
--- /dev/null
+++ b/product_chemical/views/product_chemical_category_views.xml
@@ -0,0 +1,150 @@
+
+
+
+ product.chemical.major.category.list
+ product.chemical.major.category
+
+
+
+
+
+
+
+
+
+ product.chemical.major.category.form
+ product.chemical.major.category
+
+
+
+
+
+ product.chemical.major.category.search
+ product.chemical.major.category
+
+
+
+
+
+
+
+
+
+
+
+ Major Categories
+ product.chemical.major.category
+ list,form
+
+
+
+ product.chemical.minor.category.list
+ product.chemical.minor.category
+
+
+
+
+
+
+
+
+
+
+ product.chemical.minor.category.form
+ product.chemical.minor.category
+
+
+
+
+
+ product.chemical.minor.category.search
+ product.chemical.minor.category
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Minor Categories
+ product.chemical.minor.category
+ list,form
+
+
diff --git a/product_chemical/views/product_chemical_law_views.xml b/product_chemical/views/product_chemical_law_views.xml
new file mode 100644
index 0000000..8d857ed
--- /dev/null
+++ b/product_chemical/views/product_chemical_law_views.xml
@@ -0,0 +1,95 @@
+
+
+
+ product.chemical.law.list
+ product.chemical.law
+
+
+
+
+
+
+
+
+
+
+ product.chemical.law.form
+ product.chemical.law
+
+
+
+
+
+ product.chemical.law.search
+ product.chemical.law
+
+
+
+
+
+
+
+
+
+ Chemical Laws
+ product.chemical.law
+ list,form
+
+
diff --git a/product_chemical/views/product_chemical_location_amount_views.xml b/product_chemical/views/product_chemical_location_amount_views.xml
new file mode 100644
index 0000000..a1bd8b4
--- /dev/null
+++ b/product_chemical/views/product_chemical_location_amount_views.xml
@@ -0,0 +1,55 @@
+
+
+
+ product.chemical.location.amount.list
+ product.chemical.location.amount
+
+
+
+
+
+
+
+
+
+
+
+
+
+ product.chemical.location.amount.search
+ product.chemical.location.amount
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Chemicals by Location
+ product.chemical.location.amount
+ list
+ {'search_default_group_location': 1, 'search_default_group_substance': 1}
+
+
diff --git a/product_chemical/views/product_chemical_menu.xml b/product_chemical/views/product_chemical_menu.xml
new file mode 100644
index 0000000..4f5d73c
--- /dev/null
+++ b/product_chemical/views/product_chemical_menu.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
diff --git a/product_chemical/views/product_chemical_substance_views.xml b/product_chemical/views/product_chemical_substance_views.xml
new file mode 100644
index 0000000..2e457ee
--- /dev/null
+++ b/product_chemical/views/product_chemical_substance_views.xml
@@ -0,0 +1,65 @@
+
+
+
+ product.chemical.substance.list
+ product.chemical.substance
+
+
+
+
+
+
+
+
+
+ product.chemical.substance.form
+ product.chemical.substance
+
+
+
+
+
+ product.chemical.substance.search
+ product.chemical.substance
+
+
+
+
+
+
+
+
+
+ Chemical Substances
+ product.chemical.substance
+ list,form
+
+
diff --git a/product_chemical/views/product_template_views.xml b/product_chemical/views/product_template_views.xml
index 34beced..4611011 100644
--- a/product_chemical/views/product_template_views.xml
+++ b/product_chemical/views/product_template_views.xml
@@ -8,6 +8,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -16,6 +80,11 @@
+
+
+
+ stock.location.form.chemical
+ stock.location
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+