From 0e493326ea195c83eb13165c14634ff1db469dd2 Mon Sep 17 00:00:00 2001 From: bosd <5e2fd43-d292-4c90-9d1f-74ff3436329a@anonaddy.me> Date: Mon, 27 Apr 2026 14:15:32 +0200 Subject: [PATCH 01/15] [MIG] punchout: Migration to 18.0 + protocol-modular refactor + production hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the base ``punchout`` module forward from 13.0 with a protocol-agnostic refactor (cXML-specific logic moved out into ``punchout_cxml``) plus the production-hardening work that landed during PR review: * Protocol modularity: ``_get_post_punchout_setup_url``, ``_store_punchout_session_response`` and ``_validate_response`` are now thin overridable hooks; the 13.0 cXML-only implementation moved to ``punchout_cxml``. * ``punchout.uom.mapping`` model: 6-tier resolution (backend → supplier → global → UNECE → uom name → caller default) with ``data/uom_mapping_data.xml`` shipping common non-UNECE codes as global defaults (STUECK, ST, STK, PC, PCS, EACH, KG, M, L). * Stored ``name`` field on ``punchout.session`` for human-readable Many2one displays. * ``punchout.backend`` inherits ``mail.thread`` and tracks state, protocol, URL, callback URL and session duration. Smart button to sessions. ``session_retention_days`` field + daily GC cron. ``max_response_size`` field + ``_check_response_size`` helper used by protocol controllers as a first-line flooding guard. * Hide the "Received" pane on the session form when ``setup_request_response`` is empty (only cXML fills it). * ``_get_browser_form_post_url`` produces RFC-clean URLs. * Redirect to the session form after the cart is received. * Dutch translation. Co-Authored-By: Thomas Binsfeld Co-Authored-By: Benjamin Willig Co-Authored-By: Holger Brunn --- punchout/README.rst | 231 ++++++++ punchout/__init__.py | 2 + punchout/__manifest__.py | 30 + punchout/controllers/__init__.py | 6 + punchout/data/ir_cron.xml | 14 + punchout/data/uom_mapping_data.xml | 67 +++ punchout/demo/punchout_demo.xml | 37 ++ punchout/i18n/fr.po | 483 ++++++++++++++++ punchout/i18n/nl.po | 503 +++++++++++++++++ punchout/models/__init__.py | 3 + punchout/models/punchout_backend.py | 193 +++++++ punchout/models/punchout_session.py | 260 +++++++++ punchout/models/punchout_uom_mapping.py | 169 ++++++ punchout/pyproject.toml | 3 + punchout/readme/CONFIGURE.md | 22 + punchout/readme/CONTEXT.md | 14 + punchout/readme/CONTRIBUTORS.md | 2 + punchout/readme/CREDITS.md | 6 + punchout/readme/DESCRIPTION.md | 3 + punchout/readme/HISTORY.md | 32 ++ punchout/readme/INSTALL.md | 2 + punchout/readme/ROADMAP.md | 5 + punchout/readme/SECURITY.md | 27 + punchout/readme/USAGE.md | 26 + punchout/security/punchout_backend.xml | 23 + punchout/security/punchout_session.xml | 14 + punchout/security/punchout_uom_mapping.xml | 24 + punchout/static/description/icon.png | Bin 0 -> 10254 bytes punchout/static/description/index.html | 583 ++++++++++++++++++++ punchout/tests/__init__.py | 2 + punchout/tests/common.py | 31 ++ punchout/tests/test_punchout.py | 82 +++ punchout/tests/test_punchout_uom_mapping.py | 172 ++++++ punchout/views/punchout_backend.xml | 173 ++++++ punchout/views/punchout_session.xml | 120 ++++ punchout/views/punchout_uom_mapping.xml | 108 ++++ 36 files changed, 3472 insertions(+) create mode 100644 punchout/README.rst create mode 100644 punchout/__init__.py create mode 100644 punchout/__manifest__.py create mode 100644 punchout/controllers/__init__.py create mode 100644 punchout/data/ir_cron.xml create mode 100644 punchout/data/uom_mapping_data.xml create mode 100644 punchout/demo/punchout_demo.xml create mode 100644 punchout/i18n/fr.po create mode 100644 punchout/i18n/nl.po create mode 100644 punchout/models/__init__.py create mode 100644 punchout/models/punchout_backend.py create mode 100644 punchout/models/punchout_session.py create mode 100644 punchout/models/punchout_uom_mapping.py create mode 100644 punchout/pyproject.toml create mode 100644 punchout/readme/CONFIGURE.md create mode 100644 punchout/readme/CONTEXT.md create mode 100644 punchout/readme/CONTRIBUTORS.md create mode 100644 punchout/readme/CREDITS.md create mode 100644 punchout/readme/DESCRIPTION.md create mode 100644 punchout/readme/HISTORY.md create mode 100644 punchout/readme/INSTALL.md create mode 100644 punchout/readme/ROADMAP.md create mode 100644 punchout/readme/SECURITY.md create mode 100644 punchout/readme/USAGE.md create mode 100644 punchout/security/punchout_backend.xml create mode 100644 punchout/security/punchout_session.xml create mode 100644 punchout/security/punchout_uom_mapping.xml create mode 100644 punchout/static/description/icon.png create mode 100644 punchout/static/description/index.html create mode 100644 punchout/tests/__init__.py create mode 100644 punchout/tests/common.py create mode 100644 punchout/tests/test_punchout.py create mode 100644 punchout/tests/test_punchout_uom_mapping.py create mode 100644 punchout/views/punchout_backend.xml create mode 100644 punchout/views/punchout_session.xml create mode 100644 punchout/views/punchout_uom_mapping.xml diff --git a/punchout/README.rst b/punchout/README.rst new file mode 100644 index 0000000000..666d1e953d --- /dev/null +++ b/punchout/README.rst @@ -0,0 +1,231 @@ +======== +Punchout +======== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a575761638f19894b0e09efcaa56bf2fe5fe3f97a4ab1109909e8a2def537c5c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fedi-lightgray.png?logo=github + :target: https://github.com/OCA/edi/tree/18.0/punchout + :alt: OCA/edi +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/edi-18-0/edi-18-0-punchout + :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/edi&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module setup the base configuration to access a punchout platform, +in order to populate odoo. For example, creating purchase orders. This +alpha version has been implemented using the eShop Wurth. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +[ This file is optional but strongly suggested to allow end-users to +evaluate the module's usefulness in their context. ] + +This module set up the base of the connection to a punchout system. Sub +module can be created for example to create a purchase order from a +shopping cart created in the punchout platform. To do this, you can make +your model inherit from "punchout.request" and add the import process by +inheriting "action_process" method. + +Linked modules: + +- punchout_environment : allows to configure a punchout backend through + server environment files +- punchout_queue_job : link the punchout request to queue jobs, + automatically call the process method when a response is received + +Installation +============ + +[ This file must only be present if there are very specific installation +instructions, such as installing non-python dependencies. The audience +is systems administrators. ] + +Configuration +============= + +To configure this module: + +1. Go to **Settings > Technical > PunchOut > PunchOut Backends** + +2. Create a new backend record with: + + - **Name**: A unique identifier for this supplier connection + - **Description**: Human-readable description + - **Protocol**: Select the punchout protocol (cXML, OCI, or IDS - + requires additional modules) + - **URL**: The supplier's punchout setup URL + - **Browser form post URL**: The callback URL where the supplier + sends the shopping cart (can be relative like + ``/punchout/cxml/receive/``) + - **Session duration**: Maximum time (in seconds) a punchout session + is valid + +3. Configure protocol-specific credentials (requires Administrator + access): + + - cXML: From/To identities, SharedSecret + - OCI: Custom vendor parameters + - IDS: Customer name, number, and password + +4. Optionally add UoM mappings to translate supplier unit codes to Odoo + UoMs + +**Note**: Backend configuration and credential fields are restricted to +users with the ``Administration/Settings`` permission group for security +reasons. + +Usage +===== + +To use this module: + +1. Configure a punchout backend with your supplier's credentials +2. Set the backend state to "Open" +3. Click the **Access** button to start a punchout session +4. Browse the supplier's catalog and add items to your cart +5. Complete the checkout on the supplier's site - this sends the cart + back to Odoo +6. The punchout session will show status "To Process" when the cart is + received +7. Process the session to create purchase orders (requires + ``punchout_purchase`` module) + +**Session States:** + +- **Draft**: Session created, waiting for supplier response +- **To Process**: Cart received from supplier, ready to create purchase + order +- **Done**: Session processed successfully +- **Error**: Something went wrong (check error message) + +**Auditing a backend:** the backend form carries a chatter and tracks +changes to ``state``, ``protocol``, ``url``, ``browser_form_post_url`` +and ``session_duration``. A "Sessions" smart button on the backend opens +the filtered list of every session that used it. + +**See also:** when ``punchout_purchase`` is installed, additional entry +points appear (Browse Supplier Catalog from a draft PO and from a vendor +record, per-product "Open at supplier" deep-links). Refer to that +module's README for the purchase-side flows. + +Known issues / Roadmap +====================== + +[ Enumerate known caveats and future potential improvements. It is +mostly intended for end-users, and can also help potential new +contributors discovering new features to implement. ] + +- + +Changelog +========= + +18.0.1.0.0 (2026) +----------------- + +- [MIG] Migration to Odoo 18.0. +- [IMP] ``punchout.uom.mapping`` now resolves supplier UoM codes through + a 6-tier chain: backend → supplier → global → UNECE → uom name → + caller default. +- [IMP] Ship ``data/uom_mapping_data.xml`` with common non-UNECE codes + as global defaults (STUECK, ST, STK, PC, PCS, EACH, KG, M, L). +- [IMP] Optional ``supplier_id`` scope on ``punchout.uom.mapping``; both + scopes (backend, supplier) are now optional. +- [FIX] ``_get_browser_form_post_url`` now produces RFC-clean URLs (no + double slashes, no trailing slash before the query string). +- [IMP] Stored ``name`` field on ``punchout.session`` so Many2one + displays show "Backend / 2026-04-26 14:02" instead of + "punchout.session,42". +- [IMP] ``punchout.backend`` inherits ``mail.thread`` and tracks changes + to state, protocol, URL, callback URL and session duration. +- [IMP] Smart button on the backend form opens the filtered list of + sessions for that backend. +- [FIX] Session form's "Received" pane is hidden when + ``setup_request_response`` is empty — only cXML actually fills it, so + the pane was permanently blank for OCI/IDS sessions. +- [IMP] ``session_retention_days`` field on backend (default 90) + daily + cron ``_gc_punchout_sessions`` that vacuums old sessions. Previous + behaviour: the table grew without bound. +- [IMP] ``max_response_size`` field on backend (default 1 MiB) + + ``_check_response_size`` helper used by the protocol controllers to + reject oversized supplier payloads. +- [ADD] Dutch translation. + +13.0.1.0.0 (2023-09-26) +----------------------- + +- [ADD] First version. + +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 +------- + +* ACSONE SA/NV + +Contributors +------------ + +- Thomas Binsfeld thomas.binsfeld@acsone.eu (ACSONE SA/NV) +- Benjamin Willig benjamin.willig@acsone.eu (ACSONE SA/NV) + +Other credits +------------- + +[ This file is optional and contains additional credits, other than +authors, contributors, and maintainers. ] + +The development of this module has been financially supported by: + +- ACSONE SA/NV + +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/edi `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/punchout/__init__.py b/punchout/__init__.py new file mode 100644 index 0000000000..91c5580fed --- /dev/null +++ b/punchout/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/punchout/__manifest__.py b/punchout/__manifest__.py new file mode 100644 index 0000000000..07d6bd8acf --- /dev/null +++ b/punchout/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Punchout", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Odoo Community Association (OCA), ACSONE SA/NV", + "website": "https://github.com/OCA/edi", + "depends": [ + # odoo addons + "base", + "mail", + # OCA addons + "uom_unece", # For UNECE UoM codes + ], + "data": [ + "security/punchout_backend.xml", + "security/punchout_session.xml", + "security/punchout_uom_mapping.xml", + "data/uom_mapping_data.xml", + "data/ir_cron.xml", + "views/punchout_backend.xml", + "views/punchout_session.xml", + "views/punchout_uom_mapping.xml", + ], + "demo": [ + "demo/punchout_demo.xml", + ], +} diff --git a/punchout/controllers/__init__.py b/punchout/controllers/__init__.py new file mode 100644 index 0000000000..09ecd359bf --- /dev/null +++ b/punchout/controllers/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2023 ACSONE SA/NV +# Copyright 2025 Bosd +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +# Base punchout module has no controllers. +# Protocol-specific controllers are in protocol modules (e.g., punchout_cxml). diff --git a/punchout/data/ir_cron.xml b/punchout/data/ir_cron.xml new file mode 100644 index 0000000000..3652515de5 --- /dev/null +++ b/punchout/data/ir_cron.xml @@ -0,0 +1,14 @@ + + + + + Punchout: garbage-collect old sessions + + code + model._gc_punchout_sessions() + 1 + days + + + diff --git a/punchout/data/uom_mapping_data.xml b/punchout/data/uom_mapping_data.xml new file mode 100644 index 0000000000..12ef742b4e --- /dev/null +++ b/punchout/data/uom_mapping_data.xml @@ -0,0 +1,67 @@ + + + + + + + + STUECK + + German: Stück (piece) + + + ST + + German abbreviation for Stück + + + STK + + German abbreviation for Stück + + + PC + + Common abbreviation for piece + + + PCS + + Common abbreviation for pieces + + + EACH + + English word form for each/piece + + + + + KG + + Short form for kilogram + + + + + M + + Short form for metre + + + + + L + + Short form for litre + + diff --git a/punchout/demo/punchout_demo.xml b/punchout/demo/punchout_demo.xml new file mode 100644 index 0000000000..fe831e8fe9 --- /dev/null +++ b/punchout/demo/punchout_demo.xml @@ -0,0 +1,37 @@ + + + + + + Demo Supplier Catalog + Demo punchout backend for testing + cxml + https://demo.supplier.example.com/punchout + /punchout/cxml/receive/ + 7200 + open + + + + + + PCE + + Common supplier code for pieces/units + + + + + BOX + + Supplier uses BOX for dozen quantities + + + + + KGM + + UNECE code for kilogram + + diff --git a/punchout/i18n/fr.po b/punchout/i18n/fr.po new file mode 100644 index 0000000000..9f131b2f6a --- /dev/null +++ b/punchout/i18n/fr.po @@ -0,0 +1,483 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * punchout +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-11-27 17:24+0000\n" +"PO-Revision-Date: 2023-11-27 17:24+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: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_kanban_view +msgid " Access" +msgstr " Accéder" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_form_view +msgid "Access" +msgstr "Accéder" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__action_process_allowed +msgid "Action Process Allowed" +msgstr "" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "An error occured during the process of the session." +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__backend_id +msgid "Backend" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__browser_form_post_url +msgid "Browser form post URL" +msgstr "" + +#. module: punchout +#: code:addons/punchout/models/punchout_backend.py:0 +#, python-format +msgid "Browser form post url is not configured on " +msgstr "L'URL browser form post n'est pas configurée sur " + +#. module: punchout +#: code:addons/punchout/models/punchout_backend.py:0 +#, python-format +msgid "Closed" +msgstr "Fermé" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__buyer_cookie_id +msgid "Cookie" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__create_uid +#: model:ir.model.fields,field_description:punchout.field_punchout_session__create_uid +msgid "Created by" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__create_date +#: model:ir.model.fields,field_description:punchout.field_punchout_session__create_date +msgid "Created on" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__dtd_file +msgid "DTD File for validation" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__deployment_mode +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_search_view +msgid "Deployment mode" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__display_name +#: model:ir.model.fields,field_description:punchout.field_punchout_session__display_name +msgid "Display Name" +msgstr "" + +#. module: punchout +#: code:addons/punchout/models/punchout_session.py:0 +#, python-format +msgid "Done" +msgstr "Traitée" + +#. module: punchout +#: code:addons/punchout/models/punchout_backend.py:0 +#: code:addons/punchout/models/punchout_session.py:0 +#, python-format +msgid "Draft" +msgstr "Brouillon" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__dtd_filename +msgid "Dtd Filename" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__dtd_version +msgid "Dtd Version" +msgstr "" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "Erreur" +msgstr "" + +#. module: punchout +#: code:addons/punchout/models/punchout_session.py:0 +#, python-format +msgid "Error" +msgstr "Erreur" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__error_message +msgid "Error Message" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__expiration_date +msgid "Expiration Date" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_backend__browser_form_post_url +msgid "Exposed URL where the shopping cart must be sent back to." +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_channel_ids +msgid "Followers (Channels)" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__from_domain +msgid "From domain" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__from_identity +msgid "From identity" +msgstr "" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_search_view +msgid "Group By" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__id +#: model:ir.model.fields,field_description:punchout.field_punchout_session__id +msgid "ID" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_session__message_needaction +#: model:ir.model.fields,help:punchout.field_punchout_session__message_unread +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_session__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend____last_update +#: model:ir.model.fields,field_description:punchout.field_punchout_session____last_update +msgid "Last Modified on" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__write_uid +#: model:ir.model.fields,field_description:punchout.field_punchout_session__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__write_date +#: model:ir.model.fields,field_description:punchout.field_punchout_session__write_date +msgid "Last Updated on" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_main_attachment_id +msgid "Main Attachment" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__session_duration +msgid "Maximum session duration" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_ids +msgid "Messages" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__name +msgid "Name" +msgstr "" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_form_view +msgid "Networkid" +msgstr "" + +#. module: punchout +#: code:addons/punchout/models/punchout_session.py:0 +#, python-format +msgid "No punchout backend found to initialize the connection." +msgstr "Aucun backend punchout trouvé pour initialisé la connexion." + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_session__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_session__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_session__message_unread_counter +msgid "Number of unread messages" +msgstr "" + +#. module: punchout +#: code:addons/punchout/models/punchout_backend.py:0 +#, python-format +msgid "Open" +msgstr "Ouvert" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "Process" +msgstr "Traiter" + +#. module: punchout +#: code:addons/punchout/models/punchout_session.py:0 +#, python-format +msgid "Processing the request" +msgstr "Traitement de la requête" + +#. module: punchout +#: model:ir.ui.menu,name:punchout.punchout_menu +msgid "PunchOut" +msgstr "" + +#. module: punchout +#: model:ir.model,name:punchout.model_punchout_backend +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_form_view +msgid "PunchOut Backend" +msgstr "Backend PunchOut" + +#. module: punchout +#: model:ir.ui.menu,name:punchout.punchout_backend_menu +msgid "PunchOut Backends" +msgstr "Backends PunchOut" + +#. module: punchout +#: code:addons/punchout/models/punchout_session.py:0 +#: code:addons/punchout/models/punchout_session.py:0 +#, python-format +msgid "PunchOut request error" +msgstr "" + +#. module: punchout +#: model:ir.actions.act_window,name:punchout.punchout_session_act_window +#: model:ir.ui.menu,name:punchout.punchout_session_menu +msgid "PunchOut sessions" +msgstr "Sessions PunchOut" + +#. module: punchout +#: model:ir.actions.act_window,name:punchout.punchout_backend_act_window +msgid "Punchout Backends" +msgstr "Backends PunchOut" + +#. module: punchout +#: model:ir.model,name:punchout.model_punchout_session +msgid "Punchout Session" +msgstr "Session PunchOut" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "Received" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__cxml_response +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "Response" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__cxml_response_date +msgid "Response Date" +msgstr "" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "Sent" +msgstr "" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "Setup" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__cxml_setup_request +msgid "Setup Request" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__cxml_setup_request_response +msgid "Setup Request Response" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__shared_secret +msgid "Shared secret" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__punchout_url +msgid "Start URL" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__state +#: model:ir.model.fields,field_description:punchout.field_punchout_session__state +msgid "State" +msgstr "Statut" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_backend__deployment_mode +msgid "Test or production" +msgstr "" + +#. module: punchout +#: code:addons/punchout/models/punchout_backend.py:0 +#, python-format +msgid "The duration of the session must be greater than 0. {name}" +msgstr "" + +#. module: punchout +#: code:addons/punchout/models/punchout_session.py:0 +#, python-format +msgid "The response has been received and will be processed." +msgstr "La réponse a été reçue et va être traitée." + +#. module: punchout +#: code:addons/punchout/models/punchout_session.py:0 +#, python-format +msgid "The response received from the backend is not valid." +msgstr "" + +#. module: punchout +#: code:addons/punchout/models/punchout_backend.py:0 +#: model:ir.model.constraint,message:punchout.constraint_punchout_backend_name_unique +#, python-format +msgid "This PunchOut backend already exists." +msgstr "" + +#. module: punchout +#: code:addons/punchout/models/punchout_session.py:0 +#, python-format +msgid "To Process" +msgstr "A traiter" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__to_domain +msgid "To domain" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__to_identity +msgid "To identity" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__url +msgid "URL" +msgstr "" + +#. module: punchout +#: code:addons/punchout/models/punchout_session.py:0 +#, python-format +msgid "Unable to process the request" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_unread +msgid "Unread Messages" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_unread_counter +msgid "Unread Messages Counter" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__user_id +msgid "User" +msgstr "" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__user_agent +msgid "User agent" +msgstr "" + +#. module: punchout +#: code:addons/punchout/models/punchout_session.py:0 +#, python-format +msgid "You are not allowed to process this request. " +msgstr "" + +#. module: punchout +#: code:addons/punchout/models/punchout_session.py:0 +#, python-format +msgid "" +"You must set a personnal email in your preferences in order to access this " +"feature." +msgstr "" +"Vous devez configurer un email personnel dans vos préférences pour utiliser " +"cette fonctionnalité." \ No newline at end of file diff --git a/punchout/i18n/nl.po b/punchout/i18n/nl.po new file mode 100644 index 0000000000..f95fc8cd9b --- /dev/null +++ b/punchout/i18n/nl.po @@ -0,0 +1,503 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * punchout +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-25 22:13+0000\n" +"PO-Revision-Date: 2026-04-25 22:13+0000\n" +"Last-Translator: \n" +"Language-Team: Dutch (https://www.transifex.com/oca/teams/23907/nl/)\n" +"Language: nl\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: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_kanban_view +msgid " Access" +msgstr " Toegang" + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_uom_mapping.py:0 +msgid "A mapping for supplier code %(code)s already exists for this scope." +msgstr "Er bestaat al een toewijzing voor leverancierscode %(code)s in deze scope." + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_form_view +msgid "Access" +msgstr "Toegang" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_needaction +msgid "Action Needed" +msgstr "Actie vereist" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__action_process_allowed +msgid "Action Process Allowed" +msgstr "Verwerken toegestaan" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "An error occured during the process of the session." +msgstr "Er is een fout opgetreden tijdens het verwerken van de sessie." + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_attachment_count +msgid "Attachment Count" +msgstr "Aantal bijlagen" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__backend_id +#: model:ir.model.fields,field_description:punchout.field_punchout_uom_mapping__backend_id +#: model_terms:ir.ui.view,arch_db:punchout.punchout_uom_mapping_search_view +msgid "Backend" +msgstr "Backend" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_uom_mapping_search_view +msgid "Backend-scoped" +msgstr "Backend-specifiek" + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_uom_mapping.py:0 +msgid "Backend: %s" +msgstr "Backend: %s" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__browser_form_post_url +msgid "Browser form post URL" +msgstr "Browser-formulier-POST URL" + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_backend.py:0 +msgid "Browser form post url is not configured on the backend. %(name)s" +msgstr "De URL voor het terugsturen van het winkelmandje is niet geconfigureerd op backend %(name)s." + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__buyer_cookie_id +msgid "Cookie" +msgstr "Cookie" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__create_uid +#: model:ir.model.fields,field_description:punchout.field_punchout_session__create_uid +#: model:ir.model.fields,field_description:punchout.field_punchout_uom_mapping__create_uid +msgid "Created by" +msgstr "Aangemaakt door" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__create_date +#: model:ir.model.fields,field_description:punchout.field_punchout_session__create_date +#: model:ir.model.fields,field_description:punchout.field_punchout_uom_mapping__create_date +msgid "Created on" +msgstr "Aangemaakt op" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__description +msgid "Description" +msgstr "Omschrijving" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__display_name +#: model:ir.model.fields,field_description:punchout.field_punchout_session__display_name +#: model:ir.model.fields,field_description:punchout.field_punchout_uom_mapping__display_name +msgid "Display Name" +msgstr "Weergavenaam" + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_session.py:0 +msgid "Done" +msgstr "Voltooid" + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_session.py:0 +msgid "Draft" +msgstr "Concept" + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_session.py:0 +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "Error" +msgstr "Fout" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__error_message +msgid "Error Message" +msgstr "Foutmelding" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__expiration_date +msgid "Expiration Date" +msgstr "Vervaldatum" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_backend__browser_form_post_url +msgid "Exposed URL where the shopping cart must be sent back to." +msgstr "Publiek toegankelijke URL waarnaar het winkelmandje moet worden teruggestuurd." + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_follower_ids +msgid "Followers" +msgstr "Volgers" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_partner_ids +msgid "Followers (Partners)" +msgstr "Volgers (Relaties)" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_uom_mapping_search_view +msgid "Global" +msgstr "Algemeen" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_search_view +#: model_terms:ir.ui.view,arch_db:punchout.punchout_uom_mapping_search_view +msgid "Group By" +msgstr "Groeperen op" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__has_message +msgid "Has Message" +msgstr "Heeft bericht" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__id +#: model:ir.model.fields,field_description:punchout.field_punchout_session__id +#: model:ir.model.fields,field_description:punchout.field_punchout_uom_mapping__id +msgid "ID" +msgstr "ID" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_session__message_needaction +msgid "If checked, new messages require your attention." +msgstr "Indien aangevinkt vereisen nieuwe berichten uw aandacht." + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_session__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "Indien aangevinkt hebben sommige berichten een afleverfout." + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_is_follower +msgid "Is Follower" +msgstr "Is volger" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__write_uid +#: model:ir.model.fields,field_description:punchout.field_punchout_session__write_uid +#: model:ir.model.fields,field_description:punchout.field_punchout_uom_mapping__write_uid +msgid "Last Updated by" +msgstr "Laatst bijgewerkt door" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__write_date +#: model:ir.model.fields,field_description:punchout.field_punchout_session__write_date +#: model:ir.model.fields,field_description:punchout.field_punchout_uom_mapping__write_date +msgid "Last Updated on" +msgstr "Laatst bijgewerkt op" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_backend__uom_mapping_ids +msgid "Map supplier-specific UoM codes to Odoo UoMs." +msgstr "Wijs leverancier-specifieke maateenheidcodes toe aan Odoo-maateenheden." + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_uom_mapping_form_view +msgid "Mapping" +msgstr "Toewijzing" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_uom_mapping__supplier_id +msgid "" +"Mapping applies to any backend using this supplier. Ignored when a backend " +"is set. Leave empty for a global mapping." +msgstr "" +"Toewijzing geldt voor elke backend die deze leverancier gebruikt. Wordt " +"genegeerd zodra een backend is ingesteld. Laat leeg voor een algemene " +"toewijzing." + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_uom_mapping__backend_id +msgid "" +"Mapping only applies to this backend. Leave empty for a supplier-wide or " +"global mapping." +msgstr "" +"Toewijzing geldt alleen voor deze backend. Laat leeg voor een leverancier-" +"brede of algemene toewijzing." + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__session_duration +msgid "Maximum session duration" +msgstr "Maximale sessieduur" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_has_error +msgid "Message Delivery error" +msgstr "Bericht afleverfout" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_ids +msgid "Messages" +msgstr "Berichten" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__name +msgid "Name" +msgstr "Naam" + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_session.py:0 +msgid "No punchout backend found to initialize the connection." +msgstr "Geen punchout-backend gevonden om de verbinding te initialiseren." + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_uom_mapping__notes +msgid "Notes" +msgstr "Notities" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_needaction_counter +msgid "Number of Actions" +msgstr "Aantal acties" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__message_has_error_counter +msgid "Number of errors" +msgstr "Aantal fouten" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_session__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "Aantal berichten dat actie vereist" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_session__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "Aantal berichten met afleverfout" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_uom_mapping__notes +msgid "Optional notes about this mapping." +msgstr "Optionele notities bij deze toewijzing." + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "Process" +msgstr "Verwerken" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__protocol +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_search_view +msgid "Protocol" +msgstr "Protocol" + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_session.py:0 +msgid "Protocol %(protocol)s does not implement setup request." +msgstr "Protocol %(protocol)s ondersteunt geen setup-verzoek." + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_session.py:0 +msgid "Protocol does not implement response storage." +msgstr "Protocol ondersteunt geen opslag van het antwoord." + +#. module: punchout +#: model:ir.ui.menu,name:punchout.punchout_menu +msgid "PunchOut" +msgstr "PunchOut" + +#. module: punchout +#: model:ir.model,name:punchout.model_punchout_backend +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_form_view +msgid "PunchOut Backend" +msgstr "PunchOut backend" + +#. module: punchout +#: model:ir.ui.menu,name:punchout.punchout_backend_menu +msgid "PunchOut Backends" +msgstr "PunchOut backends" + +#. module: punchout +#: model:ir.actions.act_window,name:punchout.punchout_session_act_window +#: model:ir.ui.menu,name:punchout.punchout_session_menu +msgid "PunchOut sessions" +msgstr "PunchOut-sessies" + +#. module: punchout +#: model:ir.actions.act_window,name:punchout.punchout_backend_act_window +msgid "Punchout Backends" +msgstr "Punchout backends" + +#. module: punchout +#: model:ir.model,name:punchout.model_punchout_session +msgid "Punchout Session" +msgstr "Punchout-sessie" + +#. module: punchout +#: model:ir.model,name:punchout.model_punchout_uom_mapping +msgid "Punchout UoM Mapping" +msgstr "Punchout maateenheid-toewijzing" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "Received" +msgstr "Ontvangen" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__response +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "Response" +msgstr "Antwoord" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__response_date +msgid "Response Date" +msgstr "Antwoorddatum" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_uom_mapping_form_view +msgid "Scope" +msgstr "Scope" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "Sent" +msgstr "Verzonden" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_session_form_view +msgid "Setup" +msgstr "Setup" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__setup_request +msgid "Setup Request" +msgstr "Setup-verzoek" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__setup_request_response +msgid "Setup Request Response" +msgstr "Antwoord op setup-verzoek" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__punchout_url +msgid "Start URL" +msgstr "Start-URL" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__state +#: model:ir.model.fields,field_description:punchout.field_punchout_session__state +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_search_view +msgid "State" +msgstr "Status" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_uom_mapping__supplier_id +#: model_terms:ir.ui.view,arch_db:punchout.punchout_uom_mapping_search_view +msgid "Supplier" +msgstr "Leverancier" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_uom_mapping__supplier_code +msgid "Supplier Code" +msgstr "Leverancierscode" + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_uom_mapping.py:0 +msgid "Supplier code cannot be empty for mapping %(name)s" +msgstr "Leverancierscode mag niet leeg zijn voor toewijzing %(name)s" + +#. module: punchout +#: model_terms:ir.ui.view,arch_db:punchout.punchout_uom_mapping_search_view +msgid "Supplier-scoped" +msgstr "Leverancier-specifiek" + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_uom_mapping.py:0 +msgid "Supplier: %s" +msgstr "Leverancier: %s" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_uom_mapping__uom_id +msgid "The Odoo UoM to use for products with this supplier code." +msgstr "De Odoo-maateenheid die moet worden gebruikt voor producten met deze leverancierscode." + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_uom_mapping__supplier_code +msgid "The UoM code used by the supplier in punchout responses." +msgstr "De maateenheidcode die door de leverancier in punchout-antwoorden wordt gebruikt." + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_backend.py:0 +msgid "The duration of the session must be greater than 0. {name}" +msgstr "De duur van de sessie moet groter zijn dan 0. {name}" + +#. module: punchout +#: model:ir.model.fields,help:punchout.field_punchout_backend__protocol +msgid "The punchout protocol used by this backend." +msgstr "Het punchout-protocol dat door deze backend wordt gebruikt." + +#. module: punchout +#: model:ir.model.constraint,message:punchout.constraint_punchout_backend_name_unique +msgid "This PunchOut backend already exists." +msgstr "Deze PunchOut backend bestaat al." + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_session.py:0 +msgid "To Process" +msgstr "Te verwerken" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__url +msgid "URL" +msgstr "URL" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_uom_mapping__uom_id +#: model_terms:ir.ui.view,arch_db:punchout.punchout_uom_mapping_search_view +msgid "UoM" +msgstr "Maateenheid" + +#. module: punchout +#: model:ir.actions.act_window,name:punchout.punchout_uom_mapping_act_window +#: model:ir.model.fields,field_description:punchout.field_punchout_backend__uom_mapping_ids +#: model:ir.ui.menu,name:punchout.punchout_uom_mapping_menu +#: model_terms:ir.ui.view,arch_db:punchout.punchout_backend_form_view +msgid "UoM Mappings" +msgstr "Maateenheid-toewijzingen" + +#. module: punchout +#: model:ir.model.fields,field_description:punchout.field_punchout_session__user_id +msgid "User" +msgstr "Gebruiker" + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_session.py:0 +msgid "You are not allowed to process this request. %(name)s" +msgstr "U bent niet bevoegd om dit verzoek te verwerken. %(name)s" + +#. module: punchout +#. odoo-python +#: code:addons/punchout/models/punchout_uom_mapping.py:0 +msgid "global" +msgstr "algemeen" diff --git a/punchout/models/__init__.py b/punchout/models/__init__.py new file mode 100644 index 0000000000..c92bf951d4 --- /dev/null +++ b/punchout/models/__init__.py @@ -0,0 +1,3 @@ +from . import punchout_backend +from . import punchout_session +from . import punchout_uom_mapping diff --git a/punchout/models/punchout_backend.py b/punchout/models/punchout_backend.py new file mode 100644 index 0000000000..d913f44a3d --- /dev/null +++ b/punchout/models/punchout_backend.py @@ -0,0 +1,193 @@ +# Copyright 2023 ACSONE SA/NV +# Copyright 2025 Bosd +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class PunchoutBackend(models.Model): + _name = "punchout.backend" + _inherit = ["mail.thread"] + _description = "PunchOut Backend" + _sql_constraints = [ + ("name_unique", "unique(name)", "This PunchOut backend already exists.") + ] + + name = fields.Char( + required=True, + tracking=True, + ) + description = fields.Char( + required=True, + tracking=True, + ) + protocol = fields.Selection( + selection="_selection_protocol", + required=True, + default="cxml", + tracking=True, + help="The punchout protocol used by this backend.", + ) + url = fields.Char( + string="URL", + required=True, + tracking=True, + ) + browser_form_post_url = fields.Char( + string="Browser form post URL", + help="Exposed URL where the shopping cart must be sent back to.", + required=True, + tracking=True, + ) + state = fields.Selection( + selection="_selection_state", default="draft", tracking=True + ) + session_duration = fields.Integer( + string="Maximum session duration", + default=7200, + tracking=True, + ) + session_retention_days = fields.Integer( + string="Session retention (days)", + default=90, + tracking=True, + help=( + "Sessions older than this many days are deleted by the " + "scheduled action 'Punchout: garbage-collect old sessions'. " + "0 = keep forever (not recommended; cart payloads can be " + "large and the table grows without bound)." + ), + ) + max_response_size = fields.Integer( + string="Maximum cart payload (bytes)", + default=1048576, # 1 MiB + tracking=True, + help=( + "Reject supplier-callback payloads larger than this many " + "bytes. Protects the receive endpoint from accidental or " + "malicious flooding. 0 = no limit (not recommended)." + ), + ) + uom_mapping_ids = fields.One2many( + comodel_name="punchout.uom.mapping", + inverse_name="backend_id", + string="UoM Mappings", + help="Map supplier-specific UoM codes to Odoo UoMs.", + ) + session_ids = fields.One2many( + comodel_name="punchout.session", + inverse_name="backend_id", + string="Sessions", + ) + session_count = fields.Integer(compute="_compute_session_count") + + def _compute_session_count(self): + # Group-by query so a backend with thousands of sessions + # doesn't load every session record into the count. + data = self.env["punchout.session"]._read_group( + [("backend_id", "in", self.ids)], ["backend_id"], ["__count"] + ) + counts = {backend.id: count for backend, count in data} + for rec in self: + rec.session_count = counts.get(rec.id, 0) + + def action_view_sessions(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Punchout Sessions"), + "res_model": "punchout.session", + "view_mode": "list,form", + "domain": [("backend_id", "=", self.id)], + "context": {"default_backend_id": self.id}, + } + + @api.model + def _selection_protocol(self): + """Return available protocols. Extended by protocol modules.""" + return [ + ("cxml", "cXML"), + ] + + @api.constrains("session_duration") + def _check_session_duration(self): + for rec in self: + if rec.session_duration <= 0: + raise ValidationError( + _( + "The duration of the session must be greater than 0. {name}" + ).format(name=rec.display_name) + ) + + @api.model + def _selection_state(self): + return [ + ("draft", "Draft"), + ("open", "Open"), + ("closed", "Closed"), + ] + + def _get_browser_form_post_url(self): + """Build the full browser form post URL.""" + self.ensure_one() + url = self.browser_form_post_url + if url and (url.startswith("http://") or url.startswith("https://")): + return url + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + if base_url.endswith("/"): + base_url = base_url[:-1] + if url and url.startswith("/"): + url = url[1:] + if not url: + raise UserError( + _("Browser form post url is not configured on " "the backend. %(name)s") + % {"name": self.display_name} + ) + + return f"{base_url}/{url.rstrip('/')}/{self.id}?db={self.env.cr.dbname}" + + def _check_response_size(self, payload): + """Raise ``UserError`` if ``payload`` exceeds the backend's + configured ``max_response_size``. Called from the protocol + controllers as a first-line guard against accidental or + malicious flooding of the receive endpoint. ``max_response_size=0`` + disables the check.""" + self.ensure_one() + cap = self.max_response_size + if cap and payload is not None and len(payload) > cap: + raise UserError( + _( + "Punchout cart payload (%(size)s bytes) exceeds the " + "configured limit of %(cap)s bytes for backend " + "%(name)s." + ) + % { + "size": len(payload), + "cap": cap, + "name": self.display_name, + } + ) + + def _check_access_backend(self): + """ + Inherit this method to check if current user can access + the backend website + """ + return True + + def redirect_to_backend(self): + self.ensure_one() + self._check_access_backend() + return ( + self.env["punchout.session"] + .with_context( + punchout_backend_id=self.id, + ) + ._redirect_to_punchout() + ) + + def _get_redirect_url(self): + self.ensure_one() + return "/web" diff --git a/punchout/models/punchout_session.py b/punchout/models/punchout_session.py new file mode 100644 index 0000000000..2f7e4ced8b --- /dev/null +++ b/punchout/models/punchout_session.py @@ -0,0 +1,260 @@ +# Copyright 2023 ACSONE SA/NV +# Copyright 2025 Bosd +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +import os +import random +import time +from datetime import datetime +from urllib.parse import urlparse +from uuid import uuid4 + +import pytz +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class PunchoutSession(models.Model): + _name = "punchout.session" + _inherit = [ + "mail.thread", + ] + _order = "create_date desc" + _description = "Punchout Session" + + name = fields.Char( + compute="_compute_name", + store=True, + readonly=True, + help=( + "Human-readable label used wherever a punchout session is " + "referenced in the UI (Many2one displays, smart button " + "captions, log messages). Built from the backend name and " + "creation date so a stale 'punchout.session,42' never " + "leaks into purchaser-facing screens." + ), + ) + backend_id = fields.Many2one( + comodel_name="punchout.backend", + readonly=True, + ) + user_id = fields.Many2one( + comodel_name="res.users", + readonly=True, + default=lambda self: self.env.uid, + ) + buyer_cookie_id = fields.Char(readonly=True, string="Cookie") + punchout_url = fields.Char(readonly=True, string="Start URL") + setup_request = fields.Text( + readonly=True, + ) + setup_request_response = fields.Text( + readonly=True, + ) + response = fields.Text( + readonly=True, + ) + response_date = fields.Datetime( + readonly=True, + ) + expiration_date = fields.Datetime( + compute="_compute_expiration_date", + store=True, + readonly=True, + compute_sudo=True, + ) + error_message = fields.Text( + readonly=True, + ) + state = fields.Selection( + selection="_selection_state", + default="draft", + tracking=True, + readonly=True, + ) + action_process_allowed = fields.Boolean(compute="_compute_action_process_allowed") + + @api.depends( + "state", + ) + def _compute_action_process_allowed(self): + for rec in self: + rec.action_process_allowed = rec.state in ("to_process", "error") + + @api.depends("backend_id.name", "create_date") + def _compute_name(self): + for rec in self: + backend_name = rec.backend_id.name or _("(no backend)") + if rec.create_date: + stamp = rec.create_date.strftime("%Y-%m-%d %H:%M") + rec.name = f"{backend_name} / {stamp}" + else: + rec.name = backend_name + + @api.depends( + "backend_id", + "create_date", + ) + def _compute_expiration_date(self): + for rec in self: + ref_date = rec.create_date or fields.Datetime.now() + rec.expiration_date = ref_date + relativedelta( + seconds=rec.backend_id.session_duration + ) + + @api.model + def _selection_state(self): + return [ + ("draft", _("Draft")), + ("error", _("Error")), + ("to_process", _("To Process")), + ("done", _("Done")), + ] + + @api.model + def _get_punchout_request_timestamp(self): + """ + Get ISO 8601 timestamp + """ + current_time = datetime.now() + timezone = pytz.timezone(self.env.user.tz or "UTC") + localized_time = current_time.astimezone(timezone) + return localized_time.strftime("%Y-%m-%dT%H:%M:%S%z") + + def _get_punchout_payload_identity(self): + ir_config_parameter_model = self.env["ir.config_parameter"].sudo() + base_url = ir_config_parameter_model.get_param("web.base.url") + parsed_url = urlparse(base_url) + domain = parsed_url.netloc + timestamp = int(time.time()) + pid = f"{os.getpid():03d}" + random_numbers_list = [random.randint(0, 9) for _ in range(5)] + random_numbers = "".join(map(str, random_numbers_list)) + return f"{timestamp}{pid}{random_numbers}@{domain}" + + def _get_punchout_buyer_cookie(self): + return f"{self.env.user.id}-{uuid4()}" + + def _get_punchout_request_user_email(self): + return self.env.user.email + + def _get_post_punchout_setup_url(self, session): + """Post setup request and get start URL. Override in protocol modules.""" + raise NotImplementedError( + _("Protocol %(protocol)s does not implement setup request.") + % {"protocol": session.backend_id.protocol} + ) + + @api.model + def _redirect_to_punchout(self): + session = self.sudo()._create_punchout_session() + if not session.punchout_url: + return False + return { + "type": "ir.actions.act_url", + "url": session.punchout_url, + "target": "new", + } + + @api.model + def _create_punchout_session(self): + punchout_backend = self._get_punchout_backend_to_use() + buyer_cookie_id = self._get_punchout_buyer_cookie() + session = self.env["punchout.session"].create( + { + "user_id": self.env.user.id, + "buyer_cookie_id": buyer_cookie_id, + "backend_id": punchout_backend.id, + } + ) + url = self._get_post_punchout_setup_url(session) + if url: + session.write({"punchout_url": url}) + return session + + @api.model + def _get_punchout_backend_to_use(self): + punchout_backend_model = self.env["punchout.backend"] + punchout_backend_id = self.env.context.get("punchout_backend_id") + if punchout_backend_id: + backend = punchout_backend_model.browse(punchout_backend_id) + else: + backend = punchout_backend_model.search([], limit=1) + if not backend: + raise UserError( + _("No punchout backend found to initialize the connection.") + ) + return backend + + @api.model + def _store_punchout_session_response(self, backend_id, response_data): + """Store response and find matching session. Override in protocol modules.""" + raise NotImplementedError(_("Protocol does not implement response storage.")) + + def _validate_response(self): + """Validate the response. Override in protocol modules.""" + return {"valid": True} + + def _check_action_process_allowed(self): + for rec in self: + if not rec.action_process_allowed: + raise UserError( + _("You are not allowed to process this request. " "%(name)s") + % {"name": rec.display_name} + ) + + def action_process(self): + self.ensure_one() + self._check_action_process_allowed() + self.sudo().write({"state": "done", "error_message": False}) + return True + + def _get_redirect_url(self): + """Where to send the user's browser after the supplier POSTs the + cart back. Default: the session form so the user sees the parsed + cart and can decide what to do next. Override in subclasses to + skip straight to a generated PO.""" + self.ensure_one() + if self.state in ("to_process", "error"): + return f"/web#id={self.id}&model=punchout.session&view_type=form" + return "/web" + + @api.model + def _gc_punchout_sessions(self): + """Delete sessions older than each backend's + ``session_retention_days``. Backends with retention=0 keep + their sessions forever (not recommended). + + Triggered by the scheduled action shipped in + ``data/ir_cron.xml``; no-ops if no backend has a positive + retention configured.""" + backends = self.env["punchout.backend"].search( + [("session_retention_days", ">", 0)] + ) + deleted_total = 0 + for backend in backends: + cutoff = fields.Datetime.now() - relativedelta( + days=backend.session_retention_days + ) + stale = self.search( + [ + ("backend_id", "=", backend.id), + ("create_date", "<", cutoff), + ] + ) + if stale: + count = len(stale) + stale.unlink() + deleted_total += count + _logger.info( + "[punchout.gc] backend=%s deleted=%d cutoff=%s", + backend.name, + count, + cutoff, + ) + return deleted_total diff --git a/punchout/models/punchout_uom_mapping.py b/punchout/models/punchout_uom_mapping.py new file mode 100644 index 0000000000..d3b0fd80f3 --- /dev/null +++ b/punchout/models/punchout_uom_mapping.py @@ -0,0 +1,169 @@ +# Copyright 2023 ACSONE SA/NV +# Copyright 2025 Bosd (migration to 18.0) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class PunchoutUomMapping(models.Model): + """Map supplier-specific UoM codes to Odoo UoMs. + + A mapping may be scoped to a backend, to a supplier, or global (both + scopes unset). Resolution priority (highest wins): + + 1. Backend-specific mapping (backend_id set) + 2. Supplier-specific mapping (supplier_id set, backend_id unset) + 3. Global mapping (both unset) + 4. UNECE code match on ``uom.uom.unece_code`` (via uom_unece) + 5. ``uom.uom.name`` match (case-insensitive) + 6. No match — caller picks the default + """ + + _name = "punchout.uom.mapping" + _description = "Punchout UoM Mapping" + + backend_id = fields.Many2one( + comodel_name="punchout.backend", + ondelete="cascade", + index=True, + help="Mapping only applies to this backend. Leave empty for a " + "supplier-wide or global mapping.", + ) + supplier_id = fields.Many2one( + comodel_name="res.partner", + string="Supplier", + ondelete="cascade", + index=True, + help="Mapping applies to any backend using this supplier. Ignored " + "when a backend is set. Leave empty for a global mapping.", + ) + supplier_code = fields.Char( + required=True, + help="The UoM code used by the supplier in punchout responses.", + ) + uom_id = fields.Many2one( + comodel_name="uom.uom", + string="UoM", + required=True, + ondelete="restrict", + help="The Odoo UoM to use for products with this supplier code.", + ) + notes = fields.Text( + help="Optional notes about this mapping.", + ) + + @api.constrains("supplier_code") + def _check_supplier_code(self): + for rec in self: + if not rec.supplier_code or not rec.supplier_code.strip(): + raise ValidationError( + _("Supplier code cannot be empty for mapping %(name)s") + % {"name": rec.display_name} + ) + + @api.constrains("backend_id", "supplier_id", "supplier_code") + def _check_unique_scope(self): + """Prevent duplicate mappings within the same scope. + + SQL-level UNIQUE treats NULLs as distinct, so we enforce uniqueness + in Python where any combination of backend/supplier scope (including + both unset) must be unique on ``supplier_code``. + """ + for rec in self: + if not rec.supplier_code: + continue + domain = [ + ("id", "!=", rec.id), + ("supplier_code", "=", rec.supplier_code), + ("backend_id", "=", rec.backend_id.id or False), + ("supplier_id", "=", rec.supplier_id.id or False), + ] + if self.search_count(domain): + raise ValidationError( + _( + "A mapping for supplier code %(code)s already exists " + "for this scope." + ) + % {"code": rec.supplier_code} + ) + + @api.depends("supplier_code", "uom_id", "backend_id", "supplier_id") + def _compute_display_name(self): + for rec in self: + scope = [] + if rec.backend_id: + scope.append(_("Backend: %s") % rec.backend_id.display_name) + elif rec.supplier_id: + scope.append(_("Supplier: %s") % rec.supplier_id.display_name) + else: + scope.append(_("global")) + code = rec.supplier_code or "" + uom = rec.uom_id.display_name or "" + rec.display_name = f"{code} → {uom} ({', '.join(scope)})" + + @api.model + def _get_uom_by_supplier_code(self, backend, supplier_code, supplier=None): + """Resolve a supplier UoM code to an ``uom.uom`` record. + + Follows the 6-tier resolution described in the class docstring. + Returns an empty recordset if no match is found; the caller decides + the default. + """ + UomUom = self.env["uom.uom"] + if not supplier_code: + return UomUom.browse() + + code = supplier_code.strip() + if supplier is None and backend: + supplier = backend.partner_id + + # 1. Backend-specific mapping + if backend: + mapping = self.search( + [ + ("backend_id", "=", backend.id), + ("supplier_code", "=", code), + ], + limit=1, + ) + if mapping: + return mapping.uom_id + + # 2. Supplier-specific mapping (not pinned to a backend) + if supplier: + mapping = self.search( + [ + ("backend_id", "=", False), + ("supplier_id", "=", supplier.id), + ("supplier_code", "=", code), + ], + limit=1, + ) + if mapping: + return mapping.uom_id + + # 3. Global mapping + mapping = self.search( + [ + ("backend_id", "=", False), + ("supplier_id", "=", False), + ("supplier_code", "=", code), + ], + limit=1, + ) + if mapping: + return mapping.uom_id + + # 4. UNECE code (uppercase match against uom_unece data) + uom = UomUom.search([("unece_code", "=", code.upper())], limit=1) + if uom: + return uom + + # 5. UoM name (case-insensitive) + uom = UomUom.search([("name", "=ilike", code)], limit=1) + if uom: + return uom + + # 6. No match — caller picks the default + return UomUom.browse() diff --git a/punchout/pyproject.toml b/punchout/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/punchout/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/punchout/readme/CONFIGURE.md b/punchout/readme/CONFIGURE.md new file mode 100644 index 0000000000..d57cc50c29 --- /dev/null +++ b/punchout/readme/CONFIGURE.md @@ -0,0 +1,22 @@ +To configure this module: + +1. Go to **Settings > Technical > PunchOut > PunchOut Backends** +2. Create a new backend record with: + - **Name**: A unique identifier for this supplier connection + - **Description**: Human-readable description + - **Protocol**: Select the punchout protocol (cXML, OCI, or IDS - requires + additional modules) + - **URL**: The supplier's punchout setup URL + - **Browser form post URL**: The callback URL where the supplier sends the + shopping cart (can be relative like `/punchout/cxml/receive/`) + - **Session duration**: Maximum time (in seconds) a punchout session is valid + +3. Configure protocol-specific credentials (requires Administrator access): + - cXML: From/To identities, SharedSecret + - OCI: Custom vendor parameters + - IDS: Customer name, number, and password + +4. Optionally add UoM mappings to translate supplier unit codes to Odoo UoMs + +**Note**: Backend configuration and credential fields are restricted to users with +the `Administration/Settings` permission group for security reasons. diff --git a/punchout/readme/CONTEXT.md b/punchout/readme/CONTEXT.md new file mode 100644 index 0000000000..c296240ddb --- /dev/null +++ b/punchout/readme/CONTEXT.md @@ -0,0 +1,14 @@ +[ This file is optional but strongly suggested to allow end-users to evaluate the +module's usefulness in their context. ] + +This module set up the base of the connection to a punchout system. Sub module can be +created for example to create a purchase order from a shopping cart created in the +punchout platform. To do this, you can make your model inherit from "punchout.request" +and add the import process by inheriting "action_process" method. + +Linked modules: + +- punchout_environment : allows to configure a punchout backend through server + environment files +- punchout_queue_job : link the punchout request to queue jobs, automatically call the + process method when a response is received diff --git a/punchout/readme/CONTRIBUTORS.md b/punchout/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..a38863df35 --- /dev/null +++ b/punchout/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Thomas Binsfeld (ACSONE SA/NV) +- Benjamin Willig (ACSONE SA/NV) diff --git a/punchout/readme/CREDITS.md b/punchout/readme/CREDITS.md new file mode 100644 index 0000000000..c960753568 --- /dev/null +++ b/punchout/readme/CREDITS.md @@ -0,0 +1,6 @@ +[ This file is optional and contains additional credits, other than authors, +contributors, and maintainers. ] + +The development of this module has been financially supported by: + +- ACSONE SA/NV diff --git a/punchout/readme/DESCRIPTION.md b/punchout/readme/DESCRIPTION.md new file mode 100644 index 0000000000..92d76f4e72 --- /dev/null +++ b/punchout/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module setup the base configuration to access a punchout platform, in order to +populate odoo. For example, creating purchase orders. This alpha version has been +implemented using the eShop Wurth. diff --git a/punchout/readme/HISTORY.md b/punchout/readme/HISTORY.md new file mode 100644 index 0000000000..157a3ba097 --- /dev/null +++ b/punchout/readme/HISTORY.md @@ -0,0 +1,32 @@ +## 18.0.1.0.0 (2026) + +- [MIG] Migration to Odoo 18.0. +- [IMP] `punchout.uom.mapping` now resolves supplier UoM codes through a + 6-tier chain: backend → supplier → global → UNECE → uom name → caller + default. +- [IMP] Ship `data/uom_mapping_data.xml` with common non-UNECE codes as + global defaults (STUECK, ST, STK, PC, PCS, EACH, KG, M, L). +- [IMP] Optional `supplier_id` scope on `punchout.uom.mapping`; both + scopes (backend, supplier) are now optional. +- [FIX] `_get_browser_form_post_url` now produces RFC-clean URLs + (no double slashes, no trailing slash before the query string). +- [IMP] Stored `name` field on `punchout.session` so Many2one displays + show "Backend / 2026-04-26 14:02" instead of "punchout.session,42". +- [IMP] `punchout.backend` inherits `mail.thread` and tracks changes + to state, protocol, URL, callback URL and session duration. +- [IMP] Smart button on the backend form opens the filtered list of + sessions for that backend. +- [FIX] Session form's "Received" pane is hidden when + `setup_request_response` is empty — only cXML actually fills it, + so the pane was permanently blank for OCI/IDS sessions. +- [IMP] `session_retention_days` field on backend (default 90) + + daily cron `_gc_punchout_sessions` that vacuums old sessions. + Previous behaviour: the table grew without bound. +- [IMP] `max_response_size` field on backend (default 1 MiB) + + `_check_response_size` helper used by the protocol controllers + to reject oversized supplier payloads. +- [ADD] Dutch translation. + +## 13.0.1.0.0 (2023-09-26) + +- [ADD] First version. diff --git a/punchout/readme/INSTALL.md b/punchout/readme/INSTALL.md new file mode 100644 index 0000000000..76fe816a45 --- /dev/null +++ b/punchout/readme/INSTALL.md @@ -0,0 +1,2 @@ +[ This file must only be present if there are very specific installation instructions, +such as installing non-python dependencies. The audience is systems administrators. ] diff --git a/punchout/readme/ROADMAP.md b/punchout/readme/ROADMAP.md new file mode 100644 index 0000000000..67208a8eb8 --- /dev/null +++ b/punchout/readme/ROADMAP.md @@ -0,0 +1,5 @@ +[ Enumerate known caveats and future potential improvements. It is mostly intended for +end-users, and can also help potential new contributors discovering new features to +implement. ] + +- diff --git a/punchout/readme/SECURITY.md b/punchout/readme/SECURITY.md new file mode 100644 index 0000000000..f4ec51544c --- /dev/null +++ b/punchout/readme/SECURITY.md @@ -0,0 +1,27 @@ +**Important Security Considerations** + +Due to the nature of punchout protocols (cXML, OCI, IDS), authentication credentials +must be transmitted through the user's browser when redirecting to supplier websites. +This means: + +- **cXML**: The SharedSecret and identity credentials are sent in the POST body to + the supplier's punchout URL. Users with browser developer tools can view this data + in the Network tab. + +- **OCI**: Vendor-specific authentication parameters may be included in URL query + strings, making them visible in the browser address bar. + +- **IDS**: Customer credentials (name, number, password) are sent via POST form data + to the supplier, visible in browser developer tools. + +**This is an inherent limitation of punchout protocols, not a bug.** + +**Recommendations:** + +1. Only enable punchout access for trusted users who need procurement functionality +2. Use the Odoo permission system to restrict access: + - Backend configuration requires `Administration/Settings` group + - Session data is only visible to administrators +3. Use HTTPS for all punchout communications +4. Regularly rotate credentials with suppliers +5. Consider using IP allowlisting on the supplier side if available diff --git a/punchout/readme/USAGE.md b/punchout/readme/USAGE.md new file mode 100644 index 0000000000..9c27556dee --- /dev/null +++ b/punchout/readme/USAGE.md @@ -0,0 +1,26 @@ +To use this module: + +1. Configure a punchout backend with your supplier's credentials +2. Set the backend state to "Open" +3. Click the **Access** button to start a punchout session +4. Browse the supplier's catalog and add items to your cart +5. Complete the checkout on the supplier's site - this sends the cart back to Odoo +6. The punchout session will show status "To Process" when the cart is received +7. Process the session to create purchase orders (requires `punchout_purchase` module) + +**Session States:** + +- **Draft**: Session created, waiting for supplier response +- **To Process**: Cart received from supplier, ready to create purchase order +- **Done**: Session processed successfully +- **Error**: Something went wrong (check error message) + +**Auditing a backend:** the backend form carries a chatter and tracks +changes to `state`, `protocol`, `url`, `browser_form_post_url` and +`session_duration`. A "Sessions" smart button on the backend opens +the filtered list of every session that used it. + +**See also:** when `punchout_purchase` is installed, additional entry +points appear (Browse Supplier Catalog from a draft PO and from a +vendor record, per-product "Open at supplier" deep-links). Refer to +that module's README for the purchase-side flows. diff --git a/punchout/security/punchout_backend.xml b/punchout/security/punchout_backend.xml new file mode 100644 index 0000000000..53a320a1c1 --- /dev/null +++ b/punchout/security/punchout_backend.xml @@ -0,0 +1,23 @@ + + + + + punchout.backend access user + + + + + + + + + punchout.backend access manager + + + + + + + + diff --git a/punchout/security/punchout_session.xml b/punchout/security/punchout_session.xml new file mode 100644 index 0000000000..6d2be4e984 --- /dev/null +++ b/punchout/security/punchout_session.xml @@ -0,0 +1,14 @@ + + + + + punchout.session access manager + + + + + + + + diff --git a/punchout/security/punchout_uom_mapping.xml b/punchout/security/punchout_uom_mapping.xml new file mode 100644 index 0000000000..b10e2d8114 --- /dev/null +++ b/punchout/security/punchout_uom_mapping.xml @@ -0,0 +1,24 @@ + + + + + punchout.uom.mapping access user + + + + + + + + + punchout.uom.mapping access manager + + + + + + + + diff --git a/punchout/static/description/icon.png b/punchout/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11 GIT binary patch literal 10254 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 + + + + +Punchout + + + +
+

