feat: implement status automation and router pattern
- Implemented modular status automation system (statusHandlers.ts). - Added handlers for 'Published' (Active/Qty 1), 'Sold' (Active/Qty 0), and 'Drafted'. - Refactored onEdit triggers into a central Router pattern in OnEditHandler.ts. - Updated Product.ts to support explicit quantity setting (fixed 0 value bug). - Updated shopifyApi.ts to implement SetInventoryItemQuantity (using ignoreCompareQuantity). - Consolidated triggers into single onEditHandler. - Updated project documentation.
This commit is contained in:
@ -6,3 +6,4 @@ This file serves as a guide for future sessions working on this codebase.
|
|||||||
2. **Update Memory**: If we make significant architectural decisions or change our working patterns, update `MEMORY.md` to reflect this.
|
2. **Update Memory**: If we make significant architectural decisions or change our working patterns, update `MEMORY.md` to reflect this.
|
||||||
3. **Check Documentation**: `README.md`, `docs/ARCHITECTURE.md`, and `docs/SETUP.md` are the sources of truth for the system. Keep them updated as code changes.
|
3. **Check Documentation**: `README.md`, `docs/ARCHITECTURE.md`, and `docs/SETUP.md` are the sources of truth for the system. Keep them updated as code changes.
|
||||||
4. **Task Tracking**: Use the `task.md` artifact to track progress on multi-step tasks.
|
4. **Task Tracking**: Use the `task.md` artifact to track progress on multi-step tasks.
|
||||||
|
5. **Shopify API Reference**: When developing features involving Shopify, **ALWAYS** check the [Shopify API Reference](https://shopify.dev/docs/api/admin-graphql) for the specific version in use (check `shopifyApi.ts` for version). Do not guess field names or structure.
|
||||||
|
|||||||
@ -13,6 +13,7 @@ The system allows you to:
|
|||||||
- 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.
|
- **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.
|
- **Manual Reconciliation**: Backfill sales data for a specific time range via menu command.
|
||||||
|
- **Status Workflow Automation**: Automatically update Shopify status and inventory based on the sheet's "status" column (e.g., "Sold" -> Active, 0 Qty).
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@ -59,13 +59,19 @@ Configuration, including API keys, is stored in a dedicated Google Sheet named "
|
|||||||
|
|
||||||
Since Apps Script functions must be top-level to be triggered or attached to buttons, `src/global.ts` explicitly exposes necessary functions from the modules to the global scope.
|
Since Apps Script functions must be top-level to be triggered or attached to buttons, `src/global.ts` explicitly exposes necessary functions from the modules to the global scope.
|
||||||
|
|
||||||
|
### 5. Status Automation (`src/statusHandlers.ts`)
|
||||||
|
|
||||||
|
A modular system handles changes to the `status` column. It uses a registry of `StatusHandler` implementations:
|
||||||
|
- **Published**: Sets Shopify Status `ACTIVE`, Quantity `1`.
|
||||||
|
- **Sold/Artist Swap**: Sets Shopify Status `ACTIVE`, Quantity `0`.
|
||||||
|
- **Drafted**: Sets Shopify Status `DRAFT`.
|
||||||
|
|
||||||
## Triggers
|
## Triggers
|
||||||
|
|
||||||
Triggers are managed programmatically via `src/triggers.ts`. Running `reinstallTriggers` will wipe existing project triggers and set up the standard set:
|
Triggers are managed programmatically via `src/triggers.ts`. Running `reinstallTriggers` will wipe existing project triggers and set up the standard set:
|
||||||
- `onEdit` -> `newSkuHandler`
|
- `onEdit` -> `onEditHandler` (Main Router)
|
||||||
- `onEdit` -> `matchProductToShopifyOnEditHandler`
|
|
||||||
- `onEdit` -> `onEditQueue`
|
|
||||||
- `TimeBased (1 min)` -> `processBatchedEdits`
|
- `TimeBased (1 min)` -> `processBatchedEdits`
|
||||||
|
- `TimeBased (10 min)` -> `checkRecentSales`
|
||||||
|
|
||||||
### 5. Troubleshooting Panel (`src/sidebar.ts`, `src/Sidebar.html`)
|
### 5. Troubleshooting Panel (`src/sidebar.ts`, `src/Sidebar.html`)
|
||||||
|
|
||||||
|
|||||||
@ -4,10 +4,15 @@ import { getCellRangeByColumnName } from "./sheetUtils"
|
|||||||
import { matchProductToShopify, updateProductToShopify } from "./match"
|
import { matchProductToShopify, updateProductToShopify } from "./match"
|
||||||
import { getColumnName, toastAndLog } from "./sheetUtils"
|
import { getColumnName, toastAndLog } from "./sheetUtils"
|
||||||
|
|
||||||
|
import { onEditQueue } from "./onEditQueue"
|
||||||
|
import { statusOnEditHandler } from "./statusHandlers"
|
||||||
|
|
||||||
export function onEditHandler(e: GoogleAppsScript.Events.SheetsOnEdit) {
|
export function onEditHandler(e: GoogleAppsScript.Events.SheetsOnEdit) {
|
||||||
//TODO: process each edited row
|
// Router pattern: execute all handlers
|
||||||
newSkuHandler(e)
|
newSkuHandler(e)
|
||||||
matchProductToShopifyOnEditHandler(e)
|
matchProductToShopifyOnEditHandler(e)
|
||||||
|
onEditQueue(e)
|
||||||
|
statusOnEditHandler(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchProductToShopifyOnEditHandler(
|
export function matchProductToShopifyOnEditHandler(
|
||||||
|
|||||||
@ -39,6 +39,7 @@ export class Product {
|
|||||||
product_depth_cm: number = 0
|
product_depth_cm: number = 0
|
||||||
product_height_cm: number = 0
|
product_height_cm: number = 0
|
||||||
photos: string = ""
|
photos: string = ""
|
||||||
|
quantity: number | null = null
|
||||||
shopify_product: shopify.Product
|
shopify_product: shopify.Product
|
||||||
shopify_default_variant_id: string = ""
|
shopify_default_variant_id: string = ""
|
||||||
shopify_default_option_id: string = ""
|
shopify_default_option_id: string = ""
|
||||||
@ -76,7 +77,7 @@ export class Product {
|
|||||||
console.log("skipping '" + headers[i] + "'")
|
console.log("skipping '" + headers[i] + "'")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (productValues[i] == "") {
|
if (productValues[i] === "") {
|
||||||
console.log(
|
console.log(
|
||||||
"keeping '" + headers[i] + "' default: '" + this[headers[i]] + "'"
|
"keeping '" + headers[i] + "' default: '" + this[headers[i]] + "'"
|
||||||
)
|
)
|
||||||
@ -211,7 +212,10 @@ export class Product {
|
|||||||
shopify.WeightUnit.GRAMS
|
shopify.WeightUnit.GRAMS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (newProduct) {
|
if (this.quantity !== null) {
|
||||||
|
console.log("UpdateShopifyProduct: setting inventory item quantity to " + this.quantity)
|
||||||
|
shop.SetInventoryItemQuantity(item, this.quantity, config)
|
||||||
|
} else if (newProduct) {
|
||||||
console.log("UpdateShopifyProduct: setting defaults on new product")
|
console.log("UpdateShopifyProduct: setting defaults on new product")
|
||||||
console.log("UpdateShopifyProduct: adjusting inventory item quantity")
|
console.log("UpdateShopifyProduct: adjusting inventory item quantity")
|
||||||
shop.UpdateInventoryItemQuantity(item, 1, config)
|
shop.UpdateInventoryItemQuantity(item, 1, config)
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
import { createMissingPhotoFolders } from "./createMissingPhotoFolders"
|
import { createMissingPhotoFolders } from "./createMissingPhotoFolders"
|
||||||
import { reinstallTriggers } from "./triggers"
|
import { reinstallTriggers } from "./triggers"
|
||||||
import { newSkuHandler } from "./newSku"
|
import { newSkuHandler } from "./newSku"
|
||||||
import { columnOnEditHandler } from "./OnEditHandler"
|
import { columnOnEditHandler, onEditHandler } from "./OnEditHandler"
|
||||||
import {
|
import {
|
||||||
onEditQueue,
|
onEditQueue,
|
||||||
processBatchedEdits
|
processBatchedEdits
|
||||||
@ -33,6 +33,7 @@ import { installSalesSyncTrigger } from "./triggers"
|
|||||||
;(global as any).matchProductToShopifyOnEditHandler = matchProductToShopifyOnEditHandler
|
;(global as any).matchProductToShopifyOnEditHandler = matchProductToShopifyOnEditHandler
|
||||||
;(global as any).updateShopifyProductHandler = updateShopifyProductHandler
|
;(global as any).updateShopifyProductHandler = updateShopifyProductHandler
|
||||||
;(global as any).columnOnEditHandler = columnOnEditHandler
|
;(global as any).columnOnEditHandler = columnOnEditHandler
|
||||||
|
;(global as any).onEditHandler = onEditHandler
|
||||||
;(global as any).onEditQueue = onEditQueue
|
;(global as any).onEditQueue = onEditQueue
|
||||||
;(global as any).processBatchedEdits = processBatchedEdits
|
;(global as any).processBatchedEdits = processBatchedEdits
|
||||||
;(global as any).reauthorizeScript = reauthorizeScript
|
;(global as any).reauthorizeScript = reauthorizeScript
|
||||||
|
|||||||
@ -671,6 +671,49 @@ export class Shop {
|
|||||||
return newItem
|
return newItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetInventoryItemQuantity(
|
||||||
|
item: shopify.InventoryItem,
|
||||||
|
quantity: number,
|
||||||
|
config: Config
|
||||||
|
) {
|
||||||
|
console.log("SetInventoryItemQuantity(" + JSON.stringify(item) + ", " + quantity + ")")
|
||||||
|
let gql = /* GraphQL */ `
|
||||||
|
mutation inventorySetQuantities($input: InventorySetQuantitiesInput!) {
|
||||||
|
inventorySetQuantities(input: $input) {
|
||||||
|
inventoryAdjustmentGroup {
|
||||||
|
changes {
|
||||||
|
name
|
||||||
|
delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
let variables = {
|
||||||
|
input: {
|
||||||
|
name: "available",
|
||||||
|
reason: "correction",
|
||||||
|
ignoreCompareQuantity: true,
|
||||||
|
quantities: [
|
||||||
|
{
|
||||||
|
inventoryItemId: item.id,
|
||||||
|
locationId: config.shopifyLocationId,
|
||||||
|
quantity: quantity,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let query = buildGqlQuery(gql, variables)
|
||||||
|
let response = this.shopifyGraphQLAPI(query)
|
||||||
|
// Response structure is different for setQuantities
|
||||||
|
console.log("SetInventoryItemQuantity response:\n" + JSON.stringify(response, null, 2))
|
||||||
|
return response.content
|
||||||
|
}
|
||||||
|
|
||||||
SetInventoryItemDefaults(item: shopify.InventoryItem, config: Config) {
|
SetInventoryItemDefaults(item: shopify.InventoryItem, config: Config) {
|
||||||
let gql = /* GraphQL */ `
|
let gql = /* GraphQL */ `
|
||||||
mutation inventoryItemUpdate($id: ID!, $input: InventoryItemInput!) {
|
mutation inventoryItemUpdate($id: ID!, $input: InventoryItemInput!) {
|
||||||
|
|||||||
89
src/statusHandlers.ts
Normal file
89
src/statusHandlers.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { getCellRangeByColumnName, getColumnName, toastAndLog } from "./sheetUtils"
|
||||||
|
|
||||||
|
export function statusOnEditHandler(e: GoogleAppsScript.Events.SheetsOnEdit) {
|
||||||
|
const sheet = e.range.getSheet()
|
||||||
|
if (sheet.getName() !== "product_inventory") return
|
||||||
|
|
||||||
|
const range = e.range
|
||||||
|
const col = range.getColumn()
|
||||||
|
// Optimization: Check if we are in the 'status' column (usually relatively early or fixed position,
|
||||||
|
// but looking up by name is safer against loose columns).
|
||||||
|
// Note: getColumnName technically opens the sheet again, but it's cached in Apps Script context usually.
|
||||||
|
const header = getColumnName("product_inventory", col)
|
||||||
|
|
||||||
|
if (header !== "status") return
|
||||||
|
|
||||||
|
// Handle multiple rows edit?
|
||||||
|
// Current requirement implies single row interactions, but let's just handle the top-left cell
|
||||||
|
// of the range for now or iterate if needed.
|
||||||
|
// Given e.value is used for single cell, let's stick to simple single-line logic or iterate.
|
||||||
|
// Safest to iterate if multiple rows selected.
|
||||||
|
|
||||||
|
const numRows = range.getNumRows()
|
||||||
|
const startRow = range.getRow()
|
||||||
|
|
||||||
|
// If e.value is present, it's a single cell edit.
|
||||||
|
// If not, it might be a paste.
|
||||||
|
|
||||||
|
const values = range.getValues() // 2D array
|
||||||
|
|
||||||
|
for (let i = 0; i < numRows; i++) {
|
||||||
|
const row = startRow + i
|
||||||
|
const val = String(values[i][0]).toLowerCase()
|
||||||
|
|
||||||
|
const handler = handlers[val]
|
||||||
|
if (handler) {
|
||||||
|
console.log(`Executing handler for status: ${val} on row ${row}`)
|
||||||
|
handler.handle(sheet, row)
|
||||||
|
} else {
|
||||||
|
console.log(`No specific action for status: ${val} on row ${row}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusHandler {
|
||||||
|
handle(sheet: GoogleAppsScript.Spreadsheet.Sheet, row: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
class PublishedHandler implements StatusHandler {
|
||||||
|
handle(sheet: GoogleAppsScript.Spreadsheet.Sheet, row: number) {
|
||||||
|
const statusCell = getCellRangeByColumnName(sheet, "shopify_status", row)
|
||||||
|
if (statusCell) statusCell.setValue("ACTIVE")
|
||||||
|
|
||||||
|
const qtyCell = getCellRangeByColumnName(sheet, "quantity", row)
|
||||||
|
if (qtyCell) qtyCell.setValue(1)
|
||||||
|
|
||||||
|
toastAndLog("Status 'Published': Set to Active, Qty 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DraftedHandler implements StatusHandler {
|
||||||
|
handle(sheet: GoogleAppsScript.Spreadsheet.Sheet, row: number) {
|
||||||
|
const statusCell = getCellRangeByColumnName(sheet, "shopify_status", row)
|
||||||
|
if (statusCell) statusCell.setValue("DRAFT")
|
||||||
|
toastAndLog("Status 'Drafted': Set to Draft")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SoldHandler implements StatusHandler {
|
||||||
|
handle(sheet: GoogleAppsScript.Spreadsheet.Sheet, row: number) {
|
||||||
|
const statusCell = getCellRangeByColumnName(sheet, "shopify_status", row)
|
||||||
|
if (statusCell) statusCell.setValue("ACTIVE")
|
||||||
|
|
||||||
|
const qtyCell = getCellRangeByColumnName(sheet, "quantity", row)
|
||||||
|
if (qtyCell) qtyCell.setValue(0)
|
||||||
|
|
||||||
|
toastAndLog("Status 'Sold': Set to Active, Qty 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers: { [key: string]: StatusHandler } = {
|
||||||
|
"published": new PublishedHandler(),
|
||||||
|
"drafted": new DraftedHandler(),
|
||||||
|
"sold": new SoldHandler(),
|
||||||
|
"artist swap": new SoldHandler(),
|
||||||
|
"freebee": new SoldHandler(),
|
||||||
|
"sold: destroyed": new SoldHandler(),
|
||||||
|
"sold: gift": new SoldHandler(),
|
||||||
|
"sold: home use": new SoldHandler(),
|
||||||
|
}
|
||||||
@ -5,14 +5,7 @@ export function reinstallTriggers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ss = SpreadsheetApp.getActive()
|
let ss = SpreadsheetApp.getActive()
|
||||||
ScriptApp.newTrigger("newSkuHandler").forSpreadsheet(ss).onEdit().create()
|
ScriptApp.newTrigger("onEditHandler").forSpreadsheet(ss).onEdit().create()
|
||||||
ScriptApp.newTrigger("matchProductToShopifyOnEditHandler")
|
|
||||||
.forSpreadsheet(ss)
|
|
||||||
.onEdit()
|
|
||||||
.create()
|
|
||||||
// ScriptApp.newTrigger("columnOnEditHandler").forSpreadsheet(ss).onEdit().create()
|
|
||||||
// ScriptApp.newTrigger("onEditQueue").forSpreadsheet(ss).onEdit().create()
|
|
||||||
ScriptApp.newTrigger("onEditQueue").forSpreadsheet(ss).onEdit().create()
|
|
||||||
ScriptApp.newTrigger("processBatchedEdits")
|
ScriptApp.newTrigger("processBatchedEdits")
|
||||||
.timeBased()
|
.timeBased()
|
||||||
.everyMinutes(1)
|
.everyMinutes(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user