diff --git a/zort_connector/README.rst b/zort_connector/README.rst new file mode 100644 index 00000000..34f452e8 --- /dev/null +++ b/zort_connector/README.rst @@ -0,0 +1,422 @@ +============== +Zort Connector +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2754a3226afef950df69653d0f6fc76bae912560d48412ff2dda2914771afe2c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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| + +Connects Odoo with `Zort `_ (Thai multi-channel +e-commerce platform) for bidirectional synchronization of orders, products, +inventory, and returns. + +**Zort API endpoints used** + +.. list-table:: + :header-rows: 1 + :widths: 5 40 55 + + * - # + - Endpoint + - Purpose + * - 1 + - ``/Order/GetOrders`` + - Pull orders from Zort into Odoo + * - 2 + - ``/ReturnOrder/GetReturnOrders`` + - Pull return orders from Zort + * - 3 + - ``/Product/AddProduct`` + - Create a new product in Zort + * - 4 + - ``/Product/UpdateProduct`` + - Push product name/price changes to Zort + * - 5 + - ``/Product/UpdateProductAvailableStockList`` + - Push available-stock quantity to Zort + +**Key features** + +- **Automatic order import** - scheduled every 10 minutes; supports paginated + results and a configurable look-back window (default 10 days). +- **Status-driven workflow** - Zort status changes trigger matching Odoo actions: + + - *Pending* → Sale Order stays draft + - *Waiting* → Sale Order confirmed + - *Success* → delivery validated + draft invoice created + - *Voided* → Sale Order cancelled + +- **Product & stock push** - create/update products in Zort from the product + form; stock is pushed automatically on every non-Zort, non-internal picking + validation. +- **Return order processing** - Zort return orders are fetched every 10 minutes; + a return picking is created on *Pending* status and validated (with a draft + credit note) on *Success* status. +- **Multi-channel support** - eCommerce channel records map Zort + ``saleschannel`` codes (Lazada, Shopee, Tiktok, …) to Odoo billing partners + and control auto-customer-creation. +- **Robust sync logging** - every sync run is recorded in **Zort > Sync Logs** + with per-page created/updated/failed counters and error messages. +- **Extensible hooks** - key methods (``_dispatch_sync_page``, + ``_dispatch_pending_orders_batch``, ``hook_process_sku``) are designed to be + overridden by add-on modules (e.g. an async queue-job extension). + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +1. Enable the connector +======================= + +Go to **Settings → Sales → Integrations → Zort Connector** and tick +**Zort Connector**. The credential fields appear below the toggle: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Field + - Description + * - Store Name + - Your store identifier on Zort (``storename`` request header) + * - API Key + - Zort API key (stored encrypted) + * - API Secret + - Zort API secret (stored encrypted) + * - Warehouse Code + - Zort warehouse code used for stock sync (default: ``W0001``) + * - Default Tax + - Tax applied to every order line imported from Zort + +Save the settings. Disabling the toggle clears all credential fields. + +2. Configure eCommerce channels +================================ + +Go to **Zort → Configuration → eCommerce Channels** and create one record per +sales channel that appears in the Zort ``saleschannel`` field +(e.g. ``lazada``, ``shopee``). + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Field + - Description + * - Name + - Display name (e.g. Lazada) + * - Code + - Must match the Zort ``saleschannel`` value (lowercase) + * - Platform Customer + - Billing partner used for sale orders from this channel; falls back to + the default marketplace customer if left empty + * - Auto Create Customer + - When enabled, an end-customer (``res.partner``) is created from the + order's customer data; deduplication is done by phone or VAT number + +3. Prepare products +==================== + +For each product that must be synchronised with Zort: + +a. Set a unique **Internal Reference** (SKU / ``default_code``). +b. Open the product form and tick **Sync with Zort** (on the product variant). +c. Use the **Create on Zort** button to push the product to Zort for the first + time. After a successful push, a **Zort Products** entry is created + automatically with the Zort-assigned product ID (``id_zort_product``). + +If a product already exists in Zort, create the mapping manually via +**Zort → Products → Zort Products** → New - fill in *Zort Product ID*, *Code*, +and link to the Odoo product. + +4. Special service products +============================ + +Three service products are required for fee lines on imported orders. They are +created automatically on module installation: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Internal Reference + - Purpose + * - ``shipping_fee`` + - Shipping cost line + * - ``zort_discount`` + - Discount line (negative amount) + * - ``zort_voucher`` + - Voucher / coupon line + +Do **not** delete or rename these products. + +5. System parameters (advanced) +================================ + +The following ``ir.config_parameter`` keys control sync behaviour and can be +changed in **Settings → Technical → Parameters → System Parameters**: + +.. list-table:: + :header-rows: 1 + :widths: 45 15 40 + + * - Key + - Default + - Description + * - ``zort_connector.order_sync_days_back`` + - ``10`` + - Days to look back when no previous sync cursor exists + * - ``zort_connector.last_sync_datetime`` + - *(empty)* + - Auto-updated after each successful sync run; clear to force a full re-sync + * - ``zort_connector.pending_order_batch_size`` + - ``500`` + - Number of ``zort.order`` records processed per cron batch + +6. Scheduled actions +===================== + +Four scheduled actions are installed and run every **10 minutes** (all guarded +by the ``zort_connector_enabled`` company flag): + +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Scheduled Action + - What it does + * - Auto Sync Zort Order + - Fetches new/updated orders from Zort + * - Process Zort Orders to Sale Orders + - Converts pending ``zort.order`` records into Odoo sale orders + * - Validate Zort - Sale Orders + - Validates draft sale orders against the original Zort data + * - Auto Sync Zort Return Order + - Fetches return orders and creates return pickings / credit notes + +The actions are created with ``noupdate="1"``; change their interval in +**Settings → Technical → Automation → Scheduled Actions**. + +Usage +===== + +Zort menu +========= + +After installation a top-level **Zort** menu is available to users in the +``Zort User`` group. The menu contains: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Menu item + - Contents + * - Zort → Orders + - List of all imported ``zort.order`` records + * - Zort → Sync Logs + - Per-page sync log with created/updated/failed counters + * - Products → Zort Products + - Zort ↔ Odoo product mapping table + * - Configuration → eCommerce Channels + - Channel settings (managers only) + +Order import +============ + +Orders are pulled from Zort automatically every 10 minutes by the +**Auto Sync Zort Order** scheduled action. + +Each Zort order is stored as a ``zort.order`` record with state **New**. A +second cron (**Process Zort Orders to Sale Orders**) converts New records into +Odoo sale orders in configurable batches (default 500). + +To trigger the import manually, open **Zort → Orders** and use the +**Sync Now** action, or run the scheduled actions from +**Settings → Technical → Automation → Scheduled Actions**. + +Order status mapping +==================== + +When a ``zort.order`` is updated by a subsequent sync run, the linked sale +order is updated automatically: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Zort status + - Odoo action + * - Pending + - Sale order remains in Draft + * - Waiting + - Sale order is Confirmed + * - Success + - Delivery is validated; a draft invoice is created + * - Voided + - Sale order is Cancelled + +Order validation +================ + +A third cron (**Validate Zort - Sale Orders**) checks each draft sale order +against the original Zort data: + +- **Amount check** - Odoo total must match the Zort ``amount`` field. +- **Line item check** - every Zort product ID must be present in the order + lines and the count must match. + +If both checks pass, ``validate_zort_order`` is set to ``True`` and the order +is confirmed. Mismatch details are written to the **Validation Message** field +on the sale order. + +To fix a missing product line manually, open the sale order and click +**Update Zort Order Lines** - the method adds any product lines present in +Zort but absent in Odoo. + +Product operations +================== + +Open a product variant form (**Products → Products**, switch to the variant). + +.. list-table:: + :header-rows: 1 + :widths: 35 65 + + * - Button + - Action + * - Create on Zort + - Calls ``/Product/AddProduct``; creates a ``zort.product`` mapping record + on success. Only available when **Sync with Zort** is ticked. + * - Update on Zort + - Calls ``/Product/UpdateProduct`` with current name, prices, and UoM. + Requires an existing ``zort.product`` mapping. + * - Update Qty to Zort + - Pushes ``qty_available`` to Zort via + ``/Product/UpdateProductAvailableStockList``. + * - Fetch Image from Zort + - Downloads the product image from Zort and sets it on the Odoo product. + +Stock synchronization +===================== + +When a delivery or receipt picking is validated, the connector automatically +pushes the updated ``qty_available`` of each Zort-linked product to Zort +(``/Product/UpdateProductAvailableStockList``). + +Pickings are **skipped** automatically if: + +- The picking is an **internal transfer**. +- The picking is linked to a **Zort sale order** (stock is managed by Zort in + that case). +- None of the move lines contain a product with a Zort mapping. + +Return orders +============= + +The **Auto Sync Zort Return Order** scheduled action fetches return orders from +Zort every 10 minutes (``/ReturnOrder/GetReturnOrders``): + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Zort return status + - Odoo action + * - Pending + - A return picking (incoming) is created from the original delivery, + quantities are set from the Zort return lines, and the Zort return + number is stored on the picking. + * - Success + - The existing return picking is validated; a draft credit note is created + and linked to the original sale order. + +Sync logs +========= + +Every order sync run creates one ``zort.sync.log`` record per page fetched. +Open **Zort → Sync Logs** to see: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Field + - Meaning + * - Sync From / To + - Date-time window of the sync run + * - Page / Total Pages + - Which page this log covers + * - State + - In Progress / Done / Failed + * - Created / Updated / Failed + - Per-page order counters + * - Error Message + - Details when a page fetch or upsert fails + +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. \ +- Saran Lim. \<\> + +Maintainers +~~~~~~~~~~~ + +.. |maintainer-TheerayutEncoder| image:: https://github.com/TheerayutEncoder.png?size=40px + :target: https://github.com/TheerayutEncoder + :alt: TheerayutEncoder +.. |maintainer-Saran440| image:: https://github.com/Saran440.png?size=40px + :target: https://github.com/Saran440 + :alt: Saran440 + +Current maintainers: + +|maintainer-TheerayutEncoder| |maintainer-Saran440| + +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 00000000..69f7babd --- /dev/null +++ b/zort_connector/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/zort_connector/__manifest__.py b/zort_connector/__manifest__.py new file mode 100644 index 00000000..3c09a0dd --- /dev/null +++ b/zort_connector/__manifest__.py @@ -0,0 +1,33 @@ +# 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)", + "website": "https://github.com/ecosoft-odoo/ecosoft-addons", + "depends": ["stock", "sale_management", "usability_api_connector"], + "data": [ + "security/res_groups.xml", + "security/ir.model.access.csv", + "data/ir_config_parameter_data.xml", + "data/api_config_data.xml", + "data/ir_cron_data.xml", + "data/ir_actions_server_data.xml", + "data/product_data.xml", + "data/partner_data.xml", + "views/res_config_settings_view.xml", + "views/zort_menu.xml", + "views/zort_sync_log_views.xml", + "views/zort_order_views.xml", + "views/zort_ecommerce_channel_views.xml", + "views/zort_product_view.xml", + "views/product_product_view.xml", + "views/sale_order_view.xml", + "views/stock_picking_view.xml", + ], + "development_status": "Alpha", + "maintainers": ["TheerayutEncoder", "Saran440"], +} diff --git a/zort_connector/data/api_config_data.xml b/zort_connector/data/api_config_data.xml new file mode 100644 index 00000000..69397345 --- /dev/null +++ b/zort_connector/data/api_config_data.xml @@ -0,0 +1,198 @@ + + + + + Zort Create Product + zort_create_product + + rest_api + external + https://open-api.zortout.com/v4 + /Product/AddProduct + post + False + (lambda c: { + "storename": c.zort_store_name, + "apikey": c.zort_api_key, + "apisecret": c.zort_api_secret, +} if c.zort_connector_enabled else {})(env.company) + + resDesc + True + { + "sku": rec.default_code, + "name": rec.name, + "sellprice": rec.lst_price, + "purchaseprice": rec.standard_price, + "unittext": rec.uom_name, +} + + + + + Zort Update Product + zort_update_product + + rest_api + external + https://open-api.zortout.com/v4 + /Product/UpdateProduct + post + False + (lambda c: { + "storename": c.zort_store_name, + "apikey": c.zort_api_key, + "apisecret": c.zort_api_secret, +} if c.zort_connector_enabled else {})(env.company) + resCode + 200 + resDesc + True + {"id": rec.zort_product_ids[:1].id_zort_product} + { + "name": rec.name, + "sellprice": rec.list_price, + "purchaseprice": rec.standard_price, + "unittext": rec.uom_name, + # "weight": rec.weight, + # "sell_vat_status": 0, + # "purchase_vat_status": 0, +} + + + + + Zort Update Product Qty + zort_update_qty + + rest_api + external + https://open-api.zortout.com/v4 + /Product/UpdateProductAvailableStockList + post + False + (lambda c: { + "storename": c.zort_store_name, + "apikey": c.zort_api_key, + "apisecret": c.zort_api_secret, +} if c.zort_connector_enabled else {})(env.company) + resCode + 200 + resDesc + True + {"warehousecode": env.company.zort_warehouse_code or "W0001"} + { + "stocks": [ + { + "sku": rec.default_code, + "stock": rec.qty_available, + } + ] +} + + + + + Zort Fetch Product Image + zort_fetch_product_image + + rest_api + external + https://open-api.zortout.com/v4 + /Product/GetProducts + get + False + (lambda c: { + "storename": c.zort_store_name, + "apikey": c.zort_api_key, + "apisecret": c.zort_api_secret, +} if c.zort_connector_enabled else {})(env.company) + {"productidlist": rec.zort_product_ids[:1].id_zort_product} + + True + + + + + Zort Get Return Orders + zort_get_return_order + + rest_api + external + https://open-api.zortout.com/v4 + /ReturnOrder/GetReturnOrders + get + False + (lambda c: { + "storename": c.zort_store_name, + "apikey": c.zort_api_key, + "apisecret": c.zort_api_secret, +} if c.zort_connector_enabled else {})(env.company) + + True + { + "returnorderdateafter": env.context.get("zort_return_date_after", today_datetime.strftime("%Y-%m-%d")), + "returnorderdatebefore": today_datetime.strftime("%Y-%m-%d"), +} + + + + + Zort Sync Stock from Picking + zort_sync_stock_picking + + rest_api + external + https://open-api.zortout.com/v4 + /Product/UpdateProductAvailableStockList + post + False + (lambda c: { + "storename": c.zort_store_name, + "apikey": c.zort_api_key, + "apisecret": c.zort_api_secret, +} if c.zort_connector_enabled else {})(env.company) + resCode + 200 + resDesc + True + {"warehousecode": env.company.zort_warehouse_code or "W0001"} + { + "stocks": [ + {"productid": zp.id_zort_product, "stock": move.product_id.qty_available} + for move in (rec.move_ids if rec.picking_type_code == "incoming" else rec.move_ids_without_package) + for zp in move.product_id.zort_product_ids + ] +} + + + + + Zort Get Orders + zort_get_order + rest_api + Zort API credentials and endpoint configuration. Edit the Headers field to set storename, apikey, and apisecret. + external + https://open-api.zortout.com/v4 + /Order/GetOrders + get + False + (lambda c: { + "storename": c.zort_store_name, + "apikey": c.zort_api_key, + "apisecret": c.zort_api_secret, +} if c.zort_connector_enabled else {})(env.company) + + False + + 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 00000000..f0007a51 --- /dev/null +++ b/zort_connector/data/ir_actions_server_data.xml @@ -0,0 +1,25 @@ + + + + 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() + + + + + Update image from zort product + + code + +for product in records: + product.action_fetch_and_update_image_from_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 00000000..e31f30e4 --- /dev/null +++ b/zort_connector/data/ir_config_parameter_data.xml @@ -0,0 +1,18 @@ + + + + zort_connector.order_sync_days_back + 10 + + + + zort_connector.last_sync_datetime + + + + zort_connector.pending_order_batch_size + 500 + + diff --git a/zort_connector/data/ir_cron_data.xml b/zort_connector/data/ir_cron_data.xml new file mode 100644 index 00000000..61890750 --- /dev/null +++ b/zort_connector/data/ir_cron_data.xml @@ -0,0 +1,39 @@ + + + + Auto Sync Zort Order + + code + if env.company.zort_connector_enabled: + model.action_call_api("zort_get_order") + 10 + minutes + + + Process Zort Orders to Sale Orders + + code + if env.company.zort_connector_enabled: + model.action_process_pending_orders() + 10 + minutes + + + Validate Zort - Sale Orders + + code + if env.company.zort_connector_enabled: + model.action_validate_zort_order() + 10 + minutes + + + Auto Sync Zort Return Order + + code + if env.company.zort_connector_enabled: + model.action_create_return_picking() + 10 + minutes + + diff --git a/zort_connector/data/partner_data.xml b/zort_connector/data/partner_data.xml new file mode 100644 index 00000000..c084beea --- /dev/null +++ b/zort_connector/data/partner_data.xml @@ -0,0 +1,23 @@ + + + + Marketplace Customer + + company + + + Shopee 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 00000000..8be7d0e6 --- /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 00000000..25bd1376 --- /dev/null +++ b/zort_connector/models/__init__.py @@ -0,0 +1,11 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import res_company +from . import res_config_settings +from . import ecommerce_channel +from . import zort_sync_log +from . import zort_order +from . import zort_product +from . import product_product +from . import sale_order +from . import stock_picking diff --git a/zort_connector/models/ecommerce_channel.py b/zort_connector/models/ecommerce_channel.py new file mode 100644 index 00000000..69650ea2 --- /dev/null +++ b/zort_connector/models/ecommerce_channel.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). + +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!", + ) + ] diff --git a/zort_connector/models/product_product.py b/zort_connector/models/product_product.py new file mode 100644 index 00000000..dc1f7139 --- /dev/null +++ b/zort_connector/models/product_product.py @@ -0,0 +1,157 @@ +# Copyright 2025 Ecosoft Co., Ltd (https://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +import base64 +import logging + +import requests +from markupsafe import Markup + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + +ZORT_CREATE_PRODUCT = "zort_create_product" +ZORT_UPDATE_PRODUCT = "zort_update_product" +ZORT_UPDATE_QTY = "zort_update_qty" +ZORT_FETCH_IMAGE = "zort_fetch_product_image" + + +class ProductProduct(models.Model): + _name = "product.product" + _inherit = ["product.product", "common.base.api"] + + zort_product_ids = fields.One2many( + comodel_name="zort.product", + inverse_name="product_id", + string="Zort Products", + ) + sync_with_zort = fields.Boolean( + string="Sync with Zort", + default=False, + help="Enable synchronization of this product with Zort API", + ) + + def decode_image_url(self, image_url): + """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 + + def _hook_update_data(self, code_api, result): + """Handle Zort API responses for product operations.""" + if code_api == ZORT_CREATE_PRODUCT: + res_code = result.get("resCode", "") + zort_product_id = result.get("resDesc", "") + if res_code == "200" and zort_product_id: + self.env["zort.product"].create( + { + "product_id": self.id, + "id_zort_product": zort_product_id, + "sku": self.default_code, + "unit_text": self.uom_name, + "name": self.name, + } + ) + self.message_post( + body=Markup("Success: Product created successfully on Zort.") + ) + else: + desc = result.get("resDesc", "Unknown error") + _logger.error("Error creating product in Zort: %s", desc) + self.message_post( + body=Markup( + f"Error: Failed to create product on Zort: {desc}
" + "Please check on Zort, the product may have " + "already been created.
If the product was created, " + "you should update the Zort Product with the " + "product ID from Zort." + ) + ) + elif code_api == ZORT_UPDATE_PRODUCT: + self.message_post( + body=Markup("Success: Product updated successfully on Zort.") + ) + elif code_api == ZORT_UPDATE_QTY: + self.message_post( + body=Markup("Success: Stock updated successfully on Zort.") + ) + elif code_api == ZORT_FETCH_IMAGE: + if result.get("error"): + _logger.warning( + "Error fetching product from Zort: %s", result.get("error") + ) + self.message_post( + body=Markup( + "Warning: No image found for this product on Zort." + ) + ) + return + products = result.get("list", []) + image_url = products[0].get("imagepath", "") if products else "" + if not image_url: + _logger.warning( + "No image found for product ID %s on Zort", self.zort_product_id + ) + self.message_post( + body=Markup( + "Warning: No image found for this product on Zort." + ) + ) + return + image_data = self.decode_image_url(image_url) + if image_data: + self.image_1920 = image_data + _logger.info( + "Image updated successfully from Zort for product %s", self.name + ) + self.message_post( + body=Markup( + "Success: Product image updated successfully from Zort." + ) + ) + else: + self.message_post( + body=Markup("Error: Failed to download image from Zort.") + ) + + def action_create_product_on_zort(self): + """Create product in Zort via common.base.api. Result is handled in + _hook_update_data. Always returns a form reload so the UI reflects the + updated zort_product_id / is_created_on_zort values.""" + self.ensure_one() + if not self.sync_with_zort: + return + return self.action_call_api(ZORT_CREATE_PRODUCT) + + def action_update_product_to_zort(self): + """Update product in Zort via common.base.api. Result is handled in + _hook_update_data.""" + self.ensure_one() + zort_product = self.env["zort.product"].search( + [("product_id", "=", self.id)], limit=1 + ) + if not zort_product: + return + return self.action_call_api(ZORT_UPDATE_PRODUCT) + + def action_update_qty_to_zort(self): + """Update stock quantity in Zort via common.base.api. Result is handled in + _hook_update_data. Always returns a form reload so the UI reflects any + updated values.""" + self.ensure_one() + return self.action_call_api(ZORT_UPDATE_QTY) + + def action_fetch_and_update_image_from_zort(self): + """Fetch and update product images from Zort for products linked to Zort.""" + self.ensure_one() + zort_product = self.zort_product_ids[0] + if not zort_product.image_url: + return + return self.action_call_api(ZORT_FETCH_IMAGE) diff --git a/zort_connector/models/res_company.py b/zort_connector/models/res_company.py new file mode 100644 index 00000000..eadbdac8 --- /dev/null +++ b/zort_connector/models/res_company.py @@ -0,0 +1,22 @@ +# 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 ResCompany(models.Model): + _inherit = "res.company" + + zort_connector_enabled = fields.Boolean( + default=False, + ) + zort_api_key = fields.Char() + zort_api_secret = fields.Char() + zort_store_name = fields.Char() + zort_warehouse_code = fields.Char( + default="W0001", + ) + zort_default_tax_id = fields.Many2one( + comodel_name="account.tax", + check_company=True, + ) diff --git a/zort_connector/models/res_config_settings.py b/zort_connector/models/res_config_settings.py new file mode 100644 index 00000000..a2dc7e67 --- /dev/null +++ b/zort_connector/models/res_config_settings.py @@ -0,0 +1,49 @@ +# 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( + related="company_id.zort_connector_enabled", + readonly=False, + help="Enable the Zort Connector to connect Odoo with Zort.", + ) + zort_api_key = fields.Char( + related="company_id.zort_api_key", + readonly=False, + help="The API key to authenticate with the Zort endpoint.", + ) + zort_api_secret = fields.Char( + related="company_id.zort_api_secret", + readonly=False, + help="The API secret to authenticate with the Zort endpoint.", + ) + zort_store_name = fields.Char( + related="company_id.zort_store_name", + readonly=False, + help="The name of the store in Zort.", + ) + zort_warehouse_code = fields.Char( + related="company_id.zort_warehouse_code", + readonly=False, + help="The warehouse code in Zort.", + ) + zort_default_tax_id = fields.Many2one( + comodel_name="account.tax", + related="company_id.zort_default_tax_id", + readonly=False, + help="Default tax to apply for Zort products.", + ) + + @api.onchange("zort_connector_enabled") + def _onchange_zort_connector_enabled(self): + if not self.zort_connector_enabled: + self.company_id.zort_api_key = "" + self.company_id.zort_api_secret = "" + self.company_id.zort_store_name = "" + self.company_id.zort_warehouse_code = "" + self.company_id.zort_default_tax_id = False diff --git a/zort_connector/models/sale_order.py b/zort_connector/models/sale_order.py new file mode 100644 index 00000000..7f75f89f --- /dev/null +++ b/zort_connector/models/sale_order.py @@ -0,0 +1,227 @@ +# Copyright 2025 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +import json +import logging + +from odoo import Command, fields, models + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + zort_order_id = fields.Many2one( + comodel_name="zort.order", + copy=False, + readonly=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_sales_channel = fields.Char( + help="The sales channel of the order in Zort.", + copy=False, + readonly=True, + ) + zort_customer_id = fields.Many2one( + comodel_name="res.partner", + string="e-Commerce Customer", + copy=False, + readonly=True, + ) + validate_zort_order = fields.Boolean( + help="Indicates if the Zort order has been validated.", + copy=False, + default=False, + readonly=True, + ) + zort_missing_product_ids = fields.Char( + help="Comma-separated list of Zort product IDs that are missing in Odoo.", + copy=False, + readonly=True, + ) + zort_validation_message = fields.Text( + help="Validation warning messages from Zort order synchronization.", + copy=False, + readonly=True, + ) + + # ------------------------------------------------------------------------- + # Update flow (called from zort.order._post_upsert_order) + # ------------------------------------------------------------------------- + + def _update_sale_order_from_zort(self, zort_rec): + """Update this sale order's Zort fields from the linked zort.order record.""" + zort_order = json.loads(zort_rec.raw_data or "{}") + self.write( + { + "zort_order_number": zort_rec.zort_order_number, + "zort_order_status": zort_rec.zort_status, + "zort_payment_status": zort_order.get("paymentstatus"), + "zort_sales_channel": zort_rec.source_channel, + } + ) + self.validate_order_from_zort() + _logger.info("Updated Sale Order: %s", self.name) + self._handle_zort_order_status(zort_rec.zort_status) + + def _handle_zort_order_status(self, zort_status): + """Trigger Odoo workflow actions based on Zort order status.""" + if zort_status == "Voided": + self.with_context(disable_cancel_warning=True).action_cancel() + elif zort_status == "Success" and self.validate_zort_order: + self._process_success_order() + + def _process_success_order(self): + """Confirm, deliver, and invoice this order when Zort status is 'Success'.""" + if self.state not in ["sale", "cancel"]: + self.action_confirm() + for picking in self.picking_ids: + if picking.state not in ["done", "cancel"]: + picking.button_validate() + _logger.info("Delivery order done for Sale Order: %s", self.name) + self.order_line.invalidate_recordset(["qty_delivered"]) + self.order_line._compute_qty_delivered() + self._create_draft_invoice() + + def _create_draft_invoice(self): + """Create a draft invoice for this sale order.""" + invoice_wizard = ( + self.env["sale.advance.payment.inv"] + .with_context(active_ids=self.ids, active_id=self.id) + .create({"advance_payment_method": "delivered"}) + ) + invoice_wizard.create_invoices() + _logger.info("Draft invoice created for Sale Order: %s", self.name) + + # ------------------------------------------------------------------------- + # Validation + # ------------------------------------------------------------------------- + + def action_validate_zort_order(self): + """Validate Zort orders that are in draft state and + have a linked zort.order record""" + orders = self.env["sale.order"].search( + [ + ("state", "=", "draft"), + ("zort_order_id", "!=", False), + ("validate_zort_order", "=", False), + ] + ) + if not orders: + return + orders.validate_order_from_zort() + confirmed = orders.filtered(lambda so: so.validate_zort_order) + for order in confirmed: + order.action_confirm() + _logger.info("Confirmed Sale Order from Zort: %s", confirmed.mapped("name")) + + def validate_order_from_zort(self): + """ + Validate this sale order against the linked zort.order record. + Checks: + 1. Total amount matches zort.order.raw_data["amount"]. + 2. All Zort product IDs are present in order lines. + """ + for order in self: + zort_data = json.loads(order.zort_order_id.raw_data or "{}") + zort_amount = zort_data.get("amount", 0.0) + msgs = [] + + if float(zort_amount) != float(order.amount_total): + msgs.append( + f"Amount mismatch: Zort amount is {zort_amount}, " + f"Odoo amount is {order.amount_total}." + ) + + existing_zort_ids = set( + order.order_line.mapped("product_id.zort_product_ids.id_zort_product") + ) + zort_product_count = 0 + missing_product_ids = [] + for line in zort_data.get("list", []): + if line.get("id"): + zort_product_count += 1 + pid = line.get("productid") + if pid and pid not in existing_zort_ids: + missing_product_ids.append(str(pid)) + + odoo_product_count = len( + order.order_line.filtered(lambda ln: ln.product_id.type != "service") + ) + if zort_product_count != odoo_product_count: + msgs.append( + f"Line item count mismatch: Zort has {zort_product_count} items, " + f"Odoo has {odoo_product_count} items." + ) + + validated = not msgs + order.write( + { + "zort_missing_product_ids": ",".join(missing_product_ids), + "validate_zort_order": validated, + "zort_validation_message": ( + "This order has been validated successfully." + if validated + else "\n".join(msgs) + ), + } + ) + + def update_zort_sale_order_line(self): + """Add missing product lines from the linked zort.order's raw_data.""" + for order in self: + zort_data = json.loads(order.zort_order_id.raw_data or "{}") + zort_line_items = zort_data.get("list", []) + existing_zort_ids = set( + order.order_line.mapped("product_id.zort_product_ids.id_zort_product") + ) + for zort_line in zort_line_items: + if zort_line.get("id") in existing_zort_ids: + continue + + product = order.zort_order_id._get_product_by_sku( + zort_line.get("productid") + ) + if not product: + _logger.warning( + "Product with Zort ID %s not found. Skipping line.", + zort_line.get("id"), + ) + continue + order.write( + { + "order_line": [ + Command.create( + { + "product_id": product.id, + "product_uom_qty": zort_line.get("number", 1), + "price_unit": zort_line.get("pricepernumber", 0.0), + "name": product.name, + "tax_id": [ + Command.set( + order.env.company.zort_default_tax_id.ids + ) + ], + } + ) + ] + } + ) + return True diff --git a/zort_connector/models/stock_picking.py b/zort_connector/models/stock_picking.py new file mode 100644 index 00000000..8c75deab --- /dev/null +++ b/zort_connector/models/stock_picking.py @@ -0,0 +1,299 @@ +# 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 datetime import timedelta + +from odoo import Command, api, fields, models + +_logger = logging.getLogger(__name__) + +ZORT_SYNC_STOCK = "zort_sync_stock_picking" +ZORT_GET_RETURN_ORDER = "zort_get_return_order" + + +class StockPicking(models.Model): + _name = "stock.picking" + _inherit = ["stock.picking", "common.base.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 _get_type_code_skip(self): + return ["internal"] + + 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 internal transfer update qty to zort + # Skip internal transfers and MRP operations + type_code_skip = self._get_type_code_skip() + if self.picking_type_code in type_code_skip: + return res + + # Skip if this picking is related to a Zort order + if self.sale_id and self.sale_id.zort_order_number: + return res + + # Update quantity to Zort for other picking types + self.action_sync_qty_to_zort() + return res + + def action_sync_qty_to_zort(self): + """Sync available qty to Zort for all products in this picking. + + Delegates API call and response handling to common.base.api via + action_call_api / _hook_update_data. Skips pickings that have no + products linked to Zort. + """ + for picking in self: + moves = ( + picking.move_ids + if picking.picking_type_code == "incoming" + else picking.move_ids_without_package + ) + if not any(move.product_id.zort_product_ids for move in moves): + continue + picking.action_call_api(ZORT_SYNC_STOCK) + + def _hook_update_data(self, code_api, result): + """Handle Zort API responses for stock sync and return order fetch.""" + if code_api == ZORT_SYNC_STOCK: + if result.get("error"): + _logger.error( + "Error syncing stock to Zort for picking %s: %s", + self.name, + result.get("error"), + ) + else: + self.updated_qty_to_zort = True + _logger.info( + "Successfully synced stock to Zort for picking %s", self.name + ) + elif code_api == ZORT_GET_RETURN_ORDER: + if result.get("error"): + _logger.error( + "Error fetching return orders from Zort: %s", result.get("error") + ) + return + return_orders = result.get("list", []) + _logger.info("Fetched %d return orders from Zort.", len(return_orders)) + if return_orders: + self._process_zort_return_orders(return_orders) + + # ========== + # Return + # ========== + + @api.model + def action_create_return_picking(self): + """Cron entry point: fetch Zort return orders and process them. + + Computes the date window in Python (where timedelta is available) and + passes it via context so params_code in api.config stays simple. + Result is dispatched to _hook_update_data → _process_zort_return_orders. + """ + ICP = self.env["ir.config_parameter"].sudo() + days_back = int(ICP.get_param("zort_connector.return_order_sync_days_back", 10)) + date_after = (fields.Date.today() - timedelta(days=days_back)).strftime( + "%Y-%m-%d" + ) + return self.with_context(zort_return_date_after=date_after).action_call_api( + ZORT_GET_RETURN_ORDER + ) + + @api.model + def _process_zort_return_orders(self, return_orders): + """Process return orders fetched from Zort. + + - Pending: create an assigned return picking from the original delivery. + - Success: validate the existing return picking and create a credit note. + + Zort return order structure (relevant fields): + referencenumber — original sale order number in Zort + number — return order number in Zort (CN-xxxxx) + status — "Pending" or "Success" + list — returned line items [{sku, number, pricepernumber}] + """ + zort_order_numbers = { + order["referencenumber"]: order + for order in return_orders + if order.get("status") in ("Pending", "Success") + and order.get("referencenumber") + } + if not zort_order_numbers: + return + + sale_orders = self.env["sale.order"].search( + [ + ("zort_order_number", "in", list(zort_order_numbers.keys())), + ("state", "=", "sale"), + ] + ) + for order in sale_orders: + return_order_data = zort_order_numbers[order.zort_order_number] + zort_return_no = return_order_data.get("number", "") + return_status = return_order_data["status"].lower() + + if return_status == "pending": + if self._has_zort_return_picking(order, zort_return_no): + continue + picking = order.picking_ids.filtered( + lambda p, so=order: p.state == "done" + and p.picking_type_code == "outgoing" + and p.origin == so.name + )[:1] + if not picking: + continue + + try: + return_wizard = ( + self.env["stock.return.picking"] + .with_context( + active_ids=[picking.id], + active_id=picking.id, + active_model="stock.picking", + ) + .create({}) + ) + 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, + } + ) + return_picking.message_post( + body=self.env._( + "Zort has created a return order: %(zort_return_no)s", + zort_return_no=zort_return_no, + ) + ) + except Exception as e: + _logger.error( + "Error creating return picking for order %s: %s", + order.name, + str(e), + ) + + elif return_status == "success": + picking_ids = order.picking_ids.filtered( + lambda p, rn=zort_return_no: p.state == "assigned" + and p.picking_type_code == "incoming" + and p.zort_return_no == rn + ) + if len(picking_ids) > 1: + _logger.warning( + "Multiple incoming pickings found for order %s. " + "Skipping return validation.", + order.name, + ) + continue + + picking = picking_ids[:1] + if picking: + picking.button_validate() + picking._create_credit_note_for_return() + _logger.info("Return picking validated for order %s", order.name) + + @api.model + def _has_zort_return_picking(self, sale_order, zort_return_no) -> bool: + """Return True if an assigned incoming return picking already exists.""" + incoming = sale_order.picking_ids.filtered( + lambda p, rn=zort_return_no: p.picking_type_code == "incoming" + and p.state == "assigned" + and p.zort_return_no == rn + ) + _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), + ) + return bool(incoming) + + def _create_credit_note_for_return(self): + """Create a draft credit note for each done return picking on this recordset. + + The credit note is linked to the original sale order so it can reconcile + with the existing invoice. + """ + sku_price_map_cache = {} + + def get_price_unit(default_code, return_item_list): + if return_item_list: + if id(return_item_list) not in sku_price_map_cache: + sku_price_map_cache[id(return_item_list)] = { + item["sku"]: item["pricepernumber"] for item in return_item_list + } + return sku_price_map_cache[id(return_item_list)].get(default_code, 0) + return 0 + + for picking in self: + 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 or {}).get("list", []) + credit_lines = [ + Command.create( + { + "product_id": move.product_id.id, + "quantity": move.quantity, + "price_unit": get_price_unit( + move.product_id.default_code, return_item_list + ), + } + ) + for move in picking.move_ids + if move.product_id and move.quantity > 0 + ] + 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=self.env._( + "Draft credit note created for return picking: %s", + credit_note.name, + ) + ) diff --git a/zort_connector/models/zort_order.py b/zort_connector/models/zort_order.py new file mode 100644 index 00000000..98dc125b --- /dev/null +++ b/zort_connector/models/zort_order.py @@ -0,0 +1,614 @@ +# Copyright 2025 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import json +import logging +import os +import re +from datetime import timedelta + +from odoo import Command, api, fields, models +from odoo.tools.safe_eval import safe_eval + +_logger = logging.getLogger(__name__) + +ZORT_GET_ORDER = "zort_get_order" + +# --------------------------------------------------------------------------- +# Demo mode - load JSON fixtures instead of calling the live API. +# Set to True while testing to avoid consuming real API tokens. +# --------------------------------------------------------------------------- +_DEMO_MODE = False +_DEMO_DIR = os.path.join(os.path.dirname(__file__), "..", "demo") + + +class ZortOrder(models.Model): + _name = "zort.order" + _inherit = ["mail.thread", "mail.activity.mixin", "common.base.api"] + _description = "Zort Order" + _rec_name = "zort_order_number" + _order = "zort_order_date desc, id desc" + + zort_order_id = fields.Char( + string="Zort Order ID", + index=True, + readonly=True, + ) + zort_order_number = fields.Char( + string="Order Number", + index=True, + readonly=True, + ) + zort_status = fields.Char(readonly=True) + zort_order_date = fields.Datetime(string="Order Date", readonly=True) + zort_update_date = fields.Datetime(string="Updated on Zort", readonly=True) + total_amount = fields.Float(readonly=True) + payment_amount = fields.Float(readonly=True) + shipping_cost = fields.Float(readonly=True) + source_channel = fields.Char(readonly=True) + channel_order_id = fields.Char(readonly=True) + customer_name = fields.Char(readonly=True) + customer_phone = fields.Char(readonly=True) + customer_address = fields.Text(readonly=True) + remark = fields.Text(readonly=True) + raw_data = fields.Text(string="Raw Data (JSON)", readonly=True) + state = fields.Selection( + selection=[ + ("new", "New"), + ("processed", "Processed"), + ("error", "Error"), + ], + default="new", + ) + sale_order_ids = fields.One2many( + comodel_name="sale.order", + inverse_name="zort_order_id", + readonly=True, + ) + sync_log_id = fields.Many2one( + comodel_name="zort.sync.log", + string="Sync Log", + readonly=True, + ) + + _sql_constraints = [ + ( + "zort_order_id_unique", + "UNIQUE(zort_order_id)", + "Zort Order ID must be unique.", + ) + ] + + @api.model + def _get_zort_api_config(self): + """Return the active api.config record with code=ZORT_MAIN.""" + return ( + self.env["api.config"] + .sudo() + .search([("code", "=", ZORT_GET_ORDER), ("active", "=", True)], limit=1) + ) + + @api.model + def _get_last_sync(self): + ICP = self.env["ir.config_parameter"].sudo() + last_sync = ICP.get_param("zort_connector.last_sync_datetime", "") + days_back = int(ICP.get_param("zort_connector.order_sync_days_back", 10)) + if last_sync: + sync_dt = fields.Datetime.to_datetime(last_sync) + return fields.Date.to_string(sync_dt.date()) + from_date = fields.Date.today() - timedelta(days=days_back) + return fields.Date.to_string(from_date) + + @staticmethod + def _parse_zort_datetime(value): + """Parse Zort datetime string (ISO 8601 'T' or Odoo space separator).""" + if not value: + return False + return fields.Datetime.to_datetime(str(value).replace("T", " ")) + + @api.model + def _prepare_order_vals(self, order_data, sync_log): + """Map a Zort API order dict to zort.order field values.""" + return { + "zort_order_id": str(order_data.get("id", "")), + "zort_order_number": order_data.get("number", ""), + "zort_status": order_data.get("status", ""), + "zort_order_date": self._parse_zort_datetime(order_data.get("orderdate")), + "zort_update_date": self._parse_zort_datetime( + order_data.get("updatedatetime") + ), + "total_amount": order_data.get("amount") or 0.0, + "payment_amount": order_data.get("paymentamount") or 0.0, + "shipping_cost": order_data.get("shippingamount") or 0.0, + "source_channel": order_data.get("saleschannel", ""), + "channel_order_id": order_data.get("integrationShop", ""), + "customer_name": order_data.get("customername", ""), + "customer_phone": order_data.get("customerphone", ""), + "customer_address": order_data.get("customeraddress", ""), + "remark": order_data.get("description", ""), + "raw_data": json.dumps(order_data, ensure_ascii=False), + "sync_log_id": sync_log.id, + } + + @api.model + def _upsert_order(self, order_data, sync_log): + """Create or update a zort.order record from raw API data""" + zort_order_id = str(order_data.get("id", "")) + if not zort_order_id: + return None, "skipped" + + vals = self._prepare_order_vals(order_data, sync_log) + existing = self.search([("zort_order_id", "=", zort_order_id)], limit=1) + if existing: + existing.write(vals) + self._post_upsert_order(existing) + return existing, "updated" + + return self.create(vals), "created" + + def action_create_sale_order(self): + zort_orders = self.filtered( + lambda zo: zo.state == "new" and not zo.sale_order_ids + ) + if not zort_orders: + return + + product_cache, channel_cache = zort_orders._build_so_caches() + so_vals_list = [] + valid_zorts = self.env["zort.order"] + for zort in zort_orders: + try: + vals = zort._prepare_dict_so( + product_cache=product_cache, channel_cache=channel_cache + ) + so_vals_list.append(vals) + valid_zorts |= zort + except Exception as e: + _logger.error( + "Error preparing SO for zort.order %s: %s", zort.zort_order_id, e + ) + zort.write({"state": "error"}) + if not so_vals_list: + return + + sale_orders = self.env["sale.order"].create(so_vals_list) + valid_zorts.write({"state": "processed"}) + return sale_orders + + @api.model + def _get_pending_order_batch_size(self): + ICP = self.env["ir.config_parameter"].sudo() + return int(ICP.get_param("zort_connector.pending_order_batch_size", 500)) + + def _build_so_caches(self): + """Build product and channel lookup caches for a recordset batch""" + channel_codes = [(c or "").lower() for c in self.mapped("source_channel") if c] + channel_recs = self.env["zort.ecommerce.channel"].search( + [("code", "in", channel_codes)] + ) + channel_cache = {rec.code: rec for rec in channel_recs} + + special_skus = ["shipping_fee", "zort_discount", "zort_voucher"] + products = self.env["product.product"].search( + [("default_code", "in", special_skus)] + ) + product_cache = {p.default_code: p for p in products} + return product_cache, channel_cache + + def _prepare_dict_so(self, product_cache=None, channel_cache=None): + """Return a sale.order vals dict for this zort.order (no DB write).""" + self.ensure_one() + zort_order = json.loads(self.raw_data or "{}") + partner_id, zort_customer_id = self._get_customers_data( + zort_order, channel_cache=channel_cache + ) + order_lines = self._prepare_sale_order_lines( + zort_order, product_cache=product_cache + ) + return { + "partner_id": partner_id, + "zort_order_id": self.id, + "zort_order_number": self.zort_order_number, + "zort_order_status": self.zort_status, + "zort_payment_status": zort_order.get("paymentstatus"), + "zort_sales_channel": self.source_channel, + "zort_customer_id": zort_customer_id, + "order_line": order_lines, + } + + def _get_customers_data(self, zort_order, channel_cache=None): + """ + Determine the billing partner (marketplace) and the platform-specific + customer (end customer) based on the sales channel. + Returns: tuple (partner_id, zort_customer_id) + """ + sales_channel = (self.source_channel or "").lower() + if channel_cache is not None: + ecommerce_channel = channel_cache.get( + sales_channel, self.env["zort.ecommerce.channel"] + ) + else: + ecommerce_channel = self.env["zort.ecommerce.channel"].search( + [("code", "=", sales_channel)], limit=1 + ) + + # 1. Marketplace Customer (Billing Partner) + default_customer = self.env.ref("zort_connector.marketplace_customer_1") + partner_id = ecommerce_channel.partner_id.id or default_customer.id + + # 2. Platform Customer (End Customer) + zort_customer_id = False + if ecommerce_channel and ecommerce_channel.auto_create_customer: + customer = self._create_customer(zort_order) + if customer: + zort_customer_id = customer.id + + return partner_id, zort_customer_id + + def _create_customer(self, zort_order): + """Find or create a res.partner from this order's customer data.""" + vals = { + "name": zort_order.get("customername", "Online Customer"), + "phone": zort_order.get("customerphone", ""), + "email": zort_order.get("customeremail", ""), + "street": zort_order.get("customeraddress", ""), + "city": zort_order.get("customerprovince", ""), + "zip": zort_order.get("customerpostcode", ""), + "vat": zort_order.get("customeridnumber", ""), + "is_company": False, + } + if vals["phone"]: + phone = self._validate_customer_phone(vals["phone"]) + existing = self.env["res.partner"].search([("phone", "=", phone)], limit=1) + if existing: + return existing + if vals["vat"]: + existing = self.env["res.partner"].search( + [("vat", "=", vals["vat"])], limit=1 + ) + if existing: + return existing + return self.env["res.partner"].create(vals) + + @staticmethod + def _validate_customer_phone(phone: str) -> str: + """Return the last 9 digits of the phone number.""" + phone = phone.strip() + # keep only digits + phone = re.sub(r"\D", "", phone) + + # case: 660812345678 + if phone.startswith("660"): + phone = "0" + phone[3:] + + # case: 66812345678 + elif phone.startswith("66"): + phone = "0" + phone[2:] + + return phone + + def hook_process_sku(self, 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 Odoo product.product by Zort product ID (SKU).""" + product_id = self.hook_process_sku(sku) + zort_product = self.env["zort.product"].search( + [("id_zort_product", "=", product_id)], limit=1 + ) + if zort_product and zort_product.product_id: + return zort_product.product_id + return self.env["product.product"] + + def _prepare_sale_order_lines(self, zort_order, product_cache=None): + """Build sale.order line command tuples from this order's raw_data.""" + order_lines = [] + for line in zort_order.get("list", []): + product = self._get_product_by_sku(line.get("productid")) + if not product: + _logger.warning( + "Product with SKU %s not found. Skipping line.", line.get("sku") + ) + continue + order_lines.append( + Command.create( + { + "product_id": product.id, + "product_uom_qty": line.get("number", 1), + "price_unit": line.get("pricepernumber", 0.0), + "name": product.name, + "tax_id": [ + Command.set(self.env.company.zort_default_tax_id.ids) + ], + }, + ) + ) + shipping_amount = zort_order.get("shippingamount", 0.0) + if shipping_amount > 0: + order_lines.append( + self._prepare_shipping_line( + shipping_amount, product_cache=product_cache + ) + ) + discount = zort_order.get("discount", 0.0) + if discount: + order_lines.append( + self._prepare_discount_line( + float(discount), product_cache=product_cache + ) + ) + voucher_amount = zort_order.get("voucheramount", 0.0) + if voucher_amount > 0: + order_lines.append( + self._prepare_voucher_line(voucher_amount, product_cache=product_cache) + ) + return order_lines + + def _prepare_shipping_line(self, shipping_amount, product_cache=None): + """Return a sale.order line command tuple for shipping fee.""" + if product_cache is not None: + product = product_cache.get("shipping_fee", self.env["product.product"]) + else: + product = self.env["product.product"].search( + [("default_code", "=", "shipping_fee")], limit=1 + ) + return Command.create( + { + "product_id": product.id, + "product_uom_qty": 1, + "price_unit": shipping_amount, + "name": "Shipping Fee", + } + ) + + def _prepare_discount_line(self, discount, product_cache=None): + """Return a sale.order line command tuple for discount (negative price).""" + if product_cache is not None: + product = product_cache.get("zort_discount", self.env["product.product"]) + else: + product = self.env["product.product"].search( + [("default_code", "=", "zort_discount")], limit=1 + ) + return Command.create( + { + "product_id": product.id, + "product_uom_qty": 1, + "price_unit": -float(discount), + "name": "Discount", + } + ) + + def _prepare_voucher_line(self, voucher_amount, product_cache=None): + """Return a sale.order line command tuple for voucher.""" + if product_cache is not None: + product = product_cache.get("zort_voucher", self.env["product.product"]) + else: + product = self.env["product.product"].search( + [("default_code", "=", "zort_voucher")], limit=1 + ) + return Command.create( + { + "product_id": product.id, + "product_uom_qty": 1, + "price_unit": voucher_amount, + "name": "Voucher", + } + ) + + def _post_upsert_order(self, zort_order): + """ + Hook called after an existing zort.order is updated. + If a sale.order is already linked, sync the updated status back. + Override in submodules to add custom behaviour. + """ + active_so = zort_order.sale_order_ids.filtered(lambda so: so.state != "cancel")[ + :1 + ] + if active_so: + active_so._update_sale_order_from_zort(zort_order) + + @api.model + def action_process_pending_orders(self): + """Entry point for cron: dispatch pending zort.order records in batches. + + Splits pending records into configurable-size batches and dispatches each + via ``_dispatch_pending_orders_batch``. Override that method in + ``zort_connector_queue`` to switch to asynchronous queue-job execution. + """ + pending = self.search([("state", "=", "new"), ("sale_order_ids", "=", False)]) + if not pending: + return + + batch_size = self._get_pending_order_batch_size() + ids = pending.ids + for i in range(0, len(ids), batch_size): + zort_batch = ids[i : i + batch_size] + self._dispatch_pending_orders_batch(zort_batch) + + @api.model + def _dispatch_pending_orders_batch(self, zort_batch): + """Hook: process a batch of pending zort.order""" + return self.browse(zort_batch).action_create_sale_order() + + def _hook_update_data(self, code_api, result): + """ + Process Zort order sync after action_call_api fetches page 1. + Handles pagination, sync log creation, and dispatching per-page work. + + Page-shifting protection: ``enddate`` is pinned to ``sync_to`` so that + orders arriving during a long sync run do not push existing orders to + later pages and cause gaps or duplicates. + + Extensibility: per-page dispatch is delegated to ``_dispatch_sync_page`` + so that add-on modules (e.g. zort_connector_queue) can override only + that method to switch to asynchronous queue-job execution without + touching this method. + """ + if code_api != ZORT_GET_ORDER: + return + + config = self._get_zort_api_config() + ICP = self.env["ir.config_parameter"].sudo() + sync_to = fields.Datetime.now() + last_sync = ICP.get_param("zort_connector.last_sync_datetime", "") + days_back = int(ICP.get_param("zort_connector.order_sync_days_back", 10)) + sync_from = ( + fields.Datetime.from_string(last_sync) + if last_sync + else sync_to - timedelta(days=days_back) + ) + + # Evaluate python_code once - single source of truth for API params. + # Pin enddate to sync_to to create a stable query window; new orders + # arriving during sync won't shift pages already fetched. + base_payload = safe_eval( + config.python_code or "{}", + globals_dict=self._get_payload_globals_dict(), + ) + base_payload.setdefault("enddate", sync_to.strftime("%Y-%m-%d")) + + # Demo mode: load page 1 from fixture instead of live API result + if _DEMO_MODE: + with open(os.path.join(_DEMO_DIR, "example1.json")) as f: + result = json.load(f) + + counts = result.get("count") or 0 + total_pages = max((counts + 499) // 500, 1) + sync_to_str = fields.Datetime.to_string(sync_to) + + # Create all sync-log records up-front so every page is visible + # in the UI immediately (state=in_progress). + all_pages = range(1, total_pages + 1) + sync_logs = ( + self.env["zort.sync.log"] + .sudo() + .create( + [ + { + "sync_from": sync_from, + "sync_to": sync_to, + "page": page, + "total_pages": total_pages, + "state": "in_progress", + } + for page in all_pages + ] + ) + ) + + for page in all_pages: + self._dispatch_sync_page( + sync_log_id=sync_logs[page - 1].id, + page=page, + total_pages=total_pages, + base_payload=base_payload, + sync_to_str=sync_to_str, + # Pass the already-fetched result for page 1 to avoid a + # redundant API call; pages > 1 must be fetched by the worker. + page_result=result if page == 1 else None, + is_last_page=(page == total_pages), + ) + + @api.model + def _dispatch_sync_page( + self, + sync_log_id, + page, + total_pages, + base_payload, + sync_to_str, + page_result, + is_last_page, + ): + """Allow hook thif method""" + self._process_sync_page( + sync_log_id=sync_log_id, + page=page, + total_pages=total_pages, + base_payload=base_payload, + sync_to_str=sync_to_str, + page_result=page_result, + is_last_page=is_last_page, + ) + + @api.model + def _process_sync_page( + self, + sync_log_id, + page, + total_pages, + base_payload, + sync_to_str, + page_result, + is_last_page, + ): + """ + Fetch (if needed) and upsert all orders for one page, then update the + linked ``zort.sync.log`` record. + + This is the actual worker - safe to call directly or via a queue job. + + :param sync_log_id: ID of the pre-created zort.sync.log record. + :param page: 1-based page number to process. + :param total_pages: Total number of pages in this sync run. + :param base_payload: Base API query parameters (dict). + :param sync_to_str: Odoo-formatted datetime string for sync window end. + :param page_result: Pre-fetched result dict (page 1 only); None for + pages > 1 so the worker fetches it itself. + :param is_last_page: When True, advance ``last_sync_datetime`` on + successful completion. + """ + sync_log = self.env["zort.sync.log"].sudo().browse(sync_log_id) + config = self._get_zort_api_config() + ICP = self.env["ir.config_parameter"].sudo() + + # Fetch data for pages > 1 (page 1 result is passed in directly). + if page_result is None: + try: + if _DEMO_MODE: + with open(os.path.join(_DEMO_DIR, f"example{page}.json")) as f: + page_result = json.load(f) + else: + response = self._execute_rest_api( + config, + auth_token=False, + payload={**base_payload, "page": page}, + ) + page_result = response.json() + except Exception as e: + msg = f"Page {page}/{total_pages} fetch failed: {e}" + _logger.error("Zort sync: %s", msg) + sync_log.write({"state": "failed", "error_message": msg}) + return + + created = updated = failed = 0 + for order_data in page_result.get("list", []): + zort_order_id = str(order_data.get("id", "")) + try: + _, action = self._upsert_order(order_data, sync_log) + if action == "created": + created += 1 + elif action == "updated": + updated += 1 + except Exception as e: + failed += 1 + _logger.error("Failed to upsert Zort order %s: %s", zort_order_id, e) + + sync_log.write( + { + "state": "done", + "order_created": created, + "order_updated": updated, + "order_failed": failed, + } + ) + + # Only advance the sync cursor on the last page and when no orders + # failed, so a partial failure retries from the same window next run. + if is_last_page and failed == 0: + ICP.set_param("zort_connector.last_sync_datetime", sync_to_str) diff --git a/zort_connector/models/zort_product.py b/zort_connector/models/zort_product.py new file mode 100644 index 00000000..f6cb0454 --- /dev/null +++ b/zort_connector/models/zort_product.py @@ -0,0 +1,34 @@ +# Copyright 2025 Ecosoft Co., Ltd (https://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ZortProduct(models.Model): + _name = "zort.product" + _description = "Zort Product" + _order = "id desc" + + name = fields.Char(required=True) + id_zort_product = fields.Char(string="Zort Product ID", required=True) + description = fields.Text() + sku = fields.Char(string="Code") + sell_price = fields.Float(string="Sale Price", digits="Product Price") + purchase_price = fields.Float(digits="Product Price") + unit_text = fields.Char(string="Unit") + image_url = fields.Char(string="Image URL") + active = fields.Boolean(default=True) + product_id = fields.Many2one( + comodel_name="product.product", + string="Odoo Product", + ondelete="set null", + ) + + _sql_constraints = [ + ( + "id_zort_product_uniq", + "UNIQUE(id_zort_product)", + "Zort Product ID must be unique.", + ), + ("sku_uniq", "UNIQUE(sku)", "SKU must be unique."), + ] diff --git a/zort_connector/models/zort_sync_log.py b/zort_connector/models/zort_sync_log.py new file mode 100644 index 00000000..f9a2c640 --- /dev/null +++ b/zort_connector/models/zort_sync_log.py @@ -0,0 +1,30 @@ +# 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 ZortSyncLog(models.Model): + _name = "zort.sync.log" + _description = "Zort Sync Log" + _order = "sync_from desc, page desc" + _rec_name = "id" + _rec_names_search = ["id"] + + sync_from = fields.Datetime(readonly=True) + sync_to = fields.Datetime(readonly=True) + page = fields.Integer(default=1, readonly=True) + total_pages = fields.Integer(default=0, readonly=True) + state = fields.Selection( + selection=[ + ("in_progress", "In Progress"), + ("done", "Done"), + ("failed", "Failed"), + ], + default="in_progress", + readonly=True, + ) + order_created = fields.Integer(string="Created", readonly=True, default=0) + order_updated = fields.Integer(string="Updated", readonly=True, default=0) + order_failed = fields.Integer(string="Failed", readonly=True, default=0) + error_message = fields.Text(readonly=True) diff --git a/zort_connector/pyproject.toml b/zort_connector/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /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/CONFIGURE.rst b/zort_connector/readme/CONFIGURE.rst new file mode 100644 index 00000000..26fa84ac --- /dev/null +++ b/zort_connector/readme/CONFIGURE.rst @@ -0,0 +1,131 @@ +1. Enable the connector +======================= + +Go to **Settings → Sales → Integrations → Zort Connector** and tick +**Zort Connector**. The credential fields appear below the toggle: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Field + - Description + * - Store Name + - Your store identifier on Zort (``storename`` request header) + * - API Key + - Zort API key (stored encrypted) + * - API Secret + - Zort API secret (stored encrypted) + * - Warehouse Code + - Zort warehouse code used for stock sync (default: ``W0001``) + * - Default Tax + - Tax applied to every order line imported from Zort + +Save the settings. Disabling the toggle clears all credential fields. + +2. Configure eCommerce channels +================================ + +Go to **Zort → Configuration → eCommerce Channels** and create one record per +sales channel that appears in the Zort ``saleschannel`` field +(e.g. ``lazada``, ``shopee``). + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Field + - Description + * - Name + - Display name (e.g. Lazada) + * - Code + - Must match the Zort ``saleschannel`` value (lowercase) + * - Platform Customer + - Billing partner used for sale orders from this channel; falls back to + the default marketplace customer if left empty + * - Auto Create Customer + - When enabled, an end-customer (``res.partner``) is created from the + order's customer data; deduplication is done by phone or VAT number + +3. Prepare products +==================== + +For each product that must be synchronised with Zort: + +a. Set a unique **Internal Reference** (SKU / ``default_code``). +b. Open the product form and tick **Sync with Zort** (on the product variant). +c. Use the **Create on Zort** button to push the product to Zort for the first + time. After a successful push, a **Zort Products** entry is created + automatically with the Zort-assigned product ID (``id_zort_product``). + +If a product already exists in Zort, create the mapping manually via +**Zort → Products → Zort Products** → New - fill in *Zort Product ID*, *Code*, +and link to the Odoo product. + +4. Special service products +============================ + +Three service products are required for fee lines on imported orders. They are +created automatically on module installation: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Internal Reference + - Purpose + * - ``shipping_fee`` + - Shipping cost line + * - ``zort_discount`` + - Discount line (negative amount) + * - ``zort_voucher`` + - Voucher / coupon line + +Do **not** delete or rename these products. + +5. System parameters (advanced) +================================ + +The following ``ir.config_parameter`` keys control sync behaviour and can be +changed in **Settings → Technical → Parameters → System Parameters**: + +.. list-table:: + :header-rows: 1 + :widths: 45 15 40 + + * - Key + - Default + - Description + * - ``zort_connector.order_sync_days_back`` + - ``10`` + - Days to look back when no previous sync cursor exists + * - ``zort_connector.last_sync_datetime`` + - *(empty)* + - Auto-updated after each successful sync run; clear to force a full re-sync + * - ``zort_connector.pending_order_batch_size`` + - ``500`` + - Number of ``zort.order`` records processed per cron batch + +6. Scheduled actions +===================== + +Four scheduled actions are installed and run every **10 minutes** (all guarded +by the ``zort_connector_enabled`` company flag): + +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Scheduled Action + - What it does + * - Auto Sync Zort Order + - Fetches new/updated orders from Zort + * - Process Zort Orders to Sale Orders + - Converts pending ``zort.order`` records into Odoo sale orders + * - Validate Zort - Sale Orders + - Validates draft sale orders against the original Zort data + * - Auto Sync Zort Return Order + - Fetches return orders and creates return pickings / credit notes + +The actions are created with ``noupdate="1"``; change their interval in +**Settings → Technical → Automation → Scheduled Actions**. diff --git a/zort_connector/readme/CONTRIBUTORS.rst b/zort_connector/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..55b14e67 --- /dev/null +++ b/zort_connector/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +- Theerayut A. \ +- Saran Lim. \<\> diff --git a/zort_connector/readme/DESCRIPTION.rst b/zort_connector/readme/DESCRIPTION.rst new file mode 100644 index 00000000..825a2af1 --- /dev/null +++ b/zort_connector/readme/DESCRIPTION.rst @@ -0,0 +1,54 @@ +Connects Odoo with `Zort `_ (Thai multi-channel +e-commerce platform) for bidirectional synchronization of orders, products, +inventory, and returns. + +**Zort API endpoints used** + +.. list-table:: + :header-rows: 1 + :widths: 5 40 55 + + * - # + - Endpoint + - Purpose + * - 1 + - ``/Order/GetOrders`` + - Pull orders from Zort into Odoo + * - 2 + - ``/ReturnOrder/GetReturnOrders`` + - Pull return orders from Zort + * - 3 + - ``/Product/AddProduct`` + - Create a new product in Zort + * - 4 + - ``/Product/UpdateProduct`` + - Push product name/price changes to Zort + * - 5 + - ``/Product/UpdateProductAvailableStockList`` + - Push available-stock quantity to Zort + +**Key features** + +- **Automatic order import** - scheduled every 10 minutes; supports paginated + results and a configurable look-back window (default 10 days). +- **Status-driven workflow** - Zort status changes trigger matching Odoo actions: + + - *Pending* → Sale Order stays draft + - *Waiting* → Sale Order confirmed + - *Success* → delivery validated + draft invoice created + - *Voided* → Sale Order cancelled + +- **Product & stock push** - create/update products in Zort from the product + form; stock is pushed automatically on every non-Zort, non-internal picking + validation. +- **Return order processing** - Zort return orders are fetched every 10 minutes; + a return picking is created on *Pending* status and validated (with a draft + credit note) on *Success* status. +- **Multi-channel support** - eCommerce channel records map Zort + ``saleschannel`` codes (Lazada, Shopee, Tiktok, …) to Odoo billing partners + and control auto-customer-creation. +- **Robust sync logging** - every sync run is recorded in **Zort > Sync Logs** + with per-page created/updated/failed counters and error messages. +- **Extensible hooks** - key methods (``_dispatch_sync_page``, + ``_dispatch_pending_orders_batch``, ``hook_process_sku``) are designed to be + overridden by add-on modules (e.g. an async queue-job extension). diff --git a/zort_connector/readme/USAGE.rst b/zort_connector/readme/USAGE.rst new file mode 100644 index 00000000..7b0dcca6 --- /dev/null +++ b/zort_connector/readme/USAGE.rst @@ -0,0 +1,153 @@ +Zort menu +========= + +After installation a top-level **Zort** menu is available to users in the +``Zort User`` group. The menu contains: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Menu item + - Contents + * - Zort → Orders + - List of all imported ``zort.order`` records + * - Zort → Sync Logs + - Per-page sync log with created/updated/failed counters + * - Products → Zort Products + - Zort ↔ Odoo product mapping table + * - Configuration → eCommerce Channels + - Channel settings (managers only) + +Order import +============ + +Orders are pulled from Zort automatically every 10 minutes by the +**Auto Sync Zort Order** scheduled action. + +Each Zort order is stored as a ``zort.order`` record with state **New**. A +second cron (**Process Zort Orders to Sale Orders**) converts New records into +Odoo sale orders in configurable batches (default 500). + +To trigger the import manually, open **Zort → Orders** and use the +**Sync Now** action, or run the scheduled actions from +**Settings → Technical → Automation → Scheduled Actions**. + +Order status mapping +==================== + +When a ``zort.order`` is updated by a subsequent sync run, the linked sale +order is updated automatically: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Zort status + - Odoo action + * - Pending + - Sale order remains in Draft + * - Waiting + - Sale order is Confirmed + * - Success + - Delivery is validated; a draft invoice is created + * - Voided + - Sale order is Cancelled + +Order validation +================ + +A third cron (**Validate Zort - Sale Orders**) checks each draft sale order +against the original Zort data: + +- **Amount check** - Odoo total must match the Zort ``amount`` field. +- **Line item check** - every Zort product ID must be present in the order + lines and the count must match. + +If both checks pass, ``validate_zort_order`` is set to ``True`` and the order +is confirmed. Mismatch details are written to the **Validation Message** field +on the sale order. + +To fix a missing product line manually, open the sale order and click +**Update Zort Order Lines** - the method adds any product lines present in +Zort but absent in Odoo. + +Product operations +================== + +Open a product variant form (**Products → Products**, switch to the variant). + +.. list-table:: + :header-rows: 1 + :widths: 35 65 + + * - Button + - Action + * - Create on Zort + - Calls ``/Product/AddProduct``; creates a ``zort.product`` mapping record + on success. Only available when **Sync with Zort** is ticked. + * - Update on Zort + - Calls ``/Product/UpdateProduct`` with current name, prices, and UoM. + Requires an existing ``zort.product`` mapping. + * - Update Qty to Zort + - Pushes ``qty_available`` to Zort via + ``/Product/UpdateProductAvailableStockList``. + * - Fetch Image from Zort + - Downloads the product image from Zort and sets it on the Odoo product. + +Stock synchronization +===================== + +When a delivery or receipt picking is validated, the connector automatically +pushes the updated ``qty_available`` of each Zort-linked product to Zort +(``/Product/UpdateProductAvailableStockList``). + +Pickings are **skipped** automatically if: + +- The picking is an **internal transfer**. +- The picking is linked to a **Zort sale order** (stock is managed by Zort in + that case). +- None of the move lines contain a product with a Zort mapping. + +Return orders +============= + +The **Auto Sync Zort Return Order** scheduled action fetches return orders from +Zort every 10 minutes (``/ReturnOrder/GetReturnOrders``): + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Zort return status + - Odoo action + * - Pending + - A return picking (incoming) is created from the original delivery, + quantities are set from the Zort return lines, and the Zort return + number is stored on the picking. + * - Success + - The existing return picking is validated; a draft credit note is created + and linked to the original sale order. + +Sync logs +========= + +Every order sync run creates one ``zort.sync.log`` record per page fetched. +Open **Zort → Sync Logs** to see: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Field + - Meaning + * - Sync From / To + - Date-time window of the sync run + * - Page / Total Pages + - Which page this log covers + * - State + - In Progress / Done / Failed + * - Created / Updated / Failed + - Per-page order counters + * - Error Message + - Details when a page fetch or upsert fails diff --git a/zort_connector/security/ir.model.access.csv b/zort_connector/security/ir.model.access.csv new file mode 100644 index 00000000..508c64dd --- /dev/null +++ b/zort_connector/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +"access_zort_sync_log_user","zort.sync.log user","model_zort_sync_log","zort_connector.group_zort_user",1,0,0,0 +"access_zort_sync_log_manager","zort.sync.log manager","model_zort_sync_log","zort_connector.group_zort_manager",1,1,1,1 +"access_zort_order_user","zort.order user","model_zort_order","zort_connector.group_zort_user",1,0,0,0 +"access_zort_order_manager","zort.order manager","model_zort_order","zort_connector.group_zort_manager",1,1,1,1 +"access_zort_ecommerce_channel","zort.ecommerce.channel","model_zort_ecommerce_channel","sales_team.group_sale_salesman",1,1,1,1 +"access_zort_product","zort.product","model_zort_product","base.group_user",1,1,1,1 diff --git a/zort_connector/security/res_groups.xml b/zort_connector/security/res_groups.xml new file mode 100644 index 00000000..be72011c --- /dev/null +++ b/zort_connector/security/res_groups.xml @@ -0,0 +1,28 @@ + + + + Zort Connector + Manage Zort orders and synchronisation. + 100 + + + + User + + + Can view Zort orders and sync logs. + + + + Administrator + + + Full access to Zort orders, sync logs, and configuration. + + + diff --git a/zort_connector/static/description/index.html b/zort_connector/static/description/index.html new file mode 100644 index 00000000..8e339c9d --- /dev/null +++ b/zort_connector/static/description/index.html @@ -0,0 +1,876 @@ + + + + + +Zort Connector + + + +
+

Zort Connector

+ + +

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

+

Connects Odoo with Zort (Thai multi-channel +e-commerce platform) for bidirectional synchronization of orders, products, +inventory, and returns.

+

Zort API endpoints used

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#EndpointPurpose
1/Order/GetOrdersPull orders from Zort into Odoo
2/ReturnOrder/GetReturnOrdersPull return orders from Zort
3/Product/AddProductCreate a new product in Zort
4/Product/UpdateProductPush product name/price changes to Zort
5/Product/UpdateProductAvailableStockListPush available-stock quantity to Zort
+

Key features

+
    +
  • Automatic order import - scheduled every 10 minutes; supports paginated +results and a configurable look-back window (default 10 days).
  • +
  • Status-driven workflow - Zort status changes trigger matching Odoo actions:
      +
    • Pending → Sale Order stays draft
    • +
    • Waiting → Sale Order confirmed
    • +
    • Success → delivery validated + draft invoice created
    • +
    • Voided → Sale Order cancelled
    • +
    +
  • +
  • Product & stock push - create/update products in Zort from the product +form; stock is pushed automatically on every non-Zort, non-internal picking +validation.
  • +
  • Return order processing - Zort return orders are fetched every 10 minutes; +a return picking is created on Pending status and validated (with a draft +credit note) on Success status.
  • +
  • Multi-channel support - eCommerce channel records map Zort +saleschannel codes (Lazada, Shopee, Tiktok, …) to Odoo billing partners +and control auto-customer-creation.
  • +
  • Robust sync logging - every sync run is recorded in Zort > Sync Logs +with per-page created/updated/failed counters and error messages.
  • +
  • Extensible hooks - key methods (_dispatch_sync_page, +_dispatch_pending_orders_batch, hook_process_sku) are designed to be +overridden by add-on modules (e.g. an async queue-job extension).
  • +
+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ + +
+

1. Enable the connector

+

Go to Settings → Sales → Integrations → Zort Connector and tick +Zort Connector. The credential fields appear below the toggle:

+ ++++ + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
Store NameYour store identifier on Zort (storename request header)
API KeyZort API key (stored encrypted)
API SecretZort API secret (stored encrypted)
Warehouse CodeZort warehouse code used for stock sync (default: W0001)
Default TaxTax applied to every order line imported from Zort
+

Save the settings. Disabling the toggle clears all credential fields.

+
+
+

2. Configure eCommerce channels

+

Go to Zort → Configuration → eCommerce Channels and create one record per +sales channel that appears in the Zort saleschannel field +(e.g. lazada, shopee).

+ ++++ + + + + + + + + + + + + + + + + + + + +
FieldDescription
NameDisplay name (e.g. Lazada)
CodeMust match the Zort saleschannel value (lowercase)
Platform CustomerBilling partner used for sale orders from this channel; falls back to +the default marketplace customer if left empty
Auto Create CustomerWhen enabled, an end-customer (res.partner) is created from the +order’s customer data; deduplication is done by phone or VAT number
+
+
+

3. Prepare products

+

For each product that must be synchronised with Zort:

+
    +
  1. Set a unique Internal Reference (SKU / default_code).
  2. +
  3. Open the product form and tick Sync with Zort (on the product variant).
  4. +
  5. Use the Create on Zort button to push the product to Zort for the first +time. After a successful push, a Zort Products entry is created +automatically with the Zort-assigned product ID (id_zort_product).
  6. +
+

If a product already exists in Zort, create the mapping manually via +Zort → Products → Zort Products → New - fill in Zort Product ID, Code, +and link to the Odoo product.

+
+
+

4. Special service products

+

Three service products are required for fee lines on imported orders. They are +created automatically on module installation:

+ ++++ + + + + + + + + + + + + + + + + +
Internal ReferencePurpose
shipping_feeShipping cost line
zort_discountDiscount line (negative amount)
zort_voucherVoucher / coupon line
+

Do not delete or rename these products.

+
+
+

5. System parameters (advanced)

+

The following ir.config_parameter keys control sync behaviour and can be +changed in Settings → Technical → Parameters → System Parameters:

+ +++++ + + + + + + + + + + + + + + + + + + + + +
KeyDefaultDescription
zort_connector.order_sync_days_back10Days to look back when no previous sync cursor exists
zort_connector.last_sync_datetime(empty)Auto-updated after each successful sync run; clear to force a full re-sync
zort_connector.pending_order_batch_size500Number of zort.order records processed per cron batch
+
+
+

6. Scheduled actions

+

Four scheduled actions are installed and run every 10 minutes (all guarded +by the zort_connector_enabled company flag):

+ ++++ + + + + + + + + + + + + + + + + + + + +
Scheduled ActionWhat it does
Auto Sync Zort OrderFetches new/updated orders from Zort
Process Zort Orders to Sale OrdersConverts pending zort.order records into Odoo sale orders
Validate Zort - Sale OrdersValidates draft sale orders against the original Zort data
Auto Sync Zort Return OrderFetches return orders and creates return pickings / credit notes
+

The actions are created with noupdate="1"; change their interval in +Settings → Technical → Automation → Scheduled Actions.

+
+
+

Usage

+
+
+

Zort menu

+

After installation a top-level Zort menu is available to users in the +Zort User group. The menu contains:

+ ++++ + + + + + + + + + + + + + + + + + + + +
Menu itemContents
Zort → OrdersList of all imported zort.order records
Zort → Sync LogsPer-page sync log with created/updated/failed counters
Products → Zort ProductsZort ↔ Odoo product mapping table
Configuration → eCommerce ChannelsChannel settings (managers only)
+
+
+

Order import

+

Orders are pulled from Zort automatically every 10 minutes by the +Auto Sync Zort Order scheduled action.

+

Each Zort order is stored as a zort.order record with state New. A +second cron (Process Zort Orders to Sale Orders) converts New records into +Odoo sale orders in configurable batches (default 500).

+

To trigger the import manually, open Zort → Orders and use the +Sync Now action, or run the scheduled actions from +Settings → Technical → Automation → Scheduled Actions.

+
+
+

Order status mapping

+

When a zort.order is updated by a subsequent sync run, the linked sale +order is updated automatically:

+ ++++ + + + + + + + + + + + + + + + + + + + +
Zort statusOdoo action
PendingSale order remains in Draft
WaitingSale order is Confirmed
SuccessDelivery is validated; a draft invoice is created
VoidedSale order is Cancelled
+
+
+

Order validation

+

A third cron (Validate Zort - Sale Orders) checks each draft sale order +against the original Zort data:

+
    +
  • Amount check - Odoo total must match the Zort amount field.
  • +
  • Line item check - every Zort product ID must be present in the order +lines and the count must match.
  • +
+

If both checks pass, validate_zort_order is set to True and the order +is confirmed. Mismatch details are written to the Validation Message field +on the sale order.

+

To fix a missing product line manually, open the sale order and click +Update Zort Order Lines - the method adds any product lines present in +Zort but absent in Odoo.

+
+
+

Product operations

+

Open a product variant form (Products → Products, switch to the variant).

+ ++++ + + + + + + + + + + + + + + + + + + + +
ButtonAction
Create on ZortCalls /Product/AddProduct; creates a zort.product mapping record +on success. Only available when Sync with Zort is ticked.
Update on ZortCalls /Product/UpdateProduct with current name, prices, and UoM. +Requires an existing zort.product mapping.
Update Qty to ZortPushes qty_available to Zort via +/Product/UpdateProductAvailableStockList.
Fetch Image from ZortDownloads the product image from Zort and sets it on the Odoo product.
+
+
+

Stock synchronization

+

When a delivery or receipt picking is validated, the connector automatically +pushes the updated qty_available of each Zort-linked product to Zort +(/Product/UpdateProductAvailableStockList).

+

Pickings are skipped automatically if:

+
    +
  • The picking is an internal transfer.
  • +
  • The picking is linked to a Zort sale order (stock is managed by Zort in +that case).
  • +
  • None of the move lines contain a product with a Zort mapping.
  • +
+
+
+

Return orders

+

The Auto Sync Zort Return Order scheduled action fetches return orders from +Zort every 10 minutes (/ReturnOrder/GetReturnOrders):

+ ++++ + + + + + + + + + + + + + +
Zort return statusOdoo action
PendingA return picking (incoming) is created from the original delivery, +quantities are set from the Zort return lines, and the Zort return +number is stored on the picking.
SuccessThe existing return picking is validated; a draft credit note is created +and linked to the original sale order.
+
+
+

Sync logs

+

Every order sync run creates one zort.sync.log record per page fetched. +Open Zort → Sync Logs to see:

+ ++++ + + + + + + + + + + + + + + + + + + + + + + +
FieldMeaning
Sync From / ToDate-time window of the sync run
Page / Total PagesWhich page this log covers
StateIn Progress / Done / Failed
Created / Updated / FailedPer-page order counters
Error MessageDetails when a page fetch or upsert fails
+
+
+

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 maintainers:

+

TheerayutEncoder Saran440

+

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

+

You are welcome to contribute.

+
+
+
+ + diff --git a/zort_connector/tests/__init__.py b/zort_connector/tests/__init__.py new file mode 100644 index 00000000..70c87205 --- /dev/null +++ b/zort_connector/tests/__init__.py @@ -0,0 +1 @@ +from . import test_zort_order_performance diff --git a/zort_connector/tests/test_zort_order_performance.py b/zort_connector/tests/test_zort_order_performance.py new file mode 100644 index 00000000..aa9d10c9 --- /dev/null +++ b/zort_connector/tests/test_zort_order_performance.py @@ -0,0 +1,127 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.tests.common import TransactionCase + + +class TestZortOrderPerformance(TransactionCase): + """Performance regression tests for ZortOrder._create_sale_order and helpers. + + Verifies that the N+1 refactor (JSON parsed once, batch SKU lookups, batch + pre-fetches) does not change observable behaviour. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Test-only products (genuinely new data for these tests) + cls.product_a = cls.env["product.product"].create( + {"name": "Product A", "default_code": "SKU-A", "type": "consu"} + ) + cls.product_b = cls.env["product.product"].create( + {"name": "Product B", "default_code": "SKU-B", "type": "consu"} + ) + + # Special products — look up the already-installed records from data XML + cls.shipping_product = cls.env.ref( + "zort_connector.product_shipping_fee" + ).product_variant_id + cls.discount_product = cls.env.ref( + "zort_connector.product_zort_discount" + ).product_variant_id + cls.voucher_product = cls.env.ref( + "zort_connector.voucher_amount_zort" + ).product_variant_id + + # Zort product mappings + cls.zp_a = cls.env["zort.product"].create( + { + "name": "Product A", + "id_zort_product": "SKU-A", + "product_id": cls.product_a.id, + } + ) + cls.zp_b = cls.env["zort.product"].create( + { + "name": "Product B", + "id_zort_product": "SKU-B", + "product_id": cls.product_b.id, + } + ) + + # A minimal zort.order record for calling instance methods + cls.zort_order = cls.env["zort.order"].create( + { + "zort_order_id": "PERF-TEST-001", + "zort_order_number": "TEST-001", + "zort_status": "new", + "source_channel": "shopee", + "raw_data": "{}", + } + ) + + # ------------------------------------------------------------------ + # Helper + # ------------------------------------------------------------------ + + def _zort_data(self, lines=None, shipping=0.0, discount=0.0, voucher=0.0): + """Return a minimal Zort order dict for testing.""" + if lines is None: + lines = [ + { + "productid": "SKU-A", + "number": 2, + "pricepernumber": 100.0, + "sku": "SKU-A", + }, + { + "productid": "SKU-B", + "number": 1, + "pricepernumber": 50.0, + "sku": "SKU-B", + }, + ] + return { + "list": lines, + "shippingamount": shipping, + "discount": discount, + "voucheramount": voucher, + } + + def test_get_customers_data_accepts_zort_order_dict(self): + """_get_customers_data must accept a pre-parsed dict.""" + zort_data = { + "customername": "Test Customer", + "customerphone": "0812345678", + "customeremail": "test@example.com", + "customeraddress": "Bangkok", + "customerprovince": "Bangkok", + "customerpostcode": "10400", + "customeridnumber": "", + } + # Calling with a dict parameter must not raise TypeError + try: + self.zort_order._get_customers_data(zort_data) + except TypeError as e: + self.fail(f"_get_customers_data raised TypeError: {e}") + + def test_create_customer_accepts_zort_order_dict(self): + """_create_customer must accept a pre-parsed dict (no internal json.loads).""" + zort_data = { + "customername": "Dict Customer", + "customerphone": "", + "customeremail": "", + "customeraddress": "", + "customerprovince": "", + "customerpostcode": "", + "customeridnumber": "", + } + partner = self.zort_order._create_customer(zort_data) + self.assertEqual(partner.name, "Dict Customer") + + def test_prepare_sale_order_lines_accepts_zort_order_dict(self): + """_prepare_sale_order_lines must accept a pre-parsed dict.""" + zort_data = self._zort_data() + commands = self.zort_order._prepare_sale_order_lines(zort_data) + self.assertEqual(len(commands), 2) # 2 lines, no shipping/discount/voucher diff --git a/zort_connector/views/product_product_view.xml b/zort_connector/views/product_product_view.xml new file mode 100644 index 00000000..425b6e5b --- /dev/null +++ b/zort_connector/views/product_product_view.xml @@ -0,0 +1,100 @@ + + + + + product.product.only.form.view.inherit + product.product + + + + + + + + + + + + + + + + + diff --git a/zort_connector/views/zort_ecommerce_channel_views.xml b/zort_connector/views/zort_ecommerce_channel_views.xml new file mode 100644 index 00000000..73b7e22a --- /dev/null +++ b/zort_connector/views/zort_ecommerce_channel_views.xml @@ -0,0 +1,104 @@ + + + + zort.ecommerce.channel.form + zort.ecommerce.channel + +
+ +
+ +
+ + + + + + + + + + + + +
+ + +
+
+ + + zort.ecommerce.channel.list + zort.ecommerce.channel + + + + + + + + + + + + + zort.ecommerce.channel.search + zort.ecommerce.channel + + + + + + + + + + + E-commerce Channels + zort.ecommerce.channel + list,form + + + +