Punchout

+ + +

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

+

This module setup the base configuration to access a punchout platform, +in order to populate odoo. For example, creating purchase orders. This +alpha version has been implemented using the eShop Wurth.

+

Table of contents

+ +
+

Use Cases / Context

+

[ This file is optional but strongly suggested to allow end-users to +evaluate the module’s usefulness in their context. ]

+

This module set up the base of the connection to a punchout system. Sub +module can be created for example to create a purchase order from a +shopping cart created in the punchout platform. To do this, you can make +your model inherit from “punchout.request” and add the import process by +inheriting “action_process” method.

+

Linked modules:

+
    +
  • punchout_environment : allows to configure a punchout backend through +server environment files
  • +
  • punchout_queue_job : link the punchout request to queue jobs, +automatically call the process method when a response is received
  • +
+
+
+

Installation

+

[ This file must only be present if there are very specific installation +instructions, such as installing non-python dependencies. The audience +is systems administrators. ]

+
+
+

Configuration

+

To configure this module:

+
    +
  1. Go to Settings > Technical > PunchOut > PunchOut Backends
  2. +
  3. Create a new backend record with:
      +
    • Name: A unique identifier for this supplier connection
    • +
    • Description: Human-readable description
    • +
    • Protocol: Select the punchout protocol (cXML, OCI, or IDS - +requires additional modules)
    • +
    • URL: The supplier’s punchout setup URL
    • +
    • Browser form post URL: The callback URL where the supplier +sends the shopping cart (can be relative like +/punchout/cxml/receive/)
    • +
    • Session duration: Maximum time (in seconds) a punchout session +is valid
    • +
    +
  4. +
  5. Configure protocol-specific credentials (requires Administrator +access):
      +
    • cXML: From/To identities, SharedSecret
    • +
    • OCI: Custom vendor parameters
    • +
    • IDS: Customer name, number, and password
    • +
    +
  6. +
  7. Optionally add UoM mappings to translate supplier unit codes to Odoo +UoMs
  8. +
