From fe9eea4c18b7e04ba91660716c92b11a49840a3c Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Tue, 14 Mar 2023 17:14:31 +0100 Subject: [PATCH] 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> --- .changeset/fast-buckets-marry.md | 7 + .changeset/slow-beers-train.md | 5 + .../inventory/inventory-items/index.js | 56 ++++ .../use-discount-configurations.tsx | 15 +- .../domain/orders/details/returns/index.tsx | 15 +- .../edit-flow-variant-form/index.tsx | 12 +- .../variant-stock-form/index.tsx | 242 +++++++++--------- .../edit/hooks/use-edit-product-actions.tsx | 14 +- .../variants/edit-variant-inventory-modal.tsx | 148 +++++++---- .../products/edit/sections/variants/table.tsx | 4 +- .../inventory/src/services/inventory-level.ts | 32 ++- packages/inventory/src/services/inventory.ts | 4 + .../src/resources/admin/inventory-item.ts | 24 ++ .../hooks/admin/inventory-item/mutations.ts | 25 ++ .../inventory-items/create-inventory-item.ts | 230 +++++++++++++++++ .../api/routes/admin/inventory-items/index.ts | 16 ++ .../transaction/create-inventory-item.ts | 171 +++++++++++++ .../medusa/src/services/product-variant.ts | 43 ++-- 18 files changed, 844 insertions(+), 219 deletions(-) create mode 100644 .changeset/fast-buckets-marry.md create mode 100644 .changeset/slow-beers-train.md create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/create-inventory-item.ts create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/transaction/create-inventory-item.ts diff --git a/.changeset/fast-buckets-marry.md b/.changeset/fast-buckets-marry.md new file mode 100644 index 0000000000..8fc6f6ac70 --- /dev/null +++ b/.changeset/fast-buckets-marry.md @@ -0,0 +1,7 @@ +--- +"medusa-react": patch +"@medusajs/medusa-js": patch +"@medusajs/medusa": patch +--- + +Add create-inventory-item endpoint diff --git a/.changeset/slow-beers-train.md b/.changeset/slow-beers-train.md new file mode 100644 index 0000000000..c8248d3516 --- /dev/null +++ b/.changeset/slow-beers-train.md @@ -0,0 +1,5 @@ +--- +"@medusajs/admin-ui": patch +--- + +fix(admin-ui): create/update/delete inventory items according to inventory items on the variant diff --git a/integration-tests/plugins/__tests__/inventory/inventory-items/index.js b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js index 002b5696a1..1eae8976b0 100644 --- a/integration-tests/plugins/__tests__/inventory/inventory-items/index.js +++ b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js @@ -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() diff --git a/packages/admin-ui/ui/src/domain/discounts/details/configurations/use-discount-configurations.tsx b/packages/admin-ui/ui/src/domain/discounts/details/configurations/use-discount-configurations.tsx index e505df70c3..b2a8515b05 100644 --- a/packages/admin-ui/ui/src/domain/discounts/details/configurations/use-discount-configurations.tsx +++ b/packages/admin-ui/ui/src/domain/discounts/details/configurations/use-discount-configurations.tsx @@ -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 diff --git a/packages/admin-ui/ui/src/domain/orders/details/returns/index.tsx b/packages/admin-ui/ui/src/domain/orders/details/returns/index.tsx index cd7a0b9bde..c2d57f5b54 100644 --- a/packages/admin-ui/ui/src/domain/orders/details/returns/index.tsx +++ b/packages/admin-ui/ui/src/domain/orders/details/returns/index.tsx @@ -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" diff --git a/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/edit-flow-variant-form/index.tsx b/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/edit-flow-variant-form/index.tsx index a7e4fcdfbd..2d7b4fe185 100644 --- a/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/edit-flow-variant-form/index.tsx +++ b/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/edit-flow-variant-form/index.tsx @@ -10,9 +10,7 @@ export type EditFlowVariantFormType = { type Props = { form: UseFormReturn 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 = ({ <> diff --git a/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/variant-stock-form/index.tsx b/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/variant-stock-form/index.tsx index f712598c83..42c4680928 100644 --- a/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/variant-stock-form/index.tsx +++ b/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/variant-stock-form/index.tsx @@ -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[] | null } type Props = { - itemId: string locationLevels: InventoryLevelDTO[] - refetchInventory: () => void form: NestedForm } -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.

-
-
-

Allow backorders

- { - return - }} - /> -
-

- When checked the product will be available for purchase despite the - product being sold out -

-
-
-

Quantity