+ Create your first e-commerce channel +

+
+
+ + + + + + +
diff --git a/zort_connector/views/zort_menu.xml b/zort_connector/views/zort_menu.xml new file mode 100644 index 00000000..8f6c51e4 --- /dev/null +++ b/zort_connector/views/zort_menu.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/zort_connector/views/zort_order_views.xml b/zort_connector/views/zort_order_views.xml new file mode 100644 index 00000000..2f2d5eae --- /dev/null +++ b/zort_connector/views/zort_order_views.xml @@ -0,0 +1,157 @@ + + + + zort.order.list + zort.order + + + + + + + + + + + + + + + + zort.order.form + zort.order + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + zort.order.search + zort.order + + + + + + + + + + + + + + + + + + + + Zort Orders + zort.order + list,form + + + {'search_default_state_new': 1} + +

+ No Zort orders yet. Run the sync to fetch orders from Zort. +

+
+
+ + +
diff --git a/zort_connector/views/zort_product_view.xml b/zort_connector/views/zort_product_view.xml new file mode 100644 index 00000000..7b9a44a4 --- /dev/null +++ b/zort_connector/views/zort_product_view.xml @@ -0,0 +1,76 @@ + + + + zort.product.search + zort.product + + + + + + + + + + zort.product.form + zort.product + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + zort.product.list + zort.product + + + + + + + + + + + + + + Zort Products + zort.product + list,form + + + + + + + +
diff --git a/zort_connector/views/zort_sync_log_views.xml b/zort_connector/views/zort_sync_log_views.xml new file mode 100644 index 00000000..6945dc81 --- /dev/null +++ b/zort_connector/views/zort_sync_log_views.xml @@ -0,0 +1,118 @@ + + + + zort.sync.log.list + zort.sync.log + + + + + + + + + + + + + + + + + zort.sync.log.form + zort.sync.log + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + +
+
+
+
+ + + zort.sync.log.search + zort.sync.log + + + + + + + + + + + + + Sync Logs + zort.sync.log + list,form + + + {'search_default_done': 0} + +

