From ffaab2a121aa68aa317521b2c16b20ede64dc7e6 Mon Sep 17 00:00:00 2001 From: juancarlosonate-tecnativa Date: Tue, 11 Nov 2025 19:05:00 +0100 Subject: [PATCH 01/27] [ADD] shoppingfeed_integration TT56368 --- shoppingfeed_integration/README.rst | 256 ++++ shoppingfeed_integration/__init__.py | 2 + shoppingfeed_integration/__manifest__.py | 35 + .../controllers/__init__.py | 1 + .../controllers/catalog.py | 281 ++++ shoppingfeed_integration/data/catalog_raw.xml | 302 +++++ shoppingfeed_integration/data/cron.xml | 21 + shoppingfeed_integration/i18n/es.po | 1148 +++++++++++++++++ .../i18n/shoppingfeed_integration.pot | 1089 ++++++++++++++++ .../migrations/18.0.1.0.1/pre-migration.py | 31 + .../migrations/18.0.1.0.2/pre-migration.py | 16 + shoppingfeed_integration/models/__init__.py | 15 + .../models/account_move.py | 84 ++ .../models/product_attribute.py | 18 + .../models/product_pricelist.py | 14 + .../models/product_template.py | 40 + shoppingfeed_integration/models/sale_order.py | 402 ++++++ .../models/shoppingfeed_channel.py | 96 ++ .../shoppingfeed_channel_country_carrier.py | 25 + .../models/shoppingfeed_log.py | 30 + .../models/shoppingfeed_store.py | 435 +++++++ .../shoppingfeed_store_attribute_field.py | 32 + .../models/shoppingfeed_store_carrier_map.py | 25 + .../shoppingfeed_store_order_type_map.py | 23 + .../models/shoppingfeed_ticket.py | 102 ++ .../models/stock_picking.py | 63 + .../models/stock_quant.py | 42 + shoppingfeed_integration/pyproject.toml | 3 + shoppingfeed_integration/readme/CONFIGURE.md | 87 ++ .../readme/CONTRIBUTORS.md | 2 + .../readme/DESCRIPTION.md | 9 + shoppingfeed_integration/readme/USAGE.md | 30 + .../security/ir.model.access.csv | 17 + shoppingfeed_integration/security/ir_rule.xml | 27 + .../security/shoppingfeed_security.xml | 27 + .../static/description/icon.png | Bin 0 -> 6084 bytes .../static/description/index.html | 642 +++++++++ shoppingfeed_integration/tests/__init__.py | 1 + .../tests/test_shoppingfeed_integration.py | 197 +++ shoppingfeed_integration/views/menus.xml | 48 + .../views/product_attribute_views.xml | 14 + .../views/product_pricelist_views.xml | 13 + .../views/product_template_views.xml | 33 + .../views/sale_order_views.xml | 68 + .../views/shoppingfeed_channel_views.xml | 63 + .../views/shoppingfeed_log_views.xml | 22 + .../views/shoppingfeed_store_views.xml | 242 ++++ .../views/shoppingfeed_ticket_views.xml | 40 + 48 files changed, 6213 insertions(+) create mode 100644 shoppingfeed_integration/README.rst create mode 100644 shoppingfeed_integration/__init__.py create mode 100644 shoppingfeed_integration/__manifest__.py create mode 100644 shoppingfeed_integration/controllers/__init__.py create mode 100644 shoppingfeed_integration/controllers/catalog.py create mode 100644 shoppingfeed_integration/data/catalog_raw.xml create mode 100644 shoppingfeed_integration/data/cron.xml create mode 100644 shoppingfeed_integration/i18n/es.po create mode 100644 shoppingfeed_integration/i18n/shoppingfeed_integration.pot create mode 100644 shoppingfeed_integration/migrations/18.0.1.0.1/pre-migration.py create mode 100644 shoppingfeed_integration/migrations/18.0.1.0.2/pre-migration.py create mode 100644 shoppingfeed_integration/models/__init__.py create mode 100644 shoppingfeed_integration/models/account_move.py create mode 100644 shoppingfeed_integration/models/product_attribute.py create mode 100644 shoppingfeed_integration/models/product_pricelist.py create mode 100644 shoppingfeed_integration/models/product_template.py create mode 100644 shoppingfeed_integration/models/sale_order.py create mode 100644 shoppingfeed_integration/models/shoppingfeed_channel.py create mode 100644 shoppingfeed_integration/models/shoppingfeed_channel_country_carrier.py create mode 100644 shoppingfeed_integration/models/shoppingfeed_log.py create mode 100644 shoppingfeed_integration/models/shoppingfeed_store.py create mode 100644 shoppingfeed_integration/models/shoppingfeed_store_attribute_field.py create mode 100644 shoppingfeed_integration/models/shoppingfeed_store_carrier_map.py create mode 100644 shoppingfeed_integration/models/shoppingfeed_store_order_type_map.py create mode 100644 shoppingfeed_integration/models/shoppingfeed_ticket.py create mode 100644 shoppingfeed_integration/models/stock_picking.py create mode 100644 shoppingfeed_integration/models/stock_quant.py create mode 100644 shoppingfeed_integration/pyproject.toml create mode 100644 shoppingfeed_integration/readme/CONFIGURE.md create mode 100644 shoppingfeed_integration/readme/CONTRIBUTORS.md create mode 100644 shoppingfeed_integration/readme/DESCRIPTION.md create mode 100644 shoppingfeed_integration/readme/USAGE.md create mode 100644 shoppingfeed_integration/security/ir.model.access.csv create mode 100644 shoppingfeed_integration/security/ir_rule.xml create mode 100644 shoppingfeed_integration/security/shoppingfeed_security.xml create mode 100644 shoppingfeed_integration/static/description/icon.png create mode 100644 shoppingfeed_integration/static/description/index.html create mode 100644 shoppingfeed_integration/tests/__init__.py create mode 100644 shoppingfeed_integration/tests/test_shoppingfeed_integration.py create mode 100644 shoppingfeed_integration/views/menus.xml create mode 100644 shoppingfeed_integration/views/product_attribute_views.xml create mode 100644 shoppingfeed_integration/views/product_pricelist_views.xml create mode 100644 shoppingfeed_integration/views/product_template_views.xml create mode 100644 shoppingfeed_integration/views/sale_order_views.xml create mode 100644 shoppingfeed_integration/views/shoppingfeed_channel_views.xml create mode 100644 shoppingfeed_integration/views/shoppingfeed_log_views.xml create mode 100644 shoppingfeed_integration/views/shoppingfeed_store_views.xml create mode 100644 shoppingfeed_integration/views/shoppingfeed_ticket_views.xml diff --git a/shoppingfeed_integration/README.rst b/shoppingfeed_integration/README.rst new file mode 100644 index 0000000..76053ed --- /dev/null +++ b/shoppingfeed_integration/README.rst @@ -0,0 +1,256 @@ +======================== +Shoppingfeed Integration +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:900310fb90d1b308542f357095bbe83e185157a9bf74ec78154f8f636d4d8098 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fshoppingfeed-lightgray.png?logo=github + :target: https://github.com/OCA/shoppingfeed/tree/18.0/shoppingfeed_integration + :alt: OCA/shoppingfeed +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/shoppingfeed-18-0/shoppingfeed-18-0-shoppingfeed_integration + :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/shoppingfeed&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module integrates Odoo with Shoppingfeed, a platform that connects +your store with multiple marketplaces. + +- Export product catalogs to Shoppingfeed in XML format +- Import orders from connected marketplaces automatically +- Synchronize product prices and stock levels in real-time +- Map marketplace channels to Odoo sale order types +- Map delivery carriers from marketplaces to Odoo carriers +- Track Shoppingfeed tickets and notifications +- Configure export filters by product type, category, and stock + availability + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Store Setup +----------- + +Create and authenticate your store +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Go to **Shoppingfeed → Stores** +2. Click **New** and fill: + + - **Username**: Shoppingfeed API email + - **Password**: Shoppingfeed API password + +3. Click **Get Token** button + + - Automatically retrieves: catalog ID, channels, country, and + language + +Configure product export +~~~~~~~~~~~~~~~~~~~~~~~~ + +In the store form, configure these tabs: + +Exportable Products tab +^^^^^^^^^^^^^^^^^^^^^^^ + +- **Export only selected**: Only export products with "Export to + Shoppingfeed" enabled +- **Export Product Types**: Select product types to include (Goods, + Services, Combos) +- **Export Rules**: Control which products to include: + + - Out of stock products + - Archived products + - Non-salable products + +Stock tab +^^^^^^^^^ + +- **Use actual stock state**: Use real quantities vs default quantity +- **Quantity type**: Salable (on-hand) or Virtual (forecasted) +- **Default quantity**: Quantity for products without stock tracking +- **Force zero quantity non salable**: Set 0 for non-salable products +- **Update quantities realtime**: Push stock changes immediately to + Shoppingfeed + +Prices tab +^^^^^^^^^^ + +- **Pricelist**: Optional pricelist for computing export prices + +Attributes tab +^^^^^^^^^^^^^^ + +- **Use product ID as SKU**: Use product ID instead of default code +- **Custom SKU field**: Select custom field for SKU (optional) +- **Additional attribute fields**: Add custom product fields to export + +Images tab +^^^^^^^^^^ + +- **Export all images**: Include all product images +- **Exported image count**: Limit number of images (if not exporting + all) + +Categories tab +^^^^^^^^^^^^^^ + +- **Allowed categories**: Restrict export to specific product categories + +Shipping tab +^^^^^^^^^^^^ + +- **Default delivery carrier**: Fallback carrier for unmapped carriers +- **Carrier mappings**: Map Shoppingfeed carrier names to Odoo carriers + + - Add lines with Shoppingfeed carrier name and corresponding Odoo + carrier + +Orders tab +^^^^^^^^^^ + +- **Import orders**: Enable automatic order import +- **Default payment term**: Payment terms for imported orders +- **Default payment mode**: Payment mode for imported orders +- **Default order type**: Order type for imported orders +- **Marketplace customer groups**: Map channels to specific order types + + - Add lines with Channel and Order Type + +Product Setup +------------- + +Mark products for export +~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Open a product +2. In **Shoppingfeed** tab: + + - Enable **Export to Shoppingfeed** (if store requires it) + - Select **Shoppingfeed Stores** to export this product to + +**Note**: Products need default code (internal reference) to export. + +Catalog Feed +------------ + +After configuration, your XML catalog feed is available at: + +:: + + https://yourdomain.com/catalog/{catalog_id}.xml + +Share this URL with Shoppingfeed for product synchronization. + +Usage +===== + +Product Updates +--------------- + +Price changes +~~~~~~~~~~~~~ + +Product price updates send automatically to linked Shoppingfeed stores. + +Stock changes +~~~~~~~~~~~~~ + +Stock updates send automatically if store has **Update quantities +realtime** enabled. + +Orders +------ + +View orders +~~~~~~~~~~~ + +Go to **Shoppingfeed → Marketplace Orders** to see marketplace orders. + +Orders show: + +- Shoppingfeed Order ID +- Shoppingfeed Reference +- Shoppingfeed Store +- Shoppingfeed Channel (Amazon, eBay, etc.) +- Shoppingfeed Status + +Process orders +~~~~~~~~~~~~~~ + +1. Confirm order +2. Validate delivery → shipment info sends to Shoppingfeed +3. Validate invoice → PDF uploads to Shoppingfeed (if channel supports + it) + +Orders auto-acknowledge in Shoppingfeed on import. + +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 +------- + +* Tecnativa + +Contributors +------------ + +- `Tecnativa `__: + + - Juan Carlos Oñate + +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. + +.. |maintainer-juancarlosonate-tecnativa| image:: https://github.com/juancarlosonate-tecnativa.png?size=40px + :target: https://github.com/juancarlosonate-tecnativa + :alt: juancarlosonate-tecnativa + +Current `maintainer `__: + +|maintainer-juancarlosonate-tecnativa| + +This module is part of the `OCA/shoppingfeed `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shoppingfeed_integration/__init__.py b/shoppingfeed_integration/__init__.py new file mode 100644 index 0000000..91c5580 --- /dev/null +++ b/shoppingfeed_integration/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/shoppingfeed_integration/__manifest__.py b/shoppingfeed_integration/__manifest__.py new file mode 100644 index 0000000..a1242d8 --- /dev/null +++ b/shoppingfeed_integration/__manifest__.py @@ -0,0 +1,35 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Shoppingfeed Integration", + "version": "18.0.1.0.2", + "summary": "Integrate Odoo with Shoppingfeed for product export and order sync", + "category": "Sales", + "author": "Tecnativa, Odoo Community Association (OCA)", + "maintainers": ["juancarlosonate-tecnativa"], + "website": "https://github.com/OCA/shoppingfeed", + "license": "AGPL-3", + "depends": [ + "stock", + "website_sale", + "product_brand", + "sale_order_type", + "account_payment_sale", + ], + "data": [ + "security/shoppingfeed_security.xml", + "security/ir.model.access.csv", + "security/ir_rule.xml", + "views/sale_order_views.xml", + "views/shoppingfeed_ticket_views.xml", + "views/shoppingfeed_store_views.xml", + "views/shoppingfeed_log_views.xml", + "views/product_template_views.xml", + "views/product_pricelist_views.xml", + "views/shoppingfeed_channel_views.xml", + "views/product_attribute_views.xml", + "views/menus.xml", + "data/cron.xml", + ], + "installable": True, +} diff --git a/shoppingfeed_integration/controllers/__init__.py b/shoppingfeed_integration/controllers/__init__.py new file mode 100644 index 0000000..e67fc18 --- /dev/null +++ b/shoppingfeed_integration/controllers/__init__.py @@ -0,0 +1 @@ +from . import catalog diff --git a/shoppingfeed_integration/controllers/catalog.py b/shoppingfeed_integration/controllers/catalog.py new file mode 100644 index 0000000..15a5d35 --- /dev/null +++ b/shoppingfeed_integration/controllers/catalog.py @@ -0,0 +1,281 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import datetime + +from lxml import etree + +from odoo import http, release +from odoo.http import Response, request + + +class CatalogController(http.Controller): + @http.route( + ["/catalog.xml", "/catalog/.xml"], + type="http", + auth="public", + website=True, + csrf=False, + ) + def catalog_feed(self, catalog_id=None, **kwargs): + # Generate Shoppingfeed product catalog XML for a specific store. + if not catalog_id: + return Response( + "Missing required catalog_id.", + status=400, + content_type="application/xml;charset=utf-8", + ) + store = ( + request.env["shoppingfeed.store"] + .sudo() + .search([("catalog_id", "=", catalog_id)], limit=1) + ) + if not store: + return Response( + f"Store with catalog_id {catalog_id} not found.", + status=404, + content_type="application/xml;charset=utf-8", + ) + products = self._get_products_for_store(store) + base_url = store.get_base_url().rstrip("/") + catalog_el = self._build_catalog_xml(store, products, base_url) + xml_bytes = etree.tostring( + catalog_el, pretty_print=True, xml_declaration=True, encoding="UTF-8" + ) + return Response( + xml_bytes, content_type="application/xml;charset=utf-8", status=200 + ) + + def _get_products_for_store(self, store): + env = request.env["product.product"].sudo().with_company(store.company_id) + if store.lang_id: + env = env.with_context(lang=store.lang_id.code) + domain = [] + if store.export_only_selected: + domain.append(("export_to_shoppingfeed", "=", True)) + domain.append(("shoppingfeed_store_ids", "in", store.id)) + # Product type filters + product_types = [] + if store.export_type_goods: + product_types.append("consu") + if store.export_type_service: + product_types.append("service") + if store.export_type_combo: + product_types.append("combo") + domain.append(("type", "in", product_types)) + if not store.export_out_of_stock: + domain.append(("qty_available", ">", 0)) + if not store.export_disabled_products: + domain.append(("active", "=", True)) + if not store.export_not_salable_products: + domain.append(("sale_ok", "=", True)) + if store.allowed_categ_ids: + domain.append(("public_categ_ids", "in", store.allowed_categ_ids.ids)) + return env.search(domain) + + def _build_catalog_xml(self, store, products, base_url): + catalog_el = etree.Element("catalog") + products_el = etree.SubElement(catalog_el, "products", version="1.0.0") + for product in products: + product_el = etree.SubElement(products_el, "product") + self._add_product_base_info(store, product_el, product, base_url) + self._add_product_price(store, product_el, product) + self._add_product_stock(store, product_el, product) + self._add_product_media(store, product_el, product, base_url) + self._add_product_attributes(store, product_el, product) + self._add_metadata(catalog_el, len(products)) + return catalog_el + + def _get_base_price(self, store, product): + price = product.lst_price or 0.0 + if store.pricelist_id: + price = store.pricelist_id._get_product_price(product, 1.0, None) + currency = ( + store.pricelist_id.currency_id + if store.pricelist_id + else store.company_id.currency_id + ) + return currency.round(price) + + def _add_product_base_info(self, store, product_el, product, base_url): + if store.use_product_id_as_sku: + sku_value = str(product.id) + elif store.custom_sku_field_id: + sku_value = getattr(product, store.custom_sku_field_id.name, False) or str( + product.id + ) + else: + sku_value = product.default_code or str(product.id) + etree.SubElement( + product_el, "reference" + ).text = f"{sku_value}_{store.country_id.code}" + etree.SubElement(product_el, "gtin").text = product.barcode or "" + etree.SubElement(product_el, "name").text = etree.CDATA(product.name or "") + if product.website_url: + product_url = base_url + product.website_url + etree.SubElement(product_el, "link").text = etree.CDATA(product_url) + if product.weight: + etree.SubElement(product_el, "weight").text = str(product.weight) + if product.product_brand_id: + brand_el = etree.SubElement(product_el, "brand") + etree.SubElement(brand_el, "name").text = etree.CDATA( + product.product_brand_id.name or "" + ) + if product.sf_forced_category_id: + category = product.sf_forced_category_id + else: + category = product.public_categ_ids.filtered( + lambda categ: categ.id in store.allowed_categ_ids.ids + )[:1] + if category: + category_el = etree.SubElement(product_el, "category") + etree.SubElement(category_el, "name").text = etree.CDATA( + category.display_name.replace(" / ", " > ") + ) + etree.SubElement(category_el, "link").text = etree.CDATA( + f"{base_url}/shop/category/{category.id}" + ) + description_el = etree.SubElement(product_el, "description") + full_desc = product.website_description or "" + etree.SubElement(description_el, "full").text = etree.CDATA(full_desc) + short_desc = product.description_sale or "" + etree.SubElement(description_el, "short").text = etree.CDATA(short_desc) + + def _add_product_price(self, store, product_el, product): + price = self._get_base_price(store, product) + if store.include_taxes_in_price and product.taxes_id: + currency = ( + store.pricelist_id.currency_id + if store.pricelist_id + else store.company_id.currency_id + ) + product_taxes = product.taxes_id._filter_taxes_by_company(store.company_id) + tax_result = product_taxes.compute_all( + price, currency=currency, quantity=1.0, product=product + ) + price = currency.round(tax_result["total_included"]) + etree.SubElement(product_el, "price").text = str(price) + + def _get_quantity_value(self, store, product): + if store.quantity_type == "virtual": + return product.virtual_available + return product.qty_available + + def _add_product_stock(self, store, product_el, product): + if not store.use_actual_stock_state: + quantity_value = store.default_quantity + else: + quantity_value = self._get_quantity_value(store, product) or 0 + if product.type == "service": + quantity_value = store.default_quantity + if not product.sale_ok and store.force_zero_quantity_non_salable: + quantity_value = 0 + etree.SubElement(product_el, "quantity").text = str(int(quantity_value)) + + def _add_product_media(self, store, product_el, product, base_url): + images_el = etree.SubElement(product_el, "images") + product_images = product.product_template_image_ids + if store.export_all_images: + all_images = product_images + else: + all_images = product_images[: store.exported_image_count] + if product.image_1920: + etree.SubElement(images_el, "image", type="main").text = etree.CDATA( + f"{base_url}/web/image/product.product/{product.id}/image_1920" + ) + for img in all_images: + etree.SubElement(images_el, "image").text = etree.CDATA( + f"{base_url}/web/image/{img._name}/{img.id}/image_1920" + ) + + def _add_product_attributes(self, store, product_el, product): + attributes_el = etree.SubElement(product_el, "attributes") + # Attributes that generate variants + for value in product.product_template_attribute_value_ids: + # Skip if attribute is not marked for export to Shoppingfeed + if not value.attribute_id.shoppingfeed_export: + continue + attr_el = etree.SubElement(attributes_el, "attribute") + name = ( + value.attribute_id.shoppingfeed_code_name_attribute + or value.attribute_id.name + ) + etree.SubElement(attr_el, "name").text = name + etree.SubElement(attr_el, "value").text = value.name + # Attributes that do NOT generate variants + for line in product.product_tmpl_id.attribute_line_ids.filtered( + lambda line_var: line_var.attribute_id.create_variant == "no_variant" + and line_var.attribute_id.shoppingfeed_export + ): + if line.value_ids: + values = ", ".join(line.value_ids.mapped("name")) + attr_el = etree.SubElement(attributes_el, "attribute") + name = ( + line.attribute_id.shoppingfeed_code_name_attribute + or line.attribute_id.name + ) + etree.SubElement(attr_el, "name").text = name + etree.SubElement(attr_el, "value").text = values + if store.additional_attribute_field_ids: + for attr_field in store.additional_attribute_field_ids: + field = attr_field.field_id + value = getattr(product, field.name, False) + if not value: + continue + attr_el = etree.SubElement(attributes_el, "attribute") + attr_name = ( + attr_field.custom_name or field.field_description or field.name + ) + etree.SubElement(attr_el, "name").text = attr_name + if field.ttype == "many2one": + etree.SubElement(attr_el, "value").text = ( + value.display_name + if hasattr(value, "display_name") + else str(value.id) + ) + elif field.ttype == "boolean": + etree.SubElement(attr_el, "value").text = ( + "True" if value else "False" + ) + elif field.ttype == "html": + etree.SubElement(attr_el, "value").text = etree.CDATA(str(value)) + elif field.ttype == "datetime": + etree.SubElement(attr_el, "value").text = etree.CDATA(str(value)) + else: + etree.SubElement(attr_el, "value").text = str(value) + self._add_product_price_attributes(store, attributes_el, product) + + def _add_product_price_attributes(self, store, attributes_el, product): + # Export price without taxes as additional attribute for marketplaces + if store.export_price_without_tax and store.price_without_tax_attribute_name: + price_without_tax = self._get_base_price(store, product) + attr_el = etree.SubElement(attributes_el, "attribute") + etree.SubElement( + attr_el, "name" + ).text = store.price_without_tax_attribute_name + etree.SubElement(attr_el, "value").text = str(price_without_tax) + # Additional pricelists as price attributes + if store.additional_pricelist_ids: + for pricelist in store.additional_pricelist_ids: + if not pricelist.shoppingfeed_attribute_name: + continue + price = pricelist.currency_id.round( + pricelist._get_product_price(product, 1.0, None) + ) + attr_el = etree.SubElement(attributes_el, "attribute") + etree.SubElement( + attr_el, "name" + ).text = pricelist.shoppingfeed_attribute_name + etree.SubElement(attr_el, "value").text = str(price) + + def _add_metadata(self, catalog_el, total_products): + metadata_el = etree.SubElement(catalog_el, "metadata") + etree.SubElement(metadata_el, "platform").text = f"Odoo:{release.version}" + etree.SubElement( + metadata_el, "agent" + ).text = f"shoppingfeed_integration:{release.version}" + etree.SubElement(metadata_el, "startedAt").text = datetime.now().isoformat() + etree.SubElement(metadata_el, "finishedAt").text = datetime.now().isoformat() + etree.SubElement(metadata_el, "invalid").text = "0" + etree.SubElement(metadata_el, "ignored").text = "0" + etree.SubElement(metadata_el, "written").text = str(total_products) diff --git a/shoppingfeed_integration/data/catalog_raw.xml b/shoppingfeed_integration/data/catalog_raw.xml new file mode 100644 index 0000000..5964a47 --- /dev/null +++ b/shoppingfeed_integration/data/catalog_raw.xml @@ -0,0 +1,302 @@ + + + + + + AB123 + + 123456789> + + + + + + 10 + + 1 + + 19.0 + + 0.56 + + 2.5 + + + + + + + + Home > Mugs ]]> + + + + + 8 + + + + + + + + + + 5.90 + + + + + + + + + + + + + Capacity + 10 + + + Material + Iron + + + + + + AB123-1 + 123456789> + 1 + 10 + + + 2.5 + + 10 + + + + 5.90 + + + + + + + + + + + + Color + Red + + + Size + Small + + + + + + AB123-1 + 123456789> + 1 + 10 + + 10 + + + + 5.90 + + + + + + + + + + + Color + Blue + + + Size + Medium + + + + + + + + AB123 + 123456789> + + + 10 + 1 + + + + + + Home > Mugs ]]> + + + + 10 + + + + + + + + 5.90 + + + + + + + + + + + Color + Red + + + Size + XML + + + Capacity + 10 + + + Material + Iron + + + + + AB123 + 123456789> + 1 + 10 + + 10 + + + + 5.90 + + + + + + + + + + + Color + Red + + + Capacity + 10 + + + + + AB123 + 123456789> + 1 + 10 + + 10 + + + + 5.90 + + + + + + + + + + + Color + Red + + + Capacity + 10 + + + + + + + + shopping-feed-php-sdk:1.0.0 + Magento:2.0.1 + 2018-05-30T09:00:00 + 2018-05-30T09:20:00 + 1 + 0 + 10 + + diff --git a/shoppingfeed_integration/data/cron.xml b/shoppingfeed_integration/data/cron.xml new file mode 100644 index 0000000..7e8d619 --- /dev/null +++ b/shoppingfeed_integration/data/cron.xml @@ -0,0 +1,21 @@ + + + + Shoppingfeed: Import Orders + + code + model.action_import_from_shoppingfeed() + 15 + minutes + False + + + Shoppingfeed: Import Tickets + + code + model.cron_fetch_all_tickets() + 60 + minutes + True + + diff --git a/shoppingfeed_integration/i18n/es.po b/shoppingfeed_integration/i18n/es.po new file mode 100644 index 0000000..5655770 --- /dev/null +++ b/shoppingfeed_integration/i18n/es.po @@ -0,0 +1,1148 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shoppingfeed_integration +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-01-22 11:45+0000\n" +"PO-Revision-Date: 2026-01-22 11:45+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: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__access_token +msgid "Access Token" +msgstr "Token de Acceso" + +#. module: shoppingfeed_integration +#: model:res.groups,comment:shoppingfeed_integration.group_shoppingfeed_user +msgid "Access to view Shoppingfeed stores, channels, and tickets." +msgstr "Acceso para ver tiendas, canales y tickets de Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__email +msgid "Account Email" +msgstr "Correo Electrónico de la Cuenta" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Account Information" +msgstr "Información de la Cuenta" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_needaction +msgid "Action Needed" +msgstr "Acción Necesaria" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__additional_attribute_field_ids +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Additional Attributes" +msgstr "Atributos Adicionales" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_attachment_count +msgid "Attachment Count" +msgstr "Cantidad de Adjuntos" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Attributes" +msgstr "Atributos" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_ticket__state__canceled +msgid "Canceled" +msgstr "Cancelado" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__carrier_map_ids +msgid "Carrier Mappings" +msgstr "Asignaciones de Transportistas" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__carrier_name +msgid "Carrier name as received from Shoppingfeed" +msgstr "Nombre del transportista tal como se recibe desde Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__feed_url +msgid "Catalog Feed URL" +msgstr "URL del Feed del Catálogo" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Categories" +msgstr "Categorías" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__allowed_categ_ids +msgid "Category Selection" +msgstr "Selección de Categorías" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__channel_id +msgid "Channel" +msgstr "Canal" + +#. module: shoppingfeed_integration +#: model:ir.actions.act_window,name:shoppingfeed_integration.action_shoppingfeed_channel +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__channel_ids +#: model:ir.ui.menu,name:shoppingfeed_integration.menu_shoppingfeed_channel +msgid "Channels" +msgstr "Canales" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__quantity_type +msgid "" +"Choose which stock quantity to export: 'Salable' uses qty_available, " +"'Virtual' uses virtual_available." +msgstr "" +"Elija qué cantidad de stock exportar: 'Vendible' usa qty_available, " +"'Virtual' usa virtual_available." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_type_combo +msgid "Combos" +msgstr "Combos" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__company_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__company_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__company_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__company_id +msgid "Company" +msgstr "Compañía" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_sale_order__shoppingfeed_raw_data +msgid "Complete JSON data received from Shoppingfeed API for this order." +msgstr "Datos JSON completos recibidos de la API de Shoppingfeed para este pedido." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__country_id +msgid "Country" +msgstr "País" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_ticket_tree +msgid "Created On" +msgstr "Creado el" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__create_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__create_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__create_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__create_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__create_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__create_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__create_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__create_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__create_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__create_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__create_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__create_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Credentials" +msgstr "Credenciales" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_sale_order__shoppingfeed_status +msgid "Current status of the order in Shoppingfeed." +msgstr "Estado actual del pedido en Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__custom_name +msgid "Custom Attribute Name" +msgstr "Nombre de Atributo Personalizado" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__custom_sku_field_id +msgid "Custom SKU Attribute" +msgstr "Atributo SKU Personalizado" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_product_attribute__shoppingfeed_code_name_attribute +msgid "Custom attribute name used when exporting products to Shoppingfeed." +msgstr "Nombre de atributo personalizado usado al exportar productos a Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__custom_name +msgid "" +"Custom name to use in the exported catalog. If empty, the field description " +"will be used." +msgstr "Nombre personalizado para usar en el catálogo exportado. Si está vacío, se usará la descripción del campo." + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_log_list +msgid "Date" +msgstr "Fecha" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__default_delivery_carrier_id +msgid "Default Delivery Carrier" +msgstr "Transportista de Entrega por Defecto" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__default_order_type_id +msgid "Default Order Type" +msgstr "Tipo de Pedido por Defecto" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__default_payment_mode_id +msgid "Default Payment Mode" +msgstr "Modo de Pago por Defecto" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__default_payment_term_id +msgid "Default Payment Terms" +msgstr "Términos de Pago por Defecto" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__default_quantity +msgid "Default Quantity" +msgstr "Cantidad por Defecto" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__default_order_type_id +msgid "" +"Default Sale Order Type to assign to imported orders when no other type is " +"detected or explicitly set from the Shoppingfeed source." +msgstr "" +"Tipo de Pedido de Venta por Defecto para asignar a pedidos importados cuando" +" no se detecta otro tipo o no se establece explícitamente desde " +"Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__default_delivery_carrier_id +msgid "" +"Default carrier to assign to imported orders when the carrier from " +"Shoppingfeed is not mapped." +msgstr "" +"Transportista por defecto para asignar a pedidos importados cuando el " +"transportista de Shoppingfeed no está mapeado." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__default_payment_mode_id +msgid "Default payment mode to assign to orders when no other is provided." +msgstr "" +"Modo de pago por defecto para asignar a pedidos cuando no se proporciona " +"otro." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__default_payment_term_id +msgid "Default payment terms to assign to orders when no other is provided." +msgstr "" +"Términos de pago por defecto para asignar a pedidos cuando no se proporciona" +" otro." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__default_quantity +msgid "" +"Default quantity to use for products that do not manage stock or when real " +"stock is not used." +msgstr "" +"Cantidad por defecto para usar en productos que no gestionan stock o cuando " +"no se usa el stock real." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__display_name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__display_name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__display_name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__display_name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__display_name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__display_name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__display_name +msgid "Display Name" +msgstr "Nombre para Mostrar" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_log__log_type__error +msgid "Error" +msgstr "Error" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__error_message +msgid "Error Message" +msgstr "Mensaje de Error" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_all_images +msgid "Export All Images" +msgstr "Exportar Todas las Imágenes" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_attribute_set_name +msgid "Export Attribute Set Name" +msgstr "Exportar Nombre del Conjunto de Atributos" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_disabled_products +msgid "Export Disabled Products" +msgstr "Exportar Productos Deshabilitados" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_not_salable_products +msgid "Export Not Salable Products" +msgstr "Exportar Productos No Vendibles" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_only_selected +msgid "Export Only Selected" +msgstr "Exportar Solo los Seleccionados" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_out_of_stock +msgid "Export Out of Stock Products" +msgstr "Exportar Productos Sin Stock" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Export Product Types" +msgstr "Exportar Tipos de Productos" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Export Rules" +msgstr "Reglas de Exportación" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_product_product__export_to_shoppingfeed +msgid "Export To Shoppingfeed" +msgstr "Exportar a Shoppingfeed" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Exportable Products" +msgstr "Productos Exportables" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__exported_image_count +msgid "Exported Image Count" +msgstr "Cantidad de Imágenes Exportadas" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_ticket__state__failed +msgid "Failed" +msgstr "Fallido" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__field_id +msgid "Field" +msgstr "Campo" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_follower_ids +msgid "Followers" +msgstr "Seguidores" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_partner_ids +msgid "Followers (Partners)" +msgstr "Seguidores (Socios)" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__force_zero_quantity_non_salable +msgid "Force Zero Quantity for Non Salable Products" +msgstr "Forzar Cantidad Cero para Productos No Vendibles" + +#. module: shoppingfeed_integration +#: model:res.groups,comment:shoppingfeed_integration.group_shoppingfeed_manager +msgid "Full access to configure and manage Shoppingfeed integration." +msgstr "Acceso completo para configurar y gestionar la integración de Shoppingfeed." + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Sync Account" +msgstr "Sincronizar Cuenta" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_type_goods +msgid "Goods" +msgstr "Mercancías" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__has_message +msgid "Has Message" +msgstr "Tiene Mensaje" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__id +msgid "ID" +msgstr "ID" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__message_needaction +msgid "If checked, new messages require your attention." +msgstr "Si está marcado, los nuevos mensajes requieren su atención." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__message_has_error +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "Si está marcado, algunos mensajes tienen un error de entrega." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_all_images +msgid "" +"If enabled, all images of the product will be exported. If disabled, only " +"the main image or the number defined in 'Exported Image Count' will be " +"included in the feed." +msgstr "" +"Si está habilitado, se exportarán todas las imágenes del producto. Si está " +"deshabilitado, solo se incluirá la imagen principal o el número definido en " +"'Cantidad de Imágenes Exportadas' en el feed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_disabled_products +msgid "" +"If enabled, include archived (inactive) products in the Shoppingfeed export." +msgstr "" +"Si está habilitado, incluye productos archivados (inactivos) en la " +"exportación de Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_not_salable_products +msgid "" +"If enabled, include products not allowed for sale (sale_ok = False) in the " +"Shoppingfeed export." +msgstr "" +"Si está habilitado, incluye productos no permitidos para la venta (sale_ok =" +" False) en la exportación de Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_attribute_set_name +msgid "" +"If enabled, include the attribute set name in the exported catalog feed. " +"Currently has no effect unless a custom attribute set field exists." +msgstr "" +"Si está habilitado, incluye el nombre del conjunto de atributos en el feed " +"del catálogo exportado. Actualmente no tiene efecto a menos que exista un " +"campo de conjunto de atributos personalizado." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_only_selected +msgid "" +"If enabled, only products explicitly marked with 'Export To Shoppingfeed' " +"will be exported in the catalog feed." +msgstr "" +"Si está habilitado, solo los productos marcados explícitamente con 'Exportar" +" a Shoppingfeed' se exportarán en el feed del catálogo." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__import_orders +msgid "If enabled, orders from Shoppingfeed will be imported automatically." +msgstr "" +"Si está habilitado, los pedidos de Shoppingfeed se importarán " +"automáticamente." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__update_quantities_realtime +msgid "" +"If enabled, product quantities will be pushed to Shoppingfeed immediately " +"when stock changes are detected. This may slow down inventory updates." +msgstr "" +"Si está habilitado, las cantidades de productos se enviarán a Shoppingfeed " +"inmediatamente cuando se detecten cambios de stock. Esto puede ralentizar " +"las actualizaciones de inventario." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__force_zero_quantity_non_salable +msgid "" +"If enabled, products that are not allowed for sale (sale_ok=False) will be " +"exported with quantity 0." +msgstr "" +"Si está habilitado, los productos que no están permitidos para la venta " +"(sale_ok=False) se exportarán con cantidad 0." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_out_of_stock +msgid "" +"If enabled, products with no stock (quantity <= 0) will be included in the " +"Shoppingfeed export." +msgstr "" +"Si está habilitado, los productos sin stock (cantidad <= 0) se incluirán en " +"la exportación de Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__use_actual_stock_state +msgid "" +"If enabled, real stock quantities will be used for products that manage " +"stock. Products without stock management will use the default quantity." +msgstr "" +"Si está habilitado, se usarán las cantidades de stock reales para productos " +"que gestionan stock. Los productos sin gestión de stock usarán la cantidad " +"por defecto." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__use_product_id_as_sku +msgid "" +"If enabled, the internal product ID will be used as the SKU in the feed, " +"instead of the product's Default Code." +msgstr "" +"Si está habilitado, se usará el ID interno del producto como SKU en el feed," +" en lugar del Código por Defecto del producto." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_product_product__export_to_shoppingfeed +msgid "" +"If enabled, this product will be exported to Shoppingfeed when the store is " +"configured to export only selected products." +msgstr "" +"Si está habilitado, este producto se exportará a Shoppingfeed cuando la " +"tienda esté configurada para exportar solo productos seleccionados." + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Images" +msgstr "Imágenes" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__import_orders +msgid "Import Orders" +msgstr "Importar Pedidos" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_type_combo +msgid "Include combo products (type = 'combo') in the Shoppingfeed export." +msgstr "" +"Incluir productos combo (tipo = 'combo') en la exportación de Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_type_goods +msgid "Include goods products (type = 'consu') in the Shoppingfeed export." +msgstr "" +"Incluir productos de mercancías (tipo = 'consu') en la exportación de " +"Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_type_service +msgid "" +"Include service products (type = 'service') in the Shoppingfeed export." +msgstr "" +"Incluir productos de servicios (tipo = 'service') en la exportación de " +"Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_stock_picking__shoppingfeed_shipped +msgid "Indicates if this picking has already been sent to ShoppingFeed" +msgstr "Indica si este albarán ya ha sido enviado a ShoppingFeed" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_sale_order__shoppingfeed_channel_id +msgid "Indicates the channel from which this sales order originated." +msgstr "Indica el canal del cual se originó este pedido de venta." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_channel__upload_invoice_not_allowed +msgid "" +"Indicates whether this channel does NOT support invoice document upload. " +"Based on Shoppingfeed API documentation — see: https://docs.shopping-" +"feed.com/#order-operations-supported-per-channel" +msgstr "" +"Indica si este canal NO soporta la carga de documentos de factura. Basado en" +" la documentación de la API de Shoppingfeed — ver: https://docs.shopping-" +"feed.com/#order-operations-supported-per-channel" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_is_follower +msgid "Is Follower" +msgstr "Es Seguidor" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_account_move +msgid "Journal Entry" +msgstr "Asiento Contable" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__lang_id +msgid "Language" +msgstr "Idioma" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__write_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__write_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__write_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__write_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__write_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__write_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__write_uid +msgid "Last Updated by" +msgstr "Última Actualización por" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__write_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__write_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__write_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__write_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__write_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__write_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__write_date +msgid "Last Updated on" +msgstr "Última Actualización el" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Leave empty to use field description" +msgstr "Dejar vacío para usar la descripción del campo" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__link +msgid "Link" +msgstr "Enlace" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__delivery_carrier_id +msgid "Local Odoo carrier equivalent for this Shoppingfeed carrier." +msgstr "Transportista local de Odoo equivalente para este transportista de Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__log_type +msgid "Log Type" +msgstr "Tipo de Log" + +#. module: shoppingfeed_integration +#: model_terms:ir.actions.act_window,help:shoppingfeed_integration.action_shoppingfeed_log +msgid "" +"Logs are created automatically when the cron job imports orders from " +"Shoppingfeed." +msgstr "Los registros se crean automáticamente cuando el trabajo cron importa pedidos desde Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.module.category,description:shoppingfeed_integration.module_category_shoppingfeed +msgid "Manage Shoppingfeed integration" +msgstr "Gestionar integración de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:res.groups,name:shoppingfeed_integration.group_shoppingfeed_manager +msgid "Manager" +msgstr "Administrador" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__channel_id +msgid "Marketplace Channel" +msgstr "Canal del Marketplace" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_store_order_type_map +msgid "Marketplace Customer Group Mapping" +msgstr "Asignación de Grupos de Clientes del Marketplace" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__marketplace_customer_group_ids +msgid "Marketplace Customer Groups" +msgstr "Grupos de Clientes del Marketplace" + +#. module: shoppingfeed_integration +#: model:ir.actions.act_window,name:shoppingfeed_integration.action_shoppingfeed_marketplace_orders +#: model:ir.ui.menu,name:shoppingfeed_integration.menu_shoppingfeed_marketplace_orders +msgid "Marketplace Orders" +msgstr "Pedidos del Marketplace" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_ticket_tree +msgid "Marketplace Ref" +msgstr "Ref. Marketplace" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_sale_order__shoppingfeed_reference +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_ticket__shoppingfeed_reference +msgid "Marketplace order reference (e.g., TEST-68fb749e8cd95)." +msgstr "Referencia del pedido del marketplace (ej., TEST-68fb749e8cd95)." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__message +msgid "Message" +msgstr "Mensaje" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_has_error +msgid "Message Delivery error" +msgstr "Error de Entrega de Mensaje" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_ids +msgid "Messages" +msgstr "Mensajes" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__name +msgid "Name" +msgstr "Nombre" + +#. module: shoppingfeed_integration +#: model_terms:ir.actions.act_window,help:shoppingfeed_integration.action_shoppingfeed_ticket +msgid "No Shoppingfeed tickets found." +msgstr "No se encontraron tickets de Shoppingfeed." + +#. module: shoppingfeed_integration +#: model_terms:ir.actions.act_window,help:shoppingfeed_integration.action_shoppingfeed_log +msgid "No order logs found." +msgstr "No se encontraron registros de pedidos." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_needaction_counter +msgid "Number of Actions" +msgstr "Número de Acciones" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_has_error_counter +msgid "Number of errors" +msgstr "Número de Errores" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__exported_image_count +msgid "" +"Number of images to export per product when 'Export All Images' is disabled." +msgstr "" +"Número de imágenes a exportar por producto cuando 'Exportar Todas las " +"Imágenes' está deshabilitado." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "Número de mensajes que requieren acción" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "Número de mensajes con error de entrega" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_sale_order__shoppingfeed_order_ref +msgid "Numeric order ID from Shoppingfeed API (e.g., 21786210256)." +msgstr "" +"ID numérico del pedido desde la API de Shoppingfeed (ej., 21786210256)." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__delivery_carrier_id +msgid "Odoo Delivery Carrier" +msgstr "Transportista de Entrega Odoo" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__order_id +msgid "Order" +msgstr "Pedido" + +#. module: shoppingfeed_integration +#: model:ir.actions.act_window,name:shoppingfeed_integration.action_shoppingfeed_log +#: model:ir.ui.menu,name:shoppingfeed_integration.menu_shoppingfeed_log +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_log_list +msgid "Order Logs" +msgstr "Registros de Pedidos" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__shoppingfeed_order_ref +msgid "Order Reference" +msgstr "Referencia del Pedido" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__order_type_id +msgid "Order Type" +msgstr "Tipo de Pedido" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Orders" +msgstr "Pedidos" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__password +msgid "Password" +msgstr "Contraseña" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__pricelist_id +msgid "Pricelist for Shoppingfeed Export" +msgstr "Tarifa para Exportación de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__pricelist_id +msgid "Pricelist used to compute prices during Shoppingfeed export." +msgstr "Tarifa utilizada para calcular precios durante la exportación a Shoppingfeed." + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Prices" +msgstr "Precios" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_product_attribute +msgid "Product Attribute" +msgstr "Atributo de producto" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_product_product +msgid "Product Variant" +msgstr "Variante de producto" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__quantity_type +msgid "Quantity Type" +msgstr "Tipo de Cantidad" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_stock_quant +msgid "Quants" +msgstr "Cuantos" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__rating_ids +msgid "Ratings" +msgstr "Valoraciones" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_order_form_shoppingfeed +msgid "Raw Data" +msgstr "Datos Sin Procesar" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__result_data +msgid "Result Data" +msgstr "Datos del Resultado" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_ticket__state__running +msgid "Running" +msgstr "En Ejecución" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_has_sms_error +msgid "SMS Delivery error" +msgstr "Error de Entrega de SMS" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_store__quantity_type__salable +msgid "Salable Quantity" +msgstr "Cantidad Vendible" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_sale_order +msgid "Sales Order" +msgstr "Pedidos de Venta" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_ticket__state__scheduled +msgid "Scheduled" +msgstr "Programado" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__custom_sku_field_id +msgid "" +"Select field from product.product to be used as the SKU in the export. Leave" +" empty to use the default behavior (Default Code or Product ID)." +msgstr "" +"Seleccione el campo de product.product a usar como SKU en la exportación. " +"Déjelo vacío para usar el comportamiento por defecto (Código por Defecto o " +"ID del Producto)." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__additional_attribute_field_ids +msgid "" +"Select one or more fields from product.product to include as additional " +"attributes in the exported Shoppingfeed catalog. You can customize the " +"attribute name for each field." +msgstr "" +"Seleccione uno o más campos de product.product para incluir como atributos " +"adicionales en el catálogo exportado de Shoppingfeed. Útil para exportar " +"datos personalizados o metadatos adicionales." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_product_product__shoppingfeed_store_ids +msgid "Select the Shoppingfeed stores where this product should be exported." +msgstr "" +"Seleccione las tiendas de Shoppingfeed donde se debe exportar este producto." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__allowed_categ_ids +msgid "Select which product categories will be exported to Shoppingfeed." +msgstr "Seleccione qué categorías de productos se exportarán a Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__sequence +msgid "Sequence" +msgstr "Secuencia" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_type_service +msgid "Services" +msgstr "Servicios" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Shipping" +msgstr "Envío" + +#. module: shoppingfeed_integration +#: model:ir.module.category,name:shoppingfeed_integration.module_category_shoppingfeed +#: model:ir.ui.menu,name:shoppingfeed_integration.menu_shoppingfeed_root +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_order_form_shoppingfeed +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_product_form_inherit_shoppingfeed +msgid "Shoppingfeed" +msgstr "Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_store_carrier_map +msgid "Shoppingfeed Carrier Mapping" +msgstr "Asignación de Transportistas de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__carrier_name +msgid "Shoppingfeed Carrier Name" +msgstr "Nombre del Transportista de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_channel +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_sale_order__shoppingfeed_channel_id +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_channel_form +msgid "Shoppingfeed Channel" +msgstr "Canal de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_product_attribute__shoppingfeed_code_name_attribute +msgid "Shoppingfeed Code Name" +msgstr "Nombre de Código Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_sale_order__shoppingfeed_order_ref +msgid "Shoppingfeed Order ID" +msgstr "ID del Pedido de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_log +msgid "Shoppingfeed Order Import Log" +msgstr "Registro de Importación de Pedidos Shoppingfeed" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_sale_order_filter_shoppingfeed +msgid "Shoppingfeed Orders" +msgstr "Pedidos de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_sale_order__shoppingfeed_raw_data +msgid "Shoppingfeed Raw Data" +msgstr "Datos Sin Procesar de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_sale_order__shoppingfeed_reference +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__shoppingfeed_reference +msgid "Shoppingfeed Reference" +msgstr "Referencia de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_stock_picking__shoppingfeed_shipped +msgid "Shoppingfeed Shipped" +msgstr "Enviado a Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_sale_order__shoppingfeed_status +msgid "Shoppingfeed Status" +msgstr "Estado de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_sale_order__shoppingfeed_store_id +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Shoppingfeed Store" +msgstr "Tienda de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_store_attribute_field +msgid "Shoppingfeed Store Additional Attribute Field" +msgstr "Campo de Atributo Adicional de Tienda Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_store +msgid "Shoppingfeed Store Configuration" +msgstr "Configuración de Tienda de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.actions.act_window,name:shoppingfeed_integration.action_shoppingfeed_store +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_product_product__shoppingfeed_store_ids +msgid "Shoppingfeed Stores" +msgstr "Tiendas de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_ticket +msgid "Shoppingfeed Ticket" +msgstr "Ticket de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.actions.act_window,name:shoppingfeed_integration.action_shoppingfeed_ticket +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_ticket_tree +msgid "Shoppingfeed Tickets" +msgstr "Tickets de Shoppingfeed" + +#. module: shoppingfeed_integration +#: model:ir.actions.server,name:shoppingfeed_integration.ir_cron_shoppingfeed_import_orders_ir_actions_server +msgid "Shoppingfeed: Import Orders" +msgstr "Shoppingfeed: Importar Pedidos" + +#. module: shoppingfeed_integration +#: model:ir.actions.server,name:shoppingfeed_integration.ir_cron_fetch_all_shoppingfeed_tickets_ir_actions_server +msgid "Shoppingfeed: Import Tickets" +msgstr "Shoppingfeed: Importar Tickets" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_sale_order_filter_shoppingfeed +msgid "Show only orders imported from Shoppingfeed." +msgstr "Mostrar solo pedidos importados desde Shoppingfeed." + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__state +msgid "State" +msgstr "Estado" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Stock" +msgstr "Stock" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__store_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__store_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__store_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__store_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__store_id +msgid "Store" +msgstr "Tienda" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__catalog_id +msgid "Store ID (Catalog)" +msgstr "ID de Tienda (Catálogo)" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Store Info" +msgstr "Información de la Tienda" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__status +msgid "Store Status" +msgstr "Estado de la Tienda" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__store_ids +#: model:ir.ui.menu,name:shoppingfeed_integration.menu_shoppingfeed_store +msgid "Stores" +msgstr "Tiendas" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_ticket__state__succeed +msgid "Succeeded" +msgstr "Exitoso" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_log__log_type__success +msgid "Success" +msgstr "Éxito" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_sale_order__shoppingfeed_store_id +msgid "The Shoppingfeed store from which this order was imported." +msgstr "La tienda de Shoppingfeed desde la cual se importó este pedido." + +#. module: shoppingfeed_integration +#: model:ir.model.constraint,message:shoppingfeed_integration.constraint_sale_order_unique_shoppingfeed_order_ref_by_company_store +msgid "This Shoppingfeed order has already been imported!" +msgstr "¡Este pedido de Shoppingfeed ya ha sido importado!" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_ticket_tree +msgid "Ticket ID" +msgstr "ID del Ticket" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__ticket_type +msgid "Ticket Type" +msgstr "Tipo de Ticket" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_ticket_tree +msgid "Ticket URL" +msgstr "URL del Ticket" + +#. module: shoppingfeed_integration +#: model:ir.ui.menu,name:shoppingfeed_integration.menu_shoppingfeed_ticket +msgid "Tickets" +msgstr "Tickets" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__token_type +msgid "Token Type" +msgstr "Tipo de Token" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_stock_picking +msgid "Transfer" +msgstr "Traslado" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__update_quantities_realtime +msgid "Update Quantities in Real Time" +msgstr "Actualizar Cantidades en Tiempo Real" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__upload_invoice_not_allowed +msgid "Upload Invoice Not Allowed" +msgstr "Carga de Factura No Permitida" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__use_actual_stock_state +msgid "Use Actual Stock State" +msgstr "Usar Estado de Stock Real" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__use_product_id_as_sku +msgid "Use Product ID for SKU" +msgstr "Usar ID del Producto para SKU" + +#. module: shoppingfeed_integration +#: model:res.groups,name:shoppingfeed_integration.group_shoppingfeed_user +msgid "User" +msgstr "Usuario" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__username +msgid "Username" +msgstr "Nombre de Usuario" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_store__quantity_type__virtual +msgid "Virtual Quantity" +msgstr "Cantidad Virtual" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__website_message_ids +msgid "Website Messages" +msgstr "Mensajes del Sitio Web" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__website_message_ids +msgid "Website communication history" +msgstr "Historial de comunicación del sitio web" diff --git a/shoppingfeed_integration/i18n/shoppingfeed_integration.pot b/shoppingfeed_integration/i18n/shoppingfeed_integration.pot new file mode 100644 index 0000000..61a55ef --- /dev/null +++ b/shoppingfeed_integration/i18n/shoppingfeed_integration.pot @@ -0,0 +1,1089 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shoppingfeed_integration +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-01-22 11:54+0000\n" +"PO-Revision-Date: 2026-01-22 11:54+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: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__access_token +msgid "Access Token" +msgstr "" + +#. module: shoppingfeed_integration +#: model:res.groups,comment:shoppingfeed_integration.group_shoppingfeed_user +msgid "Access to view Shoppingfeed stores, channels, and tickets." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__email +msgid "Account Email" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Account Information" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__additional_attribute_field_ids +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Additional Attributes" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Attributes" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_ticket__state__canceled +msgid "Canceled" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__carrier_map_ids +msgid "Carrier Mappings" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__carrier_name +msgid "Carrier name as received from Shoppingfeed" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__feed_url +msgid "Catalog Feed URL" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Categories" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__allowed_categ_ids +msgid "Category Selection" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__channel_id +msgid "Channel" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.actions.act_window,name:shoppingfeed_integration.action_shoppingfeed_channel +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__channel_ids +#: model:ir.ui.menu,name:shoppingfeed_integration.menu_shoppingfeed_channel +msgid "Channels" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__quantity_type +msgid "" +"Choose which stock quantity to export: 'Salable' uses qty_available, " +"'Virtual' uses virtual_available." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_type_combo +msgid "Combos" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__company_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__company_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__company_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__company_id +msgid "Company" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_sale_order__shoppingfeed_raw_data +msgid "Complete JSON data received from Shoppingfeed API for this order." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__country_id +msgid "Country" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_ticket_tree +msgid "Created On" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__create_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__create_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__create_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__create_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__create_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__create_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__create_uid +msgid "Created by" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__create_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__create_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__create_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__create_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__create_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__create_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__create_date +msgid "Created on" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Credentials" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_sale_order__shoppingfeed_status +msgid "Current status of the order in Shoppingfeed." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__custom_name +msgid "Custom Attribute Name" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__custom_sku_field_id +msgid "Custom SKU Attribute" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_product_attribute__shoppingfeed_code_name_attribute +msgid "Custom attribute name used when exporting products to Shoppingfeed." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__custom_name +msgid "" +"Custom name to use in the exported catalog. If empty, the field description " +"will be used." +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_log_list +msgid "Date" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__default_delivery_carrier_id +msgid "Default Delivery Carrier" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__default_order_type_id +msgid "Default Order Type" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__default_payment_mode_id +msgid "Default Payment Mode" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__default_payment_term_id +msgid "Default Payment Terms" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__default_quantity +msgid "Default Quantity" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__default_order_type_id +msgid "" +"Default Sale Order Type to assign to imported orders when no other type is " +"detected or explicitly set from the Shoppingfeed source." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__default_delivery_carrier_id +msgid "" +"Default carrier to assign to imported orders when the carrier from " +"Shoppingfeed is not mapped." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__default_payment_mode_id +msgid "Default payment mode to assign to orders when no other is provided." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__default_payment_term_id +msgid "Default payment terms to assign to orders when no other is provided." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__default_quantity +msgid "" +"Default quantity to use for products that do not manage stock or when real " +"stock is not used." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__display_name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__display_name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__display_name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__display_name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__display_name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__display_name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__display_name +msgid "Display Name" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_log__log_type__error +msgid "Error" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__error_message +msgid "Error Message" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_all_images +msgid "Export All Images" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_attribute_set_name +msgid "Export Attribute Set Name" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_disabled_products +msgid "Export Disabled Products" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_not_salable_products +msgid "Export Not Salable Products" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_only_selected +msgid "Export Only Selected" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_out_of_stock +msgid "Export Out of Stock Products" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Export Product Types" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Export Rules" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_product_product__export_to_shoppingfeed +msgid "Export To Shoppingfeed" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Exportable Products" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__exported_image_count +msgid "Exported Image Count" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_ticket__state__failed +msgid "Failed" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__field_id +msgid "Field" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__force_zero_quantity_non_salable +msgid "Force Zero Quantity for Non Salable Products" +msgstr "" + +#. module: shoppingfeed_integration +#: model:res.groups,comment:shoppingfeed_integration.group_shoppingfeed_manager +msgid "Full access to configure and manage Shoppingfeed integration." +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Sync Account" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_type_goods +msgid "Goods" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__has_message +msgid "Has Message" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__id +msgid "ID" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__message_needaction +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__message_has_error +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_all_images +msgid "" +"If enabled, all images of the product will be exported. If disabled, only " +"the main image or the number defined in 'Exported Image Count' will be " +"included in the feed." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_disabled_products +msgid "" +"If enabled, include archived (inactive) products in the Shoppingfeed export." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_not_salable_products +msgid "" +"If enabled, include products not allowed for sale (sale_ok = False) in the " +"Shoppingfeed export." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_attribute_set_name +msgid "" +"If enabled, include the attribute set name in the exported catalog feed. " +"Currently has no effect unless a custom attribute set field exists." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_only_selected +msgid "" +"If enabled, only products explicitly marked with 'Export To Shoppingfeed' " +"will be exported in the catalog feed." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__import_orders +msgid "If enabled, orders from Shoppingfeed will be imported automatically." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__update_quantities_realtime +msgid "" +"If enabled, product quantities will be pushed to Shoppingfeed immediately " +"when stock changes are detected. This may slow down inventory updates." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__force_zero_quantity_non_salable +msgid "" +"If enabled, products that are not allowed for sale (sale_ok=False) will be " +"exported with quantity 0." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_out_of_stock +msgid "" +"If enabled, products with no stock (quantity <= 0) will be included in the " +"Shoppingfeed export." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__use_actual_stock_state +msgid "" +"If enabled, real stock quantities will be used for products that manage " +"stock. Products without stock management will use the default quantity." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__use_product_id_as_sku +msgid "" +"If enabled, the internal product ID will be used as the SKU in the feed, " +"instead of the product's Default Code." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_product_product__export_to_shoppingfeed +msgid "" +"If enabled, this product will be exported to Shoppingfeed when the store is " +"configured to export only selected products." +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Images" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__import_orders +msgid "Import Orders" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_type_combo +msgid "Include combo products (type = 'combo') in the Shoppingfeed export." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_type_goods +msgid "Include goods products (type = 'consu') in the Shoppingfeed export." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__export_type_service +msgid "" +"Include service products (type = 'service') in the Shoppingfeed export." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_stock_picking__shoppingfeed_shipped +msgid "Indicates if this picking has already been sent to ShoppingFeed" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_sale_order__shoppingfeed_channel_id +msgid "Indicates the channel from which this sales order originated." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_channel__upload_invoice_not_allowed +msgid "" +"Indicates whether this channel does NOT support invoice document upload. " +"Based on Shoppingfeed API documentation — see: https://docs.shopping-" +"feed.com/#order-operations-supported-per-channel" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_account_move +msgid "Journal Entry" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__lang_id +msgid "Language" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__write_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__write_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__write_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__write_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__write_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__write_uid +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__write_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__write_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__write_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__write_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__write_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__write_date +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__write_date +msgid "Last Updated on" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Leave empty to use field description" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__link +msgid "Link" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__delivery_carrier_id +msgid "Local Odoo carrier equivalent for this Shoppingfeed carrier." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__log_type +msgid "Log Type" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.actions.act_window,help:shoppingfeed_integration.action_shoppingfeed_log +msgid "" +"Logs are created automatically when the cron job imports orders from " +"Shoppingfeed." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.module.category,description:shoppingfeed_integration.module_category_shoppingfeed +msgid "Manage Shoppingfeed integration" +msgstr "" + +#. module: shoppingfeed_integration +#: model:res.groups,name:shoppingfeed_integration.group_shoppingfeed_manager +msgid "Manager" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__channel_id +msgid "Marketplace Channel" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_store_order_type_map +msgid "Marketplace Customer Group Mapping" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__marketplace_customer_group_ids +msgid "Marketplace Customer Groups" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.actions.act_window,name:shoppingfeed_integration.action_shoppingfeed_marketplace_orders +#: model:ir.ui.menu,name:shoppingfeed_integration.menu_shoppingfeed_marketplace_orders +msgid "Marketplace Orders" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_ticket_tree +msgid "Marketplace Ref" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_sale_order__shoppingfeed_reference +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_ticket__shoppingfeed_reference +msgid "Marketplace order reference (e.g., TEST-68fb749e8cd95)." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__message +msgid "Message" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_ids +msgid "Messages" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__name +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__name +msgid "Name" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.actions.act_window,help:shoppingfeed_integration.action_shoppingfeed_ticket +msgid "No Shoppingfeed tickets found." +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.actions.act_window,help:shoppingfeed_integration.action_shoppingfeed_log +msgid "No order logs found." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__exported_image_count +msgid "" +"Number of images to export per product when 'Export All Images' is disabled." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_sale_order__shoppingfeed_order_ref +msgid "Numeric order ID from Shoppingfeed API (e.g., 21786210256)." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__delivery_carrier_id +msgid "Odoo Delivery Carrier" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__order_id +msgid "Order" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.actions.act_window,name:shoppingfeed_integration.action_shoppingfeed_log +#: model:ir.ui.menu,name:shoppingfeed_integration.menu_shoppingfeed_log +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_log_list +msgid "Order Logs" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__shoppingfeed_order_ref +msgid "Order Reference" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__order_type_id +msgid "Order Type" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Orders" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__password +msgid "Password" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__pricelist_id +msgid "Pricelist for Shoppingfeed Export" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__pricelist_id +msgid "Pricelist used to compute prices during Shoppingfeed export." +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Prices" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_product_attribute +msgid "Product Attribute" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_product_product +msgid "Product Variant" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__quantity_type +msgid "Quantity Type" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_stock_quant +msgid "Quants" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__rating_ids +msgid "Ratings" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_order_form_shoppingfeed +msgid "Raw Data" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__result_data +msgid "Result Data" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_ticket__state__running +msgid "Running" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__message_has_sms_error +msgid "SMS Delivery error" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_store__quantity_type__salable +msgid "Salable Quantity" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_ticket__state__scheduled +msgid "Scheduled" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__custom_sku_field_id +msgid "" +"Select field from product.product to be used as the SKU in the export. Leave" +" empty to use the default behavior (Default Code or Product ID)." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__additional_attribute_field_ids +msgid "" +"Select one or more fields from product.product to include as additional " +"attributes in the exported Shoppingfeed catalog. You can customize the " +"attribute name for each field." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_product_product__shoppingfeed_store_ids +msgid "Select the Shoppingfeed stores where this product should be exported." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__allowed_categ_ids +msgid "Select which product categories will be exported to Shoppingfeed." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__sequence +msgid "Sequence" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__export_type_service +msgid "Services" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Shipping" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.module.category,name:shoppingfeed_integration.module_category_shoppingfeed +#: model:ir.ui.menu,name:shoppingfeed_integration.menu_shoppingfeed_root +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_order_form_shoppingfeed +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_product_form_inherit_shoppingfeed +msgid "Shoppingfeed" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_store_carrier_map +msgid "Shoppingfeed Carrier Mapping" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__carrier_name +msgid "Shoppingfeed Carrier Name" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_channel +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_sale_order__shoppingfeed_channel_id +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_channel_form +msgid "Shoppingfeed Channel" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_product_attribute__shoppingfeed_code_name_attribute +msgid "Shoppingfeed Code Name" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_sale_order__shoppingfeed_order_ref +msgid "Shoppingfeed Order ID" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_log +msgid "Shoppingfeed Order Import Log" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_sale_order_filter_shoppingfeed +msgid "Shoppingfeed Orders" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_sale_order__shoppingfeed_raw_data +msgid "Shoppingfeed Raw Data" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_sale_order__shoppingfeed_reference +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__shoppingfeed_reference +msgid "Shoppingfeed Reference" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_stock_picking__shoppingfeed_shipped +msgid "Shoppingfeed Shipped" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_sale_order__shoppingfeed_status +msgid "Shoppingfeed Status" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_sale_order__shoppingfeed_store_id +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Shoppingfeed Store" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_store_attribute_field +msgid "Shoppingfeed Store Additional Attribute Field" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_store +msgid "Shoppingfeed Store Configuration" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.actions.act_window,name:shoppingfeed_integration.action_shoppingfeed_store +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_product_product__shoppingfeed_store_ids +msgid "Shoppingfeed Stores" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_shoppingfeed_ticket +msgid "Shoppingfeed Ticket" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.actions.act_window,name:shoppingfeed_integration.action_shoppingfeed_ticket +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_ticket_tree +msgid "Shoppingfeed Tickets" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.actions.server,name:shoppingfeed_integration.ir_cron_shoppingfeed_import_orders_ir_actions_server +msgid "Shoppingfeed: Import Orders" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.actions.server,name:shoppingfeed_integration.ir_cron_fetch_all_shoppingfeed_tickets_ir_actions_server +msgid "Shoppingfeed: Import Tickets" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_sale_order_filter_shoppingfeed +msgid "Show only orders imported from Shoppingfeed." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__state +msgid "State" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Stock" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_log__store_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_attribute_field__store_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_carrier_map__store_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store_order_type_map__store_id +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__store_id +msgid "Store" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__catalog_id +msgid "Store ID (Catalog)" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_store_form +msgid "Store Info" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__status +msgid "Store Status" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__store_ids +#: model:ir.ui.menu,name:shoppingfeed_integration.menu_shoppingfeed_store +msgid "Stores" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_ticket__state__succeed +msgid "Succeeded" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_log__log_type__success +msgid "Success" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_sale_order__shoppingfeed_store_id +msgid "The Shoppingfeed store from which this order was imported." +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.constraint,message:shoppingfeed_integration.constraint_sale_order_unique_shoppingfeed_order_ref_by_company_store +msgid "This Shoppingfeed order has already been imported!" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_ticket_tree +msgid "Ticket ID" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_ticket__ticket_type +msgid "Ticket Type" +msgstr "" + +#. module: shoppingfeed_integration +#: model_terms:ir.ui.view,arch_db:shoppingfeed_integration.view_shoppingfeed_ticket_tree +msgid "Ticket URL" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.ui.menu,name:shoppingfeed_integration.menu_shoppingfeed_ticket +msgid "Tickets" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__token_type +msgid "Token Type" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model,name:shoppingfeed_integration.model_stock_picking +msgid "Transfer" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__update_quantities_realtime +msgid "Update Quantities in Real Time" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_channel__upload_invoice_not_allowed +msgid "Upload Invoice Not Allowed" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__use_actual_stock_state +msgid "Use Actual Stock State" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__use_product_id_as_sku +msgid "Use Product ID for SKU" +msgstr "" + +#. module: shoppingfeed_integration +#: model:res.groups,name:shoppingfeed_integration.group_shoppingfeed_user +msgid "User" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__username +msgid "Username" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields.selection,name:shoppingfeed_integration.selection__shoppingfeed_store__quantity_type__virtual +msgid "Virtual Quantity" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,field_description:shoppingfeed_integration.field_shoppingfeed_store__website_message_ids +msgid "Website Messages" +msgstr "" + +#. module: shoppingfeed_integration +#: model:ir.model.fields,help:shoppingfeed_integration.field_shoppingfeed_store__website_message_ids +msgid "Website communication history" +msgstr "" diff --git a/shoppingfeed_integration/migrations/18.0.1.0.1/pre-migration.py b/shoppingfeed_integration/migrations/18.0.1.0.1/pre-migration.py new file mode 100644 index 0000000..3b33c11 --- /dev/null +++ b/shoppingfeed_integration/migrations/18.0.1.0.1/pre-migration.py @@ -0,0 +1,31 @@ +M2M_TABLE = "shoppingfeed_channel_shoppingfeed_store_rel" + + +def migrate(cr, version): + if not version: + return + cr.execute( + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = %s)", + (M2M_TABLE,), + ) + if not cr.fetchone()[0]: + return + cr.execute( + """ + ALTER TABLE shoppingfeed_channel + ADD COLUMN IF NOT EXISTS store_id INTEGER + REFERENCES shoppingfeed_store(id) ON DELETE CASCADE + """ + ) + cr.execute( + """ + UPDATE shoppingfeed_channel c + SET store_id = ( + SELECT shoppingfeed_store_id + FROM shoppingfeed_channel_shoppingfeed_store_rel + WHERE shoppingfeed_channel_id = c.id + LIMIT 1 + ) + """ + ) + cr.execute("DELETE FROM shoppingfeed_channel WHERE store_id IS NULL") diff --git a/shoppingfeed_integration/migrations/18.0.1.0.2/pre-migration.py b/shoppingfeed_integration/migrations/18.0.1.0.2/pre-migration.py new file mode 100644 index 0000000..05b14f3 --- /dev/null +++ b/shoppingfeed_integration/migrations/18.0.1.0.2/pre-migration.py @@ -0,0 +1,16 @@ +def migrate(cr, version): + if not version: + return + cr.execute( + """ + ALTER TABLE shoppingfeed_channel + ADD COLUMN IF NOT EXISTS sf_channel_name VARCHAR + """ + ) + cr.execute( + """ + UPDATE shoppingfeed_channel + SET sf_channel_name = name + WHERE sf_channel_name IS NULL + """ + ) diff --git a/shoppingfeed_integration/models/__init__.py b/shoppingfeed_integration/models/__init__.py new file mode 100644 index 0000000..52bcb8b --- /dev/null +++ b/shoppingfeed_integration/models/__init__.py @@ -0,0 +1,15 @@ +from . import shoppingfeed_channel +from . import shoppingfeed_channel_country_carrier +from . import shoppingfeed_store_order_type_map +from . import shoppingfeed_store_carrier_map +from . import shoppingfeed_store_attribute_field +from . import shoppingfeed_store +from . import shoppingfeed_log +from . import product_template +from . import product_pricelist +from . import stock_quant +from . import sale_order +from . import stock_picking +from . import shoppingfeed_ticket +from . import account_move +from . import product_attribute diff --git a/shoppingfeed_integration/models/account_move.py b/shoppingfeed_integration/models/account_move.py new file mode 100644 index 0000000..c9600dd --- /dev/null +++ b/shoppingfeed_integration/models/account_move.py @@ -0,0 +1,84 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import io +import json + +import requests + +from odoo import models + + +class AccountMove(models.Model): + _inherit = "account.move" + + def _shoppingfeed_upload_invoice(self): + for move in self: + if move.move_type != "out_invoice": + continue + sale_orders = move.line_ids.sale_line_ids.order_id.filtered( + lambda so, move=move: so.company_id == move.company_id + ) + sale_order = sale_orders[:1] + if ( + not sale_order + or not sale_order.shoppingfeed_order_ref + or not sale_order.shoppingfeed_store_id + or not sale_order.shoppingfeed_channel_id + or sale_order.shoppingfeed_channel_id.upload_invoice_not_allowed + ): + continue + store = sale_order.shoppingfeed_store_id + if not store.access_token or not store.catalog_id: + continue + if store._shoppingfeed_is_demo_mode(): + continue + pdf_content, _ = self.env["ir.actions.report"]._render_qweb_pdf( + "account.account_invoices", move.ids + ) + pdf_file = io.BytesIO(pdf_content) + pdf_file.name = f"{move.name or 'invoice'}.pdf" + url = f"https://api.shopping-feed.com/v1/store/{store.catalog_id}/order/upload-documents" + headers = { + "Authorization": store.access_token, + } + payload = { + "order": [ + { + "id": int(sale_order.shoppingfeed_order_ref), + "documents": [{"type": "invoice"}], + } + ] + } + files = {"files[]": (pdf_file.name, pdf_file, "application/pdf")} + data = {"body": json.dumps(payload)} + requests.post(url, headers=headers, files=files, data=data, timeout=30) + + def _shoppingfeed_auto_pay(self): + for move in self: + if move.move_type != "out_invoice" or move.payment_state == "paid": + continue + sale_orders = move.line_ids.sale_line_ids.order_id.filtered( + lambda so, move=move: so.company_id == move.company_id + ) + sale_order = sale_orders[:1] + channel = sale_order.shoppingfeed_channel_id + if not sale_order or not channel or not channel.auto_pay: + continue + if ( + not move.preferred_payment_method_line_id + and channel.payment_method_line_id + ): + move.preferred_payment_method_line_id = channel.payment_method_line_id + if not move.preferred_payment_method_line_id: + continue + self.env["account.payment.register"].with_context( + active_model="account.move", + active_ids=move.ids, + ).create({})._create_payments() + + def action_post(self): + res = super().action_post() + self._shoppingfeed_upload_invoice() + self._shoppingfeed_auto_pay() + return res diff --git a/shoppingfeed_integration/models/product_attribute.py b/shoppingfeed_integration/models/product_attribute.py new file mode 100644 index 0000000..cc7bb5a --- /dev/null +++ b/shoppingfeed_integration/models/product_attribute.py @@ -0,0 +1,18 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ProductAttribute(models.Model): + _inherit = "product.attribute" + + shoppingfeed_code_name_attribute = fields.Char( + string="Shoppingfeed Code Name", + help="Custom attribute name used when exporting products to Shoppingfeed.", + ) + shoppingfeed_export = fields.Boolean( + string="Export to Shoppingfeed", + default=True, + help="If checked, this attribute will be included when generating the " + "catalog for Shoppingfeed.", + ) diff --git a/shoppingfeed_integration/models/product_pricelist.py b/shoppingfeed_integration/models/product_pricelist.py new file mode 100644 index 0000000..b11a490 --- /dev/null +++ b/shoppingfeed_integration/models/product_pricelist.py @@ -0,0 +1,14 @@ +# Copyright 2026 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ProductPricelist(models.Model): + _inherit = "product.pricelist" + + shoppingfeed_attribute_name = fields.Char( + help=( + "Name to use for this pricelist when exported as an attribute " + "in Shoppingfeed catalogs." + ), + ) diff --git a/shoppingfeed_integration/models/product_template.py b/shoppingfeed_integration/models/product_template.py new file mode 100644 index 0000000..e584999 --- /dev/null +++ b/shoppingfeed_integration/models/product_template.py @@ -0,0 +1,40 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + export_to_shoppingfeed = fields.Boolean( + default=False, + help="If enabled, this product will be exported to Shoppingfeed " + "when the store is configured to export only selected products.", + ) + shoppingfeed_store_ids = fields.Many2many( + comodel_name="shoppingfeed.store", + string="Shoppingfeed Stores", + help="Select the Shoppingfeed stores where this product should be exported.", + ) + sf_forced_category_id = fields.Many2one( + comodel_name="product.public.category", + string="Forced Category", + help="If set, this category will always be used when exporting to " + "Shoppingfeed, overriding the automatic category selection.", + ) + sf_forced_category_parent_id = fields.Many2one( + comodel_name="product.public.category", + string="Forced Category Parent", + compute="_compute_sf_forced_category_parent_id", + ) + + @api.depends("sf_forced_category_id", "sf_forced_category_id.parent_id") + def _compute_sf_forced_category_parent_id(self): + for rec in self: + cat = rec.sf_forced_category_id + rec.sf_forced_category_parent_id = cat.parent_id or cat + + def _shoppingfeed_update_pricing(self): + # TODO: implement real-time price push to Shoppingfeed API + pass diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py new file mode 100644 index 0000000..c590ef2 --- /dev/null +++ b/shoppingfeed_integration/models/sale_order.py @@ -0,0 +1,402 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import json +from datetime import datetime, timezone + +import requests +from markupsafe import Markup + +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + shoppingfeed_order_ref = fields.Char( + string="Shoppingfeed Order ID", + help="Numeric order ID from Shoppingfeed API (e.g., 21786210256).", + copy=False, + index=True, + readonly=True, + ) + shoppingfeed_reference = fields.Char( + help="Marketplace order reference (e.g., TEST-68fb749e8cd95).", + copy=False, + index=True, + readonly=True, + ) + shoppingfeed_store_id = fields.Many2one( + comodel_name="shoppingfeed.store", + string="Shoppingfeed Store", + help="The Shoppingfeed store from which this order was imported.", + copy=False, + readonly=True, + index=True, + ) + shoppingfeed_channel_id = fields.Many2one( + comodel_name="shoppingfeed.channel", + string="Shoppingfeed Channel", + help="Indicates the channel from which this sales order originated.", + copy=False, + readonly=True, + ) + shoppingfeed_status = fields.Char( + readonly=True, + help="Current status of the order in Shoppingfeed.", + copy=False, + ) + shoppingfeed_raw_data = fields.Text( + readonly=True, + copy=False, + help="Complete JSON data received from Shoppingfeed API for this order.", + ) + + _sql_constraints = [ + ( + "unique_shoppingfeed_order_ref_by_company_store", + "unique(shoppingfeed_order_ref, company_id, shoppingfeed_store_id)", + "This Shoppingfeed order has already been imported!", + ), + ] + + def _shoppingfeed_fetch_orders(self, store): + # Fetch only unacknowledged orders from Shoppingfeed for the given store. + url = f"https://api.shopping-feed.com/v1/store/{store.catalog_id}/order" + headers = { + "Authorization": store.access_token, + "Content-Type": "application/json", + } + params = {"acknowledgment": "unacknowledged", "status": "waiting_shipment"} + response = requests.get(url, headers=headers, params=params, timeout=30) + return response.json().get("_embedded", {}).get("order", []) + + def _shoppingfeed_prepare_partner( + self, billing, store, channel=None, additional_fields=None + ): + # Always create a new billing partner from Shoppingfeed data. + company = billing.get("company") or "" + if company: + name = company + is_company = True + else: + first = billing.get("firstName") or "" + last = billing.get("lastName") or "" + name = ( + f"{first} {last}".strip() + or billing.get("email") + or "Shoppingfeed Customer" + ) + is_company = False + vals = { + "name": name, + "is_company": is_company, + "email": billing.get("email"), + "street": billing.get("street") or "", + "street2": billing.get("street2"), + "zip": billing.get("postalCode") or "", + "city": billing.get("city"), + "phone": billing.get("phone") or billing.get("mobilePhone"), + "country_id": self.env["res.country"] + .search([("code", "=", billing.get("country"))], limit=1) + .id, + "company_id": store.company_id.id, + } + af = additional_fields or {} + vat = ( + af.get("buyer_tax_registration_id") + or af.get("mms-customer-tax-id") + or af.get("buyer_identification_number") + or af.get("buyer_identifier_number") + ) + if vat: + vals["vat"] = vat + if channel and channel.account_id: + vals["property_account_receivable_id"] = channel.account_id.id + if channel and channel.payment_method_line_id: + vals["property_inbound_payment_method_line_id"] = ( + channel.payment_method_line_id.id + ) + return self.env["res.partner"].create(vals) + + def _shoppingfeed_prepare_shipping(self, shipping, partner, store): + # Prepare or create delivery address for the Shoppingfeed order. + if not shipping: + return None + shipping_vals = { + "parent_id": partner.id, + "type": "delivery", + "name": ( + f"{shipping.get('firstName', '')} " f"{shipping.get('lastName', '')}" + ).strip() + or "Shipping Address", + "street": shipping.get("street") or "", + "street2": shipping.get("street2"), + "zip": shipping.get("postalCode") or "", + "city": shipping.get("city"), + "phone": shipping.get("phone") or shipping.get("mobilePhone"), + "email": shipping.get("email"), + "country_id": self.env["res.country"] + .search([("code", "=", shipping.get("country"))], limit=1) + .id, + "company_id": store.company_id.id, + } + shipping_partner = self.env["res.partner"].search( + [ + ("parent_id", "=", partner.id), + ("type", "=", "delivery"), + ("street", "=", shipping_vals["street"]), + ("zip", "=", shipping_vals["zip"]), + ], + limit=1, + ) or self.env["res.partner"].create(shipping_vals) + return shipping_partner + + def _shoppingfeed_clean_product_reference(self, reference): + if not reference or "_" not in reference: + return reference + parts = reference.rsplit("_", 1) + if len(parts) == 2 and len(parts[1]) == 2: + return parts[0] + return reference + + def _shoppingfeed_resolve_product_reference(self, item_reference, aliases=None): + """Resolve product reference considering aliases from itemsReferencesAliases. + When a SKU is manually edited in ShoppingFeed, the old SKU remains in the + item reference but a mapping is created in itemsReferencesAliases pointing + to the new SKU. This method resolves the correct SKU to use. + """ + # Check if there's an alias for this reference + if aliases and item_reference in aliases: + return self._shoppingfeed_clean_product_reference(aliases[item_reference]) + # Return the cleaned original reference + return self._shoppingfeed_clean_product_reference(item_reference) + + def _shoppingfeed_acknowledge_order(self, store, order): + # Acknowledge the imported order to Shoppingfeed. + if store._shoppingfeed_is_demo_mode(): + return + url = f"https://api.shopping-feed.com/v1/store/{store.catalog_id}/order/acknowledge" + headers = { + "Authorization": store.access_token, + "Content-Type": "application/json", + } + payload = { + "order": [ + { + "id": int(order.get("id")), + "status": "success", + "acknowledgedAt": datetime.now(timezone.utc).isoformat(), + } + ] + } + requests.post(url, json=payload, headers=headers, timeout=30) + + def _create_shoppingfeed_log(self, store, order, channel, error_message): + self.env["shoppingfeed.log"].create( + { + "name": order.get("reference") or f"Order {order.get('id')}", + "store_id": store.id, + "channel_id": channel.id if channel else False, + "shoppingfeed_reference": order.get("reference"), + "error_message": error_message, + } + ) + + def _shoppingfeed_validate_products(self, order): + # Validate that all products exist before creating the order. + missing_products = [] + aliases = order.get("itemsReferencesAliases", {}) + for item in order.get("items", []): + ref = item.get("reference") + clean_ref = self._shoppingfeed_resolve_product_reference(ref, aliases) + product = self.env["product.product"].search( + [("default_code", "=", clean_ref)], limit=1 + ) + if not product: + missing_products.append(clean_ref) + return missing_products + + def _shoppingfeed_get_carrier( + self, store, channel, carrier_name, shipping_country=None + ): + carrier = False + if carrier_name: + carrier_map = store.carrier_map_ids.filtered( + lambda m, carrier_name=carrier_name: m.carrier_name.strip().lower() + == carrier_name.strip().lower() + ) + if carrier_map: + carrier = carrier_map.delivery_carrier_id + if not carrier: + if channel and shipping_country and channel.country_carrier_ids: + country_rule = channel.country_carrier_ids.filtered( + lambda r, c=shipping_country: r.country_id == c + ) + if country_rule: + carrier = country_rule[0].delivery_carrier_id + if not carrier: + if channel and channel.default_delivery_carrier_id: + carrier = channel.default_delivery_carrier_id + elif store.default_delivery_carrier_id: + carrier = store.default_delivery_carrier_id + return carrier + + def _shoppingfeed_prepare_order_line(self, item, aliases=None): + ref = item.get("reference") + clean_ref = self._shoppingfeed_resolve_product_reference(ref, aliases) + product = self.env["product.product"].search( + [("default_code", "=", clean_ref)], limit=1 + ) + if not product: + return False + return { + "product_id": product.id, + "product_uom_qty": item.get("quantity", 1.0), + "price_unit": item.get("price", product.list_price), + "name": item.get("name") or product.display_name, + } + + def _shoppingfeed_create_sale_order( + self, + store, + order, + partner, + shipping_partner, + sf_channel, + order_type_id, + carrier, + ): + # Create sale order and lines from Shoppingfeed data. + ext_id = str(order.get("id")) + currency = self.env["res.currency"].search( + [("name", "=", order.get("payment", {}).get("currency", "EUR"))], + limit=1, + ) + aliases = order.get("itemsReferencesAliases", {}) + order_lines = [] + for item in order.get("items", []): + line_vals = self._shoppingfeed_prepare_order_line(item, aliases) + if line_vals: + order_lines.append((0, 0, line_vals)) + sale_order = self.create( + { + "partner_id": partner.id, + "partner_invoice_id": partner.id, + "partner_shipping_id": shipping_partner.id + if shipping_partner + else partner.id, + "shoppingfeed_reference": order.get("reference"), + "currency_id": currency.id, + "shoppingfeed_order_ref": ext_id, + "shoppingfeed_store_id": store.id, + "type_id": order_type_id or store.default_order_type_id.id, + "payment_mode_id": store.default_payment_mode_id.id, + "payment_term_id": store.default_payment_term_id.id, + "shoppingfeed_channel_id": sf_channel.id, + "shoppingfeed_status": order.get("status"), + "company_id": store.company_id.id, + "shoppingfeed_raw_data": json.dumps( + order, indent=2, ensure_ascii=False + ), + "order_line": order_lines, + } + ) + if carrier: + payment = order.get("payment", {}) or {} + shipping_cost = payment.get("shippingAmount", 0.0) + sale_order.set_delivery_line(carrier, shipping_cost) + link = ( + f"https://app.shopping-feed.com/v3/es/orders/detail/" + f"{order.get('id')}?store={order.get('storeId')}" + ) + body = Markup( + "

