feat: implement periodic shopify sales sync
- automated sales check (default 10 mins) - manual reconciliation menu - updates 'status' and 'shopify_status' in sheet - updated docs
This commit is contained in:
@ -9,7 +9,10 @@ The system allows you to:
|
|||||||
- Automatically upload product photos from Google Drive to Shopify.
|
- Automatically upload product photos from Google Drive to Shopify.
|
||||||
- specific triggers (`onEdit`, `onOpen`) to sync changes to Shopify in real-time or on-demand.
|
- specific triggers (`onEdit`, `onOpen`) to sync changes to Shopify in real-time or on-demand.
|
||||||
- Handle rate limiting and concurrency using a custom queue system.
|
- Handle rate limiting and concurrency using a custom queue system.
|
||||||
|
- Handle rate limiting and concurrency using a custom queue system.
|
||||||
- Monitor and troubleshoot background processes via a custom side panel.
|
- Monitor and troubleshoot background processes via a custom side panel.
|
||||||
|
- **Automated Sales Sync**: Periodically check Shopify for recent sales and mark items as "sold" in the sheet.
|
||||||
|
- **Manual Reconciliation**: Backfill sales data for a specific time range via menu command.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@ -52,6 +52,7 @@
|
|||||||
- `shopifyLocationId`: Location ID for inventory.
|
- `shopifyLocationId`: Location ID for inventory.
|
||||||
- `shopifyCountryCodeOfOrigin`: Two-letter country code (e.g., `US`).
|
- `shopifyCountryCodeOfOrigin`: Two-letter country code (e.g., `US`).
|
||||||
- `shopifyProvinceCodeOfOrigin`: Two-letter province code (e.g., `NY`).
|
- `shopifyProvinceCodeOfOrigin`: Two-letter province code (e.g., `NY`).
|
||||||
|
- `SalesSyncFrequency`: Interval (in minutes) to check for new sales. Valid: 1, 5, 10, 15, 30.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
"oauthScopes": [
|
"oauthScopes": [
|
||||||
"https://www.googleapis.com/auth/spreadsheets",
|
"https://www.googleapis.com/auth/spreadsheets",
|
||||||
"https://www.googleapis.com/auth/script.external_request",
|
"https://www.googleapis.com/auth/script.external_request",
|
||||||
"https://www.googleapis.com/auth/script.container.ui"
|
"https://www.googleapis.com/auth/script.container.ui",
|
||||||
|
"https://www.googleapis.com/auth/script.scriptapp",
|
||||||
|
"https://www.googleapis.com/auth/drive"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export class Config {
|
|||||||
shopifyLocationId: string
|
shopifyLocationId: string
|
||||||
shopifyCountryCodeOfOrigin: string
|
shopifyCountryCodeOfOrigin: string
|
||||||
shopifyProvinceCodeOfOrigin: string
|
shopifyProvinceCodeOfOrigin: string
|
||||||
|
salesSyncFrequency: number
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
let ss = SpreadsheetApp.getActive()
|
let ss = SpreadsheetApp.getActive()
|
||||||
@ -69,5 +70,12 @@ export class Config {
|
|||||||
"shopifyProvinceCodeOfOrigin",
|
"shopifyProvinceCodeOfOrigin",
|
||||||
"value"
|
"value"
|
||||||
)
|
)
|
||||||
|
let freq = vlookupByColumns(
|
||||||
|
"vars",
|
||||||
|
"key",
|
||||||
|
"SalesSyncFrequency",
|
||||||
|
"value"
|
||||||
|
)
|
||||||
|
this.salesSyncFrequency = freq ? parseInt(freq) : 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import {
|
|||||||
} from "./onEditQueue"
|
} from "./onEditQueue"
|
||||||
import { fillProductFromTemplate } from "./fillProductFromTemplate"
|
import { fillProductFromTemplate } from "./fillProductFromTemplate"
|
||||||
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
|
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
|
||||||
|
import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
|
||||||
|
import { installSalesSyncTrigger } from "./triggers"
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
;(global as any).onOpen = onOpen
|
;(global as any).onOpen = onOpen
|
||||||
@ -43,3 +45,6 @@ import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } fr
|
|||||||
;(global as any).setQueueEnabled = setQueueEnabled
|
;(global as any).setQueueEnabled = setQueueEnabled
|
||||||
;(global as any).deleteEdit = deleteEdit
|
;(global as any).deleteEdit = deleteEdit
|
||||||
;(global as any).pushEdit = pushEdit
|
;(global as any).pushEdit = pushEdit
|
||||||
|
;(global as any).checkRecentSales = checkRecentSales
|
||||||
|
;(global as any).reconcileSalesHandler = reconcileSalesHandler
|
||||||
|
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
|
||||||
|
|||||||
@ -2,7 +2,8 @@ import { getShopifyProducts, runShopifyOrders } from "./shopifyApi"
|
|||||||
import { fillProductFromTemplate } from "./fillProductFromTemplate"
|
import { fillProductFromTemplate } from "./fillProductFromTemplate"
|
||||||
import { createMissingPhotoFolders } from "./createMissingPhotoFolders"
|
import { createMissingPhotoFolders } from "./createMissingPhotoFolders"
|
||||||
import { matchProductToShopify, updateProductToShopify } from "./match"
|
import { matchProductToShopify, updateProductToShopify } from "./match"
|
||||||
import { reinstallTriggers } from "./triggers"
|
import { reinstallTriggers, installSalesSyncTrigger } from "./triggers"
|
||||||
|
import { reconcileSalesHandler } from "./salesSync"
|
||||||
import { toastAndLog } from "./sheetUtils"
|
import { toastAndLog } from "./sheetUtils"
|
||||||
import { showSidebar } from "./sidebar"
|
import { showSidebar } from "./sidebar"
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ export function initMenu() {
|
|||||||
.addItem("Create missing photo folders", createMissingPhotoFolders.name)
|
.addItem("Create missing photo folders", createMissingPhotoFolders.name)
|
||||||
.addItem("Run Shopify Orders", runShopifyOrders.name)
|
.addItem("Run Shopify Orders", runShopifyOrders.name)
|
||||||
.addItem("Get Shopify Products", getShopifyProducts.name)
|
.addItem("Get Shopify Products", getShopifyProducts.name)
|
||||||
|
.addItem("Reconcile Sales...", reconcileSalesHandler.name)
|
||||||
)
|
)
|
||||||
.addSeparator()
|
.addSeparator()
|
||||||
.addSubMenu(
|
.addSubMenu(
|
||||||
@ -30,6 +32,7 @@ export function initMenu() {
|
|||||||
.createMenu("Utilities...")
|
.createMenu("Utilities...")
|
||||||
.addItem("Reauthorize script", reauthorizeScript.name)
|
.addItem("Reauthorize script", reauthorizeScript.name)
|
||||||
.addItem("Reinstall triggers", reinstallTriggers.name)
|
.addItem("Reinstall triggers", reinstallTriggers.name)
|
||||||
|
.addItem("Update Sales Sync Trigger", installSalesSyncTrigger.name)
|
||||||
.addItem("Troubleshoot", showSidebar.name)
|
.addItem("Troubleshoot", showSidebar.name)
|
||||||
)
|
)
|
||||||
.addToUi()
|
.addToUi()
|
||||||
|
|||||||
111
src/salesSync.ts
Normal file
111
src/salesSync.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { Config } from "./config";
|
||||||
|
import { Shop } from "./shopifyApi";
|
||||||
|
import { Product } from "./Product";
|
||||||
|
import { getRowByColumnValue, getCellRangeByColumnName, toastAndLog } from "./sheetUtils";
|
||||||
|
|
||||||
|
// Declare SpreadsheetApp globally for Google Apps Script environment
|
||||||
|
declare var SpreadsheetApp: GoogleAppsScript.Spreadsheet.SpreadsheetApp;
|
||||||
|
|
||||||
|
export function checkRecentSales() {
|
||||||
|
console.log("Starting checkRecentSales...");
|
||||||
|
const config = new Config();
|
||||||
|
const freq = config.salesSyncFrequency || 10;
|
||||||
|
|
||||||
|
// 2.5x lookback
|
||||||
|
const now = new Date();
|
||||||
|
const lookbackMs = freq * 2.5 * 60 * 1000;
|
||||||
|
const startTime = new Date(now.getTime() - lookbackMs);
|
||||||
|
|
||||||
|
const shop = new Shop();
|
||||||
|
console.log(`Fetching orders from ${startTime.toISOString()} to ${now.toISOString()}`);
|
||||||
|
|
||||||
|
const orders = shop.FetchOrders(startTime, now);
|
||||||
|
console.log(`Found ${orders.length} orders.`);
|
||||||
|
|
||||||
|
syncOrders(orders, shop);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileSalesHandler() {
|
||||||
|
const ui = SpreadsheetApp.getUi();
|
||||||
|
const result = ui.prompt("Reconcile Sales", "Enter number of days to look back:", ui.ButtonSet.OK_CANCEL);
|
||||||
|
|
||||||
|
if (result.getSelectedButton() == ui.Button.OK) {
|
||||||
|
const days = parseInt(result.getResponseText());
|
||||||
|
if (isNaN(days) || days <= 0) {
|
||||||
|
toastAndLog("Invalid number of days.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toastAndLog(`Reconciling sales for last ${days} days...`);
|
||||||
|
const now = new Date();
|
||||||
|
const startTime = new Date(now.getTime() - (days * 24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
const shop = new Shop();
|
||||||
|
const orders = shop.FetchOrders(startTime, now);
|
||||||
|
toastAndLog(`Found ${orders.length} orders. Syncing...`);
|
||||||
|
|
||||||
|
syncOrders(orders, shop);
|
||||||
|
toastAndLog("Reconciliation complete.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncOrders(orders: any[], shop: Shop) {
|
||||||
|
const processedSkus = new Set<string>();
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
const lineItems = order.line_items;
|
||||||
|
if (!lineItems || !Array.isArray(lineItems)) continue;
|
||||||
|
|
||||||
|
for (const item of lineItems) {
|
||||||
|
const sku = item.sku;
|
||||||
|
if (!sku) continue;
|
||||||
|
|
||||||
|
if (processedSkus.has(sku)) continue;
|
||||||
|
processedSkus.add(sku);
|
||||||
|
|
||||||
|
console.log(`Processing sold SKU: ${sku}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = getRowByColumnValue("product_inventory", "sku", sku);
|
||||||
|
if (!row) {
|
||||||
|
console.log(`SKU ${sku} not found in sheet.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("product_inventory");
|
||||||
|
if (!sheet) {
|
||||||
|
console.error("Could not find product_inventory sheet");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Update status to 'sold'
|
||||||
|
const statusCell = getCellRangeByColumnName(sheet, "status", row);
|
||||||
|
if (statusCell) {
|
||||||
|
const currentStatus = statusCell.getValue();
|
||||||
|
if (currentStatus !== "sold") {
|
||||||
|
statusCell.setValue("sold");
|
||||||
|
console.log(`Set status='sold' for SKU ${sku}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Could not find 'status' column for SKU ${sku}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sync Shopify Status
|
||||||
|
// Use Product class to fetch fresh data
|
||||||
|
const product = new Product(sku);
|
||||||
|
product.MatchToShopifyProduct(shop);
|
||||||
|
|
||||||
|
if (product.shopify_product && product.shopify_product.status) {
|
||||||
|
const shopifyStatusCell = getCellRangeByColumnName(sheet, "shopify_status", row);
|
||||||
|
if (shopifyStatusCell) {
|
||||||
|
shopifyStatusCell.setValue(product.shopify_product.status);
|
||||||
|
console.log(`Updated shopify_status='${product.shopify_product.status}' for SKU ${sku}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error processing SKU ${sku}: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -482,6 +482,48 @@ export class Shop {
|
|||||||
} while (!done)
|
} while (!done)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FetchOrders(start: Date, end: Date) {
|
||||||
|
let endpoint = Shop.endpoints.orders
|
||||||
|
let start_str = start.toISOString()
|
||||||
|
let end_str = end.toISOString()
|
||||||
|
var params = {
|
||||||
|
created_at_min: start_str,
|
||||||
|
created_at_max: end_str,
|
||||||
|
fields: "id,created_at,financial_status,name,line_items,subtotal_price,total_price,total_tax",
|
||||||
|
status: "any"
|
||||||
|
}
|
||||||
|
|
||||||
|
var all_orders: any[] = []
|
||||||
|
var done = false
|
||||||
|
var next_link = ""
|
||||||
|
do {
|
||||||
|
var response
|
||||||
|
if (next_link === "") {
|
||||||
|
response = this.shopifyAPI(endpoint, params)
|
||||||
|
} else {
|
||||||
|
response = this.shopifyAPI(endpoint, params, next_link)
|
||||||
|
}
|
||||||
|
let resp = response.content
|
||||||
|
let headers = response.headers
|
||||||
|
var orders_arr = resp["orders"]
|
||||||
|
if (orders_arr.length > 0) {
|
||||||
|
all_orders = all_orders.concat(orders_arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headers["Link"] !== undefined) {
|
||||||
|
var links = parseLinkHeader(headers["Link"])
|
||||||
|
if (links["next"] === undefined) {
|
||||||
|
done = true
|
||||||
|
} else {
|
||||||
|
next_link = links["next"]["href"]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
done = true
|
||||||
|
}
|
||||||
|
} while (!done)
|
||||||
|
return all_orders
|
||||||
|
}
|
||||||
|
|
||||||
GetProducts() {
|
GetProducts() {
|
||||||
let done = false
|
let done = false
|
||||||
let query = ""
|
let query = ""
|
||||||
|
|||||||
@ -17,4 +17,35 @@ export function reinstallTriggers() {
|
|||||||
.timeBased()
|
.timeBased()
|
||||||
.everyMinutes(1)
|
.everyMinutes(1)
|
||||||
.create()
|
.create()
|
||||||
|
|
||||||
|
installSalesSyncTrigger()
|
||||||
|
}
|
||||||
|
|
||||||
|
import { Config } from "./config"
|
||||||
|
|
||||||
|
export function installSalesSyncTrigger() {
|
||||||
|
const config = new Config()
|
||||||
|
// Valid minute intervals for Apps Script
|
||||||
|
const valid = [1, 5, 10, 15, 30]
|
||||||
|
let freq = config.salesSyncFrequency || 10
|
||||||
|
|
||||||
|
if (valid.indexOf(freq) === -1) {
|
||||||
|
console.warn(`Invalid frequency ${freq}. Must be 1, 5, 10, 15, 30. Defaulting to 10.`)
|
||||||
|
freq = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete existing 'checkRecentSales' triggers to avoid duplicates
|
||||||
|
const triggers = ScriptApp.getProjectTriggers()
|
||||||
|
for (const t of triggers) {
|
||||||
|
if (t.getHandlerFunction() === "checkRecentSales") {
|
||||||
|
ScriptApp.deleteTrigger(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScriptApp.newTrigger("checkRecentSales")
|
||||||
|
.timeBased()
|
||||||
|
.everyMinutes(freq)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
console.log(`Installed 'checkRecentSales' trigger running every ${freq} minutes.`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user