+

Note: Backend configuration and credential fields are restricted to +users with the Administration/Settings permission group for security +reasons.

+
+
+

Usage

+

To use this module:

+
    +
  1. Configure a punchout backend with your supplier’s credentials
  2. +
  3. Set the backend state to “Open”
  4. +
  5. Click the Access button to start a punchout session
  6. +
  7. Browse the supplier’s catalog and add items to your cart
  8. +
  9. Complete the checkout on the supplier’s site - this sends the cart +back to Odoo
  10. +
  11. The punchout session will show status “To Process” when the cart is +received
  12. +
  13. Process the session to create purchase orders (requires +punchout_purchase module)
  14. +
+

Session States:

+
    +
  • Draft: Session created, waiting for supplier response
  • +
  • To Process: Cart received from supplier, ready to create purchase +order
  • +
  • Done: Session processed successfully
  • +
  • Error: Something went wrong (check error message)
  • +
+

Auditing a backend: the backend form carries a chatter and tracks +changes to state, protocol, url, browser_form_post_url +and session_duration. A “Sessions” smart button on the backend opens +the filtered list of every session that used it.

+

See also: when punchout_purchase is installed, additional entry +points appear (Browse Supplier Catalog from a draft PO and from a vendor +record, per-product “Open at supplier” deep-links). Refer to that +module’s README for the purchase-side flows.

