Manage product market access by location stock status, with Mechanic.

Mechanic is a development and ecommerce automation platform for Shopify. :)

Manage product market access by location stock status

This task runs when inventory level changes occur at a configured location in the task, and it will check the stock status across all of that product's variants at each configured location. If the variants are all out of stock at a location, the task will unpublish the product from each catalog associated with each configured market for that location, thus denying the product from online sale in those markets. On the flipside, the task will publish a product to the related market catalogs if any of its variants are in stock at any of the configured locations for a market.

Runs Occurs whenever an inventory level is updated, Occurs when a user manually triggers the task, and Occurs when a bulk operation is completed. Configuration includes locations and market names, ignore products with any of these tags, ignore variants that do not track inventory, and ignore variants that are configured for overselling.

15-day free trial – unlimited tasks

Documentation

This task runs when inventory level changes occur at a configured location in the task, and it will check the stock status across all of that product's variants at each configured location. If the variants are all out of stock at a location, the task will unpublish the product from each catalog associated with each configured market for that location, thus denying the product from online sale in those markets. On the flipside, the task will publish a product to the related market catalogs if any of its variants are in stock at any of the configured locations for a market.

Configure the task with the location names on the left and the paired market names on the right. Multiple market names for a location should be entered on separate lines. Market names may be shared between locations, and only a single location needs to be in stock for a product to be published to that market.

The task may also be run manually to query for all active products in the shop and process them in the same manner. Optionally, enter tags for products that should be ignored completely by this task.

Notes:
- This task only processes region type markets
- In this task, a product is considered in stock at a location if ANY of its variants stocked at that location:
- have inventory tracking disabled OR
- have overselling enabled OR
- have > 0 "available" inventory
- The logic checks for inventory tracking and overselling can each be ignored through their respective task options; meaning the task will skip the variants which meet that criteria.

Developer details

Mechanic is designed to benefit everybody: merchants, customers, developers, agencies, Shopifolks, everybody.

That’s why we make it easy to configure automation without code, why we make it easy to tweak the underlying code once tasks are installed, and why we publish it all here for everyone to learn from.

(By the way, have you seen our documentation? Have you joined the Slack community?)

Open source
View on GitHub to contribute to this task
Subscriptions
shopify/inventory_levels/update
mechanic/user/trigger
mechanic/shopify/bulk_operation
Tasks use subscriptions to sign up for specific kinds of events. Learn more
Options
locations and market names (keyval, multiline, required) , ignore products with any of these tags (array) , ignore variants that do not track inventory (boolean) , ignore variants that are configured for overselling (boolean)
Code
{% comment %}
  -- using a keyval with multiline configuration field allows location names to be mapped to multiple markets
{% endcomment %}

{% assign locations_and_market_names = options.locations_and_market_names__keyval_multiline_required %}
{% assign ignore_products_with_any_of_these_tags = options.ignore_products_with_any_of_these_tags__array %}
{% assign ignore_variants_that_do_not_track_inventory = options.ignore_variants_that_do_not_track_inventory__boolean %}
{% assign ignore_variants_that_are_configured_for_overselling = options.ignore_variants_that_are_configured_for_overselling__boolean %}

{% if event.preview %}
  {% assign locations_and_market_names = hash %}
  {% assign locations_and_market_names["Warehouse"] = "International" %}
{% endif %}

{% assign location_names = locations_and_market_names | keys %}
{% assign market_names
  = locations_and_market_names
  | values
  | join: newline
  | split: newline
  | uniq
%}

{% assign location_names_and_ids = hash %}
{% assign inventory_level_inputs = array %}

{% comment %}
  -- check to make sure all of the location names configured in the task match locations in this shop
{% endcomment %}

