From d1491d8c09fc53cf01f0b0ee0e020516c5cabfb5 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 6 Sep 2024 10:27:08 +0200 Subject: [PATCH] [WIP][ADD] product_catalog: new module Backport from the v17 functionality TT50477 --- product_catalog/README.rst | 102 ++++ product_catalog/__init__.py | 2 + product_catalog/__manifest__.py | 23 + product_catalog/controllers/__init__.py | 1 + product_catalog/controllers/catalog.py | 54 +++ product_catalog/models/__init__.py | 1 + .../models/product_catalog_mixin.py | 136 ++++++ product_catalog/readme/CONTEXT.md | 1 + product_catalog/readme/CONTRIBUTORS.md | 2 + product_catalog/readme/DESCRIPTION.md | 5 + product_catalog/readme/ROADMAP.md | 1 + product_catalog/readme/USAGE.md | 3 + product_catalog/static/description/index.html | 451 ++++++++++++++++++ .../src/product_catalog/kanban_controller.js | 54 +++ .../src/product_catalog/kanban_model.js | 37 ++ .../src/product_catalog/kanban_record.js | 136 ++++++ .../src/product_catalog/kanban_record.xml | 25 + .../src/product_catalog/kanban_renderer.js | 39 ++ .../src/product_catalog/kanban_renderer.xml | 29 ++ .../static/src/product_catalog/kanban_view.js | 20 + .../product_catalog/kanban_view_buttons.xml | 18 + .../product_catalog/order_line/order_line.js | 42 ++ .../order_line/order_line.scss | 20 + .../product_catalog/order_line/order_line.xml | 91 ++++ .../product_catalog/search/search_panel.js | 55 +++ .../product_catalog/search/search_panel.xml | 50 ++ product_catalog/views/product_views.xml | 140 ++++++ sale_product_catalog/README.rst | 94 ++++ sale_product_catalog/__init__.py | 1 + sale_product_catalog/__manifest__.py | 15 + sale_product_catalog/models/__init__.py | 1 + sale_product_catalog/models/sale_order.py | 172 +++++++ sale_product_catalog/readme/CONTRIBUTORS.md | 2 + sale_product_catalog/readme/DESCRIPTION.md | 1 + sale_product_catalog/readme/ROADMAP.md | 1 + sale_product_catalog/readme/USAGE.md | 5 + .../static/description/index.html | 445 +++++++++++++++++ .../views/sale_order_views.xml | 18 + .../odoo/addons/product_catalog | 1 + setup/product_catalog/setup.py | 6 + .../odoo/addons/sale_product_catalog | 1 + setup/sale_product_catalog/setup.py | 6 + .../odoo/addons/stock_product_catalog | 1 + setup/stock_product_catalog/setup.py | 6 + stock_product_catalog/README.rst | 94 ++++ stock_product_catalog/__init__.py | 1 + stock_product_catalog/__manifest__.py | 16 + stock_product_catalog/models/__init__.py | 2 + stock_product_catalog/models/stock_picking.py | 136 ++++++ .../models/stock_picking_type.py | 24 + stock_product_catalog/readme/CONTRIBUTORS.md | 2 + stock_product_catalog/readme/DESCRIPTION.md | 1 + stock_product_catalog/readme/ROADMAP.md | 2 + stock_product_catalog/readme/USAGE.md | 5 + .../static/description/index.html | 445 +++++++++++++++++ .../views/stock_picking_type_views.xml | 17 + .../views/stock_picking_views.xml | 19 + 57 files changed, 3078 insertions(+) create mode 100644 product_catalog/README.rst create mode 100644 product_catalog/__init__.py create mode 100644 product_catalog/__manifest__.py create mode 100644 product_catalog/controllers/__init__.py create mode 100644 product_catalog/controllers/catalog.py create mode 100644 product_catalog/models/__init__.py create mode 100644 product_catalog/models/product_catalog_mixin.py create mode 100644 product_catalog/readme/CONTEXT.md create mode 100644 product_catalog/readme/CONTRIBUTORS.md create mode 100644 product_catalog/readme/DESCRIPTION.md create mode 100644 product_catalog/readme/ROADMAP.md create mode 100644 product_catalog/readme/USAGE.md create mode 100644 product_catalog/static/description/index.html create mode 100644 product_catalog/static/src/product_catalog/kanban_controller.js create mode 100644 product_catalog/static/src/product_catalog/kanban_model.js create mode 100644 product_catalog/static/src/product_catalog/kanban_record.js create mode 100644 product_catalog/static/src/product_catalog/kanban_record.xml create mode 100644 product_catalog/static/src/product_catalog/kanban_renderer.js create mode 100644 product_catalog/static/src/product_catalog/kanban_renderer.xml create mode 100644 product_catalog/static/src/product_catalog/kanban_view.js create mode 100644 product_catalog/static/src/product_catalog/kanban_view_buttons.xml create mode 100644 product_catalog/static/src/product_catalog/order_line/order_line.js create mode 100644 product_catalog/static/src/product_catalog/order_line/order_line.scss create mode 100644 product_catalog/static/src/product_catalog/order_line/order_line.xml create mode 100644 product_catalog/static/src/product_catalog/search/search_panel.js create mode 100644 product_catalog/static/src/product_catalog/search/search_panel.xml create mode 100644 product_catalog/views/product_views.xml create mode 100644 sale_product_catalog/README.rst create mode 100644 sale_product_catalog/__init__.py create mode 100644 sale_product_catalog/__manifest__.py create mode 100644 sale_product_catalog/models/__init__.py create mode 100644 sale_product_catalog/models/sale_order.py create mode 100644 sale_product_catalog/readme/CONTRIBUTORS.md create mode 100644 sale_product_catalog/readme/DESCRIPTION.md create mode 100644 sale_product_catalog/readme/ROADMAP.md create mode 100644 sale_product_catalog/readme/USAGE.md create mode 100644 sale_product_catalog/static/description/index.html create mode 100644 sale_product_catalog/views/sale_order_views.xml create mode 120000 setup/product_catalog/odoo/addons/product_catalog create mode 100644 setup/product_catalog/setup.py create mode 120000 setup/sale_product_catalog/odoo/addons/sale_product_catalog create mode 100644 setup/sale_product_catalog/setup.py create mode 120000 setup/stock_product_catalog/odoo/addons/stock_product_catalog create mode 100644 setup/stock_product_catalog/setup.py create mode 100644 stock_product_catalog/README.rst create mode 100644 stock_product_catalog/__init__.py create mode 100644 stock_product_catalog/__manifest__.py create mode 100644 stock_product_catalog/models/__init__.py create mode 100644 stock_product_catalog/models/stock_picking.py create mode 100644 stock_product_catalog/models/stock_picking_type.py create mode 100644 stock_product_catalog/readme/CONTRIBUTORS.md create mode 100644 stock_product_catalog/readme/DESCRIPTION.md create mode 100644 stock_product_catalog/readme/ROADMAP.md create mode 100644 stock_product_catalog/readme/USAGE.md create mode 100644 stock_product_catalog/static/description/index.html create mode 100644 stock_product_catalog/views/stock_picking_type_views.xml create mode 100644 stock_product_catalog/views/stock_picking_views.xml diff --git a/product_catalog/README.rst b/product_catalog/README.rst new file mode 100644 index 000000000000..d5b0491a9eb3 --- /dev/null +++ b/product_catalog/README.rst @@ -0,0 +1,102 @@ +=============== +Product Catalog +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:3d34fa62aed534517dd4da5bbb4a76ef626aa74cd47b3bd7a0c726c8d7de853e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fproduct--attribute-lightgray.png?logo=github + :target: https://github.com/OCA/product-attribute/tree/16.0/product_catalog + :alt: OCA/product-attribute +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-attribute-16-0/product-attribute-16-0-product_catalog + :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/product-attribute&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +A backport of Odoo's v17 Product Catalog. + +Changes over mainstream: + +:: + + - Price is an optional value now so we can use it in other models. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +Context... + +Usage +===== + +To use this module, you need to: + +1. Go to ... + +Known issues / Roadmap +====================== + +- Be able to filter just the added products. + +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 +------- + +* Odoo SA +* Tecnativa + +Contributors +------------ + +- `Tecnativa `__ + + - David Vidal + +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/product-attribute `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/product_catalog/__init__.py b/product_catalog/__init__.py new file mode 100644 index 000000000000..91c5580fed36 --- /dev/null +++ b/product_catalog/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/product_catalog/__manifest__.py b/product_catalog/__manifest__.py new file mode 100644 index 000000000000..bce9ac30c89f --- /dev/null +++ b/product_catalog/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2024 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Product Catalog", + "summary": "Backport of Odoos v17 product catalog", + "version": "16.0.1.0.0", + "author": "Odoo SA, Tecnativa, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/product-attribute", + "license": "AGPL-3", + "category": "Product", + "depends": ["product"], + "data": [ + "views/product_views.xml", + ], + "demo": [], + "assets": { + "web.assets_backend": [ + "product_catalog/static/src/product_catalog/**/*.js", + "product_catalog/static/src/product_catalog/**/*.xml", + "product_catalog/static/src/product_catalog/**/*.scss", + ], + }, +} diff --git a/product_catalog/controllers/__init__.py b/product_catalog/controllers/__init__.py new file mode 100644 index 000000000000..e67fc1889923 --- /dev/null +++ b/product_catalog/controllers/__init__.py @@ -0,0 +1 @@ +from . import catalog diff --git a/product_catalog/controllers/catalog.py b/product_catalog/controllers/catalog.py new file mode 100644 index 000000000000..1951227c2efd --- /dev/null +++ b/product_catalog/controllers/catalog.py @@ -0,0 +1,54 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.http import Controller, request, route + + +class ProductCatalogController(Controller): + @route("/product/catalog/order_lines_info", auth="user", type="json") + def product_catalog_get_order_lines_info( + self, res_model, order_id, product_ids, **kwargs + ): + """Returns products information to be shown in the catalog. + + :param string res_model: The order model. + :param int order_id: The order id. + :param list product_ids: The products currently displayed in the product catalog, as a list + of `product.product` ids. + :rtype: dict + :return: A dict with the following structure: + { + product.id: { + 'productId': int + 'quantity': float (optional) + 'price': float + 'readOnly': bool (optional) + } + } + """ + order = request.env[res_model].browse(order_id) + return order.with_company( + order.company_id + )._get_product_catalog_order_line_info( + product_ids, + **kwargs, + ) + + @route("/product/catalog/update_order_line_info", auth="user", type="json") + def product_catalog_update_order_line_info( + self, res_model, order_id, product_id, quantity=0, **kwargs + ): + """Update order line information on a given order for a given product. + + :param string res_model: The order model. + :param int order_id: The order id. + :param int product_id: The product, as a `product.product` id. + :return: The unit price price of the product, based on the pricelist of the order and + the quantity selected. + :rtype: float + """ + order = request.env[res_model].browse(order_id) + return order.with_company(order.company_id)._update_order_line_info( + product_id, + quantity, + **kwargs, + ) diff --git a/product_catalog/models/__init__.py b/product_catalog/models/__init__.py new file mode 100644 index 000000000000..7a9dc77ecb63 --- /dev/null +++ b/product_catalog/models/__init__.py @@ -0,0 +1 @@ +from . import product_catalog_mixin diff --git a/product_catalog/models/product_catalog_mixin.py b/product_catalog/models/product_catalog_mixin.py new file mode 100644 index 000000000000..b3bb61639e5c --- /dev/null +++ b/product_catalog/models/product_catalog_mixin.py @@ -0,0 +1,136 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, models + + +class ProductCatalogMixin(models.AbstractModel): + """This mixin should be inherited when the model should be able to work + with the product catalog. + It assumes the model using this mixin has a O2M field where the products are added/removed and + this field's co-related model should has a method named `_get_product_catalog_lines_data`. + """ + + _name = "product.catalog.mixin" + _description = "Product Catalog Mixin" + + def action_add_from_catalog(self): + kanban_view_id = self.env.ref("product_catalog.product_view_kanban_catalog").id + search_view_id = self.env.ref("product_catalog.product_view_search_catalog").id + additional_context = self._get_action_add_from_catalog_extra_context() + return { + "type": "ir.actions.act_window", + "name": _("Products"), + "res_model": "product.product", + "views": [(kanban_view_id, "kanban"), (False, "form")], + "search_view_id": [search_view_id, "search"], + "domain": self._get_product_catalog_domain(), + "context": {**self.env.context, **additional_context}, + } + + def _default_order_line_values(self): + return { + "quantity": 0, + "readOnly": self._is_readonly() if self else False, + } + + def _get_product_catalog_domain(self): + """Get the domain to search for products in the catalog. + + For a model that uses products that has to be hidden in the catalog, it + must override this method and extend the appropriate domain. + :returns: A list of tuples that represents a domain. + :rtype: list + """ + return [("company_id", "in", [self.company_id.id, False])] + + def _get_product_catalog_record_lines(self, product_ids): + """Returns the record's lines grouped by product. + Must be overrided by each model using this mixin. + + :param list product_ids: The ids of the products currently displayed in the product catalog. + :rtype: dict + """ + return {} + + def _get_product_catalog_order_data(self, products, **kwargs): + """Returns a dict containing the products' data. Those data are for products who aren't in + the record yet. For products already in the record, see `_get_product_catalog_lines_data`. + + For each product, its id is the key and the value is another dict with all needed data. + By default, the price is the only needed data but each model is free to add more data. + Must be overrided by each model using this mixin. + + :param products: Recordset of `product.product`. + :param dict kwargs: additional values given for inherited models. + :rtype: dict + :return: A dict with the following structure: + { + 'productId': int + 'quantity': float (optional) + 'productType': string + 'price': float + 'readOnly': bool (optional) + } + """ + res = {} + for product in products: + res[product.id] = {"productType": product.type} + return res + + def _get_product_catalog_order_line_info(self, product_ids, **kwargs): + """Returns products information to be shown in the catalog. + :param list product_ids: The products currently displayed in the product catalog, as a list + of `product.product` ids. + :param dict kwargs: additional values given for inherited models. + :rtype: dict + :return: A dict with the following structure: + { + 'productId': int + 'quantity': float (optional) + 'productType': string + 'price': float (optional) + 'readOnly': bool (optional) + } + """ + order_line_info = {} + default_data = self._default_order_line_values() + + for product, record_lines in self._get_product_catalog_record_lines( + product_ids + ).items(): + order_line_info[product.id] = { + **record_lines._get_product_catalog_lines_data(**kwargs), + "productType": product.type, + } + product_ids.remove(product.id) + + products = self.env["product.product"].browse(product_ids) + product_data = self._get_product_catalog_order_data(products, **kwargs) + for product_id, data in product_data.items(): + order_line_info[product_id] = {**default_data, **data} + return order_line_info + + def _get_action_add_from_catalog_extra_context(self): + return { + "product_catalog_order_id": self.id, + "product_catalog_order_model": self._name, + } + + def _is_readonly(self): + """Must be overrided by each model using this mixin. + :return: Whether the record is read-only or not. + :rtype: bool + """ + return False + + def _update_order_line_info(self, product_id, quantity, **kwargs): + """Update the line information for a given product or create a new one if none exists yet. + Must be overrided by each model using this mixin. + :param int product_id: The product, as a `product.product` id. + :param int quantity: The product's quantity. + :param dict kwargs: additional values given for inherited models. + :return: The unit price of the product, based on the pricelist of the + purchase order and the quantity selected. + :rtype: float + """ + return 0 diff --git a/product_catalog/readme/CONTEXT.md b/product_catalog/readme/CONTEXT.md new file mode 100644 index 000000000000..ad0da6d412af --- /dev/null +++ b/product_catalog/readme/CONTEXT.md @@ -0,0 +1 @@ +Context... diff --git a/product_catalog/readme/CONTRIBUTORS.md b/product_catalog/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..3a16ddf047ae --- /dev/null +++ b/product_catalog/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Tecnativa](https://tecnativa.com) + - David Vidal diff --git a/product_catalog/readme/DESCRIPTION.md b/product_catalog/readme/DESCRIPTION.md new file mode 100644 index 000000000000..24d8670b85cb --- /dev/null +++ b/product_catalog/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +A backport of Odoo's v17 Product Catalog. + +Changes over mainstream: + + - Price is an optional value now so we can use it in other models. diff --git a/product_catalog/readme/ROADMAP.md b/product_catalog/readme/ROADMAP.md new file mode 100644 index 000000000000..0df50824ab14 --- /dev/null +++ b/product_catalog/readme/ROADMAP.md @@ -0,0 +1 @@ +- Be able to filter just the added products. diff --git a/product_catalog/readme/USAGE.md b/product_catalog/readme/USAGE.md new file mode 100644 index 000000000000..66681dab4bc2 --- /dev/null +++ b/product_catalog/readme/USAGE.md @@ -0,0 +1,3 @@ +To use this module, you need to: + +1. Go to ... diff --git a/product_catalog/static/description/index.html b/product_catalog/static/description/index.html new file mode 100644 index 000000000000..63ba25c5a0a2 --- /dev/null +++ b/product_catalog/static/description/index.html @@ -0,0 +1,451 @@ + + + + + +Product Catalog + + + +
+

