[18.0][MIG] punchout: cXML / OCI / IDS punchout for Odoo 18#1333
Draft
bosd wants to merge 15 commits into
Draft
Conversation
…ction hardening 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 <thomas.binsfeld@acsone.eu> Co-Authored-By: Benjamin Willig <benjamin.willig@acsone.eu> Co-Authored-By: Holger Brunn <hbrunn@hunki-enterprises.com>
Carries the cXML protocol module forward from 13.0 (where its logic lived inside ``punchout``). After the protocol-modular refactor in the base module, this module owns: * QWeb template for ``PunchOutSetupRequest`` rendering with proper cXML DTD declaration and ``cXML/`` payload structure. * HTTP setup-request POST + response parsing (``_get_post_punchout_setup_url``, ``_check_punchout_request_ok``). * Cart-callback parsing — extracts ``BuyerCookie`` from the returning ``PunchOutOrderMessage`` and matches it to the originating session. * Optional DTD validation against an admin-uploaded DTD file. * ``punchout.backend`` extended with cXML credentials (From/To/Sender identities, shared secret, user agent, deployment mode). * Receive controller at ``/punchout/cxml/receive/<backend_id>``, marked ``readonly=False`` so Odoo 18's speculative read-only cursor doesn't trigger a warning-and-retry on every callback. * ``SELECT ... FOR UPDATE`` lock on the matched session so a concurrent callback (replay, retry) can't double-process. * Cart-payload size cap via ``backend._check_response_size``. * ``[punchout.cxml.*]`` log prefix for ops triage. * **Test Connection** button on the backend form — sends a real setup request and verifies a valid response. Catches wrong URL / wrong credentials / expired DTD link before the user clicks Access and sees a confusing redirect failure mid-flow. * HTTP-level controller tests covering happy path, unknown buyer cookie, and oversized payload. Co-Authored-By: Thomas Binsfeld <thomas.binsfeld@acsone.eu> Co-Authored-By: Benjamin Willig <benjamin.willig@acsone.eu>
OCI 3.0 / 4.0 (SELECT) catalog-browse protocol support. Inherits the protocol-modular base from ``punchout``: * Builds an OCI catalog URL with the supplier's URL + custom auth parameters + ``HOOK_URL`` for the cart-return endpoint. * Receive controller at ``/punchout/oci/receive/<backend_id>``, marked ``readonly=False``. Parses the supplier's form-encoded ``NEW_ITEM-*`` payload back into a session. * HOOK_URL carries a ``punchout_session_token`` query param (the session's buyer cookie) so the callback can pin the returning cart unambiguously to the originating session. Without this OCI has no per-session correlator and concurrent sessions on the same backend get mis-routed. Legacy "most recent draft session" lookup kept as fallback for backends configured before the token mechanism existed. * ``SELECT ... FOR UPDATE`` lock on the matched session so a concurrent callback can't double-process. * Cart-payload size cap via ``backend._check_response_size``. * ``oci_version`` selection field on backend (3.0, 4.0, 5.0). 4.0+ extended modes (DETAIL, SOURCING, etc.) are not yet implemented — see ROADMAP. * ``[punchout.oci.*]`` log prefix for ops triage. * HTTP-level controller tests covering happy path with token, unknown token, and oversized payload. Co-Authored-By: Holger Brunn <hbrunn@hunki-enterprises.com>
IDS (German industry standard) catalog-browse protocol. Inherits the protocol-modular base from ``punchout``: * Builds an IDS catalog URL with name_kunde / kndnr / pw_kunde authentication parameters + hook_url for the cart-return endpoint. * Receive controller at ``/punchout/ids/receive/<backend_id>``, marked ``readonly=False``. Parses the supplier's ``warenkorb`` XML payload back into a session. * hook_url carries a ``punchout_session_token`` query param so the callback can pin the returning cart unambiguously to the originating session. Same rationale as the OCI sibling — IDS has no built-in per-session correlator. Legacy "most recent draft session" lookup kept as fallback. * ``SELECT ... FOR UPDATE`` lock on the matched session. * Cart-payload size cap via ``backend._check_response_size``. * ``[punchout.ids.*]`` log prefix for ops triage. * HTTP-level controller tests covering happy path with token, unknown token, and oversized payload. Co-Authored-By: Holger Brunn <hbrunn@hunki-enterprises.com>
The protocol-agnostic glue that turns a returning punchout cart into an Odoo purchase order, plus the user-facing entry points that surface punchout where purchasers actually work: * ``punchout.session.purchase_order_id`` link + auto-process: when the cart-receive callback flips the session to ``to_process`` and the backend has a ``partner_id``, the PO is created (or the cart's lines appended to a pre-linked PO) automatically. The session redirect lands the user on the PO. * Browse Supplier Catalog button on draft purchase orders (vendor must have at least one Open punchout backend) — pre-links the session to the current PO so returning cart lines are appended rather than creating a new PO. * Browse Supplier Catalog button on the vendor (res.partner) form — always creates a new PO from the returning cart. * Open at supplier deep-link: ``product_url_template`` field on res.partner (e.g. ``https://supplier.example.com/parts/{vendor_code}``); the product template form and per-vendor row in the product Vendors list each get an "Open at supplier" link that substitutes the seller's product_code into the template. * PO-line ``punchout_session_id`` tag column (admin-only) so purchasers can tell at a glance which lines came from which punchout cart vs were added manually. * PO smart button counts every distinct session that contributed lines and opens a list view when more than one is involved. Admin-only (matches ``punchout.session`` security). * Cart-vs-product chatter warnings on the PO: per-line UoM mismatches (cart UoM != product primary UoM, where Odoo silently coerces) and PO-level currency mismatches (cart prices in a different currency than the PO's pricelist resolved to; Odoo stores raw cart numbers as ``price_unit`` without conversion). * Auto-process failures post a chatter message on the session and on the pre-linked PO so the purchaser is notified next to the affected record instead of having to read server logs. * PO and chatter messages produced by the supplier-callback auto-process flow are attributed to the session's ``user_id`` (the purchaser who initiated the punchout) instead of the sudo'd public user the callback runs as. * Backend form surfaces ``partner_id``, ``company_id``, ``product_category_id`` and ``auto_create_products`` (the model carried these but the form never showed them). * USAGE.md documenting all entry points and the tax-handling convention (cart prices land tax-excluded by default). Co-Authored-By: Holger Brunn <hbrunn@hunki-enterprises.com>
Glue between ``punchout_cxml`` and ``punchout_purchase`` — implements the protocol-specific cart parsing for cXML's ``PunchOutOrderMessage`` payload: * Parses ``ItemIn`` elements into purchase order line vals. * Auto-creates products when ``backend.auto_create_products`` is set, populating supplier code, name, price, currency and lead time from cart fields. ``currency_id`` resolved from cXML's ``UnitPrice/Money[@Currency]`` ISO code, falling back to the company currency (Odoo 18 made the field NOT NULL). * UoM lookup routed through ``punchout.uom.mapping._get_uom_by_supplier_code`` for full 6-tier resolution. Auto-created products carry the supplier- provided UoM as their primary UoM so the resulting order line doesn't trip same-category constraints. * Warns (logger) when a supplier-part code matches multiple products for the same partner — picks the first deterministically rather than silently. Surfaces stale supplierinfo data without breaking the punchout flow. Co-Authored-By: Thomas Binsfeld <thomas.binsfeld@acsone.eu>
Glue between ``punchout_oci`` and ``punchout_purchase`` — implements the protocol-specific cart parsing for OCI's form-encoded ``NEW_ITEM-*`` payload: * Parses ``NEW_ITEM-*[index]`` form fields into purchase order line vals. Handles ``LONGTEXT_*:132[]`` long-description fields. * Auto-creates products when ``backend.auto_create_products`` is set, populating supplier code (``VENDORMAT``), name, price, currency (``CURRENCY``) and lead time (``LEADTIME``). ``currency_id`` falls back to the company currency when the cart's code is unknown / absent (Odoo 18 made the field NOT NULL). * UoM lookup routed through ``punchout.uom.mapping._get_uom_by_supplier_code`` for full 6-tier resolution. * Warns (logger) when a vendor code matches multiple products for the same partner — picks the first deterministically rather than silently. Co-Authored-By: Holger Brunn <hbrunn@hunki-enterprises.com>
Glue between ``punchout_ids`` and ``punchout_purchase`` — implements the protocol-specific cart parsing for IDS' ``warenkorb`` XML payload: * Parses ``OrderItem`` elements (``ArtNo``, ``Kurztext``, ``Qty``, ``QU``, ``NetPrice``, ``VAT``, optional ``EAN``, optional ``Langtext``) into purchase order line vals. * Product matching: by ``(seller.partner_id, ArtNo)`` and/or by ``barcode = EAN``. The previous ``OR barcode = False`` fallback matched almost any product in the database and grafted unrelated sellers onto them — fixed. * Auto-creates products when ``backend.auto_create_products`` is set. Tax matching: looks up an existing purchase tax with the cart's ``VAT`` percentage on the same company. * Delivery-date extraction: ``OrderInfo/DeliveryDate`` first, then ``DeliveryWeek``/``DeliveryYear`` (Friday of that week), else today. * UoM lookup routed through ``punchout.uom.mapping._get_uom_by_supplier_code`` for full 6-tier resolution. Odoo 19 dropped ``uom.uom.category_id`` and the reference-UoM concept; the supplier UoM is used directly as the product's primary UoM. * Warns (logger) when an ArtNo matches multiple products for the same partner. Co-Authored-By: Holger Brunn <hbrunn@hunki-enterprises.com>
…emos Self-contained mock OCI supplier so the punchout flow can be demonstrated end-to-end on a single Odoo instance — no external supplier needed for runboat / demo / smoke testing. * Public ``/oci/mock/catalog`` and ``/oci/mock/cart`` endpoints rendering a catalog browse page and a cart-submit page with the HOOK_URL the buyer supplied. * Demo backend pre-configured to point at these endpoints, with ``partner_id`` already set so the cart-to-PO auto-process flow works out of the box on a fresh install. * Ships the OCA icon.
added 4 commits
April 27, 2026 20:16
Empty hook fired once per newly-created product from an OCI cart, so private / glue modules can enrich the product (image, dimensions, HS code, brand) from the supplier's REST API without monkey-patching ``_get_or_create_product_oci``. * Hook fires only on auto-create — re-using an existing product (matched via supplierinfo) does NOT call the hook. Keeps quota burn down for known products on overrides that hit the supplier's REST API. * ``raw_data`` is the OCI ``NEW_ITEM-*`` cart-line dict so overrides can pull protocol-specific fields (VENDORMAT, LONGTEXT, CURRENCY, …) without re-parsing the whole cart. * Tests cover both call-once-on-create and skipped-on-match paths.
Empty hook fired once per newly-created product from a cXML cart, so private / glue modules can enrich the product (image, dimensions, HS code, brand) from the supplier's REST API without monkey-patching ``_get_or_create_product_cxml``. * Hook fires only on auto-create — re-using an existing product (matched via supplierinfo) does NOT call the hook. * ``raw_data`` is a dict with ``supplier_part_id``, ``description``, ``unit_price``, and the raw ``item_detail`` lxml element so overrides can pull cXML-specific fields without re-parsing. * Tests cover both call-once-on-create and skipped-on-match paths.
Empty hook fired once per newly-created product from an IDS cart, so private / glue modules can enrich the product (image, dimensions, HS code, brand) from the supplier's REST API without monkey-patching ``_get_or_create_product_ids``. * Hook fires only on auto-create — re-using an existing product (matched via supplierinfo) does NOT call the hook. * ``raw_data`` is the parsed IDS ``OrderItem`` lxml objectify element so overrides can pull IDS-specific fields (ArtNo, EAN, Langtext) without re-parsing. * Tests cover both call-once-on-create and skipped-on-match paths.
…ocess author resolution
Two related fixes from real-world deployment testing:
1. **Hide "Browse Supplier Catalog" buttons when the vendor has no
open punchout backend.**
New computed field ``has_punchout_backend`` on ``purchase.order``
(driven by ``partner_id``) and on ``res.partner`` (driven by
``supplier_rank``). Used by the ``invisible`` expression on the
PO header button, the PO line area "Browse supplier catalog"
link, and the vendor form smart button.
Previously the buttons appeared on every draft PO and every
supplier; clicking on a vendor without punchout raised a
UserError. Now the affordance only shows when it's actionable.
2. **Harden ``action_create_purchase_order`` and
``_notify_auto_process_failure`` against empty ``env.user``.**
The supplier-callback controller is ``auth="none"`` so
``env.user`` may be empty (or the public user). When
``session.user_id`` is also empty / unresolvable, ``author``
ended up empty and ``with_user(empty)`` poisoned the env —
``env.uid`` resolved to nothing, ``env.user`` became an empty
recordset, and the product-create chain crashed deep in stock's
``_default_responsible_id`` on
``self.env.user._is_superuser()`` (Expected singleton).
Fix:
* ``author = self.user_id or self.env.user or admin or
SUPERUSER`` — always resolves to a real user.
* ``_notify_auto_process_failure`` wraps both message_posts in
try/except; losing the chatter notification entirely is worse
than attributing it to admin, but crashing the whole callback
is worse than losing the chatter.
* Same hardening on the failure-notification path so a failure
during auto-process can't itself cascade into a 500.
Tests cover the four ``has_punchout_backend`` cases (open backend
true, no backend false, draft backend false, non-supplier false)
on both purchase.order and res.partner.
… is empty
Real-world deployment surfaced a residual issue from the previous
auto-process hardening: the chatter showed
"Auto-creation of the purchase order failed. Error: Expected
singleton: res.users()" even though the 500 was prevented. The
deeper auto-process chain (product create → mail.thread.create →
default_get → stock._default_responsible_id → env.user._is_superuser)
still tripped on an empty env.user, and the state-tracking message
was attributed to "unknown user" rather than a system actor.
**Root cause:** the supplier-callback controller is ``auth="none"``,
so ``env.user`` is the public user or an empty recordset. ``sudo()``
in Odoo 18+ flips the superuser flag but doesn't change ``env.uid``.
``with_user(empty)`` returns self unchanged (per the v19 docstring:
"if not user: return self"), so my previous ``with_user(author)``
fallback inside ``action_create_purchase_order`` only worked when
``author`` was a real user — it didn't help when the env was already
poisoned upstream.
**Fix:** detect controller-path writes (``vals.get("state") ==
"to_process"`` AND env.user / env.uid empty) at the very top of
``write()`` and re-enter the write under OdooBot
(``base.user_root``, the SUPERUSER). This:
* Attributes the state-tracking message to OdooBot rather than
"unknown user" — clearly signals "system action", avoids
confusion with manual admin edits.
* Provides a real ``self.env.user`` for the auto-process flow that
follows. ``with_user(SUPERUSER)`` implicitly enables superuser
mode (per Odoo docstring: "by convention, the superuser is always
in superuser mode") so the deeper product-create chain bypasses
partner / company ACLs that would otherwise resolve env.user to
empty.
* Per-line / per-PO chatter attribution to the punchout-initiating
user (``session.user_id``) is preserved via ``with_user(author)``
for the actual writes inside ``action_create_purchase_order`` —
only the system-level entry point is OdooBot.
Also simplified ``action_create_purchase_order`` and
``_notify_auto_process_failure`` to use ``base.user_root`` directly
as the system fallback (was ``base.user_admin`` → SUPERUSER chain).
Test: ``test_state_change_with_empty_env_user_does_not_crash``
simulates the controller path with an explicit empty-user env and
verifies the write completes without raising and state transitions
correctly. Pre-fix this test would crash with the
``Expected singleton: res.users()`` error.
… / storable / tracking on backend
Replaces the previously hardcoded ``type="consu"`` in
``_get_or_create_product_oci/cxml/ids`` with a backend-driven set of
defaults. Three new fields on ``punchout.backend``:
* ``default_product_type`` (Selection: Goods / Service, default
Goods) — type set on auto-created products.
* ``default_is_storable`` (Boolean, default False) — when set, the
auto-created product is marked storable so the warehouse tracks
inventory. Typical for spare-parts vendors. Silently ignored when
the ``stock`` module isn't installed (the field doesn't exist on
product.template).
* ``default_tracking`` (Selection: By Quantity / By Lots / By Unique
Serial Number, default By Quantity) — inventory-tracking method,
only meaningful when ``default_is_storable`` is True.
New helper ``backend._get_auto_create_product_defaults()`` returns
the appropriate ``vals`` dict; the per-protocol create methods
splat it into their own product_vals build, replacing the previous
``"type": "consu"``, ``"purchase_ok": True``, and ``categ_id``
copy-paste in three places.
Stock-aware fields (``is_storable``, ``tracking``) are emitted only
when the corresponding ``product.template`` field exists in the
install — keeps the punchout stack usable on installs without
``stock``. The ``tracking`` key is emitted only when
``is_storable=True`` (avoids setting tracking on non-storable
products which Odoo would silently ignore anyway).
Note on Odoo 19: ``product.template.type`` no longer has the
``product`` (storable) value — all physical products are ``consu``
("Goods") and the storable flag is the separate ``is_storable``
boolean. The new fields surface this 19-era split cleanly. Odoo 18
treats the same fields the same way (``is_storable`` already exists
in 18 too), so the change is portable.
Backwards-compatible: the defaults (``type="consu"``,
``is_storable=False``) match the previous hardcoded behaviour.
Customers opt in to storable per-backend.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR ports the punchout modules to Odoo 18.0, drawing from two earlier
contributions:
The modules are reorganised into a protocol-agnostic base plus per-protocol
add-ons, with optional purchase-order integration glue:
Notable design points
through six tiers: backend-specific → supplier-specific → global → UNECE
(via `uom_unece`) → `uom.uom.name` match → caller default. Common
non-UNECE codes (STUECK, ST, STK, PC, PCS, EACH, KG, M, L) ship as
`noupdate`-flagged global defaults.
(vendor product code) rather than a custom mapping model.
and `auto_create_products` is enabled, the protocol glue creates a new
`product.product` carrying the supplier-provided UoM.
Migration source
Branched off OCA/edi `18.0` and ported via `oca-port` from the original
contributions. Original copyright headers are preserved on touched files;
migration credit is added alongside, per OCA convention.
Test plan
backend/session creation, scope constraints)
(protocol parsing, controllers, session storage)
install + 9 / 10 / 9 tests pass (cart → PO line mapping, UoM
mapping, malformed payloads, wrong-protocol deferral, regression
against permissive matching)
local mock supplier and confirm a draft PO is created with the
expected lines and UoMs
Total: 65 tests across the 8 modules, all green on a fresh `punchout_sandbox`
build (Odoo 18.0 + uom_unece 18.0).
Open items
decoding, IDS `OrderItem`) — these were the most adapted areas going
from 13.0 / 16.0 to 18.0.
from the Therp PR; if any IDS-using OCA member has a sample payload
from a real supplier I'd love to validate against it.