+ No sync logs yet. Logs are created automatically by the cron job. +

+
+
+ + +
diff --git a/zort_connector_mrp/README.rst b/zort_connector_mrp/README.rst new file mode 100644 index 00000000..0e64d4db --- /dev/null +++ b/zort_connector_mrp/README.rst @@ -0,0 +1,99 @@ +==================== +Zort Connector - MRP +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c074c354454514f09170d80e2cd1ebfb46901041241dd76fc9ebba998057ef77 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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_mrp + :alt: ecosoft-odoo/ecosoft-addons + +|badge1| |badge2| |badge3| + +Extends ``zort_connector`` with **Bill of Materials (BoM Kit)** support for +Zort stock synchronization. + +Without this module, ``zort_connector`` pushes the raw ``qty_available`` of +each product variant. When a product is assembled from components via an MRP +BoM Kit, the sellable quantity is determined by the kit's available stock - not +the finished-good's own on-hand quantity. This module ensures Zort always +receives the correct figure. + +**What it adds** + +- **BoM stock sync** - a daily scheduled action + (**Auto Update BOM Qty Available**) pushes the ``qty_available`` of every + BoM-linked product (where **Sync with Zort** is enabled) to Zort via + ``/Product/UpdateProductAvailableStockList``. +- **``Update Qty to Zort`` flag on BoM** - tracks whether a BoM record has + already been synced; only unsynced BoMs are included in each run. +- **MRP operation skip** - manufacturing consumption pickings + (``picking_type_code = mrp_operation``) are excluded from the automatic + per-picking stock sync inherited from ``zort_connector``, preventing + intermediate production moves from pushing incorrect stock figures. + +**Dependencies** + +- ``zort_connector`` (Ecosoft) +- ``mrp`` (Odoo Manufacturing) + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**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. \ +- Saran Lim. \<\> + +Maintainers +~~~~~~~~~~~ + +.. |maintainer-Saran440| image:: https://github.com/Saran440.png?size=40px + :target: https://github.com/Saran440 + :alt: Saran440 + +Current maintainer: + +|maintainer-Saran440| + +This module is part of the `ecosoft-odoo/ecosoft-addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/zort_connector_mrp/__init__.py b/zort_connector_mrp/__init__.py new file mode 100644 index 00000000..69f7babd --- /dev/null +++ b/zort_connector_mrp/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/zort_connector_mrp/__manifest__.py b/zort_connector_mrp/__manifest__.py new file mode 100644 index 00000000..009f9583 --- /dev/null +++ b/zort_connector_mrp/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2025 Ecosoft Co., Ltd (https://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Zort Connector - MRP", + "summary": "Process Zort with BoM Kit", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Ecosoft, Odoo Community Association (OCA)", + "website": "https://github.com/ecosoft-odoo/ecosoft-addons", + "depends": ["zort_connector", "mrp"], + "data": [ + "data/api_config_data.xml", + "data/ir_cron_data.xml", + "views/mrp_bom_views.xml", + ], + "development_status": "Alpha", + "maintainers": ["Saran440"], +} diff --git a/zort_connector_mrp/data/api_config_data.xml b/zort_connector_mrp/data/api_config_data.xml new file mode 100644 index 00000000..3ebc0d6f --- /dev/null +++ b/zort_connector_mrp/data/api_config_data.xml @@ -0,0 +1,28 @@ + + + + + Zort Update BOM Product Qty + zort_update_bom_qty + + rest_api + external + https://open-api.zortout.com/v4 + /Product/UpdateProductAvailableStockList + post + False + (lambda c: { + "storename": c.zort_store_name, + "apikey": c.zort_api_key, + "apisecret": c.zort_api_secret, +} if c.zort_connector_enabled else {})(env.company) + resCode + 200 + resDesc + True + {"warehousecode": env.company.zort_warehouse_code or "W0001"} + {"stocks": bom_stocks} + + diff --git a/zort_connector_mrp/data/ir_cron_data.xml b/zort_connector_mrp/data/ir_cron_data.xml new file mode 100644 index 00000000..dccdf057 --- /dev/null +++ b/zort_connector_mrp/data/ir_cron_data.xml @@ -0,0 +1,11 @@ + + + + Auto Update BOM Qty Available + + code + model.update_bom_qty_to_zort() + 1 + days + + diff --git a/zort_connector_mrp/models/__init__.py b/zort_connector_mrp/models/__init__.py new file mode 100644 index 00000000..06c82d29 --- /dev/null +++ b/zort_connector_mrp/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import mrp_bom +from . import stock_picking diff --git a/zort_connector_mrp/models/mrp_bom.py b/zort_connector_mrp/models/mrp_bom.py new file mode 100644 index 00000000..0178249b --- /dev/null +++ b/zort_connector_mrp/models/mrp_bom.py @@ -0,0 +1,61 @@ +# 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__) + +ZORT_UPDATE_BOM_QTY = "zort_update_bom_qty" + + +class MrpBom(models.Model): + _name = "mrp.bom" + _inherit = ["mrp.bom", "common.base.api"] + + qty_available = fields.Float( + related="product_tmpl_id.qty_available", + string="On Hand Quantity", + readonly=True, + ) + is_updated_qty_to_zort = fields.Boolean( + string="Update Qty to Zort", + default=False, + help="Indicates whether to update the BOM quantity to Zort.", + ) + + def _get_payload_globals_dict(self): + result = super()._get_payload_globals_dict() + boms = self.search([("product_tmpl_id.zort_product_id", "!=", False)]) + stocks_dict = {} + 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, + } + result["bom_stocks"] = list(stocks_dict.values()) + return result + + def _hook_update_data(self, code_api, result): + if code_api == ZORT_UPDATE_BOM_QTY: + _logger.info("Updated BOM products' available stock to Zort") + + @api.model + def update_bom_qty_to_zort(self): + """Update BOM quantity to Zort as the product's available quantity.""" + synced_templates = self.env["product.template"].search( + [("product_variant_ids.sync_with_zort", "=", True)] + ) + boms = self.search( + [ + ("product_tmpl_id", "in", synced_templates.ids), + ("is_updated_qty_to_zort", "=", False), + ] + ) + + if not boms: + return + return self.action_call_api(ZORT_UPDATE_BOM_QTY) diff --git a/zort_connector_mrp/models/stock_picking.py b/zort_connector_mrp/models/stock_picking.py new file mode 100644 index 00000000..a590b58d --- /dev/null +++ b/zort_connector_mrp/models/stock_picking.py @@ -0,0 +1,11 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + def _get_type_code_skip(self): + return super()._get_type_code_skip() + ["mrp_operation"] diff --git a/zort_connector_mrp/pyproject.toml b/zort_connector_mrp/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/zort_connector_mrp/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/zort_connector_mrp/readme/CONTRIBUTORS.rst b/zort_connector_mrp/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..55b14e67 --- /dev/null +++ b/zort_connector_mrp/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +- Theerayut A. \ +- Saran Lim. \<\> diff --git a/zort_connector_mrp/readme/DESCRIPTION.rst b/zort_connector_mrp/readme/DESCRIPTION.rst new file mode 100644 index 00000000..a74c48e0 --- /dev/null +++ b/zort_connector_mrp/readme/DESCRIPTION.rst @@ -0,0 +1,26 @@ +Extends ``zort_connector`` with **Bill of Materials (BoM Kit)** support for +Zort stock synchronization. + +Without this module, ``zort_connector`` pushes the raw ``qty_available`` of +each product variant. When a product is assembled from components via an MRP +BoM Kit, the sellable quantity is determined by the kit's available stock - not +the finished-good's own on-hand quantity. This module ensures Zort always +receives the correct figure. + +**What it adds** + +- **BoM stock sync** - a daily scheduled action + (**Auto Update BOM Qty Available**) pushes the ``qty_available`` of every + BoM-linked product (where **Sync with Zort** is enabled) to Zort via + ``/Product/UpdateProductAvailableStockList``. +- **``Update Qty to Zort`` flag on BoM** - tracks whether a BoM record has + already been synced; only unsynced BoMs are included in each run. +- **MRP operation skip** - manufacturing consumption pickings + (``picking_type_code = mrp_operation``) are excluded from the automatic + per-picking stock sync inherited from ``zort_connector``, preventing + intermediate production moves from pushing incorrect stock figures. + +**Dependencies** + +- ``zort_connector`` (Ecosoft) +- ``mrp`` (Odoo Manufacturing) diff --git a/zort_connector_mrp/static/description/index.html b/zort_connector_mrp/static/description/index.html new file mode 100644 index 00000000..42fddaf8 --- /dev/null +++ b/zort_connector_mrp/static/description/index.html @@ -0,0 +1,449 @@ + + + + + +Zort Connector - MRP + + + +
+