Order created automatically by Shoppingfeed.

" + f"

Marketplace: {sf_channel.name or 'Unknown'}

" + f"

" + "View order in Shoppingfeed

" + ) + sale_order.message_post( + body=body, message_type="comment", subtype_xmlid="mail.mt_note" + ) + return sale_order + + @api.model + def action_import_from_shoppingfeed(self): + stores = ( + self.env["shoppingfeed.store"] + .sudo() + .search( + [ + ("access_token", "!=", False), + ("catalog_id", "!=", False), + ] + ) + ) + for store in stores: + self.with_company(store.company_id)._import_orders_from_shoppingfeed(store) + + def _import_orders_from_shoppingfeed(self, store): + if not store.import_orders: + return + for order in self._shoppingfeed_fetch_orders(store): + ext_id = str(order.get("id")) + if self.search([("shoppingfeed_order_ref", "=", ext_id)], limit=1): + continue + channel = (order.get("_embedded") or {}).get("channel", {}) + channel_name = channel.get("name", "Unknown Marketplace") + sf_channel = ( + self.env["shoppingfeed.channel"] + .sudo() + .search( + [ + ("sf_channel_name", "=", channel_name), + ("store_id", "=", store.id), + ], + limit=1, + ) + ) + missing_products = self._shoppingfeed_validate_products(order) + if missing_products: + error_msg = f"Products not found: {', '.join(missing_products)}" + self._create_shoppingfeed_log(store, order, sf_channel, error_msg) + continue + try: + with self.env.cr.savepoint(): + billing = order.get("billingAddress", {}) or {} + additional_fields = order.get("additionalFields", {}) or {} + partner = self._shoppingfeed_prepare_partner( + billing, store, sf_channel, additional_fields + ) + shipping_partner = self._shoppingfeed_prepare_shipping( + order.get("shippingAddress", {}), partner, store + ) + # Type mapping + mapping = store.marketplace_customer_group_ids.filtered( + lambda m, + channel_name=channel_name: m.channel_id.sf_channel_name + == channel_name + ) + order_type_id = mapping.order_type_id.id if mapping else False + # Carrier mapping + shipment = order.get("shipment", {}) or {} + carrier_name = shipment.get("carrier") + shipping_country = ( + shipping_partner.country_id if shipping_partner else None + ) + carrier = self._shoppingfeed_get_carrier( + store, sf_channel, carrier_name, shipping_country + ) + self._shoppingfeed_create_sale_order( + store, + order, + partner, + shipping_partner, + sf_channel, + order_type_id, + carrier, + ) + self._shoppingfeed_acknowledge_order(store, order) + except Exception as e: + error_msg = f"Error creating order: {str(e)}" + self._create_shoppingfeed_log(store, order, sf_channel, error_msg) + continue diff --git a/shoppingfeed_integration/models/shoppingfeed_channel.py b/shoppingfeed_integration/models/shoppingfeed_channel.py new file mode 100644 index 0000000..eb365c5 --- /dev/null +++ b/shoppingfeed_integration/models/shoppingfeed_channel.py @@ -0,0 +1,96 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ShoppingfeedChannel(models.Model): + _name = "shoppingfeed.channel" + _description = "Shoppingfeed Channel" + _sql_constraints = [ + ( + "unique_sf_channel_name_per_store", + "UNIQUE(sf_channel_name, store_id)", + "API channel name must be unique per store.", + ) + ] + + name = fields.Char(required=True) + sf_channel_name = fields.Char( + readonly=True, + copy=False, + help=( + "Channel identifier as received from the Shoppingfeed API. " + "Used internally to match incoming orders to this channel. " + "Set automatically on account sync." + ), + ) + store_id = fields.Many2one( + comodel_name="shoppingfeed.store", + string="Store", + required=True, + ondelete="cascade", + ) + company_id = fields.Many2one( + comodel_name="res.company", + related="store_id.company_id", + store=True, + ) + default_delivery_carrier_id = fields.Many2one( + comodel_name="delivery.carrier", + string="Default Delivery Carrier", + help=( + "Default carrier to assign to imported orders from this channel " + "when the carrier from Shoppingfeed is not mapped. " + "If not set, the store's default carrier will be used instead." + ), + ) + country_carrier_ids = fields.One2many( + comodel_name="shoppingfeed.channel.country.carrier", + inverse_name="channel_id", + string="Carriers by Country", + help=( + "Override the default carrier based on the delivery country. " + "If the order's destination country matches one of these rules, " + "the specified carrier will be used instead of the channel default." + ), + ) + upload_invoice_not_allowed = fields.Boolean( + help=( + "Indicates whether this channel does NOT support invoice document upload. " + "Based on Shoppingfeed API documentation — see: " + "https://docs.shopping-feed.com/#order-operations-supported-per-channel" + ), + default=False, + ) + payment_method_line_id = fields.Many2one( + comodel_name="account.payment.method.line", + string="Payment Method", + domain="""[ + ('journal_id.active', '=', True), + ('payment_type', '=', 'inbound'), + ('company_id', 'parent_of', company_id), + ]""", + help=( + "Inbound payment method used to register the marketplace payment " + "when an order is imported from this channel. " + "The journal is derived from the selected payment method." + ), + ) + auto_pay = fields.Boolean( + string="Automatic Payment", + default=True, + help=( + "When enabled, invoices from this channel are automatically paid " + "upon confirmation using the configured payment method." + ), + ) + account_id = fields.Many2one( + comodel_name="account.account", + string="Receivable Account", + domain=[("account_type", "=", "asset_receivable")], + help=( + "Receivable account assigned to partners created from orders of this" + " channel." + ), + ) diff --git a/shoppingfeed_integration/models/shoppingfeed_channel_country_carrier.py b/shoppingfeed_integration/models/shoppingfeed_channel_country_carrier.py new file mode 100644 index 0000000..b9713d0 --- /dev/null +++ b/shoppingfeed_integration/models/shoppingfeed_channel_country_carrier.py @@ -0,0 +1,25 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ShoppingfeedChannelCountryCarrier(models.Model): + _name = "shoppingfeed.channel.country.carrier" + _description = "Shoppingfeed Channel Carrier by Country" + + channel_id = fields.Many2one( + comodel_name="shoppingfeed.channel", + required=True, + ondelete="cascade", + ) + country_id = fields.Many2one( + comodel_name="res.country", + string="Delivery Country", + required=True, + ) + delivery_carrier_id = fields.Many2one( + comodel_name="delivery.carrier", + string="Carrier", + required=True, + ) diff --git a/shoppingfeed_integration/models/shoppingfeed_log.py b/shoppingfeed_integration/models/shoppingfeed_log.py new file mode 100644 index 0000000..a1c1a53 --- /dev/null +++ b/shoppingfeed_integration/models/shoppingfeed_log.py @@ -0,0 +1,30 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ShoppingfeedLog(models.Model): + _name = "shoppingfeed.log" + _description = "Shoppingfeed Order Import Log" + _order = "create_date desc" + + name = fields.Char(required=True, index=True) + store_id = fields.Many2one( + comodel_name="shoppingfeed.store", + string="Store", + ondelete="cascade", + index=True, + ) + channel_id = fields.Many2one( + comodel_name="shoppingfeed.channel", + string="Channel", + ondelete="set null", + ) + company_id = fields.Many2one( + comodel_name="res.company", + related="store_id.company_id", + store=True, + readonly=True, + ) + shoppingfeed_reference = fields.Char(index=True) + error_message = fields.Text() diff --git a/shoppingfeed_integration/models/shoppingfeed_store.py b/shoppingfeed_integration/models/shoppingfeed_store.py new file mode 100644 index 0000000..c529e88 --- /dev/null +++ b/shoppingfeed_integration/models/shoppingfeed_store.py @@ -0,0 +1,435 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import requests + +from odoo import api, fields, models +from odoo.exceptions import AccessError + + +class ShoppingfeedStore(models.Model): + _name = "shoppingfeed.store" + _inherit = ["mail.thread"] + _description = "Shoppingfeed Store Configuration" + + name = fields.Char() + country_id = fields.Many2one("res.country", string="Country") + company_id = fields.Many2one( + comodel_name="res.company", default=lambda self: self.env.company, required=True + ) + username = fields.Char(required=True) + password = fields.Char(required=True) + access_token = fields.Char(readonly=True) + token_type = fields.Char(readonly=True) + catalog_id = fields.Char(string="Store ID (Catalog)", tracking=True) + channel_ids = fields.One2many( + comodel_name="shoppingfeed.channel", + inverse_name="store_id", + string="Channels", + tracking=True, + ) + marketplace_customer_group_ids = fields.One2many( + comodel_name="shoppingfeed.store.order.type.map", + inverse_name="store_id", + string="Marketplace Customer Groups", + ) + lang_id = fields.Many2one("res.lang", string="Language", tracking=True) + email = fields.Char(string="Account Email") + status = fields.Char(string="Store Status", tracking=True) + website_id = fields.Many2one( + comodel_name="website", + string="Canonical Website", + help=( + "Website used to build product, category and image URLs in the " + "catalog feed (and the feed URL itself). If empty, the system " + "falls back to the global 'web.base.url' parameter and to the " + "host the request comes from." + ), + ) + feed_url = fields.Char( + string="Catalog Feed URL", + compute="_compute_feed_url", + readonly=True, + tracking=True, + ) + export_only_selected = fields.Boolean( + help="If enabled, only products explicitly marked with " + "'Export To Shoppingfeed' will be exported in the catalog feed.", + default=True, + tracking=True, + ) + export_type_goods = fields.Boolean( + string="Goods", + default=True, + help="Include goods products (type = 'consu') in the Shoppingfeed export.", + ) + export_type_service = fields.Boolean( + string="Services", + default=False, + tracking=True, + help="Include service products (type = 'service') in the Shoppingfeed export.", + ) + export_type_combo = fields.Boolean( + string="Combos", + default=False, + tracking=True, + help="Include combo products (type = 'combo') in the Shoppingfeed export.", + ) + export_out_of_stock = fields.Boolean( + string="Export Out of Stock Products", + default=True, + tracking=True, + help=( + "If enabled, products with no stock (quantity <= 0) will be included " + "in the Shoppingfeed export." + ), + ) + export_disabled_products = fields.Boolean( + default=False, + tracking=True, + help=( + "If enabled, include archived (inactive) products in the " + "Shoppingfeed export." + ), + ) + export_not_salable_products = fields.Boolean( + default=False, + tracking=True, + help=( + "If enabled, include products not allowed for sale (sale_ok = False) " + "in the Shoppingfeed export." + ), + ) + use_actual_stock_state = fields.Boolean( + default=True, + tracking=True, + help=( + "If enabled, real stock quantities will be used for products that " + "manage stock. Products without stock management will use the " + "default quantity." + ), + ) + quantity_type = fields.Selection( + [ + ("salable", "Salable Quantity"), + ("virtual", "Virtual Quantity"), + ], + default="salable", + tracking=True, + help=( + "Choose which stock quantity to export: " + "'Salable' uses qty_available, 'Virtual' uses virtual_available." + ), + ) + default_quantity = fields.Integer( + default=100, + tracking=True, + help=( + "Default quantity to use for products that do not manage stock or " + "when real stock is not used." + ), + ) + force_zero_quantity_non_salable = fields.Boolean( + string="Force Zero Quantity for Non Salable Products", + default=False, + tracking=True, + help=( + "If enabled, products that are not allowed for sale (sale_ok=False) " + "will be exported with quantity 0." + ), + ) + update_quantities_realtime = fields.Boolean( + string="Update Quantities in Real Time", + default=True, + tracking=True, + help=( + "If enabled, product quantities will be pushed to Shoppingfeed immediately " + "when stock changes are detected. " + "This may slow down inventory updates." + ), + ) + update_prices_realtime = fields.Boolean( + string="Update Prices in Real Time", + default=True, + tracking=True, + help=( + "If enabled, product prices will be pushed to Shoppingfeed immediately " + "when price changes are detected." + ), + ) + pricelist_id = fields.Many2one( + comodel_name="product.pricelist", + string="Pricelist for Shoppingfeed Export", + tracking=True, + help="Pricelist used to compute prices during Shoppingfeed export.", + ) + include_taxes_in_price = fields.Boolean( + string="Include Taxes in Price", + default=False, + tracking=True, + help="If enabled, the exported price will include customer taxes.", + ) + export_price_without_tax = fields.Boolean( + string="Export Price Without Tax as Attribute", + default=False, + tracking=True, + help="If enabled, the price without tax will be added as an attribute.", + ) + price_without_tax_attribute_name = fields.Char( + string="Attribute Name for Price Without Tax", + default="price_without_tax", + tracking=True, + help="Name of the attribute for the price without tax.", + ) + additional_pricelist_ids = fields.Many2many( + comodel_name="product.pricelist", + relation="shoppingfeed_store_pricelist_rel", + column1="store_id", + column2="pricelist_id", + string="Additional Pricelists", + tracking=True, + help="Additional pricelists to export as price attributes in the catalog.", + ) + use_product_id_as_sku = fields.Boolean( + string="Use Product ID for SKU", + default=False, + tracking=True, + help=( + "If enabled, the internal product ID will be used as the SKU in the feed, " + "instead of the product's Default Code." + ), + ) + custom_sku_field_id = fields.Many2one( + comodel_name="ir.model.fields", + string="Custom SKU Attribute", + domain="[('model', '=', 'product.product'), ('ttype', 'in', ['char', 'text'])]", + tracking=True, + help=( + "Select field from product.product to be used as the SKU in the export. " + "Leave empty to use the default behavior (Default Code or Product ID)." + ), + ) + additional_attribute_field_ids = fields.One2many( + comodel_name="shoppingfeed.store.attribute.field", + inverse_name="store_id", + string="Additional Attributes", + help=( + "Select one or more fields from product.product to include as " + "additional attributes in the exported Shoppingfeed catalog. " + "You can customize the attribute name for each field." + ), + ) + # TODO: Currently has no effect unless a custom attribute set field exists. + export_attribute_set_name = fields.Boolean( + default=False, + tracking=True, + help=( + "If enabled, include the attribute set name in the exported catalog feed. " + "Currently has no effect unless a custom attribute set field exists." + ), + ) + export_all_images = fields.Boolean( + default=True, + tracking=True, + help=( + "If enabled, all images of the product will be exported. " + "If disabled, only the main image or the number defined " + "in 'Exported Image Count' will be included in the feed." + ), + ) + exported_image_count = fields.Integer( + default=1, + tracking=True, + help=( + "Number of images to export per product when 'Export All Images' " + "is disabled." + ), + ) + allowed_categ_ids = fields.Many2many( + comodel_name="product.public.category", + string="Category Selection", + help="Select which product categories will be exported to Shoppingfeed.", + ) + import_orders = fields.Boolean( + default=True, + tracking=True, + help="If enabled, orders from Shoppingfeed will be imported automatically.", + ) + default_order_type_id = fields.Many2one( + comodel_name="sale.order.type", + string="Default Order Type", + tracking=True, + help=( + "Default Sale Order Type to assign to imported orders when no other type " + "is detected or explicitly set from the Shoppingfeed source." + ), + ) + default_payment_mode_id = fields.Many2one( + comodel_name="account.payment.mode", + string="Default Payment Mode", + tracking=True, + help="Default payment mode to assign to orders when no other is provided.", + ) + default_payment_term_id = fields.Many2one( + comodel_name="account.payment.term", + string="Default Payment Terms", + tracking=True, + help="Default payment terms to assign to orders when no other is provided.", + ) + carrier_map_ids = fields.One2many( + comodel_name="shoppingfeed.store.carrier.map", + inverse_name="store_id", + string="Carrier Mappings", + ) + default_delivery_carrier_id = fields.Many2one( + comodel_name="delivery.carrier", + string="Default Delivery Carrier", + help=( + "Default carrier to assign to imported orders " + "when the carrier from Shoppingfeed is not mapped." + ), + ) + notify_shipment = fields.Boolean( + string="Notify Shipment to ShoppingFeed", + default=True, + tracking=True, + help=( + "If enabled, shipment notifications will be sent to ShoppingFeed " + "when orders are shipped." + ), + ) + demo_mode = fields.Boolean( + default=False, + copy=False, + tracking=True, + help=( + "When active, ALL outgoing write requests to the ShoppingFeed API " + "are silently blocked: order acknowledgement, inventory updates, " + "shipment notifications and invoice uploads are suppressed. " + "Orders will still be imported from ShoppingFeed (GET requests " + "are not affected). Use this to test the integration without " + "affecting production data in ShoppingFeed." + ), + ) + + @api.depends("catalog_id", "website_id") + def _compute_feed_url(self): + for store in self: + base_url = store.get_base_url().rstrip("/") + store.feed_url = ( + f"{base_url}/catalog/{store.catalog_id}.xml" if store.catalog_id else "" + ) + + def action_toggle_demo_mode(self): + self.env["res.users"].check_access_rights("write") + if not self.env.user.has_group("base.group_system"): + raise AccessError( + self.env._( + "Only administrators can toggle the demo mode " + "on a Shoppingfeed store." + ) + ) + for store in self: + store.demo_mode = not store.demo_mode + + def _shoppingfeed_is_demo_mode(self): + self.ensure_one() + return bool(self.demo_mode) + + def action_get_access_token(self): + for store in self: + self._authenticate_with_shoppingfeed(store) + self._fetch_account_info(store) + + def _authenticate_with_shoppingfeed(self, store): + payload = { + "grant_type": "password", + "username": store.username, + "password": store.password, + } + headers = {"Content-Type": "application/json"} + auth_response = requests.post( + "https://api.shopping-feed.com/v1/auth", + json=payload, + headers=headers, + timeout=20, + ) + if auth_response.status_code != 200: + raise ValueError(f"Authentication failed: {auth_response.text}") + auth_data = auth_response.json() + access_token = auth_data.get("access_token") + token_type = auth_data.get("token_type") + if not access_token: + raise ValueError("No access token received from Shoppingfeed.") + store.write({"access_token": access_token, "token_type": token_type}) + + def _fetch_account_info(self, store): + # Retrieve store info, language, and channels from Shoppingfeed. + info_headers = { + "Content-Type": "application/json", + "Authorization": store.access_token, + } + info_response = requests.get( + "https://api.shopping-feed.com/v1/me", + headers=info_headers, + timeout=20, + ) + if info_response.status_code != 200: + raise ValueError(f"Failed to retrieve account info: {info_response.text}") + info_data = info_response.json() + self._update_store_from_info(store, info_data, info_headers) + + def _update_store_from_info(self, store, info_data, info_headers): + email = info_data.get("email") + embedded = info_data.get("_embedded", {}) + stores = embedded.get("store", []) + if not stores: + return + sf_store = stores[0] + catalog_id = str(sf_store.get("id")) + country_code = sf_store.get("country") + name = sf_store.get("name") + status = sf_store.get("status") + country = self.env["res.country"].search([("code", "=", country_code)], limit=1) + store.write( + { + "catalog_id": catalog_id, + "name": name or store.name, + "status": status, + "country_id": country.id if country else False, + "email": email, + } + ) + self._fetch_and_update_channels(store, sf_store, info_headers) + + def _fetch_and_update_channels(self, store, sf_store, info_headers): + # Fetch and update installed channels for this store. + channel_link = sf_store.get("_links", {}).get("channel", {}).get("href") + if not channel_link: + return + if not channel_link.startswith("http"): + channel_link = f"https://api.shopping-feed.com{channel_link}" + channel_response = requests.get(channel_link, headers=info_headers, timeout=20) + if channel_response.status_code != 200: + return + data = channel_response.json() + store_channels = data.get("_embedded", {}).get("storeChannel", []) + # Collect installed channel names from API + installed_names = { + channel_data["name"] + for sc in store_channels + if sc.get("installed") + for channel_data in [sc.get("_embedded", {}).get("channel", {})] + if channel_data.get("name") + } + Channel = self.env["shoppingfeed.channel"].sudo() + # Create channels that don't exist yet for this store (matched by API name) + existing_api_names = set(store.channel_ids.mapped("sf_channel_name")) + for api_name in installed_names: + if api_name not in existing_api_names: + Channel.create( + { + "name": api_name, + "sf_channel_name": api_name, + "store_id": store.id, + } + ) diff --git a/shoppingfeed_integration/models/shoppingfeed_store_attribute_field.py b/shoppingfeed_integration/models/shoppingfeed_store_attribute_field.py new file mode 100644 index 0000000..8664990 --- /dev/null +++ b/shoppingfeed_integration/models/shoppingfeed_store_attribute_field.py @@ -0,0 +1,32 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ShoppingfeedStoreAttributeField(models.Model): + _name = "shoppingfeed.store.attribute.field" + _description = "Shoppingfeed Store Additional Attribute Field" + _order = "sequence, id" + + store_id = fields.Many2one( + comodel_name="shoppingfeed.store", + string="Store", + required=True, + ondelete="cascade", + ) + field_id = fields.Many2one( + comodel_name="ir.model.fields", + string="Field", + required=True, + domain="[('model', '=', 'product.product'), " + "('ttype', 'in', ['char', 'text', 'integer', 'float', " + "'selection', 'many2one', 'boolean', 'html', 'datetime'])]", + ondelete="cascade", + ) + custom_name = fields.Char( + string="Custom Attribute Name", + help="Custom name to use in the exported catalog. " + "If empty, the field description will be used.", + ) + sequence = fields.Integer(default=10) diff --git a/shoppingfeed_integration/models/shoppingfeed_store_carrier_map.py b/shoppingfeed_integration/models/shoppingfeed_store_carrier_map.py new file mode 100644 index 0000000..d8ef40c --- /dev/null +++ b/shoppingfeed_integration/models/shoppingfeed_store_carrier_map.py @@ -0,0 +1,25 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ShoppingfeedStoreCarrierMap(models.Model): + _name = "shoppingfeed.store.carrier.map" + _description = "Shoppingfeed Carrier Mapping" + + store_id = fields.Many2one( + comodel_name="shoppingfeed.store", + ondelete="cascade", + required=True, + ) + carrier_name = fields.Char( + string="Shoppingfeed Carrier Name", + required=True, + help="Carrier name as received from Shoppingfeed", + ) + delivery_carrier_id = fields.Many2one( + comodel_name="delivery.carrier", + string="Odoo Delivery Carrier", + required=True, + help="Local Odoo carrier equivalent for this Shoppingfeed carrier.", + ) diff --git a/shoppingfeed_integration/models/shoppingfeed_store_order_type_map.py b/shoppingfeed_integration/models/shoppingfeed_store_order_type_map.py new file mode 100644 index 0000000..218b3ef --- /dev/null +++ b/shoppingfeed_integration/models/shoppingfeed_store_order_type_map.py @@ -0,0 +1,23 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ShoppingfeedStoreOrderTypeMap(models.Model): + _name = "shoppingfeed.store.order.type.map" + _description = "Marketplace Customer Group Mapping" + + store_id = fields.Many2one( + comodel_name="shoppingfeed.store", ondelete="cascade", required=True + ) + channel_id = fields.Many2one( + comodel_name="shoppingfeed.channel", + string="Marketplace Channel", + required=True, + domain="[('id', 'in', parent.channel_ids)]", + ) + order_type_id = fields.Many2one( + comodel_name="sale.order.type", + string="Order Type", + required=True, + ) diff --git a/shoppingfeed_integration/models/shoppingfeed_ticket.py b/shoppingfeed_integration/models/shoppingfeed_ticket.py new file mode 100644 index 0000000..bf01136 --- /dev/null +++ b/shoppingfeed_integration/models/shoppingfeed_ticket.py @@ -0,0 +1,102 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import requests + +from odoo import api, fields, models + + +class ShoppingfeedTicket(models.Model): + _name = "shoppingfeed.ticket" + _description = "Shoppingfeed Ticket" + _order = "create_date desc" + + name = fields.Char(required=True, index=True) + store_id = fields.Many2one("shoppingfeed.store", required=True, ondelete="cascade") + order_id = fields.Many2one("sale.order") + shoppingfeed_reference = fields.Char( + help="Marketplace order reference (e.g., TEST-68fb749e8cd95).", + copy=False, + index=True, + readonly=True, + ) + ticket_type = fields.Char() + state = fields.Selection( + [ + ("scheduled", "Scheduled"), + ("running", "Running"), + ("succeed", "Succeeded"), + ("failed", "Failed"), + ("canceled", "Canceled"), + ], + default="scheduled", + ) + message = fields.Text() + result_data = fields.Text() + company_id = fields.Many2one( + comodel_name="res.company", + related="store_id.company_id", + store=True, + readonly=True, + ) + link = fields.Char() + + @api.model + def cron_fetch_all_tickets(self): + # Get all active Shoppingfeed stores with valid credentials + stores = ( + self.env["shoppingfeed.store"] + .sudo() + .search( + [ + ("access_token", "!=", False), + ("catalog_id", "!=", False), + ] + ) + ) + for store in stores: + url = f"https://api.shopping-feed.com/v1/store/{store.catalog_id}/ticket" + headers = { + "Authorization": store.access_token, + "Content-Type": "application/json", + } + response = requests.get(url, headers=headers, timeout=30) + if response.status_code != 200: + continue + data = response.json() or {} + tickets = data.get("_embedded", {}).get("ticket", []) + for t in tickets: + payload = t.get("payload", {}) or {} + reference = payload.get("reference") + order = ( + self.env["sale.order"] + .sudo() + .search( + [ + ("shoppingfeed_reference", "=", reference), + ("company_id", "=", store.company_id.id), + ], + limit=1, + ) + ) + existing = self.search( + [ + ("name", "=", t.get("id")), + ("store_id", "=", store.id), + ], + limit=1, + ) + vals = { + "store_id": store.id, + "order_id": order.id if order else False, + "shoppingfeed_reference": reference, + "ticket_type": t.get("type"), + "state": t.get("state"), + "message": t.get("result", {}).get("message"), + "result_data": str(t.get("result", {}).get("data")), + "link": t.get("_links", {}).get("self", {}).get("href", ""), + } + if existing: + existing.write(vals) + else: + vals["name"] = t.get("id") + self.create(vals) diff --git a/shoppingfeed_integration/models/stock_picking.py b/shoppingfeed_integration/models/stock_picking.py new file mode 100644 index 0000000..e4ce5a1 --- /dev/null +++ b/shoppingfeed_integration/models/stock_picking.py @@ -0,0 +1,63 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import requests + +from odoo import fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + shoppingfeed_shipped = fields.Boolean( + copy=False, + help="Indicates if this picking has already been sent to ShoppingFeed", + ) + + def _action_done(self): + res = super()._action_done() + self._shoppingfeed_notify_shipment() + return res + + def _shoppingfeed_ship_order_payload(self, sale_order): + self.ensure_one() + return { + "id": int(sale_order.shoppingfeed_order_ref), + "carrier": self.carrier_id.name or "Unknown", + "trackingLink": self.carrier_id.tracking_url or "", + "trackingNumber": self.carrier_tracking_ref or "", + } + + def _shoppingfeed_notify_shipment(self): + # Notify Shoppingfeed that the order has been shipped + for picking in self: + sale_order = picking.sale_id + if ( + picking.shoppingfeed_shipped + or not sale_order + or not sale_order.shoppingfeed_order_ref + or not sale_order.shoppingfeed_store_id + ): + continue + store = sale_order.shoppingfeed_store_id + if ( + not store.access_token + or not store.catalog_id + or not store.notify_shipment + ): + continue + if store._shoppingfeed_is_demo_mode(): + continue + order_payload = picking._shoppingfeed_ship_order_payload(sale_order) + if not order_payload: + continue + url = ( + f"https://api.shopping-feed.com/v1/store/{store.catalog_id}/order/ship" + ) + headers = { + "Authorization": store.access_token, + "Content-Type": "application/json", + } + payload = {"order": [order_payload]} + response = requests.post(url, json=payload, headers=headers, timeout=30) + if response.status_code == 202: + picking.shoppingfeed_shipped = True diff --git a/shoppingfeed_integration/models/stock_quant.py b/shoppingfeed_integration/models/stock_quant.py new file mode 100644 index 0000000..853b8d7 --- /dev/null +++ b/shoppingfeed_integration/models/stock_quant.py @@ -0,0 +1,42 @@ +# Copyright 2025 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import requests + +from odoo import models + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + def write(self, vals): + res = super().write(vals) + if "quantity" in vals or "inventory_quantity" in vals: + self._shoppingfeed_update_inventory() + return res + + def _shoppingfeed_update_inventory(self): + for quant in self: + product = quant.product_id + if not product.default_code or not product.shoppingfeed_store_ids: + continue + stores = product.shoppingfeed_store_ids.filtered( + lambda store, product=product, quant=quant: ( + not store.export_only_selected or product.export_to_shoppingfeed + ) + and store.update_quantities_realtime + and store.company_id == quant.company_id + ) + quantity = int(product.qty_available) + for store in stores: + if store._shoppingfeed_is_demo_mode(): + continue + reference = f"{product.default_code}_{store.country_id.code}" + payload = { + "inventory": [{"reference": reference, "quantity": quantity}] + } + url = f"https://api.shopping-feed.com/v1/catalog/{store.catalog_id}/inventory" + headers = { + "Authorization": store.access_token, + "Content-Type": "application/json", + } + requests.put(url, headers=headers, json=payload, timeout=30) diff --git a/shoppingfeed_integration/pyproject.toml b/shoppingfeed_integration/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/shoppingfeed_integration/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/shoppingfeed_integration/readme/CONFIGURE.md b/shoppingfeed_integration/readme/CONFIGURE.md new file mode 100644 index 0000000..8483e36 --- /dev/null +++ b/shoppingfeed_integration/readme/CONFIGURE.md @@ -0,0 +1,87 @@ +## Store Setup + +### Create and authenticate your store + +1. Go to **Shoppingfeed → Stores** +2. Click **New** and fill: + - **Username**: Shoppingfeed API email + - **Password**: Shoppingfeed API password +3. Click **Get Token** button + - Automatically retrieves: catalog ID, channels, country, and language + +### Configure product export + +In the store form, configure these tabs: + +#### Exportable Products tab + +- **Export only selected**: Only export products with "Export to Shoppingfeed" enabled +- **Export Product Types**: Select product types to include (Goods, Services, Combos) +- **Export Rules**: Control which products to include: + - Out of stock products + - Archived products + - Non-salable products + +#### Stock tab + +- **Use actual stock state**: Use real quantities vs default quantity +- **Quantity type**: Salable (on-hand) or Virtual (forecasted) +- **Default quantity**: Quantity for products without stock tracking +- **Force zero quantity non salable**: Set 0 for non-salable products +- **Update quantities realtime**: Push stock changes immediately to Shoppingfeed + +#### Prices tab + +- **Pricelist**: Optional pricelist for computing export prices + +#### Attributes tab + +- **Use product ID as SKU**: Use product ID instead of default code +- **Custom SKU field**: Select custom field for SKU (optional) +- **Additional attribute fields**: Add custom product fields to export + +#### Images tab + +- **Export all images**: Include all product images +- **Exported image count**: Limit number of images (if not exporting all) + +#### Categories tab + +- **Allowed categories**: Restrict export to specific product categories + +#### Shipping tab + +- **Default delivery carrier**: Fallback carrier for unmapped carriers +- **Carrier mappings**: Map Shoppingfeed carrier names to Odoo carriers + - Add lines with Shoppingfeed carrier name and corresponding Odoo carrier + +#### Orders tab + +- **Import orders**: Enable automatic order import +- **Default payment term**: Payment terms for imported orders +- **Default payment mode**: Payment mode for imported orders +- **Default order type**: Order type for imported orders +- **Marketplace customer groups**: Map channels to specific order types + - Add lines with Channel and Order Type + +## Product Setup + +### Mark products for export + +1. Open a product +2. In **Shoppingfeed** tab: + - Enable **Export to Shoppingfeed** (if store requires it) + - Select **Shoppingfeed Stores** to export this product to + +**Note**: Products need default code (internal reference) to export. + +## Catalog Feed + +After configuration, your XML catalog feed is available at: + +``` +https://yourdomain.com/catalog/{catalog_id}.xml +``` + +Share this URL with Shoppingfeed for product synchronization. + diff --git a/shoppingfeed_integration/readme/CONTRIBUTORS.md b/shoppingfeed_integration/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..a4415b2 --- /dev/null +++ b/shoppingfeed_integration/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Tecnativa](https://www.tecnativa.com): + - Juan Carlos Oñate diff --git a/shoppingfeed_integration/readme/DESCRIPTION.md b/shoppingfeed_integration/readme/DESCRIPTION.md new file mode 100644 index 0000000..b963c8d --- /dev/null +++ b/shoppingfeed_integration/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +This module integrates Odoo with Shoppingfeed, a platform that connects your store with multiple marketplaces. + +* Export product catalogs to Shoppingfeed in XML format +* Import orders from connected marketplaces automatically +* Synchronize product prices and stock levels in real-time +* Map marketplace channels to Odoo sale order types +* Map delivery carriers from marketplaces to Odoo carriers +* Track Shoppingfeed tickets and notifications +* Configure export filters by product type, category, and stock availability diff --git a/shoppingfeed_integration/readme/USAGE.md b/shoppingfeed_integration/readme/USAGE.md new file mode 100644 index 0000000..c0b1c63 --- /dev/null +++ b/shoppingfeed_integration/readme/USAGE.md @@ -0,0 +1,30 @@ +## Product Updates + +### Price changes + +Product price updates send automatically to linked Shoppingfeed stores. + +### Stock changes + +Stock updates send automatically if store has **Update quantities realtime** enabled. + +## Orders + +### View orders + +Go to **Shoppingfeed → Marketplace Orders** to see marketplace orders. + +Orders show: +- Shoppingfeed Order ID +- Shoppingfeed Reference +- Shoppingfeed Store +- Shoppingfeed Channel (Amazon, eBay, etc.) +- Shoppingfeed Status + +### Process orders + +1. Confirm order +2. Validate delivery → shipment info sends to Shoppingfeed +3. Validate invoice → PDF uploads to Shoppingfeed (if channel supports it) + +Orders auto-acknowledge in Shoppingfeed on import. diff --git a/shoppingfeed_integration/security/ir.model.access.csv b/shoppingfeed_integration/security/ir.model.access.csv new file mode 100644 index 0000000..7f548c1 --- /dev/null +++ b/shoppingfeed_integration/security/ir.model.access.csv @@ -0,0 +1,17 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +shoppingfeed_integration.access_shoppingfeed_ticket_user,access_shoppingfeed_ticket_user,shoppingfeed_integration.model_shoppingfeed_ticket,shoppingfeed_integration.group_shoppingfeed_user,1,0,0,0 +shoppingfeed_integration.access_shoppingfeed_ticket_manager,access_shoppingfeed_ticket_manager,shoppingfeed_integration.model_shoppingfeed_ticket,shoppingfeed_integration.group_shoppingfeed_manager,1,1,1,1 +shoppingfeed_integration.access_shoppingfeed_log_user,access_shoppingfeed_log_user,shoppingfeed_integration.model_shoppingfeed_log,shoppingfeed_integration.group_shoppingfeed_user,1,0,0,0 +shoppingfeed_integration.access_shoppingfeed_log_manager,access_shoppingfeed_log_manager,shoppingfeed_integration.model_shoppingfeed_log,shoppingfeed_integration.group_shoppingfeed_manager,1,1,1,1 +shoppingfeed_integration.access_shoppingfeed_store_user,access_shoppingfeed_store_user,shoppingfeed_integration.model_shoppingfeed_store,shoppingfeed_integration.group_shoppingfeed_user,1,0,0,0 +shoppingfeed_integration.access_shoppingfeed_store_manager,access_shoppingfeed_store_manager,shoppingfeed_integration.model_shoppingfeed_store,shoppingfeed_integration.group_shoppingfeed_manager,1,1,1,1 +shoppingfeed_integration.access_shoppingfeed_channel_user,access_shoppingfeed_channel_user,shoppingfeed_integration.model_shoppingfeed_channel,shoppingfeed_integration.group_shoppingfeed_user,1,0,0,0 +shoppingfeed_integration.access_shoppingfeed_channel_manager,access_shoppingfeed_channel_manager,shoppingfeed_integration.model_shoppingfeed_channel,shoppingfeed_integration.group_shoppingfeed_manager,1,1,1,1 +shoppingfeed_integration.access_shoppingfeed_store_order_type_map_user,access_shoppingfeed_store_order_type_map_user,shoppingfeed_integration.model_shoppingfeed_store_order_type_map,shoppingfeed_integration.group_shoppingfeed_user,1,0,0,0 +shoppingfeed_integration.access_shoppingfeed_store_order_type_map_manager,access_shoppingfeed_store_order_type_map_manager,shoppingfeed_integration.model_shoppingfeed_store_order_type_map,shoppingfeed_integration.group_shoppingfeed_manager,1,1,1,1 +shoppingfeed_integration.access_shoppingfeed_store_carrier_map_user,access_shoppingfeed_store_carrier_map_user,shoppingfeed_integration.model_shoppingfeed_store_carrier_map,shoppingfeed_integration.group_shoppingfeed_user,1,0,0,0 +shoppingfeed_integration.access_shoppingfeed_store_carrier_map_manager,access_shoppingfeed_store_carrier_map_manager,shoppingfeed_integration.model_shoppingfeed_store_carrier_map,shoppingfeed_integration.group_shoppingfeed_manager,1,1,1,1 +shoppingfeed_integration.access_shoppingfeed_store_attribute_field_user,access_shoppingfeed_store_attribute_field_user,shoppingfeed_integration.model_shoppingfeed_store_attribute_field,shoppingfeed_integration.group_shoppingfeed_user,1,0,0,0 +shoppingfeed_integration.access_shoppingfeed_store_attribute_field_manager,access_shoppingfeed_store_attribute_field_manager,shoppingfeed_integration.model_shoppingfeed_store_attribute_field,shoppingfeed_integration.group_shoppingfeed_manager,1,1,1,1 +shoppingfeed_integration.access_shoppingfeed_channel_country_carrier_user,access_shoppingfeed_channel_country_carrier_user,shoppingfeed_integration.model_shoppingfeed_channel_country_carrier,shoppingfeed_integration.group_shoppingfeed_user,1,0,0,0 +shoppingfeed_integration.access_shoppingfeed_channel_country_carrier_manager,access_shoppingfeed_channel_country_carrier_manager,shoppingfeed_integration.model_shoppingfeed_channel_country_carrier,shoppingfeed_integration.group_shoppingfeed_manager,1,1,1,1 diff --git a/shoppingfeed_integration/security/ir_rule.xml b/shoppingfeed_integration/security/ir_rule.xml new file mode 100644 index 0000000..e044d94 --- /dev/null +++ b/shoppingfeed_integration/security/ir_rule.xml @@ -0,0 +1,27 @@ + + + + Shoppingfeed Store: company restriction + + [('company_id', 'in', company_ids)] + + + + Shoppingfeed Channel: company restriction + + [('company_id', 'in', company_ids)] + + + + Shoppingfeed Ticket: company restriction + + [('company_id', 'in', company_ids)] + + + + Shoppingfeed Log: company restriction + + [('company_id', 'in', company_ids)] + + + diff --git a/shoppingfeed_integration/security/shoppingfeed_security.xml b/shoppingfeed_integration/security/shoppingfeed_security.xml new file mode 100644 index 0000000..e0c5b97 --- /dev/null +++ b/shoppingfeed_integration/security/shoppingfeed_security.xml @@ -0,0 +1,27 @@ + + + + Shoppingfeed + Manage Shoppingfeed integration + 100 + + + User + + + Access to view Shoppingfeed stores, channels, and tickets. + + + Manager + + + Full access to configure and manage Shoppingfeed integration. + + + + + diff --git a/shoppingfeed_integration/static/description/icon.png b/shoppingfeed_integration/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0f85671c57767ca8331cf830e1edcecf597b51cb GIT binary patch literal 6084 zcmV;#7dz;QP)u!A2|Oh|UH||!024sR%FhKPNxi|x+T7j+EK{(xy%{i4 zH%n-fo2N`!aBFpkQet_ZrLe8Dyjo~}W^jUliJ3P?Vt9d(L{e+S$IOhCqSMvceu$Wu zpsWA@DVfm{7XSbj+DSw~RCt`looRcjJQIMivbq3TLA36zyRBC1`~QF1O9Es;KqiUj znGfgmx@wV|naq|2?d|o_Zgo0c!?N^Pn$0Hq&$5her_*ZxgKyVfdGQ#{z)&A;g zUq#q%b*s^Gpxu{YzJjpbu^@DW((1f|QaK?^cMp_COZ5$@CG1!dbeULcAr%s~RN$$H zR7Kb_>fE`WNS%*%RgFnCgq@0;#?j9*kmymgSS+G=v>~x@gsk|(j*@#*K-lhbFYw3D;26xYA9%<6%_^jnW0W8);EVB^^K&Bz>%%daVbD0Q3RNk?C{Fn3Ut+w~ z*?Vr76_B1EZez$t#_m5S>=cZ>>EGD*gy}MguW_ChC{i?bh!M8)->(~s`&S^3mxvd9 z-4_pv)Wu9yh%j%UdUv`N8+&D9`@#A4ki!d^ssLdt?*^>-ZmCLrRJ`SGZsnbsK)wnm z%zde4tS*5{^--~d%iPF+-OCAEyyfWD{8fAzW6tO3P&EPx`4Ola)6EO{62{#$=W~Fg z5k{!HIccEZq zfDoS&GJtybE@Yv$hq`H~JGxjzs3|->+_#sdXhj*^yAZ@@ge;(S76FV4cGO===YSJI zJ@Dr5dguFN5yU^X-gq_^pAa%mM%RzCE zi&z=@s|cA?O<#OLM@;y2$XXCdm;d{;Sz`G*Zttswp4o0(8C}8S(fe93h*b$(6`>5E z9ek%X9kRszF^mI679ngDI)cV&zepcJTuUFJPHfB}iV@Na=n)h!{7QQdy3ssrmVQxO z82iC-rCaI8R!}vLzZgTUs5Ht6Ee7xc{Ptj*I6K{nu3;fw25H~Z9=^5M0bTW&9gF4l zVksdFgnDxWbTV0jY&F;Z6DMMa-a0X7-TDJss<&Z+Mrq_PBcyIrw~jDZXZycKYI(H+ zfo~_Zt#0grh~be5v}F#pjF1lE?ipr1c9`VGRX{iWGEIAGI8PyR^cq?=sty#^dLq21`c~REY>BYkmt)H4snuV9Y9Yh zG@S#n(}%d9CnOI;kAU@rhi?w*obs}qf*;eTshI;BGV(o*JR$X?z`@W6P(>aj}VQzPA*m;7H3ZEv|B|;Ix}EDnyPRr zKdIp%XH#<|+FB;Fkk*Rr3PSQ6j4LoJ8RN{tIEi$4c?s_pdct)cux}DVZTL;XoK;Rp zRk(oa!k;4*-j)JLIk6i^8^-rjbEgW#(q9xSkt3uk1P>=4jbP(X#o`PfWj4=?n$YA4 z-BYm=SwgBp@NmMRLT@UWc}ud}XTIh$2N*k>X`vEXLZU)z2aVd;NUfEc^(WYAS(To^ z(;fh|!B3$Q8A75$aHNP$21Q(O2rUsDF!bMwn|M?pk`av7{QA}dW(9VkoXx9TT7fD zIMXYY@C(<$c`JRQKqT&#xYKryF#Cm2ftO@7(B^Teqzj)c-s-)lct~h=YUBusBfX0d z;&C%qlhJtLDw&(>at@(}0+9^Y#GUelr2i!3F2bv&EN2Uj$x>O}STT1}7UUmf<`FY( zafBIoWv=vu5Y|2_7BoYXOs+=k3c>0a!O7@1GgFHrl<_kP3H{3=om`za=#AK)wfQ(- z!xCn}P>+P}(nEyuAD5|kC~ebpT-DIdZg{vVF)uL6&y>r3nfh$Hm1qrH*V}U zfSN9bNS`F97DGskbSuF0a-nA0C4?jm5bftK@sts`aL#1x zlNfPgOx+hypvIc1o)OXxlxVjzsh*xU0E@-nEm8%}Z6-zt;l*OkxQ>*@#tPvg-9Ly2 z#Bp#p3S)$@jHYb7(AZe97A?|~#B{L~_*1e?#F(<%07P6&CcP^gFEn--3e>)d*cV*x zz}p`*$XnMyCPuI_xUd6Jqr*@zp}|t4#aSd_BUu91v=$r57{Mu##EUj2B+*cyL6Zp{ zh~StXoSP&@PzBbmBTA(|XCk7;L_^_Wh#IEwPL1c!vlwxdj}e>>*Gxpzm|-Y0HMYcN zx(C>Ka3Y1EuOLRSGB`2=QDe$`nRaJ7>sLD>j!7ZdWS786#0W-Amnrl$>S&}gXZs7M zHFbSW9OOwMNdM$nv#_?%mRma}%p8qm7Xq(ms+qdm!8!}_-)xM46@nEqA*)YXF`)`a z(@1-q^+sa0UN*NIl`2pgl*asSOql&FSQ`=v@Z6UEH0qK#Gti z-&RaW8aGmlnX;8I`fXPIF%;x03qjZYeT(KNDMUKYrc*-jVzLKGSHfH??UV-j15*ex zLEbsyy7k(VA=KO~t3iYrtb9`*PXsruUHPN({8dpQNSlR&_f^s%+sK*eh_t@nG!iW2 zTh`w8$qHDe+Zz@Id9(kMP2NtV0GYh4t&w1%pxv1`8>^(uBzw&*oe%Pv250Zrl&`yU zA_ZLaB>|am(IJFNW^l(FTIw91R5z;-f#y+G$e$EDY>|)GV zw9zG7HL?GY%VT&#D^I9L>IfU*C23;EdP^($W^{J_R6>8*C-S~d`K--IRdt97vrQ%p zp~_=t?5xQ|P9f8cPAuJ63}#zPn=C&&jAVm+j*z6@T6PEOR>NQk{>n~xE;^*MyK|KC z#e^jFeidOOw5NrL#Tz~Mj>0jBWb|@Ebii8CfhJ|=JE-)dNEjWyWE?g;FAMUVG~=<@ z!qV>Kw+de0m3o@!(bFsy0@5JA8v9gS;zCFNJEer}?^~9T4pl~r2Vbf(9*%=Umvno% z89_6Z-sCsMx_wUG&nc`Ps`ICh@la(>LxOxsA(-`IV)_1v)3n}XmFn}NCM(sroJMtC zY6p9xvJlh;Bbufq%?i`mr56%DudJ^tlE z0V3OLQ#85O7;p80ZZGR%oNboAoJ-P|_oSJzmG?Gmzf@JvJ4g;T;Jdv{jBxwUdokuH z!Y164&fnOcx2k!I040J|K|brnTtCWP$?OxS0&{6QW#=sv%{|$d4mwWnyLi>ez^U1b z=^gQ>HnJgOz|K99v`upU@VK5&FN^|dj4=C`Y-_;bSsj=R#YnPg%FiFFngd2huoedS znUnQmy1UV{doxX(znvwg54kyDMf3Q)FhQP9xXdRtjBkiTX7nya$j{@e3Ky)fF(M1{ z?QJd7GaWpIc#;~4lNGxbL(O^`R+t#kGqVYo+h)|=5o0K~paqDT+tchZT3-VcC z=DU8s-oeePADQ&D$E}H?&h)5aevNT4G{{E{z^wHU=cjv#N^!8`*R?5iHB6AF6E63P zApa<)iHoZbal-uCM6%3Veysvq2$BgG>+&>4@G`;)`j2>Disoz`Wov{JVT1fnlm+<_ z^j3Rktax!V9p=^u(>+1L8zvG3QVX3q*{X`p5$+VtPn@6&d(&BdowtIu`be0Sg~Kol z^1nip@(%j*mMfaMJ+9T4-*3_2W5k&hf@1mQ1P|eh=s%WHO<9*9<}^xBrl-BSxESdQfgs-a(;8wCPr~|4?QRDH9_G&OYUf3qh$87RUUS zSOrzMhg4!8FpUvRke|!0yh{>VF~Z_~z|>5EghfGK!OFX2GD^(T@E;0GY?%( zAoH#~>#O#8ZAF^G<3@HT50#{P61ZIDd2P&@dsWSh;%&%7oRH2{Elo=<2RDf)XYSQH zH?ib6qae<MZDI_x^z&n?#?QN)OLCf}>%bv!L1Yy!q_(`AG+fnRW%6 zN3591ms=8-i=W6%)-G2U*KDIBqkG2U1+PP?z<7#_gf^+rv6bCI1!Ovv1Ly14MHPp zulD;b*msn84bV{)X~0;?#l#WzSQgM@LEcfqyl3VRz>YIYMxxw0W;c0ETVO2>dCv^< zdesha733^QPFtVi*{M0ieh``)^-hp?1u^e?ih%bzOnSn%58g{v6iC}}I-fRUn4V|~r{uCedV z1D@W{OB<(uBH3F%`Qr|-@^yC{CHo#ysN48R=b+}Gg+gS@%o*xs4nprC<=!QG zg=x_orHGL?sjbGLmM*~ghAt3eybS1Fvg~WsJ6o9DnD5y{)V1d3-r0LLH+KdC4!6|W zfbRq5-s!x7ZvfF|18NW9aTeJ{5T-A_i&3VJ(e{c2}y4ku(Y z+YivmxIZ%Q<>ue5-T)%@HzYD0!t%2OOm}3ivs&P{2kcw84Y_x#v))FJLg=@X^H|>A zrl(QAAc~vr#5vMWeM7^`fDMEtUgilQ^Y(Fl@d-pUog=lI)@u9d^n30sIqzfU_9i1o zJ00jJe!)xN2A{O^RO)4(5w=-G@IGs|zh*BMt*)m=@8jdc88lAchiSz8Zm|KcPjNjK zu>JKLl`nNX_?d8lyE29YjtU<^jTDMSYX2O&`LAMt&^&Vav3Bx=IZKThrx(#QuBOaJmnqK&u}}fRx0aq`T>ZR|aW?vy z=lFDpg^Cd7d}=%#5#wuYbuju_8TqdR-!Ggn=Wx)`i1Up|9s5LF=Q6i)d>|JiPM9B! zsBoN~dP9|Ij@j#Pr=2_>a0E&a=A0@XkBjT4vSVc3(dRYiKbue&DoMzf8_D-u#{4vy z$dAauWO$kzR-Uh3A)%1)(NvKlqr^`9UGd@Aa{5z37&AWl9HPq@GrG<9-`nFQ>^VqA_}^Z5Ic>l9^IH_d73&U3SDH@= zVJw}}dqmpApoY=yT2ZuEETYIlC$nR;vSZEhABx5YUepkl8tWsiUP+}^eOOcx#`2J4 ziHodvVMQU~n<7Y+cyFo+V`+738DWX&hFZM07ZAqM?v#7SDmts9YV5J{3PL1pm@f5h z>lJiJUPXwd-FmJ~-Fh7ymX{Ky5`%WSN%TX!dJ-YKoz82~Qu#kuqeDRiVvNlI0000< KMNUMnLSTZWe5!K* literal 0 HcmV?d00001 diff --git a/shoppingfeed_integration/static/description/index.html b/shoppingfeed_integration/static/description/index.html new file mode 100644 index 0000000..b213ef1 --- /dev/null +++ b/shoppingfeed_integration/static/description/index.html @@ -0,0 +1,642 @@ + + + + + +Shoppingfeed Integration + + + +
+