+
+
+

Known issues / Roadmap

+

[ Enumerate known caveats and future potential improvements. It is +mostly intended for end-users, and can also help potential new +contributors discovering new features to implement. ]

+
    +
  • +
+
+
+

Changelog

+
+

18.0.1.0.0 (2026)

+
    +
  • [MIG] Migration to Odoo 18.0.
  • +
  • [IMP] punchout.uom.mapping now resolves supplier UoM codes through +a 6-tier chain: backend → supplier → global → UNECE → uom name → +caller default.
  • +
  • [IMP] Ship data/uom_mapping_data.xml with common non-UNECE codes +as global defaults (STUECK, ST, STK, PC, PCS, EACH, KG, M, L).
  • +
  • [IMP] Optional supplier_id scope on punchout.uom.mapping; both +scopes (backend, supplier) are now optional.
  • +
  • [FIX] _get_browser_form_post_url now produces RFC-clean URLs (no +double slashes, no trailing slash before the query string).
  • +
  • [IMP] Stored name field on punchout.session so Many2one +displays show “Backend / 2026-04-26 14:02” instead of +“punchout.session,42”.
  • +
  • [IMP] punchout.backend inherits mail.thread and tracks changes +to state, protocol, URL, callback URL and session duration.
  • +
  • [IMP] Smart button on the backend form opens the filtered list of +sessions for that backend.
  • +
  • [FIX] Session form’s “Received” pane is hidden when +setup_request_response is empty — only cXML actually fills it, so +the pane was permanently blank for OCI/IDS sessions.
  • +
  • [IMP] session_retention_days field on backend (default 90) + daily +cron _gc_punchout_sessions that vacuums old sessions. Previous +behaviour: the table grew without bound.
  • +
  • [IMP] max_response_size field on backend (default 1 MiB) + +_check_response_size helper used by the protocol controllers to +reject oversized supplier payloads.
  • +
  • [ADD] Dutch translation.
  • +
