diff --git a/project_estimation/README.rst b/project_estimation/README.rst new file mode 100644 index 0000000000..8d54c7b850 --- /dev/null +++ b/project_estimation/README.rst @@ -0,0 +1,131 @@ +================== +Project Estimation +================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0faffaf36b0c57e81ee69946cb1d5445d5dae4f3efa82e3e0219d5f7a1fec03e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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%2Fproject-lightgray.png?logo=github + :target: https://github.com/OCA/project/tree/18.0/project_estimation + :alt: OCA/project +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/project-18-0/project-18-0-project_estimation + :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/project&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module is a pre-sales module designed to manage project cost +estimation before confirming a Sales Order. + +In many service-based businesses, project discussions start with rough +calculations, scope alignment, and internal margin validation. Creating +a full Project too early can mix pre-sales assumptions with confirmed +execution data. This module separates the estimation phase from the +execution phase. + +The purpose of this module is to: + +- Provide a structured space for cost calculation before commitment +- Allow internal validation of pricing and margin +- Prevent pollution of confirmed Project data with unvalidated + assumptions +- Enable clean conversion to Sales Order once approved + +This module represents the commercial negotiation stage, not the +operational delivery stage. + +Once the estimation is validated and approved, it can be converted into +a Sales Order. If the deal does not proceed, the estimation can simply +be cancelled without impacting project reporting. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use this module, you need to: + +1. Go to menu Project > Estimation +2. Create a new Project Estimation +3. Add estimation lines such as services, materials, or working hours. + For each line, define quantity, cost, and price. +4. Review the overall estimation. Adjust pricing or quantities until the + expected margin meets business requirements. +5. Once internally validated, confirm the estimation. +6. Click Create Sales Order. It will generate a Sales Order based on the + estimation lines, and the standard sales workflow continues from + there. +7. If the deal does not proceed, simply cancel the estimation. No + Project or accounting records will be affected. + +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 +------- + +* Ecosoft + +Contributors +------------ + +- `Ecosoft `__: + + - Saran Lim. saranl@ecosoft.co.th + +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-Saran440| image:: https://github.com/Saran440.png?size=40px + :target: https://github.com/Saran440 + :alt: Saran440 + +Current `maintainer `__: + +|maintainer-Saran440| + +This module is part of the `OCA/project `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/project_estimation/__init__.py b/project_estimation/__init__.py new file mode 100644 index 0000000000..25aa51eca4 --- /dev/null +++ b/project_estimation/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import models +from . import wizard diff --git a/project_estimation/__manifest__.py b/project_estimation/__manifest__.py new file mode 100644 index 0000000000..0537286140 --- /dev/null +++ b/project_estimation/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2026 Ecosoft Co., Ltd (https://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "Project Estimation", + "summary": "Pre-sales project cost estimation and margin calculation", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Ecosoft, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/project", + "depends": ["project", "sale_project"], + "data": [ + "data/sequence.xml", + "security/project_estimation_security.xml", + "security/ir.model.access.csv", + "security/res_groups.xml", + "wizard/create_sale_order_view.xml", + "views/res_config_settings_views.xml", + "views/project_estimation_views.xml", + "views/sale_order_view.xml", + ], + "development_status": "Alpha", + "maintainers": ["Saran440"], +} diff --git a/project_estimation/data/sequence.xml b/project_estimation/data/sequence.xml new file mode 100644 index 0000000000..4cd5b41c7c --- /dev/null +++ b/project_estimation/data/sequence.xml @@ -0,0 +1,10 @@ + + + + Project Estimation + project.estimation + PEST + 5 + + + diff --git a/project_estimation/models/__init__.py b/project_estimation/models/__init__.py new file mode 100644 index 0000000000..de8b62d126 --- /dev/null +++ b/project_estimation/models/__init__.py @@ -0,0 +1,6 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import res_config_settings +from . import project_estimation +from . import project_estimation_line +from . import sale_order diff --git a/project_estimation/models/project_estimation.py b/project_estimation/models/project_estimation.py new file mode 100644 index 0000000000..bbae514a4b --- /dev/null +++ b/project_estimation/models/project_estimation.py @@ -0,0 +1,214 @@ +# Copyright 2026 Ecosoft Co., Ltd (https://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class ProjectEstimation(models.Model): + _name = "project.estimation" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Project Estimation" + _order = "name desc" + + name = fields.Char( + default="/", + required=True, + copy=False, + ) + partner_id = fields.Many2one( + comodel_name="res.partner", + required=True, + tracking=True, + ) + project_id = fields.Many2one( + comodel_name="project.project", + tracking=True, + ) + sale_order_ids = fields.One2many( + comodel_name="sale.order", + inverse_name="project_estimation_id", + copy=False, + ) + date = fields.Date( + string="Estimation Date", + default=fields.Date.today, + required=True, + tracking=True, + ) + user_id = fields.Many2one( + comodel_name="res.users", + string="Responsible", + default=lambda self: self.env.user, + tracking=True, + ) + company_id = fields.Many2one( + comodel_name="res.company", + required=True, + default=lambda self: self.env.company, + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + compute="_compute_currency_id", + store=True, + ) + line_ids = fields.One2many( + comodel_name="project.estimation.line", + inverse_name="estimation_id", + string="Estimation Lines", + copy=True, + ) + + amount_untaxed = fields.Monetary( + string="Untaxed Amount", store=True, compute="_compute_amounts", tracking=5 + ) + amount_tax = fields.Monetary(string="Taxes", store=True, compute="_compute_amounts") + amount_total = fields.Monetary( + string="Total", store=True, compute="_compute_amounts", tracking=4 + ) + + total_cost = fields.Monetary( + compute="_compute_totals", + store=True, + ) + total_margin = fields.Monetary( + compute="_compute_totals", + store=True, + ) + total_margin_percent = fields.Float( + string="Total Margin (%)", + compute="_compute_totals", + store=True, + ) + target_margin = fields.Float( + string="Target Margin (%)", + ) + target_sale_price = fields.Monetary( + compute="_compute_target_sale_price", + store=True, + ) + expected_profit = fields.Monetary( + compute="_compute_expected_profit", + store=True, + ) + state = fields.Selection( + selection=[ + ("draft", "Draft"), + ("confirm", "Confirmed"), + ("approved", "Approved"), + ("done", "Done"), + ("cancelled", "Cancelled"), + ], + default="draft", + tracking=True, + ) + note = fields.Html(string="Notes") + + @api.depends("line_ids.price_subtotal", "currency_id", "company_id") + def _compute_amounts(self): + AccountTax = self.env["account.tax"] + for rec in self: + lines = rec.line_ids.filtered(lambda x: not x.display_type) + base_lines = [ + line._prepare_base_line_for_taxes_computation() for line in lines + ] + AccountTax._add_tax_details_in_base_lines(base_lines, rec.company_id) + AccountTax._round_base_lines_tax_details(base_lines, rec.company_id) + tax_totals = AccountTax._get_tax_totals_summary( + base_lines=base_lines, + currency=rec.currency_id or rec.company_id.currency_id, + company=rec.company_id, + ) + rec.amount_untaxed = tax_totals["base_amount_currency"] + rec.amount_tax = tax_totals["tax_amount_currency"] + rec.amount_total = tax_totals["total_amount_currency"] + + @api.depends("company_id") + def _compute_currency_id(self): + for rec in self: + rec.currency_id = rec.company_id.currency_id + + @api.depends("line_ids.price_subtotal", "line_ids.margin", "line_ids.cost_subtotal") + def _compute_totals(self): + for rec in self: + rec.total_cost = sum(rec.line_ids.mapped("cost_subtotal")) + rec.total_margin = sum(rec.line_ids.mapped("margin")) + rec.total_margin_percent = ( + rec.amount_untaxed and rec.total_margin / rec.amount_untaxed + ) + + @api.depends("total_cost", "target_margin") + def _compute_target_sale_price(self): + for rec in self: + margin = rec.target_margin + if margin >= 100: + rec.target_sale_price = 0.0 + elif margin > 0: + rec.target_sale_price = rec.total_cost / (1 - margin / 100) + else: + rec.target_sale_price = rec.total_cost + + @api.depends("target_sale_price", "total_cost") + def _compute_expected_profit(self): + for rec in self: + rec.expected_profit = rec.target_sale_price - rec.total_cost + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("name", "/") == "/": + vals["name"] = ( + self.env["ir.sequence"].next_by_code("project.estimation") or "/" + ) + return super().create(vals_list) + + def action_confirm(self): + return self.write({"state": "confirm"}) + + def action_approve(self): + return self.write({"state": "approved"}) + + def action_cancel(self): + return self.write({"state": "cancelled"}) + + def action_done(self): + return self.write({"state": "done"}) + + def action_draft(self): + return self.write({"state": "draft"}) + + def action_create_sale_order(self): + """Open wizard to create a Sale Order from the estimation.""" + self.ensure_one() + if not self.line_ids: + raise UserError(self.env._("Please add at least one estimation line.")) + return { + "name": self.env._("Create Sale Order"), + "type": "ir.actions.act_window", + "res_model": "project.estimation.create.sale.order", + "view_mode": "form", + "target": "new", + "context": { + "default_estimation_id": self.id, + }, + } + + def action_view_sale_order(self): + self.ensure_one() + return { + "name": self.env._("Sale Order"), + "type": "ir.actions.act_window", + "res_model": "sale.order", + "view_mode": "list,form", + "domain": [("project_estimation_id", "=", self.id)], + } + + def action_view_project(self): + self.ensure_one() + return { + "name": self.env._("Project"), + "type": "ir.actions.act_window", + "res_model": "project.project", + "view_mode": "form", + "res_id": self.project_id.id, + } diff --git a/project_estimation/models/project_estimation_line.py b/project_estimation/models/project_estimation_line.py new file mode 100644 index 0000000000..394f6b2cc6 --- /dev/null +++ b/project_estimation/models/project_estimation_line.py @@ -0,0 +1,236 @@ +# Copyright 2026 Ecosoft Co., Ltd (https://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ProjectEstimationLine(models.Model): + _name = "project.estimation.line" + _description = "Project Estimation Line" + _order = "sequence, id" + + estimation_id = fields.Many2one( + comodel_name="project.estimation", + required=True, + ondelete="cascade", + ) + sequence = fields.Integer(default=10) + display_type = fields.Selection( + selection=[ + ("line_section", "Section"), + ("line_note", "Note"), + ], + default=False, + ) + name = fields.Char( + string="Description", + compute="_compute_name", + store=True, + readonly=False, + required=True, + precompute=True, + ) + product_id = fields.Many2one( + comodel_name="product.product", + ) + cost_type = fields.Selection( + selection=[ + ("labor", "Labor"), + ("material", "Material"), + ("service", "Service"), + ("overhead", "Overhead"), + ], + ) + product_uom_qty = fields.Float( + string="Quantity", digits="Product Unit of Measure", default=1.0 + ) + product_uom_id = fields.Many2one( + comodel_name="uom.uom", + string="UoM", + domain="[('category_id', '=', product_uom_category_id)]", + ) + product_uom_category_id = fields.Many2one(related="product_id.uom_id.category_id") + unit_cost = fields.Float( + string="Cost", + compute="_compute_unit_cost", + min_display_digits="Product Price", + store=True, + readonly=False, + copy=False, + ) + cost_ratio = fields.Float( + string="Cost Ratio (%)", + inverse="_inverse_cost_ratio", + store=True, + copy=False, + ) + price_unit = fields.Float( + string="Sale Price", + compute="_compute_price_unit", + inverse="_inverse_price_unit", + store=True, + min_display_digits="Product Price", + readonly=False, + ) + margin = fields.Float( + compute="_compute_margin", + min_display_digits="Product Price", + store=True, + ) + margin_percent = fields.Float( + string="Margin (%)", + compute="_compute_margin", + store=True, + ) + discount = fields.Float( + string="Disc.%", + digits="Discount", + ) + discount_fixed = fields.Float( + string="Discount (Fixed)", + digits="Product Price", + help="Fixed amount discount.", + ) + price_subtotal = fields.Monetary( + string="Amount", + compute="_compute_price_subtotal", + store=True, + ) + cost_subtotal = fields.Monetary( + string="Cost Amount", + compute="_compute_cost_subtotal", + store=True, + ) + tax_id = fields.Many2many( + comodel_name="account.tax", string="Taxes", check_company=True + ) + company_id = fields.Many2one( + related="estimation_id.company_id", + store=True, + ) + currency_id = fields.Many2one( + related="estimation_id.currency_id", + ) + + @api.depends("product_id", "company_id", "currency_id", "product_uom_id") + def _compute_unit_cost(self): + for line in self: + if not line.product_id: + line.unit_cost = 0.0 + continue + line = line.with_company(line.company_id) + + # Convert the cost to the line UoM + product_cost = line.product_id.uom_id._compute_price( + line.product_id.standard_price, + line.product_uom_id, + ) + line.unit_cost = product_cost + + def _inverse_cost_ratio(self): + for rec in self: + if rec.cost_ratio: + rec.price_unit = rec.unit_cost / rec.cost_ratio + + @api.depends("product_id", "company_id", "currency_id", "product_uom_id") + def _compute_price_unit(self): + for line in self: + if not line.product_id: + line.price_unit = 0.0 + continue + line = line.with_company(line.company_id) + + # Convert the price to the line UoM + product_price = line.product_id.uom_id._compute_price( + line.product_id.list_price, + line.product_uom_id, + ) + line.price_unit = product_price + + def _inverse_price_unit(self): + for rec in self: + if rec.price_unit <= 0: + rec.cost_ratio = 0.0 + else: + rec.cost_ratio = rec.unit_cost / rec.price_unit + + @api.depends( + "product_uom_qty", "discount", "discount_fixed", "price_unit", "tax_id" + ) + def _compute_price_subtotal(self): + for line in self: + base_line = line._prepare_base_line_for_taxes_computation() + self.env["account.tax"]._add_tax_details_in_base_line( + base_line, line.company_id + ) + line.price_subtotal = base_line["tax_details"][ + "raw_total_excluded_currency" + ] + + @api.depends("product_uom_qty", "unit_cost") + def _compute_cost_subtotal(self): + for line in self: + line.cost_subtotal = line.product_uom_qty * line.unit_cost + + @api.depends("price_subtotal", "product_uom_qty", "unit_cost") + def _compute_margin(self): + for line in self: + line.margin = line.price_subtotal - (line.unit_cost * line.product_uom_qty) + line.margin_percent = ( + line.price_subtotal and line.margin / line.price_subtotal + ) + + @api.onchange("discount_fixed") + def _onchange_discount_fixed(self): + for line in self: + if line.discount_fixed: + line.discount = 0.0 + + @api.onchange("discount") + def _onchange_discount(self): + for line in self: + if line.discount: + line.discount_fixed = 0.0 + + def _get_discount_from_fixed_discount(self): + """Calculate the discount percentage from the fixed discount amount.""" + self.ensure_one() + if not self.discount_fixed: + return self.discount + + return ( + (self.price_unit != 0) + and ((self.discount_fixed) / self.price_unit) * 100 + or 0.00 + ) + + def _prepare_base_line_for_taxes_computation(self, **kwargs): + """Convert the current record to a dictionary in order to use the + generic taxes computation method defined on account.tax. + + :return: A python dictionary. + """ + self.ensure_one() + return self.env["account.tax"]._prepare_base_line_for_taxes_computation( + self, + **{ + "tax_ids": self.tax_id, + "quantity": self.product_uom_qty, + "partner_id": self.estimation_id.partner_id, + "currency_id": self.estimation_id.currency_id + or self.estimation_id.company_id.currency_id, + # 'rate': self.estimation_id.currency_rate, + "discount": self._get_discount_from_fixed_discount(), + **kwargs, + }, + ) + + @api.depends("product_id") + def _compute_name(self): + for line in self: + if not line.product_id: + continue + line.name = ( + line.product_id.get_product_multiline_description_sale() + or line.product_id.name + ) diff --git a/project_estimation/models/res_config_settings.py b/project_estimation/models/res_config_settings.py new file mode 100644 index 0000000000..498816b4a9 --- /dev/null +++ b/project_estimation/models/res_config_settings.py @@ -0,0 +1,12 @@ +# Copyright 2026 Ecosoft Co., Ltd (https://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + group_discount_project_estimation = fields.Boolean( + implied_group="project_estimation.group_discount_project_estimation" + ) diff --git a/project_estimation/models/sale_order.py b/project_estimation/models/sale_order.py new file mode 100644 index 0000000000..962c55857d --- /dev/null +++ b/project_estimation/models/sale_order.py @@ -0,0 +1,22 @@ +# Copyright 2026 Ecosoft Co., Ltd (https://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + project_estimation_id = fields.Many2one( + comodel_name="project.estimation", + ) + + def action_view_estimation(self): + self.ensure_one() + return { + "name": self.env._("Project Estimation"), + "type": "ir.actions.act_window", + "res_model": "project.estimation", + "view_mode": "form", + "res_id": self.project_estimation_id.id, + } diff --git a/project_estimation/pyproject.toml b/project_estimation/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/project_estimation/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/project_estimation/readme/CONTRIBUTORS.md b/project_estimation/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..9190ef2406 --- /dev/null +++ b/project_estimation/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Ecosoft](https://ecosoft.co.th): + - Saran Lim. diff --git a/project_estimation/readme/DESCRIPTION.md b/project_estimation/readme/DESCRIPTION.md new file mode 100644 index 0000000000..f9be569f3b --- /dev/null +++ b/project_estimation/readme/DESCRIPTION.md @@ -0,0 +1,13 @@ +This module is a pre-sales module designed to manage project cost estimation before confirming a Sales Order. + +In many service-based businesses, project discussions start with rough calculations, scope alignment, and internal margin validation. Creating a full Project too early can mix pre-sales assumptions with confirmed execution data. This module separates the estimation phase from the execution phase. + +The purpose of this module is to: +- Provide a structured space for cost calculation before commitment +- Allow internal validation of pricing and margin +- Prevent pollution of confirmed Project data with unvalidated assumptions +- Enable clean conversion to Sales Order once approved + +This module represents the commercial negotiation stage, not the operational delivery stage. + +Once the estimation is validated and approved, it can be converted into a Sales Order. If the deal does not proceed, the estimation can simply be cancelled without impacting project reporting. diff --git a/project_estimation/readme/USAGE.md b/project_estimation/readme/USAGE.md new file mode 100644 index 0000000000..3a357168c1 --- /dev/null +++ b/project_estimation/readme/USAGE.md @@ -0,0 +1,9 @@ +To use this module, you need to: + +1. Go to menu Project > Estimation +2. Create a new Project Estimation +3. Add estimation lines such as services, materials, or working hours. For each line, define quantity, cost, and price. +4. Review the overall estimation. Adjust pricing or quantities until the expected margin meets business requirements. +5. Once internally validated, confirm the estimation. +6. Click Create Sales Order. It will generate a Sales Order based on the estimation lines, and the standard sales workflow continues from there. +7. If the deal does not proceed, simply cancel the estimation. No Project or accounting records will be affected. diff --git a/project_estimation/security/ir.model.access.csv b/project_estimation/security/ir.model.access.csv new file mode 100644 index 0000000000..3659b76085 --- /dev/null +++ b/project_estimation/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_project_estimation_user,access_project_estimation_user,model_project_estimation,project.group_project_user,1,1,1,1 +access_project_estimation_line_user,access_project_estimation_line_user,model_project_estimation_line,project.group_project_user,1,1,1,1 +access_project_estimation_create_sale_order,access_project_estimation_create_sale_order,model_project_estimation_create_sale_order,project.group_project_user,1,1,1,1 +access_project_estimation_create_sale_order_line,access_project_estimation_create_sale_order_line,model_project_estimation_create_sale_order_line,project.group_project_user,1,1,1,1 diff --git a/project_estimation/security/project_estimation_security.xml b/project_estimation/security/project_estimation_security.xml new file mode 100644 index 0000000000..2d04b2ae22 --- /dev/null +++ b/project_estimation/security/project_estimation_security.xml @@ -0,0 +1,11 @@ + + + + Project Estimation multi company rule + + + ['|',('company_id','=',False),('company_id', 'in', company_ids)] + + diff --git a/project_estimation/security/res_groups.xml b/project_estimation/security/res_groups.xml new file mode 100644 index 0000000000..ce464cdf77 --- /dev/null +++ b/project_estimation/security/res_groups.xml @@ -0,0 +1,7 @@ + + + + Discount on Project Estimation + + + diff --git a/project_estimation/static/description/index.html b/project_estimation/static/description/index.html new file mode 100644 index 0000000000..85f575dfb5 --- /dev/null +++ b/project_estimation/static/description/index.html @@ -0,0 +1,472 @@ + + + + + +Project Estimation + + + +
+

