details:   https://code.tryton.org/tryton/commit/072e73a4830e
branch:    default
user:      Cédric Krier <[email protected]>
date:      Thu Oct 09 19:12:53 2025 +0200
description:
        Add support for product kit on Shopify

        Closes #14129
diffstat:

 modules/web_shop_shopify/CHANGELOG                                       |    
1 +
 modules/web_shop_shopify/sale.py                                         |   
32 +
 modules/web_shop_shopify/setup.py                                        |    
1 +
 modules/web_shop_shopify/stock.py                                        |   
51 +
 modules/web_shop_shopify/tests/scenario_web_shop_shopify_product_kit.rst |  
296 ++++++++++
 modules/web_shop_shopify/tests/test_module.py                            |    
5 +-
 modules/web_shop_shopify/tests/test_scenario.py                          |    
1 +
 modules/web_shop_shopify/tests/tools.py                                  |    
5 +
 modules/web_shop_shopify/tryton.cfg                                      |    
6 +
 9 files changed, 396 insertions(+), 2 deletions(-)

diffs (480 lines):

diff -r 1d15a4b5bb79 -r 072e73a4830e modules/web_shop_shopify/CHANGELOG
--- a/modules/web_shop_shopify/CHANGELOG        Thu Jul 03 16:49:46 2025 +0200
+++ b/modules/web_shop_shopify/CHANGELOG        Thu Oct 09 19:12:53 2025 +0200
@@ -1,3 +1,4 @@
+* Add support for product kit
 * Add carrier selection
 * Use GraphQL API
 * Add option to notify customer about fulfillment
diff -r 1d15a4b5bb79 -r 072e73a4830e modules/web_shop_shopify/sale.py
--- a/modules/web_shop_shopify/sale.py  Thu Jul 03 16:49:46 2025 +0200
+++ b/modules/web_shop_shopify/sale.py  Thu Oct 09 19:12:53 2025 +0200
@@ -971,4 +971,36 @@
         if sale.carrier:
             setattr_changed(self, 'product', sale.carrier.carrier_product)
 
+
+class Line_Kit(metaclass=PoolMeta):
+    __name__ = 'sale.line'
+
+    @classmethod
+    def get_from_shopify(
+            cls, sale, line_item, quantity, warehouse=None, line=None):
+        pool = Pool()
+        UoM = pool.get('product.uom')
+        Component = pool.get('sale.line.component')
+        line = super().get_from_shopify(
+            sale, line_item, quantity, warehouse=warehouse, line=line)
+        if getattr(line, 'components', None):
+            quantity = UoM.compute_qty(
+                line.unit, line.quantity,
+                line.product.default_uom, round=False)
+            for component in line.components:
+                if not component.fixed:
+                    quantity = component.unit.round(
+                        quantity * component.quantity_ratio)
+                    setattr_changed(component, 'quantity', quantity)
+            line.components = line.components
+        elif (getattr(sale, 'state', 'draft') != 'draft'
+                and line.product
+                and line.product.type == 'kit'):
+            components = []
+            for component in line.product.components_used:
+                components.append(line.get_component(component))
+            Component.set_price_ratio(components, line.quantity)
+            line.components = components
+        return line
+
 # TODO: refund as return sale
diff -r 1d15a4b5bb79 -r 072e73a4830e modules/web_shop_shopify/setup.py
--- a/modules/web_shop_shopify/setup.py Thu Jul 03 16:49:46 2025 +0200
+++ b/modules/web_shop_shopify/setup.py Thu Oct 09 19:12:53 2025 +0200
@@ -57,6 +57,7 @@
     get_require_version('trytond_customs'),
     get_require_version('trytond_product_image'),
     get_require_version('trytond_product_image_attribute'),
+    get_require_version('trytond_product_kit'),
     get_require_version('trytond_product_measurements'),
     get_require_version('trytond_sale_discount'),
     get_require_version('trytond_sale_invoice_grouping'),
