From 6230bca1edb4811a24de11531452f0ab284b4f9b Mon Sep 17 00:00:00 2001 From: Bryson Armstrong Date: Fri, 6 Dec 2024 00:31:16 -0700 Subject: [PATCH] feat: add endpoint to update product inventory --- server/src/routes/update-inventory/root.ts | 59 +++++++ server/src/shopify/inventory.ts | 175 +++++++++++++++++++++ server/src/shopify/product.ts | 36 ++--- shared/src/model/inventory.model.ts | 46 +++++- shared/src/model/product.model.ts | 4 +- shared/src/model/variant.model.ts | 5 +- 6 files changed, 290 insertions(+), 35 deletions(-) create mode 100644 server/src/routes/update-inventory/root.ts create mode 100644 server/src/shopify/inventory.ts diff --git a/server/src/routes/update-inventory/root.ts b/server/src/routes/update-inventory/root.ts new file mode 100644 index 0000000..9b775c0 --- /dev/null +++ b/server/src/routes/update-inventory/root.ts @@ -0,0 +1,59 @@ +import { FastifyPluginAsyncTypebox, TypeBoxTypeProvider } from '@fastify/type-provider-typebox' +import { UpdateInventoryRequestSchema, UpdateInventoryResponseSchema } from 'shared' +import { InvalidRequestError, NotFoundError } from '../../util/errors' +import { ErrorResSchema } from '../../models/errors.model' +import { ServerOptions } from '../../server' +import { + getProductInventoryLocation, + getVariantInventoryLocation, + updateAvailableInventory +} from '../../shopify/inventory' + +// maybe use a generic argument for FastifyPluginAsync if we use options with fastify instance +const updateInventory: FastifyPluginAsyncTypebox = async (fastifyApp, { shopifyClient }): Promise => { + const fastify = fastifyApp.withTypeProvider() + + const fastifyInstance = fastify.post('/', { + schema: { + body: UpdateInventoryRequestSchema, + response: { + 200: UpdateInventoryResponseSchema, + 400: ErrorResSchema, + 404: ErrorResSchema + } + } + }, async (request, reply) => { + let inventoryPairs: Array<{ inventoryItemId: string, locationId: string }> | null + + if (request.body.variantId) { + inventoryPairs = await getVariantInventoryLocation(shopifyClient, request.body.variantId) + if (inventoryPairs == null) { + throw new NotFoundError('Product variant not found') + } + } else if (request.body.productId) { + inventoryPairs = await getProductInventoryLocation(shopifyClient, request.body.productId) + if (inventoryPairs == null) { + throw new NotFoundError('Product not found') + } + } else { + throw new InvalidRequestError('Must specify either productId or variantId') + } + + if (request.body.locationId) { + inventoryPairs = inventoryPairs.filter(p => p.locationId === request.body.locationId) + } + if (inventoryPairs.length == 0) { + throw new NotFoundError('Inventory Item not found') + } + if (inventoryPairs.length > 1) { + // Error type + throw new NotFoundError(`Multiple inventories found: ${inventoryPairs.toString()}`) + } + + const response = await updateAvailableInventory(shopifyClient, inventoryPairs[0].inventoryItemId, inventoryPairs[0].locationId, request.body.quantity) + + reply.status(200).send(response) + }) +} + +export default updateInventory diff --git a/server/src/shopify/inventory.ts b/server/src/shopify/inventory.ts new file mode 100644 index 0000000..89ee428 --- /dev/null +++ b/server/src/shopify/inventory.ts @@ -0,0 +1,175 @@ +import { ShopifyGraphQLClient } from './shopify-client' +import { logger } from '../util/logger' +import { + ProductSchema, + Product, + PageInfo, + PageInfoSchema, + UpdateInventoryResponse, + UpdateInventoryResponseSchema +} from 'shared' +import { isValid } from '../util/validate' + +const MAX_VARIANTS_COUNT = 10 +const MAX_LOCATIONS_COUNT = 5 + +export const internalVariantQuery = /* GraphQL */ ` +id +inventoryItem { + id + inventoryLevels(first: ${MAX_LOCATIONS_COUNT}) { + nodes { + id + location { + id + name + } + quantities(names: ["available"]) { + name + quantity + } + } + } +} +` + +const internalProductQuery = /* GraphQL */ ` +id +variants(first: ${MAX_VARIANTS_COUNT}) { + nodes { + ${internalVariantQuery} + } +} +` + +const getVariantLocationsQuery = /* GraphQL */ ` + query variantLocations($id: ID!) { + productVariant(id: $id) { + ${internalVariantQuery} + } + } +` + +const getProductLocationsQuery = /* GraphQL */ ` + query productLocations($id: ID!) { + product(id: $id) { + ${internalProductQuery} + } + } +` + +const inventoryItemMutation = /* GraphQL */ ` + mutation inventorySetQuantities($input: InventorySetQuantitiesInput!) { + inventorySetQuantities(input: $input) { + inventoryAdjustmentGroup { + reason + referenceDocumentUri + changes { + name + delta + quantityAfterChange + } + } + userErrors { + code + field + message + } + } + } +` + +interface InventoryLocation { + inventoryItemId: string + locationId: string +} + +export async function getVariantInventoryLocation ( + client: ShopifyGraphQLClient, + variantId: string +): Promise { + const { data, errors } = await client.request(getVariantLocationsQuery, { + id: variantId + }) as { data: Record, errors: unknown } + + if (errors != null) { + throw errors + } + + // fail fast if there was no match + if (data == null || data.productVariant == null) { + return null + } + + const variant = data.productVariant + const inventoryLocations: InventoryLocation[] = [] + for (const inventoryLevel of variant.inventoryItem.inventoryLevels.nodes) { + inventoryLocations.push({ + inventoryItemId: variant.inventoryItem.id, + locationId: inventoryLevel.location.id + }) + } + + return inventoryLocations +} + +export async function getProductInventoryLocation ( + client: ShopifyGraphQLClient, + productId: string +): Promise { + const { data, errors } = await client.request(getProductLocationsQuery, { + id: productId + }) as { data: Record, errors: unknown } + + if (errors != null) { + throw errors + } + + // fail fast if there was no match + if (data == null || data.product == null) { + return null + } + + const inventoryLocations: InventoryLocation[] = [] + for (const variant of data.product.variants.nodes) { + for (const inventoryLevel of variant.inventoryItem.inventoryLevels.nodes) { + inventoryLocations.push({ + inventoryItemId: variant.inventoryItem.id, + locationId: inventoryLevel.location.id + }) + } + } + + return inventoryLocations +} + +export async function updateAvailableInventory ( + client: ShopifyGraphQLClient, + inventoryItemId: string, + locationId: string, + quantity: number +): Promise { + const { data, errors } = await client.request(inventoryItemMutation, { + input: { + ignoreCompareQuantity: true, + reason: 'correction', + name: 'available', + quantities: [ + { + inventoryItemId, + locationId, + quantity + } + ] + } + }) as { data: Record, errors: unknown } + + if (errors != null) { + throw errors + } + const response: any = data.inventorySetQuantities + if (!isValid(UpdateInventoryResponseSchema, response, 'inventory response')) { + throw new Error('Error mapping response. Changes may have been made.') + } + return response +} diff --git a/server/src/shopify/product.ts b/server/src/shopify/product.ts index 7c783f0..88f0861 100644 --- a/server/src/shopify/product.ts +++ b/server/src/shopify/product.ts @@ -1,10 +1,10 @@ import { ShopifyGraphQLClient } from './shopify-client' import { logger } from '../util/logger' -import {ProductSchema, Product, PageInfo, PageInfoSchema, Inventory} from 'shared' +import { ProductSchema, Product, PageInfo, PageInfoSchema } from 'shared' import { isValid } from '../util/validate' -const MAX_VARIANTS_COUNT = 10; -const MAX_LOCATIONS_COUNT = 5; +const MAX_VARIANTS_COUNT = 10 +const MAX_LOCATIONS_COUNT = 5 export const internalVariantQuery = /* GraphQL */ ` id @@ -27,6 +27,7 @@ contextualPricing(context: {}) { } inventoryQuantity inventoryItem { + id inventoryLevels(first: ${MAX_LOCATIONS_COUNT}) { nodes { id @@ -88,34 +89,21 @@ const getProductsQuery = /* GraphQL */ ` } ` -function parseProduct(product: any): Product { - let productInventory: Inventory = { - totalInventory: product.totalInventory, - totalAvailableInventory: 0, - totalCommittedInventory: 0 - } +function parseProduct (product: any): Product { + product.totalAvailableInventory = 0 + product.totalCommittedInventory = 0 for (const variant of product.variants.nodes) { - let variantInventory: Inventory = { - totalInventory: variant.inventoryQuantity, - totalAvailableInventory: 0, - totalCommittedInventory: 0 - } - for (const inventoryLevel of variant.inventoryItem.inventoryLevels.nodes) { for (const quantity of inventoryLevel.quantities) { - if (quantity.name == "available") { - variantInventory.totalAvailableInventory += quantity.quantity; - productInventory.totalAvailableInventory += quantity.quantity; - } else if (quantity.name == "committed") { - variantInventory.totalCommittedInventory += quantity.quantity; - productInventory.totalCommittedInventory += quantity.quantity; + if (quantity.name == 'available') { + product.totalAvailableInventory += quantity.quantity + } else if (quantity.name == 'committed') { + product.totalCommittedInventory += quantity.quantity } } } - variant.inventory = variantInventory; } - product.inventory = productInventory; if (!isValid(ProductSchema, product, 'product')) { throw new Error('Error mapping product') } @@ -167,7 +155,7 @@ export async function getProducts ( const products: Product[] = [] for (const edge of data.products.edges) { - products.push(await parseProduct(edge.node)) + products.push(parseProduct(edge.node)) } const pageInfo = data.products.pageInfo if (!isValid(PageInfoSchema, pageInfo, 'page info')) { diff --git a/shared/src/model/inventory.model.ts b/shared/src/model/inventory.model.ts index 6935aa0..9932b7d 100644 --- a/shared/src/model/inventory.model.ts +++ b/shared/src/model/inventory.model.ts @@ -1,8 +1,42 @@ -import {Static, Type} from "@fastify/type-provider-typebox"; +import { Static, Type } from '@fastify/type-provider-typebox' +import { Nullable } from './misc' -export const InventorySchema = Type.Object({ - totalInventory: Type.Integer(), - totalCommittedInventory: Type.Integer(), - totalAvailableInventory: Type.Integer() +export const VariantInventoryItemSchema = Type.Object({ + id: Type.String(), + inventoryLevels: Type.Object({ + nodes: Type.Array(Type.Object({ + location: Type.Object({ + id: Type.String(), + name: Type.String() + }), + quantities: Type.Array(Type.Object({ + name: Type.String(), + quantity: Type.Integer() + })) + })) + }) }) -export type Inventory = Static +export type VariantInventoryItem = Static + +export const UpdateInventoryRequestSchema = Type.Object({ + productId: Type.Optional(Type.String()), + variantId: Type.Optional(Type.String()), + locationId: Type.Optional(Type.String()), + quantity: Type.Integer() +}) +export type UpdateInventoryRequest = Static + +export const UpdateInventoryResponseSchema = Type.Object({ + inventoryAdjustmentGroup: Nullable(Type.Object({ + reason: Type.String(), + changes: Type.Array(Type.Object({ + name: Type.String(), + delta: Type.Integer() + })) + })), + userErrors: Type.Array(Type.Object({ + field: Type.String(), + message: Type.String() + })) +}) +export type UpdateInventoryResponse = Static diff --git a/shared/src/model/product.model.ts b/shared/src/model/product.model.ts index 9d5705a..961ad21 100644 --- a/shared/src/model/product.model.ts +++ b/shared/src/model/product.model.ts @@ -2,7 +2,6 @@ import { Static, Type } from '@fastify/type-provider-typebox' import { ImageObjSchema } from './image.model' import { VariantSchema } from './variant.model' import { PageInfoSchema } from './response.model' -import {InventorySchema} from "./inventory.model"; export const ProductSchema = Type.Object({ id: Type.String(), @@ -15,7 +14,8 @@ export const ProductSchema = Type.Object({ nodes: Type.Array(VariantSchema) }), totalInventory: Type.Integer(), - inventory: InventorySchema + totalAvailableInventory: Type.Integer(), + totalCommittedInventory: Type.Integer() }) export type Product = Static diff --git a/shared/src/model/variant.model.ts b/shared/src/model/variant.model.ts index 42365a8..81c4344 100644 --- a/shared/src/model/variant.model.ts +++ b/shared/src/model/variant.model.ts @@ -1,7 +1,7 @@ import { Static, Type } from '@fastify/type-provider-typebox' import { ImageObjSchema } from './image.model' import { Nullable } from './misc' -import {InventorySchema} from "./inventory.model"; +import { VariantInventoryItemSchema } from './inventory.model' export const VariantSchema = Type.Object({ id: Type.String(), @@ -14,7 +14,6 @@ export const VariantSchema = Type.Object({ }) }), sku: Nullable(Type.String()), - inventory: InventorySchema + inventoryItem: VariantInventoryItemSchema }) export type Variant = Static -