diff --git a/spreadsheet_sale_calculator/README.rst b/spreadsheet_sale_calculator/README.rst new file mode 100644 index 00000000..2531f36d --- /dev/null +++ b/spreadsheet_sale_calculator/README.rst @@ -0,0 +1,208 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=========================== +Spreadsheet Sale Calculator +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:52516a3d5cfbe9c5492ee70a59cfabf88edf4b9e87807b5fc4417b4b8a2a57c9 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fspreadsheet-lightgray.png?logo=github + :target: https://github.com/OCA/spreadsheet/tree/19.0/spreadsheet_sale_calculator + :alt: OCA/spreadsheet +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/spreadsheet-19-0/spreadsheet-19-0-spreadsheet_sale_calculator + :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/spreadsheet&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds a **quote calculator** to quotations: an Odoo +spreadsheet, attached to the order, whose results are written straight +onto the order lines. + +Many quotes depend on a calculation — square metres, weights, tiered +discounts, material breakdowns — that usually lives in a separate +spreadsheet and is copied back onto the order by hand. This module +brings that spreadsheet inside Odoo: you build the calculation once with +ordinary formulas, point its result cells at the order-line fields they +should feed (unit price, quantity, discount, description...), and push +the computed values onto the lines in a single click. + +The usual way is to prepare the calculator once on a **quotation +template** and let every quote made from it reuse it, but you can also +attach one straight to a single order. Either way each quote gets its +own private copy, so a salesperson can adjust their own inputs without +ever touching the shared original. + +The sync is one-way (spreadsheet → order lines) and triggered by hand, +so it never disturbs normal quoting and the spreadsheet stays completely +free-form. + +It builds on **spreadsheet_field_linking**, which provides the +cell-to-field linking; this module adds the sale-specific tools: the +template calculator, the per-quote copy, and the smart button on the +order. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +The quote calculator is set up once on a quotation template, though, +then rides along on every quote made from that template. It works out of +the box — there is nothing to configure. Everything below happens with +ordinary spreadsheet formulas and the linking tools from +**spreadsheet_field_linking**. + +Prepare the calculator on a quotation template +---------------------------------------------- + +Go to **Sales → Configuration → Quotation Templates** and open (or +create) a template, adding its lines as usual. In the **Quote +Calculator** field, create a new spreadsheet and open it — it starts +empty, ready for whatever calculation you need. + +|image1| + +Build your calculation with normal formulas: the inputs, the +intermediate steps, and the result cells you want to end up on the +order. + +Then link each **result cell** to the order-line field it should feed — +unit price, quantity, discount, the product on a line, and so on. +Linking a cell to a field, and choosing which line it targets, is done +with the tools from **spreadsheet_field_linking**; see its documentation +for how. The mappings live inside the spreadsheet, so they travel with +every copy. + +Attach a calculator to a quotation +---------------------------------- + +There are two ways to give an order a calculator: + +- **From a quotation template** — on a new quotation, choose a + **quotation template** that carries a calculator and the order picks + it up automatically. This is the usual route, since one template + serves every quote made from it. +- **Directly on the order** — pick a spreadsheet in the **Quote + Calculator Template** field of the order itself, with no quotation + template involved. Handy for a one-off quote or an ad-hoc calculation. + +|image2| + +Either way a **Quote Calculator** smart button appears. Choosing the +template or the spreadsheet copies nothing on its own — it only points +the order at the calculator to use. The private copy is made when you +**first click the button**, which is why the button can show before any +spreadsheet exists yet. From then on the button just reopens that copy, +so your changes never affect the shared original or any other quote. + +Run the calculator and write the values back +-------------------------------------------- + +Click the **Quote Calculator** smart button to open the editor and +adjust your input values. + +|image3| + +When you are happy with the result, click **Write to record** in the top +bar. For each linked cell the current value is read, checked against the +field's type, and written onto the matching order line; the workbook is +saved at the same time. Because the order lines are created from the +template lines in the same order, position ``1`` still points at the +first line after the quote is made, so nothing has to be re-mapped on +copy. + +|image4| + +Change or rebuild the calculator +-------------------------------- + +If you switch the quotation template — or the chosen calculator — after +a copy already exists, a **Rebuild Calculator** button appears next to +the field. It discards the current copy and rebuilds a fresh one from +the new template, which is handy when the template's calculator has been +improved. The inputs in the old copy are lost, so it asks for +confirmation first. + +Good to know +------------ + +- The sync is one-way: the spreadsheet writes onto the order, never the + other way around. +- It only runs when you click **Write to record**, so editing the sheet + never changes the order on its own. +- The **Quote Calculator** button is what creates the order's private + copy, on its first click; selecting a template or spreadsheet only + sets which calculator to copy. That is why the button can appear while + no spreadsheet exists yet. +- An **empty** cell clears its field to the empty value (``0``, blank + text, no relation). +- Which fields can be reached, how line positions are counted, and when + a new line is created (or deliberately not) are all handled by + **spreadsheet_field_linking** — see its documentation for the details + and limitations. + +.. |image1| image:: https://raw.githubusercontent.com/OCA/spreadsheet/19.0/spreadsheet_sale_calculator/static/description/quote_template_set_calculator_template.png +.. |image2| image:: https://raw.githubusercontent.com/OCA/spreadsheet/19.0/spreadsheet_sale_calculator/static/description/quotation_with_attached_calculator.png +.. |image3| image:: https://raw.githubusercontent.com/OCA/spreadsheet/19.0/spreadsheet_sale_calculator/static/description/calculator_edit_and_apply_values.png +.. |image4| image:: https://raw.githubusercontent.com/OCA/spreadsheet/19.0/spreadsheet_sale_calculator/static/description/calculator_reflected_values.png + +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 +------- + +* arielbarreiros96 + +Contributors +------------ + +- `arielbarreiros96 `__ + +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. + +This module is part of the `OCA/spreadsheet `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/spreadsheet_sale_calculator/__init__.py b/spreadsheet_sale_calculator/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/spreadsheet_sale_calculator/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/spreadsheet_sale_calculator/__manifest__.py b/spreadsheet_sale_calculator/__manifest__.py new file mode 100644 index 00000000..a547d811 --- /dev/null +++ b/spreadsheet_sale_calculator/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2026 arielbarreiros96 +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0). +{ + "name": "Spreadsheet Sale Calculator", + "version": "19.0.3.0.0", + "summary": "Drive sale order line values from an embedded Odoo spreadsheet", + "author": "arielbarreiros96, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/spreadsheet", + "license": "AGPL-3", + "category": "Sales", + "depends": ["sale_management", "spreadsheet_field_linking"], + "data": [ + "security/spreadsheet_sale_calculator_groups.xml", + "views/sale_order_template_views.xml", + "views/sale_order_views.xml", + ], + "installable": True, + "application": False, +} diff --git a/spreadsheet_sale_calculator/i18n/spreadsheet_sale_calculator.pot b/spreadsheet_sale_calculator/i18n/spreadsheet_sale_calculator.pot new file mode 100644 index 00000000..5f38dd1c --- /dev/null +++ b/spreadsheet_sale_calculator/i18n/spreadsheet_sale_calculator.pot @@ -0,0 +1,114 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * spreadsheet_sale_calculator +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-31 17:35+0000\n" +"PO-Revision-Date: 2026-07-31 17:35+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: spreadsheet_sale_calculator +#. odoo-python +#: code:addons/spreadsheet_sale_calculator/models/sale_order.py:0 +msgid "%s Calculator" +msgstr "" + +#. module: spreadsheet_sale_calculator +#. odoo-python +#: code:addons/spreadsheet_sale_calculator/models/sale_order.py:0 +msgid "Cannot write calculator values into a cancelled order." +msgstr "" + +#. module: spreadsheet_sale_calculator +#: model_terms:ir.ui.view,arch_db:spreadsheet_sale_calculator.view_order_form_spreadsheet_calculator +msgid "" +"Discard the current calculator and rebuild it from the selected template" +msgstr "" + +#. module: spreadsheet_sale_calculator +#: model:ir.model.fields,field_description:spreadsheet_sale_calculator.field_sale_order__display_name +#: model:ir.model.fields,field_description:spreadsheet_sale_calculator.field_sale_order_template__display_name +#: model:ir.model.fields,field_description:spreadsheet_sale_calculator.field_spreadsheet_spreadsheet__display_name +msgid "Display Name" +msgstr "" + +#. module: spreadsheet_sale_calculator +#: model:ir.model.fields,field_description:spreadsheet_sale_calculator.field_sale_order__show_update_calculator +msgid "Has Quote Calculator Template Changed" +msgstr "" + +#. module: spreadsheet_sale_calculator +#: model:ir.model.fields,field_description:spreadsheet_sale_calculator.field_sale_order__id +#: model:ir.model.fields,field_description:spreadsheet_sale_calculator.field_sale_order_template__id +#: model:ir.model.fields,field_description:spreadsheet_sale_calculator.field_spreadsheet_spreadsheet__id +msgid "ID" +msgstr "" + +#. module: spreadsheet_sale_calculator +#: model:ir.model,name:spreadsheet_sale_calculator.model_sale_order_template +msgid "Quotation Template" +msgstr "" + +#. module: spreadsheet_sale_calculator +#: model:ir.model.fields,field_description:spreadsheet_sale_calculator.field_sale_order__calculator_spreadsheet_id +#: model:ir.model.fields,field_description:spreadsheet_sale_calculator.field_sale_order_template__quote_calculator_id +#: model_terms:ir.ui.view,arch_db:spreadsheet_sale_calculator.view_order_form_spreadsheet_calculator +msgid "Quote Calculator" +msgstr "" + +#. module: spreadsheet_sale_calculator +#: model:ir.model.fields,field_description:spreadsheet_sale_calculator.field_sale_order__template_calculator_id +msgid "Quote Calculator Template" +msgstr "" + +#. module: spreadsheet_sale_calculator +#: model_terms:ir.ui.view,arch_db:spreadsheet_sale_calculator.view_order_form_spreadsheet_calculator +msgid "Rebuild Calculator" +msgstr "" + +#. module: spreadsheet_sale_calculator +#: model:ir.model,name:spreadsheet_sale_calculator.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: spreadsheet_sale_calculator +#: model:ir.model,name:spreadsheet_sale_calculator.model_spreadsheet_spreadsheet +msgid "Spreadsheet" +msgstr "" + +#. module: spreadsheet_sale_calculator +#: model:ir.model.fields,help:spreadsheet_sale_calculator.field_sale_order__template_calculator_id +msgid "" +"Spreadsheet copied as this order's quote calculator. Defaults to the one set" +" on the quotation template; pick another to override it." +msgstr "" + +#. module: spreadsheet_sale_calculator +#. odoo-python +#: code:addons/spreadsheet_sale_calculator/models/sale_order.py:0 +msgid "" +"This order has no quote calculator. Set one on its quotation template first." +msgstr "" + +#. module: spreadsheet_sale_calculator +#. odoo-python +#: code:addons/spreadsheet_sale_calculator/models/sale_order.py:0 +msgid "" +"This order is locked. Unlock it before writing calculator values into its " +"lines." +msgstr "" + +#. module: spreadsheet_sale_calculator +#: model_terms:ir.ui.view,arch_db:spreadsheet_sale_calculator.view_order_form_spreadsheet_calculator +msgid "" +"This will discard the current quote calculator and rebuild it from the " +"selected template. Any changes made in it will be lost." +msgstr "" diff --git a/spreadsheet_sale_calculator/models/__init__.py b/spreadsheet_sale_calculator/models/__init__.py new file mode 100644 index 00000000..8db9afcc --- /dev/null +++ b/spreadsheet_sale_calculator/models/__init__.py @@ -0,0 +1,3 @@ +from . import spreadsheet_spreadsheet +from . import sale_order +from . import sale_order_template diff --git a/spreadsheet_sale_calculator/models/sale_order.py b/spreadsheet_sale_calculator/models/sale_order.py new file mode 100644 index 00000000..bfce8663 --- /dev/null +++ b/spreadsheet_sale_calculator/models/sale_order.py @@ -0,0 +1,96 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class SaleOrder(models.Model): + _name = "sale.order" + _inherit = ["sale.order", "spreadsheet.linkable"] + + template_calculator_id = fields.Many2one( + "spreadsheet.spreadsheet", + string="Quote Calculator Template", + compute="_compute_template_calculator_id", + store=True, + readonly=False, + copy=False, + domain=[("link_res_model", "=", False)], + help="Spreadsheet copied as this order's quote calculator. Defaults to " + "the one set on the quotation template; pick another to override it.", + ) + calculator_spreadsheet_id = fields.Many2one( + "spreadsheet.spreadsheet", + string="Quote Calculator", + copy=False, + ) + show_update_calculator = fields.Boolean( + string="Has Quote Calculator Template Changed", store=False + ) + + @api.depends("sale_order_template_id") + def _compute_template_calculator_id(self): + for order in self: + order.template_calculator_id = ( + order.sale_order_template_id.quote_calculator_id + ) + + @api.onchange("template_calculator_id") + def _onchange_template_calculator_id_show_update(self): + self.show_update_calculator = ( + self._origin.template_calculator_id != self.template_calculator_id + ) + + def _build_calculator_copy(self): + self.ensure_one() + return self.template_calculator_id.sudo().copy( + { + "name": self.env._("%s Calculator", self.name), + "owner_id": self.env.user.id, + } + ) + + def action_open_quote_calculator(self): + self.ensure_one() + if not self.calculator_spreadsheet_id: + if not self.template_calculator_id: + raise UserError( + self.env._( + "This order has no quote calculator. Set one on its " + "quotation template first." + ) + ) + self.calculator_spreadsheet_id = self._build_calculator_copy().id + self._link_spreadsheet(self.calculator_spreadsheet_id) + return self.calculator_spreadsheet_id.open_spreadsheet() + + def action_update_quote_calculator(self): + self.ensure_one() + stale = self.calculator_spreadsheet_id + self.calculator_spreadsheet_id = ( + self._build_calculator_copy() if self.template_calculator_id else False + ) + if stale: + stale.sudo().unlink() + self.show_update_calculator = False + + def _check_linking_allowed(self): + self.ensure_one() + if self.state == "cancel": + raise UserError( + self.env._("Cannot write calculator values into a cancelled order.") + ) + if self.locked: + raise UserError( + self.env._( + "This order is locked. Unlock it before writing " + "calculator values into its lines." + ) + ) + + def _linking_write(self, record, values): + # Write price_unit last and alone so the pricelist recompute can't clobber it. + values = dict(values) + price = values.pop("price_unit", None) + if values: + record.write(values) + if price is not None: + record.write({"price_unit": price}) diff --git a/spreadsheet_sale_calculator/models/sale_order_template.py b/spreadsheet_sale_calculator/models/sale_order_template.py new file mode 100644 index 00000000..a0796231 --- /dev/null +++ b/spreadsheet_sale_calculator/models/sale_order_template.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class SaleOrderTemplate(models.Model): + _inherit = "sale.order.template" + + quote_calculator_id = fields.Many2one( + "spreadsheet.spreadsheet", + string="Quote Calculator", + copy=False, + domain=[("link_res_model", "=", False)], + ) diff --git a/spreadsheet_sale_calculator/models/spreadsheet_spreadsheet.py b/spreadsheet_sale_calculator/models/spreadsheet_spreadsheet.py new file mode 100644 index 00000000..857d09d5 --- /dev/null +++ b/spreadsheet_sale_calculator/models/spreadsheet_spreadsheet.py @@ -0,0 +1,23 @@ +from odoo import models + + +class SpreadsheetSpreadsheet(models.Model): + _inherit = "spreadsheet.spreadsheet" + + def _get_calculator_template(self): + self.ensure_one() + return self.env["sale.order.template"].search( + [("quote_calculator_id", "=", self.id)], limit=1 + ) + + def _get_link_context(self): + self.ensure_one() + if self._get_calculator_template(): + return { + "isLinkable": True, + "writable": False, + "model": "sale.order", + "resId": False, + "recordName": False, + } + return super()._get_link_context() diff --git a/spreadsheet_sale_calculator/pyproject.toml b/spreadsheet_sale_calculator/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spreadsheet_sale_calculator/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spreadsheet_sale_calculator/readme/CONTRIBUTORS.md b/spreadsheet_sale_calculator/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..2ea54631 --- /dev/null +++ b/spreadsheet_sale_calculator/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- [arielbarreiros96](https://www.github.com/arielbarreiros96) diff --git a/spreadsheet_sale_calculator/readme/DESCRIPTION.md b/spreadsheet_sale_calculator/readme/DESCRIPTION.md new file mode 100644 index 00000000..036ea2b7 --- /dev/null +++ b/spreadsheet_sale_calculator/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +This module adds a **quote calculator** to quotations: an Odoo spreadsheet, attached to the order, whose results are written straight onto the order lines. + +Many quotes depend on a calculation — square metres, weights, tiered discounts, material breakdowns — that usually lives in a separate spreadsheet and is copied back onto the order by hand. This module brings that spreadsheet inside Odoo: you build the calculation once with ordinary formulas, point its result cells at the order-line fields they should feed (unit price, quantity, discount, description...), and push the computed values onto the lines in a single click. + +The usual way is to prepare the calculator once on a **quotation template** and let every quote made from it reuse it, but you can also attach one straight to a single order. Either way each quote gets its own private copy, so a salesperson can adjust their own inputs without ever touching the shared original. + +The sync is one-way (spreadsheet → order lines) and triggered by hand, so it never disturbs normal quoting and the spreadsheet stays completely free-form. + +It builds on **spreadsheet_field_linking**, which provides the cell-to-field linking; this module adds the sale-specific tools: the template calculator, the per-quote copy, and the smart button on the order. diff --git a/spreadsheet_sale_calculator/readme/USAGE.md b/spreadsheet_sale_calculator/readme/USAGE.md new file mode 100644 index 00000000..b313b47c --- /dev/null +++ b/spreadsheet_sale_calculator/readme/USAGE.md @@ -0,0 +1,44 @@ +The quote calculator is set up once on a quotation template, though, then rides along on every quote made from that template. It works out of the box — there is nothing to configure. Everything below happens with ordinary spreadsheet formulas and the linking tools from **spreadsheet_field_linking**. + +## Prepare the calculator on a quotation template + +Go to **Sales → Configuration → Quotation Templates** and open (or create) a template, adding its lines as usual. In the **Quote Calculator** field, create a new spreadsheet and open it — it starts empty, ready for whatever calculation you need. + +![](../static/description/quote_template_set_calculator_template.png) + +Build your calculation with normal formulas: the inputs, the intermediate steps, and the result cells you want to end up on the order. + +Then link each **result cell** to the order-line field it should feed — unit price, quantity, discount, the product on a line, and so on. Linking a cell to a field, and choosing which line it targets, is done with the tools from **spreadsheet_field_linking**; see its documentation for how. The mappings live inside the spreadsheet, so they travel with every copy. + +## Attach a calculator to a quotation + +There are two ways to give an order a calculator: + +- **From a quotation template** — on a new quotation, choose a **quotation template** that carries a calculator and the order picks it up automatically. This is the usual route, since one template serves every quote made from it. +- **Directly on the order** — pick a spreadsheet in the **Quote Calculator Template** field of the order itself, with no quotation template involved. Handy for a one-off quote or an ad-hoc calculation. + +![](../static/description/quotation_with_attached_calculator.png) + +Either way a **Quote Calculator** smart button appears. Choosing the template or the spreadsheet copies nothing on its own — it only points the order at the calculator to use. The private copy is made when you **first click the button**, which is why the button can show before any spreadsheet exists yet. From then on the button just reopens that copy, so your changes never affect the shared original or any other quote. + +## Run the calculator and write the values back + +Click the **Quote Calculator** smart button to open the editor and adjust your input values. + +![](../static/description/calculator_edit_and_apply_values.png) + +When you are happy with the result, click **Write to record** in the top bar. For each linked cell the current value is read, checked against the field's type, and written onto the matching order line; the workbook is saved at the same time. Because the order lines are created from the template lines in the same order, position `1` still points at the first line after the quote is made, so nothing has to be re-mapped on copy. + +![](../static/description/calculator_reflected_values.png) + +## Change or rebuild the calculator + +If you switch the quotation template — or the chosen calculator — after a copy already exists, a **Rebuild Calculator** button appears next to the field. It discards the current copy and rebuilds a fresh one from the new template, which is handy when the template's calculator has been improved. The inputs in the old copy are lost, so it asks for confirmation first. + +## Good to know + +- The sync is one-way: the spreadsheet writes onto the order, never the other way around. +- It only runs when you click **Write to record**, so editing the sheet never changes the order on its own. +- The **Quote Calculator** button is what creates the order's private copy, on its first click; selecting a template or spreadsheet only sets which calculator to copy. That is why the button can appear while no spreadsheet exists yet. +- An **empty** cell clears its field to the empty value (`0`, blank text, no relation). +- Which fields can be reached, how line positions are counted, and when a new line is created (or deliberately not) are all handled by **spreadsheet_field_linking** — see its documentation for the details and limitations. diff --git a/spreadsheet_sale_calculator/security/spreadsheet_sale_calculator_groups.xml b/spreadsheet_sale_calculator/security/spreadsheet_sale_calculator_groups.xml new file mode 100644 index 00000000..c079ded2 --- /dev/null +++ b/spreadsheet_sale_calculator/security/spreadsheet_sale_calculator_groups.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/spreadsheet_sale_calculator/static/description/calculator_edit_and_apply_values.png b/spreadsheet_sale_calculator/static/description/calculator_edit_and_apply_values.png new file mode 100644 index 00000000..0283454a Binary files /dev/null and b/spreadsheet_sale_calculator/static/description/calculator_edit_and_apply_values.png differ diff --git a/spreadsheet_sale_calculator/static/description/calculator_reflected_values.png b/spreadsheet_sale_calculator/static/description/calculator_reflected_values.png new file mode 100644 index 00000000..2fd65fb8 Binary files /dev/null and b/spreadsheet_sale_calculator/static/description/calculator_reflected_values.png differ diff --git a/spreadsheet_sale_calculator/static/description/icon.png b/spreadsheet_sale_calculator/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/spreadsheet_sale_calculator/static/description/icon.png differ diff --git a/spreadsheet_sale_calculator/static/description/index.html b/spreadsheet_sale_calculator/static/description/index.html new file mode 100644 index 00000000..25da4aac --- /dev/null +++ b/spreadsheet_sale_calculator/static/description/index.html @@ -0,0 +1,545 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Spreadsheet Sale Calculator