diff -r 1d15a4b5bb79 -r 072e73a4830e modules/web_shop_shopify/stock.py
--- a/modules/web_shop_shopify/stock.py Thu Jul 03 16:49:46 2025 +0200
+++ b/modules/web_shop_shopify/stock.py Thu Oct 09 19:12:53 2025 +0200
@@ -222,3 +222,54 @@
                     quantity=quantity,
                     move=self.rec_name,
                     ))
+
+
+class Move_Kit(metaclass=PoolMeta):
+    __name__ = 'stock.move'
+
+    def get_shopify(self, fulfillment_orders, location_id):
+        pool = Pool()
+        SaleLineComponent = pool.get('sale.line.component')
+        UoM = pool.get('product.uom')
+        yield from super().get_shopify(fulfillment_orders, location_id)
+        if not isinstance(self.origin, SaleLineComponent):
+            return
+
+        sale_line = self.origin.line
+
+        # Track only the first component
+        if min(c.id for c in sale_line.components) != self.origin.id:
+            return
+
+        location_id = id2gid('Location', location_id)
+        identifier = id2gid('LineItem', sale_line.shopify_identifier)
+
+        c_quantity = UoM.compute_qty(
+                self.unit, self.quantity, self.origin.unit, round=False)
+        if self.origin.quantity:
+            ratio = c_quantity / self.origin.quantity
+        else:
+            ratio = 1
+        quantity = int(sale_line.quantity * ratio)
+        for fulfillment_order in fulfillment_orders['nodes']:
+            if (fulfillment_order['assignedLocation']['location']['id']
+                    != location_id):
+                continue
+            for line_item in fulfillment_order['lineItems']['nodes']:
+                if line_item['lineItem']['id'] == identifier:
+                    qty = min(
+                        quantity, line_item['lineItem']['fulfillableQuantity'])
+                    if qty:
+                        yield fulfillment_order['id'], {
+                            'id': line_item['id'],
+                            'quantity': qty,
+                            }
+                    quantity -= qty
+                    if quantity <= 0:
+                        return
+        else:
+            raise ShopifyError(gettext(
+                    'web_shop_shopify.msg_fulfillment_order_line_not_found',
+                    quantity=quantity,
+                    move=self.rec_name,
+                    ))
diff -r 1d15a4b5bb79 -r 072e73a4830e 
modules/web_shop_shopify/tests/scenario_web_shop_shopify_product_kit.rst
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/modules/web_shop_shopify/tests/scenario_web_shop_shopify_product_kit.rst  
Thu Oct 09 19:12:53 2025 +0200
@@ -0,0 +1,296 @@
+=====================================
+Web Shop Shopify Product Kit Scenario
+=====================================
+
+Imports::
+
+    >>> import os
+    >>> import random
+    >>> import string
+    >>> import time
+    >>> from decimal import Decimal
+
+    >>> import shopify
+    >>> from shopify.api_version import ApiVersion
+
+    >>> from proteus import Model
+    >>> from trytond.modules.account.tests.tools import (
+    ...     create_chart, create_fiscalyear, get_accounts)
+    >>> from trytond.modules.account_invoice.tests.tools import (
+    ...     set_fiscalyear_invoice_sequences)
+    >>> from trytond.modules.company.tests.tools import create_company, 
get_company
+    >>> from trytond.modules.web_shop_shopify.common import gid2id, id2gid
+    >>> from trytond.modules.web_shop_shopify.tests import tools
+    >>> from trytond.tests.tools import activate_modules
+
+    >>> FETCH_SLEEP, MAX_SLEEP = 1, 10
+
+Activate modules::
+
+    >>> config = activate_modules([
+    ...         'web_shop_shopify',
+    ...         'product_kit',
+    ...         ],
+    ...     create_company, create_chart)
+
+    >>> Account = Model.get('account.account')
+    >>> Category = Model.get('product.category')
+    >>> Cron = Model.get('ir.cron')
+    >>> Location = Model.get('stock.location')
+    >>> PaymentJournal = Model.get('account.payment.journal')
+    >>> Product = Model.get('product.product')
+    >>> ProductTemplate = Model.get('product.template')
+    >>> Sale = Model.get('sale.sale')
+    >>> ShopifyIdentifier = Model.get('web.shop.shopify_identifier')
+    >>> Uom = Model.get('product.uom')
+    >>> WebShop = Model.get('web.shop')
+
+Get company::
+
+    >>> company = get_company()
+
+Get accounts::
+
+    >>> accounts = get_accounts()
+
+Create fiscal year::
+
+    >>> fiscalyear = set_fiscalyear_invoice_sequences(create_fiscalyear())
+    >>> fiscalyear.click('create_period')
+
+Create payment journal::
+
+    >>> shopify_account = Account(parent=accounts['receivable'].parent)
+    >>> shopify_account.name = "Shopify"
+    >>> shopify_account.type = accounts['receivable'].type
+    >>> shopify_account.reconcile = True
+    >>> shopify_account.save()
+
+    >>> payment_journal = PaymentJournal()
+    >>> payment_journal.name = "Shopify"
+    >>> payment_journal.process_method = 'shopify'
+    >>> payment_journal.save()
+
+Define a web shop::
+
+    >>> web_shop = WebShop(name="Web Shop")
+    >>> web_shop.type = 'shopify'
+    >>> web_shop.shopify_url = os.getenv('SHOPIFY_URL')
+    >>> web_shop.shopify_password = os.getenv('SHOPIFY_PASSWORD')
+    >>> web_shop.shopify_version = sorted(ApiVersion.versions, reverse=True)[1]
+    >>> shop_warehouse = web_shop.shopify_warehouses.new()
+    >>> shop_warehouse.warehouse, = Location.find([('type', '=', 'warehouse')])
+    >>> shopify_payment_journal = web_shop.shopify_payment_journals.new()
+    >>> shopify_payment_journal.journal = payment_journal
+    >>> web_shop.save()
+
+    >>> shopify.ShopifyResource.activate_session(shopify.Session(
+    ...         web_shop.shopify_url,
+    ...         web_shop.shopify_version,
+    ...         web_shop.shopify_password))
+
+    >>> location = tools.get_location()
+
+    >>> shop_warehouse, = web_shop.shopify_warehouses
+    >>> shop_warehouse.shopify_id = str(gid2id(location['id']))
+    >>> web_shop.save()
+
+Create categories::
+
+    >>> category = Category(name="Category")
+    >>> category.save()
+
+    >>> account_category = Category(name="Account Category")
+    >>> account_category.accounting = True
+    >>> account_category.account_expense = accounts['expense']
+    >>> account_category.account_revenue = accounts['revenue']
+    >>> account_category.save()
+
+Create product kit::
+
+    >>> unit, = Uom.find([('name', '=', "Unit")])
+    >>> meter, = Uom.find([('name', '=', "Meter")])
+
+    >>> template = ProductTemplate()
+    >>> template.name = "Component 1"
+    >>> template.default_uom = unit
+    >>> template.type = 'goods'
+    >>> template.save()
+    >>> component1, = template.products
+
+    >>> template = ProductTemplate()
+    >>> template.name = "Component 2"
+    >>> template.default_uom = meter
+    >>> template.type = 'goods'
+    >>> template.save()
+    >>> component2, = template.products
+
+    >>> template = ProductTemplate()
+    >>> template.name = "Product Kit"
+    >>> template.default_uom = unit
+    >>> template.type = 'kit'
+    >>> template.salable = True
+    >>> template.list_price = Decimal('100.0000')
+    >>> template.account_category = account_category
+    >>> template.categories.append(Category(category.id))
+    >>> template.save()
+    >>> product, = template.products
+    >>> product.suffix_code = 'PROD'
+    >>> product.save()
+
+    >>> _ = template.components.new(product=component1, quantity=2)
+    >>> _ = template.components.new(product=component2, quantity=5)
+    >>> template.save()
+
+Set categories, products and attributes to web shop::
+
+    >>> web_shop.categories.append(Category(category.id))
+    >>> web_shop.products.append(Product(product.id))
+    >>> web_shop.save()
+
+Run update product::
+
+    >>> cron_update_product, = Cron.find([
+    ...     ('method', '=', 'web.shop|shopify_update_product'),
+    ...     ])
+    >>> cron_update_product.click('run_once')
+
+Create an order on Shopify::
+
+    >>> customer = tools.create_customer({
+    ...         'lastName': "Customer",
+    ...         'email': (''.join(
+    ...                 random.choice(string.ascii_letters) for _ in range(10))
+    ...             + '@example.com'),
+    ...         'addresses': [{
+    ...                 'address1': "Street",
+    ...                 'city': "City",
+    ...                 'countryCode': 'BE',
+    ...                 }],
+    ...         })
+
+    >>> order = tools.create_order({
+    ...         'customerId': customer['id'],
+    ...         'lineItems': [{
+    ...             'variantId': id2gid(
+    ...                 'ProductVariant',
+    ...                 product.shopify_identifiers[0].shopify_identifier),
+    ...             'quantity': 3,
+    ...             }],
+    ...         'financialStatus': 'AUTHORIZED',
+    ...         'transactions': [{
+    ...             'kind': 'AUTHORIZATION',
+    ...             'status': 'SUCCESS',
+    ...             'amountSet': {
+    ...                 'shopMoney': {
+    ...                     'amount': 300,
+    ...                     'currencyCode': company.currency.code,
+    ...                     },
+    ...                 },
+    ...             'test': True,
+    ...             }],
+    ...         })
+    >>> order['totalPriceSet']['presentmentMoney']['amount']
+    '300.0'
+    >>> order['displayFinancialStatus']
+    'AUTHORIZED'
+
+    >>> transaction = tools.capture_order(
+    ...     order['id'], 300, order['transactions'][0]['id'])
+
+Run fetch order::
+
+    >>> with config.set_context(shopify_orders=[gid2id(order['id'])]):
+    ...     cron_fetch_order, = Cron.find([
+    ...         ('method', '=', 'web.shop|shopify_fetch_order'),
+    ...         ])
+    ...     for _ in range(MAX_SLEEP):
+    ...         cron_fetch_order.click('run_once')
+    ...         if Sale.find([]):
+    ...             break
+    ...         time.sleep(FETCH_SLEEP)
+
+    >>> sale, = Sale.find([])
+    >>> sale.total_amount
+    Decimal('300.00')
+    >>> sale_line, = sale.lines
+    >>> sale_line.quantity
+    3.0
+
+Make a partial shipment of components::
+
+    >>> shipment, = sale.shipments
+    >>> for move in shipment.inventory_moves:
+    ...     if move.product == component1:
+    ...         move.quantity = 4
+    ...     else:
+    ...         move.quantity = 0
+    >>> shipment.click('pick')
+    >>> shipment.click('pack')
+    >>> shipment.click('do')
+    >>> shipment.state
+    'done'
+
+    >>> order = tools.get_order(order['id'])
+    >>> order['displayFulfillmentStatus']
+    'PARTIALLY_FULFILLED'
+    >>> fulfillment, = order['fulfillments']
+    >>> fulfillment_line_item, = fulfillment['fulfillmentLineItems']['nodes']
+    >>> fulfillment_line_item['quantity']
+    2
+
+Make a partial shipment for a single component::
+
+    >>> sale.reload()
+    >>> _, shipment = sale.shipments
+    >>> for move in shipment.inventory_moves:
+    ...     if move.product == component1:
+    ...         move.quantity = 0
+    ...     else:
+    ...         move.quantity = 10
+    >>> shipment.click('pick')
+    >>> shipment.click('pack')
+    >>> shipment.click('do')
+    >>> shipment.state
+    'done'
+
+    >>> order = tools.get_order(order['id'])
+    >>> order['displayFulfillmentStatus']
+    'PARTIALLY_FULFILLED'
+    >>> fulfillment, = order['fulfillments']
+    >>> fulfillment_line_item, = fulfillment['fulfillmentLineItems']['nodes']
+    >>> fulfillment_line_item['quantity']
+    2
+
+Ship remaining::
+
+    >>> sale.reload()
+    >>> _, _, shipment = sale.shipments
+    >>> shipment.click('pick')
+    >>> shipment.click('pack')
+    >>> shipment.click('do')
+    >>> shipment.state
+    'done'
+
+    >>> order = tools.get_order(order['id'])
+    >>> order['displayFulfillmentStatus']
+    'FULFILLED'
+
+Clean up::
+
+    >>> tools.delete_order(order['id'])
+    >>> for product in ShopifyIdentifier.find(
+    ...         [('record', 'like', 'product.template,%')]):
+    ...     tools.delete_product(id2gid('Product', product.shopify_identifier))
+    >>> for category in ShopifyIdentifier.find(
+    ...         [('record', 'like', 'product.category,%')]):
+    ...     tools.delete_collection(id2gid('Collection', 
category.shopify_identifier))
+    >>> for _ in range(MAX_SLEEP):
+    ...     try:
+    ...         tools.delete_customer(customer['id'])
+    ...     except Exception:
+    ...         time.sleep(FETCH_SLEEP)
+    ...     else:
+    ...         break
+
+    >>> shopify.ShopifyResource.clear_session()
diff -r 1d15a4b5bb79 -r 072e73a4830e 
modules/web_shop_shopify/tests/test_module.py
--- a/modules/web_shop_shopify/tests/test_module.py     Thu Jul 03 16:49:46 
2025 +0200
+++ b/modules/web_shop_shopify/tests/test_module.py     Thu Oct 09 19:12:53 
2025 +0200
@@ -11,8 +11,9 @@
     module = 'web_shop_shopify'
     extras = [
         'carrier', 'customs', 'product_image', 'product_image_attribute',
-        'product_measurements', 'sale_discount', 'sale_invoice_grouping',
-        'sale_secondary_unit', 'sale_shipment_cost', 'stock_package_shipping']
+        'product_kit', 'product_measurements', 'sale_discount',
+        'sale_invoice_grouping', 'sale_secondary_unit', 'sale_shipment_cost',
+        'stock_package_shipping']
 
     def test_id2gid(self):
         "Test ID to GID"
