Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
f171eb5
chore(release): Bumped to Version 1.17.1
frappe-bot Jun 5, 2023
b59a1ed
chore: ignore if unicommerce is not enabled
ankush Jun 5, 2023
c6b487a
Merge branch 'develop'
ankush Jun 22, 2023
394a2e6
chore(release): Bumped to Version 1.17.2
frappe-bot Jun 22, 2023
f8d61ec
Merge branch 'develop'
ankush Jun 29, 2023
f346383
chore(release): Bumped to Version 1.17.3
frappe-bot Jun 29, 2023
a524ec9
Merge pull request #255 from frappe/develop
ankush Jul 7, 2023
4c4b077
chore(release): Bumped to Version 1.17.4
frappe-bot Jul 7, 2023
fda8e76
Merge branch 'develop'
s-aga-r Aug 2, 2023
ee9def8
chore(release): Bumped to Version 1.18.0
frappe-bot Aug 2, 2023
889531b
Merge branch 'develop'
s-aga-r Aug 3, 2023
34cfb56
chore(release): Bumped to Version 1.18.1
frappe-bot Aug 3, 2023
94581e6
Merge branch 'develop'
s-aga-r Aug 3, 2023
b0feb7b
chore(release): Bumped to Version 1.18.2
frappe-bot Aug 3, 2023
187ffdb
fix: shopify default customer (#270)
s-aga-r Oct 9, 2023
0047478
chore(release): Bumped to Version 1.18.3
frappe-bot Oct 9, 2023
a976469
Merge pull request #272 from frappe/develop
ankush Oct 17, 2023
3a00eb9
chore(release): Bumped to Version 1.18.4
frappe-bot Oct 17, 2023
48d9077
Merge branch 'develop'
ankush Oct 24, 2023
e584872
chore(release): Bumped to Version 1.19.0
frappe-bot Oct 24, 2023
626d730
Merge pull request #276 from frappe/develop
ankush Oct 31, 2023
5197835
chore(release): Bumped to Version 1.19.1
frappe-bot Oct 31, 2023
425520d
Merge branch 'develop'
ankush Nov 8, 2023
c62e360
chore(release): Bumped to Version 1.19.2
frappe-bot Nov 8, 2023
f9a9dd1
fix: error when there is no billing address is shopify order (#283)
saadsafda Nov 20, 2023
02a2e7f
chore(release): Bumped to Version 1.19.3
frappe-bot Nov 20, 2023
c5ab362
chore: bump `boto3`
s-aga-r Nov 29, 2023
a7c4e6e
Merge pull request #288 from frappe/develop
ankush Dec 4, 2023
2e06e87
chore(release): Bumped to Version 1.19.4
frappe-bot Dec 4, 2023
94a33ad
Merge pull request #291 from frappe/develop
ankush Dec 5, 2023
6790770
chore(release): Bumped to Version 1.19.5
frappe-bot Dec 5, 2023
9554341
Merge branch 'develop'
ankush Dec 7, 2023
1480e7a
chore(release): Bumped to Version 1.19.6
frappe-bot Dec 7, 2023
8d41768
fix: shopify sync issue without customer
rohitwaghchaure Jan 19, 2024
49cec24
Merge pull request #302 from rohitwaghchaure/fixed-shopify-sync-issue…
rohitwaghchaure Jan 19, 2024
d7ed829
Merge pull request #306 from frappe/develop
ankush Feb 7, 2024
1ea1f73
ci: use node 20 for release
ankush Feb 7, 2024
c4db7c0
chore(release): Bumped to Version 1.20.0
frappe-bot Feb 7, 2024
1465cfe
Merge pull request #311 from frappe/develop
ankush Mar 27, 2024
c12600d
chore(release): Bumped to Version 1.20.1
frappe-bot Mar 27, 2024
80f30bc
fix: Add required_apps hooks for erpnext
shadrak98 Aug 11, 2025
1bcb69f
Merge pull request #383 from frappe/mergify/bp/main/pr-371
Vigneshsekar Sep 23, 2025
16cd5c1
chore(release): Bumped to Version 1.20.2
frappe-bot Sep 23, 2025
7840d52
Skyway: Shopify one-way stock push + default-warehouse hook + recreat…
Connect4systems Nov 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- name: Setup Node.js v14
uses: actions/setup-node@v2
with:
node-version: 18
node-version: 20
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save
Expand Down
2 changes: 1 addition & 1 deletion ecommerce_integrations/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.17.0"
__version__ = "1.20.2"
1 change: 1 addition & 0 deletions ecommerce_integrations/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
app_color = "grey"
app_email = "[email protected]"
app_license = "GNU GPL v3.0"
required_apps = ["frappe/erpnext"]

# Includes in <head>
# ------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2023-10-24 10:38:49.247431",
"modified": "2025-09-28 19:36:38.323699",
"modified_by": "Administrator",
"module": "shopify",
"name": "Shopify Setting",
Expand All @@ -407,8 +407,19 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Website Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
Expand Down
220 changes: 161 additions & 59 deletions ecommerce_integrations/shopify/inventory.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from collections import Counter

import frappe
Expand All @@ -6,88 +7,189 @@
from shopify.resources import InventoryLevel, Variant

from ecommerce_integrations.controllers.inventory import (
get_inventory_levels,
update_inventory_sync_status,
get_inventory_levels,
update_inventory_sync_status,
)
from ecommerce_integrations.controllers.scheduling import need_to_run
from ecommerce_integrations.shopify.connection import temp_shopify_session
from ecommerce_integrations.shopify.constants import MODULE_NAME, SETTING_DOCTYPE
from ecommerce_integrations.shopify.utils import create_shopify_log


def update_inventory_on_shopify() -> None:
"""Upload stock levels from ERPNext to Shopify.

Called by scheduler on configured interval.
"""
setting = frappe.get_doc(SETTING_DOCTYPE)
def _log(status: str, message: str, data=None, method: str = "update_inventory_on_shopify"):
create_shopify_log(status=status, message=message, request_data=data or {}, method=method)

if not setting.is_enabled() or not setting.update_erpnext_stock_levels_to_shopify:
return

if not need_to_run(SETTING_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync"):
return
def _filter_to_flagged_items(inventory_levels):
"""Keep rows for Items with custom_sync_to_shopify = 1 and not disabled."""
if not inventory_levels:
return []

warehous_map = setting.get_erpnext_to_integration_wh_mapping()
inventory_levels = get_inventory_levels(tuple(warehous_map.keys()), MODULE_NAME)
ecom_names = [d.ecom_item for d in inventory_levels if getattr(d, "ecom_item", None)]
if not ecom_names:
return []

if inventory_levels:
upload_inventory_data_to_shopify(inventory_levels, warehous_map)
ecom_rows = frappe.get_all(
"Ecommerce Item",
filters={"name": ["in", ecom_names], "integration": MODULE_NAME},
fields=["name", "erpnext_item_code"],
)
ecom_to_item = {r["name"]: r["erpnext_item_code"] for r in ecom_rows if r.get("erpnext_item_code")}

allowed_items = set(
frappe.get_all(
"Item",
filters={"name": ["in", list(ecom_to_item.values())], "custom_sync_to_shopify": 1, "disabled": 0},
pluck="name",
)
)

@temp_shopify_session
def upload_inventory_data_to_shopify(inventory_levels, warehous_map) -> None:
synced_on = now()
filtered = []
for d in inventory_levels:
erp_item = ecom_to_item.get(getattr(d, "ecom_item", ""))
if erp_item in allowed_items:
filtered.append(d)

for inventory_sync_batch in create_batch(inventory_levels, 50):
for d in inventory_sync_batch:
d.shopify_location_id = warehous_map[d.warehouse]
return filtered

try:
variant = Variant.find(d.variant_id)
inventory_id = variant.inventory_item_id

InventoryLevel.set(
location_id=d.shopify_location_id,
inventory_item_id=inventory_id,
# shopify doesn't support fractional quantity
available=cint(d.actual_qty) - cint(d.reserved_qty),
)
update_inventory_sync_status(d.ecom_item, time=synced_on)
d.status = "Success"
except ResourceNotFound:
# Variant or location is deleted, mark as last synced and ignore.
update_inventory_sync_status(d.ecom_item, time=synced_on)
d.status = "Not Found"
except Exception as e:
d.status = "Failed"
d.failure_reason = str(e)
def _commit_row_and_continue(d, synced_on):
try:
if getattr(d, "ecom_item", None):
update_inventory_sync_status(getattr(d, "ecom_item"), time=synced_on)
except Exception:
pass
frappe.db.commit()

frappe.db.commit()

_log_inventory_update_status(inventory_sync_batch)
def _log_batch_status(inventory_levels) -> None:
"""Per-row status summary in Shopify Log."""
log_message = "variant_id,location_id,status,failure_reason\n"
log_message += "\n".join(
f"{getattr(d,'variant_id','')},{getattr(d,'shopify_location_id','')},"
f"{getattr(d,'status','')},{getattr(d,'failure_reason','') or ''}"
for d in inventory_levels
)

statuses = [getattr(d, "status", "Failed") for d in inventory_levels]
stats = Counter(statuses)
total = max(len(inventory_levels), 1)
pct = stats.get("Success", 0) / total

def _log_inventory_update_status(inventory_levels) -> None:
"""Create log of inventory update."""
log_message = "variant_id,location_id,status,failure_reason\n"
status = "Success" if pct == 1 else ("Partial Success" if pct > 0 else "Failed")
_log(status, f"Updated {pct * 100}% items\n\n{log_message}")

log_message += "\n".join(
f"{d.variant_id},{d.shopify_location_id},{d.status},{d.failure_reason or ''}"
for d in inventory_levels
)

stats = Counter([d.status for d in inventory_levels])
@temp_shopify_session
def upload_inventory_data_to_shopify(inventory_levels, warehouse_map) -> None:
"""Push inventory to Shopify for each row (Default Warehouse only)."""
synced_on = now()

for batch in create_batch(inventory_levels, 50):
for d in batch:
# force single mapped location for Default Warehouse
try:
d.shopify_location_id = int(warehouse_map[d.warehouse])
except Exception:
d.status = "Failed"
d.failure_reason = f"No numeric Shopify Location for ERP Warehouse: {d.warehouse}"
_commit_row_and_continue(d, synced_on)
continue

try:
if not getattr(d, "variant_id", None):
d.status = "Failed"
d.failure_reason = "Missing variant_id in Ecommerce Item mapping."
_commit_row_and_continue(d, synced_on)
continue

variant = Variant.find(d.variant_id)
inventory_item_id = getattr(variant, "inventory_item_id", None)
if not inventory_item_id:
d.status = "Failed"
d.failure_reason = f"Shopify variant {d.variant_id} has no inventory_item_id."
_commit_row_and_continue(d, synced_on)
continue

available = cint(d.actual_qty) - cint(d.reserved_qty) # Shopify wants integers
InventoryLevel.set(
location_id=d.shopify_location_id,
inventory_item_id=inventory_item_id,
available=int(available),
)
update_inventory_sync_status(d.ecom_item, time=synced_on)
d.status = "Success"

except ResourceNotFound:
update_inventory_sync_status(d.ecom_item, time=synced_on)
d.status = "Not Found"
d.failure_reason = (
f"Variant or Location not found. variant_id={getattr(d,'variant_id',None)} "
f"loc={getattr(d,'shopify_location_id',None)}"
)
except Exception as e:
d.status = "Failed"
d.failure_reason = str(e)

frappe.db.commit()

_log_batch_status(batch)


def _get_numeric_location_id(setting, default_wh: str) -> int:
erp_to_shop = setting.get_erpnext_to_integration_wh_mapping() or {}
if not default_wh or default_wh not in erp_to_shop:
raise Exception(f"No Shopify Location mapping for Default Warehouse: {default_wh}")
try:
return int(erp_to_shop[default_wh])
except Exception:
raise Exception(
f"Shopify Location ID must be numeric. Got '{erp_to_shop[default_wh]}' for '{default_wh}'."
)


def _run_push_for_default_warehouse(setting) -> None:
"""Core one-way push using ONLY the Default Warehouse on Shopify Setting."""
if not setting.is_enabled() or not setting.update_erpnext_stock_levels_to_shopify:
return

default_wh = getattr(setting, "warehouse", None)
if not default_wh:
_log("Error", "Default Warehouse is not set on Shopify Setting (field: warehouse).")
return

location_id = None
try:
location_id = _get_numeric_location_id(setting, default_wh)
except Exception as e:
_log("Error", str(e))
return

# inventory rows for only the Default Warehouse
inventory_levels = get_inventory_levels((default_wh,), MODULE_NAME)
if not inventory_levels:
_log("Success", f"No inventory rows found for Default Warehouse: {default_wh}")
return

# keep only flagged items
inventory_levels = _filter_to_flagged_items(inventory_levels)
if not inventory_levels:
_log("Success", "No flagged items to sync (custom_sync_to_shopify=1 & not disabled).")
return

# push
upload_inventory_data_to_shopify(inventory_levels, {default_wh: location_id})

percent_successful = stats["Success"] / len(inventory_levels)

if percent_successful == 0:
status = "Failed"
elif percent_successful < 1:
status = "Partial Success"
else:
status = "Success"
def update_inventory_on_shopify() -> None:
"""Scheduled job entrypoint – one-way push from ERPNext to Shopify."""
setting = frappe.get_doc(SETTING_DOCTYPE)
if not need_to_run(SETTING_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync"):
return
_run_push_for_default_warehouse(setting)

log_message = f"Updated {percent_successful * 100}% items\n\n" + log_message

create_shopify_log(method="update_inventory_on_shopify", status=status, message=log_message)
def update_inventory_on_shopify_now() -> None:
"""Immediate manual push (can be called from console or other code)."""
setting = frappe.get_doc(SETTING_DOCTYPE)
_run_push_for_default_warehouse(setting)
Loading