+
+
+

13.0.1.0.0 (2023-09-26)

+
    +
  • [ADD] First version.
  • +
+
+
+
+

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

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

[ This file is optional and contains additional credits, other than +authors, contributors, and maintainers. ]

+

The development of this module has been financially supported by:

+
    +
  • ACSONE SA/NV
  • +
+
+
+

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

+

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

+
+
+
+ + diff --git a/punchout/tests/__init__.py b/punchout/tests/__init__.py new file mode 100644 index 0000000000..0d0945a096 --- /dev/null +++ b/punchout/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_punchout +from . import test_punchout_uom_mapping diff --git a/punchout/tests/common.py b/punchout/tests/common.py new file mode 100644 index 0000000000..2f5f9c1512 --- /dev/null +++ b/punchout/tests/common.py @@ -0,0 +1,31 @@ +# Copyright 2023 ACSONE SA/NV +# Copyright 2025 Bosd +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from uuid import uuid4 + +from odoo.tests.common import TransactionCase + + +class TestPunchoutCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.backend_model = cls.env["punchout.backend"] + cls.session_model = cls.env["punchout.session"] + cls.backend = cls.backend_model.create( + { + "name": str(uuid4()), + "description": str(uuid4()), + "protocol": "cxml", + "url": "https://example.com/punchout", + "browser_form_post_url": "/punchout/receive/", + } + ) + + cls.session = cls.session_model.create( + { + "backend_id": cls.backend.id, + "buyer_cookie_id": "2-cc162436-fcab-4cfb-888d-abfd8708520d", + } + ) diff --git a/punchout/tests/test_punchout.py b/punchout/tests/test_punchout.py new file mode 100644 index 0000000000..dc64532c16 --- /dev/null +++ b/punchout/tests/test_punchout.py @@ -0,0 +1,82 @@ +# Copyright 2023 ACSONE SA/NV +# Copyright 2025 Bosd +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import fields +from odoo.exceptions import UserError, ValidationError + +from .common import TestPunchoutCommon + + +class TestPunchout(TestPunchoutCommon): + def test_backend_creation(self): + """Test that backend is created with correct values.""" + self.assertTrue(self.backend.id) + self.assertEqual(self.backend.protocol, "cxml") + self.assertEqual(self.backend.state, "draft") + + def test_session_creation(self): + """Test that session is created with correct values.""" + self.assertTrue(self.session.id) + self.assertEqual(self.session.backend_id, self.backend) + self.assertEqual(self.session.state, "draft") + + def test_session_duration_validation(self): + """Test that session duration must be positive.""" + with self.assertRaises(ValidationError): + self.backend.write({"session_duration": 0}) + + def test_expiration_date_computed(self): + """Test that expiration date is computed based on session duration.""" + self.assertTrue(self.session.expiration_date) + self.assertTrue(self.session.expiration_date > self.session.create_date) + + def test_check_response_size_under_limit(self): + """Payload under cap returns silently.""" + self.backend.max_response_size = 1024 + self.backend._check_response_size("x" * 100) + + def test_check_response_size_over_limit(self): + """Oversized payload raises UserError.""" + self.backend.max_response_size = 100 + with self.assertRaises(UserError): + self.backend._check_response_size("x" * 101) + + def test_check_response_size_disabled(self): + """``max_response_size = 0`` disables the check (any size passes).""" + self.backend.max_response_size = 0 + self.backend._check_response_size("x" * 10_000_000) + + def test_gc_deletes_sessions_older_than_retention(self): + """Sessions older than backend.session_retention_days get unlinked.""" + self.backend.session_retention_days = 30 + old_session = self.session_model.create({"backend_id": self.backend.id}) + # Age the row by writing create_date directly (skipping ORM defaults). + self.env.cr.execute( + "UPDATE punchout_session SET create_date = %s WHERE id = %s", + (fields.Datetime.now() - timedelta(days=60), old_session.id), + ) + old_session.invalidate_recordset() + deleted = self.session_model._gc_punchout_sessions() + self.assertGreaterEqual(deleted, 1) + self.assertFalse(old_session.exists()) + + def test_gc_keeps_recent_sessions(self): + """Sessions inside the retention window are not touched.""" + self.backend.session_retention_days = 30 + recent = self.session_model.create({"backend_id": self.backend.id}) + self.session_model._gc_punchout_sessions() + self.assertTrue(recent.exists()) + + def test_gc_skips_zero_retention(self): + """``session_retention_days = 0`` opts out of GC entirely.""" + self.backend.session_retention_days = 0 + old_session = self.session_model.create({"backend_id": self.backend.id}) + self.env.cr.execute( + "UPDATE punchout_session SET create_date = %s WHERE id = %s", + (fields.Datetime.now() - timedelta(days=10000), old_session.id), + ) + self.session_model._gc_punchout_sessions() + self.assertTrue(old_session.exists()) diff --git a/punchout/tests/test_punchout_uom_mapping.py b/punchout/tests/test_punchout_uom_mapping.py new file mode 100644 index 0000000000..871a070e17 --- /dev/null +++ b/punchout/tests/test_punchout_uom_mapping.py @@ -0,0 +1,172 @@ +# Copyright 2025 Bosd +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import ValidationError +from odoo.tests import tagged + +from .common import TestPunchoutCommon + + +@tagged("post_install", "-at_install") +class TestPunchoutUomMappingResolution(TestPunchoutCommon): + """Cover the 6-tier resolution in ``_get_uom_by_supplier_code``.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Mapping = cls.env["punchout.uom.mapping"] + cls.unit = cls.env.ref("uom.product_uom_unit") + cls.dozen = cls.env.ref("uom.product_uom_dozen") + cls.kg = cls.env.ref("uom.product_uom_kgm") + cls.litre = cls.env.ref("uom.product_uom_litre") + cls.supplier = cls.env["res.partner"].create({"name": "Test Supplier"}) + cls.other_supplier = cls.env["res.partner"].create({"name": "Other Supplier"}) + cls.backend.partner_id = cls.supplier + + # --- Resolution priority --------------------------------------------- + + def test_backend_mapping_wins_over_supplier_global_and_unece(self): + self.Mapping.create({"supplier_code": "XYZ", "uom_id": self.unit.id}) # global + self.Mapping.create( + { + "supplier_code": "XYZ", + "supplier_id": self.supplier.id, + "uom_id": self.litre.id, + } + ) + self.Mapping.create( + { + "supplier_code": "XYZ", + "backend_id": self.backend.id, + "uom_id": self.dozen.id, + } + ) + uom = self.Mapping._get_uom_by_supplier_code(self.backend, "XYZ") + self.assertEqual(uom, self.dozen) + + def test_supplier_mapping_wins_over_global_and_unece(self): + self.Mapping.create({"supplier_code": "YYY", "uom_id": self.unit.id}) # global + self.Mapping.create( + { + "supplier_code": "YYY", + "supplier_id": self.supplier.id, + "uom_id": self.litre.id, + } + ) + uom = self.Mapping._get_uom_by_supplier_code(self.backend, "YYY") + self.assertEqual(uom, self.litre) + + def test_global_mapping_wins_over_unece_and_name(self): + # STUECK is not a UNECE code, so only global/name would match. + # Global mapping takes priority over name-based fallback. + global_mapping = self.Mapping.create( + {"supplier_code": "STUECK-TEST", "uom_id": self.dozen.id} + ) + uom = self.Mapping._get_uom_by_supplier_code(self.backend, "STUECK-TEST") + self.assertEqual(uom, global_mapping.uom_id) + + def test_unece_code_resolves(self): + # KGM is UNECE for kilogram; no mapping defined. + uom = self.Mapping._get_uom_by_supplier_code(self.backend, "KGM") + self.assertEqual(uom, self.kg) + + def test_unece_code_is_case_insensitive(self): + uom = self.Mapping._get_uom_by_supplier_code(self.backend, "kgm") + self.assertEqual(uom, self.kg) + + def test_name_match_fallback(self): + # Odoo ships a uom.uom named exactly "Units" — match by name. + uom = self.Mapping._get_uom_by_supplier_code(self.backend, "Units") + self.assertEqual(uom, self.unit) + + def test_no_match_returns_empty_recordset(self): + uom = self.Mapping._get_uom_by_supplier_code( + self.backend, "ZZZ_NOT_A_REAL_CODE" + ) + self.assertFalse(uom) + + def test_empty_code_returns_empty_recordset(self): + self.assertFalse(self.Mapping._get_uom_by_supplier_code(self.backend, "")) + self.assertFalse(self.Mapping._get_uom_by_supplier_code(self.backend, None)) + + def test_supplier_scope_ignored_for_other_supplier(self): + # Mapping belongs to other_supplier — our backend's partner shouldn't see it. + self.Mapping.create( + { + "supplier_code": "AAA", + "supplier_id": self.other_supplier.id, + "uom_id": self.litre.id, + } + ) + uom = self.Mapping._get_uom_by_supplier_code(self.backend, "AAA") + self.assertFalse(uom) + + def test_explicit_supplier_param_overrides_backend_partner(self): + self.Mapping.create( + { + "supplier_code": "BBB", + "supplier_id": self.other_supplier.id, + "uom_id": self.dozen.id, + } + ) + uom = self.Mapping._get_uom_by_supplier_code( + self.backend, "BBB", supplier=self.other_supplier + ) + self.assertEqual(uom, self.dozen) + + # --- Default data shipped in data/uom_mapping_data.xml ---------------- + + def test_global_default_stueck_resolves(self): + uom = self.Mapping._get_uom_by_supplier_code(self.backend, "STUECK") + self.assertEqual(uom, self.unit) + + def test_global_default_kg_resolves(self): + uom = self.Mapping._get_uom_by_supplier_code(self.backend, "KG") + self.assertEqual(uom, self.kg) + + # --- Scope constraints ----------------------------------------------- + + def test_duplicate_global_mapping_rejected(self): + self.Mapping.create({"supplier_code": "DUP", "uom_id": self.unit.id}) + with self.assertRaises(ValidationError): + self.Mapping.create({"supplier_code": "DUP", "uom_id": self.dozen.id}) + + def test_duplicate_backend_mapping_rejected(self): + self.Mapping.create( + { + "supplier_code": "DUP2", + "backend_id": self.backend.id, + "uom_id": self.unit.id, + } + ) + with self.assertRaises(ValidationError): + self.Mapping.create( + { + "supplier_code": "DUP2", + "backend_id": self.backend.id, + "uom_id": self.dozen.id, + } + ) + + def test_same_code_different_scopes_allowed(self): + """A global, a supplier-scoped, and a backend-scoped mapping for the + same code can coexist — they live in separate scopes.""" + self.Mapping.create({"supplier_code": "OK", "uom_id": self.unit.id}) + self.Mapping.create( + { + "supplier_code": "OK", + "supplier_id": self.supplier.id, + "uom_id": self.litre.id, + } + ) + self.Mapping.create( + { + "supplier_code": "OK", + "backend_id": self.backend.id, + "uom_id": self.dozen.id, + } + ) + + def test_empty_supplier_code_rejected(self): + with self.assertRaises(ValidationError): + self.Mapping.create({"supplier_code": " ", "uom_id": self.unit.id}) diff --git a/punchout/views/punchout_backend.xml b/punchout/views/punchout_backend.xml new file mode 100644 index 0000000000..b4d5c7b3f3 --- /dev/null +++ b/punchout/views/punchout_backend.xml @@ -0,0 +1,173 @@ + + + + + + punchout.backend + +
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + punchout.backend + + + + + + + + + + + + + punchout.backend + + + + + + + + + + + + + + punchout.backend + + + + + + + +
+

+ +

+
+ + + +
+
+
+
+