{% for location_name in location_names %}
  {% capture query %}
    query {
      locations(
        first: 1
        query: {{ location_name | json | prepend: "name:" | json }}
      ) {
        nodes {
          id
          name
        }
      }
    }
  {% endcapture %}

  {% assign result = query | shopify %}

  {% if event.preview %}
    {% capture result_json %}
      {
        "data": {
          "locations": {
            "nodes": [
              {
                "id": "gid://shopify/Location/1234567890"
              }
            ]
          }
        }
      }
    {% endcapture %}

    {% assign result = result_json | parse_json %}
  {% endif %}

  {% comment %}
    -- using "echo" __error instead of log so that an error indicator shows in the event run log but the task still continues processing
  {% endcomment %}

  {% if result.data.locations == blank %}
    {% action "echo"
      __error: "Configured location name does not match a location in this shop.",
      location_name: location_name
    %}
    {% continue %}
  {% endif %}

  {% assign location_id = result.data.locations.nodes.first.id %}
  {% assign location_names_and_ids[location_name] = location_id %}

  {% comment %}
    -- save inventory level inputs for bulk querying so that only the locations configured in this task are returned
    -- have to set alias for the inventoryLevel field because GraphQL requires field name uniqueness within each returned node
  {% endcomment %}

  {% capture inventory_level_input %}
    {{ location_id | replace: "gid://shopify/Location/", "location_" }}: inventoryLevel(locationId: {{ location_id | json }}) {
      location {
        name
      }
      quantities(names: "available") {
        quantity
      }
    }
  {% endcapture %}

  {% assign inventory_level_inputs = inventory_level_inputs | push: inventory_level_input %}
{% endfor %}

{% comment %}
  -- get all region markets, their catalogs, and linked publications and save in a hash for quicker lookup
{% endcomment %}

{% assign cursor = nil %}
{% assign market_names_and_publication_ids = hash %}
{% assign published_inputs = array %}

{% for n in (1..100) %}
  {% capture query %}
    query {
      markets(
        first: 250
        after: {{ cursor | json }}
        type: REGION
      ) {
        pageInfo {
          hasNextPage
          endCursor
        }
        nodes {
          id
          name
          status
          catalogsCount {
            count
            precision
          }
          catalogs(first: 250) {
            nodes {
              id
              title
              publication {
                id
              }
            }
          }
        }
      }
    }
  {% endcapture %}

  {% assign result = query | shopify %}

  {% if event.preview %}
    {% capture result_json %}
      {
        "data": {
          "markets": {
            "nodes": [
              {
                "id": "gid://shopify/Market/1234567890",
                "name": "International",
                "status": "ACTIVE",
                "type": "REGION",
                "catalogsCount": {
                  "count": 2,
                  "precision": "EXACT"
                },
                "catalogs": {
                  "nodes": [
                    {
                      "id": "gid://shopify/MarketCatalog/1234567890",
                      "title": "International",
                      "publication": {
                        "id": "gid://shopify/Publication/1234567890"
                      }
                    },
                    {
                      "id": "gid://shopify/MarketCatalog/2345678901",
                      "title": "International - VIP",
                      "publication": {
                        "id": "gid://shopify/Publication/2345678901"
                      }
                    }
                  ]
                }
              }
            ]
          }
        }
      }
    {% endcapture %}

    {% assign result = result_json | parse_json %}
  {% endif %}

  {% assign markets = markets | concat: result.data.markets.nodes %}

  {% for market in result.data.markets.nodes %}
    {% assign publication_ids = market.catalogs.nodes | map: "publication" | map: "id" %}
    {% assign market_names_and_publication_ids[market.name] = publication_ids %}

    {% comment %}
      -- save published inputs for checking to see which publications a product is currently published to
      -- have to set alias for the publishedOnPublication field because GraphQL requires field name uniqueness within each returned node
    {% endcomment %}

    {% for publication_id in publication_ids %}
      {% capture published_input -%}
        published_{{ publication_id | remove: "gid://shopify/Publication/" }}: publishedOnPublication(publicationId: {{ publication_id | json }})
      {%- endcapture %}

      {% assign published_inputs = published_inputs | push: published_input %}
    {% endfor %}
  {% endfor %}

  {% if result.data.markets.pageInfo.hasNextPage %}
    {% assign cursor = result.data.markets.pageInfo.endCursor %}
  {% else %}
    {% break %}
  {% endif %}
{% endfor %}