Shoppingfeed Integration

+ + +

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

+

This module integrates Odoo with Shoppingfeed, a platform that connects +your store with multiple marketplaces.

+
    +
  • Export product catalogs to Shoppingfeed in XML format
  • +
  • Import orders from connected marketplaces automatically
  • +
  • Synchronize product prices and stock levels in real-time
  • +
  • Map marketplace channels to Odoo sale order types
  • +
  • Map delivery carriers from marketplaces to Odoo carriers
  • +
  • Track Shoppingfeed tickets and notifications
  • +
  • Configure export filters by product type, category, and stock +availability
  • +
+

Table of contents

+ +
+

Configuration

+
+

Store Setup

+
+

Create and authenticate your store

+
    +
  1. Go to Shoppingfeed → Stores
  2. +
  3. Click New and fill:
      +
    • Username: Shoppingfeed API email
    • +
    • Password: Shoppingfeed API password
    • +
    +
  4. +
  5. Click Get Token button
      +
    • Automatically retrieves: catalog ID, channels, country, and +language
    • +
    +
  6. +
+
+
+

Configure product export

+

In the store form, configure these tabs:

+
+

Exportable Products tab

+
    +
  • Export only selected: Only export products with “Export to +Shoppingfeed” enabled
  • +
  • Export Product Types: Select product types to include (Goods, +Services, Combos)
  • +
  • Export Rules: Control which products to include:
      +
    • Out of stock products
    • +
    • Archived products
    • +
    • Non-salable products
    • +
    +
  • +
