From d8dda50a970e987c288590ab11bb440db6d72e4a Mon Sep 17 00:00:00 2001 From: Deniz Gallo Date: Fri, 3 Jul 2026 13:05:49 +0200 Subject: [PATCH 1/3] [REF] connector_extension: add fallback hook for incomplete external alt key When the external alternate key computed from the export mapper has null fields, to_binding_from_internal_key gave up without any chance to find the existing external record, so the exporter always created a new one. Extract the post-search binding logic into _to_binding_from_external_record and call a new overridable hook _get_external_record_alt_fallback when the key is incomplete. The default implementation returns no record, keeping the previous behaviour for all connectors. --- connector_extension/components/binder.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/connector_extension/components/binder.py b/connector_extension/components/binder.py index 356ce28da..02ce91ea1 100644 --- a/connector_extension/components/binder.py +++ b/connector_extension/components/binder.py @@ -493,8 +493,21 @@ def to_binding_from_internal_key(self, relation): # TODO: check if we can put this in a hook external_alt_id = self.dict2id(id_values, in_field=False, alt_field=True) if self.is_id_null(external_alt_id): - return self.model + record = self._get_external_record_alt_fallback(relation, id_values) + return self._to_binding_from_external_record(relation, record) record = self._get_external_record_alt(relation, id_values) + return self._to_binding_from_external_record(relation, record) + + def _get_external_record_alt_fallback(self, relation, id_values): + """Hook called when the external alternate key is incomplete (some + of its fields are null) and the external record cannot be searched + directly. Override it to find the external record by indirect means. + By default it returns no record, keeping the standard behaviour: + the record will be created on the external system. + """ + return {} + + def _to_binding_from_external_record(self, relation, record): if record: external_id = self.dict2id(record, in_field=False) binding = self.wrap_record(relation) From 4c52177743d2f30e151578a4f9b99fa02f16f685 Mon Sep 17 00:00:00 2001 From: Deniz Gallo Date: Fri, 3 Jul 2026 13:06:08 +0200 Subject: [PATCH 2/3] [FIX] connector_woocommerce: relink variable product templates on export A variable product template exports sku=None on purpose (the SKUs belong to its variations), so the alternate-key re-match could never find it: exporting a variable product whose parent already existed on WooCommerce but had no binding always created a duplicate parent product. Implement the incomplete-alt-key fallback for the template binder: derive the external parent through one of the template variations, either from an already bound variation (woocommerce_idparent) or searching a variation by SKU and reading its parent_id. --- .../models/product_template/binder.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/connector_woocommerce/models/product_template/binder.py b/connector_woocommerce/models/product_template/binder.py index 59551e2fe..246264dde 100644 --- a/connector_woocommerce/models/product_template/binder.py +++ b/connector_woocommerce/models/product_template/binder.py @@ -26,6 +26,29 @@ def external_alt_id(self): def internal_alt_id(self): return ["default_code"] + def _get_external_record_alt_fallback(self, relation, id_values): + # A variable product template carries no SKU of its own on WooCommerce + # (the SKUs belong to its variations), so it can never be found through + # the alternate key. Derive the external parent product through one of + # its variations instead. + if not relation.has_attributes: + return super()._get_external_record_alt_fallback(relation, id_values) + adapter = self.component(usage="backend.adapter") + variants = relation.with_context(active_test=False).product_variant_ids + for binding in variants.woocommerce_bind_ids.filtered( + lambda x: x.backend_id == self.backend_record + ): + if binding.woocommerce_idparent: + return adapter.read(binding.woocommerce_idparent) + variation_adapter = self.component( + usage="backend.adapter", model_name="woocommerce.product.product" + ) + for sku in variants.filtered("default_code").mapped("default_code"): + for variation in variation_adapter.search_read([("sku", "=", sku)]): + if variation.get("parent_id"): + return adapter.read(variation["parent_id"]) + return super()._get_external_record_alt_fallback(relation, id_values) + # def _additional_external_binding_fields(self, external_data, relation): # return { # **super()._additional_external_binding_fields(external_data, relation), From 46a6bf3a2e588411935f4227cc3b016a7b4a85dd Mon Sep 17 00:00:00 2001 From: Deniz Gallo Date: Fri, 3 Jul 2026 13:06:21 +0200 Subject: [PATCH 3/3] [FIX] connector_woocommerce_wpml: relink translations on export Non-master languages never export the SKU, so their alternate key is always incomplete and the re-match gave up before reaching the existing translations-based redirect: exporting a product whose translations already existed on WooCommerce but had no bindings always created duplicated translations (and re-pointed the WPML translation slots to them, orphaning the originals). Implement the incomplete-alt-key fallback on the WPML binder mixin: reach the external record through the master language binding and follow its translations to the requested language. Extract the duplicated language-redirect logic from the template and variation binders into the mixin, and fix the variation translation read, which needs the parent product of the requested language (resolved through the template binding). --- .../models/product_product/binder.py | 28 ++++++++----- .../models/product_template/binder.py | 18 ++++----- .../models/product_wpml_mixin/binder.py | 40 +++++++++++++++++++ 3 files changed, 64 insertions(+), 22 deletions(-) diff --git a/connector_woocommerce_wpml/models/product_product/binder.py b/connector_woocommerce_wpml/models/product_product/binder.py index c42c2b8fc..26ab58a0d 100644 --- a/connector_woocommerce_wpml/models/product_product/binder.py +++ b/connector_woocommerce_wpml/models/product_product/binder.py @@ -41,15 +41,21 @@ def wpml_additional_external_binding_fields(self, external_data, relation): def _get_external_record_alt(self, relation, id_values): res = super()._get_external_record_alt(relation, id_values) if res: - relation_wp_lang = self.env["res.lang"]._get_wpml_code_from_iso_code( - relation.env.context.get("lang") - ) - if res.get("lang") != relation_wp_lang: - if res.get("translations") and res["translations"].get( - relation_wp_lang - ): - adapter = self.component(usage="backend.adapter") - res = adapter.read(res["translations"][relation_wp_lang]) - else: - return None + res = self._wpml_redirect_record_lang(relation, res) return res + + def _get_external_record_alt_fallback(self, relation, id_values): + record = super()._get_external_record_alt_fallback(relation, id_values) + if record: + record = self._wpml_redirect_record_lang(relation, record) + return record or {} + + def _wpml_read_translation(self, relation, record, translation_id): + # A variation is read under its parent product: resolve the parent + # for the requested language through the template binding. + template_binder = self.binder_for("woocommerce.product.template") + template_binding = template_binder.wrap_record(relation.product_tmpl_id) + if not template_binding: + return None + adapter = self.component(usage="backend.adapter") + return adapter.read([template_binding.woocommerce_idproduct, translation_id]) diff --git a/connector_woocommerce_wpml/models/product_template/binder.py b/connector_woocommerce_wpml/models/product_template/binder.py index 6e2b49d19..bcdba7853 100644 --- a/connector_woocommerce_wpml/models/product_template/binder.py +++ b/connector_woocommerce_wpml/models/product_template/binder.py @@ -34,15 +34,11 @@ def wpml_additional_external_binding_fields(self, external_data, relation): def _get_external_record_alt(self, relation, id_values): res = super()._get_external_record_alt(relation, id_values) if res: - relation_wp_lang = self.env["res.lang"]._get_wpml_code_from_iso_code( - relation.env.context.get("lang") - ) - if res.get("lang") != relation_wp_lang: - if res.get("translations") and res["translations"].get( - relation_wp_lang - ): - adapter = self.component(usage="backend.adapter") - res = adapter.read(res["translations"][relation_wp_lang]) - else: - return None + res = self._wpml_redirect_record_lang(relation, res) return res + + def _get_external_record_alt_fallback(self, relation, id_values): + record = super()._get_external_record_alt_fallback(relation, id_values) + if record: + record = self._wpml_redirect_record_lang(relation, record) + return record or {} diff --git a/connector_woocommerce_wpml/models/product_wpml_mixin/binder.py b/connector_woocommerce_wpml/models/product_wpml_mixin/binder.py index 32523d219..2dc80f030 100644 --- a/connector_woocommerce_wpml/models/product_wpml_mixin/binder.py +++ b/connector_woocommerce_wpml/models/product_wpml_mixin/binder.py @@ -30,3 +30,43 @@ def wpml_additional_external_binding_fields(self, external_data, relation): **super()._additional_external_binding_fields(external_data, relation), "woocommerce_lang": external_data["lang"], } + + def wpml_get_master_binding(self, relation): + return self.model.with_context(active_test=False).search( + [ + (self._odoo_field, "=", relation.id), + (self._backend_field, "=", self.backend_record.id), + ("woocommerce_master_lang", "=", True), + ], + limit=1, + ) + + def _wpml_read_translation(self, relation, record, translation_id): + adapter = self.component(usage="backend.adapter") + return adapter.read(translation_id) + + def _wpml_redirect_record_lang(self, relation, record): + relation_wp_lang = self.env["res.lang"]._get_wpml_code_from_iso_code( + relation.env.context.get("lang") + ) + if record.get("lang") != relation_wp_lang: + if record.get("translations") and record["translations"].get( + relation_wp_lang + ): + return self._wpml_read_translation( + relation, record, record["translations"][relation_wp_lang] + ) + return None + return record + + def _get_external_record_alt_fallback(self, relation, id_values): + record = super()._get_external_record_alt_fallback(relation, id_values) + if not record: + # Non-master languages don't export the SKU, so their alternate + # key is always incomplete: reach the external record through the + # master language binding instead. + master_binding = self.wpml_get_master_binding(relation) + if master_binding: + adapter = self.component(usage="backend.adapter") + record = adapter.read(self.dict2id(master_binding, in_field=True)) + return record or {}