feat(medusa, admin-ui, medusa-react, medusa-js): Allow toggling of manage inventory (#3435)

**What**
- Toggle manage inventory in the inventory management modal

**How**
- Create/update/remove inventory item based on if `manage_inventory` is set and if an inventory item already exists
- Move all stock location updates to when the modal is submitted
- Add create-inventory-item endpoint in the core

Fixes CORE-1196

Co-authored-by: Sebastian Rindom <7554214+srindom@users.noreply.github.com>
This commit is contained in:
Philip Korsholm
2023-03-14 17:14:31 +01:00
committed by GitHub
parent 30a3203640
commit fe9eea4c18
18 changed files with 844 additions and 219 deletions

View File

@@ -0,0 +1,7 @@
---
"medusa-react": patch
"@medusajs/medusa-js": patch
"@medusajs/medusa": patch
---
Add create-inventory-item endpoint

View File

@@ -0,0 +1,5 @@
---
"@medusajs/admin-ui": patch
---
fix(admin-ui): create/update/delete inventory items according to inventory items on the variant

View File

@@ -278,6 +278,62 @@ describe("Inventory Items endpoints", () => {
})
})
it.only("Creates an inventory item using the api", async () => {
const product = await simpleProductFactory(dbConnection, {})
const api = useApi()
const productRes = await api.get(
`/admin/products/${product.id}`,
adminHeaders
)
const variantId = productRes.data.product.variants[0].id
let variantInventoryRes = await api.get(
`/admin/variants/${variantId}/inventory`,
adminHeaders
)
expect(variantInventoryRes.data).toEqual({
variant: {
id: variantId,
inventory: [],
sales_channel_availability: [],
},
})
expect(variantInventoryRes.status).toEqual(200)
const inventoryItemCreateRes = await api.post(
`/admin/inventory-items`,
{ variant_id: variantId },
adminHeaders
)
variantInventoryRes = await api.get(
`/admin/variants/${variantId}/inventory`,
adminHeaders
)
expect(variantInventoryRes.data).toEqual({
variant: {
id: variantId,
inventory: [
expect.objectContaining({
...inventoryItemCreateRes.data.inventory_item,
}),
],
sales_channel_availability: [
expect.objectContaining({
available_quantity: 0,
channel_name: "Default Sales Channel",
}),
],
},
})
expect(variantInventoryRes.status).toEqual(200)
})
describe("List inventory items", () => {
it("Lists inventory items with location", async () => {
const api = useApi()

View File

@@ -1,14 +1,15 @@
import { Discount } from "@medusajs/medusa"
import { parse } from "iso8601-duration"
import { useAdminUpdateDiscount } from "medusa-react"
import moment from "moment"
import { ReactNode } from "react"
import ClockIcon from "../../../../components/fundamentals/icons/clock-icon"
import TrashIcon from "../../../../components/fundamentals/icons/trash-icon"
import { ActionType } from "../../../../components/molecules/actionables"
import useNotification from "../../../../hooks/use-notification"
import ClockIcon from "../../../../components/fundamentals/icons/clock-icon"
import { Discount } from "@medusajs/medusa"
import TrashIcon from "../../../../components/fundamentals/icons/trash-icon"
import { getErrorMessage } from "../../../../utils/error-messages"
import moment from "moment"
import { parse } from "iso8601-duration"
import { removeNullish } from "../../../../utils/remove-nullish"
import { useAdminUpdateDiscount } from "medusa-react"
import useNotification from "../../../../hooks/use-notification"
type displaySetting = {
title: string

View File

@@ -4,21 +4,24 @@ import {
Order,
StockLocationDTO,
} from "@medusajs/medusa"
import { useAdminStockLocations } from "medusa-react"
import { useAdminRequestReturn, useAdminShippingOptions } from "medusa-react"
import {
useAdminRequestReturn,
useAdminShippingOptions,
useAdminStockLocations,
} from "medusa-react"
import React, { useContext, useEffect, useState } from "react"
import Spinner from "../../../../components/atoms/spinner"
import LayeredModal, {
LayeredModalContext,
} from "../../../../components/molecules/modal/layered-modal"
import Button from "../../../../components/fundamentals/button"
import CheckIcon from "../../../../components/fundamentals/icons/check-icon"
import EditIcon from "../../../../components/fundamentals/icons/edit-icon"
import IconTooltip from "../../../../components/molecules/icon-tooltip"
import Modal from "../../../../components/molecules/modal"
import LayeredModal, {
LayeredModalContext,
} from "../../../../components/molecules/modal/layered-modal"
import RMAShippingPrice from "../../../../components/molecules/rma-select-shipping"
import Select from "../../../../components/molecules/select/next-select/select"
// import Select from "../../../../components/molecules/select"
import CurrencyInput from "../../../../components/organisms/currency-input"
import RMASelectProductTable from "../../../../components/organisms/rma-select-product-table"
import useNotification from "../../../../hooks/use-notification"

View File

@@ -10,9 +10,7 @@ export type EditFlowVariantFormType = {
type Props = {
form: UseFormReturn<EditFlowVariantFormType, any>
locationLevels: InventoryLevelDTO[]
refetchInventory: () => void
isLoading: boolean
itemId: string
}
/**
@@ -34,13 +32,7 @@ type Props = {
* )
* }
*/
const EditFlowVariantForm = ({
form,
isLoading,
locationLevels,
refetchInventory,
itemId,
}: Props) => {
const EditFlowVariantForm = ({ form, isLoading, locationLevels }: Props) => {
if (isLoading) {
return null
}
@@ -49,8 +41,6 @@ const EditFlowVariantForm = ({
<>
<VariantStockForm
locationLevels={locationLevels}
refetchInventory={refetchInventory}
itemId={itemId}
form={nestedForm(form, "stock")}
/>
</>

View File

@@ -1,13 +1,9 @@
import React, { useMemo, useState, useContext } from "react"
import Modal from "../../../../../components/molecules/modal"
import { LayeredModalContext } from "../../../../../components/molecules/modal/layered-modal"
import {
useAdminCreateLocationLevel,
useAdminDeleteLocationLevel,
useAdminStockLocations,
} from "medusa-react"
import { useAdminStockLocations } from "medusa-react"
import { InventoryLevelDTO, StockLocationDTO } from "@medusajs/medusa"
import { Controller } from "react-hook-form"
import { Controller, useFieldArray } from "react-hook-form"
import Button from "../../../../../components/fundamentals/button"
import Switch from "../../../../../components/atoms/switch"
import InputField from "../../../../../components/molecules/input"
@@ -16,55 +12,60 @@ import IconBadge from "../../../../../components/fundamentals/icon-badge"
import BuildingsIcon from "../../../../../components/fundamentals/icons/buildings-icon"
export type VariantStockFormType = {
manage_inventory: boolean
manage_inventory?: boolean
allow_backorder: boolean
inventory_quantity: number | null
sku: string | null
ean: string | null
upc: string | null
barcode: string | null
location_levels: InventoryLevelDTO[] | null
location_levels?: Partial<InventoryLevelDTO>[] | null
}
type Props = {
itemId: string
locationLevels: InventoryLevelDTO[]
refetchInventory: () => void
form: NestedForm<VariantStockFormType>
}
const VariantStockForm = ({
form,
locationLevels,
refetchInventory,
itemId,
}: Props) => {
const VariantStockForm = ({ form, locationLevels }: Props) => {
const locationLevelMap = useMemo(
() => new Map(locationLevels.map((l) => [l.location_id, l])),
[locationLevels]
)
const layeredModalContext = useContext(LayeredModalContext)
const { stock_locations: locations, isLoading } = useAdminStockLocations()
const deleteLevel = useAdminDeleteLocationLevel(itemId)
const createLevel = useAdminCreateLocationLevel(itemId)
const { path, control, register, watch } = form
const { path, control, register } = form
const manageInventory = watch(path("manage_inventory"))
const handleUpdateLocations = async (value) => {
await Promise.all(
value.removed.map(async (id) => {
await deleteLevel.mutateAsync(id)
})
const {
fields: selectedLocations,
append,
remove,
} = useFieldArray({
control,
name: path("location_levels"),
})
const handleUpdateLocations = async (data: {
added: string[]
removed: string[]
}) => {
const removed = data.removed.map((r) =>
selectedLocations.findIndex((sl) => sl.location_id === r)
)
await Promise.all(
value.added.map(async (id) => {
await createLevel.mutateAsync({
stocked_quantity: 0,
location_id: id,
})
})
)
removed.forEach((r) => remove(r))
refetchInventory()
data.added.forEach((added) => {
append({
location_id: added,
stocked_quantity: locationLevelMap.get(added)?.stocked_quantity ?? 0,
})
})
}
return (
@@ -111,88 +112,95 @@ const VariantStockForm = ({
returns are made.
</p>
</div>
<div className="gap-y-2xsmall flex flex-col">
<div className="flex items-center justify-between">
<h3 className="inter-base-semibold mb-2xsmall">Allow backorders</h3>
<Controller
control={control}
name={path("allow_backorder")}
render={({ field: { value, onChange } }) => {
return <Switch checked={value} onCheckedChange={onChange} />
}}
/>
</div>
<p className="inter-base-regular text-grey-50">
When checked the product will be available for purchase despite the
product being sold out
</p>
</div>
<div className="flex w-full flex-col text-base">
<h3 className="inter-base-semibold mb-2xsmall">Quantity</h3>
{!isLoading && locations && (
<div className="flex w-full flex-col">
<div className="inter-base-regular text-grey-50 flex justify-between py-3">
<div className="">Location</div>
<div className="">In Stock</div>
{manageInventory && (
<>
<div className="gap-y-2xsmall flex flex-col">
<div className="flex items-center justify-between">
<h3 className="inter-base-semibold mb-2xsmall">
Allow backorders
</h3>
<Controller
control={control}
name={path("allow_backorder")}
render={({ field: { value, onChange } }) => {
return <Switch checked={value} onCheckedChange={onChange} />
}}
/>
</div>
{locationLevels.map((level, i) => {
const locationDetails = locations.find(
(l) => l.id === level.location_id
)
return (
<div key={level.id} className="flex items-center py-3">
<div className="inter-base-regular flex items-center">
<IconBadge className="mr-base">
<BuildingsIcon />
</IconBadge>
{locationDetails?.name}
</div>
<div className="ml-auto flex">
<div className="mr-base text-small text-grey-50 flex flex-col">
<span className="whitespace-nowrap text-right">
{`${level.reserved_quantity} reserved`}
</span>
<span className="whitespace-nowrap text-right">{`${
level.stocked_quantity - level.reserved_quantity
} available`}</span>
</div>
<InputField
placeholder={"0"}
type="number"
{...register(
path(`location_levels.${i}.stocked_quantity`),
{ valueAsNumber: true }
)}
/>
</div>
</div>
)
})}
<p className="inter-base-regular text-grey-50">
When checked the product will be available for purchase despite
the product being sold out
</p>
</div>
)}
</div>
<div className="flex">
<Button
variant="secondary"
size="small"
type="button"
className="w-full"
onClick={() => {
layeredModalContext.push(
// @ts-ignore
ManageLocationsScreen(
layeredModalContext.pop,
locationLevels,
locations,
handleUpdateLocations
)
)
}}
>
Manage locations
</Button>
</div>
<div className="flex w-full flex-col text-base">
<h3 className="inter-base-semibold mb-2xsmall">Quantity</h3>
{!isLoading && locations && (
<div className="flex w-full flex-col">
<div className="inter-base-regular text-grey-50 flex justify-between py-3">
<div className="">Location</div>
<div className="">In Stock</div>
</div>
{selectedLocations.map((level, i) => {
console.log(level)
const locationDetails = locations.find(
(l: StockLocationDTO) => l.id === level.location_id
)
return (
<div key={level.id} className="flex items-center py-3">
<div className="inter-base-regular flex items-center">
<IconBadge className="mr-base">
<BuildingsIcon />
</IconBadge>
{locationDetails?.name}
</div>
<div className="ml-auto flex">
<div className="mr-base text-small text-grey-50 flex flex-col">
<span className="whitespace-nowrap text-right">
{`${level.reserved_quantity} reserved`}
</span>
<span className="whitespace-nowrap text-right">{`${
level.stocked_quantity - level.reserved_quantity
} available`}</span>
</div>
<InputField
placeholder={"0"}
type="number"
{...register(
path(`location_levels.${i}.stocked_quantity`),
{ valueAsNumber: true }
)}
/>
</div>
</div>
)
})}
</div>
)}
</div>
<div className="flex">
<Button
variant="secondary"
size="small"
type="button"
className="w-full"
onClick={() => {
layeredModalContext.push(
// @ts-ignore
ManageLocationsScreen(
layeredModalContext.pop,
selectedLocations,
locations,
handleUpdateLocations
)
)
}}
>
Manage locations
</Button>
</div>
</>
)}
</div>
</div>
)
@@ -200,7 +208,7 @@ const VariantStockForm = ({
export const ManageLocationsScreen = (
pop: () => void,
levels: InventoryLevelDTO[],
levels: Partial<InventoryLevelDTO>[],
locations: StockLocationDTO[],
onSubmit: (value: any) => Promise<void>
) => {
@@ -218,7 +226,7 @@ export const ManageLocationsScreen = (
}
type ManageLocationFormProps = {
existingLevels: InventoryLevelDTO[]
existingLevels: Partial<InventoryLevelDTO>[]
locationOptions: StockLocationDTO[]
onSubmit: (value: any) => Promise<void>
}
@@ -232,7 +240,7 @@ const ManageLocationsForm = ({
const { pop } = layeredModalContext
const existingLocations = useMemo(() => {
return existingLevels.map((level) => level.location_id)
return existingLevels.map((level) => level.location_id!)
}, [existingLevels])
const [selectedLocations, setSelectedLocations] =
@@ -266,7 +274,7 @@ const ManageLocationsForm = ({
(locationId: string) => !existingLocations.includes(locationId)
)
const removedLevels = existingLocations.filter(
(locationId) => !selectedLocations.includes(locationId)
(locationId) => !!locationId && !selectedLocations.includes(locationId)
)
await onSubmit({

View File

@@ -11,11 +11,12 @@ import {
useAdminUpdateProduct,
useAdminUpdateVariant,
} from "medusa-react"
import { useNavigate } from "react-router-dom"
import useImperativeDialog from "../../../../hooks/use-imperative-dialog"
import useNotification from "../../../../hooks/use-notification"
import { getErrorMessage } from "../../../../utils/error-messages"
import { removeNullish } from "../../../../utils/remove-nullish"
import useImperativeDialog from "../../../../hooks/use-imperative-dialog"
import { useNavigate } from "react-router-dom"
import useNotification from "../../../../hooks/use-notification"
const useEditProductActions = (productId: string) => {
const dialog = useImperativeDialog()
@@ -70,8 +71,11 @@ const useEditProductActions = (productId: string) => {
successMessage = "Variant was updated successfully"
) => {
updateVariant.mutate(
// @ts-ignore - TODO fix type on request
{ variant_id: id, ...removeNullish(payload) },
{
variant_id: id,
...removeNullish(payload),
manage_inventory: payload.manage_inventory,
},
{
onSuccess: () => {
notification("Success", successMessage, "success")

View File

@@ -1,20 +1,24 @@
import { Product, ProductVariant } from "@medusajs/medusa"
import {
useAdminUpdateLocationLevel,
useAdminVariantsInventory,
} from "medusa-react"
import React, { useContext } from "react"
InventoryLevelDTO,
Product,
ProductVariant,
VariantInventory,
} from "@medusajs/medusa"
import { useMedusa } from "medusa-react"
import { useAdminVariantsInventory } from "medusa-react"
import { useContext } from "react"
import { useForm } from "react-hook-form"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import LayeredModal, {
LayeredModalContext,
} from "../../../../../components/molecules/modal/layered-modal"
import EditFlowVariantForm, {
EditFlowVariantFormType,
} from "../../../components/variant-inventory-form/edit-flow-variant-form"
import useEditProductActions from "../../hooks/use-edit-product-actions"
import LayeredModal, {
LayeredModalContext,
} from "../../../../../components/molecules/modal/layered-modal"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import { createUpdatePayload } from "./edit-variants-modal/edit-variant-screen"
import useEditProductActions from "../../hooks/use-edit-product-actions"
import { removeNullish } from "../../../../../utils/remove-nullish"
type Props = {
onClose: () => void
@@ -24,6 +28,7 @@ type Props = {
}
const EditVariantInventoryModal = ({ onClose, product, variant }: Props) => {
const { client } = useMedusa()
const layeredModalContext = useContext(LayeredModalContext)
const {
// @ts-ignore
@@ -32,31 +37,94 @@ const EditVariantInventoryModal = ({ onClose, product, variant }: Props) => {
refetch,
} = useAdminVariantsInventory(variant.id)
const itemId = variantInventory?.inventory[0]?.id
const variantInventoryItem = variantInventory?.inventory[0]
const itemId = variantInventoryItem?.id
const { mutate: updateLocationLevel } = useAdminUpdateLocationLevel(
itemId || ""
)
const handleClose = () => {
onClose()
}
const { onUpdateVariant, updatingVariant } = useEditProductActions(product.id)
const onSubmit = async (data) => {
const { location_levels } = data.stock
await Promise.all(
location_levels.map(async (level) => {
await updateLocationLevel({
stockLocationId: level.location_id,
stocked_quantity: level.stocked_quantity,
})
})
)
// / TODO: Call update location level with new values
const onSubmit = async (data: EditFlowVariantFormType) => {
const locationLevels = data.stock.location_levels || []
const manageInventory = data.stock.manage_inventory
delete data.stock.manage_inventory
delete data.stock.location_levels
let inventoryItemId: string | undefined = itemId
const upsertPayload = removeNullish(data.stock)
if (variantInventoryItem) {
// variant inventory exists and we can remove location levels
// (it's important to do this before potentially deleting the inventory item)
const deleteLocations = manageInventory
? variantInventoryItem?.location_levels?.filter(
(itemLevel: InventoryLevelDTO) => {
return !locationLevels.find(
(level) => level.location_id === itemLevel.location_id
)
}
) ?? []
: []
if (inventoryItemId) {
await Promise.all(
deleteLocations.map(async (location: InventoryLevelDTO) => {
await client.admin.inventoryItems.deleteLocationLevel(
inventoryItemId!,
location.id
)
})
)
}
if (!manageInventory) {
// has an inventory item but no longer wants to manage inventory
await client.admin.inventoryItems.delete(itemId!)
inventoryItemId = undefined
} else {
// has an inventory item and wants to update inventory
await client.admin.inventoryItems.update(itemId!, upsertPayload)
}
} else if (manageInventory) {
// does not have an inventory item but wants to manage inventory
const { inventory_item } = await client.admin.inventoryItems.create({
variant_id: variant.id,
...upsertPayload,
})
inventoryItemId = inventory_item.id
}
// If some inventory Item exists update location levels
if (inventoryItemId) {
await Promise.all(
locationLevels.map(async (level) => {
if (!level.location_id) {
return
}
if (level.id) {
await client.admin.inventoryItems.updateLocationLevel(
inventoryItemId!,
level.location_id,
{
stocked_quantity: level.stocked_quantity,
}
)
} else {
await client.admin.inventoryItems.createLocationLevel(
inventoryItemId!,
{
location_id: level.location_id,
stocked_quantity: level.stocked_quantity!,
}
)
}
})
)
}
// @ts-ignore
onUpdateVariant(variant.id, createUpdatePayload(data), () => {
refetch()
@@ -71,8 +139,7 @@ const EditVariantInventoryModal = ({ onClose, product, variant }: Props) => {
</Modal.Header>
{!isLoadingInventory && (
<StockForm
variantInventory={variantInventory}
refetchInventory={refetch}
variantInventory={variantInventory!}
onSubmit={onSubmit}
isLoadingInventory={isLoadingInventory}
handleClose={handleClose}
@@ -86,10 +153,15 @@ const EditVariantInventoryModal = ({ onClose, product, variant }: Props) => {
const StockForm = ({
variantInventory,
onSubmit,
refetchInventory,
isLoadingInventory,
handleClose,
updatingVariant,
}: {
variantInventory: VariantInventory
onSubmit: (data: EditFlowVariantFormType) => void
isLoadingInventory: boolean
handleClose: () => void
updatingVariant: boolean
}) => {
const form = useForm<EditFlowVariantFormType>({
defaultValues: getEditVariantDefaultValues(variantInventory),
@@ -99,32 +171,20 @@ const StockForm = ({
formState: { isDirty },
handleSubmit,
reset,
watch,
} = form
const locationLevels = watch("stock.location_levels")
const { location_levels } = variantInventory.inventory[0]
React.useEffect(() => {
form.setValue("stock.location_levels", location_levels)
}, [form, location_levels])
const locationLevels = variantInventory.inventory[0]?.location_levels || []
const handleOnSubmit = handleSubmit((data) => {
// @ts-ignore
onSubmit(data)
})
const itemId = variantInventory.inventory[0].id
return (
<form onSubmit={handleOnSubmit} noValidate>
<Modal.Content>
<EditFlowVariantForm
form={form}
refetchInventory={refetchInventory}
locationLevels={locationLevels || []}
itemId={itemId}
locationLevels={locationLevels}
isLoading={isLoadingInventory}
/>
</Modal.Content>

View File

@@ -106,9 +106,9 @@ const VariantsTable = ({ variants, actions }: Props) => {
const getTableRowActionables = (variant: ProductVariant) => {
const inventoryManagementActions = []
if (hasInventoryService && variant.manage_inventory) {
if (hasInventoryService) {
inventoryManagementActions.push({
label: "Manage inventory", // TODO: Only add this item if variant.manageInventory is true
label: "Manage inventory",
icon: <BuildingsIcon size="20" />,
onClick: () => updateVariantInventory(variant),
})

View File

@@ -1,4 +1,4 @@
import { DeepPartial, EntityManager, FindManyOptions } from "typeorm"
import { DeepPartial, EntityManager, FindManyOptions, In } from "typeorm"
import { isDefined, MedusaError } from "medusa-core-utils"
import {
buildQuery,
@@ -190,14 +190,34 @@ export default class InventoryLevelService extends TransactionBaseService {
}
/**
* Deletes an inventory level by ID.
* @param inventoryLevelId - The ID of the inventory level to delete.
*/
async delete(inventoryLevelId: string): Promise<void> {
* Deletes inventory levels by inventory Item ID.
* @param inventoryItemId - The ID or IDs of the inventory item to delete inventory levels for.
*/
async deleteByInventoryItemId(inventoryItemId: string | string[]): Promise<void> {
const ids = Array.isArray(inventoryItemId) ? inventoryItemId : [inventoryItemId]
await this.atomicPhase_(async (manager) => {
const levelRepository = manager.getRepository(InventoryLevel)
await levelRepository.delete({ id: inventoryLevelId })
await levelRepository.delete({ inventory_item_id: In(ids) })
await this.eventBusService_
.withTransaction(manager)
.emit(InventoryLevelService.Events.DELETED, {
inventory_item_id: inventoryItemId,
})
})
}
/**
* Deletes an inventory level by ID.
* @param inventoryLevelId - The ID or IDs of the inventory level to delete.
*/
async delete(inventoryLevelId: string | string[]): Promise<void> {
const ids = Array.isArray(inventoryLevelId) ? inventoryLevelId : [inventoryLevelId]
await this.atomicPhase_(async (manager) => {
const levelRepository = manager.getRepository(InventoryLevel)
await levelRepository.delete({ id: In(ids) })
await this.eventBusService_
.withTransaction(manager)

View File

@@ -245,6 +245,10 @@ export default class InventoryService
* @param inventoryItemId - the id of the inventory item to delete
*/
async deleteInventoryItem(inventoryItemId: string): Promise<void> {
await this.inventoryLevelService_
.withTransaction(this.activeManager_)
.deleteByInventoryItemId(inventoryItemId)
return await this.inventoryItemService_
.withTransaction(this.activeManager_)
.delete(inventoryItemId)

View File

@@ -9,6 +9,8 @@ import {
AdminInventoryItemsListWithVariantsAndLocationLevelsRes,
AdminInventoryItemsLocationLevelsRes,
AdminPostInventoryItemsItemLocationLevelsReq,
AdminPostInventoryItemsReq,
AdminPostInventoryItemsParams,
} from "@medusajs/medusa"
import { ResponsePromise } from "../../typings"
import BaseResource from "../base"
@@ -75,6 +77,28 @@ class AdminInventoryItemsResource extends BaseResource {
return this.client.request("DELETE", path, undefined, {}, customHeaders)
}
/**
* Create an Inventory Item
* @experimental This feature is under development and may change in the future.
* To use this feature please install @medusajs/inventory
* @description creates an Inventory Item
* @returns the created Inventory Item
*/
create(
payload: AdminPostInventoryItemsReq,
query?: AdminPostInventoryItemsParams,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminInventoryItemsRes> {
let path = `/admin/inventory-items`
if (query) {
const queryString = qs.stringify(query)
path += `?${queryString}`
}
return this.client.request("POST", path, payload, {}, customHeaders)
}
/**
* Retrieve a list of Inventory Items
* @experimental This feature is under development and may change in the future.

View File

@@ -4,6 +4,8 @@ import {
AdminPostInventoryItemsInventoryItemReq,
AdminPostInventoryItemsItemLocationLevelsLevelReq,
AdminPostInventoryItemsItemLocationLevelsReq,
AdminPostInventoryItemsReq,
AdminPostInventoryItemsParams
} from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import {
@@ -17,6 +19,29 @@ import { adminInventoryItemsKeys } from "./queries"
// inventory item
// create inventory item
export const useAdminCreateInventoryItem = (
options?: UseMutationOptions<
Response<AdminInventoryItemsRes>,
Error,
AdminPostInventoryItemsReq
>
) => {
const { client } = useMedusa()
const queryClient = useQueryClient()
return useMutation(
(payload: AdminPostInventoryItemsReq, query?: AdminPostInventoryItemsParams) =>
client.admin.inventoryItems.create(payload, query),
buildOptions(
queryClient,
[adminInventoryItemsKeys.lists()],
options
)
)
}
// update inventory item
export const useAdminUpdateInventoryItem = (
inventoryItemId: string,

View File

@@ -0,0 +1,230 @@
import { IsNumber, IsObject, IsOptional, IsString } from "class-validator"
import {
ProductVariantInventoryService,
ProductVariantService,
} from "../../../../services"
import { IInventoryService } from "../../../../interfaces"
import { validator } from "../../../../utils/validator"
import { EntityManager } from "typeorm"
import { createInventoryItemTransaction } from "./transaction/create-inventory-item"
import { MedusaError } from "medusa-core-utils"
import { FindParams } from "../../../../types/common"
/**
* @oas [post] /admin/inventory-items
* operationId: "PostInventoryItems"
* summary: "Create an Inventory Item."
* description: "Creates an Inventory Item."
* x-authenticated: true
* parameters:
* - (query) expand {string} Comma separated list of relations to include in the results.
* - (query) fields {string} Comma separated list of fields to include in the results.
* requestBody:
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/AdminPostInventoryItemsItemLocationLevelsReq"
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.inventoryItems.create(inventoryItemId, {
* variant_id: 'variant_123',
* sku: "sku-123",
* })
* .then(({ inventory_item }) => {
* console.log(inventory_item.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request POST 'https://medusa-url.com/admin/inventory-items' \
* --header 'Authorization: Bearer {api_token}' \
* --header 'Content-Type: application/json' \
* --data-raw '{
* "variant_id": "variant_123",
* "sku": "sku-123",
* }'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Inventory Items
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/AdminInventoryItemsRes"
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req, res) => {
const validated = await validator(AdminPostInventoryItemsReq, req.body)
const { variant_id, ...input } = validated
const inventoryService: IInventoryService =
req.scope.resolve("inventoryService")
const productVariantInventoryService: ProductVariantInventoryService =
req.scope.resolve("productVariantInventoryService")
const productVariantService: ProductVariantService = req.scope.resolve(
"productVariantService"
)
let inventoryItems = await productVariantInventoryService.listByVariant(
variant_id
)
// TODO: this is a temporary fix to prevent duplicate inventory items since we don't support this functionality yet
if (inventoryItems.length) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Inventory Item already exists for this variant"
)
}
const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => {
await createInventoryItemTransaction(
{
manager: transactionManager,
inventoryService,
productVariantInventoryService,
productVariantService,
},
variant_id,
input
)
})
inventoryItems = await productVariantInventoryService.listByVariant(
variant_id
)
const inventoryItem = await inventoryService.retrieveInventoryItem(
inventoryItems[0].inventory_item_id,
req.retrieveConfig
)
res.status(200).json({ inventory_item: inventoryItem })
}
/**
* @schema AdminPostInventoryItemsReq
* type: object
* properties:
* sku:
* description: The unique SKU for the Product Variant.
* type: string
* ean:
* description: The EAN number of the item.
* type: string
* upc:
* description: The UPC number of the item.
* type: string
* barcode:
* description: A generic GTIN field for the Product Variant.
* type: string
* hs_code:
* description: The Harmonized System code for the Product Variant.
* type: string
* inventory_quantity:
* description: The amount of stock kept for the Product Variant.
* type: integer
* default: 0
* allow_backorder:
* description: Whether the Product Variant can be purchased when out of stock.
* type: boolean
* manage_inventory:
* description: Whether Medusa should keep track of the inventory for this Product Variant.
* type: boolean
* default: true
* weight:
* description: The wieght of the Product Variant.
* type: number
* length:
* description: The length of the Product Variant.
* type: number
* height:
* description: The height of the Product Variant.
* type: number
* width:
* description: The width of the Product Variant.
* type: number
* origin_country:
* description: The country of origin of the Product Variant.
* type: string
* mid_code:
* description: The Manufacturer Identification code for the Product Variant.
* type: string
* material:
* description: The material composition of the Product Variant.
* type: string
* metadata:
* description: An optional set of key-value pairs with additional information.
* type: object
*/
export class AdminPostInventoryItemsReq {
@IsString()
variant_id: string
@IsString()
@IsOptional()
sku?: string
@IsString()
@IsOptional()
hs_code?: string
@IsNumber()
@IsOptional()
weight?: number
@IsNumber()
@IsOptional()
length?: number
@IsNumber()
@IsOptional()
height?: number
@IsNumber()
@IsOptional()
width?: number
@IsString()
@IsOptional()
origin_country?: string
@IsString()
@IsOptional()
mid_code?: string
@IsString()
@IsOptional()
material?: string
@IsObject()
@IsOptional()
metadata?: Record<string, unknown>
}
export class AdminPostInventoryItemsParams extends FindParams {}

View File

@@ -23,6 +23,10 @@ import {
} from "./update-location-level"
import { checkRegisteredModules } from "../../../middlewares/check-registered-modules"
import { ProductVariant } from "../../../../models"
import {
AdminPostInventoryItemsParams,
AdminPostInventoryItemsReq,
} from "./create-inventory-item"
const route = Router()
@@ -73,6 +77,17 @@ export default (app) => {
middlewares.wrap(require("./create-location-level").default)
)
route.post(
"/",
transformQuery(AdminPostInventoryItemsParams, {
defaultFields: defaultAdminInventoryItemFields,
defaultRelations: defaultAdminInventoryItemRelations,
isList: false,
}),
transformBody(AdminPostInventoryItemsReq),
middlewares.wrap(require("./create-inventory-item").default)
)
route.get(
"/:id/location-levels",
transformQuery(AdminGetInventoryItemsItemLocationLevelsParams, {
@@ -264,6 +279,7 @@ export type AdminInventoryItemsLocationLevelsRes = {
}
export * from "./list-inventory-items"
export * from "./create-inventory-item"
export * from "./get-inventory-item"
export * from "./update-inventory-item"
export * from "./list-location-levels"

View File

@@ -0,0 +1,171 @@
import {
DistributedTransaction,
TransactionHandlerType,
TransactionOrchestrator,
TransactionPayload,
TransactionState,
TransactionStepsDefinition,
} from "../../../../../utils/transaction"
import { ulid } from "ulid"
import { EntityManager } from "typeorm"
import { IInventoryService } from "../../../../../interfaces"
import {
ProductVariantInventoryService,
ProductVariantService,
} from "../../../../../services"
import { InventoryItemDTO } from "../../../../../types/inventory"
import { ProductVariant } from "../../../../../models"
import { MedusaError } from "medusa-core-utils"
enum actions {
createInventoryItem = "createInventoryItem",
attachInventoryItem = "attachInventoryItem",
}
const flow: TransactionStepsDefinition = {
next: {
action: actions.createInventoryItem,
saveResponse: true,
next: {
action: actions.attachInventoryItem,
noCompensation: true,
},
},
}
const createInventoryItemStrategy = new TransactionOrchestrator(
"create-inventory-item",
flow
)
type InjectedDependencies = {
manager: EntityManager
productVariantService: ProductVariantService
productVariantInventoryService: ProductVariantInventoryService
inventoryService: IInventoryService
}
type CreateInventoryItemInput = {
sku?: string
hs_code?: string
weight?: number
length?: number
height?: number
width?: number
origin_country?: string
mid_code?: string
material?: string
metadata?: Record<string, unknown>
}
export const createInventoryItemTransaction = async (
dependencies: InjectedDependencies,
variantId: string,
input: CreateInventoryItemInput
): Promise<DistributedTransaction> => {
const {
manager,
productVariantService,
inventoryService,
productVariantInventoryService,
} = dependencies
const productVariantInventoryServiceTx =
productVariantInventoryService.withTransaction(manager)
const productVariantServiceTx = productVariantService.withTransaction(manager)
const variant = await productVariantServiceTx.retrieve(variantId)
async function createInventoryItem(input: CreateInventoryItemInput) {
return await inventoryService!.createInventoryItem({
sku: variant.sku,
origin_country: variant.origin_country,
hs_code: variant.hs_code,
mid_code: variant.mid_code,
material: variant.material,
weight: variant.weight,
length: variant.length,
height: variant.height,
width: variant.width,
})
}
async function removeInventoryItem(inventoryItem: InventoryItemDTO) {
if (inventoryItem) {
await inventoryService!.deleteInventoryItem(inventoryItem.id)
}
}
async function attachInventoryItem(
variant: ProductVariant,
inventoryItem: InventoryItemDTO
) {
if (!variant.manage_inventory) {
return
}
await productVariantInventoryServiceTx.attachInventoryItem(
variant.id,
inventoryItem.id
)
}
async function transactionHandler(
actionId: string,
type: TransactionHandlerType,
payload: TransactionPayload
) {
const command = {
[actions.createInventoryItem]: {
[TransactionHandlerType.INVOKE]: async (
data: CreateInventoryItemInput
) => {
return await createInventoryItem(data)
},
[TransactionHandlerType.COMPENSATE]: async (
data: CreateInventoryItemInput,
{ invoke }
) => {
await removeInventoryItem(invoke[actions.createInventoryItem])
},
},
[actions.attachInventoryItem]: {
[TransactionHandlerType.INVOKE]: async (
data: CreateInventoryItemInput,
{ invoke }
) => {
const { [actions.createInventoryItem]: inventoryItem } = invoke
return await attachInventoryItem(variant, inventoryItem)
},
},
}
return command[actionId][type](payload.data, payload.context)
}
const transaction = await createInventoryItemStrategy.beginTransaction(
ulid(),
transactionHandler,
input
)
await createInventoryItemStrategy.resume(transaction)
if (transaction.getState() !== TransactionState.DONE) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
transaction
.getErrors()
.map((err) => err.error?.message)
.join("\n")
)
}
return transaction
}
export const revertVariantTransaction = async (
transaction: DistributedTransaction
) => {
await createInventoryItemStrategy.cancelTransaction(transaction)
}

View File

@@ -1,4 +1,3 @@
import { isDefined, MedusaError } from "medusa-core-utils"
import {
Brackets,
EntityManager,
@@ -10,26 +9,6 @@ import {
IsNull,
SelectQueryBuilder,
} from "typeorm"
import {
IPriceSelectionStrategy,
PriceSelectionContext,
TransactionBaseService,
} from "../interfaces"
import {
MoneyAmount,
Product,
ProductOptionValue,
ProductVariant,
} from "../models"
import { CartRepository } from "../repositories/cart"
import { MoneyAmountRepository } from "../repositories/money-amount"
import { ProductRepository } from "../repositories/product"
import { ProductOptionValueRepository } from "../repositories/product-option-value"
import {
FindWithRelationsOptions,
ProductVariantRepository,
} from "../repositories/product-variant"
import { FindConfig } from "../types/common"
import {
CreateProductVariantInput,
FilterableProductVariantProps,
@@ -37,8 +16,30 @@ import {
ProductVariantPrice,
UpdateProductVariantInput,
} from "../types/product-variant"
import {
FindWithRelationsOptions,
ProductVariantRepository,
} from "../repositories/product-variant"
import {
IPriceSelectionStrategy,
PriceSelectionContext,
TransactionBaseService,
} from "../interfaces"
import { MedusaError, isDefined } from "medusa-core-utils"
import {
MoneyAmount,
Product,
ProductOptionValue,
ProductVariant,
} from "../models"
import { buildQuery, buildRelations, setMetadata } from "../utils"
import { CartRepository } from "../repositories/cart"
import EventBusService from "./event-bus"
import { FindConfig } from "../types/common"
import { MoneyAmountRepository } from "../repositories/money-amount"
import { ProductOptionValueRepository } from "../repositories/product-option-value"
import { ProductRepository } from "../repositories/product"
import RegionService from "./region"
class ProductVariantService extends TransactionBaseService {