+
+
+

Stock tab

+
    +
  • Use actual stock state: Use real quantities vs default quantity
  • +
  • Quantity type: Salable (on-hand) or Virtual (forecasted)
  • +
  • Default quantity: Quantity for products without stock tracking
  • +
  • Force zero quantity non salable: Set 0 for non-salable products
  • +
  • Update quantities realtime: Push stock changes immediately to +Shoppingfeed
  • +
+
+
+

Prices tab

+
    +
  • Pricelist: Optional pricelist for computing export prices
  • +
+
+
+

Attributes tab

+
    +
  • Use product ID as SKU: Use product ID instead of default code
  • +
  • Custom SKU field: Select custom field for SKU (optional)
  • +
  • Additional attribute fields: Add custom product fields to export
  • +
+
+
+

Images tab

+
    +
  • Export all images: Include all product images
  • +
  • Exported image count: Limit number of images (if not exporting +all)
  • +
+
+
+

Categories tab

+
    +
  • Allowed categories: Restrict export to specific product categories
  • +
+
+
+

Shipping tab

+
    +
  • Default delivery carrier: Fallback carrier for unmapped carriers
  • +
  • Carrier mappings: Map Shoppingfeed carrier names to Odoo carriers
      +
    • Add lines with Shoppingfeed carrier name and corresponding Odoo +carrier
    • +
    +
  • +