Zort Connector - MRP

+ + +

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

+

Extends zort_connector with Bill of Materials (BoM Kit) support for +Zort stock synchronization.

+

Without this module, zort_connector pushes the raw qty_available of +each product variant. When a product is assembled from components via an MRP +BoM Kit, the sellable quantity is determined by the kit’s available stock - not +the finished-good’s own on-hand quantity. This module ensures Zort always +receives the correct figure.

+

What it adds

+
    +
  • BoM stock sync - a daily scheduled action +(Auto Update BOM Qty Available) pushes the qty_available of every +BoM-linked product (where Sync with Zort is enabled) to Zort via +/Product/UpdateProductAvailableStockList.
  • +
  • ``Update Qty to Zort`` flag on BoM - tracks whether a BoM record has +already been synced; only unsynced BoMs are included in each run.
  • +
  • MRP operation skip - manufacturing consumption pickings +(picking_type_code = mrp_operation) are excluded from the automatic +per-picking stock sync inherited from zort_connector, preventing +intermediate production moves from pushing incorrect stock figures.
  • +
+

Dependencies

+
    +
  • zort_connector (Ecosoft)
  • +
  • mrp (Odoo Manufacturing)
  • +
+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

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:

+

Saran440

+

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

+

You are welcome to contribute.

+
+
+
+ + diff --git a/zort_connector_mrp/views/mrp_bom_views.xml b/zort_connector_mrp/views/mrp_bom_views.xml new file mode 100644 index 00000000..4d577422 --- /dev/null +++ b/zort_connector_mrp/views/mrp_bom_views.xml @@ -0,0 +1,16 @@ + + + + mrp.bom + + + + + + + + diff --git a/zort_connector_queue/README.rst b/zort_connector_queue/README.rst new file mode 100644 index 00000000..fd006446 --- /dev/null +++ b/zort_connector_queue/README.rst @@ -0,0 +1,97 @@ +==================== +Zort Connector Queue +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:e538629fc7b20839ae96e37201a05ee6e10d0b323d58bda49d4e9e949610ae6f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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_queue + :alt: ecosoft-odoo/ecosoft-addons + +|badge1| |badge2| |badge3| + +Extends ``zort_connector`` to process order synchronization and pending-order +conversion **asynchronously via queue jobs** (using ``queue_job_cron``). + +By default, ``zort_connector`` fetches and processes all Zort order pages +synchronously inside the cron transaction. For stores with a large number of +orders this can cause long-running transactions and cron timeouts. This module +offloads the heavy work to the OCA queue-job runner so each page is processed +in its own independent job. + +**What it adds** + +- **Async page dispatch** - overrides ``_dispatch_sync_page`` so that each + page of a Zort order sync run is enqueued as a separate queue job + (description: *"Zort sync page N/total"*). +- **Async pending-order batches** - overrides ``_dispatch_pending_orders_batch`` + so that each batch of pending ``zort.order`` records is enqueued as a + separate queue job. +- **Cron flag** - sets ``run_as_queue_job = True`` and + ``no_parallel_queue_job_run = True`` on the base **Auto Sync Zort Order** + cron, so the scheduler dispatches a queue job instead of running inline and + prevents duplicate concurrent runs. +- **Graceful fallback** - both dispatch overrides detect whether they are + already running inside a queue job (via ``job_uuid`` in context). If not, + they fall back to synchronous base behaviour, so the module is safe to + install even when the queue runner is temporarily stopped. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**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 +~~~~~~~~~~~~ + +- Saran Lim. \<\> + +Maintainers +~~~~~~~~~~~ + +.. |maintainer-Saran440| image:: https://github.com/Saran440.png?size=40px + :target: https://github.com/Saran440 + :alt: Saran440 + +Current maintainer: + +|maintainer-Saran440| + +This module is part of the `ecosoft-odoo/ecosoft-addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/zort_connector_queue/__init__.py b/zort_connector_queue/__init__.py new file mode 100644 index 00000000..69f7babd --- /dev/null +++ b/zort_connector_queue/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/zort_connector_queue/__manifest__.py b/zort_connector_queue/__manifest__.py new file mode 100644 index 00000000..dc33f59f --- /dev/null +++ b/zort_connector_queue/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Ecosoft Co., Ltd (https://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Zort Connector Queue", + "summary": "Process Zort sync logs via Queue Jobs", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Ecosoft, Odoo Community Association (OCA)", + "website": "https://github.com/ecosoft-odoo/ecosoft-addons", + "depends": ["zort_connector", "queue_job_cron"], + "data": [ + "data/ir_cron_data.xml", + ], + "development_status": "Alpha", + "maintainers": ["Saran440"], +} diff --git a/zort_connector_queue/data/ir_cron_data.xml b/zort_connector_queue/data/ir_cron_data.xml new file mode 100644 index 00000000..2aad9bd5 --- /dev/null +++ b/zort_connector_queue/data/ir_cron_data.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/zort_connector_queue/models/__init__.py b/zort_connector_queue/models/__init__.py new file mode 100644 index 00000000..504a4ba2 --- /dev/null +++ b/zort_connector_queue/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import zort_order diff --git a/zort_connector_queue/models/zort_order.py b/zort_connector_queue/models/zort_order.py new file mode 100644 index 00000000..e8bb4d89 --- /dev/null +++ b/zort_connector_queue/models/zort_order.py @@ -0,0 +1,73 @@ +# 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, models + + +class ZortOrder(models.Model): + _inherit = "zort.order" + + def _is_running_as_queue_job(self): + """Return True when the current execution context is a queue job. + + ``queue_job_cron`` dispatches a cron as a queue job when the cron's + ``run_as_queue_job`` flag is enabled. The queue-job runner always injects + ``job_uuid`` into the environment context, so this key being present means + we are already inside a job and should dispatch sub-work as jobs too. + """ + return bool(self.env.context.get("job_uuid")) + + @api.model + def _dispatch_sync_page( + self, + sync_log_id, + page, + total_pages, + base_payload, + sync_to_str, + page_result, + is_last_page, + ): + """Dispatch as a queue job only when called from a queue job. + + If the ir.cron that triggered this run has ``run_as_queue_job`` enabled, + ``job_uuid`` is present in context and each page becomes an async job. + Otherwise falls back to synchronous execution (base behaviour). + """ + if not self._is_running_as_queue_job(): + return super()._dispatch_sync_page( + sync_log_id=sync_log_id, + page=page, + total_pages=total_pages, + base_payload=base_payload, + sync_to_str=sync_to_str, + page_result=page_result, + is_last_page=is_last_page, + ) + desc = f"Zort sync page {page}/{total_pages}" + self.with_delay(description=desc)._process_sync_page( + sync_log_id=sync_log_id, + page=page, + total_pages=total_pages, + base_payload=base_payload, + sync_to_str=sync_to_str, + page_result=page_result, + is_last_page=is_last_page, + ) + + @api.model + def _dispatch_pending_orders_batch(self, record_ids): + """Override: dispatch as a queue job only when called from a queue job. + + If the ir.cron that triggered this run has ``run_as_queue_job`` enabled, + ``job_uuid`` is present in context and each batch becomes an async job. + Otherwise falls back to synchronous execution (base behaviour). + """ + if not self._is_running_as_queue_job(): + return super()._dispatch_pending_orders_batch(record_ids) + start = record_ids[0] if record_ids else "?" + end = record_ids[-1] if record_ids else "?" + desc = ( + f"Zort pending orders batch (ids {start}–{end}, {len(record_ids)} records)" + ) + self.with_delay(description=desc)._process_pending_orders_batch(record_ids) diff --git a/zort_connector_queue/pyproject.toml b/zort_connector_queue/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/zort_connector_queue/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/zort_connector_queue/readme/CONTRIBUTORS.rst b/zort_connector_queue/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..8d0d3315 --- /dev/null +++ b/zort_connector_queue/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +- Saran Lim. \<\> diff --git a/zort_connector_queue/readme/DESCRIPTION.rst b/zort_connector_queue/readme/DESCRIPTION.rst new file mode 100644 index 00000000..39f71ecd --- /dev/null +++ b/zort_connector_queue/readme/DESCRIPTION.rst @@ -0,0 +1,25 @@ +Extends ``zort_connector`` to process order synchronization and pending-order +conversion **asynchronously via queue jobs** (using ``queue_job_cron``). + +By default, ``zort_connector`` fetches and processes all Zort order pages +synchronously inside the cron transaction. For stores with a large number of +orders this can cause long-running transactions and cron timeouts. This module +offloads the heavy work to the OCA queue-job runner so each page is processed +in its own independent job. + +**What it adds** + +- **Async page dispatch** - overrides ``_dispatch_sync_page`` so that each + page of a Zort order sync run is enqueued as a separate queue job + (description: *"Zort sync page N/total"*). +- **Async pending-order batches** - overrides ``_dispatch_pending_orders_batch`` + so that each batch of pending ``zort.order`` records is enqueued as a + separate queue job. +- **Cron flag** - sets ``run_as_queue_job = True`` and + ``no_parallel_queue_job_run = True`` on the base **Auto Sync Zort Order** + cron, so the scheduler dispatches a queue job instead of running inline and + prevents duplicate concurrent runs. +- **Graceful fallback** - both dispatch overrides detect whether they are + already running inside a queue job (via ``job_uuid`` in context). If not, + they fall back to synchronous base behaviour, so the module is safe to + install even when the queue runner is temporarily stopped. diff --git a/zort_connector_queue/static/description/index.html b/zort_connector_queue/static/description/index.html new file mode 100644 index 00000000..7497ebf7 --- /dev/null +++ b/zort_connector_queue/static/description/index.html @@ -0,0 +1,447 @@ + + + + + +Zort Connector Queue + + + +
+

