import refreshTokenHelper from "../../../utils/refreshTokenHelper" import errorHandlingHelper from "../../../utils/errorHandlingHelper" import fetchHelper from "../../../utils/fetchHelper" import { getShopifyNewAccessToken, shopifyNewGraphQL, parseShopifyGid, buildShopifyGid } from "../../../utils/shopifyNewAuthHelper" // POST /api/shopify-new/stocks/sync // Syncs local qty to Shopify via GraphQL inventorySetQuantities mutation // Body: { orderSourceId, variantId (SKU), inventoryItemId?, quantity, sku?, productId?, locationId? } // Returns same response shape as /api/shopify/stocks/sync for frontend compatibility const handleFunc = async (event: any, authToken: any = null) => { const token = authToken ?? await getTokenHelper(event) const body = await readBody(event) const { orderSourceId, variantId, quantity, sku, productId, locationId: bodyLocationId } = body let { inventoryItemId } = body if(!orderSourceId) { return { status: 400, message: 'orderSourceId is required', success: false } } if(!variantId) { return { status: 400, message: 'variantId (SKU) is required', success: false } } if(quantity === undefined || quantity === null) { return { status: 400, message: 'quantity is required', success: false } } // Load order source to verify it's shopify-new and get credentials const os: any = await fetchHelper(event, `models/c_ordersource/${orderSourceId}`, 'GET', token, null) if(!os) { return { status: 404, message: 'Order Source not found', success: false } } if(os?.Marketplace?.identifier !== 'shopify-new') { return { status: 400, message: 'Selected Order Source is not Shopify-New', success: false } } try { const { accessToken, graphqlUrl } = await getShopifyNewAccessToken(os) const targetQty = Number(quantity) const skuToFind = String(variantId).toLowerCase() // If inventoryItemId is provided and already a GID, use it directly if(inventoryItemId) { console.log(`shopify-new: Using provided inventoryItemId: ${inventoryItemId} for SKU "${variantId}"`) } // If inventoryItemId is not provided, look up variant by SKU via GraphQL if(!inventoryItemId) { console.log(`shopify-new: Searching for SKU "${skuToFind}" in Shopify...`) const variantQuery = `query($skuQuery: String!) { productVariants(first: 1, query: $skuQuery) { edges { node { id sku inventoryItem { id } product { id } } } } }` const variantRes = await shopifyNewGraphQL(graphqlUrl, accessToken, variantQuery, { skuQuery: `sku:${variantId}` }) const variantEdges = variantRes?.data?.productVariants?.edges || [] if(variantEdges.length > 0) { inventoryItemId = variantEdges[0].node?.inventoryItem?.id console.log(`shopify-new: Found SKU "${skuToFind}" -> inventoryItemId: ${inventoryItemId}`) } } if(!inventoryItemId) { return { status: 400, success: false, message: `Could not find variant with SKU "${variantId}" in Shopify.`, variantId } } // Resolve location ID // Priority: body param > order source config > item's own stocked location > shop locations query // (iDempiere returns the ColumnName casing verbatim — the column is shopify_location_id) const osLocationId = os?.shopify_location_id ?? os?.Shopify_Location_ID ?? null let effectiveLocationId = bodyLocationId || osLocationId let locationResolveError = '' if(!effectiveLocationId) { // Where is this inventory item already stocked? Needs only read_inventory — // works even when the shop-wide locations query is denied (missing read_locations). try { const itemGidForLookup = buildShopifyGid('InventoryItem', String(inventoryItemId).includes('gid://') ? parseShopifyGid(inventoryItemId) : inventoryItemId) const levelsQuery = `query($id: ID!) { inventoryItem(id: $id) { inventoryLevels(first: 5) { edges { node { location { id name } } } } } }` const levelsRes = await shopifyNewGraphQL(graphqlUrl, accessToken, levelsQuery, { id: itemGidForLookup }) const levelEdges = levelsRes?.data?.inventoryItem?.inventoryLevels?.edges || [] if(levelEdges.length > 0) { effectiveLocationId = parseShopifyGid(levelEdges[0]?.node?.location?.id) console.log(`shopify-new: Using item's stocked location ${effectiveLocationId} (${levelEdges[0]?.node?.location?.name})`) } } catch (lvlErr: any) { locationResolveError = lvlErr?.message || String(lvlErr) console.log('shopify-new: inventoryLevels location lookup failed:', locationResolveError) } } if(!effectiveLocationId) { // Shop-wide locations query (requires read_locations) try { const locQuery = `{ locations(first: 5) { edges { node { id name isActive } } } }` const locRes = await shopifyNewGraphQL(graphqlUrl, accessToken, locQuery) const locationEdges = locRes?.data?.locations?.edges || [] const activeLocation = locationEdges.find((e: any) => e.node.isActive) || locationEdges[0] if(activeLocation) { effectiveLocationId = parseShopifyGid(activeLocation.node.id) console.log(`shopify-new: Using location ${effectiveLocationId} (${activeLocation.node.name})`) } } catch (locErr: any) { locationResolveError = locErr?.message || locationResolveError console.error('shopify-new: Failed to fetch locations:', locErr?.message) } } if(!effectiveLocationId) { return { status: 400, success: false, message: `Could not determine Shopify location${locationResolveError ? ` — ${locationResolveError}` : ''}. Select a location on the page, set Shopify_Location_ID on the order source, or grant the app the read_locations scope.`, variantId } } // Build GIDs for the mutation const inventoryItemGid = buildShopifyGid('InventoryItem', inventoryItemId.includes('gid://') ? parseShopifyGid(inventoryItemId) : inventoryItemId) const locationGid = buildShopifyGid('Location', effectiveLocationId) // Set inventory via inventorySetQuantities mutation. // ignoreCompareQuantity: true is REQUIRED — without it (or a compareQuantity per // entry) Shopify rejects every quantity with COMPARE_QUANTITY_REQUIRED. const setMutation = `mutation inventorySetQuantities($input: InventorySetQuantitiesInput!) { inventorySetQuantities(input: $input) { inventoryAdjustmentGroup { createdAt changes { name delta quantityAfterChange } } userErrors { field message code } } }` const setVariables = { input: { name: "available", reason: "correction", ignoreCompareQuantity: true, quantities: [ { inventoryItemId: inventoryItemGid, locationId: locationGid, quantity: targetQty } ] } } console.log('shopify-new: inventory set request:', JSON.stringify(setVariables)) const result = await shopifyNewGraphQL(graphqlUrl, accessToken, setMutation, setVariables) // Check for userErrors let userErrors = result?.data?.inventorySetQuantities?.userErrors || [] // If the item is not stocked (activated) at the target location yet, activate it // there with the desired quantity — that both connects the location and sets the qty. const notStocked = userErrors.some((e: any) => String(e.code || '').includes('NOT_STOCKED') || /not stocked/i.test(String(e.message || ''))) if(notStocked) { console.log(`shopify-new: Item not stocked at location ${effectiveLocationId}, activating...`) const activateMutation = `mutation inventoryActivate($inventoryItemId: ID!, $locationId: ID!, $available: Int) { inventoryActivate(inventoryItemId: $inventoryItemId, locationId: $locationId, available: $available) { inventoryLevel { quantities(names: ["available"]) { name quantity } } userErrors { field message } } }` const activateRes = await shopifyNewGraphQL(graphqlUrl, accessToken, activateMutation, { inventoryItemId: inventoryItemGid, locationId: locationGid, available: targetQty }) const activateErrors = activateRes?.data?.inventoryActivate?.userErrors || [] if(activateErrors.length === 0) { const activatedQty = activateRes?.data?.inventoryActivate?.inventoryLevel?.quantities ?.find((q: any) => q.name === 'available')?.quantity return { status: 200, success: true, message: `Stock set to ${activatedQty ?? targetQty} for SKU "${variantId}" at location ${effectiveLocationId} (item activated at location)`, variantId, inventoryItemId: inventoryItemGid, quantity: targetQty, updatedStock: activatedQty ?? targetQty, locationId: effectiveLocationId, shopifyResponse: activateRes?.data } } userErrors = [...userErrors, ...activateErrors] } if(userErrors.length > 0) { const errMsg = userErrors.map((e: any) => `${Array.isArray(e.field) ? e.field.join('.') : e.field}: ${e.message}`).join('; ') return { status: 400, success: false, message: `Shopify error: ${errMsg}`, variantId, shopifyResponse: result?.data } } // Get the updated quantity from the response const changes = result?.data?.inventorySetQuantities?.inventoryAdjustmentGroup?.changes || [] const updatedStock = changes.length > 0 ? changes[0].quantityAfterChange : targetQty console.log('shopify-new: inventory set response:', JSON.stringify(result?.data).slice(0, 800)) return { status: 200, success: true, message: `Stock set to ${updatedStock} for SKU "${variantId}" at location ${effectiveLocationId}`, variantId, inventoryItemId: inventoryItemGid, quantity: targetQty, updatedStock, locationId: effectiveLocationId, shopifyResponse: result?.data } } catch (err: any) { const errData = err?.data || err const errMsg = errData?.message || errData?.errors || err?.message || 'Failed to sync stock to Shopify-New' const errStatus = err?.status || err?.response?.status || errData?.status || 500 console.error('shopify-new stock sync error:', { variantId, quantity, error: errData }) return { status: errStatus, success: false, message: typeof errMsg === 'object' ? JSON.stringify(errMsg) : errMsg, error: errData, variantId } } } export default defineEventHandler(async (event) => { try { return await handleFunc(event) } catch (err: any) { try { const authToken = await refreshTokenHelper(event) return await handleFunc(event, authToken) } catch (error: any) { const data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) if([401, 402, 403, 407].includes(Number(data.status))) { //@ts-ignore setCookie(event, 'user', null) } return { ...data, success: false } } } })