import { assertNoUserErrors, clampInt, nodesFromConnection, pageInfoFromConnection, shopifyGraphql, trimString, } from './lib/client.ts' import { PAGE_INFO_FIELDS } from './lib/fragments.ts' import { toGid } from './lib/shop.ts' import type { ShopifyClientOptions, ShopifyPageInfo } from './types.ts' export type LocationSummary = { id: string name: string isActive: boolean fulfillsOnlineOrders: boolean address: { city: string | null province: string | null country: string | null } | null } export type InventoryLevelSummary = { id: string available: number | null location: { id: string; name: string | null } | null item: { id: string; sku: string | null } | null } /** * List inventory locations. * * @example * import { listLocations } from 'kody:@kody/shopify/inventory' * const { items } = await listLocations({ shop: 'acme' }) */ export async function listLocations( params: ShopifyClientOptions & { first?: number; after?: string } = {}, ) { const first = clampInt(params.first, 1, 50, 20) const data = await shopifyGraphql<{ locations: unknown }>({ shop: params.shop, apiVersion: params.apiVersion, query: `query ListLocations($first: Int!, $after: String) { locations(first: $first, after: $after) { ${PAGE_INFO_FIELDS} nodes { id name isActive fulfillsOnlineOrders address { city province country } } } }`, variables: { first, after: trimString(params.after) || null, }, }) const items = nodesFromConnection>(data.locations).map( (node): LocationSummary => { const address = node.address && typeof node.address === 'object' ? (node.address as Record) : null return { id: String(node.id || ''), name: String(node.name || ''), isActive: Boolean(node.isActive), fulfillsOnlineOrders: Boolean(node.fulfillsOnlineOrders), address: address ? { city: typeof address.city === 'string' ? address.city : null, province: typeof address.province === 'string' ? address.province : null, country: typeof address.country === 'string' ? address.country : null, } : null, } }, ) return { items, pageInfo: pageInfoFromConnection(data.locations) as ShopifyPageInfo, } } /** * List inventory levels for one inventory item. */ export async function listInventoryLevels( params: ShopifyClientOptions & { inventoryItemId: string first?: number after?: string }, ) { const id = toGid('InventoryItem', params.inventoryItemId) const first = clampInt(params.first, 1, 50, 20) const data = await shopifyGraphql<{ inventoryItem: { inventoryLevels: unknown } | null }>({ shop: params.shop, apiVersion: params.apiVersion, query: `query InventoryLevels($id: ID!, $first: Int!, $after: String) { inventoryItem(id: $id) { inventoryLevels(first: $first, after: $after) { ${PAGE_INFO_FIELDS} nodes { id quantities(names: ["available"]) { name quantity } location { id name } item { id sku } } } } }`, variables: { id, first, after: trimString(params.after) || null, }, }) if (!data.inventoryItem) throw new Error(`Inventory item not found: ${id}`) const items = nodesFromConnection>( data.inventoryItem.inventoryLevels, ).map((node): InventoryLevelSummary => { const quantities = Array.isArray(node.quantities) ? node.quantities : [] const available = quantities.find( (entry) => entry && typeof entry === 'object' && (entry as { name?: unknown }).name === 'available', ) as { quantity?: number } | undefined const location = node.location && typeof node.location === 'object' ? (node.location as Record) : null const item = node.item && typeof node.item === 'object' ? (node.item as Record) : null return { id: String(node.id || ''), available: typeof available?.quantity === 'number' ? available.quantity : null, location: location ? { id: String(location.id || ''), name: typeof location.name === 'string' ? location.name : null, } : null, item: item ? { id: String(item.id || ''), sku: typeof item.sku === 'string' ? item.sku : null, } : null, } }) return { items, pageInfo: pageInfoFromConnection( data.inventoryItem.inventoryLevels, ) as ShopifyPageInfo, } } export type AdjustInventoryParams = ShopifyClientOptions & { inventoryItemId: string locationId: string delta: number reason?: | 'correction' | 'cycle_count_available' | 'damaged' | 'movement_created' | 'movement_received' | 'movement_updated' | 'other' | 'promotion' | 'quality_control' | 'received' | 'reservation_created' | 'reservation_deleted' | 'reservation_updated' | 'restock' | 'safety_stock' | 'shrinkage' dryRun?: boolean } /** * Adjust available inventory by a signed delta. Defaults to dry-run. */ export async function adjustInventory(params: AdjustInventoryParams) { const delta = Number(params.delta) if (!Number.isFinite(delta) || !Number.isInteger(delta)) { throw new Error('delta must be an integer (positive or negative).') } const input = { reason: params.reason || 'correction', name: 'available', changes: [ { delta, inventoryItemId: toGid('InventoryItem', params.inventoryItemId), locationId: toGid('Location', params.locationId), }, ], } const idempotencyKey = crypto.randomUUID() if (params.dryRun !== false) { return { dryRun: true as const, wouldCall: { mutation: 'inventoryAdjustQuantities', input, idempotencyKey, }, } } const data = await shopifyGraphql<{ inventoryAdjustQuantities: unknown }>({ shop: params.shop, apiVersion: params.apiVersion, query: `mutation AdjustInventory($input: InventoryAdjustQuantitiesInput!, $idempotencyKey: String!) { inventoryAdjustQuantities(input: $input) @idempotent(key: $idempotencyKey) { inventoryAdjustmentGroup { createdAt reason } userErrors { field message } } }`, variables: { input, idempotencyKey }, }) assertNoUserErrors(data.inventoryAdjustQuantities, 'inventoryAdjustQuantities') return data.inventoryAdjustQuantities } /** Default export: list locations. */ export default listLocations