+
+
+

Orders tab

+
    +
  • Import orders: Enable automatic order import
  • +
  • Default payment term: Payment terms for imported orders
  • +
  • Default payment mode: Payment mode for imported orders
  • +
  • Default order type: Order type for imported orders
  • +
  • Marketplace customer groups: Map channels to specific order types
      +
    • Add lines with Channel and Order Type
    • +
    +
  • +
+
+
+
+
+

Product Setup

+
+

Mark products for export

+
    +
  1. Open a product
  2. +
  3. In Shoppingfeed tab:
      +
    • Enable Export to Shoppingfeed (if store requires it)
    • +
    • Select Shoppingfeed Stores to export this product to
    • +
    +
  4. +
+

Note: Products need default code (internal reference) to export.

+
+
+
+

Catalog Feed

+

After configuration, your XML catalog feed is available at:

+
+https://yourdomain.com/catalog/{catalog_id}.xml
+
+

Share this URL with Shoppingfeed for product synchronization.

+
+
+
+

Usage

+
+

Product Updates

+
+

Price changes

+

Product price updates send automatically to linked Shoppingfeed stores.

+
+
+

Stock changes

+

Stock updates send automatically if store has Update quantities +realtime enabled.

+
+
+
+

Orders

+
+

View orders

+

Go to Shoppingfeed → Marketplace Orders to see marketplace orders.

+

Orders show:

+
    +
  • Shoppingfeed Order ID
  • +
  • Shoppingfeed Reference
  • +
  • Shoppingfeed Store
  • +
  • Shoppingfeed Channel (Amazon, eBay, etc.)
  • +
  • Shoppingfeed Status
  • +
+
+
+

Process orders

+
    +
  1. Confirm order
  2. +
  3. Validate delivery → shipment info sends to Shoppingfeed
  4. +
  5. Validate invoice → PDF uploads to Shoppingfeed (if channel supports +it)
  6. +
+

Orders auto-acknowledge in Shoppingfeed on import.

+
+
+
+
+

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

+
    +
  • Tecnativa
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

juancarlosonate-tecnativa

+

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

+

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

+
+
+
+ + diff --git a/shoppingfeed_integration/tests/__init__.py b/shoppingfeed_integration/tests/__init__.py new file mode 100644 index 0000000..1ac6738 --- /dev/null +++ b/shoppingfeed_integration/tests/__init__.py @@ -0,0 +1 @@ +from . import test_shoppingfeed_integration diff --git a/shoppingfeed_integration/tests/test_shoppingfeed_integration.py b/shoppingfeed_integration/tests/test_shoppingfeed_integration.py new file mode 100644 index 0000000..7640e76 --- /dev/null +++ b/shoppingfeed_integration/tests/test_shoppingfeed_integration.py @@ -0,0 +1,197 @@ +# Copyright 2026 Juan Carlos Oñate - Tecnativa +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import Command +from odoo.tests import tagged + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +@tagged("post_install", "-at_install") +class TestShoppingfeedIntegration(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.bank_journal = cls.company_data["default_journal_bank"] + cls.inbound_payment_method_line = cls.env["account.payment.method.line"].create( + { + "name": "SF Inbound Payment Method", + "payment_method_id": cls.bank_journal.available_payment_method_ids[ + 0 + ].id, + "payment_type": "inbound", + "journal_id": cls.bank_journal.id, + } + ) + cls.sf_store = cls.env["shoppingfeed.store"].create( + { + "name": "Test SF Store", + "username": "test_user", + "password": "test_pass", + "company_id": cls.env.company.id, + } + ) + cls.sf_channel = cls.env["shoppingfeed.channel"].create( + { + "name": "Test Channel", + "store_id": cls.sf_store.id, + "payment_method_line_id": cls.inbound_payment_method_line.id, + "auto_pay": True, + } + ) + cls.product_a.default_code = "TEST-SKU-001" + + @classmethod + def _sf_order_line(cls, product=None): + product = product or cls.product_a + return Command.create( + { + "product_id": product.id, + "product_uom_qty": 1.0, + "price_unit": product.list_price, + } + ) + + def test_partner_gets_payment_method_on_create(self): + """New partner created during SF import receives + property_inbound_payment_method_line_id from the channel.""" + billing = { + "email": "sf_new_customer@example.com", + "firstName": "SF", + "lastName": "Customer", + "street": "Calle Test 1", + "postalCode": "28001", + "city": "Madrid", + "country": "ES", + "phone": "600000000", + } + partner = self.env["sale.order"]._shoppingfeed_prepare_partner( + billing, self.sf_store, channel=self.sf_channel + ) + self.assertRecordValues( + partner, + [ + { + "property_inbound_payment_method_line_id": ( + self.inbound_payment_method_line.id + ) + } + ], + ) + + def test_invoice_auto_paid_on_post(self): + """Invoice from a SF order with auto_pay=True is paid on confirmation.""" + self.partner_a.property_inbound_payment_method_line_id = ( + self.inbound_payment_method_line + ) + sale_order = self.env["sale.order"].create( + { + "partner_id": self.partner_a.id, + "shoppingfeed_order_ref": "SF-TEST-AUTOPAY-001", + "shoppingfeed_store_id": self.sf_store.id, + "shoppingfeed_channel_id": self.sf_channel.id, + "order_line": [self._sf_order_line()], + } + ) + sale_order.action_confirm() + invoice = sale_order._create_invoices() + invoice.action_post() + self.assertEqual(invoice.payment_state, "paid") + + def test_invoice_not_auto_paid_when_disabled(self): + """Invoice is NOT auto-paid when channel has auto_pay=False.""" + self.sf_channel.auto_pay = False + self.partner_a.property_inbound_payment_method_line_id = ( + self.inbound_payment_method_line + ) + sale_order = self.env["sale.order"].create( + { + "partner_id": self.partner_a.id, + "shoppingfeed_order_ref": "SF-TEST-NOPAY-001", + "shoppingfeed_store_id": self.sf_store.id, + "shoppingfeed_channel_id": self.sf_channel.id, + "order_line": [self._sf_order_line()], + } + ) + sale_order.action_confirm() + invoice = sale_order._create_invoices() + invoice.action_post() + self.assertNotEqual(invoice.payment_state, "paid") + self.sf_channel.auto_pay = True + + def test_product_reference_cleaning(self): + """_shoppingfeed_clean_product_reference strips 2-char country suffixes only.""" + so = self.env["sale.order"] + self.assertEqual( + so._shoppingfeed_clean_product_reference("SKU123_ES"), "SKU123" + ) + self.assertEqual( + so._shoppingfeed_clean_product_reference("SKU123_FR"), "SKU123" + ) + self.assertEqual(so._shoppingfeed_clean_product_reference("SKU123"), "SKU123") + self.assertEqual( + so._shoppingfeed_clean_product_reference("SKU123_EUR"), "SKU123_EUR" + ) + + def test_product_reference_resolution_with_alias(self): + """_shoppingfeed_resolve_product_reference follows alias mapping.""" + so = self.env["sale.order"] + aliases = {"OLD-SKU": "NEW-SKU_ES"} + self.assertEqual( + so._shoppingfeed_resolve_product_reference("OLD-SKU", aliases), "NEW-SKU" + ) + self.assertEqual( + so._shoppingfeed_resolve_product_reference("DIRECT_ES", aliases), "DIRECT" + ) + + def test_validate_products_all_found(self): + """_shoppingfeed_validate_products returns empty list when all SKUs exist.""" + order = { + "items": [{"reference": "TEST-SKU-001"}], + "itemsReferencesAliases": {}, + } + self.assertEqual( + self.env["sale.order"]._shoppingfeed_validate_products(order), [] + ) + + def test_validate_products_missing_sku(self): + """_shoppingfeed_validate_products returns missing SKUs.""" + order = { + "items": [{"reference": "NONEXISTENT-SKU"}], + "itemsReferencesAliases": {}, + } + missing = self.env["sale.order"]._shoppingfeed_validate_products(order) + self.assertIn("NONEXISTENT-SKU", missing) + + def test_create_sale_order_from_sf_data(self): + """_shoppingfeed_create_sale_order creates a sale.order with SF metadata.""" + order_dict = { + "id": "SF-999001", + "reference": "MKT-TEST-REF-001", + "status": "waiting_shipment", + "payment": {"currency": "EUR", "shippingAmount": 0.0}, + "items": [], + "itemsReferencesAliases": {}, + "storeId": "test", + } + sale_order = self.env["sale.order"]._shoppingfeed_create_sale_order( + self.sf_store, + order_dict, + self.partner_a, + None, + self.sf_channel, + False, + False, + ) + self.assertRecordValues( + sale_order, + [ + { + "shoppingfeed_order_ref": "SF-999001", + "shoppingfeed_reference": "MKT-TEST-REF-001", + "shoppingfeed_channel_id": self.sf_channel.id, + "shoppingfeed_store_id": self.sf_store.id, + "partner_id": self.partner_a.id, + } + ], + ) diff --git a/shoppingfeed_integration/views/menus.xml b/shoppingfeed_integration/views/menus.xml new file mode 100644 index 0000000..76307fc --- /dev/null +++ b/shoppingfeed_integration/views/menus.xml @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/shoppingfeed_integration/views/product_attribute_views.xml b/shoppingfeed_integration/views/product_attribute_views.xml new file mode 100644 index 0000000..ab22a12 --- /dev/null +++ b/shoppingfeed_integration/views/product_attribute_views.xml @@ -0,0 +1,14 @@ + + + + product.attribute.form.shoppingfeed + product.attribute + + + + + + + + + diff --git a/shoppingfeed_integration/views/product_pricelist_views.xml b/shoppingfeed_integration/views/product_pricelist_views.xml new file mode 100644 index 0000000..70dc2bd --- /dev/null +++ b/shoppingfeed_integration/views/product_pricelist_views.xml @@ -0,0 +1,13 @@ + + + + product.pricelist.form.inherit.shoppingfeed + product.pricelist + + + + + + + + diff --git a/shoppingfeed_integration/views/product_template_views.xml b/shoppingfeed_integration/views/product_template_views.xml new file mode 100644 index 0000000..ff73978 --- /dev/null +++ b/shoppingfeed_integration/views/product_template_views.xml @@ -0,0 +1,33 @@ + + + + product.template.form.shoppingfeed + product.template + + +
+ + + +
+ + + + + + + + + +
+
+
diff --git a/shoppingfeed_integration/views/sale_order_views.xml b/shoppingfeed_integration/views/sale_order_views.xml new file mode 100644 index 0000000..c14e80c --- /dev/null +++ b/shoppingfeed_integration/views/sale_order_views.xml @@ -0,0 +1,68 @@ + + + + sale.order.form.shoppingfeed + sale.order + + + + + + + + + + + + + + + + + + + + sale.order.tree.shoppingfeed + sale.order + + + + hide + + + hide + + + hide + + + + + + + + + + Marketplace Orders + sale.order + list,form + [('shoppingfeed_order_ref', '!=', False)] + {} + + + sale.order.search.shoppingfeed + sale.order + + + + + + + + + diff --git a/shoppingfeed_integration/views/shoppingfeed_channel_views.xml b/shoppingfeed_integration/views/shoppingfeed_channel_views.xml new file mode 100644 index 0000000..697a4c4 --- /dev/null +++ b/shoppingfeed_integration/views/shoppingfeed_channel_views.xml @@ -0,0 +1,63 @@ + + + + shoppingfeed.channel.tree + shoppingfeed.channel + + + + + + + + + + + shoppingfeed.channel.form + shoppingfeed.channel + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + Channels + shoppingfeed.channel + list,form + +
diff --git a/shoppingfeed_integration/views/shoppingfeed_log_views.xml b/shoppingfeed_integration/views/shoppingfeed_log_views.xml new file mode 100644 index 0000000..c1d5015 --- /dev/null +++ b/shoppingfeed_integration/views/shoppingfeed_log_views.xml @@ -0,0 +1,22 @@ + + + + shoppingfeed.log.list + shoppingfeed.log + + + + + + + + + + + + + Order Logs + shoppingfeed.log + list + + diff --git a/shoppingfeed_integration/views/shoppingfeed_store_views.xml b/shoppingfeed_integration/views/shoppingfeed_store_views.xml new file mode 100644 index 0000000..10c28fa --- /dev/null +++ b/shoppingfeed_integration/views/shoppingfeed_store_views.xml @@ -0,0 +1,242 @@ + + + + shoppingfeed.store.tree + shoppingfeed.store + + + + + + + + + + + + + + shoppingfeed.store.form + shoppingfeed.store + +
+
+
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + Shoppingfeed Stores + shoppingfeed.store + list,form + +
diff --git a/shoppingfeed_integration/views/shoppingfeed_ticket_views.xml b/shoppingfeed_integration/views/shoppingfeed_ticket_views.xml new file mode 100644 index 0000000..76488aa --- /dev/null +++ b/shoppingfeed_integration/views/shoppingfeed_ticket_views.xml @@ -0,0 +1,40 @@ + + + + shoppingfeed.ticket.tree + shoppingfeed.ticket + + + + + + + + + + + + + + + + + Shoppingfeed Tickets + shoppingfeed.ticket + list + +