+ +

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

+

This module adds a quote calculator to quotations: an Odoo +spreadsheet, attached to the order, whose results are written straight +onto the order lines.

+

Many quotes depend on a calculation — square metres, weights, tiered +discounts, material breakdowns — that usually lives in a separate +spreadsheet and is copied back onto the order by hand. This module +brings that spreadsheet inside Odoo: you build the calculation once with +ordinary formulas, point its result cells at the order-line fields they +should feed (unit price, quantity, discount, description…), and push +the computed values onto the lines in a single click.

+

The usual way is to prepare the calculator once on a quotation +template and let every quote made from it reuse it, but you can also +attach one straight to a single order. Either way each quote gets its +own private copy, so a salesperson can adjust their own inputs without +ever touching the shared original.

+

The sync is one-way (spreadsheet → order lines) and triggered by hand, +so it never disturbs normal quoting and the spreadsheet stays completely +free-form.

+

It builds on spreadsheet_field_linking, which provides the +cell-to-field linking; this module adds the sale-specific tools: the +template calculator, the per-quote copy, and the smart button on the +order.

+

Table of contents

+ +
+

Usage

+

The quote calculator is set up once on a quotation template, though, +then rides along on every quote made from that template. It works out of +the box — there is nothing to configure. Everything below happens with +ordinary spreadsheet formulas and the linking tools from +spreadsheet_field_linking.