- {!isLoading && locations && ( -
-
-
Location
-
In Stock
+ {manageInventory && ( + <> +
+
+

+ Allow backorders +

+ { + return + }} + />
- {locationLevels.map((level, i) => { - const locationDetails = locations.find( - (l) => l.id === level.location_id - ) - - return ( -
-
- - - - {locationDetails?.name} -
-
-
- - {`${level.reserved_quantity} reserved`} - - {`${ - level.stocked_quantity - level.reserved_quantity - } available`} -
- -
-
- ) - })} +

+ When checked the product will be available for purchase despite + the product being sold out +

- )} -
-
- -
+
+

Quantity

+ {!isLoading && locations && ( +
+
+
Location
+
In Stock
+
+ {selectedLocations.map((level, i) => { + console.log(level) + const locationDetails = locations.find( + (l: StockLocationDTO) => l.id === level.location_id + ) + + return ( +
+
+ + + + {locationDetails?.name} +
+
+
+ + {`${level.reserved_quantity} reserved`} + + {`${ + level.stocked_quantity - level.reserved_quantity + } available`} +
+ +
+
+ ) + })} +
+ )} +
+
+ +
+ + )}
) @@ -200,7 +208,7 @@ const VariantStockForm = ({ export const ManageLocationsScreen = ( pop: () => void, - levels: InventoryLevelDTO[], + levels: Partial[], locations: StockLocationDTO[], onSubmit: (value: any) => Promise ) => { @@ -218,7 +226,7 @@ export const ManageLocationsScreen = ( } type ManageLocationFormProps = { - existingLevels: InventoryLevelDTO[] + existingLevels: Partial[] locationOptions: StockLocationDTO[] onSubmit: (value: any) => Promise } @@ -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({ diff --git a/packages/admin-ui/ui/src/domain/products/edit/hooks/use-edit-product-actions.tsx b/packages/admin-ui/ui/src/domain/products/edit/hooks/use-edit-product-actions.tsx index 44c88e2fbf..d3fdc45495 100644 --- a/packages/admin-ui/ui/src/domain/products/edit/hooks/use-edit-product-actions.tsx +++ b/packages/admin-ui/ui/src/domain/products/edit/hooks/use-edit-product-actions.tsx @@ -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") diff --git a/packages/admin-ui/ui/src/domain/products/edit/sections/variants/edit-variant-inventory-modal.tsx b/packages/admin-ui/ui/src/domain/products/edit/sections/variants/edit-variant-inventory-modal.tsx index 881bbd8a3e..3211c53e41 100644 --- a/packages/admin-ui/ui/src/domain/products/edit/sections/variants/edit-variant-inventory-modal.tsx +++ b/packages/admin-ui/ui/src/domain/products/edit/sections/variants/edit-variant-inventory-modal.tsx @@ -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) => { {!isLoadingInventory && ( { const StockForm = ({ variantInventory, onSubmit, - refetchInventory, isLoadingInventory, handleClose, updatingVariant, +}: { + variantInventory: VariantInventory + onSubmit: (data: EditFlowVariantFormType) => void + isLoadingInventory: boolean + handleClose: () => void + updatingVariant: boolean }) => { const form = useForm({ 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 (
diff --git a/packages/admin-ui/ui/src/domain/products/edit/sections/variants/table.tsx b/packages/admin-ui/ui/src/domain/products/edit/sections/variants/table.tsx index f3c1cc73a2..872455c92f 100644 --- a/packages/admin-ui/ui/src/domain/products/edit/sections/variants/table.tsx +++ b/packages/admin-ui/ui/src/domain/products/edit/sections/variants/table.tsx @@ -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: , onClick: () => updateVariantInventory(variant), }) diff --git a/packages/inventory/src/services/inventory-level.ts b/packages/inventory/src/services/inventory-level.ts index aad5d60a00..5b691e7129 100644 --- a/packages/inventory/src/services/inventory-level.ts +++ b/packages/inventory/src/services/inventory-level.ts @@ -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 { + * 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 { + 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 { + 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) diff --git a/packages/inventory/src/services/inventory.ts b/packages/inventory/src/services/inventory.ts index ef13449298..86f9277c2d 100644 --- a/packages/inventory/src/services/inventory.ts +++ b/packages/inventory/src/services/inventory.ts @@ -245,6 +245,10 @@ export default class InventoryService * @param inventoryItemId - the id of the inventory item to delete */ async deleteInventoryItem(inventoryItemId: string): Promise { + await this.inventoryLevelService_ + .withTransaction(this.activeManager_) + .deleteByInventoryItemId(inventoryItemId) + return await this.inventoryItemService_ .withTransaction(this.activeManager_) .delete(inventoryItemId) diff --git a/packages/medusa-js/src/resources/admin/inventory-item.ts b/packages/medusa-js/src/resources/admin/inventory-item.ts index 13fd6b0f6d..c926dbc689 100644 --- a/packages/medusa-js/src/resources/admin/inventory-item.ts +++ b/packages/medusa-js/src/resources/admin/inventory-item.ts @@ -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 = {} + ): ResponsePromise { + 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. diff --git a/packages/medusa-react/src/hooks/admin/inventory-item/mutations.ts b/packages/medusa-react/src/hooks/admin/inventory-item/mutations.ts index e80e098115..3aa85be187 100644 --- a/packages/medusa-react/src/hooks/admin/inventory-item/mutations.ts +++ b/packages/medusa-react/src/hooks/admin/inventory-item/mutations.ts @@ -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, + 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, diff --git a/packages/medusa/src/api/routes/admin/inventory-items/create-inventory-item.ts b/packages/medusa/src/api/routes/admin/inventory-items/create-inventory-item.ts new file mode 100644 index 0000000000..d13471ec94 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/create-inventory-item.ts @@ -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 +} + +export class AdminPostInventoryItemsParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/admin/inventory-items/index.ts b/packages/medusa/src/api/routes/admin/inventory-items/index.ts index 1e60be2b21..7863179cfb 100644 --- a/packages/medusa/src/api/routes/admin/inventory-items/index.ts +++ b/packages/medusa/src/api/routes/admin/inventory-items/index.ts @@ -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" diff --git a/packages/medusa/src/api/routes/admin/inventory-items/transaction/create-inventory-item.ts b/packages/medusa/src/api/routes/admin/inventory-items/transaction/create-inventory-item.ts new file mode 100644 index 0000000000..3b1d117d9b --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/transaction/create-inventory-item.ts @@ -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 +} + +export const createInventoryItemTransaction = async ( + dependencies: InjectedDependencies, + variantId: string, + input: CreateInventoryItemInput +): Promise => { + 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) +} diff --git a/packages/medusa/src/services/product-variant.ts b/packages/medusa/src/services/product-variant.ts index 749358ff82..12a06f7720 100644 --- a/packages/medusa/src/services/product-variant.ts +++ b/packages/medusa/src/services/product-variant.ts @@ -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 {