From a1f73dabe70ebd3790d4bfb5543f9eb3950ce747 Mon Sep 17 00:00:00 2001 From: luis Date: Sat, 14 Feb 2026 17:34:18 +0100 Subject: [PATCH 01/27] Arbore/arbore#85 add mail_quoted_reply_icon --- mail_quoted_reply_icon/__init__.py | 0 mail_quoted_reply_icon/__manifest__.py | 19 +++++++++++ .../src/models/message_action_view.esm.js | 33 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 mail_quoted_reply_icon/__init__.py create mode 100644 mail_quoted_reply_icon/__manifest__.py create mode 100644 mail_quoted_reply_icon/static/src/models/message_action_view.esm.js diff --git a/mail_quoted_reply_icon/__init__.py b/mail_quoted_reply_icon/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mail_quoted_reply_icon/__manifest__.py b/mail_quoted_reply_icon/__manifest__.py new file mode 100644 index 0000000..2feb759 --- /dev/null +++ b/mail_quoted_reply_icon/__manifest__.py @@ -0,0 +1,19 @@ +{ + "name": "Mail Quoted Reply Icon", + "summary": "Changes the icon for quoted reply from fa-reply to fa-envelope", + "version": "16.0.1.0.0", + "category": "Discuss", + "license": "AGPL-3", + "author": "Criptoamrt", + "website": "https://criptomart.net", + "depends": ["mail_quoted_reply"], + "data": [], + "demo": [], + "installable": True, + "application": False, + "assets": { + "web.assets_backend": [ + "/mail_quoted_reply_icon/static/src/models/*.js", + ], + }, +} diff --git a/mail_quoted_reply_icon/static/src/models/message_action_view.esm.js b/mail_quoted_reply_icon/static/src/models/message_action_view.esm.js new file mode 100644 index 0000000..cf8ee56 --- /dev/null +++ b/mail_quoted_reply_icon/static/src/models/message_action_view.esm.js @@ -0,0 +1,33 @@ +/** @odoo-module **/ + +import {registerPatch} from "@mail/model/model_core"; + +registerPatch({ + name: "MessageActionView", + fields: { + classNames: { + compute() { + let classNames = this._super() || ""; + if ( + this.messageAction.messageActionListOwner === + this.messageAction.replyMessageAction + ) { + // Replace the fa-reply icon added by mail_quoted_reply with fa-envelope + classNames = classNames.replace("fa-reply", "fa-envelope"); + } + return classNames; + }, + }, + title: { + compute() { + if ( + this.messageAction.messageActionListOwner === + this.messageAction.replyMessageAction + ) { + return this.env._t("Reply by Email"); + } + return this._super(); + }, + }, + }, +}); From 25faab83bb015b17e7adb34ee07e51eb45abc98e Mon Sep 17 00:00:00 2001 From: luis Date: Tue, 17 Feb 2026 17:36:13 +0100 Subject: [PATCH 02/27] Criptomart/red-supermercados-coop#37 purchase_order_product_recommendation_supermarket: round qty_to_order when product_uom_categ_unit --- .../wizards/purchase_order_recommendation.py | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py index ad14f13..9d4840e 100644 --- a/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py +++ b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py @@ -174,13 +174,21 @@ class PurchaseOrderRecommendationSupermarketWizard(models.TransientModel): if days_with_stock != 0 else 1 ) - + uom_categ_unit = self.env.ref( + "uom.product_uom_categ_unit", raise_if_not_found=False + ) if self.order_days != 0: - qty_to_order = max( - 0, - (self.order_days * res["units_avg_delivered"]) - - res["units_virtual_available"], - ) + qty_calc = (self.order_days * res["units_avg_delivered"]) - res[ + "units_virtual_available" + ] + qty_to_order = max(0, qty_calc) + # Round only if the UoM category is 'uom.product_uom_categ_unit' + if ( + product_id.uom_po_id + and uom_categ_unit + and product_id.uom_po_id.category_id.id == uom_categ_unit.id + ): + qty_to_order = math.ceil(qty_to_order) # Force a minimum suggested quantity when forecast <= 0 and product configured # We apply this BEFORE packaging adjustment so packages logic can upscale it. @@ -198,7 +206,15 @@ class PurchaseOrderRecommendationSupermarketWizard(models.TransientModel): packaging_qty = product_id.packaging_ids[:1].qty if packaging_qty: qty_to_order = math.ceil(qty_to_order / packaging_qty) * packaging_qty - res["units_included"] = qty_to_order + # Round only if the UoM category is 'uom.product_uom_categ_unit' + if ( + product_id.uom_po_id + and uom_categ_unit + and product_id.uom_po_id.category_id.id == uom_categ_unit.id + ): + res["units_included"] = math.ceil(qty_to_order) + else: + res["units_included"] = qty_to_order # Get quantities scrapped domain = self._get_move_line_domain(product_id, src="internal", dst="inventory") From dab4123379310fc6e340002c09dab18208f9648b Mon Sep 17 00:00:00 2001 From: luis Date: Tue, 17 Feb 2026 17:48:09 +0100 Subject: [PATCH 03/27] purchase_order_product_recommendation_supermarket: fix round problem in _compute_packaging_qty --- .../wizards/purchase_order_recommendation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py index 9d4840e..b385aed 100644 --- a/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py +++ b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py @@ -433,7 +433,7 @@ class PurchaseOrderRecommendationLine(models.TransientModel): def _compute_packaging_qty(self): for rec in self: if rec.packaging_id and rec.packaging_id.qty: - rec.packaging_qty = int(rec.units_included // rec.packaging_id.qty) + rec.packaging_qty = math.ceil(rec.units_included / rec.packaging_id.qty) else: rec.packaging_qty = 0 From bf02699b612a6eff7ffc5b1ae05bed2fba484728 Mon Sep 17 00:00:00 2001 From: luis Date: Tue, 24 Feb 2026 13:37:26 +0100 Subject: [PATCH 04/27] FoodcoopBCN/foodcoopbcn#167 add pos_product_available_link --- pos_product_available_link/README.rst | 43 +++++++++++++++++++ pos_product_available_link/__init__.py | 1 + pos_product_available_link/__manifest__.py | 13 ++++++ pos_product_available_link/models/__init__.py | 1 + pos_product_available_link/models/product.py | 32 ++++++++++++++ .../tests/test_product_available_link.py | 21 +++++++++ 6 files changed, 111 insertions(+) create mode 100644 pos_product_available_link/README.rst create mode 100644 pos_product_available_link/__init__.py create mode 100644 pos_product_available_link/__manifest__.py create mode 100644 pos_product_available_link/models/__init__.py create mode 100644 pos_product_available_link/models/product.py create mode 100644 pos_product_available_link/tests/test_product_available_link.py diff --git a/pos_product_available_link/README.rst b/pos_product_available_link/README.rst new file mode 100644 index 0000000..8956fc4 --- /dev/null +++ b/pos_product_available_link/README.rst @@ -0,0 +1,43 @@ +POS Product Available Link +========================= + +Automatically disables available_in_pos when sale_ok is unchecked on products. + +Features +======== + +* When you uncheck "Can be Sold" (sale_ok), the product is also removed from POS (available_in_pos). +* Prevents products with sale_ok=False from appearing in POS. + +Installation +============ + +Install this module as usual via Apps or with odoo-bin. + +Configuration +============= + +No configuration required. + +Usage +===== + +1. Go to Products. +2. Uncheck "Can be Sold". +3. The product will no longer be available in POS. + +Known issues / Roadmap +====================== + +* Only disables available_in_pos when sale_ok is unchecked. Does not re-enable. + +Credits +======= + +Authors +------- +* Your Name + +Contributors +------------ +* Odoo Community Association (OCA) diff --git a/pos_product_available_link/__init__.py b/pos_product_available_link/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/pos_product_available_link/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/pos_product_available_link/__manifest__.py b/pos_product_available_link/__manifest__.py new file mode 100644 index 0000000..cfea84e --- /dev/null +++ b/pos_product_available_link/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "POS Product Available Link", + "summary": "Automatically enable/disable available_in_pos when sale_ok is checked/unchecked.", + "version": "16.0.1.0.0", + "category": "Point of Sale", + "license": "AGPL-3", + "author": "Criptomart", + "website": "https://criptomart.net", + "depends": ["point_of_sale", "product"], + "data": [], + "installable": True, + "application": False, +} diff --git a/pos_product_available_link/models/__init__.py b/pos_product_available_link/models/__init__.py new file mode 100644 index 0000000..9649db7 --- /dev/null +++ b/pos_product_available_link/models/__init__.py @@ -0,0 +1 @@ +from . import product diff --git a/pos_product_available_link/models/product.py b/pos_product_available_link/models/product.py new file mode 100644 index 0000000..683e0e0 --- /dev/null +++ b/pos_product_available_link/models/product.py @@ -0,0 +1,32 @@ +# Copyright 2026 Your Name +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + @api.onchange("sale_ok") + def _onchange_sale_ok_link_available_in_pos(self): + for product in self: + product.available_in_pos = product.sale_ok + + def write(self, vals): + if "sale_ok" in vals: + vals["available_in_pos"] = vals["sale_ok"] + return super().write(vals) + + +class ProductProduct(models.Model): + _inherit = "product.product" + + @api.onchange("sale_ok") + def _onchange_sale_ok_link_available_in_pos(self): + for product in self: + product.available_in_pos = product.sale_ok + + def write(self, vals): + if "sale_ok" in vals: + vals["available_in_pos"] = vals["sale_ok"] + return super().write(vals) diff --git a/pos_product_available_link/tests/test_product_available_link.py b/pos_product_available_link/tests/test_product_available_link.py new file mode 100644 index 0000000..95084f1 --- /dev/null +++ b/pos_product_available_link/tests/test_product_available_link.py @@ -0,0 +1,21 @@ +from odoo.tests.common import TransactionCase + + +class TestProductAvailableLink(TransactionCase): + def setUp(self): + super().setUp() + self.product = self.env["product.product"].create( + { + "name": "Test Product", + "sale_ok": True, + "available_in_pos": True, + } + ) + + def test_uncheck_sale_ok_disables_available_in_pos(self): + self.product.sale_ok = False + self.assertFalse(self.product.available_in_pos) + + def test_write_sale_ok_false_disables_available_in_pos(self): + self.product.write({"sale_ok": False}) + self.assertFalse(self.product.available_in_pos) From d9a0eeb87855d4b0b03041fb1958ae8f83d6e5a0 Mon Sep 17 00:00:00 2001 From: luis Date: Mon, 9 Mar 2026 17:09:55 +0100 Subject: [PATCH 05/27] [16.0] add purchase_report_received_amount --- purchase_report_received_amount/README.rst | 47 ++++++++++++++++++ purchase_report_received_amount/__init__.py | 1 + .../__manifest__.py | 13 +++++ purchase_report_received_amount/i18n/es.po | 48 +++++++++++++++++++ .../i18n/purchase_report_received_amount.pot | 46 ++++++++++++++++++ .../report/__init__.py | 1 + .../report/purchase_report.py | 37 ++++++++++++++ 7 files changed, 193 insertions(+) create mode 100644 purchase_report_received_amount/README.rst create mode 100644 purchase_report_received_amount/__init__.py create mode 100644 purchase_report_received_amount/__manifest__.py create mode 100644 purchase_report_received_amount/i18n/es.po create mode 100644 purchase_report_received_amount/i18n/purchase_report_received_amount.pot create mode 100644 purchase_report_received_amount/report/__init__.py create mode 100644 purchase_report_received_amount/report/purchase_report.py diff --git a/purchase_report_received_amount/README.rst b/purchase_report_received_amount/README.rst new file mode 100644 index 0000000..d0b0279 --- /dev/null +++ b/purchase_report_received_amount/README.rst @@ -0,0 +1,47 @@ +================================ +Purchase Report Received Amount +================================ + +This module adds two new measures to the Purchase Report (purchase.report): + +* **Received Amount**: The monetary value of received quantities with discounts applied +* **Received Amount (Undiscounted)**: The monetary value of received quantities without discounts + +Features +======== + +* Adds ``price_received`` field: Amount of received quantity with discount applied +* Adds ``price_received_undiscounted`` field: Amount of received quantity without discount +* Both fields are converted to company currency for multi-currency support +* Compatible with unit of measure conversions + +Installation +============ + +Install the module from the Odoo Apps menu. + +This module depends on: +* ``purchase`` +* ``purchase_discount`` (OCA) + +Configuration +============= + +No configuration needed. After installation, the new measures will be available +in the Purchase Analysis report. + +Usage +===== + +1. Go to Purchase > Reporting > Purchase Analysis +2. Click on "Measures" button +3. Select "Received Amount" or "Received Amount (Undiscounted)" +4. The pivot/graph view will display the values + +Credits +======= + +Authors +~~~~~~~ + +* Criptomart diff --git a/purchase_report_received_amount/__init__.py b/purchase_report_received_amount/__init__.py new file mode 100644 index 0000000..4c4f242 --- /dev/null +++ b/purchase_report_received_amount/__init__.py @@ -0,0 +1 @@ +from . import report diff --git a/purchase_report_received_amount/__manifest__.py b/purchase_report_received_amount/__manifest__.py new file mode 100644 index 0000000..4e2fc31 --- /dev/null +++ b/purchase_report_received_amount/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "Purchase Report Received Amount", + "version": "16.0.1.0.0", + "depends": ["purchase", "purchase_discount"], + "author": "Criptomart", + "category": "Purchases", + "summary": "Add received amount measures to purchase report", + "license": "AGPL-3", + "data": [], + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/purchase_report_received_amount/i18n/es.po b/purchase_report_received_amount/i18n/es.po new file mode 100644 index 0000000..5c761e3 --- /dev/null +++ b/purchase_report_received_amount/i18n/es.po @@ -0,0 +1,48 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_report_received_amount +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-09 16:04+0000\n" +"PO-Revision-Date: 2026-03-09 17:07+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.8\n" + +#. module: purchase_report_received_amount +#: model:ir.model.fields,help:purchase_report_received_amount.field_purchase_report__price_received +msgid "Amount of received quantity (with discount applied)" +msgstr "Precio total recibido (con descuentos aplicados)" + +#. module: purchase_report_received_amount +#: model:ir.model.fields,help:purchase_report_received_amount.field_purchase_report__price_received_undiscounted +msgid "Amount of received quantity without discount" +msgstr "Precio total recibido (sin descuentos aplicados)" + +#. module: purchase_report_received_amount +#: model:ir.model,name:purchase_report_received_amount.model_purchase_report +msgid "Purchase Report" +msgstr "Informe de compra" + +#. module: purchase_report_received_amount +#: model:ir.model.fields,field_description:purchase_report_received_amount.field_purchase_report__price_received +msgid "Received Amount" +msgstr "Precio total recibido" + +#. module: purchase_report_received_amount +#: model:ir.model.fields,field_description:purchase_report_received_amount.field_purchase_report__price_received_undiscounted +msgid "Received Amount (Undiscounted)" +msgstr "Precio total recibido sin descuentos" + +#. module: purchase_report_received_amount +#: model:ir.model.fields,field_description:purchase_report_received_amount.field_purchase_report__smart_search +msgid "Smart Search" +msgstr "Búsqueda inteligente" diff --git a/purchase_report_received_amount/i18n/purchase_report_received_amount.pot b/purchase_report_received_amount/i18n/purchase_report_received_amount.pot new file mode 100644 index 0000000..be680d4 --- /dev/null +++ b/purchase_report_received_amount/i18n/purchase_report_received_amount.pot @@ -0,0 +1,46 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_report_received_amount +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-09 16:03+0000\n" +"PO-Revision-Date: 2026-03-09 16:03+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: purchase_report_received_amount +#: model:ir.model.fields,help:purchase_report_received_amount.field_purchase_report__price_received +msgid "Amount of received quantity (with discount applied)" +msgstr "" + +#. module: purchase_report_received_amount +#: model:ir.model.fields,help:purchase_report_received_amount.field_purchase_report__price_received_undiscounted +msgid "Amount of received quantity without discount" +msgstr "" + +#. module: purchase_report_received_amount +#: model:ir.model,name:purchase_report_received_amount.model_purchase_report +msgid "Purchase Report" +msgstr "" + +#. module: purchase_report_received_amount +#: model:ir.model.fields,field_description:purchase_report_received_amount.field_purchase_report__price_received +msgid "Received Amount" +msgstr "" + +#. module: purchase_report_received_amount +#: model:ir.model.fields,field_description:purchase_report_received_amount.field_purchase_report__price_received_undiscounted +msgid "Received Amount (Undiscounted)" +msgstr "" + +#. module: purchase_report_received_amount +#: model:ir.model.fields,field_description:purchase_report_received_amount.field_purchase_report__smart_search +msgid "Smart Search" +msgstr "" diff --git a/purchase_report_received_amount/report/__init__.py b/purchase_report_received_amount/report/__init__.py new file mode 100644 index 0000000..84b6eef --- /dev/null +++ b/purchase_report_received_amount/report/__init__.py @@ -0,0 +1 @@ +from . import purchase_report diff --git a/purchase_report_received_amount/report/purchase_report.py b/purchase_report_received_amount/report/purchase_report.py new file mode 100644 index 0000000..8597867 --- /dev/null +++ b/purchase_report_received_amount/report/purchase_report.py @@ -0,0 +1,37 @@ +# Copyright 2024 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PurchaseReport(models.Model): + _inherit = "purchase.report" + + price_received = fields.Float( + string="Received Amount", + readonly=True, + help="Amount of received quantity (with discount applied)", + ) + price_received_undiscounted = fields.Float( + string="Received Amount (Undiscounted)", + readonly=True, + help="Amount of received quantity without discount", + ) + + def _select(self): + res = super()._select() + # Add received amount with discount (uses the already discounted price from purchase_discount) + res += """, + sum( + l.qty_received / line_uom.factor * product_uom.factor + * (1.0 - COALESCE(l.discount, 0.0) / 100.0) * l.price_unit + / COALESCE(po.currency_rate, 1.0) + )::decimal(16,2) * currency_table.rate as price_received""" + # Add received amount without discount + res += """, + sum( + l.qty_received / line_uom.factor * product_uom.factor + * l.price_unit + / COALESCE(po.currency_rate, 1.0) + )::decimal(16,2) * currency_table.rate as price_received_undiscounted""" + return res From f586c99df9de4d6df353b0005f4fac0ec8687e2d Mon Sep 17 00:00:00 2001 From: luis Date: Thu, 2 Apr 2026 12:43:36 +0200 Subject: [PATCH 06/27] add purchase_price_review_status --- purchase_price_review_status/README.md | 9 ++ purchase_price_review_status/__init__.py | 1 + purchase_price_review_status/__manifest__.py | 16 ++++ purchase_price_review_status/i18n/es.po | 39 ++++++++ .../i18n/purchase_price_review_status.pot | 38 ++++++++ .../models/__init__.py | 2 + .../models/purchase_order.py | 11 +++ .../models/stock_picking.py | 12 +++ .../views/purchase_order_views.xml | 93 +++++++++++++++++++ .../views/stock_picking_views.xml | 39 ++++++++ 10 files changed, 260 insertions(+) create mode 100644 purchase_price_review_status/README.md create mode 100644 purchase_price_review_status/__init__.py create mode 100644 purchase_price_review_status/__manifest__.py create mode 100644 purchase_price_review_status/i18n/es.po create mode 100644 purchase_price_review_status/i18n/purchase_price_review_status.pot create mode 100644 purchase_price_review_status/models/__init__.py create mode 100644 purchase_price_review_status/models/purchase_order.py create mode 100644 purchase_price_review_status/models/stock_picking.py create mode 100644 purchase_price_review_status/views/purchase_order_views.xml create mode 100644 purchase_price_review_status/views/stock_picking_views.xml diff --git a/purchase_price_review_status/README.md b/purchase_price_review_status/README.md new file mode 100644 index 0000000..e58f78c --- /dev/null +++ b/purchase_price_review_status/README.md @@ -0,0 +1,9 @@ +# Purchase Price Review Status + +Adds a boolean field on purchase orders to indicate whether purchase prices were reviewed against receipts. + +It decorates list views according to these rules: + +- Green: not fully received and prices reviewed. +- Red: not fully received and prices not reviewed. +- Default color: fully received (unchanged behavior). diff --git a/purchase_price_review_status/__init__.py b/purchase_price_review_status/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/purchase_price_review_status/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/purchase_price_review_status/__manifest__.py b/purchase_price_review_status/__manifest__.py new file mode 100644 index 0000000..3c0df01 --- /dev/null +++ b/purchase_price_review_status/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Purchase Price Review Status", + "version": "16.0.1.0.0", + "summary": "Track if purchase prices were reviewed and color PO/receipt lists", + "license": "AGPL-3", + "author": "Criptomart", + "website": "https://criptomart.net", + "depends": ["purchase_stock"], + "data": [ + "views/purchase_order_views.xml", + "views/stock_picking_views.xml", + ], + "installable": True, +} diff --git a/purchase_price_review_status/i18n/es.po b/purchase_price_review_status/i18n/es.po new file mode 100644 index 0000000..36d5adc --- /dev/null +++ b/purchase_price_review_status/i18n/es.po @@ -0,0 +1,39 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_price_review_status +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-02 00:00+0000\n" +"PO-Revision-Date: 2026-04-02 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: purchase_price_review_status +#: model:ir.model.fields,field_description:purchase_price_review_status.field_purchase_order__prices_reviewed +#: model:ir.model.fields,field_description:purchase_price_review_status.field_stock_picking__purchase_prices_reviewed +msgid "Purchase Prices Reviewed" +msgstr "Precios de Compra Revisados" + +#. module: purchase_price_review_status +#: model:ir.model.fields,help:purchase_price_review_status.field_purchase_order__prices_reviewed +msgid "Indicates whether purchase prices were reviewed against the receipt." +msgstr "Indica si los precios de compra fueron revisados con la recepción." + +#. module: purchase_price_review_status +#: model_terms:ir.ui.view,arch_db:purchase_price_review_status.view_purchase_order_filter_price_review +#: model_terms:ir.ui.view,arch_db:purchase_price_review_status.purchase_order_view_search_price_review +msgid "Prices Not Reviewed" +msgstr "Precios No Revisados" + +#. module: purchase_price_review_status +#: model_terms:ir.ui.view,arch_db:purchase_price_review_status.view_picking_internal_search_price_review +msgid "Purchase Prices Not Reviewed" +msgstr "Precios de Compra No Revisados" diff --git a/purchase_price_review_status/i18n/purchase_price_review_status.pot b/purchase_price_review_status/i18n/purchase_price_review_status.pot new file mode 100644 index 0000000..f97265d --- /dev/null +++ b/purchase_price_review_status/i18n/purchase_price_review_status.pot @@ -0,0 +1,38 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_price_review_status +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-02 00:00+0000\n" +"PO-Revision-Date: 2026-04-02 00:00+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: purchase_price_review_status +#: model:ir.model.fields,field_description:purchase_price_review_status.field_purchase_order__prices_reviewed +#: model:ir.model.fields,field_description:purchase_price_review_status.field_stock_picking__purchase_prices_reviewed +msgid "Purchase Prices Reviewed" +msgstr "" + +#. module: purchase_price_review_status +#: model:ir.model.fields,help:purchase_price_review_status.field_purchase_order__prices_reviewed +msgid "Indicates whether purchase prices were reviewed against the receipt." +msgstr "" + +#. module: purchase_price_review_status +#: model_terms:ir.ui.view,arch_db:purchase_price_review_status.view_purchase_order_filter_price_review +#: model_terms:ir.ui.view,arch_db:purchase_price_review_status.purchase_order_view_search_price_review +msgid "Prices Not Reviewed" +msgstr "" + +#. module: purchase_price_review_status +#: model_terms:ir.ui.view,arch_db:purchase_price_review_status.view_picking_internal_search_price_review +msgid "Purchase Prices Not Reviewed" +msgstr "" diff --git a/purchase_price_review_status/models/__init__.py b/purchase_price_review_status/models/__init__.py new file mode 100644 index 0000000..d2e1293 --- /dev/null +++ b/purchase_price_review_status/models/__init__.py @@ -0,0 +1,2 @@ +from . import purchase_order +from . import stock_picking diff --git a/purchase_price_review_status/models/purchase_order.py b/purchase_price_review_status/models/purchase_order.py new file mode 100644 index 0000000..06f017f --- /dev/null +++ b/purchase_price_review_status/models/purchase_order.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + prices_reviewed = fields.Boolean( + string="Purchase Prices Reviewed", + default=False, + help="Indicates whether purchase prices were reviewed against the receipt.", + ) diff --git a/purchase_price_review_status/models/stock_picking.py b/purchase_price_review_status/models/stock_picking.py new file mode 100644 index 0000000..1c8511b --- /dev/null +++ b/purchase_price_review_status/models/stock_picking.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + purchase_prices_reviewed = fields.Boolean( + related="purchase_id.prices_reviewed", + string="Purchase Prices Reviewed", + readonly=True, + store=False, + ) diff --git a/purchase_price_review_status/views/purchase_order_views.xml b/purchase_price_review_status/views/purchase_order_views.xml new file mode 100644 index 0000000..1d49ce6 --- /dev/null +++ b/purchase_price_review_status/views/purchase_order_views.xml @@ -0,0 +1,93 @@ + + + + + purchase.order.form.price.review + purchase.order + + + + + + + + + + purchase.order.view.tree.price.review + purchase.order + + + + receipt_status != 'full' and prices_reviewed + receipt_status != 'full' and not prices_reviewed + + + + + + + + + + purchase.order.tree.price.review + purchase.order + + + + receipt_status != 'full' and prices_reviewed + receipt_status != 'full' and not prices_reviewed + + + + + + + + + + purchase.order.kpis.tree.price.review + purchase.order + + + + receipt_status != 'full' and prices_reviewed + receipt_status != 'full' and not prices_reviewed + + + + + + + + + + purchase.order.filter.price.review + purchase.order + + + + + + + + + + purchase.order.view.search.price.review + purchase.order + + + + + + + + + diff --git a/purchase_price_review_status/views/stock_picking_views.xml b/purchase_price_review_status/views/stock_picking_views.xml new file mode 100644 index 0000000..b281490 --- /dev/null +++ b/purchase_price_review_status/views/stock_picking_views.xml @@ -0,0 +1,39 @@ + + + + + stock.picking.tree.price.review + stock.picking + + + + picking_type_code == 'incoming' and state not in ('done', 'cancel') and purchase_id and purchase_prices_reviewed + picking_type_code == 'incoming' and state not in ('done', 'cancel') and purchase_id and not purchase_prices_reviewed + + + + + + + + + + stock.picking.internal.search.price.review + stock.picking + + + + + + + + + From 91971ae56b747b5bc89cae1ba7ddfde41a0b92a3 Mon Sep 17 00:00:00 2001 From: luis Date: Thu, 2 Apr 2026 13:02:18 +0200 Subject: [PATCH 07/27] add purchase_order_supplierinfo_update_triple_discount --- .../README.md | 9 +++ .../__init__.py | 1 + .../__manifest__.py | 18 ++++++ .../i18n/es.po | 62 +++++++++++++++++++ ...er_supplierinfo_update_triple_discount.pot | 61 ++++++++++++++++++ .../models/__init__.py | 2 + .../models/purchase_order_line.py | 43 +++++++++++++ .../models/res_config_settings.py | 32 ++++++++++ .../views/res_config_settings_views.xml | 47 ++++++++++++++ 9 files changed, 275 insertions(+) create mode 100644 purchase_order_supplierinfo_update_triple_discount/README.md create mode 100644 purchase_order_supplierinfo_update_triple_discount/__init__.py create mode 100644 purchase_order_supplierinfo_update_triple_discount/__manifest__.py create mode 100644 purchase_order_supplierinfo_update_triple_discount/i18n/es.po create mode 100644 purchase_order_supplierinfo_update_triple_discount/i18n/purchase_order_supplierinfo_update_triple_discount.pot create mode 100644 purchase_order_supplierinfo_update_triple_discount/models/__init__.py create mode 100644 purchase_order_supplierinfo_update_triple_discount/models/purchase_order_line.py create mode 100644 purchase_order_supplierinfo_update_triple_discount/models/res_config_settings.py create mode 100644 purchase_order_supplierinfo_update_triple_discount/views/res_config_settings_views.xml diff --git a/purchase_order_supplierinfo_update_triple_discount/README.md b/purchase_order_supplierinfo_update_triple_discount/README.md new file mode 100644 index 0000000..707ce28 --- /dev/null +++ b/purchase_order_supplierinfo_update_triple_discount/README.md @@ -0,0 +1,9 @@ +# Purchase Order Supplierinfo Update Triple Discount + +This module extends `purchase_order_supplierinfo_update` to optionally update triple discount fields in supplierinfo when confirming purchase orders. + +## Features + +- Adds three Purchase settings to enable/disable synchronization for each field independently. +- Keeps original behavior for supplier price update. +- Updates `discount1`, `discount2` and `discount3` from purchase order lines to supplierinfo only for the enabled fields. diff --git a/purchase_order_supplierinfo_update_triple_discount/__init__.py b/purchase_order_supplierinfo_update_triple_discount/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/purchase_order_supplierinfo_update_triple_discount/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/purchase_order_supplierinfo_update_triple_discount/__manifest__.py b/purchase_order_supplierinfo_update_triple_discount/__manifest__.py new file mode 100644 index 0000000..26ecfaf --- /dev/null +++ b/purchase_order_supplierinfo_update_triple_discount/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Purchase Order Supplierinfo Update Triple Discount", + "version": "16.0.1.0.0", + "summary": "Optionally sync triple discounts to supplierinfo on purchase confirmation", + "license": "AGPL-3", + "author": "Criptomart", + "website": "https://criptomart.net", + "depends": [ + "purchase_order_supplierinfo_update", + "purchase_triple_discount", + ], + "data": [ + "views/res_config_settings_views.xml", + ], + "installable": True, +} diff --git a/purchase_order_supplierinfo_update_triple_discount/i18n/es.po b/purchase_order_supplierinfo_update_triple_discount/i18n/es.po new file mode 100644 index 0000000..0fa0b2e --- /dev/null +++ b/purchase_order_supplierinfo_update_triple_discount/i18n/es.po @@ -0,0 +1,62 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_order_supplierinfo_update_triple_discount +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-02 00:00+0000\n" +"PO-Revision-Date: 2026-04-02 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model:ir.model.fields,field_description:purchase_order_supplierinfo_update_triple_discount.field_res_config_settings__update_supplierinfo_discount1 +msgid "Update Supplierinfo Discount 1" +msgstr "Actualizar Descuento 1 en Info de Proveedor" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model:ir.model.fields,field_description:purchase_order_supplierinfo_update_triple_discount.field_res_config_settings__update_supplierinfo_discount2 +msgid "Update Supplierinfo Discount 2" +msgstr "Actualizar Descuento 2 en Info de Proveedor" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model:ir.model.fields,field_description:purchase_order_supplierinfo_update_triple_discount.field_res_config_settings__update_supplierinfo_discount3 +msgid "Update Supplierinfo Discount 3" +msgstr "Actualizar Descuento 3 en Info de Proveedor" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model:ir.model.fields,help:purchase_order_supplierinfo_update_triple_discount.field_res_config_settings__update_supplierinfo_discount1 +msgid "If enabled, confirming a purchase order will update discount1." +msgstr "Si está habilitado, al confirmar una orden de compra se actualizará el descuento1." + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model:ir.model.fields,help:purchase_order_supplierinfo_update_triple_discount.field_res_config_settings__update_supplierinfo_discount2 +msgid "If enabled, confirming a purchase order will update discount2." +msgstr "Si está habilitado, al confirmar una orden de compra se actualizará el descuento2." + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model:ir.model.fields,help:purchase_order_supplierinfo_update_triple_discount.field_res_config_settings__update_supplierinfo_discount3 +msgid "If enabled, confirming a purchase order will update discount3." +msgstr "Si está habilitado, al confirmar una orden de compra se actualizará el descuento3." + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model_terms:ir.ui.view,arch_db:purchase_order_supplierinfo_update_triple_discount.res_config_settings_view_form_purchase_supplierinfo_triple_discount +msgid "Update discount1 from purchase lines into supplierinfo" +msgstr "Actualizar descuento1 de las líneas de compra en la info del proveedor" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model_terms:ir.ui.view,arch_db:purchase_order_supplierinfo_update_triple_discount.res_config_settings_view_form_purchase_supplierinfo_triple_discount +msgid "Update discount2 from purchase lines into supplierinfo" +msgstr "Actualizar descuento2 de las líneas de compra en la info del proveedor" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model_terms:ir.ui.view,arch_db:purchase_order_supplierinfo_update_triple_discount.res_config_settings_view_form_purchase_supplierinfo_triple_discount +msgid "Update discount3 from purchase lines into supplierinfo" +msgstr "Actualizar descuento3 de las líneas de compra en la info del proveedor" diff --git a/purchase_order_supplierinfo_update_triple_discount/i18n/purchase_order_supplierinfo_update_triple_discount.pot b/purchase_order_supplierinfo_update_triple_discount/i18n/purchase_order_supplierinfo_update_triple_discount.pot new file mode 100644 index 0000000..7781983 --- /dev/null +++ b/purchase_order_supplierinfo_update_triple_discount/i18n/purchase_order_supplierinfo_update_triple_discount.pot @@ -0,0 +1,61 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_order_supplierinfo_update_triple_discount +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-02 00:00+0000\n" +"PO-Revision-Date: 2026-04-02 00:00+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: purchase_order_supplierinfo_update_triple_discount +#: model:ir.model.fields,field_description:purchase_order_supplierinfo_update_triple_discount.field_res_config_settings__update_supplierinfo_discount1 +msgid "Update Supplierinfo Discount 1" +msgstr "" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model:ir.model.fields,field_description:purchase_order_supplierinfo_update_triple_discount.field_res_config_settings__update_supplierinfo_discount2 +msgid "Update Supplierinfo Discount 2" +msgstr "" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model:ir.model.fields,field_description:purchase_order_supplierinfo_update_triple_discount.field_res_config_settings__update_supplierinfo_discount3 +msgid "Update Supplierinfo Discount 3" +msgstr "" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model:ir.model.fields,help:purchase_order_supplierinfo_update_triple_discount.field_res_config_settings__update_supplierinfo_discount1 +msgid "If enabled, confirming a purchase order will update discount1." +msgstr "" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model:ir.model.fields,help:purchase_order_supplierinfo_update_triple_discount.field_res_config_settings__update_supplierinfo_discount2 +msgid "If enabled, confirming a purchase order will update discount2." +msgstr "" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model:ir.model.fields,help:purchase_order_supplierinfo_update_triple_discount.field_res_config_settings__update_supplierinfo_discount3 +msgid "If enabled, confirming a purchase order will update discount3." +msgstr "" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model_terms:ir.ui.view,arch_db:purchase_order_supplierinfo_update_triple_discount.res_config_settings_view_form_purchase_supplierinfo_triple_discount +msgid "Update discount1 from purchase lines into supplierinfo" +msgstr "" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model_terms:ir.ui.view,arch_db:purchase_order_supplierinfo_update_triple_discount.res_config_settings_view_form_purchase_supplierinfo_triple_discount +msgid "Update discount2 from purchase lines into supplierinfo" +msgstr "" + +#. module: purchase_order_supplierinfo_update_triple_discount +#: model_terms:ir.ui.view,arch_db:purchase_order_supplierinfo_update_triple_discount.res_config_settings_view_form_purchase_supplierinfo_triple_discount +msgid "Update discount3 from purchase lines into supplierinfo" +msgstr "" diff --git a/purchase_order_supplierinfo_update_triple_discount/models/__init__.py b/purchase_order_supplierinfo_update_triple_discount/models/__init__.py new file mode 100644 index 0000000..247ce48 --- /dev/null +++ b/purchase_order_supplierinfo_update_triple_discount/models/__init__.py @@ -0,0 +1,2 @@ +from . import purchase_order_line +from . import res_config_settings diff --git a/purchase_order_supplierinfo_update_triple_discount/models/purchase_order_line.py b/purchase_order_supplierinfo_update_triple_discount/models/purchase_order_line.py new file mode 100644 index 0000000..d4c685f --- /dev/null +++ b/purchase_order_supplierinfo_update_triple_discount/models/purchase_order_line.py @@ -0,0 +1,43 @@ +from odoo import models +from odoo.tools.misc import str2bool + + +class PurchaseOrderLine(models.Model): + _inherit = "purchase.order.line" + + _SUPPLIERINFO_DISCOUNT_CONFIG = { + "discount1": ( + "purchase_order_supplierinfo_update_triple_discount" + ".update_supplierinfo_discount1" + ), + "discount2": ( + "purchase_order_supplierinfo_update_triple_discount" + ".update_supplierinfo_discount2" + ), + "discount3": ( + "purchase_order_supplierinfo_update_triple_discount" + ".update_supplierinfo_discount3" + ), + } + + def _update_supplierinfo(self, seller): + """Extend supplierinfo update to optionally sync each discount field.""" + self.ensure_one() + super()._update_supplierinfo(seller) + if not seller: + return + + config = self.env["ir.config_parameter"].sudo() + values = {} + for field_name, parameter_name in self._SUPPLIERINFO_DISCOUNT_CONFIG.items(): + if field_name not in self._fields or field_name not in seller._fields: + continue + should_update = str2bool( + config.get_param(parameter_name, default=False), + default=False, + ) + if should_update: + values[field_name] = self[field_name] + + if values: + seller.sudo().write(values) diff --git a/purchase_order_supplierinfo_update_triple_discount/models/res_config_settings.py b/purchase_order_supplierinfo_update_triple_discount/models/res_config_settings.py new file mode 100644 index 0000000..cc22e0e --- /dev/null +++ b/purchase_order_supplierinfo_update_triple_discount/models/res_config_settings.py @@ -0,0 +1,32 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + update_supplierinfo_discount1 = fields.Boolean( + string="Update Supplierinfo Discount 1", + config_parameter=( + "purchase_order_supplierinfo_update_triple_discount" + ".update_supplierinfo_discount1" + ), + help="If enabled, confirming a purchase order will update discount1.", + ) + + update_supplierinfo_discount2 = fields.Boolean( + string="Update Supplierinfo Discount 2", + config_parameter=( + "purchase_order_supplierinfo_update_triple_discount" + ".update_supplierinfo_discount2" + ), + help="If enabled, confirming a purchase order will update discount2.", + ) + + update_supplierinfo_discount3 = fields.Boolean( + string="Update Supplierinfo Discount 3", + config_parameter=( + "purchase_order_supplierinfo_update_triple_discount" + ".update_supplierinfo_discount3" + ), + help="If enabled, confirming a purchase order will update discount3.", + ) diff --git a/purchase_order_supplierinfo_update_triple_discount/views/res_config_settings_views.xml b/purchase_order_supplierinfo_update_triple_discount/views/res_config_settings_views.xml new file mode 100644 index 0000000..ccddda2 --- /dev/null +++ b/purchase_order_supplierinfo_update_triple_discount/views/res_config_settings_views.xml @@ -0,0 +1,47 @@ + + + + + res.config.settings.view.form.purchase.supplierinfo.triple.discount + res.config.settings + + + +
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
From 524e3d643c9d66cc66a1f437f48adce0399cdbe9 Mon Sep 17 00:00:00 2001 From: luis Date: Thu, 2 Apr 2026 14:23:36 +0200 Subject: [PATCH 08/27] product_sale_price_from_pricelist: allow to configure rounding of list_price_theoritical after taxes. Add the configured pricelist margin in product form view --- product_sale_price_from_pricelist/i18n/es.po | 27 ++++++++++ .../product_sale_price_from_pricelist.pot | 27 ++++++++++ .../models/product_pricelist.py | 10 +++- .../models/product_template.py | 52 +++++++++++++++++-- .../views/product_view.xml | 14 +++++ 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/product_sale_price_from_pricelist/i18n/es.po b/product_sale_price_from_pricelist/i18n/es.po index 272316f..002dd2c 100644 --- a/product_sale_price_from_pricelist/i18n/es.po +++ b/product_sale_price_from_pricelist/i18n/es.po @@ -17,6 +17,33 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 3.6\n" +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields,field_description:product_sale_price_from_pricelist.field_product_pricelist__price_rounding_step +msgid "Sale Price Rounding Step" +msgstr "Redondeo del precio de venta" + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields,help:product_sale_price_from_pricelist.field_product_pricelist__price_rounding_step +msgid "Round the theoretical sale price (after taxes) to the nearest multiple of this value. Set to 0.00 to disable rounding (e.g. 0.05 rounds to the nearest 5 cents)." +msgstr "Redondea el precio de venta teórico (con impuestos) al múltiplo más cercano de este valor. Pon 0,00 para desactivar el redondeo (p. ej. 0,05 redondea al céntimo más cercano de 5)." + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields,field_description:product_sale_price_from_pricelist.field_product_product__configured_sale_margin +#: model:ir.model.fields,field_description:product_sale_price_from_pricelist.field_product_template__configured_sale_margin +msgid "Configured sale margin (%)" +msgstr "Margen de venta configurado (%)" + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields,help:product_sale_price_from_pricelist.field_product_product__configured_sale_margin +#: model:ir.model.fields,help:product_sale_price_from_pricelist.field_product_template__configured_sale_margin +msgid "Effective margin configured by the automatic pricelist chain for this product. It reflects tariff configuration, not the current manual sale price." +msgstr "Margen efectivo configurado por la cadena de tarifas automáticas para este producto. Refleja la configuración de tarifas, no el precio de venta manual actual." + +#. module: product_sale_price_from_pricelist +#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.product_pricelist_view_price_rounding +msgid "Sale Price Rounding" +msgstr "Redondeo del precio de venta" + #. module: product_sale_price_from_pricelist #: model:ir.model.fields,help:product_sale_price_from_pricelist.field_product_pricelist_item__base msgid "" diff --git a/product_sale_price_from_pricelist/i18n/product_sale_price_from_pricelist.pot b/product_sale_price_from_pricelist/i18n/product_sale_price_from_pricelist.pot index 4875e43..b5019fc 100644 --- a/product_sale_price_from_pricelist/i18n/product_sale_price_from_pricelist.pot +++ b/product_sale_price_from_pricelist/i18n/product_sale_price_from_pricelist.pot @@ -15,6 +15,33 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields,field_description:product_sale_price_from_pricelist.field_product_pricelist__price_rounding_step +msgid "Sale Price Rounding Step" +msgstr "" + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields,help:product_sale_price_from_pricelist.field_product_pricelist__price_rounding_step +msgid "Round the theoretical sale price (after taxes) to the nearest multiple of this value. Set to 0.00 to disable rounding (e.g. 0.05 rounds to the nearest 5 cents)." +msgstr "" + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields,field_description:product_sale_price_from_pricelist.field_product_product__configured_sale_margin +#: model:ir.model.fields,field_description:product_sale_price_from_pricelist.field_product_template__configured_sale_margin +msgid "Configured sale margin (%)" +msgstr "" + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields,help:product_sale_price_from_pricelist.field_product_product__configured_sale_margin +#: model:ir.model.fields,help:product_sale_price_from_pricelist.field_product_template__configured_sale_margin +msgid "Effective margin configured by the automatic pricelist chain for this product. It reflects tariff configuration, not the current manual sale price." +msgstr "" + +#. module: product_sale_price_from_pricelist +#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.product_pricelist_view_price_rounding +msgid "Sale Price Rounding" +msgstr "" + #. module: product_sale_price_from_pricelist #: model:ir.model.fields,help:product_sale_price_from_pricelist.field_product_pricelist_item__base msgid "" diff --git a/product_sale_price_from_pricelist/models/product_pricelist.py b/product_sale_price_from_pricelist/models/product_pricelist.py index 72400e4..1442d6b 100644 --- a/product_sale_price_from_pricelist/models/product_pricelist.py +++ b/product_sale_price_from_pricelist/models/product_pricelist.py @@ -4,7 +4,7 @@ # import logging -from odoo import api, models +from odoo import api, fields, models # _logger = logging.getLogger(__name__) @@ -12,6 +12,14 @@ from odoo import api, models class ProductPricelist(models.Model): _inherit = "product.pricelist" + price_rounding_step = fields.Float( + string="Sale Price Rounding Step", + digits=(12, 2), + default=0.0, + help="Round the theoretical sale price (after taxes) to the nearest multiple of this value. " + "Set to 0.00 to disable rounding (e.g. 0.05 rounds to the nearest 5 cents).", + ) + def _compute_price_rule(self, products, qty, uom=None, date=False, **kwargs): ProductPricelistItem = self.env["product.pricelist.item"] ProductProduct = self.env["product.product"] diff --git a/product_sale_price_from_pricelist/models/product_template.py b/product_sale_price_from_pricelist/models/product_template.py index ad79bd0..49c10a7 100644 --- a/product_sale_price_from_pricelist/models/product_template.py +++ b/product_sale_price_from_pricelist/models/product_template.py @@ -45,6 +45,51 @@ class ProductTemplate(models.Model): required=True, company_dependent=True, ) + configured_sale_margin = fields.Float( + string="Configured sale margin (%)", + digits="Discount", + compute="_compute_configured_sale_margin", + help="Effective margin configured by the automatic pricelist chain for " + "this product. It reflects tariff configuration, not the current " + "manual sale price.", + ) + + def _compute_configured_sale_margin(self): + pricelist_id = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("product_sale_price_from_pricelist.product_pricelist_automatic") + or False + ) + if not pricelist_id: + for template in self: + template.configured_sale_margin = 0.0 + return + + pricelist = self.env["product.pricelist"].browse(int(pricelist_id)) + for template in self: + template.configured_sale_margin = 0.0 + if ( + not template.product_variant_id + or template.last_purchase_price_compute_type == "manual_update" + or not template.last_purchase_price_received + ): + continue + + # Get the final untaxed price after all chained pricelists are applied. + final_price = template.product_variant_id._get_price( + qty=1, + pricelist=pricelist, + ) + untaxed_price = final_price.get(template.product_variant_id.id, {}).get( + "value" + ) + if untaxed_price is None: + continue + + template.configured_sale_margin = ( + (untaxed_price / template.last_purchase_price_received) - 1.0 + ) * 100.0 def _compute_theoritical_price(self): pricelist_obj = self.env["product.pricelist"] @@ -82,9 +127,10 @@ class ProductTemplate(models.Model): tax_price["taxes"][0]["amount"] + partial_price[template.product_variant_id.id]["value"] ) - # Round to 0.05 - if round(price_with_taxes % 0.05, 2) != 0: - price_with_taxes = round(price_with_taxes * 20) / 20 + # Round to configurable step (defined on the pricelist) + step = pricelist.price_rounding_step + if step: + price_with_taxes = math.ceil(price_with_taxes / step) * step template.write( { diff --git a/product_sale_price_from_pricelist/views/product_view.xml b/product_sale_price_from_pricelist/views/product_view.xml index eca9ce8..9a59924 100644 --- a/product_sale_price_from_pricelist/views/product_view.xml +++ b/product_sale_price_from_pricelist/views/product_view.xml @@ -23,6 +23,7 @@ readonly="1" /> + @@ -40,6 +41,19 @@ + + product.pricelist.view.price.rounding + product.pricelist + + + + + + + + + + product.print.supermarket.res.config.settings.form res.config.settings From f775050ea86ca79679b9a9871b066187db5ca258 Mon Sep 17 00:00:00 2001 From: luis Date: Tue, 7 Apr 2026 11:47:35 +0200 Subject: [PATCH 09/27] purchase_order_product_recommendation_supermarket: add filter products by main supplier --- .../__manifest__.py | 2 +- .../i18n/es.po | 14 ++++++++++++++ ...se_order_product_recommendation_supermarket.pot | 12 ++++++++++++ .../wizards/purchase_order_recommendation.py | 10 ++++++++++ .../wizards/purchase_order_recommendation.xml | 1 + 5 files changed, 38 insertions(+), 1 deletion(-) diff --git a/purchase_order_product_recommendation_supermarket/__manifest__.py b/purchase_order_product_recommendation_supermarket/__manifest__.py index 880934c..5f825e7 100644 --- a/purchase_order_product_recommendation_supermarket/__manifest__.py +++ b/purchase_order_product_recommendation_supermarket/__manifest__.py @@ -8,7 +8,7 @@ "license": "AGPL-3", "author": "Criptomart", "website": "https://criptomart.net", - "depends": ["purchase_order_product_recommendation"], + "depends": ["purchase_order_product_recommendation", "product_main_seller"], "data": [ "security/ir.model.access.csv", "wizards/purchase_order_recommendation.xml", diff --git a/purchase_order_product_recommendation_supermarket/i18n/es.po b/purchase_order_product_recommendation_supermarket/i18n/es.po index 7a1005f..b1ce8f5 100644 --- a/purchase_order_product_recommendation_supermarket/i18n/es.po +++ b/purchase_order_product_recommendation_supermarket/i18n/es.po @@ -532,6 +532,20 @@ msgstr "" "Cantidad de productos comprados durante el periodo (Entre la fecha de inicio" " y fecha final)." +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__filter_by_main_seller +msgid "Only main vendor products" +msgstr "Solo productos del proveedor principal" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__filter_by_main_seller +msgid "" +"If enabled, only products whose main vendor matches the purchase order " +"vendor will be recommended." +msgstr "" +"Si está activado, solo se recomendarán productos cuyo proveedor principal " +"coincida con el proveedor del pedido de compra." + #. module: purchase_order_product_recommendation_supermarket #: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__units_scrapped msgid "Units Scrapped" diff --git a/purchase_order_product_recommendation_supermarket/i18n/purchase_order_product_recommendation_supermarket.pot b/purchase_order_product_recommendation_supermarket/i18n/purchase_order_product_recommendation_supermarket.pot index ed2134e..6e3c7d9 100644 --- a/purchase_order_product_recommendation_supermarket/i18n/purchase_order_product_recommendation_supermarket.pot +++ b/purchase_order_product_recommendation_supermarket/i18n/purchase_order_product_recommendation_supermarket.pot @@ -458,6 +458,18 @@ msgstr "" msgid "Units Received" msgstr "" +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__filter_by_main_seller +msgid "Only main vendor products" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__filter_by_main_seller +msgid "" +"If enabled, only products whose main vendor matches the purchase order " +"vendor will be recommended." +msgstr "" + #. module: purchase_order_product_recommendation_supermarket #: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__units_scrapped msgid "Units Scrapped" diff --git a/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py index b385aed..e22a017 100644 --- a/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py +++ b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py @@ -25,6 +25,11 @@ class PurchaseOrderRecommendationSupermarketWizard(models.TransientModel): default=False, help="If disabled, the previous period (same number of days immediately preceding) is NOT queried. This saves a read_group on stock move lines and skips zero-stock day analysis for that window.", ) + filter_by_main_seller = fields.Boolean( + string="Only main vendor products", + default=False, + help="If enabled, only products whose main vendor matches the purchase order vendor will be recommended.", + ) total_days = fields.Integer( string="Total days", compute="_compute_total_days", @@ -306,6 +311,11 @@ class PurchaseOrderRecommendationSupermarketWizard(models.TransientModel): products = products.filtered( lambda x: x.categ_id.id in self.product_category_ids.ids ) + if self.filter_by_main_seller: + partner = self.order_id.partner_id.commercial_partner_id + products = products.filtered( + lambda x: x.product_tmpl_id.main_seller_id == partner + ) return products diff --git a/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.xml b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.xml index 26089f4..c675687 100644 --- a/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.xml +++ b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.xml @@ -26,6 +26,7 @@ + From 489eefefc707afe8eaaa00e95a8834bc11ce42c2 Mon Sep 17 00:00:00 2001 From: luis Date: Thu, 9 Apr 2026 12:48:23 +0200 Subject: [PATCH 10/27] product_sale_price_from_pricelist: add rounding method to compute theroritical price --- product_sale_price_from_pricelist/README.md | 11 ++++++++ .../__manifest__.py | 2 +- product_sale_price_from_pricelist/i18n/es.po | 25 +++++++++++++++++++ .../product_sale_price_from_pricelist.pot | 25 +++++++++++++++++++ .../models/product_pricelist.py | 14 +++++++++++ .../models/product_template.py | 10 +++++++- .../views/product_view.xml | 1 + 7 files changed, 86 insertions(+), 2 deletions(-) diff --git a/product_sale_price_from_pricelist/README.md b/product_sale_price_from_pricelist/README.md index 4fbebb0..6b5e482 100644 --- a/product_sale_price_from_pricelist/README.md +++ b/product_sale_price_from_pricelist/README.md @@ -12,6 +12,7 @@ Automatically calculate and update product sale prices based on the last purchas - Triple discount - Manual update - **Tax-Aware Pricing**: Automatically includes tax calculations in pricing +- **Configurable Price Rounding**: Round the final theoretical price (after taxes) to a configurable step, with choice of rounding direction - **UoM Conversion**: Handles different purchase and sale units of measure - **Batch Updates**: Update theoretical prices for multiple products - **Product Flags**: Mark products for price updates and track status @@ -23,7 +24,17 @@ Automatically calculate and update product sale prices based on the last purchas - **Last purchase price**: Cost at which the product was last purchased - **Last purchase price calculation type**: Choose how to apply discounts - Mark **Last purchase price updated** when prices need to be reviewed +## Price Rounding +Pricelist can be configured to round the final theoretical price (after taxes) to a fixed step: + +- **Sale Price Rounding Step**: Interval to round to (e.g. `0.05` rounds to the nearest 5 cents). Set to `0.00` to disable. +- **Sale Price Rounding Method**: Direction applied when rounding: + - **Always up (ceil)**: rounds to the next multiple of the step *(default)* + - **Always down (floor)**: rounds to the previous multiple of the step + - **Nearest (round)**: rounds to the closest multiple of the step + +These settings are found on pricelist form under the **Sale Price Rounding** group. ## Price Calculation Types - **Without Discounts**: Uses base purchase price without discounts diff --git a/product_sale_price_from_pricelist/__manifest__.py b/product_sale_price_from_pricelist/__manifest__.py index 66ed993..f65a0da 100644 --- a/product_sale_price_from_pricelist/__manifest__.py +++ b/product_sale_price_from_pricelist/__manifest__.py @@ -1,6 +1,6 @@ { "name": "Product Sale Price from Pricelist", - "version": "16.0.1.0.0", + "version": "16.0.1.1.0", "category": "product", "summary": "Set sale price from pricelist based on last purchase price", "description": """ diff --git a/product_sale_price_from_pricelist/i18n/es.po b/product_sale_price_from_pricelist/i18n/es.po index 002dd2c..b721349 100644 --- a/product_sale_price_from_pricelist/i18n/es.po +++ b/product_sale_price_from_pricelist/i18n/es.po @@ -27,6 +27,31 @@ msgstr "Redondeo del precio de venta" msgid "Round the theoretical sale price (after taxes) to the nearest multiple of this value. Set to 0.00 to disable rounding (e.g. 0.05 rounds to the nearest 5 cents)." msgstr "Redondea el precio de venta teórico (con impuestos) al múltiplo más cercano de este valor. Pon 0,00 para desactivar el redondeo (p. ej. 0,05 redondea al céntimo más cercano de 5)." +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields,field_description:product_sale_price_from_pricelist.field_product_pricelist__price_rounding_method +msgid "Sale Price Rounding Method" +msgstr "Dirección del redondeo" + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields,help:product_sale_price_from_pricelist.field_product_pricelist__price_rounding_method +msgid "Direction to apply when rounding the theoretical sale price to the configured step.\n* Always up: rounds to the next multiple (ceil).\n* Always down: rounds to the previous multiple (floor).\n* Nearest: rounds to the closest multiple (standard round)." +msgstr "Dirección a aplicar al redondear el precio de venta teórico al escalón configurado.\n* Siempre arriba: redondea al siguiente múltiplo (techo).\n* Siempre abajo: redondea al múltiplo anterior (suelo).\n* Más cercano: redondea al múltiplo más próximo (redondeo estándar)." + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields.selection,name:product_sale_price_from_pricelist.selection__product_pricelist__price_rounding_method__up +msgid "Always up (ceil)" +msgstr "Siempre arriba (techo)" + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields.selection,name:product_sale_price_from_pricelist.selection__product_pricelist__price_rounding_method__down +msgid "Always down (floor)" +msgstr "Siempre abajo (suelo)" + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields.selection,name:product_sale_price_from_pricelist.selection__product_pricelist__price_rounding_method__round +msgid "Nearest (round)" +msgstr "Más cercano (redondeo)" + #. module: product_sale_price_from_pricelist #: model:ir.model.fields,field_description:product_sale_price_from_pricelist.field_product_product__configured_sale_margin #: model:ir.model.fields,field_description:product_sale_price_from_pricelist.field_product_template__configured_sale_margin diff --git a/product_sale_price_from_pricelist/i18n/product_sale_price_from_pricelist.pot b/product_sale_price_from_pricelist/i18n/product_sale_price_from_pricelist.pot index b5019fc..95b1bcb 100644 --- a/product_sale_price_from_pricelist/i18n/product_sale_price_from_pricelist.pot +++ b/product_sale_price_from_pricelist/i18n/product_sale_price_from_pricelist.pot @@ -25,6 +25,31 @@ msgstr "" msgid "Round the theoretical sale price (after taxes) to the nearest multiple of this value. Set to 0.00 to disable rounding (e.g. 0.05 rounds to the nearest 5 cents)." msgstr "" +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields,field_description:product_sale_price_from_pricelist.field_product_pricelist__price_rounding_method +msgid "Sale Price Rounding Method" +msgstr "" + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields,help:product_sale_price_from_pricelist.field_product_pricelist__price_rounding_method +msgid "Direction to apply when rounding the theoretical sale price to the configured step.\n* Always up: rounds to the next multiple (ceil).\n* Always down: rounds to the previous multiple (floor).\n* Nearest: rounds to the closest multiple (standard round)." +msgstr "" + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields.selection,name:product_sale_price_from_pricelist.selection__product_pricelist__price_rounding_method__up +msgid "Always up (ceil)" +msgstr "" + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields.selection,name:product_sale_price_from_pricelist.selection__product_pricelist__price_rounding_method__down +msgid "Always down (floor)" +msgstr "" + +#. module: product_sale_price_from_pricelist +#: model:ir.model.fields.selection,name:product_sale_price_from_pricelist.selection__product_pricelist__price_rounding_method__round +msgid "Nearest (round)" +msgstr "" + #. module: product_sale_price_from_pricelist #: model:ir.model.fields,field_description:product_sale_price_from_pricelist.field_product_product__configured_sale_margin #: model:ir.model.fields,field_description:product_sale_price_from_pricelist.field_product_template__configured_sale_margin diff --git a/product_sale_price_from_pricelist/models/product_pricelist.py b/product_sale_price_from_pricelist/models/product_pricelist.py index 1442d6b..2c71e20 100644 --- a/product_sale_price_from_pricelist/models/product_pricelist.py +++ b/product_sale_price_from_pricelist/models/product_pricelist.py @@ -19,6 +19,20 @@ class ProductPricelist(models.Model): help="Round the theoretical sale price (after taxes) to the nearest multiple of this value. " "Set to 0.00 to disable rounding (e.g. 0.05 rounds to the nearest 5 cents).", ) + price_rounding_method = fields.Selection( + [ + ("up", "Always up (ceil)"), + ("down", "Always down (floor)"), + ("round", "Nearest (round)"), + ], + string="Sale Price Rounding Method", + default="round", + required=True, + help="Direction to apply when rounding the theoretical sale price to the configured step.\n" + "* Always up: rounds to the next multiple (ceil).\n" + "* Always down: rounds to the previous multiple (floor).\n" + "* Nearest: rounds to the closest multiple (standard round).", + ) def _compute_price_rule(self, products, qty, uom=None, date=False, **kwargs): ProductPricelistItem = self.env["product.pricelist.item"] diff --git a/product_sale_price_from_pricelist/models/product_template.py b/product_sale_price_from_pricelist/models/product_template.py index 49c10a7..449baf6 100644 --- a/product_sale_price_from_pricelist/models/product_template.py +++ b/product_sale_price_from_pricelist/models/product_template.py @@ -130,7 +130,15 @@ class ProductTemplate(models.Model): # Round to configurable step (defined on the pricelist) step = pricelist.price_rounding_step if step: - price_with_taxes = math.ceil(price_with_taxes / step) * step + method = pricelist.price_rounding_method + if method == "down": + price_with_taxes = ( + math.floor(price_with_taxes / step) * step + ) + elif method == "round": + price_with_taxes = round(price_with_taxes / step) * step + else: # "up" (default) + price_with_taxes = math.ceil(price_with_taxes / step) * step template.write( { diff --git a/product_sale_price_from_pricelist/views/product_view.xml b/product_sale_price_from_pricelist/views/product_view.xml index 9a59924..492db34 100644 --- a/product_sale_price_from_pricelist/views/product_view.xml +++ b/product_sale_price_from_pricelist/views/product_view.xml @@ -49,6 +49,7 @@ + From c76254e3c79cc9ec28e9344770b15b1bd45b92c8 Mon Sep 17 00:00:00 2001 From: luis Date: Mon, 13 Apr 2026 13:53:23 +0200 Subject: [PATCH 11/27] purchase_order_supplierinfo_update_triple_discount: update discounts on write --- .../models/purchase_order_line.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/purchase_order_supplierinfo_update_triple_discount/models/purchase_order_line.py b/purchase_order_supplierinfo_update_triple_discount/models/purchase_order_line.py index d4c685f..12fcbe0 100644 --- a/purchase_order_supplierinfo_update_triple_discount/models/purchase_order_line.py +++ b/purchase_order_supplierinfo_update_triple_discount/models/purchase_order_line.py @@ -20,6 +20,12 @@ class PurchaseOrderLine(models.Model): ), } + def write(self, vals): + res = super().write(vals) + if any(field in vals for field in self._SUPPLIERINFO_DISCOUNT_CONFIG): + self.update_supplierinfo_price() + return res + def _update_supplierinfo(self, seller): """Extend supplierinfo update to optionally sync each discount field.""" self.ensure_one() From 611979ea2e32659e76138eb6959973aa4b263710 Mon Sep 17 00:00:00 2001 From: luis Date: Fri, 24 Apr 2026 10:33:34 +0200 Subject: [PATCH 12/27] pos_hide_partnerlist: hide details (edit-partner-button) button --- pos_hide_partnerlist/__manifest__.py | 1 + .../src/xml/Screens/PartnerListScreen/PartnerLine.xml | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 pos_hide_partnerlist/static/src/xml/Screens/PartnerListScreen/PartnerLine.xml diff --git a/pos_hide_partnerlist/__manifest__.py b/pos_hide_partnerlist/__manifest__.py index 563d576..e359ba1 100644 --- a/pos_hide_partnerlist/__manifest__.py +++ b/pos_hide_partnerlist/__manifest__.py @@ -9,6 +9,7 @@ "assets": { "point_of_sale.assets": [ "pos_hide_partnerlist/static/src/js/HideCustomerList.js", + "pos_hide_partnerlist/static/src/xml/Screens/PartnerListScreen/PartnerLine.xml", ], }, "installable": True, diff --git a/pos_hide_partnerlist/static/src/xml/Screens/PartnerListScreen/PartnerLine.xml b/pos_hide_partnerlist/static/src/xml/Screens/PartnerListScreen/PartnerLine.xml new file mode 100644 index 0000000..bc8fa0f --- /dev/null +++ b/pos_hide_partnerlist/static/src/xml/Screens/PartnerListScreen/PartnerLine.xml @@ -0,0 +1,10 @@ + + + + + + false + + + + From 7f43afafdf7efbc51d589d9cf536e28d555e52bd Mon Sep 17 00:00:00 2001 From: luis Date: Fri, 24 Apr 2026 10:36:27 +0200 Subject: [PATCH 13/27] remove pos_hide_partner_info already in OCA --- pos_hide_partner_info/README.rst | 78 ---- pos_hide_partner_info/__init__.py | 0 pos_hide_partner_info/__manifest__.py | 28 -- pos_hide_partner_info/i18n/it.po | 36 -- .../i18n/pos_hide_partner_info.pot | 34 -- pos_hide_partner_info/readme/CONTRIBUTORS.rst | 2 - pos_hide_partner_info/readme/DESCRIPTION.rst | 1 - .../static/description/icon.png | Bin 9455 -> 0 bytes .../static/description/index.html | 422 ------------------ .../PartnerListScreen/PartnerDetailsEdit.xml | 55 --- .../Screens/PartnerListScreen/PartnerLine.xml | 19 - .../PartnerListScreen/PartnerListScreen.xml | 16 - 12 files changed, 691 deletions(-) delete mode 100644 pos_hide_partner_info/README.rst delete mode 100644 pos_hide_partner_info/__init__.py delete mode 100644 pos_hide_partner_info/__manifest__.py delete mode 100644 pos_hide_partner_info/i18n/it.po delete mode 100644 pos_hide_partner_info/i18n/pos_hide_partner_info.pot delete mode 100644 pos_hide_partner_info/readme/CONTRIBUTORS.rst delete mode 100644 pos_hide_partner_info/readme/DESCRIPTION.rst delete mode 100644 pos_hide_partner_info/static/description/icon.png delete mode 100644 pos_hide_partner_info/static/description/index.html delete mode 100644 pos_hide_partner_info/static/src/xml/Screens/PartnerListScreen/PartnerDetailsEdit.xml delete mode 100644 pos_hide_partner_info/static/src/xml/Screens/PartnerListScreen/PartnerLine.xml delete mode 100644 pos_hide_partner_info/static/src/xml/Screens/PartnerListScreen/PartnerListScreen.xml diff --git a/pos_hide_partner_info/README.rst b/pos_hide_partner_info/README.rst deleted file mode 100644 index 6e635b7..0000000 --- a/pos_hide_partner_info/README.rst +++ /dev/null @@ -1,78 +0,0 @@ -======================= -POS - Hide Partner Info -======================= - -.. - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! This file is generated by oca-gen-addon-readme !! - !! changes will be overwritten. !! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:f9665df958eb28056a581851f1734aee1e9d0d935dc56e0f19e99cd0caf57a66 - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -.. |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/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%2Fpos-lightgray.png?logo=github - :target: https://github.com/OCA/pos/tree/16.0/pos_hide_partner_info - :alt: OCA/pos -.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/pos-16-0/pos-16-0-pos_hide_partner_info - :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/pos&target_branch=16.0 - :alt: Try me on Runboat - -|badge1| |badge2| |badge3| |badge4| |badge5| - -This module hides the phone and address fields in PoS customer list. - -**Table of contents** - -.. contents:: - :local: - -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 -~~~~~~~ - -* BEES coop - Cellule IT -* Coop IT Easy SC - -Contributors -~~~~~~~~~~~~ - -* BEES coop - Cellule IT -* Coop IT Easy SC - -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/pos `_ project on GitHub. - -You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/pos_hide_partner_info/__init__.py b/pos_hide_partner_info/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pos_hide_partner_info/__manifest__.py b/pos_hide_partner_info/__manifest__.py deleted file mode 100644 index 67dd640..0000000 --- a/pos_hide_partner_info/__manifest__.py +++ /dev/null @@ -1,28 +0,0 @@ -# SPDX-FileCopyrightText: 2017 - 2020 BEES coop SCRLfs -# SPDX-FileContributor: Elouan Lebars -# SPDX-FileContributor: Rémy Taymans -# SPDX-FileContributor: Vincent Van Rossem -# SPDX-FileContributor: Elise Dupont -# SPDX-FileContributor: Thibault François -# SPDX-FileContributor: Grégoire Leeuwerck -# SPDX-FileContributor: Houssine Bakkali -# -# SPDX-License-Identifier: AGPL-3.0-or-later - -{ - "name": "POS - Hide Partner Info", - "summary": "Hide phone and address fields in PoS customer list", - "version": "16.0.1.0.0", - "category": "Point Of Sale", - "website": "https://github.com/OCA/pos", - "author": "BEES coop - Cellule IT, Coop IT Easy SC, Odoo Community Association (OCA)", - "license": "AGPL-3", - "depends": [ - "point_of_sale", - ], - "assets": { - "point_of_sale.assets": [ - "pos_hide_partner_info/static/src/xml/**/*.xml", - ], - }, -} diff --git a/pos_hide_partner_info/i18n/it.po b/pos_hide_partner_info/i18n/it.po deleted file mode 100644 index a6da3f5..0000000 --- a/pos_hide_partner_info/i18n/it.po +++ /dev/null @@ -1,36 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * pos_hide_partner_info -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 12.0\n" -"Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2023-03-23 12:22+0000\n" -"Last-Translator: mymage \n" -"Language-Team: none\n" -"Language: it\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: \n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 4.14.1\n" - -#. module: pos_hide_partner_info -#. openerp-web -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:6 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:9 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:15 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:18 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:24 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:27 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:30 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:35 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:38 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:41 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:44 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:47 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:50 -#, python-format -msgid "oe_hidden" -msgstr "oe_hidden" diff --git a/pos_hide_partner_info/i18n/pos_hide_partner_info.pot b/pos_hide_partner_info/i18n/pos_hide_partner_info.pot deleted file mode 100644 index 665de0d..0000000 --- a/pos_hide_partner_info/i18n/pos_hide_partner_info.pot +++ /dev/null @@ -1,34 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * pos_hide_partner_info -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 12.0\n" -"Report-Msgid-Bugs-To: \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: pos_hide_partner_info -#. openerp-web -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:6 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:9 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:15 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:18 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:24 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:27 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:30 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:35 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:38 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:41 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:44 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:47 -#: code:addons/pos_hide_partner_info/static/src/xml/templates.xml:50 -#, python-format -msgid "oe_hidden" -msgstr "" - diff --git a/pos_hide_partner_info/readme/CONTRIBUTORS.rst b/pos_hide_partner_info/readme/CONTRIBUTORS.rst deleted file mode 100644 index 5cf09c2..0000000 --- a/pos_hide_partner_info/readme/CONTRIBUTORS.rst +++ /dev/null @@ -1,2 +0,0 @@ -* BEES coop - Cellule IT -* Coop IT Easy SC diff --git a/pos_hide_partner_info/readme/DESCRIPTION.rst b/pos_hide_partner_info/readme/DESCRIPTION.rst deleted file mode 100644 index 0b1ea0d..0000000 --- a/pos_hide_partner_info/readme/DESCRIPTION.rst +++ /dev/null @@ -1 +0,0 @@ -This module hides the phone and address fields in PoS customer list. diff --git a/pos_hide_partner_info/static/description/icon.png b/pos_hide_partner_info/static/description/icon.png deleted file mode 100644 index 3a0328b516c4980e8e44cdb63fd945757ddd132d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I diff --git a/pos_hide_partner_info/static/description/index.html b/pos_hide_partner_info/static/description/index.html deleted file mode 100644 index 3ead684..0000000 --- a/pos_hide_partner_info/static/description/index.html +++ /dev/null @@ -1,422 +0,0 @@ - - - - - -POS - Hide Partner Info - - - -
-

POS - Hide Partner Info

- - -

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

-

This module hides the phone and address fields in PoS customer list.

-

Table of contents

- -
-

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

-
    -
  • BEES coop - Cellule IT
  • -
  • Coop IT Easy SC
  • -
-
-
-

Contributors

-
    -
  • BEES coop - Cellule IT
  • -
  • Coop IT Easy SC
  • -
-
-
-

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/pos project on GitHub.

-

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

-
-
-
- - diff --git a/pos_hide_partner_info/static/src/xml/Screens/PartnerListScreen/PartnerDetailsEdit.xml b/pos_hide_partner_info/static/src/xml/Screens/PartnerListScreen/PartnerDetailsEdit.xml deleted file mode 100644 index 7250376..0000000 --- a/pos_hide_partner_info/static/src/xml/Screens/PartnerListScreen/PartnerDetailsEdit.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - - diff --git a/pos_hide_partner_info/static/src/xml/Screens/PartnerListScreen/PartnerLine.xml b/pos_hide_partner_info/static/src/xml/Screens/PartnerListScreen/PartnerLine.xml deleted file mode 100644 index 7197871..0000000 --- a/pos_hide_partner_info/static/src/xml/Screens/PartnerListScreen/PartnerLine.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - false - - - false - - - false - - - - diff --git a/pos_hide_partner_info/static/src/xml/Screens/PartnerListScreen/PartnerListScreen.xml b/pos_hide_partner_info/static/src/xml/Screens/PartnerListScreen/PartnerListScreen.xml deleted file mode 100644 index 1a6b679..0000000 --- a/pos_hide_partner_info/static/src/xml/Screens/PartnerListScreen/PartnerListScreen.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - false - - - false - - - false - - - - From 9b25650118255dba1b7337db0a6d59e9ab7b9ce3 Mon Sep 17 00:00:00 2001 From: luis Date: Fri, 24 Apr 2026 10:39:23 +0200 Subject: [PATCH 14/27] remove pos_require_product_quantity already in OCA --- pos_require_product_quantity/README.rst | 79 ---- pos_require_product_quantity/__init__.py | 1 - pos_require_product_quantity/__manifest__.py | 28 -- pos_require_product_quantity/i18n/es.po | 50 --- .../i18n/pos_require_product_quantity.pot | 60 --- pos_require_product_quantity/i18n/pt.po | 65 --- .../models/__init__.py | 2 - .../models/pos_config.py | 14 - .../models/res_config_setting.py | 11 - .../readme/CONTRIBUTORS.rst | 3 - .../readme/DESCRIPTION.rst | 5 - .../static/description/icon.png | Bin 9455 -> 0 bytes .../static/description/index.html | 424 ------------------ .../static/src/js/screens.js | 44 -- pos_require_product_quantity/views/assets.xml | 15 - .../views/pos_config.xml | 25 -- 16 files changed, 826 deletions(-) delete mode 100644 pos_require_product_quantity/README.rst delete mode 100644 pos_require_product_quantity/__init__.py delete mode 100644 pos_require_product_quantity/__manifest__.py delete mode 100644 pos_require_product_quantity/i18n/es.po delete mode 100644 pos_require_product_quantity/i18n/pos_require_product_quantity.pot delete mode 100644 pos_require_product_quantity/i18n/pt.po delete mode 100644 pos_require_product_quantity/models/__init__.py delete mode 100644 pos_require_product_quantity/models/pos_config.py delete mode 100644 pos_require_product_quantity/models/res_config_setting.py delete mode 100644 pos_require_product_quantity/readme/CONTRIBUTORS.rst delete mode 100644 pos_require_product_quantity/readme/DESCRIPTION.rst delete mode 100644 pos_require_product_quantity/static/description/icon.png delete mode 100644 pos_require_product_quantity/static/description/index.html delete mode 100644 pos_require_product_quantity/static/src/js/screens.js delete mode 100644 pos_require_product_quantity/views/assets.xml delete mode 100644 pos_require_product_quantity/views/pos_config.xml diff --git a/pos_require_product_quantity/README.rst b/pos_require_product_quantity/README.rst deleted file mode 100644 index fa56a67..0000000 --- a/pos_require_product_quantity/README.rst +++ /dev/null @@ -1,79 +0,0 @@ -=============================== -Require Product Quantity in POS -=============================== - -.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! This file is generated by oca-gen-addon-readme !! - !! changes will be overwritten. !! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -.. |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/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%2Fpos-lightgray.png?logo=github - :target: https://github.com/OCA/pos/tree/14.0/pos_require_product_quantity - :alt: OCA/pos -.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/pos-14-0/pos-14-0-pos_require_product_quantity - :alt: Translate me on Weblate -.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png - :target: https://runbot.odoo-community.org/runbot/184/14.0 - :alt: Try me on Runbot - -|badge1| |badge2| |badge3| |badge4| |badge5| - -This module forbids adding empty order lines on POS orders. - -When clicking on the "Payment" button in the Point of Sale, -a popup is shown if product quantity for one or more order -lines is set to 0. - -**Table of contents** - -.. contents:: - :local: - -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 smashing it by providing a detailed and welcomed -`feedback `_. - -Do not contact contributors directly about support or help with technical issues. - -Credits -======= - -Authors -~~~~~~~ - -* Coop IT Easy SCRLfs - -Contributors -~~~~~~~~~~~~ - -* Robin Keunen -* Iván Todorovich -* Foram Shah - -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/pos `_ project on GitHub. - -You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/pos_require_product_quantity/__init__.py b/pos_require_product_quantity/__init__.py deleted file mode 100644 index 0650744..0000000 --- a/pos_require_product_quantity/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import models diff --git a/pos_require_product_quantity/__manifest__.py b/pos_require_product_quantity/__manifest__.py deleted file mode 100644 index e59b799..0000000 --- a/pos_require_product_quantity/__manifest__.py +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright 2019-2020 Coop IT Easy SCRLfs -# @author Robin Keunen -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -{ - "name": "Require Product Quantity in POS", - "version": "16.0.1.0.0", - "author": "Coop IT Easy SCRLfs, Odoo Community Association (OCA)", - "website": "https://github.com/OCA/pos", - "license": "AGPL-3", - "category": "Point of Sale", - "summary": """ - A popup is shown if product quantity is set to 0 for one or more order - lines when clicking on "Payment" button. - """, - "depends": [ - "point_of_sale", - ], - "data": [ - "views/pos_config.xml", - "views/assets.xml", - ], - "assets": { - "point_of_sale.assets": [ - "pos_require_product_quantity/static/src/js/screens.js" - ], - }, - "installable": True, -} diff --git a/pos_require_product_quantity/i18n/es.po b/pos_require_product_quantity/i18n/es.po deleted file mode 100644 index a1a694f..0000000 --- a/pos_require_product_quantity/i18n/es.po +++ /dev/null @@ -1,50 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * pos_require_product_quantity -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 12.0\n" -"Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2020-07-27 11:19+0000\n" -"Last-Translator: Daniel Martinez Vila \n" -"Language-Team: none\n" -"Language: es\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: \n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 3.10\n" - -#. module: pos_require_product_quantity -#. openerp-web -#: code:addons/pos_require_product_quantity/static/src/js/screens.js:30 -#, python-format -msgid "Missing quantities" -msgstr "Cantidades faltantes" - -#. module: pos_require_product_quantity -#. openerp-web -#: code:addons/pos_require_product_quantity/static/src/js/screens.js:32 -#, python-format -msgid "No quantity set for products:" -msgstr "No hay cantidad establecida para productos:" - -#. module: pos_require_product_quantity -#: model:ir.model,name:pos_require_product_quantity.model_pos_config -msgid "Point of Sale Configuration" -msgstr "Configuración del punto de venta" - -#. module: pos_require_product_quantity -#: model:ir.model.fields,field_description:pos_require_product_quantity.field_pos_config__require_product_quantity -msgid "Require product quantity in POS" -msgstr "Requerir cantidad de producto en PdV" - -#. module: pos_require_product_quantity -#: model_terms:ir.ui.view,arch_db:pos_require_product_quantity.view_pos_config_form -msgid "" -"Show pop-up when product quantity is set to 0 for one or more order lines " -"when clicking on \"Payment\" button." -msgstr "" -"Mostrar ventana emergente cuando la cantidad del producto se establece en 0 " -"para una o más líneas de pedido al hacer clic en el botón \"Pago\"." diff --git a/pos_require_product_quantity/i18n/pos_require_product_quantity.pot b/pos_require_product_quantity/i18n/pos_require_product_quantity.pot deleted file mode 100644 index 64a983f..0000000 --- a/pos_require_product_quantity/i18n/pos_require_product_quantity.pot +++ /dev/null @@ -1,60 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * pos_require_product_quantity -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 14.0\n" -"Report-Msgid-Bugs-To: \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: pos_require_product_quantity -#: model:ir.model.fields,field_description:pos_require_product_quantity.field_pos_config__display_name -msgid "Display Name" -msgstr "" - -#. module: pos_require_product_quantity -#: model:ir.model.fields,field_description:pos_require_product_quantity.field_pos_config__id -msgid "ID" -msgstr "" - -#. module: pos_require_product_quantity -#: model:ir.model.fields,field_description:pos_require_product_quantity.field_pos_config____last_update -msgid "Last Modified on" -msgstr "" - -#. module: pos_require_product_quantity -#. openerp-web -#: code:addons/pos_require_product_quantity/static/src/js/screens.js:0 -#, python-format -msgid "Missing quantities" -msgstr "" - -#. module: pos_require_product_quantity -#. openerp-web -#: code:addons/pos_require_product_quantity/static/src/js/screens.js:0 -#, python-format -msgid "No quantity set for products:" -msgstr "" - -#. module: pos_require_product_quantity -#: model:ir.model,name:pos_require_product_quantity.model_pos_config -msgid "Point of Sale Configuration" -msgstr "" - -#. module: pos_require_product_quantity -#: model:ir.model.fields,field_description:pos_require_product_quantity.field_pos_config__require_product_quantity -msgid "Require product quantity in POS" -msgstr "" - -#. module: pos_require_product_quantity -#: model_terms:ir.ui.view,arch_db:pos_require_product_quantity.pos_config_view_form -msgid "" -"Show pop-up when product quantity is set to 0 for one or more order lines " -"when clicking on \"Payment\" button." -msgstr "" diff --git a/pos_require_product_quantity/i18n/pt.po b/pos_require_product_quantity/i18n/pt.po deleted file mode 100644 index 1a80372..0000000 --- a/pos_require_product_quantity/i18n/pt.po +++ /dev/null @@ -1,65 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * pos_require_product_quantity -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 14.0\n" -"Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2022-02-01 12:33+0000\n" -"Last-Translator: Pedro Castro Silva \n" -"Language-Team: none\n" -"Language: pt\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: \n" -"Plural-Forms: nplurals=2; plural=n > 1;\n" -"X-Generator: Weblate 4.3.2\n" - -#. module: pos_require_product_quantity -#: model:ir.model.fields,field_description:pos_require_product_quantity.field_pos_config__display_name -msgid "Display Name" -msgstr "Nome a Exibir" - -#. module: pos_require_product_quantity -#: model:ir.model.fields,field_description:pos_require_product_quantity.field_pos_config__id -msgid "ID" -msgstr "ID" - -#. module: pos_require_product_quantity -#: model:ir.model.fields,field_description:pos_require_product_quantity.field_pos_config____last_update -msgid "Last Modified on" -msgstr "Última Modificação em" - -#. module: pos_require_product_quantity -#. openerp-web -#: code:addons/pos_require_product_quantity/static/src/js/screens.js:0 -#, python-format -msgid "Missing quantities" -msgstr "Quantidades em falta" - -#. module: pos_require_product_quantity -#. openerp-web -#: code:addons/pos_require_product_quantity/static/src/js/screens.js:0 -#, python-format -msgid "No quantity set for products:" -msgstr "Não foi definida quantidade para os produtos:" - -#. module: pos_require_product_quantity -#: model:ir.model,name:pos_require_product_quantity.model_pos_config -msgid "Point of Sale Configuration" -msgstr "Configuração do Ponto de Venda" - -#. module: pos_require_product_quantity -#: model:ir.model.fields,field_description:pos_require_product_quantity.field_pos_config__require_product_quantity -msgid "Require product quantity in POS" -msgstr "Requerer quantidade no POS" - -#. module: pos_require_product_quantity -#: model_terms:ir.ui.view,arch_db:pos_require_product_quantity.pos_config_view_form -msgid "" -"Show pop-up when product quantity is set to 0 for one or more order lines " -"when clicking on \"Payment\" button." -msgstr "" -"Exibir pop-up quando a quantidade do produto for 0 em pelo menos uma linha " -"ao clicar no botão \"Pagamento\"." diff --git a/pos_require_product_quantity/models/__init__.py b/pos_require_product_quantity/models/__init__.py deleted file mode 100644 index 53fc156..0000000 --- a/pos_require_product_quantity/models/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from . import pos_config -from . import res_config_setting diff --git a/pos_require_product_quantity/models/pos_config.py b/pos_require_product_quantity/models/pos_config.py deleted file mode 100644 index 0e5c4cc..0000000 --- a/pos_require_product_quantity/models/pos_config.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright 2019-2020 Coop IT Easy SCRLfs -# @author Robin Keunen -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - -from odoo import fields, models - - -class PosConfig(models.Model): - _inherit = "pos.config" - - require_product_quantity = fields.Boolean( - string="Require product quantity in POS", - default=False, - ) diff --git a/pos_require_product_quantity/models/res_config_setting.py b/pos_require_product_quantity/models/res_config_setting.py deleted file mode 100644 index 83d4e2e..0000000 --- a/pos_require_product_quantity/models/res_config_setting.py +++ /dev/null @@ -1,11 +0,0 @@ -from odoo import api, fields, models - - -class ResConfigSettings(models.TransientModel): - _inherit = "res.config.settings" - - require_product_quantity = fields.Boolean( - related="pos_config_id.require_product_quantity", - readonly=False, - help="Require product quantity in POS", - ) diff --git a/pos_require_product_quantity/readme/CONTRIBUTORS.rst b/pos_require_product_quantity/readme/CONTRIBUTORS.rst deleted file mode 100644 index b0ec353..0000000 --- a/pos_require_product_quantity/readme/CONTRIBUTORS.rst +++ /dev/null @@ -1,3 +0,0 @@ -* Robin Keunen -* Iván Todorovich -* Foram Shah diff --git a/pos_require_product_quantity/readme/DESCRIPTION.rst b/pos_require_product_quantity/readme/DESCRIPTION.rst deleted file mode 100644 index cc4c84a..0000000 --- a/pos_require_product_quantity/readme/DESCRIPTION.rst +++ /dev/null @@ -1,5 +0,0 @@ -This module forbids adding empty order lines on POS orders. - -When clicking on the "Payment" button in the Point of Sale, -a popup is shown if product quantity for one or more order -lines is set to 0. diff --git a/pos_require_product_quantity/static/description/icon.png b/pos_require_product_quantity/static/description/icon.png deleted file mode 100644 index 3a0328b516c4980e8e44cdb63fd945757ddd132d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9455 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 z z?n?eq1XP>p-IM$Z^C;2L3itnbJZAip*Zo0aw2bs8@(s^~*8T9go!%dHcAz2lM;`yp zD=7&xjFV$S&5uDaiScyD?B-i1ze`+CoRtz`Wn+Zl&#s4&}MO{@N!ufrzjG$B79)Y2d3tBk&)TxUTw@QS0TEL_?njX|@vq?Uz(nBFK5Pq7*xj#u*R&i|?7+6# z+|r_n#SW&LXhtheZdah{ZVoqwyT{D>MC3nkFF#N)xLi{p7J1jXlmVeb;cP5?e(=f# zuT7fvjSbjS781v?7{)-X3*?>tq?)Yd)~|1{BDS(pqC zC}~H#WXlkUW*H5CDOo<)#x7%RY)A;ShGhI5s*#cRDA8YgqG(HeKDx+#(ZQ?386dv! zlXCO)w91~Vw4AmOcATuV653fa9R$fyK8ul%rG z-wfS zihugoZyr38Im?Zuh6@RcF~t1anQu7>#lPpb#}4cOA!EM11`%f*07RqOVkmX{p~KJ9 z^zP;K#|)$`^Rb{rnHGH{~>1(fawV0*Z#)}M`m8-?ZJV<+e}s9wE# z)l&az?w^5{)`S(%MRzxdNqrs1n*-=jS^_jqE*5XDrA0+VE`5^*p3CuM<&dZEeCjoz zR;uu_H9ZPZV|fQq`Cyw4nscrVwi!fE6ciMmX$!_hN7uF;jjKG)d2@aC4ropY)8etW=xJvni)8eHi`H$%#zn^WJ5NLc-rqk|u&&4Z6fD_m&JfSI1Bvb?b<*n&sfl0^t z=HnmRl`XrFvMKB%9}>PaA`m-fK6a0(8=qPkWS5bb4=v?XcWi&hRY?O5HdulRi4?fN zlsJ*N-0Qw+Yic@s0(2uy%F@ib;GjXt01Fmx5XbRo6+n|pP(&nodMoap^z{~q ziEeaUT@Mxe3vJSfI6?uLND(CNr=#^W<1b}jzW58bIfyWTDle$mmS(|x-0|2UlX+9k zQ^EX7Nw}?EzVoBfT(-LT|=9N@^hcn-_p&sqG z&*oVs2JSU+N4ZD`FhCAWaS;>|wH2G*Id|?pa#@>tyxX`+4HyIArWDvVrX)2WAOQff z0qyHu&-S@i^MS-+j--!pr4fPBj~_8({~e1bfcl0wI1kaoN>mJL6KUPQm5N7lB(ui1 zE-o%kq)&djzWJ}ob<-GfDlkB;F31j-VHKvQUGQ3sp`CwyGJk_i!y^sD0fqC@$9|jO zOqN!r!8-p==F@ZVP=U$qSpY(gQ0)59P1&t@y?5rvg<}E+GB}26NYPp4f2YFQrQtot5mn3wu_qprZ=>Ig-$ zbW26Ws~IgY>}^5w`vTB(G`PTZaDiGBo5o(tp)qli|NeV( z@H_=R8V39rt5J5YB2Ky?4eJJ#b`_iBe2ot~6%7mLt5t8Vwi^Jy7|jWXqa3amOIoRb zOr}WVFP--DsS`1WpN%~)t3R!arKF^Q$e12KEqU36AWwnCBICpH4XCsfnyrHr>$I$4 z!DpKX$OKLWarN7nv@!uIA+~RNO)l$$w}p(;b>mx8pwYvu;dD_unryX_NhT8*Tj>BTrTTL&!?O+%Rv;b?B??gSzdp?6Uug9{ zd@V08Z$BdI?fpoCS$)t4mg4rT8Q_I}h`0d-vYZ^|dOB*Q^S|xqTV*vIg?@fVFSmMpaw0qtTRbx} z({Pg?#{2`sc9)M5N$*N|4;^t$+QP?#mov zGVC@I*lBVrOU-%2y!7%)fAKjpEFsgQc4{amtiHb95KQEwvf<(3T<9-Zm$xIew#P22 zc2Ix|App^>v6(3L_MCU0d3W##AB0M~3D00EWoKZqsJYT(#@w$Y_H7G22M~ApVFTRHMI_3be)Lkn#0F*V8Pq zc}`Cjy$bE;FJ6H7p=0y#R>`}-m4(0F>%@P|?7fx{=R^uFdISRnZ2W_xQhD{YuR3t< z{6yxu=4~JkeA;|(J6_nv#>Nvs&FuLA&PW^he@t(UwFFE8)|a!R{`E`K`i^ZnyE4$k z;(749Ix|oi$c3QbEJ3b~D_kQsPz~fIUKym($a_7dJ?o+40*OLl^{=&oq$<#Q(yyrp z{J-FAniyAw9tPbe&IhQ|a`DqFTVQGQ&Gq3!C2==4x{6EJwiPZ8zub-iXoUtkJiG{} zPaR&}_fn8_z~(=;5lD-aPWD3z8PZS@AaUiomF!G8I}Mf>e~0g#BelA-5#`cj;O5>N Xviia!U7SGha1wx#SCgwmn*{w2TRX*I diff --git a/pos_require_product_quantity/static/description/index.html b/pos_require_product_quantity/static/description/index.html deleted file mode 100644 index cc781a1..0000000 --- a/pos_require_product_quantity/static/description/index.html +++ /dev/null @@ -1,424 +0,0 @@ - - - - - - -Require Product Quantity in POS - - - -
-

Require Product Quantity in POS

- - -

Beta License: AGPL-3 OCA/pos Translate me on Weblate Try me on Runbot

-

This module forbids adding empty order lines on POS orders.

-

When clicking on the “Payment” button in the Point of Sale, -a popup is shown if product quantity for one or more order -lines is set to 0.

-

Table of contents

- -
-

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 smashing it by providing a detailed and welcomed -feedback.

-

Do not contact contributors directly about support or help with technical issues.

-
-
-

Credits

-
-

Authors

-
    -
  • Coop IT Easy SCRLfs
  • -
-
-
-

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.

-

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

-

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

-
-
-
- - diff --git a/pos_require_product_quantity/static/src/js/screens.js b/pos_require_product_quantity/static/src/js/screens.js deleted file mode 100644 index a38b898..0000000 --- a/pos_require_product_quantity/static/src/js/screens.js +++ /dev/null @@ -1,44 +0,0 @@ -/* - Copyright 2019 Coop IT Easy SCRLfs - Robin Keunen - License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -*/ - -odoo.define("pos_require_product_quantity.screens", function (require) { - "use strict"; - - const ProductScreen = require("point_of_sale.ProductScreen"); - const Registries = require("point_of_sale.Registries"); - const {Gui} = require("point_of_sale.Gui"); - var core = require("web.core"); - var _t = core._t; - - const CustomProductScreen = (ProductScreen) => - class extends ProductScreen { - _onClickPay() { - const result = super._onClickPay(...arguments); - var lines_without_qty = _.filter( - this.env.pos.get_order().get_orderlines(), - function (line) { - return line.quantity === 0; - } - ); - if (lines_without_qty.length > 0) { - this.showScreen("ProductScreen"); - Gui.showPopup("ConfirmPopup", { - title: _t("Missing quantities"), - body: - _t("No quantity set for products:") + - "\n" + - _.map(lines_without_qty, function (line) { - return " - " + line.product.display_name; - }).join("\n"), - }); - } - return result; - } - }; - - Registries.Component.extend(ProductScreen, CustomProductScreen); - return ProductScreen; -}); diff --git a/pos_require_product_quantity/views/assets.xml b/pos_require_product_quantity/views/assets.xml deleted file mode 100644 index f802971..0000000 --- a/pos_require_product_quantity/views/assets.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/pos_require_product_quantity/views/pos_config.xml b/pos_require_product_quantity/views/pos_config.xml deleted file mode 100644 index 51ad34a..0000000 --- a/pos_require_product_quantity/views/pos_config.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - pos.config.form.view.inherit - res.config.settings - - - -
-
- -
-
-
-
-
-
-
- -
From b7e8f8967c29ccb863a602ce00ead5cf7ec4a3b3 Mon Sep 17 00:00:00 2001 From: luis Date: Fri, 24 Apr 2026 13:34:06 +0200 Subject: [PATCH 15/27] add pos_cashdro_refund,pos_cashdro_allow_manual --- pos_cashdro_allow_manual/__init__.py | 3 + pos_cashdro_allow_manual/__manifest__.py | 23 ++++ pos_cashdro_allow_manual/models/__init__.py | 4 + .../models/pos_payment_method.py | 18 +++ .../models/pos_session.py | 12 ++ .../static/src/js/PaymentScreen.esm.js | 45 +++++++ .../src/xml/PaymentScreenPaymentLines.xml | 64 ++++++++++ .../views/pos_payment_method_views.xml | 18 +++ pos_cashdro_refund/__init__.py | 2 + pos_cashdro_refund/__manifest__.py | 21 ++++ .../static/src/js/PasswordInputPopup.js | 38 ++++++ .../src/js/payment_cashdro_refund.esm.js | 117 ++++++++++++++++++ .../static/src/xml/PasswordInputPopup.xml | 33 +++++ .../static/src/js/cashdro_rounding.esm.js | 11 ++ 14 files changed, 409 insertions(+) create mode 100644 pos_cashdro_allow_manual/__init__.py create mode 100644 pos_cashdro_allow_manual/__manifest__.py create mode 100644 pos_cashdro_allow_manual/models/__init__.py create mode 100644 pos_cashdro_allow_manual/models/pos_payment_method.py create mode 100644 pos_cashdro_allow_manual/models/pos_session.py create mode 100644 pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js create mode 100644 pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml create mode 100644 pos_cashdro_allow_manual/views/pos_payment_method_views.xml create mode 100644 pos_cashdro_refund/__init__.py create mode 100644 pos_cashdro_refund/__manifest__.py create mode 100644 pos_cashdro_refund/static/src/js/PasswordInputPopup.js create mode 100644 pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js create mode 100644 pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml diff --git a/pos_cashdro_allow_manual/__init__.py b/pos_cashdro_allow_manual/__init__.py new file mode 100644 index 0000000..9897421 --- /dev/null +++ b/pos_cashdro_allow_manual/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import models diff --git a/pos_cashdro_allow_manual/__manifest__.py b/pos_cashdro_allow_manual/__manifest__.py new file mode 100644 index 0000000..c866887 --- /dev/null +++ b/pos_cashdro_allow_manual/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "POS CashDro Allow Manual Amount", + "summary": "Permite introducir manualmente el importe en pagos CashDro", + "version": "16.0.1.0.0", + "category": "Point of Sale", + "license": "AGPL-3", + "author": "Criptomart", + "depends": [ + "pos_payment_method_cashdro", + ], + "data": [ + "views/pos_payment_method_views.xml", + ], + "assets": { + "point_of_sale.assets": [ + "pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js", + "pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml", + ], + }, + "installable": True, +} diff --git a/pos_cashdro_allow_manual/models/__init__.py b/pos_cashdro_allow_manual/models/__init__.py new file mode 100644 index 0000000..5d0027b --- /dev/null +++ b/pos_cashdro_allow_manual/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import pos_payment_method +from . import pos_session diff --git a/pos_cashdro_allow_manual/models/pos_payment_method.py b/pos_cashdro_allow_manual/models/pos_payment_method.py new file mode 100644 index 0000000..ead4aa4 --- /dev/null +++ b/pos_cashdro_allow_manual/models/pos_payment_method.py @@ -0,0 +1,18 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class PosPaymentMethod(models.Model): + _inherit = "pos.payment.method" + + cashdro_allow_manual_amount = fields.Boolean( + string="Permitir importe manual", + help=( + "Cuando está activado, aparece un botón 'Manual' en la pantalla de " + "pago del TPV que permite confirmar el importe de la línea sin pasar " + "por la máquina CashDro. Útil ante fallos de comunicación, " + "devoluciones u otras situaciones de emergencia." + ), + default=True, + ) diff --git a/pos_cashdro_allow_manual/models/pos_session.py b/pos_cashdro_allow_manual/models/pos_session.py new file mode 100644 index 0000000..1a0bf31 --- /dev/null +++ b/pos_cashdro_allow_manual/models/pos_session.py @@ -0,0 +1,12 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import models + + +class PosSession(models.Model): + _inherit = "pos.session" + + def _loader_params_pos_payment_method(self): + result = super()._loader_params_pos_payment_method() + result["search_params"]["fields"].append("cashdro_allow_manual_amount") + return result diff --git a/pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js b/pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js new file mode 100644 index 0000000..6a9df37 --- /dev/null +++ b/pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js @@ -0,0 +1,45 @@ +/** @odoo-module **/ +// Copyright 2026 Criptomart +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import {Component} from "point_of_sale.Registries"; +import PaymentScreen from "point_of_sale.PaymentScreen"; +import {useListener} from "@web/core/utils/hooks"; + +const CashdroAllowManualPaymentScreen = (OriginalPaymentScreen) => + class extends OriginalPaymentScreen { + setup() { + super.setup(); + useListener("cashdro-send-manual", this._cashdroSendManual); + } + + /** + * Mark the Cashdro payment line as done using the amount that is already + * set on the line (entered manually via the numpad), without contacting + * the CashDro machine. + * + * Sets `cashdro_manual_done = true` on the line so the delete button + * remains visible even in `done` state, letting the cashier undo the + * operation in case of a mistake. + * + * Refuses to proceed when the amount is 0 to avoid locking the POS with + * an undeletable zero-amount payment line. + */ + async _cashdroSendManual({detail: line}) { + if (!line.get_amount()) { + this.showPopup("ErrorPopup", { + title: this.env._t("Importe no válido"), + body: this.env._t( + "Introduzca el importe en el teclado numérico antes de confirmar manualmente el pago." + ), + }); + return; + } + // Flag this line as manually confirmed so the template keeps + // showing its delete button even after the status is 'done'. + line.cashdro_manual_done = true; + line.set_payment_status("done"); + } + }; + +Component.extend(PaymentScreen, CashdroAllowManualPaymentScreen); diff --git a/pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml b/pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml new file mode 100644 index 0000000..105b80e --- /dev/null +++ b/pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml @@ -0,0 +1,64 @@ + + + + + + + + +
+ Manual +
+
+
+ + + !line.payment_status or !['done', 'reversed', 'waitingCard', 'waitingCapture'].includes(line.payment_status) or line.cashdro_manual_done + +
+ +
diff --git a/pos_cashdro_allow_manual/views/pos_payment_method_views.xml b/pos_cashdro_allow_manual/views/pos_payment_method_views.xml new file mode 100644 index 0000000..b1cc651 --- /dev/null +++ b/pos_cashdro_allow_manual/views/pos_payment_method_views.xml @@ -0,0 +1,18 @@ + + + + + pos.payment.method.cashdro.allow.manual.form + pos.payment.method + + + + + + + + diff --git a/pos_cashdro_refund/__init__.py b/pos_cashdro_refund/__init__.py new file mode 100644 index 0000000..efb0bb1 --- /dev/null +++ b/pos_cashdro_refund/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). diff --git a/pos_cashdro_refund/__manifest__.py b/pos_cashdro_refund/__manifest__.py new file mode 100644 index 0000000..5914bbe --- /dev/null +++ b/pos_cashdro_refund/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "POS CashDro Refund", + "summary": "Permite devoluciones (payouts) a través de la máquina CashDro desde el TPV", + "version": "16.0.1.0.0", + "category": "Point of Sale", + "license": "AGPL-3", + "author": "Criptomart", + "depends": [ + "pos_payment_method_cashdro", + ], + "assets": { + "point_of_sale.assets": [ + "pos_cashdro_refund/static/src/js/PasswordInputPopup.js", + "pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js", + "pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml", + ], + }, + "installable": True, +} diff --git a/pos_cashdro_refund/static/src/js/PasswordInputPopup.js b/pos_cashdro_refund/static/src/js/PasswordInputPopup.js new file mode 100644 index 0000000..0dfb636 --- /dev/null +++ b/pos_cashdro_refund/static/src/js/PasswordInputPopup.js @@ -0,0 +1,38 @@ +odoo.define("pos_cashdro_refund.PasswordInputPopup", function (require) { + "use strict"; + /* Copyright 2026 Criptomart - extracted from pos_payment_method_cashdro + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + + const AbstractAwaitablePopup = require("point_of_sale.AbstractAwaitablePopup"); + const Registries = require("point_of_sale.Registries"); + const {_lt} = require("@web/core/l10n/translation"); + const {onMounted, useRef, useState} = owl; + + class PasswordInputPopup extends AbstractAwaitablePopup { + setup() { + super.setup(); + this.state = useState({inputValue: this.props.startingValue}); + this.inputRef = useRef("input"); + onMounted(this.onMounted); + } + onMounted() { + this.inputRef.el.focus(); + } + getPayload() { + return this.state.inputValue; + } + } + PasswordInputPopup.template = "PasswordInputPopup"; + PasswordInputPopup.defaultProps = { + confirmText: _lt("Ok"), + cancelText: _lt("Cancel"), + title: "", + body: "", + startingValue: "", + placeholder: "", + }; + + Registries.Component.add(PasswordInputPopup); + + return PasswordInputPopup; +}); diff --git a/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js b/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js new file mode 100644 index 0000000..de8bc67 --- /dev/null +++ b/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js @@ -0,0 +1,117 @@ +/** @odoo-module */ +/* Copyright 2026 Criptomart - extracted from pos_payment_method_cashdro + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + Adds refund (payout) support to the CashDro payment terminal integration. + When the order due amount is negative (i.e. a return/refund), this module: + 1. Asks the cashier for the CashDro password to authorise the payout. + 2. Sends a type=3 (payout) request to the CashDro instead of the normal + type=4 (collection) request. + 3. Sets the payment line amount to the negative amount dispensed by the + machine (totalout). + Normal (positive) payments are delegated unchanged to the original + cashdro_send_payment_request method. +*/ + +import {PaymentCashdro} from "@pos_payment_method_cashdro/js/payment_cashdro.esm"; +import {Gui} from "point_of_sale.Gui"; +import {_t} from "web.core"; + +/** + * Dedicated refund (payout) handler added to the CashDro prototype. + * + * Keeping this as a SEPARATE named method (rather than inlining it inside an + * override of cashdro_send_payment_request) means that pos_cashdro_rounding + * can always delegate refunds here regardless of which module loaded last. + * Both modules patch cashdro_send_payment_request directly on the prototype, + * so the last writer wins for that method; but _cashdro_send_refund_request is + * only ever written by this module and is never overwritten. + */ +PaymentCashdro.prototype._cashdro_send_refund_request = async function (order) { + const payment_line = order.selected_paymentline; + const due = order.get_due(payment_line); + // Include cash rounding if applied (same logic as pos_cashdro_rounding for + // normal payments). get_rounding_applied() returns 0 when no rounding is + // configured, so this is safe without pos_cashdro_rounding installed. + const rounding = order.get_rounding_applied ? order.get_rounding_applied() : 0; + const amount = Math.round(Math.abs(due + rounding) * 100); + + // Require password authorisation before opening the drawer for a payout. + const {confirmed, payload: password} = await Gui.showPopup( + "PasswordInputPopup", + { + title: _t("CashDro Refund Authorization"), + body: _t("Enter the CashDro password to authorize this refund"), + placeholder: _t("Password"), + } + ); + if (!confirmed) { + payment_line.set_payment_status("retry"); + return false; + } + const method = payment_line.payment_method; + if (password !== method.cashdro_password) { + await Gui.showPopup("ErrorPopup", { + title: _t("Authentication Failed"), + body: _t("The CashDro password is incorrect."), + }); + payment_line.set_payment_status("retry"); + return false; + } + + const start_url = this._cashdro_payout_url({amount: amount}); + console.log(start_url); + const res = await this._cashdro_request(start_url); + console.log(res); + const operation_id = res.data || ""; + this.pos.get_order().cashdro_operation = operation_id; + + const ack_url = this._cashdro_ack_url(operation_id); + const res_ack = await this._cashdro_request(ack_url); + console.log(res_ack); + + const ask_url = this._cashdro_ask_url(operation_id); + const operation_data = await this._cashdro_request_payment(ask_url); + console.log(operation_data); + + const data = JSON.parse(operation_data.data); + payment_line.cashdro_operation_data = data; + // CashDro reports the dispensed amount in totalout for payouts. + const dispensed = data.operation.totalout / 100; + payment_line.set_amount(-dispensed); + return true; +}; + +/** + * Build the URL for a type=3 (payout/refund) operation on the CashDro. + */ +PaymentCashdro.prototype._cashdro_payout_url = function (parameters) { + const user = this.pos.get_cashier().id || this.pos.user.id; + let url = `${this._cashdro_url()}&operation=startOperation&type=3`; + url += `&posid=pos-${this.pos.pos_session.name}`; + url += `&posuser=${user}`; + url += `¶meters=${encodeURIComponent(JSON.stringify(parameters))}`; + return url; +}; + +/** + * Override cashdro_send_payment_request to route refunds to + * _cashdro_send_refund_request. + * + * NOTE: if pos_cashdro_rounding is also installed it will overwrite this + * override (because "rounding" > "refund" alphabetically), but that is fine + * because pos_cashdro_rounding also delegates negative amounts to + * _cashdro_send_refund_request. This override only matters when the rounding + * module is NOT installed. + */ +const _originalSendPaymentRequest = + PaymentCashdro.prototype.cashdro_send_payment_request; + +PaymentCashdro.prototype.cashdro_send_payment_request = async function (order) { + const payment_line = order.selected_paymentline; + const due = order.get_due(payment_line); + if (due < 0) { + return this._cashdro_send_refund_request(order); + } + return _originalSendPaymentRequest.call(this, order); +}; diff --git a/pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml b/pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml new file mode 100644 index 0000000..3e6358c --- /dev/null +++ b/pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml @@ -0,0 +1,33 @@ + + + + + + + + + diff --git a/pos_cashdro_rounding/static/src/js/cashdro_rounding.esm.js b/pos_cashdro_rounding/static/src/js/cashdro_rounding.esm.js index e117a1f..222c9f2 100644 --- a/pos_cashdro_rounding/static/src/js/cashdro_rounding.esm.js +++ b/pos_cashdro_rounding/static/src/js/cashdro_rounding.esm.js @@ -11,6 +11,17 @@ import { PaymentCashdro } from "@pos_payment_method_cashdro/js/payment_cashdro.e PaymentCashdro.prototype.cashdro_send_payment_request = async function (order) { const payment_line = order.selected_paymentline; try { + // If pos_cashdro_refund is installed this method handles payouts. + // Rounding is not applicable to refunds so we delegate and return. + const due = order.get_due(payment_line); + if (due < 0) { + if (this._cashdro_send_refund_request) { + return this._cashdro_send_refund_request(order); + } + // Refund module not installed – nothing we can do. + payment_line.set_payment_status("retry"); + return false; + } // Cashdro treats decimals as positions in an integer we also have // to deal with floating point computing to avoid decimals at the // end or the drawer will reject our request. From d994ab600e398b2ada44a76accb371a9e3153466 Mon Sep 17 00:00:00 2001 From: luis Date: Mon, 27 Apr 2026 13:26:03 +0200 Subject: [PATCH 16/27] pos_cashdro_refund: fix change lines --- .../static/src/js/payment_cashdro_refund.esm.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js b/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js index de8bc67..05e812b 100644 --- a/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js +++ b/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js @@ -77,7 +77,10 @@ PaymentCashdro.prototype._cashdro_send_refund_request = async function (order) { const data = JSON.parse(operation_data.data); payment_line.cashdro_operation_data = data; // CashDro reports the dispensed amount in totalout for payouts. - const dispensed = data.operation.totalout / 100; + // The API may return totalout as a negative value (money going out), + // so we use Math.abs to get the magnitude and explicitly set the + // payment line to a negative amount (refund to customer). + const dispensed = Math.abs(data.operation.totalout) / 100; payment_line.set_amount(-dispensed); return true; }; From 86101218e98632fd668d552dd68e510df87705e6 Mon Sep 17 00:00:00 2001 From: luis Date: Tue, 28 Apr 2026 10:47:43 +0200 Subject: [PATCH 17/27] pos_cashdro_allow_manual: move config from paymet.method to res.config --- pos_cashdro_allow_manual/__manifest__.py | 2 +- pos_cashdro_allow_manual/models/__init__.py | 4 +-- .../{pos_payment_method.py => pos_config.py} | 6 ++-- .../models/pos_session.py | 12 -------- .../models/res_config_settings.py | 12 ++++++++ .../src/xml/PaymentScreenPaymentLines.xml | 4 +-- .../views/pos_config_views.xml | 28 +++++++++++++++++++ .../views/pos_payment_method_views.xml | 18 ------------ 8 files changed, 48 insertions(+), 38 deletions(-) rename pos_cashdro_allow_manual/models/{pos_payment_method.py => pos_config.py} (82%) delete mode 100644 pos_cashdro_allow_manual/models/pos_session.py create mode 100644 pos_cashdro_allow_manual/models/res_config_settings.py create mode 100644 pos_cashdro_allow_manual/views/pos_config_views.xml delete mode 100644 pos_cashdro_allow_manual/views/pos_payment_method_views.xml diff --git a/pos_cashdro_allow_manual/__manifest__.py b/pos_cashdro_allow_manual/__manifest__.py index c866887..29215f4 100644 --- a/pos_cashdro_allow_manual/__manifest__.py +++ b/pos_cashdro_allow_manual/__manifest__.py @@ -11,7 +11,7 @@ "pos_payment_method_cashdro", ], "data": [ - "views/pos_payment_method_views.xml", + "views/pos_config_views.xml", ], "assets": { "point_of_sale.assets": [ diff --git a/pos_cashdro_allow_manual/models/__init__.py b/pos_cashdro_allow_manual/models/__init__.py index 5d0027b..f65aa41 100644 --- a/pos_cashdro_allow_manual/models/__init__.py +++ b/pos_cashdro_allow_manual/models/__init__.py @@ -1,4 +1,4 @@ # Copyright 2026 Criptomart # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from . import pos_payment_method -from . import pos_session +from . import pos_config +from . import res_config_settings diff --git a/pos_cashdro_allow_manual/models/pos_payment_method.py b/pos_cashdro_allow_manual/models/pos_config.py similarity index 82% rename from pos_cashdro_allow_manual/models/pos_payment_method.py rename to pos_cashdro_allow_manual/models/pos_config.py index ead4aa4..5ecfaf2 100644 --- a/pos_cashdro_allow_manual/models/pos_payment_method.py +++ b/pos_cashdro_allow_manual/models/pos_config.py @@ -3,11 +3,11 @@ from odoo import fields, models -class PosPaymentMethod(models.Model): - _inherit = "pos.payment.method" +class PosConfig(models.Model): + _inherit = "pos.config" cashdro_allow_manual_amount = fields.Boolean( - string="Permitir importe manual", + string="Permitir importe manual CashDro", help=( "Cuando está activado, aparece un botón 'Manual' en la pantalla de " "pago del TPV que permite confirmar el importe de la línea sin pasar " diff --git a/pos_cashdro_allow_manual/models/pos_session.py b/pos_cashdro_allow_manual/models/pos_session.py deleted file mode 100644 index 1a0bf31..0000000 --- a/pos_cashdro_allow_manual/models/pos_session.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright 2026 Criptomart -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import models - - -class PosSession(models.Model): - _inherit = "pos.session" - - def _loader_params_pos_payment_method(self): - result = super()._loader_params_pos_payment_method() - result["search_params"]["fields"].append("cashdro_allow_manual_amount") - return result diff --git a/pos_cashdro_allow_manual/models/res_config_settings.py b/pos_cashdro_allow_manual/models/res_config_settings.py new file mode 100644 index 0000000..be379b9 --- /dev/null +++ b/pos_cashdro_allow_manual/models/res_config_settings.py @@ -0,0 +1,12 @@ +# Copyright 2026 Criptomart +# 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" + + pos_cashdro_allow_manual_amount = fields.Boolean( + related="pos_config_id.cashdro_allow_manual_amount", + readonly=False, + ) diff --git a/pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml b/pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml index 105b80e..40489d7 100644 --- a/pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml +++ b/pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml @@ -7,7 +7,7 @@ The button is shown when: - the payment method is of type "cashdro" - - cashdro_allow_manual_amount is True on that payment method + - cashdro_allow_manual_amount is True on the pos.config - the line is in "pending" or "retry" state (the two states where the numpad is already active and the cashier can edit the amount) @@ -32,7 +32,7 @@
+ + + + res.config.settings.cashdro.allow.manual.form + res.config.settings + + + +
+
+ +
+
+
+
+
+
+
+
diff --git a/pos_cashdro_allow_manual/views/pos_payment_method_views.xml b/pos_cashdro_allow_manual/views/pos_payment_method_views.xml deleted file mode 100644 index b1cc651..0000000 --- a/pos_cashdro_allow_manual/views/pos_payment_method_views.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - pos.payment.method.cashdro.allow.manual.form - pos.payment.method - - - - - - - - From 5247938b86d5f15237a1a4352c4281e6fd3f3a97 Mon Sep 17 00:00:00 2001 From: luis Date: Fri, 5 Jun 2026 19:55:00 +0200 Subject: [PATCH 18/27] add spreadsheet_dashboard_pos_cooperative & pos_dashboard_cooperative --- pos_dashboard_cooperative/README.md | 29 + pos_dashboard_cooperative/__init__.py | 1 + pos_dashboard_cooperative/__manifest__.py | 27 + pos_dashboard_cooperative/i18n/es.po | 151 + .../i18n/pos_dashboard_cooperative.pot | 149 + .../readme/CONFIGURE.rst | 7 + .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 24 + pos_dashboard_cooperative/readme/ROADMAP.rst | 12 + pos_dashboard_cooperative/readme/USAGE.rst | 39 + pos_dashboard_cooperative/report/__init__.py | 1 + .../report/pos_order_report.py | 130 + .../report/pos_order_report_views.xml | 125 + pos_dashboard_cooperative/tests/__init__.py | 1 + .../tests/test_pos_dashboard.py | 215 ++ .../views/dashboard_tiles.xml | 104 + pos_dashboard_cooperative/views/menus.xml | 68 + .../views/pos_order_report_category_views.xml | 43 + .../views/pos_order_report_member_views.xml | 57 + .../views/pos_order_report_time_views.xml | 33 + .../README.md | 31 + .../__init__.py | 1 + .../__manifest__.py | 20 + .../data/dashboards.xml | 82 + .../data/files/categories_dashboard.json | 1799 +++++++++++ .../data/files/members_dashboard.json | 2758 +++++++++++++++++ .../data/files/overview_dashboard.json | 367 +++ .../data/files/time_dashboard.json | 221 ++ .../i18n/es.po | 634 ++++ .../spreadsheet_dashboard_pos_cooperative.pot | 633 ++++ .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 23 + .../readme/ROADMAP.rst | 38 + .../readme/USAGE.rst | 149 + 34 files changed, 7974 insertions(+) create mode 100644 pos_dashboard_cooperative/README.md create mode 100644 pos_dashboard_cooperative/__init__.py create mode 100644 pos_dashboard_cooperative/__manifest__.py create mode 100644 pos_dashboard_cooperative/i18n/es.po create mode 100644 pos_dashboard_cooperative/i18n/pos_dashboard_cooperative.pot create mode 100644 pos_dashboard_cooperative/readme/CONFIGURE.rst create mode 100644 pos_dashboard_cooperative/readme/CONTRIBUTORS.rst create mode 100644 pos_dashboard_cooperative/readme/DESCRIPTION.rst create mode 100644 pos_dashboard_cooperative/readme/ROADMAP.rst create mode 100644 pos_dashboard_cooperative/readme/USAGE.rst create mode 100644 pos_dashboard_cooperative/report/__init__.py create mode 100644 pos_dashboard_cooperative/report/pos_order_report.py create mode 100644 pos_dashboard_cooperative/report/pos_order_report_views.xml create mode 100644 pos_dashboard_cooperative/tests/__init__.py create mode 100644 pos_dashboard_cooperative/tests/test_pos_dashboard.py create mode 100644 pos_dashboard_cooperative/views/dashboard_tiles.xml create mode 100644 pos_dashboard_cooperative/views/menus.xml create mode 100644 pos_dashboard_cooperative/views/pos_order_report_category_views.xml create mode 100644 pos_dashboard_cooperative/views/pos_order_report_member_views.xml create mode 100644 pos_dashboard_cooperative/views/pos_order_report_time_views.xml create mode 100644 spreadsheet_dashboard_pos_cooperative/README.md create mode 100644 spreadsheet_dashboard_pos_cooperative/__init__.py create mode 100644 spreadsheet_dashboard_pos_cooperative/__manifest__.py create mode 100644 spreadsheet_dashboard_pos_cooperative/data/dashboards.xml create mode 100644 spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json create mode 100644 spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json create mode 100644 spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json create mode 100644 spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json create mode 100644 spreadsheet_dashboard_pos_cooperative/i18n/es.po create mode 100644 spreadsheet_dashboard_pos_cooperative/i18n/spreadsheet_dashboard_pos_cooperative.pot create mode 100644 spreadsheet_dashboard_pos_cooperative/readme/CONTRIBUTORS.rst create mode 100644 spreadsheet_dashboard_pos_cooperative/readme/DESCRIPTION.rst create mode 100644 spreadsheet_dashboard_pos_cooperative/readme/ROADMAP.rst create mode 100644 spreadsheet_dashboard_pos_cooperative/readme/USAGE.rst diff --git a/pos_dashboard_cooperative/README.md b/pos_dashboard_cooperative/README.md new file mode 100644 index 0000000..ca37e3f --- /dev/null +++ b/pos_dashboard_cooperative/README.md @@ -0,0 +1,29 @@ +# POS Dashboard Cooperative + +Real-time BI dashboards for cooperative supermarkets, built on top of +Point of Sale data. + +This module extends `report.pos.order` with three new dimensions: + +| Field | Description | +|---|---| +| `is_member_at_date` | True if the customer held a `share.line` whose `effective_date` was on or before the order date. | +| `categ_root_id` | Product family (root of `product.category` parent_path). | +| `categ_parent_id` | Product subfamily (parent of the leaf category). | +| `date_day` | Order date truncated to day. | +| `hour_of_day` | Hour of the order (`EXTRACT(HOUR FROM date_order)`). | +| `day_of_week` | Day of week, 1 = Monday … 7 = Sunday (ISO). | + +Pre-configured menus under *Point of Sale → Reporting → Cooperative +Dashboards* cover overview, members vs. non-members, categories, top +products, and time distributions. Four tiles surface the headline KPIs +on the `web_dashboard_tile` board. + +For spreadsheet dashboards published in Odoo's *Dashboards* menu, install +the companion module `spreadsheet_dashboard_pos_cooperative`. + +See `readme/` for the OCA-style structured documentation. + +## License + +AGPL-3.0 or later. diff --git a/pos_dashboard_cooperative/__init__.py b/pos_dashboard_cooperative/__init__.py new file mode 100644 index 0000000..4c4f242 --- /dev/null +++ b/pos_dashboard_cooperative/__init__.py @@ -0,0 +1 @@ +from . import report diff --git a/pos_dashboard_cooperative/__manifest__.py b/pos_dashboard_cooperative/__manifest__.py new file mode 100644 index 0000000..ce78c5d --- /dev/null +++ b/pos_dashboard_cooperative/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "POS Dashboard Cooperative", + "version": "16.0.1.0.0", + "summary": "Real-time BI dashboards for cooperative supermarkets (POS)", + "license": "AGPL-3", + "author": "Criptomart, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/pos", + "category": "Point of Sale", + "depends": [ + "point_of_sale", + "cooperator", + "pos_margin", + "web_dashboard_tile", + "web_pivot_computed_measure", + ], + "data": [ + "report/pos_order_report_views.xml", + "views/pos_order_report_member_views.xml", + "views/pos_order_report_category_views.xml", + "views/pos_order_report_time_views.xml", + "views/dashboard_tiles.xml", + "views/menus.xml", + ], + "installable": True, +} diff --git a/pos_dashboard_cooperative/i18n/es.po b/pos_dashboard_cooperative/i18n/es.po new file mode 100644 index 0000000..19431dc --- /dev/null +++ b/pos_dashboard_cooperative/i18n/es.po @@ -0,0 +1,151 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_dashboard_cooperative +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-06-05 12:00+0000\n" +"Last-Translator: Criptomart\n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_by_day_of_week +msgid "Sales by Day of Week" +msgstr "Ventas por día de la semana" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_by_hour +msgid "Sales by Hour of Day" +msgstr "Ventas por hora del día" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_categories +msgid "Sales by Category" +msgstr "Ventas por categoría" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_members +msgid "Members vs Non-members" +msgstr "Socios vs no socios" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_overview +msgid "Cooperative POS Overview" +msgstr "Vista general TPV cooperativa" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_top_members +msgid "Top Member Buyers" +msgstr "Top socias compradoras" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_top_products +msgid "Top Products" +msgstr "Top productos" + +#. module: pos_dashboard_cooperative +#: model:ir.model,name:pos_dashboard_cooperative.model_report_pos_order +msgid "Point of Sale Orders Report" +msgstr "Informe de pedidos del TPV" + +#. module: pos_dashboard_cooperative +#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__categ_parent_id +msgid "Product Subfamily (parent)" +msgstr "Subfamilia de producto (padre)" + +#. module: pos_dashboard_cooperative +#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__categ_root_id +msgid "Product Family (root)" +msgstr "Familia de producto (raíz)" + +#. module: pos_dashboard_cooperative +#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__date_day +msgid "Order Day" +msgstr "Día del pedido" + +#. module: pos_dashboard_cooperative +#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__day_of_week +msgid "Day of Week (1=Mon, 7=Sun)" +msgstr "Día de la semana (1=lun, 7=dom)" + +#. module: pos_dashboard_cooperative +#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__hour_of_day +msgid "Hour of Day" +msgstr "Hora del día" + +#. module: pos_dashboard_cooperative +#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__is_member_at_date +msgid "Member at Order Date" +msgstr "Socio en la fecha del pedido" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_by_day +msgid "By Day of Week" +msgstr "Por día de la semana" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_by_hour +msgid "By Hour of Day" +msgstr "Por hora del día" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_categories +msgid "By Category" +msgstr "Por categoría" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_cooperative +msgid "Cooperative Dashboards" +msgstr "Tableros de la cooperativa" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_members +msgid "Members vs Non-members" +msgstr "Socios vs no socios" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_overview +msgid "Overview" +msgstr "Vista general" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_top_members +msgid "Top Members" +msgstr "Top socias" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_top_products +msgid "Top Products" +msgstr "Top productos" + +#. module: pos_dashboard_cooperative +#: model:tile.category,name:pos_dashboard_cooperative.tile_category_cooperative +msgid "Cooperative POS" +msgstr "TPV cooperativa" + +#. module: pos_dashboard_cooperative +#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_margin_last_30_days +msgid "Margin last 30 days (€)" +msgstr "Margen últimos 30 días (€)" + +#. module: pos_dashboard_cooperative +#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_last_30_days +msgid "Sales last 30 days (€)" +msgstr "Ventas últimos 30 días (€)" + +#. module: pos_dashboard_cooperative +#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_members_last_30_days +msgid "Members sales last 30 days (€)" +msgstr "Ventas a socias últimos 30 días (€)" + +#. module: pos_dashboard_cooperative +#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_non_members_last_30_days +msgid "Non-members sales last 30 days (€)" +msgstr "Ventas a no socias últimos 30 días (€)" diff --git a/pos_dashboard_cooperative/i18n/pos_dashboard_cooperative.pot b/pos_dashboard_cooperative/i18n/pos_dashboard_cooperative.pot new file mode 100644 index 0000000..9875547 --- /dev/null +++ b/pos_dashboard_cooperative/i18n/pos_dashboard_cooperative.pot @@ -0,0 +1,149 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_dashboard_cooperative +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \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: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_by_day_of_week +msgid "Sales by Day of Week" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_by_hour +msgid "Sales by Hour of Day" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_categories +msgid "Sales by Category" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_members +msgid "Members vs Non-members" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_overview +msgid "Cooperative POS Overview" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_top_members +msgid "Top Member Buyers" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_top_products +msgid "Top Products" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.model,name:pos_dashboard_cooperative.model_report_pos_order +msgid "Point of Sale Orders Report" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__categ_parent_id +msgid "Product Subfamily (parent)" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__categ_root_id +msgid "Product Family (root)" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__date_day +msgid "Order Day" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__day_of_week +msgid "Day of Week (1=Mon, 7=Sun)" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__hour_of_day +msgid "Hour of Day" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__is_member_at_date +msgid "Member at Order Date" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_by_day +msgid "By Day of Week" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_by_hour +msgid "By Hour of Day" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_categories +msgid "By Category" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_cooperative +msgid "Cooperative Dashboards" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_members +msgid "Members vs Non-members" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_overview +msgid "Overview" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_top_members +msgid "Top Members" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_top_products +msgid "Top Products" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:tile.category,name:pos_dashboard_cooperative.tile_category_cooperative +msgid "Cooperative POS" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_margin_last_30_days +msgid "Margin last 30 days (€)" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_last_30_days +msgid "Sales last 30 days (€)" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_members_last_30_days +msgid "Members sales last 30 days (€)" +msgstr "" + +#. module: pos_dashboard_cooperative +#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_non_members_last_30_days +msgid "Non-members sales last 30 days (€)" +msgstr "" diff --git a/pos_dashboard_cooperative/readme/CONFIGURE.rst b/pos_dashboard_cooperative/readme/CONFIGURE.rst new file mode 100644 index 0000000..85e3354 --- /dev/null +++ b/pos_dashboard_cooperative/readme/CONFIGURE.rst @@ -0,0 +1,7 @@ +No specific configuration is required after installation. The module +auto-installs ``pos_margin``, ``web_dashboard_tile`` and +``web_pivot_computed_measure`` if they are not already present. + +To control who sees the new dashboards, adjust the standard POS +security groups (``point_of_sale.group_pos_manager``) — the new analyses +inherit the access rules already in place for ``report.pos.order``. diff --git a/pos_dashboard_cooperative/readme/CONTRIBUTORS.rst b/pos_dashboard_cooperative/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..c8ac156 --- /dev/null +++ b/pos_dashboard_cooperative/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Criptomart diff --git a/pos_dashboard_cooperative/readme/DESCRIPTION.rst b/pos_dashboard_cooperative/readme/DESCRIPTION.rst new file mode 100644 index 0000000..8e92fa6 --- /dev/null +++ b/pos_dashboard_cooperative/readme/DESCRIPTION.rst @@ -0,0 +1,24 @@ +Real-time Business Intelligence dashboards for cooperative supermarkets +built on top of Point of Sale data. Extends the standard +``report.pos.order`` view to expose new dimensions that the standard POS +analysis lacks: + +* **Cooperative member dimension** — ``is_member_at_date`` flags each + ticket line according to the customer's share status *at the order + date*, so historic comparisons (members vs. non-members) reflect the + reality of the moment. +* **Category hierarchy** — ``categ_root_id`` (family) and + ``categ_parent_id`` (subfamily) derived from + ``product.category.parent_path``, ready to drive drilldowns. +* **Time dimensions** — ``date_day``, ``hour_of_day``, ``day_of_week`` + for daily/weekly/hourly distributions. + +Pre-configured menu entries under *Point of Sale → Reporting → +Cooperative Dashboards* cover overview, members vs. non-members, +categories, top products, day-of-week and hour-of-day analyses. Four +dashboard tiles surface the most important KPIs on the +``web_dashboard_tile`` board. + +The companion module ``spreadsheet_dashboard_pos_cooperative`` publishes +spreadsheet dashboards in Odoo's *Dashboards* menu that consume the +same data. diff --git a/pos_dashboard_cooperative/readme/ROADMAP.rst b/pos_dashboard_cooperative/readme/ROADMAP.rst new file mode 100644 index 0000000..4a92257 --- /dev/null +++ b/pos_dashboard_cooperative/readme/ROADMAP.rst @@ -0,0 +1,12 @@ +* Materialize the SQL view into a regular table populated by a nightly + cron, in case real-time aggregation becomes too slow on very large + POS datasets. +* Refine the ``is_member_at_date`` calculation by consuming + ``subscription.register`` to subtract sell-back and transfer + operations from the share balance. The current implementation simply + checks for the existence of a ``share.line`` dated on or before the + order date, which over-reports memberships when shares are returned. +* Add multi-company test coverage once a clean multi-company test + fixture is available. +* Add Galician, Catalan, French, Dutch and Croatian translations + (currently shipped with Spanish only). diff --git a/pos_dashboard_cooperative/readme/USAGE.rst b/pos_dashboard_cooperative/readme/USAGE.rst new file mode 100644 index 0000000..8e7d40b --- /dev/null +++ b/pos_dashboard_cooperative/readme/USAGE.rst @@ -0,0 +1,39 @@ +Open *Point of Sale → Reporting → Cooperative Dashboards* and pick one +of the predefined analyses: + +* **Overview** — sales, margin and order count grouped by month. +* **Members vs Non-members** — same axes split by cooperative + membership at the order date. +* **Top Members** — sales aggregated per cooperative member, sort by + total to obtain the top buyers. +* **By Category** — sales by product family (root category); drill down + to subfamily and product from the *Group By* menu. +* **Top Products** — sales per product. +* **By Day of Week / By Hour of Day** — temporal distributions. + +In every view, additional dimensions are available from the *Filters* +and *Group By* menus: ``Members``, ``Non members``, ``Member``, +``Product Family``, ``Product Subfamily``, ``Day of Week``, +``Hour of Day``. + +Time dimensions +=============== + +* ``day_of_week`` is the ISO day number (``EXTRACT(ISODOW FROM + date_order)``): **1 = Monday, 2 = Tuesday, 3 = Wednesday, 4 = + Thursday, 5 = Friday, 6 = Saturday, 7 = Sunday**. +* ``hour_of_day`` is the integer hour in the order's timezone + (``EXTRACT(HOUR FROM date_order)``), 0..23. + +These are deliberately exposed as integers rather than translated +labels so they aggregate cleanly in pivots, charts and spreadsheet +formulas. Map the numbers to weekday names in the consumer (for +example, in the o-spreadsheet dashboards) if a localized label is +needed. + +Pivot views support the ratios defined by ``web_pivot_computed_measure`` +(e.g. average ticket = total sales / order count). Click *Insert +Computed Measure* in the pivot's *Measures* menu. + +For curated dashboards rendered in Odoo's *Dashboards* menu, install +the companion module ``spreadsheet_dashboard_pos_cooperative``. diff --git a/pos_dashboard_cooperative/report/__init__.py b/pos_dashboard_cooperative/report/__init__.py new file mode 100644 index 0000000..1ddfcaa --- /dev/null +++ b/pos_dashboard_cooperative/report/__init__.py @@ -0,0 +1 @@ +from . import pos_order_report diff --git a/pos_dashboard_cooperative/report/pos_order_report.py b/pos_dashboard_cooperative/report/pos_order_report.py new file mode 100644 index 0000000..a208841 --- /dev/null +++ b/pos_dashboard_cooperative/report/pos_order_report.py @@ -0,0 +1,130 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PosOrderReport(models.Model): + _inherit = "report.pos.order" + + # --- Time dimensions ---------------------------------------------------- + date_day = fields.Date( + string="Order Day", + readonly=True, + ) + hour_of_day = fields.Integer( + string="Hour of Day", + readonly=True, + group_operator=None, + ) + day_of_week = fields.Integer( + string="Day of Week (1=Mon, 7=Sun)", + readonly=True, + group_operator=None, + ) + + # --- Category hierarchy ------------------------------------------------- + # Family = root category, subfamily = direct parent of categ_id. + # Computed from product.category.parent_path (materialized path + # "1/3/7/"), which is maintained automatically by Odoo (_parent_store). + categ_root_id = fields.Many2one( + "product.category", + string="Product Family (root)", + readonly=True, + ) + categ_root_id_int = fields.Integer( + string="Product Family ID (root)", + readonly=True, + group_operator=None, + ) + categ_parent_id = fields.Many2one( + "product.category", + string="Product Subfamily (parent)", + readonly=True, + ) + categ_parent_id_int = fields.Integer( + string="Product Subfamily ID (parent)", + readonly=True, + group_operator=None, + ) + product_categ_id_int = fields.Integer( + string="Product Category ID (leaf)", + readonly=True, + group_operator=None, + ) + product_id_int = fields.Integer( + string="Product ID", + readonly=True, + group_operator=None, + ) + + # --- Cooperative member dimension -------------------------------------- + # True when the customer of the ticket held at least one share line whose + # effective_date was on or before the order date, in the same company. + # + # NOTE: this is an approximation. cooperator does not store an end date + # on share.line; share buy-backs and transfers are recorded only in + # subscription.register. A future version may refine the calculation by + # consuming subscription.register to substract sell_back/transfer + # operations, at the cost of a more expensive subquery. + is_member_at_date = fields.Boolean( + string="Member at Order Date", + readonly=True, + ) + + def _select(self): + return ( + super()._select() + + """, + s.date_order::date AS date_day, + EXTRACT(HOUR FROM (s.date_order AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Madrid')::int AS hour_of_day, + EXTRACT(ISODOW FROM s.date_order)::int AS day_of_week, + cat_root.id AS categ_root_id, + cat_root.id AS categ_root_id_int, + pt_categ.parent_id AS categ_parent_id, + pt_categ.parent_id AS categ_parent_id_int, + pt.categ_id AS product_categ_id_int, + pt.id AS product_id_int, + CASE WHEN EXISTS ( + SELECT 1 FROM share_line sl + WHERE sl.partner_id = s.partner_id + AND sl.company_id = s.company_id + AND sl.effective_date IS NOT NULL + AND sl.effective_date <= s.date_order::date + ) THEN TRUE ELSE FALSE END AS is_member_at_date + """ + ) + + def _from(self): + # Extra joins to resolve: + # - pt_categ: direct category of the product (used for parent_id and + # parent_path lookup). + # - cat_root: root category, found by taking the first id in + # parent_path. parent_path looks like "1/3/7/". + return ( + super()._from() + + """ + LEFT JOIN product_category pt_categ ON pt_categ.id = pt.categ_id + LEFT JOIN product_category cat_root + ON cat_root.id = NULLIF( + split_part(pt_categ.parent_path, '/', 1), '' + )::int + """ + ) + + def _group_by(self): + # Append non-aggregated expressions added in _select() that are not + # already in the parent GROUP BY. s.date_order is already there + # (point_of_sale.report.pos_order_report._group_by), so the time + # dimensions derived from it (date_day, hour_of_day, day_of_week) + # are functionally dependent and don't need to be listed. + # is_member_at_date is a scalar subquery; not needed either. + return ( + super()._group_by() + + """, + cat_root.id, + pt_categ.parent_id, + pt.categ_id, + pt.id + """ + ) diff --git a/pos_dashboard_cooperative/report/pos_order_report_views.xml b/pos_dashboard_cooperative/report/pos_order_report_views.xml new file mode 100644 index 0000000..18a48cb --- /dev/null +++ b/pos_dashboard_cooperative/report/pos_order_report_views.xml @@ -0,0 +1,125 @@ + + + + + + + report.pos.order.search.cooperative + report.pos.order + + + + + + + + + + + + + + + + + + + + + + report.pos.order.pivot.cooperative + report.pos.order + + + + + + + + + + + + report.pos.order.tree.cooperative + report.pos.order + + + + + + + + + + + + + + + Cooperative POS Overview + report.pos.order + graph,pivot,tree + + { + 'search_default_order_month': 1, + 'group_by_no_leaf': 1, + 'group_by': [], + } + +

+ No data yet! +

+ Sell some products at the Point of Sale to populate this + cooperative dashboard. +

+
+
+ +
diff --git a/pos_dashboard_cooperative/tests/__init__.py b/pos_dashboard_cooperative/tests/__init__.py new file mode 100644 index 0000000..275170b --- /dev/null +++ b/pos_dashboard_cooperative/tests/__init__.py @@ -0,0 +1 @@ +from . import test_pos_dashboard diff --git a/pos_dashboard_cooperative/tests/test_pos_dashboard.py b/pos_dashboard_cooperative/tests/test_pos_dashboard.py new file mode 100644 index 0000000..2c85f1a --- /dev/null +++ b/pos_dashboard_cooperative/tests/test_pos_dashboard.py @@ -0,0 +1,215 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import date, timedelta + +import odoo +from odoo.tests.common import TransactionCase + + +@odoo.tests.tagged("post_install", "-at_install") +class TestPosDashboardCooperative(TransactionCase): + """Validate the extra dimensions added by pos_dashboard_cooperative on + top of point_of_sale.report.pos.order: + + - time dimensions (date_day, hour_of_day, day_of_week) + - product category hierarchy (categ_root_id, categ_parent_id) + - cooperative member historic flag (is_member_at_date) + + These tests use a minimal hand-built pos.session to stay independent + from any existing data in the database and from the heavy + TestPoSCommon setup machinery, which requires a clean accounting + setup. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.ref("base.main_company") + + # Two-level category hierarchy: family -> subfamily -> leaf + cls.family_root = cls.env["product.category"].create( + {"name": "DASHBOARD-FAMILY-ROOT"} + ) + cls.subfamily = cls.env["product.category"].create( + {"name": "DASHBOARD-SUBFAMILY", "parent_id": cls.family_root.id} + ) + cls.categ_leaf = cls.env["product.category"].create( + {"name": "DASHBOARD-LEAF", "parent_id": cls.subfamily.id} + ) + + # Product sold at the POS in every test. + cls.product = cls.env["product.product"].create( + { + "name": "DASHBOARD-PRODUCT", + "type": "consu", + "available_in_pos": True, + "list_price": 10.0, + "standard_price": 6.0, + "categ_id": cls.categ_leaf.id, + } + ) + + # Share product needed by cooperator.share.line. + cls.share_product = cls.env["product.product"].create( + { + "name": "DASHBOARD-SHARE", + "is_share": True, + "type": "service", + "list_price": 25.0, + } + ) + + # Two partners: one cooperative member (share dated in the past) + # and one non-member. + cls.member_partner = cls.env["res.partner"].create( + {"name": "DASHBOARD-MEMBER"} + ) + cls.non_member_partner = cls.env["res.partner"].create( + {"name": "DASHBOARD-NON-MEMBER"} + ) + cls.env["share.line"].create( + { + "partner_id": cls.member_partner.id, + "share_product_id": cls.share_product.id, + "share_number": 1, + "share_unit_price": 25.0, + "effective_date": date.today() - timedelta(days=365), + "company_id": cls.company.id, + } + ) + + # Reuse an existing POS config to avoid bootstrapping payment + # methods and journals from scratch. Any installed config is + # acceptable since we never go through the POS UI. + cls.pos_config = cls.env["pos.config"].search( + [("company_id", "=", cls.company.id)], limit=1 + ) + assert cls.pos_config, "A pos.config in the main company is required." + if not cls.pos_config.current_session_id: + cls.pos_config.open_ui() + cls.pos_session = cls.pos_config.current_session_id + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + def _create_pos_order(self, partner): + return self.env["pos.order"].create( + { + "session_id": self.pos_session.id, + "partner_id": partner.id, + "company_id": self.company.id, + "lines": [ + ( + 0, + 0, + { + "name": "DASHBOARD-OL", + "product_id": self.product.id, + "price_unit": 10.0, + "discount": 0, + "qty": 1.0, + "price_subtotal": 10.0, + "price_subtotal_incl": 10.0, + "total_cost": 6.0, + }, + ) + ], + "amount_total": 10.0, + "amount_tax": 0.0, + "amount_paid": 0.0, + "amount_return": 0.0, + } + ) + + def _reports_for(self, partner): + return ( + self.env["report.pos.order"] + .sudo() + .search( + [ + ("product_id", "=", self.product.id), + ("partner_id", "=", partner.id), + ], + order="id", + ) + ) + + # ------------------------------------------------------------------ + # Tests + # ------------------------------------------------------------------ + def test_member_flag_for_member_partner(self): + """Partner with a share line predating the order is flagged as + member.""" + self._create_pos_order(self.member_partner) + reports = self._reports_for(self.member_partner) + self.assertTrue(reports) + self.assertTrue(reports[0].is_member_at_date) + + def test_member_flag_for_non_member_partner(self): + """Partner without any share line is not flagged as member.""" + self._create_pos_order(self.non_member_partner) + reports = self._reports_for(self.non_member_partner) + self.assertTrue(reports) + self.assertFalse(reports[0].is_member_at_date) + + def test_member_flag_is_historic(self): + """Share line dated AFTER the order date does not mark the partner + as member for that order.""" + future_member = self.env["res.partner"].create( + {"name": "DASHBOARD-FUTURE-MEMBER"} + ) + self._create_pos_order(future_member) + self.env["share.line"].create( + { + "partner_id": future_member.id, + "share_product_id": self.share_product.id, + "share_number": 1, + "share_unit_price": 25.0, + "effective_date": date.today() + timedelta(days=30), + "company_id": self.company.id, + } + ) + reports = self._reports_for(future_member) + self.assertTrue(reports) + self.assertFalse( + reports[0].is_member_at_date, + "Member flag must reflect the situation at order date.", + ) + + def test_category_hierarchy_is_resolved(self): + """categ_root_id and categ_parent_id reflect the product category + ancestors derived from parent_path.""" + self._create_pos_order(self.member_partner) + reports = self._reports_for(self.member_partner) + self.assertTrue(reports) + report = reports[0] + self.assertEqual(report.categ_root_id, self.family_root) + self.assertEqual(report.categ_parent_id, self.subfamily) + + def test_time_dimensions_are_populated(self): + """date_day matches order date; hour_of_day / day_of_week are in + valid ranges.""" + self._create_pos_order(self.member_partner) + reports = self._reports_for(self.member_partner) + self.assertTrue(reports) + report = reports[0] + self.assertIsNotNone(report.date_day) + self.assertGreaterEqual(report.hour_of_day, 0) + self.assertLess(report.hour_of_day, 24) + self.assertGreaterEqual(report.day_of_week, 1) + self.assertLessEqual(report.day_of_week, 7) + + def test_read_group_by_member_flag(self): + """Aggregations grouped by the member flag return one row per + member status when both kinds of partners have purchased.""" + self._create_pos_order(self.member_partner) + self._create_pos_order(self.non_member_partner) + results = self.env["report.pos.order"].read_group( + [("product_id", "=", self.product.id)], + ["price_total", "is_member_at_date"], + ["is_member_at_date"], + ) + statuses = {r["is_member_at_date"] for r in results} + self.assertIn(True, statuses) + self.assertIn(False, statuses) diff --git a/pos_dashboard_cooperative/views/dashboard_tiles.xml b/pos_dashboard_cooperative/views/dashboard_tiles.xml new file mode 100644 index 0000000..2a74920 --- /dev/null +++ b/pos_dashboard_cooperative/views/dashboard_tiles.xml @@ -0,0 +1,104 @@ + + + + + + + Cooperative POS + 100 + + + + + + Sales last 30 days (€) + 10 + + + + sum + + {:,.2f} € + [('state', 'in', ['paid', 'done', 'invoiced']), + ('date', '>=', (context_today - relativedelta(days=30)).strftime('%Y-%m-%d'))] + #2E7D32 + #FFFFFF + + + + Margin last 30 days (€) + 20 + + + + sum + + {:,.2f} € + [('state', 'in', ['paid', 'done', 'invoiced']), + ('date', '>=', (context_today - relativedelta(days=30)).strftime('%Y-%m-%d'))] + #1565C0 + #FFFFFF + + + + Members sales last 30 days (€) + 30 + + + + sum + + {:,.2f} € + [('state', 'in', ['paid', 'done', 'invoiced']), + ('is_member_at_date', '=', True), + ('date', '>=', (context_today - relativedelta(days=30)).strftime('%Y-%m-%d'))] + #EF6C00 + #FFFFFF + + + + Non-members sales last 30 days (€) + 40 + + + + sum + + {:,.2f} € + [('state', 'in', ['paid', 'done', 'invoiced']), + ('is_member_at_date', '=', False), + ('date', '>=', (context_today - relativedelta(days=30)).strftime('%Y-%m-%d'))] + #6A1B9A + #FFFFFF + + + diff --git a/pos_dashboard_cooperative/views/menus.xml b/pos_dashboard_cooperative/views/menus.xml new file mode 100644 index 0000000..8bafc82 --- /dev/null +++ b/pos_dashboard_cooperative/views/menus.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/pos_dashboard_cooperative/views/pos_order_report_category_views.xml b/pos_dashboard_cooperative/views/pos_order_report_category_views.xml new file mode 100644 index 0000000..1af9acf --- /dev/null +++ b/pos_dashboard_cooperative/views/pos_order_report_category_views.xml @@ -0,0 +1,43 @@ + + + + + + + Sales by Category + report.pos.order + pivot,graph,tree + + { + 'search_default_group_categ_root': 1, + 'search_default_order_month': 1, + 'group_by_no_leaf': 1, + } + +

+ No data yet! +

+ Add Subfamily or Product group-bys to drill down. Useful + to detect tractor categories. +

+
+
+ + + Top Products + report.pos.order + pivot,tree,graph + + { + 'group_by': ['product_id'], + 'group_by_no_leaf': 1, + } + + +
diff --git a/pos_dashboard_cooperative/views/pos_order_report_member_views.xml b/pos_dashboard_cooperative/views/pos_order_report_member_views.xml new file mode 100644 index 0000000..a0abb2d --- /dev/null +++ b/pos_dashboard_cooperative/views/pos_order_report_member_views.xml @@ -0,0 +1,57 @@ + + + + + + + Members vs Non-members + report.pos.order + pivot,graph,tree + + { + 'search_default_group_member': 1, + 'search_default_order_month': 1, + 'group_by_no_leaf': 1, + } + +

+ No data yet! +

+ This view contrasts cooperative members and non-members on + the same axes (sales, ticket count, margin). +

+
+
+ + + + Top Member Buyers + report.pos.order + pivot,tree,graph + + [('is_member_at_date', '=', True)] + { + 'search_default_members': 1, + 'group_by': ['partner_id'], + 'group_by_no_leaf': 1, + } + +

+ No data yet! +

+ Sales aggregated per cooperative member. Sort by total + price to obtain the top buyers. +

+
+
+ +
diff --git a/pos_dashboard_cooperative/views/pos_order_report_time_views.xml b/pos_dashboard_cooperative/views/pos_order_report_time_views.xml new file mode 100644 index 0000000..0e8edcd --- /dev/null +++ b/pos_dashboard_cooperative/views/pos_order_report_time_views.xml @@ -0,0 +1,33 @@ + + + + + + + Sales by Day of Week + report.pos.order + graph,pivot,tree + + { + 'search_default_group_day_of_week': 1, + 'group_by_no_leaf': 1, + } + + + + Sales by Hour of Day + report.pos.order + graph,pivot,tree + + { + 'search_default_group_hour_of_day': 1, + 'group_by_no_leaf': 1, + } + + + diff --git a/spreadsheet_dashboard_pos_cooperative/README.md b/spreadsheet_dashboard_pos_cooperative/README.md new file mode 100644 index 0000000..3feca4e --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/README.md @@ -0,0 +1,31 @@ +# Spreadsheet Dashboard POS Cooperative + +Publishes spreadsheet-based dashboards in Odoo's *Dashboards* menu, in +a dedicated *Cooperative* group. Built on top of o-spreadsheet, the +same technology used by Odoo's official `spreadsheet_dashboard_*` +modules (`spreadsheet_dashboard_sale`, `spreadsheet_dashboard_purchase`, +etc.). + +Depends on `pos_dashboard_cooperative` for the underlying data model +(`report.pos.order` extended with member / category / time +dimensions). + +Four dashboards are bundled: + +| Dashboard | Contents | +|---|---| +| POS Overview | Sales, Orders, Avg ticket, Margin + monthly chart | +| POS Members | Members vs non-members KPIs + monthly chart + TOP 50 buyers | +| POS Categories | Members contribution + TOP 10 families / subfamilies / products | +| POS Sales by Time | Sales by day of week + sales by hour of day | + +The JSON files under `data/files/` are hand-authored and intentionally +diff-friendly. To redesign one of them visually, install +`spreadsheet_oca`, edit in the o-spreadsheet UI, export to JSON and +replace the matching file. + +See `readme/USAGE.rst` for the full procedure and metric definitions. + +## License + +AGPL-3.0 or later. diff --git a/spreadsheet_dashboard_pos_cooperative/__init__.py b/spreadsheet_dashboard_pos_cooperative/__init__.py new file mode 100644 index 0000000..d9b29b8 --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/__init__.py @@ -0,0 +1 @@ +# Part of the spreadsheet_dashboard ecosystem. Nothing to import. diff --git a/spreadsheet_dashboard_pos_cooperative/__manifest__.py b/spreadsheet_dashboard_pos_cooperative/__manifest__.py new file mode 100644 index 0000000..7db1242 --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Spreadsheet dashboard for POS cooperative", + "version": "16.0.1.0.0", + "category": "Hidden", + "summary": "Spreadsheet dashboards for cooperative supermarkets POS", + "license": "AGPL-3", + "author": "Criptomart, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/pos", + "depends": [ + "spreadsheet_dashboard", + "pos_dashboard_cooperative", + ], + "data": [ + "data/dashboards.xml", + ], + "auto_install": ["pos_dashboard_cooperative"], + "installable": True, +} diff --git a/spreadsheet_dashboard_pos_cooperative/data/dashboards.xml b/spreadsheet_dashboard_pos_cooperative/data/dashboards.xml new file mode 100644 index 0000000..795d620 --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/data/dashboards.xml @@ -0,0 +1,82 @@ + + + + + + + PDV Supermercado + 150 + + + + + Ventas + + + + 100 + + + + + Ventas horas y días + + + + 400 + + + + + Categorías + + + + 300 + + + + + Socias + + + + 200 + + + diff --git a/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json b/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json new file mode 100644 index 0000000..2dbc93c --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json @@ -0,0 +1,1799 @@ +{ + "version": 12, + "sheets": [ + { + "id": "dashboard", + "name": "Categories", + "colNumber": 12, + "rowNumber": 50, + "rows": { + "0": { + "size": 40 + } + }, + "cols": { + "0": { + "size": 340 + }, + "1": { + "size": 140 + }, + "2": { + "size": 100 + }, + "3": { + "size": 100 + }, + "4": { + "size": 110 + }, + "5": { + "size": 340 + }, + "6": { + "size": 140 + }, + "7": { + "size": 100 + }, + "8": { + "size": 100 + }, + "9": { + "size": 110 + } + }, + "merges": [], + "cells": { + "A1": { + "style": 1, + "content": "=_t(\"Cooperative POS — Categories\")" + }, + "A3": { + "style": 2, + "content": "=_t(\"Top 10 categorías\")" + }, + "A4": { + "style": 3, + "content": "=_t(\"Categoría\")", + "border": 1 + }, + "B4": { + "style": 3, + "content": "=_t(\"Ventas (€)\")", + "border": 1 + }, + "C4": { + "style": 3, + "content": "=_t(\"Tickets\")", + "border": 1 + }, + "D4": { + "style": 3, + "content": "=_t(\"Cuota %\")", + "border": 1 + }, + "E4": { + "style": 3, + "content": "=_t(\"Var %\")", + "border": 1 + }, + "F3": { + "style": 2, + "content": "=_t(\"Top 10 familias\")" + }, + "F4": { + "style": 3, + "content": "=_t(\"Familia\")", + "border": 1 + }, + "G4": { + "style": 3, + "content": "=_t(\"Ventas (€)\")", + "border": 1 + }, + "H4": { + "style": 3, + "content": "=_t(\"Tickets\")", + "border": 1 + }, + "I4": { + "style": 3, + "content": "=_t(\"Cuota %\")", + "border": 1 + }, + "J4": { + "style": 3, + "content": "=_t(\"Var %\")", + "border": 1 + }, + "A16": { + "style": 2, + "content": "=_t(\"Top 10 subfamilias\")" + }, + "A17": { + "style": 3, + "content": "=_t(\"Subfamilia\")", + "border": 1 + }, + "B17": { + "style": 3, + "content": "=_t(\"Ventas (€)\")", + "border": 1 + }, + "C17": { + "style": 3, + "content": "=_t(\"Tickets\")", + "border": 1 + }, + "D17": { + "style": 3, + "content": "=_t(\"Cuota %\")", + "border": 1 + }, + "E17": { + "style": 3, + "content": "=_t(\"Var %\")", + "border": 1 + }, + "F16": { + "style": 2, + "content": "=_t(\"Top 10 productos\")" + }, + "F17": { + "style": 3, + "content": "=_t(\"Producto\")", + "border": 1 + }, + "G17": { + "style": 3, + "content": "=_t(\"Ventas (€)\")", + "border": 1 + }, + "H17": { + "style": 3, + "content": "=_t(\"Unidades\")", + "border": 1 + }, + "I17": { + "style": 3, + "content": "=_t(\"Cuota %\")", + "border": 1 + }, + "J17": { + "style": 3, + "content": "=_t(\"Var %\")", + "border": 1 + }, + "A5": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(1,\"#categ_root_id\",1)" + }, + "B5": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",1)" + }, + "C5": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(1,\"order_id\",\"#categ_root_id\",1)" + }, + "D5": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",1)/Data!B1,0)" + }, + "E5": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",1)-ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",1)))/ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",1)),0)" + }, + "A6": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(1,\"#categ_root_id\",2)" + }, + "B6": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",2)" + }, + "C6": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(1,\"order_id\",\"#categ_root_id\",2)" + }, + "D6": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",2)/Data!B1,0)" + }, + "E6": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",2)-ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",2)))/ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",2)),0)" + }, + "A7": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(1,\"#categ_root_id\",3)" + }, + "B7": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",3)" + }, + "C7": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(1,\"order_id\",\"#categ_root_id\",3)" + }, + "D7": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",3)/Data!B1,0)" + }, + "E7": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",3)-ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",3)))/ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",3)),0)" + }, + "A8": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(1,\"#categ_root_id\",4)" + }, + "B8": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",4)" + }, + "C8": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(1,\"order_id\",\"#categ_root_id\",4)" + }, + "D8": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",4)/Data!B1,0)" + }, + "E8": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",4)-ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",4)))/ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",4)),0)" + }, + "A9": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(1,\"#categ_root_id\",5)" + }, + "B9": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",5)" + }, + "C9": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(1,\"order_id\",\"#categ_root_id\",5)" + }, + "D9": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",5)/Data!B1,0)" + }, + "E9": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",5)-ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",5)))/ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",5)),0)" + }, + "A10": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(1,\"#categ_root_id\",6)" + }, + "B10": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",6)" + }, + "C10": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(1,\"order_id\",\"#categ_root_id\",6)" + }, + "D10": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",6)/Data!B1,0)" + }, + "E10": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",6)-ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",6)))/ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",6)),0)" + }, + "A11": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(1,\"#categ_root_id\",7)" + }, + "B11": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",7)" + }, + "C11": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(1,\"order_id\",\"#categ_root_id\",7)" + }, + "D11": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",7)/Data!B1,0)" + }, + "E11": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",7)-ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",7)))/ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",7)),0)" + }, + "A12": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(1,\"#categ_root_id\",8)" + }, + "B12": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",8)" + }, + "C12": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(1,\"order_id\",\"#categ_root_id\",8)" + }, + "D12": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",8)/Data!B1,0)" + }, + "E12": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",8)-ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",8)))/ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",8)),0)" + }, + "A13": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(1,\"#categ_root_id\",9)" + }, + "B13": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",9)" + }, + "C13": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(1,\"order_id\",\"#categ_root_id\",9)" + }, + "D13": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",9)/Data!B1,0)" + }, + "E13": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",9)-ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",9)))/ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",9)),0)" + }, + "A14": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(1,\"#categ_root_id\",10)" + }, + "B14": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",10)" + }, + "C14": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(1,\"order_id\",\"#categ_root_id\",10)" + }, + "D14": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",10)/Data!B1,0)" + }, + "E14": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(1,\"price_total\",\"#categ_root_id\",10)-ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",10)))/ODOO.PIVOT(6,\"price_total\",\"categ_root_id\",ODOO.PIVOT.HEADER(1,\"#categ_root_id_int\",10)),0)" + }, + "F5": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(2,\"#categ_parent_id\",1)" + }, + "G5": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",1)" + }, + "H5": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(2,\"order_id\",\"#categ_parent_id\",1)" + }, + "I5": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",1)/Data!B1,0)" + }, + "J5": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",1)-ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",1)))/ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",1)),0)" + }, + "F6": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(2,\"#categ_parent_id\",2)" + }, + "G6": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",2)" + }, + "H6": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(2,\"order_id\",\"#categ_parent_id\",2)" + }, + "I6": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",2)/Data!B1,0)" + }, + "J6": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",2)-ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",2)))/ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",2)),0)" + }, + "F7": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(2,\"#categ_parent_id\",3)" + }, + "G7": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",3)" + }, + "H7": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(2,\"order_id\",\"#categ_parent_id\",3)" + }, + "I7": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",3)/Data!B1,0)" + }, + "J7": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",3)-ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",3)))/ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",3)),0)" + }, + "F8": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(2,\"#categ_parent_id\",4)" + }, + "G8": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",4)" + }, + "H8": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(2,\"order_id\",\"#categ_parent_id\",4)" + }, + "I8": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",4)/Data!B1,0)" + }, + "J8": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",4)-ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",4)))/ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",4)),0)" + }, + "F9": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(2,\"#categ_parent_id\",5)" + }, + "G9": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",5)" + }, + "H9": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(2,\"order_id\",\"#categ_parent_id\",5)" + }, + "I9": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",5)/Data!B1,0)" + }, + "J9": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",5)-ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",5)))/ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",5)),0)" + }, + "F10": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(2,\"#categ_parent_id\",6)" + }, + "G10": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",6)" + }, + "H10": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(2,\"order_id\",\"#categ_parent_id\",6)" + }, + "I10": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",6)/Data!B1,0)" + }, + "J10": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",6)-ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",6)))/ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",6)),0)" + }, + "F11": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(2,\"#categ_parent_id\",7)" + }, + "G11": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",7)" + }, + "H11": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(2,\"order_id\",\"#categ_parent_id\",7)" + }, + "I11": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",7)/Data!B1,0)" + }, + "J11": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",7)-ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",7)))/ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",7)),0)" + }, + "F12": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(2,\"#categ_parent_id\",8)" + }, + "G12": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",8)" + }, + "H12": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(2,\"order_id\",\"#categ_parent_id\",8)" + }, + "I12": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",8)/Data!B1,0)" + }, + "J12": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",8)-ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",8)))/ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",8)),0)" + }, + "F13": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(2,\"#categ_parent_id\",9)" + }, + "G13": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",9)" + }, + "H13": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(2,\"order_id\",\"#categ_parent_id\",9)" + }, + "I13": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",9)/Data!B1,0)" + }, + "J13": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",9)-ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",9)))/ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",9)),0)" + }, + "F14": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(2,\"#categ_parent_id\",10)" + }, + "G14": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",10)" + }, + "H14": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(2,\"order_id\",\"#categ_parent_id\",10)" + }, + "I14": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",10)/Data!B1,0)" + }, + "J14": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(2,\"price_total\",\"#categ_parent_id\",10)-ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",10)))/ODOO.PIVOT(7,\"price_total\",\"categ_parent_id\",ODOO.PIVOT.HEADER(2,\"#categ_parent_id_int\",10)),0)" + }, + "A18": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#product_categ_id\",1)" + }, + "B18": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",1)" + }, + "C18": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#product_categ_id\",1)" + }, + "D18": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",1)/Data!B1,0)" + }, + "E18": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",1)-ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",1)))/ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",1)),0)" + }, + "A19": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#product_categ_id\",2)" + }, + "B19": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",2)" + }, + "C19": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#product_categ_id\",2)" + }, + "D19": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",2)/Data!B1,0)" + }, + "E19": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",2)-ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",2)))/ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",2)),0)" + }, + "A20": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#product_categ_id\",3)" + }, + "B20": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",3)" + }, + "C20": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#product_categ_id\",3)" + }, + "D20": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",3)/Data!B1,0)" + }, + "E20": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",3)-ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",3)))/ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",3)),0)" + }, + "A21": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#product_categ_id\",4)" + }, + "B21": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",4)" + }, + "C21": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#product_categ_id\",4)" + }, + "D21": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",4)/Data!B1,0)" + }, + "E21": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",4)-ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",4)))/ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",4)),0)" + }, + "A22": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#product_categ_id\",5)" + }, + "B22": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",5)" + }, + "C22": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#product_categ_id\",5)" + }, + "D22": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",5)/Data!B1,0)" + }, + "E22": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",5)-ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",5)))/ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",5)),0)" + }, + "A23": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#product_categ_id\",6)" + }, + "B23": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",6)" + }, + "C23": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#product_categ_id\",6)" + }, + "D23": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",6)/Data!B1,0)" + }, + "E23": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",6)-ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",6)))/ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",6)),0)" + }, + "A24": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#product_categ_id\",7)" + }, + "B24": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",7)" + }, + "C24": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#product_categ_id\",7)" + }, + "D24": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",7)/Data!B1,0)" + }, + "E24": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",7)-ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",7)))/ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",7)),0)" + }, + "A25": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#product_categ_id\",8)" + }, + "B25": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",8)" + }, + "C25": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#product_categ_id\",8)" + }, + "D25": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",8)/Data!B1,0)" + }, + "E25": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",8)-ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",8)))/ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",8)),0)" + }, + "A26": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#product_categ_id\",9)" + }, + "B26": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",9)" + }, + "C26": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#product_categ_id\",9)" + }, + "D26": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",9)/Data!B1,0)" + }, + "E26": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",9)-ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",9)))/ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",9)),0)" + }, + "A27": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#product_categ_id\",10)" + }, + "B27": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",10)" + }, + "C27": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#product_categ_id\",10)" + }, + "D27": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",10)/Data!B1,0)" + }, + "E27": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(3,\"price_total\",\"#product_categ_id\",10)-ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",10)))/ODOO.PIVOT(8,\"price_total\",\"product_categ_id\",ODOO.PIVOT.HEADER(3,\"#product_categ_id_int\",10)),0)" + }, + "F18": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(4,\"#product_id\",1)" + }, + "G18": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(4,\"price_total\",\"#product_id\",1)" + }, + "H18": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(4,\"product_qty\",\"#product_id\",1)" + }, + "I18": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(4,\"price_total\",\"#product_id\",1)/Data!B1,0)" + }, + "J18": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(4,\"price_total\",\"#product_id\",1)-ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",1)))/ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",1)),0)" + }, + "F19": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(4,\"#product_id\",2)" + }, + "G19": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(4,\"price_total\",\"#product_id\",2)" + }, + "H19": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(4,\"product_qty\",\"#product_id\",2)" + }, + "I19": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(4,\"price_total\",\"#product_id\",2)/Data!B1,0)" + }, + "J19": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(4,\"price_total\",\"#product_id\",2)-ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",2)))/ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",2)),0)" + }, + "F20": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(4,\"#product_id\",3)" + }, + "G20": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(4,\"price_total\",\"#product_id\",3)" + }, + "H20": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(4,\"product_qty\",\"#product_id\",3)" + }, + "I20": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(4,\"price_total\",\"#product_id\",3)/Data!B1,0)" + }, + "J20": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(4,\"price_total\",\"#product_id\",3)-ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",3)))/ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",3)),0)" + }, + "F21": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(4,\"#product_id\",4)" + }, + "G21": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(4,\"price_total\",\"#product_id\",4)" + }, + "H21": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(4,\"product_qty\",\"#product_id\",4)" + }, + "I21": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(4,\"price_total\",\"#product_id\",4)/Data!B1,0)" + }, + "J21": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(4,\"price_total\",\"#product_id\",4)-ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",4)))/ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",4)),0)" + }, + "F22": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(4,\"#product_id\",5)" + }, + "G22": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(4,\"price_total\",\"#product_id\",5)" + }, + "H22": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(4,\"product_qty\",\"#product_id\",5)" + }, + "I22": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(4,\"price_total\",\"#product_id\",5)/Data!B1,0)" + }, + "J22": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(4,\"price_total\",\"#product_id\",5)-ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",5)))/ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",5)),0)" + }, + "F23": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(4,\"#product_id\",6)" + }, + "G23": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(4,\"price_total\",\"#product_id\",6)" + }, + "H23": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(4,\"product_qty\",\"#product_id\",6)" + }, + "I23": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(4,\"price_total\",\"#product_id\",6)/Data!B1,0)" + }, + "J23": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(4,\"price_total\",\"#product_id\",6)-ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",6)))/ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",6)),0)" + }, + "F24": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(4,\"#product_id\",7)" + }, + "G24": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(4,\"price_total\",\"#product_id\",7)" + }, + "H24": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(4,\"product_qty\",\"#product_id\",7)" + }, + "I24": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(4,\"price_total\",\"#product_id\",7)/Data!B1,0)" + }, + "J24": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(4,\"price_total\",\"#product_id\",7)-ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",7)))/ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",7)),0)" + }, + "F25": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(4,\"#product_id\",8)" + }, + "G25": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(4,\"price_total\",\"#product_id\",8)" + }, + "H25": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(4,\"product_qty\",\"#product_id\",8)" + }, + "I25": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(4,\"price_total\",\"#product_id\",8)/Data!B1,0)" + }, + "J25": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(4,\"price_total\",\"#product_id\",8)-ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",8)))/ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",8)),0)" + }, + "F26": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(4,\"#product_id\",9)" + }, + "G26": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(4,\"price_total\",\"#product_id\",9)" + }, + "H26": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(4,\"product_qty\",\"#product_id\",9)" + }, + "I26": { + "style": "5", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(4,\"price_total\",\"#product_id\",9)/Data!B1,0)" + }, + "J26": { + "style": "5", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(4,\"price_total\",\"#product_id\",9)-ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",9)))/ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",9)),0)" + }, + "F27": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(4,\"#product_id\",10)" + }, + "G27": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(4,\"price_total\",\"#product_id\",10)" + }, + "H27": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(4,\"product_qty\",\"#product_id\",10)" + }, + "I27": { + "style": "4", + "format": "3", + "content": "=IFERROR(ODOO.PIVOT(4,\"price_total\",\"#product_id\",10)/Data!B1,0)" + }, + "J27": { + "style": "4", + "format": "3", + "content": "=IFERROR((ODOO.PIVOT(4,\"price_total\",\"#product_id\",10)-ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",10)))/ODOO.PIVOT(9,\"price_total\",\"product_id\",ODOO.PIVOT.HEADER(4,\"#product_id_int\",10)),0)" + } + }, + "conditionalFormats": [], + "figures": [], + "areGridLinesVisible": false, + "isVisible": true + }, + { + "id": "data", + "name": "Data", + "colNumber": 3, + "rowNumber": 3, + "rows": {}, + "cols": { + "0": { + "size": 200 + }, + "1": { + "size": 140 + } + }, + "merges": [], + "cells": { + "A1": { + "content": "=_t(\"Total ventas\")" + }, + "B1": { + "content": "=ODOO.PIVOT(5,\"price_total\")" + } + }, + "conditionalFormats": [], + "figures": [], + "areGridLinesVisible": true, + "isVisible": false + } + ], + "entities": {}, + "styles": { + "1": { + "bold": true, + "fontSize": 22, + "textColor": "#2E7D32" + }, + "2": { + "bold": true, + "fontSize": 14, + "textColor": "#333333" + }, + "3": { + "bold": true, + "fillColor": "#EFEFEF", + "textColor": "#333333", + "align": "center" + }, + "4": { + "fillColor": "#F8F8F8" + }, + "5": {} + }, + "formats": { + "1": "#,##0.00 [$€]", + "2": "#,##0", + "3": "0.0%" + }, + "borders": { + "1": { + "bottom": [ + "thin", + "#000" + ] + } + }, + "revisionId": "START_REVISION", + "chartOdooMenusReferences": {}, + "odooVersion": 1, + "lists": {}, + "listNextId": 1, + "pivots": { + "1": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ], + [ + "categ_root_id", + "!=", + false + ] + ], + "id": "1", + "measures": [ + { + "field": "price_total" + }, + { + "field": "order_id" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "categ_root_id", + "categ_root_id_int" + ], + "name": "Top categorías", + "sortedColumn": { + "groupId": [ + [], + [] + ], + "measure": "price_total", + "order": "desc" + } + }, + "2": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ], + [ + "categ_parent_id", + "!=", + false + ] + ], + "id": "2", + "measures": [ + { + "field": "price_total" + }, + { + "field": "order_id" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "categ_parent_id", + "categ_parent_id_int" + ], + "name": "Top familias", + "sortedColumn": { + "groupId": [ + [], + [] + ], + "measure": "price_total", + "order": "desc" + } + }, + "3": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ], + [ + "product_categ_id", + "!=", + false + ] + ], + "id": "3", + "measures": [ + { + "field": "price_total" + }, + { + "field": "order_id" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "product_categ_id", + "product_categ_id_int" + ], + "name": "Top subfamilias", + "sortedColumn": { + "groupId": [ + [], + [] + ], + "measure": "price_total", + "order": "desc" + } + }, + "4": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ], + [ + "product_id", + "!=", + false + ] + ], + "id": "4", + "measures": [ + { + "field": "price_total" + }, + { + "field": "product_qty" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "product_id", + "product_id_int" + ], + "name": "Top productos", + "sortedColumn": { + "groupId": [ + [], + [] + ], + "measure": "price_total", + "order": "desc" + } + }, + "5": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ] + ], + "id": "5", + "measures": [ + { + "field": "price_total" + } + ], + "model": "report.pos.order", + "rowGroupBys": [], + "name": "Total ventas", + "sortedColumn": null + }, + "6": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ], + [ + "categ_root_id", + "!=", + false + ] + ], + "id": "6", + "measures": [ + { + "field": "price_total" + }, + { + "field": "order_id" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "categ_root_id" + ], + "name": "Top categorías - prev", + "sortedColumn": { + "groupId": [ + [], + [] + ], + "measure": "price_total", + "order": "desc" + } + }, + "7": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ], + [ + "categ_parent_id", + "!=", + false + ] + ], + "id": "7", + "measures": [ + { + "field": "price_total" + }, + { + "field": "order_id" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "categ_parent_id" + ], + "name": "Top familias - prev", + "sortedColumn": { + "groupId": [ + [], + [] + ], + "measure": "price_total", + "order": "desc" + } + }, + "8": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ], + [ + "product_categ_id", + "!=", + false + ] + ], + "id": "8", + "measures": [ + { + "field": "price_total" + }, + { + "field": "order_id" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "product_categ_id" + ], + "name": "Top subfamilias - prev", + "sortedColumn": { + "groupId": [ + [], + [] + ], + "measure": "price_total", + "order": "desc" + } + }, + "9": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ], + [ + "product_id", + "!=", + false + ] + ], + "id": "9", + "measures": [ + { + "field": "price_total" + }, + { + "field": "product_qty" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "product_id" + ], + "name": "Top productos - prev", + "sortedColumn": { + "groupId": [ + [], + [] + ], + "measure": "price_total", + "order": "desc" + } + }, + "10": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ] + ], + "id": "10", + "measures": [ + { + "field": "price_total" + } + ], + "model": "report.pos.order", + "rowGroupBys": [], + "name": "Total ventas - prev", + "sortedColumn": null + } + }, + "pivotNextId": 11, + "globalFilters": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "type": "date", + "label": "Period", + "defaultValue": {}, + "rangeType": "month", + "defaultsToCurrentPeriod": true, + "pivotFields": { + "1": { + "field": "date", + "type": "datetime", + "offset": 0 + }, + "2": { + "field": "date", + "type": "datetime", + "offset": 0 + }, + "3": { + "field": "date", + "type": "datetime", + "offset": 0 + }, + "4": { + "field": "date", + "type": "datetime", + "offset": 0 + }, + "5": { + "field": "date", + "type": "datetime", + "offset": 0 + }, + "6": { + "field": "date", + "type": "datetime", + "offset": -1 + }, + "7": { + "field": "date", + "type": "datetime", + "offset": -1 + }, + "8": { + "field": "date", + "type": "datetime", + "offset": -1 + }, + "9": { + "field": "date", + "type": "datetime", + "offset": -1 + }, + "10": { + "field": "date", + "type": "datetime", + "offset": -1 + } + }, + "listFields": {}, + "graphFields": {} + }, + { + "id": "22222222-2222-2222-2222-222222222222", + "type": "relation", + "label": "Categoría", + "modelName": "product.category", + "defaultValue": [], + "defaultValueDisplayNames": [], + "rangeType": "year", + "defaultsToCurrentPeriod": false, + "pivotFields": { + "1": { + "field": "product_categ_id", + "type": "many2one" + }, + "2": { + "field": "product_categ_id", + "type": "many2one" + }, + "3": { + "field": "product_categ_id", + "type": "many2one" + }, + "4": { + "field": "product_categ_id", + "type": "many2one" + }, + "5": { + "field": "product_categ_id", + "type": "many2one" + }, + "6": { + "field": "product_categ_id", + "type": "many2one" + }, + "7": { + "field": "product_categ_id", + "type": "many2one" + }, + "8": { + "field": "product_categ_id", + "type": "many2one" + }, + "9": { + "field": "product_categ_id", + "type": "many2one" + }, + "10": { + "field": "product_categ_id", + "type": "many2one" + } + }, + "listFields": {}, + "graphFields": {} + }, + { + "id": "33333333-3333-3333-3333-333333333333", + "type": "relation", + "label": "Producto", + "modelName": "product.product", + "defaultValue": [], + "defaultValueDisplayNames": [], + "rangeType": "year", + "defaultsToCurrentPeriod": false, + "pivotFields": { + "1": { + "field": "product_id", + "type": "many2one" + }, + "2": { + "field": "product_id", + "type": "many2one" + }, + "3": { + "field": "product_id", + "type": "many2one" + }, + "4": { + "field": "product_id", + "type": "many2one" + }, + "5": { + "field": "product_id", + "type": "many2one" + }, + "6": { + "field": "product_id", + "type": "many2one" + }, + "7": { + "field": "product_id", + "type": "many2one" + }, + "8": { + "field": "product_id", + "type": "many2one" + }, + "9": { + "field": "product_id", + "type": "many2one" + }, + "10": { + "field": "product_id", + "type": "many2one" + } + }, + "listFields": {}, + "graphFields": {} + } + ] +} diff --git a/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json b/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json new file mode 100644 index 0000000..ddb8b6d --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json @@ -0,0 +1,2758 @@ +{ + "version": 12, + "sheets": [ + { + "id": "dashboard", + "name": "Members", + "colNumber": 12, + "rowNumber": 100, + "rows": { + "0": { + "size": 40 + } + }, + "cols": { + "0": { + "size": 60 + }, + "1": { + "size": 220 + }, + "2": { + "size": 120 + }, + "3": { + "size": 120 + }, + "4": { + "size": 100 + }, + "5": { + "size": 130 + }, + "6": { + "size": 100 + }, + "7": { + "size": 50 + }, + "8": { + "size": 50 + }, + "9": { + "size": 50 + } + }, + "merges": [], + "cells": { + "A1": { + "style": 1, + "content": "=_t(\"Cooperative POS — Members vs Non-members\")" + }, + "A3": { + "style": 2, + "content": "=_t(\"KPIs de socias\")" + }, + "A11": { + "style": 2, + "content": "=_t(\"KPIs de no socias\")" + }, + "A19": { + "style": 2, + "content": "=_t(\"Evolución mensual — Socias vs no socias\")" + }, + "A33": { + "style": 2, + "content": "=_t(\"Contribución de las socias\")" + }, + "A41": { + "style": 2, + "content": "=_t(\"TOP 50 socias compradoras\")" + }, + "A42": { + "style": 3, + "content": "=_t(\"#\")", + "border": 1 + }, + "B42": { + "style": 3, + "content": "=_t(\"Socia\")", + "border": 1 + }, + "C42": { + "style": 3, + "content": "=_t(\"Ventas (€)\")", + "border": 1 + }, + "D42": { + "style": 3, + "content": "=_t(\"Tickets\")", + "border": 1 + }, + "E42": { + "style": 3, + "content": "=_t(\"Unidades\")", + "border": 1 + }, + "F42": { + "style": 3, + "content": "=_t(\"Ticket medio (€)\")", + "border": 1 + }, + "G42": { + "style": 3, + "content": "=_t(\"UPT\")", + "border": 1 + }, + "A43": { + "style": "5", + "content": "1" + }, + "B43": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",1)" + }, + "C43": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",1)" + }, + "D43": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",1)" + }, + "E43": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",1)" + }, + "F43": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",1)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",1),0)" + }, + "G43": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",1)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",1),0)" + }, + "A44": { + "style": "4", + "content": "2" + }, + "B44": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",2)" + }, + "C44": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",2)" + }, + "D44": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",2)" + }, + "E44": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",2)" + }, + "F44": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",2)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",2),0)" + }, + "G44": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",2)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",2),0)" + }, + "A45": { + "style": "5", + "content": "3" + }, + "B45": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",3)" + }, + "C45": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",3)" + }, + "D45": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",3)" + }, + "E45": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",3)" + }, + "F45": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",3)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",3),0)" + }, + "G45": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",3)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",3),0)" + }, + "A46": { + "style": "4", + "content": "4" + }, + "B46": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",4)" + }, + "C46": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",4)" + }, + "D46": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",4)" + }, + "E46": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",4)" + }, + "F46": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",4)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",4),0)" + }, + "G46": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",4)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",4),0)" + }, + "A47": { + "style": "5", + "content": "5" + }, + "B47": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",5)" + }, + "C47": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",5)" + }, + "D47": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",5)" + }, + "E47": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",5)" + }, + "F47": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",5)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",5),0)" + }, + "G47": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",5)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",5),0)" + }, + "A48": { + "style": "4", + "content": "6" + }, + "B48": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",6)" + }, + "C48": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",6)" + }, + "D48": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",6)" + }, + "E48": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",6)" + }, + "F48": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",6)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",6),0)" + }, + "G48": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",6)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",6),0)" + }, + "A49": { + "style": "5", + "content": "7" + }, + "B49": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",7)" + }, + "C49": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",7)" + }, + "D49": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",7)" + }, + "E49": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",7)" + }, + "F49": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",7)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",7),0)" + }, + "G49": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",7)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",7),0)" + }, + "A50": { + "style": "4", + "content": "8" + }, + "B50": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",8)" + }, + "C50": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",8)" + }, + "D50": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",8)" + }, + "E50": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",8)" + }, + "F50": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",8)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",8),0)" + }, + "G50": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",8)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",8),0)" + }, + "A51": { + "style": "5", + "content": "9" + }, + "B51": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",9)" + }, + "C51": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",9)" + }, + "D51": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",9)" + }, + "E51": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",9)" + }, + "F51": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",9)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",9),0)" + }, + "G51": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",9)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",9),0)" + }, + "A52": { + "style": "4", + "content": "10" + }, + "B52": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",10)" + }, + "C52": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",10)" + }, + "D52": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",10)" + }, + "E52": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",10)" + }, + "F52": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",10)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",10),0)" + }, + "G52": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",10)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",10),0)" + }, + "A53": { + "style": "5", + "content": "11" + }, + "B53": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",11)" + }, + "C53": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",11)" + }, + "D53": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",11)" + }, + "E53": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",11)" + }, + "F53": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",11)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",11),0)" + }, + "G53": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",11)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",11),0)" + }, + "A54": { + "style": "4", + "content": "12" + }, + "B54": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",12)" + }, + "C54": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",12)" + }, + "D54": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",12)" + }, + "E54": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",12)" + }, + "F54": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",12)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",12),0)" + }, + "G54": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",12)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",12),0)" + }, + "A55": { + "style": "5", + "content": "13" + }, + "B55": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",13)" + }, + "C55": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",13)" + }, + "D55": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",13)" + }, + "E55": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",13)" + }, + "F55": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",13)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",13),0)" + }, + "G55": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",13)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",13),0)" + }, + "A56": { + "style": "4", + "content": "14" + }, + "B56": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",14)" + }, + "C56": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",14)" + }, + "D56": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",14)" + }, + "E56": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",14)" + }, + "F56": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",14)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",14),0)" + }, + "G56": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",14)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",14),0)" + }, + "A57": { + "style": "5", + "content": "15" + }, + "B57": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",15)" + }, + "C57": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",15)" + }, + "D57": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",15)" + }, + "E57": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",15)" + }, + "F57": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",15)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",15),0)" + }, + "G57": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",15)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",15),0)" + }, + "A58": { + "style": "4", + "content": "16" + }, + "B58": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",16)" + }, + "C58": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",16)" + }, + "D58": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",16)" + }, + "E58": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",16)" + }, + "F58": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",16)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",16),0)" + }, + "G58": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",16)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",16),0)" + }, + "A59": { + "style": "5", + "content": "17" + }, + "B59": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",17)" + }, + "C59": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",17)" + }, + "D59": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",17)" + }, + "E59": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",17)" + }, + "F59": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",17)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",17),0)" + }, + "G59": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",17)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",17),0)" + }, + "A60": { + "style": "4", + "content": "18" + }, + "B60": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",18)" + }, + "C60": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",18)" + }, + "D60": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",18)" + }, + "E60": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",18)" + }, + "F60": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",18)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",18),0)" + }, + "G60": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",18)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",18),0)" + }, + "A61": { + "style": "5", + "content": "19" + }, + "B61": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",19)" + }, + "C61": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",19)" + }, + "D61": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",19)" + }, + "E61": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",19)" + }, + "F61": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",19)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",19),0)" + }, + "G61": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",19)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",19),0)" + }, + "A62": { + "style": "4", + "content": "20" + }, + "B62": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",20)" + }, + "C62": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",20)" + }, + "D62": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",20)" + }, + "E62": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",20)" + }, + "F62": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",20)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",20),0)" + }, + "G62": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",20)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",20),0)" + }, + "A63": { + "style": "5", + "content": "21" + }, + "B63": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",21)" + }, + "C63": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",21)" + }, + "D63": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",21)" + }, + "E63": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",21)" + }, + "F63": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",21)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",21),0)" + }, + "G63": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",21)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",21),0)" + }, + "A64": { + "style": "4", + "content": "22" + }, + "B64": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",22)" + }, + "C64": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",22)" + }, + "D64": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",22)" + }, + "E64": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",22)" + }, + "F64": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",22)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",22),0)" + }, + "G64": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",22)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",22),0)" + }, + "A65": { + "style": "5", + "content": "23" + }, + "B65": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",23)" + }, + "C65": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",23)" + }, + "D65": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",23)" + }, + "E65": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",23)" + }, + "F65": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",23)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",23),0)" + }, + "G65": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",23)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",23),0)" + }, + "A66": { + "style": "4", + "content": "24" + }, + "B66": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",24)" + }, + "C66": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",24)" + }, + "D66": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",24)" + }, + "E66": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",24)" + }, + "F66": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",24)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",24),0)" + }, + "G66": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",24)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",24),0)" + }, + "A67": { + "style": "5", + "content": "25" + }, + "B67": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",25)" + }, + "C67": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",25)" + }, + "D67": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",25)" + }, + "E67": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",25)" + }, + "F67": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",25)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",25),0)" + }, + "G67": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",25)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",25),0)" + }, + "A68": { + "style": "4", + "content": "26" + }, + "B68": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",26)" + }, + "C68": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",26)" + }, + "D68": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",26)" + }, + "E68": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",26)" + }, + "F68": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",26)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",26),0)" + }, + "G68": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",26)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",26),0)" + }, + "A69": { + "style": "5", + "content": "27" + }, + "B69": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",27)" + }, + "C69": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",27)" + }, + "D69": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",27)" + }, + "E69": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",27)" + }, + "F69": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",27)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",27),0)" + }, + "G69": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",27)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",27),0)" + }, + "A70": { + "style": "4", + "content": "28" + }, + "B70": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",28)" + }, + "C70": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",28)" + }, + "D70": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",28)" + }, + "E70": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",28)" + }, + "F70": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",28)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",28),0)" + }, + "G70": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",28)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",28),0)" + }, + "A71": { + "style": "5", + "content": "29" + }, + "B71": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",29)" + }, + "C71": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",29)" + }, + "D71": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",29)" + }, + "E71": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",29)" + }, + "F71": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",29)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",29),0)" + }, + "G71": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",29)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",29),0)" + }, + "A72": { + "style": "4", + "content": "30" + }, + "B72": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",30)" + }, + "C72": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",30)" + }, + "D72": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",30)" + }, + "E72": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",30)" + }, + "F72": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",30)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",30),0)" + }, + "G72": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",30)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",30),0)" + }, + "A73": { + "style": "5", + "content": "31" + }, + "B73": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",31)" + }, + "C73": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",31)" + }, + "D73": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",31)" + }, + "E73": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",31)" + }, + "F73": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",31)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",31),0)" + }, + "G73": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",31)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",31),0)" + }, + "A74": { + "style": "4", + "content": "32" + }, + "B74": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",32)" + }, + "C74": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",32)" + }, + "D74": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",32)" + }, + "E74": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",32)" + }, + "F74": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",32)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",32),0)" + }, + "G74": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",32)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",32),0)" + }, + "A75": { + "style": "5", + "content": "33" + }, + "B75": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",33)" + }, + "C75": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",33)" + }, + "D75": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",33)" + }, + "E75": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",33)" + }, + "F75": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",33)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",33),0)" + }, + "G75": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",33)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",33),0)" + }, + "A76": { + "style": "4", + "content": "34" + }, + "B76": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",34)" + }, + "C76": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",34)" + }, + "D76": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",34)" + }, + "E76": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",34)" + }, + "F76": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",34)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",34),0)" + }, + "G76": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",34)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",34),0)" + }, + "A77": { + "style": "5", + "content": "35" + }, + "B77": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",35)" + }, + "C77": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",35)" + }, + "D77": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",35)" + }, + "E77": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",35)" + }, + "F77": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",35)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",35),0)" + }, + "G77": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",35)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",35),0)" + }, + "A78": { + "style": "4", + "content": "36" + }, + "B78": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",36)" + }, + "C78": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",36)" + }, + "D78": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",36)" + }, + "E78": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",36)" + }, + "F78": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",36)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",36),0)" + }, + "G78": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",36)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",36),0)" + }, + "A79": { + "style": "5", + "content": "37" + }, + "B79": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",37)" + }, + "C79": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",37)" + }, + "D79": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",37)" + }, + "E79": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",37)" + }, + "F79": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",37)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",37),0)" + }, + "G79": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",37)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",37),0)" + }, + "A80": { + "style": "4", + "content": "38" + }, + "B80": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",38)" + }, + "C80": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",38)" + }, + "D80": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",38)" + }, + "E80": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",38)" + }, + "F80": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",38)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",38),0)" + }, + "G80": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",38)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",38),0)" + }, + "A81": { + "style": "5", + "content": "39" + }, + "B81": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",39)" + }, + "C81": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",39)" + }, + "D81": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",39)" + }, + "E81": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",39)" + }, + "F81": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",39)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",39),0)" + }, + "G81": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",39)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",39),0)" + }, + "A82": { + "style": "4", + "content": "40" + }, + "B82": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",40)" + }, + "C82": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",40)" + }, + "D82": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",40)" + }, + "E82": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",40)" + }, + "F82": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",40)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",40),0)" + }, + "G82": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",40)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",40),0)" + }, + "A83": { + "style": "5", + "content": "41" + }, + "B83": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",41)" + }, + "C83": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",41)" + }, + "D83": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",41)" + }, + "E83": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",41)" + }, + "F83": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",41)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",41),0)" + }, + "G83": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",41)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",41),0)" + }, + "A84": { + "style": "4", + "content": "42" + }, + "B84": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",42)" + }, + "C84": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",42)" + }, + "D84": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",42)" + }, + "E84": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",42)" + }, + "F84": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",42)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",42),0)" + }, + "G84": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",42)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",42),0)" + }, + "A85": { + "style": "5", + "content": "43" + }, + "B85": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",43)" + }, + "C85": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",43)" + }, + "D85": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",43)" + }, + "E85": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",43)" + }, + "F85": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",43)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",43),0)" + }, + "G85": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",43)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",43),0)" + }, + "A86": { + "style": "4", + "content": "44" + }, + "B86": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",44)" + }, + "C86": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",44)" + }, + "D86": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",44)" + }, + "E86": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",44)" + }, + "F86": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",44)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",44),0)" + }, + "G86": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",44)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",44),0)" + }, + "A87": { + "style": "5", + "content": "45" + }, + "B87": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",45)" + }, + "C87": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",45)" + }, + "D87": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",45)" + }, + "E87": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",45)" + }, + "F87": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",45)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",45),0)" + }, + "G87": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",45)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",45),0)" + }, + "A88": { + "style": "4", + "content": "46" + }, + "B88": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",46)" + }, + "C88": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",46)" + }, + "D88": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",46)" + }, + "E88": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",46)" + }, + "F88": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",46)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",46),0)" + }, + "G88": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",46)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",46),0)" + }, + "A89": { + "style": "5", + "content": "47" + }, + "B89": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",47)" + }, + "C89": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",47)" + }, + "D89": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",47)" + }, + "E89": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",47)" + }, + "F89": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",47)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",47),0)" + }, + "G89": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",47)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",47),0)" + }, + "A90": { + "style": "4", + "content": "48" + }, + "B90": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",48)" + }, + "C90": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",48)" + }, + "D90": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",48)" + }, + "E90": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",48)" + }, + "F90": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",48)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",48),0)" + }, + "G90": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",48)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",48),0)" + }, + "A91": { + "style": "5", + "content": "49" + }, + "B91": { + "style": "5", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",49)" + }, + "C91": { + "style": "5", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",49)" + }, + "D91": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",49)" + }, + "E91": { + "style": "5", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",49)" + }, + "F91": { + "style": "5", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",49)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",49),0)" + }, + "G91": { + "style": "5", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",49)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",49),0)" + }, + "A92": { + "style": "4", + "content": "50" + }, + "B92": { + "style": "4", + "content": "=ODOO.PIVOT.HEADER(3,\"#partner_id\",50)" + }, + "C92": { + "style": "4", + "format": "1", + "content": "=ODOO.PIVOT(3,\"price_total\",\"#partner_id\",50)" + }, + "D92": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"order_id\",\"#partner_id\",50)" + }, + "E92": { + "style": "4", + "format": "2", + "content": "=ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",50)" + }, + "F92": { + "style": "4", + "format": "1", + "content": "=IFERROR(ODOO.PIVOT(3,\"price_total\",\"#partner_id\",50)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",50),0)" + }, + "G92": { + "style": "4", + "format": "2", + "content": "=IFERROR(ODOO.PIVOT(3,\"product_qty\",\"#partner_id\",50)/ODOO.PIVOT(3,\"order_id\",\"#partner_id\",50),0)" + } + }, + "conditionalFormats": [], + "figures": [ + { + "id": "kpi-soc-1", + "x": 0, + "y": 60, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Ventas socias (€)", + "keyValue": "Data!D1", + "baseline": "Data!E1", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-soc-2", + "x": 190, + "y": 60, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Tickets socias", + "keyValue": "Data!D2", + "baseline": "Data!E2", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-soc-3", + "x": 380, + "y": 60, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Ticket medio socias (€)", + "keyValue": "Data!D3", + "baseline": "Data!E3", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-soc-4", + "x": 570, + "y": 60, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "UPT socias", + "keyValue": "Data!D4", + "baseline": "Data!E4", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-soc-5", + "x": 760, + "y": 60, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Compradoras distintas (socias)", + "keyValue": "Data!D5", + "baseline": "Data!E5", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-soc-6", + "x": 950, + "y": 60, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Frecuencia socias", + "keyValue": "Data!D6", + "baseline": "Data!E6", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-nsoc-1", + "x": 0, + "y": 210, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Ventas no socias (€)", + "keyValue": "Data!H1", + "baseline": "Data!I1", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-nsoc-2", + "x": 190, + "y": 210, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Tickets no socias", + "keyValue": "Data!H2", + "baseline": "Data!I2", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-nsoc-3", + "x": 380, + "y": 210, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Ticket medio no socias (€)", + "keyValue": "Data!H3", + "baseline": "Data!I3", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-nsoc-4", + "x": 570, + "y": 210, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "UPT no socias", + "keyValue": "Data!H4", + "baseline": "Data!I4", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-nsoc-5", + "x": 760, + "y": 210, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Compradoras distintas (no socias)", + "keyValue": "Data!H5", + "baseline": "Data!I5", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "chart-monthly-evolution", + "x": 0, + "y": 360, + "width": 1140, + "height": 280, + "tag": "chart", + "data": { + "title": "Evolución mensual — Socias vs no socias (€)", + "background": "#FFFFFF", + "legendPosition": "bottom", + "metaData": { + "groupBy": [ + "date:month" + ], + "measure": "price_total", + "order": null, + "resModel": "report.pos.order" + }, + "searchParams": { + "comparison": null, + "context": { + "group_by": [ + "date:month" + ], + "group_by_no_leaf": 1 + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ] + ], + "groupBy": [ + "date:month" + ], + "orderBy": [] + }, + "type": "odoo_line", + "verticalAxisPosition": "left" + } + }, + { + "id": "kpi-contrib-1", + "x": 0, + "y": 680, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Ventas socias (€)", + "keyValue": "Data!D1", + "baseline": "Data!E1", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-contrib-2", + "x": 190, + "y": 680, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Cuota de compra", + "keyValue": "Data!D10", + "baseline": "Data!E10", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-contrib-3", + "x": 380, + "y": 680, + "width": 180, + "height": 110, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Concentración de ventas", + "keyValue": "Data!D11", + "baseline": "Data!E11", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + } + ], + "areGridLinesVisible": false, + "isVisible": true + }, + { + "id": "data", + "name": "Data", + "colNumber": 10, + "rowNumber": 15, + "rows": {}, + "cols": { + "0": { + "size": 220 + }, + "1": { + "size": 140 + }, + "2": { + "size": 140 + }, + "3": { + "size": 140 + }, + "4": { + "size": 140 + }, + "5": { + "size": 140 + }, + "6": { + "size": 140 + }, + "7": { + "size": 140 + }, + "8": { + "size": 140 + } + }, + "merges": [], + "cells": { + "A1": { + "content": "=_t(\"Socias - Ventas\")" + }, + "B1": { + "content": "=ODOO.PIVOT(1,\"price_total\",\"is_member_at_date\",TRUE)" + }, + "C1": { + "content": "=ODOO.PIVOT(4,\"price_total\",\"is_member_at_date\",TRUE)" + }, + "D1": { + "content": "=FORMAT.LARGE.NUMBER(B1)" + }, + "E1": { + "content": "=FORMAT.LARGE.NUMBER(C1)" + }, + "F1": { + "content": "=ODOO.PIVOT(1,\"price_total\",\"is_member_at_date\",FALSE)" + }, + "G1": { + "content": "=ODOO.PIVOT(4,\"price_total\",\"is_member_at_date\",FALSE)" + }, + "H1": { + "content": "=FORMAT.LARGE.NUMBER(F1)" + }, + "I1": { + "content": "=FORMAT.LARGE.NUMBER(G1)" + }, + "A2": { + "content": "=_t(\"Socias - Tickets\")" + }, + "B2": { + "content": "=ODOO.PIVOT(1,\"order_id\",\"is_member_at_date\",TRUE)" + }, + "C2": { + "content": "=ODOO.PIVOT(4,\"order_id\",\"is_member_at_date\",TRUE)" + }, + "D2": { + "content": "=FORMAT.LARGE.NUMBER(B2)" + }, + "E2": { + "content": "=FORMAT.LARGE.NUMBER(C2)" + }, + "F2": { + "content": "=ODOO.PIVOT(1,\"order_id\",\"is_member_at_date\",FALSE)" + }, + "G2": { + "content": "=ODOO.PIVOT(4,\"order_id\",\"is_member_at_date\",FALSE)" + }, + "H2": { + "content": "=FORMAT.LARGE.NUMBER(F2)" + }, + "I2": { + "content": "=FORMAT.LARGE.NUMBER(G2)" + }, + "A3": { + "content": "=_t(\"Socias - Ticket medio\")" + }, + "B3": { + "content": "=IFERROR(B1/B2,0)" + }, + "C3": { + "content": "=IFERROR(C1/C2,0)" + }, + "D3": { + "content": "=ROUND(B3,2)" + }, + "E3": { + "content": "=ROUND(C3,2)" + }, + "F3": { + "content": "=IFERROR(F1/F2,0)" + }, + "G3": { + "content": "=IFERROR(G1/G2,0)" + }, + "H3": { + "content": "=ROUND(F3,2)" + }, + "I3": { + "content": "=ROUND(G3,2)" + }, + "A4": { + "content": "=_t(\"Socias - UPT\")" + }, + "B4": { + "content": "=IFERROR(ODOO.PIVOT(1,\"product_qty\",\"is_member_at_date\",TRUE)/B2,0)" + }, + "C4": { + "content": "=IFERROR(ODOO.PIVOT(4,\"product_qty\",\"is_member_at_date\",TRUE)/C2,0)" + }, + "D4": { + "content": "=ROUND(B4,2)" + }, + "E4": { + "content": "=ROUND(C4,2)" + }, + "F4": { + "content": "=IFERROR(ODOO.PIVOT(1,\"product_qty\",\"is_member_at_date\",FALSE)/F2,0)" + }, + "G4": { + "content": "=IFERROR(ODOO.PIVOT(4,\"product_qty\",\"is_member_at_date\",FALSE)/G2,0)" + }, + "H4": { + "content": "=ROUND(F4,2)" + }, + "I4": { + "content": "=ROUND(G4,2)" + }, + "A5": { + "content": "=_t(\"Socias - Compradoras distintas\")" + }, + "B5": { + "content": "=ODOO.PIVOT(1,\"partner_id\",\"is_member_at_date\",TRUE)" + }, + "C5": { + "content": "=ODOO.PIVOT(4,\"partner_id\",\"is_member_at_date\",TRUE)" + }, + "D5": { + "content": "=FORMAT.LARGE.NUMBER(B5)" + }, + "E5": { + "content": "=FORMAT.LARGE.NUMBER(C5)" + }, + "F5": { + "content": "=ODOO.PIVOT(1,\"partner_id\",\"is_member_at_date\",FALSE)" + }, + "G5": { + "content": "=ODOO.PIVOT(4,\"partner_id\",\"is_member_at_date\",FALSE)" + }, + "H5": { + "content": "=FORMAT.LARGE.NUMBER(F5)" + }, + "I5": { + "content": "=FORMAT.LARGE.NUMBER(G5)" + }, + "A6": { + "content": "=_t(\"Socias - Frecuencia\")" + }, + "B6": { + "content": "=IFERROR(B2/B5,0)" + }, + "C6": { + "content": "=IFERROR(C2/C5,0)" + }, + "D6": { + "content": "=ROUND(B6,2)" + }, + "E6": { + "content": "=ROUND(C6,2)" + }, + "A8": { + "content": "=_t(\"Total ventas\")" + }, + "B8": { + "content": "=ODOO.PIVOT(1,\"price_total\")" + }, + "C8": { + "content": "=ODOO.PIVOT(4,\"price_total\")" + }, + "D8": { + "content": "=FORMAT.LARGE.NUMBER(B8)" + }, + "E8": { + "content": "=FORMAT.LARGE.NUMBER(C8)" + }, + "A9": { + "content": "=_t(\"Total compradoras distintas\")" + }, + "B9": { + "content": "=ODOO.PIVOT(1,\"partner_id\")" + }, + "C9": { + "content": "=ODOO.PIVOT(4,\"partner_id\")" + }, + "D9": { + "content": "=FORMAT.LARGE.NUMBER(B9)" + }, + "E9": { + "content": "=FORMAT.LARGE.NUMBER(C9)" + }, + "A10": { + "content": "=_t(\"Cuota de compra %\")" + }, + "B10": { + "content": "=IFERROR(B1/B8,0)" + }, + "C10": { + "content": "=IFERROR(C1/C8,0)" + }, + "D10": { + "content": "=ROUND(B10*100,2)" + }, + "E10": { + "content": "=ROUND(C10*100,2)" + }, + "A11": { + "content": "=_t(\"Concentración de ventas %\")" + }, + "B11": { + "content": "=IFERROR(B5/B9,0)" + }, + "C11": { + "content": "=IFERROR(C5/C9,0)" + }, + "D11": { + "content": "=ROUND(B11*100,2)" + }, + "E11": { + "content": "=ROUND(C11*100,2)" + } + }, + "conditionalFormats": [], + "figures": [], + "areGridLinesVisible": true, + "isVisible": false + } + ], + "entities": {}, + "styles": { + "1": { + "bold": true, + "fontSize": 22, + "textColor": "#2E7D32" + }, + "2": { + "bold": true, + "fontSize": 14, + "textColor": "#333333" + }, + "3": { + "bold": true, + "fillColor": "#EFEFEF", + "textColor": "#333333", + "align": "center" + }, + "4": { + "fillColor": "#F8F8F8" + }, + "5": {} + }, + "formats": { + "1": "#,##0.00 [$€]", + "2": "#,##0" + }, + "borders": { + "1": { + "bottom": [ + "thin", + "#000" + ] + } + }, + "revisionId": "START_REVISION", + "chartOdooMenusReferences": {}, + "odooVersion": 1, + "lists": {}, + "listNextId": 1, + "pivots": { + "1": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ] + ], + "id": "1", + "measures": [ + { + "field": "price_total" + }, + { + "field": "order_id" + }, + { + "field": "product_qty" + }, + { + "field": "partner_id" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "is_member_at_date" + ], + "name": "TPV por condición de socia", + "sortedColumn": null + }, + "2": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ] + ], + "id": "2", + "measures": [ + { + "field": "price_total" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "date:month" + ], + "name": "Evolución mensual por condición de socia", + "sortedColumn": null + }, + "3": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ], + [ + "is_member_at_date", + "=", + true + ] + ], + "id": "3", + "measures": [ + { + "field": "price_total" + }, + { + "field": "order_id" + }, + { + "field": "product_qty" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "partner_id" + ], + "name": "Mejores compradoras (socias)", + "sortedColumn": { + "groupId": [ + [], + [] + ], + "measure": "price_total", + "order": "desc" + } + }, + "4": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ] + ], + "id": "4", + "measures": [ + { + "field": "price_total" + }, + { + "field": "order_id" + }, + { + "field": "product_qty" + }, + { + "field": "partner_id" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "is_member_at_date" + ], + "name": "TPV por condición de socia - previous", + "sortedColumn": null + }, + "5": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ] + ], + "id": "5", + "measures": [ + { + "field": "price_total" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "date:month" + ], + "name": "Evolución mensual - previous", + "sortedColumn": null + }, + "6": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ], + [ + "is_member_at_date", + "=", + true + ] + ], + "id": "6", + "measures": [ + { + "field": "price_total" + }, + { + "field": "order_id" + }, + { + "field": "product_qty" + } + ], + "model": "report.pos.order", + "rowGroupBys": [ + "partner_id" + ], + "name": "Mejores compradoras - previous", + "sortedColumn": { + "groupId": [ + [], + [] + ], + "measure": "price_total", + "order": "desc" + } + } + }, + "pivotNextId": 7, + "globalFilters": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "type": "date", + "label": "Period", + "defaultValue": {}, + "rangeType": "month", + "defaultsToCurrentPeriod": true, + "pivotFields": { + "1": { + "field": "date", + "type": "datetime", + "offset": 0 + }, + "2": { + "field": "date", + "type": "datetime", + "offset": 0 + }, + "3": { + "field": "date", + "type": "datetime", + "offset": 0 + }, + "4": { + "field": "date", + "type": "datetime", + "offset": -1 + }, + "5": { + "field": "date", + "type": "datetime", + "offset": -1 + }, + "6": { + "field": "date", + "type": "datetime", + "offset": -1 + } + }, + "listFields": {}, + "graphFields": { + "chart-monthly-evolution": { + "field": "date", + "type": "datetime", + "offset": 0 + } + } + }, + { + "id": "22222222-2222-2222-2222-222222222222", + "type": "relation", + "label": "Categoría", + "modelName": "product.category", + "defaultValue": [], + "defaultValueDisplayNames": [], + "rangeType": "year", + "defaultsToCurrentPeriod": false, + "pivotFields": { + "1": { + "field": "product_categ_id", + "type": "many2one" + }, + "2": { + "field": "product_categ_id", + "type": "many2one" + }, + "3": { + "field": "product_categ_id", + "type": "many2one" + }, + "4": { + "field": "product_categ_id", + "type": "many2one" + }, + "5": { + "field": "product_categ_id", + "type": "many2one" + }, + "6": { + "field": "product_categ_id", + "type": "many2one" + } + }, + "listFields": {}, + "graphFields": {} + }, + { + "id": "33333333-3333-3333-3333-333333333333", + "type": "relation", + "label": "Producto", + "modelName": "product.product", + "defaultValue": [], + "defaultValueDisplayNames": [], + "rangeType": "year", + "defaultsToCurrentPeriod": false, + "pivotFields": { + "1": { + "field": "product_id", + "type": "many2one" + }, + "2": { + "field": "product_id", + "type": "many2one" + }, + "3": { + "field": "product_id", + "type": "many2one" + }, + "4": { + "field": "product_id", + "type": "many2one" + }, + "5": { + "field": "product_id", + "type": "many2one" + }, + "6": { + "field": "product_id", + "type": "many2one" + } + }, + "listFields": {}, + "graphFields": {} + }, + { + "id": "44444444-4444-4444-4444-444444444444", + "type": "relation", + "label": "Socia", + "modelName": "res.partner", + "defaultValue": [], + "defaultValueDisplayNames": [], + "rangeType": "year", + "defaultsToCurrentPeriod": false, + "pivotFields": { + "1": { + "field": "partner_id", + "type": "many2one" + }, + "2": { + "field": "partner_id", + "type": "many2one" + }, + "3": { + "field": "partner_id", + "type": "many2one" + }, + "4": { + "field": "partner_id", + "type": "many2one" + }, + "5": { + "field": "partner_id", + "type": "many2one" + }, + "6": { + "field": "partner_id", + "type": "many2one" + } + }, + "listFields": {}, + "graphFields": {} + } + ] +} diff --git a/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json b/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json new file mode 100644 index 0000000..7cac554 --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json @@ -0,0 +1,367 @@ +{ + "version": 12, + "sheets": [ + { + "id": "dashboard", + "name": "Overview", + "colNumber": 26, + "rowNumber": 50, + "rows": { + "0": { + "size": 40 + } + }, + "cols": {}, + "merges": [], + "cells": { + "A1": { + "style": 1, + "content": "=_t(\"Cooperative POS — Overview\")" + }, + "A3": { + "style": 2, + "content": "=_t(\"Monthly evolution\")" + } + }, + "conditionalFormats": [], + "figures": [ + { + "id": "kpi-sales", + "x": 0, + "y": 60, + "width": 200, + "height": 120, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Sales (€)", + "keyValue": "Data!D1", + "baseline": "Data!E1", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-orders", + "x": 220, + "y": 60, + "width": 200, + "height": 120, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Orders", + "keyValue": "Data!D2", + "baseline": "Data!E2", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-ticket", + "x": 440, + "y": 60, + "width": 200, + "height": 120, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Average ticket (€)", + "keyValue": "Data!D3", + "baseline": "Data!E3", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "kpi-margin", + "x": 660, + "y": 60, + "width": 200, + "height": 120, + "tag": "chart", + "data": { + "type": "scorecard", + "title": "Gross margin (€)", + "keyValue": "Data!D4", + "baseline": "Data!E4", + "baselineDescr": "since last period", + "baselineMode": "percentage", + "baselineColorUp": "#00A04A", + "baselineColorDown": "#DC6965" + } + }, + { + "id": "chart-monthly-sales", + "x": 0, + "y": 220, + "width": 1000, + "height": 300, + "tag": "chart", + "data": { + "title": "Monthly sales (€)", + "background": "#FFFFFF", + "legendPosition": "none", + "metaData": { + "groupBy": [ + "date:month" + ], + "measure": "price_total", + "order": null, + "resModel": "report.pos.order" + }, + "searchParams": { + "comparison": null, + "context": { + "group_by": [ + "date:month" + ], + "group_by_no_leaf": 1 + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ] + ], + "groupBy": [ + "date:month" + ], + "orderBy": [] + }, + "type": "odoo_line", + "verticalAxisPosition": "left" + } + } + ], + "areGridLinesVisible": false, + "isVisible": true + }, + { + "id": "data", + "name": "Data", + "colNumber": 5, + "rowNumber": 10, + "rows": {}, + "cols": { + "0": { + "size": 200 + }, + "1": { + "size": 140 + }, + "2": { + "size": 140 + }, + "3": { + "size": 140 + }, + "4": { + "size": 140 + } + }, + "merges": [], + "cells": { + "A1": { + "content": "=_t(\"KPI - Sales\")" + }, + "A2": { + "content": "=_t(\"KPI - Orders\")" + }, + "A3": { + "content": "=_t(\"KPI - Average ticket\")" + }, + "A4": { + "content": "=_t(\"KPI - Margin\")" + }, + "B1": { + "content": "=ODOO.PIVOT(1,\"price_total\")" + }, + "B2": { + "content": "=ODOO.PIVOT(1,\"order_id\")" + }, + "B3": { + "content": "=IFERROR(B1/B2,0)" + }, + "B4": { + "content": "=ODOO.PIVOT(1,\"margin\")" + }, + "C1": { + "content": "=ODOO.PIVOT(2,\"price_total\")" + }, + "C2": { + "content": "=ODOO.PIVOT(2,\"order_id\")" + }, + "C3": { + "content": "=IFERROR(C1/C2,0)" + }, + "C4": { + "content": "=ODOO.PIVOT(2,\"margin\")" + }, + "D1": { + "content": "=FORMAT.LARGE.NUMBER(B1)" + }, + "D2": { + "content": "=FORMAT.LARGE.NUMBER(B2)" + }, + "D3": { + "content": "=FORMAT.LARGE.NUMBER(B3)" + }, + "D4": { + "content": "=FORMAT.LARGE.NUMBER(B4)" + }, + "E1": { + "content": "=FORMAT.LARGE.NUMBER(C1)" + }, + "E2": { + "content": "=FORMAT.LARGE.NUMBER(C2)" + }, + "E3": { + "content": "=FORMAT.LARGE.NUMBER(C3)" + }, + "E4": { + "content": "=FORMAT.LARGE.NUMBER(C4)" + } + }, + "conditionalFormats": [], + "figures": [], + "areGridLinesVisible": true, + "isVisible": false + } + ], + "entities": {}, + "styles": { + "1": { + "bold": true, + "fontSize": 22, + "textColor": "#2E7D32" + }, + "2": { + "bold": true, + "fontSize": 14, + "textColor": "#333333" + } + }, + "formats": { + "1": "#,##0.00 [$€]", + "2": "#,##0" + }, + "borders": {}, + "revisionId": "START_REVISION", + "chartOdooMenusReferences": {}, + "odooVersion": 1, + "lists": {}, + "listNextId": 1, + "pivots": { + "1": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ] + ], + "id": "1", + "measures": [ + { + "field": "price_total" + }, + { + "field": "margin" + }, + { + "field": "order_id" + } + ], + "model": "report.pos.order", + "rowGroupBys": [], + "name": "POS KPIs - current", + "sortedColumn": null + }, + "2": { + "colGroupBys": [], + "context": { + "group_by_no_leaf": 1, + "group_by": [] + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ] + ], + "id": "2", + "measures": [ + { + "field": "price_total" + }, + { + "field": "margin" + }, + { + "field": "order_id" + } + ], + "model": "report.pos.order", + "rowGroupBys": [], + "name": "POS KPIs - previous", + "sortedColumn": null + } + }, + "pivotNextId": 3, + "globalFilters": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "type": "date", + "label": "Period", + "defaultValue": {}, + "rangeType": "month", + "defaultsToCurrentPeriod": true, + "pivotFields": { + "1": { + "field": "date", + "type": "datetime", + "offset": 0 + }, + "2": { + "field": "date", + "type": "datetime", + "offset": -1 + } + }, + "listFields": {}, + "graphFields": { + "chart-monthly-sales": { + "field": "date", + "type": "datetime", + "offset": 0 + } + } + } + ] +} diff --git a/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json b/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json new file mode 100644 index 0000000..9fd9a09 --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json @@ -0,0 +1,221 @@ +{ + "version": 12, + "sheets": [ + { + "id": "dashboard", + "name": "Sales by time", + "colNumber": 26, + "rowNumber": 50, + "rows": { + "0": { + "size": 40 + } + }, + "cols": {}, + "merges": [], + "cells": { + "A1": { + "style": 1, + "content": "=_t(\"Cooperative POS — Sales by hours and days\")" + }, + "A3": { + "style": 2, + "content": "=_t(\"Sales by day of the week\")" + }, + "A20": { + "style": 2, + "content": "=_t(\"Sales by hour of the day\")" + } + }, + "conditionalFormats": [], + "figures": [ + { + "id": "chart-by-day", + "x": 0, + "y": 60, + "width": 1000, + "height": 280, + "tag": "chart", + "data": { + "title": "Sales by day of week", + "background": "#FFFFFF", + "legendPosition": "none", + "metaData": { + "groupBy": [ + "day_of_week" + ], + "measure": "price_total", + "order": null, + "resModel": "report.pos.order" + }, + "searchParams": { + "comparison": null, + "context": { + "group_by": [ + "day_of_week" + ], + "group_by_no_leaf": 1 + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ] + ], + "groupBy": [ + "day_of_week" + ], + "orderBy": [] + }, + "type": "odoo_line", + "verticalAxisPosition": "left" + } + }, + { + "id": "chart-by-hour", + "x": 0, + "y": 380, + "width": 1000, + "height": 280, + "tag": "chart", + "data": { + "title": "Sales by hour of day", + "background": "#FFFFFF", + "legendPosition": "none", + "metaData": { + "groupBy": [ + "hour_of_day" + ], + "measure": "price_total", + "order": null, + "resModel": "report.pos.order" + }, + "searchParams": { + "comparison": null, + "context": { + "group_by": [ + "hour_of_day" + ], + "group_by_no_leaf": 1 + }, + "domain": [ + [ + "state", + "in", + [ + "paid", + "done", + "invoiced" + ] + ] + ], + "groupBy": [ + "hour_of_day" + ], + "orderBy": [] + }, + "type": "odoo_line", + "verticalAxisPosition": "left" + } + } + ], + "areGridLinesVisible": false, + "isVisible": true + } + ], + "entities": {}, + "styles": { + "1": { + "bold": true, + "fontSize": 22, + "textColor": "#2E7D32" + }, + "2": { + "bold": true, + "fontSize": 14, + "textColor": "#333333" + } + }, + "formats": {}, + "borders": {}, + "revisionId": "START_REVISION", + "chartOdooMenusReferences": {}, + "odooVersion": 1, + "lists": {}, + "listNextId": 1, + "pivots": {}, + "pivotNextId": 1, + "globalFilters": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "type": "date", + "label": "Period", + "defaultValue": {}, + "rangeType": "month", + "defaultsToCurrentPeriod": true, + "pivotFields": {}, + "listFields": {}, + "graphFields": { + "chart-by-day": { + "field": "date", + "type": "datetime", + "offset": 0 + }, + "chart-by-hour": { + "field": "date", + "type": "datetime", + "offset": 0 + } + } + }, + { + "id": "22222222-2222-2222-2222-222222222222", + "type": "relation", + "label": "Product Category", + "modelName": "product.category", + "defaultValue": [], + "defaultValueDisplayNames": [], + "rangeType": "year", + "defaultsToCurrentPeriod": false, + "pivotFields": {}, + "listFields": {}, + "graphFields": { + "chart-by-day": { + "field": "product_categ_id", + "type": "many2one" + }, + "chart-by-hour": { + "field": "product_categ_id", + "type": "many2one" + } + } + }, + { + "id": "33333333-3333-3333-3333-333333333333", + "type": "relation", + "label": "Product", + "modelName": "product.product", + "defaultValue": [], + "defaultValueDisplayNames": [], + "rangeType": "year", + "defaultsToCurrentPeriod": false, + "pivotFields": {}, + "listFields": {}, + "graphFields": { + "chart-by-day": { + "field": "product_id", + "type": "many2one" + }, + "chart-by-hour": { + "field": "product_id", + "type": "many2one" + } + } + } + ] +} diff --git a/spreadsheet_dashboard_pos_cooperative/i18n/es.po b/spreadsheet_dashboard_pos_cooperative/i18n/es.po new file mode 100644 index 0000000..fcc380a --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/i18n/es.po @@ -0,0 +1,634 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * spreadsheet_dashboard_pos_cooperative +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-05 15:43+0000\n" +"PO-Revision-Date: 2026-06-05 15:43+0000\n" +"Last-Translator: Criptomart\n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: es\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Average ticket (€)" +msgstr "Ticket medio (€)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Categoría" +msgstr "Categoría" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Compradoras distintas (no socias)" +msgstr "Compradoras distintas (no socias)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Compradoras distintas (socias)" +msgstr "Compradoras distintas (socias)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Contribución de las socias" +msgstr "Contribución de las socias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Cooperative POS — Categories" +msgstr "TPV Cooperativa — Categorías" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Cooperative POS — Members vs Non-members" +msgstr "TPV Cooperativa — Socias vs no socias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Cooperative POS — Overview" +msgstr "TPV Cooperativa — Vista general" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Cooperative POS — Sales by hours and days" +msgstr "TPV Cooperativa — Ventas por horas y días" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Cuota %" +msgstr "Cuota %" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Cuota de compra" +msgstr "Cuota de compra" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Cuota de compra %" +msgstr "Cuota de compra %" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Evolución mensual por condición de socia" +msgstr "Evolución mensual por condición de socia" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Evolución mensual — Socias vs no socias" +msgstr "Evolución mensual — Socias vs no socias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Evolución mensual — Socias vs no socias (€)" +msgstr "Evolución mensual — Socias vs no socias (€)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Familia" +msgstr "Familia" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Frecuencia socias" +msgstr "Frecuencia socias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Gross margin (€)" +msgstr "Margen bruto (€)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "KPI - Average ticket" +msgstr "KPI - Ticket medio" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "KPI - Margin" +msgstr "KPI - Margen" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "KPI - Orders" +msgstr "KPI - Tickets" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "KPI - Sales" +msgstr "KPI - Ventas" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "KPIs de no socias" +msgstr "KPIs de no socias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "KPIs de socias" +msgstr "KPIs de socias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Mejores compradoras (socias)" +msgstr "Mejores compradoras (socias)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Monthly evolution" +msgstr "Evolución mensual" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Monthly sales (€)" +msgstr "Ventas mensuales (€)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Orders" +msgstr "Tickets" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "POS KPIs" +msgstr "POS KPIs" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Penetración socias" +msgstr "Penetración socias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Penetración socias %" +msgstr "Penetración socias %" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Period" +msgstr "Período" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Product" +msgstr "Producto" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Product Category" +msgstr "Categoría de producto" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Producto" +msgstr "Producto" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Sales (€)" +msgstr "Ventas (€)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Sales by day of the week" +msgstr "Ventas por día de la semana" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Sales by day of week" +msgstr "Ventas por día de la semana" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Sales by hour of day" +msgstr "Ventas por hora del día" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Sales by hour of the day" +msgstr "Ventas por hora del día" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socia" +msgstr "Socia" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socias - Compradoras distintas" +msgstr "Socias - Compradoras distintas" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socias - Frecuencia" +msgstr "Socias - Frecuencia" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socias - Ticket medio" +msgstr "Socias - Ticket medio" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socias - Tickets" +msgstr "Socias - Tickets" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socias - UPT" +msgstr "Socias - UPT" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socias - Ventas" +msgstr "Socias - Ventas" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Subfamilia" +msgstr "Subfamilia" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "TOP 50 socias compradoras" +msgstr "TOP 50 socias compradoras" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "TPV por condición de socia" +msgstr "TPV por condición de socia" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Ticket medio (€)" +msgstr "Ticket medio (€)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Ticket medio no socias (€)" +msgstr "Ticket medio no socias (€)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Ticket medio socias (€)" +msgstr "Ticket medio socias (€)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Tickets" +msgstr "Tickets" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Tickets no socias" +msgstr "Tickets no socias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Tickets socias" +msgstr "Tickets socias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top 10 categorías" +msgstr "Top 10 categorías" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top 10 familias" +msgstr "Top 10 familias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top 10 productos" +msgstr "Top 10 productos" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top 10 subfamilias" +msgstr "Top 10 subfamilias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top categorías" +msgstr "Top categorías" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top familias" +msgstr "Top familias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top productos" +msgstr "Top productos" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top subfamilias" +msgstr "Top subfamilias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Total compradoras distintas" +msgstr "Total compradoras distintas" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Total ventas" +msgstr "Total ventas" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "UPT" +msgstr "UPT" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "UPT no socias" +msgstr "UPT no socias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "UPT socias" +msgstr "UPT socias" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Unidades" +msgstr "Unidades" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Ventas (€)" +msgstr "Ventas (€)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Ventas no socias (€)" +msgstr "Ventas no socias (€)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Ventas socias (€)" +msgstr "Ventas socias (€)" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "en el período" +msgstr "en el período" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "in selected period" +msgstr "en el período seleccionado" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "sales / orders" +msgstr "ventas / tickets" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "socias compradoras / total" +msgstr "socias compradoras / total" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "socias únicas" +msgstr "socias únicas" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "tickets" +msgstr "tickets" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "tickets / compradora" +msgstr "tickets / compradora" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "uds / ticket" +msgstr "uds / ticket" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "ventas / tickets" +msgstr "ventas / tickets" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "ventas socias / total" +msgstr "ventas socias / total" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "únicas" +msgstr "únicas" diff --git a/spreadsheet_dashboard_pos_cooperative/i18n/spreadsheet_dashboard_pos_cooperative.pot b/spreadsheet_dashboard_pos_cooperative/i18n/spreadsheet_dashboard_pos_cooperative.pot new file mode 100644 index 0000000..b07097e --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/i18n/spreadsheet_dashboard_pos_cooperative.pot @@ -0,0 +1,633 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * spreadsheet_dashboard_pos_cooperative +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-05 15:43+0000\n" +"PO-Revision-Date: 2026-06-05 15:43+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_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Average ticket (€)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Categoría" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Compradoras distintas (no socias)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Compradoras distintas (socias)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Contribución de las socias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Cooperative POS — Categories" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Cooperative POS — Members vs Non-members" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Cooperative POS — Overview" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Cooperative POS — Sales by hours and days" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Cuota %" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Cuota de compra" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Cuota de compra %" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Evolución mensual por condición de socia" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Evolución mensual — Socias vs no socias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Evolución mensual — Socias vs no socias (€)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Familia" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Frecuencia socias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Gross margin (€)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "KPI - Average ticket" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "KPI - Margin" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "KPI - Orders" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "KPI - Sales" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "KPIs de no socias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "KPIs de socias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Mejores compradoras (socias)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Monthly evolution" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Monthly sales (€)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Orders" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "POS KPIs" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Penetración socias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Penetración socias %" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Period" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Product" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Product Category" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Producto" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "Sales (€)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Sales by day of the week" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Sales by day of week" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Sales by hour of day" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/time_dashboard.json:0 +#, python-format +msgid "Sales by hour of the day" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socia" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socias - Compradoras distintas" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socias - Frecuencia" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socias - Ticket medio" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socias - Tickets" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socias - UPT" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Socias - Ventas" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Subfamilia" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "TOP 50 socias compradoras" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "TPV por condición de socia" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Ticket medio (€)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Ticket medio no socias (€)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Ticket medio socias (€)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Tickets" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Tickets no socias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Tickets socias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top 10 categorías" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top 10 familias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top 10 productos" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top 10 subfamilias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top categorías" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top familias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top productos" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#, python-format +msgid "Top subfamilias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Total compradoras distintas" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Total ventas" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "UPT" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "UPT no socias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "UPT socias" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Unidades" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/categories_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Ventas (€)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Ventas no socias (€)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "Ventas socias (€)" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "en el período" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "in selected period" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "sales / orders" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "socias compradoras / total" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "socias únicas" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/overview_dashboard.json:0 +#, python-format +msgid "tickets" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "tickets / compradora" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "uds / ticket" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "ventas / tickets" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "ventas socias / total" +msgstr "" + +#. module: spreadsheet_dashboard_pos_cooperative +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_pos_cooperative/data/files/members_dashboard.json:0 +#, python-format +msgid "únicas" +msgstr "" diff --git a/spreadsheet_dashboard_pos_cooperative/readme/CONTRIBUTORS.rst b/spreadsheet_dashboard_pos_cooperative/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..c8ac156 --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Criptomart diff --git a/spreadsheet_dashboard_pos_cooperative/readme/DESCRIPTION.rst b/spreadsheet_dashboard_pos_cooperative/readme/DESCRIPTION.rst new file mode 100644 index 0000000..cb34354 --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/readme/DESCRIPTION.rst @@ -0,0 +1,23 @@ +Publishes spreadsheet-based dashboards for cooperative supermarkets +in Odoo's *Dashboards* menu, under a dedicated *Cooperative* group. + +The dashboards consume the data prepared by ``pos_dashboard_cooperative`` +(extended ``report.pos.order`` with member-at-date flag, category +hierarchy and time dimensions). They use Odoo's o-spreadsheet engine, +the same as Odoo Enterprise's official ``spreadsheet_dashboard_*`` +modules. + +Four dashboards ship out of the box: + +* **POS Overview** — headline KPIs (Sales, Orders, Average ticket, + Margin) and monthly evolution. +* **POS Members** — members vs non-members comparison, monthly + evolution chart, TOP 50 buying members. +* **POS Categories** — members contribution KPIs and TOP 10 families, + subfamilies and products. +* **POS Sales by Time** — sales by day of the week and hour of the + day. + +Each dashboard is a small hand-authored JSON file. They can be +redesigned visually inside o-spreadsheet (with ``spreadsheet_oca`` +installed) and re-exported; see USAGE for the workflow. diff --git a/spreadsheet_dashboard_pos_cooperative/readme/ROADMAP.rst b/spreadsheet_dashboard_pos_cooperative/readme/ROADMAP.rst new file mode 100644 index 0000000..091864f --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/readme/ROADMAP.rst @@ -0,0 +1,38 @@ +* Period-over-period comparisons on the scorecards (the BI brief shows + "X% desde el último periodo" arrows on every KPI). In o-spreadsheet + this is wired through ``baseline`` references plus a comparison + range on the date global filter. Postponed until the four dashboards + are validated by the customer in production. + +* Quarter-level selection (*Q1..Q4*) on the ``Period`` filter. The + o-spreadsheet date filter in Odoo 16 only accepts a single + ``rangeType`` per filter; we picked ``month`` because it is the + finest granularity. A second date filter with ``rangeType: + "quarter"`` would coexist visually but duplicate the year picker, + which the customer rejected. Revisit in Odoo 17 where the filter + UI allows choosing the granularity dynamically. + +* Rolling-window date filter (*Last 30 / 90 / 180 days*) as an + alternative to the month picker. Same limitation as above (one + ``rangeType`` per filter). + +* "Sales concentration" KPI on the Categories dashboard: share of + sales captured by the top 20% of buying members. The Top buyers + pivot already exists in the Members dashboard; only an aggregation + formula plus a scorecard would be needed. + +* Stricter definition of **Members penetration** as ``distinct member + buyers / total members in the cooperative`` (instead of the current + ``/ total buyers in the period``). Requires sourcing the total from + ``share.line`` rather than from ``report.pos.order``. + +* Translate ``spreadsheet.dashboard.name`` / ``spreadsheet.dashboard.group.name`` + so the entries in the *Dashboards* menu render in the user's + language (currently always English: *POS Overview*, *POS Members*, + etc.). Blocked upstream: the core ``name`` fields are not declared + ``translate=True``. Either patch the core fields downstream or rename + the records to the customer's primary language in the XML. + +* Add Galician, Catalan, French, Dutch and Croatian translations. + Spanish (``es.po``) ships out of the box. The other locales can be + produced from the same ``.pot`` by translating each ``msgstr``. diff --git a/spreadsheet_dashboard_pos_cooperative/readme/USAGE.rst b/spreadsheet_dashboard_pos_cooperative/readme/USAGE.rst new file mode 100644 index 0000000..5445324 --- /dev/null +++ b/spreadsheet_dashboard_pos_cooperative/readme/USAGE.rst @@ -0,0 +1,149 @@ +Open *Dashboards → Cooperative* in the Odoo backend. Four dashboards +are published: + +The workbook content (titles, KPI labels, table headers, scorecard +descriptions) is available in **English (source) and Spanish** out of +the box. Users with ``lang = es_ES`` will see all in-cell texts in +Spanish; the rest of the locales can be added by translating the +``i18n/spreadsheet_dashboard_pos_cooperative.pot`` template. + +Note: the dashboard names shown in the *Dashboards* menu sidebar +(*POS Overview*, *POS Members*, *POS Categories*, *POS Sales by +Time*) are not translatable because ``spreadsheet.dashboard.name`` is +not declared as a translatable field in Odoo core. + +POS Overview +============ + +Headline KPIs and monthly evolution for the whole store. + +* **Sales (€)** — sum of ``price_total`` over orders in the period. +* **Orders** — count of distinct ``order_id``. +* **Average ticket (€)** — ``Sales / Orders``. +* **Gross margin (€)** — sum of ``margin``. +* A line chart with monthly sales evolution. + +POS Members +=========== + +Detailed comparison between cooperative members and non-members. For +each group the dashboard shows: + +* Sales (€), Orders, Average ticket (€), UPT, Distinct buyers, and + (members only) Frequency. +* Monthly evolution chart with one line per group. +* TOP 50 buying members (ranked by sales), with sales, orders, units, + average ticket and UPT. + +POS Categories +============== + +Members contribution KPIs and the top product breakdowns. + +* **Members sales (€)** — sales from orders where the customer was a + cooperative member at the order date. +* **Purchase share** — ``members sales / total sales``. +* **Members penetration** — ``distinct members buyers / distinct total + buyers`` (see "Metric definitions" below for the caveat). +* TOP 10 product families (root categories) with sales, orders, share %. +* TOP 10 product subfamilies (parent categories) with the same columns. +* TOP 10 products with sales, units, share %. + +POS Sales by Time +================= + +* Line chart of sales by ISO day of the week (1 = Monday, 7 = Sunday). +* Line chart of sales by hour of the day (0..23). + +Global filters +============== + +Every dashboard ships a ``Period`` date filter. Members, Categories +and Sales by Time also ship ``Product Category``, ``Product`` and +(Members only) ``Member`` relation filters. Each filter is wired to +every pivot and chart on the dashboard, so changing one selection +updates the whole view. + +* **Period** — year picker plus a month dropdown + (January..December). Pick a year only to look at the full year + (e.g. *2026*, *2025*), or add a month to narrow the range (e.g. + *March 2026*). Defaults to the current month. +* **Product Category** — relation picker over ``product.category``. + Choose one or more categories to restrict every metric to products + of those categories. Empty = no restriction. +* **Product** — relation picker over ``product.product``. Useful to + audit a specific reference. +* **Member** — relation picker over ``res.partner``. Available only + on the Members dashboard; restricts the comparison and the TOP 50 + to the selected partners. + +Note: the o-spreadsheet date filter in Odoo 16 only accepts a single +``rangeType`` per filter (year, quarter or month). We chose ``month`` +because it is the finest granularity the customer requested; for +year-only views, just pick a year and leave the month dropdown +unset. Rolling ranges (*Last 30 / 90 / 180 days*) and quarter +selection (*Q1..Q4*) are not available on the spreadsheet filters; +both are handled in ``pos_dashboard_cooperative`` through the +standard search view on *Point of Sale → Reporting → Cooperative +Dashboards*, which exposes the usual Odoo date filters. + +Metric definitions +================== + +The BI brief from the customer left several KPIs without a precise +formulation. The dashboards use the following standard retail +definitions; revise them in the JSON if your cooperative prefers a +different convention. + +* **UPT** (Units per Ticket) = ``total units sold / number of + orders``. ``product_qty`` summed and divided by distinct + ``order_id``. +* **Frequency** = ``number of orders / number of distinct buyers``. + Measures how many tickets the average member buyer made in the + period. +* **Average ticket (€)** = ``total sales / number of orders``. POS has + no separate "average ticket value", so this metric matches the + "Ticket medio" and "Valor medio del ticket" rows in the BI brief. +* **Purchase share %** = ``members sales / total sales``. Share of + total revenue contributed by member-driven orders. +* **Members penetration %** = ``distinct member buyers / distinct + total buyers`` *in the selected period*. Note: a stricter definition + would be ``distinct member buyers / total members in the + cooperative`` (regardless of whether they bought). That denominator + is not exposed by ``report.pos.order``; a future iteration may add a + separate KPI sourced from ``share.line``. +* **Sales concentration** (not yet rendered) would be the share of + sales captured by the top X% of buying members, typically X = 20. + Easy to add later from the same ``Top buyers`` pivot. + +Iterating on the layouts +======================== + +Each dashboard is a ``data/files/*.json`` file shipped as a base64 +binary on a ``spreadsheet.dashboard`` record. They are intentionally +small and hand-authored to be diff-friendly. + +To redesign one of them visually: + +1. Install ``spreadsheet_oca`` (from OCA's ``spreadsheet`` repository) + on top of this module. It provides full o-spreadsheet editing in + the backend, including saving back to ``spreadsheet.dashboard``. +2. Open the dashboard in *Dashboards → Cooperative*. +3. Edit pivots, charts, styles and global filters from the + o-spreadsheet UI. The data sources are pre-wired against + ``report.pos.order`` and consume the dimensions exposed by + ``pos_dashboard_cooperative``: + + * ``is_member_at_date`` (boolean) — cooperative member flag at the + order date. + * ``categ_root_id`` / ``categ_parent_id`` — product family / + subfamily. + * ``hour_of_day`` / ``day_of_week`` — time distributions. + +4. Export the workbook to JSON. +5. Replace the matching ``data/files/.json`` and reinstall (or + upgrade) the module. + +Reference: the same workflow is used by Odoo core's +``spreadsheet_dashboard_sale``, ``spreadsheet_dashboard_purchase`` and +similar modules. From 0f2c8c0565bc2e6a68bcae81db926932d2c52e07 Mon Sep 17 00:00:00 2001 From: luis Date: Tue, 16 Jun 2026 10:56:18 +0200 Subject: [PATCH 19/27] add project_task_analytic_account_sync --- project_task_analytic_account_sync/README.md | 22 +++++ .../__init__.py | 1 + .../__manifest__.py | 20 +++++ .../models/__init__.py | 2 + .../models/project_project.py | 20 +++++ .../models/project_task.py | 42 ++++++++++ .../tests/__init__.py | 1 + ...test_project_task_analytic_account_sync.py | 82 +++++++++++++++++++ .../views/project_task_views.xml | 13 +++ 9 files changed, 203 insertions(+) create mode 100644 project_task_analytic_account_sync/README.md create mode 100644 project_task_analytic_account_sync/__init__.py create mode 100644 project_task_analytic_account_sync/__manifest__.py create mode 100644 project_task_analytic_account_sync/models/__init__.py create mode 100644 project_task_analytic_account_sync/models/project_project.py create mode 100644 project_task_analytic_account_sync/models/project_task.py create mode 100644 project_task_analytic_account_sync/tests/__init__.py create mode 100644 project_task_analytic_account_sync/tests/test_project_task_analytic_account_sync.py create mode 100644 project_task_analytic_account_sync/views/project_task_views.xml diff --git a/project_task_analytic_account_sync/README.md b/project_task_analytic_account_sync/README.md new file mode 100644 index 0000000..ca76024 --- /dev/null +++ b/project_task_analytic_account_sync/README.md @@ -0,0 +1,22 @@ +# Project Task Analytic Account Sync + +This module forces the analytic account of every project task to always be the same as the one defined on its project. It removes the possibility of manual divergences introduced by the `is_analytic_account_id_changed` flag in Odoo core. + +## Behavior + +- **Task creation**: When a task is created and assigned to a project, its `analytic_account_id` is set to the project's analytic account. +- **Project change**: When a task is moved to another project, its analytic account is updated to the new project's account. +- **Project analytic account change**: When a project's analytic account is modified, the change is propagated to **all** existing tasks (active and archived). +- **Manual override ignored**: Writing a different analytic account directly on a task is immediately reverted. +- **Analytic account auto-creation**: When Odoo auto-creates a project analytic account, the existing tasks are synchronized. + +## Usage + +No user action is required. The synchronization is transparent and enforced server-side. + +## Technical notes + +- The `analytic_account_id` field on `project.task` is kept as a computed stored field but the compute is overridden to always ignore `is_analytic_account_id_changed`. +- `project.project.write` detects changes in `analytic_account_id` and propagates them in batch. +- `project.task.write` detects changes in `project_id` or `analytic_account_id` and corrects the value immediately. +- The task form view makes the analytic account field readonly to avoid confusion. diff --git a/project_task_analytic_account_sync/__init__.py b/project_task_analytic_account_sync/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/project_task_analytic_account_sync/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/project_task_analytic_account_sync/__manifest__.py b/project_task_analytic_account_sync/__manifest__.py new file mode 100644 index 0000000..02c5fbf --- /dev/null +++ b/project_task_analytic_account_sync/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Project Task Analytic Account Sync", + "version": "16.0.1.0.0", + "category": "Project", + "license": "AGPL-3", + "summary": "Synchronize project task analytic accounts strictly with their project", + "author": "Criptomart", + "website": "https://github.com/criptomart/odoo-addons", + "depends": ["project"], + "data": [ + "views/project_task_views.xml", + ], + "installable": True, + "application": False, + "auto_install": False, + "sequence": 99, +} diff --git a/project_task_analytic_account_sync/models/__init__.py b/project_task_analytic_account_sync/models/__init__.py new file mode 100644 index 0000000..2128188 --- /dev/null +++ b/project_task_analytic_account_sync/models/__init__.py @@ -0,0 +1,2 @@ +from . import project_project +from . import project_task diff --git a/project_task_analytic_account_sync/models/project_project.py b/project_task_analytic_account_sync/models/project_project.py new file mode 100644 index 0000000..aa77f9c --- /dev/null +++ b/project_task_analytic_account_sync/models/project_project.py @@ -0,0 +1,20 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProjectProject(models.Model): + _inherit = "project.project" + + def write(self, vals): + res = super().write(vals) + if "analytic_account_id" in vals: + self.env["project.task"].with_context(active_test=False).search( + [("project_id", "in", self.ids)] + ).write({"analytic_account_id": vals["analytic_account_id"]}) + return res + + # No need to override _create_analytic_account: the core method already + # calls project.write({'analytic_account_id': ...}) which triggers our + # write() override above and propagates the account to all tasks. diff --git a/project_task_analytic_account_sync/models/project_task.py b/project_task_analytic_account_sync/models/project_task.py new file mode 100644 index 0000000..6a7d736 --- /dev/null +++ b/project_task_analytic_account_sync/models/project_task.py @@ -0,0 +1,42 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class ProjectTask(models.Model): + _inherit = "project.task" + + @api.depends("project_id.analytic_account_id") + def _compute_analytic_account_id(self): + super()._compute_analytic_account_id() + for task in self: + expected = ( + task.project_id.analytic_account_id if task.project_id else False + ) + if task.analytic_account_id != expected: + task.analytic_account_id = expected + + def write(self, vals): + if "project_id" in vals and "analytic_account_id" not in vals: + # Project changed: force the correct account from the new project + new_project = self.env["project.project"].browse(vals["project_id"]) + vals["analytic_account_id"] = ( + new_project.analytic_account_id.id if new_project else False + ) + elif "analytic_account_id" in vals and "project_id" not in vals: + # Validate provided account against the current project + for task in self: + expected = ( + task.project_id.analytic_account_id.id if task.project_id else False + ) + if vals["analytic_account_id"] != expected: + vals["analytic_account_id"] = expected + break + elif "project_id" in vals and "analytic_account_id" in vals: + # Both changed: force account from the new project + new_project = self.env["project.project"].browse(vals["project_id"]) + vals["analytic_account_id"] = ( + new_project.analytic_account_id.id if new_project else False + ) + return super().write(vals) diff --git a/project_task_analytic_account_sync/tests/__init__.py b/project_task_analytic_account_sync/tests/__init__.py new file mode 100644 index 0000000..31c2177 --- /dev/null +++ b/project_task_analytic_account_sync/tests/__init__.py @@ -0,0 +1 @@ +from . import test_project_task_analytic_account_sync diff --git a/project_task_analytic_account_sync/tests/test_project_task_analytic_account_sync.py b/project_task_analytic_account_sync/tests/test_project_task_analytic_account_sync.py new file mode 100644 index 0000000..a80791e --- /dev/null +++ b/project_task_analytic_account_sync/tests/test_project_task_analytic_account_sync.py @@ -0,0 +1,82 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + + +class TestProjectTaskAnalyticAccountSync(TransactionCase): + def setUp(self): + super().setUp() + self.AnalyticAccount = self.env["account.analytic.account"] + self.Project = self.env["project.project"] + self.Task = self.env["project.task"] + + self.plan = self.env["account.analytic.plan"].create( + {"name": "Test Plan", "company_id": self.env.company.id} + ) + + self.analytic_1 = self.AnalyticAccount.create( + {"name": "Analytic Account 1", "plan_id": self.plan.id} + ) + self.analytic_2 = self.AnalyticAccount.create( + {"name": "Analytic Account 2", "plan_id": self.plan.id} + ) + + self.project_a = self.Project.create( + { + "name": "Project A", + "analytic_account_id": self.analytic_1.id, + } + ) + self.project_b = self.Project.create( + { + "name": "Project B", + "analytic_account_id": self.analytic_2.id, + } + ) + + def test_task_sync_on_create(self): + """RF-03: a new task must adopt the project's analytic account.""" + task = self.Task.create( + { + "name": "Test Task", + "project_id": self.project_a.id, + } + ) + self.assertEqual(task.analytic_account_id, self.analytic_1) + + def test_task_sync_on_project_change(self): + """RF-04: moving a task to another project updates its analytic account.""" + task = self.Task.create( + { + "name": "Test Task", + "project_id": self.project_a.id, + } + ) + self.assertEqual(task.analytic_account_id, self.analytic_1) + task.write({"project_id": self.project_b.id}) + self.assertEqual(task.analytic_account_id, self.analytic_2) + + def test_task_sync_on_project_analytic_change(self): + """RF-02: changing the project's analytic account propagates to tasks.""" + task = self.Task.create( + { + "name": "Test Task", + "project_id": self.project_a.id, + } + ) + self.assertEqual(task.analytic_account_id, self.analytic_1) + self.project_a.write({"analytic_account_id": self.analytic_2.id}) + self.assertEqual(task.analytic_account_id, self.analytic_2) + + def test_task_sync_overrides_manual_change(self): + """RF-01: a manual change on the task must be reverted immediately.""" + task = self.Task.create( + { + "name": "Test Task", + "project_id": self.project_a.id, + } + ) + self.assertEqual(task.analytic_account_id, self.analytic_1) + task.write({"analytic_account_id": self.analytic_2.id}) + self.assertEqual(task.analytic_account_id, self.analytic_1) diff --git a/project_task_analytic_account_sync/views/project_task_views.xml b/project_task_analytic_account_sync/views/project_task_views.xml new file mode 100644 index 0000000..db2e1c0 --- /dev/null +++ b/project_task_analytic_account_sync/views/project_task_views.xml @@ -0,0 +1,13 @@ + + + + project.task.form.analytic.sync + project.task + + + + 1 + + + + From 08e34c1426156ff5107e6ca55e895af10251a847 Mon Sep 17 00:00:00 2001 From: luis Date: Wed, 24 Jun 2026 11:49:41 +0200 Subject: [PATCH 20/27] add payment_redsys_preauth --- payment_redsys_preauth/README.rst | 133 +++++ payment_redsys_preauth/__init__.py | 4 + payment_redsys_preauth/__manifest__.py | 15 + .../controllers/__init__.py | 2 + payment_redsys_preauth/controllers/main.py | 29 ++ .../data/payment_redsys.xml | 26 + payment_redsys_preauth/models/__init__.py | 2 + .../models/payment_provider.py | 135 +++++ .../models/payment_transaction.py | 150 ++++++ payment_redsys_preauth/readme/CONFIGURE.rst | 54 ++ .../readme/CONTRIBUTORS.rst | 3 + payment_redsys_preauth/readme/DESCRIPTION.rst | 1 + payment_redsys_preauth/readme/INSTALL.rst | 0 payment_redsys_preauth/readme/ROADMAP.rst | 9 + .../static/description/icon.png | Bin 0 -> 27527 bytes .../static/description/index.html | 480 ++++++++++++++++++ payment_redsys_preauth/static/img/bizum.png | Bin 0 -> 4959 bytes .../static/src/img/redsys_icon.png | Bin 0 -> 11108 bytes 18 files changed, 1043 insertions(+) create mode 100644 payment_redsys_preauth/README.rst create mode 100644 payment_redsys_preauth/__init__.py create mode 100644 payment_redsys_preauth/__manifest__.py create mode 100644 payment_redsys_preauth/controllers/__init__.py create mode 100644 payment_redsys_preauth/controllers/main.py create mode 100644 payment_redsys_preauth/data/payment_redsys.xml create mode 100644 payment_redsys_preauth/models/__init__.py create mode 100644 payment_redsys_preauth/models/payment_provider.py create mode 100644 payment_redsys_preauth/models/payment_transaction.py create mode 100644 payment_redsys_preauth/readme/CONFIGURE.rst create mode 100644 payment_redsys_preauth/readme/CONTRIBUTORS.rst create mode 100644 payment_redsys_preauth/readme/DESCRIPTION.rst create mode 100644 payment_redsys_preauth/readme/INSTALL.rst create mode 100644 payment_redsys_preauth/readme/ROADMAP.rst create mode 100644 payment_redsys_preauth/static/description/icon.png create mode 100644 payment_redsys_preauth/static/description/index.html create mode 100644 payment_redsys_preauth/static/img/bizum.png create mode 100644 payment_redsys_preauth/static/src/img/redsys_icon.png diff --git a/payment_redsys_preauth/README.rst b/payment_redsys_preauth/README.rst new file mode 100644 index 0000000..8adcc75 --- /dev/null +++ b/payment_redsys_preauth/README.rst @@ -0,0 +1,133 @@ +=========================================== +Pasarela de pago Redsys con preautorización +=========================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1eb9535d052a619aa545a383869076ca1dc1fade22746cb3fe13afa726c3e3a3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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-Criptomart%2Ffoodcoopbcn-lightgray.png?logo=github + :target: https://github.com/Criptomart/foodcoopbcn/tree/12.0/payment_redsys_preauth + :alt: Criptomart/foodcoopbcn + +|badge1| |badge2| |badge3| + +Este módulo añade la opción de pago a través de la pasarela de Redsys con preautorización con tarjeta bancaria. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Parámetros +~~~~~~~~~~ + +* **Nombre del comercio**: Indicaremos el nombre del comercio. + +* **Número de comercio (FUC)**: Indicaremos el número de comercio que + nuestra entidad nos ha comunicado. + +* **Clave secreta de encriptación**: Indicaremos la clave de encriptación + que tiene el comercio. + +* **Número de terminal**: Indicaremos el terminal del TPV. + +* **Tipo de firma**: Seleccionaremos el tipo de firma del comercio. + +* **Tipo de moneda**: Seleccionaremos la moneda de nuestro terminal TPV + (Normalmente EUR - Euros). + +* **Tipo de transacción**: Indicaremos el tipo de transacción, 1. + +* **Idioma TPV**: Indicaremos el idioma en el TPV. + +* **Método de pago**: Indicaremos que tipo de pago se debe aceptar, pago con + tarjeta, Bizum u otro de los disponibles. + +* **URL_OK/URL_KO**: Durante el proceso del pago, y una vez que + se muestra al cliente la pantalla con el resultado del mismo, es + posible redirigir su navegador a una URL para las transacciones + autorizadas y a otra si la transacción ha sido denegada. A éstas + se las denomina URL_OK y URL_KO, respectivamente. Se trata + de dos URLs que pueden ser proporcionadas por el comercio. + +* **Porcentaje de pago**: Indicar el porcentaje de pago que se permite, si + se deja a 0.0 se entiende 100%. + +Nota +~~~~ + +Se tiene que verificar la configuración del comercio en el +módulo de administración de Redsys, donde la opción “Parámetros en las +URLs” debe tener el valor “SI”. + +En caso de que exista más de una base de datos en la instalación, cuando la +pasarela de pago envía el formulario a "/payment/redsys/return" odoo no sabe +con que base de datos procesar esta información, por lo que hay que establecer +los parametros **dbfilter** y **dbname** en el archivo de configuración. + +Para mostrar simultáneamente en el comercio electrónico varios de los métodos +de pago que proporciona Redsys, como pago con tarjeta y Bizum por ejemplo, +debemos duplicar el medio de pago y escoger en el campo método de pago el que +corresponda en cada caso. + +En los pagos con preautorización, el titular del TPV deberá solicitar al banco su activación. + + +Known issues / Roadmap +====================== + +De acuerdo a los requisitos de Redsys, el tamaño máximo del número de pedido +resultante (``Ds_Order``) es de 12 caracteres. Este addon trunca dicho +dato en el comienzo de la transacción, con el resultado de que, aunque Redsys +la aceptará, no se finalizará el pedido pedido en Odoo al retornar y entregará +un Error 500 a Redsys. +Para evitar esto, es recomendable seleccionar secuencias de pedido que se +ajusten a esta limitación, en particular de 10 o menos caracteres, al ser la +referencia un número extendido a partir del pedido, que incluye un guion y un +número secuencial para posibles repeticiones de pago. + +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 +~~~~~~~ + +* Criptomart + +Contributors +~~~~~~~~~~~~ + +* `Criptomart `_: + + * Santi Noreña + +Maintainers +~~~~~~~~~~~ + +This module is part of the `Criptomart/foodcoopbcn `_ project on GitHub. + +You are welcome to contribute. diff --git a/payment_redsys_preauth/__init__.py b/payment_redsys_preauth/__init__.py new file mode 100644 index 0000000..822e370 --- /dev/null +++ b/payment_redsys_preauth/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3 - See https://www.gnu.org/licenses/agpl-3.0.html + +from . import models +from . import controllers diff --git a/payment_redsys_preauth/__manifest__.py b/payment_redsys_preauth/__manifest__.py new file mode 100644 index 0000000..6b75ed6 --- /dev/null +++ b/payment_redsys_preauth/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2024 - 2025 Criptomart +{ + "name": "Pasarela de pago Redsys con preautorización", + "category": "Payment Acquirer", + "summary": "Payment Acquirer: Redsys Implementation with Preauthorization", + "version": "16.0.0.0.1", + "author": "Criptomart", + "website": "https://git.criptomart.net/foodcoopbcn", + "depends": ["payment_redsys","sale_order_invoice_amount"], + "data": [ + "data/payment_redsys.xml" + ], + "license": "AGPL-3", + "installable": True, +} diff --git a/payment_redsys_preauth/controllers/__init__.py b/payment_redsys_preauth/controllers/__init__.py new file mode 100644 index 0000000..8b6d05e --- /dev/null +++ b/payment_redsys_preauth/controllers/__init__.py @@ -0,0 +1,2 @@ + +from . import main diff --git a/payment_redsys_preauth/controllers/main.py b/payment_redsys_preauth/controllers/main.py new file mode 100644 index 0000000..63b03ee --- /dev/null +++ b/payment_redsys_preauth/controllers/main.py @@ -0,0 +1,29 @@ +# © 2024 - 2025 criptomart.net +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import pprint + +from odoo import http +from odoo.http import request +from odoo.addons.payment_redsys.controllers.main import RedsysController + +_logger = logging.getLogger(__name__) + + + +class RedsysController(RedsysController): + _return_url = '/payment/redsys/return' + _cancel_url = '/payment/redsys/cancel' + _exception_url = '/payment/redsys/error' + _reject_url = '/payment/redsys/reject' + + + @http.route(['/payment/redsys/result/'], type='http', auth='public',methods=['GET'], website=True) + def redsys_result(self, page, **post): + _logger.info('________ Redsys return sync: %s -- %s', + pprint.pformat(post), page) + # double check + if post: + request.env['payment.transaction'].sudo()._handle_notification_data('redsys', post) + return request.redirect('/payment/status') diff --git a/payment_redsys_preauth/data/payment_redsys.xml b/payment_redsys_preauth/data/payment_redsys.xml new file mode 100644 index 0000000..e6b8db2 --- /dev/null +++ b/payment_redsys_preauth/data/payment_redsys.xml @@ -0,0 +1,26 @@ + + + + + + Redsys Preautorización + + redsys + + Bussines name + 8888888 + Bussines description + xxxxxxxxxxxx -- SHA256 + 001 + 1 + + You will be redirected to the Redsys payment gateway after clicking on the payment button.

]]>
+
+ + + Bizum + + + +
diff --git a/payment_redsys_preauth/models/__init__.py b/payment_redsys_preauth/models/__init__.py new file mode 100644 index 0000000..9afbccd --- /dev/null +++ b/payment_redsys_preauth/models/__init__.py @@ -0,0 +1,2 @@ +from . import payment_provider +from . import payment_transaction diff --git a/payment_redsys_preauth/models/payment_provider.py b/payment_redsys_preauth/models/payment_provider.py new file mode 100644 index 0000000..17ddf9f --- /dev/null +++ b/payment_redsys_preauth/models/payment_provider.py @@ -0,0 +1,135 @@ +# © 2024 - 2025 criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import json +import pprint +import requests +import urllib + +from odoo import models, api, exceptions, fields +from odoo.addons.payment.models.payment_provider import ValidationError +from odoo.tools.float_utils import float_compare +from odoo.tools.translate import _ +import odoo.exceptions + +_logger = logging.getLogger(__name__) + + +class PaymentProvider(models.Model): + _inherit = 'payment.provider' + + @api.depends('code') + def _compute_feature_support_fields(self): + super()._compute_feature_support_fields() + self.update({ + 'support_manual_capture': 'partial', + }) + + def get_redsys_rest_urls(self, environment): + """ Redsys Rest URLs + """ + if environment == 'prod': + return { + 'redsys_rest_url': + 'https://sis.redsys.es/sis/rest/trataPeticionREST', + } + else: + # redsys no acepta preautorizaciones en test, pero puede que cambie... + return { + 'redsys_rest_url': + 'https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST', + } + + def _prepare_merchant_parameters(self, tx_values): + base_url = self._get_website_url() + callback_url = "http://botiga.foodcoopbcn.cat" + values = { + "Ds_Sermepa_Url": self._redsys_get_api_url(), + "Ds_Merchant_Amount": str(int(round(tx_values["amount"] * 100))), + "Ds_Merchant_Currency": self.redsys_currency or "978", + "Ds_Merchant_Order": ( + tx_values["reference"] and tx_values["reference"][-12:] or False + ), + "Ds_Merchant_MerchantCode": ( + self.redsys_merchant_code and self.redsys_merchant_code[:9] + ), + "Ds_Merchant_Terminal": self.redsys_terminal or "1", + "Ds_Merchant_TransactionType": (self.redsys_transaction_type or "1"), + "Ds_Merchant_Titular": tx_values.get( + "billing_partner", self.env.user.partner_id + ).display_name[:60], + "Ds_Merchant_MerchantName": ( + self.redsys_merchant_name and self.redsys_merchant_name[:25] + ), + "Ds_Merchant_MerchantUrl": ( + "%s/payment/redsys/return" % (callback_url or base_url) + )[:250], + "Ds_Merchant_MerchantData": self.redsys_merchant_data or "", + "Ds_Merchant_ProductDescription": ( + self.redsys_merchant_description + and self.redsys_merchant_description[:125] + ), + "Ds_Merchant_ConsumerLanguage": (self.redsys_merchant_lang or "001"), + "Ds_Merchant_UrlOk": "%s/payment/redsys/result/redsys_result_ok" % base_url, + "Ds_Merchant_UrlKo": "%s/payment/redsys/result/redsys_result_ko" % base_url, + "Ds_Merchant_Paymethods": self.redsys_pay_method or "T", + } + return self._url_encode64(json.dumps(values)).decode("utf-8") + + def _prepare_s2s_merchant_parameters(self, tx_values): + values = { + 'Ds_Merchant_Amount': str(int(round(tx_values['amount'] * 100))), + 'Ds_Merchant_Currency': self.redsys_currency or '978', + 'Ds_Merchant_Order': ( + tx_values['reference'] and tx_values['reference'][-12:] or + False), + 'Ds_Merchant_MerchantCode': ( + self.redsys_merchant_code and + self.redsys_merchant_code[:9]), + 'Ds_Merchant_Terminal': self.redsys_terminal or '1', + } + if 'transaction_type' in tx_values: + values.update({'Ds_Merchant_TransactionType': tx_values['transaction_type']}) + else: + values.update({'Ds_Merchant_TransactionType': + (self.redsys_transaction_type or '2')}), + return self._url_encode64(json.dumps(values)) + + def redsys_s2s_create_transaction(self, values): + self.ensure_one() + redsys_values = dict() + merchant_parameters = self._prepare_s2s_merchant_parameters(values) + redsys_values.update({ + 'Ds_SignatureVersion': str(self.redsys_signature_version), + 'Ds_MerchantParameters': merchant_parameters, + 'Ds_Signature': self.sign_parameters( + self.redsys_secret_key, merchant_parameters), + }) + return redsys_values + + def redsys_send_s2s_msg(self, values): + toSend = self.redsys_s2s_create_transaction(values) + url = self.get_redsys_rest_urls('prod')['redsys_rest_url'] + _logger.info('Redsys: sending rest values:\n%s', pprint.pformat(toSend)) + try: + if toSend and url: + response = requests.post(url, data=toSend) + else: + raise exceptions.ValidationError('Error en los parámetros de la petición rest') + except requests.exceptions.RequestException as e: + _logger.error('Redsys: Error sending rest transaction %s:\n%s', values['reference'], str(e)) + return False + if response and response.status_code != 200: + _logger.error('Redsys: Error sending rest transaction %s: %s', values['reference'], response.status_code) + return False + _logger.info('Redsys: rest response:\n%s', pprint.pformat(response.text)) + try: + errorCheck = response.json() + if errorCheck.get('errorCode', ''): + _logger.error('Redsys: Error recibido tx %s: %s', values['reference'], errorCheck.get('errorCode')) + except json.JSONDecodeError: + _logger.error('Error al decodificar la respuesta JSON para la transacción %s', values['reference']) + return False + return errorCheck + diff --git a/payment_redsys_preauth/models/payment_transaction.py b/payment_redsys_preauth/models/payment_transaction.py new file mode 100644 index 0000000..d99c675 --- /dev/null +++ b/payment_redsys_preauth/models/payment_transaction.py @@ -0,0 +1,150 @@ +# © 2024 - 2025 criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import json +import pprint +import requests + +from odoo import models, api, exceptions, fields +from odoo.addons.payment.models.payment_provider import ValidationError +from odoo.tools.float_utils import float_compare +from odoo.tools.translate import _ + + +_logger = logging.getLogger(__name__) + + +class TxRedsysPreAuth(models.Model): + _inherit = 'payment.transaction' + + def _get_specific_rendering_values(self, processing_values): + if self.provider_code != "redsys": + return super()._get_specific_rendering_values(processing_values) + redsys_values = dict(processing_values) + merchant_parameters = self.provider_id._prepare_merchant_parameters( + processing_values + ) + redsys_values.update( + { + "api_url": self.provider_id._redsys_get_api_url(), + "Ds_SignatureVersion": str(self.provider_id.redsys_signature_version), + "Ds_MerchantParameters": merchant_parameters, + "Ds_Signature": self.provider_id.sign_parameters( + self.provider_id.redsys_secret_key, merchant_parameters + ), + } + ) + return redsys_values + + # chequea que el usuario cancele el pago 9915. cambia el stado de done a authorized + @api.model + def _get_redsys_state(self, status_code): + if 0 <= status_code <= 100: + return "authorized" + elif status_code <= 203: + return "pending" + elif status_code == 400: + return "cancel" + elif status_code == 900: + return "done" + elif 912 <= status_code <= 9915: + return "cancel" + else: + return "error" + + def _process_notification_data(self, data): + if self.provider_code != "redsys": + return super()._process_notification_data(data) + params = self.merchant_params_json2dict(data) + status_code = int(params.get('Ds_Response', '29999')) + state = self._get_redsys_state(status_code) + vals = { + 'state': state, + 'redsys_txnid': params.get('Ds_AuthorisationCode'), + } + state_message = "" + feedback_error = False + if state == 'done': + vals['state_message'] = _('Ok: %s') % params.get('Ds_Response') + self._set_done() + self._finalize_post_processing() + if state == 'authorized': + vals['state_message'] = _('Auth Ok: %s') % params.get('Ds_Response') + self._set_authorized() + self._finalize_post_processing() + elif state == 'pending': # 'Payment error: code: %s.' + state_message = _("Redsys: feedback error: %s" %(status_code)) + self._set_pending() + elif state == 'cancel': # 'Payment error: bank unavailable.' + state_message = _("Redsys: feedback error: %s" %(status_code)) + self._set_canceled() + else: + state_message = _("Redsys: feedback error: %s" %(status_code)) + self._set_error(state_message) + feedback_error = True + if state_message: + vals['state_message'] = state_message % { + "status_code": params.get("Ds_Response"), + "error_code": params.get("Ds_ErrorCode"), + } + if state == 'error': + _logger.warning(vals['state_message']) + self.write(vals) + + def _send_capture_request(self, amount_to_capture=None): + self.ensure_one() + self._ensure_provider_is_not_disabled() + _logger.info('Redsys: Capturing transaction %s -- %s', self.reference, amount_to_capture) + try: + if amount_to_capture: + values = {'amount': amount_to_capture} + else: + values = {'amount': sum(order.invoiced_amount for order in self.sale_order_ids)} + values.update({'reference': self.reference}) + values.update({'transaction_type': '2'}) + response = self.provider_id.redsys_send_s2s_msg(values) + if not response: + raise exceptions.ValidationError('Error mandando mensaje rest'); + if response.get('errorCode', ''): + raise exceptions.ValidationError('Ref %s: Error Code %s' %(self.reference, response.get('errorCode'))) + params = self.merchant_params_json2dict(response) + _logger.info('Redsys: parameters received:\n%s', pprint.pformat(params)) + if params['Ds_Response'] == '0900' and params['Ds_Order'] == self.reference: + self.provider_reference = params['Ds_AuthorisationCode'] + self._set_done() + _logger.info('Confirmacion de preautorizacion realizada con exito para la transaccion %s', self.reference) + message = ('Preautorizacion confirmada') + self.sale_order_ids.message_post(body=message) + else: + raise exceptions.ValidationError('Error en confirmacion de preautorizacion tx: %s - %s', self.reference, params['Ds_Response']) + except Exception as e: + _logger.exception('Redsys: Error capturando preautorización transacción %s:\n%s', self.reference, str(e)) + raise exceptions.UserError(_('Error capturing preauthorization for transaction %s:\n%s') % (self.reference, str(e))) + + def _send_void_request(self, amount_to_void=None): + self.ensure_one() + self._ensure_provider_is_not_disabled() + _logger.info('Redsys: Preauthorization cancel for transaction %s', self.reference) + try: + if amount_to_void: + values = {'amount': amount_to_void} + else: + values = {'amount': self.amount} + values.update({'reference': self.reference}) + values.update({'transaction_type': '9'}) + response = self.provider_id.redsys_send_s2s_msg(values) + if not response: + raise exceptions.ValidationError(_('error redsys_s2s_msg')) + if response.get('errorCode', ''): + raise exceptions.ValidationError('Ref %s: Error Code %s' % (self.reference, response.get('errorCode'))) + params = self.merchant_params_json2dict(response) + _logger.info('Redsys: parameters received):\n%s', pprint.pformat(params)) + if params['Ds_Response'] == '0400' and params['Ds_Order'] == self.reference: + self._set_canceled() + _logger.info('Preautorización cancelada con éxito para la transacción %s', self.reference) + else: + raise exceptions.ValidationError('No se pudo cancelar la preautorización para la transacción %s - %s', self.reference, params['Ds_Response']) + except Exception as e: + _logger.exception('Redsys Error cancelando preautorización tx %s:\n%s', self.reference, str(e)) + raise exceptions.UserError(_('Error cancelling preauthorization for transaction %s:\n%s') % (self.reference, str(e))) diff --git a/payment_redsys_preauth/readme/CONFIGURE.rst b/payment_redsys_preauth/readme/CONFIGURE.rst new file mode 100644 index 0000000..f23c184 --- /dev/null +++ b/payment_redsys_preauth/readme/CONFIGURE.rst @@ -0,0 +1,54 @@ +Parámetros +~~~~~~~~~~ + +* **Nombre del comercio**: Indicaremos el nombre del comercio. + +* **Número de comercio (FUC)**: Indicaremos el número de comercio que + nuestra entidad nos ha comunicado. + +* **Clave secreta de encriptación**: Indicaremos la clave de encriptación + que tiene el comercio. + +* **Número de terminal**: Indicaremos el terminal del TPV. + +* **Tipo de firma**: Seleccionaremos el tipo de firma del comercio. + +* **Tipo de moneda**: Seleccionaremos la moneda de nuestro terminal TPV + (Normalmente EUR - Euros). + +* **Tipo de transacción**: Indicaremos el tipo de transacción, 1. + +* **Idioma TPV**: Indicaremos el idioma en el TPV. + +* **Método de pago**: Indicaremos que tipo de pago se debe aceptar, pago con + tarjeta, Bizum u otro de los disponibles. + +* **URL_OK/URL_KO**: Durante el proceso del pago, y una vez que + se muestra al cliente la pantalla con el resultado del mismo, es + posible redirigir su navegador a una URL para las transacciones + autorizadas y a otra si la transacción ha sido denegada. A éstas + se las denomina URL_OK y URL_KO, respectivamente. Se trata + de dos URLs que pueden ser proporcionadas por el comercio. + +* **Porcentaje de pago**: Indicar el porcentaje de pago que se permite, si + se deja a 0.0 se entiende 100%. + +Nota +~~~~ + +Se tiene que verificar la configuración del comercio en el +módulo de administración de Redsys, donde la opción “Parámetros en las +URLs” debe tener el valor “SI”. + +En caso de que exista más de una base de datos en la instalación, cuando la +pasarela de pago envía el formulario a "/payment/redsys/return" odoo no sabe +con que base de datos procesar esta información, por lo que hay que establecer +los parametros **dbfilter** y **dbname** en el archivo de configuración. + +Para mostrar simultáneamente en el comercio electrónico varios de los métodos +de pago que proporciona Redsys, como pago con tarjeta y Bizum por ejemplo, +debemos duplicar el medio de pago y escoger en el campo método de pago el que +corresponda en cada caso. + +En los pagos con preautorización, el titular del TPV deberá solicitar al banco su activación. + diff --git a/payment_redsys_preauth/readme/CONTRIBUTORS.rst b/payment_redsys_preauth/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..e466f95 --- /dev/null +++ b/payment_redsys_preauth/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Criptomart `_: + + * Santi Noreña diff --git a/payment_redsys_preauth/readme/DESCRIPTION.rst b/payment_redsys_preauth/readme/DESCRIPTION.rst new file mode 100644 index 0000000..2604a50 --- /dev/null +++ b/payment_redsys_preauth/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Este módulo añade la opción de pago a través de la pasarela de Redsys con preautorización con tarjeta bancaria. diff --git a/payment_redsys_preauth/readme/INSTALL.rst b/payment_redsys_preauth/readme/INSTALL.rst new file mode 100644 index 0000000..e69de29 diff --git a/payment_redsys_preauth/readme/ROADMAP.rst b/payment_redsys_preauth/readme/ROADMAP.rst new file mode 100644 index 0000000..78f45e5 --- /dev/null +++ b/payment_redsys_preauth/readme/ROADMAP.rst @@ -0,0 +1,9 @@ +De acuerdo a los requisitos de Redsys, el tamaño máximo del número de pedido +resultante (``Ds_Order``) es de 12 caracteres. Este addon trunca dicho +dato en el comienzo de la transacción, con el resultado de que, aunque Redsys +la aceptará, no se finalizará el pedido pedido en Odoo al retornar y entregará +un Error 500 a Redsys. +Para evitar esto, es recomendable seleccionar secuencias de pedido que se +ajusten a esta limitación, en particular de 10 o menos caracteres, al ser la +referencia un número extendido a partir del pedido, que incluye un guion y un +número secuencial para posibles repeticiones de pago. diff --git a/payment_redsys_preauth/static/description/icon.png b/payment_redsys_preauth/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b25df9b7a5cea2eacc0280c025f6d1a2f048874d GIT binary patch literal 27527 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 e|F2=4N!#o`bJVJEIDqS7SW1so + + + + +Pasarela de pago Redsys con preautorización + + + +
+

Pasarela de pago Redsys con preautorización

+ + +

Beta License: AGPL-3 Criptomart/foodcoopbcn

+

Este módulo añade la opción de pago a través de la pasarela de Redsys con preautorización con tarjeta bancaria.

+

Table of contents

+ +
+

Configuration

+
+

Parámetros

+
    +
  • Nombre del comercio: Indicaremos el nombre del comercio.
  • +
  • Número de comercio (FUC): Indicaremos el número de comercio que +nuestra entidad nos ha comunicado.
  • +
  • Clave secreta de encriptación: Indicaremos la clave de encriptación +que tiene el comercio.
  • +
  • Número de terminal: Indicaremos el terminal del TPV.
  • +
  • Tipo de firma: Seleccionaremos el tipo de firma del comercio.
  • +
  • Tipo de moneda: Seleccionaremos la moneda de nuestro terminal TPV +(Normalmente EUR - Euros).
  • +
  • Tipo de transacción: Indicaremos el tipo de transacción, 1.
  • +
  • Idioma TPV: Indicaremos el idioma en el TPV.
  • +
  • Método de pago: Indicaremos que tipo de pago se debe aceptar, pago con +tarjeta, Bizum u otro de los disponibles.
  • +
  • URL_OK/URL_KO: Durante el proceso del pago, y una vez que +se muestra al cliente la pantalla con el resultado del mismo, es +posible redirigir su navegador a una URL para las transacciones +autorizadas y a otra si la transacción ha sido denegada. A éstas +se las denomina URL_OK y URL_KO, respectivamente. Se trata +de dos URLs que pueden ser proporcionadas por el comercio.
  • +
  • Porcentaje de pago: Indicar el porcentaje de pago que se permite, si +se deja a 0.0 se entiende 100%.
  • +
+
+
+

Nota

+

Se tiene que verificar la configuración del comercio en el +módulo de administración de Redsys, donde la opción “Parámetros en las +URLs” debe tener el valor “SI”.

+

En caso de que exista más de una base de datos en la instalación, cuando la +pasarela de pago envía el formulario a “/payment/redsys/return” odoo no sabe +con que base de datos procesar esta información, por lo que hay que establecer +los parametros dbfilter y dbname en el archivo de configuración.

+

Para mostrar simultáneamente en el comercio electrónico varios de los métodos +de pago que proporciona Redsys, como pago con tarjeta y Bizum por ejemplo, +debemos duplicar el medio de pago y escoger en el campo método de pago el que +corresponda en cada caso.

+

En los pagos con preautorización, el titular del TPV deberá solicitar al banco su activación.

+
+
+
+

Known issues / Roadmap

+

De acuerdo a los requisitos de Redsys, el tamaño máximo del número de pedido +resultante (Ds_Order) es de 12 caracteres. Este addon trunca dicho +dato en el comienzo de la transacción, con el resultado de que, aunque Redsys +la aceptará, no se finalizará el pedido pedido en Odoo al retornar y entregará +un Error 500 a Redsys. +Para evitar esto, es recomendable seleccionar secuencias de pedido que se +ajusten a esta limitación, en particular de 10 o menos caracteres, al ser la +referencia un número extendido a partir del pedido, que incluye un guion y un +número secuencial para posibles repeticiones de pago.

+
+
+

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

+
    +
  • Criptomart
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the Criptomart/foodcoopbcn project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/payment_redsys_preauth/static/img/bizum.png b/payment_redsys_preauth/static/img/bizum.png new file mode 100644 index 0000000000000000000000000000000000000000..8af46a769ce8f2108322bdab641e084b39b1bb2e GIT binary patch literal 4959 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 literal 0 HcmV?d00001 diff --git a/payment_redsys_preauth/static/src/img/redsys_icon.png b/payment_redsys_preauth/static/src/img/redsys_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e9ddbae18a4b23b8ad990eb8147e43222dc184e7 GIT binary patch literal 11108 zcmV-qE1T4bP)hewb^C83Xd28J(NBN+ z(_e7A-D?0-f8YDw_o4gmzyBlc?d^VDzbtUGK?s3i7*thNd4s`VTTf3XR znYg86abXiX2{M7h^(;MIbAw(!=k3XyMLK6AW|-nZuZf`xV(9_}Tb0gvOpSosDA`ot z*jKicM3Z^O6Iqg(0!k^|E`tico2oK5UYCvnX0!IwIg3m|0*-(fnnsz|KoiVy$Lvr_ zA*C$+z6euAAo7D~laC?N$Dj~JsOGz z+{0hJXB%FZ!JmHnAcN5~LI{LX+_R&V`)^rODo?>y9POXv@mB^o*cD-DGD9j`Kq+vW zI^lqu4NXBlb;kx))|Q`B4+T7TVw4B>bTJ;!qKb8`F8A=6cW|auko7tcx^5%y^ zdDddruK{Zckv@u;e1!b?Be>Kk1`2^fq>rIIt5BZROt-KA4t7QOf6tyKn#?1FQ}|Eb zzMjTPKL<}oc=FXDQaK9%O%uH14J*+!fmDj#@f834`-j=}$^eCH|>kMu?P&9CfbFq%f!M2S#S*SLFoD~%O?ma460lD$YHxp+0(h_7gAH?79d1o2FsMLK6W zBQOwfn>1GVO6HHt(D=wLYxv`PH?y`rSbC>J2nLH~f9B}cbO?9Q0=WW4bN3wi# zPZuW!W9UVbqrJ9_U%SB>VUPztcnjb7 z#P!^LWi!5FR~AB0uq6*Z*Ub<2_W_D{rofkQ0Y8fdEYmAYT9 zVAD6AI%|}ns1JK7_n1(WE)*r5R^~Cet)qb}+benC$&>u^6DLd0&s&nyBXN`v9PEy; z|8xXRJF7>JoSEYH|K9-=j=`SI%hDi}%oI2_7~_hzv)y?qFy`vdi-LfT)-Y@9%Xz6M z3Ic{>8HiCbSl85-@&4=E(2B}i2#6-KbPmVQvA@>V2MKr#as`{`PL5F*^3YNh!0XZx zLLhC0$JA-6^ke86mUKp9T@%y=y-Xx>Jn&Q}@pPfo6BKZ4aEfDtQ)i1TM2Wn-EqU?O zIPciea!y(1!?H=9QV{mL*xV9!$m3j3Q2@7Ws;8yutQ({W!SG~;SUO+2PS-TrY69pQ z#4`o{;nCxaCbDd54za4XjBvn>l!~sg1Wz9wAzL(Ilv31&yj;DynwPsLN)0#-x1n9& zQc0yK6xD?QPL0HgX9`pVTub≧$%#Xo8(v>iOQj-gCSKDHUrP%K0xhti&i9!4!*p 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 Date: Fri, 31 Jul 2026 09:17:42 +0000 Subject: [PATCH 21/27] Actualizar product_sale_price_from_pricelist/models/product_pricelist.py --- product_sale_price_from_pricelist/models/product_pricelist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/product_sale_price_from_pricelist/models/product_pricelist.py b/product_sale_price_from_pricelist/models/product_pricelist.py index 2c71e20..c3ac429 100644 --- a/product_sale_price_from_pricelist/models/product_pricelist.py +++ b/product_sale_price_from_pricelist/models/product_pricelist.py @@ -39,7 +39,7 @@ class ProductPricelist(models.Model): ProductProduct = self.env["product.product"] res = super()._compute_price_rule( products, - qty=1, + qty=qty, uom=uom, date=date, ) From b07082f70db18c8b19e7dd726e75d1421525a874 Mon Sep 17 00:00:00 2001 From: luis Date: Mon, 3 Aug 2026 13:10:09 +0200 Subject: [PATCH 22/27] add stock_account_avco_negative_fix --- .../models/stock_move.py | 6 +- .../models/stock_move.py | 6 +- stock_account_avco_negative_fix/README.md | 75 ++++++++++++ stock_account_avco_negative_fix/__init__.py | 1 + .../__manifest__.py | 13 +++ .../models/__init__.py | 1 + .../models/stock_move.py | 95 +++++++++++++++ .../tests/__init__.py | 1 + .../tests/test_avco_negative_fix.py | 108 ++++++++++++++++++ 9 files changed, 302 insertions(+), 4 deletions(-) create mode 100644 stock_account_avco_negative_fix/README.md create mode 100644 stock_account_avco_negative_fix/__init__.py create mode 100644 stock_account_avco_negative_fix/__manifest__.py create mode 100644 stock_account_avco_negative_fix/models/__init__.py create mode 100644 stock_account_avco_negative_fix/models/stock_move.py create mode 100644 stock_account_avco_negative_fix/tests/__init__.py create mode 100644 stock_account_avco_negative_fix/tests/test_avco_negative_fix.py diff --git a/product_sale_price_from_pricelist/models/stock_move.py b/product_sale_price_from_pricelist/models/stock_move.py index 9865db0..86cbc33 100644 --- a/product_sale_price_from_pricelist/models/stock_move.py +++ b/product_sale_price_from_pricelist/models/stock_move.py @@ -16,8 +16,10 @@ _logger = logging.getLogger(__name__) class StockMove(models.Model): _inherit = "stock.move" - def product_price_update_before_done(self): - super(StockMove, self).product_price_update_before_done() + def product_price_update_before_done(self, forced_qty=None): + super(StockMove, self).product_price_update_before_done( + forced_qty=forced_qty + ) for move in self.filtered(lambda move: move.location_id.usage == "supplier"): if ( move.product_id.last_purchase_price_compute_type diff --git a/product_update_price_last_purchase/models/stock_move.py b/product_update_price_last_purchase/models/stock_move.py index 424c563..09b3e1d 100644 --- a/product_update_price_last_purchase/models/stock_move.py +++ b/product_update_price_last_purchase/models/stock_move.py @@ -15,8 +15,10 @@ _logger = logging.getLogger(__name__) class StockMove(models.Model): _inherit = "stock.move" - def product_price_update_before_done(self): - res = super(StockMove, self).product_price_update_before_done() + def product_price_update_before_done(self, forced_qty=None): + res = super(StockMove, self).product_price_update_before_done( + forced_qty=forced_qty + ) for move in self.filtered(lambda move: move.location_id.usage == "supplier"): if ( move.product_id.last_purchase_price_received_compute_type diff --git a/stock_account_avco_negative_fix/README.md b/stock_account_avco_negative_fix/README.md new file mode 100644 index 0000000..3251a7e --- /dev/null +++ b/stock_account_avco_negative_fix/README.md @@ -0,0 +1,75 @@ +# Stock Account AVCO Negative Fix + +Corrige un bug del core de Odoo 16 en la valoración de coste promedio (AVCO) +que puede dejar el `standard_price` de un producto en un valor negativo (o +cero) cuando existe stock negativo (`quantity_svl < 0`) y se recibe una +compra que compensa solo parcialmente ese stock. + +## Descripción del problema + +El método `product_price_update_before_done()` de +`stock_account/models/stock_move.py` calcula el nuevo coste medio ponderado +de un producto AVCO con la fórmula: + +``` +new_std_price = (amount_unit * qty_svl + price_unit_in * qty) / (qty_svl + qty) +``` + +Cuando `(qty_svl + qty)` es un valor positivo muy pequeño (el stock negativo +casi se compensa con la compra recibida, pero no del todo), el numerador +puede resultar negativo y el `standard_price` calculado se vuelve negativo, +corrompiendo la valoración de inventario en los recálculos posteriores. + +Bug reportado en upstream sin corrección oficial: +https://github.com/odoo/odoo/issues/187169 + +## Solución + +Este módulo **no** copia ni sobrescribe el algoritmo del core. En su lugar, +hereda `stock.move` y aplica una estrategia de post-procesado con `super()` +sobre `_action_done()`, basada en una **precondición** evaluada antes de que +nada más intervenga: + +1. Antes de llamar a `super()`, para cada movimiento entrante AVCO relevante + (`move._is_in()` y `cost_method == 'average'`), comprueba si el producto + tenía `quantity_svl <= 0` **antes** de este movimiento (la precondición + bajo la cual el core puede degenerar a un coste negativo o cero). Si se + cumple, captura el `price_unit` entrante (`move._get_price_unit()`). +2. Llama a `super()._action_done()` para que el core ejecute su lógica + completa sin modificaciones (media ponderada AVCO, rama FIFO y el + `_run_fifo_vacuum()` final). +3. Después de `super()`, para cada producto marcado como "en riesgo" en el + paso 1, reescribe **incondicionalmente** su `standard_price` con el + `price_unit` capturado —sin comprobar el valor que haya quedado escrito—, + replicando el mismo patrón de escritura del core (`disable_auto_svl=True`, + `sudo()`) para no generar SVLs de revaluación espurios. + +**¿Por qué precondición incondicional y no comprobar el signo final?** Una +primera versión de este fix comprobaba el signo de `standard_price` +*después* de `super()`. Se detectó que ese criterio es insuficiente: otros +módulos que reaccionan durante `_action_done()` (por ejemplo, el OCA +`product_cost_price_avco_sync`, que sincroniza el coste AVCO al reaccionar a +`stock.valuation.layer.write()` disparado por `_run_fifo_vacuum()`) pueden +reescribir `standard_price` a un valor positivo pero distinto del esperado, +haciendo que la comprobación `<= 0` nunca se cumpla aunque la situación de +riesgo sí se haya dado. Al decidir **antes** de que empiece la cadena y +aplicar la corrección de forma incondicional, el fix es robusto frente a +cualquier módulo que reescriba `standard_price` durante el proceso, ahora o +en el futuro. + +Este enfoque evita duplicar el algoritmo del core (resiliente a cambios de +upstream) y es compatible con cualquier otro módulo, presente o futuro, que +también herede `product_price_update_before_done`/`_action_done` sobre +`stock.move`, ya que la cadena de `super()` se respeta íntegramente. + +## Alcance + +- Solo actúa sobre productos con `cost_method = 'average'`. +- Productos FIFO o de coste estándar no se ven afectados. +- No introduce modelos, vistas ni campos nuevos. + +## Créditos + +### Autores + +- Criptomart diff --git a/stock_account_avco_negative_fix/__init__.py b/stock_account_avco_negative_fix/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/stock_account_avco_negative_fix/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_account_avco_negative_fix/__manifest__.py b/stock_account_avco_negative_fix/__manifest__.py new file mode 100644 index 0000000..73606b0 --- /dev/null +++ b/stock_account_avco_negative_fix/__manifest__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Stock Account AVCO Negative Fix", + "version": "16.0.1.0.0", + "summary": "Prevent negative AVCO cost on incoming moves with negative stock", + "license": "AGPL-3", + "author": "Criptomart", + "website": "https://github.com/OCA/stock-logistics-workflow", + "depends": ["stock_account"], + "data": [], + "installable": True, +} diff --git a/stock_account_avco_negative_fix/models/__init__.py b/stock_account_avco_negative_fix/models/__init__.py new file mode 100644 index 0000000..6bda2d2 --- /dev/null +++ b/stock_account_avco_negative_fix/models/__init__.py @@ -0,0 +1 @@ +from . import stock_move diff --git a/stock_account_avco_negative_fix/models/stock_move.py b/stock_account_avco_negative_fix/models/stock_move.py new file mode 100644 index 0000000..c81ccf0 --- /dev/null +++ b/stock_account_avco_negative_fix/models/stock_move.py @@ -0,0 +1,95 @@ +from odoo import models +from odoo.tools import float_compare + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _action_done(self, cancel_backorder=False): + """Fix negative AVCO ``standard_price`` after a move is done. + + Odoo core (``stock_account/models/stock_move.py``, + ``product_price_update_before_done``) computes the new average cost + of an AVCO product as a weighted average between the current + valuation (``quantity_svl`` / ``standard_price``) and the incoming + move. When the product has non-positive stock (``quantity_svl <= 0``) + and the incoming quantity only partially compensates it, the + resulting weighted average can become negative or zero, corrupting + the product cost for every subsequent outgoing valuation. + + This is a known, unfixed upstream bug: see + https://github.com/odoo/odoo/issues/187169 + + Rather than copying/overriding the core algorithm (which would break + compatibility with any OCA module -- present or future -- also + inheriting ``product_price_update_before_done``), we post-process the + final result of the whole ``_action_done`` flow: + + - *Before* calling ``super()``, we detect the AVCO products that are + "at risk": incoming moves (``move._is_in()``) of a product with + ``cost_method == 'average'`` whose stock valuation quantity + (``quantity_svl``) is already ``<= 0`` *before* this move is + processed. This is exactly the precondition under which the core's + weighted-average formula can degenerate to a negative or zero + result. We capture, for each such ``(company, product)``, the + incoming move's own ``price_unit`` (``move._get_price_unit()``). + - We let ``super()._action_done()`` run its full logic unmodified + (AVCO weighted average, FIFO branch, and the ``_run_fifo_vacuum()`` + step that runs at the very end of ``_action_done``). + - *After* ``super()`` has fully completed, for every "at risk" + ``(company, product)`` captured above, we unconditionally rewrite + ``standard_price`` with the captured incoming ``price_unit`` (not + the product's configured purchase price). + + Note this fix is intentionally based on the *precondition* + (``quantity_svl <= 0`` before the move) rather than on inspecting the + *final* ``standard_price`` sign. An earlier version of this fix only + rewrote ``standard_price`` when it was left ``<= 0`` after + ``super()``, but that check could be silently bypassed: some module + reacting to the ``_run_fifo_vacuum()`` step (e.g. the OCA module + ``product_cost_price_avco_sync``, which hooks into + ``stock.valuation.layer.write()``) can rewrite ``standard_price`` to + a *different but still positive* value before our check ran, + preventing the fix from ever triggering even though the scenario + that causes the original bug did occur. Keying off the precondition + instead makes the fix robust regardless of what any other module + does afterwards, without depending on assumptions about whether the + vacuum runs in production (in practice, ``product_cost_price_avco_sync`` + disables the vacuum outside of tests for performance reasons, since + it is only meaningful for FIFO -- but the fix should not rely on + that being permanently true). + """ + at_risk_price_unit = {} + for move in self.filtered( + lambda m: m._is_in() + and m.with_company(m.company_id).product_id.cost_method == "average" + ): + company = move.company_id + product = move.product_id.with_company(company) + quantity_svl = product.sudo().quantity_svl + rounding = product.uom_id.rounding + if float_compare(quantity_svl, 0.0, precision_rounding=rounding) <= 0: + at_risk_price_unit[(company.id, product.id)] = move._get_price_unit() + + res = super()._action_done(cancel_backorder=cancel_backorder) + + if not at_risk_price_unit: + return res + + companies = self.env["res.company"].browse( + {key[0] for key in at_risk_price_unit} + ) + products = self.env["product.product"].browse( + {key[1] for key in at_risk_price_unit} + ) + company_by_id = {company.id: company for company in companies} + product_by_id = {product.id: product for product in products} + + for (company_id, product_id), price_unit in at_risk_price_unit.items(): + company = company_by_id[company_id] + product = product_by_id[product_id] + product.with_company(company.id).with_context( + disable_auto_svl=True + ).sudo().write({"standard_price": price_unit}) + + return res diff --git a/stock_account_avco_negative_fix/tests/__init__.py b/stock_account_avco_negative_fix/tests/__init__.py new file mode 100644 index 0000000..64b554e --- /dev/null +++ b/stock_account_avco_negative_fix/tests/__init__.py @@ -0,0 +1 @@ +from . import test_avco_negative_fix diff --git a/stock_account_avco_negative_fix/tests/test_avco_negative_fix.py b/stock_account_avco_negative_fix/tests/test_avco_negative_fix.py new file mode 100644 index 0000000..a3f7f07 --- /dev/null +++ b/stock_account_avco_negative_fix/tests/test_avco_negative_fix.py @@ -0,0 +1,108 @@ +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestAvcoNegativeFix(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") + cls.customer_location = cls.env.ref("stock.stock_location_customers") + cls.stock_location = cls.env.ref("stock.stock_location_stock") + cls.picking_type_in = cls.env.ref("stock.picking_type_in") + cls.picking_type_out = cls.env.ref("stock.picking_type_out") + cls.categ_avco = cls.env["product.category"].create( + {"name": "Test AVCO category", "property_cost_method": "average"} + ) + cls.categ_fifo = cls.env["product.category"].create( + {"name": "Test FIFO category", "property_cost_method": "fifo"} + ) + + def _create_product(self, categ, standard_price): + return self.env["product.product"].create( + { + "name": "Test product %s" % categ.name, + "type": "product", + "categ_id": categ.id, + "standard_price": standard_price, + } + ) + + def _validate_move(self, product, qty, location_id, location_dest_id, price_unit=None): + move_vals = { + "name": "test move", + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "location_id": location_id.id, + "location_dest_id": location_dest_id.id, + } + if price_unit is not None: + move_vals["price_unit"] = price_unit + move = self.env["stock.move"].create(move_vals) + move._action_confirm() + move.quantity_done = qty + move._action_done() + return move + + def _sell_without_stock(self, product, qty): + return self._validate_move( + product, qty, self.stock_location, self.customer_location + ) + + def _receive(self, product, qty, price_unit): + return self._validate_move( + product, qty, self.supplier_location, self.stock_location, price_unit + ) + + def test_negative_result_is_corrected(self): + """TC-01 (RF-01): incoming move on a product with quantity_svl <= 0 + (risk precondition) unconditionally gets standard_price set to the + incoming move's price unit, reproducing product_id=8611.""" + product = self._create_product(self.categ_avco, 6.71) + self._sell_without_stock(product, 1.056) + self.assertAlmostEqual(product.quantity_svl, -1.056, places=3) + self.assertAlmostEqual(product.standard_price, 6.71, places=2) + + self._receive(product, 1.0, 7.56) + + self.assertGreater(product.standard_price, 0.0) + self.assertAlmostEqual(product.standard_price, 7.56, places=2) + + def test_zero_result_is_corrected(self): + """TC-02 (RF-02): quantity_svl exactly 0 before the incoming move is + also a risk precondition; standard_price is set to the incoming + move's price unit.""" + product = self._create_product(self.categ_avco, 6.0) + self._sell_without_stock(product, 2.0) + self.assertAlmostEqual(product.quantity_svl, -2.0, places=3) + self.assertAlmostEqual(product.standard_price, 6.0, places=2) + + self._receive(product, 3.0, 4.0) + + self.assertNotAlmostEqual(product.standard_price, 0.0, places=2) + self.assertAlmostEqual(product.standard_price, 4.0, places=2) + + def test_positive_result_is_untouched(self): + """TC-03 (RF-03): with quantity_svl > 0 before the incoming move + (no risk precondition), the post-processing fix does not + intervene at all and the core's weighted average is left as-is.""" + product = self._create_product(self.categ_avco, 1.0) + self._receive(product, 10.0, 10.0) + self.assertAlmostEqual(product.quantity_svl, 10.0, places=3) + self.assertAlmostEqual(product.standard_price, 10.0, places=2) + + self._receive(product, 10.0, 20.0) + + self.assertAlmostEqual(product.standard_price, 15.0, places=2) + + def test_non_avco_product_not_affected(self): + """TC-04 (RF-04): the fix only applies to cost_method == 'average' + products; FIFO products follow the core's unmodified behaviour.""" + product = self._create_product(self.categ_fifo, 5.0) + self.assertEqual(product.cost_method, "fifo") + + self._receive(product, 10.0, 8.0) + + self.assertAlmostEqual(product.standard_price, 8.0, places=2) From 8b0d0bb6cc5e0bac286331309189a0d53e056e01 Mon Sep 17 00:00:00 2001 From: luis Date: Tue, 22 Sep 2026 10:58:36 +0200 Subject: [PATCH 23/27] add hr_timesheet_sheet_no_review, hr_timesheet_sheet_holidays, hr_timesheet_sheet_delete_cascade --- hr_timesheet_sheet_delete_cascade/__init__.py | 2 + .../__manifest__.py | 25 + hr_timesheet_sheet_delete_cascade/i18n/es.po | 333 ++++++++ .../hr_timesheet_sheet_delete_cascade.pot | 334 ++++++++ .../models/__init__.py | 4 + .../models/account_analytic_line.py | 62 ++ .../models/hr_timesheet_sheet.py | 308 ++++++++ .../models/res_company.py | 28 + .../models/res_config_settings.py | 14 + .../readme/CONFIGURE.rst | 24 + .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 19 + .../readme/ROADMAP.rst | 16 + .../readme/USAGE.rst | 26 + .../security/hr_timesheet_sheet_security.xml | 30 + .../security/ir.model.access.csv | 2 + .../tests/__init__.py | 1 + .../test_hr_timesheet_sheet_delete_cascade.py | 744 ++++++++++++++++++ .../views/hr_timesheet_sheet_views.xml | 62 ++ .../views/res_config_settings_views.xml | 52 ++ .../wizards/__init__.py | 1 + .../wizards/delete_sheet_wizard.py | 113 +++ .../wizards/delete_sheet_wizard_views.xml | 60 ++ hr_timesheet_sheet_holidays/__init__.py | 1 + hr_timesheet_sheet_holidays/__manifest__.py | 17 + hr_timesheet_sheet_holidays/i18n/es.po | 41 + .../i18n/hr_timesheet_sheet_holidays.pot | 35 + .../models/__init__.py | 2 + .../models/account_analytic_line.py | 74 ++ .../models/hr_timesheet_sheet.py | 21 + .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 24 + .../readme/ROADMAP.rst | 5 + hr_timesheet_sheet_holidays/readme/USAGE.rst | 39 + hr_timesheet_sheet_holidays/tests/__init__.py | 1 + .../tests/test_hr_timesheet_sheet_holidays.py | 509 ++++++++++++ hr_timesheet_sheet_no_review/__init__.py | 2 + hr_timesheet_sheet_no_review/__manifest__.py | 23 + .../i18n/hr_timesheet_sheet_no_review.pot | 139 ++++ .../models/__init__.py | 1 + .../models/hr_timesheet_sheet.py | 48 ++ .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 20 + .../readme/ROADMAP.rst | 31 + hr_timesheet_sheet_no_review/readme/USAGE.rst | 22 + .../security/ir.model.access.csv | 2 + .../tests/__init__.py | 1 + .../test_hr_timesheet_sheet_no_review.py | 358 +++++++++ .../views/hr_timesheet_sheet_views.xml | 103 +++ hr_timesheet_sheet_no_review/views/menus.xml | 35 + .../wizards/__init__.py | 1 + .../wizards/unfreeze_wizard.py | 79 ++ .../wizards/unfreeze_wizard_views.xml | 50 ++ 53 files changed, 3953 insertions(+) create mode 100644 hr_timesheet_sheet_delete_cascade/__init__.py create mode 100644 hr_timesheet_sheet_delete_cascade/__manifest__.py create mode 100644 hr_timesheet_sheet_delete_cascade/i18n/es.po create mode 100644 hr_timesheet_sheet_delete_cascade/i18n/hr_timesheet_sheet_delete_cascade.pot create mode 100644 hr_timesheet_sheet_delete_cascade/models/__init__.py create mode 100644 hr_timesheet_sheet_delete_cascade/models/account_analytic_line.py create mode 100644 hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py create mode 100644 hr_timesheet_sheet_delete_cascade/models/res_company.py create mode 100644 hr_timesheet_sheet_delete_cascade/models/res_config_settings.py create mode 100644 hr_timesheet_sheet_delete_cascade/readme/CONFIGURE.rst create mode 100644 hr_timesheet_sheet_delete_cascade/readme/CONTRIBUTORS.rst create mode 100644 hr_timesheet_sheet_delete_cascade/readme/DESCRIPTION.rst create mode 100644 hr_timesheet_sheet_delete_cascade/readme/ROADMAP.rst create mode 100644 hr_timesheet_sheet_delete_cascade/readme/USAGE.rst create mode 100644 hr_timesheet_sheet_delete_cascade/security/hr_timesheet_sheet_security.xml create mode 100644 hr_timesheet_sheet_delete_cascade/security/ir.model.access.csv create mode 100644 hr_timesheet_sheet_delete_cascade/tests/__init__.py create mode 100644 hr_timesheet_sheet_delete_cascade/tests/test_hr_timesheet_sheet_delete_cascade.py create mode 100644 hr_timesheet_sheet_delete_cascade/views/hr_timesheet_sheet_views.xml create mode 100644 hr_timesheet_sheet_delete_cascade/views/res_config_settings_views.xml create mode 100644 hr_timesheet_sheet_delete_cascade/wizards/__init__.py create mode 100644 hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard.py create mode 100644 hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard_views.xml create mode 100644 hr_timesheet_sheet_holidays/__init__.py create mode 100644 hr_timesheet_sheet_holidays/__manifest__.py create mode 100644 hr_timesheet_sheet_holidays/i18n/es.po create mode 100644 hr_timesheet_sheet_holidays/i18n/hr_timesheet_sheet_holidays.pot create mode 100644 hr_timesheet_sheet_holidays/models/__init__.py create mode 100644 hr_timesheet_sheet_holidays/models/account_analytic_line.py create mode 100644 hr_timesheet_sheet_holidays/models/hr_timesheet_sheet.py create mode 100644 hr_timesheet_sheet_holidays/readme/CONTRIBUTORS.rst create mode 100644 hr_timesheet_sheet_holidays/readme/DESCRIPTION.rst create mode 100644 hr_timesheet_sheet_holidays/readme/ROADMAP.rst create mode 100644 hr_timesheet_sheet_holidays/readme/USAGE.rst create mode 100644 hr_timesheet_sheet_holidays/tests/__init__.py create mode 100644 hr_timesheet_sheet_holidays/tests/test_hr_timesheet_sheet_holidays.py create mode 100644 hr_timesheet_sheet_no_review/__init__.py create mode 100644 hr_timesheet_sheet_no_review/__manifest__.py create mode 100644 hr_timesheet_sheet_no_review/i18n/hr_timesheet_sheet_no_review.pot create mode 100644 hr_timesheet_sheet_no_review/models/__init__.py create mode 100644 hr_timesheet_sheet_no_review/models/hr_timesheet_sheet.py create mode 100644 hr_timesheet_sheet_no_review/readme/CONTRIBUTORS.rst create mode 100644 hr_timesheet_sheet_no_review/readme/DESCRIPTION.rst create mode 100644 hr_timesheet_sheet_no_review/readme/ROADMAP.rst create mode 100644 hr_timesheet_sheet_no_review/readme/USAGE.rst create mode 100644 hr_timesheet_sheet_no_review/security/ir.model.access.csv create mode 100644 hr_timesheet_sheet_no_review/tests/__init__.py create mode 100644 hr_timesheet_sheet_no_review/tests/test_hr_timesheet_sheet_no_review.py create mode 100644 hr_timesheet_sheet_no_review/views/hr_timesheet_sheet_views.xml create mode 100644 hr_timesheet_sheet_no_review/views/menus.xml create mode 100644 hr_timesheet_sheet_no_review/wizards/__init__.py create mode 100644 hr_timesheet_sheet_no_review/wizards/unfreeze_wizard.py create mode 100644 hr_timesheet_sheet_no_review/wizards/unfreeze_wizard_views.xml diff --git a/hr_timesheet_sheet_delete_cascade/__init__.py b/hr_timesheet_sheet_delete_cascade/__init__.py new file mode 100644 index 0000000..aee8895 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/hr_timesheet_sheet_delete_cascade/__manifest__.py b/hr_timesheet_sheet_delete_cascade/__manifest__.py new file mode 100644 index 0000000..e23d999 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "HR Timesheet Sheet - Delete Cascade", + "version": "16.0.1.0.0", + "category": "Human Resources", + "summary": "Delete a timesheet sheet along with its timesheet lines," + " with confirmation", + "author": "Criptomart, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/timesheet", + "license": "AGPL-3", + "development_status": "Beta", + "depends": ["hr_timesheet_sheet_holidays", "sale_timesheet"], + "data": [ + "security/ir.model.access.csv", + "security/hr_timesheet_sheet_security.xml", + "wizards/delete_sheet_wizard_views.xml", + "views/hr_timesheet_sheet_views.xml", + "views/res_config_settings_views.xml", + ], + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/hr_timesheet_sheet_delete_cascade/i18n/es.po b/hr_timesheet_sheet_delete_cascade/i18n/es.po new file mode 100644 index 0000000..9f206dc --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/i18n/es.po @@ -0,0 +1,333 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_timesheet_sheet_delete_cascade +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-09-15 08:20+0000\n" +"PO-Revision-Date: 2026-09-15 08:20+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" +"Language: es\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%(count)s entries, %(hours)s hours in total." +msgstr "%(count)s partes, %(hours)s horas en total." + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%(employee)s - %(type)s (%(date_from)s - %(date_to)s): %(days)s days" +msgstr "%(employee)s - %(type)s (%(date_from)s - %(date_to)s): %(days)s días" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%s days will be restored to the employee balances." +msgstr "Se devolverán %s días al saldo de los empleados." + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%s time off entries will be detached from the sheet and will survive the deletion." +msgstr "%s partes de ausencia se desvincularán de la hoja y sobrevivirán al borrado." + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.res_config_settings_view_form +msgid "" +"" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_account_analytic_line +msgid "Analytic Line" +msgstr "Línea analítica" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__blocking_reason +msgid "Blocking Reason" +msgstr "Motivo del bloqueo" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "Cancel" +msgstr "Cancelar" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__hr_timesheet_sheet_delete_wizard__leave_policy__cancel +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__res_company__timesheet_sheet_delete_leave_policy__cancel +msgid "Cancel the time off and restore the balance" +msgstr "Anular la ausencia y devolver los días al saldo" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.res_config_settings_view_form +msgid "" +"Choose what happens to the time off entries contained\n" +" in a timesheet sheet when the sheet is deleted. Keeping\n" +" the time off leaves the request untouched and only\n" +" detaches its entries from the sheet. Cancelling the time\n" +" off refuses and deletes the whole request and restores\n" +" the days to the employee balance, including the days\n" +" that fall outside the deleted sheet: it cannot be\n" +" undone." +msgstr "" +"Elige qué pasa con los partes de ausencia contenidos en una\n" +" hoja de servicio cuando se borra la hoja. Conservar la\n" +" ausencia la deja intacta y solo desvincula sus partes de\n" +" la hoja. Anular la ausencia rechaza y borra la ausencia\n" +" entera y devuelve los días al saldo del empleado,\n" +" incluidos los que caen fuera de la hoja borrada: no se\n" +" puede deshacer." + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_res_company +msgid "Companies" +msgstr "Compañías" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_res_config_settings +msgid "Config Settings" +msgstr "Ajustes de configuración" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_days +msgid "Days Restored" +msgstr "Días devueltos" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_form +msgid "Delete Sheet" +msgstr "Borrar hoja" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Delete Timesheet Sheet" +msgstr "Borrar hoja de servicio" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.actions.act_window,name:hr_timesheet_sheet_delete_cascade.action_hr_timesheet_sheet_delete_wizard +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_hr_timesheet_sheet_delete_wizard +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "Delete Timesheet Sheets" +msgstr "Borrar hojas de servicio" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "Delete permanently" +msgstr "Borrar definitivamente" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Deleting a timesheet sheet also deletes its timesheet entries and cannot be undone. Use the Delete Sheet button on the sheet to review what will be deleted and confirm it." +msgstr "Borrar una hoja de servicio borra también sus partes de horas y no se puede deshacer. Usa el botón Borrar hoja de la propia hoja para revisar qué se va a borrar y confirmarlo." + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__timesheet_hours +msgid "Hours" +msgstr "Horas" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__id +msgid "ID" +msgstr "ID" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__invoiced_count +msgid "Invoiced Entries" +msgstr "Partes facturados" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__hr_timesheet_sheet_delete_wizard__leave_policy__detach +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__res_company__timesheet_sheet_delete_leave_policy__detach +msgid "Keep the time off, detach its lines" +msgstr "Conservar la ausencia y desvincular sus líneas" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard____last_update +msgid "Last Modified on" +msgstr "Última modificación el" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__write_uid +msgid "Last Updated by" +msgstr "Última actualización por" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__write_date +msgid "Last Updated on" +msgstr "Última actualización el" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard.py:0 +#, python-format +msgid "No timesheet sheet was selected." +msgstr "No se ha seleccionado ninguna hoja de servicio." + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Sheet %(sheet)s contains %(count)s invoiced timesheet entries, which can never be deleted." +msgstr "La hoja %(sheet)s contiene %(count)s partes facturados, que no se pueden borrar nunca." + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Sheet %(sheet)s contains entries of time off that cannot be cancelled any more: %(leaves)s. Remove them from the Time Off application first." +msgstr "La hoja %(sheet)s contiene partes de ausencias que ya no se pueden anular: %(leaves)s. Retíralos antes desde la aplicación de Ausencias." + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Sheet %(sheet)s is already submitted or approved and cannot be deleted." +msgstr "La hoja %(sheet)s ya está enviada o aprobada y no se puede borrar." + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__sheet_count +msgid "Sheets" +msgstr "Hojas" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__summary_html +msgid "Summary" +msgstr "Resumen" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "The time off requests themselves are not modified and the employee balances do not change." +msgstr "Las ausencias no se modifican y el saldo de los empleados no varía." + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "" +"This cannot be undone. The selected sheets and the timesheet\n" +" entries they contain will be permanently deleted." +msgstr "" +"Esto no se puede deshacer. Las hojas seleccionadas y los partes de horas\n" +" que contienen se borrarán definitivamente." + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__partial_leave_ids +msgid "Time Off Beyond The Sheet Period" +msgstr "Ausencias que exceden el periodo de la hoja" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_count +msgid "Time Off Entries" +msgstr "Partes de ausencia" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_policy +msgid "Time Off Policy" +msgstr "Política de ausencias" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_res_company__timesheet_sheet_delete_leave_policy +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_res_config_settings__timesheet_sheet_delete_leave_policy +msgid "Time Off Policy On Sheet Deletion" +msgstr "Política de ausencias al borrar una hoja" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_ids +msgid "Time Off To Cancel" +msgstr "Ausencias que se van a anular" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Time off" +msgstr "Ausencias" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Time off to cancel" +msgstr "Ausencias que se van a anular" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__timesheet_count +msgid "Timesheet Entries" +msgstr "Partes de horas" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_hr_timesheet_sheet +msgid "Timesheet Sheet" +msgstr "Hoja de servicio" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__sheet_ids +msgid "Timesheet Sheets" +msgstr "Hojas de servicio" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Timesheet entries to delete" +msgstr "Partes de horas que se van a borrar" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Timesheet sheets to delete" +msgstr "Hojas de servicio que se van a borrar" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Warning: the following time off requests also cover days outside the period of the deleted sheets, and they will be cancelled in full: %s" +msgstr "Aviso: las siguientes ausencias cubren también días fuera del periodo de las hojas borradas, y se anularán por completo: %s" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,help:hr_timesheet_sheet_delete_cascade.field_res_company__timesheet_sheet_delete_leave_policy +#: model:ir.model.fields,help:hr_timesheet_sheet_delete_cascade.field_res_config_settings__timesheet_sheet_delete_leave_policy +msgid "" +"What happens to the time off entries contained in a timesheet sheet when the sheet is deleted.\n" +"Keep the time off: the time off request is left untouched, its entries are detached from the sheet and survive, and the employee balance does not change.\n" +"Cancel the time off: the whole time off request is refused and deleted, and the days are restored to the employee balance. This cannot be undone." +msgstr "" +"Qué pasa con los partes de ausencia contenidos en una hoja de servicio cuando se borra la hoja.\n" +"Conservar la ausencia: la ausencia queda intacta, sus partes se desvinculan de la hoja y sobreviven, y el saldo del empleado no varía.\n" +"Anular la ausencia: la ausencia entera se rechaza y se borra, y los días vuelven al saldo del empleado. Esto no se puede deshacer." \ No newline at end of file diff --git a/hr_timesheet_sheet_delete_cascade/i18n/hr_timesheet_sheet_delete_cascade.pot b/hr_timesheet_sheet_delete_cascade/i18n/hr_timesheet_sheet_delete_cascade.pot new file mode 100644 index 0000000..1d2c870 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/i18n/hr_timesheet_sheet_delete_cascade.pot @@ -0,0 +1,334 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_timesheet_sheet_delete_cascade +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-09-15 08:20+0000\n" +"PO-Revision-Date: 2026-09-15 08:20+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: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%(count)s entries, %(hours)s hours in total." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%(employee)s - %(type)s (%(date_from)s - %(date_to)s): %(days)s days" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "%s days will be restored to the employee balances." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"%s time off entries will be detached from the sheet and will survive the " +"deletion." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.res_config_settings_view_form +msgid "" +"" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_account_analytic_line +msgid "Analytic Line" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__blocking_reason +msgid "Blocking Reason" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "Cancel" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__hr_timesheet_sheet_delete_wizard__leave_policy__cancel +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__res_company__timesheet_sheet_delete_leave_policy__cancel +msgid "Cancel the time off and restore the balance" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.res_config_settings_view_form +msgid "" +"Choose what happens to the time off entries contained\n" +" in a timesheet sheet when the sheet is deleted. Keeping\n" +" the time off leaves the request untouched and only\n" +" detaches its entries from the sheet. Cancelling the time\n" +" off refuses and deletes the whole request and restores\n" +" the days to the employee balance, including the days\n" +" that fall outside the deleted sheet: it cannot be\n" +" undone." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_res_company +msgid "Companies" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_days +msgid "Days Restored" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_form +msgid "Delete Sheet" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Delete Timesheet Sheet" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.actions.act_window,name:hr_timesheet_sheet_delete_cascade.action_hr_timesheet_sheet_delete_wizard +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_hr_timesheet_sheet_delete_wizard +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "Delete Timesheet Sheets" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "Delete permanently" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"Deleting a timesheet sheet also deletes its timesheet entries and cannot be " +"undone. Use the Delete Sheet button on the sheet to review what will be " +"deleted and confirm it." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__display_name +msgid "Display Name" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__timesheet_hours +msgid "Hours" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__id +msgid "ID" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__invoiced_count +msgid "Invoiced Entries" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__hr_timesheet_sheet_delete_wizard__leave_policy__detach +#: model:ir.model.fields.selection,name:hr_timesheet_sheet_delete_cascade.selection__res_company__timesheet_sheet_delete_leave_policy__detach +msgid "Keep the time off, detach its lines" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard____last_update +msgid "Last Modified on" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/wizards/delete_sheet_wizard.py:0 +#, python-format +msgid "No timesheet sheet was selected." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"Sheet %(sheet)s contains %(count)s invoiced timesheet entries, which can " +"never be deleted." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"Sheet %(sheet)s contains entries of time off that cannot be cancelled any " +"more: %(leaves)s. Remove them from the Time Off application first." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"Sheet %(sheet)s is already submitted or approved and cannot be deleted." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__sheet_count +msgid "Sheets" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__summary_html +msgid "Summary" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"The time off requests themselves are not modified and the employee balances " +"do not change." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_delete_cascade.hr_timesheet_sheet_delete_wizard_form +msgid "" +"This cannot be undone. The selected sheets and the timesheet\n" +" entries they contain will be permanently deleted." +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__partial_leave_ids +msgid "Time Off Beyond The Sheet Period" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_count +msgid "Time Off Entries" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_policy +msgid "Time Off Policy" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_res_company__timesheet_sheet_delete_leave_policy +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_res_config_settings__timesheet_sheet_delete_leave_policy +msgid "Time Off Policy On Sheet Deletion" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__leave_ids +msgid "Time Off To Cancel" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Time off" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Time off to cancel" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__timesheet_count +msgid "Timesheet Entries" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model,name:hr_timesheet_sheet_delete_cascade.model_hr_timesheet_sheet +msgid "Timesheet Sheet" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,field_description:hr_timesheet_sheet_delete_cascade.field_hr_timesheet_sheet_delete_wizard__sheet_ids +msgid "Timesheet Sheets" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Timesheet entries to delete" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "Timesheet sheets to delete" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#. odoo-python +#: code:addons/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py:0 +#, python-format +msgid "" +"Warning: the following time off requests also cover days outside the period " +"of the deleted sheets, and they will be cancelled in full: %s" +msgstr "" + +#. module: hr_timesheet_sheet_delete_cascade +#: model:ir.model.fields,help:hr_timesheet_sheet_delete_cascade.field_res_company__timesheet_sheet_delete_leave_policy +#: model:ir.model.fields,help:hr_timesheet_sheet_delete_cascade.field_res_config_settings__timesheet_sheet_delete_leave_policy +msgid "" +"What happens to the time off entries contained in a timesheet sheet when the sheet is deleted.\n" +"Keep the time off: the time off request is left untouched, its entries are detached from the sheet and survive, and the employee balance does not change.\n" +"Cancel the time off: the whole time off request is refused and deleted, and the days are restored to the employee balance. This cannot be undone." +msgstr "" diff --git a/hr_timesheet_sheet_delete_cascade/models/__init__.py b/hr_timesheet_sheet_delete_cascade/models/__init__.py new file mode 100644 index 0000000..dcdb896 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_company +from . import res_config_settings +from . import account_analytic_line +from . import hr_timesheet_sheet diff --git a/hr_timesheet_sheet_delete_cascade/models/account_analytic_line.py b/hr_timesheet_sheet_delete_cascade/models/account_analytic_line.py new file mode 100644 index 0000000..085b643 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/models/account_analytic_line.py @@ -0,0 +1,62 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + +# The context of an RPC call is controlled by the client: odoo.api.call_kw +# rebuilds the environment with whatever dictionary arrives in the request, so +# a flag carrying a JSON value could be forged by any authenticated user and +# the cascade would delete a sheet without the confirmation wizard. This +# sentinel is compared by identity and cannot be produced by a deserializer, +# so it only ever reaches the consumer from inside this process. +_SHEET_DELETE_CASCADE = object() + + +class AccountAnalyticLine(models.Model): + _inherit = "account.analytic.line" + + def _sheet_delete_cascade_token(self): + """Hook for extensions""" + return _SHEET_DELETE_CASCADE + + def _in_sheet_delete_cascade(self): + return ( + self.env.context.get("sheet_delete_cascade") + is self._sheet_delete_cascade_token() + ) + + def _sheet_leave_policy(self): + """Hook for extensions""" + companies = self.sheet_id.company_id or self.company_id or self.env.company + policies = companies.mapped("timesheet_sheet_delete_leave_policy") + return "cancel" if "cancel" in policies else "detach" + + def _in_deliberate_delete_cascade(self): + """Hook for extensions + + The sentinel alone is not enough: the elevated privileges that only the + cancellation branch of the sheet grants are required as a second + barrier, so an in-process caller that merely carries the cascade + context cannot delete a time off entry on its way. + """ + return self._in_sheet_delete_cascade() and self.env.su + + def _detach_leave_lines_on_unlink(self): + if self._in_deliberate_delete_cascade(): + return self._sheet_leave_policy() != "cancel" + return super()._detach_leave_lines_on_unlink() + + @api.ondelete(at_uninstall=False) + def _unlink_except_linked_leave(self): + # Defence in depth. The cancellation branch of the cascade never gets + # here with a time off entry: project_timesheet_holidays clears + # holiday_id before unlinking the entries of a refused request + # (models/hr_holidays.py:127-134), so by then they are ordinary lines. + # This keeps the contract true for any other caller that unlinks a time + # off entry from inside a deliberate cascade. + if ( + self._in_deliberate_delete_cascade() + and self._sheet_leave_policy() == "cancel" + ): + return + return super()._unlink_except_linked_leave() diff --git a/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py b/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py new file mode 100644 index 0000000..9eefcd0 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/models/hr_timesheet_sheet.py @@ -0,0 +1,308 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import SUPERUSER_ID, _, models +from odoo.exceptions import UserError +from odoo.tools.misc import format_date, format_duration + +REFUSABLE_LEAVE_STATES = ("draft", "confirm", "validate", "validate1") + + +class Sheet(models.Model): + _inherit = "hr_timesheet.sheet" + + def _sheet_delete_cascade_token(self): + """Hook for extensions""" + return self.env["account.analytic.line"]._sheet_delete_cascade_token() + + def _with_delete_cascade(self): + """Hook for extensions + + The deliberate way to run the cascade from code. It is a private + method on purpose: a name starting with an underscore cannot be + reached through call_kw, so the sentinel cannot be obtained remotely. + """ + token = self._sheet_delete_cascade_token() + return self.with_context(sheet_delete_cascade=token) + + def _in_delete_cascade(self): + return ( + self.env.context.get("sheet_delete_cascade") + is self._sheet_delete_cascade_token() + ) + + def _sheet_leave_policy(self): + """Hook for extensions + + The policy in force for this recordset: ``cancel`` as soon as one + company asks for it, so that a summary never understates what is about + to happen. The destructive path works sheet by sheet, where the + recordset holds a single sheet and the answer is exact. + """ + companies = self.company_id or self.env.company + policies = companies.mapped("timesheet_sheet_delete_leave_policy") + return "cancel" if "cancel" in policies else "detach" + + def _delete_cascade_cancel_sheets(self): + """Hook for extensions""" + return self.filtered(lambda sheet: sheet._sheet_leave_policy() == "cancel") + + def _sheet_leave_lines(self): + """Hook for extensions""" + return self.mapped("timesheet_ids").filtered("holiday_id") + + def _sheet_leaves(self): + """Hook for extensions + + Read in sudo: a timesheet manager entitled to delete the sheet of + another employee is not necessarily entitled to read that employee's + time off, and the confirmation wizard has to describe it. Only reached + with the ``cancel`` policy; the ``detach`` policy never reads hr.leave. + """ + return self._sheet_leave_lines().holiday_id.sudo() + + def _sheet_leaves_to_cancel(self): + """Hook for extensions""" + return self._sheet_leaves().filtered( + lambda leave: leave.state in REFUSABLE_LEAVE_STATES + ) + + def _sheet_leaves_not_cancellable(self): + """Hook for extensions""" + return self._sheet_leaves().filtered( + lambda leave: leave.state not in REFUSABLE_LEAVE_STATES + ) + + def _sheet_leaves_beyond_period(self): + """Hook for extensions""" + sheet_lines = self.mapped("timesheet_ids") + leaves = self._sheet_leaves_to_cancel() + return leaves.filtered(lambda leave: bool(leave.timesheet_ids - sheet_lines)) + + def _delete_cascade_blocking(self): + """Hook for extensions""" + reasons = [] + for sheet in self: + invoiced = sheet.timesheet_ids.filtered("timesheet_invoice_id") + if invoiced: + reasons.append( + _( + "Sheet %(sheet)s contains %(count)s invoiced timesheet" + " entries, which can never be deleted.", + sheet=sheet.complete_name, + count=len(invoiced), + ) + ) + if sheet.state in ("confirm", "done"): + reasons.append( + _( + "Sheet %(sheet)s is already submitted or approved and" + " cannot be deleted.", + sheet=sheet.complete_name, + ) + ) + if sheet._sheet_leave_policy() != "cancel": + continue + stuck = sheet._sheet_leaves_not_cancellable() + if stuck: + reasons.append( + _( + "Sheet %(sheet)s contains entries of time off that" + " cannot be cancelled any more: %(leaves)s. Remove" + " them from the Time Off application first.", + sheet=sheet.complete_name, + leaves=", ".join( + "%s (%s)" % (leave.display_name, leave.state) + for leave in stuck + ), + ) + ) + return reasons + + def _delete_cascade_check(self): + """Hook for extensions""" + reasons = self._delete_cascade_blocking() + if reasons: + raise UserError("\n".join(reasons)) + + def _delete_cascade_detach_section(self): + """Hook for extensions""" + leave_lines = self._sheet_leave_lines() + if not leave_lines: + return None + return { + "title": _("Time off"), + "lines": [ + _( + "%s time off entries will be detached from the sheet and" + " will survive the deletion.", + len(leave_lines), + ), + _( + "The time off requests themselves are not modified and the" + " employee balances do not change." + ), + ], + } + + def _delete_cascade_cancel_section(self): + """Hook for extensions""" + leaves = self._sheet_leaves_to_cancel() + if not self._sheet_leave_lines(): + return None, 0.0 + lines = [ + _( + "%(employee)s - %(type)s (%(date_from)s - %(date_to)s):" + " %(days)s days", + employee=leave.employee_id.name, + type=leave.holiday_status_id.name, + date_from=format_date(self.env, leave.date_from), + date_to=format_date(self.env, leave.date_to), + days=leave.number_of_days, + ) + for leave in leaves + ] + leave_days = sum(leaves.mapped("number_of_days")) + lines.append( + _("%s days will be restored to the employee balances.", leave_days) + ) + beyond = self._sheet_leaves_beyond_period() + if beyond: + lines.append( + _( + "Warning: the following time off requests also cover days" + " outside the period of the deleted sheets, and they will" + " be cancelled in full: %s", + ", ".join( + "%s (%s - %s)" + % ( + leave.employee_id.name, + format_date(self.env, leave.date_from), + format_date(self.env, leave.date_to), + ) + for leave in beyond + ), + ) + ) + return {"title": _("Time off to cancel"), "lines": lines}, leave_days + + def _delete_cascade_leave_sections(self): + """Hook for extensions + + Built per policy rather than per batch: a selection may span companies + that answer differently, and the summary has to describe what each + sheet is really going to do. + """ + sections = [] + cancel_sheets = self._delete_cascade_cancel_sheets() + detach_section = (self - cancel_sheets)._delete_cascade_detach_section() + if detach_section: + sections.append(detach_section) + cancel_section, leave_days = cancel_sheets._delete_cascade_cancel_section() + if cancel_section: + sections.append(cancel_section) + return sections, leave_days + + def _delete_cascade_prepare(self): + """Hook for extensions""" + lines = self.mapped("timesheet_ids") + invoiced = lines.filtered("timesheet_invoice_id") + hours = sum(lines.mapped("unit_amount")) + sections = [ + { + "title": _("Timesheet sheets to delete"), + "lines": [sheet.complete_name for sheet in self], + }, + { + "title": _("Timesheet entries to delete"), + "lines": [ + _( + "%(count)s entries, %(hours)s hours in total.", + count=len(lines), + hours=format_duration(hours), + ) + ], + }, + ] + leave_sections, leave_days = self._delete_cascade_leave_sections() + sections.extend(leave_sections) + return { + "sheet_count": len(self), + "timesheet_count": len(lines), + "timesheet_hours": hours, + "invoiced_count": len(invoiced), + "leave_count": len(self._sheet_leave_lines()), + "leave_days": leave_days, + "leave_policy": self._sheet_leave_policy(), + "blocking": self._delete_cascade_blocking(), + "sections": sections, + } + + def _delete_cascade_cancel_leaves(self): + """Hook for extensions""" + # sudo() is not enough here, and the extra elevation is deliberate. + # sudo() flips env.su but keeps the uid, and hr.leave's + # _unlink_if_correct_states gates the deletion on + # user_has_groups('hr_holidays.group_hr_holidays_user'), which reads + # the uid: without a Time Off officer behind it, deleting a time off + # that already started raises. SUPERUSER_ID is the only way to run the + # documented refuse/draft/unlink sequence as any employee. + # It stays safe because it is confined to hr.leave, it only ever + # reaches the requests found inside sheets the user is already allowed + # to delete by the record rules of this module, and it is unreachable + # unless the company policy is "cancel". + leaves = self._sheet_leaves_to_cancel().with_user(SUPERUSER_ID).sudo() + if not leaves: + return + leaves.action_refuse() + leaves.action_draft() + leaves.unlink() + + def _delete_cascade_apply(self): + """Hook for extensions""" + for sheet in self: + if sheet._sheet_leave_policy() == "cancel": + sheet._delete_cascade_cancel_leaves() + # action_refuse() calls _check_missing_global_leave_timesheets(), + # which can create public holiday entries that land in this very + # sheet. Re-read the set so they are deleted with it instead of + # being left behind pointing at a sheet that no longer exists. + sheet.invalidate_recordset(["timesheet_ids"]) + sheet.timesheet_ids.exists().unlink() + continue + lines = sheet.timesheet_ids.exists() + leave_lines = lines.filtered("holiday_id") + if leave_lines: + leave_lines.write({"sheet_id": False}) + other_lines = lines - leave_lines + if other_lines: + other_lines.unlink() + + def unlink(self): + if not self._in_delete_cascade(): + raise UserError( + _( + "Deleting a timesheet sheet also deletes its timesheet" + " entries and cannot be undone. Use the Delete Sheet" + " button on the sheet to review what will be deleted and" + " confirm it." + ) + ) + self._delete_cascade_check() + self._delete_cascade_apply() + return super().unlink() + + def action_delete_sheet(self): + return { + "type": "ir.actions.act_window", + "name": _("Delete Timesheet Sheet"), + "res_model": "hr_timesheet.sheet.delete.wizard", + "view_mode": "form", + "target": "new", + "context": dict( + self.env.context, + active_model="hr_timesheet.sheet", + active_ids=self.ids, + active_id=self.ids[0] if self.ids else False, + ), + } diff --git a/hr_timesheet_sheet_delete_cascade/models/res_company.py b/hr_timesheet_sheet_delete_cascade/models/res_company.py new file mode 100644 index 0000000..b693fb7 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/models/res_company.py @@ -0,0 +1,28 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +LEAVE_POLICY_SELECTION = [ + ("detach", "Keep the time off, detach its lines"), + ("cancel", "Cancel the time off and restore the balance"), +] + + +class ResCompany(models.Model): + _inherit = "res.company" + + timesheet_sheet_delete_leave_policy = fields.Selection( + selection=LEAVE_POLICY_SELECTION, + string="Time Off Policy On Sheet Deletion", + default="detach", + required=True, + help="What happens to the time off entries contained in a timesheet" + " sheet when the sheet is deleted.\n" + "Keep the time off: the time off request is left untouched, its" + " entries are detached from the sheet and survive, and the employee" + " balance does not change.\n" + "Cancel the time off: the whole time off request is refused and" + " deleted, and the days are restored to the employee balance. This" + " cannot be undone.", + ) diff --git a/hr_timesheet_sheet_delete_cascade/models/res_config_settings.py b/hr_timesheet_sheet_delete_cascade/models/res_config_settings.py new file mode 100644 index 0000000..814dcf2 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/models/res_config_settings.py @@ -0,0 +1,14 @@ +# Copyright 2026 Criptomart +# 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" + + timesheet_sheet_delete_leave_policy = fields.Selection( + related="company_id.timesheet_sheet_delete_leave_policy", + readonly=False, + required=True, + ) diff --git a/hr_timesheet_sheet_delete_cascade/readme/CONFIGURE.rst b/hr_timesheet_sheet_delete_cascade/readme/CONFIGURE.rst new file mode 100644 index 0000000..c4cb504 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/readme/CONFIGURE.rst @@ -0,0 +1,24 @@ +Two things are not resolved by installing the module and have to be reviewed +by hand. + +**The record rules change existing permissions.** Before this module, every +internal user could write and delete the timesheet sheet of any other +employee. The module restricts writing and deleting to the owner of the sheet +for every internal user (``base.group_user``), and lifts the restriction for +the members of *Timesheets / Administrator* +(``hr_timesheet.group_timesheet_manager``), who keep access to every sheet. +The restrictive rule is set on ``base.group_user`` on purpose: a rule set on a +timesheet group would leave any internal user outside that group with the +unrestricted access the ACL grants and no rule at all. Reading is left open so +that the *All Timesheet Sheets* menu and the reports keep working. Check with +the customer whether any flow relies on a colleague editing somebody else's +sheet before installing. + +**The time off policy has a conservative default.** In +*Settings / Timesheets / Timesheet Options*, the field *Time Off Policy On +Sheet Deletion* is per company and defaults to *Keep the time off, detach its +lines*: deleting a sheet never touches a time off request nor an employee +balance. An installation that expects the deletion of a sheet to cancel the +time off it contains has to set *Cancel the time off and restore the balance* +on purpose. That value is irreversible and cancels the whole request, even the +days that fall in other weeks. diff --git a/hr_timesheet_sheet_delete_cascade/readme/CONTRIBUTORS.rst b/hr_timesheet_sheet_delete_cascade/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..3c75247 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Criptomart + + * Luis Martínez diff --git a/hr_timesheet_sheet_delete_cascade/readme/DESCRIPTION.rst b/hr_timesheet_sheet_delete_cascade/readme/DESCRIPTION.rst new file mode 100644 index 0000000..d17cddb --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/readme/DESCRIPTION.rst @@ -0,0 +1,19 @@ +In ``hr_timesheet_sheet``, deleting a timesheet sheet does not delete the +timesheet entries it contains: they are left behind with an empty ``Sheet`` +field, invisible from any sheet and hard to find again. + +This module makes the deletion of a sheet cascade to its timesheet entries, +behind a confirmation wizard that states beforehand how many entries and how +many hours are about to disappear. Invoiced entries are never deleted: a sheet +that contains one cannot be deleted at all. + +Because a sheet also contains the entries generated by approved time off, the +module makes that case an explicit company policy: either the time off request +is kept untouched and only its entries are detached from the sheet (the +default), or the request is cancelled and the days are restored to the +employee balance. + +Turning a harmless ``unlink()`` into a destructive one is only safe if the +record rules are in place first, so the module also adds the record rules that +``hr_timesheet.sheet`` is missing: without them any internal user can delete +the sheet of any other employee. diff --git a/hr_timesheet_sheet_delete_cascade/readme/ROADMAP.rst b/hr_timesheet_sheet_delete_cascade/readme/ROADMAP.rst new file mode 100644 index 0000000..7002136 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/readme/ROADMAP.rst @@ -0,0 +1,16 @@ +* Public holidays generate timesheet entries carrying ``global_leave_id`` + instead of ``holiday_id``. Neither policy applies to them: they do not belong + to a single employee and there is no balance to restore. They are deleted + with the sheet like any other entry, and they are removed on their own when + the public holiday itself is deleted, through the ``ondelete='cascade'`` of + the field. + +* The ``cancel`` policy refuses, resets and deletes the time off request. An + installation that wants to keep a history of the cancelled requests would + stop at the refusal, which is already where the days are restored to the + balance. It is a known variant and it is not implemented. + +* The confirmation wizard reads the time off requests in ``sudo`` under the + ``cancel`` policy, because a timesheet manager entitled to delete the sheet + of another employee is not necessarily entitled to read that employee's time + off. diff --git a/hr_timesheet_sheet_delete_cascade/readme/USAGE.rst b/hr_timesheet_sheet_delete_cascade/readme/USAGE.rst new file mode 100644 index 0000000..9c1b539 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/readme/USAGE.rst @@ -0,0 +1,26 @@ +The native trash bin is removed from the sheet form, list and kanban views. +Sheets are deleted from the **Delete Sheet** button in the header of the sheet +form, or from the **Action** menu of the list view for several sheets at once. + +Both open a confirmation wizard that lists the sheets, the number of timesheet +entries and the total number of hours that will be deleted, and a time off +section that depends on the configured policy. Nothing is deleted until +**Delete permanently** is pressed, and the button is hidden when something +blocks the deletion. + +A plain ``sheet.unlink()`` from a script or a server action raises a +``UserError`` that points to the button, so the confirmation step cannot be +skipped by accident. Code that really means to delete does +``sheet._with_delete_cascade().unlink()``. + +The cascade is armed by a sentinel object rather than by a plain value in the +context, and ``_with_delete_cascade()`` is the only way to obtain it. The +context of an RPC call is controlled by the caller, so a context flag holding +a JSON value could be forged by any authenticated user and would delete a +sheet without ever showing the confirmation; a sentinel compared by identity +cannot be produced by a deserializer, and a method whose name starts with an +underscore cannot be reached remotely. + +With the ``Cancel the time off`` policy, cancelling a time off request posts a +message in its chatter telling the employee that the request has been refused. +The wording talks about a refusal, not about a deleted timesheet sheet. diff --git a/hr_timesheet_sheet_delete_cascade/security/hr_timesheet_sheet_security.xml b/hr_timesheet_sheet_delete_cascade/security/hr_timesheet_sheet_security.xml new file mode 100644 index 0000000..b4eb750 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/security/hr_timesheet_sheet_security.xml @@ -0,0 +1,30 @@ + + + + + Timesheet sheet: only own sheets can be changed + + [('employee_id.user_id', '=', user.id)] + + + + + + + + Timesheet sheet: managers can change every sheet + + [(1, '=', 1)] + + + + + + + diff --git a/hr_timesheet_sheet_delete_cascade/security/ir.model.access.csv b/hr_timesheet_sheet_delete_cascade/security/ir.model.access.csv new file mode 100644 index 0000000..c6ea578 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/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_hr_timesheet_sheet_delete_wizard,hr_timesheet.sheet.delete.wizard,model_hr_timesheet_sheet_delete_wizard,base.group_user,1,1,1,1 diff --git a/hr_timesheet_sheet_delete_cascade/tests/__init__.py b/hr_timesheet_sheet_delete_cascade/tests/__init__.py new file mode 100644 index 0000000..8855e3a --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/tests/__init__.py @@ -0,0 +1 @@ +from . import test_hr_timesheet_sheet_delete_cascade diff --git a/hr_timesheet_sheet_delete_cascade/tests/test_hr_timesheet_sheet_delete_cascade.py b/hr_timesheet_sheet_delete_cascade/tests/test_hr_timesheet_sheet_delete_cascade.py new file mode 100644 index 0000000..c15ac58 --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/tests/test_hr_timesheet_sheet_delete_cascade.py @@ -0,0 +1,744 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +from datetime import date, datetime + +from odoo import SUPERUSER_ID +from odoo.api import call_kw +from odoo.exceptions import AccessError, UserError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +WEEK_START = date(2026, 10, 5) +WEEK_END = date(2026, 10, 11) + + +@tagged("post_install", "-at_install") +class TestHrTimesheetSheetDeleteCascade(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + tracking_disable=True, + no_reset_password=True, + mail_create_nolog=True, + mail_notrack=True, + ) + ) + cls.company = cls.env["res.company"].create({"name": "Delete Cascade Co"}) + cls.env.user.write({"company_ids": [(4, cls.company.id)]}) + cls.company.resource_calendar_id.tz = "UTC" + cls.internal_project = cls.company.internal_project_id + cls.timeoff_task = cls.company.leave_timesheet_task_id + + cls.user = cls._new_user("tsdc_employee") + cls.user_other = cls._new_user("tsdc_other") + cls.user_span = cls._new_user("tsdc_span") + cls.user_manager = cls._new_user( + "tsdc_manager", group="hr_timesheet.group_timesheet_manager" + ) + cls.user_internal = cls._new_internal_user("tsdc_internal") + cls.employee = cls._new_employee("Cascade Employee", cls.user) + cls.employee_other = cls._new_employee("Other Employee", cls.user_other) + cls.employee_span = cls._new_employee("Span Employee", cls.user_span) + cls._new_employee("Cascade Manager", cls.user_manager) + + cls.project = cls.env["project.project"].create( + { + "name": "Delete Cascade Project", + "company_id": cls.company.id, + "allow_timesheets": True, + "is_timesheet_task_required": False, + } + ) + cls.leave_type = cls.env["hr.leave.type"].create( + { + "name": "Paid Time Off (cascade test)", + "company_id": cls.company.id, + "requires_allocation": "yes", + "allocation_validation_type": "no", + "leave_validation_type": "no_validation", + "timesheet_generate": True, + "timesheet_project_id": cls.internal_project.id, + "timesheet_task_id": cls.timeoff_task.id, + } + ) + cls._allocate(cls.employee) + cls._allocate(cls.employee_span) + + cls.leave = cls._request_leave( + cls.employee, + datetime(2026, 10, 6, 6, 0, 0), + datetime(2026, 10, 8, 18, 0, 0), + ) + cls.leave_lines = cls.leave.sudo().timesheet_ids + cls.leave_span = cls._request_leave( + cls.employee_span, + datetime(2026, 10, 8, 6, 0, 0), + datetime(2026, 10, 13, 18, 0, 0), + ) + cls.leave_span_lines = cls.leave_span.sudo().timesheet_ids + + cls.sheet = cls._new_sheet(cls.employee) + cls.regular_line = cls._new_regular_line(cls.employee, 4.0) + cls.sheet._compute_timesheet_ids() + cls.sheet_other = cls._new_sheet(cls.employee_other) + + # Fixture helpers + + @classmethod + def _new_user(cls, login, group="hr_timesheet.group_hr_timesheet_user"): + return ( + cls.env["res.users"] + .with_context(no_reset_password=True, default_role_line_ids=[]) + .create( + { + "name": login, + "login": login, + "email": "%s@example.org" % login, + "company_id": cls.company.id, + "company_ids": [(6, 0, [cls.company.id])], + "groups_id": [ + ( + 6, + 0, + [ + cls.env.ref(group).id, + cls.env.ref("project.group_project_user").id, + # Third party add-ons read project_id.stage_id + # when a timesheet line is written, and that + # field is protected by this group. + cls.env.ref("project.group_project_stages").id, + ], + ) + ], + } + ) + ) + + @classmethod + def _new_internal_user(cls, login): + return ( + cls.env["res.users"] + .with_context(no_reset_password=True, default_role_line_ids=[]) + .create( + { + "name": login, + "login": login, + "email": "%s@example.org" % login, + "company_id": cls.company.id, + "company_ids": [(6, 0, [cls.company.id])], + "groups_id": [(6, 0, [cls.env.ref("base.group_user").id])], + } + ) + ) + + @classmethod + def _new_employee(cls, name, user): + return cls.env["hr.employee"].create( + {"name": name, "user_id": user.id, "company_id": cls.company.id} + ) + + @classmethod + def _allocate(cls, employee): + allocation = cls.env["hr.leave.allocation"].create( + { + "name": "Allocation for %s" % employee.name, + "holiday_status_id": cls.leave_type.id, + "employee_id": employee.id, + "number_of_days": 25, + "date_from": date(2026, 1, 1), + } + ) + if allocation.state != "validate": + allocation.action_validate() + return allocation + + @classmethod + def _request_leave(cls, employee, date_from, date_to): + return cls.env["hr.leave"].create( + { + "name": "Time off for %s" % employee.name, + "holiday_status_id": cls.leave_type.id, + "employee_id": employee.id, + "date_from": date_from, + "date_to": date_to, + } + ) + + @classmethod + def _new_sheet(cls, employee): + return cls.env["hr_timesheet.sheet"].create( + { + "employee_id": employee.id, + "company_id": cls.company.id, + "date_start": WEEK_START, + "date_end": WEEK_END, + } + ) + + @classmethod + def _new_regular_line(cls, employee, hours): + return cls.env["account.analytic.line"].create( + { + "name": "Regular work", + "project_id": cls.project.id, + "employee_id": employee.id, + "date": WEEK_START, + "unit_amount": hours, + } + ) + + # Test helpers + + def _set_policy(self, policy): + self.company.timesheet_sheet_delete_leave_policy = policy + + def _recompute(self, records): + token = self.env["account.analytic.line"]._sheet_recompute_token() + return records.with_context(sheet_recompute=token) + + def _cascade(self, records): + token = self.env["account.analytic.line"]._sheet_delete_cascade_token() + return records.with_context(sheet_delete_cascade=token) + + def _wizard(self, sheets, user=None): + return ( + self.env["hr_timesheet.sheet.delete.wizard"] + .with_user(user or self.user) + .with_context( + active_model="hr_timesheet.sheet", active_ids=sheets.ids + ) + .create({}) + ) + + def _leave_balance(self, employee): + leave_type = self.leave_type.with_context(employee_id=employee.id) + leave_type.invalidate_recordset() + return leave_type.leaves_taken, leave_type.virtual_remaining_leaves + + def _span_sheet(self): + sheet = self._new_sheet(self.employee_span) + sheet._compute_timesheet_ids() + return sheet + + def _second_company_sheet_with_leave(self, policy): + company = self.env["res.company"].create({"name": "Cascade Co %s" % policy}) + company.resource_calendar_id.tz = "UTC" + company.timesheet_sheet_delete_leave_policy = policy + user = ( + self.env["res.users"] + .with_context(no_reset_password=True, default_role_line_ids=[]) + .create( + { + "name": "tsdc_%s" % policy, + "login": "tsdc_%s" % policy, + "email": "tsdc_%s@example.org" % policy, + "company_id": company.id, + "company_ids": [(6, 0, [company.id])], + "groups_id": [ + ( + 6, + 0, + [ + self.env.ref( + "hr_timesheet.group_hr_timesheet_user" + ).id, + self.env.ref("project.group_project_user").id, + self.env.ref("project.group_project_stages").id, + ], + ) + ], + } + ) + ) + employee = self.env["hr.employee"].create( + { + "name": "Employee %s" % policy, + "user_id": user.id, + "company_id": company.id, + } + ) + leave_type = self.env["hr.leave.type"].create( + { + "name": "Paid Time Off (%s)" % policy, + "company_id": company.id, + "requires_allocation": "yes", + "allocation_validation_type": "no", + "leave_validation_type": "no_validation", + "timesheet_generate": True, + "timesheet_project_id": company.internal_project_id.id, + "timesheet_task_id": company.leave_timesheet_task_id.id, + } + ) + allocation = self.env["hr.leave.allocation"].create( + { + "name": "Allocation %s" % policy, + "holiday_status_id": leave_type.id, + "employee_id": employee.id, + "number_of_days": 25, + "date_from": date(2026, 1, 1), + } + ) + if allocation.state != "validate": + allocation.action_validate() + leave = self.env["hr.leave"].create( + { + "name": "Time off %s" % policy, + "holiday_status_id": leave_type.id, + "employee_id": employee.id, + "date_from": datetime(2026, 10, 6, 6, 0, 0), + "date_to": datetime(2026, 10, 7, 18, 0, 0), + } + ) + sheet = self.env["hr_timesheet.sheet"].create( + { + "employee_id": employee.id, + "company_id": company.id, + "date_start": WEEK_START, + "date_end": WEEK_END, + } + ) + sheet._compute_timesheet_ids() + return { + "company": company, + "user": user, + "employee": employee, + "leave": leave, + "sheet": sheet, + } + + def _invoice(self): + journal = self.env["account.journal"].create( + { + "name": "Cascade Test Journal", + "code": "CTJ", + "type": "general", + "company_id": self.company.id, + } + ) + return self.env["account.move"].create( + { + "move_type": "entry", + "journal_id": journal.id, + "company_id": self.company.id, + } + ) + + # Fixture sanity + + def test_fixture(self): + self.assertEqual(len(self.leave_lines), 3) + self.assertEqual(self.leave.state, "validate") + self.assertEqual(len(self.leave_span_lines), 4) + self.assertEqual( + self.sheet.timesheet_ids, self.leave_lines | self.regular_line + ) + + # RF-01 + + def test_wizard_preview_does_not_delete(self): + wizard = self._wizard(self.sheet) + self.assertEqual(wizard.sheet_count, 1) + self.assertEqual(wizard.timesheet_count, len(self.sheet.timesheet_ids)) + self.assertEqual( + wizard.timesheet_hours, + sum(self.sheet.timesheet_ids.mapped("unit_amount")), + ) + self.assertIn(self.sheet.complete_name, wizard.summary_html) + self.assertTrue(self.sheet.exists()) + self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids) + + def test_wizard_action_opens_on_selected_sheets(self): + action = self.sheet.with_user(self.user).action_delete_sheet() + self.assertEqual(action["res_model"], "hr_timesheet.sheet.delete.wizard") + self.assertEqual(action["context"]["active_ids"], self.sheet.ids) + + # RF-02 + + def test_cascade_deletes_timesheets(self): + self._set_policy("detach") + sheet = self.sheet_other + line_a = self._new_regular_line(self.employee_other, 2.0) + line_b = self._new_regular_line(self.employee_other, 3.0) + sheet._compute_timesheet_ids() + line_ids = sheet.timesheet_ids.ids + self.assertEqual(sorted(line_ids), sorted((line_a | line_b).ids)) + self._wizard(sheet, user=self.user_other).action_confirm_delete() + self.assertFalse(sheet.exists()) + self.assertFalse(self.env["account.analytic.line"].browse(line_ids).exists()) + + def test_bare_unlink_is_refused(self): + with self.assertRaises(UserError): + self.sheet.with_user(self.user).unlink() + self.assertTrue(self.sheet.exists()) + + # RF-03 + + def test_user_cannot_delete_other_sheet(self): + other = self.sheet_other.with_user(self.user) + with self.assertRaises(AccessError): + other.check_access_rule("unlink") + with self.assertRaises(AccessError): + other.check_access_rule("write") + + def test_user_can_delete_own_sheet(self): + own = self.sheet.with_user(self.user) + own.check_access_rule("unlink") + own.check_access_rule("write") + + def test_internal_user_without_timesheet_group_cannot_delete_other_sheet(self): + self.assertFalse( + self.user_internal.has_group("hr_timesheet.group_hr_timesheet_user") + ) + sheet = self.sheet.with_user(self.user_internal) + with self.assertRaises(AccessError): + sheet.check_access_rule("unlink") + with self.assertRaises(AccessError): + sheet.check_access_rule("write") + + def test_manager_can_delete_any_sheet(self): + sheets = (self.sheet | self.sheet_other).with_user(self.user_manager) + sheets.check_access_rule("unlink") + sheets.check_access_rule("write") + + # RF-04 + + def test_invoiced_timesheets_block_deletion(self): + self.regular_line.timesheet_invoice_id = self._invoice() + wizard = self._wizard(self.sheet) + self.assertEqual(wizard.invoiced_count, 1) + self.assertTrue(wizard.blocking_reason) + self.assertIn(self.sheet.complete_name, wizard.blocking_reason) + self.assertIn("1", wizard.blocking_reason) + with self.assertRaises(UserError): + wizard.action_confirm_delete() + with self.assertRaises(UserError): + self._cascade(self.sheet.with_user(self.user)).unlink() + self.assertTrue(self.sheet.exists()) + + # RF-05 + + def test_policy_default_is_detach(self): + company = self.env["res.company"].create({"name": "Fresh Cascade Co"}) + self.assertEqual(company.timesheet_sheet_delete_leave_policy, "detach") + self.assertEqual(self.sheet._sheet_leave_policy(), "detach") + + def test_policy_detach_keeps_leave(self): + self._set_policy("detach") + leave_line_ids = self.leave_lines.ids + self._wizard(self.sheet).action_confirm_delete() + self.assertFalse(self.sheet.exists()) + self.assertFalse(self.regular_line.exists()) + lines = self.env["account.analytic.line"].browse(leave_line_ids) + self.assertEqual(lines.exists(), lines) + self.assertFalse(lines.mapped("sheet_id")) + self.assertTrue(self.leave.exists()) + self.assertEqual(self.leave.state, "validate") + + def test_policy_detach_keeps_balance(self): + self._set_policy("detach") + taken, remaining = self._leave_balance(self.employee) + self._wizard(self.sheet).action_confirm_delete() + self.assertEqual(self._leave_balance(self.employee), (taken, remaining)) + + # RF-06 + + def test_cascade_cancels_leaves_and_restores_balance(self): + self._set_policy("cancel") + taken, remaining = self._leave_balance(self.employee) + self.assertEqual(taken, 3.0) + leave_line_ids = self.leave_lines.ids + leave = self.leave + self._wizard(self.sheet).action_confirm_delete() + self.assertFalse(self.sheet.exists()) + self.assertFalse( + self.env["account.analytic.line"].browse(leave_line_ids).exists() + ) + self.assertFalse(self.regular_line.exists()) + self.assertFalse(leave.exists()) + new_taken, new_remaining = self._leave_balance(self.employee) + self.assertEqual(new_taken, taken - 3.0) + self.assertEqual(new_remaining, remaining + 3.0) + + # RF-07 + + def test_wizard_lists_leaves(self): + self._set_policy("cancel") + wizard = self._wizard(self.sheet) + self.assertEqual(wizard.leave_policy, "cancel") + self.assertEqual(wizard.leave_count, len(self.leave_lines)) + self.assertEqual(wizard.leave_days, 3.0) + self.assertEqual(wizard.leave_ids, self.leave) + self.assertFalse(wizard.partial_leave_ids) + self.assertIn(self.employee.name, wizard.summary_html) + self.assertIn(self.leave_type.name, wizard.summary_html) + + def test_wizard_warns_about_leaves_spanning_sheets(self): + self._set_policy("cancel") + sheet = self._span_sheet() + wizard = self._wizard(sheet, user=self.user_span) + self.assertEqual(wizard.partial_leave_ids, self.leave_span) + self.assertIn("Warning", wizard.summary_html) + self.assertIn(self.employee_span.name, wizard.summary_html) + + def test_wizard_detach_policy_states_leaves_untouched(self): + self._set_policy("detach") + wizard = self._wizard(self.sheet) + self.assertEqual(wizard.leave_policy, "detach") + self.assertEqual(wizard.leave_count, len(self.leave_lines)) + self.assertEqual(wizard.leave_days, 0.0) + self.assertFalse(wizard.leave_ids) + self.assertFalse(wizard.partial_leave_ids) + self.assertIn("balances do not change", wizard.summary_html) + + def test_wizard_without_leaves_has_no_leave_section(self): + line = self._new_regular_line(self.employee_other, 2.0) + sheet = self.sheet_other + sheet._compute_timesheet_ids() + self.assertEqual(sheet.timesheet_ids, line) + for policy in ("detach", "cancel"): + self._set_policy(policy) + wizard = self._wizard(sheet, user=self.user_other) + self.assertEqual(wizard.leave_count, 0) + self.assertNotIn("Time off", wizard.summary_html) + + def test_uncancellable_leave_blocks_deletion(self): + self._set_policy("cancel") + # A request already refused outside this module keeps its entries: + # action_refuse() would have removed them, a data fix would not. + self.leave.with_user(SUPERUSER_ID).sudo().write({"state": "refuse"}) + self.assertEqual(self.sheet._sheet_leaves_to_cancel(), self.env["hr.leave"]) + self.assertEqual(self.sheet._sheet_leaves_not_cancellable(), self.leave) + wizard = self._wizard(self.sheet) + self.assertTrue(wizard.blocking_reason) + self.assertIn(self.sheet.complete_name, wizard.blocking_reason) + self.assertIn("cannot be cancelled", wizard.blocking_reason) + with self.assertRaises(UserError): + wizard.action_confirm_delete() + self.env.invalidate_all() + self.assertTrue(self.sheet.exists()) + self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids) + + def test_multicompany_batch_summary_matches_each_policy(self): + self._set_policy("cancel") + other = self._second_company_sheet_with_leave("detach") + self.env.user.write({"company_ids": [(4, other["company"].id)]}) + sheets = self.sheet | other["sheet"] + data = sheets._delete_cascade_prepare() + titles = [section["title"] for section in data["sections"]] + # Compared against the titles the model itself produces, so the test + # does not depend on the language of the user running it. + self.assertIn(other["sheet"]._delete_cascade_detach_section()["title"], titles) + self.assertIn(self.sheet._delete_cascade_cancel_section()[0]["title"], titles) + self.assertEqual(data["leave_days"], 3.0) + self.assertEqual(data["leave_count"], 5) + cancel_title = self.sheet._delete_cascade_cancel_section()[0]["title"] + cancelled = " ".join( + line + for section in data["sections"] + if section["title"] == cancel_title + for line in section["lines"] + ) + self.assertIn(self.employee.name, cancelled) + self.assertNotIn(other["employee"].name, cancelled) + wizard = self._wizard(sheets, user=self.env.user) + self.assertEqual(wizard.leave_ids, self.leave) + self.assertEqual(wizard.leave_days, 3.0) + + def test_public_holiday_lines_created_while_cancelling_are_deleted(self): + self._set_policy("cancel") + global_leave = ( + self.env["resource.calendar.leaves"] + .with_context(allowed_company_ids=[self.company.id]) + .create( + { + "name": "Public holiday", + "calendar_id": self.company.resource_calendar_id.id, + "company_id": self.company.id, + "date_from": datetime(2026, 10, 7, 0, 0, 0), + "date_to": datetime(2026, 10, 7, 23, 59, 0), + } + ) + ) + self.assertFalse(global_leave.timesheet_ids) + sheet_id = self.sheet.id + self._wizard(self.sheet).action_confirm_delete() + self.env.flush_all() + self.env.invalidate_all() + self.assertFalse(self.sheet.exists()) + self.assertFalse(global_leave.timesheet_ids) + self.env.cr.execute( + "SELECT COUNT(*) FROM account_analytic_line WHERE sheet_id = %s", + (sheet_id,), + ) + self.assertEqual(self.env.cr.fetchone()[0], 0) + + # RF-08 + + def test_bare_unlink_of_leave_line_is_refused(self): + line = self.leave_lines[0].with_user(self.user) + with self.assertRaises(UserError): + line.unlink() + self.env.invalidate_all() + self.assertTrue(self.leave_lines[0].exists()) + self.assertTrue(self.leave_lines[0].sheet_id) + + def test_unlink_during_sheet_recompute_detaches(self): + line = self.leave_lines[0].with_user(self.user) + self._recompute(line).unlink() + self.env.invalidate_all() + self.assertTrue(self.leave_lines[0].exists()) + self.assertFalse(self.leave_lines[0].sheet_id) + self.assertTrue(self.leave.exists()) + + def test_unlink_during_delete_cascade_cancel_deletes(self): + self._set_policy("cancel") + line = self.leave_lines[0] + self._cascade(line.with_user(self.user).sudo()).unlink() + self.assertFalse(line.exists()) + + def test_unlink_during_delete_cascade_detach_detaches(self): + self._set_policy("detach") + line = self.leave_lines[0] + self._cascade(line.with_user(self.user).sudo()).unlink() + self.env.invalidate_all() + self.assertTrue(line.exists()) + self.assertFalse(line.sheet_id) + self.assertTrue(self.leave.exists()) + + def test_delete_cascade_flag_without_sudo_is_refused(self): + line = self.leave_lines[0].with_user(self.user) + for policy in ("detach", "cancel"): + self._set_policy(policy) + with self.assertRaises(UserError): + self._cascade(line).unlink() + self.env.invalidate_all() + self.assertTrue(self.leave_lines[0].exists()) + self.assertTrue(self.leave_lines[0].sheet_id) + + def test_both_flags_delete_cascade_wins(self): + self._set_policy("cancel") + line = self.leave_lines[0] + self._cascade(self._recompute(line.with_user(self.user).sudo())).unlink() + self.assertFalse(line.exists()) + + def test_both_flags_with_detach_policy_still_detaches(self): + self._set_policy("detach") + line = self.leave_lines[0] + self._cascade(self._recompute(line.with_user(self.user).sudo())).unlink() + self.env.invalidate_all() + self.assertTrue(line.exists()) + self.assertFalse(line.sheet_id) + + # RF-09 + + def test_no_cascade_flag_leak_after_deletion(self): + self._set_policy("cancel") + self._wizard(self.sheet).action_confirm_delete() + self.assertIsNone(self.env.context.get("sheet_delete_cascade")) + other_line = self.leave_span_lines[0].with_user(self.user_span) + with self.assertRaises(UserError): + other_line.unlink() + self.env.invalidate_all() + self.assertTrue(self.leave_span_lines[0].exists()) + self.assertIsNone(self.env.context.get("sheet_delete_cascade")) + + # Published hooks + + def test_delete_cascade_prepare_contract(self): + data = self.sheet._delete_cascade_prepare() + self.assertEqual( + set(data), + { + "sheet_count", + "timesheet_count", + "timesheet_hours", + "invoiced_count", + "leave_count", + "leave_days", + "leave_policy", + "blocking", + "sections", + }, + ) + self.assertEqual(data["blocking"], []) + self.assertTrue(all("title" in s and "lines" in s for s in data["sections"])) + + def test_detach_hook_matrix(self): + """One assertion per row of the RF-08 table, on the same leave line. + + The base recordset is bound to a plain employee on purpose: the class + env is superuser, so a line taken straight from the fixture would carry + env.su and the row that must fail without it would pass for the wrong + reason. + """ + line = self.leave_lines[0].with_user(self.user) + self.assertFalse(line.env.su) + line_su = line.sudo() + + # Row 1: no flags. + self.assertFalse(line._detach_leave_lines_on_unlink()) + # Row 2: sheet recompute, any policy. + self.assertTrue(self._recompute(line)._detach_leave_lines_on_unlink()) + + self._set_policy("cancel") + # Row 3: deliberate cascade with cancel deletes instead of detaching. + self.assertFalse(self._cascade(line_su)._detach_leave_lines_on_unlink()) + # Row 5, cancel: the sentinel alone, without env.su, detaches nothing. + self.assertFalse(self._cascade(line)._detach_leave_lines_on_unlink()) + self.assertFalse(self._cascade(line)._in_deliberate_delete_cascade()) + # Row 6: the cascade wins over a concurrent recompute. + self.assertFalse( + self._cascade(self._recompute(line_su))._detach_leave_lines_on_unlink() + ) + + self._set_policy("detach") + # Row 4: deliberate cascade with detach keeps the line alive. + self.assertTrue(self._cascade(line_su)._detach_leave_lines_on_unlink()) + # Row 5, detach: still nothing without env.su. + self.assertFalse(self._cascade(line)._detach_leave_lines_on_unlink()) + self.assertFalse(self._cascade(line)._in_deliberate_delete_cascade()) + + def test_context_token_is_not_serializable(self): + token = self.env["account.analytic.line"]._sheet_delete_cascade_token() + with self.assertRaises(TypeError): + json.dumps(token) + + def test_forged_cascade_flag_does_not_delete_sheet(self): + model = self.env["hr_timesheet.sheet"].with_user(self.user) + with self.assertRaises(UserError): + call_kw( + model, + "unlink", + [self.sheet.ids], + {"context": {"sheet_delete_cascade": True}}, + ) + self.env.invalidate_all() + self.assertTrue(self.sheet.exists()) + self.assertEqual(self.sheet.timesheet_ids.exists(), self.sheet.timesheet_ids) + self.assertFalse( + model.with_context(sheet_delete_cascade=True)._in_delete_cascade() + ) + + def test_forged_cascade_flag_does_not_delete_leave_line(self): + model = self.env["account.analytic.line"].with_user(self.user) + line = self.leave_lines[0] + self._set_policy("cancel") + with self.assertRaises(UserError): + call_kw( + model, + "unlink", + [[line.id]], + {"context": {"sheet_delete_cascade": True}}, + ) + self.env.invalidate_all() + self.assertTrue(line.exists()) + self.assertFalse( + model.sudo() + .with_context(sheet_delete_cascade=True) + ._in_deliberate_delete_cascade() + ) diff --git a/hr_timesheet_sheet_delete_cascade/views/hr_timesheet_sheet_views.xml b/hr_timesheet_sheet_delete_cascade/views/hr_timesheet_sheet_views.xml new file mode 100644 index 0000000..76f947b --- /dev/null +++ b/hr_timesheet_sheet_delete_cascade/views/hr_timesheet_sheet_views.xml @@ -0,0 +1,62 @@ + + + + + hr_timesheet.sheet.form.delete.cascade + hr_timesheet.sheet + + +
+ 0 +
+ + ' + data.warning + '
'); + } + else { + cart_alert.html(' ' + data.warning); + } + $input.val(data.quantity); + } + }); +}, + +_onChangeCartQuantity: function (ev) { + var $input = $(ev.currentTarget); + if ($input.data('update_change')) { + return; + } + // Replace comma with dot to support decimal separator + var inputValue = ($input.val() || 0).toString().replace(',', '.'); + var value = parseFloat(inputValue, 10); + if (isNaN(value)) { + value = 1; + } + + // Check if decimal quantities are allowed for this product + var allowDecimal = $input.data('allow-decimal'); + // Convert to boolean - check for string 'true' or boolean true + var allowDecimals = (allowDecimal === true || allowDecimal === 'true'); + + if (!allowDecimals) { + // Round to nearest integer for products with unit-type UoM + value = Math.round(value); + // Update the input to show the rounded value + $input.val(value); + } + + var $dom = $input.closest('tr'); + // var default_price = parseFloat($dom.find('.text-danger > span.oe_currency_value').text()); + var $dom_optional = $dom.nextUntil(':not(.optional_product.info)'); + var line_id = parseInt($input.data('line-id'), 10); + var productIDs = [parseInt($input.data('product-id'), 10)]; + this._changeCartQuantity($input, value, $dom_optional, line_id, productIDs); + } +}); + +}); diff --git a/website_decimal_quantity/static/src/js/website_sale_utils.js b/website_decimal_quantity/static/src/js/website_sale_utils.js new file mode 100644 index 0000000..d1a82f9 --- /dev/null +++ b/website_decimal_quantity/static/src/js/website_sale_utils.js @@ -0,0 +1,98 @@ +odoo.define('website_decimal_quantity.utils', function (require) { +'use strict'; + +var utils = require('website_sale.utils'); +var utilsDecimalQty = utils.updateCartNavBar; + +/** +* This module overrides the updateCartNavBar function of the website_sale.utils module to provide +* support for decimal quantities in the cart. +* It replaces the cart quantity text with an animated icon and updates the cart summary and lines with the new data. +*/ + +utils.updateCartNavBar = function(data) { + // Ensure cart_quantity is treated as a number for proper evaluation + var cartQty = parseFloat(data.cart_quantity) || 0; + // Round to 2 decimal places to avoid floating point precision issues + var displayQty = Math.round(cartQty * 100) / 100; + + $(".my_cart_quantity") + .parents('li.o_wsale_my_cart').removeClass('d-none').end() + .addClass('o_mycart_zoom_animation').delay(300) + .queue(function () { + $(this) + .toggleClass('fa fa-warning', cartQty === 0) + .attr('title', data.warning) + .text(displayQty > 0 ? displayQty : '') + .removeClass('o_mycart_zoom_animation') + .dequeue(); + }); + + $(".js_cart_lines").first().before(data['website_sale.cart_lines']).end().remove(); + $(".js_cart_summary").replaceWith(data['website_sale.short_cart_summary']); +} + +/** +* Override _addToCartInPage to handle decimal quantities correctly in the animation condition +*/ +var originalAddToCart = utils.cartHandlerMixin.addToCart; +var originalAddToCartInPage = utils.cartHandlerMixin._addToCartInPage; + +utils.cartHandlerMixin._addToCartInPage = function(params) { + var self = this; + params.force_create = true; + return this._rpc({ + route: "/shop/cart/update_json", + params: params, + }).then(async function(data) { + console.log('Data received from cart update:', data); + + // The response might not include cart_quantity if display=false or on error + // In that case, we need to make another call to get the total or calculate it + var currentQty = parseFloat($(".my_cart_quantity").text()) || 0; + var newQty; + + if (data.cart_quantity !== undefined) { + newQty = parseFloat(data.cart_quantity) || 0; + } else { + // If cart_quantity is not in response, add the quantity that was just added + newQty = currentQty + parseFloat(params.add_qty || 0); + // Ensure we have the templates by making the call with display=true + if (!data['website_sale.cart_lines']) { + // Make another call to get the rendered templates + var displayData = await self._rpc({ + route: "/shop/cart/update_json", + params: { + product_id: params.product_id, + line_id: data.line_id, + set_qty: data.quantity, + display: true, + force_create: true + }, + }); + // Merge the data + Object.assign(data, displayData); + newQty = parseFloat(displayData.cart_quantity) || newQty; + } + } + + sessionStorage.setItem('website_sale_cart_quantity', newQty); + + console.log('Current Qty:', currentQty, 'New Qty:', newQty, 'Data:', data); + + // Animate if quantity changed and is greater than 0 + if (newQty > 0 && newQty !== currentQty) { + // Ensure data has cart_quantity for updateCartNavBar + if (data.cart_quantity === undefined) { + data.cart_quantity = newQty; + } + // No animation if the product's page images are hidden + if ($('div[data-image_width]').data('image_width') !== 'none') { + await utils.animateClone($('header .o_wsale_my_cart').first(), self.$itemImgContainer, 25, 40); + } + utils.updateCartNavBar(data); + } + }); +}; + +}); diff --git a/website_decimal_quantity/views/uom_category_views.xml b/website_decimal_quantity/views/uom_category_views.xml new file mode 100644 index 0000000..9b97568 --- /dev/null +++ b/website_decimal_quantity/views/uom_category_views.xml @@ -0,0 +1,13 @@ + + + + uom.category.form.inherit + uom.category + + + + + + + + diff --git a/website_decimal_quantity/views/website_sale_templates.xml b/website_decimal_quantity/views/website_sale_templates.xml new file mode 100644 index 0000000..131fcc2 --- /dev/null +++ b/website_decimal_quantity/views/website_sale_templates.xml @@ -0,0 +1,32 @@ + + + + + + + + + + From 3c5809774fd53ed45411ca3efd72ee271a9916d8 Mon Sep 17 00:00:00 2001 From: luis Date: Mon, 5 Oct 2026 18:34:47 +0200 Subject: [PATCH 26/27] website_search_products: fix redirect after login --- .../controllers/controller.py | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/website_search_products/controllers/controller.py b/website_search_products/controllers/controller.py index ed2d861..204850e 100644 --- a/website_search_products/controllers/controller.py +++ b/website_search_products/controllers/controller.py @@ -38,23 +38,10 @@ class InventoryForm(http.Controller): class WebsiteController(Website): """Extend Website controller to redirect portal users after login.""" - @http.route(website=True, auth="public") - def web_login(self, redirect=None, *args, **kw): - """Handle login redirection based on user group.""" - response = super().web_login(redirect=redirect, *args, **kw) - - # Check if login was successful + def _login_redirect(self, uid, redirect=None): + """Send non-internal users to the website home instead of /my, + unless an explicit redirect was requested.""" if not redirect and request.params.get("login_success"): - user = request.env["res.users"].browse(request.uid) - - # Redirect internal users to web, others to inventory search - if user.has_group("base.group_user"): - # Keep original redirect for internal users - redirect_url = "/web" - else: - # Redirect portal users to inventory search - redirect_url = "/inventory/search" - - return werkzeug_redirect(redirect_url) - - return response + if not request.env["res.users"].browse(uid)._is_internal(): + redirect = "/" + return super()._login_redirect(uid, redirect=redirect) From 3be3de173ea2378beefadf0bce7a3bb61f0dec38 Mon Sep 17 00:00:00 2001 From: luis Date: Tue, 6 Oct 2026 12:10:26 +0200 Subject: [PATCH 27/27] add partner_email_sync_portal_login --- partner_email_sync_portal_login/README.md | 24 +++++ partner_email_sync_portal_login/__init__.py | 2 + .../__manifest__.py | 17 +++ partner_email_sync_portal_login/i18n/es.po | 67 ++++++++++++ .../i18n/partner_email_sync_portal_login.pot | 60 +++++++++++ .../models/__init__.py | 1 + .../models/res_partner.py | 102 ++++++++++++++++++ .../tests/__init__.py | 1 + .../test_partner_email_sync_portal_login.py | 99 +++++++++++++++++ .../wizard/__init__.py | 1 + .../wizard/portal_wizard.py | 14 +++ 11 files changed, 388 insertions(+) create mode 100644 partner_email_sync_portal_login/README.md create mode 100644 partner_email_sync_portal_login/__init__.py create mode 100644 partner_email_sync_portal_login/__manifest__.py create mode 100644 partner_email_sync_portal_login/i18n/es.po create mode 100644 partner_email_sync_portal_login/i18n/partner_email_sync_portal_login.pot create mode 100644 partner_email_sync_portal_login/models/__init__.py create mode 100644 partner_email_sync_portal_login/models/res_partner.py create mode 100644 partner_email_sync_portal_login/tests/__init__.py create mode 100644 partner_email_sync_portal_login/tests/test_partner_email_sync_portal_login.py create mode 100644 partner_email_sync_portal_login/wizard/__init__.py create mode 100644 partner_email_sync_portal_login/wizard/portal_wizard.py diff --git a/partner_email_sync_portal_login/README.md b/partner_email_sync_portal_login/README.md new file mode 100644 index 0000000..7214f18 --- /dev/null +++ b/partner_email_sync_portal_login/README.md @@ -0,0 +1,24 @@ +# Partner Email Sync Portal Login + +When the email of a contact that has a **portal user** is changed and saved: + +- the user login is updated with the new (normalized) email, and +- a password reset email is sent to the new address. + +A note is posted in the contact chatter with the result. + +## Behaviour details + +- Only portal users are affected; internal users are never modified. +- Empty or invalid emails are ignored (login is kept). +- Changes that only differ in letter case do not trigger anything. +- If another user already uses the new email as login, the change is + blocked with an error message. +- If the reset email cannot be sent, the login is still updated and the + error is recorded in the chatter. +- From the portal access wizard ("Grant portal access" / "Invite again") + the login is synced but no reset email is sent, since the wizard sends + its own invitation. +- Use the context key `skip_portal_login_sync` to disable the behaviour + (e.g. in imports). +- Changing the login closes the user's open sessions. diff --git a/partner_email_sync_portal_login/__init__.py b/partner_email_sync_portal_login/__init__.py new file mode 100644 index 0000000..9b42961 --- /dev/null +++ b/partner_email_sync_portal_login/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/partner_email_sync_portal_login/__manifest__.py b/partner_email_sync_portal_login/__manifest__.py new file mode 100644 index 0000000..b5ea4c6 --- /dev/null +++ b/partner_email_sync_portal_login/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Partner Email Sync Portal Login", + "summary": "Update the portal user login and send a password reset " + "email when the partner email changes", + "version": "16.0.1.0.0", + "category": "Tools", + "website": "https://criptomart.net", + "author": "Criptomart", + "maintainers": ["criptomart"], + "license": "AGPL-3", + "depends": ["portal"], + "data": [], + "installable": True, +} diff --git a/partner_email_sync_portal_login/i18n/es.po b/partner_email_sync_portal_login/i18n/es.po new file mode 100644 index 0000000..d7269cf --- /dev/null +++ b/partner_email_sync_portal_login/i18n/es.po @@ -0,0 +1,67 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_email_sync_portal_login +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-06 09:57+0000\n" +"PO-Revision-Date: 2026-10-06 09:57+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "" +"Cannot change the email of %(partner)s: there is already a user with login " +"%(login)s." +msgstr "" +"No se puede cambiar el email de %(partner)s: ya existe un usuario con el login " +"%(login)s." + +#. module: partner_email_sync_portal_login +#: model:ir.model,name:partner_email_sync_portal_login.model_res_partner +msgid "Contact" +msgstr "Contacto" + +#. module: partner_email_sync_portal_login +#: model:ir.model,name:partner_email_sync_portal_login.model_portal_wizard_user +msgid "Portal User Config" +msgstr "Configuración de usuario de portal" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "" +"Portal user login changed from %(old)s to %(new)s and a password reset email" +" has been sent." +msgstr "" +"Login del usuario de portal cambiado de %(old)s a %(new)s y enviado el correo " +"de restablecimiento de contraseña." + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "" +"Portal user login changed from %(old)s to %(new)s, but the password reset " +"email could not be sent: %(error)s" +msgstr "" +"Login del usuario de portal cambiado de %(old)s a %(new)s, pero no se ha podido " +"enviar el correo de restablecimiento de contraseña: %(error)s" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "Portal user login changed from %(old)s to %(new)s." +msgstr "Login del usuario de portal cambiado de %(old)s a %(new)s." diff --git a/partner_email_sync_portal_login/i18n/partner_email_sync_portal_login.pot b/partner_email_sync_portal_login/i18n/partner_email_sync_portal_login.pot new file mode 100644 index 0000000..90e3269 --- /dev/null +++ b/partner_email_sync_portal_login/i18n/partner_email_sync_portal_login.pot @@ -0,0 +1,60 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_email_sync_portal_login +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-06 09:57+0000\n" +"PO-Revision-Date: 2026-10-06 09:57+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: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "" +"Cannot change the email of %(partner)s: there is already a user with login " +"%(login)s." +msgstr "" + +#. module: partner_email_sync_portal_login +#: model:ir.model,name:partner_email_sync_portal_login.model_res_partner +msgid "Contact" +msgstr "" + +#. module: partner_email_sync_portal_login +#: model:ir.model,name:partner_email_sync_portal_login.model_portal_wizard_user +msgid "Portal User Config" +msgstr "" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "" +"Portal user login changed from %(old)s to %(new)s and a password reset email" +" has been sent." +msgstr "" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "" +"Portal user login changed from %(old)s to %(new)s, but the password reset " +"email could not be sent: %(error)s" +msgstr "" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "Portal user login changed from %(old)s to %(new)s." +msgstr "" diff --git a/partner_email_sync_portal_login/models/__init__.py b/partner_email_sync_portal_login/models/__init__.py new file mode 100644 index 0000000..91fed54 --- /dev/null +++ b/partner_email_sync_portal_login/models/__init__.py @@ -0,0 +1 @@ +from . import res_partner diff --git a/partner_email_sync_portal_login/models/res_partner.py b/partner_email_sync_portal_login/models/res_partner.py new file mode 100644 index 0000000..e22276e --- /dev/null +++ b/partner_email_sync_portal_login/models/res_partner.py @@ -0,0 +1,102 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from markupsafe import Markup + +from odoo import _, models +from odoo.exceptions import UserError +from odoo.tools import email_normalize + +_logger = logging.getLogger(__name__) + + +class ResPartner(models.Model): + _inherit = "res.partner" + + def write(self, vals): + res = super().write(vals) + if "email" in vals and not self.env.context.get("skip_portal_login_sync"): + self._sync_portal_user_login() + return res + + def _get_portal_users_to_sync(self): + self.ensure_one() + group_portal = self.env.ref("base.group_portal") + return self.sudo().user_ids.filtered(lambda u: group_portal in u.groups_id) + + def _sync_portal_user_login(self): + """Set the partner email as login of its portal users and send them + a password reset email.""" + Users = self.env["res.users"].sudo().with_context(active_test=False) + for partner in self: + users = partner._get_portal_users_to_sync() + if not users: + continue + new_login = email_normalize(partner.email) + if not new_login: + _logger.info( + "Portal login not synced for partner %s: invalid email %r", + partner.id, + partner.email, + ) + continue + for user in users: + if email_normalize(user.login) == new_login: + continue + if Users.search_count( + [("login", "=", new_login), ("id", "!=", user.id)] + ): + raise UserError( + _( + "Cannot change the email of %(partner)s: there is " + "already a user with login %(login)s.", + partner=partner.display_name, + login=new_login, + ) + ) + old_login = user.login + user.write({"login": new_login}) + _logger.info( + "Portal user %s login changed from %s to %s", + user.id, + old_login, + new_login, + ) + if self.env.context.get("portal_login_sync_no_mail"): + body = _( + "Portal user login changed from %(old)s to %(new)s.", + old=old_login, + new=new_login, + ) + else: + body = partner._send_portal_login_reset_password( + user, old_login, new_login + ) + partner.sudo().message_post(body=Markup("

%s

") % body) + + def _send_portal_login_reset_password(self, user, old_login, new_login): + try: + with self.env.cr.savepoint(): + user.action_reset_password() + except Exception as e: + # Do not block the email change if the reset email cannot be sent + _logger.warning( + "Password reset email for portal user %s could not be sent: %s", + user.id, + e, + ) + return _( + "Portal user login changed from %(old)s to %(new)s, but the " + "password reset email could not be sent: %(error)s", + old=old_login, + new=new_login, + error=str(e), + ) + return _( + "Portal user login changed from %(old)s to %(new)s and a password " + "reset email has been sent.", + old=old_login, + new=new_login, + ) diff --git a/partner_email_sync_portal_login/tests/__init__.py b/partner_email_sync_portal_login/tests/__init__.py new file mode 100644 index 0000000..71522fb --- /dev/null +++ b/partner_email_sync_portal_login/tests/__init__.py @@ -0,0 +1 @@ +from . import test_partner_email_sync_portal_login diff --git a/partner_email_sync_portal_login/tests/test_partner_email_sync_portal_login.py b/partner_email_sync_portal_login/tests/test_partner_email_sync_portal_login.py new file mode 100644 index 0000000..de52ecc --- /dev/null +++ b/partner_email_sync_portal_login/tests/test_partner_email_sync_portal_login.py @@ -0,0 +1,99 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.tests import TransactionCase, tagged + +from odoo.addons.auth_signup.models.res_users import ResUsers + +RESET_PASSWORD = "action_reset_password" + + +@tagged("post_install", "-at_install") +class TestPartnerEmailSyncPortalLogin(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + Users = cls.env["res.users"].with_context(no_reset_password=True) + cls.portal_user = Users.create( + { + "name": "Portal Member", + "login": "member@example.com", + "email": "member@example.com", + "groups_id": [(6, 0, [cls.env.ref("base.group_portal").id])], + } + ) + cls.internal_user = Users.create( + { + "name": "Internal Member", + "login": "internal@example.com", + "email": "internal@example.com", + "groups_id": [(6, 0, [cls.env.ref("base.group_user").id])], + } + ) + + def test_portal_user_login_synced_and_reset_sent(self): + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + self.portal_user.partner_id.email = "New.Member@Example.com" + self.assertEqual(self.portal_user.login, "new.member@example.com") + reset.assert_called_once() + self.assertEqual(reset.call_args.args[0], self.portal_user) + + def test_internal_user_not_synced(self): + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + self.internal_user.partner_id.email = "other@example.com" + self.assertEqual(self.internal_user.login, "internal@example.com") + reset.assert_not_called() + + def test_partner_without_user(self): + partner = self.env["res.partner"].create( + {"name": "No User", "email": "nouser@example.com"} + ) + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + partner.email = "nouser2@example.com" + reset.assert_not_called() + + def test_invalid_or_empty_email_keeps_login(self): + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + self.portal_user.partner_id.email = False + self.portal_user.partner_id.email = "not-an-email" + self.assertEqual(self.portal_user.login, "member@example.com") + reset.assert_not_called() + + def test_same_email_different_case_not_resent(self): + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + self.portal_user.partner_id.email = "MEMBER@example.com" + self.assertEqual(self.portal_user.login, "member@example.com") + reset.assert_not_called() + + def test_login_already_used(self): + with self.assertRaises(UserError): + self.portal_user.partner_id.email = "internal@example.com" + + def test_reset_email_failure_does_not_block(self): + with patch.object( + ResUsers, RESET_PASSWORD, autospec=True, side_effect=UserError("boom") + ): + self.portal_user.partner_id.email = "fail@example.com" + self.assertEqual(self.portal_user.login, "fail@example.com") + + def test_skip_context(self): + partner = self.portal_user.partner_id.with_context( + skip_portal_login_sync=True + ) + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + partner.email = "skip@example.com" + self.assertEqual(self.portal_user.login, "member@example.com") + reset.assert_not_called() + + def test_no_mail_context_syncs_login_only(self): + partner = self.portal_user.partner_id.with_context( + portal_login_sync_no_mail=True + ) + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + partner.email = "wizard@example.com" + self.assertEqual(self.portal_user.login, "wizard@example.com") + reset.assert_not_called() diff --git a/partner_email_sync_portal_login/wizard/__init__.py b/partner_email_sync_portal_login/wizard/__init__.py new file mode 100644 index 0000000..8bff21f --- /dev/null +++ b/partner_email_sync_portal_login/wizard/__init__.py @@ -0,0 +1 @@ +from . import portal_wizard diff --git a/partner_email_sync_portal_login/wizard/portal_wizard.py b/partner_email_sync_portal_login/wizard/portal_wizard.py new file mode 100644 index 0000000..d212959 --- /dev/null +++ b/partner_email_sync_portal_login/wizard/portal_wizard.py @@ -0,0 +1,14 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class PortalWizardUser(models.TransientModel): + _inherit = "portal.wizard.user" + + def _update_partner_email(self): + # The portal wizard already sends its own invitation email + return super( + PortalWizardUser, self.with_context(portal_login_sync_no_mail=True) + )._update_partner_email()