{% comment %}
  -- check to make sure all of the market names configured in the task match markets in this shop
  -- using "echo" __error instead of log or error tags so that an error indicator shows in the event run log but the task still continues processing
{% endcomment %}

{% for market_name in market_names %}
  {% if market_names_and_publication_ids[market_name] == blank %}
    {% action "echo"
      __error: "Configured market name does not match a market in this shop.",
      market_name: market_name
    %}
  {% endif %}
{% endfor %}

{% log
  location_names_and_ids: location_names_and_ids,
  market_names_and_publication_ids: market_names_and_publication_ids
%}

{% if event.topic == "shopify/inventory_levels/update" %}
  {% comment %}
    -- use the inventory level to query available inventory for ALL of the product's variants at each configured location
    -- Shopify supports 100 variants per product (as of June 2023)
  {% endcomment %}

  {% capture query %}
    query {
      inventoryLevel(id: {{ inventory_level.admin_graphql_api_id | json }}) {
        location {
          name
        }
        item {
          variant {
            product {
              id
              title
              status
              tags
              {{ published_inputs | join: newline }}
              variants(first: 100) {
                nodes {
                  inventoryPolicy
                  inventoryItem {
                    tracked
                    {{ inventory_level_inputs | join: newline }}
                  }
                }
              }
            }
          }
        }
      }
    }
  {% endcapture %}

  {% assign result = query | shopify %}

  {% if event.preview %}
    {% capture result_json %}
      {
        "data": {
          "inventoryLevel": {
            "location": {
              "name": "Warehouse"
            },
            "item": {
              "variant": {
                "product": {
                  "id": "gid://shopify/Product/1234567890",
                  "title": "Widget",
                  "status": "ACTIVE",
                  "published_1234567890": true,
                  "variants": {
                    "nodes": [
                      {
                        "inventoryPolicy": "DENY",
                        "inventoryItem": {
                          "tracked": true,
                          "location_1234567890": {
                            "location": {
                              "name": "International"
                            },
                            "quantities": [
                              {
                                "quantity":0
                              }
                            ]
                          }
                        }
                      }
                    ]
                  }
                }
              }
            }
          }
        }
      }
    {% endcapture %}

    {% assign result = result_json | parse_json %}
  {% endif %}

  {% assign location_name = result.data.inventoryLevel.location.name %}
  {% assign product = result.data.inventoryLevel.item.variant.product %}

  {% comment %}
    -- stop processing if this product has any of the configured ignore tags
  {% endcomment %}

  {% assign has_ignore_tag = nil %}

  {% for ignore_tag in ignore_products_with_any_of_these_tags %}
    {% if product.tags contains ignore_tag %}
      {% assign has_ignore_tag = true %}
      {% break %}
    {% endif %}
  {% endfor %}

  {% if has_ignore_tag %}
    {% log
      message: "Product has one of the configured ignore tags and will be skipped.",
      product: product
    %}
    {% break %}
  {% endif %}

  {% comment %}
    -- stop processing if this location isn't configured in the task or if the product is not active
  {% endcomment %}

  {% unless location_names contains location_name and product.status == "ACTIVE" %}
    {% break %}
  {% endunless %}

  {% comment %}
    -- make sure the products processing loop only checks this specific product and location
  {% endcomment %}

  {% assign products = array | push: product %}

