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 16:14:31 +00:00
committed by GitHub
co-authored by Sebastian Rindom
parent 30a3203640
commit fe9eea4c18
18 changed files with 844 additions and 219 deletions
@@ -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 {}
@@ -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"
@@ -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)
}