diff --git a/README.md b/README.md index 88b2bc7..ceeda5b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,10 @@ The system allows you to: - Automatically upload product photos from Google Drive to Shopify. - 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. - 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 diff --git a/docs/SETUP.md b/docs/SETUP.md index cbe31e1..811f103 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -52,6 +52,7 @@ - `shopifyLocationId`: Location ID for inventory. - `shopifyCountryCodeOfOrigin`: Two-letter country code (e.g., `US`). - `shopifyProvinceCodeOfOrigin`: Two-letter province code (e.g., `NY`). + - `SalesSyncFrequency`: Interval (in minutes) to check for new sales. Valid: 1, 5, 10, 15, 30. ## Deployment diff --git a/src/appsscript.json b/src/appsscript.json index 3285671..43cb781 100644 --- a/src/appsscript.json +++ b/src/appsscript.json @@ -7,6 +7,8 @@ "oauthScopes": [ "https://www.googleapis.com/auth/spreadsheets", "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" ] } diff --git a/src/config.ts b/src/config.ts index 710a88f..1c60770 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,7 @@ export class Config { shopifyLocationId: string shopifyCountryCodeOfOrigin: string shopifyProvinceCodeOfOrigin: string + salesSyncFrequency: number constructor() { let ss = SpreadsheetApp.getActive() @@ -69,5 +70,12 @@ export class Config { "shopifyProvinceCodeOfOrigin", "value" ) + let freq = vlookupByColumns( + "vars", + "key", + "SalesSyncFrequency", + "value" + ) + this.salesSyncFrequency = freq ? parseInt(freq) : 10 } } diff --git a/src/global.ts b/src/global.ts index d265f6a..32f3c6a 100644 --- a/src/global.ts +++ b/src/global.ts @@ -21,6 +21,8 @@ import { } from "./onEditQueue" import { fillProductFromTemplate } from "./fillProductFromTemplate" import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar" +import { checkRecentSales, reconcileSalesHandler } from "./salesSync" +import { installSalesSyncTrigger } from "./triggers" // prettier-ignore ;(global as any).onOpen = onOpen @@ -43,3 +45,6 @@ import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } fr ;(global as any).setQueueEnabled = setQueueEnabled ;(global as any).deleteEdit = deleteEdit ;(global as any).pushEdit = pushEdit +;(global as any).checkRecentSales = checkRecentSales +;(global as any).reconcileSalesHandler = reconcileSalesHandler +;(global as any).installSalesSyncTrigger = installSalesSyncTrigger diff --git a/src/initMenu.ts b/src/initMenu.ts index 0eb6802..6f49184 100644 --- a/src/initMenu.ts +++ b/src/initMenu.ts @@ -2,7 +2,8 @@ import { getShopifyProducts, runShopifyOrders } from "./shopifyApi" import { fillProductFromTemplate } from "./fillProductFromTemplate" import { createMissingPhotoFolders } from "./createMissingPhotoFolders" import { matchProductToShopify, updateProductToShopify } from "./match" -import { reinstallTriggers } from "./triggers" +import { reinstallTriggers, installSalesSyncTrigger } from "./triggers" +import { reconcileSalesHandler } from "./salesSync" import { toastAndLog } from "./sheetUtils" import { showSidebar } from "./sidebar" @@ -23,6 +24,7 @@ export function initMenu() { .addItem("Create missing photo folders", createMissingPhotoFolders.name) .addItem("Run Shopify Orders", runShopifyOrders.name) .addItem("Get Shopify Products", getShopifyProducts.name) + .addItem("Reconcile Sales...", reconcileSalesHandler.name) ) .addSeparator() .addSubMenu( @@ -30,6 +32,7 @@ export function initMenu() { .createMenu("Utilities...") .addItem("Reauthorize script", reauthorizeScript.name) .addItem("Reinstall triggers", reinstallTriggers.name) + .addItem("Update Sales Sync Trigger", installSalesSyncTrigger.name) .addItem("Troubleshoot", showSidebar.name) ) .addToUi() diff --git a/src/salesSync.ts b/src/salesSync.ts new file mode 100644 index 0000000..c80eed2 --- /dev/null +++ b/src/salesSync.ts @@ -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(); + + 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}`); + } + } + } +} diff --git a/src/shopifyApi.ts b/src/shopifyApi.ts index 4c2cb83..a2068e2 100644 --- a/src/shopifyApi.ts +++ b/src/shopifyApi.ts @@ -482,6 +482,48 @@ export class Shop { } 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() { let done = false let query = "" diff --git a/src/triggers.ts b/src/triggers.ts index a6c78d9..0a44105 100644 --- a/src/triggers.ts +++ b/src/triggers.ts @@ -17,4 +17,35 @@ export function reinstallTriggers() { .timeBased() .everyMinutes(1) .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.`) }