diff --git a/README.md b/README.md index ae12534b..a3b9c9eb 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ addon | version | maintainers | summary [spreadsheet_dashboard_purchase_oca](spreadsheet_dashboard_purchase_oca/) | 18.0.1.0.0 | | Spreadsheet dashboard for vendors [spreadsheet_dashboard_purchase_stock_oca](spreadsheet_dashboard_purchase_stock_oca/) | 18.0.1.0.0 | | Spreadsheet dashboard for purchases [spreadsheet_oca](spreadsheet_oca/) | 18.0.1.3.0 | | Allow to edit spreadsheets +[spreadsheet_quotation](spreadsheet_quotation/) | 18.0.1.0.0 | | Allow add a calculator and sync items with a qoutation [//]: # (end addons) diff --git a/spreadsheet_quotation/README.rst b/spreadsheet_quotation/README.rst new file mode 100644 index 00000000..11cf8859 --- /dev/null +++ b/spreadsheet_quotation/README.rst @@ -0,0 +1,113 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=============================== +Spreadsheet Quotation Calculator +=============================== + +|badge1| |badge2| |badge3| |badge4| |badge5| + +.. |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/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/18.0/spreadsheet_quotation + :alt: OCA/spreadsheet +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/spreadsheet-18-0/spreadsheet-18-0-spreadsheet_quotation + :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=18.0 + :alt: Try me on Runboat + +This module allows linking spreadsheet calculators to quotation templates +in Odoo. When a sale order is created from a template that has a +calculator, a copy of the spreadsheet is automatically assigned to the +order with a pre-configured global filter so the ``ODOO.LIST`` formulas +display only that order's lines. + +The spreadsheet calculator is built on top of ``spreadsheet_oca`` and +uses ``ODOO.LIST`` formulas to display sale order line data such as +product, quantity, and unit price. A Field Sync side panel lets users map +spreadsheet columns to sale order line fields and push calculated values +back to the quotation. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +This module requires: + +- ``spreadsheet_oca`` from the OCA spreadsheet repository +- ``sale_management`` from Odoo core addons + +Usage +===== + +Setting up a quotation calculator +--------------------------------- + +1. Go to **Sales > Configuration > Quotation Templates**. +2. Open or create a quotation template. +3. Click **Create Calculator** next to the quotation calculator field. +4. Set a name and the initial number of rows in the wizard. +5. Customize the spreadsheet and save it. + +Using the calculator on a sale order +------------------------------------ + +1. Create a new quotation and select a template with a calculator. +2. Use the **Calculator** smart button to open the spreadsheet. +3. Edit values in the spreadsheet. +4. Use **Field Sync** to map columns to sale order line fields. +5. Save the spreadsheet to sync values back to the sale order. + +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 detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Odoo Community Association (OCA) +* Cloud Lotus + +Contributors +------------ + +* OCA Contributors + +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_quotation/__init__.py b/spreadsheet_quotation/__init__.py new file mode 100644 index 00000000..cefe6dff --- /dev/null +++ b/spreadsheet_quotation/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models +from . import wizards diff --git a/spreadsheet_quotation/__manifest__.py b/spreadsheet_quotation/__manifest__.py new file mode 100644 index 00000000..7e09205c --- /dev/null +++ b/spreadsheet_quotation/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2026 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Spreadsheet Quotation Calculator", + "summary": ( + "Use spreadsheets as quotation calculators linked " "to sale order templates" + ), + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Odoo Community Association (OCA), Cloud Lotus", + "website": "https://github.com/OCA/spreadsheet", + "depends": ["spreadsheet_oca", "sale_management"], + "data": [ + "security/ir.model.access.csv", + "wizards/spreadsheet_quotation_create.xml", + "views/sale_order_template_views.xml", + "views/sale_order_views.xml", + ], + "assets": { + "spreadsheet.o_spreadsheet": [ + "spreadsheet_quotation/static/src/quotation_spreadsheet/field_sync_plugin.esm.js", + "spreadsheet_quotation/static/src/quotation_spreadsheet/field_sync_side_panel.esm.js", + "spreadsheet_quotation/static/src/quotation_spreadsheet/field_sync_side_panel.xml", + "spreadsheet_quotation/static/src/quotation_spreadsheet/quotation_spreadsheet.xml", + ], + }, + "installable": True, + "development_status": "Alpha", +} diff --git a/spreadsheet_quotation/models/__init__.py b/spreadsheet_quotation/models/__init__.py new file mode 100644 index 00000000..e7c158fb --- /dev/null +++ b/spreadsheet_quotation/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import sale_order_template +from . import sale_order diff --git a/spreadsheet_quotation/models/sale_order.py b/spreadsheet_quotation/models/sale_order.py new file mode 100644 index 00000000..800a8af7 --- /dev/null +++ b/spreadsheet_quotation/models/sale_order.py @@ -0,0 +1,79 @@ +# Copyright 2026 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json + +from odoo import _, api, fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + spreadsheet_id = fields.Many2one( + "spreadsheet.spreadsheet", + string="Quotation Calculator", + copy=False, + ) + has_spreadsheet = fields.Boolean( + compute="_compute_has_spreadsheet", + ) + + @api.depends("spreadsheet_id") + def _compute_has_spreadsheet(self): + for order in self: + order.has_spreadsheet = bool(order.spreadsheet_id) + + @api.onchange("sale_order_template_id") + def _onchange_sale_order_template_id_spreadsheet(self): + if self.sale_order_template_id and self.sale_order_template_id.spreadsheet_id: + spreadsheet = self._copy_template_spreadsheet( + self.sale_order_template_id.spreadsheet_id, + ) + self.spreadsheet_id = spreadsheet + elif not self.sale_order_template_id: + self.spreadsheet_id = False + + def _copy_template_spreadsheet(self, template_spreadsheet): + """Create a copy of the template spreadsheet for this sale order.""" + return template_spreadsheet.copy( + {"name": _("Calculator - %s", self.name or _("New"))} + ) + + def _update_spreadsheet_filter(self): + """Update the global filter default value in the spreadsheet JSON + so the list is filtered to this sale order's id.""" + self.ensure_one() + if not self.spreadsheet_id: + return + data = self.spreadsheet_id.spreadsheet_raw + if not data: + return + if isinstance(data, str): + data = json.loads(data) + + for gf in data.get("globalFilters", []): + if gf.get("type") == "relation" and gf.get("modelName") == "sale.order": + gf["defaultValue"] = [self.id] + break + + self.spreadsheet_id.spreadsheet_raw = data + + def write(self, vals): + res = super().write(vals) + if "spreadsheet_id" in vals: + for order in self.filtered("spreadsheet_id"): + order._update_spreadsheet_filter() + return res + + @api.model_create_multi + def create(self, vals_list): + orders = super().create(vals_list) + for order in orders.filtered("spreadsheet_id"): + order._update_spreadsheet_filter() + return orders + + def action_open_spreadsheet_calculator(self): + self.ensure_one() + if self.spreadsheet_id: + return self.spreadsheet_id.open_spreadsheet() + return False diff --git a/spreadsheet_quotation/models/sale_order_template.py b/spreadsheet_quotation/models/sale_order_template.py new file mode 100644 index 00000000..72a001a8 --- /dev/null +++ b/spreadsheet_quotation/models/sale_order_template.py @@ -0,0 +1,35 @@ +# Copyright 2026 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class SaleOrderTemplate(models.Model): + _inherit = "sale.order.template" + + spreadsheet_id = fields.Many2one( + "spreadsheet.spreadsheet", + string="Quotation Calculator", + copy=True, + help="Spreadsheet used as pricing calculator for quotations " + "created from this template.", + ) + + def action_create_spreadsheet_calculator(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": "Create Quotation Calculator", + "res_model": "spreadsheet.quotation.create", + "view_mode": "form", + "target": "new", + "context": { + "default_sale_order_template_id": self.id, + }, + } + + def action_open_spreadsheet_calculator(self): + self.ensure_one() + if self.spreadsheet_id: + return self.spreadsheet_id.open_spreadsheet() + return self.action_create_spreadsheet_calculator() diff --git a/spreadsheet_quotation/pyproject.toml b/spreadsheet_quotation/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spreadsheet_quotation/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spreadsheet_quotation/readme/CONTRIBUTORS.md b/spreadsheet_quotation/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..50823e92 --- /dev/null +++ b/spreadsheet_quotation/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* OCA Contributors diff --git a/spreadsheet_quotation/readme/DESCRIPTION.md b/spreadsheet_quotation/readme/DESCRIPTION.md new file mode 100644 index 00000000..c1196843 --- /dev/null +++ b/spreadsheet_quotation/readme/DESCRIPTION.md @@ -0,0 +1,15 @@ +This module allows linking spreadsheet calculators to quotation templates +in Odoo. When a sale order is created from a template that has a +calculator, a copy of the spreadsheet is automatically assigned to the +order with a pre-configured global filter so the ODOO.LIST formulas +display only that order's lines. + +The spreadsheet calculator is built on top of ``spreadsheet_oca`` and +uses ODOO.LIST formulas to display sale order line data (product, +quantity, unit price, etc.). Users can add custom formulas, calculations, +and charts to build complex pricing logic. + +A **Field Sync** side panel lets users map spreadsheet columns to +sale order line fields. This column-based approach is more intuitive +than cell-by-cell mapping and makes it easy to push calculated values +back to the sale order. diff --git a/spreadsheet_quotation/readme/USAGE.md b/spreadsheet_quotation/readme/USAGE.md new file mode 100644 index 00000000..a714f3c1 --- /dev/null +++ b/spreadsheet_quotation/readme/USAGE.md @@ -0,0 +1,42 @@ +Setting up a quotation calculator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Go to **Sales > Configuration > Quotation Templates**. +2. Open or create a quotation template. +3. Click **Create Calculator** next to the "Quotation Calculator" field. +4. A wizard will open; set a name and number of initial rows, then click + **Create Calculator**. +5. The spreadsheet editor opens with a pre-configured list of + ``sale.order.line`` fields. +6. Customize the spreadsheet: add formulas, charts, or extra columns as + needed. +7. Save the spreadsheet. + +Using the calculator on a sale order +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Create a new quotation and select the template that has a calculator. +2. A **Calculator** smart button appears on the sale order form. +3. Click it to open the spreadsheet filtered to the current order's + lines. +4. Edit values in the spreadsheet. +5. Use **File > Field Sync** to map columns to sale order line fields. +6. Save the spreadsheet from the editor. + +Syncing values back to the order +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +After configuring field sync mappings in the spreadsheet, the mapped +column values can be pushed to the corresponding sale order line fields +when saving the spreadsheet or from the sale order form. + +Step-by-step guide +~~~~~~~~~~~~~~~~~~ + +[1. Add quotation templates in settings](image1.png) +[2. Create a new template & create a calculator](image2.png) +[3. Map Sync fields > columns to fields](image3.png) +[4. Save template > create a quotation from template and open calculator](image4.png) +[5. add new lines to the calculator, save and sync](image5.png) +[6. Add new products if any](image6.png) +[7. Quotation updated!!!](image7.png) \ No newline at end of file diff --git a/spreadsheet_quotation/readme/image1.png b/spreadsheet_quotation/readme/image1.png new file mode 100644 index 00000000..1eff55c7 Binary files /dev/null and b/spreadsheet_quotation/readme/image1.png differ diff --git a/spreadsheet_quotation/readme/image2.png b/spreadsheet_quotation/readme/image2.png new file mode 100644 index 00000000..eda72070 Binary files /dev/null and b/spreadsheet_quotation/readme/image2.png differ diff --git a/spreadsheet_quotation/readme/image3.png b/spreadsheet_quotation/readme/image3.png new file mode 100644 index 00000000..0c6dc222 Binary files /dev/null and b/spreadsheet_quotation/readme/image3.png differ diff --git a/spreadsheet_quotation/readme/image4.png b/spreadsheet_quotation/readme/image4.png new file mode 100644 index 00000000..c03e9d8f Binary files /dev/null and b/spreadsheet_quotation/readme/image4.png differ diff --git a/spreadsheet_quotation/readme/image5.png b/spreadsheet_quotation/readme/image5.png new file mode 100644 index 00000000..d163ba0e Binary files /dev/null and b/spreadsheet_quotation/readme/image5.png differ diff --git a/spreadsheet_quotation/readme/image6.png b/spreadsheet_quotation/readme/image6.png new file mode 100644 index 00000000..15615675 Binary files /dev/null and b/spreadsheet_quotation/readme/image6.png differ diff --git a/spreadsheet_quotation/readme/image7.png b/spreadsheet_quotation/readme/image7.png new file mode 100644 index 00000000..92824d69 Binary files /dev/null and b/spreadsheet_quotation/readme/image7.png differ diff --git a/spreadsheet_quotation/security/ir.model.access.csv b/spreadsheet_quotation/security/ir.model.access.csv new file mode 100644 index 00000000..f4ec051d --- /dev/null +++ b/spreadsheet_quotation/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spreadsheet_quotation_create,access_spreadsheet_quotation_create,model_spreadsheet_quotation_create,sales_team.group_sale_salesman,1,1,1,1 diff --git a/spreadsheet_quotation/static/src/quotation_spreadsheet/field_sync_plugin.esm.js b/spreadsheet_quotation/static/src/quotation_spreadsheet/field_sync_plugin.esm.js new file mode 100644 index 00000000..11dd81b2 --- /dev/null +++ b/spreadsheet_quotation/static/src/quotation_spreadsheet/field_sync_plugin.esm.js @@ -0,0 +1,107 @@ +/** @odoo-module **/ + +import {CorePlugin, coreTypes, registries} from "@odoo/o-spreadsheet"; +import {_t} from "@web/core/l10n/translation"; + +const {corePluginRegistry, topbarMenuRegistry} = registries; + +coreTypes.add("UPDATE_FIELD_SYNC_CONFIG"); +coreTypes.add("ADD_FIELD_SYNC_MAPPING"); +coreTypes.add("REMOVE_FIELD_SYNC_MAPPING"); + +/** + * Plugin that stores field-sync configuration in the spreadsheet data. + * + * The configuration maps spreadsheet columns to sale.order.line fields, + * allowing the user to sync computed values back to the sale order. + * + * The mappings are stored as a list of {column, field} objects where + * `column` is the 0-based column index and `field` is the technical + * field name on sale.order.line. + */ +export class FieldSyncCorePlugin extends CorePlugin { + static getters = /** @type {const} */ ([ + "getFieldSyncConfig", + "getFieldSyncMappings", + "getFieldSyncListId", + ]); + + constructor(config) { + super(config); + this.fieldSyncConfig = { + listId: "1", + mappings: [], + }; + } + + allowDispatch(cmd) { + switch (cmd.type) { + case "UPDATE_FIELD_SYNC_CONFIG": + case "ADD_FIELD_SYNC_MAPPING": + case "REMOVE_FIELD_SYNC_MAPPING": + return "Success"; + } + return "Success"; + } + + handle(cmd) { + switch (cmd.type) { + case "UPDATE_FIELD_SYNC_CONFIG": + this.history.update("fieldSyncConfig", cmd.config); + break; + case "ADD_FIELD_SYNC_MAPPING": { + const mappings = [...this.fieldSyncConfig.mappings, cmd.mapping]; + this.history.update("fieldSyncConfig", { + ...this.fieldSyncConfig, + mappings, + }); + break; + } + case "REMOVE_FIELD_SYNC_MAPPING": { + const mappings = this.fieldSyncConfig.mappings.filter( + (_, idx) => idx !== cmd.index + ); + this.history.update("fieldSyncConfig", { + ...this.fieldSyncConfig, + mappings, + }); + break; + } + } + } + + getFieldSyncConfig() { + return this.fieldSyncConfig; + } + + getFieldSyncMappings() { + return this.fieldSyncConfig.mappings || []; + } + + getFieldSyncListId() { + return this.fieldSyncConfig.listId || "1"; + } + + import(data) { + if (data.fieldSyncConfig) { + this.fieldSyncConfig = data.fieldSyncConfig; + } + } + + export(data) { + data.fieldSyncConfig = this.fieldSyncConfig; + } +} + +corePluginRegistry.add("FieldSyncCorePlugin", FieldSyncCorePlugin); + +topbarMenuRegistry.addChild("field_sync", ["file"], { + name: _t("Field Sync"), + sequence: 80, + execute: (env) => env.openSidePanel("FieldSyncPanel", {}), + icon: "o-spreadsheet-Icon.EDIT", + isVisible: (env) => { + const listIds = env.model.getters.getListIds(); + return listIds.length > 0; + }, +}); diff --git a/spreadsheet_quotation/static/src/quotation_spreadsheet/field_sync_side_panel.esm.js b/spreadsheet_quotation/static/src/quotation_spreadsheet/field_sync_side_panel.esm.js new file mode 100644 index 00000000..c9faf724 --- /dev/null +++ b/spreadsheet_quotation/static/src/quotation_spreadsheet/field_sync_side_panel.esm.js @@ -0,0 +1,426 @@ +/** @odoo-module **/ + +import {ConfirmationDialog} from "@web/core/confirmation_dialog/confirmation_dialog"; +import {registries} from "@odoo/o-spreadsheet"; +import {_t} from "@web/core/l10n/translation"; +import {useService} from "@web/core/utils/hooks"; + +const {sidePanelRegistry} = registries; +const {Component, useState, onWillStart} = owl; + +const SYNCABLE_FIELDS = [ + {name: "product_id", label: _t("Product"), type: "many2one"}, + {name: "name", label: _t("Description"), type: "char"}, + {name: "product_uom_qty", label: _t("Quantity"), type: "float"}, + {name: "price_unit", label: _t("Unit Price"), type: "float"}, + {name: "discount", label: _t("Discount (%)"), type: "float"}, +]; + +export class FieldSyncPanel extends Component { + static template = "spreadsheet_quotation.FieldSyncPanel"; + static props = {}; + + setup() { + this.orm = useService("orm"); + this.notification = useService("notification"); + this.dialog = useService("dialog"); + this.availableColumns = []; + this.syncableFields = SYNCABLE_FIELDS; + this.state = useState({ + newColumn: "", + newField: "", + saving: false, + }); + onWillStart(async () => { + this._loadAvailableColumns(); + }); + } + + get mappings() { + try { + return this.env.model.getters.getFieldSyncMappings(); + } catch { + return []; + } + } + + get listId() { + try { + return this.env.model.getters.getFieldSyncListId(); + } catch { + return "1"; + } + } + + get hasSOList() { + try { + const id = this.listId; + if (!id || !this.env.model.getters.isExistingList(id)) { + return false; + } + const def = this.env.model.getters.getListDefinition(id); + return def.model === "sale.order.line"; + } catch { + return false; + } + } + + get orderId() { + try { + const filters = this.env.model.getters.getGlobalFilters(); + for (const filter of filters) { + if (filter.type === "relation" && filter.modelName === "sale.order") { + const value = this.env.model.getters.getGlobalFilterValue( + filter.id + ); + if (value && value.length > 0) { + return value[0]; + } + const defaultValue = + this.env.model.getters.getGlobalFilterDefaultValue(filter.id); + if (defaultValue && defaultValue.length > 0) { + return defaultValue[0]; + } + } + } + } catch { + // Filter getters not available + } + return null; + } + + get hasProductMapping() { + return this.mappings.some((m) => m.field === "product_id"); + } + + _loadAvailableColumns() { + try { + const sheetId = this.env.model.getters.getActiveSheetId(); + const numberOfCols = this.env.model.getters.getNumberCols(sheetId); + for (let i = 0; i < numberOfCols && i < 26; i++) { + this.availableColumns.push(String.fromCharCode(65 + i)); + } + } catch { + for (let i = 0; i < 10; i++) { + this.availableColumns.push(String.fromCharCode(65 + i)); + } + } + } + + getFieldLabel(fieldName) { + const field = this.syncableFields.find((f) => f.name === fieldName); + return field ? field.label.toString() : fieldName; + } + + addMapping() { + if (!this.state.newColumn || !this.state.newField) { + return; + } + const existing = this.mappings.find((m) => m.column === this.state.newColumn); + if (existing) { + this.notification.add(_t("This column already has a mapping"), { + type: "warning", + }); + return; + } + const existingField = this.mappings.find( + (m) => m.field === this.state.newField + ); + if (existingField) { + this.notification.add(_t("This field already has a mapping"), { + type: "warning", + }); + return; + } + this.env.model.dispatch("ADD_FIELD_SYNC_MAPPING", { + mapping: { + column: this.state.newColumn, + field: this.state.newField, + }, + }); + this.state.newColumn = ""; + this.state.newField = ""; + } + + removeMapping(index) { + this.env.model.dispatch("REMOVE_FIELD_SYNC_MAPPING", {index}); + } + + /** + * Convert a column letter (A, B, ..., Z) to a 0-based index. + */ + _colLetterToIndex(letter) { + let index = 0; + for (let i = 0; i < letter.length; i++) { + index = index * 26 + (letter.charCodeAt(i) - 64); + } + return index - 1; + } + + /** + * Read one spreadsheet row and return the SO line ID (if it maps to an + * existing ODOO.LIST record), the mapped field values, and a flag + * indicating whether the row contains any data worth syncing. + */ + _readRow(sheetId, row) { + const result = {lineId: null, values: {}, productName: null, hasData: false}; + + try { + const listPosition = row - 1; + const idResult = this.env.model.getters.getListCellValueAndFormat( + this.listId, + listPosition, + "id" + ); + if (idResult && idResult.value && typeof idResult.value === "number") { + result.lineId = idResult.value; + } + } catch { + // No list record at this position + } + + for (const mapping of this.mappings) { + const colIdx = this._colLetterToIndex(mapping.column); + const cell = this.env.model.getters.getEvaluatedCell({ + sheetId, + col: colIdx, + row, + }); + if ( + cell.type !== "empty" && + cell.value !== undefined && + cell.value !== "" + ) { + result.hasData = true; + if (mapping.field === "product_id") { + result.productName = String(cell.value); + } else { + result.values[mapping.field] = cell.value; + } + } + } + + if (result.lineId) { + result.hasData = true; + } + return result; + } + + /** + * Search for a product by name using name_search (handles display_name, + * internal reference, etc.). Returns the product ID or null. + */ + async _findProduct(name) { + if (!name) { + return null; + } + const exact = await this.orm.call("product.product", "name_search", [], { + name, + operator: "=", + limit: 1, + }); + if (exact.length) { + return exact[0][0]; + } + const fuzzy = await this.orm.call("product.product", "name_search", [], { + name, + operator: "ilike", + limit: 1, + }); + if (fuzzy.length) { + return fuzzy[0][0]; + } + return null; + } + + /** + * Show a confirmation dialog listing the products that will be created. + * Resolves to true when the user confirms, false otherwise. + */ + _confirmCreateProducts(productNames) { + return new Promise((resolve) => { + const list = productNames.map((n) => ` • ${n}`).join("\n"); + const body = + _t( + "The following products were not found in the catalog and will be created:" + ) + + "\n\n" + + list + + "\n\n" + + _t("Do you want to create them?"); + + this.dialog.add(ConfirmationDialog, { + title: _t("Create New Products"), + body, + confirmLabel: _t("Create and Sync"), + confirm: () => resolve(true), + cancel: () => resolve(false), + }); + }); + } + + /** + * Main sync entry-point. + * + * 1. Scan spreadsheet rows for data (existing lines and new rows). + * 2. Resolve product names to IDs (via name_search). + * 3. If products are missing, ask the user for confirmation to create them. + * 4. Build ORM x2many commands and write to the sale order. + */ + async saveToSaleOrder() { + if (!this.hasSOList) { + this.notification.add(_t("No sale order line list found"), { + type: "warning", + }); + return; + } + const orderId = this.orderId; + if (!orderId) { + this.notification.add( + _t("No sale order linked. Set the Sale Order filter first."), + {type: "warning"} + ); + return; + } + if (!this.mappings.length) { + this.notification.add(_t("No field mappings configured"), { + type: "warning", + }); + return; + } + + this.state.saving = true; + try { + await this.env.saveSpreadsheet(); + + const sheetId = this.env.model.getters.getActiveSheetId(); + const listId = this.listId; + + const listDataSource = this.env.model.getters.getListDataSource(listId); + if (listDataSource && !listDataSource.isReady()) { + await listDataSource.load(); + } + + // ---- 1. Scan rows ---- + const existingUpdates = {}; + const newRows = []; + const maxRow = this.env.model.getters.getNumberRows(sheetId); + let emptyStreak = 0; + + for (let row = 1; row < maxRow && emptyStreak < 5; row++) { + const rowData = this._readRow(sheetId, row); + if (!rowData.hasData) { + emptyStreak++; + continue; + } + emptyStreak = 0; + + if (rowData.lineId) { + existingUpdates[rowData.lineId] = { + values: rowData.values, + productName: rowData.productName, + }; + } else if ( + rowData.productName || + Object.keys(rowData.values).length > 0 + ) { + newRows.push({ + values: rowData.values, + productName: rowData.productName, + }); + } + } + + // ---- 2. Resolve product names to IDs ---- + const allProductNames = new Set(); + for (const data of Object.values(existingUpdates)) { + if (data.productName) { + allProductNames.add(data.productName); + } + } + for (const row of newRows) { + if (row.productName) { + allProductNames.add(row.productName); + } + } + + const productMap = {}; + for (const name of allProductNames) { + const productId = await this._findProduct(name); + if (productId) { + productMap[name] = productId; + } + } + + // ---- 3. Handle missing products ---- + const missingProducts = [...allProductNames].filter((n) => !productMap[n]); + + if (missingProducts.length > 0) { + const confirmed = await this._confirmCreateProducts(missingProducts); + if (!confirmed) { + this.notification.add(_t("Sync cancelled"), {type: "info"}); + return; + } + for (const name of missingProducts) { + const ids = await this.orm.create("product.product", [{name}]); + productMap[name] = ids[0]; + } + } + + // ---- 4. Build ORM commands ---- + const writeCommands = []; + + for (const [lineId, data] of Object.entries(existingUpdates)) { + const vals = {...data.values}; + if (data.productName && productMap[data.productName]) { + vals.product_id = productMap[data.productName]; + } + if (Object.keys(vals).length > 0) { + writeCommands.push([1, parseInt(lineId), vals]); + } + } + + for (const row of newRows) { + const vals = {...row.values}; + if (row.productName && productMap[row.productName]) { + vals.product_id = productMap[row.productName]; + } + if (vals.product_id || Object.keys(vals).length > 0) { + writeCommands.push([0, 0, vals]); + } + } + + // ---- 5. Execute ---- + if (writeCommands.length) { + await this.orm.call("sale.order", "write", [ + [orderId], + {order_line: writeCommands}, + ]); + + const updated = writeCommands.filter((c) => c[0] === 1).length; + const created = writeCommands.filter((c) => c[0] === 0).length; + const parts = []; + if (updated) { + parts.push(_t("%s line(s) updated", updated)); + } + if (created) { + parts.push(_t("%s line(s) created", created)); + } + this.notification.add(parts.join(", "), {type: "success"}); + } else { + this.notification.add(_t("No values to sync"), {type: "info"}); + } + } catch (e) { + this.notification.add(_t("Error syncing: %s", e.message || e), { + type: "danger", + }); + } finally { + this.state.saving = false; + } + } +} + +sidePanelRegistry.add("FieldSyncPanel", { + title: _t("Field Sync"), + Body: FieldSyncPanel, +}); diff --git a/spreadsheet_quotation/static/src/quotation_spreadsheet/field_sync_side_panel.xml b/spreadsheet_quotation/static/src/quotation_spreadsheet/field_sync_side_panel.xml new file mode 100644 index 00000000..1f0f70e6 --- /dev/null +++ b/spreadsheet_quotation/static/src/quotation_spreadsheet/field_sync_side_panel.xml @@ -0,0 +1,140 @@ + + + + +
+
+
Column Mappings
+

+ Map spreadsheet columns to sale order line fields. + Existing lines will be updated in place; new rows with + product data will be added as new quotation lines. +

+
+ +
+
+ No sale order line list found in this spreadsheet. +
+
+ + +
+
+ + Linked to Sale Order # +
+
+
+
+ Set the Sale Order filter to link this calculator to an order. +
+
+ +
+ + + + + + + + + + + + + + + + +
ColumnField +
+ + + + + +
+
+ +
+

No mappings configured yet.

+
+ +
+
Add Mapping
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + Product sync enabled. + New rows with a product name will be added as new + quotation lines. If a product is not found in the + catalog you will be asked to confirm its creation. +
+
+ +
+ +
+
+
+
+ +
diff --git a/spreadsheet_quotation/static/src/quotation_spreadsheet/quotation_spreadsheet.xml b/spreadsheet_quotation/static/src/quotation_spreadsheet/quotation_spreadsheet.xml new file mode 100644 index 00000000..51c9fb43 --- /dev/null +++ b/spreadsheet_quotation/static/src/quotation_spreadsheet/quotation_spreadsheet.xml @@ -0,0 +1,4 @@ + + + + diff --git a/spreadsheet_quotation/tests/__init__.py b/spreadsheet_quotation/tests/__init__.py new file mode 100644 index 00000000..a26a5545 --- /dev/null +++ b/spreadsheet_quotation/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_spreadsheet_quotation diff --git a/spreadsheet_quotation/tests/test_spreadsheet_quotation.py b/spreadsheet_quotation/tests/test_spreadsheet_quotation.py new file mode 100644 index 00000000..c653e566 --- /dev/null +++ b/spreadsheet_quotation/tests/test_spreadsheet_quotation.py @@ -0,0 +1,222 @@ +# Copyright 2026 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json + +from odoo.tests.common import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestSpreadsheetQuotation(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.template = cls.env["sale.order.template"].create( + { + "name": "Test Template", + "sale_order_template_line_ids": [ + ( + 0, + 0, + { + "product_id": cls.product.id, + "product_uom_qty": 5, + "product_uom_id": cls.product.uom_id.id, + }, + ) + ], + } + ) + + def _create_calculator(self, template=None, name="My Calculator", lines=10): + template = template or self.template + wizard = ( + self.env["spreadsheet.quotation.create"] + .with_context(default_sale_order_template_id=template.id) + .create( + { + "name": name, + "line_count": lines, + "sale_order_template_id": template.id, + } + ) + ) + return wizard.action_create() + + def test_wizard_creates_calculator(self): + """The wizard should create a spreadsheet linked to the template.""" + self.assertFalse(self.template.spreadsheet_id) + action = self._create_calculator() + + self.assertTrue(self.template.spreadsheet_id) + self.assertEqual(self.template.spreadsheet_id.name, "My Calculator") + self.assertEqual(action["type"], "ir.actions.client") + self.assertEqual(action["tag"], "action_spreadsheet_oca") + + def test_wizard_spreadsheet_contains_list(self): + """The spreadsheet should contain a list definition for sale.order.line.""" + self._create_calculator() + + data = self.template.spreadsheet_id.spreadsheet_raw + if isinstance(data, str): + data = json.loads(data) + + self.assertIn("lists", data) + list_def = data["lists"].get("1") + self.assertIsNotNone(list_def) + self.assertEqual(list_def["model"], "sale.order.line") + self.assertIn("product_id", list_def["columns"]) + self.assertIn("product_uom_qty", list_def["columns"]) + self.assertIn("price_unit", list_def["columns"]) + + def test_wizard_list_filters_display_type(self): + """The list domain should exclude section/note lines.""" + self._create_calculator() + + data = self.template.spreadsheet_id.spreadsheet_raw + if isinstance(data, str): + data = json.loads(data) + + list_def = data["lists"]["1"] + self.assertIn(["display_type", "=", False], list_def["domain"]) + + def test_wizard_spreadsheet_contains_global_filter(self): + """The spreadsheet should contain a global filter for sale.order.""" + self._create_calculator() + + data = self.template.spreadsheet_id.spreadsheet_raw + if isinstance(data, str): + data = json.loads(data) + + self.assertIn("globalFilters", data) + filters = data["globalFilters"] + self.assertEqual(len(filters), 1) + self.assertEqual(filters[0]["type"], "relation") + self.assertEqual(filters[0]["modelName"], "sale.order") + + def test_wizard_field_matching(self): + """The list should have field matching linking it to the global filter.""" + self._create_calculator() + + data = self.template.spreadsheet_id.spreadsheet_raw + if isinstance(data, str): + data = json.loads(data) + + list_def = data["lists"]["1"] + filter_id = data["globalFilters"][0]["id"] + self.assertIn(filter_id, list_def["fieldMatching"]) + matching = list_def["fieldMatching"][filter_id] + self.assertEqual(matching["chain"], "order_id") + self.assertEqual(matching["type"], "many2one") + + def test_wizard_spreadsheet_cells_have_formulas(self): + """The spreadsheet should contain ODOO.LIST formulas in cells.""" + self._create_calculator(lines=5) + + data = self.template.spreadsheet_id.spreadsheet_raw + if isinstance(data, str): + data = json.loads(data) + + sheets = data.get("sheets", []) + self.assertTrue(sheets) + cells = sheets[0].get("cells", {}) + self.assertIn("A1", cells) + self.assertIn("ODOO.LIST.HEADER", cells["A1"]["content"]) + self.assertIn("A2", cells) + self.assertIn("ODOO.LIST", cells["A2"]["content"]) + + def test_template_application_copies_spreadsheet(self): + """Applying a template with a calculator to a SO should copy it.""" + self._create_calculator() + + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + order.sale_order_template_id = self.template + order._onchange_sale_order_template_id() + order._onchange_sale_order_template_id_spreadsheet() + + self.assertTrue(order.spreadsheet_id) + self.assertNotEqual(order.spreadsheet_id, self.template.spreadsheet_id) + + def test_copied_spreadsheet_has_filter_with_order_id(self): + """The copied spreadsheet should have the global filter set to the SO id.""" + self._create_calculator() + + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + order.sale_order_template_id = self.template + order._onchange_sale_order_template_id() + order._onchange_sale_order_template_id_spreadsheet() + + data = order.spreadsheet_id.spreadsheet_raw + if isinstance(data, str): + data = json.loads(data) + + filters = data.get("globalFilters", []) + so_filter = next( + (f for f in filters if f.get("modelName") == "sale.order"), + None, + ) + self.assertIsNotNone(so_filter) + self.assertEqual(so_filter["defaultValue"], [order.id]) + + def test_template_without_spreadsheet(self): + """Applying a template without calculator should not create one.""" + template_no_calc = self.env["sale.order.template"].create( + {"name": "No Calculator Template"} + ) + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + order.sale_order_template_id = template_no_calc + order._onchange_sale_order_template_id() + order._onchange_sale_order_template_id_spreadsheet() + + self.assertFalse(order.spreadsheet_id) + + def test_clearing_template_clears_spreadsheet(self): + """Removing the template should clear the spreadsheet reference.""" + self._create_calculator() + + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + order.sale_order_template_id = self.template + order._onchange_sale_order_template_id() + order._onchange_sale_order_template_id_spreadsheet() + self.assertTrue(order.spreadsheet_id) + + order.sale_order_template_id = False + order._onchange_sale_order_template_id_spreadsheet() + self.assertFalse(order.spreadsheet_id) + + def test_open_spreadsheet_action(self): + """Opening the calculator should return a client action.""" + self._create_calculator() + + action = self.template.action_open_spreadsheet_calculator() + self.assertEqual(action["type"], "ir.actions.client") + self.assertEqual(action["tag"], "action_spreadsheet_oca") + + def test_col_index_to_letter(self): + """Column index conversion should produce correct letters.""" + wizard = self.env["spreadsheet.quotation.create"] + self.assertEqual(wizard._col_index_to_letter(0), "A") + self.assertEqual(wizard._col_index_to_letter(1), "B") + self.assertEqual(wizard._col_index_to_letter(25), "Z") + self.assertEqual(wizard._col_index_to_letter(26), "AA") + self.assertEqual(wizard._col_index_to_letter(27), "AB") + + def test_has_spreadsheet_computed(self): + """has_spreadsheet should reflect the presence of spreadsheet_id.""" + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + self.assertFalse(order.has_spreadsheet) + + self._create_calculator() + + order.sale_order_template_id = self.template + order._onchange_sale_order_template_id() + order._onchange_sale_order_template_id_spreadsheet() + + self.assertTrue(order.has_spreadsheet) diff --git a/spreadsheet_quotation/views/sale_order_template_views.xml b/spreadsheet_quotation/views/sale_order_template_views.xml new file mode 100644 index 00000000..90b32ae3 --- /dev/null +++ b/spreadsheet_quotation/views/sale_order_template_views.xml @@ -0,0 +1,31 @@ + + + + sale.order.template.form.spreadsheet + sale.order.template + + + + + + + + + + diff --git a/spreadsheet_quotation/views/sale_order_views.xml b/spreadsheet_quotation/views/sale_order_views.xml new file mode 100644 index 00000000..3d70cdd9 --- /dev/null +++ b/spreadsheet_quotation/views/sale_order_views.xml @@ -0,0 +1,23 @@ + + + + sale.order.form.spreadsheet + sale.order + + +
+ + + +
+
+
+
diff --git a/spreadsheet_quotation/wizards/__init__.py b/spreadsheet_quotation/wizards/__init__.py new file mode 100644 index 00000000..16c9bd45 --- /dev/null +++ b/spreadsheet_quotation/wizards/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import spreadsheet_quotation_create diff --git a/spreadsheet_quotation/wizards/spreadsheet_quotation_create.py b/spreadsheet_quotation/wizards/spreadsheet_quotation_create.py new file mode 100644 index 00000000..c94cf96d --- /dev/null +++ b/spreadsheet_quotation/wizards/spreadsheet_quotation_create.py @@ -0,0 +1,135 @@ +# Copyright 2026 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import uuid + +from odoo import _, api, fields, models + +DEFAULT_COLUMNS = [ + "product_id", + "product_uom_qty", + "qty_delivered", + "qty_invoiced", + "qty_to_invoice", + "product_uom_id", + "price_unit", + "price_tax", + "price_subtotal", +] + +DEFAULT_LINE_COUNT = 20 + + +class SpreadsheetQuotationCreate(models.TransientModel): + _name = "spreadsheet.quotation.create" + _description = "Create Quotation Calculator Spreadsheet" + + sale_order_template_id = fields.Many2one( + "sale.order.template", + required=True, + readonly=True, + ) + name = fields.Char( + default="Quotation Calculator", + required=True, + ) + line_count = fields.Integer( + string="Number of lines", + default=DEFAULT_LINE_COUNT, + help="Initial number of rows to display in the list.", + ) + + def action_create(self): + self.ensure_one() + spreadsheet_data = self._build_spreadsheet_data() + spreadsheet = self.env["spreadsheet.spreadsheet"].create( + { + "name": self.name, + "spreadsheet_raw": spreadsheet_data, + } + ) + self.sale_order_template_id.spreadsheet_id = spreadsheet + return spreadsheet.open_spreadsheet() + + def _build_spreadsheet_data(self): + columns = DEFAULT_COLUMNS + line_count = self.line_count or DEFAULT_LINE_COUNT + filter_id = str(uuid.uuid4()) + list_id = "1" + sheet_id = "sheet1" + + cells = self._build_cells(list_id, columns, line_count) + col_count = len(columns) + + lang = self.env["res.lang"]._lang_get(self.env.user.lang) + locale = lang._odoo_lang_to_spreadsheet_locale() + + return { + "version": 1, + "sheets": [ + { + "id": sheet_id, + "name": _("Sale Order Lines"), + "cells": cells, + "colNumber": max(col_count, 10), + "rowNumber": max(line_count + 2, 30), + } + ], + "settings": {"locale": locale}, + "revisionId": "START_REVISION", + "lists": { + list_id: { + "columns": columns, + "domain": [["display_type", "=", False]], + "model": "sale.order.line", + "context": {}, + "orderBy": [], + "id": list_id, + "name": _("Sale Order Lines"), + "fieldMatching": { + filter_id: { + "chain": "order_id", + "type": "many2one", + } + }, + } + }, + "listNextId": 2, + "globalFilters": [ + { + "id": filter_id, + "type": "relation", + "label": _("Sale Order"), + "modelName": "sale.order", + "defaultValue": [], + } + ], + } + + @api.model + def _build_cells(self, list_id, columns, line_count): + cells = {} + for col_idx, col_name in enumerate(columns): + col_letter = self._col_index_to_letter(col_idx) + cells[f"{col_letter}1"] = { + "content": f'=ODOO.LIST.HEADER({list_id},"{col_name}")', + } + for row in range(1, line_count + 1): + cells[f"{col_letter}{row + 1}"] = { + "content": f'=ODOO.LIST({list_id},{row},"{col_name}")', + } + return cells + + @api.model + def _col_index_to_letter(self, idx): + """Convert a 0-based column index to spreadsheet letters. + + Examples: A, B, ..., Z, AA, ... + """ + result = "" + while True: + result = chr(ord("A") + idx % 26) + result + idx = idx // 26 - 1 + if idx < 0: + break + return result diff --git a/spreadsheet_quotation/wizards/spreadsheet_quotation_create.xml b/spreadsheet_quotation/wizards/spreadsheet_quotation_create.xml new file mode 100644 index 00000000..c4ca8fd9 --- /dev/null +++ b/spreadsheet_quotation/wizards/spreadsheet_quotation_create.xml @@ -0,0 +1,25 @@ + + + + spreadsheet.quotation.create.form + spreadsheet.quotation.create + +
+ + + + + + +
+
+
+