{% elsif event.topic == "mechanic/user/trigger" %}
  {% comment %}
    -- use bulk operation to query all active products, and their variants and paired inventory items in the shop
    -- only query for inventory levels of locations configured in task, using inputs captured earlier
    -- NOTE: bulk operation queries require edges and ids for each node, and __typename should be included for filtering returned data
  {% endcomment %}

  {% assign search_query = "status:active" %}

  {% for ignore_tag in ignore_products_with_any_of_these_tags %}
    {% assign search_query
      = ignore_tag
      | json
      | prepend: " tag_not:"
      | prepend: search_query
    %}
  {% endfor %}

  {% capture bulk_operation_query %}
    query {
      products(
        query: {{ search_query | json }}
      ) {
        edges {
          node {
            __typename
            id
            title
            {{ published_inputs | join: newline }}
            variants {
              edges {
                node {
                  __typename
                  id
                  inventoryPolicy
                  inventoryItem {
                    tracked
                    {{ inventory_level_inputs | join: newline }}
                  }
                }
              }
            }
          }
        }
      }
    }
  {% endcapture %}

  {% action "shopify" %}
    mutation {
      bulkOperationRunQuery(
        query: {{ bulk_operation_query | json }}
      ) {
        bulkOperation {
          id
          status
        }
        userErrors {
          field
          message
        }
      }
    }
  {% endaction %}

  {% break %}

{% elsif event.topic == "mechanic/shopify/bulk_operation" %}
  {% if event.preview %}
    {% capture jsonl_string %}
      {"__typename":"Product","id":"gid://shopify/Product/1234567890","title":"Widget","published_1234567890":true}
      {"__typename":"ProductVariant","inventoryPolicy": "DENY","inventoryItem":{"tracked":true,"location_1234567890":{"location":{"name":"International"},"quantities":[{"quantity":0}]}},"__parentId":"gid://shopify/Product/1234567890"}
    {% endcapture %}

    {% assign bulkOperation = hash %}
    {% assign bulkOperation["objects"] = jsonl_string | parse_jsonl %}
  {% endif %}

  {% assign products = bulkOperation.objects | where: "__typename", "Product" %}
  {% assign bulk_variants = bulkOperation.objects | where: "__typename", "ProductVariant" %}
{% endif %}

{% comment %}
  -- this loop will process all products returned by the bulk operation OR the single product which had an inventory level event
{% endcomment %}

{% assign publications_to_update = hash %}