+ No Shoppingfeed tickets found. +

+
+
+
From 99b6d0c4f40b926267c979cfba129aea3ad6c5a3 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Fri, 15 May 2026 10:03:55 -0500 Subject: [PATCH 02/27] [IMP] shoppingfeed_integration: Add field in the channel to disable invoice generation Some channels or marketplaces generate invoices on their own platform. In those cases, we do not want to generate the invoice in Odoo. To prevent this, you must enable this field. --- shoppingfeed_integration/models/sale_order.py | 30 +++++++++++++++++++ .../models/shoppingfeed_channel.py | 8 +++++ .../views/shoppingfeed_channel_views.xml | 1 + 3 files changed, 39 insertions(+) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index c590ef2..13999d6 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -59,6 +59,24 @@ class SaleOrder(models.Model): ), ] + def _is_shoppingfeed_disable_invoicing(self): + return self.shoppingfeed_channel_id.disable_invoicing + + @api.depends("shoppingfeed_channel_id") + def _compute_invoice_status(self): + res = super()._compute_invoice_status() + self.filtered(lambda so: so._is_shoppingfeed_disable_invoicing()).update( + {"invoice_status": "no"} + ) + return res + + @api.depends("shoppingfeed_channel_id") + def _compute_amount_to_invoice(self): + res = super()._compute_amount_to_invoice() + for so in self.filtered(lambda so: so._is_shoppingfeed_disable_invoicing()): + so.amount_to_invoice = 0 + return res + def _shoppingfeed_fetch_orders(self, store): # Fetch only unacknowledged orders from Shoppingfeed for the given store. url = f"https://api.shopping-feed.com/v1/store/{store.catalog_id}/order" @@ -400,3 +418,15 @@ def _import_orders_from_shoppingfeed(self, store): error_msg = f"Error creating order: {str(e)}" self._create_shoppingfeed_log(store, order, sf_channel, error_msg) continue + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + @api.depends("order_id.shoppingfeed_channel_id") + def _compute_invoice_status(self): + res = super()._compute_invoice_status() + self.filtered( + lambda sol: sol.order_id._is_shoppingfeed_disable_invoicing() + ).invoice_status = "no" + return res diff --git a/shoppingfeed_integration/models/shoppingfeed_channel.py b/shoppingfeed_integration/models/shoppingfeed_channel.py index eb365c5..fa690e2 100644 --- a/shoppingfeed_integration/models/shoppingfeed_channel.py +++ b/shoppingfeed_integration/models/shoppingfeed_channel.py @@ -63,6 +63,14 @@ class ShoppingfeedChannel(models.Model): ), default=False, ) + disable_invoicing = fields.Boolean( + help=( + "Mark this field if the channel " + "generates the invoice on your platform " + "and it is not necessary to invoice the sales order in Odoo." + ), + default=False, + ) payment_method_line_id = fields.Many2one( comodel_name="account.payment.method.line", string="Payment Method", diff --git a/shoppingfeed_integration/views/shoppingfeed_channel_views.xml b/shoppingfeed_integration/views/shoppingfeed_channel_views.xml index 697a4c4..cb40d17 100644 --- a/shoppingfeed_integration/views/shoppingfeed_channel_views.xml +++ b/shoppingfeed_integration/views/shoppingfeed_channel_views.xml @@ -31,6 +31,7 @@ groups="base.group_multi_company" /> + Date: Sat, 16 May 2026 11:52:24 +0200 Subject: [PATCH 03/27] [IMP] shoppingfeed_integration: Performance and memory consumption --- .../controllers/catalog.py | 267 +++++++++++++++--- 1 file changed, 226 insertions(+), 41 deletions(-) diff --git a/shoppingfeed_integration/controllers/catalog.py b/shoppingfeed_integration/controllers/catalog.py index 15a5d35..dc2e3c3 100644 --- a/shoppingfeed_integration/controllers/catalog.py +++ b/shoppingfeed_integration/controllers/catalog.py @@ -1,6 +1,7 @@ # Copyright 2025 Juan Carlos Oñate - Tecnativa # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import tempfile from datetime import datetime from lxml import etree @@ -36,20 +37,15 @@ def catalog_feed(self, catalog_id=None, **kwargs): status=404, content_type="application/xml;charset=utf-8", ) - products = self._get_products_for_store(store) - base_url = store.get_base_url().rstrip("/") - catalog_el = self._build_catalog_xml(store, products, base_url) - xml_bytes = etree.tostring( - catalog_el, pretty_print=True, xml_declaration=True, encoding="UTF-8" - ) + catalog_file = self._generate_catalog_file(store) return Response( - xml_bytes, content_type="application/xml;charset=utf-8", status=200 + catalog_file, + content_type="application/xml;charset=utf-8", + status=200, + direct_passthrough=True, ) - def _get_products_for_store(self, store): - env = request.env["product.product"].sudo().with_company(store.company_id) - if store.lang_id: - env = env.with_context(lang=store.lang_id.code) + def _get_product_domain_for_store(self, store): domain = [] if store.export_only_selected: domain.append(("export_to_shoppingfeed", "=", True)) @@ -71,25 +67,184 @@ def _get_products_for_store(self, store): domain.append(("sale_ok", "=", True)) if store.allowed_categ_ids: domain.append(("public_categ_ids", "in", store.allowed_categ_ids.ids)) - return env.search(domain) + return domain + + def _get_product_env_for_store(self, store): + env = request.env["product.product"].sudo().with_company(store.company_id) + if store.lang_id: + env = env.with_context(lang=store.lang_id.code) + return env + + def _get_products_for_store(self, store): + return self._get_product_env_for_store(store).search( + self._get_product_domain_for_store(store) + ) + + def _generate_catalog_file(self, store): + catalog_file = tempfile.TemporaryFile() + for chunk in self._iter_catalog_xml(store): + catalog_file.write(chunk) + catalog_file.seek(0) + return catalog_file + + def _iter_catalog_xml(self, store, batch_size=500): + product_env = self._get_product_env_for_store(store) + domain = self._get_product_domain_for_store(store) + product_ids = product_env.search(domain).ids + total_products = len(product_ids) + generation_started_at = datetime.now() + catalog_context = self._get_catalog_context(store) + + yield b'\n' + yield b'\n\n' + for index in range(0, total_products, batch_size): + batch = product_env.browse(product_ids[index : index + batch_size]) + self._fetch_product_batch_fields(store, batch) + batch_context = self._get_product_batch_context(store, batch) + for product in batch: + product_el = self._build_product_xml( + store, product, catalog_context, batch_context + ) + yield etree.tostring(product_el, encoding="UTF-8") + yield b"\n" + request.env.cache.invalidate() + yield b"\n" + metadata_el = self._build_metadata_xml(total_products, generation_started_at) + yield etree.tostring(metadata_el, encoding="UTF-8") + yield b"\n\n" + + def _get_catalog_context(self, store): + return { + "allowed_categ_ids": set(store.allowed_categ_ids.ids), + "base_url": store.get_base_url().rstrip("/"), + } + + def _fetch_product_batch_fields(self, store, products): + field_names = [ + "barcode", + "default_code", + "description_sale", + "lst_price", + "name", + "product_brand_id", + "product_template_attribute_value_ids", + "product_template_image_ids", + "product_tmpl_id", + "public_categ_ids", + "sale_ok", + "sf_forced_category_id", + "taxes_id", + "type", + "website_description", + "website_url", + "weight", + ] + if store.custom_sku_field_id: + field_names.append(store.custom_sku_field_id.name) + field_names.extend( + attr_field.field_id.name + for attr_field in store.additional_attribute_field_ids + ) + products.fetch(set(field_names)) + + def _get_product_batch_context(self, store, products): + base_prices = self._get_base_prices(store, products) + quantity_values = self._get_quantity_values(store, products) + main_images = self._get_main_images(products) + additional_prices = {} + for pricelist in store.additional_pricelist_ids: + if not pricelist.shoppingfeed_attribute_name: + continue + prices = pricelist._get_products_price(products, 1.0, None) + additional_prices[pricelist.id] = { + product_id: pricelist.currency_id.round(price) + for product_id, price in prices.items() + } + return { + "additional_prices": additional_prices, + "base_prices": base_prices, + "main_images": main_images, + "quantity_values": quantity_values, + } + + def _get_base_prices(self, store, products): + currency = ( + store.pricelist_id.currency_id + if store.pricelist_id + else store.company_id.currency_id + ) + if store.pricelist_id: + prices = store.pricelist_id._get_products_price(products, 1.0, None) + else: + prices = {product.id: product.lst_price or 0.0 for product in products} + return { + product_id: currency.round(price) for product_id, price in prices.items() + } + + def _get_quantity_values(self, store, products): + if not store.use_actual_stock_state: + return {} + if store.quantity_type == "virtual": + return { + data["id"]: data["virtual_available"] + for data in products.read(["virtual_available"]) + } + return dict(products._get_only_qty_available()) + + def _get_main_images(self, products): + products_data = products.with_context(bin_size=True).read( + ["image_variant_1920", "product_tmpl_id"] + ) + template_ids = { + data["product_tmpl_id"][0] + for data in products_data + if data["product_tmpl_id"] and not data["image_variant_1920"] + } + template_images = { + data["id"]: bool(data["image_1920"]) + for data in products.env["product.template"] + .browse(template_ids) + .with_context(bin_size=True) + .read(["image_1920"]) + } + return { + data["id"]: bool(data["image_variant_1920"]) + or template_images.get(data["product_tmpl_id"][0], False) + for data in products_data + } def _build_catalog_xml(self, store, products, base_url): catalog_el = etree.Element("catalog") products_el = etree.SubElement(catalog_el, "products", version="1.0.0") + catalog_context = self._get_catalog_context(store) + if base_url: + catalog_context["base_url"] = base_url.rstrip("/") + batch_context = self._get_product_batch_context(store, products) for product in products: - product_el = etree.SubElement(products_el, "product") - self._add_product_base_info(store, product_el, product, base_url) - self._add_product_price(store, product_el, product) - self._add_product_stock(store, product_el, product) - self._add_product_media(store, product_el, product, base_url) - self._add_product_attributes(store, product_el, product) + products_el.append( + self._build_product_xml(store, product, catalog_context, batch_context) + ) self._add_metadata(catalog_el, len(products)) return catalog_el - def _get_base_price(self, store, product): - price = product.lst_price or 0.0 - if store.pricelist_id: - price = store.pricelist_id._get_product_price(product, 1.0, None) + def _build_product_xml(self, store, product, catalog_context, batch_context): + product_el = etree.Element("product") + self._add_product_base_info(store, product_el, product, catalog_context) + self._add_product_price(store, product_el, product, batch_context) + self._add_product_stock(store, product_el, product, batch_context) + self._add_product_media( + store, product_el, product, catalog_context, batch_context + ) + self._add_product_attributes(store, product_el, product, batch_context) + return product_el + + def _get_base_price(self, store, product, batch_context=None): + if batch_context: + price = batch_context["base_prices"].get(product.id, 0.0) + else: + price = product.lst_price or 0.0 + if store.pricelist_id: + price = store.pricelist_id._get_product_price(product, 1.0, None) currency = ( store.pricelist_id.currency_id if store.pricelist_id @@ -97,7 +252,8 @@ def _get_base_price(self, store, product): ) return currency.round(price) - def _add_product_base_info(self, store, product_el, product, base_url): + def _add_product_base_info(self, store, product_el, product, catalog_context): + base_url = catalog_context["base_url"] if store.use_product_id_as_sku: sku_value = str(product.id) elif store.custom_sku_field_id: @@ -125,7 +281,7 @@ def _add_product_base_info(self, store, product_el, product, base_url): category = product.sf_forced_category_id else: category = product.public_categ_ids.filtered( - lambda categ: categ.id in store.allowed_categ_ids.ids + lambda categ: categ.id in catalog_context["allowed_categ_ids"] )[:1] if category: category_el = etree.SubElement(product_el, "category") @@ -141,8 +297,8 @@ def _add_product_base_info(self, store, product_el, product, base_url): short_desc = product.description_sale or "" etree.SubElement(description_el, "short").text = etree.CDATA(short_desc) - def _add_product_price(self, store, product_el, product): - price = self._get_base_price(store, product) + def _add_product_price(self, store, product_el, product, batch_context=None): + price = self._get_base_price(store, product, batch_context) if store.include_taxes_in_price and product.taxes_id: currency = ( store.pricelist_id.currency_id @@ -156,30 +312,37 @@ def _add_product_price(self, store, product_el, product): price = currency.round(tax_result["total_included"]) etree.SubElement(product_el, "price").text = str(price) - def _get_quantity_value(self, store, product): + def _get_quantity_value(self, store, product, batch_context=None): + if batch_context: + return batch_context["quantity_values"].get(product.id, 0.0) if store.quantity_type == "virtual": return product.virtual_available return product.qty_available - def _add_product_stock(self, store, product_el, product): + def _add_product_stock(self, store, product_el, product, batch_context=None): if not store.use_actual_stock_state: quantity_value = store.default_quantity else: - quantity_value = self._get_quantity_value(store, product) or 0 + quantity_value = ( + self._get_quantity_value(store, product, batch_context) or 0 + ) if product.type == "service": quantity_value = store.default_quantity if not product.sale_ok and store.force_zero_quantity_non_salable: quantity_value = 0 etree.SubElement(product_el, "quantity").text = str(int(quantity_value)) - def _add_product_media(self, store, product_el, product, base_url): + def _add_product_media( + self, store, product_el, product, catalog_context, batch_context=None + ): + base_url = catalog_context["base_url"] images_el = etree.SubElement(product_el, "images") product_images = product.product_template_image_ids if store.export_all_images: all_images = product_images else: all_images = product_images[: store.exported_image_count] - if product.image_1920: + if self._has_main_image(product, batch_context): etree.SubElement(images_el, "image", type="main").text = etree.CDATA( f"{base_url}/web/image/product.product/{product.id}/image_1920" ) @@ -188,7 +351,12 @@ def _add_product_media(self, store, product_el, product, base_url): f"{base_url}/web/image/{img._name}/{img.id}/image_1920" ) - def _add_product_attributes(self, store, product_el, product): + def _has_main_image(self, product, batch_context=None): + if batch_context: + return batch_context["main_images"].get(product.id, False) + return self._get_main_images(product).get(product.id, False) + + def _add_product_attributes(self, store, product_el, product, batch_context=None): attributes_el = etree.SubElement(product_el, "attributes") # Attributes that generate variants for value in product.product_template_attribute_value_ids: @@ -204,8 +372,10 @@ def _add_product_attributes(self, store, product_el, product): etree.SubElement(attr_el, "value").text = value.name # Attributes that do NOT generate variants for line in product.product_tmpl_id.attribute_line_ids.filtered( - lambda line_var: line_var.attribute_id.create_variant == "no_variant" - and line_var.attribute_id.shoppingfeed_export + lambda line_var: ( + line_var.attribute_id.create_variant == "no_variant" + and line_var.attribute_id.shoppingfeed_export + ) ): if line.value_ids: values = ", ".join(line.value_ids.mapped("name")) @@ -243,12 +413,14 @@ def _add_product_attributes(self, store, product_el, product): etree.SubElement(attr_el, "value").text = etree.CDATA(str(value)) else: etree.SubElement(attr_el, "value").text = str(value) - self._add_product_price_attributes(store, attributes_el, product) + self._add_product_price_attributes(store, attributes_el, product, batch_context) - def _add_product_price_attributes(self, store, attributes_el, product): + def _add_product_price_attributes( + self, store, attributes_el, product, batch_context=None + ): # Export price without taxes as additional attribute for marketplaces if store.export_price_without_tax and store.price_without_tax_attribute_name: - price_without_tax = self._get_base_price(store, product) + price_without_tax = self._get_base_price(store, product, batch_context) attr_el = etree.SubElement(attributes_el, "attribute") etree.SubElement( attr_el, "name" @@ -259,22 +431,35 @@ def _add_product_price_attributes(self, store, attributes_el, product): for pricelist in store.additional_pricelist_ids: if not pricelist.shoppingfeed_attribute_name: continue - price = pricelist.currency_id.round( - pricelist._get_product_price(product, 1.0, None) - ) + if batch_context: + price = batch_context["additional_prices"][pricelist.id].get( + product.id, 0.0 + ) + else: + price = pricelist.currency_id.round( + pricelist._get_product_price(product, 1.0, None) + ) attr_el = etree.SubElement(attributes_el, "attribute") etree.SubElement( attr_el, "name" ).text = pricelist.shoppingfeed_attribute_name etree.SubElement(attr_el, "value").text = str(price) + def _build_metadata_xml(self, total_products, started_at): + metadata_el = etree.Element("metadata") + self._add_metadata_values(metadata_el, total_products, started_at) + return metadata_el + def _add_metadata(self, catalog_el, total_products): metadata_el = etree.SubElement(catalog_el, "metadata") + self._add_metadata_values(metadata_el, total_products, datetime.now()) + + def _add_metadata_values(self, metadata_el, total_products, started_at): etree.SubElement(metadata_el, "platform").text = f"Odoo:{release.version}" etree.SubElement( metadata_el, "agent" ).text = f"shoppingfeed_integration:{release.version}" - etree.SubElement(metadata_el, "startedAt").text = datetime.now().isoformat() + etree.SubElement(metadata_el, "startedAt").text = started_at.isoformat() etree.SubElement(metadata_el, "finishedAt").text = datetime.now().isoformat() etree.SubElement(metadata_el, "invalid").text = "0" etree.SubElement(metadata_el, "ignored").text = "0" From 08bf0e8d736129b2cb5b684a4612905d7895b281 Mon Sep 17 00:00:00 2001 From: Carlos Dauden Date: Sat, 16 May 2026 12:04:41 +0200 Subject: [PATCH 04/27] [IMP] shoppingfeed_integration: Reduce SQL calls using data dict --- .../controllers/catalog.py | 466 ++++++++++++++---- 1 file changed, 382 insertions(+), 84 deletions(-) diff --git a/shoppingfeed_integration/controllers/catalog.py b/shoppingfeed_integration/controllers/catalog.py index dc2e3c3..0afbf5b 100644 --- a/shoppingfeed_integration/controllers/catalog.py +++ b/shoppingfeed_integration/controllers/catalog.py @@ -11,6 +11,30 @@ class CatalogController(http.Controller): + _PRODUCT_DATA_FIELDS = [ + "barcode", + "default_code", + "description_sale", + "name", + "product_brand_id", + "product_template_attribute_value_ids", + "product_template_image_ids", + "product_tmpl_id", + "public_categ_ids", + "sale_ok", + "sf_forced_category_id", + "taxes_id", + "type", + "website_description", + "website_url", + "weight", + ] + + _PRODUCT_FETCH_FIELDS = [ + *_PRODUCT_DATA_FIELDS, + "lst_price", + ] + @http.route( ["/catalog.xml", "/catalog/.xml"], type="http", @@ -73,6 +97,8 @@ def _get_product_env_for_store(self, store): env = request.env["product.product"].sudo().with_company(store.company_id) if store.lang_id: env = env.with_context(lang=store.lang_id.code) + if store.export_disabled_products: + env = env.with_context(active_test=False) return env def _get_products_for_store(self, store): @@ -119,43 +145,34 @@ def _get_catalog_context(self, store): "base_url": store.get_base_url().rstrip("/"), } - def _fetch_product_batch_fields(self, store, products): - field_names = [ - "barcode", - "default_code", - "description_sale", - "lst_price", - "name", - "product_brand_id", - "product_template_attribute_value_ids", - "product_template_image_ids", - "product_tmpl_id", - "public_categ_ids", - "sale_ok", - "sf_forced_category_id", - "taxes_id", - "type", - "website_description", - "website_url", - "weight", - ] + def _get_product_field_names(self, store, base_fields): + field_names = list(base_fields) if store.custom_sku_field_id: field_names.append(store.custom_sku_field_id.name) field_names.extend( attr_field.field_id.name for attr_field in store.additional_attribute_field_ids ) - products.fetch(set(field_names)) + return list(dict.fromkeys(field_names)) + + def _fetch_product_batch_fields(self, store, products): + products.fetch(self._get_product_field_names(store, self._PRODUCT_FETCH_FIELDS)) def _get_product_batch_context(self, store, products): + product_data = self._get_product_data(store, products) + brand_names = self._get_brand_names(products, product_data) + category_names = self._get_category_names(store, products, product_data) + variant_attributes = self._get_variant_attributes(products, product_data) + no_variant_attributes = self._get_no_variant_attributes(products, product_data) base_prices = self._get_base_prices(store, products) quantity_values = self._get_quantity_values(store, products) - main_images = self._get_main_images(products) + main_images = self._get_main_images(products, product_data) + taxes_by_key = self._get_taxes_by_key(store, products, product_data) additional_prices = {} for pricelist in store.additional_pricelist_ids: if not pricelist.shoppingfeed_attribute_name: continue - prices = pricelist._get_products_price(products, 1.0, None) + prices = self._get_pricelist_prices(store, pricelist, products) additional_prices[pricelist.id] = { product_id: pricelist.currency_id.round(price) for product_id, price in prices.items() @@ -163,8 +180,173 @@ def _get_product_batch_context(self, store, products): return { "additional_prices": additional_prices, "base_prices": base_prices, + "brand_names": brand_names, + "category_names": category_names, "main_images": main_images, + "no_variant_attributes": no_variant_attributes, + "product_data": product_data, "quantity_values": quantity_values, + "taxes_by_key": taxes_by_key, + "variant_attributes": variant_attributes, + } + + def _get_product_data(self, store, products): + return { + data["id"]: data + for data in products.read( + self._get_product_field_names(store, self._PRODUCT_DATA_FIELDS) + ) + } + + def _get_brand_names(self, products, product_data): + brand_ids = { + data["product_brand_id"][0] + for data in product_data.values() + if data["product_brand_id"] + } + if not brand_ids: + return {} + return { + data["id"]: data["name"] + for data in products.env["product.brand"].browse(brand_ids).read(["name"]) + } + + def _get_category_names(self, store, products, product_data): + allowed_categ_ids = set(store.allowed_categ_ids.ids) + category_ids = { + category_id + for data in product_data.values() + for category_id in ( + [data["sf_forced_category_id"][0]] + if data["sf_forced_category_id"] + else [ + public_categ_id + for public_categ_id in data["public_categ_ids"] + if public_categ_id in allowed_categ_ids + ] + ) + } + if not category_ids: + return {} + return { + data["id"]: data["display_name"].replace(" / ", " > ") + for data in products.env["product.public.category"] + .browse(category_ids) + .read(["display_name"]) + } + + def _get_variant_attributes(self, products, product_data): + product_ptav_ids = { + product_id: data["product_template_attribute_value_ids"] + for product_id, data in product_data.items() + } + ptav_ids = { + ptav_id for value_ids in product_ptav_ids.values() for ptav_id in value_ids + } + if not ptav_ids: + return {} + ptav_data = ( + products.env["product.template.attribute.value"] + .browse(ptav_ids) + .read(["attribute_id", "name"]) + ) + attribute_ids = { + data["attribute_id"][0] for data in ptav_data if data["attribute_id"] + } + attribute_data = self._get_attribute_data(products, attribute_ids) + ptav_map = { + data["id"]: { + "attribute": attribute_data.get(data["attribute_id"][0]), + "value": data["name"], + } + for data in ptav_data + if data["attribute_id"] + } + return { + product_id: [ + { + "name": ptav_map[ptav_id]["attribute"]["export_name"], + "value": ptav_map[ptav_id]["value"], + } + for ptav_id in value_ids + if ptav_id in ptav_map + and ptav_map[ptav_id]["attribute"] + and ptav_map[ptav_id]["attribute"]["shoppingfeed_export"] + ] + for product_id, value_ids in product_ptav_ids.items() + } + + def _get_no_variant_attributes(self, products, product_data): + template_ids = { + data["product_tmpl_id"][0] + for data in product_data.values() + if data["product_tmpl_id"] + } + if not template_ids: + return {} + lines_data = products.env["product.template.attribute.line"].search_read( + [("product_tmpl_id", "in", list(template_ids))], + ["attribute_id", "product_tmpl_id", "value_ids"], + order="sequence, attribute_id, id", + ) + attribute_ids = { + data["attribute_id"][0] for data in lines_data if data["attribute_id"] + } + attribute_data = self._get_attribute_data(products, attribute_ids) + value_ids = { + value_id for data in lines_data for value_id in data.get("value_ids", []) + } + value_names = {} + if value_ids: + value_names = { + data["id"]: data["name"] + for data in products.env["product.attribute.value"] + .browse(value_ids) + .read(["name"]) + } + result = {} + for data in lines_data: + attribute_id = data["attribute_id"] and data["attribute_id"][0] + attribute = attribute_data.get(attribute_id) + if ( + not attribute + or attribute["create_variant"] != "no_variant" + or not attribute["shoppingfeed_export"] + or not data["value_ids"] + ): + continue + template_id = data["product_tmpl_id"][0] + result.setdefault(template_id, []).append( + { + "name": attribute["export_name"], + "value": ", ".join( + value_names[value_id] + for value_id in data["value_ids"] + if value_id in value_names + ), + } + ) + return result + + def _get_attribute_data(self, products, attribute_ids): + if not attribute_ids: + return {} + return { + data["id"]: { + "create_variant": data["create_variant"], + "export_name": data["shoppingfeed_code_name_attribute"] or data["name"], + "shoppingfeed_export": data["shoppingfeed_export"], + } + for data in products.env["product.attribute"] + .browse(attribute_ids) + .read( + [ + "create_variant", + "name", + "shoppingfeed_code_name_attribute", + "shoppingfeed_export", + ] + ) } def _get_base_prices(self, store, products): @@ -174,13 +356,31 @@ def _get_base_prices(self, store, products): else store.company_id.currency_id ) if store.pricelist_id: - prices = store.pricelist_id._get_products_price(products, 1.0, None) + prices = self._get_pricelist_prices(store, store.pricelist_id, products) else: prices = {product.id: product.lst_price or 0.0 for product in products} return { product_id: currency.round(price) for product_id, price in prices.items() } + def _get_pricelist_prices(self, store, pricelist, products): + return pricelist._get_products_price(products, 1.0, None) + + def _get_taxes_by_key(self, store, products, product_data): + if not store.include_taxes_in_price: + return {} + tax_keys = { + tuple(data["taxes_id"]) + for data in product_data.values() + if data["taxes_id"] + } + return { + tax_key: products.env["account.tax"] + .browse(tax_key) + ._filter_taxes_by_company(store.company_id) + for tax_key in tax_keys + } + def _get_quantity_values(self, store, products): if not store.use_actual_stock_state: return {} @@ -191,7 +391,44 @@ def _get_quantity_values(self, store, products): } return dict(products._get_only_qty_available()) - def _get_main_images(self, products): + def _get_main_images(self, products, product_data=None): + if product_data is None: + return self._get_main_images_from_binary_fields(products) + product_ids = list(product_data) + template_ids = { + data["product_tmpl_id"][0] + for data in product_data.values() + if data["product_tmpl_id"] + } + attachments = products.env["ir.attachment"].sudo() + product_image_ids = set( + attachments.search( + [ + ("res_model", "=", "product.product"), + ("res_field", "=", "image_variant_1920"), + ("res_id", "in", product_ids), + ] + ).mapped("res_id") + ) + template_image_ids = set( + attachments.search( + [ + ("res_model", "=", "product.template"), + ("res_field", "=", "image_1920"), + ("res_id", "in", list(template_ids)), + ] + ).mapped("res_id") + ) + return { + product_id: product_id in product_image_ids + or ( + data["product_tmpl_id"] + and data["product_tmpl_id"][0] in template_image_ids + ) + for product_id, data in product_data.items() + } + + def _get_main_images_from_binary_fields(self, products): products_data = products.with_context(bin_size=True).read( ["image_variant_1920", "product_tmpl_id"] ) @@ -229,7 +466,9 @@ def _build_catalog_xml(self, store, products, base_url): def _build_product_xml(self, store, product, catalog_context, batch_context): product_el = etree.Element("product") - self._add_product_base_info(store, product_el, product, catalog_context) + self._add_product_base_info( + store, product_el, product, catalog_context, batch_context + ) self._add_product_price(store, product_el, product, batch_context) self._add_product_stock(store, product_el, product, batch_context) self._add_product_media( @@ -252,60 +491,83 @@ def _get_base_price(self, store, product, batch_context=None): ) return currency.round(price) - def _add_product_base_info(self, store, product_el, product, catalog_context): + def _get_batch_product_data(self, product, batch_context=None): + if not batch_context: + return {} + return batch_context["product_data"].get(product.id, {}) + + def _add_product_base_info( + self, store, product_el, product, catalog_context, batch_context + ): + product_data = batch_context["product_data"][product.id] base_url = catalog_context["base_url"] if store.use_product_id_as_sku: sku_value = str(product.id) elif store.custom_sku_field_id: - sku_value = getattr(product, store.custom_sku_field_id.name, False) or str( + sku_value = product_data.get(store.custom_sku_field_id.name) or str( product.id ) else: - sku_value = product.default_code or str(product.id) + sku_value = product_data["default_code"] or str(product.id) etree.SubElement( product_el, "reference" ).text = f"{sku_value}_{store.country_id.code}" - etree.SubElement(product_el, "gtin").text = product.barcode or "" - etree.SubElement(product_el, "name").text = etree.CDATA(product.name or "") - if product.website_url: - product_url = base_url + product.website_url + etree.SubElement(product_el, "gtin").text = product_data["barcode"] or "" + etree.SubElement(product_el, "name").text = etree.CDATA( + product_data["name"] or "" + ) + if product_data["website_url"]: + product_url = base_url + product_data["website_url"] etree.SubElement(product_el, "link").text = etree.CDATA(product_url) - if product.weight: - etree.SubElement(product_el, "weight").text = str(product.weight) - if product.product_brand_id: + if product_data["weight"]: + etree.SubElement(product_el, "weight").text = str(product_data["weight"]) + if product_data["product_brand_id"]: brand_el = etree.SubElement(product_el, "brand") etree.SubElement(brand_el, "name").text = etree.CDATA( - product.product_brand_id.name or "" + batch_context["brand_names"].get( + product_data["product_brand_id"][0], "" + ) ) - if product.sf_forced_category_id: - category = product.sf_forced_category_id + if product_data["sf_forced_category_id"]: + category_id = product_data["sf_forced_category_id"][0] else: - category = product.public_categ_ids.filtered( - lambda categ: categ.id in catalog_context["allowed_categ_ids"] - )[:1] - if category: + category_id = next( + ( + category_id + for category_id in product_data["public_categ_ids"] + if category_id in catalog_context["allowed_categ_ids"] + ), + False, + ) + if category_id: category_el = etree.SubElement(product_el, "category") etree.SubElement(category_el, "name").text = etree.CDATA( - category.display_name.replace(" / ", " > ") + batch_context["category_names"].get(category_id, "") ) etree.SubElement(category_el, "link").text = etree.CDATA( - f"{base_url}/shop/category/{category.id}" + f"{base_url}/shop/category/{category_id}" ) description_el = etree.SubElement(product_el, "description") - full_desc = product.website_description or "" + full_desc = product_data["website_description"] or "" etree.SubElement(description_el, "full").text = etree.CDATA(full_desc) - short_desc = product.description_sale or "" + short_desc = product_data["description_sale"] or "" etree.SubElement(description_el, "short").text = etree.CDATA(short_desc) def _add_product_price(self, store, product_el, product, batch_context=None): + product_data = self._get_batch_product_data(product, batch_context) price = self._get_base_price(store, product, batch_context) - if store.include_taxes_in_price and product.taxes_id: + tax_ids = product_data.get("taxes_id") if product_data else product.taxes_id.ids + if store.include_taxes_in_price and tax_ids: currency = ( store.pricelist_id.currency_id if store.pricelist_id else store.company_id.currency_id ) - product_taxes = product.taxes_id._filter_taxes_by_company(store.company_id) + if batch_context: + product_taxes = batch_context["taxes_by_key"][tuple(tax_ids)] + else: + product_taxes = product.env["account.tax"].browse(tax_ids) + product_taxes = product_taxes._filter_taxes_by_company(store.company_id) tax_result = product_taxes.compute_all( price, currency=currency, quantity=1.0, product=product ) @@ -320,24 +582,32 @@ def _get_quantity_value(self, store, product, batch_context=None): return product.qty_available def _add_product_stock(self, store, product_el, product, batch_context=None): + product_data = self._get_batch_product_data(product, batch_context) if not store.use_actual_stock_state: quantity_value = store.default_quantity else: quantity_value = ( self._get_quantity_value(store, product, batch_context) or 0 ) - if product.type == "service": + product_type = product_data.get("type") if product_data else product.type + if product_type == "service": quantity_value = store.default_quantity - if not product.sale_ok and store.force_zero_quantity_non_salable: + sale_ok = product_data.get("sale_ok") if product_data else product.sale_ok + if not sale_ok and store.force_zero_quantity_non_salable: quantity_value = 0 etree.SubElement(product_el, "quantity").text = str(int(quantity_value)) def _add_product_media( self, store, product_el, product, catalog_context, batch_context=None ): + product_data = self._get_batch_product_data(product, batch_context) base_url = catalog_context["base_url"] images_el = etree.SubElement(product_el, "images") - product_images = product.product_template_image_ids + product_images = ( + product_data.get("product_template_image_ids") + if product_data + else product.product_template_image_ids.ids + ) if store.export_all_images: all_images = product_images else: @@ -346,9 +616,9 @@ def _add_product_media( etree.SubElement(images_el, "image", type="main").text = etree.CDATA( f"{base_url}/web/image/product.product/{product.id}/image_1920" ) - for img in all_images: + for image_id in all_images: etree.SubElement(images_el, "image").text = etree.CDATA( - f"{base_url}/web/image/{img._name}/{img.id}/image_1920" + f"{base_url}/web/image/product.image/{image_id}/image_1920" ) def _has_main_image(self, product, batch_context=None): @@ -356,40 +626,64 @@ def _has_main_image(self, product, batch_context=None): return batch_context["main_images"].get(product.id, False) return self._get_main_images(product).get(product.id, False) - def _add_product_attributes(self, store, product_el, product, batch_context=None): + def _add_product_attributes( # noqa: C901 + self, store, product_el, product, batch_context=None + ): + product_data = self._get_batch_product_data(product, batch_context) attributes_el = etree.SubElement(product_el, "attributes") # Attributes that generate variants - for value in product.product_template_attribute_value_ids: - # Skip if attribute is not marked for export to Shoppingfeed - if not value.attribute_id.shoppingfeed_export: - continue + if batch_context: + variant_attributes = batch_context["variant_attributes"].get(product.id, []) + else: + variant_attributes = [] + for value in product.product_template_attribute_value_ids: + if not value.attribute_id.shoppingfeed_export: + continue + variant_attributes.append( + { + "name": value.attribute_id.shoppingfeed_code_name_attribute + or value.attribute_id.name, + "value": value.name, + } + ) + for attribute in variant_attributes: attr_el = etree.SubElement(attributes_el, "attribute") - name = ( - value.attribute_id.shoppingfeed_code_name_attribute - or value.attribute_id.name - ) - etree.SubElement(attr_el, "name").text = name - etree.SubElement(attr_el, "value").text = value.name + etree.SubElement(attr_el, "name").text = attribute["name"] + etree.SubElement(attr_el, "value").text = attribute["value"] # Attributes that do NOT generate variants - for line in product.product_tmpl_id.attribute_line_ids.filtered( - lambda line_var: ( - line_var.attribute_id.create_variant == "no_variant" - and line_var.attribute_id.shoppingfeed_export + if batch_context: + template_id = product_data["product_tmpl_id"][0] + no_variant_attributes = batch_context["no_variant_attributes"].get( + template_id, [] ) - ): - if line.value_ids: - values = ", ".join(line.value_ids.mapped("name")) - attr_el = etree.SubElement(attributes_el, "attribute") - name = ( - line.attribute_id.shoppingfeed_code_name_attribute - or line.attribute_id.name + else: + no_variant_attributes = [] + for line in product.product_tmpl_id.attribute_line_ids.filtered( + lambda line_var: ( + line_var.attribute_id.create_variant == "no_variant" + and line_var.attribute_id.shoppingfeed_export ) - etree.SubElement(attr_el, "name").text = name - etree.SubElement(attr_el, "value").text = values + ): + if line.value_ids: + no_variant_attributes.append( + { + "name": line.attribute_id.shoppingfeed_code_name_attribute + or line.attribute_id.name, + "value": ", ".join(line.value_ids.mapped("name")), + } + ) + for attribute in no_variant_attributes: + attr_el = etree.SubElement(attributes_el, "attribute") + etree.SubElement(attr_el, "name").text = attribute["name"] + etree.SubElement(attr_el, "value").text = attribute["value"] if store.additional_attribute_field_ids: for attr_field in store.additional_attribute_field_ids: field = attr_field.field_id - value = getattr(product, field.name, False) + value = ( + product_data.get(field.name) + if product_data + else getattr(product, field.name, False) + ) if not value: continue attr_el = etree.SubElement(attributes_el, "attribute") @@ -398,11 +692,15 @@ def _add_product_attributes(self, store, product_el, product, batch_context=None ) etree.SubElement(attr_el, "name").text = attr_name if field.ttype == "many2one": - etree.SubElement(attr_el, "value").text = ( - value.display_name - if hasattr(value, "display_name") - else str(value.id) - ) + if isinstance(value, tuple | list): + attr_value = value[1] if len(value) > 1 else str(value[0]) + else: + attr_value = ( + value.display_name + if hasattr(value, "display_name") + else str(value.id) + ) + etree.SubElement(attr_el, "value").text = attr_value elif field.ttype == "boolean": etree.SubElement(attr_el, "value").text = ( "True" if value else "False" From 5f5503626af03b65bd2d22e75b59df72d4abb845 Mon Sep 17 00:00:00 2001 From: eduezerouali-tecnativa Date: Mon, 18 May 2026 10:04:15 +0200 Subject: [PATCH 05/27] [IMP] shoppingfeed_integration: add filter to sales for shoppingfeed references --- shoppingfeed_integration/views/sale_order_views.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/shoppingfeed_integration/views/sale_order_views.xml b/shoppingfeed_integration/views/sale_order_views.xml index c14e80c..3f08092 100644 --- a/shoppingfeed_integration/views/sale_order_views.xml +++ b/shoppingfeed_integration/views/sale_order_views.xml @@ -55,6 +55,7 @@ + Date: Mon, 18 May 2026 10:17:03 +0200 Subject: [PATCH 06/27] [IMP] shoppingfeed_integration: No vat validation for created partners TT56368 --- shoppingfeed_integration/models/sale_order.py | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index 13999d6..1163bbc 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -105,6 +105,9 @@ def _shoppingfeed_prepare_partner( or "Shoppingfeed Customer" ) is_company = False + country = self.env["res.country"].search( + [("code", "=", billing.get("country"))], limit=1 + ) vals = { "name": name, "is_company": is_company, @@ -114,9 +117,7 @@ def _shoppingfeed_prepare_partner( "zip": billing.get("postalCode") or "", "city": billing.get("city"), "phone": billing.get("phone") or billing.get("mobilePhone"), - "country_id": self.env["res.country"] - .search([("code", "=", billing.get("country"))], limit=1) - .id, + "country_id": country.id, "company_id": store.company_id.id, } af = additional_fields or {} @@ -126,15 +127,29 @@ def _shoppingfeed_prepare_partner( or af.get("buyer_identification_number") or af.get("buyer_identifier_number") ) + vat_valid = False if vat: vals["vat"] = vat + if country: + vat_valid = self._shoppingfeed_valid_vat(vat, country, is_company) if channel and channel.account_id: vals["property_account_receivable_id"] = channel.account_id.id if channel and channel.payment_method_line_id: vals["property_inbound_payment_method_line_id"] = ( channel.payment_method_line_id.id ) - return self.env["res.partner"].create(vals) + return ( + self.env["res.partner"] + .with_context(no_vat_validation=not vat_valid) + .create(vals) + ) + + @api.model + def _shoppingfeed_valid_vat(self, vat, country, is_company): + """Overwrite by other modules to check valid vat methods""" + if self.env["res.partner"]._run_vat_test(vat, country, is_company) is False: + return False + return True def _shoppingfeed_prepare_shipping(self, shipping, partner, store): # Prepare or create delivery address for the Shoppingfeed order. @@ -166,7 +181,9 @@ def _shoppingfeed_prepare_shipping(self, shipping, partner, store): ("zip", "=", shipping_vals["zip"]), ], limit=1, - ) or self.env["res.partner"].create(shipping_vals) + ) or self.env["res.partner"].with_context(no_vat_validation=True).create( + shipping_vals + ) return shipping_partner def _shoppingfeed_clean_product_reference(self, reference): From fe78216aedaccb590c5c5628b5febe3fbc8796c9 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 18 May 2026 09:59:05 -0500 Subject: [PATCH 07/27] [IMP] shoppingfeed_integration: Add option to auto-confirm sales orders When this feature is enabled (by default), new orders are confirmed automatically. --- shoppingfeed_integration/models/sale_order.py | 4 +++- shoppingfeed_integration/models/shoppingfeed_channel.py | 7 +++++++ .../views/shoppingfeed_channel_views.xml | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index 1163bbc..4d2f24f 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -421,7 +421,7 @@ def _import_orders_from_shoppingfeed(self, store): carrier = self._shoppingfeed_get_carrier( store, sf_channel, carrier_name, shipping_country ) - self._shoppingfeed_create_sale_order( + new_sale = self._shoppingfeed_create_sale_order( store, order, partner, @@ -430,6 +430,8 @@ def _import_orders_from_shoppingfeed(self, store): order_type_id, carrier, ) + if sf_channel.auto_confirm_sale: + new_sale.action_confirm() self._shoppingfeed_acknowledge_order(store, order) except Exception as e: error_msg = f"Error creating order: {str(e)}" diff --git a/shoppingfeed_integration/models/shoppingfeed_channel.py b/shoppingfeed_integration/models/shoppingfeed_channel.py index fa690e2..75088b2 100644 --- a/shoppingfeed_integration/models/shoppingfeed_channel.py +++ b/shoppingfeed_integration/models/shoppingfeed_channel.py @@ -93,6 +93,13 @@ class ShoppingfeedChannel(models.Model): "upon confirmation using the configured payment method." ), ) + auto_confirm_sale = fields.Boolean( + string="Automatic Sale Confirmation", + default=True, + help=( + "When enabled, sales orders from this channel are automatically confirmed." + ), + ) account_id = fields.Many2one( comodel_name="account.account", string="Receivable Account", diff --git a/shoppingfeed_integration/views/shoppingfeed_channel_views.xml b/shoppingfeed_integration/views/shoppingfeed_channel_views.xml index cb40d17..fd185db 100644 --- a/shoppingfeed_integration/views/shoppingfeed_channel_views.xml +++ b/shoppingfeed_integration/views/shoppingfeed_channel_views.xml @@ -38,6 +38,7 @@ options="{'no_create': True}" /> + From 592b0f879ce6c2fcb9b7817b2f30d4f5f17ec20e Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Mon, 18 May 2026 10:05:24 -0500 Subject: [PATCH 08/27] [IMP] shoppingfeed_integration: Add option to configure the filter used for orders imported from the API --- shoppingfeed_integration/models/sale_order.py | 5 ++++- shoppingfeed_integration/models/shoppingfeed_store.py | 8 ++++++++ .../views/shoppingfeed_store_views.xml | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index 4d2f24f..89e7bbf 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -84,7 +84,10 @@ def _shoppingfeed_fetch_orders(self, store): "Authorization": store.access_token, "Content-Type": "application/json", } - params = {"acknowledgment": "unacknowledged", "status": "waiting_shipment"} + params = { + "acknowledgment": store.filter_order_acknowledgment, + "status": "waiting_shipment", + } response = requests.get(url, headers=headers, params=params, timeout=30) return response.json().get("_embedded", {}).get("order", []) diff --git a/shoppingfeed_integration/models/shoppingfeed_store.py b/shoppingfeed_integration/models/shoppingfeed_store.py index c529e88..897904b 100644 --- a/shoppingfeed_integration/models/shoppingfeed_store.py +++ b/shoppingfeed_integration/models/shoppingfeed_store.py @@ -310,6 +310,14 @@ class ShoppingfeedStore(models.Model): "affecting production data in ShoppingFeed." ), ) + filter_order_acknowledgment = fields.Selection( + [ + ("acknowledged", "Acknowledged"), + ("unacknowledged", "Unacknowledged"), + ], + default="unacknowledged", + string="Filter Acknowledgment", + ) @api.depends("catalog_id", "website_id") def _compute_feed_url(self): diff --git a/shoppingfeed_integration/views/shoppingfeed_store_views.xml b/shoppingfeed_integration/views/shoppingfeed_store_views.xml index 10c28fa..ea7a52d 100644 --- a/shoppingfeed_integration/views/shoppingfeed_store_views.xml +++ b/shoppingfeed_integration/views/shoppingfeed_store_views.xml @@ -220,6 +220,7 @@ + From c188892d00ecae893df51f176ebcfee2c18bedba Mon Sep 17 00:00:00 2001 From: sergio-teruel Date: Mon, 18 May 2026 22:32:27 +0200 Subject: [PATCH 09/27] [FIX] shoppingfeed_integration: include_taxes_in_price not take into account to retrieve sale orders from channel TT62541 --- shoppingfeed_integration/models/sale_order.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index 89e7bbf..a0d6d6b 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -279,7 +279,7 @@ def _shoppingfeed_get_carrier( carrier = store.default_delivery_carrier_id return carrier - def _shoppingfeed_prepare_order_line(self, item, aliases=None): + def _shoppingfeed_prepare_order_line(self, store, item, aliases=None): ref = item.get("reference") clean_ref = self._shoppingfeed_resolve_product_reference(ref, aliases) product = self.env["product.product"].search( @@ -287,10 +287,13 @@ def _shoppingfeed_prepare_order_line(self, item, aliases=None): ) if not product: return False + price = item.get("price", product.list_price) + if store.include_taxes_in_price: + price = price - item.get("taxAmount", 0.0) return { "product_id": product.id, "product_uom_qty": item.get("quantity", 1.0), - "price_unit": item.get("price", product.list_price), + "price_unit": price, "name": item.get("name") or product.display_name, } @@ -313,7 +316,7 @@ def _shoppingfeed_create_sale_order( aliases = order.get("itemsReferencesAliases", {}) order_lines = [] for item in order.get("items", []): - line_vals = self._shoppingfeed_prepare_order_line(item, aliases) + line_vals = self._shoppingfeed_prepare_order_line(store, item, aliases) if line_vals: order_lines.append((0, 0, line_vals)) sale_order = self.create( From 79db2e98b5ffd9a2c95236c0beb046aee257b6a7 Mon Sep 17 00:00:00 2001 From: sergio-teruel Date: Tue, 19 May 2026 01:52:34 +0200 Subject: [PATCH 10/27] [IMP] shoppingfeed_integration: Log post shoppingfeed upload status TT62540 --- .../models/account_move.py | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/shoppingfeed_integration/models/account_move.py b/shoppingfeed_integration/models/account_move.py index c9600dd..58bd738 100644 --- a/shoppingfeed_integration/models/account_move.py +++ b/shoppingfeed_integration/models/account_move.py @@ -6,7 +6,7 @@ import requests -from odoo import models +from odoo import _, models class AccountMove(models.Model): @@ -33,7 +33,7 @@ def _shoppingfeed_upload_invoice(self): continue if store._shoppingfeed_is_demo_mode(): continue - pdf_content, _ = self.env["ir.actions.report"]._render_qweb_pdf( + pdf_content, __ = self.env["ir.actions.report"]._render_qweb_pdf( "account.account_invoices", move.ids ) pdf_file = io.BytesIO(pdf_content) @@ -52,7 +52,30 @@ def _shoppingfeed_upload_invoice(self): } files = {"files[]": (pdf_file.name, pdf_file, "application/pdf")} data = {"body": json.dumps(payload)} - requests.post(url, headers=headers, files=files, data=data, timeout=30) + response = requests.post( + url, headers=headers, files=files, data=data, timeout=30 + ) + if response.ok: + move.message_post( + body=_( + "Invoice uploaded to Shoppingfeed successfully " + "(order ref: %(ref)s, HTTP %(status)s).", + ref=sale_order.shoppingfeed_order_ref, + status=response.status_code, + ), + subtype_xmlid="mail.mt_note", + ) + else: + move.message_post( + body=_( + "Failed to upload invoice to Shoppingfeed " + "(order ref: %(ref)s, HTTP %(status)s): %(detail)s", + ref=sale_order.shoppingfeed_order_ref, + status=response.status_code, + detail=response.text, + ), + subtype_xmlid="mail.mt_note", + ) def _shoppingfeed_auto_pay(self): for move in self: From 5f54dcfb2c929f6f6ce14bb3ddad16f568c35470 Mon Sep 17 00:00:00 2001 From: sergio-teruel Date: Tue, 19 May 2026 12:36:15 +0200 Subject: [PATCH 11/27] [IMP] shoppingfeed_integration: compute taxes correctly TT62572 --- shoppingfeed_integration/models/sale_order.py | 65 +++++++++++-------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index a0d6d6b..a727a44 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -279,7 +279,7 @@ def _shoppingfeed_get_carrier( carrier = store.default_delivery_carrier_id return carrier - def _shoppingfeed_prepare_order_line(self, store, item, aliases=None): + def _shoppingfeed_prepare_order_line(self, store, item, new_order, aliases=None): ref = item.get("reference") clean_ref = self._shoppingfeed_resolve_product_reference(ref, aliases) product = self.env["product.product"].search( @@ -288,14 +288,23 @@ def _shoppingfeed_prepare_order_line(self, store, item, aliases=None): if not product: return False price = item.get("price", product.list_price) - if store.include_taxes_in_price: - price = price - item.get("taxAmount", 0.0) - return { + vals = { "product_id": product.id, "product_uom_qty": item.get("quantity", 1.0), "price_unit": price, "name": item.get("name") or product.display_name, } + if not store.include_taxes_in_price: + return vals + new_sol = self.env["sale.order.line"].new({**vals, "order_id": new_order}) + tax_data = new_sol.tax_id.with_context(force_price_include=True).compute_all( + item.get("price"), + currency=new_order.currency_id, + quantity=1.0, + partner=new_order.partner_shipping_id, + ) + vals["price_unit"] = tax_data["total_excluded"] + return vals def _shoppingfeed_create_sale_order( self, @@ -314,34 +323,34 @@ def _shoppingfeed_create_sale_order( limit=1, ) aliases = order.get("itemsReferencesAliases", {}) + so_vals = { + "partner_id": partner.id, + "partner_invoice_id": partner.id, + "partner_shipping_id": shipping_partner.id + if shipping_partner + else partner.id, + "shoppingfeed_reference": order.get("reference"), + "currency_id": currency.id, + "shoppingfeed_order_ref": ext_id, + "shoppingfeed_store_id": store.id, + "type_id": order_type_id or store.default_order_type_id.id, + "payment_mode_id": store.default_payment_mode_id.id, + "payment_term_id": store.default_payment_term_id.id, + "shoppingfeed_channel_id": sf_channel.id, + "shoppingfeed_status": order.get("status"), + "company_id": store.company_id.id, + "shoppingfeed_raw_data": json.dumps(order, indent=2, ensure_ascii=False), + } + new_so = self.new(so_vals) order_lines = [] for item in order.get("items", []): - line_vals = self._shoppingfeed_prepare_order_line(store, item, aliases) + line_vals = self._shoppingfeed_prepare_order_line( + store, item, new_so, aliases + ) if line_vals: order_lines.append((0, 0, line_vals)) - sale_order = self.create( - { - "partner_id": partner.id, - "partner_invoice_id": partner.id, - "partner_shipping_id": shipping_partner.id - if shipping_partner - else partner.id, - "shoppingfeed_reference": order.get("reference"), - "currency_id": currency.id, - "shoppingfeed_order_ref": ext_id, - "shoppingfeed_store_id": store.id, - "type_id": order_type_id or store.default_order_type_id.id, - "payment_mode_id": store.default_payment_mode_id.id, - "payment_term_id": store.default_payment_term_id.id, - "shoppingfeed_channel_id": sf_channel.id, - "shoppingfeed_status": order.get("status"), - "company_id": store.company_id.id, - "shoppingfeed_raw_data": json.dumps( - order, indent=2, ensure_ascii=False - ), - "order_line": order_lines, - } - ) + so_vals["order_line"] = order_lines + sale_order = self.create(so_vals) if carrier: payment = order.get("payment", {}) or {} shipping_cost = payment.get("shippingAmount", 0.0) From 612fdb47365ea1936eb5f18d16260f50f00d7b73 Mon Sep 17 00:00:00 2001 From: sergio-teruel Date: Tue, 19 May 2026 13:31:06 +0200 Subject: [PATCH 12/27] [IMP] shoppingfeed_integration: compute taxes correctly for carrier sale orde line TT62572 --- shoppingfeed_integration/models/sale_order.py | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index a727a44..fa93031 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -279,6 +279,16 @@ def _shoppingfeed_get_carrier( carrier = store.default_delivery_carrier_id return carrier + def _shoppingfeed_get_tax_data(self, sale_order, line_vals): + new_sol = self.env["sale.order.line"].new({**line_vals, "order_id": sale_order}) + tax_data = new_sol.tax_id.with_context(force_price_include=True).compute_all( + line_vals["price_unit"], + currency=sale_order.currency_id, + quantity=1.0, + partner=sale_order.partner_shipping_id, + ) + return tax_data + def _shoppingfeed_prepare_order_line(self, store, item, new_order, aliases=None): ref = item.get("reference") clean_ref = self._shoppingfeed_resolve_product_reference(ref, aliases) @@ -294,15 +304,10 @@ def _shoppingfeed_prepare_order_line(self, store, item, new_order, aliases=None) "price_unit": price, "name": item.get("name") or product.display_name, } - if not store.include_taxes_in_price: - return vals - new_sol = self.env["sale.order.line"].new({**vals, "order_id": new_order}) - tax_data = new_sol.tax_id.with_context(force_price_include=True).compute_all( - item.get("price"), - currency=new_order.currency_id, - quantity=1.0, - partner=new_order.partner_shipping_id, - ) + # TODO: Add condition to remove taxes depends on store or channel field + # if not store.include_taxes_in_price: + # return vals + tax_data = self._shoppingfeed_get_tax_data(new_order, vals) vals["price_unit"] = tax_data["total_excluded"] return vals @@ -353,8 +358,15 @@ def _shoppingfeed_create_sale_order( sale_order = self.create(so_vals) if carrier: payment = order.get("payment", {}) or {} - shipping_cost = payment.get("shippingAmount", 0.0) - sale_order.set_delivery_line(carrier, shipping_cost) + # TODO: Add condition to remove taxes depends on store or channel field + tax_data = self._shoppingfeed_get_tax_data( + new_so, + { + "product_id": carrier.product_id.id, + "price_unit": payment.get("shippingAmount", 0.0), + }, + ) + sale_order.set_delivery_line(carrier, tax_data["total_excluded"]) link = ( f"https://app.shopping-feed.com/v3/es/orders/detail/" f"{order.get('id')}?store={order.get('storeId')}" From 0c6eb8ed48505041f1deae3ac12eb0c26e2961a9 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Tue, 19 May 2026 11:28:41 -0500 Subject: [PATCH 13/27] [IMP] shoppingfeed_integration: Change the carrier field from many2one to many2many in store carrier mappings With this change, for each mapping line the user can configure multiple carriers, and the logic selects the first one that matches the shipping address. For example, the customer buys from the Spain store, but the delivery address is in another country. --- shoppingfeed_integration/__manifest__.py | 2 +- .../migrations/18.0.1.0.3/post-migration.py | 15 +++++++++++++++ shoppingfeed_integration/models/sale_order.py | 16 +++++++++------- .../models/shoppingfeed_store_carrier_map.py | 2 +- .../views/shoppingfeed_store_views.xml | 6 +++++- 5 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 shoppingfeed_integration/migrations/18.0.1.0.3/post-migration.py diff --git a/shoppingfeed_integration/__manifest__.py b/shoppingfeed_integration/__manifest__.py index a1242d8..e6c995c 100644 --- a/shoppingfeed_integration/__manifest__.py +++ b/shoppingfeed_integration/__manifest__.py @@ -2,7 +2,7 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). { "name": "Shoppingfeed Integration", - "version": "18.0.1.0.2", + "version": "18.0.1.0.3", "summary": "Integrate Odoo with Shoppingfeed for product export and order sync", "category": "Sales", "author": "Tecnativa, Odoo Community Association (OCA)", diff --git a/shoppingfeed_integration/migrations/18.0.1.0.3/post-migration.py b/shoppingfeed_integration/migrations/18.0.1.0.3/post-migration.py new file mode 100644 index 0000000..8fa156c --- /dev/null +++ b/shoppingfeed_integration/migrations/18.0.1.0.3/post-migration.py @@ -0,0 +1,15 @@ +# Copyright 2026 Tecnativa - Carlos Lopez +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + openupgrade.m2o_to_x2m( + env.cr, + env["shoppingfeed.store.carrier.map"], + "shoppingfeed_store_carrier_map", + "delivery_carrier_ids", + "delivery_carrier_id", + ) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index fa93031..f02f821 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -255,20 +255,25 @@ def _shoppingfeed_validate_products(self, order): return missing_products def _shoppingfeed_get_carrier( - self, store, channel, carrier_name, shipping_country=None + self, store, channel, carrier_name, partner, shipping_partner=None ): carrier = False + shipping_partner = shipping_partner or partner + shipping_country = shipping_partner.country_id if shipping_partner else None if carrier_name: carrier_map = store.carrier_map_ids.filtered( lambda m, carrier_name=carrier_name: m.carrier_name.strip().lower() == carrier_name.strip().lower() ) if carrier_map: - carrier = carrier_map.delivery_carrier_id + for candidate_carrier in carrier_map.delivery_carrier_ids: + if candidate_carrier._match_address(shipping_partner): + carrier = candidate_carrier + break if not carrier: if channel and shipping_country and channel.country_carrier_ids: country_rule = channel.country_carrier_ids.filtered( - lambda r, c=shipping_country: r.country_id == c + lambda rule, country=shipping_country: rule.country_id == country ) if country_rule: carrier = country_rule[0].delivery_carrier_id @@ -442,11 +447,8 @@ def _import_orders_from_shoppingfeed(self, store): # Carrier mapping shipment = order.get("shipment", {}) or {} carrier_name = shipment.get("carrier") - shipping_country = ( - shipping_partner.country_id if shipping_partner else None - ) carrier = self._shoppingfeed_get_carrier( - store, sf_channel, carrier_name, shipping_country + store, sf_channel, carrier_name, partner, shipping_partner ) new_sale = self._shoppingfeed_create_sale_order( store, diff --git a/shoppingfeed_integration/models/shoppingfeed_store_carrier_map.py b/shoppingfeed_integration/models/shoppingfeed_store_carrier_map.py index d8ef40c..938f13c 100644 --- a/shoppingfeed_integration/models/shoppingfeed_store_carrier_map.py +++ b/shoppingfeed_integration/models/shoppingfeed_store_carrier_map.py @@ -17,7 +17,7 @@ class ShoppingfeedStoreCarrierMap(models.Model): required=True, help="Carrier name as received from Shoppingfeed", ) - delivery_carrier_id = fields.Many2one( + delivery_carrier_ids = fields.Many2many( comodel_name="delivery.carrier", string="Odoo Delivery Carrier", required=True, diff --git a/shoppingfeed_integration/views/shoppingfeed_store_views.xml b/shoppingfeed_integration/views/shoppingfeed_store_views.xml index ea7a52d..881f78f 100644 --- a/shoppingfeed_integration/views/shoppingfeed_store_views.xml +++ b/shoppingfeed_integration/views/shoppingfeed_store_views.xml @@ -209,7 +209,11 @@ - + From 6bb2d9b0b346b977a4b4aa275ba19c029c175213 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Thu, 21 May 2026 07:45:25 -0500 Subject: [PATCH 14/27] [IMP] shoppingfeed_integration: Format phone numbers for billing and shipping addresses --- shoppingfeed_integration/__manifest__.py | 1 + shoppingfeed_integration/models/sale_order.py | 22 ++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/shoppingfeed_integration/__manifest__.py b/shoppingfeed_integration/__manifest__.py index e6c995c..62e7ff8 100644 --- a/shoppingfeed_integration/__manifest__.py +++ b/shoppingfeed_integration/__manifest__.py @@ -15,6 +15,7 @@ "product_brand", "sale_order_type", "account_payment_sale", + "phone_validation", ], "data": [ "security/shoppingfeed_security.xml", diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index f02f821..5b0616c 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -91,6 +91,13 @@ def _shoppingfeed_fetch_orders(self, store): response = requests.get(url, headers=headers, params=params, timeout=30) return response.json().get("_embedded", {}).get("order", []) + @api.model + def _shoppingfeed_format_phone(self, phone_number, country): + phone_sanitized = self._phone_format( + number=phone_number, country=country, force_format="INTERNATIONAL" + ) + return phone_sanitized or phone_number + def _shoppingfeed_prepare_partner( self, billing, store, channel=None, additional_fields=None ): @@ -111,6 +118,8 @@ def _shoppingfeed_prepare_partner( country = self.env["res.country"].search( [("code", "=", billing.get("country"))], limit=1 ) + phone = billing.get("phone") or billing.get("mobilePhone") + phone = self._shoppingfeed_format_phone(phone, country) vals = { "name": name, "is_company": is_company, @@ -119,7 +128,7 @@ def _shoppingfeed_prepare_partner( "street2": billing.get("street2"), "zip": billing.get("postalCode") or "", "city": billing.get("city"), - "phone": billing.get("phone") or billing.get("mobilePhone"), + "phone": phone, "country_id": country.id, "company_id": store.company_id.id, } @@ -158,6 +167,11 @@ def _shoppingfeed_prepare_shipping(self, shipping, partner, store): # Prepare or create delivery address for the Shoppingfeed order. if not shipping: return None + country = self.env["res.country"].search( + [("code", "=", shipping.get("country"))], limit=1 + ) + phone = shipping.get("phone") or shipping.get("mobilePhone") + phone = self._shoppingfeed_format_phone(phone, country) shipping_vals = { "parent_id": partner.id, "type": "delivery", @@ -169,11 +183,9 @@ def _shoppingfeed_prepare_shipping(self, shipping, partner, store): "street2": shipping.get("street2"), "zip": shipping.get("postalCode") or "", "city": shipping.get("city"), - "phone": shipping.get("phone") or shipping.get("mobilePhone"), + "phone": phone, "email": shipping.get("email"), - "country_id": self.env["res.country"] - .search([("code", "=", shipping.get("country"))], limit=1) - .id, + "country_id": country.id, "company_id": store.company_id.id, } shipping_partner = self.env["res.partner"].search( From efe0bd22e3cc35fc0439c43ba188146d4317a3a2 Mon Sep 17 00:00:00 2001 From: sergio-teruel Date: Thu, 21 May 2026 18:32:48 +0200 Subject: [PATCH 15/27] [IMP] shoppingfeed_integration: Debug log message to send picking to SF REMOVE BEFORE MERGE --- shoppingfeed_integration/models/stock_picking.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/shoppingfeed_integration/models/stock_picking.py b/shoppingfeed_integration/models/stock_picking.py index e4ce5a1..151e316 100644 --- a/shoppingfeed_integration/models/stock_picking.py +++ b/shoppingfeed_integration/models/stock_picking.py @@ -1,9 +1,13 @@ # Copyright 2025 Juan Carlos Oñate - Tecnativa # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import logging + import requests from odoo import fields, models +_logger = logging.getLogger(__name__) + class StockPicking(models.Model): _inherit = "stock.picking" @@ -23,13 +27,14 @@ def _shoppingfeed_ship_order_payload(self, sale_order): return { "id": int(sale_order.shoppingfeed_order_ref), "carrier": self.carrier_id.name or "Unknown", - "trackingLink": self.carrier_id.tracking_url or "", + "trackingLink": self.carrier_tracking_url or "", "trackingNumber": self.carrier_tracking_ref or "", } def _shoppingfeed_notify_shipment(self): # Notify Shoppingfeed that the order has been shipped for picking in self: + _logger.info("Envio de tracking a SF") sale_order = picking.sale_id if ( picking.shoppingfeed_shipped @@ -37,6 +42,7 @@ def _shoppingfeed_notify_shipment(self): or not sale_order.shoppingfeed_order_ref or not sale_order.shoppingfeed_store_id ): + _logger.info("Envio de tracking a SF 1er - contune") continue store = sale_order.shoppingfeed_store_id if ( @@ -44,11 +50,13 @@ def _shoppingfeed_notify_shipment(self): or not store.catalog_id or not store.notify_shipment ): + _logger.info("Envio de tracking a SF 2 - contune") continue if store._shoppingfeed_is_demo_mode(): continue order_payload = picking._shoppingfeed_ship_order_payload(sale_order) if not order_payload: + _logger.info("Envio de tracking a SF 4 - contune") continue url = ( f"https://api.shopping-feed.com/v1/store/{store.catalog_id}/order/ship" @@ -59,5 +67,10 @@ def _shoppingfeed_notify_shipment(self): } payload = {"order": [order_payload]} response = requests.post(url, json=payload, headers=headers, timeout=30) + _logger.info( + f"Envio de tracking a SF after " + f"response status: {response.status_code} " + f"response text: {response.text}" + ) if response.status_code == 202: picking.shoppingfeed_shipped = True From f098b90ef802d9841630be33323b427fc500c137 Mon Sep 17 00:00:00 2001 From: sergio-teruel Date: Thu, 21 May 2026 22:20:06 +0200 Subject: [PATCH 16/27] [FIX] shoppingfeed_integration: Invoices not upload to shoppingfeed in some cases --- shoppingfeed_integration/models/account_move.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/shoppingfeed_integration/models/account_move.py b/shoppingfeed_integration/models/account_move.py index 58bd738..4ac5398 100644 --- a/shoppingfeed_integration/models/account_move.py +++ b/shoppingfeed_integration/models/account_move.py @@ -1,13 +1,15 @@ # Copyright 2025 Juan Carlos Oñate - Tecnativa # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). - import io import json +import logging import requests from odoo import _, models +_logger = logging.getLogger(__name__) + class AccountMove(models.Model): _inherit = "account.move" @@ -100,8 +102,9 @@ def _shoppingfeed_auto_pay(self): active_ids=move.ids, ).create({})._create_payments() - def action_post(self): - res = super().action_post() - self._shoppingfeed_upload_invoice() - self._shoppingfeed_auto_pay() - return res + def _post(self, soft=True): + posted = super()._post(soft) + invoices = posted.filtered(lambda move: move.is_invoice()) + invoices._shoppingfeed_upload_invoice() + invoices._shoppingfeed_auto_pay() + return posted From 680bc804884aaf37bb8975c1b63ca863a4b0575c Mon Sep 17 00:00:00 2001 From: Carlos Dauden Date: Fri, 22 May 2026 11:19:18 +0200 Subject: [PATCH 17/27] [FIX] shoppingfeed_integration: Singleton error on order import Method _shoppingfeed_format_phone called without order and _phone_format has ensure_one TT62646 --- shoppingfeed_integration/models/sale_order.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index 5b0616c..62e9982 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -93,6 +93,8 @@ def _shoppingfeed_fetch_orders(self, store): @api.model def _shoppingfeed_format_phone(self, phone_number, country): + if not self: + return phone_number phone_sanitized = self._phone_format( number=phone_number, country=country, force_format="INTERNATIONAL" ) From 7c54a5c25daac00c385bce9384c38b9fed296c22 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Tue, 26 May 2026 10:38:56 -0500 Subject: [PATCH 18/27] [IMP] shoppingfeed_integration: Prevent singleton error when no phone number is set Before this commit, the formatting was never performed because self was always empty. In this commit: https://github.com/OCA/shoppingfeed/commit/44e22b8b8da21acaab9e5b4489f45ee7176eb064 the code was changed to skip formatting when self is empty, but self is always empty in this case. This commit fixes the code to perform the formatting only when the phone field is set; if it is empty, the formatting is skipped. https://github.com/odoo/odoo/blob/74995277d6817103ae1816e4653e81460c3daf56/addons/phone_validation/models/models.py#L74 --- shoppingfeed_integration/models/sale_order.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index 62e9982..b9d772c 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -93,8 +93,8 @@ def _shoppingfeed_fetch_orders(self, store): @api.model def _shoppingfeed_format_phone(self, phone_number, country): - if not self: - return phone_number + if not phone_number: + return "" phone_sanitized = self._phone_format( number=phone_number, country=country, force_format="INTERNATIONAL" ) From 2907d8be21f0528214c9996ef03538d46fdc523e Mon Sep 17 00:00:00 2001 From: juancarlosonate-tecnativa Date: Thu, 28 May 2026 13:55:31 +0200 Subject: [PATCH 19/27] [IMP] shoppingfeed_integration: Add date_download_since filter to fetch orders since a given datetime TT62769 --- shoppingfeed_integration/models/sale_order.py | 2 ++ shoppingfeed_integration/models/shoppingfeed_store.py | 1 + shoppingfeed_integration/views/shoppingfeed_store_views.xml | 1 + 3 files changed, 4 insertions(+) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index b9d772c..b9f68f5 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -88,6 +88,8 @@ def _shoppingfeed_fetch_orders(self, store): "acknowledgment": store.filter_order_acknowledgment, "status": "waiting_shipment", } + if store.date_download_since: + params["since"] = store.date_download_since.isoformat(timespec="seconds") response = requests.get(url, headers=headers, params=params, timeout=30) return response.json().get("_embedded", {}).get("order", []) diff --git a/shoppingfeed_integration/models/shoppingfeed_store.py b/shoppingfeed_integration/models/shoppingfeed_store.py index 897904b..e790e0d 100644 --- a/shoppingfeed_integration/models/shoppingfeed_store.py +++ b/shoppingfeed_integration/models/shoppingfeed_store.py @@ -318,6 +318,7 @@ class ShoppingfeedStore(models.Model): default="unacknowledged", string="Filter Acknowledgment", ) + date_download_since = fields.Datetime() @api.depends("catalog_id", "website_id") def _compute_feed_url(self): diff --git a/shoppingfeed_integration/views/shoppingfeed_store_views.xml b/shoppingfeed_integration/views/shoppingfeed_store_views.xml index 881f78f..faf22f1 100644 --- a/shoppingfeed_integration/views/shoppingfeed_store_views.xml +++ b/shoppingfeed_integration/views/shoppingfeed_store_views.xml @@ -225,6 +225,7 @@ + From d2600f737a92a870804bff54286f60338f426d1f Mon Sep 17 00:00:00 2001 From: juancarlosonate-tecnativa Date: Thu, 28 May 2026 18:35:48 +0200 Subject: [PATCH 20/27] [IMP] shoppingfeed_integration: neutralize sets demo_mode on all stores --- shoppingfeed_integration/data/neutralize.sql | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 shoppingfeed_integration/data/neutralize.sql diff --git a/shoppingfeed_integration/data/neutralize.sql b/shoppingfeed_integration/data/neutralize.sql new file mode 100644 index 0000000..be0a2d8 --- /dev/null +++ b/shoppingfeed_integration/data/neutralize.sql @@ -0,0 +1,3 @@ +UPDATE shoppingfeed_store + SET demo_mode = TRUE + WHERE demo_mode IS DISTINCT FROM TRUE; From 98317887547f10bacdeeae55b8d5ae991ec50689 Mon Sep 17 00:00:00 2001 From: juancarlosonate-tecnativa Date: Tue, 2 Jun 2026 10:28:24 +0200 Subject: [PATCH 21/27] [IMP] shoppingfeed_integration: Add configurable order status filter on store --- shoppingfeed_integration/models/sale_order.py | 5 +++-- .../models/shoppingfeed_store.py | 19 +++++++++++++++++++ .../views/shoppingfeed_store_views.xml | 1 + 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index b9f68f5..51c3438 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -78,7 +78,7 @@ def _compute_amount_to_invoice(self): return res def _shoppingfeed_fetch_orders(self, store): - # Fetch only unacknowledged orders from Shoppingfeed for the given store. + # Fetch orders from Shoppingfeed for the given store. url = f"https://api.shopping-feed.com/v1/store/{store.catalog_id}/order" headers = { "Authorization": store.access_token, @@ -86,8 +86,9 @@ def _shoppingfeed_fetch_orders(self, store): } params = { "acknowledgment": store.filter_order_acknowledgment, - "status": "waiting_shipment", } + if store.filter_order_status: + params["status"] = store.filter_order_status if store.date_download_since: params["since"] = store.date_download_since.isoformat(timespec="seconds") response = requests.get(url, headers=headers, params=params, timeout=30) diff --git a/shoppingfeed_integration/models/shoppingfeed_store.py b/shoppingfeed_integration/models/shoppingfeed_store.py index e790e0d..3354367 100644 --- a/shoppingfeed_integration/models/shoppingfeed_store.py +++ b/shoppingfeed_integration/models/shoppingfeed_store.py @@ -318,6 +318,25 @@ class ShoppingfeedStore(models.Model): default="unacknowledged", string="Filter Acknowledgment", ) + filter_order_status = fields.Selection( + [ + ("created", "Created"), + ("waiting_store_acceptance", "Waiting Store Acceptance"), + ("refused", "Refused"), + ("waiting_shipment", "Waiting Shipment"), + ("shipped", "Shipped"), + ("cancelled", "Cancelled"), + ("refunded", "Refunded"), + ("partially_refunded", "Partially Refunded"), + ("partially_shipped", "Partially Shipped"), + ], + default="waiting_shipment", + string="Filter Status", + help=( + "Only orders in this Shoppingfeed status will be imported. " + "Leave empty to import orders regardless of their status." + ), + ) date_download_since = fields.Datetime() @api.depends("catalog_id", "website_id") diff --git a/shoppingfeed_integration/views/shoppingfeed_store_views.xml b/shoppingfeed_integration/views/shoppingfeed_store_views.xml index faf22f1..d5222e6 100644 --- a/shoppingfeed_integration/views/shoppingfeed_store_views.xml +++ b/shoppingfeed_integration/views/shoppingfeed_store_views.xml @@ -225,6 +225,7 @@ + From 6a55a6d70daf63d67d11fdaedaabbd90a55e0ea8 Mon Sep 17 00:00:00 2001 From: juancarlosonate-tecnativa Date: Tue, 2 Jun 2026 11:07:59 +0200 Subject: [PATCH 22/27] [IMP] shoppingfeed_integration: Request max page size when fetching orders --- shoppingfeed_integration/models/sale_order.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index 51c3438..47494c8 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -86,6 +86,7 @@ def _shoppingfeed_fetch_orders(self, store): } params = { "acknowledgment": store.filter_order_acknowledgment, + "limit": 200, } if store.filter_order_status: params["status"] = store.filter_order_status From adad3297d3ba376f0f4bf00f498e4b427dca478d Mon Sep 17 00:00:00 2001 From: juancarlosonate-tecnativa Date: Tue, 2 Jun 2026 12:08:06 +0200 Subject: [PATCH 23/27] [FIX] shoppingfeed_integration: use invalidate_all to prevent feed 500 --- shoppingfeed_integration/controllers/catalog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shoppingfeed_integration/controllers/catalog.py b/shoppingfeed_integration/controllers/catalog.py index 0afbf5b..7cbbf00 100644 --- a/shoppingfeed_integration/controllers/catalog.py +++ b/shoppingfeed_integration/controllers/catalog.py @@ -133,7 +133,7 @@ def _iter_catalog_xml(self, store, batch_size=500): ) yield etree.tostring(product_el, encoding="UTF-8") yield b"\n" - request.env.cache.invalidate() + request.env.invalidate_all() yield b"\n" metadata_el = self._build_metadata_xml(total_products, generation_started_at) yield etree.tostring(metadata_el, encoding="UTF-8") From 85985d49b44e132d2c1c214ea55504b09479a095 Mon Sep 17 00:00:00 2001 From: juancarlosonate-tecnativa Date: Wed, 3 Jun 2026 13:52:42 +0200 Subject: [PATCH 24/27] [IMP] shoppingfeed_integration: store marketplace reference in client_order_ref --- shoppingfeed_integration/__manifest__.py | 2 +- .../migrations/18.0.1.0.4/post-migration.py | 14 ++++++++++++++ shoppingfeed_integration/models/sale_order.py | 1 + .../tests/test_shoppingfeed_integration.py | 1 + 4 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 shoppingfeed_integration/migrations/18.0.1.0.4/post-migration.py diff --git a/shoppingfeed_integration/__manifest__.py b/shoppingfeed_integration/__manifest__.py index 62e7ff8..4e063d9 100644 --- a/shoppingfeed_integration/__manifest__.py +++ b/shoppingfeed_integration/__manifest__.py @@ -2,7 +2,7 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). { "name": "Shoppingfeed Integration", - "version": "18.0.1.0.3", + "version": "18.0.1.0.4", "summary": "Integrate Odoo with Shoppingfeed for product export and order sync", "category": "Sales", "author": "Tecnativa, Odoo Community Association (OCA)", diff --git a/shoppingfeed_integration/migrations/18.0.1.0.4/post-migration.py b/shoppingfeed_integration/migrations/18.0.1.0.4/post-migration.py new file mode 100644 index 0000000..18a785b --- /dev/null +++ b/shoppingfeed_integration/migrations/18.0.1.0.4/post-migration.py @@ -0,0 +1,14 @@ +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + env.cr.execute( + """ + UPDATE sale_order + SET client_order_ref = shoppingfeed_reference + WHERE shoppingfeed_reference IS NOT NULL + AND shoppingfeed_reference != '' + AND (client_order_ref IS NULL OR client_order_ref = '') + """ + ) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index 47494c8..b48bd77 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -357,6 +357,7 @@ def _shoppingfeed_create_sale_order( "partner_shipping_id": shipping_partner.id if shipping_partner else partner.id, + "client_order_ref": order.get("reference"), "shoppingfeed_reference": order.get("reference"), "currency_id": currency.id, "shoppingfeed_order_ref": ext_id, diff --git a/shoppingfeed_integration/tests/test_shoppingfeed_integration.py b/shoppingfeed_integration/tests/test_shoppingfeed_integration.py index 7640e76..afb9c71 100644 --- a/shoppingfeed_integration/tests/test_shoppingfeed_integration.py +++ b/shoppingfeed_integration/tests/test_shoppingfeed_integration.py @@ -189,6 +189,7 @@ def test_create_sale_order_from_sf_data(self): { "shoppingfeed_order_ref": "SF-999001", "shoppingfeed_reference": "MKT-TEST-REF-001", + "client_order_ref": "MKT-TEST-REF-001", "shoppingfeed_channel_id": self.sf_channel.id, "shoppingfeed_store_id": self.sf_store.id, "partner_id": self.partner_a.id, From 593a40063fe609df795675df72bd661bf0d17d99 Mon Sep 17 00:00:00 2001 From: sergio-teruel Date: Mon, 8 Jun 2026 15:30:26 +0200 Subject: [PATCH 25/27] [FIX] shoppingfeed_integration: Context key make no check vies valid in delivery partners TT62772 --- shoppingfeed_integration/models/sale_order.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index b48bd77..f6d2009 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -202,9 +202,7 @@ def _shoppingfeed_prepare_shipping(self, shipping, partner, store): ("zip", "=", shipping_vals["zip"]), ], limit=1, - ) or self.env["res.partner"].with_context(no_vat_validation=True).create( - shipping_vals - ) + ) or self.env["res.partner"].create(shipping_vals) return shipping_partner def _shoppingfeed_clean_product_reference(self, reference): From 4291d523860d0167944f88dc51edc96f4ed839d7 Mon Sep 17 00:00:00 2001 From: sergio-teruel Date: Tue, 9 Jun 2026 12:35:56 +0200 Subject: [PATCH 26/27] [IMP] shoppingfeed_integration: Set country vat code and force vat on child partner creation TT62772 --- shoppingfeed_integration/models/sale_order.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index f6d2009..9739c15 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -150,6 +150,9 @@ def _shoppingfeed_prepare_partner( vals["vat"] = vat if country: vat_valid = self._shoppingfeed_valid_vat(vat, country, is_company) + if vat_valid: + if len(vat) > 1 and not vat[1].isalpha(): + vals["vat"] = f"{country.code}{vat}" if channel and channel.account_id: vals["property_account_receivable_id"] = channel.account_id.id if channel and channel.payment_method_line_id: @@ -180,6 +183,7 @@ def _shoppingfeed_prepare_shipping(self, shipping, partner, store): phone = self._shoppingfeed_format_phone(phone, country) shipping_vals = { "parent_id": partner.id, + "vat": partner.vat, "type": "delivery", "name": ( f"{shipping.get('firstName', '')} " f"{shipping.get('lastName', '')}" From 835e3866a93081c662f9f9d07ffdd9d4fdb7e64d Mon Sep 17 00:00:00 2001 From: juancarlosonate-tecnativa Date: Wed, 10 Jun 2026 15:39:12 +0200 Subject: [PATCH 27/27] [IMP] shoppingfeed_integration: reuse existing partner by VAT to avoid duplicate contacts --- shoppingfeed_integration/models/sale_order.py | 28 +++++++++---- .../tests/test_shoppingfeed_integration.py | 39 +++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/shoppingfeed_integration/models/sale_order.py b/shoppingfeed_integration/models/sale_order.py index 9739c15..b7af80a 100644 --- a/shoppingfeed_integration/models/sale_order.py +++ b/shoppingfeed_integration/models/sale_order.py @@ -107,7 +107,6 @@ def _shoppingfeed_format_phone(self, phone_number, country): def _shoppingfeed_prepare_partner( self, billing, store, channel=None, additional_fields=None ): - # Always create a new billing partner from Shoppingfeed data. company = billing.get("company") or "" if company: name = company @@ -121,6 +120,25 @@ def _shoppingfeed_prepare_partner( or "Shoppingfeed Customer" ) is_company = False + af = additional_fields or {} + vat = ( + af.get("buyer_tax_registration_id") + or af.get("mms-customer-tax-id") + or af.get("buyer_identification_number") + or af.get("buyer_identifier_number") + ) + # Reuse an existing partner with the same VAT instead of duplicating it. + if vat: + existing = self.env["res.partner"].search( + [ + ("vat", "=", vat), + ("parent_id", "=", False), + ("company_id", "in", [store.company_id.id, False]), + ], + limit=1, + ) + if existing: + return existing country = self.env["res.country"].search( [("code", "=", billing.get("country"))], limit=1 ) @@ -138,13 +156,6 @@ def _shoppingfeed_prepare_partner( "country_id": country.id, "company_id": store.company_id.id, } - af = additional_fields or {} - vat = ( - af.get("buyer_tax_registration_id") - or af.get("mms-customer-tax-id") - or af.get("buyer_identification_number") - or af.get("buyer_identifier_number") - ) vat_valid = False if vat: vals["vat"] = vat @@ -202,6 +213,7 @@ def _shoppingfeed_prepare_shipping(self, shipping, partner, store): [ ("parent_id", "=", partner.id), ("type", "=", "delivery"), + ("name", "=", shipping_vals["name"]), ("street", "=", shipping_vals["street"]), ("zip", "=", shipping_vals["zip"]), ], diff --git a/shoppingfeed_integration/tests/test_shoppingfeed_integration.py b/shoppingfeed_integration/tests/test_shoppingfeed_integration.py index afb9c71..ab1827f 100644 --- a/shoppingfeed_integration/tests/test_shoppingfeed_integration.py +++ b/shoppingfeed_integration/tests/test_shoppingfeed_integration.py @@ -79,6 +79,45 @@ def test_partner_gets_payment_method_on_create(self): ], ) + def test_billing_partner_dedup_by_vat(self): + SO = self.env["sale.order"] + billing = { + "company": "Amazon Business EU SARL", + "email": "ab@example.com", + "street": "38 avenue John F. Kennedy", + "postalCode": "L-1855", + "city": "Luxembourg", + "country": "LU", + } + af_de = {"buyer_tax_registration_id": "DE319514546"} + p1 = SO._shoppingfeed_prepare_partner( + billing, self.sf_store, channel=self.sf_channel, additional_fields=af_de + ) + p2 = SO._shoppingfeed_prepare_partner( + billing, self.sf_store, channel=self.sf_channel, additional_fields=af_de + ) + self.assertEqual(p1, p2) + p3 = SO._shoppingfeed_prepare_partner( + billing, + self.sf_store, + channel=self.sf_channel, + additional_fields={"buyer_tax_registration_id": "IT13397910962"}, + ) + self.assertNotEqual(p1, p3) + b2c = { + "firstName": "John", + "lastName": "Doe", + "email": "jd@example.com", + "country": "ES", + } + c1 = SO._shoppingfeed_prepare_partner( + b2c, self.sf_store, channel=self.sf_channel + ) + c2 = SO._shoppingfeed_prepare_partner( + b2c, self.sf_store, channel=self.sf_channel + ) + self.assertNotEqual(c1, c2) + def test_invoice_auto_paid_on_post(self): """Invoice from a SF order with auto_pay=True is paid on confirmation.""" self.partner_a.property_inbound_payment_method_line_id = (