+
+

Prepare the calculator on a quotation template

+

Go to Sales → Configuration → Quotation Templates and open (or +create) a template, adding its lines as usual. In the Quote +Calculator field, create a new spreadsheet and open it — it starts +empty, ready for whatever calculation you need.

+

image1

+

Build your calculation with normal formulas: the inputs, the +intermediate steps, and the result cells you want to end up on the +order.

+

Then link each result cell to the order-line field it should feed — +unit price, quantity, discount, the product on a line, and so on. +Linking a cell to a field, and choosing which line it targets, is done +with the tools from spreadsheet_field_linking; see its documentation +for how. The mappings live inside the spreadsheet, so they travel with +every copy.

+
+
+

Attach a calculator to a quotation

+

There are two ways to give an order a calculator:

+
    +
  • From a quotation template — on a new quotation, choose a +quotation template that carries a calculator and the order picks +it up automatically. This is the usual route, since one template +serves every quote made from it.
  • +
  • Directly on the order — pick a spreadsheet in the Quote +Calculator Template field of the order itself, with no quotation +template involved. Handy for a one-off quote or an ad-hoc calculation.
  • +
+

image2

+

Either way a Quote Calculator smart button appears. Choosing the +template or the spreadsheet copies nothing on its own — it only points +the order at the calculator to use. The private copy is made when you +first click the button, which is why the button can show before any +spreadsheet exists yet. From then on the button just reopens that copy, +so your changes never affect the shared original or any other quote.