Project Estimation

+ + +

Alpha License: AGPL-3 OCA/project Translate me on Weblate Try me on Runboat

+

This module is a pre-sales module designed to manage project cost +estimation before confirming a Sales Order.

+

In many service-based businesses, project discussions start with rough +calculations, scope alignment, and internal margin validation. Creating +a full Project too early can mix pre-sales assumptions with confirmed +execution data. This module separates the estimation phase from the +execution phase.

+

The purpose of this module is to:

+
    +
  • Provide a structured space for cost calculation before commitment
  • +
  • Allow internal validation of pricing and margin
  • +
  • Prevent pollution of confirmed Project data with unvalidated +assumptions
  • +
  • Enable clean conversion to Sales Order once approved
  • +
+

This module represents the commercial negotiation stage, not the +operational delivery stage.

+

Once the estimation is validated and approved, it can be converted into +a Sales Order. If the deal does not proceed, the estimation can simply +be cancelled without impacting project reporting.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+

To use this module, you need to:

+
    +
  1. Go to menu Project > Estimation
  2. +
  3. Create a new Project Estimation
  4. +
  5. Add estimation lines such as services, materials, or working hours. +For each line, define quantity, cost, and price.
  6. +
  7. Review the overall estimation. Adjust pricing or quantities until the +expected margin meets business requirements.
  8. +
  9. Once internally validated, confirm the estimation.
  10. +
  11. Click Create Sales Order. It will generate a Sales Order based on the +estimation lines, and the standard sales workflow continues from +there.
  12. +
  13. If the deal does not proceed, simply cancel the estimation. No +Project or accounting records will be affected.
  14. +