diff -r 1d15a4b5bb79 -r 072e73a4830e 
modules/web_shop_shopify/tests/test_scenario.py
--- a/modules/web_shop_shopify/tests/test_scenario.py   Thu Jul 03 16:49:46 
2025 +0200
+++ b/modules/web_shop_shopify/tests/test_scenario.py   Thu Oct 09 19:12:53 
2025 +0200
@@ -13,5 +13,6 @@
         kwargs.setdefault('skips', set()).update({
                 'scenario_web_shop_shopify.rst',
                 'scenario_web_shop_shopify_secondary_unit.rst',
+                'scenario_web_shop_shopify_product_kit.rst',
                 })
     return load_doc_tests(__name__, __file__, *args, **kwargs)
diff -r 1d15a4b5bb79 -r 072e73a4830e modules/web_shop_shopify/tests/tools.py
--- a/modules/web_shop_shopify/tests/tools.py   Thu Jul 03 16:49:46 2025 +0200
+++ b/modules/web_shop_shopify/tests/tools.py   Thu Oct 09 19:12:53 2025 +0200
@@ -164,6 +164,11 @@
             }
             fulfillments {
                 id
+                fulfillmentLineItems(first: 10) {
+                    nodes {
+                        quantity
+                    }
+                }
             }
             closed
         }
diff -r 1d15a4b5bb79 -r 072e73a4830e modules/web_shop_shopify/tryton.cfg
--- a/modules/web_shop_shopify/tryton.cfg       Thu Jul 03 16:49:46 2025 +0200
+++ b/modules/web_shop_shopify/tryton.cfg       Thu Oct 09 19:12:53 2025 +0200
@@ -17,6 +17,7 @@
     customs
     product_image
     product_image_attribute
+    product_kit
     product_measurements
     sale_discount
     sale_invoice_grouping
@@ -81,6 +82,11 @@
 model:
     product.Image_Attribute
 
+[register product_kit]
+model:
+    stock.Move_Kit
+    sale.Line_Kit
+
 [register sale_discount]
 model:
     sale.Line_Discount

Reply via email to