+
+
+

Run the calculator and write the values back

+

Click the Quote Calculator smart button to open the editor and +adjust your input values.

+

image3

+

When you are happy with the result, click Write to record in the top +bar. For each linked cell the current value is read, checked against the +field’s type, and written onto the matching order line; the workbook is +saved at the same time. Because the order lines are created from the +template lines in the same order, position 1 still points at the +first line after the quote is made, so nothing has to be re-mapped on +copy.

+

image4

+
+
+

Change or rebuild the calculator

+

If you switch the quotation template — or the chosen calculator — after +a copy already exists, a Rebuild Calculator button appears next to +the field. It discards the current copy and rebuilds a fresh one from +the new template, which is handy when the template’s calculator has been +improved. The inputs in the old copy are lost, so it asks for +confirmation first.

+
+
+

Good to know

+
    +
  • The sync is one-way: the spreadsheet writes onto the order, never the +other way around.
  • +
  • It only runs when you click Write to record, so editing the sheet +never changes the order on its own.
  • +
  • The Quote Calculator button is what creates the order’s private +copy, on its first click; selecting a template or spreadsheet only +sets which calculator to copy. That is why the button can appear while +no spreadsheet exists yet.
  • +
  • An empty cell clears its field to the empty value (0, blank +text, no relation).
  • +
  • Which fields can be reached, how line positions are counted, and when +a new line is created (or deliberately not) are all handled by +spreadsheet_field_linking — see its documentation for the details +and limitations.
  • +