Product Catalog

+ + +

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

+

A backport of Odoo’s v17 Product Catalog.

+

Changes over mainstream:

+
+- Price is an optional value now so we can use it in other models.
+
+

Table of contents

+ +
+

Use Cases / Context

+

Context…

+
+
+

Usage

+

To use this module, you need to:

+
    +
  1. Go to …
  2. +
+
+
+

Known issues / Roadmap

+
    +
  • Be able to filter just the added products.
  • +
+
+
+

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

+
    +
  • Odoo SA
  • +
  • Tecnativa
  • +
+
+
+

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

+

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

+
+
+
+ + diff --git a/product_catalog/static/src/product_catalog/kanban_controller.js b/product_catalog/static/src/product_catalog/kanban_controller.js new file mode 100644 index 000000000000..a378ba672a7c --- /dev/null +++ b/product_catalog/static/src/product_catalog/kanban_controller.js @@ -0,0 +1,54 @@ +/** @odoo-module **/ + +import {KanbanController} from "@web/views/kanban/kanban_controller"; +import {onWillStart} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; +import {_t} from "@web/core/l10n/translation"; + +export class ProductCatalogKanbanController extends KanbanController { + setup() { + super.setup(); + this.action = useService("action"); + this.orm = useService("orm"); + this.orderId = this.props.context.order_id; + this.orderResModel = this.props.context.product_catalog_order_model; + onWillStart(async () => this._defineButtonContent()); + } + + // Force the slot for the "Back to Quotation" button to always be shown. + get canCreate() { + return true; + } + + async _defineButtonContent() { + // Define the button's label depending of the order's state. + const orderStateInfo = await this.orm.searchRead( + this.orderResModel, + [["id", "=", this.orderId]], + ["state"] + ); + const orderIsQuotation = ["draft", "sent"].includes(orderStateInfo[0].state); + if (orderIsQuotation) { + this.buttonString = _t("Back to Quotation"); + } else { + this.buttonString = _t("Back to Order"); + } + } + + async backToQuotation() { + // Restore the last form view from the breadcrumbs if breadcrumbs are available. + // If, for some weird reason, the user reloads the page then the breadcrumbs are + // lost, and we fall back to the form view ourselves. + if (this.env.config.breadcrumbs.length > 1) { + await this.action.restore(); + } else { + await this.action.doAction({ + type: "ir.actions.act_window", + res_model: this.orderResModel, + views: [[false, "form"]], + view_mode: "form", + res_id: this.orderId, + }); + } + } +} diff --git a/product_catalog/static/src/product_catalog/kanban_model.js b/product_catalog/static/src/product_catalog/kanban_model.js new file mode 100644 index 000000000000..3550d2af01c8 --- /dev/null +++ b/product_catalog/static/src/product_catalog/kanban_model.js @@ -0,0 +1,37 @@ +/** @odoo-module */ +import {DynamicRecordList, RelationalModel} from "@web/views/relational_model"; + +export class ProductCatalogDynamicRecordList extends DynamicRecordList { + async _loadRecords() { + const records = await super._loadRecords(); + const orderLinesInfo = await this.model.rpc( + "/product/catalog/order_lines_info", + { + order_id: this.context.order_id, + product_ids: records.map((rec) => rec.resId), + res_model: this.context.product_catalog_order_model, + } + ); + for (const record of records) { + record.productCatalogData = orderLinesInfo[record.resId]; + } + return records; + } +} + +export class ProductCatalogKanbanModel extends RelationalModel { + static DynamicRecordList = ProductCatalogDynamicRecordList; + + async _loadData(params) { + if (!params.isMonoRecord && !params.groupBy.length) { + const orderLinesInfo = await this.rpc("/product/catalog/order_lines_info", { + order_id: params.context.order_id, + product_ids: this.root.records.map((rec) => rec.resId), + res_model: params.context.product_catalog_order_model, + }); + for (const record of this.root.records) { + record.productCatalogData = orderLinesInfo[record.id]; + } + } + } +} diff --git a/product_catalog/static/src/product_catalog/kanban_record.js b/product_catalog/static/src/product_catalog/kanban_record.js new file mode 100644 index 000000000000..95f1e21db35b --- /dev/null +++ b/product_catalog/static/src/product_catalog/kanban_record.js @@ -0,0 +1,136 @@ +/** @odoo-module */ +import {useSubEnv} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; +import {useDebounced} from "@web/core/utils/timing"; +import {KanbanRecord} from "@web/views/kanban/kanban_record"; +import {ProductCatalogOrderLine} from "./order_line/order_line"; + +export class ProductCatalogKanbanRecord extends KanbanRecord { + static template = "ProductCatalogKanbanRecord"; + static components = { + ...KanbanRecord.components, + ProductCatalogOrderLine, + }; + + setup() { + super.setup(); + this.rpc = useService("rpc"); + this.debouncedUpdateQuantity = useDebounced(this._updateQuantity, 500, { + execBeforeUnmount: true, + }); + + useSubEnv({ + currencyId: this.props.record.context.product_catalog_currency_id, + orderId: this.props.record.context.product_catalog_order_id, + orderResModel: this.props.record.context.product_catalog_order_model, + digits: this.props.record.context.product_catalog_digits, + displayUoM: this.props.record.context.display_uom, + precision: this.props.record.context.precision, + productId: this.props.record.resId, + addProduct: this.addProduct.bind(this), + removeProduct: this.removeProduct.bind(this), + increaseQuantity: this.increaseQuantity.bind(this), + setQuantity: this.setQuantity.bind(this), + decreaseQuantity: this.decreaseQuantity.bind(this), + }); + } + + get orderLineComponent() { + return ProductCatalogOrderLine; + } + + get productCatalogData() { + return this.props.record.productCatalogData; + } + + onGlobalClick(ev) { + // avoid a concurrent update when clicking on the buttons (that are inside the record) + if (ev.target.closest(".o_product_catalog_cancel_global_click")) { + return; + } + if (this.productCatalogData.quantity === 0) { + this.addProduct(); + } else { + this.increaseQuantity(); + } + } + + //-------------------------------------------------------------------------- + // Data Exchanges + //-------------------------------------------------------------------------- + + updateProductCatalogData() { + this.props.record.update({productCatalogData: this.productCatalogData}); + } + + async _updateQuantity() { + const price = await this._updateQuantityAndGetPrice(); + this.productCatalogData.price = parseFloat(price); + this.updateProductCatalogData(); + } + + _updateQuantityAndGetPrice() { + return this.rpc( + "/product/catalog/update_order_line_info", + this._getUpdateQuantityAndGetPrice() + ); + } + + _getUpdateQuantityAndGetPrice() { + return { + order_id: this.env.orderId, + product_id: this.env.productId, + quantity: this.productCatalogData.quantity, + res_model: this.env.orderResModel, + }; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + updateQuantity(quantity) { + if (this.productCatalogData.readOnly) { + return; + } + this.productCatalogData.quantity = quantity || 0; + this.debouncedUpdateQuantity(); + } + + /** + * Add the product to the order + */ + addProduct(qty = 1) { + this.updateQuantity(qty); + } + + /** + * Remove the product to the order + */ + removeProduct() { + this.updateQuantity(0); + } + + /** + * Increase the quantity of the product on the order line. + */ + increaseQuantity(qty = 1) { + this.updateQuantity(this.productCatalogData.quantity + qty); + } + + /** + * Set the quantity of the product on the order line. + * + * @param {Event} event + */ + setQuantity(event) { + this.updateQuantity(parseFloat(event.target.value)); + } + + /** + * Decrease the quantity of the product on the order line. + */ + decreaseQuantity() { + this.updateQuantity(parseFloat(this.productCatalogData.quantity - 1)); + } +} diff --git a/product_catalog/static/src/product_catalog/kanban_record.xml b/product_catalog/static/src/product_catalog/kanban_record.xml new file mode 100644 index 000000000000..29356bcc40c2 --- /dev/null +++ b/product_catalog/static/src/product_catalog/kanban_record.xml @@ -0,0 +1,25 @@ + + + +
+
+ + +
+
+
+
diff --git a/product_catalog/static/src/product_catalog/kanban_renderer.js b/product_catalog/static/src/product_catalog/kanban_renderer.js new file mode 100644 index 000000000000..f2e6b9afbe52 --- /dev/null +++ b/product_catalog/static/src/product_catalog/kanban_renderer.js @@ -0,0 +1,39 @@ +/** @odoo-module **/ + +import {KanbanRenderer} from "@web/views/kanban/kanban_renderer"; +import {useService} from "@web/core/utils/hooks"; + +import {ProductCatalogKanbanRecord} from "./kanban_record"; + +export class ProductCatalogKanbanRenderer extends KanbanRenderer { + static template = "ProductCatalogKanbanRenderer"; + static components = { + ...KanbanRenderer.components, + KanbanRecord: ProductCatalogKanbanRecord, + }; + + setup() { + super.setup(); + this.action = useService("action"); + } + + get createProductContext() { + return {}; + } + + async createProduct() { + await this.action.doAction( + { + type: "ir.actions.act_window", + res_model: "product.product", + target: "new", + views: [[false, "form"]], + view_mode: "form", + context: this.createProductContext, + }, + { + onClose: () => this.props.list.model.load(), + } + ); + } +} diff --git a/product_catalog/static/src/product_catalog/kanban_renderer.xml b/product_catalog/static/src/product_catalog/kanban_renderer.xml new file mode 100644 index 000000000000..42db20960e5a --- /dev/null +++ b/product_catalog/static/src/product_catalog/kanban_renderer.xml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/product_catalog/static/src/product_catalog/kanban_view.js b/product_catalog/static/src/product_catalog/kanban_view.js new file mode 100644 index 000000000000..a517b7a5df0b --- /dev/null +++ b/product_catalog/static/src/product_catalog/kanban_view.js @@ -0,0 +1,20 @@ +/** @odoo-module **/ + +import {kanbanView} from "@web/views/kanban/kanban_view"; +import {registry} from "@web/core/registry"; + +import {ProductCatalogKanbanController} from "./kanban_controller"; +import {ProductCatalogKanbanModel} from "./kanban_model"; +import {ProductCatalogKanbanRenderer} from "./kanban_renderer"; +import {ProductCatalogSearchPanel} from "./search/search_panel"; + +export const productCatalogKanbanView = { + ...kanbanView, + Controller: ProductCatalogKanbanController, + Model: ProductCatalogKanbanModel, + Renderer: ProductCatalogKanbanRenderer, + SearchPanel: ProductCatalogSearchPanel, + buttonTemplate: "ProductCatalogKanbanButtons", +}; + +registry.category("views").add("product_kanban_catalog", productCatalogKanbanView); diff --git a/product_catalog/static/src/product_catalog/kanban_view_buttons.xml b/product_catalog/static/src/product_catalog/kanban_view_buttons.xml new file mode 100644 index 000000000000..45798cfd739e --- /dev/null +++ b/product_catalog/static/src/product_catalog/kanban_view_buttons.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + +
+ +
+ +
+
+ +
+
diff --git a/product_catalog/static/src/product_catalog/search/search_panel.js b/product_catalog/static/src/product_catalog/search/search_panel.js new file mode 100644 index 000000000000..6183b572399a --- /dev/null +++ b/product_catalog/static/src/product_catalog/search/search_panel.js @@ -0,0 +1,55 @@ +/** @odoo-module **/ + +import {SearchPanel} from "@web/search/search_panel/search_panel"; +import {useState} from "@odoo/owl"; + +export class ProductCatalogSearchPanel extends SearchPanel { + setup() { + super.setup(); + + this.state = useState({ + ...this.state, + sectionOfAttributes: {}, + }); + } + + updateActiveValues() { + super.updateActiveValues(); + this.state.sectionOfAttributes = this.buildSection(); + } + + buildSection() { + const values = this.env.searchModel.filters[0].values; + let sections = new Map(); + + values.forEach((element) => { + const name = element.display_name; + const id = element.id; + const count = element.__count; + + if (sections.has(name)) { + let currentAttr = sections.get(name); + currentAttr.get("ids").push(id); + currentAttr.set("count", currentAttr.get("count") + count); + } else { + let newAttr = new Map(); + newAttr.set("ids", [id]); + newAttr.set("count", count); + sections.set(name, newAttr); + } + }); + + return sections; + } + + toggleSectionFilterValue(filterId, attrIds, {currentTarget}) { + attrIds.forEach((id) => { + this.toggleFilterValue(filterId, id, {currentTarget}); + }); + } +} + +ProductCatalogSearchPanel.subTemplates = { + ...SearchPanel.subTemplates, + filtersGroup: "ProductCatalogSearchPanel.FiltersGroup", +}; diff --git a/product_catalog/static/src/product_catalog/search/search_panel.xml b/product_catalog/static/src/product_catalog/search/search_panel.xml new file mode 100644 index 000000000000..5bba1ca67d00 --- /dev/null +++ b/product_catalog/static/src/product_catalog/search/search_panel.xml @@ -0,0 +1,50 @@ + + + +
  • +
  • + + +
    + + +
    +
  • + +
    +
    diff --git a/product_catalog/views/product_views.xml b/product_catalog/views/product_views.xml new file mode 100644 index 000000000000..1decb87e7f4d --- /dev/null +++ b/product_catalog/views/product_views.xml @@ -0,0 +1,140 @@ + + + + product.view.kanban.catalog + product.product + + + + + + +
    + Edit +
    +
    + +
    +
    + Product +
    +
    +
    +
    + +

    + +

    +
    +
    + [] +
    + +
    + +
    +
    +
    + + + + + + + product.view.search.catalog + product.product + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sale_product_catalog/README.rst b/sale_product_catalog/README.rst new file mode 100644 index 000000000000..5062204400de --- /dev/null +++ b/sale_product_catalog/README.rst @@ -0,0 +1,94 @@ +==================== +Sale Product Catalog +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:e15b8a712eb3399c22281379449b50e2b307fe8076a721c03159ab634d3164a1 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fproduct--attribute-lightgray.png?logo=github + :target: https://github.com/OCA/product-attribute/tree/16.0/sale_product_catalog + :alt: OCA/product-attribute +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-attribute-16-0/product-attribute-16-0-sale_product_catalog + :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/product-attribute&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Implementation of the product catalog for sale orders. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To access the catalog from a sales order. + +1. Go to an existing sales order or create a new one. +2. In the order lines tree there's a new option named *Catalog*. +3. Click it and start adding products to the order. + +Known issues / Roadmap +====================== + +- This is a backport of a core 17.0 feature so this module's lifespan + is limited to v16 + +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 +------- + +* Odoo SA +* Tecnativa + +Contributors +------------ + +- `Tecnativa `__ + + - David Vidal + +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/product-attribute `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_product_catalog/__init__.py b/sale_product_catalog/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/sale_product_catalog/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_product_catalog/__manifest__.py b/sale_product_catalog/__manifest__.py new file mode 100644 index 000000000000..7466cfaa10a4 --- /dev/null +++ b/sale_product_catalog/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2024 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Sale Product Catalog", + "summary": "Backport of Odoos v17 product catalog for sale orders", + "version": "16.0.1.0.0", + "author": "Odoo SA, Tecnativa, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/product-attribute", + "license": "AGPL-3", + "category": "Product", + "depends": ["sale", "product_catalog"], + "data": [ + "views/sale_order_views.xml", + ], +} diff --git a/sale_product_catalog/models/__init__.py b/sale_product_catalog/models/__init__.py new file mode 100644 index 000000000000..6aacb753131f --- /dev/null +++ b/sale_product_catalog/models/__init__.py @@ -0,0 +1 @@ +from . import sale_order diff --git a/sale_product_catalog/models/sale_order.py b/sale_product_catalog/models/sale_order.py new file mode 100644 index 000000000000..3a4a4b6866b9 --- /dev/null +++ b/sale_product_catalog/models/sale_order.py @@ -0,0 +1,172 @@ +from collections import defaultdict + +from odoo import models +from odoo.osv import expression + + +class SaleOrder(models.Model): + _name = "sale.order" + _inherit = ["sale.order", "product.catalog.mixin"] + + def _default_order_line_values(self): + default_data = super()._default_order_line_values() + new_default_data = self.env["sale.order.line"]._get_product_catalog_lines_data() + return {**default_data, **new_default_data} + + def _get_action_add_from_catalog_extra_context(self): + return { + **super()._get_action_add_from_catalog_extra_context(), + "product_catalog_currency_id": self.currency_id.id, + "product_catalog_digits": self.order_line._fields["price_unit"].get_digits( + self.env + ), + } + + def _get_product_catalog_domain(self): + return expression.AND( + [super()._get_product_catalog_domain(), [("sale_ok", "=", True)]] + ) + + def _get_product_catalog_order_data(self, products, **kwargs): + pricelist = self.pricelist_id._get_products_price( + quantity=1.0, + products=products, + currency=self.currency_id, + date=self.date_order, + **kwargs, + ) + res = super()._get_product_catalog_order_data(products, **kwargs) + for product in products: + res[product.id]["price"] = pricelist.get(product.id) + if product.sale_line_warn != "no-message" and product.sale_line_warn_msg: + res[product.id]["warning"] = product.sale_line_warn_msg + if product.sale_line_warn == "block": + res[product.id]["readOnly"] = True + return res + + def _get_product_catalog_record_lines(self, product_ids): + grouped_lines = defaultdict(lambda: self.env["sale.order.line"]) + for line in self.order_line: + if line.display_type or line.product_id.id not in product_ids: + continue + grouped_lines[line.product_id] |= line + return grouped_lines + + def _update_order_line_info(self, product_id, quantity, **kwargs): + """Update sale order line information for a given product or create a + new one if none exists yet. + :param int product_id: The product, as a `product.product` id. + :return: The unit price of the product, based on the pricelist of the + sale order and the quantity selected. + :rtype: float + """ + sol = self.order_line.filtered(lambda line: line.product_id.id == product_id) + if sol: + if quantity != 0: + sol.product_uom_qty = quantity + elif self.state in ["draft", "sent"]: + price_unit = self.pricelist_id._get_product_price( + product=sol.product_id, + quantity=1.0, + currency=self.currency_id, + date=self.date_order, + **kwargs, + ) + sol.unlink() + return price_unit + else: + sol.product_uom_qty = 0 + elif quantity > 0: + sol = self.env["sale.order.line"].create( + { + "order_id": self.id, + "product_id": product_id, + "product_uom_qty": quantity, + # Put it at the end of the order + "sequence": ( + (self.order_line and self.order_line[-1].sequence + 1) or 10 + ), + } + ) + return sol.price_unit + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def _get_product_catalog_lines_data(self, **kwargs): + """Return information about sale order lines in `self`. + + If `self` is empty, this method returns only the default value(s) needed for + the product catalog. In this case, the quantity that equals 0. + + Otherwise, it returns a quantity and a price based on the product of the SOL(s) + and whether the product is read-only or not. + + A product is considered read-only if the order is considered read-only (see + ``SaleOrder._is_readonly`` for more details) or if `self` contains multiple + records or if it has sale_line_warn == "block". + + Note: This method cannot be called with multiple records that have different + products linked. + + :raise odoo.exceptions.ValueError: ``len(self.product_id) != 1`` + :rtype: dict + :return: A dict with the following structure: + { + 'quantity': float, + 'price': float, + 'readOnly': bool, + 'warning': String + } + """ + if len(self) == 1: + res = { + "quantity": self.product_uom_qty, + "price": self.price_unit, + "readOnly": self.order_id._is_readonly() + or (self.product_id.sale_line_warn == "block"), + } + if ( + self.product_id.sale_line_warn != "no-message" + and self.product_id.sale_line_warn_msg + ): + res["warning"] = self.product_id.sale_line_warn_msg + return res + elif self: + self.product_id.ensure_one() + order_line = self[0] + order = order_line.order_id + res = { + "readOnly": True, + "price": order.pricelist_id._get_product_price( + product=order_line.product_id, + quantity=1.0, + currency=order.currency_id, + date=order.date_order, + **kwargs, + ), + "quantity": sum( + self.mapped( + lambda line: line.product_uom._compute_quantity( + qty=line.product_uom_qty, + to_unit=line.product_id.uom_id, + ) + ) + ), + } + if ( + self.product_id.sale_line_warn != "no-message" + and self.product_id.sale_line_warn_msg + ): + res["warning"] = self.product_id.sale_line_warn_msg + return res + else: + # price will be computed in batch with pricelist utils so not given here + return { + "quantity": 0, + } + + def action_add_from_catalog(self): + order = self.env["sale.order"].browse(self.env.context.get("order_id")) + return order.action_add_from_catalog() diff --git a/sale_product_catalog/readme/CONTRIBUTORS.md b/sale_product_catalog/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..3a16ddf047ae --- /dev/null +++ b/sale_product_catalog/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Tecnativa](https://tecnativa.com) + - David Vidal diff --git a/sale_product_catalog/readme/DESCRIPTION.md b/sale_product_catalog/readme/DESCRIPTION.md new file mode 100644 index 000000000000..737c74c4bd29 --- /dev/null +++ b/sale_product_catalog/readme/DESCRIPTION.md @@ -0,0 +1 @@ +Implementation of the product catalog for sale orders. diff --git a/sale_product_catalog/readme/ROADMAP.md b/sale_product_catalog/readme/ROADMAP.md new file mode 100644 index 000000000000..2533dd7b9144 --- /dev/null +++ b/sale_product_catalog/readme/ROADMAP.md @@ -0,0 +1 @@ +- This is a backport of a core 17.0 feature so this module's lifespan is limited to v16 diff --git a/sale_product_catalog/readme/USAGE.md b/sale_product_catalog/readme/USAGE.md new file mode 100644 index 000000000000..9ed8043817cc --- /dev/null +++ b/sale_product_catalog/readme/USAGE.md @@ -0,0 +1,5 @@ +To access the catalog from a sales order. + +1. Go to an existing sales order or create a new one. +2. In the order lines tree there's a new option named *Catalog*. +3. Click it and start adding products to the order. diff --git a/sale_product_catalog/static/description/index.html b/sale_product_catalog/static/description/index.html new file mode 100644 index 000000000000..18108070c485 --- /dev/null +++ b/sale_product_catalog/static/description/index.html @@ -0,0 +1,445 @@ + + + + + +Sale Product Catalog + + + +
    +

    Sale Product Catalog

    + + +

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

    +

    Implementation of the product catalog for sale orders.

    +

    Table of contents

    + +
    +

    Usage

    +

    To access the catalog from a sales order.

    +
      +
    1. Go to an existing sales order or create a new one.
    2. +
    3. In the order lines tree there’s a new option named Catalog.
    4. +
    5. Click it and start adding products to the order.
    6. +
    +
    +
    +

    Known issues / Roadmap

    +
      +
    • This is a backport of a core 17.0 feature so this module’s lifespan +is limited to v16
    • +
    +
    +
    +

    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

    +
      +
    • Odoo SA
    • +
    • Tecnativa
    • +
    +
    +
    +

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

    +

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

    +
    +
    +
    + + diff --git a/sale_product_catalog/views/sale_order_views.xml b/sale_product_catalog/views/sale_order_views.xml new file mode 100644 index 000000000000..c3fcda053dff --- /dev/null +++ b/sale_product_catalog/views/sale_order_views.xml @@ -0,0 +1,18 @@ + + + + sale.order + + + +
    +
    +
    +