diff --git a/zort_connector/README.rst b/zort_connector/README.rst new file mode 100644 index 00000000..91cdcbcf --- /dev/null +++ b/zort_connector/README.rst @@ -0,0 +1,173 @@ +============== +Zort Connector +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:9a7fb265c67b3fca2819f2ebc2ffadda7683e67b56116044131ca90655c85d64 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-ecosoft--odoo%2Fecosoft--addons-lightgray.png?logo=github + :target: https://github.com/ecosoft-odoo/ecosoft-addons/tree/18.0/zort_connector + :alt: ecosoft-odoo/ecosoft-addons + +|badge1| |badge2| |badge3| + + +========================================== +คู่มือการใช้งานและพัฒนาโมดูล zort_connector +========================================== + +ภาพรวม +============================== + +โมดูล ``zort_connector`` สำหรับ Odoo นี้ถูกออกแบบมาเพื่อเชื่อมต่อกับระบบ ZORT ผ่าน API โดยมีการทำงานหลักผ่าน Scheduled Action (Cron Job) และมีการกำหนด Action ต่าง ๆ เพื่อซิงค์ข้อมูลระหว่าง Odoo และ ZORT + +การเริ่มต้นทำงานของระบบ +===================== + +ระบบจะเริ่มต้นทำงานโดยอัตโนมัติผ่าน Scheduled Action (Cron Job) ที่กำหนดไว้ในไฟล์ ``data/ir_cron_data.xml`` โดยแต่ละ Action จะถูกตั้งเวลาให้ทำงานตามรอบที่กำหนด เช่น ทุก ๆ 5 นาที หรือทุก ๆ 1 ชั่วโมง + +ตัวอย่าง Scheduled Action ที่มีในระบบ +------------------------------------ + +- **ซิงค์สินค้า (Sync Product)** + - ดึงข้อมูลสินค้าใหม่/อัปเดตจาก ZORT เข้ามาใน Odoo +- **ซิงค์ออเดอร์ (Sync Sale Order)** + - ดึงข้อมูลออเดอร์ใหม่จาก ZORT +- **ซิงค์สต็อก (Sync Stock Picking)** + - อัปเดตสถานะสต็อกจาก Odoo ไปยัง ZORT +- **ซิงค์พาร์ทเนอร์ (Sync Partner)** + - ดึงข้อมูลลูกค้า/ซัพพลายเออร์ +- **ซิงค์ BOM (Sync BOM)** + - ดึงข้อมูล BOM จาก ZORT + +Action เหล่านี้สามารถดูรายละเอียดและแก้ไขได้ที่ไฟล์ ``data/ir_cron_data.xml`` และโค้ดที่เกี่ยวข้องในโฟลเดอร์ ``models/`` + +การทำงานของแต่ละ Action +============================== + +แต่ละ Action จะมีฟังก์ชันหลักในไฟล์ Python ที่เกี่ยวข้อง เช่น +- ``models/product_product.py`` สำหรับซิงค์สินค้า +- ``models/sale_order.py`` สำหรับซิงค์ออเดอร์ +- ``models/stock_picking.py`` สำหรับซิงค์สต็อก +- ``models/partner.py`` สำหรับซิงค์พาร์ทเนอร์ +- ``models/mrp_bom.py`` สำหรับซิงค์ BOM + +โดยแต่ละฟังก์ชันจะเรียกใช้งาน API ผ่านโมดูล ``zort_api`` + +API Specification +================= + +รายละเอียดของ API ที่ใช้เชื่อมต่อกับ ZORT ถูกกำหนดไว้ในโฟลเดอร์ ``zort_api/`` โดยเฉพาะไฟล์ ``zort_api/zort_api.py`` ซึ่งจะมีฟังก์ชันสำหรับเรียกใช้งาน API ของ ZORT เช่น +- การดึงข้อมูลสินค้า +- การดึงข้อมูลออเดอร์ +- การอัปเดตสต็อก +- การดึงข้อมูล BOM + +หากต้องการแก้ไขหรือเพิ่ม endpoint ใหม่ ให้แก้ไขที่ไฟล์นี้ และควรเขียน docstring อธิบายแต่ละฟังก์ชันให้ชัดเจน + +แนวทางสำหรับ Developer +======================== + +1. **เพิ่ม/แก้ไข Action** + - เพิ่ม/แก้ไข cron job ที่ไฟล์ ``data/ir_cron_data.xml`` + - เขียนฟังก์ชันในไฟล์ Python ที่เกี่ยวข้องใน ``models/`` +2. **ปรับปรุง API** + - แก้ไขหรือเพิ่มฟังก์ชันใน ``zort_api/zort_api.py`` + - ตรวจสอบและทดสอบการเชื่อมต่อกับ ZORT +3. **การตั้งค่า** + - สามารถตั้งค่าการเชื่อมต่อ (API Key, URL ฯลฯ) ได้ที่เมนูตั้งค่าของ Odoo หรือไฟล์ ``data/ir_config_parameter_data.xml`` +4. **การทดสอบ** + - ทดสอบการทำงานของแต่ละ Action โดยดู log หรือผลลัพธ์ใน Odoo + +อธิบายโครงสร้างข้อมูลสำคัญ +=========================== + +Zort Product +------------ + +``zort.product`` คือโมเดลที่ใช้เก็บข้อมูลสินค้าในฝั่ง ZORT โดยจะมีฟิลด์ ``id_zort_product`` ซึ่งเป็นรหัสอ้างอิงสินค้าจาก ZORT (Zort Product ID) และมีฟิลด์ ``product_id`` ที่ผูกกับสินค้าใน Odoo (``product.product``) อีกที + +**เหตุผลที่ต้องมี zort.product** + +- ZORT มีรหัสสินค้า (id_zort_product) ที่ไม่ซ้ำกับ SKU และ SKU อาจซ้ำกันหรือเปลี่ยนแปลงได้ +- การซิงค์ออเดอร์จาก ZORT เข้ามาใน Odoo จะใช้ id_zort_product ในการจับคู่กับสินค้าใน Odoo ได้แม่นยำกว่าการใช้ SKU +- ช่วยให้การ mapping ข้อมูลระหว่าง 2 ระบบถูกต้อง แม้ SKU จะไม่ unique หรือเปลี่ยนแปลงได้ + +Zort eCommerce Channel +---------------------- + +``zort.ecommerce.channel`` คือโมเดลที่ใช้เก็บข้อมูลช่องทางการขาย (eCommerce Channel) ที่เชื่อมต่อกับ ZORT เช่น Lazada, Shopee, Facebook เป็นต้น + +**วัตถุประสงค์** + +- ใช้เก็บการตั้งค่าของแต่ละช่องทาง เช่น รหัส channel (``code``), ลูกค้า platform (``partner_id``), การสร้างลูกค้าอัตโนมัติ (``auto_create_customer``) +- ใช้สำหรับ mapping ข้อมูล order ที่มาจากแต่ละช่องทาง เพื่อให้สามารถแยกจัดการและตั้งค่าพิเศษได้ในแต่ละ channel +- รองรับการขยายช่องทางขายใหม่ ๆ ในอนาคต + +สรุป +==== + +- ระบบเริ่มทำงานด้วย Scheduled Action (Cron Job) +- Action หลัก ๆ ได้แก่ ซิงค์สินค้า ออเดอร์ สต็อก พาร์ทเนอร์ BOM +- มีโครงสร้างข้อมูลสำคัญ ได้แก่ zort.product (สำหรับ mapping สินค้า) และ zort.ecommerce.channel (สำหรับจัดการช่องทางขาย) +- API spec และโค้ดการเชื่อมต่ออยู่ที่ ``zort_api/zort_api.py`` +- Developer สามารถต่อยอดหรือแก้ไขได้ตามแนวทางข้างต้น + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +======= + + +Credits +======= + +Authors +------- + +* Ecosoft + + +Contributors +------------ + +- Theerayut A. + + +Maintainers +----------- + +.. |maintainer-theerayuta@ecosoft.co.th| image:: https://github.com/theerayuta@ecosoft.co.th.png?size=40px + :target: https://github.com/theerayuta@ecosoft.co.th + :alt: theerayuta@ecosoft.co.th + +Current maintainer: + +|maintainer-theerayuta@ecosoft.co.th| + +This module is part of the `ecosoft-odoo/ecosoft-addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/zort_connector/__init__.py b/zort_connector/__init__.py new file mode 100644 index 00000000..b597d19a --- /dev/null +++ b/zort_connector/__init__.py @@ -0,0 +1,3 @@ +from . import zort_api +from . import controllers +from . import models diff --git a/zort_connector/__manifest__.py b/zort_connector/__manifest__.py new file mode 100644 index 00000000..3d816040 --- /dev/null +++ b/zort_connector/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2025 Ecosoft Co., Ltd (https://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Zort Connector", + "summary": "Connects Odoo with Zort", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Ecosoft, Odoo Community Association (OCA)", + "maintainers": ["theerayuta@ecosoft.co.th"], + "website": "https://github.com/ecosoft-odoo/ecosoft-addons", + "depends": ["stock", "sale_management", "mrp"], + "data": [ + "security/ir.model.access.csv", + "data/ir_actions_server_data.xml", + "data/ir_config_parameter_data.xml", + "data/ir_cron_data.xml", + "data/partner_data.xml", + "data/product_data.xml", + "views/res_config_settings_view.xml", + "views/sale_order_view.xml", + "views/stock_picking_view.xml", + "views/zort_ecommerce_channel_views.xml", + "views/mrp_bom_views.xml", + "views/zort_product_view.xml", + "views/product_product_view.xml", + ], +} diff --git a/zort_connector/controllers/__init__.py b/zort_connector/controllers/__init__.py new file mode 100644 index 00000000..e046e49f --- /dev/null +++ b/zort_connector/controllers/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/zort_connector/controllers/controllers.py b/zort_connector/controllers/controllers.py new file mode 100644 index 00000000..4e3c069d --- /dev/null +++ b/zort_connector/controllers/controllers.py @@ -0,0 +1,36 @@ +from odoo import http +from odoo.http import Response, request + + +class ZortConnector(http.Controller): + @http.route( + "/zort_connector/view_zort_order_json/", + type="http", + auth="user", + ) + def view_zort_order_json(self, sale_order_id, **kwargs): + sale_order = request.env["sale.order"].sudo().browse(sale_order_id) + if not sale_order.exists(): + return Response("Sale Order not found", status=404) + import json + + return Response( + json.dumps(sale_order.zort_order_data, indent=2, ensure_ascii=False), + mimetype="application/json", + ) + + @http.route( + "/zort_connector/view_zort_return_order_json/", + type="http", + auth="user", + ) + def view_zort_return_order_json(self, picking_id, **kwargs): + picking = request.env["stock.picking"].sudo().browse(picking_id) + if not picking.exists(): + return Response("Picking not found", status=404) + import json + + return Response( + json.dumps(picking.zort_return_data, indent=2, ensure_ascii=False), + mimetype="application/json", + ) diff --git a/zort_connector/data/ir_actions_server_data.xml b/zort_connector/data/ir_actions_server_data.xml new file mode 100644 index 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..429cbb7a --- /dev/null +++ b/zort_connector/data/ir_config_parameter_data.xml @@ -0,0 +1,11 @@ + + + + zort_connector.limit_timeout + 10 + + + zort_connector.order_sync_days_back + 10 + + diff --git a/zort_connector/data/ir_cron_data.xml b/zort_connector/data/ir_cron_data.xml new file mode 100644 index 00000000..0db4959d --- /dev/null +++ b/zort_connector/data/ir_cron_data.xml @@ -0,0 +1,28 @@ + + + + + Auto Sync Zort Order + + code + model.process_sales_order_from_zort() + 10 + minutes + + + Auto Sync Zort Return Order + + code + model.action_create_return_picking() + 10 + minutes + + + Auto Update BOM Qty Available + + code + model.update_bom_qty_to_zort() + 1 + days + + diff --git a/zort_connector/data/partner_data.xml b/zort_connector/data/partner_data.xml new file mode 100644 index 00000000..14b7f316 --- /dev/null +++ b/zort_connector/data/partner_data.xml @@ -0,0 +1,23 @@ + + + + Marketplace Customer + + company + + + Shoppee Customer + + company + + + Lazada Customer + + company + + + Tiktok Customer + + company + + diff --git a/zort_connector/data/product_data.xml b/zort_connector/data/product_data.xml new file mode 100644 index 00000000..c3dc67cc --- /dev/null +++ b/zort_connector/data/product_data.xml @@ -0,0 +1,33 @@ + + + + Shipping Fee + service + + + 0.0 + 0.0 + shipping_fee + order + + + Discount + service + + + 0.0 + 0.0 + zort_discount + order + + + Zort Voucher + service + + + 0.0 + 0.0 + zort_voucher + order + + diff --git a/zort_connector/models/__init__.py b/zort_connector/models/__init__.py new file mode 100644 index 00000000..abf8e86e --- /dev/null +++ b/zort_connector/models/__init__.py @@ -0,0 +1,8 @@ +from . import ecommerce_channel +from . import mrp_bom +from . import product_product +from . import res_company +from . import res_config_settings +from . import sale_order +from . import stock_picking +from . import zort_product diff --git a/zort_connector/models/ecommerce_channel.py b/zort_connector/models/ecommerce_channel.py new file mode 100644 index 00000000..e4526454 --- /dev/null +++ b/zort_connector/models/ecommerce_channel.py @@ -0,0 +1,67 @@ +# 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" + 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 for this eCommerce channel", + ) + auto_create_customer = fields.Boolean( + help="Auto create customer for save customer data from this eCommerce channel", + default=False, + ) + + _sql_constraints = [ + ( + "code_uniq", + "unique(code)", + "The code of the eCommerce channel must be unique!", + ) + ] + + def open_ecommerce_config_form(self): + """Open eCommerce configuration form""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("E-commerce Channel"), + "res_model": "zort.ecommerce.channel", + "view_mode": "form", + "res_id": self.id, + "target": "current", + } diff --git a/zort_connector/models/mrp_bom.py b/zort_connector/models/mrp_bom.py new file mode 100644 index 00000000..42789ec2 --- /dev/null +++ b/zort_connector/models/mrp_bom.py @@ -0,0 +1,81 @@ +# Copyright 2025 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class MrpBom(models.Model): + _name = "mrp.bom" + _inherit = ["mrp.bom", "zort.api"] + + qty_available = fields.Float( + related="product_tmpl_id.qty_available", + string="On Hand Quantity", + readonly=True, + ) + is_updated_qty_to_zort = fields.Boolean( + string="Update Qty to Zort", + default=False, + help="Indicates whether to update the BOM quantity to Zort.", + ) + + @api.model + def update_bom_qty_to_zort(self): + """Update BOM quantity to Zort as the product's available quantity.""" + + warehousecode = self.env.company.zort_warehouse_code or "W0001" + + stocks_dict = {} + 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), + ] + ) + + for bom in boms: + product_id = bom.product_tmpl_id.product_variant_ids[0].id + zort_product = self.env["zort.product"].search( + [("product_id", "=", product_id)] + ) + if product_id not in stocks_dict: + stocks_dict[product_id] = { + "productid": zort_product.id_zort_product, + "stock": bom.qty_available, + } + + # Convert dictionary values to list + all_stocks = list(stocks_dict.values()) + + if all_stocks: + data = {"stocks": all_stocks} + try: + response = self._update_product_available_stock_list( + warehousecode, data + ) + + if "error" in response: + _logger.error( + "API error updating products' available stock to Zort: %s", + response["error"], + ) + return False + else: + _logger.info( + "Updated %d products' available stock to Zort", len(all_stocks) + ) + boms.write({"is_updated_qty_to_zort": True}) + return True + + except Exception as e: + _logger.error( + "Failed to update products' available stock to Zort: %s", str(e) + ) + return False diff --git a/zort_connector/models/product_product.py b/zort_connector/models/product_product.py new file mode 100644 index 00000000..a65932c2 --- /dev/null +++ b/zort_connector/models/product_product.py @@ -0,0 +1,212 @@ +# Copyright 2025 Ecosoft Co., Ltd (https://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +import base64 +import json +import logging + +import requests +from markupsafe import Markup + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class ProductProduct(models.Model): + _name = "product.product" + _inherit = ["product.product", "zort.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 action_fetch_and_update_image_from_zort(self): + """Fetch and update product images from Zort.""" + self.ensure_one() + if not self.zort_product_ids: + return + zort_product = self.zort_product_ids[0] + if not zort_product.image_url: + return + + try: + response = requests.get(zort_product.image_url, timeout=10) + response.raise_for_status() + + image_data = base64.b64encode(response.content) + self.image_1920 = image_data + _logger.info( + "Successfully updated image for product %s from Zort.", + self.display_name, + ) + + except requests.RequestException as e: + _logger.error( + "Failed to fetch image from Zort for product %s: %s", + self.display_name, + str(e), + ) + + def _add_lognote_and_reload(self, title: str, message: str, data: dict): + """Add a log note to the chatter and return reload action.""" + formatted_data = json.dumps(data, indent=2, ensure_ascii=False) + message = Markup(f"{title}: {message}
{formatted_data}
") + self.message_post(body=message) + return { + "type": "ir.actions.client", + "tag": "reload", + } + + def action_create_product_on_zort(self): + """Create product in Zort based on the current product variant.""" + self.ensure_one() + + if not self.sync_with_zort: + return + + data = { + "sku": self.default_code, + "name": self.name, + "sellprice": self.list_price, + "purchaseprice": self.standard_price, + "unittext": self.uom_name, + # "weight": self.weight, # we can uncomment later + # "sell_vat_status": 0, # we can uncomment later + # "purchase_vat_status": 0 # we can uncomment later + } + response = self._add_product(data) + + if response.get("error"): + _logger.error("Error creating product in Zort: %s", response.get("error")) + return self._add_lognote_and_reload( + title="Error", + message="Failed to create product on Zort: {}".format( + response.get("error") + ), + data=data, + ) + elif response.get("resCode") != "200": + _logger.error( + "Error creating product in Zort: %s", + response.get("resDesc", "Unknown error"), + ) + return self._add_lognote_and_reload( + title="Error", + message=( + "Failed to create product on Zort: {}\n" + "Please check on Zort, the product may have already been created.\n" + "If the product was created, you should update the Zort Product " + "with the product ID from Zort." + ).format(response.get("resDesc", "Unknown error")), + data=data, + ) + + # If api call is successful "resDesc" will contain the product ID in Zort + if response.get("resDesc", ""): + # Create zort.product record + self.env["zort.product"].create( + { + "product_id": self.id, + "id_zort_product": response.get("resDesc", ""), + "sku": self.default_code, + "unittext": self.uom_name, + "name": self.name, + } + ) + + return self._add_lognote_and_reload( + title="Success", message="Product created successfully on Zort.", data=data + ) + + def action_update_product_to_zort(self): + """Update product in Zort based on the current product variant.""" + self.ensure_one() + + data = { + "name": self.name, + "sellprice": self.list_price, + "purchaseprice": self.standard_price, + "unittext": self.uom_name, + # "weight": self.weight, // we can uncomment later + # "sell_vat_status": 0, // we can uncomment later + # "purchase_vat_status": 0 // we can uncomment later + } + zort_product = self.env["zort.product"].search( + [("product_id", "=", self.id)], limit=1 + ) + if not zort_product: + return + + response = self._update_product(zort_product.id_zort_product, data) + + if response.get("error"): + _logger.error("Error updating product in Zort: %s", response.get("error")) + return self._add_lognote_and_reload( + title="Error", + message="Failed to update product on Zort: {}".format( + response.get("error") + ), + data=data, + ) + + return self._add_lognote_and_reload( + title="Success", message="Product updated successfully on Zort.", data=data + ) + + def action_update_qty_to_zort(self): + self.ensure_one() + + data = { + "stocks": [ + { + "sku": self.default_code, + "stock": self.qty_available, + } + ] + } + warehousecode = self.env.company.zort_warehouse_code or "W0001" + response = self._update_product_available_stock_list( + warehousecode=warehousecode, data=data + ) + + if response.get("error"): + _logger.error( + "Error updating stock in Zort: %s", + response.get("error"), + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": "ZORT", + "message": "Failed to update stock.", + "type": "danger", + "sticky": False, + }, + } + + _logger.info( + "Stock updated successfully on Zort: %s", + response, + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": "ZORT", + "message": "Stock updated successfully.", + "type": "success", + "sticky": False, + }, + } diff --git a/zort_connector/models/res_company.py b/zort_connector/models/res_company.py new file mode 100644 index 00000000..a0150eb7 --- /dev/null +++ b/zort_connector/models/res_company.py @@ -0,0 +1,25 @@ +# 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_endpoint_url = fields.Char( + default="https://open-api.zortout.com/v4", + ) + 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..ba15a612 --- /dev/null +++ b/zort_connector/models/res_config_settings.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 api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + zort_connector_enabled = fields.Boolean( + help="Enable the Zort Connector to connect Odoo with Zort.", + related="company_id.zort_connector_enabled", + readonly=False, + ) + zort_endpoint_url = fields.Char( + help="The URL of the Zort endpoint to connect with.", + related="company_id.zort_endpoint_url", + readonly=False, + ) + zort_api_key = fields.Char( + help="The API key to authenticate with the Zort endpoint.", + related="company_id.zort_api_key", + readonly=False, + ) + zort_api_secret = fields.Char( + help="The API secret to authenticate with the Zort endpoint.", + related="company_id.zort_api_secret", + readonly=False, + ) + zort_store_name = fields.Char( + help="The name of the store in Zort.", + related="company_id.zort_store_name", + readonly=False, + ) + zort_warehouse_code = fields.Char( + help="The warehouse code in Zort.", + related="company_id.zort_warehouse_code", + readonly=False, + ) + zort_default_tax_id = fields.Many2one( + comodel_name="account.tax", + help="Default tax to apply for Zort products.", + related="company_id.zort_default_tax_id", + readonly=False, + ) + + @api.onchange("zort_connector_enabled") + def _onchange_zort_connector_enabled(self): + if not self.zort_connector_enabled: + self.company_id.zort_endpoint_url = "" + 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..944afc6f --- /dev/null +++ b/zort_connector/models/sale_order.py @@ -0,0 +1,648 @@ +# Copyright 2025 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +import logging +import time +from datetime import datetime, timedelta + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _name = "sale.order" + _inherit = ["sale.order", "zort.api"] + + # This field can be used to store the Zort order ID + is_zort_order = fields.Boolean( + help="Indicates if this sale order is created from Zort.", + default=False, + readonly=True, + index=True, + ) + zort_order_id = fields.Char( + help="The ID of the order in Zort.", + copy=False, + readonly=True, + index=True, + ) + zort_order_number = fields.Char( + help="The order number in Zort.", + copy=False, + readonly=True, + index=True, + ) + zort_order_status = fields.Char( + help="The status of the order in Zort.", + copy=False, + readonly=True, + ) + zort_payment_status = fields.Char( + help="The payment status of the order in Zort.", + copy=False, + readonly=True, + ) + zort_order_data = fields.Json( + help="The raw order data fetched from Zort.", + copy=False, + readonly=True, + ) + zort_sales_channel = fields.Char( + help="The sales channel of the order in Zort.", + copy=False, + readonly=True, + ) + zort_customer_id = fields.Many2one( + "res.partner", + "e-Commerce Customer", + copy=False, + readonly=True, + ) + 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, + ) + + @api.model + def process_sales_order_from_zort(self, status="0", orderidlist="", numberlist=""): + """ + Create or update sale orders in Odoo based on data from Zort. + + This method: + - Updates existing sale orders that match Zort order IDs + - Creates new sale orders from Zort data + + Args: + status (str, optional): Zort order status filter for fetching new orders. + orderidlist (str, optional): Comma-separated Zort order IDs to process. + numberlist (str, optional): Comma-separated Zort order numbers to process. + + Returns: + bool: True if the operation completes successfully. + """ + # Update existing orders + self._update_existing_zort_orders() + + # Create new orders + self._create_new_zort_orders(status, orderidlist, numberlist) + + return True + + def _update_existing_zort_orders(self): + """Update existing sale orders with latest data from Zort.""" + zort_ids = self._get_existing_zort_order_ids() + if not zort_ids: + return + + page = 1 + limit = 500 + max_pages = 500 # Safety limit to prevent infinite loops + while page <= max_pages: + res = self._get_list_order( + status="", orderidlist=zort_ids, page=page, limit=limit + ) + # TODO: Handle API errors properly + # on this point, we just break the loop + if res.get("error"): + _logger.error( + "Error fetching orders from Zort: %s", + res.get("error"), + ) + break + orders = res.get("list", []) + if not orders: + break + + for order in orders: + try: + so = self.search([("zort_order_id", "=", order.get("id"))], limit=1) + if so: + self._update_sale_order_from_zort(so, order) + except Exception as e: + _logger.error("Error updating existing sale order from Zort: %s", e) + page += 1 + time.sleep(5) + + def _update_sale_order_from_zort(self, sale_order, zort_order): + """Update a single sale order with Zort data and handle status changes.""" + # Update order data + sale_order.write( + { + "zort_order_number": zort_order.get("number"), + "zort_order_status": zort_order.get("status"), + "zort_payment_status": zort_order.get("paymentstatus"), + "zort_sales_channel": zort_order.get("saleschannel"), + "zort_order_data": zort_order, + } + ) + sale_order.validate_order_from_zort() + _logger.info("Updated Sale Order: %s", sale_order.name) + + # Handle status-based actions + self._handle_zort_order_status(sale_order, zort_order.get("status")) + + def _handle_zort_order_status(self, sale_order, zort_status): + """Handle order workflow based on Zort order status.""" + if zort_status == "Voided": + sale_order.with_context(disable_cancel_warning=True).action_cancel() + elif zort_status == "Success": + if sale_order.validate_zort_order: + self._process_success_order(sale_order) + + def _process_success_order(self, sale_order): + """Process order when Zort status is 'Success'.""" + # Confirm order if not already confirmed + if sale_order.state not in ["sale", "cancel"]: + sale_order.action_confirm() + + # Validate deliveries + for picking in sale_order.picking_ids: + if picking.state not in ["done", "cancel"]: + picking.button_validate() + _logger.info("Delivery order done for Sale Order: %s", sale_order.name) + + # Force recomputation of delivered quantities + sale_order.order_line.invalidate_recordset(["qty_delivered"]) + sale_order.order_line._compute_qty_delivered() + + # Create draft invoice + self._create_draft_invoice(sale_order) + + def _create_draft_invoice(self, sale_order): + """Create draft invoice for the sale order.""" + invoice_wizard = ( + self.env["sale.advance.payment.inv"] + .with_context(active_ids=sale_order.ids, active_id=sale_order.id) + .create({"advance_payment_method": "delivered"}) + ) + invoice_wizard.create_invoices() + _logger.info("Draft invoice created for Sale Order: %s", sale_order.name) + + def _create_new_zort_orders(self, status, orderidlist, numberlist): + """Create new sale orders from Zort data with exponential backoff.""" + _logger.info("Creating Sale Order from Zort...") + + zort_ids = [ + int(id) + for id in self._get_existing_zort_order_ids().split(",") + if id.strip() + ] + + page = 1 + limit = 500 + orderdateafter = self.get_order_date_after() + max_pages = 500 # Safety limit to prevent infinite loops + while page <= max_pages: + res = self._get_list_order( + status=status, + orderidlist=orderidlist, + numberlist=numberlist, + page=page, + limit=limit, + orderdateafter=orderdateafter, + ) + # TODO: Handle API errors properly + # on this point, we just break the loop + if res.get("error"): + _logger.error( + "Error fetching orders from Zort: %s", + res.get("error"), + ) + break + + orders = res.get("list", []) + if not orders: + break + + for order in orders: + try: + if order.get("id") in zort_ids: + continue + self._create_single_sale_order(order) + _logger.info( + "Successfully created Sale Order from Zort order %s", + order.get("id"), + ) + except Exception as e: + _logger.error( + "Error creating sale order from Zort order %s: %s", + order.get("id"), + e, + ) + page += 1 + time.sleep(5) + + def _create_single_sale_order(self, zort_order): + """Create a single sale order from Zort order data.""" + # Prepare order data + order_data = { + "partner_id": self._get_marketplace_customer(zort_order), + "is_zort_order": True, + "zort_order_id": zort_order.get("id"), + "zort_order_number": zort_order.get("number"), + "zort_order_status": zort_order.get("status"), + "zort_payment_status": zort_order.get("paymentstatus"), + "zort_sales_channel": zort_order.get("saleschannel"), + "zort_order_data": zort_order, + "zort_customer_id": self._get_platform_customer(zort_order), + } + + # Create the sale order + sale_order = self.create(order_data) + + # Add order lines + order_lines = self._prepare_order_lines(zort_order) + if order_lines: + sale_order.order_line = order_lines + + # validate sale order first + sale_order.validate_order_from_zort() + if sale_order.validate_zort_order: + sale_order.action_confirm() + _logger.info("Created Sale Order: %s", sale_order.name) + + @staticmethod + def hook_process_sku(sku): + """ + Hook to process SKU before fetching product. + Override in custom modules to modify SKU format if needed. + Example: Remove suffix after '#' (e.g., 'a-1234#left' -> 'a-1234'). + """ + return sku + + def get_product_by_sku(self, sku): + """ + Fetch product by SKU (default_code). + """ + id = self.hook_process_sku(sku) + zort_product = self.env["zort.product"].search( + [("id_zort_product", "=", id)], limit=1 + ) + if zort_product and zort_product.product_id: + return zort_product.product_id + return self.env["product.product"] + + def _prepare_order_lines(self, zort_order): + """ + Prepare order lines from Zort order data.""" + order_lines = [] + # Add product lines + for line in zort_order.get("list", []): + # Instead of send sku, send zort product id. + 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 + + company = self.env.company + order_lines.append( + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": line.get("number", 1), + "price_unit": line.get("pricepernumber", 0.0), + "name": product.name, + "tax_id": [(6, 0, company.zort_default_tax_id.ids)], + }, + ) + ) + + # Add shipping fee if exists + shipping_amount = zort_order.get("shippingamount", 0.0) + if shipping_amount > 0: + order_lines.append(self._add_shipping_fee_line(shipping_amount)) + + # Add discount if exists + discount = zort_order.get("discount", 0.0) + if discount and isinstance(discount, str): + discount = float(discount) + order_lines.append(self._add_discount_line(discount)) + + # Add voucher_amount if exists + voucher_amount = zort_order.get("voucheramount", 0.0) + if voucher_amount != 0: + order_lines.append(self._add_voucher_line(voucher_amount)) + + return order_lines + + def _add_shipping_fee_line(self, shipping_amount): + """Add a shipping fee line to the sale order.""" + shipping_fee_product = self.env["product.product"].search( + [("default_code", "=", "shipping_fee")], limit=1 + ) + return ( + 0, + 0, + { + "product_id": shipping_fee_product.id, + "product_uom_qty": 1, + "price_unit": shipping_amount, + "name": "Shipping Fee", + }, + ) + + def _add_discount_line(self, discount): + """Add a discount line to the sale order.""" + discount_product = self.env["product.product"].search( + [("default_code", "=", "zort_discount")], limit=1 + ) + if isinstance(discount, str): + discount = float(discount) + + return ( + 0, + 0, + { + "product_id": discount_product.id, + "product_uom_qty": 1, + "price_unit": -discount, + "name": "Discount", + }, + ) + + def _add_voucher_line(self, voucher_amount): + """Add a voucher line to the sale order.""" + voucher_product = self.env["product.product"].search( + [("default_code", "=", "zort_voucher")], limit=1 + ) + return ( + 0, + 0, + { + "product_id": voucher_product.id, + "product_uom_qty": 1, + "price_unit": voucher_amount, + "name": "Voucher", + }, + ) + + def _get_existing_zort_order_ids(self): + """ + Get existing Zort orders (id) in draft, sent, or sale state + Return as comma-separated string Ex. "1234,5678,91011" + """ + days_back = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("zort_connector.order_sync_days_back", default="10") + ) + try: + days = int(days_back) + if days <= 0: + days = 10 + except (TypeError, ValueError): + days = 10 + orderdateafter = datetime.now() - timedelta(days=days) + zort_orders = self.search( + [ + ("is_zort_order", "=", True), + ("state", "in", ["draft", "sent", "sale"]), + ("date_order", ">=", orderdateafter), + ] + ) + zort_order_ids = [ + order.zort_order_id for order in zort_orders if order.zort_order_id + ] + zort_order_ids_str = ",".join(zort_order_ids) + return zort_order_ids_str + + def _get_marketplace_customer(self, order: dict) -> int: + """ + Determine the appropriate customer for the Zort order. + 1. For Magento orders, match or create a customer by phone number. + 2. For other platforms, use the marketplace customer. + :return: res.partner id + """ + default_customer = self.env.ref("zort_connector.marketplace_customer_1") + sales_channel = (order.get("saleschannel") or "").lower() + + # check if ecommerce channel use platform customer + ecommerce_channel = self.env["zort.ecommerce.channel"].search( + [("code", "=", sales_channel)], limit=1 + ) + platform_customer_id = ( + ecommerce_channel.partner_id.id + if ecommerce_channel and ecommerce_channel.partner_id + else default_customer.id + ) + return platform_customer_id + + def _get_platform_customer(self, order: dict) -> int: + """ + Get the platform customer (res.partner) for the Zort order. + :return: res.partner id + """ + sales_channel = (order.get("saleschannel") or "").lower() + + ecommerce_channel = self.env["zort.ecommerce.channel"].search( + [("code", "=", sales_channel)], limit=1 + ) + if ecommerce_channel and ecommerce_channel.auto_create_customer: + customer = self.create_new_customer(order) + if customer: + return customer.id + + return False + + def action_view_zort_order_json(self): + """ + Action to view the raw Zort order data in JSON format. + :return: dict - Action dictionary to open a new window with JSON data. + """ + return { + "type": "ir.actions.act_url", + "url": f"/zort_connector/view_zort_order_json/{self.id}", + "target": "new", + } + + @api.model + def create_new_customer(self, order_data: dict): + """ + Create a new customer based on the provided keyword arguments. + Make sure each key in kwargs matches a field in res.partner model. + Example kwargs: { + 'name': 'John Doe', + 'phone': '1234567890', + 'email': 'john.doe@example.com' + } + :return: res.partner record + """ + vals = { + "name": order_data.get("customername", "Online Customer"), + "phone": order_data.get("customerphone", ""), + "email": order_data.get("customeremail", ""), + "street": order_data.get("customeraddress", ""), + "city": order_data.get("customerprovince", ""), + "zip": order_data.get("customerpostcode", ""), + "vat": order_data.get("customeridnumber", ""), + "is_company": False, + } + existing_customer = None + if vals["phone"]: + phone = self.validate_customer_phone(vals["phone"]) + existing_customer = self.env["res.partner"].search( + [("phone", "=", phone)], limit=1 + ) + if vals["vat"]: + existing_customer = self.env["res.partner"].search( + [("vat", "=", vals["vat"])], limit=1 + ) + if existing_customer: + return existing_customer + + new_customer = self.env["res.partner"].create(vals) + return new_customer + + @staticmethod + def validate_customer_phone(phone: str) -> str: + """ + Return the last 9 digits of the phone number. + :param phone: str - Phone number to process. + :return: str - Last 9 digits of the phone number. + """ + phone = phone.strip() + return phone[-9:] if len(phone) >= 9 else phone + + def get_order_date_after(self): + """ + Get the order date after value from configuration. + :return: str - Date in 'YYYY-MM-DD' format. + """ + days_back = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("zort_connector.order_sync_days_back", default="10") + ) + try: + days = int(days_back) + if days <= 0: + days = 10 + except (TypeError, ValueError): + days = 10 + + orderdateafter = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + return orderdateafter + + def validate_order_from_zort(self): + """ + Validate the order data received from Zort. + 1. By check zort_order_data.get("amount") compare with sale order amount_total. + 2. Check line item from zort_order_data.get("list") each zort product id + compare with product_id.zort_product_ids in sale order lines. + + :return: bool - True if validation passes, False otherwise. + """ + for order in self: + zort_data = order.zort_order_data or {} + zort_amount = zort_data.get("amount", 0.0) + validated = True + msg = "" + if float(zort_amount) != float(order.amount_total): + msg += ( + f"Amount mismatch: Zort amount is {zort_amount}, " + f"Odoo amount is {order.amount_total}.\n" + ) + validated = False + + zort_line_items = zort_data.get("list", []) + zort_product_count = len( + [line for line in zort_line_items if line.get("id")] + ) + odoo_product_count = len( + order.order_line.filtered( + lambda line: line.product_id.type != "service" + ) + ) + if zort_product_count != odoo_product_count: + # This condition in sale order line not include discount, + # shipping, voucher lines + msg += ( + f"Line item count mismatch: Zort has {zort_product_count} items, " + f"Odoo has {odoo_product_count} items.\n" + ) + + missing_product_ids = [] + existing_zort_ids = set( + order.order_line.mapped("product_id.zort_product_ids.id_zort_product") + ) + for zort_line in zort_line_items: + zort_product_id = zort_line.get("productid") + if zort_product_id not in existing_zort_ids: + missing_product_ids.append(str(zort_product_id)) + + order.zort_missing_product_ids = ",".join(missing_product_ids) + order.zort_validation_message = msg.strip() + order.validate_zort_order = bool(validated) + if order.validate_zort_order: + order.zort_validation_message = ( + "This order has been validated successfully." + ) + return bool(validated) + + def update_zort_sale_order_line(self): + """ + Update sale order lines based on the latest Zort order data. + Just add missing products from order data (Json). + """ + for order in self: + zort_data = order.zort_order_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: + zort_product_id = zort_line.get("id") + if zort_product_id in existing_zort_ids: + continue + + product = self.get_product_by_sku(zort_line.get("productid")) + if not product: + _logger.warning( + "Product with Zort ID %s not found. Skipping line.", + zort_product_id, + ) + continue + + order.write( + { + "order_line": [ + ( + 0, + 0, + { + "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": [ + ( + 6, + 0, + 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..e1c3941b --- /dev/null +++ b/zort_connector/models/stock_picking.py @@ -0,0 +1,367 @@ +import logging +from datetime import datetime, timedelta + +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) + + +class StockPicking(models.Model): + _name = "stock.picking" + _inherit = ["stock.picking", "zort.api"] + + updated_qty_to_zort = fields.Boolean( + default=False, help="Indicates if the quantity has been updated to Zort." + ) + zort_return_data = fields.Json( + help="Zort return order data", + copy=False, + ) + zort_return_no = fields.Char( + help="Zort return order number", + copy=False, + ) + + def button_validate(self): + res = super().button_validate() + # Only update qty to zort if the picking is not related to a Zort order + # if not internal transfer update qty to zort + # Skip internal transfers and MRP operations + if self.picking_type_code in ("internal", "mrp_operation"): + 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._sync_qty_to_zort() + return res + + def action_sync_qty_to_zort(self): + """Public action method for server actions.""" + return self._sync_qty_to_zort() + + def _sync_qty_to_zort(self): + """ + Sync the quantity of products in Zort, always update available qty to Zort + when click button_validate. + """ + for picking in self: + data = {"stocks": []} + # Use move_ids_without_package for outgoing, move_ids for incoming + moves = ( + picking.move_ids + if picking.picking_type_code == "incoming" + else picking.move_ids_without_package + ) + for move in moves: + for product in move.product_id.zort_product_ids: + qty_available = move.product_id.qty_available + data["stocks"].append( + { + "productid": product.id_zort_product, + "stock": qty_available, + } + ) + if not data["stocks"]: + continue + wh_code = ( + picking.env["ir.config_parameter"] + .sudo() + .get_param("zort_connector.warehouse_code", default="W0001") + ) + try: + response = picking._update_product_available_stock_list( + warehousecode=wh_code, + data=data, + ) + if response.get("error"): + _logger.error( + "Error syncing stock to Zort: %s", response.get("error") + ) + else: + picking.updated_qty_to_zort = True + _logger.info( + "Successfully synced stock to Zort for picking %s", picking.name + ) + except Exception as e: + _logger.error( + "Exception occurred while syncing stock to Zort: %s", str(e) + ) + + @api.model + def action_create_return_picking(self): + """ + Handle Zort return orders by fetching them and creating corresponding + return pickings in Odoo. + + Logic: + - Fetch return orders using `_get_zort_return_order()`. + - For each return order: + - If status is 'Pending': create assigned return picking. + - If status is 'Success': done return picking. + """ + return_orders = self._get_zort_return_order() + _logger.info("Fetched %d return orders from Zort.", len(return_orders)) + if not return_orders: + _logger.info("No return orders found in Zort.") + return + + ######################################## + # Example structure of zort_order_numbers: + # { + # "ZORT-12345": { + # "number": "CN-12345", # Return order number in Zort + # "status": "Pending", # Return status: "Pending" or "Success" + # "item_list": [ # List of returned items + # { + # "sku": "SKU-001", # Product SKU + # "number": 2, # Quantity returned + # "pricepernumber": 100 # Unit price per item + # }, + # { + # "sku": "SKU-002", + # "number": 1, + # "pricepernumber": 200 + # } + # ] + # } + # } + ######################################## + zort_order_numbers: dict = {} + + for order in return_orders: + # On Zort, the sale order number is stored in "referencenumber" + zort_so_no = order.get("referencenumber") + status = order.get("status") + if status in ["Pending", "Success"]: + zort_order_numbers[zort_so_no] = order + + sale_orders = self.env["sale.order"].search( + [ + ("zort_order_number", "in", list(zort_order_numbers.keys())), + ("state", "=", "sale"), + ] + ) + for order in sale_orders: + zort_so_no = order.zort_order_number + return_order_data = zort_order_numbers.get(zort_so_no, {}) + return_status = return_order_data["status"].lower() + + if return_status == "pending": + picking_ids = order.picking_ids.filtered( + lambda p, order=order: p.state == "done" + and p.picking_type_code == "outgoing" + and p.origin == order.name + ) + picking = picking_ids[0] if picking_ids else None + zort_return_no = return_order_data.get("number", "") + if picking and not self._created_zort_return_picking( + order, zort_return_no + ): + try: + # Prepare context for the return wizard + return_wizard = ( + self.env["stock.return.picking"] + .with_context( + active_ids=[picking.id], + active_id=picking.id, + active_model="stock.picking", + ) + .create({}) + ) + + # Prepare item lines for the return wizard + # Sample structure: + # item_lines = { + # "SKU-001": 2, + # "SKU-002": 1 + # } + item_lines = { + item["sku"]: item["number"] + for item in return_order_data.get("list", []) + } + for line in return_wizard.product_return_moves: + sku = line.product_id.default_code + if sku in item_lines: + line.quantity = item_lines[sku] + result = return_wizard.action_create_returns() + if result and result.get("res_id"): + return_picking = self.env["stock.picking"].browse( + result["res_id"] + ) + return_picking.write( + { + "zort_return_no": zort_return_no, + "zort_return_data": return_order_data, + } + ) + msg = _( + "Zort has created a return order: %(zort_return_no)s", + zort_return_no=zort_return_no, + ) + return_picking.message_post(body=msg) + except Exception as e: + _logger.error( + "Error creating return picking for order %s: %s", + order.name, + str(e), + ) + continue + + elif return_status == "success": + zort_return_no = return_order_data.get("number", "") + picking_ids = order.picking_ids.filtered( + lambda p, return_no=zort_return_no: p.state == "assigned" + and p.picking_type_code == "incoming" + and p.zort_return_no == return_no + ) + if len(picking_ids) > 1: + _logger.warning( + "Multiple incoming pickings found. Skipping return validation." + ) + continue + picking = picking_ids[0] if picking_ids else None + if picking and picking.state == "assigned": + picking.button_validate() + self._create_credit_note_for_return(picking.ids) + _logger.info( + "Return picking has been validated for order %s", order.name + ) + + @api.model + def _created_zort_return_picking(self, sale_order, zort_return_no) -> bool: + """ + Returns True if no assigned incoming return picking exists for the sale order. + """ + incoming_pickings = sale_order.picking_ids.filtered( + lambda p, return_no=zort_return_no: p.picking_type_code == "incoming" + and p.state == "assigned" + and p.zort_return_no == return_no + ) + _logger.info( + "Checking existing return pickings for order %s with Zort's return no %s: " + "found %d", + sale_order.name, + zort_return_no, + len(incoming_pickings), + ) + return bool(incoming_pickings) + + def _create_credit_note_for_return(self, picking_ids): + """ + Create a credit note for the return picking. + This method should be called after the return picking is created. + + Ensure this credit note should be able to reconcile with invoices + related to the original sale order. + """ + + def get_price_unit(default_code, return_item_list): + # If return_item_list is available, match SKU to get pricepernumber + if return_item_list: + sku_to_price = { + item["sku"]: item["pricepernumber"] for item in return_item_list + } + return sku_to_price.get(default_code, 0) + return 0 + + for picking in self.env["stock.picking"].browse(picking_ids): + if ( + picking.picking_type_code != "incoming" + or not picking.zort_return_no + or picking.state != "done" + ): + continue + sale_order = picking.sale_id + if not sale_order: + continue + + return_item_list = picking.zort_return_data.get("list", []) + + # Prepare lines for credit note: only products in the return picking + credit_lines = [] + for move in picking.move_ids: + if move.product_id and move.quantity > 0: + credit_lines.append( + ( + 0, + 0, + { + "product_id": move.product_id.id, + "quantity": move.quantity, + "price_unit": get_price_unit( + move.product_id.default_code, return_item_list + ), + }, + ) + ) + if not credit_lines: + continue + credit_note = self.env["account.move"].create( + { + "move_type": "out_refund", + "invoice_origin": sale_order.name, + "invoice_user_id": sale_order.user_id.id, + "partner_id": sale_order.partner_id.id, + "invoice_date": fields.Date.context_today(self), + "invoice_line_ids": credit_lines, + "invoice_payment_term_id": sale_order.payment_term_id.id, + "ref": f"Picking No. {picking.name}", + } + ) + picking.message_post( + body=_( + "Draft credit note created for return picking: %s", credit_note.name + ) + ) + + @api.model + def _get_zort_return_order(self, **kwargs) -> list: + """ + Fetches return orders from Zort + """ + # TODO: date to query should be configurable on settings + + # Use a default date range of 10 days for get return orders + returnorderdateafter = (datetime.now() - timedelta(days=10)).strftime( + "%Y-%m-%d" + ) + returnorderdatebefore = datetime.now().strftime("%Y-%m-%d") + kwargs.update( + { + "returnorderdateafter": returnorderdateafter, + "returnorderdatebefore": returnorderdatebefore, + } + ) + + _logger.info("Fetching return orders from Zort...") + response = self._get_return_orders(**kwargs) + + # Handle API Read timed out. + if response.get("error"): + _logger.error( + "Error fetching return orders from Zort: %s", response.get("error") + ) + return [] + + res = response.get("res") + if res.get("resCode") != "200": + _logger.error( + "Error fetching return orders from Zort: %s", res.get("resMessage") + ) + return [] + + return response.get("list", []) + + def action_view_return_order_json(self): + """ + Action to view return order JSON data. + This method is used to display the return order data in a dialog. + """ + return { + "type": "ir.actions.act_url", + "url": f"/zort_connector/view_zort_return_order_json/{self.id}", + "target": "new", + } diff --git a/zort_connector/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/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/CONTRIBUTORS.rst b/zort_connector/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..dab62959 --- /dev/null +++ b/zort_connector/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +- Theerayut A. diff --git a/zort_connector/readme/DESCRIPTION.rst b/zort_connector/readme/DESCRIPTION.rst new file mode 100644 index 00000000..16d13f07 --- /dev/null +++ b/zort_connector/readme/DESCRIPTION.rst @@ -0,0 +1,101 @@ + +========================================== +คู่มือการใช้งานและพัฒนาโมดูล zort_connector +========================================== + +ภาพรวม +============================== + +โมดูล ``zort_connector`` สำหรับ Odoo นี้ถูกออกแบบมาเพื่อเชื่อมต่อกับระบบ ZORT ผ่าน API โดยมีการทำงานหลักผ่าน Scheduled Action (Cron Job) และมีการกำหนด Action ต่าง ๆ เพื่อซิงค์ข้อมูลระหว่าง Odoo และ ZORT + +การเริ่มต้นทำงานของระบบ +===================== + +ระบบจะเริ่มต้นทำงานโดยอัตโนมัติผ่าน Scheduled Action (Cron Job) ที่กำหนดไว้ในไฟล์ ``data/ir_cron_data.xml`` โดยแต่ละ Action จะถูกตั้งเวลาให้ทำงานตามรอบที่กำหนด เช่น ทุก ๆ 5 นาที หรือทุก ๆ 1 ชั่วโมง + +ตัวอย่าง Scheduled Action ที่มีในระบบ +------------------------------------ + +- **ซิงค์สินค้า (Sync Product)** + - ดึงข้อมูลสินค้าใหม่/อัปเดตจาก ZORT เข้ามาใน Odoo +- **ซิงค์ออเดอร์ (Sync Sale Order)** + - ดึงข้อมูลออเดอร์ใหม่จาก ZORT +- **ซิงค์สต็อก (Sync Stock Picking)** + - อัปเดตสถานะสต็อกจาก Odoo ไปยัง ZORT +- **ซิงค์พาร์ทเนอร์ (Sync Partner)** + - ดึงข้อมูลลูกค้า/ซัพพลายเออร์ +- **ซิงค์ BOM (Sync BOM)** + - ดึงข้อมูล BOM จาก ZORT + +Action เหล่านี้สามารถดูรายละเอียดและแก้ไขได้ที่ไฟล์ ``data/ir_cron_data.xml`` และโค้ดที่เกี่ยวข้องในโฟลเดอร์ ``models/`` + +การทำงานของแต่ละ Action +============================== + +แต่ละ Action จะมีฟังก์ชันหลักในไฟล์ Python ที่เกี่ยวข้อง เช่น +- ``models/product_product.py`` สำหรับซิงค์สินค้า +- ``models/sale_order.py`` สำหรับซิงค์ออเดอร์ +- ``models/stock_picking.py`` สำหรับซิงค์สต็อก +- ``models/partner.py`` สำหรับซิงค์พาร์ทเนอร์ +- ``models/mrp_bom.py`` สำหรับซิงค์ BOM + +โดยแต่ละฟังก์ชันจะเรียกใช้งาน API ผ่านโมดูล ``zort_api`` + +API Specification +================= + +รายละเอียดของ API ที่ใช้เชื่อมต่อกับ ZORT ถูกกำหนดไว้ในโฟลเดอร์ ``zort_api/`` โดยเฉพาะไฟล์ ``zort_api/zort_api.py`` ซึ่งจะมีฟังก์ชันสำหรับเรียกใช้งาน API ของ ZORT เช่น +- การดึงข้อมูลสินค้า +- การดึงข้อมูลออเดอร์ +- การอัปเดตสต็อก +- การดึงข้อมูล BOM + +หากต้องการแก้ไขหรือเพิ่ม endpoint ใหม่ ให้แก้ไขที่ไฟล์นี้ และควรเขียน docstring อธิบายแต่ละฟังก์ชันให้ชัดเจน + +แนวทางสำหรับ Developer +======================== + +1. **เพิ่ม/แก้ไข Action** + - เพิ่ม/แก้ไข cron job ที่ไฟล์ ``data/ir_cron_data.xml`` + - เขียนฟังก์ชันในไฟล์ Python ที่เกี่ยวข้องใน ``models/`` +2. **ปรับปรุง API** + - แก้ไขหรือเพิ่มฟังก์ชันใน ``zort_api/zort_api.py`` + - ตรวจสอบและทดสอบการเชื่อมต่อกับ ZORT +3. **การตั้งค่า** + - สามารถตั้งค่าการเชื่อมต่อ (API Key, URL ฯลฯ) ได้ที่เมนูตั้งค่าของ Odoo หรือไฟล์ ``data/ir_config_parameter_data.xml`` +4. **การทดสอบ** + - ทดสอบการทำงานของแต่ละ Action โดยดู log หรือผลลัพธ์ใน Odoo + +อธิบายโครงสร้างข้อมูลสำคัญ +=========================== + +Zort Product +------------ + +``zort.product`` คือโมเดลที่ใช้เก็บข้อมูลสินค้าในฝั่ง ZORT โดยจะมีฟิลด์ ``id_zort_product`` ซึ่งเป็นรหัสอ้างอิงสินค้าจาก ZORT (Zort Product ID) และมีฟิลด์ ``product_id`` ที่ผูกกับสินค้าใน Odoo (``product.product``) อีกที + +**เหตุผลที่ต้องมี zort.product** + +- ZORT มีรหัสสินค้า (id_zort_product) ที่ไม่ซ้ำกับ SKU และ SKU อาจซ้ำกันหรือเปลี่ยนแปลงได้ +- การซิงค์ออเดอร์จาก ZORT เข้ามาใน Odoo จะใช้ id_zort_product ในการจับคู่กับสินค้าใน Odoo ได้แม่นยำกว่าการใช้ SKU +- ช่วยให้การ mapping ข้อมูลระหว่าง 2 ระบบถูกต้อง แม้ SKU จะไม่ unique หรือเปลี่ยนแปลงได้ + +Zort eCommerce Channel +---------------------- + +``zort.ecommerce.channel`` คือโมเดลที่ใช้เก็บข้อมูลช่องทางการขาย (eCommerce Channel) ที่เชื่อมต่อกับ ZORT เช่น Lazada, Shopee, Facebook เป็นต้น + +**วัตถุประสงค์** + +- ใช้เก็บการตั้งค่าของแต่ละช่องทาง เช่น รหัส channel (``code``), ลูกค้า platform (``partner_id``), การสร้างลูกค้าอัตโนมัติ (``auto_create_customer``) +- ใช้สำหรับ mapping ข้อมูล order ที่มาจากแต่ละช่องทาง เพื่อให้สามารถแยกจัดการและตั้งค่าพิเศษได้ในแต่ละ channel +- รองรับการขยายช่องทางขายใหม่ ๆ ในอนาคต + +สรุป +==== + +- ระบบเริ่มทำงานด้วย Scheduled Action (Cron Job) +- Action หลัก ๆ ได้แก่ ซิงค์สินค้า ออเดอร์ สต็อก พาร์ทเนอร์ BOM +- มีโครงสร้างข้อมูลสำคัญ ได้แก่ zort.product (สำหรับ mapping สินค้า) และ zort.ecommerce.channel (สำหรับจัดการช่องทางขาย) +- API spec และโค้ดการเชื่อมต่ออยู่ที่ ``zort_api/zort_api.py`` +- Developer สามารถต่อยอดหรือแก้ไขได้ตามแนวทางข้างต้น diff --git a/zort_connector/security/ir.model.access.csv b/zort_connector/security/ir.model.access.csv new file mode 100644 index 00000000..93c726f7 --- /dev/null +++ b/zort_connector/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +"access_zort_ecommerce_channel","zort.ecommerce.channel","model_zort_ecommerce_channel","sales_team.group_sale_salesman",1,1,1,1 +"access_zort_product","zort.product","model_zort_product","base.group_user",1,1,1,1 diff --git a/zort_connector/static/description/index.html b/zort_connector/static/description/index.html new file mode 100644 index 00000000..497e6266 --- /dev/null +++ b/zort_connector/static/description/index.html @@ -0,0 +1,496 @@ + + + + + +Zort Connector + + + +
+

Zort Connector

+ + +

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

+

# คู่มือการใช้งานและพัฒนาโมดูล zort_connector

+

## ภาพรวม

+

โมดูล zort_connector สำหรับ Odoo นี้ถูกออกแบบมาเพื่อเชื่อมต่อกับระบบ ZORT ผ่าน API โดยมีการทำงานหลักผ่าน Scheduled Action (Cron Job) และมีการกำหนด Action ต่าง ๆ เพื่อซิงค์ข้อมูลระหว่าง Odoo และ ZORT

+

+

## การเริ่มต้นทำงานของระบบ

+

ระบบจะเริ่มต้นทำงานโดยอัตโนมัติผ่าน Scheduled Action (Cron Job) ที่กำหนดไว้ในไฟล์ data/ir_cron_data.xml โดยแต่ละ Action จะถูกตั้งเวลาให้ทำงานตามรอบที่กำหนด เช่น ทุก ๆ 5 นาที หรือทุก ๆ 1 ชั่วโมง

+

### ตัวอย่าง Scheduled Action ที่มีในระบบ

+
    +
  • ซิงค์สินค้า (Sync Product) +- ดึงข้อมูลสินค้าใหม่/อัปเดตจาก ZORT เข้ามาใน Odoo
  • +
  • ซิงค์ออเดอร์ (Sync Sale Order) +- ดึงข้อมูลออเดอร์ใหม่จาก ZORT
  • +
  • ซิงค์สต็อก (Sync Stock Picking) +- อัปเดตสถานะสต็อกจาก Odoo ไปยัง ZORT
  • +
  • ซิงค์พาร์ทเนอร์ (Sync Partner) +- ดึงข้อมูลลูกค้า/ซัพพลายเออร์
  • +
  • ซิงค์ BOM (Sync BOM) +- ดึงข้อมูล BOM จาก ZORT
  • +
+

Action เหล่านี้สามารถดูรายละเอียดและแก้ไขได้ที่ไฟล์ data/ir_cron_data.xml และโค้ดที่เกี่ยวข้องในโฟลเดอร์ models/

+

+

## การทำงานของแต่ละ Action

+

แต่ละ Action จะมีฟังก์ชันหลักในไฟล์ Python ที่เกี่ยวข้อง เช่น +- models/product_product.py สำหรับซิงค์สินค้า +- models/sale_order.py สำหรับซิงค์ออเดอร์ +- models/stock_picking.py สำหรับซิงค์สต็อก +- models/partner.py สำหรับซิงค์พาร์ทเนอร์ +- models/mrp_bom.py สำหรับซิงค์ BOM

+

โดยแต่ละฟังก์ชันจะเรียกใช้งาน API ผ่านโมดูล zort_api

+

+

## API Specification

+

รายละเอียดของ API ที่ใช้เชื่อมต่อกับ ZORT ถูกกำหนดไว้ในโฟลเดอร์ zort_api/ โดยเฉพาะไฟล์ zort_api/zort_api.py ซึ่งจะมีฟังก์ชันสำหรับเรียกใช้งาน API ของ ZORT เช่น +- การดึงข้อมูลสินค้า +- การดึงข้อมูลออเดอร์ +- การอัปเดตสต็อก +- การดึงข้อมูล BOM

+

หากต้องการแก้ไขหรือเพิ่ม endpoint ใหม่ ให้แก้ไขที่ไฟล์นี้ และควรเขียน docstring อธิบายแต่ละฟังก์ชันให้ชัดเจน

+

+

## แนวทางสำหรับ Developer

+
    +
  1. เพิ่ม/แก้ไข Action +- เพิ่ม/แก้ไข cron job ที่ไฟล์ data/ir_cron_data.xml +- เขียนฟังก์ชันในไฟล์ Python ที่เกี่ยวข้องใน models/
  2. +
  3. ปรับปรุง API +- แก้ไขหรือเพิ่มฟังก์ชันใน zort_api/zort_api.py +- ตรวจสอบและทดสอบการเชื่อมต่อกับ ZORT
  4. +
  5. การตั้งค่า +- สามารถตั้งค่าการเชื่อมต่อ (API Key, URL ฯลฯ) ได้ที่เมนูตั้งค่าของ Odoo หรือไฟล์ data/ir_config_parameter_data.xml
  6. +
  7. การทดสอบ +- ทดสอบการทำงานของแต่ละ Action โดยดู log หรือผลลัพธ์ใน Odoo
  8. +
+

+

+

## อธิบายโครงสร้างข้อมูลสำคัญ

+

### Zort Product

+

zort.product คือโมเดลที่ใช้เก็บข้อมูลสินค้าในฝั่ง ZORT โดยจะมีฟิลด์ id_zort_product ซึ่งเป็นรหัสอ้างอิงสินค้าจาก ZORT (Zort Product ID) และมีฟิลด์ product_id ที่ผูกกับสินค้าใน Odoo (product.product) อีกที

+

เหตุผลที่ต้องมี zort.product

+
    +
  • ZORT มีรหัสสินค้า (id_zort_product) ที่ไม่ซ้ำกับ SKU และ SKU อาจซ้ำกันหรือเปลี่ยนแปลงได้
  • +
  • การซิงค์ออเดอร์จาก ZORT เข้ามาใน Odoo จะใช้ id_zort_product ในการจับคู่กับสินค้าใน Odoo ได้แม่นยำกว่าการใช้ SKU
  • +
  • ช่วยให้การ mapping ข้อมูลระหว่าง 2 ระบบถูกต้อง แม้ SKU จะไม่ unique หรือเปลี่ยนแปลงได้
  • +
+

### Zort eCommerce Channel

+

zort.ecommerce.channel คือโมเดลที่ใช้เก็บข้อมูลช่องทางการขาย (eCommerce Channel) ที่เชื่อมต่อกับ ZORT เช่น Lazada, Shopee, Facebook เป็นต้น

+

วัตถุประสงค์

+
    +
  • ใช้เก็บการตั้งค่าของแต่ละช่องทาง เช่น รหัส channel (code), ลูกค้า platform (partner_id), การสร้างลูกค้าอัตโนมัติ (auto_create_customer)
  • +
  • ใช้สำหรับ mapping ข้อมูล order ที่มาจากแต่ละช่องทาง เพื่อให้สามารถแยกจัดการและตั้งค่าพิเศษได้ในแต่ละ channel
  • +
  • รองรับการขยายช่องทางขายใหม่ ๆ ในอนาคต
  • +
+

+

## สรุป

+
    +
  • ระบบเริ่มทำงานด้วย Scheduled Action (Cron Job)
  • +
  • Action หลัก ๆ ได้แก่ ซิงค์สินค้า ออเดอร์ สต็อก พาร์ทเนอร์ BOM
  • +
  • มีโครงสร้างข้อมูลสำคัญ ได้แก่ zort.product (สำหรับ mapping สินค้า) และ zort.ecommerce.channel (สำหรับจัดการช่องทางขาย)
  • +
  • API spec และโค้ดการเชื่อมต่ออยู่ที่ zort_api/zort_api.py
  • +
  • Developer สามารถต่อยอดหรือแก้ไขได้ตามแนวทางข้างต้น
  • +
+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Ecosoft
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

Current maintainer:

+

theerayuta@ecosoft.co.th

+

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

+

You are welcome to contribute.

+
+
+
+ + diff --git a/zort_connector/views/mrp_bom_views.xml b/zort_connector/views/mrp_bom_views.xml new file mode 100644 index 00000000..4d577422 --- /dev/null +++ b/zort_connector/views/mrp_bom_views.xml @@ -0,0 +1,16 @@ + + + + mrp.bom + + + + + + + + diff --git a/zort_connector/views/product_product_view.xml b/zort_connector/views/product_product_view.xml new file mode 100644 index 00000000..6d474ccf --- /dev/null +++ b/zort_connector/views/product_product_view.xml @@ -0,0 +1,47 @@ + + + + product.product.only.form.view.inherit + product.product + + + + +