diff --git a/zort_connector/README.rst b/zort_connector/README.rst new file mode 100644 index 0000000..7b547ec --- /dev/null +++ b/zort_connector/README.rst @@ -0,0 +1,100 @@ +============== +zort_connector +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:9a7fb265c67b3fca2819f2ebc2ffadda7683e67b56116044131ca90655c85d64 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-ecosoft--odoo%2Fecosoft--addons-lightgray.png?logo=github + :target: https://github.com/ecosoft-odoo/ecosoft-addons/tree/18.0/zort_connector + :alt: ecosoft-odoo/ecosoft-addons + +|badge1| |badge2| |badge3| + +Zort Connector for Odoo + +Integrates Odoo with Zort e-commerce platform for bidirectional synchronization of orders, products, inventory, and returns. + +**5 Main Zort API Endpoints Used:** + +1. **Add Product API** - Create products in Zort from Odoo +2. **Update Product API** - Update product information in Zort +3. **Update Stock API** - Sync inventory quantities to Zort +4. **Get Orders API** - Import orders from Zort to Odoo +5. **Get Return Orders API** - Process return orders from Zort + +**Key Features** + +* **Order Management**: Automatic import every 10 minutes with status mapping (Pending→Draft, Waiting→Confirmed, Success→Delivered+Invoiced, Voided→Cancelled) +* **Product Sync**: Create/update products in Zort with SKU matching +* **Stock Sync**: Real-time inventory updates on picking validation +* **Returns Processing**: Automatic return picking creation and credit notes +* **Multi-platform Support**: Lazada, Shopee, Magento integration +* **Robust Logging**: Complete API request/response tracking + +**Requirements** + +* Odoo 18.0+ +* Zort API credentials (Key, Secret, Store Name) +* Valid SKUs (default_code) on products + +**Quick Setup** + +1. Enable Zort Connector in Settings +2. Configure API credentials +3. Mark products "Sync with Zort" +4. Scheduled actions handle automatic synchronization + +**Table of contents** + +.. contents:: + :local: + +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 +~~~~~~~ + +* Ecosoft + +Contributors +~~~~~~~~~~~~ + +- Theerayut A. + +Maintainers +~~~~~~~~~~~ + +.. |maintainer-theerayuta@ecosoft.co.th| image:: https://github.com/theerayuta@ecosoft.co.th.png?size=40px + :target: https://github.com/theerayuta@ecosoft.co.th + :alt: theerayuta@ecosoft.co.th + +Current maintainer: + +|maintainer-theerayuta@ecosoft.co.th| + +This module is part of the `ecosoft-odoo/ecosoft-addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/zort_connector/__init__.py b/zort_connector/__init__.py new file mode 100644 index 0000000..b597d19 --- /dev/null +++ b/zort_connector/__init__.py @@ -0,0 +1,3 @@ +from . import zort_api +from . import controllers +from . import models diff --git a/zort_connector/__manifest__.py b/zort_connector/__manifest__.py new file mode 100644 index 0000000..d7d54d4 --- /dev/null +++ b/zort_connector/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2025 Ecosoft Co., Ltd (https://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "zort_connector", + "summary": "Connects Odoo with Zort", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Ecosoft, Odoo Community Association (OCA)", + "maintainers": ["theerayuta@ecosoft.co.th"], + "website": "https://github.com/ecosoft-odoo/ecosoft-addons", + "depends": ["stock", "sale_management", "mrp"], + "data": [ + "security/ir.model.access.csv", + "data/ir_actions_server_data.xml", + "data/ir_config_parameter_data.xml", + "data/ir_cron_data.xml", + "data/partner_data.xml", + "data/product_data.xml", + "views/res_config_settings_view.xml", + "views/sale_order_view.xml", + "views/product_template_view.xml", + "views/res_partner_view.xml", + "views/stock_picking_view.xml", + "views/zort_ecommerce_channel_views.xml", + ], +} diff --git a/zort_connector/controllers/__init__.py b/zort_connector/controllers/__init__.py new file mode 100644 index 0000000..e046e49 --- /dev/null +++ b/zort_connector/controllers/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/zort_connector/controllers/controllers.py b/zort_connector/controllers/controllers.py new file mode 100644 index 0000000..be2c5ae --- /dev/null +++ b/zort_connector/controllers/controllers.py @@ -0,0 +1,40 @@ +from odoo import http +from odoo.http import Response, request + + +class ZortConnector(http.Controller): + @http.route("/zort_connector/zort_connector", auth="public") + def index(self, **kw): + return "Hello, world" + + @http.route( + "/zort_connector/view_zort_order_json/", + type="http", + auth="user", + ) + def view_zort_order_json(self, sale_order_id, **kwargs): + sale_order = request.env["sale.order"].sudo().browse(sale_order_id) + if not sale_order.exists(): + return Response("Sale Order not found", status=404) + import json + + return Response( + json.dumps(sale_order.zort_order_data, indent=2, ensure_ascii=False), + mimetype="application/json", + ) + + @http.route( + "/zort_connector/view_zort_return_order_json/", + type="http", + auth="user", + ) + def view_zort_return_order_json(self, picking_id, **kwargs): + picking = request.env["stock.picking"].sudo().browse(picking_id) + if not picking.exists(): + return Response("Picking not found", status=404) + import json + + return Response( + json.dumps(picking.zort_return_data, indent=2, ensure_ascii=False), + mimetype="application/json", + ) diff --git a/zort_connector/data/ir_actions_server_data.xml b/zort_connector/data/ir_actions_server_data.xml new file mode 100644 index 0000000..7c269d3 --- /dev/null +++ b/zort_connector/data/ir_actions_server_data.xml @@ -0,0 +1,15 @@ + + + + Update Quantity to Zort + + + action + code + +# If update qty in stock.picking fail this allow user to manually trigger the update +if record.state == 'done' and not record.updated_qty_to_zort: + record.action_sync_qty_to_zort() + + + diff --git a/zort_connector/data/ir_config_parameter_data.xml b/zort_connector/data/ir_config_parameter_data.xml new file mode 100644 index 0000000..429cbb7 --- /dev/null +++ b/zort_connector/data/ir_config_parameter_data.xml @@ -0,0 +1,11 @@ + + + + zort_connector.limit_timeout + 10 + + + zort_connector.order_sync_days_back + 10 + + diff --git a/zort_connector/data/ir_cron_data.xml b/zort_connector/data/ir_cron_data.xml new file mode 100644 index 0000000..0db4959 --- /dev/null +++ b/zort_connector/data/ir_cron_data.xml @@ -0,0 +1,28 @@ + + + + + Auto Sync Zort Order + + code + model.process_sales_order_from_zort() + 10 + minutes + + + Auto Sync Zort Return Order + + code + model.action_create_return_picking() + 10 + minutes + + + Auto Update BOM Qty Available + + code + model.update_bom_qty_to_zort() + 1 + days + + diff --git a/zort_connector/data/partner_data.xml b/zort_connector/data/partner_data.xml new file mode 100644 index 0000000..6832220 --- /dev/null +++ b/zort_connector/data/partner_data.xml @@ -0,0 +1,27 @@ + + + + Marketplace Customer + + company + + + + Shoppee Customer + + company + + + + Lazada Customer + + company + + + + Tiktok Customer + + company + + + diff --git a/zort_connector/data/product_data.xml b/zort_connector/data/product_data.xml new file mode 100644 index 0000000..2f4e886 --- /dev/null +++ b/zort_connector/data/product_data.xml @@ -0,0 +1,30 @@ + + + + Shipping Fee + service + + + 0.0 + 0.0 + shipping_fee + + + Discount + service + + + 0.0 + 0.0 + zort_discount + + + Zort Voucher + service + + + 0.0 + 0.0 + zort_voucher + + diff --git a/zort_connector/models/__init__.py b/zort_connector/models/__init__.py new file mode 100644 index 0000000..5bb837d --- /dev/null +++ b/zort_connector/models/__init__.py @@ -0,0 +1,7 @@ +from . import ecommerce_channel +from . import res_config_settings +from . import sale_order +from . import product_template +from . import res_partner +from . import stock_picking +from . import mrp_bom diff --git a/zort_connector/models/ecommerce_channel.py b/zort_connector/models/ecommerce_channel.py new file mode 100644 index 0000000..ec4e88e --- /dev/null +++ b/zort_connector/models/ecommerce_channel.py @@ -0,0 +1,68 @@ +# Copyright 2025 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +from odoo import _, fields, models + + +class ZortEcommerceChannel(models.Model): + """ + Represents a Zort eCommerce channel configuration. + + Features: + - Store channel settings (e.g., dummy customer, auto-create customer). + - Example: Lazada + name = "Lazada" + code = "lazada" + use_dummy_customer = True + auto_create_customer = False + + Note: + Sale channel, check from zort api on field `saleschannel` + API: https://open-api.zortout.com/v4/Order/GetOrders + """ + + _name = "zort.ecommerce.channel" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Zort eCommerce Channel" + _order = "sequence, name" + _rec_name = "name" + + name = fields.Char(required=True, tracking=True) + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True) + code = fields.Char( + required=True, + tracking=True, + help="Check from Zort API 'saleschannel'", + ) + description = fields.Text() + partner_id = fields.Many2one( + comodel_name="res.partner", + string="Platform Customer", + help="Platform customer to use if no match found", + ) + auto_create_customer = fields.Boolean( + help="Auto create customer if no match found", + default=False, + ) + + _sql_constraints = [ + ( + "code_uniq", + "unique(code)", + _("The code of the eCommerce channel must be unique!"), + ) + ] + + def open_ecommerce_config_form(self): + """Open eCommerce configuration form""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("E-commerce Channel"), + "res_model": "zort.ecommerce.channel", + "view_mode": "form", + "res_id": self.id, + "target": "current", + } diff --git a/zort_connector/models/mrp_bom.py b/zort_connector/models/mrp_bom.py new file mode 100644 index 0000000..1013ace --- /dev/null +++ b/zort_connector/models/mrp_bom.py @@ -0,0 +1,55 @@ +# Copyright 2025 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class MrpBom(models.Model): + _name = "mrp.bom" + _inherit = ["mrp.bom", "zort.api"] + + qty_available = fields.Float( + related="product_tmpl_id.qty_available", + string="On Hand Quantity", + readonly=True, + ) + + @api.model + def update_bom_qty_to_zort(self): + """Update BOM quantity to Zort as the product's available quantity.""" + + warehousecode = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("zort_connector.warehouse_code") + or "W0001" + ) + + stocks_dict = {} + boms = self.search([("product_tmpl_id.zort_product_id", "!=", False)]) + for bom in boms: + product_id = bom.product_tmpl_id.zort_product_id + if product_id not in stocks_dict: + stocks_dict[product_id] = { + "productid": product_id, + "stock": bom.qty_available, + } + + # Convert dictionary values to list + all_stocks = list(stocks_dict.values()) + + if all_stocks: + data = {"stocks": all_stocks} + try: + self._update_product_available_stock_list(warehousecode, data) + _logger.info( + "Updated %d products' available stock to Zort", len(all_stocks) + ) + except Exception as e: + _logger.error( + "Failed to update products' available stock to Zort: %s", str(e) + ) diff --git a/zort_connector/models/product_template.py b/zort_connector/models/product_template.py new file mode 100644 index 0000000..f96d811 --- /dev/null +++ b/zort_connector/models/product_template.py @@ -0,0 +1,216 @@ +# Copyright 2025 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +import base64 +import json +import logging + +import requests +from markupsafe import Markup + +from odoo import _, fields, models + +_logger = logging.getLogger(__name__) + + +class ProductTemplate(models.Model): + _name = "product.template" + _inherit = ["product.template", "zort.api"] + + sync_with_zort = fields.Boolean( + string="Sync with Zort", + default=False, + help="Enable synchronization of this product with Zort API", + ) + is_created_on_zort = fields.Boolean( + string="Created on Zort", + default=False, + help="Indicates if this product has been created on Zort", + ) + zort_product_id = fields.Char( + help="The unique identifier of the product in Zort", + ) + + def action_create_product_on_zort(self): + """Create product in Zort based on the current product template.""" + self.ensure_one() + + if not self.sync_with_zort: + return + + data = { + "sku": self.default_code, + "name": self.name, + "sellprice": self.list_price, + "purchaseprice": self.standard_price, + "unittext": self.uom_name, + # "weight": self.weight, # we can uncomment later + # "sell_vat_status": 0, # we can uncomment later + # "purchase_vat_status": 0 # we can uncomment later + } + response = self._add_product(data) + + if response.get("error"): + _logger.error("Error creating product in Zort: %s", response.get("error")) + return self._add_lognote_and_reload( + title="Error", + message="Failed to create product on Zort: {}".format( + response.get("error") + ), + data=data, + ) + elif response.get("resCode") != "200": + _logger.error( + "Error creating product in Zort: %s", + response.get("resDesc", "Unknown error"), + ) + return self._add_lognote_and_reload( + title="Error", + message=( + "Failed to create product on Zort: {}\n" + "Please check on Zort, the product may have already been created.\n" + "If the product was created, you should update the Zort Product " + "with the product ID from Zort." + ).format(response.get("resDesc", "Unknown error")), + data=data, + ) + + self.is_created_on_zort = True + self.zort_product_id = response.get("resDesc", "") + + return self._add_lognote_and_reload( + title="Success", message="Product created successfully on Zort.", data=data + ) + + def action_update_product_to_zort(self): + """Update product in Zort based on the current product template.""" + self.ensure_one() + if not self.is_created_on_zort: + return + + data = { + "name": self.name, + "sellprice": self.list_price, + "purchaseprice": self.standard_price, + "unittext": self.uom_name, + # "weight": self.weight, // we can uncomment later + # "sell_vat_status": 0, // we can uncomment later + # "purchase_vat_status": 0 // we can uncomment later + } + response = self._update_product(self.zort_product_id, data) + + if response.get("error"): + _logger.error("Error updating product in Zort: %s", response.get("error")) + return self._add_lognote_and_reload( + title="Error", + message="Failed to update product on Zort: {}".format( + response.get("error") + ), + data=data, + ) + + return self._add_lognote_and_reload( + title="Success", message="Product updated successfully on Zort.", data=data + ) + + def action_update_qty_to_zort(self): + self.ensure_one() + if not self.is_created_on_zort: + return + + data = { + "stocks": [ + { + "sku": self.default_code, + "stock": self.qty_available, + } + ] + } + response = self._update_product_available_stock_list( + warehousecode="W0001", data=data + ) + + if response.get("error"): + _logger.error("Error updating stock in Zort: %s", response.get("error")) + return self._add_lognote_and_reload( + title="Error", + message="Failed to update stock on Zort: {}".format( + response.get("error") + ), + data=data, + ) + + _logger.info("Stock updated successfully on Zort: %s", response) + return self._add_lognote_and_reload( + title="Success", message="Stock updated successfully on Zort.", data=data + ) + + def _add_lognote_and_reload(self, title: str, message: str, data: dict): + """Add a log note to the chatter and return reload action.""" + formatted_data = json.dumps(data, indent=2, ensure_ascii=False) + message = Markup(f"{title}: {message}
{formatted_data}
") + self.message_post(body=message) + return { + "type": "ir.actions.client", + "tag": "reload", + } + + def fetch_product_image_from_zort( + self, sku_list: str = "", product_id_list: str = "" + ) -> dict: + """ + Fetch product images from Zort for given SKU or product ID list. + Returns a dict mapping product IDs to image URLs. + """ + response = self._get_products(skulist=sku_list, productidlist=product_id_list) + if response.get("error"): + _logger.error( + "Error fetching product images from Zort: %s", response.get("error") + ) + return {} + return { + str(product.get("id")): product.get("imagepath") + for product in response.get("list", []) + if product.get("id") and product.get("imagepath") + } + + def action_fetch_and_update_image_from_zort(self): + """Fetch and update product images from Zort for products linked to Zort.""" + self.ensure_one() + if not self.zort_product_id: + return + image_map = self.fetch_product_image_from_zort( + product_id_list=self.zort_product_id + ) + image_url = image_map.get(self.zort_product_id) + if not image_url: + _logger.warning( + "No image found for product ID %s on Zort", self.zort_product_id + ) + self.message_post(body=_("No image found for this product on Zort.")) + return + try: + response = requests.get(image_url, timeout=10) + response.raise_for_status() + image_data = base64.b64encode(response.content).decode("utf-8") + self.image_1920 = image_data + _logger.info( + "Image updated successfully from Zort for product %s", self.name + ) + self.message_post(body=_("Product image updated successfully from Zort.")) + except requests.RequestException as e: + _logger.error("Error fetching image from Zort: %s", str(e)) + self.message_post(body=_("Failed to fetch image from Zort: %s", str(e))) + + @staticmethod + def decode_image_url(image_url: str): + """Set the product image.""" + try: + response = requests.get(image_url, timeout=10) + response.raise_for_status() + image_data = base64.b64encode(response.content).decode("utf-8") + return image_data + except requests.RequestException as e: + _logger.error("Error fetching image from Zort: %s", str(e)) + return None diff --git a/zort_connector/models/res_config_settings.py b/zort_connector/models/res_config_settings.py new file mode 100644 index 0000000..ea67109 --- /dev/null +++ b/zort_connector/models/res_config_settings.py @@ -0,0 +1,62 @@ +# Copyright 2025 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + zort_connector_enabled = fields.Boolean( + string="Enable Zort Connector", + help="Enable the Zort Connector to connect Odoo with Zort.", + default=False, + config_parameter="zort_connector.enabled", + ) + zort_endpoint_url = fields.Char( + string="Zort Endpoint URL", + help="The URL of the Zort endpoint to connect with.", + default="https://open-api.zortout.com/v4", + config_parameter="zort_connector.endpoint_url", + ) + zort_api_key = fields.Char( + string="Zort API Key", + help="The API key to authenticate with the Zort endpoint.", + default="", + config_parameter="zort_connector.api_key", + ) + zort_api_secret = fields.Char( + string="Zort API Secret", + help="The API secret to authenticate with the Zort endpoint.", + default="", + config_parameter="zort_connector.api_secret", + ) + zort_store_name = fields.Char( + help="The name of the store in Zort.", + default="", + config_parameter="zort_connector.store_name", + ) + zort_warehouse_code = fields.Char( + help="The warehouse code in Zort.", + default="W0001", + config_parameter="zort_connector.warehouse_code", + ) + + @api.onchange("zort_connector_enabled") + def _onchange_zort_connector_enabled(self): + if not self.zort_connector_enabled: + self.env["ir.config_parameter"].sudo().set_param( + "zort_connector.endpoint_url", "" + ) + self.env["ir.config_parameter"].sudo().set_param( + "zort_connector.api_key", "" + ) + self.env["ir.config_parameter"].sudo().set_param( + "zort_connector.api_secret", "" + ) + self.env["ir.config_parameter"].sudo().set_param( + "zort_connector.store_name", "" + ) + self.env["ir.config_parameter"].sudo().set_param( + "zort_connector.warehouse_code", "" + ) diff --git a/zort_connector/models/res_partner.py b/zort_connector/models/res_partner.py new file mode 100644 index 0000000..a1d5cbc --- /dev/null +++ b/zort_connector/models/res_partner.py @@ -0,0 +1,17 @@ +# Copyright 2025 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + customer_platform_code = fields.Char( + string="Customer Platform", + help=( + "Platform where the customer was acquired " + "(e.g., lazada, shopee). Use lowercase." + ), + ) diff --git a/zort_connector/models/sale_order.py b/zort_connector/models/sale_order.py new file mode 100644 index 0000000..2834e28 --- /dev/null +++ b/zort_connector/models/sale_order.py @@ -0,0 +1,509 @@ +# Copyright 2025 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +import logging +import time +from datetime import datetime, timedelta + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _name = "sale.order" + _inherit = ["sale.order", "zort.api"] + + # This field can be used to store the Zort order ID + is_zort_order = fields.Boolean( + help="Indicates if this sale order is created from Zort.", + default=False, + readonly=True, + index=True, + ) + zort_order_id = fields.Char( + help="The ID of the order in Zort.", + copy=False, + readonly=True, + index=True, + ) + zort_order_number = fields.Char( + help="The order number in Zort.", + copy=False, + readonly=True, + index=True, + ) + zort_order_status = fields.Char( + help="The status of the order in Zort.", + copy=False, + readonly=True, + ) + zort_payment_status = fields.Char( + help="The payment status of the order in Zort.", + copy=False, + readonly=True, + ) + zort_order_data = fields.Json( + help="The raw order data fetched from Zort.", + copy=False, + readonly=True, + ) + zort_sales_channel = fields.Char( + help="The sales channel of the order in Zort.", + copy=False, + readonly=True, + ) + zort_customer_id = fields.Many2one( + "res.partner", + "e-Commerce Customer", + copy=False, + readonly=True, + ) + + @api.model + def process_sales_order_from_zort(self, status="0", orderidlist="", numberlist=""): + """ + Create or update sale orders in Odoo based on data from Zort. + + This method: + - Updates existing sale orders that match Zort order IDs + - Creates new sale orders from Zort data + + Args: + status (str, optional): Zort order status filter for fetching new orders. + orderidlist (str, optional): Comma-separated Zort order IDs to process. + numberlist (str, optional): Comma-separated Zort order numbers to process. + + Returns: + bool: True if the operation completes successfully. + """ + # Update existing orders + self._update_existing_zort_orders() + + # Create new orders + self._create_new_zort_orders(status, orderidlist, numberlist) + + return True + + def _update_existing_zort_orders(self): + """Update existing sale orders with latest data from Zort.""" + zort_ids = self._get_existing_zort_order_ids() + if not zort_ids: + return + + page = 1 + limit = 500 + while True: + res = self._get_list_order( + status="", orderidlist=zort_ids, page=page, limit=limit + ) + # TODO: Handle API errors properly + # on this point, we just break the loop + if res.get("error"): + _logger.error( + "Error fetching orders from Zort: %s", + res.get("error"), + ) + break + orders = res.get("list", []) + if not orders: + break + + for order in orders: + try: + so = self.search([("zort_order_id", "=", order.get("id"))], limit=1) + if so: + self._update_sale_order_from_zort(so, order) + except Exception as e: + _logger.error("Error updating existing sale order from Zort: %s", e) + page += 1 + time.sleep(5) + + def _update_sale_order_from_zort(self, sale_order, zort_order): + """Update a single sale order with Zort data and handle status changes.""" + # Update order data + sale_order.write( + { + "zort_order_number": zort_order.get("number"), + "zort_order_status": zort_order.get("status"), + "zort_payment_status": zort_order.get("paymentstatus"), + "zort_sales_channel": zort_order.get("saleschannel"), + "zort_order_data": zort_order, + } + ) + _logger.info("Updated Sale Order: %s", sale_order.name) + + # Handle status-based actions + self._handle_zort_order_status(sale_order, zort_order.get("status")) + + def _handle_zort_order_status(self, sale_order, zort_status): + """Handle order workflow based on Zort order status.""" + if zort_status == "Voided": + sale_order.action_cancel() + elif zort_status == "Success": + self._process_success_order(sale_order) + + def _process_success_order(self, sale_order): + """Process order when Zort status is 'Success'.""" + # Confirm order if not already confirmed + if sale_order.state not in ["sale", "cancel"]: + sale_order.action_confirm() + + # Validate deliveries + for picking in sale_order.picking_ids: + if picking.state not in ["done", "cancel"]: + picking.button_validate() + _logger.info("Delivery order done for Sale Order: %s", sale_order.name) + + # Force recomputation of delivered quantities + sale_order.order_line.invalidate_recordset(["qty_delivered"]) + sale_order.order_line._compute_qty_delivered() + + # Create draft invoice + self._create_draft_invoice(sale_order) + + def _create_draft_invoice(self, sale_order): + """Create draft invoice for the sale order.""" + invoice_wizard = ( + self.env["sale.advance.payment.inv"] + .with_context(active_ids=sale_order.ids, active_id=sale_order.id) + .create({"advance_payment_method": "delivered"}) + ) + invoice_wizard.create_invoices() + _logger.info("Draft invoice created for Sale Order: %s", sale_order.name) + + def _create_new_zort_orders(self, status, orderidlist, numberlist): + """Create new sale orders from Zort data with exponential backoff.""" + _logger.info("Creating Sale Order from Zort...") + + zort_ids = [ + int(id) + for id in self._get_existing_zort_order_ids().split(",") + if id.strip() + ] + + page = 1 + limit = 500 + orderdateafter = self.get_order_date_after() + while True: + res = self._get_list_order( + status=status, + orderidlist=orderidlist, + numberlist=numberlist, + page=page, + limit=limit, + orderdateafter=orderdateafter, + ) + # TODO: Handle API errors properly + # on this point, we just break the loop + if res.get("error"): + _logger.error( + "Error fetching orders from Zort: %s", + res.get("error"), + ) + break + + orders = res.get("list", []) + if not orders: + break + + for order in orders: + try: + if order.get("id") in zort_ids: + continue + self._create_single_sale_order(order) + _logger.info( + "Successfully created Sale Order from Zort order %s", + order.get("id"), + ) + except Exception as e: + _logger.error( + "Error creating sale order from Zort order %s: %s", + order.get("id"), + e, + ) + page += 1 + time.sleep(5) + + def _create_single_sale_order(self, zort_order): + """Create a single sale order from Zort order data.""" + # Prepare order data + order_data = { + "partner_id": self._get_marketplace_customer(zort_order), + "is_zort_order": True, + "zort_order_id": zort_order.get("id"), + "zort_order_number": zort_order.get("number"), + "zort_order_status": zort_order.get("status"), + "zort_payment_status": zort_order.get("paymentstatus"), + "zort_sales_channel": zort_order.get("saleschannel"), + "zort_order_data": zort_order, + "zort_customer_id": self._get_platform_customer(zort_order), + } + + # Create the sale order + sale_order = self.create(order_data) + + # Add order lines + order_lines = self._prepare_order_lines(zort_order) + if order_lines: + sale_order.order_line = order_lines + sale_order.action_confirm() + _logger.info("Created Sale Order: %s", sale_order.name) + + @staticmethod + def hook_process_sku(sku): + """ + Hook to process SKU before fetching product. + Override in custom modules to modify SKU format if needed. + Example: Remove suffix after '#' (e.g., 'a-1234#left' -> 'a-1234'). + """ + return sku + + def get_product_by_sku(self, sku): + """Fetch product by SKU (default_code).""" + sku = self.hook_process_sku(sku) + return self.env["product.product"].search([("default_code", "=", sku)], limit=1) + + def _prepare_order_lines(self, zort_order): + """Prepare order lines from Zort order data.""" + order_lines = [] + + # Add product lines + for line in zort_order.get("list", []): + product = self.get_product_by_sku(line.get("sku")) + if not product: + _logger.warning( + "Product with SKU %s not found. Skipping line.", + line.get("sku"), + ) + continue + + order_lines.append( + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": line.get("number", 1), + "price_unit": line.get("pricepernumber", 0.0), + "name": line.get("name", product.name), + }, + ) + ) + + # Add shipping fee if exists + shipping_amount = zort_order.get("shippingamount", 0.0) + if shipping_amount > 0: + order_lines.append(self._add_shipping_fee_line(shipping_amount)) + + # Add discount if exists + discount = zort_order.get("discount", 0.0) + if discount and isinstance(discount, str): + discount = float(discount) + order_lines.append(self._add_discount_line(discount)) + + # Add voucher_amount if exists + voucher_amount = zort_order.get("voucheramount", 0.0) + if voucher_amount > 0: + order_lines.append(self._add_voucher_line(voucher_amount)) + + return order_lines + + def _add_shipping_fee_line(self, shipping_amount): + """Add a shipping fee line to the sale order.""" + shipping_fee_product = self.env["product.product"].search( + [("default_code", "=", "shipping_fee")], limit=1 + ) + return ( + 0, + 0, + { + "product_id": shipping_fee_product.id, + "product_uom_qty": 1, + "price_unit": shipping_amount, + "name": "Shipping Fee", + }, + ) + + def _add_discount_line(self, discount): + """Add a discount line to the sale order.""" + discount_product = self.env["product.product"].search( + [("default_code", "=", "zort_discount")], limit=1 + ) + if isinstance(discount, str): + discount = float(discount) + + return ( + 0, + 0, + { + "product_id": discount_product.id, + "product_uom_qty": 1, + "price_unit": -discount, + "name": "Discount", + }, + ) + + def _add_voucher_line(self, voucher_amount): + """Add a voucher line to the sale order.""" + voucher_product = self.env["product.product"].search( + [("default_code", "=", "zort_voucher")], limit=1 + ) + return ( + 0, + 0, + { + "product_id": voucher_product.id, + "product_uom_qty": 1, + "price_unit": voucher_amount, + "name": "Voucher", + }, + ) + + def _get_existing_zort_order_ids(self): + """ + Get existing Zort orders (id) in draft, sent, or sale state + Return as comma-separated string Ex. "1234,5678,91011" + """ + days_back = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("zort_connector.order_sync_days_back", default="10") + ) + try: + days = int(days_back) + if days <= 0: + days = 10 + except (TypeError, ValueError): + days = 10 + orderdateafter = datetime.now() - timedelta(days=days) + zort_orders = self.search( + [ + ("is_zort_order", "=", True), + ("state", "in", ["draft", "sent", "sale"]), + ("date_order", ">=", orderdateafter), + ] + ) + zort_order_ids = [ + order.zort_order_id for order in zort_orders if order.zort_order_id + ] + zort_order_ids_str = ",".join(zort_order_ids) + return zort_order_ids_str + + def _get_marketplace_customer(self, order: dict) -> int: + """ + Determine the appropriate customer for the Zort order. + 1. For Magento orders, match or create a customer by phone number. + 2. For other platforms, use the marketplace customer. + :return: res.partner id + """ + default_customer = self.env.ref("zort_connector.marketplace_customer_1") + sales_channel = (order.get("saleschannel") or "").lower() + + # check if ecommerce channel use platform customer + ecommerce_channel = self.env["zort.ecommerce.channel"].search( + [("code", "=", sales_channel)], limit=1 + ) + platform_customer_id = ( + ecommerce_channel.partner_id.id + if ecommerce_channel and ecommerce_channel.partner_id + else default_customer.id + ) + return platform_customer_id + + def _get_platform_customer(self, order: dict) -> int: + """ + Get the platform customer (res.partner) for the Zort order. + :return: res.partner id + """ + sales_channel = (order.get("saleschannel") or "").lower() + + ecommerce_channel = self.env["zort.ecommerce.channel"].search( + [("code", "=", sales_channel)], limit=1 + ) + if ecommerce_channel and ecommerce_channel.auto_create_customer: + customer = self.create_new_customer(order) + if customer: + return customer.id + + return False + + def action_view_zort_order_json(self): + """ + Action to view the raw Zort order data in JSON format. + :return: dict - Action dictionary to open a new window with JSON data. + """ + return { + "type": "ir.actions.act_url", + "url": f"/zort_connector/view_zort_order_json/{self.id}", + "target": "new", + } + + @api.model + def create_new_customer(self, order_data: dict): + """ + Create a new customer based on the provided keyword arguments. + Make sure each key in kwargs matches a field in res.partner model. + Example kwargs: { + 'name': 'John Doe', + 'phone': '1234567890', + 'email': 'john.doe@example.com' + } + :return: res.partner record + """ + vals = { + "name": order_data.get("customername", "Online Customer"), + "phone": order_data.get("customerphone", ""), + "email": order_data.get("customeremail", ""), + "street": order_data.get("customeraddress", ""), + "city": order_data.get("customerprovince", ""), + "zip": order_data.get("customerpostcode", ""), + "vat": order_data.get("customeridnumber", ""), + "is_company": False, + } + existing_customer = None + if vals["phone"]: + phone = self.validate_customer_phone(vals["phone"]) + existing_customer = self.env["res.partner"].search( + [("phone", "=", phone)], limit=1 + ) + if vals["vat"]: + existing_customer = self.env["res.partner"].search( + [("vat", "=", vals["vat"])], limit=1 + ) + if existing_customer: + return existing_customer + + new_customer = self.env["res.partner"].create(vals) + return new_customer + + @staticmethod + def validate_customer_phone(phone: str) -> str: + """ + Return the last 9 digits of the phone number. + :param phone: str - Phone number to process. + :return: str - Last 9 digits of the phone number. + """ + phone = phone.strip() + return phone[-9:] if len(phone) >= 9 else phone + + def get_order_date_after(self): + """ + Get the order date after value from configuration. + :return: str - Date in 'YYYY-MM-DD' format. + """ + days_back = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("zort_connector.order_sync_days_back", default="10") + ) + try: + days = int(days_back) + if days <= 0: + days = 10 + except (TypeError, ValueError): + days = 10 + + orderdateafter = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + return orderdateafter diff --git a/zort_connector/models/stock_picking.py b/zort_connector/models/stock_picking.py new file mode 100644 index 0000000..a5bc32d --- /dev/null +++ b/zort_connector/models/stock_picking.py @@ -0,0 +1,376 @@ +import logging +from datetime import datetime, timedelta + +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) + + +class StockPicking(models.Model): + _name = "stock.picking" + _inherit = ["stock.picking", "zort.api"] + + updated_qty_to_zort = fields.Boolean( + default=False, help="Indicates if the quantity has been updated to Zort." + ) + zort_return_data = fields.Json( + help="Zort return order data", + copy=False, + ) + zort_return_no = fields.Char( + help="Zort return order number", + copy=False, + ) + + def button_validate(self): + res = super().button_validate() + # Only update qty to zort if the picking is not related to a Zort order + if not self.sale_id or not getattr(self.sale_id, "is_zort_order", False): + # TODO: Handle API errors gracefully. + # Since button_validate is called after the stock move is done, + # it is safe to sync qty to Zort here. + self._sync_qty_to_zort() + return res + + def action_sync_qty_to_zort(self): + """Public action method for server actions.""" + return self._sync_qty_to_zort() + + def _sync_qty_to_zort(self): + """ + Syncs the quantity of products in Zort based on the stock picking type. + Uses picking_type_code to decide whether to increase or decrease stock. + """ + for picking in self: + data = {"stocks": []} + # Use move_ids_without_package for outgoing, move_ids for incoming + moves = ( + picking.move_ids + if picking.picking_type_code == "incoming" + else picking.move_ids_without_package + ) + for move in moves: + if move.product_id.is_created_on_zort: + data["stocks"].append( + { + "sku": move.product_id.default_code, + "stock": move.quantity, + } + ) + + if not data.get("stocks"): + continue + if picking.picking_type_code == "incoming": + wh_code = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("zort_connector.warehouse_code", default="W0001") + ) + response = self._increase_product_stock_list( + warehousecode=wh_code, data=data + ) + action = _("increased") + elif picking.picking_type_code == "outgoing": + response = self._decrease_product_stock_list( + warehousecode=wh_code, data=data + ) + action = _("decreased") + else: + continue # Do nothing for other picking types + + if response.get("error"): + _logger.error( + "Error {} product stock in Zort: {}".format( + action, response.get("error") + ) + ) + picking.message_post( + body=_( + "Failed to {action} product stock on Zort: {error}".format( + action=action, error=response.get("error") + ) + ), + subtype_xmlid="mail.mt_note", + ) + else: + self.updated_qty_to_zort = True + picking.message_post( + body=_(f"Product stock {action} successfully on Zort."), + subtype_xmlid="mail.mt_note", + ) + + @api.model + def action_create_return_picking(self): + """ + Handle Zort return orders by fetching them and creating corresponding + return pickings in Odoo. + + Logic: + - Fetch return orders using `_get_zort_return_order()`. + - For each return order: + - If status is 'Pending': create assigned return picking. + - If status is 'Success': done return picking. + """ + return_orders = self._get_zort_return_order() + _logger.info("Fetched %d return orders from Zort.", len(return_orders)) + if not return_orders: + _logger.info("No return orders found in Zort.") + return + + ######################################## + # Example structure of zort_order_numbers: + # { + # "ZORT-12345": { + # "number": "CN-12345", # Return order number in Zort + # "status": "Pending", # Return status: "Pending" or "Success" + # "item_list": [ # List of returned items + # { + # "sku": "SKU-001", # Product SKU + # "number": 2, # Quantity returned + # "pricepernumber": 100 # Unit price per item + # }, + # { + # "sku": "SKU-002", + # "number": 1, + # "pricepernumber": 200 + # } + # ] + # } + # } + ######################################## + zort_order_numbers: dict = {} + + for order in return_orders: + # On Zort, the sale order number is stored in "referencenumber" + zort_so_no = order.get("referencenumber") + status = order.get("status") + if status in ["Pending", "Success"]: + zort_order_numbers[zort_so_no] = order + + sale_orders = self.env["sale.order"].search( + [ + ("zort_order_number", "in", list(zort_order_numbers.keys())), + ("state", "=", "sale"), + ] + ) + for order in sale_orders: + zort_so_no = order.zort_order_number + return_order_data = zort_order_numbers.get(zort_so_no, {}) + return_status = return_order_data["status"].lower() + + if return_status == "pending": + picking_ids = order.picking_ids.filtered( + lambda p, order=order: p.state == "done" + and p.picking_type_code == "outgoing" + and p.origin == order.name + ) + picking = picking_ids[0] if picking_ids else None + zort_return_no = return_order_data.get("number", "") + if picking and not self._created_zort_return_picking( + order, zort_return_no + ): + try: + # Prepare context for the return wizard + return_wizard = ( + self.env["stock.return.picking"] + .with_context( + active_ids=[picking.id], + active_id=picking.id, + active_model="stock.picking", + ) + .create({}) + ) + + # Prepare item lines for the return wizard + # Sample structure: + # item_lines = { + # "SKU-001": 2, + # "SKU-002": 1 + # } + item_lines = { + item["sku"]: item["number"] + for item in return_order_data.get("list", []) + } + for line in return_wizard.product_return_moves: + sku = line.product_id.default_code + if sku in item_lines: + line.quantity = item_lines[sku] + result = return_wizard.action_create_returns() + if result and result.get("res_id"): + return_picking = self.env["stock.picking"].browse( + result["res_id"] + ) + return_picking.write( + { + "zort_return_no": zort_return_no, + "zort_return_data": return_order_data, + } + ) + msg = _( + "Zort has created a return order: %(zort_return_no)s", + zort_return_no=zort_return_no, + ) + return_picking.message_post(body=msg) + except Exception as e: + _logger.error( + "Error creating return picking for order %s: %s", + order.name, + str(e), + ) + continue + + elif return_status == "success": + zort_return_no = return_order_data.get("number", "") + picking_ids = order.picking_ids.filtered( + lambda p, return_no=zort_return_no: p.state == "assigned" + and p.picking_type_code == "incoming" + and p.zort_return_no == return_no + ) + if len(picking_ids) > 1: + _logger.warning( + "Multiple incoming pickings found. Skipping return validation." + ) + continue + picking = picking_ids[0] if picking_ids else None + if picking and picking.state == "assigned": + picking.button_validate() + self._create_credit_note_for_return(picking.ids) + _logger.info( + "Return picking has been validated for order %s", order.name + ) + + @api.model + def _created_zort_return_picking(self, sale_order, zort_return_no) -> bool: + """ + Returns True if no assigned incoming return picking exists for the sale order. + """ + incoming_pickings = sale_order.picking_ids.filtered( + lambda p, return_no=zort_return_no: p.picking_type_code == "incoming" + and p.state == "assigned" + and p.zort_return_no == return_no + ) + _logger.info( + "Checking existing return pickings for order %s with Zort's return no %s: " + "found %d", + sale_order.name, + zort_return_no, + len(incoming_pickings), + ) + return bool(incoming_pickings) + + def _create_credit_note_for_return(self, picking_ids): + """ + Create a credit note for the return picking. + This method should be called after the return picking is created. + + Ensure this credit note should be able to reconcile with invoices + related to the original sale order. + """ + + def get_price_unit(default_code, return_item_list): + # If return_item_list is available, match SKU to get pricepernumber + if return_item_list: + sku_to_price = { + item["sku"]: item["pricepernumber"] for item in return_item_list + } + return sku_to_price.get(default_code, 0) + return 0 + + for picking in self.env["stock.picking"].browse(picking_ids): + if ( + picking.picking_type_code != "incoming" + or not picking.zort_return_no + or picking.state != "done" + ): + continue + sale_order = picking.sale_id + if not sale_order: + continue + + return_item_list = picking.zort_return_data.get("list", []) + + # Prepare lines for credit note: only products in the return picking + credit_lines = [] + for move in picking.move_ids: + if move.product_id and move.quantity > 0: + credit_lines.append( + ( + 0, + 0, + { + "product_id": move.product_id.id, + "quantity": move.quantity, + "price_unit": get_price_unit( + move.product_id.default_code, return_item_list + ), + }, + ) + ) + if not credit_lines: + continue + credit_note = self.env["account.move"].create( + { + "move_type": "out_refund", + "invoice_origin": sale_order.name, + "invoice_user_id": sale_order.user_id.id, + "partner_id": sale_order.partner_id.id, + "invoice_date": fields.Date.context_today(self), + "invoice_line_ids": credit_lines, + "invoice_payment_term_id": sale_order.payment_term_id.id, + "ref": f"Picking No. {picking.name}", + } + ) + picking.message_post( + body=_( + "Draft credit note created for return picking: %s", credit_note.name + ) + ) + + @api.model + def _get_zort_return_order(self, **kwargs) -> list: + """ + Fetches return orders from Zort + """ + # TODO: date to query should be configurable on settings + + # Use a default date range of 10 days for get return orders + returnorderdateafter = (datetime.now() - timedelta(days=10)).strftime( + "%Y-%m-%d" + ) + returnorderdatebefore = datetime.now().strftime("%Y-%m-%d") + kwargs.update( + { + "returnorderdateafter": returnorderdateafter, + "returnorderdatebefore": returnorderdatebefore, + } + ) + + _logger.info("Fetching return orders from Zort...") + response = self._get_return_orders(**kwargs) + + # Handle API Read timed out. + if response.get("error"): + _logger.error( + "Error fetching return orders from Zort: %s", response.get("error") + ) + return [] + + res = response.get("res") + if res.get("resCode") != "200": + _logger.error( + "Error fetching return orders from Zort: %s", res.get("resMessage") + ) + return [] + + return response.get("list", []) + + def action_view_return_order_json(self): + """ + Action to view return order JSON data. + This method is used to display the return order data in a dialog. + """ + return { + "type": "ir.actions.act_url", + "url": f"/zort_connector/view_zort_return_order_json/{self.id}", + "target": "new", + } diff --git a/zort_connector/pyproject.toml b/zort_connector/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/zort_connector/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/zort_connector/readme/CONTRIBUTORS.rst b/zort_connector/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..dab6295 --- /dev/null +++ b/zort_connector/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +- Theerayut A. diff --git a/zort_connector/readme/DESCRIPTION.rst b/zort_connector/readme/DESCRIPTION.rst new file mode 100644 index 0000000..711f37a --- /dev/null +++ b/zort_connector/readme/DESCRIPTION.rst @@ -0,0 +1,33 @@ +Zort Connector for Odoo + +Integrates Odoo with Zort e-commerce platform for bidirectional synchronization of orders, products, inventory, and returns. + +**5 Main Zort API Endpoints Used:** + +1. **Add Product API** - Create products in Zort from Odoo +2. **Update Product API** - Update product information in Zort +3. **Update Stock API** - Sync inventory quantities to Zort +4. **Get Orders API** - Import orders from Zort to Odoo +5. **Get Return Orders API** - Process return orders from Zort + +**Key Features** + +* **Order Management**: Automatic import every 10 minutes with status mapping (Pending→Draft, Waiting→Confirmed, Success→Delivered+Invoiced, Voided→Cancelled) +* **Product Sync**: Create/update products in Zort with SKU matching +* **Stock Sync**: Real-time inventory updates on picking validation +* **Returns Processing**: Automatic return picking creation and credit notes +* **Multi-platform Support**: Lazada, Shopee, Magento integration +* **Robust Logging**: Complete API request/response tracking + +**Requirements** + +* Odoo 18.0+ +* Zort API credentials (Key, Secret, Store Name) +* Valid SKUs (default_code) on products + +**Quick Setup** + +1. Enable Zort Connector in Settings +2. Configure API credentials +3. Mark products "Sync with Zort" +4. Scheduled actions handle automatic synchronization diff --git a/zort_connector/security/ir.model.access.csv b/zort_connector/security/ir.model.access.csv new file mode 100644 index 0000000..2dbd800 --- /dev/null +++ b/zort_connector/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +"access_zort_ecommerce_channel","zort.ecommerce.channel","model_zort_ecommerce_channel","sales_team.group_sale_salesman",1,1,1,1 diff --git a/zort_connector/static/description/index.html b/zort_connector/static/description/index.html new file mode 100644 index 0000000..ea1aa83 --- /dev/null +++ b/zort_connector/static/description/index.html @@ -0,0 +1,449 @@ + + + + + +zort_connector + + + +
+

zort_connector

+ + +

Beta License: AGPL-3 ecosoft-odoo/ecosoft-addons

+

Zort Connector for Odoo

+

Integrates Odoo with Zort e-commerce platform for bidirectional synchronization of orders, products, inventory, and returns.

+

5 Main Zort API Endpoints Used:

+
    +
  1. Add Product API - Create products in Zort from Odoo
  2. +
  3. Update Product API - Update product information in Zort
  4. +
  5. Update Stock API - Sync inventory quantities to Zort
  6. +
  7. Get Orders API - Import orders from Zort to Odoo
  8. +
  9. Get Return Orders API - Process return orders from Zort
  10. +
+

Key Features

+
    +
  • Order Management: Automatic import every 10 minutes with status mapping (Pending→Draft, Waiting→Confirmed, Success→Delivered+Invoiced, Voided→Cancelled)
  • +
  • Product Sync: Create/update products in Zort with SKU matching
  • +
  • Stock Sync: Real-time inventory updates on picking validation
  • +
  • Returns Processing: Automatic return picking creation and credit notes
  • +
  • Multi-platform Support: Lazada, Shopee, Magento integration
  • +
  • Robust Logging: Complete API request/response tracking
  • +
+

Requirements

+
    +
  • Odoo 18.0+
  • +
  • Zort API credentials (Key, Secret, Store Name)
  • +
  • Valid SKUs (default_code) on products
  • +
+

Quick Setup

+
    +
  1. Enable Zort Connector in Settings
  2. +
  3. Configure API credentials
  4. +
  5. Mark products “Sync with Zort”
  6. +
  7. Scheduled actions handle automatic synchronization
  8. +
+

Table of contents

+ +
+

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

+
    +
  • Ecosoft
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

Current maintainer:

+

theerayuta@ecosoft.co.th

+

This module is part of the ecosoft-odoo/ecosoft-addons project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/zort_connector/views/mrp_bom_views.xml b/zort_connector/views/mrp_bom_views.xml new file mode 100644 index 0000000..4d57742 --- /dev/null +++ b/zort_connector/views/mrp_bom_views.xml @@ -0,0 +1,16 @@ + + + + mrp.bom + + + + + + + + diff --git a/zort_connector/views/product_template_view.xml b/zort_connector/views/product_template_view.xml new file mode 100644 index 0000000..c103565 --- /dev/null +++ b/zort_connector/views/product_template_view.xml @@ -0,0 +1,48 @@ + + + + product.template.only.form.view.inherit.sale + product.template + + + + +