diff --git a/modular_type/__init__.py b/modular_type/__init__.py
new file mode 100644
index 00000000000..9b4296142f4
--- /dev/null
+++ b/modular_type/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import wizard
diff --git a/modular_type/__manifest__.py b/modular_type/__manifest__.py
new file mode 100644
index 00000000000..40e7f8aeefb
--- /dev/null
+++ b/modular_type/__manifest__.py
@@ -0,0 +1,20 @@
+{
+ "name": "Modular MRP",
+ "version": "1.0",
+ "description": """Add modular types on products for MRP quantity multiplication.""",
+ "author": "Soham",
+ "depends": [
+ "product",
+ "mrp",
+ "sale_management",
+ "sale_mrp",
+ ],
+ "data": [
+ "security/ir.model.access.csv",
+ "views/product_template_views.xml",
+ "views/mrp_bom_views.xml",
+ "views/sale_line_modular_value_wizard_views.xml",
+ "views/sale_order_views.xml",
+ ],
+ "license": "LGPL-3",
+}
diff --git a/modular_type/models/__init__.py b/modular_type/models/__init__.py
new file mode 100644
index 00000000000..49b5d794aa5
--- /dev/null
+++ b/modular_type/models/__init__.py
@@ -0,0 +1,6 @@
+from . import modular_type
+from . import product_template
+from . import mrp_bom_line
+from . import sale_order_line
+from . import sale_order_line_modular_value
+from . import sale_order
diff --git a/modular_type/models/modular_type.py b/modular_type/models/modular_type.py
new file mode 100644
index 00000000000..44d4abafa3d
--- /dev/null
+++ b/modular_type/models/modular_type.py
@@ -0,0 +1,8 @@
+from odoo import fields, models
+
+
+class ModularType(models.Model):
+ _name = "modular.type"
+ _description = "Modular Type"
+
+ name = fields.Char(required=True)
diff --git a/modular_type/models/mrp_bom_line.py b/modular_type/models/mrp_bom_line.py
new file mode 100644
index 00000000000..29d08d15ce4
--- /dev/null
+++ b/modular_type/models/mrp_bom_line.py
@@ -0,0 +1,10 @@
+from odoo import fields, models
+
+
+class MrpBomLine(models.Model):
+ _inherit = "mrp.bom.line"
+
+ modular_type_id = fields.Many2one(
+ "modular.type",
+ string="Modular Type",
+ )
diff --git a/modular_type/models/product_template.py b/modular_type/models/product_template.py
new file mode 100644
index 00000000000..0c31b6bd246
--- /dev/null
+++ b/modular_type/models/product_template.py
@@ -0,0 +1,10 @@
+from odoo import fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = "product.template"
+
+ modular_type_ids = fields.Many2many(
+ "modular.type",
+ string="Modular Types",
+ )
diff --git a/modular_type/models/sale_order.py b/modular_type/models/sale_order.py
new file mode 100644
index 00000000000..20d79697c30
--- /dev/null
+++ b/modular_type/models/sale_order.py
@@ -0,0 +1,12 @@
+from odoo import models
+
+
+class SaleOrder(models.Model):
+ _inherit = "sale.order"
+
+ def action_confirm(self):
+ result = super().action_confirm()
+
+ self.order_line._apply_modular_values_to_productions()
+
+ return result
diff --git a/modular_type/models/sale_order_line.py b/modular_type/models/sale_order_line.py
new file mode 100644
index 00000000000..84e55940b5d
--- /dev/null
+++ b/modular_type/models/sale_order_line.py
@@ -0,0 +1,49 @@
+from odoo import fields, models
+
+
+class SaleOrderLine(models.Model):
+ _inherit = "sale.order.line"
+
+ modular_value_ids = fields.One2many(
+ "sale.order.line.modular.value",
+ "sale_line_id",
+ string="Modular Values",
+ )
+
+ def action_open_modular_value_wizard(self):
+ self.ensure_one()
+
+ return {
+ "type": "ir.actions.act_window",
+ "name": "Set Modular Values",
+ "res_model": "sale.line.modular.value.wizard",
+ "view_mode": "form",
+ "target": "new",
+ "context": {
+ "default_sale_line_id": self.id,
+ },
+ }
+
+ def _apply_modular_values_to_productions(self):
+ for line in self:
+ productions = self.env["mrp.production"].search([
+ ("origin", "=", line.order_id.name),
+ ("product_id", "=", line.product_id.id),
+ ])
+
+ modular_values = {
+ v.modular_type_id.id: v.value
+ for v in line.modular_value_ids
+ }
+
+ for move in productions.move_raw_ids.filtered(
+ lambda m: m.bom_line_id.modular_type_id
+ ):
+ multiplier = modular_values.get(
+ move.bom_line_id.modular_type_id.id
+ )
+
+ if multiplier is not None:
+ move.product_uom_qty = move.bom_line_id.product_qty * multiplier
+ else:
+ move.product_uom_qty = 0.0
diff --git a/modular_type/models/sale_order_line_modular_value.py b/modular_type/models/sale_order_line_modular_value.py
new file mode 100644
index 00000000000..fbb9284f8df
--- /dev/null
+++ b/modular_type/models/sale_order_line_modular_value.py
@@ -0,0 +1,25 @@
+from odoo import fields, models
+
+
+class SaleOrderLineModularValue(models.Model):
+ _name = "sale.order.line.modular.value"
+ _description = "Sale Order Line Modular Value"
+
+ sale_line_id = fields.Many2one(
+ "sale.order.line",
+ string="Sale Order Line",
+ required=True,
+ ondelete="cascade",
+ )
+
+ modular_type_id = fields.Many2one(
+ "modular.type",
+ string="Modular Type",
+ required=True,
+ )
+
+ value = fields.Float(
+ string="Value",
+ default=1.0,
+ required=True,
+ )
diff --git a/modular_type/security/ir.model.access.csv b/modular_type/security/ir.model.access.csv
new file mode 100644
index 00000000000..2c76a71f643
--- /dev/null
+++ b/modular_type/security/ir.model.access.csv
@@ -0,0 +1,5 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_modular_type_user,access.modular.type.user,model_modular_type,base.group_user,1,1,1,1
+access_sale_order_line_modular_value_user,access.sale.order.line.modular.value.user,model_sale_order_line_modular_value,base.group_user,1,1,1,1
+access_sale_line_modular_value_wizard_user,access.sale.line.modular.value.wizard.user,model_sale_line_modular_value_wizard,base.group_user,1,1,1,1
+access_sale_line_modular_value_wizard_line_user,access.sale.line.modular.value.wizard.line.user,model_sale_line_modular_value_wizard_line,base.group_user,1,1,1,1
diff --git a/modular_type/tests/__init__.py b/modular_type/tests/__init__.py
new file mode 100644
index 00000000000..f2c01d9b8e0
--- /dev/null
+++ b/modular_type/tests/__init__.py
@@ -0,0 +1,2 @@
+from . import common
+from . import test_modular_type
diff --git a/modular_type/tests/common.py b/modular_type/tests/common.py
new file mode 100644
index 00000000000..a372dc1656c
--- /dev/null
+++ b/modular_type/tests/common.py
@@ -0,0 +1,109 @@
+from odoo.tests import TransactionCase
+
+
+class ModularTypeCommon(TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.mto_route = cls.env.ref("stock.route_warehouse0_mto")
+ cls.manufacture_route = cls.env.ref("mrp.route_warehouse0_manufacture")
+
+ cls.type_sections = cls.env["modular.type"].create({
+ "name": "Sections"
+ })
+ cls.type_meters = cls.env["modular.type"].create({
+ "name": "Meters"
+ })
+
+ cls.product = cls.env["product.template"].create({
+ "name": "Test Fencing Product",
+ "type": "consu",
+ "modular_type_ids": [(6, 0, [
+ cls.type_sections.id,
+ cls.type_meters.id,
+ ])],
+ "route_ids": [(6, 0, [
+ cls.mto_route.id,
+ cls.manufacture_route.id,
+ ])],
+ })
+
+ cls.product_no_modular = cls.env["product.template"].create({
+ "name": "Normal Product",
+ "type": "consu",
+ "route_ids": [(6, 0, [
+ cls.mto_route.id,
+ cls.manufacture_route.id,
+ ])],
+ })
+
+ cls.component_sections = cls.env["product.product"].create({
+ "name": "Railing",
+ "type": "consu",
+ })
+ cls.component_meters = cls.env["product.product"].create({
+ "name": "Liner",
+ "type": "consu",
+ })
+ cls.component_no_type = cls.env["product.product"].create({
+ "name": "Screw",
+ "type": "consu",
+ })
+
+ cls.bom = cls.env["mrp.bom"].create({
+ "product_tmpl_id": cls.product.id,
+ "product_qty": 1.0,
+ "type": "normal",
+ "bom_line_ids": [
+ (0, 0, {
+ "product_id": cls.component_sections.id,
+ "product_qty": 5.0,
+ "modular_type_id": cls.type_sections.id,
+ }),
+ (0, 0, {
+ "product_id": cls.component_meters.id,
+ "product_qty": 2.0,
+ "modular_type_id": cls.type_meters.id,
+ }),
+ (0, 0, {
+ "product_id": cls.component_no_type.id,
+ "product_qty": 10.0,
+ }),
+ ],
+ })
+
+ cls.bom_no_modular = cls.env["mrp.bom"].create({
+ "product_tmpl_id": cls.product_no_modular.id,
+ "product_qty": 1.0,
+ "type": "normal",
+ "bom_line_ids": [
+ (0, 0, {
+ "product_id": cls.component_no_type.id,
+ "product_qty": 10.0,
+ }),
+ ],
+ })
+
+ cls.customer = cls.env["res.partner"].create({
+ "name": "Test Customer"
+ })
+
+ def _create_so(self, product_tmpl, qty=1.0, **values):
+ """Helper — create a sale order with one line"""
+ return self.env["sale.order"].create({
+ "partner_id": self.customer.id,
+ "order_line": [(0, 0, {
+ "product_id": product_tmpl.product_variant_ids[0].id,
+ "product_uom_qty": qty,
+ })],
+ **values,
+ })
+
+ def _get_mo(self, so, so_line):
+ """Helper — fetch MO created from a confirmed SO line"""
+ return self.env["mrp.production"].search([
+ ("origin", "=", so.name),
+ ("product_id", "=", so_line.product_id.id),
+ ])
diff --git a/modular_type/tests/test_modular_type.py b/modular_type/tests/test_modular_type.py
new file mode 100644
index 00000000000..df17e784ebe
--- /dev/null
+++ b/modular_type/tests/test_modular_type.py
@@ -0,0 +1,81 @@
+from odoo.tests import tagged
+from .common import ModularTypeCommon
+
+
+@tagged("post_install", "-at_install", "modular_type")
+class TestModularType(ModularTypeCommon):
+
+ def test_01_modular_values_applied_to_mo(self):
+ so = self._create_so(self.product)
+ so_line = so.order_line[0]
+
+ so_line.write({
+ "modular_value_ids": [
+ (0, 0, {
+ "modular_type_id": self.type_sections.id,
+ "value": 6.0,
+ }),
+ (0, 0, {
+ "modular_type_id": self.type_meters.id,
+ "value": 3.0,
+ }),
+ ]
+ })
+
+ so.action_confirm()
+ so_line._apply_modular_values_to_productions()
+
+ mo = self._get_mo(so, so_line)
+ self.assertTrue(mo, "MO should be created after SO confirm")
+
+ for move in mo.move_raw_ids:
+ if move.bom_line_id.modular_type_id == self.type_sections:
+ self.assertEqual(
+ move.product_uom_qty, 30.0,
+ "Sections: 5 × 6 = 30"
+ )
+ elif move.bom_line_id.modular_type_id == self.type_meters:
+ self.assertEqual(
+ move.product_uom_qty, 6.0,
+ "Meters: 2 × 3 = 6"
+ )
+
+ def test_02_no_values_set_qty_is_zero(self):
+ so = self._create_so(self.product)
+ so_line = so.order_line[0]
+
+ self.assertFalse(
+ so_line.modular_value_ids,
+ "No modular values should be set"
+ )
+
+ so.action_confirm()
+ so_line._apply_modular_values_to_productions()
+
+ mo = self._get_mo(so, so_line)
+ self.assertTrue(mo, "MO should still be created")
+
+ for move in mo.move_raw_ids.filtered(
+ lambda m: m.bom_line_id.modular_type_id
+ ):
+ self.assertEqual(
+ move.product_uom_qty, 0.0,
+ f"{move.product_id.name} qty should be 0 when no values set"
+ )
+
+ def test_03_no_modular_type_standard_qty(self):
+ so = self._create_so(self.product_no_modular, qty=2.0)
+ so_line = so.order_line[0]
+
+ so.action_confirm()
+
+ so_line._apply_modular_values_to_productions()
+
+ mo = self._get_mo(so, so_line)
+ self.assertTrue(mo, "MO should be created")
+
+ for move in mo.move_raw_ids:
+ self.assertEqual(
+ move.product_uom_qty, 20.0,
+ "Standard product should follow normal qty calculation"
+ )
diff --git a/modular_type/views/mrp_bom_views.xml b/modular_type/views/mrp_bom_views.xml
new file mode 100644
index 00000000000..e63cfeddb98
--- /dev/null
+++ b/modular_type/views/mrp_bom_views.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ mrp.bom.form.inherit.modular.type
+ mrp.bom
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modular_type/views/product_template_views.xml b/modular_type/views/product_template_views.xml
new file mode 100644
index 00000000000..4f18216f5ee
--- /dev/null
+++ b/modular_type/views/product_template_views.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ product.template.form.inherit.modular.type
+ product.template
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modular_type/views/sale_line_modular_value_wizard_views.xml b/modular_type/views/sale_line_modular_value_wizard_views.xml
new file mode 100644
index 00000000000..a923fd97c52
--- /dev/null
+++ b/modular_type/views/sale_line_modular_value_wizard_views.xml
@@ -0,0 +1,30 @@
+
+
+
+
+ sale.line.modular.value.wizard.form
+ sale.line.modular.value.wizard
+
+
+
+
+
+
diff --git a/modular_type/views/sale_order_views.xml b/modular_type/views/sale_order_views.xml
new file mode 100644
index 00000000000..413ef7424e1
--- /dev/null
+++ b/modular_type/views/sale_order_views.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ sale.order.form.inherit.modular.value
+ sale.order
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modular_type/wizard/__init__.py b/modular_type/wizard/__init__.py
new file mode 100644
index 00000000000..27a333f7f41
--- /dev/null
+++ b/modular_type/wizard/__init__.py
@@ -0,0 +1 @@
+from . import sale_line_modular_value_wizard
diff --git a/modular_type/wizard/sale_line_modular_value_wizard.py b/modular_type/wizard/sale_line_modular_value_wizard.py
new file mode 100644
index 00000000000..d4374b1894b
--- /dev/null
+++ b/modular_type/wizard/sale_line_modular_value_wizard.py
@@ -0,0 +1,93 @@
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+
+
+class SaleLineModularValueWizard(models.TransientModel):
+ _name = "sale.line.modular.value.wizard"
+ _description = "Sale Line Modular Value Wizard"
+
+ sale_line_id = fields.Many2one(
+ "sale.order.line",
+ string="Sale Order Line",
+ required=True,
+ )
+
+ line_ids = fields.One2many(
+ "sale.line.modular.value.wizard.line",
+ "wizard_id",
+ string="Modular Values",
+ )
+
+ @api.model
+ def default_get(self, fields):
+ res = super().default_get(fields)
+
+ sale_line_id = self.env.context.get("default_sale_line_id")
+ if not sale_line_id:
+ return res
+
+ sale_line = self.env["sale.order.line"].browse(sale_line_id)
+
+ modular_types = sale_line.product_id.product_tmpl_id.modular_type_ids
+ if not modular_types:
+ raise UserError("Please configure Modular Types on the selected product first.")
+
+ existing_values = {}
+ for v in sale_line.modular_value_ids:
+ existing_values[v.modular_type_id.id] = v.value
+
+ lines = []
+ for modular_type in modular_types:
+ value = existing_values.get(modular_type.id, 1.0)
+ lines.append((0, 0, {
+ "modular_type_id": modular_type.id,
+ "value": value,
+ }))
+
+ res["line_ids"] = lines
+ return res
+
+ def action_save(self):
+ self.ensure_one()
+
+ valid_lines = self.line_ids.filtered(lambda line: line.modular_type_id)
+
+ if not valid_lines:
+ raise UserError(
+ "No modular types found. Please configure Modular Types on the product first."
+ )
+
+ self.sale_line_id.modular_value_ids.unlink()
+
+ for line in valid_lines:
+ self.env["sale.order.line.modular.value"].create({
+ "sale_line_id": self.sale_line_id.id,
+ "modular_type_id": line.modular_type_id.id,
+ "value": line.value,
+ })
+
+ return {"type": "ir.actions.act_window_close"}
+
+
+class SaleLineModularValueWizardLine(models.TransientModel):
+ _name = "sale.line.modular.value.wizard.line"
+ _description = "Sale Line Modular Value Wizard Line"
+
+ wizard_id = fields.Many2one(
+ "sale.line.modular.value.wizard",
+ required=True,
+ ondelete="cascade",
+ )
+
+ modular_type_id = fields.Many2one(
+ "modular.type",
+ string="Modular Type",
+ required=True,
+ readonly=True,
+ )
+
+ value = fields.Float(
+ string="Value",
+ default=1.0,
+ required=True,
+ )