chore(core-flows,inventory,types,medusa): add fixes to inventory module + location levels api (#7629)

what:

- santizes inputs to prevent reserved_quantity from being updated directly
- inventory items create api can create location levels
- add validation to update quantity of reservation items
- general cleanup

RESOLVES CORE-2254
This commit is contained in:
Riqwan Thamir
2024-06-06 14:58:17 +02:00
committed by GitHub
parent 3fbb8aa671
commit 0507dbe027
13 changed files with 366 additions and 93 deletions

View File

@@ -612,6 +612,38 @@ medusaIntegrationTestRunner({
})
)
})
it("should create inventory items along with location levels", async () => {
const response = await api.post(
`/admin/inventory-items?fields=*location_levels`,
{
sku: "test-sku",
location_levels: [
{
location_id: stockLocation1.id,
stocked_quantity: 20,
incoming_quantity: 40,
},
],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.inventory_item).toEqual(
expect.objectContaining({
sku: "test-sku",
location_levels: [
expect.objectContaining({
id: expect.any(String),
inventory_item_id: expect.any(String),
stocked_quantity: 20,
incoming_quantity: 40,
}),
],
})
)
})
})
describe("POST /admin/inventory-items/:id", () => {

View File

@@ -88,16 +88,25 @@ medusaIntegrationTestRunner({
it("should throw if available quantity is less than reservation quantity", async () => {
const payload = {
quantity: 100000,
quantity: 10000,
inventory_item_id: inventoryItem1.id,
line_item_id: "line-item-id",
location_id: stockLocation1.id,
}
const res = await api
.post(`/admin/reservations`, payload, adminHeaders)
.post(
`/admin/reservations?fields=*inventory_item.location_levels`,
payload,
adminHeaders
)
.catch((err) => err)
const res1 = await api.get(
`/admin/inventory-items/${inventoryItem1.id}`,
adminHeaders
)
expect(res.response.status).toBe(400)
expect(res.response.data).toEqual({
type: "not_allowed",
@@ -133,8 +142,7 @@ medusaIntegrationTestRunner({
reservationId = reservationResponse.data.reservation.id
})
// TODO: Implement this on the module
it.skip("should throw if available quantity is less than reservation quantity", async () => {
it("should throw if available quantity is less than reservation quantity", async () => {
const payload = { quantity: 100000 }
const res = await api
.post(`/admin/reservations/${reservationId}`, payload, adminHeaders)

View File

@@ -351,18 +351,33 @@ medusaIntegrationTestRunner({
},
])
const inventoryItem = await inventoryModule.create({
sku: "inv-1234",
})
const inventoryItem = (
await api.post(
`/admin/inventory-items`,
{
sku: "inv-1234",
location_levels: [
{
location_id: location.id,
stocked_quantity: 2,
},
],
},
adminHeaders
)
).data.inventory_item
await inventoryModule.createInventoryLevels([
await api.post(
`/admin/reservations`,
{
line_item_id: "line-item-id-1",
inventory_item_id: inventoryItem.id,
location_id: location.id,
stocked_quantity: 2,
reserved_quantity: 2,
description: "test description",
quantity: 2,
},
])
adminHeaders
)
const region = await regionModuleService.create({
name: "US",

View File

@@ -3,23 +3,83 @@ import {
createWorkflow,
transform,
} from "@medusajs/workflows-sdk"
import {
attachInventoryItemToVariants,
createInventoryItemsStep,
validateInventoryItemsForCreate,
} from "../steps"
import { createInventoryItemsStep } from "../steps"
import { InventoryNext } from "@medusajs/types"
import { createInventoryLevelsWorkflow } from "./create-inventory-levels"
type LocationLevelWithoutInventory = Omit<
InventoryNext.CreateInventoryLevelInput,
"inventory_item_id"
>
interface WorkflowInput {
items: InventoryNext.CreateInventoryItemInput[]
items: (InventoryNext.CreateInventoryItemInput & {
location_levels?: LocationLevelWithoutInventory[]
})[]
}
const buildLocationLevelMapAndItemData = (data: WorkflowInput) => {
data.items = data.items ?? []
const inventoryItems: InventoryNext.CreateInventoryItemInput[] = []
// Keep an index to location levels mapping to inject the created inventory item
// id into the location levels workflow input
const locationLevelMap: Record<number, LocationLevelWithoutInventory[]> = {}
data.items.forEach(({ location_levels, ...inventoryItem }, index) => {
locationLevelMap[index] = location_levels?.length ? location_levels : []
inventoryItems.push(inventoryItem)
})
return {
locationLevelMap,
inventoryItems,
}
}
const buildInventoryLevelsInput = (data: {
locationLevelMap: Record<number, LocationLevelWithoutInventory[]>
items: InventoryNext.InventoryItemDTO[]
}) => {
const inventoryLevels: InventoryNext.CreateInventoryLevelInput[] = []
let index = 0
// The order of the input is critical to accurately create location levels for
// the right inventory item
data.items.forEach((item, index) => {
const locationLevels = data.locationLevelMap[index] || []
locationLevels.forEach((locationLevel) =>
inventoryLevels.push({
...locationLevel,
inventory_item_id: item.id,
})
)
})
return {
input: {
inventory_levels: inventoryLevels,
},
}
}
export const createInventoryItemsWorkflowId = "create-inventory-items-workflow"
export const createInventoryItemsWorkflow = createWorkflow(
createInventoryItemsWorkflowId,
(input: WorkflowData<WorkflowInput>) => {
const items = createInventoryItemsStep(input.items)
const { locationLevelMap, inventoryItems } = transform(
input,
buildLocationLevelMapAndItemData
)
const items = createInventoryItemsStep(inventoryItems)
const inventoryLevelsInput = transform(
{ items, locationLevelMap },
buildInventoryLevelsInput
)
createInventoryLevelsWorkflow.runAsStep(inventoryLevelsInput)
return items
}

View File

@@ -145,6 +145,7 @@ export type ReservationItemDTO = {
line_item_id?: string | null
description?: string | null
created_by?: string | null
allow_backorder?: boolean
metadata: Record<string, unknown> | null
created_at: string | Date
updated_at: string | Date

View File

@@ -37,6 +37,11 @@ export interface ReservationItemDTO {
*/
description?: string | null
/**
* Allow backorder of the item. If true, it won't check inventory levels before reserving it.
*/
allow_backorder?: boolean
/**
* The created by of the reservation item.
*/

View File

@@ -11,10 +11,6 @@ export interface CreateInventoryLevelInput {
* The stocked quantity of the associated inventory item in the associated location.
*/
stocked_quantity?: number
/**
* The reserved quantity of the associated inventory item in the associated location.
*/
reserved_quantity?: number
/**
* The incoming quantity of the associated inventory item in the associated location.
*/

View File

@@ -17,6 +17,10 @@ export interface UpdateReservationItemInput {
* The description of the reservation item.
*/
description?: string
/**
* Allow backorder of the item. If true, it won't check inventory levels before reserving it.
*/
allow_backorder?: boolean
/**
* Holds custom data in key-value pairs.
*/

View File

@@ -104,6 +104,7 @@ export const AdminCreateInventoryItem = z
requires_shipping: z.boolean().optional(),
thumbnail: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
location_levels: z.array(AdminCreateInventoryLocationLevel).optional(),
})
.strict()

View File

@@ -209,6 +209,25 @@ moduleIntegrationTestRunner({
expect.objectContaining({ id: expect.any(String), ...data[1] }),
])
})
it("should not create with required quantity", async () => {
const data = {
inventory_item_id: inventoryItem.id,
location_id: "location-1",
stocked_quantity: 2,
reserved_quantity: 1000,
}
const inventoryLevel = await service.createInventoryLevels(data)
expect(inventoryLevel).toEqual(
expect.objectContaining({
id: expect.any(String),
...data,
reserved_quantity: 0,
})
)
})
})
describe("update", () => {
@@ -301,8 +320,10 @@ moduleIntegrationTestRunner({
describe("updateReservationItems", () => {
let reservationItem
let inventoryItem
beforeEach(async () => {
const inventoryItem = await service.create({
inventoryItem = await service.create({
sku: "test-sku",
origin_country: "test-country",
})
@@ -311,26 +332,26 @@ moduleIntegrationTestRunner({
{
inventory_item_id: inventoryItem.id,
location_id: "location-1",
stocked_quantity: 2,
stocked_quantity: 10,
},
{
inventory_item_id: inventoryItem.id,
location_id: "location-2",
stocked_quantity: 2,
stocked_quantity: 10,
},
])
reservationItem = await service.createReservationItems({
inventory_item_id: inventoryItem.id,
location_id: "location-1",
quantity: 2,
quantity: 3,
})
})
it("should update a reservationItem", async () => {
const update = {
id: reservationItem.id,
quantity: 5,
quantity: 1,
}
const updated = await service.updateReservationItems(update)
@@ -341,7 +362,7 @@ moduleIntegrationTestRunner({
it("should adjust reserved_quantity of inventory level after updates increasing reserved quantity", async () => {
const update = {
id: reservationItem.id,
quantity: 5,
quantity: 7,
}
await service.updateReservationItems(update)
@@ -358,7 +379,7 @@ moduleIntegrationTestRunner({
it("should adjust reserved_quantity of inventory level after updates decreasing reserved quantity", async () => {
const update = {
id: reservationItem.id,
quantity: 1,
quantity: 2,
}
await service.updateReservationItems(update)
@@ -371,10 +392,26 @@ moduleIntegrationTestRunner({
expect(inventoryLevel.reserved_quantity).toEqual(update.quantity)
})
it("should throw error when increasing reserved quantity beyond availability", async () => {
const update = {
id: reservationItem.id,
quantity: 10,
}
const error = await service
.updateReservationItems(update)
.catch((e) => e)
expect(error.message).toEqual(
`Not enough stock available for item ${inventoryItem.id} at location location-1`
)
})
})
describe("deleteReservationItemsByLineItem", () => {
let inventoryItem: InventoryItemDTO
beforeEach(async () => {
inventoryItem = await service.create({
sku: "test-sku",
@@ -415,7 +452,7 @@ moduleIntegrationTestRunner({
])
})
it("deleted reseravation items by line item and restore them", async () => {
it("should delete reseravation items by line item and restore them", async () => {
const reservationsPreDeleted = await service.listReservationItems({
line_item_id: "line-item-id",
})
@@ -461,7 +498,17 @@ moduleIntegrationTestRunner({
])
})
it("adjusts inventory levels accordingly when removing reservations by line item", async () => {
it("should adjust inventory levels accordingly when removing reservations by line item", async () => {
const inventoryLevelBefore =
await service.retrieveInventoryLevelByItemAndLocation(
inventoryItem.id,
"location-1"
)
expect(inventoryLevelBefore).toEqual(
expect.objectContaining({ reserved_quantity: 6 })
)
await service.deleteReservationItemsByLineItem("line-item-id")
const inventoryLevel =
@@ -543,7 +590,7 @@ moduleIntegrationTestRunner({
expect(reservationsPostDeleted).toEqual([])
})
it("adjusts inventory levels accordingly when removing reservations by line item", async () => {
it("should adjust inventory levels accordingly when removing reservations by line item", async () => {
const inventoryLevelPreDelete =
await service.retrieveInventoryLevelByItemAndLocation(
inventoryItem.id,
@@ -570,6 +617,7 @@ moduleIntegrationTestRunner({
describe("deleteInventoryItemLevelByLocationId", () => {
let inventoryItem: InventoryItemDTO
beforeEach(async () => {
inventoryItem = await service.create({
sku: "test-sku",
@@ -581,7 +629,6 @@ moduleIntegrationTestRunner({
inventory_item_id: inventoryItem.id,
location_id: "location-1",
stocked_quantity: 2,
reserved_quantity: 6,
},
{
inventory_item_id: inventoryItem.id,
@@ -589,6 +636,12 @@ moduleIntegrationTestRunner({
stocked_quantity: 2,
},
])
await service.createReservationItems({
inventory_item_id: inventoryItem.id,
location_id: "location-1",
quantity: 1,
})
})
it("should remove inventory levels with given location id", async () => {
@@ -601,6 +654,7 @@ moduleIntegrationTestRunner({
expect.objectContaining({
stocked_quantity: 2,
location_id: "location-1",
reserved_quantity: 1,
}),
expect.objectContaining({
stocked_quantity: 2,
@@ -741,6 +795,7 @@ moduleIntegrationTestRunner({
describe("retrieveAvailableQuantity", () => {
let inventoryItem: InventoryItemDTO
beforeEach(async () => {
inventoryItem = await service.create({
sku: "test-sku",
@@ -761,7 +816,6 @@ moduleIntegrationTestRunner({
inventory_item_id: inventoryItem.id,
location_id: "location-2",
stocked_quantity: 4,
reserved_quantity: 2,
},
{
inventory_item_id: inventoryItem1.id,
@@ -774,6 +828,13 @@ moduleIntegrationTestRunner({
stocked_quantity: 3,
},
])
await service.createReservationItems({
line_item_id: "test",
inventory_item_id: inventoryItem.id,
location_id: "location-2",
quantity: 2,
})
})
it("should calculate current stocked quantity across locations", async () => {
@@ -781,17 +842,20 @@ moduleIntegrationTestRunner({
inventoryItem.id,
["location-1", "location-2"]
)
expect(level).toEqual(6)
})
})
describe("retrieveStockedQuantity", () => {
let inventoryItem: InventoryItemDTO
beforeEach(async () => {
inventoryItem = await service.create({
sku: "test-sku",
origin_country: "test-country",
})
const inventoryItem1 = await service.create({
sku: "test-sku-1",
origin_country: "test-country",
@@ -807,7 +871,6 @@ moduleIntegrationTestRunner({
inventory_item_id: inventoryItem.id,
location_id: "location-2",
stocked_quantity: 4,
reserved_quantity: 2,
},
{
inventory_item_id: inventoryItem1.id,
@@ -822,7 +885,7 @@ moduleIntegrationTestRunner({
])
})
it("retrieves stocked location", async () => {
it("should retrieve stocked location", async () => {
const stockedQuantity = await service.retrieveStockedQuantity(
inventoryItem.id,
["location-1", "location-2"]
@@ -831,13 +894,16 @@ moduleIntegrationTestRunner({
expect(stockedQuantity).toEqual(8)
})
})
describe("retrieveReservedQuantity", () => {
let inventoryItem: InventoryItemDTO
beforeEach(async () => {
inventoryItem = await service.create({
sku: "test-sku",
origin_country: "test-country",
})
const inventoryItem1 = await service.create({
sku: "test-sku-1",
origin_country: "test-country",
@@ -853,19 +919,34 @@ moduleIntegrationTestRunner({
inventory_item_id: inventoryItem.id,
location_id: "location-2",
stocked_quantity: 4,
reserved_quantity: 2,
},
{
inventory_item_id: inventoryItem1.id,
location_id: "location-1",
stocked_quantity: 3,
reserved_quantity: 2,
},
{
inventory_item_id: inventoryItem.id,
location_id: "location-3",
stocked_quantity: 3,
reserved_quantity: 2,
},
])
await service.createReservationItems([
{
inventory_item_id: inventoryItem.id,
location_id: "location-2",
quantity: 2,
},
{
inventory_item_id: inventoryItem1.id,
location_id: "location-1",
quantity: 2,
},
{
inventory_item_id: inventoryItem.id,
location_id: "location-3",
quantity: 2,
},
])
})
@@ -882,45 +963,44 @@ moduleIntegrationTestRunner({
describe("confirmInventory", () => {
let inventoryItem: InventoryItemDTO
beforeEach(async () => {
inventoryItem = await service.create({
sku: "test-sku",
origin_country: "test-country",
})
await service.createInventoryLevels([
{
inventory_item_id: inventoryItem.id,
location_id: "location-1",
stocked_quantity: 4,
},
{
inventory_item_id: inventoryItem.id,
location_id: "location-2",
stocked_quantity: 4,
reserved_quantity: 2,
},
])
await service.createInventoryLevels({
inventory_item_id: inventoryItem.id,
location_id: "location-1",
stocked_quantity: 4,
})
await service.createReservationItems({
inventory_item_id: inventoryItem.id,
location_id: "location-1",
quantity: 2,
})
})
it("should return true if quantity is less than or equal to available quantity", async () => {
const reservedQuantity = await service.confirmInventory(
inventoryItem.id,
"location-1",
["location-1"],
2
)
expect(reservedQuantity).toBeTruthy()
})
it("should return true if quantity is more than available quantity", async () => {
it("should return false if quantity is more than available quantity", async () => {
const reservedQuantity = await service.confirmInventory(
inventoryItem.id,
"location-1",
["location-1"],
3
)
expect(reservedQuantity).toBeTruthy()
expect(reservedQuantity).toBeFalsy()
})
})
})

View File

@@ -1,5 +1,5 @@
import InventoryService from "./services/inventory"
import { ModuleExports } from "@medusajs/types"
import InventoryService from "./services/inventory-module"
const service = InventoryService

View File

@@ -1,2 +1,2 @@
export { default as InventoryModuleService } from "./inventory"
export { default as InventoryLevelService } from "./inventory-level"
export { default as InventoryModuleService } from "./inventory-module"

View File

@@ -18,6 +18,7 @@ import {
MedusaContext,
MedusaError,
ModulesSdkUtils,
arrayDifference,
isDefined,
isString,
partitionArray,
@@ -151,6 +152,63 @@ export default class InventoryModuleService<
return inventoryLevels
}
// reserved_quantity should solely be handled through creating & updating reservation items
// We sanitize the inputs here to prevent that from being used to update it
private sanitizeInventoryLevelInput<TDTO = unknown>(
input: (TDTO & {
reserved_quantity?: number
})[]
): TDTO[] {
return input.map((input) => {
const { reserved_quantity, ...validInput } = input
return validInput as TDTO
})
}
private sanitizeInventoryItemInput<TDTO = unknown>(
input: (TDTO & {
location_levels?: object[]
})[]
): TDTO[] {
return input.map((input) => {
const { location_levels, ...validInput } = input
return validInput as TDTO
})
}
private async ensureInventoryAvailability(
data: {
allow_backorder: boolean
inventory_item_id: string
location_id: string
quantity: number
}[],
context: Context
) {
const checkLevels = data.map(async (reservation) => {
if (!!reservation.allow_backorder) {
return
}
const available = await this.retrieveAvailableQuantity(
reservation.inventory_item_id,
[reservation.location_id],
context
)
if (available < reservation.quantity) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Not enough stock available for item ${reservation.inventory_item_id} at location ${reservation.location_id}`
)
}
})
await promiseAll(checkLevels)
}
async createReservationItems(
input: InventoryNext.CreateReservationItemInput[],
context?: Context
@@ -171,27 +229,12 @@ export default class InventoryModuleService<
InventoryNext.ReservationItemDTO[] | InventoryNext.ReservationItemDTO
> {
const toCreate = Array.isArray(input) ? input : [input]
const sanitized = toCreate.map((d) => ({
...d,
allow_backorder: d.allow_backorder || false,
}))
const checkLevels = toCreate.map(async (item) => {
if (!!item.allow_backorder) {
return
}
const available = await this.retrieveAvailableQuantity(
item.inventory_item_id,
[item.location_id],
context
)
if (available < item.quantity) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Not enough stock available for item ${item.inventory_item_id} at location ${item.location_id}`
)
}
})
await promiseAll(checkLevels)
await this.ensureInventoryAvailability(sanitized, context)
const created = await this.createReservationItems_(toCreate, context)
@@ -289,8 +332,9 @@ export default class InventoryModuleService<
): Promise<
InventoryNext.InventoryItemDTO | InventoryNext.InventoryItemDTO[]
> {
const toCreate = Array.isArray(input) ? input : [input]
const toCreate = this.sanitizeInventoryItemInput(
Array.isArray(input) ? input : [input]
)
const result = await this.createInventoryItems_(toCreate, context)
context.messageAggregator?.saveRawMessageData(
@@ -340,7 +384,9 @@ export default class InventoryModuleService<
): Promise<
InventoryNext.InventoryLevelDTO[] | InventoryNext.InventoryLevelDTO
> {
const toCreate = Array.isArray(input) ? input : [input]
const toCreate = this.sanitizeInventoryLevelInput(
Array.isArray(input) ? input : [input]
)
const created = await this.createInventoryLevels_(toCreate, context)
@@ -398,7 +444,9 @@ export default class InventoryModuleService<
): Promise<
InventoryNext.InventoryItemDTO | InventoryNext.InventoryItemDTO[]
> {
const updates = Array.isArray(input) ? input : [input]
const updates = this.sanitizeInventoryItemInput(
Array.isArray(input) ? input : [input]
)
const result = await this.updateInventoryItems_(updates, context)
@@ -508,7 +556,9 @@ export default class InventoryModuleService<
): Promise<
InventoryNext.InventoryLevelDTO | InventoryNext.InventoryLevelDTO[]
> {
const input = Array.isArray(updates) ? updates : [updates]
const input = this.sanitizeInventoryLevelInput(
Array.isArray(updates) ? updates : [updates]
)
const levels = await this.updateInventoryLevels_(input, context)
@@ -593,7 +643,6 @@ export default class InventoryModuleService<
InventoryNext.ReservationItemDTO | InventoryNext.ReservationItemDTO[]
> {
const update = Array.isArray(input) ? input : [input]
const result = await this.updateReservationItems_(update, context)
context.messageAggregator?.saveRawMessageData(
@@ -621,26 +670,47 @@ export default class InventoryModuleService<
input: (InventoryNext.UpdateReservationItemInput & { id: string })[],
@MedusaContext() context: Context = {}
): Promise<TReservationItem[]> {
const ids = input.map((u) => u.id)
const reservationItems = await this.listReservationItems(
{ id: input.map((u) => u.id) },
{ id: ids },
{},
context
)
const diff = arrayDifference(
ids,
reservationItems.map((i) => i.id)
)
if (diff.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Reservation item with id ${diff.join(", ")} not found`
)
}
const reservationMap: Map<string, ReservationItemDTO> = new Map(
reservationItems.map((r) => [r.id, r])
)
const availabilityData = input.map((data) => {
const reservation = reservationMap.get(data.id)!
return {
...data,
quantity: data.quantity ?? reservation.quantity,
allow_backorder:
data.allow_backorder || reservation.allow_backorder || false,
inventory_item_id: reservation.inventory_item_id,
location_id: data.location_id ?? reservation.location_id,
}
})
await this.ensureInventoryAvailability(availabilityData, context)
const adjustments: Map<string, Map<string, number>> = input.reduce(
(acc, update) => {
const reservation = reservationMap.get(update.id)
if (!reservation) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Reservation item with id ${update.id} not found`
)
}
const reservation = reservationMap.get(update.id)!
const locationMap = acc.get(reservation.inventory_item_id) ?? new Map()
if (
@@ -676,6 +746,7 @@ export default class InventoryModuleService<
}
acc.set(reservation.inventory_item_id, locationMap)
return acc
},
new Map()