Zort Connector Queue

+ + +

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

+

Extends zort_connector to process order synchronization and pending-order +conversion asynchronously via queue jobs (using queue_job_cron).

+

By default, zort_connector fetches and processes all Zort order pages +synchronously inside the cron transaction. For stores with a large number of +orders this can cause long-running transactions and cron timeouts. This module +offloads the heavy work to the OCA queue-job runner so each page is processed +in its own independent job.

+

What it adds

+
    +
  • Async page dispatch - overrides _dispatch_sync_page so that each +page of a Zort order sync run is enqueued as a separate queue job +(description: “Zort sync page N/total”).
  • +
  • Async pending-order batches - overrides _dispatch_pending_orders_batch +so that each batch of pending zort.order records is enqueued as a +separate queue job.
  • +
  • Cron flag - sets run_as_queue_job = True and +no_parallel_queue_job_run = True on the base Auto Sync Zort Order +cron, so the scheduler dispatches a queue job instead of running inline and +prevents duplicate concurrent runs.
  • +
  • Graceful fallback - both dispatch overrides detect whether they are +already running inside a queue job (via job_uuid in context). If not, +they fall back to synchronous base behaviour, so the module is safe to +install even when the queue runner is temporarily stopped.
  • +
+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

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:

+

Saran440

+

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

+

You are welcome to contribute.

+
+
+
+ +