+
+
+

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

+
    +
  • Ecosoft
  • +
+
+
+

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:

+

Saran440

+

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

+

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

+
+
+
+ + diff --git a/project_estimation/tests/__init__.py b/project_estimation/tests/__init__.py new file mode 100644 index 0000000000..c4d1a4baff --- /dev/null +++ b/project_estimation/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_project_estimation diff --git a/project_estimation/tests/test_project_estimation.py b/project_estimation/tests/test_project_estimation.py new file mode 100644 index 0000000000..4230005111 --- /dev/null +++ b/project_estimation/tests/test_project_estimation.py @@ -0,0 +1,168 @@ +# Copyright 2026 Ecosoft Co., Ltd (https://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.tests.common import TransactionCase + + +class TestProjectEstimation(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + cls.company = cls.env.company + cls.partner = cls.env["res.partner"].create({"name": "Test Customer"}) + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + + cls.tax_7 = cls.env["account.tax"].create( + { + "name": "Tax 7%", + "amount_type": "percent", + "amount": 7.0, + "type_tax_use": "sale", + } + ) + + # Product + cls.product = cls.env["product.product"].create( + { + "name": "Test Service", + "type": "service", + "uom_id": cls.uom_unit.id, + "uom_po_id": cls.uom_unit.id, + "standard_price": 50.0, + "list_price": 100.0, + } + ) + + def test_01_estimation_computations(self): + """Test subtotal, margin, and total computations on estimation.""" + estimation = self.env["project.estimation"].create( + { + "partner_id": self.partner.id, + "target_margin": 20.0, + "line_ids": [ + Command.create( + { + "product_id": self.product.id, + "name": self.product.name, + "product_uom_qty": 2.0, + "price_unit": 100.0, + "unit_cost": 50.0, + "tax_id": [Command.set(self.tax_7.ids)], + } + ) + ], + } + ) + + line = estimation.line_ids[0] + + self.assertEqual(line.price_subtotal, 200.0) # 2 * 100 + self.assertEqual(line.cost_subtotal, 100.0) # 2 * 50 + self.assertEqual(line.margin, 100.0) # 200 - 100 + self.assertEqual(line.margin_percent, 0.5) # 100 / 200 = 50% + + self.assertEqual(estimation.total_cost, 100.0) + self.assertEqual(estimation.amount_untaxed, 200.0) + self.assertEqual(estimation.amount_tax, 14.0) # 200 * 0.07 + self.assertEqual(estimation.amount_total, 214.0) + + self.assertEqual(estimation.total_margin, 100.0) + self.assertEqual(estimation.total_margin_percent, 0.5) + + # Target expected fields + # total_cost / (1 - target_margin) -> 100 / (1 - 0.2) = 125 + self.assertEqual(estimation.target_sale_price, 125.0) + self.assertEqual(estimation.expected_profit, 25.0) + + def test_02_workflow_and_wizard(self): + """Test estimation workflow and wizard Sale Order generation.""" + estimation = self.env["project.estimation"].create( + { + "partner_id": self.partner.id, + "line_ids": [ + Command.create( + { + "product_id": self.product.id, + "name": self.product.name, + "product_uom_qty": 1.0, + "price_unit": 100.0, + "unit_cost": 50.0, + } + ) + ], + } + ) + + self.assertEqual(estimation.state, "draft") + estimation.action_approve() + self.assertEqual(estimation.state, "approved") + + # Open Wizard + wizard = ( + self.env["project.estimation.create.sale.order"] + .with_context(default_estimation_id=estimation.id) + .create( + { + "estimation_id": estimation.id, + } + ) + ) + + # Ensure wizard loaded lines properly + self.assertEqual(len(wizard.line_ids), 1) + self.assertEqual(wizard.line_ids[0].price_unit, 100.0) + + # Execute wizard + res = wizard.action_create_sale_order() + + # Check wizard result + self.assertEqual(res["res_model"], "sale.order") + self.assertEqual(estimation.state, "won") + + so = self.env["sale.order"].browse(res["res_id"]) + self.assertEqual(so.partner_id, self.partner) + self.assertEqual(len(so.order_line), 1) + self.assertEqual(so.order_line[0].price_unit, 100.0) + + # Ensure project was created as requested + self.assertTrue(estimation.project_id) + self.assertEqual(estimation.project_id.name, estimation.name) + + def test_03_display_type_no_computations(self): + """Test section/note lines and ensure they don't break computation.""" + estimation = self.env["project.estimation"].create( + { + "partner_id": self.partner.id, + "line_ids": [ + Command.create( + { + "display_type": "line_section", + "name": "Section A", + } + ), + Command.create( + { + "product_id": self.product.id, + "name": self.product.name, + "product_uom_qty": 1.0, + "price_unit": 100.0, + "unit_cost": 50.0, + } + ), + ], + } + ) + + section_line = estimation.line_ids.filtered( + lambda line: line.display_type == "line_section" + ) + self.assertTrue(section_line) + self.assertEqual(section_line.cost_subtotal, 0.0) + self.assertEqual(section_line.price_subtotal, 0.0) + + # Totals should ignore section line + self.assertEqual(estimation.total_cost, 50.0) + self.assertEqual(estimation.amount_untaxed, 100.0) diff --git a/project_estimation/views/project_estimation_views.xml b/project_estimation/views/project_estimation_views.xml new file mode 100644 index 0000000000..da778e2559 --- /dev/null +++ b/project_estimation/views/project_estimation_views.xml @@ -0,0 +1,366 @@ + + + + + project.estimation.list + project.estimation + + + + + + + + + + + + + + + + + + + + project.estimation.form + project.estimation + +
+
+
+ +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ + +
+
+ + + project.estimation.search + project.estimation + + + + + + + + + + + + + + + + + + + + + Estimations + project.estimation + list,form + + + + + +
diff --git a/project_estimation/views/res_config_settings_views.xml b/project_estimation/views/res_config_settings_views.xml new file mode 100644 index 0000000000..e276b02a7a --- /dev/null +++ b/project_estimation/views/res_config_settings_views.xml @@ -0,0 +1,24 @@ + + + + Configure project display name + res.config.settings + + + + + + + + + + + + diff --git a/project_estimation/views/sale_order_view.xml b/project_estimation/views/sale_order_view.xml new file mode 100644 index 0000000000..345ab76142 --- /dev/null +++ b/project_estimation/views/sale_order_view.xml @@ -0,0 +1,13 @@ + + + + sale.order.form + sale.order + + + + + + + + diff --git a/project_estimation/wizard/__init__.py b/project_estimation/wizard/__init__.py new file mode 100644 index 0000000000..4497c268f9 --- /dev/null +++ b/project_estimation/wizard/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import create_sale_order diff --git a/project_estimation/wizard/create_sale_order.py b/project_estimation/wizard/create_sale_order.py new file mode 100644 index 0000000000..43329be018 --- /dev/null +++ b/project_estimation/wizard/create_sale_order.py @@ -0,0 +1,118 @@ +# Copyright 2026 Ecosoft Co., Ltd (https://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command, _, api, fields, models +from odoo.exceptions import UserError + + +class ProjectEstimationCreateSaleOrder(models.TransientModel): + _name = "project.estimation.create.sale.order" + _description = "Create Sale Order from Estimation" + + estimation_id = fields.Many2one( + comodel_name="project.estimation", + required=True, + readonly=True, + ) + partner_id = fields.Many2one( + related="estimation_id.partner_id", + ) + line_ids = fields.One2many( + comodel_name="project.estimation.create.sale.order.line", + inverse_name="wizard_id", + string="Sale Order Lines", + ) + + def _get_data_lines(self, line): + return { + "display_type": line.display_type, + "product_id": line.product_id.id, + "name": line.name, + "product_uom_qty": line.product_uom_qty, + "price_unit": line.price_unit, + "tax_id": [Command.set(line.tax_id.ids)], + } + + def _get_sale_order_vals(self): + so_lines = [ + Command.create(self._get_data_lines(line)) for line in self.line_ids + ] + so_vals = [ + { + "partner_id": self.partner_id.id, + "origin": self.estimation_id.name, + "project_estimation_id": self.estimation_id.id, + "order_line": so_lines, + } + ] + return so_vals + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + estimation_id = res.get("estimation_id") or self.env.context.get( + "default_estimation_id" + ) + if estimation_id: + estimation = self.env["project.estimation"].browse(estimation_id) + lines = [ + Command.create(self._get_data_lines(line)) + for line in estimation.line_ids + ] + res["line_ids"] = lines + return res + + def action_create_sale_order(self): + self.ensure_one() + if not self.line_ids: + raise UserError(self.env._("No lines to create Sale Order.")) + + # Create Sale Order + so_vals = self._get_sale_order_vals() + sale_order = self.env["sale.order"].create(so_vals) + + # Return action to view the created Sale Order + return { + "name": _("Sale Order"), + "type": "ir.actions.act_window", + "res_model": "sale.order", + "view_mode": "form", + "res_id": sale_order.id, + } + + +class ProjectEstimationCreateSaleOrderLine(models.TransientModel): + _name = "project.estimation.create.sale.order.line" + _description = "Create Sale Order Line" + + wizard_id = fields.Many2one( + comodel_name="project.estimation.create.sale.order", + required=True, + ondelete="cascade", + ) + display_type = fields.Selection( + selection=[ + ("line_section", "Section"), + ("line_note", "Note"), + ], + default=False, + ) + product_id = fields.Many2one( + comodel_name="product.product", + ) + name = fields.Char(string="Description") + product_uom_qty = fields.Float(string="Quantity", default=1.0) + price_unit = fields.Float(string="Unit Price") + tax_id = fields.Many2many( + comodel_name="account.tax", + string="Taxes", + ) + price_subtotal = fields.Float( + string="Subtotal", + compute="_compute_price_subtotal", + ) + + @api.depends("product_uom_qty", "price_unit") + def _compute_price_subtotal(self): + for line in self: + line.price_subtotal = line.product_uom_qty * line.price_unit diff --git a/project_estimation/wizard/create_sale_order_view.xml b/project_estimation/wizard/create_sale_order_view.xml new file mode 100644 index 0000000000..8d5351caaf --- /dev/null +++ b/project_estimation/wizard/create_sale_order_view.xml @@ -0,0 +1,43 @@ + + + + project.estimation.create.sale.order.form + project.estimation.create.sale.order + +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+