+
+
+
+

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

+
    +
  • arielbarreiros96
  • +
+
+ +
+

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.

+

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

+

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

+
+
+
+
+ + diff --git a/spreadsheet_sale_calculator/static/description/quotation_with_attached_calculator.png b/spreadsheet_sale_calculator/static/description/quotation_with_attached_calculator.png new file mode 100644 index 00000000..dbb5e5a4 Binary files /dev/null and b/spreadsheet_sale_calculator/static/description/quotation_with_attached_calculator.png differ diff --git a/spreadsheet_sale_calculator/static/description/quote_template_set_calculator_template.png b/spreadsheet_sale_calculator/static/description/quote_template_set_calculator_template.png new file mode 100644 index 00000000..c6c87f88 Binary files /dev/null and b/spreadsheet_sale_calculator/static/description/quote_template_set_calculator_template.png differ diff --git a/spreadsheet_sale_calculator/tests/__init__.py b/spreadsheet_sale_calculator/tests/__init__.py new file mode 100644 index 00000000..cd4e028e --- /dev/null +++ b/spreadsheet_sale_calculator/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 arielbarreiros96 +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0). + +from . import test_apply_field_mappings diff --git a/spreadsheet_sale_calculator/tests/test_apply_field_mappings.py b/spreadsheet_sale_calculator/tests/test_apply_field_mappings.py new file mode 100644 index 00000000..9e8757ed --- /dev/null +++ b/spreadsheet_sale_calculator/tests/test_apply_field_mappings.py @@ -0,0 +1,306 @@ +# Copyright 2026 arielbarreiros96 +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0). + +from odoo import Command +from odoo.exceptions import UserError +from odoo.tests import Form, TransactionCase + + +class TestApplyFieldMappings(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Test Partner"}) + cls.product = cls.env["product.product"].create( + {"name": "Test Product", "list_price": 100.0} + ) + cls.order = cls.env["sale.order"].create( + { + "partner_id": cls.partner.id, + "order_line": [ + Command.create( + { + "product_id": cls.product.id, + "product_uom_qty": 1, + "price_unit": 100.0, + } + ), + Command.create({"display_type": "line_section", "name": "Section"}), + Command.create( + { + "product_id": cls.product.id, + "product_uom_qty": 2, + "price_unit": 50.0, + } + ), + ], + } + ) + cls.product_lines = cls.order.order_line.filtered( + lambda line: not line.display_type + ) + + def _line(self, field, position, value): + return { + "chain": f"order_line.{field}", + "selectors": {"order_line": position}, + "value": value, + } + + def test_write_value_to_line(self): + result = self.order.apply_field_mappings([self._line("price_unit", 1, 250.0)]) + self.assertEqual(result, {"updated": 1, "created": 0}) + self.assertEqual(self.product_lines[0].price_unit, 250.0) + + def test_position_skips_section_lines(self): + self.order.apply_field_mappings([self._line("product_uom_qty", 2, 9)]) + self.assertEqual(self.product_lines[1].product_uom_qty, 9) + self.assertEqual(self.product_lines[0].product_uom_qty, 1) + + def test_price_unit_survives_quantity_change(self): + self.order.apply_field_mappings([self._line("price_unit", 1, 250.0)]) + self.product_lines[0].product_uom_qty = 5 + self.assertEqual(self.product_lines[0].price_unit, 250.0) + + def test_readonly_line_field_is_skipped(self): + result = self.order.apply_field_mappings( + [self._line("price_subtotal", 1, 999.0)] + ) + self.assertEqual(result, {"updated": 0, "created": 0}) + + def test_creates_new_line_beyond_count(self): + result = self.order.apply_field_mappings( + [ + self._line("product_id", 3, self.product.id), + self._line("product_uom_qty", 3, 4), + self._line("price_unit", 3, 75.0), + ] + ) + self.assertEqual(result, {"updated": 0, "created": 1}) + lines = self.order.order_line.filtered(lambda line: not line.display_type) + self.assertEqual(len(lines), 3) + self.assertEqual(lines[2].product_id, self.product) + self.assertEqual(lines[2].product_uom_qty, 4) + self.assertEqual(lines[2].price_unit, 75.0) + + def test_locked_order_raises(self): + self.order.locked = True + with self.assertRaises(UserError): + self.order.apply_field_mappings([self._line("price_unit", 1, 1.0)]) + + def test_cancelled_order_raises(self): + self.order.state = "cancel" + with self.assertRaises(UserError): + self.order.apply_field_mappings([self._line("price_unit", 1, 1.0)]) + + +class TestCalculatorSpreadsheet(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.user.group_ids += cls.env.ref( + "sale_management.group_sale_order_template" + ) + cls.partner = cls.env["res.partner"].create({"name": "Test Partner"}) + cls.product = cls.env["product.product"].create({"name": "Test Product"}) + + def _make_template(self): + return self.env["sale.order.template"].create( + { + "name": "Test Template", + "sale_order_template_line_ids": [ + Command.create( + {"product_id": self.product.id, "product_uom_qty": 1} + ), + Command.create({"display_type": "line_section", "name": "Section"}), + Command.create( + {"product_id": self.product.id, "product_uom_qty": 2} + ), + ], + } + ) + + def _make_calculator(self): + return self.env["spreadsheet.spreadsheet"].create({"name": "Calc"}) + + def test_context_non_calculator(self): + context = self._make_calculator().get_linking_context() + self.assertFalse(context["isLinkable"]) + self.assertFalse(context["writable"]) + + def test_context_from_order(self): + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create({"product_id": self.product.id}), + Command.create({"display_type": "line_section", "name": "S"}), + Command.create({"product_id": self.product.id}), + ], + } + ) + calculator = self._make_calculator() + order.calculator_spreadsheet_id = calculator + order._link_spreadsheet(calculator) + context = calculator.get_linking_context() + self.assertTrue(context["isLinkable"]) + self.assertTrue(context["writable"]) + self.assertEqual(context["resId"], order.id) + self.assertEqual(context["recordName"], order.display_name) + selectors = calculator.get_link_selectors("order_line.price_unit") + positions = [line["position"] for line in selectors[0]["labels"]] + self.assertEqual(positions, [1, 2]) + + def test_context_from_template(self): + template = self._make_template() + calculator = self._make_calculator() + template.quote_calculator_id = calculator + context = calculator.get_linking_context() + self.assertTrue(context["isLinkable"]) + self.assertFalse(context["writable"]) + self.assertFalse(context["resId"]) + self.assertEqual(context["model"], "sale.order") + selectors = calculator.get_link_selectors("order_line.price_unit") + self.assertEqual(selectors[0]["segment"], "order_line") + self.assertEqual(selectors[0]["labels"], []) + + def test_template_calculator_defaults_from_quotation_template(self): + template = self._make_template() + calculator = self._make_calculator() + template.quote_calculator_id = calculator + order = self.env["sale.order"].create( + {"partner_id": self.partner.id, "sale_order_template_id": template.id} + ) + self.assertEqual(order.template_calculator_id, calculator) + + def test_selecting_template_calculator_directly(self): + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + calculator = self._make_calculator() + order.template_calculator_id = calculator + action = order.action_open_quote_calculator() + self.assertTrue(order.calculator_spreadsheet_id) + self.assertNotEqual(order.calculator_spreadsheet_id, calculator) + self.assertEqual( + action["params"]["spreadsheet_id"], order.calculator_spreadsheet_id.id + ) + + def test_changing_template_calculator_keeps_copy(self): + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + order.template_calculator_id = self._make_calculator() + order.action_open_quote_calculator() + copy = order.calculator_spreadsheet_id + self.assertTrue(copy) + order.template_calculator_id = self._make_calculator() + self.assertEqual(order.calculator_spreadsheet_id, copy) + + def test_open_quote_calculator_without_template_raises(self): + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + with self.assertRaises(UserError): + order.action_open_quote_calculator() + + def test_open_quote_calculator_copies_template_calculator(self): + template = self._make_template() + template.quote_calculator_id = self._make_calculator() + order = self.env["sale.order"].create( + {"partner_id": self.partner.id, "sale_order_template_id": template.id} + ) + action = order.action_open_quote_calculator() + self.assertTrue(order.calculator_spreadsheet_id) + self.assertNotEqual( + order.calculator_spreadsheet_id, template.quote_calculator_id + ) + self.assertEqual(order.calculator_spreadsheet_id.owner_id, self.env.user) + self.assertEqual(action["tag"], "action_spreadsheet_oca") + self.assertEqual( + action["params"]["spreadsheet_id"], order.calculator_spreadsheet_id.id + ) + + def test_open_existing_calculator_reuses_it(self): + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + calculator = self._make_calculator() + order.calculator_spreadsheet_id = calculator + action = order.action_open_quote_calculator() + self.assertEqual(order.calculator_spreadsheet_id, calculator) + self.assertEqual(action["params"]["spreadsheet_id"], calculator.id) + + def test_changing_template_keeps_calculator(self): + template = self._make_template() + template.quote_calculator_id = self._make_calculator() + order = self.env["sale.order"].create( + {"partner_id": self.partner.id, "sale_order_template_id": template.id} + ) + order.action_open_quote_calculator() + copy = order.calculator_spreadsheet_id + self.assertTrue(copy) + order.sale_order_template_id = self._make_template() + self.assertEqual(order.calculator_spreadsheet_id, copy) + + def test_changing_template_offers_rebuild(self): + template = self._make_template() + template.quote_calculator_id = self._make_calculator() + order = self.env["sale.order"].create( + {"partner_id": self.partner.id, "sale_order_template_id": template.id} + ) + order.action_open_quote_calculator() + self.assertTrue(order.calculator_spreadsheet_id) + other = self._make_template() + other.quote_calculator_id = self._make_calculator() + with Form(order) as form: + self.assertFalse(form.show_update_calculator) + form.sale_order_template_id = other + self.assertTrue(form.show_update_calculator) + self.assertTrue(order.calculator_spreadsheet_id) + + def test_changing_template_offers_rebuild_without_existing_copy(self): + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + with Form(order) as form: + self.assertFalse(form.show_update_calculator) + form.template_calculator_id = self._make_calculator() + self.assertTrue(form.show_update_calculator) + + def test_rebuild_calculator_replaces_copy(self): + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + order.template_calculator_id = self._make_calculator() + order.action_open_quote_calculator() + stale = order.calculator_spreadsheet_id + self.assertTrue(stale) + order.template_calculator_id = self._make_calculator() + order.show_update_calculator = True + order.action_update_quote_calculator() + self.assertFalse(stale.exists()) + self.assertTrue(order.calculator_spreadsheet_id) + self.assertNotEqual(order.calculator_spreadsheet_id, stale) + self.assertFalse(order.show_update_calculator) + + def test_rebuild_leaves_calculator_that_re_offers_rebuild(self): + template = self._make_template() + template.quote_calculator_id = self._make_calculator() + order = self.env["sale.order"].create( + {"partner_id": self.partner.id, "sale_order_template_id": template.id} + ) + order.action_open_quote_calculator() + order.action_update_quote_calculator() + self.assertTrue(order.calculator_spreadsheet_id) + other = self._make_template() + other.quote_calculator_id = self._make_calculator() + with Form(order) as form: + form.sale_order_template_id = other + self.assertTrue(form.show_update_calculator) + + def test_setting_same_template_keeps_calculator(self): + template = self._make_template() + template.quote_calculator_id = self._make_calculator() + order = self.env["sale.order"].create( + {"partner_id": self.partner.id, "sale_order_template_id": template.id} + ) + order.action_open_quote_calculator() + calculator = order.calculator_spreadsheet_id + self.assertTrue(calculator) + order.write({"sale_order_template_id": template.id}) + self.assertEqual(order.calculator_spreadsheet_id, calculator) + + def test_setting_template_without_calculator_is_noop(self): + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + template = self._make_template() + order.write({"sale_order_template_id": template.id}) + self.assertFalse(order.calculator_spreadsheet_id) diff --git a/spreadsheet_sale_calculator/views/sale_order_template_views.xml b/spreadsheet_sale_calculator/views/sale_order_template_views.xml new file mode 100644 index 00000000..5bc22bc5 --- /dev/null +++ b/spreadsheet_sale_calculator/views/sale_order_template_views.xml @@ -0,0 +1,13 @@ + + + + sale.order.template.form.spreadsheet + sale.order.template + + + + + + + + diff --git a/spreadsheet_sale_calculator/views/sale_order_views.xml b/spreadsheet_sale_calculator/views/sale_order_views.xml new file mode 100644 index 00000000..99200c84 --- /dev/null +++ b/spreadsheet_sale_calculator/views/sale_order_views.xml @@ -0,0 +1,43 @@ + + + + sale.order.form.spreadsheet.calculator + sale.order + + + +