{% for product in products %}
  {% comment %}
    -- variants array depends on how this task run was initiated
  {% endcomment %}

  {% if event.topic == "shopify/inventory_levels/update" %}
    {% assign variants = product.variants.nodes %}

  {% elsif event.topic == "mechanic/shopify/bulk_operation" %}
    {% assign variants = bulk_variants | where: "__parentId", product.id %}
  {% endif %}

  {% assign publication_ids_that_should_be_published = array %}
  {% assign publication_ids_that_should_be_unpublished = array %}

  {% for keyval in location_names_and_ids %}
    {% assign location_name = keyval[0] %}
    {% assign location_id = keyval[1] %}
    {% assign location_alias = location_id | replace: "gid://shopify/Location/", "location_" %}
    {% assign location_market_names = locations_and_market_names[location_name] | split: newline %}

    {% comment %}
      -- a product is considered in stock at a location IF
        a) ANY of the variants stocked at that location have inventory tracking disabled ~OR~
        b) ANY of the variants stocked at that location have overselling enabled ~OR~
        c) ANY of the variants stocked at that location have > 0 "available" inventory
      -- checks for a) and b) can each be disabled through task options; meaning the task will skip variants which meet that criteria
    {% endcomment %}

    {% assign location_in_stock = nil %}

    {% for variant in variants %}
      {% comment %}
        -- only proceed with inventory check if the variant is stocked at this location
      {% endcomment %}

      {% if variant.inventoryItem[location_alias] == blank  %}
        {% continue %}
      {% endif %}

      {% unless variant.inventoryItem.tracked %}
        {% comment %}
          -- variants that do not track inventory are considered in stock, unless the option to ignore them is checked
        {% endcomment %}

        {% if ignore_variants_that_do_not_track_inventory %}
          {% continue %}

        {% else %}
          {% assign location_in_stock = true %}
          {% break %}
        {% endif %}
      {% endunless %}

      {% if variant.inventoryPolicy == "CONTINUE" %}
        {% comment %}
          -- variants set for overselling are considered in stock, unless the option to ignore them is checked
        {% endcomment %}

        {% if ignore_variants_that_are_configured_for_overselling %}
          {% continue %}

        {% else %}
          {% assign location_in_stock = true %}
          {% break %}
        {% endif %}
      {% endif %}

      {% assign variant_available = variant.inventoryItem[location_alias].quantities.first.quantity %}

      {% if variant_available > 0 %}
        {% assign location_in_stock = true %}
        {% break %}
      {% endif %}
    {% endfor %}

    {% comment %}
      -- for each location market, add the associated publication IDs to appropriate un/publish array based on location stock status
    {% endcomment %}

    {% for location_market_name in location_market_names %}
      {% assign publication_ids = market_names_and_publication_ids[location_market_name] %}

      {% if location_in_stock %}
        {% assign publication_ids_that_should_be_published = publication_ids_that_should_be_published | concat: publication_ids %}
      {% else %}
        {% assign publication_ids_that_should_be_unpublished = publication_ids_that_should_be_unpublished | concat: publication_ids %}
      {% endif %}
    {% endfor %}
  {% endfor %}

  {% comment %}
    -- create publish inputs for each qualifying publication ID where the product isn't already published
  {% endcomment %}

  {% assign publish_inputs = array %}

  {% for publication_id in publication_ids_that_should_be_published %}
    {% assign published_alias = publication_id | replace: "gid://shopify/Publication/", "published_" %}

    {% unless product[published_alias] %}
      {% assign publish_input = hash %}
      {% assign publish_input["publicationId"] = publication_id %}
      {% assign publish_inputs = publish_inputs | push: publish_input %}
    {% endunless %}
  {% endfor %}

  {% comment %}
    -- create unpublish inputs for each qualifying publication ID where the product isn't already unpublished AND the publication ID does not appear in the "should be published array" (to account for locations that share a market)
  {% endcomment %}

  {% assign unpublish_inputs = array %}

  {% for publication_id in publication_ids_that_should_be_unpublished %}
    {% assign published_alias = publication_id | replace: "gid://shopify/Publication/", "published_" %}

    {% unless publication_ids_that_should_be_published contains publication_id %}
      {% if product[published_alias] %}
        {% assign unpublish_input = hash %}
        {% assign unpublish_input["publicationId"] = publication_id %}
        {% assign unpublish_inputs = unpublish_inputs | push: unpublish_input %}
      {% endif %}
    {% endunless %}
  {% endfor %}

  {% comment %}
    -- "ALLOW" or "DENY" a product access to/from markets by un/publishing it from the associated market publications
  {% endcomment %}

  {% if publish_inputs != blank %}
    {% action "shopify" %}
      mutation {
        publishablePublish(
          id: {{ product.id | json }}
          input: {{ publish_inputs | uniq | graphql_arguments }}
        ) {
          publishable {
            ... on Product {
              title
            }
          }
          userErrors {
            field
            message
          }
        }
      }
    {% endaction %}
  {% endif %}

  {% if unpublish_inputs != blank %}
    {% action "shopify" %}
      mutation {
        publishableUnpublish(
          id: {{ product.id | json }}
          input: {{ unpublish_inputs | uniq | graphql_arguments }}
        ) {
          publishable {
            ... on Product {
              title
            }
          }
          userErrors {
            field
            message
          }
        }
      }
    {% endaction %}
  {% endif %}
{% endfor %}
Task code is written in Mechanic Liquid, an extension of open-source Liquid enhanced for automation. Learn more